Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
src/shared/model/generated-zod/
7 changes: 4 additions & 3 deletions .prettierrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
"endOfLine": "lf",
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"jsxSingleQuote": false,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "none",
"printWidth": 160,
"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
}
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
58 changes: 29 additions & 29 deletions src/app/monitoring/actions.ts
Original file line number Diff line number Diff line change
@@ -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<ServerActionResult<unknown, NodeResourceModel[]>>;
simpleAction(async () => {
await getAuthUserSession();
return await clusterService.getNodeResourceUsage();
}) as Promise<ServerActionResult<unknown, NodeResourceModel[]>>;

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<ServerActionResult<unknown, AppVolumeMonitoringUsageModel[]>>;
simpleAction(async () => {
const session = await getAuthUserSession();
let volumesUsage = await monitoringService.getAllAppVolumesUsage();
volumesUsage = volumesUsage?.filter((volume) => UserGroupUtils.sessionHasReadAccessForApp(session, volume.appId));
return volumesUsage;
}) as Promise<ServerActionResult<unknown, AppVolumeMonitoringUsageModel[]>>;

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<ServerActionResult<unknown, AppMonitoringUsageModel[]>>;
simpleAction(async () => {
const session = await getAuthUserSession();
let updatedNodeRessources = await monitoringService.getMonitoringForAllApps();
updatedNodeRessources = updatedNodeRessources?.filter((app) => UserGroupUtils.sessionHasReadAccessForApp(session, app.appId));
return updatedNodeRessources;
}) as Promise<ServerActionResult<unknown, AppMonitoringUsageModel[]>>;

export const getReservedVolumeStorage = async () =>
simpleAction(async () => {
await getAuthUserSession();
return await clusterService.getReservedVolumeStorage();
}) as Promise<ServerActionResult<unknown, number>>;
simpleAction(async () => {
await getAuthUserSession();
return await clusterService.getReservedVolumeStorage();
}) as Promise<ServerActionResult<unknown, number>>;
Loading
Loading