added status badge

This commit is contained in:
biersoeckli
2024-11-10 13:21:02 +00:00
parent a6a9afe556
commit b84082981c
8 changed files with 217 additions and 60 deletions

View File

@@ -0,0 +1,74 @@
import k3s from "@/server/adapter/kubernetes-api.adapter";
import appService from "@/server/services/app.service";
import deploymentService from "@/server/services/deployment.service";
import { simpleRoute } from "@/server/utils/action-wrapper.utils";
import { Informer, V1Pod } from "@kubernetes/client-node";
import { z } from "zod";
import * as k8s from '@kubernetes/client-node';
// Prevents this route's response from being cached
export const dynamic = "force-dynamic";
const zodInputModel = z.object({
appId: z.string(),
});
export async function POST(request: Request) {
return simpleRoute(async () => {
const input = await request.json();
const podInfo = zodInputModel.parse(input);
let { appId } = podInfo;
const app = await appService.getById(appId);
const namespace = app.projectId;
// Source:
// https://github.com/kubernetes-client/javascript/blob/master/examples/typescript/informer/informer-with-label-selector.ts
// https://github.com/kubernetes-client/javascript/blob/master/examples/typescript/watch/watch-example.ts
let informer: Informer<V1Pod>;
const encoder = new TextEncoder()
const customReadable = new ReadableStream({
start(controller) {
const getDeploymentStatus = async () => {
console.log(`Getting Deployment Status for app ${appId}`);
const deploymentStatus = await deploymentService.getDeploymentStatus(app.projectId, app.id);
controller.enqueue(encoder.encode(deploymentStatus))
};
informer = k8s.makeInformer(
k3s.getKubeConfig(),
`/api/v1/namespaces/${namespace}/pods`,
() => k3s.core.listNamespacedPod(namespace, undefined, undefined, undefined, undefined, `app=${app.id}`)
);
informer.on('add', () => getDeploymentStatus());
informer.on('update', () => getDeploymentStatus());
informer.on('change', () => getDeploymentStatus());
informer.on('delete', () => getDeploymentStatus());
informer.on('error', async (err: any) => {
console.error(`Error while listening for Deplyoment Changes for app ${appId}: `, err);
// Try to restart informer after 5sec
await new Promise(resolve => setTimeout(resolve, 5000));
informer.start();
});
getDeploymentStatus(); // Initial status
},
cancel() {
console.log("Stream closed.")
informer?.stop();
}
});
return new Response(customReadable, {
headers: {
Connection: "keep-alive",
"Content-Encoding": "none",
"Cache-Control": "no-cache, no-transform",
"Content-Type": "text/event-stream; charset=utf-8",
},
});
});
}

View File

@@ -18,7 +18,7 @@ const zodInputModel = z.object({
export async function POST(request: Request) {
return simpleRoute(async () => {
const input = await request.json();
console.log(input)
const podInfo = zodInputModel.parse(input);
let { namespace, podName, buildJobName } = podInfo;
let pod;

View File

@@ -1,27 +0,0 @@
// Prevents this route's response from being cached
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const encoder = new TextEncoder()
// Create a streaming response
const customReadable = new ReadableStream({
start(controller) {
const message = "A sample message."
controller.enqueue(encoder.encode(`data: ${message}\n\n`))
},
cancel() {
console.log("Stream closed.")
}
})
// Return the stream response and keep the connection alive
return new Response(customReadable, {
// Set the headers for Server-Sent Events (SSE)
headers: {
Connection: "keep-alive",
"Content-Encoding": "none",
"Cache-Control": "no-cache, no-transform",
"Content-Type": "text/event-stream; charset=utf-8",
},
})
}

View File

@@ -5,6 +5,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { deploy, startApp, stopApp } from "./action";
import { AppExtendedModel } from "@/model/app-extended.model";
import { Toast } from "@/lib/toast.utils";
import AppStatus from "./app-status";
export default function AppActionButtons({
app
@@ -13,6 +14,7 @@ export default function AppActionButtons({
}) {
return <Card>
<CardContent className="p-4 flex gap-4">
<div className="self-center"><AppStatus appId={app.id} /></div>
<Button onClick={() => Toast.fromAction(() => deploy(app.id))}>Deploy</Button>
<Button onClick={() => Toast.fromAction(() => startApp(app.id))} variant="secondary">Start</Button>
<Button onClick={() => Toast.fromAction(() => stopApp(app.id))} variant="secondary">Stop</Button>

View File

@@ -0,0 +1,92 @@
'use client'
import { useEffect, useRef, useState } from "react";
import { Textarea } from "@/components/ui/textarea";
import React from "react";
import { DeplyomentStatus } from "@/model/deployment-info.model";
import { set } from "date-fns";
export default function AppStatus({
appId,
}: {
appId?: string;
}) {
const [isConnected, setIsConnected] = useState(false);
const [status, setStatus] = useState<DeplyomentStatus>('UNKNOWN');
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const initializeConnection = async (controller: AbortController) => {
// Initiate the first call to connect to SSE API
setStatus('UNKNOWN');
const signal = controller.signal;
const apiResponse = await fetch('/api/app-status', {
method: "POST",
headers: {
"Content-Type": "text/event-stream",
},
body: JSON.stringify({ appId }),
signal: signal,
});
if (!apiResponse.ok) return;
if (!apiResponse.body) return;
setIsConnected(true);
// To decode incoming data as a string
const reader = apiResponse.body
.pipeThrough(new TextDecoderStream())
.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) {
setIsConnected(false);
break;
}
if (value) {
setStatus(value as DeplyomentStatus);
}
}
}
useEffect(() => {
if (!appId) {
return;
}
const controller = new AbortController();
initializeConnection(controller);
return () => {
console.log('Disconnecting from status listener');
setStatus('UNKNOWN');
controller.abort();
};
}, [appId]);
const mapToStatusColor = (status: DeplyomentStatus) => {
switch (status) {
case 'UNKNOWN':
return 'bg-gray-500';
case 'DEPLOYING':
return 'bg-orange-500';
case 'DEPLOYED':
return 'bg-green-500';
case 'SHUTTING_DOWN':
return 'bg-orange-500';
case 'SHUTDOWN':
return 'bg-gray-500';
default:
return 'bg-gray-500';
}
};
return <>
<div className={mapToStatusColor(status) + ' rounded-full w-3 h-3'}>
<div></div>
</div>
</>;
}

View File

@@ -7,6 +7,7 @@ export const deploymentStatusEnumZod = z.union([
z.literal('DEPLOYED'),
z.literal('DEPLOYING'),
z.literal('SHUTDOWN'),
z.literal('SHUTTING_DOWN'),
]);
export const deploymentInfoZodModel = z.object({

View File

@@ -1,8 +1,13 @@
import * as k8s from '@kubernetes/client-node';
const getK8sCoreApiClient = () => {
const getKubeConfig = () => {
const kc = new k8s.KubeConfig();
kc.loadFromFile('/workspace/kube-config.config'); // todo update --> use security role
return kc;
}
const getK8sCoreApiClient = () => {
const kc = getKubeConfig()
const k8sCoreClient = kc.makeApiClient(k8s.CoreV1Api);
return k8sCoreClient;
}
@@ -10,8 +15,7 @@ const k8sCoreClient = globalThis.k8sCoreGlobal ?? getK8sCoreApiClient()
if (process.env.NODE_ENV !== 'production') globalThis.k8sCoreGlobal = k8sCoreClient
const getK8sAppsApiClient = () => {
const kc = new k8s.KubeConfig();
kc.loadFromFile('/workspace/kube-config.config'); // todo update --> use security role
const kc = getKubeConfig()
const k8sCoreClient = kc.makeApiClient(k8s.AppsV1Api);
return k8sCoreClient;
}
@@ -19,8 +23,7 @@ const k8sAppsClient = globalThis.k8sAppsGlobal ?? getK8sAppsApiClient()
if (process.env.NODE_ENV !== 'production') globalThis.k8sAppsGlobal = k8sAppsClient
const getK8sBatchApiClient = () => {
const kc = new k8s.KubeConfig();
kc.loadFromFile('/workspace/kube-config.config'); // todo update --> use security role
const kc = getKubeConfig()
const k8sJobClient = kc.makeApiClient(k8s.BatchV1Api);
return k8sJobClient;
}
@@ -29,8 +32,7 @@ if (process.env.NODE_ENV !== 'production') globalThis.k8sJobGlobal = k8sJobClien
const getK8sLogApiClient = () => {
const kc = new k8s.KubeConfig();
kc.loadFromFile('/workspace/kube-config.config'); // todo update --> use security role
const kc = getKubeConfig()
const logClient = new k8s.Log(kc)
return logClient;
}
@@ -38,8 +40,7 @@ const k8sLogClient = globalThis.k8sLogGlobal ?? getK8sLogApiClient()
if (process.env.NODE_ENV !== 'production') globalThis.k8sLogGlobal = k8sLogClient
const getK8sCustomObjectsApiClient = () => {
const kc = new k8s.KubeConfig();
kc.loadFromFile('/workspace/kube-config.config'); // todo update --> use security role
const kc = getKubeConfig()
const client = kc.makeApiClient(k8s.CustomObjectsApi);
return client;
}
@@ -47,8 +48,7 @@ const k8sCustomObjectsClient = globalThis.k8sCustomObjectsGlobal ?? getK8sCustom
if (process.env.NODE_ENV !== 'production') globalThis.k8sCustomObjectsGlobal = k8sCustomObjectsClient
const getK8sNetworkApiClient = () => {
const kc = new k8s.KubeConfig();
kc.loadFromFile('/workspace/kube-config.config'); // todo update --> use security role
const kc = getKubeConfig()
const networkClient = kc.makeApiClient(k8s.NetworkingV1Api);
return networkClient;
}
@@ -75,6 +75,10 @@ class K3sApiAdapter {
log = k8sLogClient;
network = k8sNetworkClient;
customObjects = k8sCustomObjectsClient;
getKubeConfig = () => {
return getKubeConfig();
}
}
const k3s = new K3sApiAdapter();

View File

@@ -1,6 +1,6 @@
import { AppExtendedModel } from "@/model/app-extended.model";
import k3s from "../adapter/kubernetes-api.adapter";
import { V1Deployment, V1Ingress, V1PersistentVolumeClaim } from "@kubernetes/client-node";
import { V1Deployment, V1Ingress, V1PersistentVolumeClaim, V1ReplicaSet } from "@kubernetes/client-node";
import buildService from "./build.service";
import { ListUtils } from "../utils/list.utils";
import { DeploymentInfoModel, DeplyomentStatus } from "@/model/deployment-info.model";
@@ -190,6 +190,14 @@ class DeploymentService {
})).filter((item) => !!item.podName && !!item.containerName) as PodsInfoModel[];
}
async getDeploymentStatus(projectId: string, appId: string) {
const deployment = await this.getDeployment(projectId, appId);
if (!deployment) {
return 'UNKNOWN';
}
return this.mapReplicasetToStatus(deployment);
}
async getPodByName(projectId: string, podName: string) {
const res = await k3s.core.readNamespacedPod(podName, projectId);
return {
@@ -198,7 +206,6 @@ class DeploymentService {
} as PodsInfoModel;
}
/**
* Searches for Build Jobs (only for Git Projects) and ReplicaSets (for all projects) and returns a list of DeploymentModel
* Build are only included if they are in status RUNNING, FAILED or UNKNOWN. SUCCESSFUL builds are not included because they are already part of the ReplicaSet history.
@@ -245,24 +252,7 @@ class DeploymentService {
const replicaSetsForDeployment = await k3s.apps.listNamespacedReplicaSet(projectId, undefined, undefined, undefined, undefined, `app=${appId}`);
const revisions = replicaSetsForDeployment.body.items.map((rs, index) => {
let status = 'UNKNOWN' as DeplyomentStatus;
if (rs.status?.replicas === 0) {
status = 'SHUTDOWN';
} else if (rs.status?.replicas === rs.status?.readyReplicas) {
status = 'DEPLOYED';
} else if (rs.status?.replicas !== rs.status?.readyReplicas) {
status = 'DEPLOYING';
}
/*
Fields for Status:
availableReplicas: 1,
conditions: undefined,
fullyLabeledReplicas: 1,
observedGeneration: 3,
readyReplicas: 1,
replicas: 1
*/
let status = this.mapReplicasetToStatus(rs);
return {
replicasetName: rs.metadata?.name!,
createdAt: rs.metadata?.creationTimestamp!,
@@ -273,7 +263,28 @@ class DeploymentService {
return ListUtils.sortByDate(revisions, (i) => i.createdAt!, true);
}
private mapReplicasetToStatus(deployment: V1Deployment | V1ReplicaSet): DeplyomentStatus {
/*
Fields for Status:
availableReplicas: 1,
conditions: undefined,
fullyLabeledReplicas: 1,
observedGeneration: 3,
readyReplicas: 1,
replicas: 1
*/
let status: DeplyomentStatus = 'UNKNOWN';
if (deployment.status?.replicas === 0) {
status = 'SHUTDOWN';
} else if (deployment.status?.replicas === deployment.status?.readyReplicas) {
status = 'DEPLOYED';
} else if (deployment.status?.replicas === 0 && deployment.status?.replicas !== deployment.status?.readyReplicas) {
status = 'SHUTTING_DOWN';
} else if (deployment.status?.replicas !== deployment.status?.readyReplicas) {
status = 'DEPLOYING';
}
return status;
}
}