From b84082981c1bace13a49a1e92dee0a6531d8e17d Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Sun, 10 Nov 2024 13:21:02 +0000 Subject: [PATCH] added status badge --- src/app/api/app-status/route.ts | 74 +++++++++++++++ src/app/api/pod-logs/route.ts | 2 +- src/app/api/pods-status/route.ts | 27 ------ .../app/[tabName]/app-action-buttons.tsx | 2 + src/app/project/app/[tabName]/app-status.tsx | 92 +++++++++++++++++++ src/model/deployment-info.model.ts | 1 + src/server/adapter/kubernetes-api.adapter.ts | 26 +++--- src/server/services/deployment.service.ts | 53 ++++++----- 8 files changed, 217 insertions(+), 60 deletions(-) create mode 100644 src/app/api/app-status/route.ts delete mode 100644 src/app/api/pods-status/route.ts create mode 100644 src/app/project/app/[tabName]/app-status.tsx diff --git a/src/app/api/app-status/route.ts b/src/app/api/app-status/route.ts new file mode 100644 index 0000000..4e05662 --- /dev/null +++ b/src/app/api/app-status/route.ts @@ -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; + 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", + }, + }); + }); +} \ No newline at end of file diff --git a/src/app/api/pod-logs/route.ts b/src/app/api/pod-logs/route.ts index 5bf22b1..b44f212 100644 --- a/src/app/api/pod-logs/route.ts +++ b/src/app/api/pod-logs/route.ts @@ -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; diff --git a/src/app/api/pods-status/route.ts b/src/app/api/pods-status/route.ts deleted file mode 100644 index d9ca59f..0000000 --- a/src/app/api/pods-status/route.ts +++ /dev/null @@ -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", - }, - }) -} \ No newline at end of file diff --git a/src/app/project/app/[tabName]/app-action-buttons.tsx b/src/app/project/app/[tabName]/app-action-buttons.tsx index bed89c5..a9dc582 100644 --- a/src/app/project/app/[tabName]/app-action-buttons.tsx +++ b/src/app/project/app/[tabName]/app-action-buttons.tsx @@ -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 +
diff --git a/src/app/project/app/[tabName]/app-status.tsx b/src/app/project/app/[tabName]/app-status.tsx new file mode 100644 index 0000000..c64c522 --- /dev/null +++ b/src/app/project/app/[tabName]/app-status.tsx @@ -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('UNKNOWN'); + const textAreaRef = useRef(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 <> +
+
+
+ ; +} diff --git a/src/model/deployment-info.model.ts b/src/model/deployment-info.model.ts index c0860a7..357a6b8 100644 --- a/src/model/deployment-info.model.ts +++ b/src/model/deployment-info.model.ts @@ -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({ diff --git a/src/server/adapter/kubernetes-api.adapter.ts b/src/server/adapter/kubernetes-api.adapter.ts index b6098fe..68c359f 100644 --- a/src/server/adapter/kubernetes-api.adapter.ts +++ b/src/server/adapter/kubernetes-api.adapter.ts @@ -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(); diff --git a/src/server/services/deployment.service.ts b/src/server/services/deployment.service.ts index e764b1b..77b3f34 100644 --- a/src/server/services/deployment.service.ts +++ b/src/server/services/deployment.service.ts @@ -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; + } }