diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 00000000..57c46271 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,18 @@ +{ + "servers": { + "shadcn": { + "command": "npx", + "args": [ + "shadcn@latest", + "mcp" + ] + }, + "next-devtools": { + "command": "npx", + "args": [ + "-y", + "next-devtools-mcp@latest" + ] + } + } +} 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/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/monitoring/monitoring-nodes.tsx b/src/app/monitoring/monitoring-nodes.tsx index 093fb602..19cdb783 100644 --- a/src/app/monitoring/monitoring-nodes.tsx +++ b/src/app/monitoring/monitoring-nodes.tsx @@ -6,32 +6,33 @@ import { PolarRadiusAxis, RadialBar, RadialBarChart, + Pie, + PieChart, } from 'recharts'; - import { Card, CardContent, CardDescription, - CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; -import { ChartConfig, ChartContainer } from '@/components/ui/chart'; +import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'; import { NodeResourceModel } from '@/shared/model/node-resource.model'; import { useBreadcrumbs, } from '@/frontend/states/zustand.states'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import ChartDiskRessources from './disk-chart'; import { Actions } from '@/frontend/utils/nextjs-actions.utils'; -import { getNodeResourceUsage, getVolumeMonitoringUsage } from './actions'; +import { getNodeResourceUsage } from './actions'; import { toast } from 'sonner'; import FullLoadingSpinner from '@/components/ui/full-loading-spinnter'; -import { AppVolumeMonitoringUsageModel } from '@/shared/model/app-volume-monitoring-usage.model'; -import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { KubeSizeConverter } from '@/shared/utils/kubernetes-size-converter.utils'; -import AppVolumeMonitoring from './app-volumes-monitoring'; -import AppRessourceMonitoring from './app-monitoring'; +import { Progress } from '@/components/ui/progress'; +import { Button } from '@/components/ui/button'; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; +import { Activity, Cpu, HardDrive, MemoryStick } from 'lucide-react'; export default function ResourcesNodes({ resourcesNodes, @@ -51,7 +52,6 @@ export default function ResourcesNodes({ } } - useEffect(() => { const intervalId = setInterval(() => fetchResourcesNodes(), 5000); return () => { @@ -59,24 +59,80 @@ export default function ResourcesNodes({ } }, [resourcesNodes]); - const chartData = [ - { browser: 'safari', usage: 1, fill: 'var(--color-safari)' }, - ]; + const { setBreadcrumbs } = useBreadcrumbs(); + useEffect( + () => setBreadcrumbs([{ name: 'Monitoring', url: '/monitoring' }] + ), []); - const chartConfig = { - usage: { - label: 'Usage', + const clusterStats = useMemo(() => { + if (!updatedNodeRessources) return { + cpuUsage: 0, cpuCapacity: 1, + ramUsage: 0, ramCapacity: 1, + diskUsageAbsolut: 0, diskUsageReserved: 0, diskCapacity: 1 + }; + + return updatedNodeRessources.reduce((acc, node) => ({ + cpuUsage: acc.cpuUsage + node.cpuUsage, + cpuCapacity: acc.cpuCapacity + node.cpuCapacity, + ramUsage: acc.ramUsage + node.ramUsage, + ramCapacity: acc.ramCapacity + node.ramCapacity, + diskUsageAbsolut: acc.diskUsageAbsolut + node.diskUsageAbsolut, + diskUsageReserved: acc.diskUsageReserved + node.diskUsageReserved, + diskCapacity: acc.diskCapacity + node.diskUsageCapacity, + }), { + cpuUsage: 0, cpuCapacity: 0, + ramUsage: 0, ramCapacity: 0, + diskUsageAbsolut: 0, diskUsageReserved: 0, diskCapacity: 0 + }); + }, [updatedNodeRessources]); + + const getUsageColor = (percentage: number) => { + if (percentage >= 90) return "hsl(var(--chart-1))"; + if (percentage >= 80) return "hsl(var(--chart-4))"; + return "hsl(var(--chart-2))"; + }; + + const pieChartConfig = { + used: { + label: "Used", + color: "hsl(var(--chart-1))", }, - safari: { - label: 'Safari', - color: 'hsl(var(--chart-2))', + free: { + label: "Free", + color: "hsl(var(--muted))", }, } satisfies ChartConfig; - const { setBreadcrumbs } = useBreadcrumbs(); - useEffect( - () => setBreadcrumbs([{ name: 'Monitoring', url: '/monitoring' }] - ), []); + const storagePieChartConfig = { + used: { + label: "Used", + color: "hsl(var(--chart-1))", + }, + reserved: { + label: "Reserved", + color: "hsl(var(--chart-2))", + }, + free: { + label: "Free", + color: "hsl(var(--muted))", + }, + } satisfies ChartConfig; + + const getChartData = (used: number, capacity: number) => { + const percentage = capacity > 0 ? (used / capacity) * 100 : 0; + return [ + { status: 'used', value: used, fill: getUsageColor(percentage) }, + { status: 'free', value: Math.max(0, capacity - used), fill: 'var(--color-free)' }, + ]; + }; + + const getStorageChartData = (used: number, reserved: number, capacity: number) => { + return [ + { status: 'used', value: used, fill: "hsl(var(--chart-1))" }, + { status: 'reserved', value: reserved, fill: "hsl(var(--chart-2))" }, + { status: 'free', value: Math.max(0, capacity - used - reserved), fill: 'var(--color-free)' }, + ]; + }; if (!updatedNodeRessources) { return @@ -84,169 +140,370 @@ export default function ResourcesNodes({ return (
- {updatedNodeRessources.map((node, index) => (<> +
+ {/* Cluster CPU */} + + + Cluster CPU + Total Cores Usage + + + + + } /> + + + + + + + + {/* Cluster RAM */} - - {node.name} - Node {index + 1} + + Cluster RAM + Total Memory Usage -
-
+ + + KubeSizeConverter.convertBytesToReadableSize(value as number)} />} /> + + + + + + - + + Cluster Storage + Total Disk Usage + + + + + { + if (value === clusterStats.diskUsageAbsolut) { + return KubeSizeConverter.convertBytesToReadableSize(clusterStats.diskUsageAbsolut) + ' (Used)'; + } + if (value === clusterStats.diskUsageReserved) { + return KubeSizeConverter.convertBytesToReadableSize(clusterStats.diskUsageReserved) + ' (Free but unusable)'; + } + return KubeSizeConverter.convertBytesToReadableSize(value as number) + ' (Free)'; + }} />} /> + + + + + + +
+ + + + Node Resources + Overview of all nodes in the cluster + + + + + + Node Name + CPU + RAM + Storage + Actions + + + + {updatedNodeRessources.map((node) => ( + + {node.name} + +
+
+ {((node.cpuUsage / node.cpuCapacity) * 100).toFixed(0)}% + {node.cpuUsage.toFixed(2)} / {node.cpuCapacity} Cores +
+ +
+
+ +
+
+ {((node.ramUsage / node.ramCapacity) * 100).toFixed(0)}% + {KubeSizeConverter.convertBytesToReadableSize(node.ramUsage)} / {KubeSizeConverter.convertBytesToReadableSize(node.ramCapacity)} +
+ +
+
+ +
+
+ {(((node.diskUsageAbsolut + node.diskUsageReserved) / node.diskUsageCapacity) * 100).toFixed(0)}% + {KubeSizeConverter.convertBytesToReadableSize(node.diskUsageAbsolut + node.diskUsageReserved)} / {KubeSizeConverter.convertBytesToReadableSize(node.diskUsageCapacity)} +
+ +
+
+ + + +
+ ))} +
+
+
+
+
+ ); +} + +function NodeDetailsSheet({ node }: { node: NodeResourceModel }) { + const chartData = [ + { browser: 'safari', usage: 1, fill: 'var(--color-safari)' }, + ]; + + const chartConfig = { + usage: { + label: 'Usage', + }, + safari: { + label: 'Safari', + color: 'hsl(var(--chart-2))', + }, + } satisfies ChartConfig; + + return ( + + + + + + + + + {node.name} + + + Detailed resource usage metrics + + + +
+ {/* CPU Chart */} + + + + CPU Usage + + + + + - + + - - - - - - - -
-
- - + + Load: {(node.cpuUsage).toFixed(2)} + + + ); + } + }} + /> + + + + + + + {/* 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 + + + + + + + + + ); } diff --git a/src/app/project/app/[appId]/app-action-buttons.tsx b/src/app/project/app/[appId]/app-action-buttons.tsx index 17d7d0b2..3261336b 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 AppStatus from "./app-status"; -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,32 @@ 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 pods = getPodsForApp(app.id); + setDeploaymentStatus(pods?.deploymentStatus ?? 'UNKNOWN'); + + 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 && - - - - -
- ) -} diff --git a/src/app/settings/server/actions.ts b/src/app/settings/server/actions.ts index 7f8b4b52..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) => { @@ -106,11 +130,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/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 5a0fdfc0..7f595c3b 100644 --- a/src/app/settings/server/page.tsx +++ b/src/app/settings/server/page.tsx @@ -22,6 +22,9 @@ 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"; +import clusterService from "@/server/services/node.service"; +import NodeInfo from "./nodeInfo"; export default async function ProjectPage({ searchParams @@ -38,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), @@ -46,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 [ @@ -54,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); @@ -70,7 +77,7 @@ export default async function ProjectPage({
@@ -82,14 +89,17 @@ export default async function ProjectPage({ - - General - Networking / Traefik - Storage & Backups - Updates {newVersionInfo &&
} - Maintenance - - + + + General + Networking / Traefik + Storage & Backups + Cluster + Updates {newVersionInfo &&
} + Maintenance + + +
@@ -111,6 +121,9 @@ export default async function ProjectPage({
+ + +
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()); } }; 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'}