feat/add app volume monitoring

This commit is contained in:
biersoeckli
2025-01-09 09:34:51 +00:00
parent 96e5a93de6
commit 6abe60c20f
24 changed files with 559 additions and 222 deletions
+1
View File
@@ -31,6 +31,7 @@
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
@@ -1,87 +0,0 @@
import { KubernetesSizeConverter } from "@/server/utils/kubernetes-size-converter.utils";
describe(KubernetesSizeConverter.name, () => {
describe('formatSize', () => {
it('should format size in Gi when megabytes is a multiple of 1024', () => {
expect(KubernetesSizeConverter.formatSize(2048)).toBe('2Gi');
expect(KubernetesSizeConverter.formatSize(1024)).toBe('1Gi');
});
it('should format size in Mi when megabytes is not a multiple of 1024', () => {
expect(KubernetesSizeConverter.formatSize(1500)).toBe('1500Mi');
expect(KubernetesSizeConverter.formatSize(512)).toBe('512Mi');
});
it('should handle edge cases', () => {
expect(KubernetesSizeConverter.formatSize(0)).toBe('0Gi');
expect(KubernetesSizeConverter.formatSize(1)).toBe('1Mi');
});
});
describe('fromNanoCpu', () => {
it('should convert nano CPUs to full CPUs correctly', () => {
expect(KubernetesSizeConverter.fromNanoCpu(2000000000)).toBe(2);
});
});
describe('toNanoCpu', () => {
it('should convert milli CPUs to nano CPUs', () => {
expect(KubernetesSizeConverter.toNanoCpu('500m')).toBe(500 * 1_000_000);
});
it('should convert CPUs to nano CPUs', () => {
expect(KubernetesSizeConverter.toNanoCpu('2')).toBe(2 * 1_000_000_000);
});
it('should convert nano CPUs to nano CPUs', () => {
expect(KubernetesSizeConverter.toNanoCpu('2n')).toBe(2);
});
it('should throw an error for invalid format', () => {
expect(() => KubernetesSizeConverter.toNanoCpu('2x')).toThrow('Invalid Kubernetes CPU format: "2x"');
});
});
describe('toBytes', () => {
it('should convert Ki to bytes correctly', () => {
expect(KubernetesSizeConverter.toBytes('1Ki')).toBe(1024);
expect(KubernetesSizeConverter.toBytes('1.5Ki')).toBe(1536);
});
it('should convert Mi to bytes correctly', () => {
expect(KubernetesSizeConverter.toBytes('1Mi')).toBe(1024 ** 2);
expect(KubernetesSizeConverter.toBytes('1.5Mi')).toBe(1.5 * 1024 ** 2);
});
it('should convert Gi to bytes correctly', () => {
expect(KubernetesSizeConverter.toBytes('1Gi')).toBe(1024 ** 3);
expect(KubernetesSizeConverter.toBytes('1.5Gi')).toBe(1.5 * 1024 ** 3);
});
it('should convert Ti to bytes correctly', () => {
expect(KubernetesSizeConverter.toBytes('1Ti')).toBe(1024 ** 4);
expect(KubernetesSizeConverter.toBytes('1.5Ti')).toBe(1.5 * 1024 ** 4);
});
it('should convert Pi to bytes correctly', () => {
expect(KubernetesSizeConverter.toBytes('1Pi')).toBe(1024 ** 5);
expect(KubernetesSizeConverter.toBytes('1.5Pi')).toBe(1.5 * 1024 ** 5);
});
it('should convert Ei to bytes correctly', () => {
expect(KubernetesSizeConverter.toBytes('1Ei')).toBe(1024 ** 6);
expect(KubernetesSizeConverter.toBytes('1.5Ei')).toBe(1.5 * 1024 ** 6);
});
it('should throw an error for invalid format', () => {
expect(() => KubernetesSizeConverter.toBytes('123')).toThrow('Invalid Kubernetes size format: "123"');
expect(() => KubernetesSizeConverter.toBytes('123.45')).toThrow('Invalid Kubernetes size format: "123.45"');
expect(() => KubernetesSizeConverter.toBytes('Mi')).toThrow('Invalid Kubernetes size format: "Mi"');
});
it('should throw an error for unsupported unit', () => {
expect(() => KubernetesSizeConverter.toBytes('123Zi')).toThrow('Unsupported unit: "Zi"');
expect(() => KubernetesSizeConverter.toBytes('123Yi')).toThrow('Unsupported unit: "Yi"');
});
});
});
@@ -0,0 +1,116 @@
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 KB');
expect(KubeSizeConverter.convertBytesToReadableSize(1024 * 1024)).toBe('1 MB');
expect(KubeSizeConverter.convertBytesToReadableSize(1024 * 1024 * 1024)).toBe('1 GB');
expect(KubeSizeConverter.convertBytesToReadableSize(1500)).toBe('1.46 KB');
});
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);
});
});
describe('fromMegabytesToBytes', () => {
it('should convert megabytes to bytes', () => {
expect(KubeSizeConverter.fromMegabytesToBytes(1)).toBe(1024 * 1024);
});
});
});
+9 -1
View File
@@ -1,7 +1,9 @@
'use server'
import monitoringService from "@/server/services/monitoring.service";
import clusterService from "@/server/services/node.service";
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
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";
@@ -9,4 +11,10 @@ export const getNodeResourceUsage = async () =>
simpleAction(async () => {
await getAuthUserSession();
return await clusterService.getNodeResourceUsage();
}) as Promise<ServerActionResult<unknown, NodeResourceModel[]>>;
}) as Promise<ServerActionResult<unknown, NodeResourceModel[]>>;
export const getVolumeMonitoringUsage = async () =>
simpleAction(async () => {
await getAuthUserSession();
return await monitoringService.getAllAppVolumesUsage();
}) as Promise<ServerActionResult<unknown, AppVolumeMonitoringUsageModel[]>>;
@@ -0,0 +1,107 @@
'use client';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { useEffect, useState } from 'react';
import { Actions } from '@/frontend/utils/nextjs-actions.utils';
import { getVolumeMonitoringUsage } from './actions';
import { toast } from 'sonner';
import FullLoadingSpinner from '@/components/ui/full-loading-spinnter';
import { AppVolumeMonitoringUsageModel } from '@/shared/model/app-volume-monitoring-usage.model';
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { KubeSizeConverter } from '@/shared/utils/kubernetes-size-converter.utils';
import { ExternalLink } from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Progress } from "@/components/ui/progress"
import dataAccess from '@/server/adapter/db.client';
import { ProgressIndicator } from '@radix-ui/react-progress';
export default function AppVolumeMonitoring({
volumesUsage
}: {
volumesUsage?: AppVolumeMonitoringUsageModel[]
}) {
const [updatedVolumeUsage, setUpdatedVolumeUsage] = useState<(AppVolumeMonitoringUsageModel & { usedPercentage: number; })[] | undefined>(volumesUsage?.map(item => ({
...item,
usedPercentage: Math.round(item.usedBytes / item.capacityBytes * 100)
})));
const fetchVolumeMonitoringUsage = async () => {
try {
const data = await Actions.run(() => getVolumeMonitoringUsage());
setUpdatedVolumeUsage(data.map(item => ({
...item,
usedPercentage: Math.round(item.usedBytes / item.capacityBytes * 100)
})));
} catch (ex) {
toast.error('An error occurred while fetching current volume usage');
console.error('An error occurred while fetching volume nodes', ex);
}
}
useEffect(() => {
const volumeUsageId = setInterval(() => fetchVolumeMonitoringUsage(), 10000);
return () => {
clearInterval(volumeUsageId);
}
}, [volumesUsage]);
if (!updatedVolumeUsage) {
return <Card>
<CardContent>
<FullLoadingSpinner />
</CardContent>
</Card>
}
return (
<Card>
<CardHeader>
<CardTitle>App Volumes Capacity</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableCaption>{updatedVolumeUsage.length} App Volumes</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>App</TableHead>
<TableHead>Mount Path</TableHead>
<TableHead>Capacity</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{updatedVolumeUsage.map((item, index) => (
<TableRow key={item.appId}>
<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>
</TableCell>
<TableCell>
<Link href={`/project/app/${item.appId}?tabName=storage`} >
<Button variant="ghost" size="sm">
<ExternalLink />
</Button>
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
+3 -13
View File
@@ -2,24 +2,14 @@
import {
Label,
PolarGrid,
PolarRadiusAxis,
RadialBar,
RadialBarChart,
} from 'recharts';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart';
import { NodeResourceModel } from '@/shared/model/node-resource.model';
import { useEffect } from 'react';
import { StringUtils } from '@/shared/utils/string.utils';
import { KubeSizeConverter } from '@/shared/utils/kubernetes-size-converter.utils';
export default function ChartDiskRessources({
nodeRessource,
@@ -62,7 +52,7 @@ export default function ChartDiskRessources({
cursor={false}
content={<ChartTooltipContent hideLabel formatter={(value, name) => {
// Convert the value from bytes to gigabytes
const formattedValue = StringUtils.convertBytesToReadableSize(value as number);
const formattedValue = KubeSizeConverter.convertBytesToReadableSize(value as number);
// Optionally, you can customize the label (name) here if needed
return <div className='flex gap-2'>
<div className='self-center rounded w-2 h-2' style={{ backgroundColor: (chartConfig as any)[name].color }}></div>
@@ -104,7 +94,7 @@ export default function ChartDiskRessources({
x={viewBox.cx}
y={(viewBox.cy || 0) + 30}
className="fill-muted-foreground">
{StringUtils.convertBytesToReadableSize(nodeRessource.diskUsageAbsolut + nodeRessource.diskUsageReserved, 1, true)} / {StringUtils.convertBytesToReadableSize(nodeRessource.diskUsageCapacity, 1)}
{KubeSizeConverter.convertBytesToReadableSize(nodeRessource.diskUsageAbsolut + nodeRessource.diskUsageReserved, 1, true)} / {KubeSizeConverter.convertBytesToReadableSize(nodeRessource.diskUsageCapacity, 1)}
</tspan>
</text>
);
+10 -3
View File
@@ -23,16 +23,21 @@ import {
} from '@/frontend/states/zustand.states';
import { useEffect, useState } from 'react';
import ChartDiskRessources from './disk-chart';
import { StringUtils } from '@/shared/utils/string.utils';
import { Actions } from '@/frontend/utils/nextjs-actions.utils';
import { getNodeResourceUsage } from './actions';
import { getNodeResourceUsage, getVolumeMonitoringUsage } from './actions';
import { toast } from 'sonner';
import FullLoadingSpinner from '@/components/ui/full-loading-spinnter';
import { AppVolumeMonitoringUsageModel } from '@/shared/model/app-volume-monitoring-usage.model';
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { KubeSizeConverter } from '@/shared/utils/kubernetes-size-converter.utils';
import AppVolumeMonitoring from './app-volumes-monitoring';
export default function ResourcesNodes({
resourcesNodes,
volumesUsage
}: {
resourcesNodes?: NodeResourceModel[];
volumesUsage?: AppVolumeMonitoringUsageModel[]
}) {
const [updatedNodeRessources, setUpdatedResourcesNodes] = useState<NodeResourceModel[] | undefined>(resourcesNodes);
@@ -47,6 +52,7 @@ export default function ResourcesNodes({
}
}
useEffect(() => {
const intervalId = setInterval(() => fetchResourcesNodes(), 5000);
return () => {
@@ -223,7 +229,7 @@ export default function ResourcesNodes({
x={viewBox.cx}
y={(viewBox.cy || 0) + 30}
className="fill-muted-foreground" >
{(node.ramUsage / (1024 * 1024 * 1024)).toFixed(2)} / {StringUtils.convertBytesToReadableSize(node.ramCapacity)}
{(node.ramUsage / (1024 * 1024 * 1024)).toFixed(2)} / {KubeSizeConverter.convertBytesToReadableSize(node.ramCapacity)}
</tspan>
</text>
);
@@ -242,6 +248,7 @@ export default function ResourcesNodes({
</Card>
</>))
}
<AppVolumeMonitoring volumesUsage={volumesUsage} />
</div >
);
}
+6 -1
View File
@@ -5,23 +5,28 @@ import PageTitle from "@/components/custom/page-title";
import clusterService from "@/server/services/node.service";
import ResourceNodes from "./monitoring-nodes";
import { NodeResourceModel } from "@/shared/model/node-resource.model";
import { AppVolumeMonitoringUsageModel } from "@/shared/model/app-volume-monitoring-usage.model";
import monitoringService from "@/server/services/monitoring.service";
export default async function ResourceNodesInfoPage() {
await getAuthUserSession();
let resourcesNode: NodeResourceModel[] | undefined;
let volumesUsage: AppVolumeMonitoringUsageModel[] | undefined;
try {
resourcesNode = await clusterService.getNodeResourceUsage();
volumesUsage = await monitoringService.getAllAppVolumesUsage();
} catch (ex) {
// do nothing --> if an error occurs, the ResourceNodes will show a loading spinner and error message
}
return (
<div className="flex-1 space-y-4 pt-6">
<PageTitle
title={'Monitoring'}
subtitle={`View all resources of the nodes which belong to the QuickStack Cluster.`}>
</PageTitle>
<ResourceNodes resourcesNodes={resourcesNode} />
<ResourceNodes resourcesNodes={resourcesNode} volumesUsage={volumesUsage} />
</div>
)
}
@@ -7,7 +7,7 @@ import { ServerActionResult, SuccessActionResult } from "@/shared/model/server-a
import appService from "@/server/services/app.service";
import buildService from "@/server/services/build.service";
import deploymentService from "@/server/services/deployment.service";
import monitorAppService from "@/server/services/monitor-app.service";
import monitoringService from "@/server/services/monitoring.service";
import podService from "@/server/services/pod.service";
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
import { PodsResourceInfoModel } from "@/shared/model/pods-resource-info.model";
@@ -37,7 +37,7 @@ export const getPodsForApp = async (appId: string) =>
export const getRessourceDataApp = async (projectId: string, appId: string) =>
simpleAction(async () => {
await getAuthUserSession();
return await monitorAppService.getMonitoringForApp(projectId, appId);
return await monitoringService.getMonitoringForApp(projectId, appId);
}) as Promise<ServerActionResult<unknown, PodsResourceInfoModel>>;
export const createNewWebhookUrl = async (appId: string) =>
@@ -6,7 +6,7 @@ import FullLoadingSpinner from "@/components/ui/full-loading-spinnter";
import { PodsResourceInfoModel } from "@/shared/model/pods-resource-info.model";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { StringUtils } from "@/shared/utils/string.utils";
import { KubeSizeConverter } from "@/shared/utils/kubernetes-size-converter.utils";
export default function MonitoringTab({
app
@@ -60,12 +60,12 @@ export default function MonitoringTab({
<div className={'px-3 py-1.5 rounded cursor-pointer'}>{selectedPod?.cpuPercent.toFixed(2)}</div>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-[350px]">{selectedPod?.cpuAbsolut.toFixed(10)} cores</p>
<p className="max-w-[350px]">{selectedPod?.cpuAbsolutCores.toFixed(10)} cores</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
<TableCell className="font-medium">{StringUtils.convertBytesToReadableSize(selectedPod?.ramAbsolut)}</TableCell>
<TableCell className="font-medium">{KubeSizeConverter.convertBytesToReadableSize(selectedPod?.ramAbsolutBytes)}</TableCell>
</TableRow>
</TableBody>
</Table>
@@ -14,6 +14,7 @@ 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";
const actionAppVolumeEditZodModel = appVolumeEditZodModel.merge(z.object({
appId: z.string(),
@@ -64,7 +65,7 @@ export const deleteVolume = async (volumeId: string) =>
export const getPvcUsage = async (appId: string, projectId: string) =>
simpleAction(async () => {
await getAuthUserSession();
return pvcService.getPvcUsageFromApp(appId, projectId);
return monitoringService.getPvcUsageFromApp(appId, projectId);
}) as Promise<ServerActionResult<any, { pvcName: string, usage: number }[]>>;
export const downloadPvcData = async (volumeId: string) =>
+2 -2
View File
@@ -7,11 +7,11 @@ import { QsLetsEncryptSettingsModel, qsLetsEncryptSettingsZodModel } from "@/sha
import quickStackService from "@/server/services/qs.service";
import { ServerActionResult, SuccessActionResult } from "@/shared/model/server-action-error-return.model";
import registryService from "@/server/services/registry.service";
import { StringUtils } from "@/shared/utils/string.utils";
import { RegistryStorageLocationSettingsModel, registryStorageLocationSettingsZodModel } from "@/shared/model/registry-storage-location-settings.model";
import { Constants } from "@/shared/utils/constants";
import { QsPublicIpv4SettingsModel, qsPublicIpv4SettingsZodModel } from "@/shared/model/qs-public-ipv4-settings.model";
import ipAddressFinderAdapter from "@/server/adapter/ip-adress-finder.adapter";
import { KubeSizeConverter } from "@/shared/utils/kubernetes-size-converter.utils";
export const updateIngressSettings = async (prevState: any, inputData: QsIngressSettingsModel) =>
saveFormAction(inputData, qsIngressSettingsZodModel, async (validatedData) => {
@@ -95,7 +95,7 @@ export const purgeRegistryImages = async () =>
simpleAction(async () => {
await getAuthUserSession();
const deletedSize = await registryService.purgeRegistryImages();
return new SuccessActionResult(undefined, `Successfully purged ${StringUtils.convertBytesToReadableSize(deletedSize)} of images.`);
return new SuccessActionResult(undefined, `Successfully purged ${KubeSizeConverter.convertBytesToReadableSize(deletedSize)} of images.`);
});
export const setCanaryChannel = async (useCanaryChannel: boolean) =>
+43
View File
@@ -0,0 +1,43 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/frontend/utils/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
color?: "blue" | "green" | "red" | "orange" | "default"
}
>(({ className, value, color = "default", ...props }, ref) => {
const colorClasses = {
blue: "bg-blue-400",
green: "bg-green-400",
red: "bg-red-500",
orange: "bg-orange-400",
default: "bg-primary",
}
return (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className={cn(
"h-full w-full flex-1 transition-all",
colorClasses[color]
)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
})
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
@@ -26,6 +26,49 @@ class LonghornApiAdapter {
return (usedStorage / (1024 * 1024));
}
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}`);
}
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);
}
async getNodeStorageInfo(nodeName: String) {
const response = await fetch(`${this.longhornBaseUrl}/v1/nodes/${nodeName}`, {
+11
View File
@@ -231,6 +231,17 @@ class AppService {
}
}
async getAllVolumesWithApp() {
return await dataAccess.client.appVolume.findMany({
include: {
app: true
},
orderBy: {
appId: 'asc'
}
});
}
async getVolumeById(id: string) {
return await dataAccess.client.appVolume.findFirst({
where: {
@@ -1,55 +0,0 @@
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 "./node.service";
import { PodsResourceInfoModel } from "@/shared/model/pods-resource-info.model";
import { KubernetesSizeConverter } from "../utils/kubernetes-size-converter.utils";
class MonitorAppService {
async getMonitoringForApp(projectId: string, appId: string): Promise<PodsResourceInfoModel> {
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 topNodes = await clusterService.getNodeInfo();
const totalResourcesNodes = topNodes.reduce(
(acc, node) => {
acc.cpu += Number(node.cpuCapacity) || 0;
acc.ramBytes += KubernetesSizeConverter.toBytes(node.ramCapacity) || 0;
return acc;
},
{ cpu: 0, ramBytes: 0 }
);
const totalResourcesApp = filteredTopPods.reduce(
(acc, pod) => {
acc.cpu += Number(pod.CPU.CurrentUsage) || 0;
acc.ramBytes += Number(pod.Memory.CurrentUsage) || 0;
return acc;
},
{ cpu: 0, ramBytes: 0 }
);
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,
cpuAbsolut: totalResourcesApp.cpu,
ramPercent: appRamUsagePercent,
ramAbsolut: totalRamAppCorrectUnit
}
}
}
const monitorAppService = new MonitorAppService();
export default monitorAppService;
+130
View File
@@ -0,0 +1,130 @@
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 "./node.service";
import { PodsResourceInfoModel } from "@/shared/model/pods-resource-info.model";
import { KubeSizeConverter } from "../../shared/utils/kubernetes-size-converter.utils";
import { AppVolumeMonitoringUsageModel } from "@/shared/model/app-volume-monitoring-usage.model";
import longhornApiAdapter from "../adapter/longhorn-api.adapter";
import dataAccess from "../adapter/db.client";
import pvcService from "./pvc.service";
import { KubeObjectNameUtils } from "../utils/kube-object-name.utils";
class MonitorService {
async getAllAppVolumesUsage() {
const [longhornData, appVolumes, pvcs] = await Promise.all([
longhornApiAdapter.getAllLonghornVolumes(),
dataAccess.client.appVolume.findMany({
include: {
app: {
include: {
project: true
}
}
},
orderBy: {
appId: 'asc'
}
}),
pvcService.getAllPvc()
]);
const appVolumesWithUsage: AppVolumeMonitoringUsageModel[] = [];
for (const appVolume of appVolumes) {
const pvc = pvcs.find(pvc => pvc.metadata?.name === KubeObjectNameUtils.toPvcName(appVolume.id));
if (!pvc) {
continue;
}
const volumeName = pvc.spec?.volumeName;
const longhornVolume = longhornData.find(volume => volume.name === volumeName);
if (!longhornVolume) {
continue;
}
appVolumesWithUsage.push({
projectId: appVolume.app.projectId,
projectName: appVolume.app.project.name,
appName: appVolume.app.name,
appId: appVolume.appId,
mountPath: appVolume.containerMountPath,
usedBytes: longhornVolume.actualSizeBytes,
capacityBytes: KubeSizeConverter.fromMegabytesToBytes(appVolume.size),
});
}
// sort appVolumesWithUsage first by projectName (asc) then by appName
appVolumesWithUsage.sort((a, b) => {
if (a.projectName === b.projectName) {
return a.appName.localeCompare(b.appName);
}
return a.projectName.localeCompare(b.projectName);
});
return appVolumesWithUsage;
}
async getMonitoringForApp(projectId: string, appId: string): Promise<PodsResourceInfoModel> {
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 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 }
);
const totalResourcesApp = filteredTopPods.reduce(
(acc, pod) => {
acc.cpu += Number(pod.CPU.CurrentUsage) || 0;
acc.ramBytes += Number(pod.Memory.CurrentUsage) || 0;
return acc;
},
{ cpu: 0, ramBytes: 0 }
);
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
}
}
async getPvcUsageFromApp(appId: string, projectId: string): Promise<Array<{ pvcName: string, usage: number }>> {
const pvcFromApp = await pvcService.getAllPvcForApp(projectId, appId);
const pvcUsageData: Array<{ pvcName: string, usage: number }> = [];
for (const pvc of pvcFromApp) {
const pvcName = pvc.metadata?.name;
const volumeName = pvc.spec?.volumeName;
if (pvcName && volumeName) {
const usage = await longhornApiAdapter.getLonghornVolume(volumeName);
pvcUsageData.push({ pvcName, usage });
}
}
return pvcUsageData;
}
}
const monitoringService = new MonitorService();
export default monitoringService;
+3 -3
View File
@@ -5,7 +5,7 @@ import { NodeResourceModel } from "@/shared/model/node-resource.model";
import { Tags } from "../utils/cache-tag-generator.utils";
import { revalidateTag, unstable_cache } from "next/cache";
import longhornApiAdapter from "../adapter/longhorn-api.adapter";
import { KubernetesSizeConverter } from "../utils/kubernetes-size-converter.utils";
import { KubeSizeConverter } from "../../shared/utils/kubernetes-size-converter.utils";
class ClusterService {
@@ -78,8 +78,8 @@ class ClusterService {
.map((metric) => {
return {
timestamp: new Date(metric.timestamp),
cpuUsage: KubernetesSizeConverter.fromNanoCpu(KubernetesSizeConverter.toNanoCpu(metric.usage.cpu)),
ramUsage: KubernetesSizeConverter.toBytes(metric.usage.memory)
cpuUsage: KubeSizeConverter.fromNanoToFullCpu(KubeSizeConverter.fromKubeSizeToNanoCpu(metric.usage.cpu)),
ramUsage: KubeSizeConverter.fromKubeSizeToBytes(metric.usage.memory)
}
});
+15 -26
View File
@@ -13,7 +13,8 @@ import dataAccess from "../adapter/db.client";
import podService from "./pod.service";
import path from "path";
import { log } from "console";
import { KubernetesSizeConverter } from "../utils/kubernetes-size-converter.utils";
import { KubeSizeConverter } from "../../shared/utils/kubernetes-size-converter.utils";
import { AppVolumeMonitoringUsageModel } from "@/shared/model/app-volume-monitoring-usage.model";
class PvcService {
@@ -47,30 +48,13 @@ class PvcService {
return fileName;
}
async getPvcUsageFromApp(appId: string, projectId: string): Promise<Array<{ pvcName: string, usage: number }>> {
const pvcFromApp = await this.getAllPvcForApp(projectId, appId);
const pvcUsageData: Array<{ pvcName: string, usage: number }> = [];
for (const pvc of pvcFromApp) {
const pvcName = pvc.metadata?.name;
const volumeName = pvc.spec?.volumeName;
if (pvcName && volumeName) {
const usage = await longhornApiAdapter.getLonghornVolume(volumeName);
pvcUsageData.push({ pvcName, usage });
}
}
return pvcUsageData;
}
async doesAppConfigurationIncreaseAnyPvcSize(app: AppExtendedModel) {
const existingPvcs = await this.getAllPvcForApp(app.projectId, app.id);
for (const appVolume of app.appVolumes) {
const pvcName = KubeObjectNameUtils.toPvcName(appVolume.id);
const existingPvc = existingPvcs.find(pvc => pvc.metadata?.name === pvcName);
if (existingPvc && existingPvc.spec!.resources!.requests!.storage !== KubernetesSizeConverter.formatSize(appVolume.size)) {
if (existingPvc && existingPvc.spec!.resources!.requests!.storage !== KubeSizeConverter.megabytesToKubeFormat(appVolume.size)) {
return true;
}
}
@@ -83,6 +67,11 @@ class PvcService {
return res.body.items.filter((item) => item.metadata?.annotations?.[Constants.QS_ANNOTATION_APP_ID] === appId);
}
async getAllPvc() {
const res = await k3s.core.listPersistentVolumeClaimForAllNamespaces();
return res.body.items;
}
async deleteUnusedPvcOfApp(app: AppExtendedModel) {
const existingPvc = await this.getAllPvcForApp(app.projectId, app.id);
@@ -128,7 +117,7 @@ class PvcService {
storageClassName: 'longhorn',
resources: {
requests: {
storage: KubernetesSizeConverter.formatSize(appVolume.size),
storage: KubeSizeConverter.megabytesToKubeFormat(appVolume.size),
},
},
},
@@ -136,13 +125,13 @@ class PvcService {
const existingPvc = existingPvcs.find(pvc => pvc.metadata?.name === pvcName);
if (existingPvc) {
if (existingPvc.spec!.resources!.requests!.storage === KubernetesSizeConverter.formatSize(appVolume.size)) {
if (existingPvc.spec!.resources!.requests!.storage === KubeSizeConverter.megabytesToKubeFormat(appVolume.size)) {
console.log(`PVC ${pvcName} for app ${app.id} already exists with the same size`);
continue;
}
// Only the Size of PVC can be updated, so we need to delete and recreate the PVC
// update PVC size
existingPvc.spec!.resources!.requests!.storage = KubernetesSizeConverter.formatSize(appVolume.size);
existingPvc.spec!.resources!.requests!.storage = KubeSizeConverter.megabytesToKubeFormat(appVolume.size);
await k3s.core.replaceNamespacedPersistentVolumeClaim(pvcName, app.projectId, existingPvc);
console.log(`Updated PVC ${pvcName} for app ${app.id}`);
@@ -150,7 +139,7 @@ class PvcService {
console.log(`Waiting for PV ${existingPvc.spec!.volumeName} to be resized to ${existingPvc.spec!.resources!.requests!.storage}...`);
await this.waitUntilPvResized(existingPvc.spec!.volumeName!, appVolume.size);
console.log(`PV ${existingPvc.spec!.volumeName} resized to ${KubernetesSizeConverter.formatSize(appVolume.size)}`);
console.log(`PV ${existingPvc.spec!.volumeName} resized to ${KubeSizeConverter.megabytesToKubeFormat(appVolume.size)}`);
} else {
await k3s.core.createNamespacedPersistentVolumeClaim(app.projectId, pvcDefinition);
console.log(`Created PVC ${pvcName} for app ${app.id}`);
@@ -179,10 +168,10 @@ class PvcService {
private async waitUntilPvResized(persistentVolumeName: string, size: number) {
let iterationCount = 0;
let pv = await k3s.core.readPersistentVolume(persistentVolumeName);
while (pv.body.spec!.capacity!.storage !== KubernetesSizeConverter.formatSize(size)) {
while (pv.body.spec!.capacity!.storage !== KubeSizeConverter.megabytesToKubeFormat(size)) {
if (iterationCount > 30) {
console.error(`Timeout: PV ${persistentVolumeName} not resized to ${KubernetesSizeConverter.formatSize(size)}`);
throw new ServiceException(`Timeout: Volume could not be resized to ${KubernetesSizeConverter.formatSize(size)}`);
console.error(`Timeout: PV ${persistentVolumeName} not resized to ${KubeSizeConverter.megabytesToKubeFormat(size)}`);
throw new ServiceException(`Timeout: Volume could not be resized to ${KubeSizeConverter.megabytesToKubeFormat(size)}`);
}
await new Promise(resolve => setTimeout(resolve, 3000)); // wait 5 Seconds, so that the PV is resized
pv = await k3s.core.readPersistentVolume(persistentVolumeName);
@@ -0,0 +1,9 @@
export interface AppVolumeMonitoringUsageModel {
projectId: string,
projectName: string,
appName: string,
appId: string,
mountPath: string,
usedBytes: number,
capacityBytes: number
}
+2 -2
View File
@@ -2,9 +2,9 @@ import { z } from "zod";
export const podsResourceInfoZodModel = z.object({
cpuPercent: z.number(),
cpuAbsolut: z.number(),
cpuAbsolutCores: z.number(),
ramPercent: z.number(),
ramAbsolut: z.number(),
ramAbsolutBytes: z.number(),
});
export type PodsResourceInfoModel = z.infer<typeof podsResourceInfoZodModel>;
@@ -1,4 +1,4 @@
export class KubernetesSizeConverter {
export class KubeSizeConverter {
private static readonly unitMultipliers: Record<string, number> = {
Ki: 1024, // Kibibytes
@@ -9,19 +9,34 @@ export class KubernetesSizeConverter {
Ei: 1024 ** 6, // Exbibytes
};
/**
* Converts a size in bytes to a human-readable format.
* eg. 1024 -> 1 KB, 1024 * 1024 -> 1 MB, etc.
*/
static convertBytesToReadableSize(bytes: number, fractionDigits = 2, hideSize = false): string {
if (isNaN(bytes) || bytes === 0) {
return '0 B';
}
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const 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 size - The Kubernetes size string to convert.
* @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.
*/
public static toBytes(size: string): number {
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 = size.match(sizeRegex);
const match = kubernetesSizeFormat.match(sizeRegex);
if (!match) {
throw new Error(`Invalid Kubernetes size format: "${size}"`);
throw new Error(`Invalid Kubernetes size format: "${kubernetesSizeFormat}"`);
}
const value = parseFloat(match[1]); // Numeric part
@@ -40,17 +55,17 @@ export class KubernetesSizeConverter {
/**
* Converts a Kubernetes CPU metric (e.g., "500m", "2") to nano CPUs.
* @param cpu - The Kubernetes CPU metric string to convert.
* @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.
*/
public static toNanoCpu(cpu: string): number {
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 = cpu.match(cpuRegex);
const match = kubernetesCpuMetric.match(cpuRegex);
if (!match) {
throw new Error(`Invalid Kubernetes CPU format: "${cpu}"`);
throw new Error(`Invalid Kubernetes CPU format: "${kubernetesCpuMetric}"`);
}
const value = parseFloat(match[1]); // Numeric part
@@ -71,18 +86,26 @@ export class KubernetesSizeConverter {
* @param nanoCpu - The number of nano CPUs to convert.
* @returns The number of full CPUs.
*/
public static fromNanoCpu(nanoCpu: number): number {
static fromNanoToFullCpu(nanoCpu: number): number {
return nanoCpu / 1_000_000_000;
}
/**
* Formats the given size in megabytes to a Kubernetes readable format.
*/
static formatSize(megabytes: number): string {
static megabytesToKubeFormat(megabytes: number): string {
if (megabytes % 1024 === 0) {
return `${Math.round(megabytes / 1024)}Gi`;
} else {
return `${Math.round(megabytes)}Mi`;
}
}
static fromBytesToMegabytes(bytes: number): number {
return bytes / 1024 / 1024;
}
static fromMegabytesToBytes(megabytes: number): number {
return megabytes * 1024 * 1024;
}
}
-12
View File
@@ -1,12 +0,0 @@
export class StringUtils {
static convertBytesToReadableSize(bytes: number, fractionDIgits = 2, hideSize = false): string {
if (isNaN(bytes) || bytes === 0) {
return '0 B';
}
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1000));
return parseFloat((bytes / Math.pow(1000, i)).toFixed(fractionDIgits)) + (hideSize ? '' : (' ' + sizes[i]));
}
}
+8
View File
@@ -1896,6 +1896,14 @@
dependencies:
"@radix-ui/react-slot" "1.1.1"
"@radix-ui/react-progress@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-progress/-/react-progress-1.1.1.tgz#af923714ba3723be9c510536749d6c530d8670e4"
integrity sha512-6diOawA84f/eMxFHcWut0aE1C2kyE9dOyCTQOMRR2C/qPiXz/X0SaiA/RLbapQaXUCmy0/hLMf9meSccD1N0pA==
dependencies:
"@radix-ui/react-context" "1.1.1"
"@radix-ui/react-primitive" "2.0.1"
"@radix-ui/react-roving-focus@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz#b30c59daf7e714c748805bfe11c76f96caaac35e"