From 7569d646be9f385ebff9de4fb6d9c0a121b47ade Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Sun, 28 Dec 2025 17:10:54 +0000 Subject: [PATCH 1/9] feat: replace live status for pods with Kubernetes watch implementation --- src/app/api/app-status/route.ts | 89 -------------- src/app/api/deployment-status/actions.ts | 61 ---------- src/app/api/deployment-status/route.ts | 114 +++++++++++++++++ .../app/[appId]/app-action-buttons.tsx | 4 +- src/app/project/app/[appId]/app-status.tsx | 92 -------------- .../overview/deployment-status-badge.tsx | 10 +- src/app/settings/server/page.tsx | 19 +-- .../services/pods-status-polling.service.ts | 115 ++++++++++++------ src/frontend/states/zustand.states.ts | 13 +- .../deployment-live-status.service.ts | 75 ++++++++++++ src/server/services/deployment.service.ts | 8 +- src/server/services/namespace.service.ts | 2 +- src/shared/model/app-pod-status.model.ts | 11 ++ src/shared/model/deployment-info.model.ts | 2 +- 14 files changed, 317 insertions(+), 298 deletions(-) delete mode 100644 src/app/api/app-status/route.ts delete mode 100644 src/app/api/deployment-status/actions.ts create mode 100644 src/app/api/deployment-status/route.ts delete mode 100644 src/app/project/app/[appId]/app-status.tsx create mode 100644 src/server/services/deployment-live-status.service.ts create mode 100644 src/shared/model/app-pod-status.model.ts diff --git a/src/app/api/app-status/route.ts b/src/app/api/app-status/route.ts deleted file mode 100644 index 79f1bdf3..00000000 --- a/src/app/api/app-status/route.ts +++ /dev/null @@ -1,89 +0,0 @@ -import k3s from "@/server/adapter/kubernetes-api.adapter"; -import appService from "@/server/services/app.service"; -import deploymentService from "@/server/services/deployment.service"; -import { isAuthorizedReadForApp, 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; - await isAuthorizedReadForApp(appId); - - 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(); - let shouldStopStreaming = false; - - const customReadable = new ReadableStream({ - start(controller) { - - const getDeploymentStatus = async () => { - const deploymentStatus = await deploymentService.getDeploymentStatus(app.projectId, app.id); - try { - controller.enqueue(encoder.encode(deploymentStatus)) - } catch (e) { - console.error(`[ENQUEUE ERROR] Error while enqueueing Deployment Status for app ${appId}: `, e); - shouldStopStreaming = true; - informer?.stop(); - controller.close(); - } - }; - - const kc = k3s.getKubeConfig(); - informer = k8s.makeInformer( - kc, - `/api/v1/namespaces/${namespace}/pods`, - () => k3s.core.listNamespacedPod(namespace, undefined, undefined, undefined, undefined, `app=${app.id}`), - `app=${app.id}` - ); - - informer.on('add', () => getDeploymentStatus()); - informer.on('update', () => getDeploymentStatus()); - //informer.on('change', () => getDeploymentStatus()); - informer.on('delete', () => getDeploymentStatus()); - informer.on('error', (err: any) => { - // todo there is a error because of the invalid Certificat Authority, so every time error - // is thrown, we need to restart the informer --> TODO - console.error(`[INFORMER ERROR] Error while listening for Deplyoment Changes for app ${appId}: `, err); - // todo fix this^^ - getDeploymentStatus() - // Try to restart informer after 5sec - //if (!shouldStopStreaming) setTimeout(() => informer.start(), 5000); - }); - - informer.start(); - getDeploymentStatus(); - console.log("[START] Starting informer for app " + appId); - }, - cancel() { - console.log("[LEAVE] Cancelling informer for app " + appId); - 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/deployment-status/actions.ts b/src/app/api/deployment-status/actions.ts deleted file mode 100644 index 797b4455..00000000 --- a/src/app/api/deployment-status/actions.ts +++ /dev/null @@ -1,61 +0,0 @@ -'use server' - -import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; -import projectService from "@/server/services/project.service"; -import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils"; -import deploymentService from "@/server/services/deployment.service"; -import { DeplyomentStatus } from "@/shared/model/deployment-info.model"; - -export interface AppPodsStatusModel { - appId: string; - appName: string; - projectId: string; - projectName: string; - replicas?: number; - readyReplicas?: number; - deploymentStatus: DeplyomentStatus; -} - -export const getAllPodsStatus = async () => - simpleAction(async () => { - await getAuthUserSession(); - - const allAppPods: AppPodsStatusModel[] = []; - const [projects, allDeployments] = await Promise.all([ - projectService.getAllProjects(), - deploymentService.getAllDeployments() - ]); - - for (const project of projects) { - for (const app of project.apps) { - const deploymentInfo = allDeployments.find(dep => - dep.metadata?.namespace === project.id && - dep.metadata?.name === app.id - ); - if (!deploymentInfo) { - allAppPods.push({ - appId: app.id, - appName: app.name, - projectId: project.id, - projectName: project.name, - replicas: undefined, - readyReplicas: undefined, - deploymentStatus: 'SHUTDOWN' // nothing is deployed, so maybe the app is just created in the database and not started yet - }); - continue; - } - const deploymentStatus = deploymentService.mapReplicasetToStatus(deploymentInfo); - allAppPods.push({ - appId: app.id, - appName: app.name, - projectId: project.id, - projectName: project.name, - replicas: deploymentInfo.status?.replicas, - readyReplicas: deploymentInfo.status?.readyReplicas, - deploymentStatus - }); - } - } - - return allAppPods; - }) as Promise>; diff --git a/src/app/api/deployment-status/route.ts b/src/app/api/deployment-status/route.ts new file mode 100644 index 00000000..ba88d054 --- /dev/null +++ b/src/app/api/deployment-status/route.ts @@ -0,0 +1,114 @@ +import k3s from "@/server/adapter/kubernetes-api.adapter"; +import deploymentLiveStatusService from "@/server/services/deployment-live-status.service"; +import { getAuthUserSession, simpleRoute } from "@/server/utils/action-wrapper.utils"; +import { V1Deployment } from "@kubernetes/client-node"; +import * as k8s from '@kubernetes/client-node'; + +// Prevents this route's response from being cached +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + return simpleRoute(async () => { + + const session = await getAuthUserSession(); + + const encoder = new TextEncoder(); + let shouldStopStreaming = false; + let watchRequest: { abort: () => void } | null = null; + + // Fetch all projects and apps to build a lookup map + let appLookup = await deploymentLiveStatusService.getAppLookup(session); + + const customReadable = new ReadableStream({ + async start(controller) { + + const sendData = (data: any) => { + if (shouldStopStreaming) return; + try { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); + } catch (e) { + console.error(`[ENQUEUE ERROR] Error while enqueueing Deployment Status data: `, e); + shouldStopStreaming = true; + controller.close(); + } + }; + + // 1. Send initial state + try { + const initialStatus = await deploymentLiveStatusService.getInitialStatus(appLookup); + sendData(initialStatus); + } catch (e) { + console.error("Error fetching initial status", e); + } + + // 2. Watch for changes + const kc = k3s.getKubeConfig(); + const watch = new k8s.Watch(kc); + console.log("[START] Starting watch for deployments "); + watchRequest = await watch.watch( + '/apis/apps/v1/deployments', + {}, + async (type, apiObj, watchObj) => { + if (shouldStopStreaming) { return; } + + const deployment = apiObj as V1Deployment; + const appId = deployment.metadata?.name; + const projectId = deployment.metadata?.namespace; + + if (!appId || !projectId) { return; } + + // ignore system namespaces + if (['default', 'longhorn-system', 'kube-public', 'kube-system', 'cert-manager'].includes(projectId)) { return; } + + // If a new deployment is detected (ADDED) and we don't know about it, + // it might be a newly created app. Refresh the lookup. + if (type === 'ADDED' && !appLookup.has(appId)) { + console.log(`[LiveStatus] New unknown deployment detected for ${appId}, refreshing app lookup`); + appLookup = await deploymentLiveStatusService.getAppLookup(session); + } + + const appInfo = appLookup.get(appId); + if (!appInfo) { + return; + } + + // Verify namespace matches project ID + if (appInfo.projectId !== projectId) { return; } + + let status; + if (type === 'DELETED') { + status = deploymentLiveStatusService.mapDeploymentToStatus(appId, appInfo, undefined); + } else { + status = deploymentLiveStatusService.mapDeploymentToStatus(appId, appInfo, deployment); + } + + sendData(status); + }, + (err) => { + if (err) console.error('Deploy watch error', err); + console.log('Deploy watch ended'); + if (!shouldStopStreaming) { + controller.close(); + } + } + ); + }, + cancel() { + console.log("[LEAVE] Cancelling informer for deployments"); + shouldStopStreaming = true; + if (watchRequest && typeof watchRequest.abort === 'function') { + watchRequest.abort(); + } + } + }); + + 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/project/app/[appId]/app-action-buttons.tsx b/src/app/project/app/[appId]/app-action-buttons.tsx index 17d7d0b2..593d8883 100644 --- a/src/app/project/app/[appId]/app-action-buttons.tsx +++ b/src/app/project/app/[appId]/app-action-buttons.tsx @@ -5,12 +5,12 @@ import { Card, CardContent } from "@/components/ui/card"; import { deploy, startApp, stopApp } from "./actions"; import { AppExtendedModel } from "@/shared/model/app-extended.model"; import { Toast } from "@/frontend/utils/toast.utils"; -import AppStatus from "./app-status"; import { ExternalLink, Hammer, Pause, Play, Rocket } from "lucide-react"; import { AppEventsDialog } from "./app-events-dialog"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { UserSession } from "@/shared/model/sim-session.model"; import { UserGroupUtils } from "@/shared/utils/role.utils"; +import PodStatusIndicator from "@/components/custom/pod-status-indicator"; export default function AppActionButtons({ app, @@ -24,7 +24,7 @@ export default function AppActionButtons({
-
+
{hasWriteAccess && <> diff --git a/src/app/project/app/[appId]/app-status.tsx b/src/app/project/app/[appId]/app-status.tsx deleted file mode 100644 index 3a30e7b8..00000000 --- a/src/app/project/app/[appId]/app-status.tsx +++ /dev/null @@ -1,92 +0,0 @@ -'use client' - -import { useEffect, useRef, useState } from "react"; -import { Textarea } from "@/components/ui/textarea"; -import React from "react"; -import { DeplyomentStatus } from "@/shared/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/app/project/app/[appId]/overview/deployment-status-badge.tsx b/src/app/project/app/[appId]/overview/deployment-status-badge.tsx index 37882bba..5d9c19b6 100644 --- a/src/app/project/app/[appId]/overview/deployment-status-badge.tsx +++ b/src/app/project/app/[appId]/overview/deployment-status-badge.tsx @@ -1,13 +1,13 @@ 'use client' -import { DeplyomentStatus } from "@/shared/model/deployment-info.model"; +import { DeploymentStatus } from "@/shared/model/deployment-info.model"; export default function DeploymentStatusBadge( { children }: { - children: DeplyomentStatus + children: DeploymentStatus } ) { @@ -16,7 +16,7 @@ export default function DeploymentStatusBadge( ) } -function getTextForStatus(status: DeplyomentStatus) { +function getTextForStatus(status: DeploymentStatus) { switch (status) { case 'SHUTDOWN': return 'Shutdown'; @@ -33,7 +33,7 @@ function getTextForStatus(status: DeplyomentStatus) { } } -function getBackgroundColorForStatus(status: DeplyomentStatus) { +function getBackgroundColorForStatus(status: DeploymentStatus) { switch (status) { case 'SHUTDOWN': @@ -51,7 +51,7 @@ function getBackgroundColorForStatus(status: DeplyomentStatus) { } } -function getTextColorForStatus(status: DeplyomentStatus) { +function getTextColorForStatus(status: DeploymentStatus) { switch (status) { case 'SHUTDOWN': diff --git a/src/app/settings/server/page.tsx b/src/app/settings/server/page.tsx index 5a0fdfc0..c731782f 100644 --- a/src/app/settings/server/page.tsx +++ b/src/app/settings/server/page.tsx @@ -22,6 +22,7 @@ import quickStackService from "@/server/services/qs.service"; import { ServerSettingsTabs } from "./server-settings-tabs"; import { Settings, Network, HardDrive, Rocket, Wrench } from "lucide-react"; import quickStackUpdateService from "@/server/services/qs-update.service"; +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; export default async function ProjectPage({ searchParams @@ -82,14 +83,16 @@ export default async function ProjectPage({ - - General - Networking / Traefik - Storage & Backups - Updates {newVersionInfo &&
} - Maintenance - - + + + General + Networking / Traefik + Storage & Backups + Updates {newVersionInfo &&
} + Maintenance + + +
diff --git a/src/frontend/services/pods-status-polling.service.ts b/src/frontend/services/pods-status-polling.service.ts index 7da6b70d..adbc5290 100644 --- a/src/frontend/services/pods-status-polling.service.ts +++ b/src/frontend/services/pods-status-polling.service.ts @@ -1,15 +1,14 @@ -import { getAllPodsStatus } from '@/app/api/deployment-status/actions'; +import { AppPodsStatusModel } from '@/shared/model/app-pod-status.model'; import { usePodsStatus } from '../states/zustand.states'; /** - * Singleton service that manages polling for all pods status. - * This service runs in the browser and updates the Zustand store with fresh data. + * Singleton service that manages streaming for all pods status. + * This service runs in the browser and updates the Zustand store with fresh data via SSE. */ class PodsStatusPollingService { private static instance: PodsStatusPollingService; - private intervalId: NodeJS.Timeout | null = null; - private isPolling = false; - private readonly POLL_INTERVAL_MS = 20000; + private controller: AbortController | null = null; + private isConnected = false; private constructor() { } @@ -21,54 +20,102 @@ class PodsStatusPollingService { } public start(): void { - if (this.isPolling) { - console.log('[PodsStatusPolling] Already polling, skipping start'); + if (this.isConnected) { + console.log('[PodsStatusService] Already connected, skipping start'); return; } - console.log('[PodsStatusPolling] Starting pod status polling'); - this.isPolling = true; - - // Fetch immediately on start - this.fetchPodsStatus(); - - this.intervalId = setInterval(() => { - this.fetchPodsStatus(); - }, this.POLL_INTERVAL_MS); + console.log('[PodsStatusService] Starting pod status stream'); + this.connect(); } public stop(): void { - if (this.intervalId) { - console.log('[PodsStatusPolling] Stopping pod status polling'); - clearInterval(this.intervalId); - this.intervalId = null; - this.isPolling = false; + if (this.controller) { + console.log('[PodsStatusService] Stopping pod status stream'); + this.controller.abort(); + this.controller = null; + this.isConnected = false; } } - private async fetchPodsStatus(): Promise { + private async connect() { + this.controller = new AbortController(); + const signal = this.controller.signal; + this.isConnected = true; + try { - const { setPodsStatus } = usePodsStatus.getState(); + const response = await fetch('/api/deployment-status', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + signal: signal, + }); - const response = await getAllPodsStatus(); + if (!response.ok || !response.body) { + throw new Error('Failed to connect to deployment status stream'); + } + + const reader = response.body + .pipeThrough(new TextDecoderStream()) + .getReader(); - if (response.status === 'success' && response.data) { - console.log('Polles status', response.data) - setPodsStatus(response.data); + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (value) { + this.processChunk(value); + } + } + } catch (error: any) { + if (error.name === 'AbortError') { + console.log('[PodsStatusService] Stream aborted'); } else { - console.error('[PodsStatusPolling] Failed to fetch pods status:', response.message); + console.error('[PodsStatusService] Stream error:', error); + // Retry logic + this.isConnected = false; + setTimeout(() => { + if (!signal.aborted) { + this.connect(); + } + }, 5000); + } + } finally { + this.isConnected = false; + } + } + + private processChunk(chunk: string) { + // SSE format: data: ...\n\n + // There might be multiple messages in one chunk + const lines = chunk.split('\n\n'); + for (const line of lines) { + if (line.startsWith('data: ')) { + const jsonStr = line.substring(6); + try { + const data = JSON.parse(jsonStr); + const { setPodsStatus, updatePodStatus } = usePodsStatus.getState(); + + if (Array.isArray(data)) { + setPodsStatus(data as AppPodsStatusModel[]); + } else { + updatePodStatus(data as AppPodsStatusModel); + } + } catch (e) { + console.error('[PodsStatusService] Error parsing JSON:', e); + } } - } catch (error) { - console.error('[PodsStatusPolling] Error fetching pods status:', error); } } - public async refresh(): Promise { - await this.fetchPodsStatus(); + public refresh(): void { + // Reconnect to refresh + this.stop(); + this.start(); } public isActive(): boolean { - return this.isPolling; + return this.isConnected; } } diff --git a/src/frontend/states/zustand.states.ts b/src/frontend/states/zustand.states.ts index 0baac290..30964576 100644 --- a/src/frontend/states/zustand.states.ts +++ b/src/frontend/states/zustand.states.ts @@ -1,6 +1,6 @@ import dataAccess from "@/server/adapter/db.client"; +import { AppPodsStatusModel } from "@/shared/model/app-pod-status.model"; import { create } from "zustand" -import { AppPodsStatusModel } from "@/app/api/deployment-status/actions"; interface ZustandConfirmDialogProps { isDialogOpen: boolean; @@ -104,6 +104,7 @@ interface ZustandPodsStatusProps { lastUpdate: Date | null; isLoading: boolean; setPodsStatus: (data: AppPodsStatusModel[]) => void; + updatePodStatus: (data: AppPodsStatusModel) => void; setLoading: (loading: boolean) => void; getPodsForApp: (appId: string) => AppPodsStatusModel | undefined; } @@ -119,6 +120,16 @@ export const usePodsStatus = create((set, get) => ({ isLoading: false, }); }, + updatePodStatus: (data) => { + set((state) => { + const newMap = new Map(state.podsStatus); + newMap.set(data.appId, data); + return { + podsStatus: newMap, + lastUpdate: new Date(), + }; + }); + }, setLoading: (loading) => { set({ isLoading: loading }); }, diff --git a/src/server/services/deployment-live-status.service.ts b/src/server/services/deployment-live-status.service.ts new file mode 100644 index 00000000..f7a20716 --- /dev/null +++ b/src/server/services/deployment-live-status.service.ts @@ -0,0 +1,75 @@ +import projectService from "@/server/services/project.service"; +import deploymentService from "@/server/services/deployment.service"; +import { UserGroupUtils } from "@/shared/utils/role.utils"; +import { UserSession } from "@/shared/model/sim-session.model"; +import { V1Deployment } from "@kubernetes/client-node"; +import { AppPodsStatusModel } from "@/shared/model/app-pod-status.model"; + +export interface AppLookupInfo { + appName: string; + projectId: string; + projectName: string; +} + +class DeploymentLiveStatusService { + + async getAppLookup(session?: UserSession): Promise> { + const projects = await projectService.getAllProjects(); + const appLookup = new Map(); + + for (const project of projects) { + for (const app of project.apps) { + if (session) { + if (!UserGroupUtils.sessionHasReadAccessForApp(session, app.id)) { + continue; + } + } + appLookup.set(app.id, { + appName: app.name, + projectId: project.id, + projectName: project.name + }); + } + } + return appLookup; + } + + async getInitialStatus(appLookup: Map): Promise { + const allDeployments = await deploymentService.getAllDeployments(); + const initialStatus: AppPodsStatusModel[] = []; + + // Iterate over all known apps to ensure we send status for everything (even SHUTDOWN) + for (const [appId, info] of Array.from(appLookup.entries())) { + const deployment = allDeployments.find(d => d.metadata?.name === appId && d.metadata?.namespace === info.projectId); + initialStatus.push(this.mapDeploymentToStatus(appId, info, deployment)); + } + return initialStatus; + } + + mapDeploymentToStatus(appId: string, appInfo: AppLookupInfo, deployment?: V1Deployment): AppPodsStatusModel { + if (deployment) { + return { + appId: appId, + appName: appInfo.appName, + projectId: appInfo.projectId, + projectName: appInfo.projectName, + replicas: deployment.status?.replicas, + readyReplicas: deployment.status?.readyReplicas, + deploymentStatus: deploymentService.mapReplicasetToStatus(deployment) + }; + } else { + return { + appId: appId, + appName: appInfo.appName, + projectId: appInfo.projectId, + projectName: appInfo.projectName, + replicas: undefined, + readyReplicas: undefined, + deploymentStatus: 'SHUTDOWN' + }; + } + } +} + +const deploymentLiveStatusService = new DeploymentLiveStatusService(); +export default deploymentLiveStatusService; diff --git a/src/server/services/deployment.service.ts b/src/server/services/deployment.service.ts index 484a4b80..f72144c1 100644 --- a/src/server/services/deployment.service.ts +++ b/src/server/services/deployment.service.ts @@ -3,7 +3,7 @@ import k3s from "../adapter/kubernetes-api.adapter"; import { V1Deployment, V1ReplicaSet } from "@kubernetes/client-node"; import buildService from "./build.service"; import { ListUtils } from "../../shared/utils/list.utils"; -import { DeploymentInfoModel, DeplyomentStatus } from "@/shared/model/deployment-info.model"; +import { DeploymentInfoModel, DeploymentStatus } from "@/shared/model/deployment-info.model"; import { BuildJobStatus } from "@/shared/model/build-job"; import { ServiceException } from "@/shared/model/service.exception.model"; import pvcService from "./pvc.service"; @@ -259,7 +259,7 @@ class DeploymentService { } mapBuildStatusToDeploymentStatus(buildJobStatus?: BuildJobStatus) { - const map = new Map([ + const map = new Map([ ['UNKNOWN', 'UNKNOWN'], ['RUNNING', 'BUILDING'], ['FAILED', 'ERROR'] @@ -292,7 +292,7 @@ class DeploymentService { return ListUtils.sortByDate(revisions, (i) => i.createdAt!, true); } - mapReplicasetToStatus(deployment: V1Deployment | V1ReplicaSet): DeplyomentStatus { + mapReplicasetToStatus(deployment: V1Deployment | V1ReplicaSet): DeploymentStatus { /* Fields for Status: availableReplicas: 1, @@ -302,7 +302,7 @@ class DeploymentService { readyReplicas: 1, replicas: 1 */ - let status: DeplyomentStatus = 'UNKNOWN'; + let status: DeploymentStatus = 'UNKNOWN'; if (deployment.status?.replicas === undefined) { return 'SHUTDOWN'; } diff --git a/src/server/services/namespace.service.ts b/src/server/services/namespace.service.ts index 7a553f29..ad541f52 100644 --- a/src/server/services/namespace.service.ts +++ b/src/server/services/namespace.service.ts @@ -3,7 +3,7 @@ import k3s from "../adapter/kubernetes-api.adapter"; import { V1Deployment, V1Ingress, V1PersistentVolumeClaim } from "@kubernetes/client-node"; import buildService from "./build.service"; import { ListUtils } from "../../shared/utils/list.utils"; -import { DeploymentInfoModel, DeplyomentStatus } from "@/shared/model/deployment-info.model"; +import { DeploymentInfoModel, DeploymentStatus } from "@/shared/model/deployment-info.model"; import { BuildJobStatus } from "@/shared/model/build-job"; import { ServiceException } from "@/shared/model/service.exception.model"; import { PodsInfoModel } from "@/shared/model/pods-info.model"; diff --git a/src/shared/model/app-pod-status.model.ts b/src/shared/model/app-pod-status.model.ts new file mode 100644 index 00000000..c8ff9d7f --- /dev/null +++ b/src/shared/model/app-pod-status.model.ts @@ -0,0 +1,11 @@ +import { DeploymentStatus } from "./deployment-info.model"; + +export interface AppPodsStatusModel { + appId: string; + appName: string; + projectId: string; + projectName: string; + replicas?: number; + readyReplicas?: number; + deploymentStatus: DeploymentStatus; +} diff --git a/src/shared/model/deployment-info.model.ts b/src/shared/model/deployment-info.model.ts index 890f4762..67836fd4 100644 --- a/src/shared/model/deployment-info.model.ts +++ b/src/shared/model/deployment-info.model.ts @@ -20,6 +20,6 @@ export const deploymentInfoZodModel = z.object({ }); export type DeploymentInfoModel = z.infer; -export type DeplyomentStatus = z.infer; +export type DeploymentStatus = z.infer; From 6a7094e15740be113ac6af41a31c4560028b7dc8 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Mon, 29 Dec 2025 10:24:04 +0000 Subject: [PATCH 2/9] feat: add revalidation for QuickStack version cache before update --- src/app/settings/server/actions.ts | 6 +++++- src/app/settings/server/qs-version-info.tsx | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/settings/server/actions.ts b/src/app/settings/server/actions.ts index 7f8b4b52..7109893e 100644 --- a/src/app/settings/server/actions.ts +++ b/src/app/settings/server/actions.ts @@ -106,11 +106,15 @@ export const cleanupOldBuildJobs = async () => return new SuccessActionResult(undefined, 'Successfully cleaned up old build jobs.'); }); +export const revalidateQuickStackVersionCache = async () => + simpleAction(async () => { + revalidateTag(Tags.quickStackVersionInfo()); // separated because updateFunction restarts backend wich results in error + }); + export const updateQuickstack = async () => simpleAction(async () => { await getAdminUserSession(); const useCaranyChannel = await paramService.getBoolean(ParamService.USE_CANARY_CHANNEL, false); - revalidateTag(Tags.quickStackVersionInfo()); await quickStackService.updateQuickStack(useCaranyChannel); return new SuccessActionResult(undefined, 'QuickStack will be updated, refresh the page in a few seconds.'); }); diff --git a/src/app/settings/server/qs-version-info.tsx b/src/app/settings/server/qs-version-info.tsx index b132d647..c06cf77f 100644 --- a/src/app/settings/server/qs-version-info.tsx +++ b/src/app/settings/server/qs-version-info.tsx @@ -1,7 +1,7 @@ 'use client'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; -import { setCanaryChannel, updateQuickstack } from "./actions"; +import { revalidateQuickStackVersionCache, setCanaryChannel, updateQuickstack } from "./actions"; import { Button } from "@/components/ui/button"; import { Toast } from "@/frontend/utils/toast.utils"; import { useConfirmDialog } from "@/frontend/states/zustand.states"; @@ -31,7 +31,8 @@ export default function QuickStackVersionInfo({ description: 'This action will restart the QuickStack service and installs the latest version. It may take a few minutes to complete.', okButton: "Update QuickStack", })) { - Toast.fromAction(() => updateQuickstack()); + await revalidateQuickStackVersionCache(); // separated because updateFunction restarts backend wich results in error + await Toast.fromAction(() => updateQuickstack()); } }; From faf72733845df551cfc5fd900f37aeee5acb251b Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Mon, 29 Dec 2025 10:40:02 +0000 Subject: [PATCH 3/9] feat: updated pod status subscription with realtime status change and removed polling mechanism --- src/app/project/app/[appId]/overview/logs.tsx | 18 +++++++++++------ src/frontend/states/zustand.states.ts | 20 ++++++++++++++++++- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/app/project/app/[appId]/overview/logs.tsx b/src/app/project/app/[appId]/overview/logs.tsx index 159e1103..b6a37f32 100644 --- a/src/app/project/app/[appId]/overview/logs.tsx +++ b/src/app/project/app/[appId]/overview/logs.tsx @@ -2,7 +2,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { AppExtendedModel } from "@/shared/model/app-extended.model"; import { useEffect, useState } from "react"; import LogsStreamed from "../../../../../components/custom/logs-streamed"; -import { getPodsForApp } from "./actions"; +import { getPodsForApp as getPodsForAppAction } from "./actions"; import { PodsInfoModel } from "@/shared/model/pods-info.model"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import FullLoadingSpinner from "@/components/ui/full-loading-spinnter"; @@ -14,6 +14,7 @@ import { TerminalDialog } from "./terminal-overlay"; import { LogsDownloadOverlay } from "./logs-download-overlay"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts"; +import { usePodsStatus } from "@/frontend/states/zustand.states"; export default function Logs({ app, @@ -24,10 +25,11 @@ export default function Logs({ }) { const [selectedPod, setSelectedPod] = useState(undefined); const [appPods, setAppPods] = useState(undefined); + const { subscribeToStatusChanges } = usePodsStatus(); const updateBuilds = async () => { try { - const response = await getPodsForApp(app.id); + const response = await getPodsForAppAction(app.id); if (response.status === 'success' && response.data) { setAppPods(response.data); } else { @@ -41,10 +43,14 @@ export default function Logs({ } useEffect(() => { - updateBuilds() - const intervalId = setInterval(updateBuilds, 10000); - return () => clearInterval(intervalId); - }, [app]); + updateBuilds(); + const unsubscribe = subscribeToStatusChanges((changedAppIds) => { + if (changedAppIds.includes(app.id)) { + updateBuilds(); + } + }); + return () => unsubscribe(); + }, [app.id]); useEffect(() => { if (appPods && selectedPod && !appPods.find(p => p.podName === selectedPod.podName)) { diff --git a/src/frontend/states/zustand.states.ts b/src/frontend/states/zustand.states.ts index 30964576..5dcf351a 100644 --- a/src/frontend/states/zustand.states.ts +++ b/src/frontend/states/zustand.states.ts @@ -1,4 +1,3 @@ -import dataAccess from "@/server/adapter/db.client"; import { AppPodsStatusModel } from "@/shared/model/app-pod-status.model"; import { create } from "zustand" @@ -103,22 +102,26 @@ interface ZustandPodsStatusProps { podsStatus: Map; lastUpdate: Date | null; isLoading: boolean; + listeners: Set<(changedAppIds: string[]) => void>; setPodsStatus: (data: AppPodsStatusModel[]) => void; updatePodStatus: (data: AppPodsStatusModel) => void; setLoading: (loading: boolean) => void; getPodsForApp: (appId: string) => AppPodsStatusModel | undefined; + subscribeToStatusChanges: (callback: (changedAppIds: string[]) => void) => () => void; } export const usePodsStatus = create((set, get) => ({ podsStatus: new Map(), lastUpdate: null, isLoading: true, + listeners: new Set(), setPodsStatus: (data) => { set({ podsStatus: new Map(data.map(app => [app.appId, app])), lastUpdate: new Date(), isLoading: false, }); + get().listeners.forEach(listener => listener(data.map(d => d.appId))); }, updatePodStatus: (data) => { set((state) => { @@ -129,6 +132,7 @@ export const usePodsStatus = create((set, get) => ({ lastUpdate: new Date(), }; }); + get().listeners.forEach(listener => listener([data.appId])); }, setLoading: (loading) => { set({ isLoading: loading }); @@ -136,4 +140,18 @@ export const usePodsStatus = create((set, get) => ({ getPodsForApp: (appId) => { return get().podsStatus.get(appId); }, + subscribeToStatusChanges: (callback) => { + set((state) => { + const newListeners = new Set(state.listeners); + newListeners.add(callback); + return { listeners: newListeners }; + }); + return () => { + set((state) => { + const newListeners = new Set(state.listeners); + newListeners.delete(callback); + return { listeners: newListeners }; + }); + }; + } })); From 7b63b9c0517bee6404dbf5bdc3485a85781d5337 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Mon, 29 Dec 2025 10:47:02 +0000 Subject: [PATCH 4/9] feat: add slight delay to pod status update on change notification --- src/app/project/app/[appId]/overview/logs.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/project/app/[appId]/overview/logs.tsx b/src/app/project/app/[appId]/overview/logs.tsx index b6a37f32..ab49c2e6 100644 --- a/src/app/project/app/[appId]/overview/logs.tsx +++ b/src/app/project/app/[appId]/overview/logs.tsx @@ -46,7 +46,8 @@ export default function Logs({ updateBuilds(); const unsubscribe = subscribeToStatusChanges((changedAppIds) => { if (changedAppIds.includes(app.id)) { - updateBuilds(); + setTimeout(() => + updateBuilds(), 500); // slight delay to ensure data is updated } }); return () => unsubscribe(); From b7bf753c4c21d8460cf40bbf5e2a555b40671a2c Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Mon, 29 Dec 2025 12:53:38 +0000 Subject: [PATCH 5/9] feat: enhance pod status updates with delayed re-fetching in logs component --- .../app/[appId]/app-action-buttons.tsx | 24 +++++++++++++++---- src/app/project/app/[appId]/overview/logs.tsx | 7 +++++- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/app/project/app/[appId]/app-action-buttons.tsx b/src/app/project/app/[appId]/app-action-buttons.tsx index 593d8883..79c4951b 100644 --- a/src/app/project/app/[appId]/app-action-buttons.tsx +++ b/src/app/project/app/[appId]/app-action-buttons.tsx @@ -5,12 +5,15 @@ import { Card, CardContent } from "@/components/ui/card"; import { deploy, startApp, stopApp } from "./actions"; import { AppExtendedModel } from "@/shared/model/app-extended.model"; import { Toast } from "@/frontend/utils/toast.utils"; -import { ExternalLink, Hammer, Pause, Play, Rocket } from "lucide-react"; +import { ExternalLink, Hammer, Pause, Play, Rocket, Square } from "lucide-react"; import { AppEventsDialog } from "./app-events-dialog"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { UserSession } from "@/shared/model/sim-session.model"; import { UserGroupUtils } from "@/shared/utils/role.utils"; import PodStatusIndicator from "@/components/custom/pod-status-indicator"; +import { usePodsStatus } from "@/frontend/states/zustand.states"; +import { useEffect, useState } from "react"; +import { DeploymentStatus } from "@/shared/model/deployment-info.model"; export default function AppActionButtons({ app, @@ -19,16 +22,29 @@ export default function AppActionButtons({ app: AppExtendedModel; session: UserSession; }) { + const [deploymentStatus, setDeploaymentStatus] = useState('UNKNOWN'); const hasWriteAccess = UserGroupUtils.sessionHasWriteAccessForApp(session, app.id); + const { subscribeToStatusChanges, getPodsForApp } = usePodsStatus(); + + useEffect(() => { + const unsubscribe = subscribeToStatusChanges((changedAppIds) => { + if (changedAppIds.includes(app.id)) { + const pods = getPodsForApp(app.id); + setDeploaymentStatus(pods?.deploymentStatus ?? 'UNKNOWN'); + } + }); + return () => unsubscribe(); + }, [app.id]); + return
{hasWriteAccess && <> - - - + {app.appType === 'APP' && app.sourceType === 'GIT' && } + + } {app.appDomains.length > 0 && + + + + + + {node.name} + + + Detailed resource usage metrics + + + +
+ {/* CPU Chart */} + + + + CPU Usage + + + + + - + + - - - - - - - -
-
- - + + ); + } + }} + /> + + + + + + + {/* RAM Chart */} + + + + Memory Usage + + + + + - + + - - - - - - -
-
- -
-
-
-
- )) - } -
+ {(node.ramUsage / node.ramCapacity * 100).toFixed(0)}% + + + RAM + + + {(node.ramUsage / (1024 * 1024 * 1024)).toFixed(2)} / {KubeSizeConverter.convertBytesToReadableSize(node.ramCapacity)} + + + ); + } + }} + /> + + + + + + + {/* Disk Chart */} + + + + Storage Usage + + + + + + +
+ + ); } From e20ff4c36a22a50d9d161875da862400f678a393 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Tue, 30 Dec 2025 13:54:25 +0000 Subject: [PATCH 8/9] feat: implement project status indicators and moved cluster settings into QuickStack settings --- src/app/monitoring/app-monitoring.tsx | 12 +++- src/app/projects/projects-table.tsx | 2 + src/app/settings/cluster/actions.ts | 30 -------- src/app/settings/cluster/page.tsx | 34 --------- src/app/settings/server/actions.ts | 24 +++++++ .../add-cluster-node-dialog.tsx | 0 .../settings/{cluster => server}/nodeInfo.tsx | 19 +++-- src/app/settings/server/page.tsx | 20 ++++-- .../traefik-ip-propagation-card.tsx | 6 +- src/app/sidebar-client.tsx | 5 -- .../custom/multi-state-progress.tsx | 37 ++++++++++ .../custom/project-status-indicator.tsx | 71 +++++++++++++++++++ 12 files changed, 177 insertions(+), 83 deletions(-) delete mode 100644 src/app/settings/cluster/actions.ts delete mode 100644 src/app/settings/cluster/page.tsx rename src/app/settings/{cluster => server}/add-cluster-node-dialog.tsx (100%) rename src/app/settings/{cluster => server}/nodeInfo.tsx (89%) rename src/app/settings/{cluster => server}/traefik-ip-propagation-card.tsx (95%) create mode 100644 src/components/custom/multi-state-progress.tsx create mode 100644 src/components/custom/project-status-indicator.tsx diff --git a/src/app/monitoring/app-monitoring.tsx b/src/app/monitoring/app-monitoring.tsx index e70ecdb8..2a1629c9 100644 --- a/src/app/monitoring/app-monitoring.tsx +++ b/src/app/monitoring/app-monitoring.tsx @@ -19,6 +19,7 @@ import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { AppMonitoringUsageModel } from '@/shared/model/app-monitoring-usage.model'; import PodStatusIndicator from '@/components/custom/pod-status-indicator'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; export default function AppRessourceMonitoring({ appsRessourceUsage @@ -84,7 +85,16 @@ export default function AppRessourceMonitoring({ {item.projectName} {item.appName} - {item.cpuUsagePercent.toFixed(3)}% / {item.cpuUsage.toFixed(5)} Cores + + + + {item.cpuUsagePercent.toFixed(3)}% + + +

{item.cpuUsage.toFixed(5)} Cores

+
+
+
{KubeSizeConverter.convertBytesToReadableSize(item.ramUsageBytes)} diff --git a/src/app/projects/projects-table.tsx b/src/app/projects/projects-table.tsx index 3ef495db..565c0ae5 100644 --- a/src/app/projects/projects-table.tsx +++ b/src/app/projects/projects-table.tsx @@ -14,6 +14,7 @@ import { useConfirmDialog } from "@/frontend/states/zustand.states"; import { EditProjectDialog } from "./edit-project-dialog"; import { UserSession } from "@/shared/model/sim-session.model"; import { UserGroupUtils } from "@/shared/utils/role.utils"; +import ProjectStatusIndicator from "@/components/custom/project-status-indicator"; export default function ProjectsTable({ data, session }: { data: Project[]; session: UserSession; }) { @@ -35,6 +36,7 @@ export default function ProjectsTable({ data, session }: { data: Project[]; sess ], ["createdAt", "Created At", true, (item) => formatDateTime(item.createdAt)], ["updatedAt", "Updated At", false, (item) => formatDateTime(item.updatedAt)], ]} diff --git a/src/app/settings/cluster/actions.ts b/src/app/settings/cluster/actions.ts deleted file mode 100644 index 847df3f1..00000000 --- a/src/app/settings/cluster/actions.ts +++ /dev/null @@ -1,30 +0,0 @@ -'use server' - -import { getAdminUserSession, simpleAction } from "@/server/utils/action-wrapper.utils"; -import { SuccessActionResult } from "@/shared/model/server-action-error-return.model"; -import clusterService from "@/server/services/node.service"; -import traefikService from "@/server/services/traefik.service"; -import { TraefikIpPropagationStatus } from "@/shared/model/traefik-ip-propagation.model"; - -export const setNodeStatus = async (nodeName: string, schedulable: boolean) => - simpleAction(async () => { - await getAdminUserSession(); - await clusterService.setNodeStatus(nodeName, schedulable); - return new SuccessActionResult(undefined, 'Successfully updated node status.'); - }); - -export const applyTraefikIpPropagation = async (enableIpPreservation: boolean) => - simpleAction(async () => { - await getAdminUserSession(); - const updatedStatus = await traefikService.applyExternalTrafficPolicy(enableIpPreservation); - return new SuccessActionResult( - updatedStatus, - `Traefik externalTrafficPolicy set to ${enableIpPreservation ? 'Local' : 'Cluster'}.`, - ); - }); - -export const getTraefikIpPropagationStatus = async () => - simpleAction(async () => { - await getAdminUserSession(); - return traefikService.getStatus(); - }); diff --git a/src/app/settings/cluster/page.tsx b/src/app/settings/cluster/page.tsx deleted file mode 100644 index feaed8fb..00000000 --- a/src/app/settings/cluster/page.tsx +++ /dev/null @@ -1,34 +0,0 @@ -'use server' - -import { getAdminUserSession, getAuthUserSession } from "@/server/utils/action-wrapper.utils"; -import PageTitle from "@/components/custom/page-title"; -import clusterService from "@/server/services/node.service"; -import NodeInfo from "./nodeInfo"; -import AddClusterNodeDialog from "./add-cluster-node-dialog"; -import { Button } from "@/components/ui/button"; -import paramService, { ParamService } from "@/server/services/param.service"; -import BreadcrumbSetter from "@/components/breadcrumbs-setter"; - -export default async function ClusterInfoPage() { - - const session = await getAdminUserSession(); - const nodeInfo = await clusterService.getNodeInfo(); - const clusterJoinToken = await paramService.getString(ParamService.K3S_JOIN_TOKEN); - - return ( -
- - - - - - - -
- ) -} diff --git a/src/app/settings/server/actions.ts b/src/app/settings/server/actions.ts index 7109893e..e976f2b3 100644 --- a/src/app/settings/server/actions.ts +++ b/src/app/settings/server/actions.ts @@ -27,7 +27,31 @@ import fs from "fs"; import { z } from "zod"; import { revalidateTag } from "next/cache"; import { Tags } from "@/server/utils/cache-tag-generator.utils"; +import clusterService from "@/server/services/node.service"; +import { TraefikIpPropagationStatus } from "@/shared/model/traefik-ip-propagation.model"; +export const setNodeStatus = async (nodeName: string, schedulable: boolean) => + simpleAction(async () => { + await getAdminUserSession(); + await clusterService.setNodeStatus(nodeName, schedulable); + return new SuccessActionResult(undefined, 'Successfully updated node status.'); + }); + +export const applyTraefikIpPropagation = async (enableIpPreservation: boolean) => + simpleAction(async () => { + await getAdminUserSession(); + const updatedStatus = await traefikService.applyExternalTrafficPolicy(enableIpPreservation); + return new SuccessActionResult( + updatedStatus, + `Traefik externalTrafficPolicy set to ${enableIpPreservation ? 'Local' : 'Cluster'}.`, + ); + }); + +export const getTraefikIpPropagationStatus = async () => + simpleAction(async () => { + await getAdminUserSession(); + return traefikService.getStatus(); + }); export const updateIngressSettings = async (prevState: any, inputData: QsIngressSettingsModel) => saveFormAction(inputData, qsIngressSettingsZodModel, async (validatedData) => { diff --git a/src/app/settings/cluster/add-cluster-node-dialog.tsx b/src/app/settings/server/add-cluster-node-dialog.tsx similarity index 100% rename from src/app/settings/cluster/add-cluster-node-dialog.tsx rename to src/app/settings/server/add-cluster-node-dialog.tsx diff --git a/src/app/settings/cluster/nodeInfo.tsx b/src/app/settings/server/nodeInfo.tsx similarity index 89% rename from src/app/settings/cluster/nodeInfo.tsx rename to src/app/settings/server/nodeInfo.tsx index 8b9a6432..ab1aa99e 100644 --- a/src/app/settings/cluster/nodeInfo.tsx +++ b/src/app/settings/server/nodeInfo.tsx @@ -4,13 +4,14 @@ import { NodeInfoModel } from "@/shared/model/node-info.model"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Code } from "@/components/custom/code"; import { Toast } from "@/frontend/utils/toast.utils"; -import { setNodeStatus } from "./actions"; import { Button } from "@/components/ui/button"; import { useBreadcrumbs, useConfirmDialog } from "@/frontend/states/zustand.states"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { useEffect } from "react"; +import { setNodeStatus } from "./actions"; +import AddClusterNodeDialog from "./add-cluster-node-dialog"; -export default async function NodeInfo({ nodeInfos }: { nodeInfos: NodeInfoModel[] }) { +export default function NodeInfo({ nodeInfos, clusterJoinToken }: { nodeInfos: NodeInfoModel[]; clusterJoinToken?: string; }) { const { openConfirmDialog: openDialog } = useConfirmDialog(); @@ -28,9 +29,16 @@ export default async function NodeInfo({ nodeInfos }: { nodeInfos: NodeInfoModel return ( - - Nodes - Overview of all Nodes in your Cluster + +
+ Nodes + Overview of all Nodes in your Cluster +
+
+ + + +
@@ -93,6 +101,7 @@ export default async function NodeInfo({ nodeInfos }: { nodeInfos: NodeInfoModel IP: {nodeInfo.ip}
+ Master Node: {nodeInfo.isMasterNode ? 'Yes' : 'No'}
Spec: {nodeInfo.cpuCapacity} CPU Cores, {nodeInfo.ramCapacity} Memory
OS: {nodeInfo.os} | {nodeInfo.architecture}
Kernel Version: {nodeInfo.kernelVersion}
diff --git a/src/app/settings/server/page.tsx b/src/app/settings/server/page.tsx index c731782f..7f595c3b 100644 --- a/src/app/settings/server/page.tsx +++ b/src/app/settings/server/page.tsx @@ -23,6 +23,8 @@ import { ServerSettingsTabs } from "./server-settings-tabs"; import { Settings, Network, HardDrive, Rocket, Wrench } from "lucide-react"; import quickStackUpdateService from "@/server/services/qs-update.service"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import clusterService from "@/server/services/node.service"; +import NodeInfo from "./nodeInfo"; export default async function ProjectPage({ searchParams @@ -39,7 +41,8 @@ export default async function ProjectPage({ regitryStorageLocation, ipv4Address, systemBackupLocation, - useCanaryChannel + useCanaryChannel, + clusterJoinToken ] = await Promise.all([ paramService.getString(ParamService.QS_SERVER_HOSTNAME, ''), paramService.getBoolean(ParamService.DISABLE_NODEPORT_ACCESS, false), @@ -47,7 +50,8 @@ export default async function ProjectPage({ paramService.getString(ParamService.REGISTRY_SOTRAGE_LOCATION, Constants.INTERNAL_REGISTRY_LOCATION), paramService.getString(ParamService.PUBLIC_IPV4_ADDRESS), paramService.getString(ParamService.QS_SYSTEM_BACKUP_LOCATION, Constants.QS_SYSTEM_BACKUP_DEACTIVATED), - paramService.getBoolean(ParamService.USE_CANARY_CHANNEL, false) + paramService.getBoolean(ParamService.USE_CANARY_CHANNEL, false), + paramService.getString(ParamService.K3S_JOIN_TOKEN) ]); const [ @@ -55,13 +59,15 @@ export default async function ProjectPage({ traefikStatus, qsPodInfos, currentVersion, - newVersionInfo + newVersionInfo, + nodeInfo ] = await Promise.all([ s3TargetService.getAll(), traefikService.getStatus(), podService.getPodsForApp(Constants.QS_NAMESPACE, Constants.QS_APP_NAME), quickStackService.getVersionOfCurrentQuickstackInstance(), - quickStackUpdateService.getNewVersionInfo() + quickStackUpdateService.getNewVersionInfo(), + clusterService.getNodeInfo() ]); const qsPodInfo = qsPodInfos.find(p => !!p); @@ -71,7 +77,7 @@ export default async function ProjectPage({
@@ -88,6 +94,7 @@ export default async function ProjectPage({ General Networking / Traefik Storage & Backups + Cluster Updates {newVersionInfo &&
} Maintenance @@ -114,6 +121,9 @@ export default async function ProjectPage({
+ + +
diff --git a/src/app/settings/cluster/traefik-ip-propagation-card.tsx b/src/app/settings/server/traefik-ip-propagation-card.tsx similarity index 95% rename from src/app/settings/cluster/traefik-ip-propagation-card.tsx rename to src/app/settings/server/traefik-ip-propagation-card.tsx index 9e5fdb2d..096b6741 100644 --- a/src/app/settings/cluster/traefik-ip-propagation-card.tsx +++ b/src/app/settings/server/traefik-ip-propagation-card.tsx @@ -5,9 +5,9 @@ import { Switch } from "@/components/ui/switch"; import { Button } from "@/components/ui/button"; import { useState, useTransition } from "react"; import { TraefikIpPropagationStatus } from "@/shared/model/traefik-ip-propagation.model"; -import { applyTraefikIpPropagation } from "./actions"; import { toast } from "sonner"; -import { Badge } from "lucide-react"; + +import { applyTraefikIpPropagation } from "./actions"; export default function TraefikIpPropagationCard({ initialStatus }: { initialStatus: TraefikIpPropagationStatus }) { const [status, setStatus] = useState(initialStatus); @@ -55,7 +55,7 @@ export default function TraefikIpPropagationCard({ initialStatus }: { initialSta
- {enabled ? 'Enable Local policy' : 'Use Cluster policy'} + {enabled ? 'Local policy enabled' : 'Cluster policy enabled'}