From 0ca93150ee23021e4d89523c9374a4741cec9166 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Tue, 23 Dec 2025 12:16:30 +0000 Subject: [PATCH] feat: enhance volume management with storage class validation and node info integration --- src/app/project/app/[appId]/app-tabs.tsx | 11 +- src/app/project/app/[appId]/page.tsx | 7 +- .../project/app/[appId]/volumes/actions.ts | 6 + .../[appId]/volumes/storage-edit-overlay.tsx | 183 +++++++++--------- .../project/app/[appId]/volumes/storages.tsx | 8 +- src/server/services/qs.service.ts | 9 +- 6 files changed, 122 insertions(+), 102 deletions(-) diff --git a/src/app/project/app/[appId]/app-tabs.tsx b/src/app/project/app/[appId]/app-tabs.tsx index 7bab846..e8085d6 100644 --- a/src/app/project/app/[appId]/app-tabs.tsx +++ b/src/app/project/app/[appId]/app-tabs.tsx @@ -23,19 +23,22 @@ import NetworkPolicy from "./advanced/network-policy"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import DbToolsCard from "./credentials/db-tools"; import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts"; +import { NodeInfoModel } from "@/shared/model/node-info.model"; export default function AppTabs({ app, role, tabName, s3Targets, - volumeBackups + volumeBackups, + nodesInfo }: { app: AppExtendedModel; role: RolePermissionEnum; tabName: string; - s3Targets: S3Target[], - volumeBackups: VolumeBackupExtendedModel[] + s3Targets: S3Target[]; + volumeBackups: VolumeBackupExtendedModel[]; + nodesInfo: NodeInfoModel[]; }) { const router = useRouter(); const readonly = role !== RolePermissionEnum.READWRITE; @@ -79,7 +82,7 @@ export default function AppTabs({ - + @@ -31,6 +33,7 @@ export default async function AppPage({ volumeBackups={volumeBackups} s3Targets={s3Targets} app={app} + nodesInfo={nodesInfo} tabName={searchParams?.tabName ?? 'overview'} /> diff --git a/src/app/project/app/[appId]/volumes/actions.ts b/src/app/project/app/[appId]/volumes/actions.ts index 7c5dbae..22ffbfe 100644 --- a/src/app/project/app/[appId]/volumes/actions.ts +++ b/src/app/project/app/[appId]/volumes/actions.ts @@ -47,9 +47,15 @@ export const saveVolume = async (prevState: any, inputData: z.infer 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.'); + } await appService.saveVolume({ ...validatedData, id: validatedData.id ?? undefined, diff --git a/src/app/project/app/[appId]/volumes/storage-edit-overlay.tsx b/src/app/project/app/[appId]/volumes/storage-edit-overlay.tsx index 3c53895..c06843d 100644 --- a/src/app/project/app/[appId]/volumes/storage-edit-overlay.tsx +++ b/src/app/project/app/[appId]/volumes/storage-edit-overlay.tsx @@ -39,6 +39,7 @@ import { toast } from "sonner" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { QuestionMarkCircledIcon } from "@radix-ui/react-icons" import { AppExtendedModel } from "@/shared/model/app-extended.model" +import { NodeInfoModel } from "@/shared/model/node-info.model" const accessModes = [ { label: "ReadWriteOnce", value: "ReadWriteOnce" }, @@ -46,11 +47,16 @@ const accessModes = [ ] as const const storageClasses = [ - { label: "Longhorn (HA)", value: "longhorn", description: "Distributed, replicated storage recommended for HA workloads." }, - { label: "Local Path", value: "local-path", description: "Node-local volumes, no replication. Ideal for single-node setups." } + { label: "Longhorn (Default)", value: "longhorn", description: "Distributed, replicated storage recommended workloads in a cluster of multiple nodes." }, + { label: "Local Path", value: "local-path", description: "Node-local volumes, no replication. Data is stored on the master node. Only works in a single node setup." } ] as const -export default function DialogEditDialog({ children, volume, app }: { children: React.ReactNode; volume?: AppVolume; app: AppExtendedModel; }) { +export default function DialogEditDialog({ children, volume, app, nodesInfo }: { + children: React.ReactNode; + volume?: AppVolume; + app: AppExtendedModel; + nodesInfo: NodeInfoModel[]; +}) { const [isOpen, setIsOpen] = useState(false); @@ -60,7 +66,7 @@ export default function DialogEditDialog({ children, volume, app }: { children: defaultValues: { ...volume, accessMode: volume?.accessMode ?? (app.replicas > 1 ? "ReadWriteMany" : "ReadWriteOnce"), - storageClassName: volume?.storageClassName ?? "longhorn" + storageClassName: (volume?.storageClassName ?? "longhorn") as 'longhorn' | 'local-path', } }); @@ -86,7 +92,7 @@ export default function DialogEditDialog({ children, volume, app }: { children: form.reset({ ...volume, accessMode: volume?.accessMode ?? (app.replicas > 1 ? "ReadWriteMany" : "ReadWriteOnce"), - storageClassName: volume?.storageClassName ?? "longhorn" + storageClassName: (volume?.storageClassName ?? "longhorn") as 'longhorn' | 'local-path', }); }, [volume]); @@ -217,88 +223,88 @@ export default function DialogEditDialog({ children, volume, app }: { children: )} /> - - ( - - -
Storage Class
-
- - - - -

- Choose where the volume is provisioned.

- Longhorn keeps data replicated across nodes.
- Local Path stores data on a single node (no HA). -

-
-
-
-
-
- - - - - - - - - - - {storageClasses.map((storageClass) => ( - { - form.setValue("storageClassName", storageClass.value); - }} - > -
- {storageClass.label} - {storageClass.description} -
- -
- ))} -
-
-
-
-
- - Longhorn is recommended for HA. Local Path is faster to provision on single-node clusters. - - -
- )} - /> + {nodesInfo.length === 1 && + ( + + +
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. +

+
+
+
+
+
+ + + + + + + + + + + {storageClasses.map((storageClass) => ( + { + form.setValue("storageClassName", storageClass.value); + }} + > +
+ {storageClass.label} + {storageClass.description} +
+ +
+ ))} +
+
+
+
+
+ + This cannot be changed after creation. + + +
+ )} + />}

{state.message}

Save @@ -308,7 +314,4 @@ export default function DialogEditDialog({ children, volume, app }: { children: ) - - - } diff --git a/src/app/project/app/[appId]/volumes/storages.tsx b/src/app/project/app/[appId]/volumes/storages.tsx index c9fe50f..581e9fc 100644 --- a/src/app/project/app/[appId]/volumes/storages.tsx +++ b/src/app/project/app/[appId]/volumes/storages.tsx @@ -22,11 +22,13 @@ 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 { NodeInfoModel } from "@/shared/model/node-info.model"; type AppVolumeWithCapacity = (AppVolume & { usedBytes?: number; capacityBytes?: number; usedPercentage?: number }); -export default function StorageList({ app, readonly }: { +export default function StorageList({ app, readonly, nodesInfo }: { app: AppExtendedModel; + nodesInfo: NodeInfoModel[]; readonly: boolean; }) { @@ -211,7 +213,7 @@ export default function StorageList({ app, readonly }: { */} {!readonly && <> - + @@ -243,7 +245,7 @@ export default function StorageList({ app, readonly }: { {!readonly && - + } diff --git a/src/server/services/qs.service.ts b/src/server/services/qs.service.ts index ca47ae9..05b3b1a 100644 --- a/src/server/services/qs.service.ts +++ b/src/server/services/qs.service.ts @@ -232,6 +232,11 @@ class QuickStackService { private async createOrUpdatePvc() { const pvcName = KubeObjectNameUtils.toPvcName(this.QUICKSTACK_DEPLOYMENT_NAME); + const allPvcs = await k3s.core.listNamespacedPersistentVolumeClaim(this.QUICKSTACK_NAMESPACE); + const existingPvc = allPvcs.body.items.find(p => p.metadata!.name === pvcName); + + const storageClassName = existingPvc?.spec?.storageClassName || 'longhorn'; + const pvc = { apiVersion: 'v1', kind: 'PersistentVolumeClaim', @@ -241,7 +246,7 @@ class QuickStackService { }, spec: { accessModes: ['ReadWriteOnce'], - storageClassName: 'longhorn', + storageClassName, resources: { requests: { storage: '1Gi' @@ -249,8 +254,6 @@ class QuickStackService { } } }; - const allPvcs = await k3s.core.listNamespacedPersistentVolumeClaim(this.QUICKSTACK_NAMESPACE); - const existingPvc = allPvcs.body.items.find(p => p.metadata!.name === pvcName); if (existingPvc) { if (existingPvc.spec!.resources!.requests!.storage === pvc.spec!.resources!.requests!.storage) { console.log(`PVC already exists with the same size, no changes`);