diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..088517c2 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,15 @@ +{ + "endOfLine": "lf", + "semi": true, + "singleQuote": true, + "jsxSingleQuote": true, + "tabWidth": 2, + "useTabs": false, + "trailingComma": "none", + "printWidth": 160, + "arrowParens": "always", + "objectWrap": "collapse", + "plugins": ["prettier-plugin-tailwindcss"], + "tailwindStylesheet": "app/globals.css", + "tailwindFunctions": ["cn", "cva"] +} diff --git a/GLOSSARY.md b/GLOSSARY.md index 5b9f92cd..d9131eb4 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -202,6 +202,10 @@ _Avoid_: disk, storage, PVC when referring to the domain concept A **Volume** attached to an **App**, optionally shareable with other Apps in the same project. _Avoid_: app storage, app disk +**App Volume Filesystem Usage**: +The occupied and available space and the capacity of the filesystem of an **App Volume**. It excludes storage consumed by snapshots outside that filesystem. +_Avoid_: Longhorn actual size, physical replica usage + **Agent Volume**: A **Volume** attached to an **Agent**, without backup scheduling. An Agent Volume is not shareable with other Agents or Apps. Its **Volume Type** is a **Shared Agent Volume**, a **Per-Sandbox Agent Volume**, or a **Per-Custom-Tag Agent Volume**. _Avoid_: agent storage, agent disk diff --git a/prisma/migrations/20261108130000_app_monitoring_history/migration.sql b/prisma/migrations/20261108130000_app_monitoring_history/migration.sql new file mode 100644 index 00000000..7aefa3d5 --- /dev/null +++ b/prisma/migrations/20261108130000_app_monitoring_history/migration.sql @@ -0,0 +1,33 @@ +CREATE TABLE "AppMonitoringSample" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sampledAt" DATETIME NOT NULL, + "appId" TEXT NOT NULL, + "cpuCores" REAL, + "ramBytes" REAL, + "measuredAt" DATETIME, + CONSTRAINT "AppMonitoringSample_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE INDEX "AppMonitoringSample_appId_sampledAt_idx" ON "AppMonitoringSample"("appId", "sampledAt"); +CREATE INDEX "AppMonitoringSample_sampledAt_idx" ON "AppMonitoringSample"("sampledAt"); +CREATE TABLE "AppVolumeMonitoringSample" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sampledAt" DATETIME NOT NULL, + "volumeId" TEXT NOT NULL, + "status" TEXT NOT NULL, + "usedBytes" REAL, + "availableBytes" REAL, + "capacityBytes" REAL, + "measuredAt" DATETIME, + CONSTRAINT "AppVolumeMonitoringSample_volumeId_fkey" FOREIGN KEY ("volumeId") REFERENCES "AppVolume" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE INDEX "AppVolumeMonitoringSample_volumeId_sampledAt_idx" ON "AppVolumeMonitoringSample"("volumeId", "sampledAt"); +CREATE INDEX "AppVolumeMonitoringSample_sampledAt_idx" ON "AppVolumeMonitoringSample"("sampledAt"); + +CREATE TABLE "AppVolumeMonitoringMembership" ( + "sampleId" TEXT NOT NULL, + "volumeId" TEXT NOT NULL, + PRIMARY KEY ("sampleId", "volumeId"), + CONSTRAINT "AppVolumeMonitoringMembership_sampleId_fkey" FOREIGN KEY ("sampleId") REFERENCES "AppVolumeMonitoringSample" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "AppVolumeMonitoringMembership_volumeId_fkey" FOREIGN KEY ("volumeId") REFERENCES "AppVolume" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE INDEX "AppVolumeMonitoringMembership_volumeId_idx" ON "AppVolumeMonitoringMembership"("volumeId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 19724a9f..9452d17b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -465,6 +465,7 @@ model App { appPorts AppPort[] appNodePorts AppNodePort[] appVolumes AppVolume[] + monitoringSamples AppMonitoringSample[] appFileMounts AppFileMount[] appBasicAuths AppBasicAuth[] appGitSshKey AppGitSshKey? @@ -570,6 +571,8 @@ model AppVolume { appId String app App @relation(fields: [appId], references: [id], onDelete: Cascade) volumeBackups VolumeBackup[] + monitoringSamples AppVolumeMonitoringSample[] + monitoringMemberships AppVolumeMonitoringMembership[] sharedVolume AppVolume? @relation("SharedVolume", fields: [sharedVolumeId], references: [id], onDelete: Cascade) sharedVolumes AppVolume[] @relation("SharedVolume") @@ -643,3 +646,43 @@ model AppBasicAuth { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +// One row per known App and Volume at each collection time. Null values are gaps. +model AppMonitoringSample { + id String @id @default(uuid()) + sampledAt DateTime + appId String + app App @relation(fields: [appId], references: [id], onDelete: Cascade) + cpuCores Float? + ramBytes Float? + measuredAt DateTime? + + @@index([appId, sampledAt]) + @@index([sampledAt]) +} + +model AppVolumeMonitoringSample { + id String @id @default(uuid()) + sampledAt DateTime + volumeId String + volume AppVolume @relation(fields: [volumeId], references: [id], onDelete: Cascade) + status String + usedBytes Float? + availableBytes Float? + capacityBytes Float? + measuredAt DateTime? + memberships AppVolumeMonitoringMembership[] + + @@index([volumeId, sampledAt]) + @@index([sampledAt]) +} + +model AppVolumeMonitoringMembership { + sampleId String + sample AppVolumeMonitoringSample @relation(fields: [sampleId], references: [id], onDelete: Cascade) + volumeId String + volume AppVolume @relation(fields: [volumeId], references: [id], onDelete: Cascade) + + @@id([sampleId, volumeId]) + @@index([volumeId]) +} diff --git a/src/__tests__/integration/server/services/app-monitoring-history.integration.spec.ts b/src/__tests__/integration/server/services/app-monitoring-history.integration.spec.ts new file mode 100644 index 00000000..f084f948 --- /dev/null +++ b/src/__tests__/integration/server/services/app-monitoring-history.integration.spec.ts @@ -0,0 +1,105 @@ +// @vitest-environment node +vi.mock('@/server/adapter/kubernetes-api.adapter', () => ({ default: {} })); + +import { createPrismaTestContext } from '@/__tests__/prisma-test.utils'; +import adapter from '@/server/adapter/app-history.adapter'; +import history from '@/server/services/standalone-services/app-monitoring-history.service'; +import metrics from '@/server/services/standalone-services/app-history.service'; +import filesystem from '@/server/services/standalone-services/app-volume-filesystem.service'; + +describe('App monitoring history persistence', () => { + const ctx = createPrismaTestContext('app-monitoring-history'); + + it('retains the boundary sample and removes history when its App is deleted', async () => { + const db = ctx.getDataAccess().client; + await db.project.create({ data: { id: 'project', name: 'Project' } }); + await db.app.create({ data: { id: 'app', name: 'App', projectId: 'project' } }); + const cutoff = new Date(Date.now() - 3600_000); + await adapter.save([ + { appId: 'app', sampledAt: new Date(cutoff.getTime() - 1), cpuCores: 1, ramBytes: 2 }, + { appId: 'app', sampledAt: cutoff, cpuCores: 3, ramBytes: 4 }, + ], [], [], cutoff); + expect((await history.getHistory(['app'])).points.filter(point => !point.isGap)) + .toMatchObject([{ cpuCores: 3, ramBytes: 4 }]); + await db.app.delete({ where: { id: 'app' } }); + expect((await history.getHistory(['app'])).points).toEqual([]); + }); + + it('applies a reduced retention period to existing history', async () => { + const db = ctx.getDataAccess().client; + await db.project.create({ data: { id: 'project', name: 'Project' } }); + await db.app.create({ data: { id: 'app', name: 'App', projectId: 'project' } }); + const now = Date.now(); + await adapter.save([{ appId: 'app', sampledAt: new Date(now - 2 * 3600_000), cpuCores: 1, ramBytes: 2 }], [], [], new Date(now - 24 * 3600_000)); + await adapter.setRetentionHours(1); + expect((await history.getHistory(['app'])).points).toEqual([]); + await adapter.save([], [], [], new Date(now - (await adapter.getRetentionHours()) * 3600_000)); + expect(await db.appMonitoringSample.count()).toBe(0); + }); + + it('stores shared Volume usage once and exposes it only through accessible mounts', async () => { + const db = ctx.getDataAccess().client; + await db.project.create({ data: { id: 'project', name: 'Project' } }); + await db.app.createMany({ data: [ + { id: 'one', name: 'One', projectId: 'project' }, + { id: 'two', name: 'Two', projectId: 'project' }, + ] }); + await db.appVolume.create({ data: { id: 'base', appId: 'one', containerMountPath: '/data', size: 1 } }); + await db.appVolume.create({ data: { id: 'shared', appId: 'two', containerMountPath: '/share', size: 1, sharedVolumeId: 'base' } }); + const sampledAt = new Date(); + await adapter.save([], [{ id: 'sample', volumeId: 'base', sampledAt, status: 'available', usedBytes: 42, measuredAt: sampledAt }], [ + { sampleId: 'sample', volumeId: 'base' }, { sampleId: 'sample', volumeId: 'shared' }, + ], new Date(sampledAt.getTime() - 3600_000)); + expect(await db.appVolumeMonitoringSample.count()).toBe(1); + expect((await history.getHistory(['two'])).volumes.map(volume => volume.label)).toEqual(['/share']); + expect((await history.getHistory(['one', 'two'])).points[0]).toMatchObject({ storageBytes: 42, availableVolumes: 1 }); + await db.appVolume.delete({ where: { id: 'base' } }); + expect((await history.getHistory(['one', 'two'])).points).toEqual([]); + }); + + + it('collects shared Volume measurements and reads them through each App', async () => { + const db = ctx.getDataAccess().client; + await db.project.create({ data: { id: 'project', name: 'Project' } }); + await db.app.createMany({ data: [ + { id: 'one', name: 'One', projectId: 'project' }, + { id: 'two', name: 'Two', projectId: 'project' }, + ] }); + await db.appVolume.create({ data: { id: 'base', appId: 'one', containerMountPath: '/data', size: 1 } }); + await db.appVolume.create({ data: { id: 'shared', appId: 'two', containerMountPath: '/share', size: 1, sharedVolumeId: 'base' } }); + const measuredAt = new Date(); + const appMetrics = vi.spyOn(metrics, 'getAppMetrics').mockResolvedValue(new Map([ + ['one', { cpuCores: 1, ramBytes: 100, measuredAt }], + ['two', { cpuCores: 2, ramBytes: 200, measuredAt }], + ])); + const volumeMetrics = vi.spyOn(filesystem, 'getVolumeFilesystemUsage').mockResolvedValue(new Map([ + ['base', { pvcName: 'pvc-base', status: 'available', usedBytes: 42, measuredAt: measuredAt.toISOString() }], + ['shared', { pvcName: 'pvc-base', status: 'available', usedBytes: 42, measuredAt: measuredAt.toISOString() }], + ])); + try { + await history.collect(); + expect(await db.appVolumeMonitoringSample.count()).toBe(1); + expect((await history.getHistory(['one', 'two'])).points[0]).toMatchObject({ cpuCores: 3, ramBytes: 300, storageBytes: 42, availableVolumes: 1 }); + expect((await history.getHistory(['two'])).volumes.map(volume => volume.label)).toEqual(['/share']); + } finally { + appMetrics.mockRestore(); + volumeMetrics.mockRestore(); + } + }); + + it('reads a day of five-minute history for 100 Apps', async () => { + const db = ctx.getDataAccess().client; + await db.project.create({ data: { id: 'project', name: 'Project' } }); + const appIds = Array.from({ length: 100 }, (_, index) => `app-${index}`); + await db.app.createMany({ data: appIds.map(id => ({ id, name: id, projectId: 'project' })) }); + const now = Date.now(); + const samples = Array.from({ length: 288 }, (_, index) => appIds.map(appId => ({ + appId, sampledAt: new Date(now - index * 5 * 60_000), cpuCores: 0.25, ramBytes: 1024, + }))).flat(); + await adapter.save(samples, [], [], new Date(now - 24 * 3600_000)); + const result = await history.getHistory(appIds); + expect(result.points).toHaveLength(288); + expect(result.points[0]).toMatchObject({ cpuCores: 25, ramBytes: 102400 }); + }, 30000); + +}); diff --git a/src/app/monitoring/actions.ts b/src/app/monitoring/actions.ts index 47b5ba91..f2f302e9 100644 --- a/src/app/monitoring/actions.ts +++ b/src/app/monitoring/actions.ts @@ -29,4 +29,10 @@ export const getMonitoringForAllApps = async () => let updatedNodeRessources = await monitoringService.getMonitoringForAllApps(); updatedNodeRessources = updatedNodeRessources?.filter((app) => UserGroupUtils.sessionHasReadAccessForApp(session, app.appId)); return updatedNodeRessources; - }) as Promise>; \ No newline at end of file + }) as Promise>; + +export const getReservedVolumeStorage = async () => + simpleAction(async () => { + await getAuthUserSession(); + return await clusterService.getReservedVolumeStorage(); + }) as Promise>; diff --git a/src/app/monitoring/app-volumes-monitoring.tsx b/src/app/monitoring/app-volumes-monitoring.tsx index 3466850d..f3e5182e 100644 --- a/src/app/monitoring/app-volumes-monitoring.tsx +++ b/src/app/monitoring/app-volumes-monitoring.tsx @@ -17,20 +17,7 @@ import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { Progress } from "@/components/ui/progress" import { PathBuilderUtils } from '@/shared/utils/path-builder.utils'; - -type AppVolumeMonitoringUsageExtendedModel = AppVolumeMonitoringUsageModel & { - usedPercentage: number; -}; - -function convertToExtendedModel(input?: AppVolumeMonitoringUsageModel[]): AppVolumeMonitoringUsageExtendedModel[] | undefined { - if (input) { - return input.map(item => ({ - ...item, - usedPercentage: Math.round(item.usedBytes / item.capacityBytes * 100) - })); - } - return undefined; -} +import { AppVolumeIdentityUtils } from '@/shared/utils/app-volume-identity.utils'; export default function AppVolumeMonitoring({ volumesUsage @@ -38,17 +25,17 @@ export default function AppVolumeMonitoring({ volumesUsage?: AppVolumeMonitoringUsageModel[] }) { - const updatedVolumeUsage = useMemo(() => convertToExtendedModel(volumesUsage), [volumesUsage]); - - const { totalUsedBytes, totalCapacityBytes } = useMemo(() => { - if (!updatedVolumeUsage) { - return { totalUsedBytes: undefined, totalCapacityBytes: undefined }; - } + const updatedVolumeUsage = volumesUsage; + const totals = useMemo(() => { + const unique = new Map(); + for (const volume of volumesUsage ?? []) unique.set(AppVolumeIdentityUtils.observationKey(volume.projectId, volume.pvcName), volume); + const values = [...unique.values()]; return { - totalUsedBytes: updatedVolumeUsage.reduce((acc, item) => acc + item.usedBytes, 0), - totalCapacityBytes: updatedVolumeUsage.reduce((acc, item) => acc + item.capacityBytes, 0), + used: values.reduce((sum, value) => sum + (value.usedBytes ?? 0), 0), + capacity: values.reduce((sum, value) => sum + (value.capacityBytes ?? 0), 0), + incomplete: values.some(value => value.status !== 'available'), }; - }, [updatedVolumeUsage]); + }, [volumesUsage]); if (!updatedVolumeUsage) { return @@ -68,10 +55,7 @@ export default function AppVolumeMonitoring({ - {updatedVolumeUsage.length} App Volumes {totalUsedBytes && totalCapacityBytes && <> - | Total used {KubeSizeConverter.convertBytesToReadableSize(totalUsedBytes)} | Total allocated {KubeSizeConverter.convertBytesToReadableSize(totalCapacityBytes)} - } - + {updatedVolumeUsage.length} App Volumes | Total used {KubeSizeConverter.convertBytesToReadableSize(totals.used)} / {KubeSizeConverter.convertBytesToReadableSize(totals.capacity)}{totals.incomplete ? ' (incomplete)' : ''} Project @@ -83,14 +67,14 @@ export default function AppVolumeMonitoring({ {updatedVolumeUsage.map((item) => ( - + {item.projectName} {item.appName} {item.mountPath} - = 90 ? 'red' : (item.usedPercentage >= 80 ? 'orange' : undefined)} /> -
{KubeSizeConverter.convertBytesToReadableSize(item.usedBytes)} / {KubeSizeConverter.convertBytesToReadableSize(item.capacityBytes)}
+ {item.status === 'available' && item.usedBytes !== undefined && item.capacityBytes !== undefined && item.capacityBytes > 0 ? ( + + ) : Unavailable ({item.status})}
@@ -107,3 +91,16 @@ export default function AppVolumeMonitoring({ ); } + +function VolumeUsage({ usedBytes, capacityBytes }: { + usedBytes: number; + capacityBytes: number; +}) { + const percentage = Math.round(usedBytes / capacityBytes * 100); + return <> + = 90 ? 'red' : percentage >= 80 ? 'orange' : undefined} /> +
+ {KubeSizeConverter.convertBytesToReadableSize(usedBytes)} / {KubeSizeConverter.convertBytesToReadableSize(capacityBytes)} +
+ ; +} diff --git a/src/app/monitoring/app-volumes-monitoring.unit.spec.ts b/src/app/monitoring/app-volumes-monitoring.unit.spec.ts new file mode 100644 index 00000000..eeee8ab3 --- /dev/null +++ b/src/app/monitoring/app-volumes-monitoring.unit.spec.ts @@ -0,0 +1,23 @@ +import React from 'react'; +import { cleanup, render, screen } from '@testing-library/react'; +import AppVolumeMonitoring from './app-volumes-monitoring'; +import { AppVolumeMonitoringUsageModel } from '@/shared/model/app-volume-monitoring-usage.model'; + +afterEach(cleanup); + +it('counts shared volumes once and marks incomplete totals', () => { + const base: AppVolumeMonitoringUsageModel = { + projectId: 'p', projectName: 'Project', appName: 'App', appId: 'a', mountPath: '/data', + pvcName: 'pvc-1', status: 'available', usedBytes: 1024, capacityBytes: 4096, + availableBytes: 3072, isBaseVolume: true, + }; + render(React.createElement(AppVolumeMonitoring, { volumesUsage: [ + base, + { ...base, appId: 'b', appName: 'Other', isBaseVolume: false }, + { ...base, appId: 'c', pvcName: 'pvc-2', status: 'unavailable', usedBytes: undefined, capacityBytes: undefined, availableBytes: undefined }, + ] })); + expect(screen.getByText(/Total used.*incomplete/).textContent).toContain('1 KiB'); + expect(screen.getByText(/Total used.*incomplete/).textContent).toContain('4 KiB'); + expect(screen.getByText('Unavailable (unavailable)')).toBeTruthy(); + expect(screen.getAllByText('1 KiB / 4 KiB')).toHaveLength(2); +}); diff --git a/src/app/monitoring/disk-chart.tsx b/src/app/monitoring/disk-chart.tsx index 25937aac..928d915e 100644 --- a/src/app/monitoring/disk-chart.tsx +++ b/src/app/monitoring/disk-chart.tsx @@ -1,217 +1,24 @@ 'use client'; -import { - Label, - PolarRadiusAxis, - RadialBar, - RadialBarChart, -} from 'recharts'; - -import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'; import { NodeResourceModel } from '@/shared/model/node-resource.model'; import { KubeSizeConverter } from '@/shared/utils/kubernetes-size-converter.utils'; +import { Progress } from '@/components/ui/progress'; -export default function ChartDiskRessources({ - nodeRessource, -}: { - nodeRessource: NodeResourceModel; -}) { - - const diskUsed = nodeRessource.diskUsageAbsolut ?? 0; - const diskReserved = nodeRessource.diskUsageReserved ?? 0; - const diskCapacity = nodeRessource.diskUsageCapacity ?? 0; - const diskSchedulable = nodeRessource.diskSpaceSchedulable ?? Math.max(0, diskCapacity - diskUsed - diskReserved); - const diskUsedAndReserved = diskUsed + diskReserved; - const diskUsagePercent = diskCapacity > 0 ? (diskUsedAndReserved / diskCapacity) * 100 : 0; - - const chartData = [{ - diskUsed, - diskReserved, - diskSchedulable - }]; +export default function ChartDiskRessources({ nodeRessource }: { nodeRessource: NodeResourceModel }) { + const { diskUsageAbsolut: used, diskUsageAvailable: available, diskUsageCapacity: capacity } = nodeRessource; - const chartConfig = { - diskUsed: { - label: "Used", - color: "var(--chart-1)", - }, - diskReserved: { - label: "Reserved (free but not usable)", - color: "var(--chart-2)", - }, - diskSchedulable: { - label: "Schedulable", - color: "var(--muted)", - }, - } satisfies ChartConfig + if (used === undefined || available === undefined || capacity === undefined || capacity <= 0) { + return

Storage metrics unavailable

; + } return ( - - - { - // Convert the value from bytes to gigabytes - const formattedValue = KubeSizeConverter.convertBytesToReadableSize(value as number); - const config = typeof name === 'string' ? chartConfig[name as keyof typeof chartConfig] : undefined; - // Optionally, you can customize the label (name) here if needed - return
-
-
{config?.label ?? name}:
-
{formattedValue}
-
- }} />} - /> - - - - - -
-
+
+
+ {(used / capacity * 100).toFixed(0)}% used + {KubeSizeConverter.convertBytesToReadableSize(used)} / {KubeSizeConverter.convertBytesToReadableSize(capacity)} +
+ +

Available: {KubeSizeConverter.convertBytesToReadableSize(available)}

+
); } - - -/* - - - - - - - - - - - - - - - - -*/ diff --git a/src/app/monitoring/monitoring-client.tsx b/src/app/monitoring/monitoring-client.tsx index a804b9a0..97b03214 100644 --- a/src/app/monitoring/monitoring-client.tsx +++ b/src/app/monitoring/monitoring-client.tsx @@ -7,7 +7,7 @@ import { usePolling } from '@/frontend/hooks/use-polling'; import { AppMonitoringUsageModel } from '@/shared/model/app-monitoring-usage.model'; import { AppVolumeMonitoringUsageModel } from '@/shared/model/app-volume-monitoring-usage.model'; import { NodeResourceModel } from '@/shared/model/node-resource.model'; -import { getMonitoringForAllApps, getNodeResourceUsage, getVolumeMonitoringUsage } from './actions'; +import { getMonitoringForAllApps, getNodeResourceUsage, getReservedVolumeStorage, getVolumeMonitoringUsage } from './actions'; import AppRessourceMonitoring from './app-monitoring'; import AppVolumeMonitoring from './app-volumes-monitoring'; import ResourcesNodes from './monitoring-nodes'; @@ -23,24 +23,29 @@ export default function MonitoringClient({ resourcesNodes, appsRessourceUsage, volumesUsage, + reservedVolumeStorage, }: { resourcesNodes?: NodeResourceModel[]; appsRessourceUsage?: AppMonitoringUsageModel[]; volumesUsage?: AppVolumeMonitoringUsageModel[]; + reservedVolumeStorage?: number; }) { + const [updatedReservedStorage, setUpdatedReservedStorage] = useState(reservedVolumeStorage); const [updatedNodes, setUpdatedNodes] = useState(resourcesNodes); const [updatedApps, setUpdatedApps] = useState(appsRessourceUsage); const [updatedVolumes, setUpdatedVolumes] = useState(volumesUsage); const refreshMonitoringData = useCallback(async () => { try { - const [nodes, volumes, apps] = await Promise.all([ + const [nodes, volumes, apps, reservedStorage] = await Promise.all([ Actions.run(() => getNodeResourceUsage()), Actions.run(() => getVolumeMonitoringUsage()), Actions.run(() => getMonitoringForAllApps()), + Actions.run(() => getReservedVolumeStorage()).catch(() => undefined), ]); setUpdatedNodes(nodes); + setUpdatedReservedStorage(reservedStorage); setUpdatedVolumes(volumes?.filter((volume) => !!volume.isBaseVolume)); setUpdatedApps(apps); } catch (ex) { @@ -53,7 +58,7 @@ export default function MonitoringClient({ return (
- +
diff --git a/src/app/monitoring/monitoring-nodes.tsx b/src/app/monitoring/monitoring-nodes.tsx index f63022ad..ded34d52 100644 --- a/src/app/monitoring/monitoring-nodes.tsx +++ b/src/app/monitoring/monitoring-nodes.tsx @@ -2,10 +2,6 @@ import { Label, - PolarGrid, - PolarRadiusAxis, - RadialBar, - RadialBarChart, Pie, PieChart, } from 'recharts'; @@ -22,23 +18,20 @@ import { useBreadcrumbs, } from '@/frontend/states/zustand.states'; import { useEffect, useMemo } from 'react'; -import ChartDiskRessources from './disk-chart'; import FullLoadingSpinner from '@/components/ui/full-loading-spinnter'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { KubeSizeConverter } from '@/shared/utils/kubernetes-size-converter.utils'; 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, + reservedVolumeStorage, }: { resourcesNodes?: NodeResourceModel[]; + reservedVolumeStorage?: number; }) { const getDiskUsageAbsolut = (node: NodeResourceModel) => node.diskUsageAbsolut ?? 0; - const getDiskUsageReserved = (node: NodeResourceModel) => node.diskUsageReserved ?? 0; const getDiskUsageCapacity = (node: NodeResourceModel) => node.diskUsageCapacity ?? 0; const toPercent = (used: number, capacity: number) => (capacity > 0 ? (used / capacity) * 100 : 0); @@ -53,7 +46,7 @@ export default function ResourcesNodes({ if (!updatedNodeRessources) return { cpuUsage: 0, cpuCapacity: 1, ramUsage: 0, ramCapacity: 1, - diskUsageAbsolut: 0, diskUsageReserved: 0, diskCapacity: 1 + diskUsageAbsolut: 0, diskCapacity: 1 }; return updatedNodeRessources.reduce((acc, node) => ({ @@ -62,12 +55,11 @@ export default function ResourcesNodes({ ramUsage: acc.ramUsage + node.ramUsage, ramCapacity: acc.ramCapacity + node.ramCapacity, diskUsageAbsolut: acc.diskUsageAbsolut + getDiskUsageAbsolut(node), - diskUsageReserved: acc.diskUsageReserved + getDiskUsageReserved(node), diskCapacity: acc.diskCapacity + getDiskUsageCapacity(node), }), { cpuUsage: 0, cpuCapacity: 0, ramUsage: 0, ramCapacity: 0, - diskUsageAbsolut: 0, diskUsageReserved: 0, diskCapacity: 0 + diskUsageAbsolut: 0, diskCapacity: 0 }); }, [updatedNodeRessources]); @@ -83,22 +75,7 @@ export default function ResourcesNodes({ color: "var(--chart-1)", }, free: { - label: "Free", - color: "var(--muted)", - }, - } satisfies ChartConfig; - - const storagePieChartConfig = { - used: { - label: "Used", - color: "var(--chart-1)", - }, - reserved: { - label: "Reserved", - color: "var(--chart-2)", - }, - free: { - label: "Free", + label: "Remaining", color: "var(--muted)", }, } satisfies ChartConfig; @@ -111,14 +88,6 @@ export default function ResourcesNodes({ ]; }; - const getStorageChartData = (used: number, reserved: number, capacity: number) => { - return [ - { status: 'used', value: used, fill: "var(--chart-1)" }, - { status: 'reserved', value: reserved, fill: "var(--chart-2)" }, - { status: 'free', value: Math.max(0, capacity - used - reserved), fill: 'var(--color-free)' }, - ]; - }; - if (!updatedNodeRessources) { return } @@ -192,38 +161,51 @@ export default function ResourcesNodes({ Cluster Storage - Total Disk Usage + Node filesystem usage and volume reservations - - - { - 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 filesystem: {KubeSizeConverter.convertBytesToReadableSize(clusterStats.diskUsageAbsolut)} used. + Volume reservation: {reservedVolumeStorage === undefined ? 'reservation unavailable' : `${KubeSizeConverter.convertBytesToReadableSize(reservedVolumeStorage)} reserved`}. + Total node capacity: {KubeSizeConverter.convertBytesToReadableSize(clusterStats.diskCapacity)}. +

+ + ) :

Storage metrics unavailable

}
@@ -241,7 +223,6 @@ export default function ResourcesNodes({ CPU RAM Storage - Actions
@@ -270,10 +251,10 @@ export default function ResourcesNodes({
{(() => { const diskUsed = getDiskUsageAbsolut(node); - const diskReserved = getDiskUsageReserved(node); const diskCapacity = getDiskUsageCapacity(node); - const diskUsedAndReserved = diskUsed + diskReserved; + const diskUsedAndReserved = diskUsed; const diskPercent = toPercent(diskUsedAndReserved, diskCapacity); + if (node.diskUsageCapacity === undefined) return Unavailable; return ( <>
@@ -286,9 +267,6 @@ export default function ResourcesNodes({ })()}
- - - ))} @@ -299,205 +277,3 @@ export default function ResourcesNodes({ ); } -function NodeDetailsSheet({ node }: { node: NodeResourceModel }) { - const chartData = [ - { browser: 'safari', usage: 1, fill: 'var(--color-safari)' }, - ]; - - const chartConfig = { - usage: { - label: 'Usage', - }, - safari: { - label: 'Safari', - color: 'var(--chart-2)', - }, - } satisfies ChartConfig; - - return ( - - View Details} /> - - - - - {node.name} - - - Detailed resource usage metrics - - - -
- {/* CPU Chart */} - - - - CPU Usage - - - - - - - - - - - - - - - {/* RAM Chart */} - - - - Memory Usage - - - - - - - - - - - - - - - {/* Disk Chart */} - - - - Storage Usage - - - - - - -
-
-
- ); -} diff --git a/src/app/monitoring/page.tsx b/src/app/monitoring/page.tsx index 3340edf3..196abf9f 100644 --- a/src/app/monitoring/page.tsx +++ b/src/app/monitoring/page.tsx @@ -12,10 +12,11 @@ export default async function ResourceNodesInfoPage() { const session = await getAuthUserSession(); - let [resourcesNode, volumesUsage, updatedNodeRessources] = await Promise.all([ + let [resourcesNode, volumesUsage, updatedNodeRessources, reservedVolumeStorage] = await Promise.all([ CatchUtils.resultOrUndefined(() => clusterService.getNodeResourceUsage()), CatchUtils.resultOrUndefined(() => monitoringService.getAllAppVolumesUsage()), - CatchUtils.resultOrUndefined(() => monitoringService.getMonitoringForAllApps()) + CatchUtils.resultOrUndefined(() => monitoringService.getMonitoringForAllApps()), + CatchUtils.resultOrUndefined(() => clusterService.getReservedVolumeStorage()) ]); // filter by role @@ -33,6 +34,7 @@ export default async function ResourceNodesInfoPage() {
diff --git a/src/app/project/[projectId]/agents/details/volumes/agent-volume-edit-overlay.tsx b/src/app/project/[projectId]/agents/details/volumes/agent-volume-edit-overlay.tsx index 83d6d52a..c348bf3a 100644 --- a/src/app/project/[projectId]/agents/details/volumes/agent-volume-edit-overlay.tsx +++ b/src/app/project/[projectId]/agents/details/volumes/agent-volume-edit-overlay.tsx @@ -111,7 +111,7 @@ export default function AgentVolumeEditOverlay({ name="size" render={({ field }) => ( - Size (MB) + Size (MiB) diff --git a/src/app/project/[projectId]/agents/details/volumes/agent-volumes-card.tsx b/src/app/project/[projectId]/agents/details/volumes/agent-volumes-card.tsx index af5137b7..1d67fd97 100644 --- a/src/app/project/[projectId]/agents/details/volumes/agent-volumes-card.tsx +++ b/src/app/project/[projectId]/agents/details/volumes/agent-volumes-card.tsx @@ -13,8 +13,8 @@ import { deleteAgentVolume } from "../configuration/actions"; import { Constants } from "@/shared/utils/constants"; function formatSize(mb: number): string { - if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`; - return `${mb} MB`; + if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GiB`; + return `${mb} MiB`; } function volumeTypeLabel(volumeType: string): string { diff --git a/src/app/project/[projectId]/apps/details/app-details-drawer.tsx b/src/app/project/[projectId]/apps/details/app-details-drawer.tsx index 50726e6a..81568319 100644 --- a/src/app/project/[projectId]/apps/details/app-details-drawer.tsx +++ b/src/app/project/[projectId]/apps/details/app-details-drawer.tsx @@ -264,20 +264,20 @@ export function AppDetailsDrawer({ - + - + {hasVolumes && ( - + - + )} {app.appType !== 'APP' && ( diff --git a/src/app/project/[projectId]/apps/details/deployments/actions.ts b/src/app/project/[projectId]/apps/details/deployments/actions.ts index 6d5957b8..115f3615 100644 --- a/src/app/project/[projectId]/apps/details/deployments/actions.ts +++ b/src/app/project/[projectId]/apps/details/deployments/actions.ts @@ -1,5 +1,7 @@ 'use server' +import appMonitoringHistoryQueryService from '@/server/services/standalone-services/app-monitoring-history.service'; + import { SuccessActionResult } from "@/shared/model/server-action-error-return.model"; import appService from "@/server/services/app.service"; import buildService from "@/server/services/build.service"; @@ -47,3 +49,10 @@ export const createNewWebhookUrl = async (appId: string) => await isAuthorizedWriteForWorkload(appId); return await appService.regenerateWebhookId(appId); }); + + +export const getAppMonitoringHistory = async (appId: string) => + simpleAction(async () => { + await isAuthorizedReadForApp(appId); + return appMonitoringHistoryQueryService.getHistory([appId]); + }); diff --git a/src/app/project/[projectId]/apps/details/deployments/app-details-deployments-tab.tsx b/src/app/project/[projectId]/apps/details/deployments/app-details-deployments-tab.tsx index c49194f3..4adbc38c 100644 --- a/src/app/project/[projectId]/apps/details/deployments/app-details-deployments-tab.tsx +++ b/src/app/project/[projectId]/apps/details/deployments/app-details-deployments-tab.tsx @@ -36,7 +36,7 @@ export default function DrawerDeploymentsTab({ const showWebhookCard = () => { openNestedDrawer({ title: 'Webhook', - description: ``, + description: 'Use this webhook URL to trigger deployments from external services (for example GitHub Actions or GitLab Pipelines).', content: ( ({ + default: { getHistory: vi.fn().mockResolvedValue({ points: [] }) }, +})); +vi.mock('@/server/services/app.service', () => ({ default: {} })); +vi.mock('@/server/services/build.service', () => ({ default: {} })); +vi.mock('@/server/services/deployment.service', () => ({ default: {} })); +vi.mock('@/server/services/monitoring.service', () => ({ default: {} })); +vi.mock('@/server/services/pod.service', () => ({ default: {} })); +vi.mock('@/server/utils/action-wrapper.utils', () => ({ + isAuthorizedReadForApp: vi.fn(), + isAuthorizedWriteForApp: vi.fn(), + isAuthorizedWriteForWorkload: vi.fn(), + simpleAction: async (callback: () => Promise) => { + try { return { status: 'success', data: await callback() }; } + catch { return { status: 'error' }; } + }, +})); +import history from '@/server/services/standalone-services/app-monitoring-history.service'; +import { isAuthorizedReadForApp } from '@/server/utils/action-wrapper.utils'; +import { getAppMonitoringHistory } from './actions'; + +beforeEach(() => vi.clearAllMocks()); + +it('returns history for an App when the user has read access', async () => { + vi.mocked(isAuthorizedReadForApp).mockResolvedValue({} as never); + const result = await getAppMonitoringHistory('allowed'); + expect(result.status).toBe('success'); + expect(isAuthorizedReadForApp).toHaveBeenCalledWith('allowed'); + expect(history.getHistory).toHaveBeenCalledWith(['allowed']); +}); + +it('does not query history when the user cannot read the App', async () => { + vi.mocked(isAuthorizedReadForApp).mockRejectedValue(new Error('not authorized')); + const result = await getAppMonitoringHistory('denied'); + expect(result.status).toBe('error'); + expect(history.getHistory).not.toHaveBeenCalled(); +}); diff --git a/src/app/project/[projectId]/apps/details/deployments/webhook-deployment.tsx b/src/app/project/[projectId]/apps/details/deployments/webhook-deployment.tsx index 2cf0e60c..f6493634 100644 --- a/src/app/project/[projectId]/apps/details/deployments/webhook-deployment.tsx +++ b/src/app/project/[projectId]/apps/details/deployments/webhook-deployment.tsx @@ -1,4 +1,3 @@ -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { AppExtendedModel } from "@/shared/model/app-extended.model"; import { useEffect, useState } from "react"; import { createNewWebhookUrl } from "./actions"; @@ -49,20 +48,12 @@ export default function WebhookDeploymentInfo({ toast.success('Webhook URL has been copied to clipboard.'); } - return <> - - - Webhook Deployment - Use this webhook URL to trigger deployments from external services (for example GitHub Actions or GitLab Pipelines). - - -
- {webhookUrl && } - {role === RolePermissionEnum.READWRITE && } -
-
-
- ; + return ( +
+ {webhookUrl && } + {role === RolePermissionEnum.READWRITE && } +
+ ); } diff --git a/src/app/project/[projectId]/apps/details/runtime/monitoring-app.tsx b/src/app/project/[projectId]/apps/details/runtime/monitoring-app.tsx index 52daa810..17a3f04d 100644 --- a/src/app/project/[projectId]/apps/details/runtime/monitoring-app.tsx +++ b/src/app/project/[projectId]/apps/details/runtime/monitoring-app.tsx @@ -1,14 +1,9 @@ -import { CardContent } from "@/components/ui/card"; +import AppMonitoringHistoryView from "@/components/custom/app-monitoring-history"; import { AppExtendedModel } from "@/shared/model/app-extended.model"; import { useCallback, useState } from "react"; import { getRessourceDataApp } from "../deployments/actions"; -import FullLoadingSpinner from "@/components/ui/full-loading-spinnter"; import { usePolling } from "@/frontend/hooks/use-polling"; import { PodsResourceInfoModel } from "@/shared/model/pods-resource-info.model"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { KubeSizeConverter } from "@/shared/utils/kubernetes-size-converter.utils"; -import { cn } from "@/frontend/utils/utils"; export default function MonitoringTab({ app, @@ -17,7 +12,7 @@ export default function MonitoringTab({ }) { const [selectedPod, setSelectedPod] = useState(undefined); - const [, setError] = useState(undefined); + const [error, setError] = useState(undefined); const updateValues = useCallback(async () => { setError(undefined); @@ -25,12 +20,13 @@ export default function MonitoringTab({ const response = await getRessourceDataApp(app.projectId, app.id); if (response.status === 'success' && response.data) { setSelectedPod(response.data); - } else { + setSelectedPod(undefined); console.error(response); setError(response.message ?? 'An unknown error occurred.'); } } catch (ex) { + setSelectedPod(undefined); console.error(ex); setError('An unknown error occurred.'); } @@ -38,33 +34,10 @@ export default function MonitoringTab({ usePolling(updateValues, { intervalMs: 10000, runImmediately: true }); - return <> - - {!selectedPod ? : -
- - - CPU % - RAM - - - - - - - - {selectedPod?.cpuPercent.toFixed(2)}} /> - -

{selectedPod?.cpuAbsolutCores.toFixed(10)} cores

-
-
-
-
- {KubeSizeConverter.convertBytesToReadableSize(selectedPod?.ramAbsolutBytes)} -
-
-
- } -
- ; + return ( + <> + {error &&

Unable to load current App metrics: {error}

} + + + ); } diff --git a/src/app/project/[projectId]/apps/details/volumes/actions.ts b/src/app/project/[projectId]/apps/details/volumes/actions.ts index 0ea634dd..1c1ccdf3 100644 --- a/src/app/project/[projectId]/apps/details/volumes/actions.ts +++ b/src/app/project/[projectId]/apps/details/volumes/actions.ts @@ -105,7 +105,7 @@ export const getPvcUsage = async (appId: string, projectId: string) => simpleAction(async () => { await isAuthorizedReadForApp(appId); return monitoringService.getPvcUsageFromApp(appId, projectId); - }) as Promise>; + }) as Promise>; export const getShareableVolumes = async (appId: string) => simpleAction(async () => { diff --git a/src/app/project/[projectId]/apps/details/volumes/backups/app-details-backups-tab.tsx b/src/app/project/[projectId]/apps/details/volumes/backups/app-details-backups-tab.tsx index b4b146d5..bfd14738 100644 --- a/src/app/project/[projectId]/apps/details/volumes/backups/app-details-backups-tab.tsx +++ b/src/app/project/[projectId]/apps/details/volumes/backups/app-details-backups-tab.tsx @@ -129,7 +129,7 @@ export function DrawerBackupsTab({ app, role, s3Targets, volumeBackups }: { // ScrollArea renders its vertical scrollbar over the right edge. Reserve space // so the split-button trigger stays reachable instead of sitting underneath it. - return
+ return
Backups
{!readonly && Backups could not be loaded Check the backup storage connection and try again. - : !backups ?
: backups.length === 0 ? + : !backups ?
: backups.length === 0 ? No backups yet diff --git a/src/app/project/[projectId]/apps/details/volumes/shared-storage-edit-overlay.tsx b/src/app/project/[projectId]/apps/details/volumes/shared-storage-edit-overlay.tsx index 22203239..ccdab001 100644 --- a/src/app/project/[projectId]/apps/details/volumes/shared-storage-edit-overlay.tsx +++ b/src/app/project/[projectId]/apps/details/volumes/shared-storage-edit-overlay.tsx @@ -133,7 +133,7 @@ export default function SharedStorageEditDialog({ app }: { label="Select Shared Volume" values={shareableVolumes.map(v => [ v.id, - `${v.app.name} - ${v.containerMountPath} (${v.size}MB)` + `${v.app.name} - ${v.containerMountPath} (${v.size}MiB)` ])} placeholder="Select volume to share..." /> @@ -153,7 +153,7 @@ export default function SharedStorageEditDialog({ app }: { />
-

Size: {form.watch("size")} MB (inherited from shared volume)

+

Size: {form.watch("size")} MiB (inherited from shared volume)

Storage Class: {form.watch("storageClassName")} (inherited from shared volume)

diff --git a/src/app/project/[projectId]/apps/details/volumes/storage-edit-overlay.tsx b/src/app/project/[projectId]/apps/details/volumes/storage-edit-overlay.tsx index 28900164..3da657f0 100644 --- a/src/app/project/[projectId]/apps/details/volumes/storage-edit-overlay.tsx +++ b/src/app/project/[projectId]/apps/details/volumes/storage-edit-overlay.tsx @@ -140,7 +140,7 @@ export default function StorageEditDialog({ volume, app, storageClasses }: { name="size" render={({ field }) => ( - Size in MB + Size in MiB diff --git a/src/app/project/[projectId]/apps/details/volumes/volumes-list.tsx b/src/app/project/[projectId]/apps/details/volumes/volumes-list.tsx index 8772f630..82c22b79 100644 --- a/src/app/project/[projectId]/apps/details/volumes/volumes-list.tsx +++ b/src/app/project/[projectId]/apps/details/volumes/volumes-list.tsx @@ -28,6 +28,7 @@ type AppVolumeWithCapacity = (AppVolume & { usedBytes?: number; capacityBytes?: number; usedPercentage?: number; + usageStatus?: string; }); export default function StorageList({ app, readonly, storageClasses }: { @@ -49,8 +50,9 @@ export default function StorageList({ app, readonly, storageClasses }: { const volume = response.data.find(x => x.pvcName === KubeObjectNameUtils.toPvcName(item.sharedVolumeId || item.id)); if (volume) { item.usedBytes = volume.usedBytes; - item.capacityBytes = KubeSizeConverter.fromMegabytesToBytes(item.size); - item.usedPercentage = Math.round(volume.usedBytes / item.capacityBytes * 100); + item.capacityBytes = volume.capacityBytes; + item.usageStatus = volume.status; + item.usedPercentage = volume.usedBytes !== undefined && volume.capacityBytes ? Math.round(volume.usedBytes / volume.capacityBytes * 100) : undefined; } } setVolumesWithStorage(mappedVolumeData); @@ -169,15 +171,15 @@ export default function StorageList({ app, readonly, storageClasses }: { {volumesWithStorage.map(volume => ( {volume.containerMountPath} - {volume.size} MB + {volume.size} MiB - {volume.usedPercentage && <> + {volume.usedPercentage !== undefined && volume.usedBytes !== undefined && volume.capacityBytes !== undefined ? <> = 90 ? 'red' : (volume.usedPercentage >= 80 ? 'orange' : undefined)} />
- {KubeSizeConverter.convertBytesToReadableSize(volume.usedBytes!)} used ({volume.usedPercentage}%) + {KubeSizeConverter.convertBytesToReadableSize(volume.usedBytes)} / {KubeSizeConverter.convertBytesToReadableSize(volume.capacityBytes)}
- } + : Unavailable{volume.usageStatus ? ` (${volume.usageStatus})` : ""}}
{volume.storageClassName?.replace('-', ' ')} {volume.accessMode} diff --git a/src/app/project/workload-components/source-wizard/types.ts b/src/app/project/workload-components/source-wizard/types.ts index 1efb34ef..f6adf8b4 100644 --- a/src/app/project/workload-components/source-wizard/types.ts +++ b/src/app/project/workload-components/source-wizard/types.ts @@ -32,4 +32,4 @@ export const sourceTypeLabels: Record = { export const buildMethodLabels: Record = appBuildMethodLabels; -export const defaultDockerfilePath = '../../[projectId]/app-components/app-detail/general/app-source-wizard/Dockerfile'; +export const defaultDockerfilePath = './Dockerfile'; diff --git a/src/app/settings/platform/operations/actions.ts b/src/app/settings/platform/operations/actions.ts new file mode 100644 index 00000000..5ac4237c --- /dev/null +++ b/src/app/settings/platform/operations/actions.ts @@ -0,0 +1,17 @@ +'use server'; + +import appHistoryService from '@/server/services/standalone-services/app-monitoring-history.service'; +import { getAdminUserSession, simpleAction } from '@/server/utils/action-wrapper.utils'; +import { z } from 'zod'; + +const retentionSchema = z.number().int().min(1).max(168); + +export const getMonitoringHistoryRetention = async () => simpleAction(async () => { + await getAdminUserSession(); + return appHistoryService.getRetentionHours(); +}); + +export const setMonitoringHistoryRetention = async (hours: number) => simpleAction(async () => { + await getAdminUserSession(); + await appHistoryService.setRetentionHours(retentionSchema.parse(hours)); +}); diff --git a/src/app/settings/platform/operations/monitoring-history-settings.tsx b/src/app/settings/platform/operations/monitoring-history-settings.tsx new file mode 100644 index 00000000..9d21b7fb --- /dev/null +++ b/src/app/settings/platform/operations/monitoring-history-settings.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { getMonitoringHistoryRetention, setMonitoringHistoryRetention } from './actions'; + +const schema = z.object({ hours: z.coerce.number().int().min(1).max(168) }); + +export default function MonitoringHistorySettings() { + const [loaded, setLoaded] = useState(false); + const [message, setMessage] = useState(''); + const form = useForm, unknown, z.output>({ + resolver: zodResolver(schema), + defaultValues: { hours: '' }, + }); + useEffect(() => { + void getMonitoringHistoryRetention().then(result => { + if (result.status === 'success' && result.data !== undefined) { + form.reset({ hours: result.data }); + setLoaded(true); + } else { + setMessage('Unable to load retention settings.'); + } + }).catch(() => setMessage('Unable to load retention settings.')); + }, [form]); + return + + App monitoring history + Keep measurements for 1 to 168 hours. Default: 24 hours. + + +
+ { + try { + const result = await setMonitoringHistoryRetention(hours); + setMessage(result.status === 'success' + ? 'Retention saved. Older samples are removed during the next collection.' + : result.message ?? 'Unable to save retention.'); + } catch { + setMessage('Unable to save retention.'); + } + })} + > + ( + + Retention (hours) + + + + + + )} + /> + + {message &&

{message}

} + + +
+
; +} diff --git a/src/app/settings/platform/operations/page.tsx b/src/app/settings/platform/operations/page.tsx index e730d6c5..59bbe62a 100644 --- a/src/app/settings/platform/operations/page.tsx +++ b/src/app/settings/platform/operations/page.tsx @@ -1,3 +1,4 @@ +import MonitoringHistorySettings from "./monitoring-history-settings" import { Constants } from "@/shared/utils/constants" import podService from "@/server/services/pod.service" import QuickStackMaintenanceSettings from "./qs-maintenance-settings" @@ -10,6 +11,7 @@ export default async function MaintenanceSettingsPage() { return
+
} diff --git a/src/components/custom/app-monitoring-history.tsx b/src/components/custom/app-monitoring-history.tsx new file mode 100644 index 00000000..cfa9cf41 --- /dev/null +++ b/src/components/custom/app-monitoring-history.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'; +import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/chart'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { KubeSizeConverter } from '@/shared/utils/kubernetes-size-converter.utils'; +import { usePolling } from '@/frontend/hooks/use-polling'; +import { PodsResourceInfoModel } from '@/shared/model/pods-resource-info.model'; +import { AppMonitoringHistory } from '@/shared/model/app-monitoring-history.model'; +import { getAppMonitoringHistory as getOneHistory } from '@/app/project/[projectId]/apps/details/deployments/actions'; +import FullLoadingSpinner from '../ui/full-loading-spinnter'; + +function HistoryChart({ title, data, dataKey, unit, currentValue, currentPercent, selectedRangeHours, useCurrentValue = false }: { + title: string; + data: object[]; + dataKey: string; + unit: string; + selectedRangeHours: number; + currentValue?: number; + currentPercent?: number; + useCurrentValue?: boolean; +}) { + const visibleSamples = data.filter(point => (point as Record)[dataKey] != null); + const latestValue = [...visibleSamples].reverse().map(point => (point as Record)[dataKey])[0]; + const formatValue = (value: number, axis = false) => unit === 'bytes' + ? KubeSizeConverter.convertBytesToReadableSize(value, axis ? 1 : 2) + : `${new Intl.NumberFormat(undefined, { maximumSignificantDigits: axis ? 2 : 6 }).format(value)}${axis ? '' : ' cores'}`; + const config = { [dataKey]: { label: title, color: "var(--chart-2)" } } satisfies ChartConfig; + return ( + + + {title} + {currentPercent !== undefined && currentValue !== undefined ? ( + + + }> + {currentPercent.toFixed(2)}% + + {currentValue} cores + + + ) : ( +
+ {useCurrentValue + ? currentValue === undefined ? 'Unavailable' : formatValue(currentValue) + : latestValue === undefined ? 'Unavailable' : formatValue(latestValue)} +
+ )} +
+ + {visibleSamples.length === 0 ?

No measurements available.

: + + + new Date(value).toLocaleTimeString([], { + ...(selectedRangeHours > 24 ? { month: 'short', day: 'numeric' } as const : { hour: '2-digit', minute: '2-digit' } as const), + })} + /> + formatValue(value, true)} /> + { + if (!active || !payload?.length) return null; + const item = payload[0]; + return ( +
+

{new Date(Number(label)).toLocaleString()}

+

{typeof item.value === "number" ? formatValue(item.value) : "Unavailable"}

+
+ ); + }} /> + +
+
} +
+
+ ); +} + +export default function AppMonitoringHistoryView({ appId, currentUsage }: { appId: string; currentUsage?: PodsResourceInfoModel }) { + const [history, setHistory] = useState(); + const [error, setError] = useState(false); + const [range, setRange] = useState('1h'); + const refresh = useCallback(async () => { + try { + const response = await getOneHistory(appId); + if (response.status !== 'success' || !response.data) { + throw new Error(response.message ?? 'Unable to load history'); + } + setHistory(response.data); + setError(false); + } catch { + setError(true); + } + }, [appId]); + usePolling(refresh, { intervalMs: 60_000, runImmediately: true }); + const retentionHours = history?.retentionHours ?? 1; + const options = [ + { value: '1h', label: 'Last hour', hours: 1 }, + { value: '5h', label: 'Last 5 hours', hours: 5 }, + { value: 'today', label: 'Today', hours: 24 }, + { value: '48h', label: 'Last 48 hours', hours: 48 }, + { value: '7d', label: 'Last 7 days', hours: 168 }, + ].filter(option => option.hours <= retentionHours); + if (![1, 5, 48, 168].includes(retentionHours)) { + options.push({ value: 'all', label: `Last ${retentionHours} hours`, hours: retentionHours }); + } + const selectedRange = options.find(option => option.value === range) ?? options[0]; + const now = Date.now(); + const start = selectedRange.value === 'today' + ? new Date(new Date(now).setHours(0, 0, 0, 0)).getTime() + : now - selectedRange.hours * 60 * 60_000; + const visiblePoints = history?.points.filter(point => point.time >= start) ?? []; + const collectionStopped = history?.points.at(-1)?.isGap; + const sampleCount = visiblePoints.filter(point => !point.isGap).length; + + return ( +
+
+ {history &&
+

Stats

+ +
} + {error &&

Unable to load monitoring history.

} + {!history && !error &&
} + {history && history.points.length === 0 && ( +

No measurements yet. History is collected every five minutes.

+ )} + {history && <> + {collectionStopped && ( +

Collection has stopped or is delayed. The chart shows a gap after the last sample.

+ )} + {sampleCount === 1 &&

One sample collected. More samples will show a trend.

} +
+ + + {history.volumes.map(volume => { + return point.time >= start)} + dataKey="usedBytes" + unit="bytes" + selectedRangeHours={selectedRange.hours} + />; + })} +
+ } +
+
+ ); +} diff --git a/src/components/custom/app-monitoring-history.unit.spec.ts b/src/components/custom/app-monitoring-history.unit.spec.ts new file mode 100644 index 00000000..aad794f9 --- /dev/null +++ b/src/components/custom/app-monitoring-history.unit.spec.ts @@ -0,0 +1,61 @@ +import React from 'react'; +import { cleanup, render, screen } from '@testing-library/react'; +import AppMonitoringHistoryView from './app-monitoring-history'; +import { getAppMonitoringHistory } from '@/app/project/[projectId]/apps/details/deployments/actions'; + +vi.mock('@/app/project/[projectId]/apps/details/deployments/actions', () => ({ getAppMonitoringHistory: vi.fn() })); +vi.mock('recharts', () => ({ + CartesianGrid: () => null, Line: () => null, LineChart: () => null, + ResponsiveContainer: () => null, Tooltip: () => null, Legend: () => null, XAxis: () => null, YAxis: () => null, +})); +afterEach(() => { cleanup(); vi.clearAllMocks(); }); + +it('shows an empty state before the first sample', async () => { + vi.mocked(getAppMonitoringHistory).mockResolvedValue({ status: 'success', data: { retentionHours: 24, points: [], volumes: [], latestCpuMeasurement: null, latestRamMeasurement: null, latestVolumeMeasurement: null } } as never); + render(React.createElement(AppMonitoringHistoryView, { appId: 'app' })); + expect(await screen.findByText(/No measurements yet/)).toBeTruthy(); + expect(screen.getByRole('combobox', { name: 'History time range' })).toBeTruthy(); +}); + +it('shows an error when history cannot be loaded', async () => { + vi.mocked(getAppMonitoringHistory).mockRejectedValue(new Error('unavailable')); + render(React.createElement(AppMonitoringHistoryView, { appId: 'app' })); + expect((await screen.findByRole('alert')).textContent).toContain('Unable to load monitoring history.'); +}); + +it('shows a stopped collection gap without treating it as a new volume sample', async () => { + const now = Date.now(); + vi.mocked(getAppMonitoringHistory).mockResolvedValue({ + status: 'success', + data: { + points: [ + { time: now - 900_000, cpuCores: 1, ramBytes: 2, storageBytes: 10, availableVolumes: 1, unavailableVolumes: 2 }, + { time: now, isGap: true, cpuCores: null, ramBytes: null, storageBytes: null, availableVolumes: 0, unavailableVolumes: 0 }, + ], + volumes: [], + latestCpuMeasurement: null, + latestRamMeasurement: null, + latestVolumeMeasurement: null, + }, + } as never); + render(React.createElement(AppMonitoringHistoryView, { appId: 'app' })); + expect(await screen.findByText(/Collection has stopped or is delayed/)).toBeTruthy(); +}); + +it('updates app card values from current usage while keeping history', async () => { + vi.mocked(getAppMonitoringHistory).mockResolvedValue({ + status: 'success', + data: { + points: [{ time: Date.now(), cpuCores: 1, ramBytes: 1024, storageBytes: null, availableVolumes: 0, unavailableVolumes: 0 }], + volumes: [], latestCpuMeasurement: null, latestRamMeasurement: null, latestVolumeMeasurement: null, + }, + } as never); + const { rerender } = render(React.createElement(AppMonitoringHistoryView, { + appId: 'app', currentUsage: { cpuAbsolutCores: 2, cpuPercent: 12.345, ramAbsolutBytes: 2048, ramPercent: 0 }, + })); + expect(await screen.findByText('12.35%')).toBeTruthy(); + rerender(React.createElement(AppMonitoringHistoryView, { + appId: 'app', currentUsage: { cpuAbsolutCores: 3, cpuPercent: 25.6, ramAbsolutBytes: 4096, ramPercent: 0 }, + })); + expect(screen.getByText('25.60%')).toBeTruthy(); +}); diff --git a/src/frontend/utils/format.utils.ts b/src/frontend/utils/format.utils.ts index fbeb4a95..0d19c4ae 100644 --- a/src/frontend/utils/format.utils.ts +++ b/src/frontend/utils/format.utils.ts @@ -54,7 +54,7 @@ export function formatTime(date: DateInput): string { export function formatBytes(bytes: number) { if (bytes === 0) return '0 B'; const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; + const sizes = ['B', 'KiB', 'MiB', 'GiB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; }; diff --git a/src/server.ts b/src/server.ts index e6bae396..6157dbb2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,6 +10,7 @@ import { FancyConsoleUtils } from './shared/utils/fancy-console.utils' import { Constants } from './shared/utils/constants' import backupService from './server/services/standalone-services/backup.service' import maintenanceService from './server/services/standalone-services/maintenance.service' +import appMonitoringHistoryService from './server/services/standalone-services/app-monitoring-history.service' import passwordChangeService from './server/services/standalone-services/password-change.service' declare global { @@ -63,6 +64,7 @@ async function initializeNextJs() { await backupService.registerAllBackups(); maintenanceService.configureMaintenanceCronJobs(); + appMonitoringHistoryService.initialize(); const app = next({ dev }); const handle = app.getRequestHandler(); diff --git a/src/server/adapter/app-history-metrics.adapter.ts b/src/server/adapter/app-history-metrics.adapter.ts new file mode 100644 index 00000000..bd6e8a8d --- /dev/null +++ b/src/server/adapter/app-history-metrics.adapter.ts @@ -0,0 +1,15 @@ +import * as k8s from '@kubernetes/client-node'; +import k3s from './kubernetes-api.adapter'; + +class AppHistoryMetricsAdapter { + async getPods() { + return (await k3s.core.listPodForAllNamespaces()).items; + } + + async getMetrics() { + return (await new k8s.Metrics(k3s.getKubeConfig()).getPodMetrics()).items; + } +} + +const appHistoryMetricsAdapter = new AppHistoryMetricsAdapter(); +export default appHistoryMetricsAdapter; diff --git a/src/server/adapter/app-history.adapter.ts b/src/server/adapter/app-history.adapter.ts new file mode 100644 index 00000000..2511ff09 --- /dev/null +++ b/src/server/adapter/app-history.adapter.ts @@ -0,0 +1,55 @@ +import { Prisma } from '@prisma/client'; +import dataAccess from '@/server/adapter/db.client'; +import { Constants } from '@/shared/utils/constants'; + +class AppHistoryAdapter { + async getPopulation() { + const [apps, volumes] = await Promise.all([ + dataAccess.client.app.findMany({ select: { id: true, projectId: true } }), + dataAccess.client.appVolume.findMany({ include: { app: { select: { projectId: true } } } }), + ]); + return { apps, volumes }; + } + + async getRetentionHours() { + const value = (await dataAccess.client.parameter.findUnique({ where: { name: Constants.APP_MONITORING_HISTORY_RETENTION_HOURS } }))?.value; + const hours = Number(value); + return Number.isInteger(hours) && hours >= 1 && hours <= 168 ? hours : 24; + } + + async setRetentionHours(hours: number) { + await dataAccess.client.parameter.upsert({ + where: { name: Constants.APP_MONITORING_HISTORY_RETENTION_HOURS }, + create: { name: Constants.APP_MONITORING_HISTORY_RETENTION_HOURS, value: String(hours) }, + update: { value: String(hours) }, + }); + } + + async save(apps: Prisma.AppMonitoringSampleCreateManyInput[], volumes: Prisma.AppVolumeMonitoringSampleCreateManyInput[], memberships: Prisma.AppVolumeMonitoringMembershipCreateManyInput[], cutoff: Date) { + await dataAccess.client.$transaction(async tx => { + for (let i = 0; i < apps.length; i += 100) await tx.appMonitoringSample.createMany({ data: apps.slice(i, i + 100) }); + for (let i = 0; i < volumes.length; i += 100) await tx.appVolumeMonitoringSample.createMany({ data: volumes.slice(i, i + 100) }); + for (let i = 0; i < memberships.length; i += 100) await tx.appVolumeMonitoringMembership.createMany({ data: memberships.slice(i, i + 100) }); + await tx.appMonitoringSample.deleteMany({ where: { sampledAt: { lt: cutoff } } }); + await tx.appVolumeMonitoringSample.deleteMany({ where: { sampledAt: { lt: cutoff } } }); + }); + } + + async getHistory(appIds: string[], cutoff: Date) { + return Promise.all([ + dataAccess.client.appMonitoringSample.findMany({ where: { appId: { in: appIds }, sampledAt: { gte: cutoff } }, orderBy: { sampledAt: 'asc' } }), + dataAccess.client.appVolumeMonitoringMembership.findMany({ + where: { volume: { appId: { in: appIds } }, sample: { sampledAt: { gte: cutoff } } }, + include: { + sample: true, + volume: { select: { appId: true, sharedVolumeId: true, containerMountPath: true, app: { select: { projectId: true } } } }, + }, + orderBy: { sample: { sampledAt: 'asc' } }, + }).then(memberships => memberships.map(item => ({ ...item.sample, volumeId: item.volumeId, volume: item.volume }))), + ]); + } + +} + +const appHistoryAdapter = new AppHistoryAdapter(); +export default appHistoryAdapter; diff --git a/src/server/adapter/kubernetes-api.adapter.ts b/src/server/adapter/kubernetes-api.adapter.ts index 46f4465c..5252c46a 100644 --- a/src/server/adapter/kubernetes-api.adapter.ts +++ b/src/server/adapter/kubernetes-api.adapter.ts @@ -1,6 +1,8 @@ import { ServiceException } from '@/shared/model/service.exception.model'; import * as k8s from '@kubernetes/client-node'; import { ConfigurationOptions, PatchStrategy } from '@kubernetes/client-node'; +import * as http from 'node:http'; +import * as https from 'node:https'; export function kubernetesPatchOptions(contentType: PatchStrategy): ConfigurationOptions { return { @@ -100,6 +102,37 @@ export class K3sApiAdapter { return new k8s.Metrics(this.getKubeConfig()); } + /** Read the fixed kubelet summary endpoint through the Kubernetes API server. */ + async getNodeSummary(nodeName: string): Promise { + const config = this.getKubeConfig(); + const cluster = config.getCurrentCluster(); + if (!cluster) throw new Error('No Kubernetes cluster configured'); + const url = new URL(cluster.server); + url.pathname = `${url.pathname.replace(/\/$/, '')}/api/v1/nodes/${encodeURIComponent(nodeName)}/proxy/stats/summary`; + const options: https.RequestOptions = { method: 'GET', headers: { Accept: 'application/json' } }; + await config.applyToHTTPSOptions(options); + const transport = url.protocol === 'https:' ? https : http; + return new Promise((resolve, reject) => { + const request = transport.request(url, options, response => { + if (response.statusCode !== 200) { + response.resume(); + reject(new Error(`Node summary returned HTTP ${response.statusCode}`)); + return; + } + let body = ''; + response.setEncoding('utf8'); + response.on('data', chunk => { body += chunk; }); + response.on('error', reject); + response.on('end', () => { + try { resolve(JSON.parse(body)); } catch (error) { reject(error); } + }); + }); + request.setTimeout(10000, () => request.destroy(new Error('Node summary timed out'))); + request.on('error', reject); + request.end(); + }); + } + /** * Applies a single Kubernetes resource to the cluster * @param kc KubeConfig instance diff --git a/src/server/adapter/longhorn-api.adapter.ts b/src/server/adapter/longhorn-api.adapter.ts index 247e4221..736815d3 100644 --- a/src/server/adapter/longhorn-api.adapter.ts +++ b/src/server/adapter/longhorn-api.adapter.ts @@ -70,58 +70,6 @@ class LonghornApiAdapter { } - async getNodeStorageInfo(nodeName: String) { - const response = await fetch(`${this.longhornBaseUrl}/v1/nodes/${nodeName}`, { - cache: 'no-cache', - method: 'GET', - headers: { - - 'Accept': 'application/json', - 'Content-Type': 'application/json' - } - }); - - if (!response.ok) { - throw new Error(`HTTP-Error: ${response.status}`); - } - - const data = await response.json() as { - disks: { - [key: string]: { - storageMaximum: number, - storageAvailable: number, - storageReserved: number, - storageScheduled: number - } - } - }; - - if (!data.disks || typeof data.disks !== "object") { - throw new Error("Invalid node data: 'disks' property is missing or not an object."); - } - - let totalStorageMaximum = 0; - let totalStorageAvailable = 0; - let totalStorageReserved = 0; - Object.values(data.disks).forEach(disk => { - totalStorageMaximum += disk.storageMaximum; - totalStorageAvailable += disk.storageAvailable; - totalStorageReserved += disk.storageReserved; - }); - - // The available Storage is the total storage minus the reserved storage (which is not available for scheduling --> 30% of disk space) - const totalSchedulableStorage = totalStorageAvailable - totalStorageReserved; - - return { - totalStorageMaximum, - totalStorageAvailable, - totalSchedulableStorage, - totalStorageReserved - }; - - } - - async backupPvc(pvcName: string) { const snapshot = await this.createSnapshot(pvcName); await this.createBackup(pvcName, snapshot.id); diff --git a/src/server/adapter/volume-summary.adapter.ts b/src/server/adapter/volume-summary.adapter.ts new file mode 100644 index 00000000..43e89624 --- /dev/null +++ b/src/server/adapter/volume-summary.adapter.ts @@ -0,0 +1,36 @@ +import k3s from './kubernetes-api.adapter'; + +export interface VolumeFilesystemSample { + pvcRef?: { namespace?: string; name?: string }; + time?: string; + usedBytes?: number; + availableBytes?: number; + capacityBytes?: number; +} + +interface NodeSummary { + pods?: { volume?: VolumeFilesystemSample[] }[]; +} + +class VolumeSummaryAdapter { + async getPersistentVolumeClaims() { + return (await k3s.core.listPersistentVolumeClaimForAllNamespaces()).items; + } + + async getPersistentVolumes() { + return (await k3s.core.listPersistentVolume()).items; + } + + async getSamples(getNodeSummary: (nodeName: string) => Promise = nodeName => k3s.getNodeSummary(nodeName)): Promise { + const nodes = await k3s.core.listNode(); + const summaries = await Promise.allSettled(nodes.items.map(async node => { + if (!node.metadata?.name) return []; + const summary = await getNodeSummary(node.metadata.name) as NodeSummary; + return summary.pods?.flatMap(pod => pod.volume ?? []) ?? []; + })); + return summaries.flatMap(result => result.status === 'fulfilled' ? result.value : []); + } +} + +const volumeSummaryAdapter = new VolumeSummaryAdapter(); +export default volumeSummaryAdapter; diff --git a/src/server/services/agent-template.service.unit.spec.ts b/src/server/services/agent-template.service.unit.spec.ts index fd67793c..07f2d7dd 100644 --- a/src/server/services/agent-template.service.unit.spec.ts +++ b/src/server/services/agent-template.service.unit.spec.ts @@ -1,5 +1,6 @@ vi.mock("next/cache", () => ({ revalidateTag: vi.fn(), + unstable_cache: (fn: (...args: any[]) => any) => fn, })); const dbProjectMocks = vi.hoisted(() => ({ diff --git a/src/server/services/cluster.service.ts b/src/server/services/cluster.service.ts index 49e5ca14..b485465a 100644 --- a/src/server/services/cluster.service.ts +++ b/src/server/services/cluster.service.ts @@ -1,12 +1,12 @@ +import { getCachedNodeSummary } from './node-summary-cache.service'; +import volumeSummaryAdapter from "../adapter/volume-summary.adapter"; import k3s, { kubernetesPatchOptions } from "../adapter/kubernetes-api.adapter"; import * as k8s from '@kubernetes/client-node'; import { NodeInfoModel } from "@/shared/model/node-info.model"; import { NodeResourceModel } from "@/shared/model/node-resource.model"; import { Tags } from "../utils/cache-tag-generator.utils"; import { revalidateTag, unstable_cache } from "next/cache"; -import longhornApiAdapter from "../adapter/longhorn-api.adapter"; import { KubeSizeConverter } from "../../shared/utils/kubernetes-size-converter.utils"; -import { CatchUtils } from "@/shared/utils/catch.utils"; import { StorageClassUtils } from "@/shared/utils/storage-class.utils"; class ClusterService { @@ -68,8 +68,8 @@ class ClusterService { } async getRuntimeClasses(): Promise { - - const allowedRuntimes = ['gvisor', 'kata']; + return await unstable_cache(async () => { + const allowedRuntimes = ['gvisor', 'kata']; const runtimeClasses = await k3s.customObjects.listClusterCustomObject({ group: 'node.k8s.io', @@ -83,27 +83,11 @@ class ClusterService { .sort((a, b) => a.localeCompare(b)); return runtimeNames.filter(rn => allowedRuntimes.includes(rn.toLocaleLowerCase())); - /*return await unstable_cache(async () => { - - const allowedRuntimes = ['gVisor', 'kata']; - - const runtimeClasses = await k3s.customObjects.listClusterCustomObject({ - group: 'node.k8s.io', - version: 'v1', - plural: 'runtimeclasses', - }) as { items?: { metadata?: { name?: string } }[] }; - - const runtimeNames = (runtimeClasses.items ?? []) - .map((runtimeClass) => runtimeClass.metadata?.name) - .filter((name): name is string => !!name) - .sort((a, b) => a.localeCompare(b)); - - return runtimeNames.filter(rn => allowedRuntimes.includes(rn)); }, [Tags.runtimeClasses()], { revalidate: 60, tags: [Tags.runtimeClasses()] - })();*/ + })(); } async setNodeStatus(nodeName: string, schedulable: boolean) { @@ -127,6 +111,14 @@ class ClusterService { } } + async getReservedVolumeStorage(): Promise { + const claims = await volumeSummaryAdapter.getPersistentVolumeClaims(); + return claims.reduce((total, claim) => { + const size = claim.spec?.resources?.requests?.storage; + return total + (size ? KubeSizeConverter.fromKubeSizeToBytes(size) : 0); + }, 0); + } + async getNodeResourceUsage(): Promise { const topNodes = await k8s.topNodes(k3s.core); @@ -146,7 +138,23 @@ class ClusterService { nodeMetrics.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); const latestUsageItem = nodeMetrics[0]; - const diskInfo = await CatchUtils.resultOrUndefined(() => longhornApiAdapter.getNodeStorageInfo(node.Node.metadata?.name!)); + let filesystem: { usedBytes?: number; availableBytes?: number; capacityBytes?: number; time?: string } | undefined; + try { + const summary = await getCachedNodeSummary(node.Node.metadata?.name!) as { + node?: { fs?: typeof filesystem }; + }; + const fs = summary.node?.fs; + const timestamp = Date.parse(fs?.time ?? ''); + if (fs && Number.isFinite(timestamp) && timestamp <= Date.now() && Date.now() - timestamp <= 5 * 60_000 && + typeof fs.usedBytes === 'number' && Number.isFinite(fs.usedBytes) && fs.usedBytes >= 0 && + typeof fs.availableBytes === 'number' && Number.isFinite(fs.availableBytes) && fs.availableBytes >= 0 && + typeof fs.capacityBytes === 'number' && Number.isFinite(fs.capacityBytes) && fs.capacityBytes > 0 && + fs.usedBytes <= fs.capacityBytes && fs.availableBytes <= fs.capacityBytes) { + filesystem = fs; + } + } catch { + // A node without a summary must not hide the other nodes' metrics. + } return { name: node.Node.metadata?.name!, @@ -154,10 +162,9 @@ class ClusterService { cpuCapacity: Number(node.CPU?.Capacity!), ramUsage: latestUsageItem.ramUsage, ramCapacity: Number(node.Memory?.Capacity!), - diskUsageAbsolut: diskInfo ? diskInfo.totalStorageMaximum - diskInfo.totalStorageAvailable : undefined, - diskUsageReserved: diskInfo?.totalStorageReserved, - diskUsageCapacity: diskInfo?.totalStorageMaximum, - diskSpaceSchedulable: diskInfo?.totalSchedulableStorage + diskUsageAbsolut: filesystem?.usedBytes, + diskUsageAvailable: filesystem?.availableBytes, + diskUsageCapacity: filesystem?.capacityBytes } })); } diff --git a/src/server/services/cluster.service.unit.spec.ts b/src/server/services/cluster.service.unit.spec.ts new file mode 100644 index 00000000..70d23cc2 --- /dev/null +++ b/src/server/services/cluster.service.unit.spec.ts @@ -0,0 +1,60 @@ +vi.mock('next/cache', () => ({ unstable_cache: (fn: unknown) => fn, revalidateTag: vi.fn() })); + + + +const mocks = vi.hoisted(() => ({ topNodes: vi.fn(), getNodeMetrics: vi.fn(), getNodeSummary: vi.fn(), getPersistentVolumeClaims: vi.fn() })); +vi.mock('@kubernetes/client-node', async (importOriginal) => ({ + ...await importOriginal(), + topNodes: mocks.topNodes, +})); +vi.mock('@/server/adapter/kubernetes-api.adapter', () => ({ + default: { core: {}, metrics: { getNodeMetrics: mocks.getNodeMetrics }, getNodeSummary: mocks.getNodeSummary }, + kubernetesPatchOptions: vi.fn(), +})); +vi.mock('@/server/adapter/volume-summary.adapter', () => ({ + default: { getPersistentVolumeClaims: mocks.getPersistentVolumeClaims }, +})); +import clusterService from './cluster.service'; + +describe('cluster node storage', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.topNodes.mockResolvedValue(['node-a', 'node-b'].map(name => ({ + Node: { metadata: { name } }, CPU: { Capacity: '2' }, Memory: { Capacity: '1000' }, + }))); + mocks.getNodeMetrics.mockResolvedValue({ items: ['node-a', 'node-b'].map(name => ({ + metadata: { name }, timestamp: new Date().toISOString(), usage: { cpu: '1000000n', memory: '100Mi' }, + })) }); + }); + + it('returns filesystem metrics and preserves other nodes when a summary fails', async () => { + mocks.getNodeSummary.mockImplementation(async (name: string) => { + if (name === 'node-b') throw new Error('unavailable'); + return { node: { fs: { time: new Date().toISOString(), usedBytes: 300, availableBytes: 600, capacityBytes: 1000 } } }; + }); + const nodes = await clusterService.getNodeResourceUsage(); + expect(nodes[0]).toMatchObject({ name: 'node-a', diskUsageAbsolut: 300, diskUsageAvailable: 600, diskUsageCapacity: 1000 }); + expect(nodes[1].diskUsageCapacity).toBeUndefined(); + }); + + it('does not present stale or incomplete filesystem measurements', async () => { + mocks.getNodeSummary.mockImplementation(async (name: string) => ({ node: { fs: name === 'node-a' + ? { time: '2000-01-01T00:00:00Z', usedBytes: 10, availableBytes: 90, capacityBytes: 100 } + : { time: new Date().toISOString(), usedBytes: 10, capacityBytes: 100 }, + } })); + const nodes = await clusterService.getNodeResourceUsage(); + expect(nodes.map(node => node.diskUsageCapacity)).toEqual([undefined, undefined]); + }); +}); + +describe('reserved volume storage', () => { + it('sums requested storage across all claims', async () => { + mocks.getPersistentVolumeClaims.mockResolvedValue([ + { spec: { resources: { requests: { storage: '2Gi' } } } }, + { spec: { resources: { requests: { storage: '512Mi' } } } }, + { spec: {} }, + ]); + + expect(await clusterService.getReservedVolumeStorage()).toBe(2684354560); + }); +}); diff --git a/src/server/services/monitoring.service.ts b/src/server/services/monitoring.service.ts index ce795dd8..cf907bd8 100644 --- a/src/server/services/monitoring.service.ts +++ b/src/server/services/monitoring.service.ts @@ -1,3 +1,4 @@ +import { getCachedNodeSummary } from './node-summary-cache.service'; import k3s from "../adapter/kubernetes-api.adapter"; import * as k8s from '@kubernetes/client-node'; import standalonePodService from "./standalone-services/standalone-pod.service"; @@ -5,72 +6,37 @@ import clusterService from "./cluster.service"; import { PodsResourceInfoModel } from "@/shared/model/pods-resource-info.model"; import { KubeSizeConverter } from "../../shared/utils/kubernetes-size-converter.utils"; import { AppVolumeMonitoringUsageModel } from "@/shared/model/app-volume-monitoring-usage.model"; -import longhornApiAdapter from "../adapter/longhorn-api.adapter"; +import { VolumeFilesystemUsage } from "@/shared/model/app-volume-monitoring-usage.model"; import dataAccess from "../adapter/db.client"; +import appVolumeFilesystemService from "./standalone-services/app-volume-filesystem.service"; import pvcService from "./pvc.service"; -import { KubeObjectNameUtils } from "../utils/kube-object-name.utils"; +import { AppVolumeIdentityUtils } from "@/shared/utils/app-volume-identity.utils"; import projectService from "./project.service"; import { AppMonitoringUsageModel } from "@/shared/model/app-monitoring-usage.model"; class MonitorService { - async getAllAppVolumesUsage() { - const [longhornData, appVolumes, pvcs] = await Promise.all([ - longhornApiAdapter.getAllLonghornVolumes(), - dataAccess.client.appVolume.findMany({ - include: { - app: { - include: { - project: true - } - } - }, - orderBy: { - appId: 'asc' - } - }), - pvcService.getAllPvc() - ]); - - const appVolumesWithUsage: AppVolumeMonitoringUsageModel[] = []; - const volumeMap = new Map(appVolumes.map(volume => [volume.id, volume])); - const pvcByName = new Map(pvcs.map(pvc => [pvc.metadata?.name, pvc])); - const longhornVolumeByName = new Map(longhornData.map(volume => [volume.name, volume])); - - for (const appVolume of appVolumes) { - const sharedVolumeId = (appVolume as { sharedVolumeId?: string | null }).sharedVolumeId; - const baseVolumeId = sharedVolumeId ?? appVolume.id; - const baseVolume = volumeMap.get(baseVolumeId); - const pvc = pvcByName.get(KubeObjectNameUtils.toPvcName(baseVolumeId)); - if (!pvc) { - continue; - } - const volumeName = pvc.spec?.volumeName; - const longhornVolume = volumeName ? longhornVolumeByName.get(volumeName) : undefined; - if (!longhornVolume) { - continue; - } - - appVolumesWithUsage.push({ - projectId: appVolume.app.projectId, - projectName: appVolume.app.project.name, - appName: appVolume.app.name, - appId: appVolume.appId, - mountPath: appVolume.containerMountPath, - usedBytes: longhornVolume.actualSizeBytes, - capacityBytes: KubeSizeConverter.fromMegabytesToBytes(baseVolume?.size ?? appVolume.size), - isBaseVolume: !sharedVolumeId - }); - } - - // sort appVolumesWithUsage first by projectName (asc) then by appName - appVolumesWithUsage.sort((a, b) => { - if (a.projectName === b.projectName) { - return a.appName.localeCompare(b.appName); - } - return a.projectName.localeCompare(b.projectName); + async getAllAppVolumesUsage(): Promise { + const appVolumes = await dataAccess.client.appVolume.findMany({ + include: { app: { include: { project: true } } }, + orderBy: { appId: 'asc' }, }); - return appVolumesWithUsage; + const usages = await appVolumeFilesystemService.getVolumeFilesystemUsage( + appVolumes.map(volume => ({ ...volume, projectId: volume.app.projectId })), + () => pvcService.getAllPvc(), + getCachedNodeSummary, + ); + return appVolumes.map(volume => { + return { + ...usages.get(volume.id)!, + projectId: volume.app.projectId, + projectName: volume.app.project.name, + appName: volume.app.name, + appId: volume.appId, + mountPath: volume.containerMountPath, + isBaseVolume: !volume.sharedVolumeId, + }; + }).sort((a, b) => a.projectName.localeCompare(b.projectName) || a.appName.localeCompare(b.appName)); } async getMonitoringForAllApps() { @@ -185,34 +151,16 @@ class MonitorService { return totalResourcesNodes; } - async getPvcUsageFromApp(appId: string, projectId: string): Promise> { - const appVolumes = await dataAccess.client.appVolume.findMany({ - where: { - appId - }, - select: { - id: true, - sharedVolumeId: true - } + async getPvcUsageFromApp(appId: string, projectId: string): Promise { + const volumes = await dataAccess.client.appVolume.findMany({ + where: { appId }, + select: { id: true, sharedVolumeId: true }, }); - if (appVolumes.length === 0) { - return []; - } - const baseVolumeIds = Array.from(new Set(appVolumes.map(volume => (volume as { sharedVolumeId?: string | null }).sharedVolumeId || volume.id))); - const pvcNames = new Set(baseVolumeIds.map(id => KubeObjectNameUtils.toPvcName(id))); - const pvcFromProject = await k3s.core.listNamespacedPersistentVolumeClaim({ namespace: projectId }); - const pvcUsageData: Array<{ pvcName: string, usedBytes: number }> = []; - - for (const pvc of pvcFromProject.items) { - const pvcName = pvc.metadata?.name; - const volumeName = pvc.spec?.volumeName; - - if (pvcName && volumeName && pvcNames.has(pvcName as `pvc-${string}`)) { - const usedBytes = await longhornApiAdapter.getLonghornVolume(volumeName); - pvcUsageData.push({ pvcName, usedBytes }); - } - } - return pvcUsageData; + const usages = await appVolumeFilesystemService.getVolumeFilesystemUsage(volumes.map(volume => ({ ...volume, projectId })), () => pvcService.getAllPvc(), getCachedNodeSummary); + const uniqueUsages = new Map( + Array.from(usages.values(), usage => [AppVolumeIdentityUtils.observationKey(projectId, usage.pvcName), usage]), + ); + return Array.from(uniqueUsages.values()); } } diff --git a/src/server/services/monitoring.service.unit.spec.ts b/src/server/services/monitoring.service.unit.spec.ts index 697afb6e..ed68363c 100644 --- a/src/server/services/monitoring.service.unit.spec.ts +++ b/src/server/services/monitoring.service.unit.spec.ts @@ -1,3 +1,5 @@ +vi.mock('next/cache', () => ({ unstable_cache: (fn: unknown) => fn, revalidateTag: vi.fn() })); + import * as k8s from '@kubernetes/client-node'; const k8sMocks = vi.hoisted(() => ({ @@ -14,7 +16,8 @@ vi.mock('@kubernetes/client-node', async (importOriginal) => { vi.mock('@/server/adapter/kubernetes-api.adapter', () => ({ default: { - core: {}, + core: { listNode: vi.fn(), listPersistentVolume: vi.fn() }, + getNodeSummary: vi.fn(), getKubeConfig: vi.fn(() => ({})), }, })); @@ -29,13 +32,6 @@ vi.mock('@/server/adapter/db.client', () => ({ }, })); -vi.mock('@/server/adapter/longhorn-api.adapter', () => ({ - default: { - getAllLonghornVolumes: vi.fn(), - getLonghornVolume: vi.fn(), - }, -})); - vi.mock('@/server/services/project.service', () => ({ default: { getAll: vi.fn(), @@ -60,8 +56,8 @@ vi.mock('@/server/services/standalone-services/standalone-pod.service', () => ({ }, })); +import k3s from '@/server/adapter/kubernetes-api.adapter'; import dataAccess from '@/server/adapter/db.client'; -import longhornApiAdapter from '@/server/adapter/longhorn-api.adapter'; import projectService from '@/server/services/project.service'; import clusterService from '@/server/services/cluster.service'; import pvcService from '@/server/services/pvc.service'; @@ -70,7 +66,6 @@ import monitoringService from './monitoring.service'; const mockedTopPods = vi.mocked(k8sMocks.topPods); const mockedFindMany = vi.mocked(dataAccess.client.appVolume.findMany); -const mockedGetAllLonghornVolumes = vi.mocked(longhornApiAdapter.getAllLonghornVolumes); const mockedProjectGetAll = vi.mocked(projectService.getAll); const mockedGetNodeInfo = vi.mocked(clusterService.getNodeInfo); const mockedGetAllPvc = vi.mocked(pvcService.getAllPvc); @@ -116,10 +111,6 @@ function appVolume(id: string, appId: string, size: number, sharedVolumeId: stri }; } -function pvc(name: string, volumeName: string) { - return { metadata: { name }, spec: { volumeName } }; -} - describe('monitoringService.getMonitoringForAllApps', () => { beforeEach(() => { vi.clearAllMocks(); @@ -166,69 +157,96 @@ describe('monitoringService.getMonitoringForAllApps', () => { }); describe('monitoringService.getAllAppVolumesUsage', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + it('reports fresh Longhorn filesystem usage for the configured volume', async () => { + mockedFindMany.mockResolvedValue([appVolume('vol-1', 'app-1', 1024, null)] as never); + mockedGetAllPvc.mockResolvedValue([{ metadata: { name: 'pvc-vol-1', namespace: 'proj-1' }, spec: { volumeName: 'pv-1' } }] as never); + vi.mocked(k3s.core.listPersistentVolume).mockResolvedValue({ items: [{ metadata: { name: 'pv-1' }, spec: { csi: { driver: 'driver.longhorn.io' } } }] } as never); + vi.mocked(k3s.core.listNode).mockResolvedValue({ items: [{ metadata: { name: 'node-1' } }] } as never); + vi.mocked(k3s.getNodeSummary).mockResolvedValue({ pods: [{ podRef: { namespace: 'proj-1' }, volume: [{ pvcRef: { namespace: 'proj-1', name: 'pvc-vol-1' }, time: new Date().toISOString(), usedBytes: 111, availableBytes: 889, capacityBytes: 1000 }] }] }); - it('resolves the correct pvc and longhorn volume for each app volume', async () => { - mockedGetAllLonghornVolumes.mockResolvedValue([ - { name: 'lh-1', actualSizeBytes: 111, sizeBytes: 1000 }, - { name: 'lh-2', actualSizeBytes: 222, sizeBytes: 2000 }, - ]); + const result = await monitoringService.getAllAppVolumesUsage(); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ usedBytes: 111, availableBytes: 889, capacityBytes: 1000, status: 'available' }); + }); + it('keeps shared and unavailable volumes while using fresh samples across nodes', async () => { mockedFindMany.mockResolvedValue([ - appVolume('vol-1', 'app-1', 1024, null), - appVolume('vol-2', 'app-2', 2048, null), + appVolume('vol-1', 'app-1', 1, null), + appVolume('vol-2', 'app-2', 1, 'vol-1'), + appVolume('vol-3', 'app-3', 1, null), + appVolume('vol-4', 'app-4', 1, null), + appVolume('vol-5', 'app-5', 1, null), ] as never); mockedGetAllPvc.mockResolvedValue([ - pvc('pvc-vol-1', 'lh-1'), - pvc('pvc-vol-2', 'lh-2'), + { metadata: { namespace: 'proj-1', name: 'pvc-vol-1' }, spec: { volumeName: 'pv-1' } }, + { metadata: { namespace: 'proj-1', name: 'pvc-vol-3' }, spec: { volumeName: 'pv-3' } }, + { metadata: { namespace: 'proj-1', name: 'pvc-vol-4' }, spec: { volumeName: 'pv-4' } }, ] as never); - + vi.mocked(k3s.core.listPersistentVolume).mockResolvedValue({ items: [ + { metadata: { name: 'pv-1' }, spec: { csi: { driver: 'driver.longhorn.io' } } }, + { metadata: { name: 'pv-3' }, spec: { csi: { driver: 'driver.longhorn.io' } } }, + { metadata: { name: 'pv-4' }, spec: { csi: { driver: 'rancher.io/local-path' } } }, + ] } as never); + vi.mocked(k3s.core.listNode).mockResolvedValue({ items: [{ metadata: { name: 'broken' } }, { metadata: { name: 'healthy' } }] } as never); + vi.mocked(k3s.getNodeSummary).mockImplementation(async (name) => { + if (name === 'broken') throw new Error('node unavailable'); + return { pods: [{ volume: [ + { pvcRef: { namespace: 'proj-1', name: 'pvc-vol-1' }, time: new Date().toISOString(), usedBytes: 0, availableBytes: 1000, capacityBytes: 1000 }, + { pvcRef: { namespace: 'proj-1', name: 'pvc-vol-3' }, time: new Date(Date.now() - 600000).toISOString(), usedBytes: 500, availableBytes: 500, capacityBytes: 1000 }, + { pvcRef: { namespace: 'proj-1', name: 'pvc-vol-4' }, time: new Date().toISOString(), usedBytes: 500, availableBytes: 500, capacityBytes: 1000 }, + ] }] }; + }); const result = await monitoringService.getAllAppVolumesUsage(); + expect(result).toHaveLength(5); + expect(result.find(v => v.appId === 'app-1')).toMatchObject({ status: 'available', usedBytes: 0 }); + expect(result.find(v => v.appId === 'app-2')).toMatchObject({ status: 'available', usedBytes: 0, pvcName: 'pvc-vol-1' }); + expect(result.find(v => v.appId === 'app-3')).toMatchObject({ status: 'stale' }); + expect(result.find(v => v.appId === 'app-4')).toMatchObject({ status: 'unsupported' }); + expect(result.find(v => v.appId === 'app-5')).toMatchObject({ status: 'unavailable' }); + }); - expect(result).toHaveLength(2); - expect(result.find((volume) => volume.appId === 'app-1')).toMatchObject({ - usedBytes: 111, - capacityBytes: 1024 * 1024 * 1024, - isBaseVolume: true, - }); - expect(result.find((volume) => volume.appId === 'app-2')).toMatchObject({ - usedBytes: 222, - capacityBytes: 2048 * 1024 * 1024, - isBaseVolume: true, - }); + it('treats missing fields and zero capacity as unavailable', async () => { + mockedFindMany.mockResolvedValue([appVolume('vol-1', 'app-1', 1, null)] as never); + mockedGetAllPvc.mockResolvedValue([{ metadata: { namespace: 'proj-1', name: 'pvc-vol-1' }, spec: { volumeName: 'pv-1' } }] as never); + vi.mocked(k3s.core.listPersistentVolume).mockResolvedValue({ items: [{ metadata: { name: 'pv-1' }, spec: { csi: { driver: 'driver.longhorn.io' } } }] } as never); + vi.mocked(k3s.core.listNode).mockResolvedValue({ items: [{ metadata: { name: 'node-1' } }] } as never); + vi.mocked(k3s.getNodeSummary).mockResolvedValue({ pods: [{ volume: [ + { pvcRef: { namespace: 'proj-1', name: 'pvc-vol-1' }, time: new Date().toISOString(), usedBytes: 0, capacityBytes: 100 }, + { pvcRef: { namespace: 'proj-1', name: 'pvc-vol-1' }, time: new Date().toISOString(), usedBytes: 0, availableBytes: 0, capacityBytes: 0 }, + ] }] }); + expect((await monitoringService.getAllAppVolumesUsage())[0]).toMatchObject({ status: 'unavailable' }); }); - it('derives a shared volume from its base volume and longhorn counterpart', async () => { - mockedGetAllLonghornVolumes.mockResolvedValue([ - { name: 'lh-1', actualSizeBytes: 111, sizeBytes: 1000 }, - ]); + it('reports unknown drivers and unmounted bound volumes without exposing host usage', async () => { mockedFindMany.mockResolvedValue([ - appVolume('vol-1', 'app-1', 1024, null), - appVolume('vol-3', 'app-3', 9999, 'vol-1'), + appVolume('unknown', 'app-1', 1, null), + appVolume('unmounted', 'app-2', 1, null), ] as never); mockedGetAllPvc.mockResolvedValue([ - pvc('pvc-vol-1', 'lh-1'), + { metadata: { namespace: 'proj-1', name: 'pvc-unknown' }, spec: { volumeName: 'pv-unknown' } }, + { metadata: { namespace: 'proj-1', name: 'pvc-unmounted' }, spec: { volumeName: 'pv-unmounted' } }, ] as never); - + vi.mocked(k3s.core.listPersistentVolume).mockResolvedValue({ items: [ + { metadata: { name: 'pv-unknown' }, spec: { csi: { driver: 'example.org/unknown' } } }, + { metadata: { name: 'pv-unmounted' }, spec: { csi: { driver: 'driver.longhorn.io' } } }, + ] } as never); + vi.mocked(k3s.core.listNode).mockResolvedValue({ items: [] } as never); const result = await monitoringService.getAllAppVolumesUsage(); - - expect(result.find((volume) => volume.appId === 'app-3')).toMatchObject({ - usedBytes: 111, - capacityBytes: 1024 * 1024 * 1024, - isBaseVolume: false, - }); + expect(result.find(v => v.appId === 'app-1')).toMatchObject({ status: 'unsupported' }); + expect(result.find(v => v.appId === 'app-2')).toMatchObject({ status: 'unavailable' }); }); - it('skips volumes without a matching pvc or longhorn volume', async () => { - mockedGetAllLonghornVolumes.mockResolvedValue([]); - mockedFindMany.mockResolvedValue([ - appVolume('vol-1', 'app-1', 1024, null), - ] as never); - mockedGetAllPvc.mockResolvedValue([] as never); - - const result = await monitoringService.getAllAppVolumesUsage(); - - expect(result).toHaveLength(0); + it('returns the underlying PVC measurement for an app shared volume', async () => { + mockedFindMany.mockResolvedValue([{ id: 'shared', sharedVolumeId: 'base' }] as never); + mockedGetAllPvc.mockResolvedValue([{ metadata: { namespace: 'proj-1', name: 'pvc-base' }, spec: { volumeName: 'pv-base' } }] as never); + vi.mocked(k3s.core.listPersistentVolume).mockResolvedValue({ items: [{ metadata: { name: 'pv-base' }, spec: { csi: { driver: 'driver.longhorn.io' } } }] } as never); + vi.mocked(k3s.core.listNode).mockResolvedValue({ items: [{ metadata: { name: 'node-1' } }] } as never); + vi.mocked(k3s.getNodeSummary).mockResolvedValue({ pods: [{ volume: [{ + pvcRef: { namespace: 'proj-1', name: 'pvc-base' }, time: new Date().toISOString(), + usedBytes: 90, availableBytes: 10, capacityBytes: 100, + }] }] }); + expect(await monitoringService.getPvcUsageFromApp('app-1', 'proj-1')).toEqual([ + expect.objectContaining({ pvcName: 'pvc-base', status: 'available', usedBytes: 90, availableBytes: 10, capacityBytes: 100 }), + ]); }); + }); diff --git a/src/server/services/node-summary-cache.service.ts b/src/server/services/node-summary-cache.service.ts new file mode 100644 index 00000000..a6562ee4 --- /dev/null +++ b/src/server/services/node-summary-cache.service.ts @@ -0,0 +1,18 @@ +import { unstable_cache } from 'next/cache'; +import k3s from '../adapter/kubernetes-api.adapter'; + +const cachedSummary = unstable_cache( + (nodeName: string) => k3s.getNodeSummary(nodeName), + ['node-summary'], + { revalidate: 20 }, +); +const inFlight = new Map>(); + +export function getCachedNodeSummary(nodeName: string): Promise { + const existing = inFlight.get(nodeName); + if (existing) return existing; + + const request = cachedSummary(nodeName).finally(() => inFlight.delete(nodeName)); + inFlight.set(nodeName, request); + return request; +} diff --git a/src/server/services/node-summary-cache.service.unit.spec.ts b/src/server/services/node-summary-cache.service.unit.spec.ts new file mode 100644 index 00000000..d659403e --- /dev/null +++ b/src/server/services/node-summary-cache.service.unit.spec.ts @@ -0,0 +1,19 @@ +vi.mock('next/cache', () => ({ unstable_cache: (fn: unknown) => fn })); +vi.mock('../adapter/kubernetes-api.adapter', () => ({ default: { getNodeSummary: vi.fn() } })); + +import k3s from '../adapter/kubernetes-api.adapter'; +import { getCachedNodeSummary } from './node-summary-cache.service'; + +it('deduplicates concurrent requests per node and retries failures', async () => { + let rejectRequest!: (error: Error) => void; + vi.mocked(k3s.getNodeSummary).mockImplementationOnce(() => new Promise((_, reject) => { rejectRequest = reject; })); + const first = getCachedNodeSummary('node-a'); + const second = getCachedNodeSummary('node-a'); + expect(first).toBe(second); + expect(k3s.getNodeSummary).toHaveBeenCalledTimes(1); + rejectRequest(new Error('failed')); + await expect(first).rejects.toThrow('failed'); + vi.mocked(k3s.getNodeSummary).mockResolvedValueOnce({ node: {} }); + await expect(getCachedNodeSummary('node-a')).resolves.toEqual({ node: {} }); + expect(k3s.getNodeSummary).toHaveBeenCalledTimes(2); +}); diff --git a/src/server/services/standalone-services/app-history.service.ts b/src/server/services/standalone-services/app-history.service.ts new file mode 100644 index 00000000..dee94b65 --- /dev/null +++ b/src/server/services/standalone-services/app-history.service.ts @@ -0,0 +1,65 @@ +import { quantityToScalar } from '@kubernetes/client-node/dist/util.js'; +import metricsAdapter from '@/server/adapter/app-history-metrics.adapter'; + +type App = { id: string; projectId: string }; +type Measurement = { cpuCores: number | null; ramBytes: number | null; measuredAt: Date | null }; + +class AppHistoryService { + async getAppMetrics(apps: App[]): Promise> { + const [pods, metrics] = await Promise.all([metricsAdapter.getPods(), metricsAdapter.getMetrics()]); + const metricsByPod = new Map(metrics.map(metric => [`${metric.metadata.namespace}/${metric.metadata.name}`, metric])); + const podsByApp = new Map(); + for (const pod of pods) { + const appId = pod.metadata?.labels?.app; + const namespace = pod.metadata?.namespace; + if (!appId || !namespace || ['Succeeded', 'Failed'].includes(pod.status?.phase ?? '')) continue; + const key = `${namespace}/${appId}`; + const group = podsByApp.get(key) ?? []; + group.push(pod); + podsByApp.set(key, group); + } + const result = new Map(); + for (const app of apps) { + const appPods = podsByApp.get(`${app.projectId}/${app.id}`) ?? []; + let cpu = 0; + let ram = 0; + let cpuComplete = appPods.length > 0; + let ramComplete = appPods.length > 0; + let oldest = Infinity; + for (const pod of appPods) { + const metric = metricsByPod.get(`${app.projectId}/${pod.metadata?.name}`); + const timestamp = Date.parse(metric?.timestamp ?? ''); + if (!metric || !Number.isFinite(timestamp) || Date.now() - timestamp > 5 * 60_000 || timestamp - Date.now() > 60_000) { + cpuComplete = false; + ramComplete = false; + continue; + } + oldest = Math.min(oldest, timestamp); + if (!pod.spec?.containers?.length) { + cpuComplete = false; + ramComplete = false; + continue; + } + for (const container of pod.spec.containers) { + const usage = metric.containers.find(item => item.name === container.name)?.usage; + for (const kind of ['cpu', 'memory'] as const) { + let value = NaN; + try { + if (usage?.[kind]) value = Number(quantityToScalar(usage[kind])); + } catch { /* Invalid quantities are gaps. */ } + if (!Number.isFinite(value) || value < 0) { + if (kind === 'cpu') cpuComplete = false; + else ramComplete = false; + } else if (kind === 'cpu') cpu += value; + else ram += value; + } + } + } + result.set(app.id, { cpuCores: cpuComplete ? cpu : null, ramBytes: ramComplete ? ram : null, measuredAt: (cpuComplete || ramComplete) && Number.isFinite(oldest) ? new Date(oldest) : null }); + } + return result; + } +} + +const appHistoryService = new AppHistoryService(); +export default appHistoryService; diff --git a/src/server/services/standalone-services/app-history.service.unit.spec.ts b/src/server/services/standalone-services/app-history.service.unit.spec.ts new file mode 100644 index 00000000..70674ed9 --- /dev/null +++ b/src/server/services/standalone-services/app-history.service.unit.spec.ts @@ -0,0 +1,44 @@ +vi.mock('@/server/adapter/db.client', () => ({ default: { client: {} } })); + +import appHistoryMetricsService from './app-history.service'; + +vi.mock('@/server/adapter/app-history-metrics.adapter', () => ({ + default: { getPods: vi.fn(), getMetrics: vi.fn() }, +})); + +import metricsAdapter from '@/server/adapter/app-history-metrics.adapter'; + +it('marks an App incomplete when a running container has no metrics', async () => { + vi.mocked(metricsAdapter.getPods).mockResolvedValue([ + { metadata: { name: 'pod', namespace: 'project', labels: { app: 'app' } }, status: { phase: 'Running' }, spec: { containers: [{ name: 'one' }, { name: 'two' }] } }, + ] as never); + vi.mocked(metricsAdapter.getMetrics).mockResolvedValue([ + { metadata: { name: 'pod', namespace: 'project' }, timestamp: new Date().toISOString(), containers: [{ name: 'one', usage: { cpu: '250m', memory: '1Mi' } }] }, + ] as never); + + const result = await appHistoryMetricsService.getAppMetrics([{ id: 'app', projectId: 'project' }]); + expect(result.get('app')).toMatchObject({ cpuCores: null, ramBytes: null }); +}); + +it('matches pod metrics by namespace and sums complete containers', async () => { + vi.mocked(metricsAdapter.getPods).mockResolvedValue([ + { metadata: { name: 'pod', namespace: 'project', labels: { app: 'app' } }, status: { phase: 'Running' }, spec: { containers: [{ name: 'one' }] } }, + ] as never); + vi.mocked(metricsAdapter.getMetrics).mockResolvedValue([ + { metadata: { name: 'pod', namespace: 'other' }, timestamp: new Date().toISOString(), containers: [{ name: 'one', usage: { cpu: '9', memory: '9Mi' } }] }, + { metadata: { name: 'pod', namespace: 'project' }, timestamp: new Date().toISOString(), containers: [{ name: 'one', usage: { cpu: '250m', memory: '1Mi' } }] }, + ] as never); + const result = await appHistoryMetricsService.getAppMetrics([{ id: 'app', projectId: 'project' }]); + expect(result.get('app')).toMatchObject({ cpuCores: 0.25, ramBytes: 1048576 }); +}); + +it('preserves RAM when CPU measurement is missing', async () => { + vi.mocked(metricsAdapter.getPods).mockResolvedValue([ + { metadata: { name: 'pod', namespace: 'project', labels: { app: 'app' } }, status: { phase: 'Running' }, spec: { containers: [{ name: 'one' }] } }, + ] as never); + vi.mocked(metricsAdapter.getMetrics).mockResolvedValue([ + { metadata: { name: 'pod', namespace: 'project' }, timestamp: new Date().toISOString(), containers: [{ name: 'one', usage: { memory: '1Mi' } }] }, + ] as never); + const result = await appHistoryMetricsService.getAppMetrics([{ id: 'app', projectId: 'project' }]); + expect(result.get('app')).toMatchObject({ cpuCores: null, ramBytes: 1048576 }); +}); diff --git a/src/server/services/standalone-services/app-monitoring-history-query.service.unit.spec.ts b/src/server/services/standalone-services/app-monitoring-history-query.service.unit.spec.ts new file mode 100644 index 00000000..0ea61ced --- /dev/null +++ b/src/server/services/standalone-services/app-monitoring-history-query.service.unit.spec.ts @@ -0,0 +1,43 @@ +vi.mock('@/server/adapter/app-history.adapter', () => ({ + default: { getRetentionHours: vi.fn().mockResolvedValue(24), getHistory: vi.fn() }, +})); +vi.mock('@/server/adapter/kubernetes-api.adapter', () => ({ default: {} })); +import adapter from '@/server/adapter/app-history.adapter'; +import historyService from '@/server/services/standalone-services/app-monitoring-history.service'; + +it('shows incomplete App totals as gaps and counts shared volumes once', async () => { + const now = new Date(); + const app = (appId: string, cpuCores: number | null) => ({ id: appId, appId, sampledAt: now, cpuCores, ramBytes: cpuCores === null ? null : 100, measuredAt: now }); + const volume = (id: string, sharedVolumeId: string | null, status: string, usedBytes: number | null) => ({ id, volumeId: id, sampledAt: now, status, usedBytes, availableBytes: null, capacityBytes: null, measuredAt: now, volume: { appId: id, sharedVolumeId, containerMountPath: '/data', app: { projectId: 'project' } } }); + vi.mocked(adapter.getHistory).mockResolvedValue([ + [app('one', 1), app('two', null)], + [volume('base', null, 'available', 20), volume('shared', 'base', 'available', 20), volume('missing', null, 'unavailable', null)], + ]); + const history = await historyService.getHistory(['one', 'two']); + expect(history.retentionHours).toBe(24); + expect(history.points[0]).toMatchObject({ cpuCores: null, ramBytes: null, storageBytes: 20, availableVolumes: 1, unavailableVolumes: 1 }); +}); + +it('inserts a gap across collection downtime', async () => { + const time = Date.now(); + const sample = (offset: number) => ({ id: String(offset), appId: 'app', sampledAt: new Date(time + offset), cpuCores: 1, ramBytes: 100, measuredAt: new Date(time + offset) }); + vi.mocked(adapter.getHistory).mockResolvedValue([[sample(0), sample(15 * 60_000)], []]); + const result = await historyService.getHistory(['app']); + expect(result.points.map(point => point.cpuCores)).toEqual([1, null, 1]); +}); + +it('shows a trailing gap while collection remains stopped', async () => { + const now = Date.now(); + const sampledAt = new Date(now - 30 * 60_000); + vi.mocked(adapter.getHistory).mockResolvedValue([[{ + id: 'one', appId: 'app', sampledAt, cpuCores: 1, ramBytes: 100, measuredAt: sampledAt, + }], [{ + id: 'volume-sample', volumeId: 'volume', sampledAt, status: 'available', usedBytes: 42, + availableBytes: 58, capacityBytes: 100, measuredAt: sampledAt, + volume: { appId: 'app', sharedVolumeId: null, containerMountPath: '/data', app: { projectId: 'project' } }, + }]]); + const result = await historyService.getHistory(['app']); + expect(result.points.at(-1)).toMatchObject({ time: expect.any(Number), cpuCores: null, ramBytes: null, storageBytes: null, isGap: true }); + expect(result.points.at(-1)!.time).toBeGreaterThanOrEqual(now); + expect(result.volumes[0].points.at(-1)).toMatchObject({ usedBytes: null }); +}); diff --git a/src/server/services/standalone-services/app-monitoring-history.service.ts b/src/server/services/standalone-services/app-monitoring-history.service.ts new file mode 100644 index 00000000..690bf991 --- /dev/null +++ b/src/server/services/standalone-services/app-monitoring-history.service.ts @@ -0,0 +1,179 @@ +import { randomUUID } from 'crypto'; +import { Prisma } from '@prisma/client'; +import appHistoryService from './app-history.service'; +import appHistoryAdapter from '@/server/adapter/app-history.adapter'; +import { AppHistoryPoint, AppMonitoringHistory, AppVolumeHistory } from '@/shared/model/app-monitoring-history.model'; +import { AppVolumeIdentityUtils } from '@/shared/utils/app-volume-identity.utils'; +import appVolumeFilesystemService from './app-volume-filesystem.service'; +import scheduleService from './schedule.service'; + +type VolumeSample = Prisma.AppVolumeMonitoringSampleCreateManyInput & { id: string }; + +const COLLECTION_INTERVAL_MINUTES = 5; +const COLLECTION_INTERVAL_MS = COLLECTION_INTERVAL_MINUTES * 60_000; +const GAP_THRESHOLD_MS = COLLECTION_INTERVAL_MS + 2 * 60_000; + +class AppMonitoringHistoryService { + private running = false; + + initialize() { + scheduleService.scheduleJob('app-monitoring-history', `*/${COLLECTION_INTERVAL_MINUTES} * * * *`, () => { + void this.collect(); + }); + void this.collect(); + } + + async getHistory(appIds: string[]): Promise { + const retention = await appHistoryAdapter.getRetentionHours(); + const [apps, volumes] = await appHistoryAdapter.getHistory(appIds, new Date(Date.now() - retention * 3600_000)); + const appsByTime = new Map(); + const volumesByTime = new Map(); + for (const item of apps) { + const time = item.sampledAt.getTime(); + const group = appsByTime.get(time) ?? []; + group.push(item); + appsByTime.set(time, group); + } + for (const item of volumes) { + const time = item.sampledAt.getTime(); + const group = volumesByTime.get(time) ?? []; + group.push(item); + volumesByTime.set(time, group); + } + const times = new Set([...appsByTime.keys(), ...volumesByTime.keys()]); + const points: AppHistoryPoint[] = []; + let latestCpuMeasurement: number | null = null; + let latestRamMeasurement: number | null = null; + let latestVolumeMeasurement: number | null = null; + const volumeSeries = new Map(); + for (const time of [...times].sort((a, b) => a - b)) { + if (points.length && time - points[points.length - 1].time > GAP_THRESHOLD_MS) { + points.push({ time: points[points.length - 1].time + COLLECTION_INTERVAL_MS, isGap: true, cpuCores: null, ramBytes: null, storageBytes: null, availableVolumes: 0, unavailableVolumes: 0 }); + } + const currentApps = appsByTime.get(time) ?? []; + const currentVolumes = volumesByTime.get(time) ?? []; + const uniqueVolumes = new Map(); + for (const item of currentVolumes) { + const key = AppVolumeIdentityUtils.key(item.volume.app.projectId, item.volumeId, item.volume.sharedVolumeId); + if (!uniqueVolumes.has(key) || item.status === 'available') uniqueVolumes.set(key, item); + let series = volumeSeries.get(item.volumeId); + if (!series) { + series = { id: item.volumeId, label: item.volume.containerMountPath, points: [] }; + volumeSeries.set(item.volumeId, series); + } + if (series.points.length && time - series.points[series.points.length - 1].time > GAP_THRESHOLD_MS) { + series.points.push({ time: series.points[series.points.length - 1].time + COLLECTION_INTERVAL_MS, usedBytes: null, measuredAt: null }); + } + series.points.push({ time, usedBytes: item.status === 'available' ? item.usedBytes : null, measuredAt: item.measuredAt?.getTime() ?? null }); + } + if (currentApps.length && currentApps.every(item => item.cpuCores !== null && item.measuredAt)) { + latestCpuMeasurement = Math.max(latestCpuMeasurement ?? 0, Math.min(...currentApps.map(item => item.measuredAt!.getTime()))); + } + if (currentApps.length && currentApps.every(item => item.ramBytes !== null && item.measuredAt)) { + latestRamMeasurement = Math.max(latestRamMeasurement ?? 0, Math.min(...currentApps.map(item => item.measuredAt!.getTime()))); + } + const available = [...uniqueVolumes.values()].filter(item => item.status === 'available' && item.usedBytes !== null); + if (available.length && available.every(item => item.measuredAt)) { + latestVolumeMeasurement = Math.max(latestVolumeMeasurement ?? 0, Math.min(...available.map(item => item.measuredAt!.getTime()))); + } + points.push({ + time, + cpuCores: currentApps.length && currentApps.every(item => item.cpuCores !== null) ? currentApps.reduce((sum, item) => sum + item.cpuCores!, 0) : null, + ramBytes: currentApps.length && currentApps.every(item => item.ramBytes !== null) ? currentApps.reduce((sum, item) => sum + item.ramBytes!, 0) : null, + storageBytes: available.length ? available.reduce((sum, item) => sum + item.usedBytes!, 0) : null, + availableVolumes: available.length, + unavailableVolumes: uniqueVolumes.size - available.length, + }); + } + const now = Date.now(); + const lastPoint = points.at(-1); + if (lastPoint && now - lastPoint.time > GAP_THRESHOLD_MS) { + points.push({ + time: now, + isGap: true, + cpuCores: null, + ramBytes: null, + storageBytes: null, + availableVolumes: 0, + unavailableVolumes: 0, + }); + } + for (const volume of volumeSeries.values()) { + const lastSample = volume.points.at(-1); + if (lastSample && now - lastSample.time > GAP_THRESHOLD_MS) { + volume.points.push({ time: now, usedBytes: null, measuredAt: null }); + } + } + return { retentionHours: retention, points, volumes: [...volumeSeries.values()], latestCpuMeasurement, latestRamMeasurement, latestVolumeMeasurement }; + } + + async getRetentionHours() { + return appHistoryAdapter.getRetentionHours(); + } + + async setRetentionHours(hours: number) { + await appHistoryAdapter.setRetentionHours(hours); + } + + async collect() { + if (this.running) return; + this.running = true; + try { + const sampledAt = new Date(); + const { apps, volumes } = await appHistoryAdapter.getPopulation(); + const [metrics, filesystem, retention] = await Promise.all([ + appHistoryService.getAppMetrics(apps).catch(error => { + console.error('App metrics collection failed', error); + return new Map(); + }), + appVolumeFilesystemService.getVolumeFilesystemUsage( + volumes.map(volume => ({ ...volume, projectId: volume.app.projectId })), + ).catch(error => { + console.error('Volume metrics collection failed', error); + return new Map(); + }), + appHistoryAdapter.getRetentionHours(), + ]); + const samples = new Map(); + const memberships: Prisma.AppVolumeMonitoringMembershipCreateManyInput[] = []; + for (const volume of volumes) { + const underlyingId = AppVolumeIdentityUtils.underlyingId(volume.id, volume.sharedVolumeId); + const identity = AppVolumeIdentityUtils.key(volume.app.projectId, volume.id, volume.sharedVolumeId); + let sample = samples.get(identity); + if (!sample) { + const usage = filesystem.get(volume.id); + sample = { + id: randomUUID(), + volumeId: underlyingId, + sampledAt, + status: usage?.status ?? 'unavailable', + usedBytes: usage?.usedBytes ?? null, + availableBytes: usage?.availableBytes ?? null, + capacityBytes: usage?.capacityBytes ?? null, + measuredAt: usage?.measuredAt ? new Date(usage.measuredAt) : null, + }; + samples.set(identity, sample); + } + memberships.push({ sampleId: sample.id, volumeId: volume.id }); + } + await appHistoryAdapter.save( + apps.map(app => ({ + appId: app.id, + sampledAt, + cpuCores: metrics.get(app.id)?.cpuCores ?? null, + ramBytes: metrics.get(app.id)?.ramBytes ?? null, + measuredAt: metrics.get(app.id)?.measuredAt ?? null, + })), + [...samples.values()], + memberships, + new Date(sampledAt.getTime() - retention * 60 * 60 * 1000), + ); + } catch (error) { + console.error('App monitoring history collection failed', error); + } finally { + this.running = false; + } + } +} +const appMonitoringHistoryService = new AppMonitoringHistoryService(); +export default appMonitoringHistoryService; diff --git a/src/server/services/standalone-services/app-monitoring-history.service.unit.spec.ts b/src/server/services/standalone-services/app-monitoring-history.service.unit.spec.ts new file mode 100644 index 00000000..f6ad3e00 --- /dev/null +++ b/src/server/services/standalone-services/app-monitoring-history.service.unit.spec.ts @@ -0,0 +1,64 @@ +vi.mock('@/server/adapter/app-history.adapter', () => ({ default: { getPopulation: vi.fn(), getRetentionHours: vi.fn(), save: vi.fn() } })); +vi.mock('./app-history.service', () => ({ default: { getAppMetrics: vi.fn() } })); +vi.mock('./app-volume-filesystem.service', () => ({ default: { getVolumeFilesystemUsage: vi.fn() } })); +vi.mock('./schedule.service', () => ({ default: { scheduleJob: vi.fn() } })); +import history from './app-monitoring-history.service'; +import adapter from '@/server/adapter/app-history.adapter'; +import metrics from './app-history.service'; +import filesystem from './app-volume-filesystem.service'; + +it('records a gap for failed App metrics while preserving successful volume data', async () => { + vi.mocked(adapter.getPopulation).mockResolvedValue({ apps: [{ id: 'app', projectId: 'p' }], volumes: [{ id: 'v', appId: 'app', sharedVolumeId: null, app: { projectId: 'p' } }] } as never); + vi.mocked(adapter.getRetentionHours).mockResolvedValue(24); + vi.mocked(metrics.getAppMetrics).mockRejectedValue(new Error('metrics unavailable')); + vi.mocked(filesystem.getVolumeFilesystemUsage).mockResolvedValue(new Map([['v', { pvcName: 'pvc-v', status: 'available', usedBytes: 4 }]])); + await history.collect(); + expect(adapter.save).toHaveBeenCalledWith( + [expect.objectContaining({ appId: 'app', cpuCores: null, ramBytes: null })], + [expect.objectContaining({ volumeId: 'v', status: 'available', usedBytes: 4 })], + [expect.objectContaining({ volumeId: 'v' })], + expect.any(Date), + ); +}); + +it('does not start a second collection while the first is running', async () => { + vi.clearAllMocks(); + let release!: (value: { apps: never[]; volumes: never[] }) => void; + vi.mocked(adapter.getPopulation).mockReturnValue(new Promise(resolve => { release = resolve; })); + vi.mocked(adapter.getRetentionHours).mockResolvedValue(24); + vi.mocked(metrics.getAppMetrics).mockResolvedValue(new Map()); + vi.mocked(filesystem.getVolumeFilesystemUsage).mockResolvedValue(new Map()); + const first = history.collect(); + await history.collect(); + expect(adapter.getPopulation).toHaveBeenCalledTimes(1); + release({ apps: [], volumes: [] }); + await first; +}); + + +it('stores one filesystem measurement for a shared Volume with a link for each mount', async () => { + vi.clearAllMocks(); + vi.mocked(adapter.getPopulation).mockResolvedValue({ apps: [], volumes: [ + { id: 'v', appId: 'app', sharedVolumeId: null, app: { projectId: 'p' } }, + { id: 'shared', appId: 'other', sharedVolumeId: 'v', app: { projectId: 'p' } }, + ] } as never); + vi.mocked(adapter.getRetentionHours).mockResolvedValue(24); + vi.mocked(metrics.getAppMetrics).mockResolvedValue(new Map()); + vi.mocked(filesystem.getVolumeFilesystemUsage).mockResolvedValue(new Map([['v', { pvcName: 'pvc-v', status: 'available', usedBytes: 4 }]])); + await history.collect(); + const [, samples, memberships] = vi.mocked(adapter.save).mock.calls[0]; + expect(samples).toHaveLength(1); + expect(memberships).toHaveLength(2); +}); + +it('collects promptly on startup and resumes collection after a restart', async () => { + vi.clearAllMocks(); + vi.mocked(adapter.getPopulation).mockResolvedValue({ apps: [], volumes: [] }); + vi.mocked(adapter.getRetentionHours).mockResolvedValue(24); + vi.mocked(metrics.getAppMetrics).mockResolvedValue(new Map()); + vi.mocked(filesystem.getVolumeFilesystemUsage).mockResolvedValue(new Map()); + history.initialize(); + await vi.waitFor(() => expect(adapter.save).toHaveBeenCalledTimes(1)); + history.initialize(); + await vi.waitFor(() => expect(adapter.save).toHaveBeenCalledTimes(2)); +}); diff --git a/src/server/services/standalone-services/app-volume-filesystem.service.ts b/src/server/services/standalone-services/app-volume-filesystem.service.ts new file mode 100644 index 00000000..0e3d5f30 --- /dev/null +++ b/src/server/services/standalone-services/app-volume-filesystem.service.ts @@ -0,0 +1,78 @@ +import { V1PersistentVolumeClaim } from '@kubernetes/client-node'; +import volumeSummaryAdapter from '@/server/adapter/volume-summary.adapter'; +import { AppVolumeIdentityUtils } from '@/shared/utils/app-volume-identity.utils'; +import { VolumeFilesystemUsage } from '@/shared/model/app-volume-monitoring-usage.model'; +import { KubeObjectNameUtils } from '@/server/utils/kube-object-name.utils'; + +class AppVolumeFilesystemService { + async getVolumeFilesystemUsage( + volumes: { id: string; sharedVolumeId: string | null; projectId: string }[], + getClaims: () => Promise = () => volumeSummaryAdapter.getPersistentVolumeClaims(), + getNodeSummary?: (nodeName: string) => Promise, + ): Promise> { + const [pvcs, persistentVolumes, samples] = await Promise.all([ + getClaims().catch(() => []), + volumeSummaryAdapter.getPersistentVolumes().catch(() => []), + volumeSummaryAdapter.getSamples(getNodeSummary).catch(() => []), + ]); + const pvcByKey = new Map(pvcs.map(pvc => [`${pvc.metadata?.namespace}/${pvc.metadata?.name}`, pvc])); + const pvByName = new Map(persistentVolumes.map(pv => [pv.metadata?.name, pv])); + const result = new Map(); + const observations = new Map(); + const now = Date.now(); + const latestSampleByKey = new Map(); + for (const sample of samples) { + const namespace = sample.pvcRef?.namespace; + const name = sample.pvcRef?.name; + const timestamp = Date.parse(sample.time ?? ''); + const values = [sample.usedBytes, sample.availableBytes, sample.capacityBytes]; + if (!namespace || !name || !Number.isFinite(timestamp) || !sample.capacityBytes || + !values.every(value => typeof value === 'number' && Number.isFinite(value) && value >= 0)) continue; + const key = AppVolumeIdentityUtils.observationKey(namespace, name); + const previous = latestSampleByKey.get(key); + if (!previous || timestamp > Date.parse(previous.time!)) latestSampleByKey.set(key, sample); + } + for (const volume of volumes) { + const pvcName = KubeObjectNameUtils.toPvcName(AppVolumeIdentityUtils.underlyingId(volume.id, volume.sharedVolumeId)); + const key = AppVolumeIdentityUtils.observationKey(volume.projectId, pvcName); + const existing = observations.get(key); + if (existing) { + result.set(volume.id, existing); + continue; + } + const pvc = pvcByKey.get(key); + const pv = pvc?.spec?.volumeName ? pvByName.get(pvc.spec.volumeName) : undefined; + if (!pv) { + observations.set(key, { pvcName, status: 'unavailable' }); + result.set(volume.id, observations.get(key)!); + continue; + } + if (pv.spec?.csi?.driver !== 'driver.longhorn.io') { + observations.set(key, { pvcName, status: 'unsupported' }); + result.set(volume.id, observations.get(key)!); + continue; + } + const sample = latestSampleByKey.get(key); + const age = sample ? now - Date.parse(sample.time!) : Infinity; + if (!sample || age > 5 * 60 * 1000 || age < -60 * 1000) { + observations.set(key, { pvcName, status: sample ? 'stale' : 'unavailable' }); + result.set(volume.id, observations.get(key)!); + continue; + } + observations.set(key, { + pvcName, + status: 'available', + usedBytes: sample.usedBytes, + availableBytes: sample.availableBytes, + capacityBytes: sample.capacityBytes, + measuredAt: sample.time, + }); + result.set(volume.id, observations.get(key)!); + } + return result; + } + +} + +const appVolumeFilesystemService = new AppVolumeFilesystemService(); +export default appVolumeFilesystemService; diff --git a/src/server/services/standalone-services/app-volume-filesystem.service.unit.spec.ts b/src/server/services/standalone-services/app-volume-filesystem.service.unit.spec.ts new file mode 100644 index 00000000..4c4467c8 --- /dev/null +++ b/src/server/services/standalone-services/app-volume-filesystem.service.unit.spec.ts @@ -0,0 +1,32 @@ +vi.mock('@/server/adapter/volume-summary.adapter', () => ({ + default: { getPersistentVolumeClaims: vi.fn(), getPersistentVolumes: vi.fn(), getSamples: vi.fn() }, +})); + +import adapter from '@/server/adapter/volume-summary.adapter'; +import filesystem from './app-volume-filesystem.service'; + +it('associates shared mounts with their project observation and preserves missing and stale status', async () => { + const now = Date.now(); + vi.mocked(adapter.getPersistentVolumeClaims).mockResolvedValue([ + { metadata: { namespace: 'one', name: 'pvc-base' }, spec: { volumeName: 'pv-one' } }, + { metadata: { namespace: 'two', name: 'pvc-base' }, spec: { volumeName: 'pv-two' } }, + ] as never); + vi.mocked(adapter.getPersistentVolumes).mockResolvedValue([ + { metadata: { name: 'pv-one' }, spec: { csi: { driver: 'driver.longhorn.io' } } }, + { metadata: { name: 'pv-two' }, spec: { csi: { driver: 'driver.longhorn.io' } } }, + ] as never); + vi.mocked(adapter.getSamples).mockResolvedValue([ + { pvcRef: { namespace: 'one', name: 'pvc-base' }, time: new Date(now).toISOString(), usedBytes: 4, availableBytes: 6, capacityBytes: 10 }, + { pvcRef: { namespace: 'two', name: 'pvc-base' }, time: new Date(now - 600_000).toISOString(), usedBytes: 8, availableBytes: 2, capacityBytes: 10 }, + ]); + const result = await filesystem.getVolumeFilesystemUsage([ + { id: 'base', sharedVolumeId: null, projectId: 'one' }, + { id: 'shared', sharedVolumeId: 'base', projectId: 'one' }, + { id: 'other', sharedVolumeId: 'base', projectId: 'two' }, + { id: 'missing', sharedVolumeId: null, projectId: 'one' }, + ]); + expect(result.get('base')).toMatchObject({ status: 'available', usedBytes: 4 }); + expect(result.get('shared')).toEqual(result.get('base')); + expect(result.get('other')).toMatchObject({ status: 'stale' }); + expect(result.get('missing')).toMatchObject({ status: 'unavailable' }); +}); diff --git a/src/shared/model/app-monitoring-history.model.ts b/src/shared/model/app-monitoring-history.model.ts new file mode 100644 index 00000000..e03936b3 --- /dev/null +++ b/src/shared/model/app-monitoring-history.model.ts @@ -0,0 +1,22 @@ +export interface AppHistoryPoint { + time: number; + isGap?: boolean; + cpuCores: number | null; + ramBytes: number | null; + storageBytes: number | null; + availableVolumes: number; + unavailableVolumes: number; +} +export interface AppVolumeHistory { + id: string; + label: string; + points: { time: number; usedBytes: number | null; measuredAt: number | null }[]; +} +export interface AppMonitoringHistory { + retentionHours: number; + points: AppHistoryPoint[]; + volumes: AppVolumeHistory[]; + latestCpuMeasurement: number | null; + latestRamMeasurement: number | null; + latestVolumeMeasurement: number | null; +} diff --git a/src/shared/model/app-volume-monitoring-usage.model.ts b/src/shared/model/app-volume-monitoring-usage.model.ts index 418036df..738a24f7 100644 --- a/src/shared/model/app-volume-monitoring-usage.model.ts +++ b/src/shared/model/app-volume-monitoring-usage.model.ts @@ -1,10 +1,19 @@ -export interface AppVolumeMonitoringUsageModel { - projectId: string, - projectName: string, - appName: string, - appId: string, - mountPath: string, - usedBytes: number, - capacityBytes: number, - isBaseVolume: boolean +export type VolumeUsageStatus = 'available' | 'unavailable' | 'stale' | 'unsupported'; + +export interface VolumeFilesystemUsage { + pvcName: string; + status: VolumeUsageStatus; + usedBytes?: number; + availableBytes?: number; + capacityBytes?: number; + measuredAt?: string; +} + +export interface AppVolumeMonitoringUsageModel extends VolumeFilesystemUsage { + projectId: string; + projectName: string; + appName: string; + appId: string; + mountPath: string; + isBaseVolume: boolean; } diff --git a/src/shared/model/generated-zod/app.ts b/src/shared/model/generated-zod/app.ts index 07769ec4..ba408b16 100644 --- a/src/shared/model/generated-zod/app.ts +++ b/src/shared/model/generated-zod/app.ts @@ -1,6 +1,6 @@ import * as z from "zod" -import { CompleteProject, RelatedProjectModel, CompleteAppDomain, RelatedAppDomainModel, CompleteAppPort, RelatedAppPortModel, CompleteAppNodePort, RelatedAppNodePortModel, CompleteAppVolume, RelatedAppVolumeModel, CompleteAppFileMount, RelatedAppFileMountModel, CompleteAppBasicAuth, RelatedAppBasicAuthModel, CompleteAppGitSshKey, RelatedAppGitSshKeyModel, CompleteAppNetworkPolicy, RelatedAppNetworkPolicyModel, CompleteRoleAppPermission, RelatedRoleAppPermissionModel, CompleteAgentNetworkPolicyRule, RelatedAgentNetworkPolicyRuleModel, CompleteAppNetworkPolicyRule, RelatedAppNetworkPolicyRuleModel } from "./index" +import { CompleteProject, RelatedProjectModel, CompleteAppDomain, RelatedAppDomainModel, CompleteAppPort, RelatedAppPortModel, CompleteAppNodePort, RelatedAppNodePortModel, CompleteAppVolume, RelatedAppVolumeModel, CompleteAppMonitoringSample, RelatedAppMonitoringSampleModel, CompleteAppFileMount, RelatedAppFileMountModel, CompleteAppBasicAuth, RelatedAppBasicAuthModel, CompleteAppGitSshKey, RelatedAppGitSshKeyModel, CompleteAppNetworkPolicy, RelatedAppNetworkPolicyModel, CompleteRoleAppPermission, RelatedRoleAppPermissionModel, CompleteAgentNetworkPolicyRule, RelatedAgentNetworkPolicyRuleModel, CompleteAppNetworkPolicyRule, RelatedAppNetworkPolicyRuleModel } from "./index" export const AppModel = z.object({ id: z.string(), @@ -60,6 +60,7 @@ export interface CompleteApp extends z.infer { appPorts: CompleteAppPort[] appNodePorts: CompleteAppNodePort[] appVolumes: CompleteAppVolume[] + monitoringSamples: CompleteAppMonitoringSample[] appFileMounts: CompleteAppFileMount[] appBasicAuths: CompleteAppBasicAuth[] appGitSshKey?: CompleteAppGitSshKey | null @@ -80,6 +81,7 @@ export const RelatedAppModel: z.ZodSchema = z.lazy(() => AppModel.e appPorts: RelatedAppPortModel.array(), appNodePorts: RelatedAppNodePortModel.array(), appVolumes: RelatedAppVolumeModel.array(), + monitoringSamples: RelatedAppMonitoringSampleModel.array(), appFileMounts: RelatedAppFileMountModel.array(), appBasicAuths: RelatedAppBasicAuthModel.array(), appGitSshKey: RelatedAppGitSshKeyModel.nullish(), diff --git a/src/shared/model/generated-zod/appmonitoringsample.ts b/src/shared/model/generated-zod/appmonitoringsample.ts new file mode 100644 index 00000000..8fc9c140 --- /dev/null +++ b/src/shared/model/generated-zod/appmonitoringsample.ts @@ -0,0 +1,25 @@ +import * as z from "zod" + +import { CompleteApp, RelatedAppModel } from "./index" + +export const AppMonitoringSampleModel = z.object({ + id: z.string(), + sampledAt: z.date(), + appId: z.string(), + cpuCores: z.number().nullish(), + ramBytes: z.number().nullish(), + measuredAt: z.date().nullish(), +}) + +export interface CompleteAppMonitoringSample extends z.infer { + app: CompleteApp +} + +/** + * RelatedAppMonitoringSampleModel contains all relations on your model in addition to the scalars + * + * NOTE: Lazy required in case of potential circular dependencies within schema + */ +export const RelatedAppMonitoringSampleModel: z.ZodSchema = z.lazy(() => AppMonitoringSampleModel.extend({ + app: RelatedAppModel, +})) diff --git a/src/shared/model/generated-zod/appvolume.ts b/src/shared/model/generated-zod/appvolume.ts index 8255d96c..c0b401ca 100644 --- a/src/shared/model/generated-zod/appvolume.ts +++ b/src/shared/model/generated-zod/appvolume.ts @@ -1,6 +1,6 @@ import * as z from "zod" -import { CompleteApp, RelatedAppModel, CompleteVolumeBackup, RelatedVolumeBackupModel } from "./index" +import { CompleteApp, RelatedAppModel, CompleteVolumeBackup, RelatedVolumeBackupModel, CompleteAppVolumeMonitoringSample, RelatedAppVolumeMonitoringSampleModel, CompleteAppVolumeMonitoringMembership, RelatedAppVolumeMonitoringMembershipModel } from "./index" export const AppVolumeModel = z.object({ id: z.string(), @@ -18,6 +18,8 @@ export const AppVolumeModel = z.object({ export interface CompleteAppVolume extends z.infer { app: CompleteApp volumeBackups: CompleteVolumeBackup[] + monitoringSamples: CompleteAppVolumeMonitoringSample[] + monitoringMemberships: CompleteAppVolumeMonitoringMembership[] sharedVolume?: CompleteAppVolume | null sharedVolumes: CompleteAppVolume[] } @@ -30,6 +32,8 @@ export interface CompleteAppVolume extends z.infer { export const RelatedAppVolumeModel: z.ZodSchema = z.lazy(() => AppVolumeModel.extend({ app: RelatedAppModel, volumeBackups: RelatedVolumeBackupModel.array(), + monitoringSamples: RelatedAppVolumeMonitoringSampleModel.array(), + monitoringMemberships: RelatedAppVolumeMonitoringMembershipModel.array(), sharedVolume: RelatedAppVolumeModel.nullish(), sharedVolumes: RelatedAppVolumeModel.array(), })) diff --git a/src/shared/model/generated-zod/appvolumemonitoringmembership.ts b/src/shared/model/generated-zod/appvolumemonitoringmembership.ts new file mode 100644 index 00000000..a075f7ca --- /dev/null +++ b/src/shared/model/generated-zod/appvolumemonitoringmembership.ts @@ -0,0 +1,23 @@ +import * as z from "zod" + +import { CompleteAppVolumeMonitoringSample, RelatedAppVolumeMonitoringSampleModel, CompleteAppVolume, RelatedAppVolumeModel } from "./index" + +export const AppVolumeMonitoringMembershipModel = z.object({ + sampleId: z.string(), + volumeId: z.string(), +}) + +export interface CompleteAppVolumeMonitoringMembership extends z.infer { + sample: CompleteAppVolumeMonitoringSample + volume: CompleteAppVolume +} + +/** + * RelatedAppVolumeMonitoringMembershipModel contains all relations on your model in addition to the scalars + * + * NOTE: Lazy required in case of potential circular dependencies within schema + */ +export const RelatedAppVolumeMonitoringMembershipModel: z.ZodSchema = z.lazy(() => AppVolumeMonitoringMembershipModel.extend({ + sample: RelatedAppVolumeMonitoringSampleModel, + volume: RelatedAppVolumeModel, +})) diff --git a/src/shared/model/generated-zod/appvolumemonitoringsample.ts b/src/shared/model/generated-zod/appvolumemonitoringsample.ts new file mode 100644 index 00000000..b86a0336 --- /dev/null +++ b/src/shared/model/generated-zod/appvolumemonitoringsample.ts @@ -0,0 +1,29 @@ +import * as z from "zod" + +import { CompleteAppVolume, RelatedAppVolumeModel, CompleteAppVolumeMonitoringMembership, RelatedAppVolumeMonitoringMembershipModel } from "./index" + +export const AppVolumeMonitoringSampleModel = z.object({ + id: z.string(), + sampledAt: z.date(), + volumeId: z.string(), + status: z.string(), + usedBytes: z.number().nullish(), + availableBytes: z.number().nullish(), + capacityBytes: z.number().nullish(), + measuredAt: z.date().nullish(), +}) + +export interface CompleteAppVolumeMonitoringSample extends z.infer { + volume: CompleteAppVolume + memberships: CompleteAppVolumeMonitoringMembership[] +} + +/** + * RelatedAppVolumeMonitoringSampleModel contains all relations on your model in addition to the scalars + * + * NOTE: Lazy required in case of potential circular dependencies within schema + */ +export const RelatedAppVolumeMonitoringSampleModel: z.ZodSchema = z.lazy(() => AppVolumeMonitoringSampleModel.extend({ + volume: RelatedAppVolumeModel, + memberships: RelatedAppVolumeMonitoringMembershipModel.array(), +})) diff --git a/src/shared/model/generated-zod/index.ts b/src/shared/model/generated-zod/index.ts index 4f65c0da..9a157304 100644 --- a/src/shared/model/generated-zod/index.ts +++ b/src/shared/model/generated-zod/index.ts @@ -32,3 +32,6 @@ export * from "./parameter" export * from "./s3target" export * from "./volumebackup" export * from "./appbasicauth" +export * from "./appmonitoringsample" +export * from "./appvolumemonitoringsample" +export * from "./appvolumemonitoringmembership" diff --git a/src/shared/model/node-resource.model.ts b/src/shared/model/node-resource.model.ts index 11974619..c7fbc327 100644 --- a/src/shared/model/node-resource.model.ts +++ b/src/shared/model/node-resource.model.ts @@ -8,8 +8,7 @@ export const nodeResourceZodModel = z.object({ ramCapacity: z.number(), diskUsageAbsolut: z.number().optional(), diskUsageCapacity: z.number().optional(), - diskUsageReserved: z.number().optional(), - diskSpaceSchedulable: z.number().optional(), + diskUsageAvailable: z.number().optional(), }) export type NodeResourceModel = z.infer; \ No newline at end of file diff --git a/src/shared/utils/app-volume-identity.utils.ts b/src/shared/utils/app-volume-identity.utils.ts new file mode 100644 index 00000000..d3011fcf --- /dev/null +++ b/src/shared/utils/app-volume-identity.utils.ts @@ -0,0 +1,13 @@ +export class AppVolumeIdentityUtils { + static underlyingId(id: string, sharedVolumeId: string | null): string { + return sharedVolumeId || id; + } + + static key(projectId: string, id: string, sharedVolumeId: string | null): string { + return `${projectId}/${this.underlyingId(id, sharedVolumeId)}`; + } + + static observationKey(projectId: string, pvcName: string): string { + return `${projectId}/${pvcName}`; + } +} diff --git a/src/shared/utils/constants.ts b/src/shared/utils/constants.ts index b3ce1d6a..6df0a825 100644 --- a/src/shared/utils/constants.ts +++ b/src/shared/utils/constants.ts @@ -1,4 +1,5 @@ export class Constants { + static readonly APP_MONITORING_HISTORY_RETENTION_HOURS = 'APP_MONITORING_HISTORY_RETENTION_HOURS'; static readonly TERMINAL_MAX_SESSIONS_PER_SOCKET = 4; static readonly TERMINAL_MAX_OPENS_PER_MINUTE = 20; static readonly TERMINAL_MAX_INPUT_BYTES = 16384; diff --git a/src/shared/utils/kubernetes-size-converter.utils.ts b/src/shared/utils/kubernetes-size-converter.utils.ts index 716c0235..d65b0160 100644 --- a/src/shared/utils/kubernetes-size-converter.utils.ts +++ b/src/shared/utils/kubernetes-size-converter.utils.ts @@ -11,14 +11,14 @@ export class KubeSizeConverter { /** * Converts a size in bytes to a human-readable format. - * eg. 1024 -> 1 KB, 1024 * 1024 -> 1 MB, etc. + * eg. 1024 -> 1 KiB, 1024 * 1024 -> 1 MiB, etc. */ static convertBytesToReadableSize(bytes: number, fractionDigits = 2, hideSize = false): string { if (isNaN(bytes) || bytes === 0) { return '0 B'; } - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const sizes = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return parseFloat((bytes / Math.pow(1024, i)).toFixed(fractionDigits)) + (hideSize ? '' : (' ' + sizes[i])); diff --git a/src/shared/utils/kubernetes-size-converter.utils.unit.spec.ts b/src/shared/utils/kubernetes-size-converter.utils.unit.spec.ts index 549186bd..5443f976 100644 --- a/src/shared/utils/kubernetes-size-converter.utils.unit.spec.ts +++ b/src/shared/utils/kubernetes-size-converter.utils.unit.spec.ts @@ -4,10 +4,10 @@ describe(KubeSizeConverter.name, () => { describe('convertBytesToReadableSize', () => { it('should convert bytes to human-readable format', () => { - expect(KubeSizeConverter.convertBytesToReadableSize(1024)).toBe('1 KB'); - expect(KubeSizeConverter.convertBytesToReadableSize(1024 * 1024)).toBe('1 MB'); - expect(KubeSizeConverter.convertBytesToReadableSize(1024 * 1024 * 1024)).toBe('1 GB'); - expect(KubeSizeConverter.convertBytesToReadableSize(1500)).toBe('1.46 KB'); + expect(KubeSizeConverter.convertBytesToReadableSize(1024)).toBe('1 KiB'); + expect(KubeSizeConverter.convertBytesToReadableSize(1024 * 1024)).toBe('1 MiB'); + expect(KubeSizeConverter.convertBytesToReadableSize(1024 * 1024 * 1024)).toBe('1 GiB'); + expect(KubeSizeConverter.convertBytesToReadableSize(1500)).toBe('1.46 KiB'); }); it('should handle zero bytes', () => {