From bae22cddccc4e19f8307e793e6ad3c2fb9967f66 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Mon, 9 Nov 2026 07:54:04 +0000 Subject: [PATCH 1/2] chore: configure prettier and format monitoring changes --- .prettierignore | 1 + .prettierrc.json | 7 +- package.json | 5 +- ...app-monitoring-history.integration.spec.ts | 190 +++--- src/app/monitoring/actions.ts | 58 +- src/app/monitoring/app-volumes-monitoring.tsx | 170 +++-- .../app-volumes-monitoring.unit.spec.ts | 36 +- src/app/monitoring/disk-chart.tsx | 30 +- src/app/monitoring/monitoring-client.tsx | 75 ++- src/app/monitoring/monitoring-nodes.tsx | 256 ++++---- src/app/monitoring/page.tsx | 70 +- .../volumes/agent-volume-edit-overlay.tsx | 421 ++++++------ .../details/volumes/agent-volumes-card.tsx | 203 +++--- .../apps/details/app-details-drawer.tsx | 481 ++++++-------- .../apps/details/deployments/actions.ts | 81 ++- .../app-details-deployments-tab.tsx | 90 ++- .../deployments/history-actions.unit.spec.ts | 39 +- .../deployments/webhook-deployment.tsx | 102 +-- .../apps/details/runtime/monitoring-app.tsx | 71 +- .../apps/details/volumes/actions.ts | 356 +++++----- .../backups/app-details-backups-tab.tsx | 343 +++++----- .../volumes/shared-storage-edit-overlay.tsx | 282 ++++---- .../details/volumes/storage-edit-overlay.tsx | 437 ++++++------- .../apps/details/volumes/volumes-list.tsx | 619 +++++++++--------- .../settings/platform/operations/actions.ts | 10 +- .../monitoring-history-settings.tsx | 122 ++-- src/app/settings/platform/operations/page.tsx | 28 +- .../custom/app-monitoring-history.tsx | 369 ++++++----- .../app-monitoring-history.unit.spec.ts | 103 +-- src/frontend/utils/format.utils.ts | 74 +-- src/server.ts | 144 ++-- .../adapter/app-history-metrics.adapter.ts | 12 +- src/server/adapter/app-history.adapter.ts | 87 +-- src/server/adapter/kubernetes-api.adapter.ts | 326 ++++----- src/server/adapter/longhorn-api.adapter.ts | 164 ++--- src/server/adapter/volume-summary.adapter.ts | 44 +- .../agent-template.service.unit.spec.ts | 463 ++++++------- src/server/services/cluster.service.ts | 330 +++++----- .../services/cluster.service.unit.spec.ts | 84 +-- src/server/services/monitoring.service.ts | 304 +++++---- .../services/monitoring.service.unit.spec.ts | 380 ++++++----- .../services/node-summary-cache.service.ts | 16 +- .../node-summary-cache.service.unit.spec.ts | 27 +- .../app-history.service.ts | 106 +-- .../app-history.service.unit.spec.ts | 66 +- ...itoring-history-query.service.unit.spec.ts | 93 ++- .../app-monitoring-history.service.ts | 298 +++++---- ...pp-monitoring-history.service.unit.spec.ts | 97 +-- .../app-volume-filesystem.service.ts | 137 ++-- ...app-volume-filesystem.service.unit.spec.ts | 48 +- .../model/app-monitoring-history.model.ts | 32 +- .../app-volume-monitoring-usage.model.ts | 24 +- src/shared/model/node-resource.model.ts | 8 +- src/shared/utils/app-volume-identity.utils.ts | 18 +- src/shared/utils/constants.ts | 119 ++-- .../utils/kubernetes-size-converter.utils.ts | 185 +++--- ...bernetes-size-converter.utils.unit.spec.ts | 221 ++++--- yarn.lock | 10 + 58 files changed, 4439 insertions(+), 4533 deletions(-) create mode 100644 .prettierignore diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..daf9120e --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +src/shared/model/generated-zod/ diff --git a/.prettierrc.json b/.prettierrc.json index 088517c2..4ddc855d 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -2,7 +2,7 @@ "endOfLine": "lf", "semi": true, "singleQuote": true, - "jsxSingleQuote": true, + "jsxSingleQuote": false, "tabWidth": 2, "useTabs": false, "trailingComma": "none", @@ -10,6 +10,7 @@ "arrowParens": "always", "objectWrap": "collapse", "plugins": ["prettier-plugin-tailwindcss"], - "tailwindStylesheet": "app/globals.css", - "tailwindFunctions": ["cn", "cva"] + "tailwindStylesheet": "src/app/globals.css", + "tailwindFunctions": ["cn", "cva"], + "bracketSpacing": true } diff --git a/package.json b/package.json index 7cb86acc..97135c8f 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "test:ui": "vitest --ui", "test:coverage": "vitest --coverage", "mcp:eval": "node scripts/mcp-eval.mjs", - "openapi-generate": "bash scripts/update-k8s-custom-crds.sh && bash scripts/update-openapi.sh" + "openapi-generate": "bash scripts/update-k8s-custom-crds.sh && bash scripts/update-openapi.sh", + "format": "prettier --write ." }, "dependencies": { "@aws-sdk/client-s3": "^3.1159.0", @@ -100,6 +101,8 @@ "eslint-plugin-unused-imports": "^4.4.1", "jsdom": "^29.0.2", "postcss": "^8.4.47", + "prettier": "^3.9.10", + "prettier-plugin-tailwindcss": "^0.8.1", "shadcn": "^4.21.0", "tailwindcss": "^4.3.3", "tsc-alias": "^1.8.16", 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 index f084f948..c9ff01c4 100644 --- a/src/__tests__/integration/server/services/app-monitoring-history.integration.spec.ts +++ b/src/__tests__/integration/server/services/app-monitoring-history.integration.spec.ts @@ -8,98 +8,114 @@ 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'); + 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('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('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('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('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); - + 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 f2f302e9..8a403f02 100644 --- a/src/app/monitoring/actions.ts +++ b/src/app/monitoring/actions.ts @@ -1,38 +1,38 @@ -'use server' +'use server'; -import monitoringService from "@/server/services/monitoring.service"; -import clusterService from "@/server/services/cluster.service"; -import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils"; -import { UserGroupUtils } from "@/shared/utils/role.utils"; -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 { ServerActionResult } from "@/shared/model/server-action-error-return.model"; +import monitoringService from '@/server/services/monitoring.service'; +import clusterService from '@/server/services/cluster.service'; +import { getAuthUserSession, simpleAction } from '@/server/utils/action-wrapper.utils'; +import { UserGroupUtils } from '@/shared/utils/role.utils'; +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 { ServerActionResult } from '@/shared/model/server-action-error-return.model'; export const getNodeResourceUsage = async () => - simpleAction(async () => { - await getAuthUserSession(); - return await clusterService.getNodeResourceUsage(); - }) as Promise>; + simpleAction(async () => { + await getAuthUserSession(); + return await clusterService.getNodeResourceUsage(); + }) as Promise>; export const getVolumeMonitoringUsage = async () => - simpleAction(async () => { - const session = await getAuthUserSession(); - let volumesUsage = await monitoringService.getAllAppVolumesUsage(); - volumesUsage = volumesUsage?.filter((volume) => UserGroupUtils.sessionHasReadAccessForApp(session, volume.appId)); - return volumesUsage; - }) as Promise>; + simpleAction(async () => { + const session = await getAuthUserSession(); + let volumesUsage = await monitoringService.getAllAppVolumesUsage(); + volumesUsage = volumesUsage?.filter((volume) => UserGroupUtils.sessionHasReadAccessForApp(session, volume.appId)); + return volumesUsage; + }) as Promise>; export const getMonitoringForAllApps = async () => - simpleAction(async () => { - const session = await getAuthUserSession(); - let updatedNodeRessources = await monitoringService.getMonitoringForAllApps(); - updatedNodeRessources = updatedNodeRessources?.filter((app) => UserGroupUtils.sessionHasReadAccessForApp(session, app.appId)); - return updatedNodeRessources; - }) as Promise>; + simpleAction(async () => { + const session = await getAuthUserSession(); + let updatedNodeRessources = await monitoringService.getMonitoringForAllApps(); + updatedNodeRessources = updatedNodeRessources?.filter((app) => UserGroupUtils.sessionHasReadAccessForApp(session, app.appId)); + return updatedNodeRessources; + }) as Promise>; export const getReservedVolumeStorage = async () => - simpleAction(async () => { - await getAuthUserSession(); - return await clusterService.getReservedVolumeStorage(); - }) as Promise>; + 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 f3e5182e..26fbe4a1 100644 --- a/src/app/monitoring/app-volumes-monitoring.tsx +++ b/src/app/monitoring/app-volumes-monitoring.tsx @@ -1,12 +1,6 @@ 'use client'; - -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from '@/components/ui/card'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { useMemo } from 'react'; import FullLoadingSpinner from '@/components/ui/full-loading-spinnter'; import { AppVolumeMonitoringUsageModel } from '@/shared/model/app-volume-monitoring-usage.model'; @@ -15,92 +9,94 @@ import { KubeSizeConverter } from '@/shared/utils/kubernetes-size-converter.util import { ExternalLink } from 'lucide-react'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; -import { Progress } from "@/components/ui/progress" +import { Progress } from '@/components/ui/progress'; import { PathBuilderUtils } from '@/shared/utils/path-builder.utils'; import { AppVolumeIdentityUtils } from '@/shared/utils/app-volume-identity.utils'; -export default function AppVolumeMonitoring({ - volumesUsage -}: { - volumesUsage?: AppVolumeMonitoringUsageModel[] -}) { - - 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 { - 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'), - }; - }, [volumesUsage]); - - if (!updatedVolumeUsage) { - return - - App Volumes Capacity - - - - - - } +export default function AppVolumeMonitoring({ volumesUsage }: { volumesUsage?: AppVolumeMonitoringUsageModel[] }) { + 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 { + 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') + }; + }, [volumesUsage]); + if (!updatedVolumeUsage) { return ( - - - App Volumes Capacity - - - - {updatedVolumeUsage.length} App Volumes | Total used {KubeSizeConverter.convertBytesToReadableSize(totals.used)} / {KubeSizeConverter.convertBytesToReadableSize(totals.capacity)}{totals.incomplete ? ' (incomplete)' : ''} - - - Project - App - Mount Path - Capacity - - - - - {updatedVolumeUsage.map((item) => ( - - {item.projectName} - {item.appName} - {item.mountPath} - - {item.status === 'available' && item.usedBytes !== undefined && item.capacityBytes !== undefined && item.capacityBytes > 0 ? ( - - ) : Unavailable ({item.status})} - - - - - - - - ))} - -
-
-
+ + + App Volumes Capacity + + + + + ); + } + + return ( + + + App Volumes Capacity + + + + + {updatedVolumeUsage.length} App Volumes | Total used {KubeSizeConverter.convertBytesToReadableSize(totals.used)} /{' '} + {KubeSizeConverter.convertBytesToReadableSize(totals.capacity)} + {totals.incomplete ? ' (incomplete)' : ''} + + + + Project + App + Mount Path + Capacity + + + + + {updatedVolumeUsage.map((item) => ( + + {item.projectName} + {item.appName} + {item.mountPath} + + {item.status === 'available' && item.usedBytes !== undefined && item.capacityBytes !== undefined && item.capacityBytes > 0 ? ( + + ) : ( + Unavailable ({item.status}) + )} + + + + + + + + ))} + +
+
+
+ ); } -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)} -
- ; +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 index eeee8ab3..7c153d5e 100644 --- a/src/app/monitoring/app-volumes-monitoring.unit.spec.ts +++ b/src/app/monitoring/app-volumes-monitoring.unit.spec.ts @@ -6,18 +6,30 @@ import { AppVolumeMonitoringUsageModel } from '@/shared/model/app-volume-monitor 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: [ + 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); + { ...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 928d915e..c2e4cccb 100644 --- a/src/app/monitoring/disk-chart.tsx +++ b/src/app/monitoring/disk-chart.tsx @@ -5,20 +5,22 @@ import { KubeSizeConverter } from '@/shared/utils/kubernetes-size-converter.util import { Progress } from '@/components/ui/progress'; export default function ChartDiskRessources({ nodeRessource }: { nodeRessource: NodeResourceModel }) { - const { diskUsageAbsolut: used, diskUsageAvailable: available, diskUsageCapacity: capacity } = nodeRessource; + const { diskUsageAbsolut: used, diskUsageAvailable: available, diskUsageCapacity: capacity } = nodeRessource; - if (used === undefined || available === undefined || capacity === undefined || capacity <= 0) { - return

Storage metrics unavailable

; - } + if (used === undefined || available === undefined || capacity === undefined || capacity <= 0) { + return

Storage metrics unavailable

; + } - return ( -
-
- {(used / capacity * 100).toFixed(0)}% used - {KubeSizeConverter.convertBytesToReadableSize(used)} / {KubeSizeConverter.convertBytesToReadableSize(capacity)} -
- -

Available: {KubeSizeConverter.convertBytesToReadableSize(available)}

-
- ); + return ( +
+
+ {((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 97b03214..7285479e 100644 --- a/src/app/monitoring/monitoring-client.tsx +++ b/src/app/monitoring/monitoring-client.tsx @@ -20,47 +20,46 @@ const MONITORING_POLL_INTERVAL_MS = 10000; * so the page runs one pausable poller instead of three independent intervals. */ export default function MonitoringClient({ - resourcesNodes, - appsRessourceUsage, - volumesUsage, - reservedVolumeStorage, + resourcesNodes, + appsRessourceUsage, + volumesUsage, + reservedVolumeStorage }: { - resourcesNodes?: NodeResourceModel[]; - appsRessourceUsage?: AppMonitoringUsageModel[]; - volumesUsage?: AppVolumeMonitoringUsageModel[]; - reservedVolumeStorage?: number; + 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 [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, 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) { + toast.error('An error occurred while fetching current resource usage'); + console.error('An error occurred while fetching monitoring data', ex); + } + }, []); - const refreshMonitoringData = useCallback(async () => { - try { - 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) { - toast.error('An error occurred while fetching current resource usage'); - console.error('An error occurred while fetching monitoring data', ex); - } - }, []); + usePolling(refreshMonitoringData, { intervalMs: MONITORING_POLL_INTERVAL_MS }); - usePolling(refreshMonitoringData, { intervalMs: MONITORING_POLL_INTERVAL_MS }); - - return ( -
- - - -
- ); + return ( +
+ + + +
+ ); } diff --git a/src/app/monitoring/monitoring-nodes.tsx b/src/app/monitoring/monitoring-nodes.tsx index ded34d52..afa2b74b 100644 --- a/src/app/monitoring/monitoring-nodes.tsx +++ b/src/app/monitoring/monitoring-nodes.tsx @@ -1,36 +1,17 @@ 'use client'; -import { - Label, - Pie, - PieChart, -} from 'recharts'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card'; +import { Label, Pie, PieChart } from 'recharts'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'; import { NodeResourceModel } from '@/shared/model/node-resource.model'; -import { - useBreadcrumbs, -} from '@/frontend/states/zustand.states'; +import { useBreadcrumbs } from '@/frontend/states/zustand.states'; import { useEffect, useMemo } from 'react'; 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'; -export default function ResourcesNodes({ - resourcesNodes, - reservedVolumeStorage, -}: { - resourcesNodes?: NodeResourceModel[]; - reservedVolumeStorage?: number; -}) { - +export default function ResourcesNodes({ resourcesNodes, reservedVolumeStorage }: { resourcesNodes?: NodeResourceModel[]; reservedVolumeStorage?: number }) { const getDiskUsageAbsolut = (node: NodeResourceModel) => node.diskUsageAbsolut ?? 0; const getDiskUsageCapacity = (node: NodeResourceModel) => node.diskUsageCapacity ?? 0; const toPercent = (used: number, capacity: number) => (capacity > 0 ? (used / capacity) * 100 : 0); @@ -38,58 +19,42 @@ export default function ResourcesNodes({ const updatedNodeRessources = resourcesNodes; const setBreadcrumbs = useBreadcrumbs((state) => state.setBreadcrumbs); - useEffect( - () => setBreadcrumbs([{ name: 'Monitoring', url: '/monitoring' }] - ), [setBreadcrumbs]); + useEffect(() => setBreadcrumbs([{ name: 'Monitoring', url: '/monitoring' }]), [setBreadcrumbs]); const clusterStats = useMemo(() => { - if (!updatedNodeRessources) return { - cpuUsage: 0, cpuCapacity: 1, - ramUsage: 0, ramCapacity: 1, - diskUsageAbsolut: 0, diskCapacity: 1 - }; + if (!updatedNodeRessources) return { cpuUsage: 0, cpuCapacity: 1, ramUsage: 0, ramCapacity: 1, diskUsageAbsolut: 0, diskCapacity: 1 }; - return updatedNodeRessources.reduce((acc, node) => ({ - cpuUsage: acc.cpuUsage + node.cpuUsage, - cpuCapacity: acc.cpuCapacity + node.cpuCapacity, - ramUsage: acc.ramUsage + node.ramUsage, - ramCapacity: acc.ramCapacity + node.ramCapacity, - diskUsageAbsolut: acc.diskUsageAbsolut + getDiskUsageAbsolut(node), - diskCapacity: acc.diskCapacity + getDiskUsageCapacity(node), - }), { - cpuUsage: 0, cpuCapacity: 0, - ramUsage: 0, ramCapacity: 0, - diskUsageAbsolut: 0, diskCapacity: 0 - }); + return updatedNodeRessources.reduce( + (acc, node) => ({ + cpuUsage: acc.cpuUsage + node.cpuUsage, + cpuCapacity: acc.cpuCapacity + node.cpuCapacity, + ramUsage: acc.ramUsage + node.ramUsage, + ramCapacity: acc.ramCapacity + node.ramCapacity, + diskUsageAbsolut: acc.diskUsageAbsolut + getDiskUsageAbsolut(node), + diskCapacity: acc.diskCapacity + getDiskUsageCapacity(node) + }), + { cpuUsage: 0, cpuCapacity: 0, ramUsage: 0, ramCapacity: 0, diskUsageAbsolut: 0, diskCapacity: 0 } + ); }, [updatedNodeRessources]); const getUsageColor = (percentage: number) => { - if (percentage >= 90) return "var(--chart-1)"; - if (percentage >= 80) return "var(--chart-4)"; - return "var(--chart-2)"; + if (percentage >= 90) return 'var(--chart-1)'; + if (percentage >= 80) return 'var(--chart-4)'; + return 'var(--chart-2)'; }; - const pieChartConfig = { - used: { - label: "Used", - color: "var(--chart-1)", - }, - free: { - label: "Remaining", - color: "var(--muted)", - }, - } satisfies ChartConfig; + const pieChartConfig = { used: { label: 'Used', color: 'var(--chart-1)' }, free: { label: 'Remaining', color: 'var(--muted)' } } satisfies ChartConfig; const getChartData = (used: number, capacity: number) => { const percentage = capacity > 0 ? (used / capacity) * 100 : 0; return [ { status: 'used', value: used, fill: getUsageColor(percentage) }, - { status: 'free', value: Math.max(0, capacity - used), fill: 'var(--color-free)' }, + { status: 'free', value: Math.max(0, capacity - used), fill: 'var(--color-free)' } ]; }; if (!updatedNodeRessources) { - return + return ; } return ( @@ -106,20 +71,22 @@ export default function ResourcesNodes({ } /> - @@ -135,22 +102,27 @@ export default function ResourcesNodes({ - KubeSizeConverter.convertBytesToReadableSize(value as number)} />} /> + KubeSizeConverter.convertBytesToReadableSize(value as number)} />} + /> - @@ -164,48 +136,90 @@ export default function ResourcesNodes({ Node filesystem usage and volume reservations - {updatedNodeRessources.some(node => node.diskUsageCapacity === undefined) && ( + {updatedNodeRessources.some((node) => node.diskUsageCapacity === undefined) && (

Incomplete: some node filesystem metrics are unavailable.

)} {clusterStats.diskCapacity > 0 ? ( <> - + - ( - {name === 'reserved' ? 'Reserved beyond node usage' : name === 'used' ? 'Used' : 'Remaining'}: {KubeSizeConverter.convertBytesToReadableSize(value as number)} - )} />} /> + ( + + {name === 'reserved' ? 'Reserved beyond node usage' : name === 'used' ? 'Used' : 'Remaining'}:{' '} + {KubeSizeConverter.convertBytesToReadableSize(value as number)} + + )} + /> + } + /> -

- Node filesystem: {KubeSizeConverter.convertBytesToReadableSize(clusterStats.diskUsageAbsolut)} used. - Volume reservation: {reservedVolumeStorage === undefined ? 'reservation unavailable' : `${KubeSizeConverter.convertBytesToReadableSize(reservedVolumeStorage)} reserved`}. - Total node capacity: {KubeSizeConverter.convertBytesToReadableSize(clusterStats.diskCapacity)}. + 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

} + ) : ( +

Storage metrics unavailable

+ )}
@@ -233,7 +247,9 @@ export default function ResourcesNodes({
{((node.cpuUsage / node.cpuCapacity) * 100).toFixed(0)}% - {node.cpuUsage.toFixed(2)} / {node.cpuCapacity} Cores + + {node.cpuUsage.toFixed(2)} / {node.cpuCapacity} Cores +
@@ -242,7 +258,9 @@ export default function ResourcesNodes({
{((node.ramUsage / node.ramCapacity) * 100).toFixed(0)}% - {KubeSizeConverter.convertBytesToReadableSize(node.ramUsage)} / {KubeSizeConverter.convertBytesToReadableSize(node.ramCapacity)} + + {KubeSizeConverter.convertBytesToReadableSize(node.ramUsage)} / {KubeSizeConverter.convertBytesToReadableSize(node.ramCapacity)} +
@@ -259,7 +277,10 @@ export default function ResourcesNodes({ <>
{diskPercent.toFixed(0)}% - {KubeSizeConverter.convertBytesToReadableSize(diskUsedAndReserved)} / {KubeSizeConverter.convertBytesToReadableSize(diskCapacity)} + + {KubeSizeConverter.convertBytesToReadableSize(diskUsedAndReserved)} /{' '} + {KubeSizeConverter.convertBytesToReadableSize(diskCapacity)} +
@@ -276,4 +297,3 @@ export default function ResourcesNodes({ ); } - diff --git a/src/app/monitoring/page.tsx b/src/app/monitoring/page.tsx index 196abf9f..b955c179 100644 --- a/src/app/monitoring/page.tsx +++ b/src/app/monitoring/page.tsx @@ -1,44 +1,40 @@ -'use server' +'use server'; -import { getAuthUserSession } from "@/server/utils/action-wrapper.utils"; -import PageTitle from "@/components/custom/page-title"; -import clusterService from "@/server/services/cluster.service"; -import MonitoringClient from "./monitoring-client"; -import monitoringService from "@/server/services/monitoring.service"; -import { UserGroupUtils } from "@/shared/utils/role.utils"; -import { CatchUtils } from "@/shared/utils/catch.utils"; +import { getAuthUserSession } from '@/server/utils/action-wrapper.utils'; +import PageTitle from '@/components/custom/page-title'; +import clusterService from '@/server/services/cluster.service'; +import MonitoringClient from './monitoring-client'; +import monitoringService from '@/server/services/monitoring.service'; +import { UserGroupUtils } from '@/shared/utils/role.utils'; +import { CatchUtils } from '@/shared/utils/catch.utils'; export default async function ResourceNodesInfoPage() { + const session = await getAuthUserSession(); - const session = await getAuthUserSession(); + let [resourcesNode, volumesUsage, updatedNodeRessources, reservedVolumeStorage] = await Promise.all([ + CatchUtils.resultOrUndefined(() => clusterService.getNodeResourceUsage()), + CatchUtils.resultOrUndefined(() => monitoringService.getAllAppVolumesUsage()), + CatchUtils.resultOrUndefined(() => monitoringService.getMonitoringForAllApps()), + CatchUtils.resultOrUndefined(() => clusterService.getReservedVolumeStorage()) + ]); - let [resourcesNode, volumesUsage, updatedNodeRessources, reservedVolumeStorage] = await Promise.all([ - CatchUtils.resultOrUndefined(() => clusterService.getNodeResourceUsage()), - CatchUtils.resultOrUndefined(() => monitoringService.getAllAppVolumesUsage()), - CatchUtils.resultOrUndefined(() => monitoringService.getMonitoringForAllApps()), - CatchUtils.resultOrUndefined(() => clusterService.getReservedVolumeStorage()) - ]); + // filter by role + volumesUsage = volumesUsage?.filter((volume) => UserGroupUtils.sessionHasReadAccessForApp(session, volume.appId)); + // only base volumes, no shared volumes + volumesUsage = volumesUsage?.filter((volume) => !!volume.isBaseVolume); + updatedNodeRessources = updatedNodeRessources?.filter((app) => UserGroupUtils.sessionHasReadAccessForApp(session, app.appId)); - // filter by role - volumesUsage = volumesUsage?.filter((volume) => UserGroupUtils.sessionHasReadAccessForApp(session, volume.appId)); - // only base volumes, no shared volumes - volumesUsage = volumesUsage?.filter((volume) => !!volume.isBaseVolume); - updatedNodeRessources = updatedNodeRessources?.filter((app) => UserGroupUtils.sessionHasReadAccessForApp(session, app.appId)); - - return ( -
- - -
- -
-
- ) + return ( +
+ +
+ +
+
+ ); } 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 c348bf3a..b8855b75 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 @@ -1,228 +1,221 @@ -'use client' +'use client'; -import type { z } from "zod"; -import { useActionState, useEffect } from "react"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { toast } from "sonner"; -import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { SubmitButton } from "@/components/custom/submit-button"; -import StorageClassCombobox from "@/components/custom/storage-class-combobox"; -import { FormUtils } from "@/frontend/utils/form.utilts"; -import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; -import { AgentVolumeEditModel, agentVolumeEditZodModel } from "@/shared/model/volume-edit.model"; -import { useDialogContext } from "@/frontend/states/dialog-context"; -import { saveAgentVolume } from "../configuration/actions"; -import { StorageClassUtils } from "@/shared/utils/storage-class.utils"; -import { Constants } from "@/shared/utils/constants"; +import type { z } from 'zod'; +import { useActionState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { toast } from 'sonner'; +import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { SubmitButton } from '@/components/custom/submit-button'; +import StorageClassCombobox from '@/components/custom/storage-class-combobox'; +import { FormUtils } from '@/frontend/utils/form.utilts'; +import { ServerActionResult } from '@/shared/model/server-action-error-return.model'; +import { AgentVolumeEditModel, agentVolumeEditZodModel } from '@/shared/model/volume-edit.model'; +import { useDialogContext } from '@/frontend/states/dialog-context'; +import { saveAgentVolume } from '../configuration/actions'; +import { StorageClassUtils } from '@/shared/utils/storage-class.utils'; +import { Constants } from '@/shared/utils/constants'; export default function AgentVolumeEditOverlay({ - existingVolume, - agentId, - storageClasses + existingVolume, + agentId, + storageClasses }: { - existingVolume?: AgentVolumeEditModel & { id?: string; storageClassName: string }; - agentId: string; - storageClasses: string[]; + existingVolume?: AgentVolumeEditModel & { id?: string; storageClassName: string }; + agentId: string; + storageClasses: string[]; }) { - const { closeDialog } = useDialogContext(); - const defaultStorageClassName = existingVolume?.storageClassName || StorageClassUtils.getDefaultStorageClass(storageClasses); + const { closeDialog } = useDialogContext(); + const defaultStorageClassName = existingVolume?.storageClassName || StorageClassUtils.getDefaultStorageClass(storageClasses); - const form = useForm, unknown, z.output>({ - resolver: zodResolver(agentVolumeEditZodModel), - defaultValues: { - containerMountPath: existingVolume?.containerMountPath || '', - size: existingVolume?.size || 1024, - storageClassName: defaultStorageClassName, - volumeType: existingVolume?.volumeType || 'ALL', - accessMode: existingVolume?.accessMode || 'ReadWriteMany', - } as AgentVolumeEditModel, - }); + const form = useForm, unknown, z.output>({ + resolver: zodResolver(agentVolumeEditZodModel), + defaultValues: { + containerMountPath: existingVolume?.containerMountPath || '', + size: existingVolume?.size || 1024, + storageClassName: defaultStorageClassName, + volumeType: existingVolume?.volumeType || 'ALL', + accessMode: existingVolume?.accessMode || 'ReadWriteMany' + } as AgentVolumeEditModel + }); - const volumeType = form.watch('volumeType'); + const volumeType = form.watch('volumeType'); - useEffect(() => { - if (volumeType === 'ALL') { - form.setValue('accessMode', 'ReadWriteMany'); - } - }, [form, volumeType]); + useEffect(() => { + if (volumeType === 'ALL') { + form.setValue('accessMode', 'ReadWriteMany'); + } + }, [form, volumeType]); - const [state, formAction] = useActionState( - (state: ServerActionResult, payload: AgentVolumeEditModel) => - saveAgentVolume(state, { ...payload, id: existingVolume?.id }, agentId), - FormUtils.getInitialFormState() - ); + const [state, formAction] = useActionState( + (state: ServerActionResult, payload: AgentVolumeEditModel) => + saveAgentVolume(state, { ...payload, id: existingVolume?.id }, agentId), + FormUtils.getInitialFormState() + ); - useEffect(() => { - if (state.status === 'success') { - form.reset(); - toast.success('Volume saved successfully', { description: 'Click "Deploy" to apply the changes.' }); - closeDialog(); - } - FormUtils.mapValidationErrorsToForm(state, form); - }, [closeDialog, form, state]); + useEffect(() => { + if (state.status === 'success') { + form.reset(); + toast.success('Volume saved successfully', { description: 'Click "Deploy" to apply the changes.' }); + closeDialog(); + } + FormUtils.mapValidationErrorsToForm(state, form); + }, [closeDialog, form, state]); - useEffect(() => { - if (existingVolume) { - form.reset({ - containerMountPath: existingVolume.containerMountPath || '', - size: existingVolume.size || 1024, - storageClassName: defaultStorageClassName, - volumeType: existingVolume.volumeType || 'ALL', - accessMode: existingVolume.accessMode || 'ReadWriteMany', - } as AgentVolumeEditModel); - } - }, [defaultStorageClassName, existingVolume, form]); + useEffect(() => { + if (existingVolume) { + form.reset({ + containerMountPath: existingVolume.containerMountPath || '', + size: existingVolume.size || 1024, + storageClassName: defaultStorageClassName, + volumeType: existingVolume.volumeType || 'ALL', + accessMode: existingVolume.accessMode || 'ReadWriteMany' + } as AgentVolumeEditModel); + } + }, [defaultStorageClassName, existingVolume, form]); - const storageClassOptions = Array.from(new Set([...storageClasses, existingVolume?.storageClassName].filter(Boolean) as string[])); + const storageClassOptions = Array.from(new Set([...storageClasses, existingVolume?.storageClassName].filter(Boolean) as string[])); - return <> - - {existingVolume ? 'Edit Volume' : 'Add Volume'} - - {existingVolume - ? 'Update the volume configuration.' - : 'Add a persistent volume to this workload.'} - - -
- form.handleSubmit((data) => formAction(data))()} - className="space-y-4" - > - ( - - Mount Path - - - - - - )} - /> - ( - - Size (MiB) - - - - - - )} - /> - ( - - Storage Class - - form.setValue("storageClassName", value)} - /> - - - This cannot be changed after creation. - - - - )} - /> - ( - - Volume Type - - - - - {volumeType === 'ALL' - ? 'Shared by every sandbox and persists after the sandbox is deleted.' - : volumeType === Constants.QS_VOLUME_TYPE_PER_CUSTOM_TAG - ? 'Private to each Custom Tag and persists across sandbox restarts, deletion and idle expiry.' - : 'Private to each sandbox and deleted with it.'} - {' '}This cannot be changed after creation. - - - - )} - /> - ( - - Access Mode - - - - - {volumeType === 'ALL' - ? 'Shared volumes require ReadWriteMany.' - : volumeType === Constants.QS_VOLUME_TYPE_PER_CUSTOM_TAG - ? 'Per-Custom-Tag volumes may use ReadWriteOnce or ReadWriteMany.' - : 'Per-sandbox volumes may use ReadWriteOnce.'} - {' '}This cannot be changed after creation. - - - - )} - /> - {state.status === 'error' &&

{state.message}

} - Save - - - ; + return ( + <> + + {existingVolume ? 'Edit Volume' : 'Add Volume'} + {existingVolume ? 'Update the volume configuration.' : 'Add a persistent volume to this workload.'} + +
+ form.handleSubmit((data) => formAction(data))()} className="space-y-4"> + ( + + Mount Path + + + + + + )} + /> + ( + + Size (MiB) + + + + + + )} + /> + ( + + Storage Class + + form.setValue('storageClassName', value)} + /> + + This cannot be changed after creation. + + + )} + /> + ( + + Volume Type + + + + + {volumeType === 'ALL' + ? 'Shared by every sandbox and persists after the sandbox is deleted.' + : volumeType === Constants.QS_VOLUME_TYPE_PER_CUSTOM_TAG + ? 'Private to each Custom Tag and persists across sandbox restarts, deletion and idle expiry.' + : 'Private to each sandbox and deleted with it.'}{' '} + This cannot be changed after creation. + + + + )} + /> + ( + + Access Mode + + + + + {volumeType === 'ALL' + ? 'Shared volumes require ReadWriteMany.' + : volumeType === Constants.QS_VOLUME_TYPE_PER_CUSTOM_TAG + ? 'Per-Custom-Tag volumes may use ReadWriteOnce or ReadWriteMany.' + : 'Per-sandbox volumes may use ReadWriteOnce.'}{' '} + This cannot be changed after creation. + + + + )} + /> + {state.status === 'error' &&

{state.message}

} + Save + + + + ); } 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 1d67fd97..26f7717a 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 @@ -1,107 +1,124 @@ 'use client'; -import { DrawerCard, DrawerCardContent, DrawerCardDescription, DrawerCardFooter, DrawerCardHeader, DrawerCardTitle } from "@/components/custom/drawer-card"; -import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Button } from "@/components/ui/button"; -import { EditIcon, Plus, TrashIcon } from "lucide-react"; -import { Toast } from "@/frontend/utils/toast.utils"; -import { useConfirmDialog, useDialog } from "@/frontend/states/zustand.states"; -import AgentVolumeEditOverlay from "./agent-volume-edit-overlay"; -import { AgentVolume } from "@prisma/client"; -import { AgentVolumeEditModel } from "@/shared/model/volume-edit.model"; -import { deleteAgentVolume } from "../configuration/actions"; -import { Constants } from "@/shared/utils/constants"; +import { DrawerCard, DrawerCardContent, DrawerCardDescription, DrawerCardFooter, DrawerCardHeader, DrawerCardTitle } from '@/components/custom/drawer-card'; +import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { EditIcon, Plus, TrashIcon } from 'lucide-react'; +import { Toast } from '@/frontend/utils/toast.utils'; +import { useConfirmDialog, useDialog } from '@/frontend/states/zustand.states'; +import AgentVolumeEditOverlay from './agent-volume-edit-overlay'; +import { AgentVolume } from '@prisma/client'; +import { AgentVolumeEditModel } from '@/shared/model/volume-edit.model'; +import { deleteAgentVolume } from '../configuration/actions'; +import { Constants } from '@/shared/utils/constants'; function formatSize(mb: number): string { - if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GiB`; - return `${mb} MiB`; + if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GiB`; + return `${mb} MiB`; } function volumeTypeLabel(volumeType: string): string { - switch (volumeType) { - case 'PER_SANDBOX': - return 'Per Sandbox'; - case Constants.QS_VOLUME_TYPE_PER_CUSTOM_TAG: - return 'Per Custom Tag'; - default: - return 'All (Shared)'; - } + switch (volumeType) { + case 'PER_SANDBOX': + return 'Per Sandbox'; + case Constants.QS_VOLUME_TYPE_PER_CUSTOM_TAG: + return 'Per Custom Tag'; + default: + return 'All (Shared)'; + } } -export default function AgentVolumesCard({ volumes, projectId, readonly, storageClasses }: { - volumes: AgentVolume[]; - projectId: string; - readonly: boolean; - storageClasses: string[]; +export default function AgentVolumesCard({ + volumes, + projectId, + readonly, + storageClasses +}: { + volumes: AgentVolume[]; + projectId: string; + readonly: boolean; + storageClasses: string[]; }) { - const { openConfirmDialog } = useConfirmDialog(); - const { openDialog } = useDialog(); + const { openConfirmDialog } = useConfirmDialog(); + const { openDialog } = useDialog(); - const asyncDeleteVolume = async (volumeId: string) => { - const confirm = await openConfirmDialog({ - title: "Delete Volume", - description: "The volume will be removed permanently. Are you sure you want to delete this volume?", - okButton: "Delete Volume" - }); - if (confirm) { - await Toast.fromAction(() => deleteAgentVolume(volumeId)); - } - }; + const asyncDeleteVolume = async (volumeId: string) => { + const confirm = await openConfirmDialog({ + title: 'Delete Volume', + description: 'The volume will be removed permanently. Are you sure you want to delete this volume?', + okButton: 'Delete Volume' + }); + if (confirm) { + await Toast.fromAction(() => deleteAgentVolume(volumeId)); + } + }; - const openEditVolumeDialog = async (volume?: AgentVolume) => { - await openDialog(, { - maxWidth: 'max-w-xl', - }); - }; + const openEditVolumeDialog = async (volume?: AgentVolume) => { + await openDialog( + , + { maxWidth: 'max-w-xl' } + ); + }; - return <> - - - Volumes - - Persistent storage volumes attached to this workload. - - - {volumes.length > 0 && - - {volumes.length} Volumes - - - Mount Path - Type - Access Mode - Size - Storage Class - {!readonly && } - - - - {volumes.map(volume => ( - - {volume.containerMountPath} - {volumeTypeLabel(volume.volumeType)} - {volume.accessMode} - {formatSize(volume.size)} - {volume.storageClassName} - {!readonly && -
- - -
-
} -
- ))} -
-
-
} - {!readonly && - - } -
- ; + return ( + <> + + + Volumes + Persistent storage volumes attached to this workload. + + {volumes.length > 0 && ( + + + {volumes.length} Volumes + + + Mount Path + Type + Access Mode + Size + Storage Class + {!readonly && } + + + + {volumes.map((volume) => ( + + {volume.containerMountPath} + {volumeTypeLabel(volume.volumeType)} + {volume.accessMode} + {formatSize(volume.size)} + {volume.storageClassName} + {!readonly && ( + +
+ + +
+
+ )} +
+ ))} +
+
+
+ )} + {!readonly && ( + + + + )} +
+ + ); } 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 81568319..1b9080de 100644 --- a/src/app/project/[projectId]/apps/details/app-details-drawer.tsx +++ b/src/app/project/[projectId]/apps/details/app-details-drawer.tsx @@ -2,39 +2,13 @@ import { type Ref } from 'react'; -import { - BarChart3, - Bot, - Boxes, - Key, - Rocket, - Logs as LogsIcon, - Pencil, - Settings, - X, - RotateCwClock, -} from 'lucide-react'; +import { BarChart3, Bot, Boxes, Key, Rocket, Logs as LogsIcon, Pencil, Settings, X, RotateCwClock } from 'lucide-react'; import { Button } from '@/components/ui/button'; - - - import { ScrollArea } from '@/components/ui/scroll-area'; -import { - Drawer, - DrawerContent, - DrawerDescription, - DrawerHeader, - DrawerTitle, -} from '@/components/ui/drawer'; +import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/ui/drawer'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from '@/components/ui/empty'; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'; import { cn } from '@/frontend/utils/utils'; import { AppSourceUtils } from '@/frontend/utils/app-source.utils'; import type { AppExtendedModel } from '@/shared/model/app-extended.model'; @@ -51,267 +25,216 @@ import { DrawerBackupsTab } from './volumes/backups/app-details-backups-tab'; import { EditAppDialog } from '@/app/project/[projectId]/apps/create/edit-app-dialog'; import type { S3Target } from '@prisma/client'; import type { VolumeBackupExtendedModel } from '@/shared/model/volume-backup-extended.model'; -import { - AppDrawerNavigationUtils, - type AppDrawerTab, -} from '@/shared/utils/app-drawer-navigation.utils'; +import { AppDrawerNavigationUtils, type AppDrawerTab } from '@/shared/utils/app-drawer-navigation.utils'; import DrawerDeploymentsTab from './deployments/app-details-deployments-tab'; -export type AppDetailsDrawerNode = { - id: string; - kind: string; - external?: boolean; - name: string; - caption?: string; -}; +export type AppDetailsDrawerNode = { id: string; kind: string; external?: boolean; name: string; caption?: string }; import { DrawerTabScrollArea } from '@/app/project/workload-components/drawer/drawer-tab-scroll-area'; import { AppStatusActions } from './app-status-actions'; export function AppDetailsDrawer({ - contentRef, - node, - app, - role, - s3Targets, - storageClasses, - volumeBackups, - gitSshPublicKey, - open, - onOpenChange, - onOpenChangeComplete, - requestedTab, - onTabChange, - openEnvironment, - onEnvironmentOpened, + contentRef, + node, + app, + role, + s3Targets, + storageClasses, + volumeBackups, + gitSshPublicKey, + open, + onOpenChange, + onOpenChangeComplete, + requestedTab, + onTabChange, + openEnvironment, + onEnvironmentOpened }: { - contentRef?: Ref; - node: AppDetailsDrawerNode; - app?: AppExtendedModel; - role?: RolePermissionEnum; - s3Targets: S3Target[]; - storageClasses: string[]; - volumeBackups: VolumeBackupExtendedModel[]; - gitSshPublicKey?: string; - open: boolean; - onOpenChange: (open: boolean) => void; - onOpenChangeComplete: (open: boolean) => void; - requestedTab?: string | null; - onTabChange: (tab: AppDrawerTab) => void; - openEnvironment?: boolean; - onEnvironmentOpened?: () => void; + contentRef?: Ref; + node: AppDetailsDrawerNode; + app?: AppExtendedModel; + role?: RolePermissionEnum; + s3Targets: S3Target[]; + storageClasses: string[]; + volumeBackups: VolumeBackupExtendedModel[]; + gitSshPublicKey?: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onOpenChangeComplete: (open: boolean) => void; + requestedTab?: string | null; + onTabChange: (tab: AppDrawerTab) => void; + openEnvironment?: boolean; + onEnvironmentOpened?: () => void; }) { - const isApp = node.kind === 'APP'; - const isExternalApp = isApp && node.external; - const needsSourceConfiguration = - app && - role === RolePermissionEnum.READWRITE && - !AppSourceUtils.isConfiguredSource(app); - const hasVolumes = (app?.appVolumes.length ?? 0) > 0; + const isApp = node.kind === 'APP'; + const isExternalApp = isApp && node.external; + const needsSourceConfiguration = app && role === RolePermissionEnum.READWRITE && !AppSourceUtils.isConfiguredSource(app); + const hasVolumes = (app?.appVolumes.length ?? 0) > 0; - const activeTab = AppDrawerNavigationUtils.resolveTab( - app?.appType, - requestedTab, - hasVolumes, - ); + const activeTab = AppDrawerNavigationUtils.resolveTab(app?.appType, requestedTab, hasVolumes); - const handleTabChange = (tab: string) => { - const nextTab = AppDrawerNavigationUtils.resolveTab(app?.appType, tab, hasVolumes); - onTabChange(nextTab); - }; + const handleTabChange = (tab: string) => { + const nextTab = AppDrawerNavigationUtils.resolveTab(app?.appType, tab, hasVolumes); + onTabChange(nextTab); + }; - return ( - - + + + + - - -
-
- {isApp ? ( - - ) : ( - - )} -
-
- - {node.name} - {app && role === RolePermissionEnum.READWRITE && ( - - - - )} - - - {node.caption ?? - (isApp ? 'App' : 'Agent sandbox')} - -
- {app && !needsSourceConfiguration && ( -
- -
- )} -
- {app && role && !needsSourceConfiguration ? ( - - - - - Deployments - - {app.appType !== 'APP' && ( - - - DB Access - - )} - - - Logs - - - - Stats - - {hasVolumes && ( - - - Backups - - )} - - - Settings - - - - ) : ( -
- )} -
- {isExternalApp ? ( - - - - - - Settings unavailable - - This app belongs to another project. Its settings can only be edited from that project. - - - - ) : needsSourceConfiguration ? ( - - - - ) : app && role && ( - <> - - - - - - - - - - - - - - {hasVolumes && ( - - - - - - )} - {app.appType !== 'APP' && ( - - -
- {role === RolePermissionEnum.READWRITE && ( - - )} - -
-
-
- )} - - -
- -
-
-
- - )} -
-
-
-
-
- ); + + Close + + + +
+
+ {isApp ? : } +
+
+ + {node.name} + {app && role === RolePermissionEnum.READWRITE && ( + + + + )} + + {node.caption ?? (isApp ? 'App' : 'Agent sandbox')} +
+ {app && !needsSourceConfiguration && ( +
+ +
+ )} +
+ {app && role && !needsSourceConfiguration ? ( + + + + + Deployments + + {app.appType !== 'APP' && ( + + + DB Access + + )} + + + Logs + + + + Stats + + {hasVolumes && ( + + + Backups + + )} + + + Settings + + + + ) : ( +
+ )} +
+ {isExternalApp ? ( + + + + + + Settings unavailable + This app belongs to another project. Its settings can only be edited from that project. + + + ) : needsSourceConfiguration ? ( + + + + ) : ( + app && + role && ( + <> + + + + + + + + + + + + + + {hasVolumes && ( + + + + + + )} + {app.appType !== 'APP' && ( + + +
+ {role === RolePermissionEnum.READWRITE && } + +
+
+
+ )} + + +
+ +
+
+
+ + ) + )} +
+ + + + + ); } diff --git a/src/app/project/[projectId]/apps/details/deployments/actions.ts b/src/app/project/[projectId]/apps/details/deployments/actions.ts index 115f3615..e45ddea0 100644 --- a/src/app/project/[projectId]/apps/details/deployments/actions.ts +++ b/src/app/project/[projectId]/apps/details/deployments/actions.ts @@ -1,58 +1,57 @@ -'use server' +'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"; -import deploymentService from "@/server/services/deployment.service"; -import monitoringService from "@/server/services/monitoring.service"; -import podService from "@/server/services/pod.service"; -import { isAuthorizedReadForApp, isAuthorizedWriteForApp, isAuthorizedWriteForWorkload, simpleAction } from "@/server/utils/action-wrapper.utils"; +import { SuccessActionResult } from '@/shared/model/server-action-error-return.model'; +import appService from '@/server/services/app.service'; +import buildService from '@/server/services/build.service'; +import deploymentService from '@/server/services/deployment.service'; +import monitoringService from '@/server/services/monitoring.service'; +import podService from '@/server/services/pod.service'; +import { isAuthorizedReadForApp, isAuthorizedWriteForApp, isAuthorizedWriteForWorkload, simpleAction } from '@/server/utils/action-wrapper.utils'; export const getDeploymentsAndBuildsForApp = async (appId: string) => - simpleAction(async () => { - await isAuthorizedReadForApp(appId); - const app = await appService.getExtendedById(appId); - return await deploymentService.getDeploymentHistory(app.projectId, appId); - }); + simpleAction(async () => { + await isAuthorizedReadForApp(appId); + const app = await appService.getExtendedById(appId); + return await deploymentService.getDeploymentHistory(app.projectId, appId); + }); export const deleteBuild = async (buildName: string) => - simpleAction(async () => { - await isAuthorizedWriteForApp(await buildService.getAppIdByBuildName(buildName)); - await buildService.deleteBuild(buildName); - return new SuccessActionResult(undefined, 'Successfully stopped and deleted build.'); - }); + simpleAction(async () => { + await isAuthorizedWriteForApp(await buildService.getAppIdByBuildName(buildName)); + await buildService.deleteBuild(buildName); + return new SuccessActionResult(undefined, 'Successfully stopped and deleted build.'); + }); export const rollbackToDeployment = async (appId: string, deploymentId: string) => - simpleAction(async () => { - await isAuthorizedWriteForApp(appId); - await appService.rollbackToDeployment(appId, deploymentId); - return new SuccessActionResult(undefined, 'Successfully started rollback.'); - }); + simpleAction(async () => { + await isAuthorizedWriteForApp(appId); + await appService.rollbackToDeployment(appId, deploymentId); + return new SuccessActionResult(undefined, 'Successfully started rollback.'); + }); export const getPodsForApp = async (appId: string) => - simpleAction(async () => { - await isAuthorizedReadForApp(appId); - const app = await appService.getExtendedById(appId); - return await podService.getPodsForApp(app.projectId, appId); - }); + simpleAction(async () => { + await isAuthorizedReadForApp(appId); + const app = await appService.getExtendedById(appId); + return await podService.getPodsForApp(app.projectId, appId); + }); export const getRessourceDataApp = async (projectId: string, appId: string) => - simpleAction(async () => { - await isAuthorizedReadForApp(appId); - return await monitoringService.getMonitoringForApp(projectId, appId); - }); + simpleAction(async () => { + await isAuthorizedReadForApp(appId); + return await monitoringService.getMonitoringForApp(projectId, appId); + }); export const createNewWebhookUrl = async (appId: string) => - simpleAction(async () => { - await isAuthorizedWriteForWorkload(appId); - return await appService.regenerateWebhookId(appId); - }); - + simpleAction(async () => { + await isAuthorizedWriteForWorkload(appId); + return await appService.regenerateWebhookId(appId); + }); export const getAppMonitoringHistory = async (appId: string) => - simpleAction(async () => { - await isAuthorizedReadForApp(appId); - return appMonitoringHistoryQueryService.getHistory([appId]); - }); + 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 4adbc38c..9334fae2 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 @@ -1,56 +1,42 @@ -import { AppExtendedModel } from "@/shared/model/app-extended.model"; -import { RolePermissionEnum } from "@/shared/model/role-extended.model"; -import { useNestedDrawer } from "../../../../workload-components/drawer/nested-drawer"; -import { DeploymentInfoModel } from "@/shared/model/deployment-info.model"; -import { BuildLogsDialogContent } from "@/app/project/workload-components/build-logs-dialog-content"; -import BuildsTab from "@/app/project/[projectId]/apps/details/deployments/deployments"; -import { formatDateTime } from "@/frontend/utils/format.utils"; -import WebhookDeploymentInfo from "@/app/project/[projectId]/apps/details/deployments/webhook-deployment"; -import { Button } from "@/components/ui/button"; -import { Webhook } from "lucide-react"; +import { AppExtendedModel } from '@/shared/model/app-extended.model'; +import { RolePermissionEnum } from '@/shared/model/role-extended.model'; +import { useNestedDrawer } from '../../../../workload-components/drawer/nested-drawer'; +import { DeploymentInfoModel } from '@/shared/model/deployment-info.model'; +import { BuildLogsDialogContent } from '@/app/project/workload-components/build-logs-dialog-content'; +import BuildsTab from '@/app/project/[projectId]/apps/details/deployments/deployments'; +import { formatDateTime } from '@/frontend/utils/format.utils'; +import WebhookDeploymentInfo from '@/app/project/[projectId]/apps/details/deployments/webhook-deployment'; +import { Button } from '@/components/ui/button'; +import { Webhook } from 'lucide-react'; -export default function DrawerDeploymentsTab({ - app, - role, -}: { - app: AppExtendedModel; - role: RolePermissionEnum; -}) { - const { openNestedDrawer } = useNestedDrawer(); +export default function DrawerDeploymentsTab({ app, role }: { app: AppExtendedModel; role: RolePermissionEnum }) { + const { openNestedDrawer } = useNestedDrawer(); - const showLogs = (deployment: DeploymentInfoModel) => { - openNestedDrawer({ - title: 'Deployment Logs', - description: `View the logs for the selected deployment ${formatDateTime(deployment.createdAt)}.`, - content: ( - - ), - }); - }; + const showLogs = (deployment: DeploymentInfoModel) => { + openNestedDrawer({ + title: 'Deployment Logs', + description: `View the logs for the selected deployment ${formatDateTime(deployment.createdAt)}.`, + content: + }); + }; - const showWebhookCard = () => { - openNestedDrawer({ - title: 'Webhook', - description: 'Use this webhook URL to trigger deployments from external services (for example GitHub Actions or GitLab Pipelines).', - content: ( - - ), - }); - } + const showWebhookCard = () => { + openNestedDrawer({ + title: 'Webhook', + description: 'Use this webhook URL to trigger deployments from external services (for example GitHub Actions or GitLab Pipelines).', + content: + }); + }; - return
-
-
Latest Deployments
- -
- -
; -} \ No newline at end of file + return ( +
+
+
Latest Deployments
+ +
+ +
+ ); +} diff --git a/src/app/project/[projectId]/apps/details/deployments/history-actions.unit.spec.ts b/src/app/project/[projectId]/apps/details/deployments/history-actions.unit.spec.ts index 1270603c..4035d571 100644 --- a/src/app/project/[projectId]/apps/details/deployments/history-actions.unit.spec.ts +++ b/src/app/project/[projectId]/apps/details/deployments/history-actions.unit.spec.ts @@ -1,19 +1,20 @@ -vi.mock('@/server/services/standalone-services/app-monitoring-history.service', () => ({ - default: { getHistory: vi.fn().mockResolvedValue({ points: [] }) }, -})); +vi.mock('@/server/services/standalone-services/app-monitoring-history.service', () => ({ 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' }; } - }, + 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'; @@ -22,16 +23,16 @@ 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']); + 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(); + 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 f6493634..1ef775e8 100644 --- a/src/app/project/[projectId]/apps/details/deployments/webhook-deployment.tsx +++ b/src/app/project/[projectId]/apps/details/deployments/webhook-deployment.tsx @@ -1,59 +1,61 @@ -import { AppExtendedModel } from "@/shared/model/app-extended.model"; -import { useEffect, useState } from "react"; -import { createNewWebhookUrl } from "./actions"; -import { Button } from "@/components/ui/button"; -import { useConfirmDialog } from "@/frontend/states/zustand.states"; -import { Toast } from "@/frontend/utils/toast.utils"; -import { ClipboardCopy } from "lucide-react"; -import { toast } from "sonner"; -import { RolePermissionEnum } from "@/shared/model/role-extended.model"; +import { AppExtendedModel } from '@/shared/model/app-extended.model'; +import { useEffect, useState } from 'react'; +import { createNewWebhookUrl } from './actions'; +import { Button } from '@/components/ui/button'; +import { useConfirmDialog } from '@/frontend/states/zustand.states'; +import { Toast } from '@/frontend/utils/toast.utils'; +import { ClipboardCopy } from 'lucide-react'; +import { toast } from 'sonner'; +import { RolePermissionEnum } from '@/shared/model/role-extended.model'; -export default function WebhookDeploymentInfo({ - app, - role -}: { - app: AppExtendedModel; - role: RolePermissionEnum; -}) { - const { openConfirmDialog } = useConfirmDialog(); - const [webhookUrl, setWebhookUrl] = useState(undefined); +export default function WebhookDeploymentInfo({ app, role }: { app: AppExtendedModel; role: RolePermissionEnum }) { + const { openConfirmDialog } = useConfirmDialog(); + const [webhookUrl, setWebhookUrl] = useState(undefined); - const setWebhookId = (webhookId?: string | null | void) => { - if (webhookId) { - const hostname = window.location.hostname; - const port = window.location.port && ![80, 443].includes(Number(window.location.port)) ? `:${window.location.port}` : ''; - const protocol = window.location.protocol; - setWebhookUrl(`${protocol}//${hostname}${port}/api/v1/webhook/deploy?id=${webhookId}`); - } + const setWebhookId = (webhookId?: string | null | void) => { + if (webhookId) { + const hostname = window.location.hostname; + const port = window.location.port && ![80, 443].includes(Number(window.location.port)) ? `:${window.location.port}` : ''; + const protocol = window.location.protocol; + setWebhookUrl(`${protocol}//${hostname}${port}/api/v1/webhook/deploy?id=${webhookId}`); } + }; - useEffect(() => { - setWebhookId(app.webhookId); - }, [app, app.webhookId]); + useEffect(() => { + setWebhookId(app.webhookId); + }, [app, app.webhookId]); - const createNewWebhookUrlAsync = async () => { - if (!await openConfirmDialog({ - title: 'Generate new Webhook URL', - description: 'Are you sure you want to generate a new Webhook URL? The old URL will be invalidated.', - okButton: 'Generate new URL' - })) { - return; - } - const newWebhookId = await Toast.fromAction(() => createNewWebhookUrl(app.id), 'Webhook URL has been regenerated.'); - setWebhookId(newWebhookId.data); + const createNewWebhookUrlAsync = async () => { + if ( + !(await openConfirmDialog({ + title: 'Generate new Webhook URL', + description: 'Are you sure you want to generate a new Webhook URL? The old URL will be invalidated.', + okButton: 'Generate new URL' + })) + ) { + return; } + const newWebhookId = await Toast.fromAction(() => createNewWebhookUrl(app.id), 'Webhook URL has been regenerated.'); + setWebhookId(newWebhookId.data); + }; - const copyWebhookUrl = () => { - navigator.clipboard.writeText(webhookUrl!); - toast.success('Webhook URL has been copied to clipboard.'); - } + const copyWebhookUrl = () => { + navigator.clipboard.writeText(webhookUrl!); + toast.success('Webhook URL has been copied to clipboard.'); + }; - return ( -
- {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 17a3f04d..2ac19bca 100644 --- a/src/app/project/[projectId]/apps/details/runtime/monitoring-app.tsx +++ b/src/app/project/[projectId]/apps/details/runtime/monitoring-app.tsx @@ -1,43 +1,38 @@ -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 { usePolling } from "@/frontend/hooks/use-polling"; -import { PodsResourceInfoModel } from "@/shared/model/pods-resource-info.model"; +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 { usePolling } from '@/frontend/hooks/use-polling'; +import { PodsResourceInfoModel } from '@/shared/model/pods-resource-info.model'; -export default function MonitoringTab({ - app, -}: { - app: AppExtendedModel; -}) { +export default function MonitoringTab({ app }: { app: AppExtendedModel }) { + const [selectedPod, setSelectedPod] = useState(undefined); + const [error, setError] = useState(undefined); - const [selectedPod, setSelectedPod] = useState(undefined); - const [error, setError] = useState(undefined); + const updateValues = useCallback(async () => { + setError(undefined); + try { + 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.'); + } + }, [app.id, app.projectId]); - const updateValues = useCallback(async () => { - setError(undefined); - try { - 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.'); - } - }, [app.id, app.projectId]) + usePolling(updateValues, { intervalMs: 10000, runImmediately: true }); - usePolling(updateValues, { intervalMs: 10000, runImmediately: true }); - - return ( - <> - {error &&

Unable to load current App metrics: {error}

} - - - ); + 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 1c1ccdf3..668b1a4b 100644 --- a/src/app/project/[projectId]/apps/details/volumes/actions.ts +++ b/src/app/project/[projectId]/apps/details/volumes/actions.ts @@ -1,232 +1,216 @@ -'use server' - -import { appVolumeEditZodModel } from "@/shared/model/volume-edit.model"; -import { ServerActionResult, SuccessActionResult } from "@/shared/model/server-action-error-return.model"; -import appService from "@/server/services/app.service"; -import { isAuthorizedReadForApp, isAuthorizedWriteForApp, isAuthorizedWriteForWorkload, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils"; -import { z } from "zod"; -import { ServiceException } from "@/shared/model/service.exception.model"; -import pvcService from "@/server/services/pvc.service"; -import { fileMountEditZodModel } from "@/shared/model/file-mount-edit.model"; -import { VolumeBackupEditModel, volumeBackupEditZodModel } from "@/shared/model/backup-volume-edit.model"; -import volumeBackupService from "@/server/services/volume-backup.service"; -import backupService from "@/server/services/standalone-services/backup.service"; -import { volumeUploadZodModel } from "@/shared/model/volume-upload.model"; -import restoreService from "@/server/services/restore.service"; -import fileBrowserService from "@/server/services/file-browser-service"; -import monitoringService from "@/server/services/monitoring.service"; -import type { BackupEntry } from "@/shared/model/backup-info.model"; - -const actionAppVolumeEditZodModel = appVolumeEditZodModel.merge(z.object({ - appId: z.string(), - id: z.string().nullish() -})); +'use server'; + +import { appVolumeEditZodModel } from '@/shared/model/volume-edit.model'; +import { ServerActionResult, SuccessActionResult } from '@/shared/model/server-action-error-return.model'; +import appService from '@/server/services/app.service'; +import { + isAuthorizedReadForApp, + isAuthorizedWriteForApp, + isAuthorizedWriteForWorkload, + saveFormAction, + simpleAction +} from '@/server/utils/action-wrapper.utils'; +import { z } from 'zod'; +import { ServiceException } from '@/shared/model/service.exception.model'; +import pvcService from '@/server/services/pvc.service'; +import { fileMountEditZodModel } from '@/shared/model/file-mount-edit.model'; +import { VolumeBackupEditModel, volumeBackupEditZodModel } from '@/shared/model/backup-volume-edit.model'; +import volumeBackupService from '@/server/services/volume-backup.service'; +import backupService from '@/server/services/standalone-services/backup.service'; +import { volumeUploadZodModel } from '@/shared/model/volume-upload.model'; +import restoreService from '@/server/services/restore.service'; +import fileBrowserService from '@/server/services/file-browser-service'; +import monitoringService from '@/server/services/monitoring.service'; +import type { BackupEntry } from '@/shared/model/backup-info.model'; + +const actionAppVolumeEditZodModel = appVolumeEditZodModel.merge(z.object({ appId: z.string(), id: z.string().nullish() })); export const restoreVolumeFromZip = async (prevState: any, inputData: FormData, volumeId: string) => - simpleAction(async () => { - await validateVolumeWriteAuthorization(volumeId); - const validatedData = volumeUploadZodModel.parse({ - volumeId, - file: '' - }); - - const file = inputData.get('file') as File; - if (!file) { - throw new ServiceException('No file provided'); - } - await restoreService.restore(file, validatedData.volumeId); - return new SuccessActionResult(); - }); + simpleAction(async () => { + await validateVolumeWriteAuthorization(volumeId); + const validatedData = volumeUploadZodModel.parse({ volumeId, file: '' }); + + const file = inputData.get('file') as File; + if (!file) { + throw new ServiceException('No file provided'); + } + await restoreService.restore(file, validatedData.volumeId); + return new SuccessActionResult(); + }); export const saveVolume = async (prevState: any, inputData: z.infer) => - saveFormAction(inputData, actionAppVolumeEditZodModel, async (validatedData) => { - await isAuthorizedWriteForApp(validatedData.appId); - const existingApp = await appService.getExtendedById(validatedData.appId); - const existingVolume = validatedData.id ? await appService.getVolumeById(validatedData.id) : undefined; - const sharedVolumeId = existingVolume?.sharedVolumeId ?? validatedData.sharedVolumeId ?? undefined; - - if (sharedVolumeId) { - const sharedVolume = await appService.getVolumeWithAppById(sharedVolumeId); - if (sharedVolume.app.projectId !== existingApp.projectId) { - throw new ServiceException('Shared volumes must belong to the same project.'); - } - if (sharedVolume.appId === validatedData.appId) { - throw new ServiceException('Shared volumes must belong to a different app.'); - } - if (!sharedVolume.shareWithOtherApps || sharedVolume.accessMode !== 'ReadWriteMany') { - throw new ServiceException('This volume is not available for sharing.'); - } - await appService.saveVolume({ - appId: validatedData.appId, - id: validatedData.id ?? undefined, - containerMountPath: validatedData.containerMountPath, - size: sharedVolume.size, - accessMode: sharedVolume.accessMode, - storageClassName: sharedVolume.storageClassName, - shareWithOtherApps: false, - sharedVolumeId: sharedVolume.id - }); - return; - } - - if (existingVolume && existingVolume.size > validatedData.size) { - throw new ServiceException('Volume size cannot be decreased'); - } - if (existingVolume && existingVolume.storageClassName !== validatedData.storageClassName) { - throw new ServiceException('Storage class cannot be changed for existing volumes'); - } - if (existingApp.replicas > 1 && validatedData.accessMode === 'ReadWriteOnce') { - throw new ServiceException('Volume access mode must be ReadWriteMany because your app has more than one replica configured.'); - } - if (validatedData.accessMode === 'ReadWriteMany' && validatedData.storageClassName === 'local-path') { - throw new ServiceException('The Local Path storage class does not support ReadWriteMany access mode. Please choose another storage class / access mode.'); - } - if (validatedData.shareWithOtherApps && (existingVolume?.accessMode ?? validatedData.accessMode) !== 'ReadWriteMany') { - throw new ServiceException('Only ReadWriteMany volumes can be shared with other apps.'); - } - await appService.saveVolume({ - ...validatedData, - id: validatedData.id ?? undefined, - accessMode: existingVolume?.accessMode ?? validatedData.accessMode as string, - storageClassName: existingVolume?.storageClassName ?? validatedData.storageClassName, - shareWithOtherApps: validatedData.shareWithOtherApps ?? false, - sharedVolumeId: null - }); + saveFormAction(inputData, actionAppVolumeEditZodModel, async (validatedData) => { + await isAuthorizedWriteForApp(validatedData.appId); + const existingApp = await appService.getExtendedById(validatedData.appId); + const existingVolume = validatedData.id ? await appService.getVolumeById(validatedData.id) : undefined; + const sharedVolumeId = existingVolume?.sharedVolumeId ?? validatedData.sharedVolumeId ?? undefined; + + if (sharedVolumeId) { + const sharedVolume = await appService.getVolumeWithAppById(sharedVolumeId); + if (sharedVolume.app.projectId !== existingApp.projectId) { + throw new ServiceException('Shared volumes must belong to the same project.'); + } + if (sharedVolume.appId === validatedData.appId) { + throw new ServiceException('Shared volumes must belong to a different app.'); + } + if (!sharedVolume.shareWithOtherApps || sharedVolume.accessMode !== 'ReadWriteMany') { + throw new ServiceException('This volume is not available for sharing.'); + } + await appService.saveVolume({ + appId: validatedData.appId, + id: validatedData.id ?? undefined, + containerMountPath: validatedData.containerMountPath, + size: sharedVolume.size, + accessMode: sharedVolume.accessMode, + storageClassName: sharedVolume.storageClassName, + shareWithOtherApps: false, + sharedVolumeId: sharedVolume.id + }); + return; + } + + if (existingVolume && existingVolume.size > validatedData.size) { + throw new ServiceException('Volume size cannot be decreased'); + } + if (existingVolume && existingVolume.storageClassName !== validatedData.storageClassName) { + throw new ServiceException('Storage class cannot be changed for existing volumes'); + } + if (existingApp.replicas > 1 && validatedData.accessMode === 'ReadWriteOnce') { + throw new ServiceException('Volume access mode must be ReadWriteMany because your app has more than one replica configured.'); + } + if (validatedData.accessMode === 'ReadWriteMany' && validatedData.storageClassName === 'local-path') { + throw new ServiceException('The Local Path storage class does not support ReadWriteMany access mode. Please choose another storage class / access mode.'); + } + if (validatedData.shareWithOtherApps && (existingVolume?.accessMode ?? validatedData.accessMode) !== 'ReadWriteMany') { + throw new ServiceException('Only ReadWriteMany volumes can be shared with other apps.'); + } + await appService.saveVolume({ + ...validatedData, + id: validatedData.id ?? undefined, + accessMode: existingVolume?.accessMode ?? (validatedData.accessMode as string), + storageClassName: existingVolume?.storageClassName ?? validatedData.storageClassName, + shareWithOtherApps: validatedData.shareWithOtherApps ?? false, + sharedVolumeId: null }); + }); export const deleteVolume = async (volumeId: string) => - simpleAction(async () => { - await validateVolumeWriteAuthorization(volumeId); - await appService.deleteVolumeById(volumeId); - return new SuccessActionResult(undefined, 'Successfully deleted volume'); - }); + simpleAction(async () => { + await validateVolumeWriteAuthorization(volumeId); + await appService.deleteVolumeById(volumeId); + return new SuccessActionResult(undefined, 'Successfully deleted volume'); + }); export const getPvcUsage = async (appId: string, projectId: string) => - simpleAction(async () => { - await isAuthorizedReadForApp(appId); - return monitoringService.getPvcUsageFromApp(appId, projectId); - }) as Promise>; + simpleAction(async () => { + await isAuthorizedReadForApp(appId); + return monitoringService.getPvcUsageFromApp(appId, projectId); + }) as Promise>; export const getShareableVolumes = async (appId: string) => - simpleAction(async () => { - await isAuthorizedReadForApp(appId); - const app = await appService.getExtendedById(appId); - const volumes = await appService.getShareableVolumesByProjectId(app.projectId, appId); - return new SuccessActionResult(volumes); - }) as Promise>; + simpleAction(async () => { + await isAuthorizedReadForApp(appId); + const app = await appService.getExtendedById(appId); + const volumes = await appService.getShareableVolumesByProjectId(app.projectId, appId); + return new SuccessActionResult(volumes); + }) as Promise< + ServerActionResult + >; export const downloadPvcData = async (volumeId: string) => - simpleAction(async () => { - await validateVolumeReadAuthorization(volumeId); - const fileNameOfDownloadedFile = await pvcService.downloadPvcData(volumeId); - return new SuccessActionResult(fileNameOfDownloadedFile, 'Successfully zipped volume data'); // returns the download path on the server - }) as Promise>; + simpleAction(async () => { + await validateVolumeReadAuthorization(volumeId); + const fileNameOfDownloadedFile = await pvcService.downloadPvcData(volumeId); + return new SuccessActionResult(fileNameOfDownloadedFile, 'Successfully zipped volume data'); // returns the download path on the server + }) as Promise>; -const actionAppFileMountEditZodModel = fileMountEditZodModel.merge(z.object({ - appId: z.string(), - id: z.string().nullish() -})); +const actionAppFileMountEditZodModel = fileMountEditZodModel.merge(z.object({ appId: z.string(), id: z.string().nullish() })); export const saveFileMount = async (prevState: any, inputData: z.infer) => - saveFormAction(inputData, actionAppFileMountEditZodModel, async (validatedData) => { - await isAuthorizedWriteForApp(validatedData.appId); - await appService.saveFileMount({ - ...validatedData, - id: validatedData.id ?? undefined, - }); - }); + saveFormAction(inputData, actionAppFileMountEditZodModel, async (validatedData) => { + await isAuthorizedWriteForApp(validatedData.appId); + await appService.saveFileMount({ ...validatedData, id: validatedData.id ?? undefined }); + }); export const deleteFileMount = async (fileMountId: string) => - simpleAction(async () => { - await validateFileMountWriteAuthorization(fileMountId); - await appService.deleteFileMountById(fileMountId); - return new SuccessActionResult(undefined, 'Successfully deleted volume'); - }); + simpleAction(async () => { + await validateFileMountWriteAuthorization(fileMountId); + await appService.deleteFileMountById(fileMountId); + return new SuccessActionResult(undefined, 'Successfully deleted volume'); + }); export const saveBackupVolume = async (prevState: any, inputData: VolumeBackupEditModel) => - saveFormAction(inputData, volumeBackupEditZodModel, async (validatedData) => { - await validateVolumeWriteAuthorization(validatedData.volumeId); - if (validatedData.retention < 1) { - throw new ServiceException('Retention must be at least 1'); - } - await volumeBackupService.save({ - ...validatedData, - id: validatedData.id ?? undefined, - }); - await backupService.registerAllBackups(); - }); + saveFormAction(inputData, volumeBackupEditZodModel, async (validatedData) => { + await validateVolumeWriteAuthorization(validatedData.volumeId); + if (validatedData.retention < 1) { + throw new ServiceException('Retention must be at least 1'); + } + await volumeBackupService.save({ ...validatedData, id: validatedData.id ?? undefined }); + await backupService.registerAllBackups(); + }); export const deleteBackupVolume = async (backupVolumeId: string) => - simpleAction(async () => { - await validateBackupVolumeWriteAuthorization(backupVolumeId); - await volumeBackupService.deleteById(backupVolumeId); - await backupService.registerAllBackups(); - return new SuccessActionResult(undefined, 'Successfully deleted backup schedule'); - }); + simpleAction(async () => { + await validateBackupVolumeWriteAuthorization(backupVolumeId); + await volumeBackupService.deleteById(backupVolumeId); + await backupService.registerAllBackups(); + return new SuccessActionResult(undefined, 'Successfully deleted backup schedule'); + }); export const runBackupVolumeSchedule = async (backupVolumeId: string) => - simpleAction(async () => { - await validateBackupVolumeWriteAuthorization(backupVolumeId); - await backupService.runBackupForSchedule(backupVolumeId); - return new SuccessActionResult(undefined, 'Backup created and uploaded successfully'); - }); + simpleAction(async () => { + await validateBackupVolumeWriteAuthorization(backupVolumeId); + await backupService.runBackupForSchedule(backupVolumeId); + return new SuccessActionResult(undefined, 'Backup created and uploaded successfully'); + }); export const getBackupsForVolumeSchedule = async (backupVolumeId: string) => - simpleAction(async () => { - await validateBackupVolumeReadAuthorization(backupVolumeId); + simpleAction(async () => { + await validateBackupVolumeReadAuthorization(backupVolumeId); - const backups = await backupService.getBackupsForVolumeSchedule(backupVolumeId); - return new SuccessActionResult(backups); - }); + const backups = await backupService.getBackupsForVolumeSchedule(backupVolumeId); + return new SuccessActionResult(backups); + }); export const downloadBackupForVolumeSchedule = async (backupVolumeId: string, backupKey: string) => - simpleAction(async () => { - await validateBackupVolumeReadAuthorization(backupVolumeId); - - const validatedData = z.object({ - backupVolumeId: z.string(), - backupKey: z.string(), - }).parse({ backupVolumeId, backupKey }); - const volumeBackup = await volumeBackupService.getById(validatedData.backupVolumeId); - const fileName = await backupService.downloadBackupForS3TargetAndKey( - volumeBackup.targetId, - validatedData.backupKey, - ); - - return new SuccessActionResult(fileName, 'Starting download...'); - }) as Promise>; + simpleAction(async () => { + await validateBackupVolumeReadAuthorization(backupVolumeId); + + const validatedData = z.object({ backupVolumeId: z.string(), backupKey: z.string() }).parse({ backupVolumeId, backupKey }); + const volumeBackup = await volumeBackupService.getById(validatedData.backupVolumeId); + const fileName = await backupService.downloadBackupForS3TargetAndKey(volumeBackup.targetId, validatedData.backupKey); + + return new SuccessActionResult(fileName, 'Starting download...'); + }) as Promise>; export const openFileBrowserForVolume = async (volumeId: string) => - simpleAction(async () => { - await validateVolumeWriteAuthorization(volumeId); - const fileBrowserDomain = await fileBrowserService.deployFileBrowserForVolume(volumeId); - return new SuccessActionResult(fileBrowserDomain, 'File browser started successfully'); - }) as Promise>; + simpleAction(async () => { + await validateVolumeWriteAuthorization(volumeId); + const fileBrowserDomain = await fileBrowserService.deployFileBrowserForVolume(volumeId); + return new SuccessActionResult(fileBrowserDomain, 'File browser started successfully'); + }) as Promise>; async function validateVolumeWriteAuthorization(volumeId: string) { - const volume = await appService.getVolumeWithAppById(volumeId); - await isAuthorizedWriteForWorkload(volume.appId); + const volume = await appService.getVolumeWithAppById(volumeId); + await isAuthorizedWriteForWorkload(volume.appId); } async function validateVolumeReadAuthorization(volumeId: string) { - const volume = await appService.getVolumeWithAppById(volumeId); - await isAuthorizedReadForApp(volume.appId); + const volume = await appService.getVolumeWithAppById(volumeId); + await isAuthorizedReadForApp(volume.appId); } async function validateFileMountWriteAuthorization(fileMountId: string) { - const appId = await appService.getFileMountAppId(fileMountId); - await isAuthorizedWriteForApp(appId); + const appId = await appService.getFileMountAppId(fileMountId); + await isAuthorizedWriteForApp(appId); } async function validateBackupVolumeWriteAuthorization(backupVolumeId: string) { - const appId = await volumeBackupService.getAppIdById(backupVolumeId); - await isAuthorizedWriteForApp(appId); + const appId = await volumeBackupService.getAppIdById(backupVolumeId); + await isAuthorizedWriteForApp(appId); } async function validateBackupVolumeReadAuthorization(backupVolumeId: string) { - const appId = await volumeBackupService.getAppIdById(backupVolumeId); - await isAuthorizedReadForApp(appId); + const appId = await volumeBackupService.getAppIdById(backupVolumeId); + await isAuthorizedReadForApp(appId); } 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 bfd14738..62672a2b 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 @@ -12,11 +12,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { Button } from '@/components/ui/button'; import { SplitButton } from '@/components/custom/split-button'; import VolumeBackupList from '@/app/project/[projectId]/apps/details/volumes/backups/volume-backup'; -import { - downloadBackupForVolumeSchedule, - getBackupsForVolumeSchedule, - runBackupVolumeSchedule, -} from '@/app/project/[projectId]/apps/details/volumes/actions'; +import { downloadBackupForVolumeSchedule, getBackupsForVolumeSchedule, runBackupVolumeSchedule } from '@/app/project/[projectId]/apps/details/volumes/actions'; import { formatDateTime } from '@/frontend/utils/format.utils'; import { Actions } from '@/frontend/utils/nextjs-actions.utils'; import { Toast } from '@/frontend/utils/toast.utils'; @@ -30,172 +26,201 @@ import { useNestedDrawer } from '../../../../../workload-components/drawer/neste type BackupRow = BackupEntry & { schedule: VolumeBackupExtendedModel }; -export function DrawerBackupsTab({ app, role, s3Targets, volumeBackups }: { - app: AppExtendedModel; - role: RolePermissionEnum; - s3Targets: S3Target[]; - volumeBackups: VolumeBackupExtendedModel[]; +export function DrawerBackupsTab({ + app, + role, + s3Targets, + volumeBackups +}: { + app: AppExtendedModel; + role: RolePermissionEnum; + s3Targets: S3Target[]; + volumeBackups: VolumeBackupExtendedModel[]; }) { - const { openNestedDrawer } = useNestedDrawer(); - const { openConfirmDialog } = useConfirmDialog(); - const [backups, setBackups] = useState(); - const [hasLoadError, setHasLoadError] = useState(false); - const [downloadingBackup, setDownloadingBackup] = useState(); - const [runningSchedule, setRunningSchedule] = useState(); - const readonly = role !== RolePermissionEnum.READWRITE; + const { openNestedDrawer } = useNestedDrawer(); + const { openConfirmDialog } = useConfirmDialog(); + const [backups, setBackups] = useState(); + const [hasLoadError, setHasLoadError] = useState(false); + const [downloadingBackup, setDownloadingBackup] = useState(); + const [runningSchedule, setRunningSchedule] = useState(); + const readonly = role !== RolePermissionEnum.READWRITE; - const loadBackups = useCallback(async () => { - setBackups(undefined); - setHasLoadError(false); - try { - const groups = await Promise.all(volumeBackups.map(async (schedule) => { - const scheduleBackups = await Actions.run(() => getBackupsForVolumeSchedule(schedule.id)); - return scheduleBackups.map((backup) => ({ ...backup, schedule })); - })); - setBackups( - groups.flat().sort((a, b) => b.backupDate.getTime() - a.backupDate.getTime()), - ); - } catch { - setHasLoadError(true); - } - }, [volumeBackups]); + const loadBackups = useCallback(async () => { + setBackups(undefined); + setHasLoadError(false); + try { + const groups = await Promise.all( + volumeBackups.map(async (schedule) => { + const scheduleBackups = await Actions.run(() => getBackupsForVolumeSchedule(schedule.id)); + return scheduleBackups.map((backup) => ({ ...backup, schedule })); + }) + ); + setBackups(groups.flat().sort((a, b) => b.backupDate.getTime() - a.backupDate.getTime())); + } catch { + setHasLoadError(true); + } + }, [volumeBackups]); - useEffect(() => { - void loadBackups(); - }, [loadBackups]); + useEffect(() => { + void loadBackups(); + }, [loadBackups]); - const scheduleLabels = useMemo(() => new Map( - volumeBackups.map((schedule) => [schedule.id, backupType(app, schedule)]), - ), [app, volumeBackups]); + const scheduleLabels = useMemo(() => new Map(volumeBackups.map((schedule) => [schedule.id, backupType(app, schedule)])), [app, volumeBackups]); - const showSchedules = () => openNestedDrawer({ - title: 'Backup Schedules', - description: 'Configure backup schedules for this app.', - content: ( - - ), + const showSchedules = () => + openNestedDrawer({ + title: 'Backup Schedules', + description: 'Configure backup schedules for this app.', + content: }); - const downloadBackup = async (backup: BackupRow) => { - const backupId = `${backup.schedule.id}:${backup.key}`; - try { - setDownloadingBackup(backupId); - const result = await Toast.fromAction(() => - downloadBackupForVolumeSchedule(backup.schedule.id, backup.key), - ); - if (result.status === 'success' && result.data) { - window.open(`/api/volume-data-download?${new URLSearchParams({ fileName: result.data }).toString()}`); - } - } finally { - setDownloadingBackup(undefined); - } - }; - - const runSchedule = async (schedule: VolumeBackupExtendedModel) => { - const confirmed = await openConfirmDialog({ - title: 'Create Backup', - description: `Create a ${scheduleLabels.get(schedule.id)?.toLowerCase()} now?`, - okButton: 'Create Backup', - }); - if (!confirmed) return; - try { - setRunningSchedule(schedule.id); - await Toast.fromAction( - () => runBackupVolumeSchedule(schedule.id), - undefined, - 'Creating backup...', - ); - await loadBackups(); - } finally { - setRunningSchedule(undefined); - } - }; + const downloadBackup = async (backup: BackupRow) => { + const backupId = `${backup.schedule.id}:${backup.key}`; + try { + setDownloadingBackup(backupId); + const result = await Toast.fromAction(() => downloadBackupForVolumeSchedule(backup.schedule.id, backup.key)); + if (result.status === 'success' && result.data) { + window.open(`/api/volume-data-download?${new URLSearchParams({ fileName: result.data }).toString()}`); + } + } finally { + setDownloadingBackup(undefined); + } + }; - if (volumeBackups.length === 0) { - return - - - No backup schedules - Create a backup schedule to protect this app's volume data. - - {!readonly && } - ; + const runSchedule = async (schedule: VolumeBackupExtendedModel) => { + const confirmed = await openConfirmDialog({ + title: 'Create Backup', + description: `Create a ${scheduleLabels.get(schedule.id)?.toLowerCase()} now?`, + okButton: 'Create Backup' + }); + if (!confirmed) return; + try { + setRunningSchedule(schedule.id); + await Toast.fromAction(() => runBackupVolumeSchedule(schedule.id), undefined, 'Creating backup...'); + await loadBackups(); + } finally { + setRunningSchedule(undefined); } + }; - // ScrollArea renders its vertical scrollbar over the right edge. Reserve space - // so the split-button trigger stays reachable instead of sitting underneath it. - return
-
-
Backups
- {!readonly && - Run backup now - {volumeBackups.map((schedule) => void runSchedule(schedule)}> - {scheduleLabels.get(schedule.id)} · {schedule.target.name} - )} - } - > - Edit Backup Schedules - } -
+ if (volumeBackups.length === 0) { + return ( + + + + + + No backup schedules + Create a backup schedule to protect this app's volume data. + + {!readonly && } + + ); + } + + // ScrollArea renders its vertical scrollbar over the right edge. Reserve space + // so the split-button trigger stays reachable instead of sitting underneath it. + return ( +
+
+
Backups
+ {!readonly && ( + + Run backup now + {volumeBackups.map((schedule) => ( + void runSchedule(schedule)}> + {scheduleLabels.get(schedule.id)} · {schedule.target.name} + + ))} + + } + > + Edit Backup Schedules + + )} +
- {hasLoadError ? - Backups could not be loaded - Check the backup storage connection and try again. - : !backups ?
: backups.length === 0 ? - - - No backups yet - None of this app's backup schedules has created a backup yet. - - : - - Created - Size - Backup Location - Type - Actions - - {backups.map((backup) => { - const backupId = `${backup.schedule.id}:${backup.key}`; - return - {formatDateTime(backup.backupDate, true)} - {backup.sizeBytes ? KubeSizeConverter.convertBytesToReadableSize(backup.sizeBytes) : 'Unknown'} -
{backup.schedule.target.name}
- - - }> - {app.appType !== 'APP' && backup.schedule.useDatabaseBackup - ? - {backupType(app, backup.schedule)} - - - -
; - })}
-
} -
; + {hasLoadError ? ( + + Backups could not be loaded + Check the backup storage connection and try again. + + ) : !backups ? ( +
+ +
+ ) : backups.length === 0 ? ( + + + + + + No backups yet + None of this app's backup schedules has created a backup yet. + + + ) : ( + + + + Created + Size + Backup Location + Type + + Actions + + + + + {backups.map((backup) => { + const backupId = `${backup.schedule.id}:${backup.key}`; + return ( + + {formatDateTime(backup.backupDate, true)} + {backup.sizeBytes ? KubeSizeConverter.convertBytesToReadableSize(backup.sizeBytes) : 'Unknown'} + +
+ {backup.schedule.target.name} +
+
+ + + }> + {app.appType !== 'APP' && backup.schedule.useDatabaseBackup ? ( + + {backupType(app, backup.schedule)} + + + + + +
+ ); + })} +
+
+ )} +
+ ); } function backupType(app: AppExtendedModel, schedule: VolumeBackupExtendedModel) { - return app.appType !== 'APP' && schedule.useDatabaseBackup - ? `Database (${app.appType.toLowerCase()})` - : 'Volume archive'; + return app.appType !== 'APP' && schedule.useDatabaseBackup ? `Database (${app.appType.toLowerCase()})` : 'Volume archive'; } 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 ccdab001..36ef3cc5 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 @@ -1,169 +1,141 @@ -'use client' +'use client'; -import type { z } from "zod"; -import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import { zodResolver } from "@hookform/resolvers/zod" -import { useForm } from "react-hook-form" +import type { z } from 'zod'; +import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; -import { useActionState, useEffect, useState } from "react"; -import { FormUtils } from "@/frontend/utils/form.utilts"; -import { SubmitButton } from "@/components/custom/submit-button"; -import { AppVolumeEditModel, appVolumeEditZodModel } from "@/shared/model/volume-edit.model" -import { ServerActionResult } from "@/shared/model/server-action-error-return.model" -import { saveVolume, getShareableVolumes } from "./actions" -import { toast } from "sonner" -import { AppExtendedModel } from "@/shared/model/app-extended.model" -import SelectFormField from "@/components/custom/select-form-field" -import { Alert, AlertDescription } from "@/components/ui/alert" -import { Info } from "lucide-react" -import { useDialog } from "@/frontend/states/zustand.states"; +import { useActionState, useEffect, useState } from 'react'; +import { FormUtils } from '@/frontend/utils/form.utilts'; +import { SubmitButton } from '@/components/custom/submit-button'; +import { AppVolumeEditModel, appVolumeEditZodModel } from '@/shared/model/volume-edit.model'; +import { ServerActionResult } from '@/shared/model/server-action-error-return.model'; +import { saveVolume, getShareableVolumes } from './actions'; +import { toast } from 'sonner'; +import { AppExtendedModel } from '@/shared/model/app-extended.model'; +import SelectFormField from '@/components/custom/select-form-field'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Info } from 'lucide-react'; +import { useDialog } from '@/frontend/states/zustand.states'; -type ShareableVolume = { - id: string; - containerMountPath: string; - size: number; - storageClassName: string; - accessMode: string; - app: { name: string }; -}; +type ShareableVolume = { id: string; containerMountPath: string; size: number; storageClassName: string; accessMode: string; app: { name: string } }; -export default function SharedStorageEditDialog({ app }: { - app: AppExtendedModel; -}) { +export default function SharedStorageEditDialog({ app }: { app: AppExtendedModel }) { + const { closeDialog } = useDialog(); + const [shareableVolumes, setShareableVolumes] = useState([]); + const [isLoadingVolumes, setIsLoadingVolumes] = useState(false); - const { closeDialog } = useDialog(); - const [shareableVolumes, setShareableVolumes] = useState([]); - const [isLoadingVolumes, setIsLoadingVolumes] = useState(false); + const form = useForm, unknown, z.output>({ + resolver: zodResolver(appVolumeEditZodModel), + defaultValues: { containerMountPath: '', size: 0, accessMode: 'ReadWriteMany', storageClassName: 'longhorn', sharedVolumeId: undefined } + }); - const form = useForm, unknown, z.output>({ - resolver: zodResolver(appVolumeEditZodModel), - defaultValues: { - containerMountPath: '', - size: 0, - accessMode: 'ReadWriteMany', - storageClassName: 'longhorn', - sharedVolumeId: undefined, - } - }); - - const [state, formAction] = useActionState((state: ServerActionResult, payload: AppVolumeEditModel) => - saveVolume(state, { - ...payload, - appId: app.id, - id: undefined - }), FormUtils.getInitialFormState()); + const [state, formAction] = useActionState( + (state: ServerActionResult, payload: AppVolumeEditModel) => saveVolume(state, { ...payload, appId: app.id, id: undefined }), + FormUtils.getInitialFormState() + ); - // Fetch shareable volumes when dialog opens - useEffect(() => { - setIsLoadingVolumes(true); - getShareableVolumes(app.id).then(result => { - if (result.status === 'success' && result.data) { - const alreadyAddedSharedVolumes = app.appVolumes - .filter(v => !!v.sharedVolumeId) - .map(v => v.sharedVolumeId); - setShareableVolumes(result.data.filter(v => !alreadyAddedSharedVolumes.includes(v.id))); - } else { - setShareableVolumes([]); - toast.error('An error occurred while fetching shareable volumes'); - } - setIsLoadingVolumes(false); - }); - }, [app.id, app.appVolumes]); + // Fetch shareable volumes when dialog opens + useEffect(() => { + setIsLoadingVolumes(true); + getShareableVolumes(app.id).then((result) => { + if (result.status === 'success' && result.data) { + const alreadyAddedSharedVolumes = app.appVolumes.filter((v) => !!v.sharedVolumeId).map((v) => v.sharedVolumeId); + setShareableVolumes(result.data.filter((v) => !alreadyAddedSharedVolumes.includes(v.id))); + } else { + setShareableVolumes([]); + toast.error('An error occurred while fetching shareable volumes'); + } + setIsLoadingVolumes(false); + }); + }, [app.id, app.appVolumes]); - // Watch selected volume and auto-fill fields - const watchedSharedVolumeId = form.watch("sharedVolumeId"); - useEffect(() => { - if (watchedSharedVolumeId) { - const selectedVolume = shareableVolumes.find(v => v.id === watchedSharedVolumeId); - if (selectedVolume) { - form.setValue("size", selectedVolume.size); - form.setValue("accessMode", selectedVolume.accessMode); - form.setValue("storageClassName", selectedVolume.storageClassName); - } - } - }, [watchedSharedVolumeId, shareableVolumes, form]); + // Watch selected volume and auto-fill fields + const watchedSharedVolumeId = form.watch('sharedVolumeId'); + useEffect(() => { + if (watchedSharedVolumeId) { + const selectedVolume = shareableVolumes.find((v) => v.id === watchedSharedVolumeId); + if (selectedVolume) { + form.setValue('size', selectedVolume.size); + form.setValue('accessMode', selectedVolume.accessMode); + form.setValue('storageClassName', selectedVolume.storageClassName); + } + } + }, [watchedSharedVolumeId, shareableVolumes, form]); - useEffect(() => { - if (state.status === 'success') { - form.reset(); - toast.success('Shared volume mounted successfully', { - description: "Click \"deploy\" to apply the changes to your app.", - }); - closeDialog(); - } - FormUtils.mapValidationErrorsToForm(state, form); - }, [closeDialog, form, state]); + useEffect(() => { + if (state.status === 'success') { + form.reset(); + toast.success('Shared volume mounted successfully', { description: 'Click "deploy" to apply the changes to your app.' }); + closeDialog(); + } + FormUtils.mapValidationErrorsToForm(state, form); + }, [closeDialog, form, state]); - return ( - <> - - Mount Shared Volume - - Mount an existing ReadWriteMany volume from another app in this project. - - -
- form.handleSubmit((data) => { - return formAction(data); - })()}> -
- {isLoadingVolumes ? ( -
Loading shareable volumes...
- ) : shareableVolumes.length === 0 ? ( - - - - No shareable volumes available. Create a ReadWriteMany volume in another app and enable sharing first. - - - ) : ( - <> - [ - v.id, - `${v.app.name} - ${v.containerMountPath} (${v.size}MiB)` - ])} - placeholder="Select volume to share..." - /> + return ( + <> + + Mount Shared Volume + Mount an existing ReadWriteMany volume from another app in this project. + + + + form.handleSubmit((data) => { + return formAction(data); + })() + } + > +
+ {isLoadingVolumes ? ( +
Loading shareable volumes...
+ ) : shareableVolumes.length === 0 ? ( + + + No shareable volumes available. Create a ReadWriteMany volume in another app and enable sharing first. + + ) : ( + <> + [v.id, `${v.app.name} - ${v.containerMountPath} (${v.size}MiB)`])} + placeholder="Select volume to share..." + /> - ( - - Mount Path in This Container - - - - - - )} - /> + ( + + Mount Path in This Container + + + + + + )} + /> -
-

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

-

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

-
- - )} +
+

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

+

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

+
+ + )} -

{state.message}

- {shareableVolumes.length > 0 && Mount Shared Volume} -
- - - - ) +

{state.message}

+ {shareableVolumes.length > 0 && Mount 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 3da657f0..1bf02246 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 @@ -1,60 +1,38 @@ -'use client' +'use client'; -import type { z } from "zod"; -import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { - Command, - CommandGroup, - CommandItem, - CommandList, -} from "@/components/ui/command" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" -import { Input } from "@/components/ui/input" -import { cn } from "@/frontend/utils/utils" -import { Button } from "@/components/ui/button" -import { Check, ChevronsUpDown, CircleHelp } from "lucide-react" -import { zodResolver } from "@hookform/resolvers/zod" -import { useForm } from "react-hook-form" +import type { z } from 'zod'; +import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Input } from '@/components/ui/input'; +import { cn } from '@/frontend/utils/utils'; +import { Button } from '@/components/ui/button'; +import { Check, ChevronsUpDown, CircleHelp } from 'lucide-react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; -import { useActionState, useEffect } from "react"; -import { FormUtils } from "@/frontend/utils/form.utilts"; -import { SubmitButton } from "@/components/custom/submit-button"; -import { AppVolume } from "@prisma/client" -import { AppVolumeEditModel, appVolumeEditZodModel } from "@/shared/model/volume-edit.model" -import { ServerActionResult } from "@/shared/model/server-action-error-return.model" -import { saveVolume } from "./actions" -import { toast } from "sonner" -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { AppExtendedModel } from "@/shared/model/app-extended.model" -import CheckboxFormField from "@/components/custom/checkbox-form-field" -import StorageClassCombobox from "@/components/custom/storage-class-combobox" -import { useDialog } from "@/frontend/states/zustand.states"; -import { StorageClassUtils } from "@/shared/utils/storage-class.utils"; +import { useActionState, useEffect } from 'react'; +import { FormUtils } from '@/frontend/utils/form.utilts'; +import { SubmitButton } from '@/components/custom/submit-button'; +import { AppVolume } from '@prisma/client'; +import { AppVolumeEditModel, appVolumeEditZodModel } from '@/shared/model/volume-edit.model'; +import { ServerActionResult } from '@/shared/model/server-action-error-return.model'; +import { saveVolume } from './actions'; +import { toast } from 'sonner'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { AppExtendedModel } from '@/shared/model/app-extended.model'; +import CheckboxFormField from '@/components/custom/checkbox-form-field'; +import StorageClassCombobox from '@/components/custom/storage-class-combobox'; +import { useDialog } from '@/frontend/states/zustand.states'; +import { StorageClassUtils } from '@/shared/utils/storage-class.utils'; const accessModes = [ - { label: "ReadWriteOnce", value: "ReadWriteOnce" }, - { label: "ReadWriteMany", value: "ReadWriteMany" }, -] as const - -export default function StorageEditDialog({ volume, app, storageClasses }: { - volume?: AppVolume; - app: AppExtendedModel; - storageClasses: string[]; -}) { + { label: 'ReadWriteOnce', value: 'ReadWriteOnce' }, + { label: 'ReadWriteMany', value: 'ReadWriteMany' } +] as const; +export default function StorageEditDialog({ volume, app, storageClasses }: { volume?: AppVolume; app: AppExtendedModel; storageClasses: string[] }) { const { closeDialog } = useDialog(); const defaultStorageClassName = volume?.storageClassName ?? StorageClassUtils.getDefaultStorageClass(storageClasses); @@ -63,33 +41,28 @@ export default function StorageEditDialog({ volume, app, storageClasses }: { defaultValues: { containerMountPath: volume?.containerMountPath ?? '', size: volume?.size ?? 0, - accessMode: volume?.accessMode ?? (app.replicas > 1 ? "ReadWriteMany" : "ReadWriteOnce"), + accessMode: volume?.accessMode ?? (app.replicas > 1 ? 'ReadWriteMany' : 'ReadWriteOnce'), storageClassName: defaultStorageClassName, shareWithOtherApps: volume?.shareWithOtherApps ?? false, - sharedVolumeId: volume?.sharedVolumeId ?? undefined, + sharedVolumeId: volume?.sharedVolumeId ?? undefined } }); // Watch accessMode to conditionally show shareWithOtherApps checkbox - const watchedAccessMode = form.watch("accessMode"); - const watchedStorageClassName = form.watch("storageClassName"); - const canBeShared = (!!volume ? volume.accessMode : watchedAccessMode === "ReadWriteMany") && - watchedStorageClassName !== "local-path" && - !volume?.sharedVolumeId; + const watchedAccessMode = form.watch('accessMode'); + const watchedStorageClassName = form.watch('storageClassName'); + const canBeShared = + (!!volume ? volume.accessMode : watchedAccessMode === 'ReadWriteMany') && watchedStorageClassName !== 'local-path' && !volume?.sharedVolumeId; - const [state, formAction] = useActionState((state: ServerActionResult, payload: AppVolumeEditModel) => - saveVolume(state, { - ...payload, - appId: app.id, - id: volume?.id - }), FormUtils.getInitialFormState()); + const [state, formAction] = useActionState( + (state: ServerActionResult, payload: AppVolumeEditModel) => saveVolume(state, { ...payload, appId: app.id, id: volume?.id }), + FormUtils.getInitialFormState() + ); useEffect(() => { if (state.status === 'success') { form.reset(); - toast.success('Volume saved successfully', { - description: "Click \"deploy\" to apply the changes to your app.", - }); + toast.success('Volume saved successfully', { description: 'Click "deploy" to apply the changes to your app.' }); closeDialog(); } FormUtils.mapValidationErrorsToForm(state, form); @@ -98,10 +71,10 @@ export default function StorageEditDialog({ volume, app, storageClasses }: { useEffect(() => { form.reset({ ...volume, - accessMode: volume?.accessMode ?? (app.replicas > 1 ? "ReadWriteMany" : "ReadWriteOnce"), + accessMode: volume?.accessMode ?? (app.replicas > 1 ? 'ReadWriteMany' : 'ReadWriteOnce'), storageClassName: defaultStorageClassName, shareWithOtherApps: volume?.shareWithOtherApps ?? false, - sharedVolumeId: volume?.sharedVolumeId ?? undefined, + sharedVolumeId: volume?.sharedVolumeId ?? undefined }); }, [app.replicas, defaultStorageClassName, form, volume]); @@ -110,179 +83,169 @@ export default function StorageEditDialog({ volume, app, storageClasses }: { return ( <> - - Edit Volume - - Configure your custom volume for this container. - - -
- form.handleSubmit((data) => { + + Edit Volume + Configure your custom volume for this container. + + + + form.handleSubmit((data) => { return formAction(data); - })()}> -
- ( - - Mount Path Container - - - - - - )} - /> + })() + } + > +
+ ( + + Mount Path Container + + + + + + )} + /> - ( - - Size in MiB - - - - - - )} - /> + ( + + Size in MiB + + + + + + )} + /> - {volume && volume.size !== values.size && volume.shareWithOtherApps && <> -

- When changing the size of a shared volume, ensure that all apps using this volume are shut down before deploying the changes. -

- } + {volume && volume.size !== values.size && volume.shareWithOtherApps && ( + <> +

+ When changing the size of a shared volume, ensure that all apps using this volume are shut down before deploying the changes. +

+ + )} - ( - - -
Access Mode
-
- - - } /> - -

- In most cases you will want to use ReadWriteOnce. - This means that the volume can be mounted only by a single container instance.

- If you want to run multiple instances/replicas of the same container, you will need to use ReadWriteMany. - This will allow multiple container instances to use the same storage on the same volume.

- After creation the access mode cannot be changed. -

-
-
-
-
-
- - - - } /> - - - - - {accessModes.map((accessMode) => ( - { - form.setValue("accessMode", accessMode.value) - }} - > - {accessMode.label} - - - ))} - - - - - - - This cannot be changed after creation. - - -
- )} - /> - ( - - -
Storage Class
-
- - - } /> - -

- Choose where the volume is provisioned.

- Longhorn keeps data replicated across nodes.
- Local Path stores data on a the master node and works only in single-node clusters. -

-
-
-
-
-
+ ( + + +
Access Mode
+
+ + + } /> + +

+ In most cases you will want to use ReadWriteOnce. This means that the volume can be mounted only by a single container instance. +
+
+ If you want to run multiple instances/replicas of the same container, you will need to use ReadWriteMany. This will allow multiple + container instances to use the same storage on the same volume. +
+
+ After creation the access mode cannot be changed. +

+
+
+
+
+
+ + - form.setValue("storageClassName", value)} - /> + className={cn('w-[200px] justify-between', !field.value && 'text-muted-foreground')} + > + {field.value ? accessModes.find((accessMode) => accessMode.value === field.value)?.label : 'Select accessMode'} + + - - This cannot be changed after creation. - - -
- )} - /> - {canBeShared && ( - - )} -

{state.message}

- Save -
- - + } + /> + + + + + {accessModes.map((accessMode) => ( + { + form.setValue('accessMode', accessMode.value); + }} + > + {accessMode.label} + + + ))} + + + + + + This cannot be changed after creation. + + + )} + /> + ( + + +
Storage Class
+
+ + + } /> + +

+ Choose where the volume is provisioned. +
+
+ Longhorn keeps data replicated across nodes. +
+ Local Path stores data on a the master node and works only in single-node clusters. +

+
+
+
+
+
+ + form.setValue('storageClassName', value)} + /> + + This cannot be changed after creation. + +
+ )} + /> + {canBeShared && } +

{state.message}

+ Save +
+ + - ) + ); } 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 82c22b79..2e21ac6e 100644 --- a/src/app/project/[projectId]/apps/details/volumes/volumes-list.tsx +++ b/src/app/project/[projectId]/apps/details/volumes/volumes-list.tsx @@ -1,249 +1,257 @@ 'use client'; -import { CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; -import { AppExtendedModel } from "@/shared/model/app-extended.model"; -import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Button } from "@/components/ui/button"; -import { Download, EditIcon, Folder, TrashIcon, Share2, Unlink, Plus, FolderPlus } from "lucide-react"; -import DialogEditDialog from "./storage-edit-overlay"; -import SharedStorageEditDialog from "./shared-storage-edit-overlay"; -import { Toast } from "@/frontend/utils/toast.utils"; -import { deleteVolume, downloadPvcData, getPvcUsage, openFileBrowserForVolume } from "./actions"; -import { useConfirmDialog, useDialog } from "@/frontend/states/zustand.states"; -import { AppVolume } from "@prisma/client"; -import React from "react"; -import { KubeObjectNameUtils } from "@/server/utils/kube-object-name.utils"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip" -import { Code } from "@/components/custom/code"; -import { Label } from "@/components/ui/label"; -import { KubeSizeConverter } from "@/shared/utils/kubernetes-size-converter.utils"; -import { Progress } from "@/components/ui/progress"; +import { CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { AppExtendedModel } from '@/shared/model/app-extended.model'; +import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { Download, EditIcon, Folder, TrashIcon, Share2, Unlink, Plus, FolderPlus } from 'lucide-react'; +import DialogEditDialog from './storage-edit-overlay'; +import SharedStorageEditDialog from './shared-storage-edit-overlay'; +import { Toast } from '@/frontend/utils/toast.utils'; +import { deleteVolume, downloadPvcData, getPvcUsage, openFileBrowserForVolume } from './actions'; +import { useConfirmDialog, useDialog } from '@/frontend/states/zustand.states'; +import { AppVolume } from '@prisma/client'; +import React from 'react'; +import { KubeObjectNameUtils } from '@/server/utils/kube-object-name.utils'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { Code } from '@/components/custom/code'; +import { Label } from '@/components/ui/label'; +import { KubeSizeConverter } from '@/shared/utils/kubernetes-size-converter.utils'; +import { Progress } from '@/components/ui/progress'; -type AppVolumeWithCapacity = (AppVolume & { - usedBytes?: number; - capacityBytes?: number; - usedPercentage?: number; - usageStatus?: string; -}); +type AppVolumeWithCapacity = AppVolume & { usedBytes?: number; capacityBytes?: number; usedPercentage?: number; usageStatus?: string }; -export default function StorageList({ app, readonly, storageClasses }: { - app: AppExtendedModel; - storageClasses: string[]; - readonly: boolean; -}) { +export default function StorageList({ app, readonly, storageClasses }: { app: AppExtendedModel; storageClasses: string[]; readonly: boolean }) { + const [volumesWithStorage, setVolumesWithStorage] = React.useState(app.appVolumes as AppVolumeWithCapacity[]); + const [isLoading, setIsLoading] = React.useState(false); - const [volumesWithStorage, setVolumesWithStorage] = React.useState(app.appVolumes as AppVolumeWithCapacity[]); - const [isLoading, setIsLoading] = React.useState(false); + const loadAndMapStorageData = React.useCallback(async () => { + const response = await getPvcUsage(app.id, app.projectId); - const loadAndMapStorageData = React.useCallback(async () => { - - const response = (await getPvcUsage(app.id, app.projectId)); - - if (response.status === 'success' && response.data) { - const mappedVolumeData = [...app.appVolumes] as AppVolumeWithCapacity[]; - for (let item of mappedVolumeData) { - const volume = response.data.find(x => x.pvcName === KubeObjectNameUtils.toPvcName(item.sharedVolumeId || item.id)); - if (volume) { - item.usedBytes = volume.usedBytes; - 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); - } else { - console.error(response); + if (response.status === 'success' && response.data) { + const mappedVolumeData = [...app.appVolumes] as AppVolumeWithCapacity[]; + for (let item of mappedVolumeData) { + const volume = response.data.find((x) => x.pvcName === KubeObjectNameUtils.toPvcName(item.sharedVolumeId || item.id)); + if (volume) { + item.usedBytes = volume.usedBytes; + item.capacityBytes = volume.capacityBytes; + item.usageStatus = volume.status; + item.usedPercentage = + volume.usedBytes !== undefined && volume.capacityBytes ? Math.round((volume.usedBytes / volume.capacityBytes) * 100) : undefined; } - }, [app.appVolumes, app.id, app.projectId]) + } + setVolumesWithStorage(mappedVolumeData); + } else { + console.error(response); + } + }, [app.appVolumes, app.id, app.projectId]); - React.useEffect(() => { - loadAndMapStorageData(); - }, [loadAndMapStorageData]); + React.useEffect(() => { + loadAndMapStorageData(); + }, [loadAndMapStorageData]); - const { openConfirmDialog: openDialog } = useConfirmDialog(); - const { openDialog: openGenericDialog } = useDialog(); + const { openConfirmDialog: openDialog } = useConfirmDialog(); + const { openDialog: openGenericDialog } = useDialog(); - const asyncDeleteVolume = async (volumeId: string, isBaseVolume: boolean) => { - try { - const confirm = await openDialog({ - title: isBaseVolume ? "Delete Volume" : "Detach Volume", - description: isBaseVolume ? "The volume will be removed and the Data will be lost. The changes will take effect, after you deploy the app. Are you sure you want to remove this volume?" : - "The volume will be detached from the app. The data will remain on the cluster and can be re-attached later. The changes will take effect, after you deploy the app. Are you sure you want to detach this volume?", - okButton: isBaseVolume ? "Delete Volume" : "Detach Volume" - }); - if (confirm) { - setIsLoading(true); - await Toast.fromAction(() => deleteVolume(volumeId)); - } - } finally { - setIsLoading(false); - } - }; - - const asyncDownloadPvcData = async (volumeId: string) => { - try { - const confirm = await openDialog({ - title: "Download Volume Data", - description: "The volume data will be zipped and downloaded. Depending on the size of the volume this can take a while. Are you sure you want to download the volume data?", - okButton: "Download" - }); - if (confirm) { - setIsLoading(true); - await Toast.fromAction(() => downloadPvcData(volumeId)).then(x => { - if (x.status === 'success' && x.data) { - window.open('/api/volume-data-download?fileName=' + x.data); - } - }); - } - } finally { - setIsLoading(false); - } + const asyncDeleteVolume = async (volumeId: string, isBaseVolume: boolean) => { + try { + const confirm = await openDialog({ + title: isBaseVolume ? 'Delete Volume' : 'Detach Volume', + description: isBaseVolume + ? 'The volume will be removed and the Data will be lost. The changes will take effect, after you deploy the app. Are you sure you want to remove this volume?' + : 'The volume will be detached from the app. The data will remain on the cluster and can be re-attached later. The changes will take effect, after you deploy the app. Are you sure you want to detach this volume?', + okButton: isBaseVolume ? 'Delete Volume' : 'Detach Volume' + }); + if (confirm) { + setIsLoading(true); + await Toast.fromAction(() => deleteVolume(volumeId)); + } + } finally { + setIsLoading(false); } + }; - const openFileBrowserForVolumeAsync = async (volumeId: string) => { - - try { - const confirm = await openDialog({ - title: "Open File Browser", - description: "To view the Files of the volume, your app has to be stopped. The file browser will be opened in a new tab. Are you sure you want to open the file browser?", - okButton: "Stop App and Open File Browser" - }); - if (!confirm) { - return; - } - setIsLoading(true); - const fileBrowserStartResult = await Toast.fromAction(() => openFileBrowserForVolume(volumeId), undefined, 'Starting file browser...') - if (fileBrowserStartResult.status !== 'success' || !fileBrowserStartResult.data) { - return; - } - await openDialog({ - title: "File Browser Ready", - description: <> - The File Browser is ready and can be opened in a new tab.
- Use the following credentials to login: -
- -
quickstack
-
-
- -
{fileBrowserStartResult.data.password}
-
-
- -
- , - okButton: '', - cancelButton: "Close" - }); - } finally { - setIsLoading(false); - } + const asyncDownloadPvcData = async (volumeId: string) => { + try { + const confirm = await openDialog({ + title: 'Download Volume Data', + description: + 'The volume data will be zipped and downloaded. Depending on the size of the volume this can take a while. Are you sure you want to download the volume data?', + okButton: 'Download' + }); + if (confirm) { + setIsLoading(true); + await Toast.fromAction(() => downloadPvcData(volumeId)).then((x) => { + if (x.status === 'success' && x.data) { + window.open('/api/volume-data-download?fileName=' + x.data); + } + }); + } + } finally { + setIsLoading(false); } + }; + const openFileBrowserForVolumeAsync = async (volumeId: string) => { + try { + const confirm = await openDialog({ + title: 'Open File Browser', + description: + 'To view the Files of the volume, your app has to be stopped. The file browser will be opened in a new tab. Are you sure you want to open the file browser?', + okButton: 'Stop App and Open File Browser' + }); + if (!confirm) { + return; + } + setIsLoading(true); + const fileBrowserStartResult = await Toast.fromAction(() => openFileBrowserForVolume(volumeId), undefined, 'Starting file browser...'); + if (fileBrowserStartResult.status !== 'success' || !fileBrowserStartResult.data) { + return; + } + await openDialog({ + title: 'File Browser Ready', + description: ( + <> + The File Browser is ready and can be opened in a new tab.
+ Use the following credentials to login: +
+ +
+ {' '} + quickstack +
+
+
+ +
+ {fileBrowserStartResult.data.password} +
+
+
+ +
+ + ), + okButton: '', + cancelButton: 'Close' + }); + } finally { + setIsLoading(false); + } + }; - return <> -
- - Volumes - Add one or more volumes to to configure persistent storage within your container. - - {volumesWithStorage.length > 0 && - - {app.appVolumes.length} Storage - - - Mount Path - Storage Size - Storage Used - Storage Class - Access Mode - Shared - - - - - {volumesWithStorage.map(volume => ( - - {volume.containerMountPath} - {volume.size} MiB - - {volume.usedPercentage !== undefined && volume.usedBytes !== undefined && volume.capacityBytes !== undefined ? <> - = 90 ? 'red' : (volume.usedPercentage >= 80 ? 'orange' : undefined)} /> -
- {KubeSizeConverter.convertBytesToReadableSize(volume.usedBytes)} / {KubeSizeConverter.convertBytesToReadableSize(volume.capacityBytes)} -
- : Unavailable{volume.usageStatus ? ` (${volume.usageStatus})` : ""}} -
- {volume.storageClassName?.replace('-', ' ')} - {volume.accessMode} - - {volume.shareWithOtherApps && ( - - - - - - Shareable - - - -

This volume can be mounted by other apps in this project

-
-
-
- )} - {volume.sharedVolumeId && ( - - - - - - Shared - - - -

This volume is mounted from another app's volume

-
-
-
- )} -
- -
- {!volume.sharedVolumeId && <> - - - - - - -

Download volume content

-
-
-
- {!readonly && - - - - - -

View content of Volume

-
-
-
} - } - {/* + return ( + <> +
+ + Volumes + Add one or more volumes to to configure persistent storage within your container. + + {volumesWithStorage.length > 0 && ( + +
+ {app.appVolumes.length} Storage + + + Mount Path + Storage Size + Storage Used + Storage Class + Access Mode + Shared + + + + + {volumesWithStorage.map((volume) => ( + + {volume.containerMountPath} + {volume.size} MiB + + {volume.usedPercentage !== undefined && volume.usedBytes !== undefined && volume.capacityBytes !== undefined ? ( + <> + = 90 ? 'red' : volume.usedPercentage >= 80 ? 'orange' : undefined} + /> +
+ {KubeSizeConverter.convertBytesToReadableSize(volume.usedBytes)} /{' '} + {KubeSizeConverter.convertBytesToReadableSize(volume.capacityBytes)} +
+ + ) : ( + Unavailable{volume.usageStatus ? ` (${volume.usageStatus})` : ''} + )} +
+ {volume.storageClassName?.replace('-', ' ')} + {volume.accessMode} + + {volume.shareWithOtherApps && ( + + + + + + Shareable + + + +

This volume can be mounted by other apps in this project

+
+
+
+ )} + {volume.sharedVolumeId && ( + + + + + + Shared + + + +

This volume is mounted from another app's volume

+
+
+
+ )} +
+ +
+ {!volume.sharedVolumeId && ( + <> + + + + + + +

Download volume content

+
+
+
+ {!readonly && ( + + + + + + +

View content of Volume

+
+
+
+ )} + + )} + {/* @@ -257,77 +265,80 @@ export default function StorageList({ app, readonly, storageClasses }: { */} - {!readonly && <> - {volume.sharedVolumeId ? ( - - - - - - -

Shared volumes cannot be edited (size and storage class are inherited)

-
-
-
- ) : ( - - - - - - -

Edit volume settings

-
-
-
- )} - - - - - - -

{volume.sharedVolumeId ? 'Detach Volume' : 'Delete Volume'}

-
-
-
- } -
-
-
- ))} -
-
-
} - {!readonly && - - - } -
- ; + {!readonly && ( + <> + {volume.sharedVolumeId ? ( + + + + + + +

Shared volumes cannot be edited (size and storage class are inherited)

+
+
+
+ ) : ( + + + + + + +

Edit volume settings

+
+
+
+ )} + + + + + + +

{volume.sharedVolumeId ? 'Detach Volume' : 'Delete Volume'}

+
+
+
+ + )} + + + + ))} + + +
+ )} + {!readonly && ( + + + + + )} + + + ); } diff --git a/src/app/settings/platform/operations/actions.ts b/src/app/settings/platform/operations/actions.ts index 5ac4237c..9fdf2f62 100644 --- a/src/app/settings/platform/operations/actions.ts +++ b/src/app/settings/platform/operations/actions.ts @@ -6,12 +6,14 @@ import { z } from 'zod'; const retentionSchema = z.number().int().min(1).max(168); -export const getMonitoringHistoryRetention = async () => simpleAction(async () => { +export const getMonitoringHistoryRetention = async () => + simpleAction(async () => { await getAdminUserSession(); return appHistoryService.getRetentionHours(); -}); + }); -export const setMonitoringHistoryRetention = async (hours: number) => simpleAction(async () => { +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 index 9d21b7fb..c73b907e 100644 --- a/src/app/settings/platform/operations/monitoring-history-settings.tsx +++ b/src/app/settings/platform/operations/monitoring-history-settings.tsx @@ -13,66 +13,64 @@ import { getMonitoringHistoryRetention, setMonitoringHistoryRetention } from './ 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}

} - - -
-
; + 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 59bbe62a..f2de91ac 100644 --- a/src/app/settings/platform/operations/page.tsx +++ b/src/app/settings/platform/operations/page.tsx @@ -1,17 +1,19 @@ -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" -import { ServerSettingsPage } from "../../server-settings-page" +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'; +import { ServerSettingsPage } from '../../server-settings-page'; export default async function MaintenanceSettingsPage() { - const pods = await podService.getPodsForApp(Constants.QS_NAMESPACE, Constants.QS_APP_NAME) - const pod = pods.find(Boolean) + const pods = await podService.getPodsForApp(Constants.QS_NAMESPACE, Constants.QS_APP_NAME); + const pod = pods.find(Boolean); - return -
- - -
-
+ return ( + +
+ + +
+
+ ); } diff --git a/src/components/custom/app-monitoring-history.tsx b/src/components/custom/app-monitoring-history.tsx index cfa9cf41..0582a718 100644 --- a/src/components/custom/app-monitoring-history.tsx +++ b/src/components/custom/app-monitoring-history.tsx @@ -13,177 +13,216 @@ import { AppMonitoringHistory } from '@/shared/model/app-monitoring-history.mode 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; +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)} + 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'}

- )} - - - {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 [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); } - 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; + }, [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} - />; - })} -
- } + 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 index aad794f9..5c94e7d3 100644 --- a/src/components/custom/app-monitoring-history.unit.spec.ts +++ b/src/components/custom/app-monitoring-history.unit.spec.ts @@ -5,57 +5,78 @@ import { getAppMonitoringHistory } from '@/app/project/[projectId]/apps/details/ 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, + CartesianGrid: () => null, + Line: () => null, + LineChart: () => null, + ResponsiveContainer: () => null, + Tooltip: () => null, + Legend: () => null, + XAxis: () => null, + YAxis: () => null })); -afterEach(() => { cleanup(); vi.clearAllMocks(); }); +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(); + 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.'); + 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(); + 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(); + 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 0d19c4ae..601266a0 100644 --- a/src/frontend/utils/format.utils.ts +++ b/src/frontend/utils/format.utils.ts @@ -1,60 +1,52 @@ - function formatLocalDate(date: Date, options: Intl.DateTimeFormatOptions): string { - return new Intl.DateTimeFormat(undefined, options).format(date); + return new Intl.DateTimeFormat(undefined, options).format(date); } type DateInput = Date | string | undefined | null; function parseDate(date: DateInput): Date | undefined { - if (typeof date === 'string') { - date = new Date(date); - } + if (typeof date === 'string') { + date = new Date(date); + } - return date instanceof Date && !Number.isNaN(date.getTime()) ? date : undefined; + return date instanceof Date && !Number.isNaN(date.getTime()) ? date : undefined; } export function formatDate(date: DateInput): string { - const parsedDate = parseDate(date); - if (!parsedDate) { - return ''; - } - return formatLocalDate(parsedDate, { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }); + const parsedDate = parseDate(date); + if (!parsedDate) { + return ''; + } + return formatLocalDate(parsedDate, { day: '2-digit', month: '2-digit', year: 'numeric' }); } export function formatDateTime(date: DateInput, includeSeconds = false): string { - const parsedDate = parseDate(date); - if (!parsedDate) { - return ''; - } - return formatLocalDate(parsedDate, { - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - ...(includeSeconds && { second: '2-digit' }), - }); + const parsedDate = parseDate(date); + if (!parsedDate) { + return ''; + } + return formatLocalDate(parsedDate, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + ...(includeSeconds && { second: '2-digit' }) + }); } export function formatTime(date: DateInput): string { - const parsedDate = parseDate(date); - if (!parsedDate) { - return ''; - } - return formatLocalDate(parsedDate, { - hour: '2-digit', - minute: '2-digit', - }); + const parsedDate = parseDate(date); + if (!parsedDate) { + return ''; + } + return formatLocalDate(parsedDate, { hour: '2-digit', minute: '2-digit' }); } export function formatBytes(bytes: number) { - if (bytes === 0) return '0 B'; - const k = 1024; - 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]; -}; + if (bytes === 0) return '0 B'; + const k = 1024; + 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 6157dbb2..907d2da1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,101 +1,87 @@ -import { createServer } from 'http' -import { parse } from 'url' -import next from 'next' -import { randomUUID } from 'crypto' -import socketIoServer from './socket-io.server' -import quickStackService from './server/services/qs.service' -import { CommandExecutorUtils } from './server/utils/command-executor.utils' -import dataAccess from './server/adapter/db.client' -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' +import { createServer } from 'http'; +import { parse } from 'url'; +import next from 'next'; +import { randomUUID } from 'crypto'; +import socketIoServer from './socket-io.server'; +import quickStackService from './server/services/qs.service'; +import { CommandExecutorUtils } from './server/utils/command-executor.utils'; +import dataAccess from './server/adapter/db.client'; +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 { - var quickStackInitKey: string | undefined; + var quickStackInitKey: string | undefined; } // Source: https://nextjs.org/docs/app/building-your-application/configuring/custom-server - -const port = parseInt(process.env.PORT || '3000', 10) -const dev = process.env.NODE_ENV !== 'production' +const port = parseInt(process.env.PORT || '3000', 10); +const dev = process.env.NODE_ENV !== 'production'; console.log(`NODE_ENV=${process.env.NODE_ENV}`); if (process.env.NODE_ENV === 'production') { - console.log(`KUBERNETES_SERVICE_HOST=${process.env.KUBERNETES_SERVICE_HOST}`); - console.log(`KUBERNETES_SERVICE_PORT=${process.env.KUBERNETES_SERVICE_PORT}`); + console.log(`KUBERNETES_SERVICE_HOST=${process.env.KUBERNETES_SERVICE_HOST}`); + console.log(`KUBERNETES_SERVICE_PORT=${process.env.KUBERNETES_SERVICE_PORT}`); } async function setupQuickStack() { - console.log('Setting up QuickStack...'); - await quickStackService.initializeQuickStack(); + console.log('Setting up QuickStack...'); + await quickStackService.initializeQuickStack(); } async function initializeNextJs() { - - globalThis.quickStackInitKey = randomUUID(); - console.log('Init key generated.'); - - FancyConsoleUtils.printQuickStack(); - if (process.env.NODE_ENV === 'production') { - // update database - console.log('Running db migration...'); - await CommandExecutorUtils.runCommand('npx prisma migrate deploy'); - - if (process.env.K3S_JOIN_TOKEN && process.env.K3S_JOIN_TOKEN.trim()) { - console.log('Saving K3S_JOIN_TOKEN to database...'); - await dataAccess.client.parameter.upsert({ - where: { - name: Constants.K3S_JOIN_TOKEN - }, - create: { - name: Constants.K3S_JOIN_TOKEN, - value: process.env.K3S_JOIN_TOKEN - }, - update: { - value: process.env.K3S_JOIN_TOKEN - } - }); - } + globalThis.quickStackInitKey = randomUUID(); + console.log('Init key generated.'); + + FancyConsoleUtils.printQuickStack(); + if (process.env.NODE_ENV === 'production') { + // update database + console.log('Running db migration...'); + await CommandExecutorUtils.runCommand('npx prisma migrate deploy'); + + if (process.env.K3S_JOIN_TOKEN && process.env.K3S_JOIN_TOKEN.trim()) { + console.log('Saving K3S_JOIN_TOKEN to database...'); + await dataAccess.client.parameter.upsert({ + where: { name: Constants.K3S_JOIN_TOKEN }, + create: { name: Constants.K3S_JOIN_TOKEN, value: process.env.K3S_JOIN_TOKEN }, + update: { value: process.env.K3S_JOIN_TOKEN } + }); } - - await backupService.registerAllBackups(); - maintenanceService.configureMaintenanceCronJobs(); - appMonitoringHistoryService.initialize(); - - const app = next({ dev }); - const handle = app.getRequestHandler(); - - app.prepare().then(() => { - - const server = createServer((req, res) => { - const parsedUrl = parse(req.url!, true) - handle(req, res, parsedUrl) - }); - - socketIoServer.initialize(server); - server.listen(port, () => { - console.log( - `> Server listening at http://localhost:${port} as ${dev ? 'development' : process.env.NODE_ENV - }` - ); - // Trigger watch services via the protected init route - fetch(`http://localhost:${port}/api/init?key=${globalThis.quickStackInitKey}`) - .catch((err) => console.error('Failed to call init route:', err)); - }); - }).catch((err) => console.error( - 'Failed to initialize watch services via init route; background watch functionality may be unavailable:', - err - )); + } + + await backupService.registerAllBackups(); + maintenanceService.configureMaintenanceCronJobs(); + appMonitoringHistoryService.initialize(); + + const app = next({ dev }); + const handle = app.getRequestHandler(); + + app + .prepare() + .then(() => { + const server = createServer((req, res) => { + const parsedUrl = parse(req.url!, true); + handle(req, res, parsedUrl); + }); + + socketIoServer.initialize(server); + server.listen(port, () => { + console.log(`> Server listening at http://localhost:${port} as ${dev ? 'development' : process.env.NODE_ENV}`); + // Trigger watch services via the protected init route + fetch(`http://localhost:${port}/api/init?key=${globalThis.quickStackInitKey}`).catch((err) => console.error('Failed to call init route:', err)); + }); + }) + .catch((err) => console.error('Failed to initialize watch services via init route; background watch functionality may be unavailable:', err)); } if (process.env.NODE_ENV === 'production' && process.env.START_MODE === 'setup') { - setupQuickStack(); + setupQuickStack(); } else if (process.env.NODE_ENV === 'production' && process.env.START_MODE === 'reset-password') { - passwordChangeService.changeAdminPasswordAndPrintNewPassword(); + passwordChangeService.changeAdminPasswordAndPrintNewPassword(); } else { - initializeNextJs(); + initializeNextJs(); } diff --git a/src/server/adapter/app-history-metrics.adapter.ts b/src/server/adapter/app-history-metrics.adapter.ts index bd6e8a8d..51de5a89 100644 --- a/src/server/adapter/app-history-metrics.adapter.ts +++ b/src/server/adapter/app-history-metrics.adapter.ts @@ -2,13 +2,13 @@ import * as k8s from '@kubernetes/client-node'; import k3s from './kubernetes-api.adapter'; class AppHistoryMetricsAdapter { - async getPods() { - return (await k3s.core.listPodForAllNamespaces()).items; - } + async getPods() { + return (await k3s.core.listPodForAllNamespaces()).items; + } - async getMetrics() { - return (await new k8s.Metrics(k3s.getKubeConfig()).getPodMetrics()).items; - } + async getMetrics() { + return (await new k8s.Metrics(k3s.getKubeConfig()).getPodMetrics()).items; + } } const appHistoryMetricsAdapter = new AppHistoryMetricsAdapter(); diff --git a/src/server/adapter/app-history.adapter.ts b/src/server/adapter/app-history.adapter.ts index 2511ff09..d1422e0f 100644 --- a/src/server/adapter/app-history.adapter.ts +++ b/src/server/adapter/app-history.adapter.ts @@ -3,52 +3,55 @@ 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 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 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 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 }))), - ]); - } + 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(); diff --git a/src/server/adapter/kubernetes-api.adapter.ts b/src/server/adapter/kubernetes-api.adapter.ts index 5252c46a..8cb96c7b 100644 --- a/src/server/adapter/kubernetes-api.adapter.ts +++ b/src/server/adapter/kubernetes-api.adapter.ts @@ -5,180 +5,186 @@ import * as http from 'node:http'; import * as https from 'node:https'; export function kubernetesPatchOptions(contentType: PatchStrategy): ConfigurationOptions { - return { - middleware: [{ - pre: (context) => { - context.setHeaderParam('Content-Type', contentType); - return new k8s.Observable(Promise.resolve(context)); - }, - post: (context) => new k8s.Observable(Promise.resolve(context)), - }], - - }; + return { + middleware: [ + { + pre: (context) => { + context.setHeaderParam('Content-Type', contentType); + return new k8s.Observable(Promise.resolve(context)); + }, + post: (context) => new k8s.Observable(Promise.resolve(context)) + } + ] + }; } export class K3sApiAdapter { - - core: k8s.CoreV1Api; - apps: k8s.AppsV1Api; - batch: k8s.BatchV1Api; - log: k8s.Log; - network: k8s.NetworkingV1Api; - storage: k8s.StorageV1Api; - customObjects: k8s.CustomObjectsApi; - metrics: k8s.Metrics; - - constructor() { - this.core = this.getK8sCoreApiClient(); - this.apps = this.getK8sAppsApiClient(); - this.batch = this.getK8sBatchApiClient(); - this.log = this.getK8sLogApiClient(); - this.network = this.getK8sNetworkApiClient(); - this.storage = this.getK8sStorageApiClient(); - this.customObjects = this.getK8sCustomObjectsApiClient(); - this.metrics = this.getMetricsApiClient(); - } - - /** - * Checks if the provided error is a k8s "Not Found" error (HTTP 404). - */ - static isNotFoundError(error: any): boolean { - return error?.code === 404; + core: k8s.CoreV1Api; + apps: k8s.AppsV1Api; + batch: k8s.BatchV1Api; + log: k8s.Log; + network: k8s.NetworkingV1Api; + storage: k8s.StorageV1Api; + customObjects: k8s.CustomObjectsApi; + metrics: k8s.Metrics; + + constructor() { + this.core = this.getK8sCoreApiClient(); + this.apps = this.getK8sAppsApiClient(); + this.batch = this.getK8sBatchApiClient(); + this.log = this.getK8sLogApiClient(); + this.network = this.getK8sNetworkApiClient(); + this.storage = this.getK8sStorageApiClient(); + this.customObjects = this.getK8sCustomObjectsApiClient(); + this.metrics = this.getMetricsApiClient(); + } + + /** + * Checks if the provided error is a k8s "Not Found" error (HTTP 404). + */ + static isNotFoundError(error: any): boolean { + return error?.code === 404; + } + + getKubeConfig = () => { + const kc = new k8s.KubeConfig(); + if (process.env.NODE_ENV === 'production') { + kc.loadFromCluster(); + } else { + kc.loadFromFile('/workspace/kube-config.config'); } - - getKubeConfig = () => { - const kc = new k8s.KubeConfig(); - if (process.env.NODE_ENV === 'production') { - kc.loadFromCluster(); - } else { - kc.loadFromFile('/workspace/kube-config.config'); + return kc; + }; + + getK8sCoreApiClient = () => { + const kc = this.getKubeConfig(); + const k8sCoreClient = kc.makeApiClient(k8s.CoreV1Api); + return k8sCoreClient; + }; + + getK8sAppsApiClient = () => { + const kc = this.getKubeConfig(); + const k8sCoreClient = kc.makeApiClient(k8s.AppsV1Api); + return k8sCoreClient; + }; + + getK8sBatchApiClient = () => { + const kc = this.getKubeConfig(); + const k8sJobClient = kc.makeApiClient(k8s.BatchV1Api); + return k8sJobClient; + }; + + getK8sLogApiClient = () => { + const kc = this.getKubeConfig(); + const logClient = new k8s.Log(kc); + return logClient; + }; + + getK8sCustomObjectsApiClient = () => { + const kc = this.getKubeConfig(); + const client = kc.makeApiClient(k8s.CustomObjectsApi); + return client; + }; + + getK8sNetworkApiClient = () => { + const kc = this.getKubeConfig(); + const networkClient = kc.makeApiClient(k8s.NetworkingV1Api); + return networkClient; + }; + + getK8sStorageApiClient = () => { + const kc = this.getKubeConfig(); + const storageClient = kc.makeApiClient(k8s.StorageV1Api); + return storageClient; + }; + + getMetricsApiClient = () => { + 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; } - return kc; - } - - getK8sCoreApiClient = () => { - const kc = this.getKubeConfig() - const k8sCoreClient = kc.makeApiClient(k8s.CoreV1Api); - return k8sCoreClient; - } - - getK8sAppsApiClient = () => { - const kc = this.getKubeConfig() - const k8sCoreClient = kc.makeApiClient(k8s.AppsV1Api); - return k8sCoreClient; - } - - getK8sBatchApiClient = () => { - const kc = this.getKubeConfig() - const k8sJobClient = kc.makeApiClient(k8s.BatchV1Api); - return k8sJobClient; - } - - getK8sLogApiClient = () => { - const kc = this.getKubeConfig() - const logClient = new k8s.Log(kc) - return logClient; - } - - getK8sCustomObjectsApiClient = () => { - const kc = this.getKubeConfig() - const client = kc.makeApiClient(k8s.CustomObjectsApi); - return client; - } - - getK8sNetworkApiClient = () => { - const kc = this.getKubeConfig() - const networkClient = kc.makeApiClient(k8s.NetworkingV1Api); - return networkClient; - } - - getK8sStorageApiClient = () => { - const kc = this.getKubeConfig() - const storageClient = kc.makeApiClient(k8s.StorageV1Api); - return storageClient; - } - - getMetricsApiClient = () => { - 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(); + 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 + * @param spec Resource specification + */ + public async applyResource(spec: any, namespace?: string): Promise { + if (!spec || !spec.kind) { + console.error('Invalid resource specification:', spec); + throw new Error('Invalid resource specification'); } - /** - * Applies a single Kubernetes resource to the cluster - * @param kc KubeConfig instance - * @param spec Resource specification - */ - public async applyResource(spec: any, namespace?: string): Promise { - if (!spec || !spec.kind) { - console.error('Invalid resource specification:', spec); - throw new Error('Invalid resource specification'); - } - - namespace = spec.metadata?.namespace || namespace; + namespace = spec.metadata?.namespace || namespace; - const name = spec.metadata?.name; + const name = spec.metadata?.name; - console.log(`Applying ${spec.kind}/${name}${namespace ? ` to namespace ${namespace}` : ''}`); + console.log(`Applying ${spec.kind}/${name}${namespace ? ` to namespace ${namespace}` : ''}`); - try { - const client = k8s.KubernetesObjectApi.makeApiClient(this.getKubeConfig()); + try { + const client = k8s.KubernetesObjectApi.makeApiClient(this.getKubeConfig()); - try { - await client.read(spec); - // If it exists, patch it - await client.patch(spec); - console.log(`Updated ${spec.kind}/${name}`); - } catch { - await client.create(spec); - console.log(`Created ${spec.kind}/${name}`); - } - } catch (error) { - console.error(`Failed to apply ${spec.kind}/${name}:`, error); - throw error; - } + try { + await client.read(spec); + // If it exists, patch it + await client.patch(spec); + console.log(`Updated ${spec.kind}/${name}`); + } catch { + await client.create(spec); + console.log(`Created ${spec.kind}/${name}`); + } + } catch (error) { + console.error(`Failed to apply ${spec.kind}/${name}:`, error); + throw error; } - - /** - * Deletes a single Kubernetes resource. The resource may be namespaced or cluster-scoped. - */ - public async deleteResource(spec: any): Promise { - if (!spec?.kind || !spec?.metadata?.name) { - throw new ServiceException('Kind and metadata.name are required in method deleteResource'); - } - - const client = k8s.KubernetesObjectApi.makeApiClient(this.getKubeConfig()); - await client.delete(spec); + } + + /** + * Deletes a single Kubernetes resource. The resource may be namespaced or cluster-scoped. + */ + public async deleteResource(spec: any): Promise { + if (!spec?.kind || !spec?.metadata?.name) { + throw new ServiceException('Kind and metadata.name are required in method deleteResource'); } + + const client = k8s.KubernetesObjectApi.makeApiClient(this.getKubeConfig()); + await client.delete(spec); + } } const k3s = new K3sApiAdapter(); diff --git a/src/server/adapter/longhorn-api.adapter.ts b/src/server/adapter/longhorn-api.adapter.ts index 736815d3..5684e619 100644 --- a/src/server/adapter/longhorn-api.adapter.ts +++ b/src/server/adapter/longhorn-api.adapter.ts @@ -1,115 +1,82 @@ class LonghornApiAdapter { - - get longhornBaseUrl() { - return process.env.NODE_ENV === 'production' ? 'http://longhorn-frontend.longhorn-system.svc.cluster.local' : 'http://localhost:8000'; + get longhornBaseUrl() { + return process.env.NODE_ENV === 'production' ? 'http://longhorn-frontend.longhorn-system.svc.cluster.local' : 'http://localhost:8000'; + } + + async getLonghornVolume(pvcName: String) { + const response = await fetch(`${this.longhornBaseUrl}/v1/volumes/${pvcName}`, { + cache: 'no-cache', + method: 'GET', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' } + }); + + if (!response.ok) { + throw new Error(`HTTP-Error: ${response.status}`); } - async getLonghornVolume(pvcName: String) { - const response = await fetch(`${this.longhornBaseUrl}/v1/volumes/${pvcName}`, { - 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(); - const data = await response.json(); + const usedStorageBytes = data.controllers?.[0]?.actualSize; - const usedStorageBytes = data.controllers?.[0]?.actualSize; + return usedStorageBytes; + } - return usedStorageBytes; - } - - async getAllLonghornVolumes(): Promise<{ - actualSizeBytes: number; - sizeBytes: number; - name: string; - }[]> { - const response = await fetch(`${this.longhornBaseUrl}/v1/volumes`, { - cache: 'no-cache', - method: 'GET', - headers: { - - 'Accept': 'application/json', - 'Content-Type': 'application/json' - } - }); - - if (!response.ok) { - throw new Error(`HTTP-Error: ${response.status}`); - } + async getAllLonghornVolumes(): Promise<{ actualSizeBytes: number; sizeBytes: number; name: string }[]> { + const response = await fetch(`${this.longhornBaseUrl}/v1/volumes`, { + cache: 'no-cache', + method: 'GET', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' } + }); - const data = await response.json() as { - data: { - id: string; - controllers: { - actualSize: string; - name: string; - size: string; - }[] - }[] - }; - - return data.data.map(volume => { - const firstController = volume.controllers.find(x => !!x); - if (!firstController || !firstController.actualSize || !firstController.size || !firstController.name) { - return undefined; - } - return { - actualSizeBytes: parseInt(firstController.actualSize), - sizeBytes: parseInt(firstController.size), - name: volume.id - }; - }).filter(x => !!x); + if (!response.ok) { + throw new Error(`HTTP-Error: ${response.status}`); } + const data = (await response.json()) as { data: { id: string; controllers: { actualSize: string; name: string; size: string }[] }[] }; - async backupPvc(pvcName: string) { - const snapshot = await this.createSnapshot(pvcName); - await this.createBackup(pvcName, snapshot.id); + return data.data + .map((volume) => { + const firstController = volume.controllers.find((x) => !!x); + if (!firstController || !firstController.actualSize || !firstController.size || !firstController.name) { + return undefined; + } + return { actualSizeBytes: parseInt(firstController.actualSize), sizeBytes: parseInt(firstController.size), name: volume.id }; + }) + .filter((x) => !!x); + } + + async backupPvc(pvcName: string) { + const snapshot = await this.createSnapshot(pvcName); + await this.createBackup(pvcName, snapshot.id); + } + + private async createBackup(pvcName: string, snapshotId: string) { + const response = await fetch(`${this.longhornBaseUrl}/v1/volumes/${pvcName}?action=snapshotBackup`, { + cache: 'no-cache', + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: snapshotId }) + }); + + if (!response.ok) { + throw new Error(`HTTP-Error: ${response.status}`); } - private async createBackup(pvcName: string, snapshotId: string) { - const response = await fetch(`${this.longhornBaseUrl}/v1/volumes/${pvcName}?action=snapshotBackup`, { - cache: 'no-cache', - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - "name": snapshotId - }) - }); + return await response.json(); + } - if (!response.ok) { - throw new Error(`HTTP-Error: ${response.status}`); - } + private async createSnapshot(pvcName: string) { + const response = await fetch(`${this.longhornBaseUrl}/v1/volumes/${pvcName}?action=snapshotCreate`, { + cache: 'no-cache', + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' } + }); - return await response.json(); + if (!response.ok) { + throw new Error(`HTTP-Error: ${response.status}`); } - private async createSnapshot(pvcName: string) { - const response = await fetch(`${this.longhornBaseUrl}/v1/volumes/${pvcName}?action=snapshotCreate`, { - cache: 'no-cache', - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - } - }); - - if (!response.ok) { - throw new Error(`HTTP-Error: ${response.status}`); - } - - /* + /* Response: { "actions": {}, @@ -132,9 +99,8 @@ class LonghornApiAdapter { } */ - return await response.json(); - } - + return await response.json(); + } } const longhornApiAdapter = new LonghornApiAdapter(); export default longhornApiAdapter; diff --git a/src/server/adapter/volume-summary.adapter.ts b/src/server/adapter/volume-summary.adapter.ts index 43e89624..63238fb6 100644 --- a/src/server/adapter/volume-summary.adapter.ts +++ b/src/server/adapter/volume-summary.adapter.ts @@ -1,35 +1,37 @@ import k3s from './kubernetes-api.adapter'; export interface VolumeFilesystemSample { - pvcRef?: { namespace?: string; name?: string }; - time?: string; - usedBytes?: number; - availableBytes?: number; - capacityBytes?: number; + pvcRef?: { namespace?: string; name?: string }; + time?: string; + usedBytes?: number; + availableBytes?: number; + capacityBytes?: number; } interface NodeSummary { - pods?: { volume?: VolumeFilesystemSample[] }[]; + pods?: { volume?: VolumeFilesystemSample[] }[]; } class VolumeSummaryAdapter { - async getPersistentVolumeClaims() { - return (await k3s.core.listPersistentVolumeClaimForAllNamespaces()).items; - } + async getPersistentVolumeClaims() { + return (await k3s.core.listPersistentVolumeClaimForAllNamespaces()).items; + } - async getPersistentVolumes() { - return (await k3s.core.listPersistentVolume()).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 : []); - } + 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(); diff --git a/src/server/services/agent-template.service.unit.spec.ts b/src/server/services/agent-template.service.unit.spec.ts index 07f2d7dd..5f9c3c27 100644 --- a/src/server/services/agent-template.service.unit.spec.ts +++ b/src/server/services/agent-template.service.unit.spec.ts @@ -1,285 +1,208 @@ -vi.mock("next/cache", () => ({ - revalidateTag: vi.fn(), - unstable_cache: (fn: (...args: any[]) => any) => fn, +vi.mock('next/cache', () => ({ revalidateTag: vi.fn(), unstable_cache: (fn: (...args: any[]) => any) => fn })); + +const dbProjectMocks = vi.hoisted(() => ({ findUnique: vi.fn() })); + +const dbGatewayMocks = vi.hoisted(() => ({ findUnique: vi.fn() })); + +const dbAgentMocks = vi.hoisted(() => ({ create: vi.fn(), findUnique: vi.fn(), findFirstOrThrow: vi.fn(), update: vi.fn() })); + +const dbAgentDomainMocks = vi.hoisted(() => ({ create: vi.fn(), deleteMany: vi.fn(), findMany: vi.fn() })); + +const dbAgentVolumeMocks = vi.hoisted(() => ({ create: vi.fn(), deleteMany: vi.fn(), findMany: vi.fn() })); + +const dbAgentFileMountMocks = vi.hoisted(() => ({ create: vi.fn(), deleteMany: vi.fn(), findMany: vi.fn() })); + +const dbAgentNetworkPolicyMocks = vi.hoisted(() => ({ deleteMany: vi.fn(), findUnique: vi.fn(), create: vi.fn(), update: vi.fn() })); + +const dbAgentNetworkPolicyRuleMocks = vi.hoisted(() => ({ deleteMany: vi.fn(), findFirst: vi.fn(), create: vi.fn() })); + +const dbAppMocks = vi.hoisted(() => ({ findFirst: vi.fn() })); + +const namespaceServiceMocks = vi.hoisted(() => ({ createNamespaceIfNotExists: vi.fn() })); + +vi.mock('@/server/adapter/db.client', () => ({ + default: { + client: { + project: dbProjectMocks, + llmGateway: dbGatewayMocks, + agent: dbAgentMocks, + agentDomain: dbAgentDomainMocks, + agentVolume: dbAgentVolumeMocks, + agentFileMount: dbAgentFileMountMocks, + agentNetworkPolicy: dbAgentNetworkPolicyMocks, + agentNetworkPolicyRule: dbAgentNetworkPolicyRuleMocks, + app: dbAppMocks, + $transaction: vi.fn((fn: any) => + fn({ + project: dbProjectMocks, + llmGateway: dbGatewayMocks, + agent: dbAgentMocks, + agentDomain: dbAgentDomainMocks, + agentVolume: dbAgentVolumeMocks, + agentFileMount: dbAgentFileMountMocks, + agentNetworkPolicy: dbAgentNetworkPolicyMocks, + agentNetworkPolicyRule: dbAgentNetworkPolicyRuleMocks, + app: dbAppMocks + }) + ) + } + } })); -const dbProjectMocks = vi.hoisted(() => ({ - findUnique: vi.fn(), -})); - -const dbGatewayMocks = vi.hoisted(() => ({ - findUnique: vi.fn(), -})); - -const dbAgentMocks = vi.hoisted(() => ({ - create: vi.fn(), - findUnique: vi.fn(), - findFirstOrThrow: vi.fn(), - update: vi.fn(), -})); - -const dbAgentDomainMocks = vi.hoisted(() => ({ - create: vi.fn(), - deleteMany: vi.fn(), - findMany: vi.fn(), -})); - -const dbAgentVolumeMocks = vi.hoisted(() => ({ - create: vi.fn(), - deleteMany: vi.fn(), - findMany: vi.fn(), -})); - -const dbAgentFileMountMocks = vi.hoisted(() => ({ - create: vi.fn(), - deleteMany: vi.fn(), - findMany: vi.fn(), -})); - -const dbAgentNetworkPolicyMocks = vi.hoisted(() => ({ - deleteMany: vi.fn(), - findUnique: vi.fn(), - create: vi.fn(), - update: vi.fn(), -})); - -const dbAgentNetworkPolicyRuleMocks = vi.hoisted(() => ({ - deleteMany: vi.fn(), - findFirst: vi.fn(), - create: vi.fn(), -})); - -const dbAppMocks = vi.hoisted(() => ({ - findFirst: vi.fn(), -})); +vi.mock('@/server/services/namespace.service', () => ({ default: namespaceServiceMocks })); -const namespaceServiceMocks = vi.hoisted(() => ({ - createNamespaceIfNotExists: vi.fn(), -})); - -vi.mock("@/server/adapter/db.client", () => ({ - default: { - client: { - project: dbProjectMocks, - llmGateway: dbGatewayMocks, - agent: dbAgentMocks, - agentDomain: dbAgentDomainMocks, - agentVolume: dbAgentVolumeMocks, - agentFileMount: dbAgentFileMountMocks, - agentNetworkPolicy: dbAgentNetworkPolicyMocks, - agentNetworkPolicyRule: dbAgentNetworkPolicyRuleMocks, - app: dbAppMocks, - $transaction: vi.fn((fn: any) => fn({ - project: dbProjectMocks, - llmGateway: dbGatewayMocks, - agent: dbAgentMocks, - agentDomain: dbAgentDomainMocks, - agentVolume: dbAgentVolumeMocks, - agentFileMount: dbAgentFileMountMocks, - agentNetworkPolicy: dbAgentNetworkPolicyMocks, - agentNetworkPolicyRule: dbAgentNetworkPolicyRuleMocks, - app: dbAppMocks, - })), - }, - }, -})); - -vi.mock("@/server/services/namespace.service", () => ({ - default: namespaceServiceMocks, -})); - -vi.mock("@/server/utils/crypto.utils", () => ({ - CryptoUtils: { - encrypt: vi.fn((value: string) => `encrypted:${value}`), - }, -})); +vi.mock('@/server/utils/crypto.utils', () => ({ CryptoUtils: { encrypt: vi.fn((value: string) => `encrypted:${value}`) } })); vi.mock('@/server/adapter/kubernetes-api.adapter', () => ({ default: {} })); -import agentTemplateService from "./agent-template.service"; -import namespaceService from "./namespace.service"; -import { opencodeAgentTemplate } from "@/shared/templates/agents/opencode.template"; -import { geminiCliAgentTemplate } from "@/shared/templates/agents/gemini-cli.template"; - -describe("agent-template.service", () => { - beforeEach(() => { - vi.clearAllMocks(); - dbProjectMocks.findUnique.mockResolvedValue({ id: "project-1" }); - dbGatewayMocks.findUnique.mockResolvedValue({ id: "gateway-1", baseUrl: "https://litellm.example" }); - dbAgentMocks.findUnique.mockResolvedValue(null); - dbAgentMocks.create.mockResolvedValue({ id: "agent-opencode", projectId: "project-1" }); - dbAgentMocks.update.mockResolvedValue({ id: "agent-opencode", projectId: "project-1" }); - dbAgentDomainMocks.findMany.mockResolvedValue([]); - dbAgentVolumeMocks.findMany.mockResolvedValue([]); - dbAgentFileMountMocks.findMany.mockResolvedValue([]); - dbAppMocks.findFirst.mockResolvedValue(null); - dbAgentNetworkPolicyMocks.findUnique.mockResolvedValue(null); - dbAgentNetworkPolicyMocks.create.mockResolvedValue({ id: 'policy-1' }); - dbAgentNetworkPolicyRuleMocks.findFirst.mockResolvedValue(null); - dbAgentNetworkPolicyRuleMocks.create.mockResolvedValue({ id: 'rule-1' }); - dbAgentMocks.findFirstOrThrow.mockResolvedValue({ - id: "agent-opencode", - name: "OpenCode", - projectId: "project-1", - llmGatewayId: "gateway-1", - modelAlias: JSON.stringify(["gpt-4o"]), - sourceType: "CONTAINER", - buildMethod: "DOCKERFILE", - containerImageSource: "ghcr.io/anomalyco/opencode:1.18.27", - containerRegistryUsername: null, - containerRegistryPassword: null, - gitUrl: null, - gitBranch: null, - gitUsername: null, - gitToken: null, - dockerfilePath: "./Dockerfile", - cpuRequest: null, - cpuLimit: null, - memoryRequest: null, - memoryLimit: null, - encryptedEnvVars: null, - containerCommand: JSON.stringify([ - "/bin/sh", - "-lc", - ]), - containerArgs: JSON.stringify([ - "exec opencode web --hostname 0.0.0.0 --port 4096", - ]), - workingDir: "/workspace", - warmPoolReplicas: 0, - deployFileBrowser: false, - healthCheckPeriodSeconds: 15, - healthCheckTimeoutSeconds: 5, - healthCheckFailureThreshold: 3, - project: { id: "project-1" }, - llmGateway: { id: "gateway-1", baseUrl: "https://litellm.example" }, - agentDomains: [], - agentVolumes: [], - agentFileMounts: [], - agentNetworkPolicy: null, - }); - namespaceServiceMocks.createNamespaceIfNotExists.mockResolvedValue(undefined); +import agentTemplateService from './agent-template.service'; +import namespaceService from './namespace.service'; +import { opencodeAgentTemplate } from '@/shared/templates/agents/opencode.template'; +import { geminiCliAgentTemplate } from '@/shared/templates/agents/gemini-cli.template'; + +describe('agent-template.service', () => { + beforeEach(() => { + vi.clearAllMocks(); + dbProjectMocks.findUnique.mockResolvedValue({ id: 'project-1' }); + dbGatewayMocks.findUnique.mockResolvedValue({ id: 'gateway-1', baseUrl: 'https://litellm.example' }); + dbAgentMocks.findUnique.mockResolvedValue(null); + dbAgentMocks.create.mockResolvedValue({ id: 'agent-opencode', projectId: 'project-1' }); + dbAgentMocks.update.mockResolvedValue({ id: 'agent-opencode', projectId: 'project-1' }); + dbAgentDomainMocks.findMany.mockResolvedValue([]); + dbAgentVolumeMocks.findMany.mockResolvedValue([]); + dbAgentFileMountMocks.findMany.mockResolvedValue([]); + dbAppMocks.findFirst.mockResolvedValue(null); + dbAgentNetworkPolicyMocks.findUnique.mockResolvedValue(null); + dbAgentNetworkPolicyMocks.create.mockResolvedValue({ id: 'policy-1' }); + dbAgentNetworkPolicyRuleMocks.findFirst.mockResolvedValue(null); + dbAgentNetworkPolicyRuleMocks.create.mockResolvedValue({ id: 'rule-1' }); + dbAgentMocks.findFirstOrThrow.mockResolvedValue({ + id: 'agent-opencode', + name: 'OpenCode', + projectId: 'project-1', + llmGatewayId: 'gateway-1', + modelAlias: JSON.stringify(['gpt-4o']), + sourceType: 'CONTAINER', + buildMethod: 'DOCKERFILE', + containerImageSource: 'ghcr.io/anomalyco/opencode:1.18.27', + containerRegistryUsername: null, + containerRegistryPassword: null, + gitUrl: null, + gitBranch: null, + gitUsername: null, + gitToken: null, + dockerfilePath: './Dockerfile', + cpuRequest: null, + cpuLimit: null, + memoryRequest: null, + memoryLimit: null, + encryptedEnvVars: null, + containerCommand: JSON.stringify(['/bin/sh', '-lc']), + containerArgs: JSON.stringify(['exec opencode web --hostname 0.0.0.0 --port 4096']), + workingDir: '/workspace', + warmPoolReplicas: 0, + deployFileBrowser: false, + healthCheckPeriodSeconds: 15, + healthCheckTimeoutSeconds: 5, + healthCheckFailureThreshold: 3, + project: { id: 'project-1' }, + llmGateway: { id: 'gateway-1', baseUrl: 'https://litellm.example' }, + agentDomains: [], + agentVolumes: [], + agentFileMounts: [], + agentNetworkPolicy: null }); - - it("creates an OpenCode agent template with workspace volume", async () => { - const template = structuredClone(opencodeAgentTemplate); - template.templates[0].llmGatewayId = "gateway-1"; - template.templates[0].modelAlias = ["gpt-4o"]; - - await agentTemplateService.createAgentFromTemplate("project-1", template); - - expect(dbAgentMocks.update).toHaveBeenCalledWith({ - where: { id: "agent-opencode" }, - data: expect.objectContaining({ - id: "agent-opencode", - name: "OpenCode", - projectId: "project-1", - llmGatewayId: "gateway-1", - modelAlias: JSON.stringify(["gpt-4o"]), - sourceType: "CONTAINER", - buildMethod: "DOCKERFILE", - containerImageSource: "ghcr.io/anomalyco/opencode:1.18.27", - warmPoolReplicas: 0, - }), - }); - expect(dbAgentVolumeMocks.create).toHaveBeenCalledWith({ - data: { - containerMountPath: "/workspace", - size: 5120, - storageClassName: "longhorn", - volumeType: "ALL", - accessMode: "ReadWriteMany", - agentId: "agent-opencode", - }, - }); - expect(dbAgentFileMountMocks.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - containerMountPath: "/root/.config/opencode/opencode.json", - agentId: "agent-opencode", - }), - }); - const opencodeConfigMount = dbAgentFileMountMocks.create.mock.calls.find(([call]) => - call.data.containerMountPath === "/root/.config/opencode/opencode.json" - )?.[0]; - expect(JSON.parse(opencodeConfigMount.data.content)).toEqual(expect.objectContaining({ - model: "quickstack-litellm/gpt-4o", - provider: expect.objectContaining({ - "quickstack-litellm": expect.objectContaining({ - options: expect.objectContaining({ - baseURL: "https://litellm.example/v1", - apiKey: "{env:QS_VIRTUAL_KEY}", - }), - }), - }), - server: { - hostname: "0.0.0.0", - port: 4096, - }, - })); - expect(namespaceService.createNamespaceIfNotExists).toHaveBeenCalledWith("project-1"); + namespaceServiceMocks.createNamespaceIfNotExists.mockResolvedValue(undefined); + }); + + it('creates an OpenCode agent template with workspace volume', async () => { + const template = structuredClone(opencodeAgentTemplate); + template.templates[0].llmGatewayId = 'gateway-1'; + template.templates[0].modelAlias = ['gpt-4o']; + + await agentTemplateService.createAgentFromTemplate('project-1', template); + + expect(dbAgentMocks.update).toHaveBeenCalledWith({ + where: { id: 'agent-opencode' }, + data: expect.objectContaining({ + id: 'agent-opencode', + name: 'OpenCode', + projectId: 'project-1', + llmGatewayId: 'gateway-1', + modelAlias: JSON.stringify(['gpt-4o']), + sourceType: 'CONTAINER', + buildMethod: 'DOCKERFILE', + containerImageSource: 'ghcr.io/anomalyco/opencode:1.18.27', + warmPoolReplicas: 0 + }) }); - - it("rejects a missing project", async () => { - dbProjectMocks.findUnique.mockResolvedValue(null); - const template = structuredClone(opencodeAgentTemplate); - template.templates[0].llmGatewayId = "gateway-1"; - template.templates[0].modelAlias = ["gpt-4o"]; - - await expect(agentTemplateService.createAgentFromTemplate("project-1", template)) - .rejects.toThrow("Project not found."); - expect(dbAgentMocks.create).not.toHaveBeenCalled(); - expect(dbAgentMocks.update).not.toHaveBeenCalled(); + expect(dbAgentVolumeMocks.create).toHaveBeenCalledWith({ + data: { + containerMountPath: '/workspace', + size: 5120, + storageClassName: 'longhorn', + volumeType: 'ALL', + accessMode: 'ReadWriteMany', + agentId: 'agent-opencode' + } }); - - it('adds an egress rule for an internal LiteLLM Gateway', async () => { - dbGatewayMocks.findUnique.mockResolvedValue({ - id: 'gateway-1', - baseUrl: 'http://svc-litellm-app.llm-project.svc.cluster.local:4000', - }); - dbAppMocks.findFirst.mockResolvedValue({ id: 'litellm-app' }); - const template = structuredClone(opencodeAgentTemplate); - template.templates[0].llmGatewayId = 'gateway-1'; - template.templates[0].modelAlias = ['gpt-4o']; - - await agentTemplateService.createAgentFromTemplate('project-1', template); - - expect(dbAgentNetworkPolicyMocks.create).toHaveBeenCalledWith({ - data: { agentId: 'agent-opencode', allowInternetAccess: true }, - }); - expect(dbAgentNetworkPolicyRuleMocks.create).toHaveBeenCalledWith({ - data: { - agentNetworkPolicyId: 'policy-1', - type: 'EGRESS', - targetAppId: 'litellm-app', - port: 4000, - protocol: 'TCP', - }, - }); + expect(dbAgentFileMountMocks.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ containerMountPath: '/root/.config/opencode/opencode.json', agentId: 'agent-opencode' }) + }); + const opencodeConfigMount = dbAgentFileMountMocks.create.mock.calls.find( + ([call]) => call.data.containerMountPath === '/root/.config/opencode/opencode.json' + )?.[0]; + expect(JSON.parse(opencodeConfigMount.data.content)).toEqual( + expect.objectContaining({ + model: 'quickstack-litellm/gpt-4o', + provider: expect.objectContaining({ + 'quickstack-litellm': expect.objectContaining({ + options: expect.objectContaining({ baseURL: 'https://litellm.example/v1', apiKey: '{env:QS_VIRTUAL_KEY}' }) + }) + }), + server: { hostname: '0.0.0.0', port: 4096 } + }) + ); + expect(namespaceService.createNamespaceIfNotExists).toHaveBeenCalledWith('project-1'); + }); + + it('rejects a missing project', async () => { + dbProjectMocks.findUnique.mockResolvedValue(null); + const template = structuredClone(opencodeAgentTemplate); + template.templates[0].llmGatewayId = 'gateway-1'; + template.templates[0].modelAlias = ['gpt-4o']; + + await expect(agentTemplateService.createAgentFromTemplate('project-1', template)).rejects.toThrow('Project not found.'); + expect(dbAgentMocks.create).not.toHaveBeenCalled(); + expect(dbAgentMocks.update).not.toHaveBeenCalled(); + }); + + it('adds an egress rule for an internal LiteLLM Gateway', async () => { + dbGatewayMocks.findUnique.mockResolvedValue({ id: 'gateway-1', baseUrl: 'http://svc-litellm-app.llm-project.svc.cluster.local:4000' }); + dbAppMocks.findFirst.mockResolvedValue({ id: 'litellm-app' }); + const template = structuredClone(opencodeAgentTemplate); + template.templates[0].llmGatewayId = 'gateway-1'; + template.templates[0].modelAlias = ['gpt-4o']; + + await agentTemplateService.createAgentFromTemplate('project-1', template); + + expect(dbAgentNetworkPolicyMocks.create).toHaveBeenCalledWith({ data: { agentId: 'agent-opencode', allowInternetAccess: true } }); + expect(dbAgentNetworkPolicyRuleMocks.create).toHaveBeenCalledWith({ + data: { agentNetworkPolicyId: 'policy-1', type: 'EGRESS', targetAppId: 'litellm-app', port: 4000, protocol: 'TCP' } }); + }); - it('adds an egress rule for a LiteLLM Gateway exposed through an app domain', async () => { - dbGatewayMocks.findUnique.mockResolvedValue({ - id: 'gateway-1', - baseUrl: 'https://litellm.example', - }); - dbAppMocks.findFirst.mockResolvedValue({ - id: 'litellm-app', - appDomains: [{ hostname: 'litellm.example', port: 4000 }], - }); - const template = structuredClone(geminiCliAgentTemplate); - template.templates[0].llmGatewayId = 'gateway-1'; - template.templates[0].modelAlias = ['gpt-4o']; + it('adds an egress rule for a LiteLLM Gateway exposed through an app domain', async () => { + dbGatewayMocks.findUnique.mockResolvedValue({ id: 'gateway-1', baseUrl: 'https://litellm.example' }); + dbAppMocks.findFirst.mockResolvedValue({ id: 'litellm-app', appDomains: [{ hostname: 'litellm.example', port: 4000 }] }); + const template = structuredClone(geminiCliAgentTemplate); + template.templates[0].llmGatewayId = 'gateway-1'; + template.templates[0].modelAlias = ['gpt-4o']; - await agentTemplateService.createAgentFromTemplate('project-1', template); + await agentTemplateService.createAgentFromTemplate('project-1', template); - expect(dbAgentNetworkPolicyRuleMocks.create).toHaveBeenCalledWith({ - data: { - agentNetworkPolicyId: 'policy-1', - type: 'EGRESS', - targetAppId: 'litellm-app', - port: 4000, - protocol: 'TCP', - }, - }); + expect(dbAgentNetworkPolicyRuleMocks.create).toHaveBeenCalledWith({ + data: { agentNetworkPolicyId: 'policy-1', type: 'EGRESS', targetAppId: 'litellm-app', port: 4000, protocol: 'TCP' } }); + }); }); diff --git a/src/server/services/cluster.service.ts b/src/server/services/cluster.service.ts index b485465a..539b1a7e 100644 --- a/src/server/services/cluster.service.ts +++ b/src/server/services/cluster.service.ts @@ -1,173 +1,183 @@ import { getCachedNodeSummary } from './node-summary-cache.service'; -import volumeSummaryAdapter from "../adapter/volume-summary.adapter"; -import k3s, { kubernetesPatchOptions } from "../adapter/kubernetes-api.adapter"; +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 { KubeSizeConverter } from "../../shared/utils/kubernetes-size-converter.utils"; -import { StorageClassUtils } from "@/shared/utils/storage-class.utils"; +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 { KubeSizeConverter } from '../../shared/utils/kubernetes-size-converter.utils'; +import { StorageClassUtils } from '@/shared/utils/storage-class.utils'; class ClusterService { - - async getNodeInfo(): Promise { - return await unstable_cache(async () => { - const nodeReturnInfo = await k3s.core.listNode(); - return nodeReturnInfo.items.map((node) => { - return { - name: node.metadata?.name!, - status: node.status?.conditions?.filter((condition) => condition.type === 'Ready')[0].status!, - os: node.status?.nodeInfo?.osImage!, - architecture: node.status?.nodeInfo?.architecture!, - cpuCapacity: node.status?.capacity?.cpu!, - ramCapacity: node.status?.capacity?.memory!, - ip: node.status?.addresses?.filter((address) => address.type === 'InternalIP')[0].address!, - kernelVersion: node.status?.nodeInfo?.kernelVersion!, - containerRuntimeVersion: node.status?.nodeInfo?.containerRuntimeVersion!, - kubeProxyVersion: node.status?.nodeInfo?.kubeProxyVersion!, - kubeletVersion: node.status?.nodeInfo?.kubeletVersion!, - isMasterNode: node.metadata?.labels?.['node-role.kubernetes.io/master'] === 'true', - - memoryOk: node.status?.conditions?.filter((condition) => condition.type === 'MemoryPressure')[0].status === 'False', - memoryStatusText: node.status?.conditions?.filter((condition) => condition.type === 'MemoryPressure')[0].message, - diskOk: node.status?.conditions?.filter((condition) => condition.type === 'DiskPressure')[0].status === 'False', - diskStatusText: node.status?.conditions?.filter((condition) => condition.type === 'DiskPressure')[0].message, - pidOk: node.status?.conditions?.filter((condition) => condition.type === 'PIDPressure')[0].status === 'False', - pidStatusText: node.status?.conditions?.filter((condition) => condition.type === 'PIDPressure')[0].message, - schedulable: !node.spec?.unschedulable - } - }); - }, - [Tags.nodeInfos()], { - revalidate: 10, - tags: [Tags.nodeInfos()] - })(); - } - - async getFirstMasterNode(): Promise { - const nodes = await this.getNodeInfo(); - nodes.sort((a, b) => a.name.localeCompare(b.name)); - return nodes.find(node => node.isMasterNode)!; // even on HA Cluster, only one node is returned - } - - async getStorageClasses(): Promise { - return await unstable_cache(async () => { - const storageClasses = await k3s.storage.listStorageClass(); - return StorageClassUtils.sortStorageClasses( - storageClasses.items - .map((storageClass) => storageClass.metadata?.name) - .filter((name): name is string => !!name) - .filter((name) => name !== 'longhorn-static') // filter out longhorn-static, because in longhorn setup only local path or longhorn should be used - ); - }, - [Tags.storageClasses()], { - revalidate: 60, - tags: [Tags.storageClasses()] - })(); + async getNodeInfo(): Promise { + return await unstable_cache( + async () => { + const nodeReturnInfo = await k3s.core.listNode(); + return nodeReturnInfo.items.map((node) => { + return { + name: node.metadata?.name!, + status: node.status?.conditions?.filter((condition) => condition.type === 'Ready')[0].status!, + os: node.status?.nodeInfo?.osImage!, + architecture: node.status?.nodeInfo?.architecture!, + cpuCapacity: node.status?.capacity?.cpu!, + ramCapacity: node.status?.capacity?.memory!, + ip: node.status?.addresses?.filter((address) => address.type === 'InternalIP')[0].address!, + kernelVersion: node.status?.nodeInfo?.kernelVersion!, + containerRuntimeVersion: node.status?.nodeInfo?.containerRuntimeVersion!, + kubeProxyVersion: node.status?.nodeInfo?.kubeProxyVersion!, + kubeletVersion: node.status?.nodeInfo?.kubeletVersion!, + isMasterNode: node.metadata?.labels?.['node-role.kubernetes.io/master'] === 'true', + + memoryOk: node.status?.conditions?.filter((condition) => condition.type === 'MemoryPressure')[0].status === 'False', + memoryStatusText: node.status?.conditions?.filter((condition) => condition.type === 'MemoryPressure')[0].message, + diskOk: node.status?.conditions?.filter((condition) => condition.type === 'DiskPressure')[0].status === 'False', + diskStatusText: node.status?.conditions?.filter((condition) => condition.type === 'DiskPressure')[0].message, + pidOk: node.status?.conditions?.filter((condition) => condition.type === 'PIDPressure')[0].status === 'False', + pidStatusText: node.status?.conditions?.filter((condition) => condition.type === 'PIDPressure')[0].message, + schedulable: !node.spec?.unschedulable + }; + }); + }, + [Tags.nodeInfos()], + { revalidate: 10, tags: [Tags.nodeInfos()] } + )(); + } + + async getFirstMasterNode(): Promise { + const nodes = await this.getNodeInfo(); + nodes.sort((a, b) => a.name.localeCompare(b.name)); + return nodes.find((node) => node.isMasterNode)!; // even on HA Cluster, only one node is returned + } + + async getStorageClasses(): Promise { + return await unstable_cache( + async () => { + const storageClasses = await k3s.storage.listStorageClass(); + return StorageClassUtils.sortStorageClasses( + storageClasses.items + .map((storageClass) => storageClass.metadata?.name) + .filter((name): name is string => !!name) + .filter((name) => name !== 'longhorn-static') // filter out longhorn-static, because in longhorn setup only local path or longhorn should be used + ); + }, + [Tags.storageClasses()], + { revalidate: 60, tags: [Tags.storageClasses()] } + )(); + } + + async getRuntimeClasses(): Promise { + 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.toLocaleLowerCase())); + }, + [Tags.runtimeClasses()], + { revalidate: 60, tags: [Tags.runtimeClasses()] } + )(); + } + + async setNodeStatus(nodeName: string, schedulable: boolean) { + try { + await k3s.core.patchNode( + { name: nodeName, body: { spec: { unschedulable: schedulable ? null : true } } }, + kubernetesPatchOptions('application/strategic-merge-patch+json') + ); + + if (!schedulable) { + // delete all pods on node + const pods = await k3s.core.listPodForAllNamespaces(); + for (const pod of pods.items) { + if (pod.spec?.nodeName === nodeName) { + await k3s.core.deleteNamespacedPod({ name: pod.metadata?.name!, namespace: pod.metadata?.namespace! }); + } + } + } + } finally { + revalidateTag(Tags.nodeInfos()); } + } + + 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); + + const metricsData: k8s.NodeMetricsList = await k3s.metrics.getNodeMetrics(); + + return await Promise.all( + topNodes.map(async (node) => { + const nodeMetrics = metricsData.items + .filter((metric) => metric.metadata.name === node.Node.metadata?.name) + .map((metric) => { + return { + timestamp: new Date(metric.timestamp), + cpuUsage: KubeSizeConverter.fromNanoToFullCpu(KubeSizeConverter.fromKubeSizeToNanoCpu(metric.usage.cpu)), + ramUsage: KubeSizeConverter.fromKubeSizeToBytes(metric.usage.memory) + }; + }); - async getRuntimeClasses(): Promise { - 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.toLocaleLowerCase())); - }, - [Tags.runtimeClasses()], { - revalidate: 60, - tags: [Tags.runtimeClasses()] - })(); - } + // sorted by timestamp descending + nodeMetrics.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); + const latestUsageItem = nodeMetrics[0]; - async setNodeStatus(nodeName: string, schedulable: boolean) { + let filesystem: { usedBytes?: number; availableBytes?: number; capacityBytes?: number; time?: string } | undefined; try { - await k3s.core.patchNode( - { name: nodeName, body: { spec: { unschedulable: schedulable ? null : true } } }, - kubernetesPatchOptions('application/strategic-merge-patch+json'), - ); - - if (!schedulable) { - // delete all pods on node - const pods = await k3s.core.listPodForAllNamespaces(); - for (const pod of pods.items) { - if (pod.spec?.nodeName === nodeName) { - await k3s.core.deleteNamespacedPod({ name: pod.metadata?.name!, namespace: pod.metadata?.namespace! }); - } - } - } - } finally { - revalidateTag(Tags.nodeInfos()); + 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. } - } - - 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); - - const metricsData: k8s.NodeMetricsList = await k3s.metrics.getNodeMetrics(); - - return await Promise.all(topNodes.map(async (node) => { - const nodeMetrics = metricsData.items.filter((metric) => metric.metadata.name === node.Node.metadata?.name) - .map((metric) => { - return { - timestamp: new Date(metric.timestamp), - cpuUsage: KubeSizeConverter.fromNanoToFullCpu(KubeSizeConverter.fromKubeSizeToNanoCpu(metric.usage.cpu)), - ramUsage: KubeSizeConverter.fromKubeSizeToBytes(metric.usage.memory) - } - }); - - // sorted by timestamp descending - nodeMetrics.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); - const latestUsageItem = nodeMetrics[0]; - - 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!, - cpuUsage: latestUsageItem.cpuUsage, - cpuCapacity: Number(node.CPU?.Capacity!), - ramUsage: latestUsageItem.ramUsage, - ramCapacity: Number(node.Memory?.Capacity!), - diskUsageAbsolut: filesystem?.usedBytes, - diskUsageAvailable: filesystem?.availableBytes, - diskUsageCapacity: filesystem?.capacityBytes - } - })); - } + return { + name: node.Node.metadata?.name!, + cpuUsage: latestUsageItem.cpuUsage, + cpuCapacity: Number(node.CPU?.Capacity!), + ramUsage: latestUsageItem.ramUsage, + ramCapacity: Number(node.Memory?.Capacity!), + diskUsageAbsolut: filesystem?.usedBytes, + diskUsageAvailable: filesystem?.availableBytes, + diskUsageCapacity: filesystem?.capacityBytes + }; + }) + ); + } } const clusterService = new ClusterService(); diff --git a/src/server/services/cluster.service.unit.spec.ts b/src/server/services/cluster.service.unit.spec.ts index 70d23cc2..744d01dc 100644 --- a/src/server/services/cluster.service.unit.spec.ts +++ b/src/server/services/cluster.service.unit.spec.ts @@ -1,60 +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, + ...(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 }, + 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' }, - })) }); + 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('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 } } }; }); - - it('does not present stale or incomplete filesystem measurements', async () => { - mocks.getNodeSummary.mockImplementation(async (name: string) => ({ node: { fs: name === 'node-a' + 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]); - }); + : { 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); - }); + 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 cf907bd8..bebf44f4 100644 --- a/src/server/services/monitoring.service.ts +++ b/src/server/services/monitoring.service.ts @@ -1,167 +1,157 @@ import { getCachedNodeSummary } from './node-summary-cache.service'; -import k3s from "../adapter/kubernetes-api.adapter"; +import k3s from '../adapter/kubernetes-api.adapter'; import * as k8s from '@kubernetes/client-node'; -import standalonePodService from "./standalone-services/standalone-pod.service"; -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 { 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 { AppVolumeIdentityUtils } from "@/shared/utils/app-volume-identity.utils"; -import projectService from "./project.service"; -import { AppMonitoringUsageModel } from "@/shared/model/app-monitoring-usage.model"; +import standalonePodService from './standalone-services/standalone-pod.service'; +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 { 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 { 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(): Promise { - const appVolumes = await dataAccess.client.appVolume.findMany({ - include: { app: { include: { project: true } } }, - orderBy: { appId: 'asc' }, - }); - 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() { - const [topPods, totalResourcesNodes, projects] = await Promise.all([ - k8s.topPods(k3s.core, new k8s.Metrics(k3s.getKubeConfig())), - this.getTotalAvailableNodeRessources(), - projectService.getAll() - ]); - - const topPodsByApp = this.groupTopPodsByApp(topPods); - const appStats: AppMonitoringUsageModel[] = []; - - for (let project of projects) { - for (let app of project.apps) { - const filteredTopPods = topPodsByApp.get(MonitorService.appPodKey(project.id, app.id)) ?? []; - const totalResourcesApp = this.calulateTotalRessourceUsageOfApp(filteredTopPods); - const cpuUsagePercent = (totalResourcesApp.cpu / totalResourcesNodes.cpu) * 100; - appStats.push({ - projectId: project.id, - projectName: project.name, - appName: app.name, - appId: app.id, - cpuUsage: totalResourcesApp.cpu, - cpuUsagePercent, - ramUsageBytes: totalResourcesApp.ramBytes - }) - } - } - appStats.sort((a, b) => { - if (a.projectName === b.projectName) { - return a.appName.localeCompare(b.appName); - } - return a.projectName.localeCompare(b.projectName); - }); - return appStats; - } - - /** - * Groups the cluster-wide pod metrics by the namespace and the `app` label, - * so each app's pods can be looked up in constant time without listing pods per app. - */ - private groupTopPodsByApp(topPods: k8s.PodStatus[]): Map { - const topPodsByApp = new Map(); - for (const topPod of topPods) { - const namespace = topPod.Pod.metadata?.namespace; - const appId = topPod.Pod.metadata?.labels?.['app']; - if (!namespace || !appId) { - continue; - } - const key = MonitorService.appPodKey(namespace, appId); - const pods = topPodsByApp.get(key); - if (pods) { - pods.push(topPod); - } else { - topPodsByApp.set(key, [topPod]); - } - } - return topPodsByApp; - } - - private static appPodKey(namespace: string, appId: string): string { - return `${namespace}/${appId}`; - } - - async getMonitoringForApp(projectId: string, appId: string): Promise { - const metricsClient = new k8s.Metrics(k3s.getKubeConfig()); - const podsFromApp = await standalonePodService.getPodsForApp(projectId, appId); - const topPods = await k8s.topPods(k3s.core, metricsClient, projectId); - - const filteredTopPods = topPods.filter((topPod) => - podsFromApp.some((pod) => pod.podName === topPod.Pod.metadata?.name) - ); - - const totalResourcesNodes = await this.getTotalAvailableNodeRessources(); - const totalResourcesApp = this.calulateTotalRessourceUsageOfApp(filteredTopPods); - - var totalRamNodesCorrectUnit: number = totalResourcesNodes.ramBytes; - var totalRamAppCorrectUnit: number = totalResourcesApp.ramBytes; - - const appCpuUsagePercent = ((totalResourcesApp.cpu / totalResourcesNodes.cpu) * 100); - const appRamUsagePercent = ((totalRamAppCorrectUnit / totalRamNodesCorrectUnit) * 100); - + async getAllAppVolumesUsage(): Promise { + const appVolumes = await dataAccess.client.appVolume.findMany({ include: { app: { include: { project: true } } }, orderBy: { appId: 'asc' } }); + const usages = await appVolumeFilesystemService.getVolumeFilesystemUsage( + appVolumes.map((volume) => ({ ...volume, projectId: volume.app.projectId })), + () => pvcService.getAllPvc(), + getCachedNodeSummary + ); + return appVolumes + .map((volume) => { return { - cpuPercent: appCpuUsagePercent, - cpuAbsolutCores: totalResourcesApp.cpu, - ramPercent: appRamUsagePercent, - ramAbsolutBytes: totalRamAppCorrectUnit - } - } - - private calulateTotalRessourceUsageOfApp(filteredTopPods: k8s.PodStatus[]) { - return filteredTopPods.reduce( - (acc, pod) => { - acc.cpu += Number(pod.CPU.CurrentUsage) || 0; - acc.ramBytes += Number(pod.Memory.CurrentUsage) || 0; - return acc; - }, - { cpu: 0, ramBytes: 0 } - ); - } - - private async getTotalAvailableNodeRessources() { - const topNodes = await clusterService.getNodeInfo(); - const totalResourcesNodes = topNodes.reduce( - (acc, node) => { - acc.cpu += Number(node.cpuCapacity) || 0; - acc.ramBytes += KubeSizeConverter.fromKubeSizeToBytes(node.ramCapacity) || 0; - return acc; - }, - { cpu: 0, ramBytes: 0 } - ); - return totalResourcesNodes; - } - - async getPvcUsageFromApp(appId: string, projectId: string): Promise { - const volumes = await dataAccess.client.appVolume.findMany({ - where: { appId }, - select: { id: true, sharedVolumeId: true }, + ...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() { + const [topPods, totalResourcesNodes, projects] = await Promise.all([ + k8s.topPods(k3s.core, new k8s.Metrics(k3s.getKubeConfig())), + this.getTotalAvailableNodeRessources(), + projectService.getAll() + ]); + + const topPodsByApp = this.groupTopPodsByApp(topPods); + const appStats: AppMonitoringUsageModel[] = []; + + for (let project of projects) { + for (let app of project.apps) { + const filteredTopPods = topPodsByApp.get(MonitorService.appPodKey(project.id, app.id)) ?? []; + const totalResourcesApp = this.calulateTotalRessourceUsageOfApp(filteredTopPods); + const cpuUsagePercent = (totalResourcesApp.cpu / totalResourcesNodes.cpu) * 100; + appStats.push({ + projectId: project.id, + projectName: project.name, + appName: app.name, + appId: app.id, + cpuUsage: totalResourcesApp.cpu, + cpuUsagePercent, + ramUsageBytes: totalResourcesApp.ramBytes }); - 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()); + } + } + appStats.sort((a, b) => { + if (a.projectName === b.projectName) { + return a.appName.localeCompare(b.appName); + } + return a.projectName.localeCompare(b.projectName); + }); + return appStats; + } + + /** + * Groups the cluster-wide pod metrics by the namespace and the `app` label, + * so each app's pods can be looked up in constant time without listing pods per app. + */ + private groupTopPodsByApp(topPods: k8s.PodStatus[]): Map { + const topPodsByApp = new Map(); + for (const topPod of topPods) { + const namespace = topPod.Pod.metadata?.namespace; + const appId = topPod.Pod.metadata?.labels?.['app']; + if (!namespace || !appId) { + continue; + } + const key = MonitorService.appPodKey(namespace, appId); + const pods = topPodsByApp.get(key); + if (pods) { + pods.push(topPod); + } else { + topPodsByApp.set(key, [topPod]); + } } + return topPodsByApp; + } + + private static appPodKey(namespace: string, appId: string): string { + return `${namespace}/${appId}`; + } + + async getMonitoringForApp(projectId: string, appId: string): Promise { + const metricsClient = new k8s.Metrics(k3s.getKubeConfig()); + const podsFromApp = await standalonePodService.getPodsForApp(projectId, appId); + const topPods = await k8s.topPods(k3s.core, metricsClient, projectId); + + const filteredTopPods = topPods.filter((topPod) => podsFromApp.some((pod) => pod.podName === topPod.Pod.metadata?.name)); + + const totalResourcesNodes = await this.getTotalAvailableNodeRessources(); + const totalResourcesApp = this.calulateTotalRessourceUsageOfApp(filteredTopPods); + + var totalRamNodesCorrectUnit: number = totalResourcesNodes.ramBytes; + var totalRamAppCorrectUnit: number = totalResourcesApp.ramBytes; + + const appCpuUsagePercent = (totalResourcesApp.cpu / totalResourcesNodes.cpu) * 100; + const appRamUsagePercent = (totalRamAppCorrectUnit / totalRamNodesCorrectUnit) * 100; + + return { cpuPercent: appCpuUsagePercent, cpuAbsolutCores: totalResourcesApp.cpu, ramPercent: appRamUsagePercent, ramAbsolutBytes: totalRamAppCorrectUnit }; + } + + private calulateTotalRessourceUsageOfApp(filteredTopPods: k8s.PodStatus[]) { + return filteredTopPods.reduce( + (acc, pod) => { + acc.cpu += Number(pod.CPU.CurrentUsage) || 0; + acc.ramBytes += Number(pod.Memory.CurrentUsage) || 0; + return acc; + }, + { cpu: 0, ramBytes: 0 } + ); + } + + private async getTotalAvailableNodeRessources() { + const topNodes = await clusterService.getNodeInfo(); + const totalResourcesNodes = topNodes.reduce( + (acc, node) => { + acc.cpu += Number(node.cpuCapacity) || 0; + acc.ramBytes += KubeSizeConverter.fromKubeSizeToBytes(node.ramCapacity) || 0; + return acc; + }, + { cpu: 0, ramBytes: 0 } + ); + return totalResourcesNodes; + } + + async getPvcUsageFromApp(appId: string, projectId: string): Promise { + const volumes = await dataAccess.client.appVolume.findMany({ where: { appId }, select: { id: true, sharedVolumeId: true } }); + 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()); + } } const monitoringService = new MonitorService(); diff --git a/src/server/services/monitoring.service.unit.spec.ts b/src/server/services/monitoring.service.unit.spec.ts index ed68363c..d3a805aa 100644 --- a/src/server/services/monitoring.service.unit.spec.ts +++ b/src/server/services/monitoring.service.unit.spec.ts @@ -2,59 +2,26 @@ vi.mock('next/cache', () => ({ unstable_cache: (fn: unknown) => fn, revalidateTa import * as k8s from '@kubernetes/client-node'; -const k8sMocks = vi.hoisted(() => ({ - topPods: vi.fn(), -})); +const k8sMocks = vi.hoisted(() => ({ topPods: vi.fn() })); vi.mock('@kubernetes/client-node', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - topPods: k8sMocks.topPods, - }; + const actual = await importOriginal(); + return { ...actual, topPods: k8sMocks.topPods }; }); vi.mock('@/server/adapter/kubernetes-api.adapter', () => ({ - default: { - core: { listNode: vi.fn(), listPersistentVolume: vi.fn() }, - getNodeSummary: vi.fn(), - getKubeConfig: vi.fn(() => ({})), - }, + default: { core: { listNode: vi.fn(), listPersistentVolume: vi.fn() }, getNodeSummary: vi.fn(), getKubeConfig: vi.fn(() => ({})) } })); -vi.mock('@/server/adapter/db.client', () => ({ - default: { - client: { - appVolume: { - findMany: vi.fn(), - }, - }, - }, -})); +vi.mock('@/server/adapter/db.client', () => ({ default: { client: { appVolume: { findMany: vi.fn() } } } })); -vi.mock('@/server/services/project.service', () => ({ - default: { - getAll: vi.fn(), - }, -})); +vi.mock('@/server/services/project.service', () => ({ default: { getAll: vi.fn() } })); -vi.mock('@/server/services/cluster.service', () => ({ - default: { - getNodeInfo: vi.fn(), - }, -})); +vi.mock('@/server/services/cluster.service', () => ({ default: { getNodeInfo: vi.fn() } })); -vi.mock('@/server/services/pvc.service', () => ({ - default: { - getAllPvc: vi.fn(), - }, -})); +vi.mock('@/server/services/pvc.service', () => ({ default: { getAllPvc: vi.fn() } })); -vi.mock('@/server/services/standalone-services/standalone-pod.service', () => ({ - default: { - getPodsForApp: vi.fn(), - }, -})); +vi.mock('@/server/services/standalone-services/standalone-pod.service', () => ({ default: { getPodsForApp: vi.fn() } })); import k3s from '@/server/adapter/kubernetes-api.adapter'; import dataAccess from '@/server/adapter/db.client'; @@ -72,181 +39,202 @@ const mockedGetAllPvc = vi.mocked(pvcService.getAllPvc); const mockedGetPodsForApp = vi.mocked(standalonePodService.getPodsForApp); function nodeInfo(overrides: Record = {}) { - return { - name: 'node-1', - cpuCapacity: '8', - ramCapacity: '16Gi', - ...overrides, - }; + return { name: 'node-1', cpuCapacity: '8', ramCapacity: '16Gi', ...overrides }; } function app(id: string, projectId = 'proj-1') { - return { id, name: `App ${id}`, projectId }; + return { id, name: `App ${id}`, projectId }; } function project(id: string, name: string, apps: ReturnType[]) { - return { id, name, apps }; + return { id, name, apps }; } function topPod(namespace: string, name: string, appId: string | undefined, cpu: number, ram: number): k8s.PodStatus { - return { - Pod: { metadata: { namespace, name, labels: { app: appId } } }, - CPU: { CurrentUsage: cpu }, - Memory: { CurrentUsage: ram }, - } as unknown as k8s.PodStatus; + return { + Pod: { metadata: { namespace, name, labels: { app: appId } } }, + CPU: { CurrentUsage: cpu }, + Memory: { CurrentUsage: ram } + } as unknown as k8s.PodStatus; } function appVolume(id: string, appId: string, size: number, sharedVolumeId: string | null) { - return { - id, - appId, - size, - sharedVolumeId, - containerMountPath: `/${id}`, - app: { - projectId: 'proj-1', - name: `App ${appId}`, - project: { name: 'Project 1' }, - }, - }; + return { id, appId, size, sharedVolumeId, containerMountPath: `/${id}`, app: { projectId: 'proj-1', name: `App ${appId}`, project: { name: 'Project 1' } } }; } describe('monitoringService.getMonitoringForAllApps', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockedGetNodeInfo.mockResolvedValue([nodeInfo()] as never); - }); - - it('fetches cluster pod metrics once regardless of the app count', async () => { - mockedProjectGetAll.mockResolvedValue([ - project('proj-1', 'Project 1', Array.from({ length: 100 }, (_, i) => app(`app-${i}`))), - ] as never); - mockedTopPods.mockResolvedValue([topPod('proj-1', 'pod-0', 'app-0', 1, 1024)]); - - const result = await monitoringService.getMonitoringForAllApps(); - - expect(mockedTopPods).toHaveBeenCalledTimes(1); - expect(mockedGetPodsForApp).not.toHaveBeenCalled(); - expect(result).toHaveLength(100); - }); - - it('assigns pod metrics by namespace and app label without listing pods per app', async () => { - mockedProjectGetAll.mockResolvedValue([ - project('proj-1', 'Project 1', [app('app-a')]), - project('proj-2', 'Project 2', [app('app-b', 'proj-2')]), - ] as never); - mockedTopPods.mockResolvedValue([ - topPod('proj-1', 'pod-a-1', 'app-a', 2, 200), - topPod('proj-1', 'pod-a-2', 'app-a', 1, 100), - topPod('proj-2', 'pod-b-1', 'app-b', 4, 400), - topPod('proj-1', 'pod-unlabeled', undefined, 8, 800), - topPod('proj-3', 'pod-other-namespace', 'app-a', 16, 1600), - ]); - - const result = await monitoringService.getMonitoringForAllApps(); - - const appA = result.find((entry) => entry.appId === 'app-a')!; - const appB = result.find((entry) => entry.appId === 'app-b')!; - expect(appA.cpuUsage).toBe(3); - expect(appA.ramUsageBytes).toBe(300); - expect(appA.cpuUsagePercent).toBeCloseTo((3 / 8) * 100); - expect(appB.cpuUsage).toBe(4); - expect(appB.ramUsageBytes).toBe(400); - expect(mockedGetPodsForApp).not.toHaveBeenCalled(); - }); + beforeEach(() => { + vi.clearAllMocks(); + mockedGetNodeInfo.mockResolvedValue([nodeInfo()] as never); + }); + + it('fetches cluster pod metrics once regardless of the app count', async () => { + mockedProjectGetAll.mockResolvedValue([ + project( + 'proj-1', + 'Project 1', + Array.from({ length: 100 }, (_, i) => app(`app-${i}`)) + ) + ] as never); + mockedTopPods.mockResolvedValue([topPod('proj-1', 'pod-0', 'app-0', 1, 1024)]); + + const result = await monitoringService.getMonitoringForAllApps(); + + expect(mockedTopPods).toHaveBeenCalledTimes(1); + expect(mockedGetPodsForApp).not.toHaveBeenCalled(); + expect(result).toHaveLength(100); + }); + + it('assigns pod metrics by namespace and app label without listing pods per app', async () => { + mockedProjectGetAll.mockResolvedValue([project('proj-1', 'Project 1', [app('app-a')]), project('proj-2', 'Project 2', [app('app-b', 'proj-2')])] as never); + mockedTopPods.mockResolvedValue([ + topPod('proj-1', 'pod-a-1', 'app-a', 2, 200), + topPod('proj-1', 'pod-a-2', 'app-a', 1, 100), + topPod('proj-2', 'pod-b-1', 'app-b', 4, 400), + topPod('proj-1', 'pod-unlabeled', undefined, 8, 800), + topPod('proj-3', 'pod-other-namespace', 'app-a', 16, 1600) + ]); + + const result = await monitoringService.getMonitoringForAllApps(); + + const appA = result.find((entry) => entry.appId === 'app-a')!; + const appB = result.find((entry) => entry.appId === 'app-b')!; + expect(appA.cpuUsage).toBe(3); + expect(appA.ramUsageBytes).toBe(300); + expect(appA.cpuUsagePercent).toBeCloseTo((3 / 8) * 100); + expect(appB.cpuUsage).toBe(4); + expect(appB.ramUsageBytes).toBe(400); + expect(mockedGetPodsForApp).not.toHaveBeenCalled(); + }); }); describe('monitoringService.getAllAppVolumesUsage', () => { - 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 }] }] }); - - 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', 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([ - { 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' }); + 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('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' }); + 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', 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([ + { 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 } + ] + } + ] + }; }); - - it('reports unknown drivers and unmounted bound volumes without exposing host usage', async () => { - mockedFindMany.mockResolvedValue([ - appVolume('unknown', 'app-1', 1, null), - appVolume('unmounted', 'app-2', 1, null), - ] as never); - mockedGetAllPvc.mockResolvedValue([ - { 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(v => v.appId === 'app-1')).toMatchObject({ status: 'unsupported' }); - expect(result.find(v => v.appId === 'app-2')).toMatchObject({ status: 'unavailable' }); + 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' }); + }); + + 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 } + ] + } + ] }); - - 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 }), - ]); + expect((await monitoringService.getAllAppVolumesUsage())[0]).toMatchObject({ status: 'unavailable' }); + }); + + it('reports unknown drivers and unmounted bound volumes without exposing host usage', async () => { + mockedFindMany.mockResolvedValue([appVolume('unknown', 'app-1', 1, null), appVolume('unmounted', 'app-2', 1, null)] as never); + mockedGetAllPvc.mockResolvedValue([ + { 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((v) => v.appId === 'app-1')).toMatchObject({ status: 'unsupported' }); + expect(result.find((v) => v.appId === 'app-2')).toMatchObject({ status: 'unavailable' }); + }); + + 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 index a6562ee4..7f704d46 100644 --- a/src/server/services/node-summary-cache.service.ts +++ b/src/server/services/node-summary-cache.service.ts @@ -1,18 +1,14 @@ 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 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 existing = inFlight.get(nodeName); + if (existing) return existing; - const request = cachedSummary(nodeName).finally(() => inFlight.delete(nodeName)); - inFlight.set(nodeName, request); - return request; + 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 index d659403e..4169bbc5 100644 --- a/src/server/services/node-summary-cache.service.unit.spec.ts +++ b/src/server/services/node-summary-cache.service.unit.spec.ts @@ -5,15 +5,20 @@ 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); + 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 index dee94b65..73f2872d 100644 --- a/src/server/services/standalone-services/app-history.service.ts +++ b/src/server/services/standalone-services/app-history.service.ts @@ -5,60 +5,66 @@ 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); + 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; } - 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; - } - } + 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. */ } - result.set(app.id, { cpuCores: cpuComplete ? cpu : null, ramBytes: ramComplete ? ram : null, measuredAt: (cpuComplete || ramComplete) && Number.isFinite(oldest) ? new Date(oldest) : null }); + if (!Number.isFinite(value) || value < 0) { + if (kind === 'cpu') cpuComplete = false; + else ramComplete = false; + } else if (kind === 'cpu') cpu += value; + else ram += value; + } } - return result; + } + 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(); 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 index 70674ed9..aea1ca34 100644 --- a/src/server/services/standalone-services/app-history.service.unit.spec.ts +++ b/src/server/services/standalone-services/app-history.service.unit.spec.ts @@ -2,43 +2,53 @@ 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() }, -})); +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); + 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 }); + 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 }); + 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 }); + 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 index 0ea61ced..cf34ca45 100644 --- 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 @@ -1,43 +1,74 @@ -vi.mock('@/server/adapter/app-history.adapter', () => ({ - default: { getRetentionHours: vi.fn().mockResolvedValue(24), getHistory: vi.fn() }, -})); +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 }); + 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]); + 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 }); + 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 index 690bf991..f031c6dc 100644 --- a/src/server/services/standalone-services/app-monitoring-history.service.ts +++ b/src/server/services/standalone-services/app-monitoring-history.service.ts @@ -14,166 +14,164 @@ const COLLECTION_INTERVAL_MS = COLLECTION_INTERVAL_MINUTES * 60_000; const GAP_THRESHOLD_MS = COLLECTION_INTERVAL_MS + 2 * 60_000; class AppMonitoringHistoryService { - private running = false; + private running = false; - initialize() { - scheduleService.scheduleJob('app-monitoring-history', `*/${COLLECTION_INTERVAL_MINUTES} * * * *`, () => { - void this.collect(); - }); - void this.collect(); - } + 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, - }); + 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); } - 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 }); - } + 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 }); } - return { retentionHours: retention, points, volumes: [...volumeSeries.values()], latestCpuMeasurement, latestRamMeasurement, latestVolumeMeasurement }; + 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 + }); } - - async getRetentionHours() { - return appHistoryAdapter.getRetentionHours(); + 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 }); } - - async setRetentionHours(hours: number) { - await appHistoryAdapter.setRetentionHours(hours); + 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; + 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 index f6ad3e00..a8d5c6bd 100644 --- 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 @@ -8,57 +8,66 @@ 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), - ); + 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; + 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); + 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)); + 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 index 0e3d5f30..c48d9e57 100644 --- a/src/server/services/standalone-services/app-volume-filesystem.service.ts +++ b/src/server/services/standalone-services/app-volume-filesystem.service.ts @@ -5,73 +5,78 @@ import { VolumeFilesystemUsage } from '@/shared/model/app-volume-monitoring-usag 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; + 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(); 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 index 4c4467c8..7d87fff7 100644 --- 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 @@ -1,32 +1,32 @@ vi.mock('@/server/adapter/volume-summary.adapter', () => ({ - default: { getPersistentVolumeClaims: vi.fn(), getPersistentVolumes: vi.fn(), getSamples: vi.fn() }, + 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' }); + 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 index e03936b3..196ea756 100644 --- a/src/shared/model/app-monitoring-history.model.ts +++ b/src/shared/model/app-monitoring-history.model.ts @@ -1,22 +1,22 @@ export interface AppHistoryPoint { - time: number; - isGap?: boolean; - cpuCores: number | null; - ramBytes: number | null; - storageBytes: number | null; - availableVolumes: number; - unavailableVolumes: number; + 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 }[]; + 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; + 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 738a24f7..374a99c0 100644 --- a/src/shared/model/app-volume-monitoring-usage.model.ts +++ b/src/shared/model/app-volume-monitoring-usage.model.ts @@ -1,19 +1,19 @@ export type VolumeUsageStatus = 'available' | 'unavailable' | 'stale' | 'unsupported'; export interface VolumeFilesystemUsage { - pvcName: string; - status: VolumeUsageStatus; - usedBytes?: number; - availableBytes?: number; - capacityBytes?: number; - measuredAt?: string; + 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; + projectId: string; + projectName: string; + appName: string; + appId: string; + mountPath: string; + isBaseVolume: boolean; } diff --git a/src/shared/model/node-resource.model.ts b/src/shared/model/node-resource.model.ts index c7fbc327..9e5bfd66 100644 --- a/src/shared/model/node-resource.model.ts +++ b/src/shared/model/node-resource.model.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from 'zod'; export const nodeResourceZodModel = z.object({ name: z.string(), @@ -8,7 +8,7 @@ export const nodeResourceZodModel = z.object({ ramCapacity: z.number(), diskUsageAbsolut: z.number().optional(), diskUsageCapacity: z.number().optional(), - diskUsageAvailable: z.number().optional(), -}) + diskUsageAvailable: z.number().optional() +}); -export type NodeResourceModel = z.infer; \ No newline at end of file +export type NodeResourceModel = z.infer; diff --git a/src/shared/utils/app-volume-identity.utils.ts b/src/shared/utils/app-volume-identity.utils.ts index d3011fcf..bf01a2ca 100644 --- a/src/shared/utils/app-volume-identity.utils.ts +++ b/src/shared/utils/app-volume-identity.utils.ts @@ -1,13 +1,13 @@ export class AppVolumeIdentityUtils { - static underlyingId(id: string, sharedVolumeId: string | null): string { - return sharedVolumeId || id; - } + 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 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}`; - } + 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 6df0a825..84a38c50 100644 --- a/src/shared/utils/constants.ts +++ b/src/shared/utils/constants.ts @@ -1,65 +1,58 @@ 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; - static readonly TERMINAL_MAX_QUEUED_INPUT_BYTES = 65536; - static readonly TERMINAL_REVALIDATION_INTERVAL_MS = 120000; - static readonly TERMINAL_MAX_DURATION_MS = 60 * 60 * 1000; - static readonly QS_ANNOTATION_APP_ID = 'qs-app-id'; - static readonly QS_ANNOTATION_AGENT_ID = 'qs-agent-id'; - static readonly QS_ANNOTATION_WORKLOAD_ID = 'qs-workload-id'; - static readonly QS_ANNOTATION_WORKLOAD_TYPE = 'qs-workload-type'; - static readonly QS_ANNOTATION_AGENT_VOLUME_ID = 'qs-agent-volume-id'; - static readonly QS_ANNOTATION_USER_ID = 'qs-user-id'; - static readonly QS_ANNOTATION_CUSTOM_TAG = 'qs-custom-tag'; - static readonly QS_ANNOTATION_BUILD_NAME = 'qs-build-name'; - static readonly QS_ANNOTATION_PROJECT_ID = 'qs-project-id'; - static readonly QS_ANNOTATION_CONTAINER_TYPE = 'qs-containter-type'; - static readonly QS_ANNOTATION_CONTAINER_TYPE_DB_BACKUP_JOB = 'qs-job-database-backup'; - static readonly QS_ANNOTATION_CONTAINER_TYPE_DB_TOOL = 'qs-container-db-tool'; - static readonly QS_ANNOTATION_DEPLOYMENT_ID = 'qs-deplyoment-id'; - static readonly QS_ANNOTATION_GIT_COMMIT = 'qs-git-commit'; - static readonly QS_ANNOTATION_GIT_COMMIT_MESSAGE = 'qs-git-commit-message'; - static readonly QS_ANNOTATION_ROLLBACK = 'qs-is-rollback'; - static readonly QS_ANNOTATION_VALUE_TRUE = 'true'; - static readonly QS_ANNOTATION_BUILD_QUEUED_AT = 'qs-build-queued-at'; - static readonly QS_ANNOTATION_BUILD_METHOD = 'qs-build-method'; - static readonly QS_ANNOTATION_GIT_SSH_SECRET = 'qs-git-ssh-secret'; - static readonly QS_ANNOTATION_UPDATED_AT = 'qs-ressource-updated-at'; - static readonly QS_LABEL_AGENT_TAGGED_VIRTUAL_KEY = 'qs-agent-tagged-virtual-key'; - static readonly QS_LABEL_AGENT_SANDBOX_VIRTUAL_KEY = 'qs-agent-sandbox-virtual-key'; - static readonly QS_ANNOTATION_SANDBOX_NAME = 'qs-sandbox-name'; - static readonly K3S_JOIN_TOKEN = 'k3sJoinToken'; - static readonly QS_NAMESPACE = 'quickstack'; - static readonly QS_APP_NAME = 'quickstack'; - static readonly QS_VOLUME_TYPE_PER_CUSTOM_TAG = 'PER_CUSTOM_TAG'; - static readonly INTERNAL_REGISTRY_LOCATION = 'internal-registry-location'; - static readonly TRAEFIK_ME_SECRET_NAME = 'traefik-me-tls'; - static readonly QS_SYSTEM_BACKUP_DEACTIVATED = 'deactivated'; - static readonly QS_SYSTEM_BACKUP_LOCATION_PARAM_KEY = 'qsSystemBackupLocation'; - static readonly DEFAULT_HEALTH_CHECK_PERIOD_SECONDS = 15; - static readonly DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS = 10; - static readonly DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD = 3; - static readonly DEFAULT_LITELLM_PORT = 4000; - static readonly DATABASE_TEMPLATE_PORTS = { - MARIADB: 3306, - MONGODB: 27017, - MYSQL: 3306, - POSTGRES: 5432, - REDIS: 6379, - } as const; - static readonly TOLERATION_FOR_EXECUTED_CRON_BACKUPS_MS = 60 * 60 * 1000; // 60 minutes; - static readonly BUILD_NODE_K3S_NATIVE_VALUE = 'k3s-native'; - static readonly BUILD_AUTO_NODE_VALUE = '__auto__'; - static readonly DEFAULT_MAX_PARALLEL_BUILDS = 1; - static readonly MAX_PARALLEL_BUILDS_LIMIT = 20; - static readonly DEFAULT_MAX_LOG_LINES = 1000; - static readonly QS_BUILD_INIT_CONTAINER_NAME = 'build-queue-init'; - static readonly QS_AGENT_ROUTER_NAMESPACE = Constants.QS_NAMESPACE; - static readonly QS_AUTH_PROXY_IMAGE = 'quickstack/auth-proxy:canary'; - static readonly QS_AGENT_CLAIM_COOKIE = 'qs-agent-claim'; - static readonly QS_AGENT_ACCESS_COOKIE = 'qs-agent-access'; - static readonly QS_PARAM_SERVER_HOSTNAME = 'qsServerHostname'; - + 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; + static readonly TERMINAL_MAX_QUEUED_INPUT_BYTES = 65536; + static readonly TERMINAL_REVALIDATION_INTERVAL_MS = 120000; + static readonly TERMINAL_MAX_DURATION_MS = 60 * 60 * 1000; + static readonly QS_ANNOTATION_APP_ID = 'qs-app-id'; + static readonly QS_ANNOTATION_AGENT_ID = 'qs-agent-id'; + static readonly QS_ANNOTATION_WORKLOAD_ID = 'qs-workload-id'; + static readonly QS_ANNOTATION_WORKLOAD_TYPE = 'qs-workload-type'; + static readonly QS_ANNOTATION_AGENT_VOLUME_ID = 'qs-agent-volume-id'; + static readonly QS_ANNOTATION_USER_ID = 'qs-user-id'; + static readonly QS_ANNOTATION_CUSTOM_TAG = 'qs-custom-tag'; + static readonly QS_ANNOTATION_BUILD_NAME = 'qs-build-name'; + static readonly QS_ANNOTATION_PROJECT_ID = 'qs-project-id'; + static readonly QS_ANNOTATION_CONTAINER_TYPE = 'qs-containter-type'; + static readonly QS_ANNOTATION_CONTAINER_TYPE_DB_BACKUP_JOB = 'qs-job-database-backup'; + static readonly QS_ANNOTATION_CONTAINER_TYPE_DB_TOOL = 'qs-container-db-tool'; + static readonly QS_ANNOTATION_DEPLOYMENT_ID = 'qs-deplyoment-id'; + static readonly QS_ANNOTATION_GIT_COMMIT = 'qs-git-commit'; + static readonly QS_ANNOTATION_GIT_COMMIT_MESSAGE = 'qs-git-commit-message'; + static readonly QS_ANNOTATION_ROLLBACK = 'qs-is-rollback'; + static readonly QS_ANNOTATION_VALUE_TRUE = 'true'; + static readonly QS_ANNOTATION_BUILD_QUEUED_AT = 'qs-build-queued-at'; + static readonly QS_ANNOTATION_BUILD_METHOD = 'qs-build-method'; + static readonly QS_ANNOTATION_GIT_SSH_SECRET = 'qs-git-ssh-secret'; + static readonly QS_ANNOTATION_UPDATED_AT = 'qs-ressource-updated-at'; + static readonly QS_LABEL_AGENT_TAGGED_VIRTUAL_KEY = 'qs-agent-tagged-virtual-key'; + static readonly QS_LABEL_AGENT_SANDBOX_VIRTUAL_KEY = 'qs-agent-sandbox-virtual-key'; + static readonly QS_ANNOTATION_SANDBOX_NAME = 'qs-sandbox-name'; + static readonly K3S_JOIN_TOKEN = 'k3sJoinToken'; + static readonly QS_NAMESPACE = 'quickstack'; + static readonly QS_APP_NAME = 'quickstack'; + static readonly QS_VOLUME_TYPE_PER_CUSTOM_TAG = 'PER_CUSTOM_TAG'; + static readonly INTERNAL_REGISTRY_LOCATION = 'internal-registry-location'; + static readonly TRAEFIK_ME_SECRET_NAME = 'traefik-me-tls'; + static readonly QS_SYSTEM_BACKUP_DEACTIVATED = 'deactivated'; + static readonly QS_SYSTEM_BACKUP_LOCATION_PARAM_KEY = 'qsSystemBackupLocation'; + static readonly DEFAULT_HEALTH_CHECK_PERIOD_SECONDS = 15; + static readonly DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS = 10; + static readonly DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD = 3; + static readonly DEFAULT_LITELLM_PORT = 4000; + static readonly DATABASE_TEMPLATE_PORTS = { MARIADB: 3306, MONGODB: 27017, MYSQL: 3306, POSTGRES: 5432, REDIS: 6379 } as const; + static readonly TOLERATION_FOR_EXECUTED_CRON_BACKUPS_MS = 60 * 60 * 1000; // 60 minutes; + static readonly BUILD_NODE_K3S_NATIVE_VALUE = 'k3s-native'; + static readonly BUILD_AUTO_NODE_VALUE = '__auto__'; + static readonly DEFAULT_MAX_PARALLEL_BUILDS = 1; + static readonly MAX_PARALLEL_BUILDS_LIMIT = 20; + static readonly DEFAULT_MAX_LOG_LINES = 1000; + static readonly QS_BUILD_INIT_CONTAINER_NAME = 'build-queue-init'; + static readonly QS_AGENT_ROUTER_NAMESPACE = Constants.QS_NAMESPACE; + static readonly QS_AUTH_PROXY_IMAGE = 'quickstack/auth-proxy:canary'; + static readonly QS_AGENT_CLAIM_COOKIE = 'qs-agent-claim'; + static readonly QS_AGENT_ACCESS_COOKIE = 'qs-agent-access'; + static readonly QS_PARAM_SERVER_HOSTNAME = 'qsServerHostname'; } diff --git a/src/shared/utils/kubernetes-size-converter.utils.ts b/src/shared/utils/kubernetes-size-converter.utils.ts index d65b0160..899e63b6 100644 --- a/src/shared/utils/kubernetes-size-converter.utils.ts +++ b/src/shared/utils/kubernetes-size-converter.utils.ts @@ -1,111 +1,110 @@ export class KubeSizeConverter { + private static readonly unitMultipliers: Record = { + Ki: 1024, // Kibibytes + Mi: 1024 ** 2, // Mebibytes + Gi: 1024 ** 3, // Gibibytes + Ti: 1024 ** 4, // Tebibytes + Pi: 1024 ** 5, // Pebibytes + Ei: 1024 ** 6 // Exbibytes + }; + + /** + * Converts a size in bytes to a human-readable format. + * 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', '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]); + } - private static readonly unitMultipliers: Record = { - Ki: 1024, // Kibibytes - Mi: 1024 ** 2, // Mebibytes - Gi: 1024 ** 3, // Gibibytes - Ti: 1024 ** 4, // Tebibytes - Pi: 1024 ** 5, // Pebibytes - Ei: 1024 ** 6, // Exbibytes - }; - - /** - * Converts a size in bytes to a human-readable format. - * 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', '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])); + /** + * Converts a Kubernetes size input (e.g., "76587.1Mi") to bytes. + * @param kubernetesSizeFormat - The Kubernetes size string to convert. + * @returns The size in bytes as a number. + * @throws Error if the input format is invalid or the unit is unsupported. + */ + static fromKubeSizeToBytes(kubernetesSizeFormat: string): number { + // Regular expression to match the numeric part and the unit + const sizeRegex = /^([0-9]*\.?[0-9]+)([a-zA-Z]+)$/; + const match = kubernetesSizeFormat.match(sizeRegex); + + if (!match) { + throw new Error(`Invalid Kubernetes size format: "${kubernetesSizeFormat}"`); } - /** - * Converts a Kubernetes size input (e.g., "76587.1Mi") to bytes. - * @param kubernetesSizeFormat - The Kubernetes size string to convert. - * @returns The size in bytes as a number. - * @throws Error if the input format is invalid or the unit is unsupported. - */ - static fromKubeSizeToBytes(kubernetesSizeFormat: string): number { - // Regular expression to match the numeric part and the unit - const sizeRegex = /^([0-9]*\.?[0-9]+)([a-zA-Z]+)$/; - const match = kubernetesSizeFormat.match(sizeRegex); - - if (!match) { - throw new Error(`Invalid Kubernetes size format: "${kubernetesSizeFormat}"`); - } - - const value = parseFloat(match[1]); // Numeric part - const unit = match[2]; // Unit part - - // Lookup the multiplier for the given unit - const multiplier = this.unitMultipliers[unit]; - - if (!multiplier) { - throw new Error(`Unsupported unit: "${unit}"`); - } - - // Convert to bytes - return value * multiplier; + const value = parseFloat(match[1]); // Numeric part + const unit = match[2]; // Unit part + + // Lookup the multiplier for the given unit + const multiplier = this.unitMultipliers[unit]; + + if (!multiplier) { + throw new Error(`Unsupported unit: "${unit}"`); } - /** + // Convert to bytes + return value * multiplier; + } + + /** * Converts a Kubernetes CPU metric (e.g., "500m", "2") to nano CPUs. * @param kubernetesCpuMetric - The Kubernetes CPU metric string to convert. * @returns The CPU in nano CPUs as a number. * @throws Error if the input format is invalid. */ - static fromKubeSizeToNanoCpu(kubernetesCpuMetric: string): number { - // Regular expression to match the numeric part and optional "m" or "n" unit - const cpuRegex = /^([0-9]*\.?[0-9]+)(m|n?)?$/; - const match = kubernetesCpuMetric.match(cpuRegex); - - if (!match) { - throw new Error(`Invalid Kubernetes CPU format: "${kubernetesCpuMetric}"`); - } - - const value = parseFloat(match[1]); // Numeric part - const unit = match[2]; // Unit part - - // Convert to nano CPUs - if (unit === "m") { - return value * 1_000_000; - } else if (unit === "n") { - return value; - } else { - return value * 1_000_000_000; - } - } + static fromKubeSizeToNanoCpu(kubernetesCpuMetric: string): number { + // Regular expression to match the numeric part and optional "m" or "n" unit + const cpuRegex = /^([0-9]*\.?[0-9]+)(m|n?)?$/; + const match = kubernetesCpuMetric.match(cpuRegex); - /** - * Converts nano CPUs to full CPUs. - * @param nanoCpu - The number of nano CPUs to convert. - * @returns The number of full CPUs. - */ - static fromNanoToFullCpu(nanoCpu: number): number { - return nanoCpu / 1_000_000_000; + if (!match) { + throw new Error(`Invalid Kubernetes CPU format: "${kubernetesCpuMetric}"`); } - /** - * Formats the given size in megabytes to a Kubernetes readable format. - */ - static megabytesToKubeFormat(megabytes: number): string { - if (megabytes % 1024 === 0) { - return `${Math.round(megabytes / 1024)}Gi`; - } else { - return `${Math.round(megabytes)}Mi`; - } - } + const value = parseFloat(match[1]); // Numeric part + const unit = match[2]; // Unit part - static fromBytesToMegabytes(bytes: number): number { - return bytes / 1024 / 1024; + // Convert to nano CPUs + if (unit === 'm') { + return value * 1_000_000; + } else if (unit === 'n') { + return value; + } else { + return value * 1_000_000_000; } + } + + /** + * Converts nano CPUs to full CPUs. + * @param nanoCpu - The number of nano CPUs to convert. + * @returns The number of full CPUs. + */ + static fromNanoToFullCpu(nanoCpu: number): number { + return nanoCpu / 1_000_000_000; + } - static fromMegabytesToBytes(megabytes: number): number { - return megabytes * 1024 * 1024; + /** + * Formats the given size in megabytes to a Kubernetes readable format. + */ + static megabytesToKubeFormat(megabytes: number): string { + if (megabytes % 1024 === 0) { + return `${Math.round(megabytes / 1024)}Gi`; + } else { + return `${Math.round(megabytes)}Mi`; } -} \ No newline at end of file + } + + static fromBytesToMegabytes(bytes: number): number { + return bytes / 1024 / 1024; + } + + static fromMegabytesToBytes(megabytes: number): number { + return megabytes * 1024 * 1024; + } +} 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 5443f976..772f4d22 100644 --- a/src/shared/utils/kubernetes-size-converter.utils.unit.spec.ts +++ b/src/shared/utils/kubernetes-size-converter.utils.unit.spec.ts @@ -1,116 +1,115 @@ -import { KubeSizeConverter } from "@/shared/utils/kubernetes-size-converter.utils"; +import { KubeSizeConverter } from '@/shared/utils/kubernetes-size-converter.utils'; describe(KubeSizeConverter.name, () => { + describe('convertBytesToReadableSize', () => { + it('should convert bytes to human-readable format', () => { + 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', () => { + expect(KubeSizeConverter.convertBytesToReadableSize(0)).toBe('0 B'); + }); + + it('should handle NaN input', () => { + expect(KubeSizeConverter.convertBytesToReadableSize(NaN)).toBe('0 B'); + }); + + it('should hide size unit if hideSize is true', () => { + expect(KubeSizeConverter.convertBytesToReadableSize(1024, 0, true)).toBe('1'); + }); + }); + + describe('formatSize', () => { + it('should format size in Gi when megabytes is a multiple of 1024', () => { + expect(KubeSizeConverter.megabytesToKubeFormat(2048)).toBe('2Gi'); + expect(KubeSizeConverter.megabytesToKubeFormat(1024)).toBe('1Gi'); + }); + + it('should format size in Mi when megabytes is not a multiple of 1024', () => { + expect(KubeSizeConverter.megabytesToKubeFormat(1500)).toBe('1500Mi'); + expect(KubeSizeConverter.megabytesToKubeFormat(512)).toBe('512Mi'); + }); + + it('should handle edge cases', () => { + expect(KubeSizeConverter.megabytesToKubeFormat(0)).toBe('0Gi'); + expect(KubeSizeConverter.megabytesToKubeFormat(1)).toBe('1Mi'); + }); + }); + + describe('fromKubeSizeToNanoCpu', () => { + it('should convert Kubernetes CPU metric to nano CPUs', () => { + expect(KubeSizeConverter.fromKubeSizeToNanoCpu('500m')).toBe(500_000_000); + expect(KubeSizeConverter.fromKubeSizeToNanoCpu('2')).toBe(2_000_000_000); + expect(KubeSizeConverter.fromKubeSizeToNanoCpu('1000000000n')).toBe(1000000000); + }); + + it('should throw error for invalid format', () => { + expect(() => KubeSizeConverter.fromKubeSizeToNanoCpu('invalid')).toThrowError('Invalid Kubernetes CPU format: "invalid"'); + }); + }); + + describe('fromNanoToFullCpu', () => { + it('should convert nano CPUs to full CPUs', () => { + expect(KubeSizeConverter.fromNanoToFullCpu(1_000_000_000)).toBe(1); + expect(KubeSizeConverter.fromNanoToFullCpu(500_000_000)).toBe(0.5); + }); + }); + + describe('toBytes', () => { + it('should convert Ki to bytes correctly', () => { + expect(KubeSizeConverter.fromKubeSizeToBytes('1Ki')).toBe(1024); + expect(KubeSizeConverter.fromKubeSizeToBytes('1.5Ki')).toBe(1536); + }); + + it('should convert Mi to bytes correctly', () => { + expect(KubeSizeConverter.fromKubeSizeToBytes('1Mi')).toBe(1024 ** 2); + expect(KubeSizeConverter.fromKubeSizeToBytes('1.5Mi')).toBe(1.5 * 1024 ** 2); + }); + + it('should convert Gi to bytes correctly', () => { + expect(KubeSizeConverter.fromKubeSizeToBytes('1Gi')).toBe(1024 ** 3); + expect(KubeSizeConverter.fromKubeSizeToBytes('1.5Gi')).toBe(1.5 * 1024 ** 3); + }); + + it('should convert Ti to bytes correctly', () => { + expect(KubeSizeConverter.fromKubeSizeToBytes('1Ti')).toBe(1024 ** 4); + expect(KubeSizeConverter.fromKubeSizeToBytes('1.5Ti')).toBe(1.5 * 1024 ** 4); + }); - describe('convertBytesToReadableSize', () => { - it('should convert bytes to human-readable format', () => { - 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', () => { - expect(KubeSizeConverter.convertBytesToReadableSize(0)).toBe('0 B'); - }); - - it('should handle NaN input', () => { - expect(KubeSizeConverter.convertBytesToReadableSize(NaN)).toBe('0 B'); - }); - - it('should hide size unit if hideSize is true', () => { - expect(KubeSizeConverter.convertBytesToReadableSize(1024, 0, true)).toBe('1'); - }); - }); - - describe('formatSize', () => { - it('should format size in Gi when megabytes is a multiple of 1024', () => { - expect(KubeSizeConverter.megabytesToKubeFormat(2048)).toBe('2Gi'); - expect(KubeSizeConverter.megabytesToKubeFormat(1024)).toBe('1Gi'); - }); - - it('should format size in Mi when megabytes is not a multiple of 1024', () => { - expect(KubeSizeConverter.megabytesToKubeFormat(1500)).toBe('1500Mi'); - expect(KubeSizeConverter.megabytesToKubeFormat(512)).toBe('512Mi'); - }); - - it('should handle edge cases', () => { - expect(KubeSizeConverter.megabytesToKubeFormat(0)).toBe('0Gi'); - expect(KubeSizeConverter.megabytesToKubeFormat(1)).toBe('1Mi'); - }); - }); - - describe('fromKubeSizeToNanoCpu', () => { - it('should convert Kubernetes CPU metric to nano CPUs', () => { - expect(KubeSizeConverter.fromKubeSizeToNanoCpu('500m')).toBe(500_000_000); - expect(KubeSizeConverter.fromKubeSizeToNanoCpu('2')).toBe(2_000_000_000); - expect(KubeSizeConverter.fromKubeSizeToNanoCpu('1000000000n')).toBe(1000000000); - }); - - it('should throw error for invalid format', () => { - expect(() => KubeSizeConverter.fromKubeSizeToNanoCpu('invalid')).toThrowError('Invalid Kubernetes CPU format: "invalid"'); - }); - }); - - describe('fromNanoToFullCpu', () => { - it('should convert nano CPUs to full CPUs', () => { - expect(KubeSizeConverter.fromNanoToFullCpu(1_000_000_000)).toBe(1); - expect(KubeSizeConverter.fromNanoToFullCpu(500_000_000)).toBe(0.5); - }); - }); - - describe('toBytes', () => { - it('should convert Ki to bytes correctly', () => { - expect(KubeSizeConverter.fromKubeSizeToBytes('1Ki')).toBe(1024); - expect(KubeSizeConverter.fromKubeSizeToBytes('1.5Ki')).toBe(1536); - }); - - it('should convert Mi to bytes correctly', () => { - expect(KubeSizeConverter.fromKubeSizeToBytes('1Mi')).toBe(1024 ** 2); - expect(KubeSizeConverter.fromKubeSizeToBytes('1.5Mi')).toBe(1.5 * 1024 ** 2); - }); - - it('should convert Gi to bytes correctly', () => { - expect(KubeSizeConverter.fromKubeSizeToBytes('1Gi')).toBe(1024 ** 3); - expect(KubeSizeConverter.fromKubeSizeToBytes('1.5Gi')).toBe(1.5 * 1024 ** 3); - }); - - it('should convert Ti to bytes correctly', () => { - expect(KubeSizeConverter.fromKubeSizeToBytes('1Ti')).toBe(1024 ** 4); - expect(KubeSizeConverter.fromKubeSizeToBytes('1.5Ti')).toBe(1.5 * 1024 ** 4); - }); - - it('should convert Pi to bytes correctly', () => { - expect(KubeSizeConverter.fromKubeSizeToBytes('1Pi')).toBe(1024 ** 5); - expect(KubeSizeConverter.fromKubeSizeToBytes('1.5Pi')).toBe(1.5 * 1024 ** 5); - }); - - it('should convert Ei to bytes correctly', () => { - expect(KubeSizeConverter.fromKubeSizeToBytes('1Ei')).toBe(1024 ** 6); - expect(KubeSizeConverter.fromKubeSizeToBytes('1.5Ei')).toBe(1.5 * 1024 ** 6); - }); - - it('should throw an error for invalid format', () => { - expect(() => KubeSizeConverter.fromKubeSizeToBytes('123')).toThrow('Invalid Kubernetes size format: "123"'); - expect(() => KubeSizeConverter.fromKubeSizeToBytes('123.45')).toThrow('Invalid Kubernetes size format: "123.45"'); - expect(() => KubeSizeConverter.fromKubeSizeToBytes('Mi')).toThrow('Invalid Kubernetes size format: "Mi"'); - }); - - it('should throw an error for unsupported unit', () => { - expect(() => KubeSizeConverter.fromKubeSizeToBytes('123Zi')).toThrow('Unsupported unit: "Zi"'); - expect(() => KubeSizeConverter.fromKubeSizeToBytes('123Yi')).toThrow('Unsupported unit: "Yi"'); - }); - }); - - describe('fromBytesToMegabytes', () => { - it('should convert bytes to megabytes', () => { - expect(KubeSizeConverter.fromBytesToMegabytes(1024 * 1024)).toBe(1); - }); - }); + it('should convert Pi to bytes correctly', () => { + expect(KubeSizeConverter.fromKubeSizeToBytes('1Pi')).toBe(1024 ** 5); + expect(KubeSizeConverter.fromKubeSizeToBytes('1.5Pi')).toBe(1.5 * 1024 ** 5); + }); + + it('should convert Ei to bytes correctly', () => { + expect(KubeSizeConverter.fromKubeSizeToBytes('1Ei')).toBe(1024 ** 6); + expect(KubeSizeConverter.fromKubeSizeToBytes('1.5Ei')).toBe(1.5 * 1024 ** 6); + }); + + it('should throw an error for invalid format', () => { + expect(() => KubeSizeConverter.fromKubeSizeToBytes('123')).toThrow('Invalid Kubernetes size format: "123"'); + expect(() => KubeSizeConverter.fromKubeSizeToBytes('123.45')).toThrow('Invalid Kubernetes size format: "123.45"'); + expect(() => KubeSizeConverter.fromKubeSizeToBytes('Mi')).toThrow('Invalid Kubernetes size format: "Mi"'); + }); + + it('should throw an error for unsupported unit', () => { + expect(() => KubeSizeConverter.fromKubeSizeToBytes('123Zi')).toThrow('Unsupported unit: "Zi"'); + expect(() => KubeSizeConverter.fromKubeSizeToBytes('123Yi')).toThrow('Unsupported unit: "Yi"'); + }); + }); + + describe('fromBytesToMegabytes', () => { + it('should convert bytes to megabytes', () => { + expect(KubeSizeConverter.fromBytesToMegabytes(1024 * 1024)).toBe(1); + }); + }); - describe('fromMegabytesToBytes', () => { - it('should convert megabytes to bytes', () => { - expect(KubeSizeConverter.fromMegabytesToBytes(1)).toBe(1024 * 1024); - }); + describe('fromMegabytesToBytes', () => { + it('should convert megabytes to bytes', () => { + expect(KubeSizeConverter.fromMegabytesToBytes(1)).toBe(1024 * 1024); }); -}); \ No newline at end of file + }); +}); diff --git a/yarn.lock b/yarn.lock index f822884a..d5de48ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7569,6 +7569,16 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== +prettier-plugin-tailwindcss@^0.8.1: + version "0.8.1" + resolved "https://registry.yarnpkg.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.8.1.tgz#bb30f2794dbaa2ef0d36e39ebc258c8c03d8f0c3" + integrity sha512-iaFMYqDsE4ffdDkn5qup0j5f2aCEBFZrdrZnvu9QKTlWx/iGPeQ4HHu7b7fCPMxeo9nwQBiOAh2nSypdFYWJkw== + +prettier@^3.9.10: + version "3.9.10" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.10.tgz#ac378e432f1a7381518f2e080816bbaefa38d585" + integrity sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA== + pretty-format@^27.0.2: version "27.5.1" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" From b56c5b43c6994c3d3b796e8de3b7f936d8f8b1aa Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Mon, 9 Nov 2026 08:27:10 +0000 Subject: [PATCH 2/2] chore: increase timeout for build job status polling to 400 seconds --- .../server/services/build.service.integration.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/integration/server/services/build.service.integration.spec.ts b/src/__tests__/integration/server/services/build.service.integration.spec.ts index f7b3a782..91f1e077 100644 --- a/src/__tests__/integration/server/services/build.service.integration.spec.ts +++ b/src/__tests__/integration/server/services/build.service.integration.spec.ts @@ -203,7 +203,7 @@ export async function runBuildAndAssert(input: BuildIntegrationInput) { await expect.poll(async () => { return await buildService.getJobStatus(buildJobName); }, { - timeout: 300_000, + timeout: 400_000, interval: 2_000, }).toBe('SUCCEEDED');