mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-01-05 02:59:54 -06:00
added status badge
This commit is contained in:
74
src/app/api/app-status/route.ts
Normal file
74
src/app/api/app-status/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
92
src/app/project/app/[tabName]/app-status.tsx
Normal file
92
src/app/project/app/[tabName]/app-status.tsx
Normal 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>
|
||||
</>;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user