Skip to content
Merged
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
15 changes: 15 additions & 0 deletions .prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"endOfLine": "lf",
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "none",
"printWidth": 160,
"arrowParens": "always",
"objectWrap": "collapse",
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "app/globals.css",
"tailwindFunctions": ["cn", "cva"]
}
4 changes: 4 additions & 0 deletions GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,10 @@ _Avoid_: disk, storage, PVC when referring to the domain concept
A **Volume** attached to an **App**, optionally shareable with other Apps in the same project.
_Avoid_: app storage, app disk

**App Volume Filesystem Usage**:
The occupied and available space and the capacity of the filesystem of an **App Volume**. It excludes storage consumed by snapshots outside that filesystem.
_Avoid_: Longhorn actual size, physical replica usage

**Agent Volume**:
A **Volume** attached to an **Agent**, without backup scheduling. An Agent Volume is not shareable with other Agents or Apps. Its **Volume Type** is a **Shared Agent Volume**, a **Per-Sandbox Agent Volume**, or a **Per-Custom-Tag Agent Volume**.
_Avoid_: agent storage, agent disk
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
CREATE TABLE "AppMonitoringSample" (
"id" TEXT NOT NULL PRIMARY KEY,
"sampledAt" DATETIME NOT NULL,
"appId" TEXT NOT NULL,
"cpuCores" REAL,
"ramBytes" REAL,
"measuredAt" DATETIME,
CONSTRAINT "AppMonitoringSample_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "AppMonitoringSample_appId_sampledAt_idx" ON "AppMonitoringSample"("appId", "sampledAt");
CREATE INDEX "AppMonitoringSample_sampledAt_idx" ON "AppMonitoringSample"("sampledAt");
CREATE TABLE "AppVolumeMonitoringSample" (
"id" TEXT NOT NULL PRIMARY KEY,
"sampledAt" DATETIME NOT NULL,
"volumeId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"usedBytes" REAL,
"availableBytes" REAL,
"capacityBytes" REAL,
"measuredAt" DATETIME,
CONSTRAINT "AppVolumeMonitoringSample_volumeId_fkey" FOREIGN KEY ("volumeId") REFERENCES "AppVolume" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "AppVolumeMonitoringSample_volumeId_sampledAt_idx" ON "AppVolumeMonitoringSample"("volumeId", "sampledAt");
CREATE INDEX "AppVolumeMonitoringSample_sampledAt_idx" ON "AppVolumeMonitoringSample"("sampledAt");

CREATE TABLE "AppVolumeMonitoringMembership" (
"sampleId" TEXT NOT NULL,
"volumeId" TEXT NOT NULL,
PRIMARY KEY ("sampleId", "volumeId"),
CONSTRAINT "AppVolumeMonitoringMembership_sampleId_fkey" FOREIGN KEY ("sampleId") REFERENCES "AppVolumeMonitoringSample" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "AppVolumeMonitoringMembership_volumeId_fkey" FOREIGN KEY ("volumeId") REFERENCES "AppVolume" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "AppVolumeMonitoringMembership_volumeId_idx" ON "AppVolumeMonitoringMembership"("volumeId");
43 changes: 43 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,7 @@ model App {
appPorts AppPort[]
appNodePorts AppNodePort[]
appVolumes AppVolume[]
monitoringSamples AppMonitoringSample[]
appFileMounts AppFileMount[]
appBasicAuths AppBasicAuth[]
appGitSshKey AppGitSshKey?
Expand Down Expand Up @@ -570,6 +571,8 @@ model AppVolume {
appId String
app App @relation(fields: [appId], references: [id], onDelete: Cascade)
volumeBackups VolumeBackup[]
monitoringSamples AppVolumeMonitoringSample[]
monitoringMemberships AppVolumeMonitoringMembership[]
sharedVolume AppVolume? @relation("SharedVolume", fields: [sharedVolumeId], references: [id], onDelete: Cascade)
sharedVolumes AppVolume[] @relation("SharedVolume")

Expand Down Expand Up @@ -643,3 +646,43 @@ model AppBasicAuth {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

// One row per known App and Volume at each collection time. Null values are gaps.
model AppMonitoringSample {
id String @id @default(uuid())
sampledAt DateTime
appId String
app App @relation(fields: [appId], references: [id], onDelete: Cascade)
cpuCores Float?
ramBytes Float?
measuredAt DateTime?

@@index([appId, sampledAt])
@@index([sampledAt])
}

model AppVolumeMonitoringSample {
id String @id @default(uuid())
sampledAt DateTime
volumeId String
volume AppVolume @relation(fields: [volumeId], references: [id], onDelete: Cascade)
status String
usedBytes Float?
availableBytes Float?
capacityBytes Float?
measuredAt DateTime?
memberships AppVolumeMonitoringMembership[]

@@index([volumeId, sampledAt])
@@index([sampledAt])
}

model AppVolumeMonitoringMembership {
sampleId String
sample AppVolumeMonitoringSample @relation(fields: [sampleId], references: [id], onDelete: Cascade)
volumeId String
volume AppVolume @relation(fields: [volumeId], references: [id], onDelete: Cascade)

@@id([sampleId, volumeId])
@@index([volumeId])
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// @vitest-environment node
vi.mock('@/server/adapter/kubernetes-api.adapter', () => ({ default: {} }));

import { createPrismaTestContext } from '@/__tests__/prisma-test.utils';
import adapter from '@/server/adapter/app-history.adapter';
import history from '@/server/services/standalone-services/app-monitoring-history.service';
import metrics from '@/server/services/standalone-services/app-history.service';
import filesystem from '@/server/services/standalone-services/app-volume-filesystem.service';

describe('App monitoring history persistence', () => {
const ctx = createPrismaTestContext('app-monitoring-history');

it('retains the boundary sample and removes history when its App is deleted', async () => {
const db = ctx.getDataAccess().client;
await db.project.create({ data: { id: 'project', name: 'Project' } });
await db.app.create({ data: { id: 'app', name: 'App', projectId: 'project' } });
const cutoff = new Date(Date.now() - 3600_000);
await adapter.save([
{ appId: 'app', sampledAt: new Date(cutoff.getTime() - 1), cpuCores: 1, ramBytes: 2 },
{ appId: 'app', sampledAt: cutoff, cpuCores: 3, ramBytes: 4 },
], [], [], cutoff);
expect((await history.getHistory(['app'])).points.filter(point => !point.isGap))
.toMatchObject([{ cpuCores: 3, ramBytes: 4 }]);
await db.app.delete({ where: { id: 'app' } });
expect((await history.getHistory(['app'])).points).toEqual([]);
});

it('applies a reduced retention period to existing history', async () => {
const db = ctx.getDataAccess().client;
await db.project.create({ data: { id: 'project', name: 'Project' } });
await db.app.create({ data: { id: 'app', name: 'App', projectId: 'project' } });
const now = Date.now();
await adapter.save([{ appId: 'app', sampledAt: new Date(now - 2 * 3600_000), cpuCores: 1, ramBytes: 2 }], [], [], new Date(now - 24 * 3600_000));
await adapter.setRetentionHours(1);
expect((await history.getHistory(['app'])).points).toEqual([]);
await adapter.save([], [], [], new Date(now - (await adapter.getRetentionHours()) * 3600_000));
expect(await db.appMonitoringSample.count()).toBe(0);
});

it('stores shared Volume usage once and exposes it only through accessible mounts', async () => {
const db = ctx.getDataAccess().client;
await db.project.create({ data: { id: 'project', name: 'Project' } });
await db.app.createMany({ data: [
{ id: 'one', name: 'One', projectId: 'project' },
{ id: 'two', name: 'Two', projectId: 'project' },
] });
await db.appVolume.create({ data: { id: 'base', appId: 'one', containerMountPath: '/data', size: 1 } });
await db.appVolume.create({ data: { id: 'shared', appId: 'two', containerMountPath: '/share', size: 1, sharedVolumeId: 'base' } });
const sampledAt = new Date();
await adapter.save([], [{ id: 'sample', volumeId: 'base', sampledAt, status: 'available', usedBytes: 42, measuredAt: sampledAt }], [
{ sampleId: 'sample', volumeId: 'base' }, { sampleId: 'sample', volumeId: 'shared' },
], new Date(sampledAt.getTime() - 3600_000));
expect(await db.appVolumeMonitoringSample.count()).toBe(1);
expect((await history.getHistory(['two'])).volumes.map(volume => volume.label)).toEqual(['/share']);
expect((await history.getHistory(['one', 'two'])).points[0]).toMatchObject({ storageBytes: 42, availableVolumes: 1 });
await db.appVolume.delete({ where: { id: 'base' } });
expect((await history.getHistory(['one', 'two'])).points).toEqual([]);
});


it('collects shared Volume measurements and reads them through each App', async () => {
const db = ctx.getDataAccess().client;
await db.project.create({ data: { id: 'project', name: 'Project' } });
await db.app.createMany({ data: [
{ id: 'one', name: 'One', projectId: 'project' },
{ id: 'two', name: 'Two', projectId: 'project' },
] });
await db.appVolume.create({ data: { id: 'base', appId: 'one', containerMountPath: '/data', size: 1 } });
await db.appVolume.create({ data: { id: 'shared', appId: 'two', containerMountPath: '/share', size: 1, sharedVolumeId: 'base' } });
const measuredAt = new Date();
const appMetrics = vi.spyOn(metrics, 'getAppMetrics').mockResolvedValue(new Map([
['one', { cpuCores: 1, ramBytes: 100, measuredAt }],
['two', { cpuCores: 2, ramBytes: 200, measuredAt }],
]));
const volumeMetrics = vi.spyOn(filesystem, 'getVolumeFilesystemUsage').mockResolvedValue(new Map([
['base', { pvcName: 'pvc-base', status: 'available', usedBytes: 42, measuredAt: measuredAt.toISOString() }],
['shared', { pvcName: 'pvc-base', status: 'available', usedBytes: 42, measuredAt: measuredAt.toISOString() }],
]));
try {
await history.collect();
expect(await db.appVolumeMonitoringSample.count()).toBe(1);
expect((await history.getHistory(['one', 'two'])).points[0]).toMatchObject({ cpuCores: 3, ramBytes: 300, storageBytes: 42, availableVolumes: 1 });
expect((await history.getHistory(['two'])).volumes.map(volume => volume.label)).toEqual(['/share']);
} finally {
appMetrics.mockRestore();
volumeMetrics.mockRestore();
}
});

it('reads a day of five-minute history for 100 Apps', async () => {
const db = ctx.getDataAccess().client;
await db.project.create({ data: { id: 'project', name: 'Project' } });
const appIds = Array.from({ length: 100 }, (_, index) => `app-${index}`);
await db.app.createMany({ data: appIds.map(id => ({ id, name: id, projectId: 'project' })) });
const now = Date.now();
const samples = Array.from({ length: 288 }, (_, index) => appIds.map(appId => ({
appId, sampledAt: new Date(now - index * 5 * 60_000), cpuCores: 0.25, ramBytes: 1024,
}))).flat();
await adapter.save(samples, [], [], new Date(now - 24 * 3600_000));
const result = await history.getHistory(appIds);
expect(result.points).toHaveLength(288);
expect(result.points[0]).toMatchObject({ cpuCores: 25, ramBytes: 102400 });
}, 30000);

});
8 changes: 7 additions & 1 deletion src/app/monitoring/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,10 @@ export const getMonitoringForAllApps = async () =>
let updatedNodeRessources = await monitoringService.getMonitoringForAllApps();
updatedNodeRessources = updatedNodeRessources?.filter((app) => UserGroupUtils.sessionHasReadAccessForApp(session, app.appId));
return updatedNodeRessources;
}) as Promise<ServerActionResult<unknown, AppMonitoringUsageModel[]>>;
}) as Promise<ServerActionResult<unknown, AppMonitoringUsageModel[]>>;

export const getReservedVolumeStorage = async () =>
simpleAction(async () => {
await getAuthUserSession();
return await clusterService.getReservedVolumeStorage();
}) as Promise<ServerActionResult<unknown, number>>;
59 changes: 28 additions & 31 deletions src/app/monitoring/app-volumes-monitoring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,38 +17,25 @@ import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Progress } from "@/components/ui/progress"
import { PathBuilderUtils } from '@/shared/utils/path-builder.utils';

type AppVolumeMonitoringUsageExtendedModel = AppVolumeMonitoringUsageModel & {
usedPercentage: number;
};

function convertToExtendedModel(input?: AppVolumeMonitoringUsageModel[]): AppVolumeMonitoringUsageExtendedModel[] | undefined {
if (input) {
return input.map(item => ({
...item,
usedPercentage: Math.round(item.usedBytes / item.capacityBytes * 100)
}));
}
return undefined;
}
import { AppVolumeIdentityUtils } from '@/shared/utils/app-volume-identity.utils';

export default function AppVolumeMonitoring({
volumesUsage
}: {
volumesUsage?: AppVolumeMonitoringUsageModel[]
}) {

const updatedVolumeUsage = useMemo(() => convertToExtendedModel(volumesUsage), [volumesUsage]);

const { totalUsedBytes, totalCapacityBytes } = useMemo(() => {
if (!updatedVolumeUsage) {
return { totalUsedBytes: undefined, totalCapacityBytes: undefined };
}
const updatedVolumeUsage = volumesUsage;
const totals = useMemo(() => {
const unique = new Map<string, AppVolumeMonitoringUsageModel>();
for (const volume of volumesUsage ?? []) unique.set(AppVolumeIdentityUtils.observationKey(volume.projectId, volume.pvcName), volume);
const values = [...unique.values()];
return {
totalUsedBytes: updatedVolumeUsage.reduce((acc, item) => acc + item.usedBytes, 0),
totalCapacityBytes: updatedVolumeUsage.reduce((acc, item) => acc + item.capacityBytes, 0),
used: values.reduce((sum, value) => sum + (value.usedBytes ?? 0), 0),
capacity: values.reduce((sum, value) => sum + (value.capacityBytes ?? 0), 0),
incomplete: values.some(value => value.status !== 'available'),
};
}, [updatedVolumeUsage]);
}, [volumesUsage]);

if (!updatedVolumeUsage) {
return <Card>
Expand All @@ -68,10 +55,7 @@ export default function AppVolumeMonitoring({
</CardHeader>
<CardContent>
<Table>
<TableCaption>{updatedVolumeUsage.length} App Volumes {totalUsedBytes && totalCapacityBytes && <>
<span className='text-slate-500'> | Total used {KubeSizeConverter.convertBytesToReadableSize(totalUsedBytes)} | Total allocated {KubeSizeConverter.convertBytesToReadableSize(totalCapacityBytes)}</span>
</>}
</TableCaption>
<TableCaption>{updatedVolumeUsage.length} App Volumes | Total used {KubeSizeConverter.convertBytesToReadableSize(totals.used)} / {KubeSizeConverter.convertBytesToReadableSize(totals.capacity)}{totals.incomplete ? ' (incomplete)' : ''}</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
Expand All @@ -83,14 +67,14 @@ export default function AppVolumeMonitoring({
</TableHeader>
<TableBody>
{updatedVolumeUsage.map((item) => (
<TableRow key={item.appId}>
<TableRow key={`${item.appId}/${item.mountPath}`}>
<TableCell>{item.projectName}</TableCell>
<TableCell>{item.appName}</TableCell>
<TableCell>{item.mountPath}</TableCell>
<TableCell className='space-y-1'>
<Progress value={item.usedPercentage}
color={item.usedPercentage >= 90 ? 'red' : (item.usedPercentage >= 80 ? 'orange' : undefined)} />
<div className='text-xs text-slate-500'>{KubeSizeConverter.convertBytesToReadableSize(item.usedBytes)} / {KubeSizeConverter.convertBytesToReadableSize(item.capacityBytes)}</div>
{item.status === 'available' && item.usedBytes !== undefined && item.capacityBytes !== undefined && item.capacityBytes > 0 ? (
<VolumeUsage usedBytes={item.usedBytes} capacityBytes={item.capacityBytes} />
) : <span>Unavailable ({item.status})</span>}
</TableCell>
<TableCell>
<Link href={PathBuilderUtils.projectAppDrawer(item.projectId, item.appId, 'settings')} >
Expand All @@ -107,3 +91,16 @@ export default function AppVolumeMonitoring({
</Card>
);
}

function VolumeUsage({ usedBytes, capacityBytes }: {
usedBytes: number;
capacityBytes: number;
}) {
const percentage = Math.round(usedBytes / capacityBytes * 100);
return <>
<Progress value={percentage} color={percentage >= 90 ? 'red' : percentage >= 80 ? 'orange' : undefined} />
<div className='text-xs text-slate-500'>
{KubeSizeConverter.convertBytesToReadableSize(usedBytes)} / {KubeSizeConverter.convertBytesToReadableSize(capacityBytes)}
</div>
</>;
}
23 changes: 23 additions & 0 deletions src/app/monitoring/app-volumes-monitoring.unit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from 'react';
import { cleanup, render, screen } from '@testing-library/react';
import AppVolumeMonitoring from './app-volumes-monitoring';
import { AppVolumeMonitoringUsageModel } from '@/shared/model/app-volume-monitoring-usage.model';

afterEach(cleanup);

it('counts shared volumes once and marks incomplete totals', () => {
const base: AppVolumeMonitoringUsageModel = {
projectId: 'p', projectName: 'Project', appName: 'App', appId: 'a', mountPath: '/data',
pvcName: 'pvc-1', status: 'available', usedBytes: 1024, capacityBytes: 4096,
availableBytes: 3072, isBaseVolume: true,
};
render(React.createElement(AppVolumeMonitoring, { volumesUsage: [
base,
{ ...base, appId: 'b', appName: 'Other', isBaseVolume: false },
{ ...base, appId: 'c', pvcName: 'pvc-2', status: 'unavailable', usedBytes: undefined, capacityBytes: undefined, availableBytes: undefined },
] }));
expect(screen.getByText(/Total used.*incomplete/).textContent).toContain('1 KiB');
expect(screen.getByText(/Total used.*incomplete/).textContent).toContain('4 KiB');
expect(screen.getByText('Unavailable (unavailable)')).toBeTruthy();
expect(screen.getAllByText('1 KiB / 4 KiB')).toHaveLength(2);
});
Loading
Loading