From 5054f4b525db76f466835a386ca3b64427891a8f Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Thu, 29 Jan 2026 14:54:58 +0100 Subject: [PATCH 1/3] Add shareable volumes across project apps --- prisma/schema.prisma | 4 + .../project/app/[appId]/volumes/actions.ts | 48 ++++- .../[appId]/volumes/storage-edit-overlay.tsx | 167 +++++++++++++++++- .../project/app/[appId]/volumes/storages.tsx | 6 +- src/server/services/app.service.ts | 22 +++ src/server/services/file-browser-service.ts | 5 +- src/server/services/monitoring.service.ts | 30 +++- src/server/services/pvc.service.ts | 74 +++++--- src/server/services/restore.service.ts | 12 +- src/shared/model/generated-zod/appvolume.ts | 2 + src/shared/model/volume-edit.model.ts | 2 + 11 files changed, 330 insertions(+), 42 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e61b10b..5ce1b4d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -257,9 +257,13 @@ model AppVolume { size Int accessMode String @default("rwo") storageClassName String @default("longhorn") + shareWithOtherApps Boolean @default(false) + sharedVolumeId String? appId String app App @relation(fields: [appId], references: [id], onDelete: Cascade) volumeBackups VolumeBackup[] + sharedVolume AppVolume? @relation("SharedVolume", fields: [sharedVolumeId], references: [id], onDelete: Cascade) + sharedVolumes AppVolume[] @relation("SharedVolume") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/app/project/app/[appId]/volumes/actions.ts b/src/app/project/app/[appId]/volumes/actions.ts index 22ffbfe..9a41d53 100644 --- a/src/app/project/app/[appId]/volumes/actions.ts +++ b/src/app/project/app/[appId]/volumes/actions.ts @@ -44,6 +44,39 @@ export const saveVolume = async (prevState: any, inputData: z.infer validatedData.size) { throw new ServiceException('Volume size cannot be decreased'); } @@ -56,11 +89,16 @@ export const saveVolume = async (prevState: any, inputData: z.infer 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>; + export const downloadPvcData = async (volumeId: string) => simpleAction(async () => { await validateVolumeReadAuthorization(volumeId); 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 c06843d..cadcf93 100644 --- a/src/app/project/app/[appId]/volumes/storage-edit-overlay.tsx +++ b/src/app/project/app/[appId]/volumes/storage-edit-overlay.tsx @@ -28,18 +28,19 @@ import { Check, ChevronsUpDown } from "lucide-react" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" import { useFormState } from 'react-dom' -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } 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 { getShareableVolumes, saveVolume } from "./actions" 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" +import { Checkbox } from "@/components/ui/checkbox" const accessModes = [ { label: "ReadWriteOnce", value: "ReadWriteOnce" }, @@ -51,14 +52,18 @@ const storageClasses = [ { 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 +type AppVolumeWithSharing = AppVolume & { sharedVolumeId?: string | null; shareWithOtherApps?: boolean }; + export default function DialogEditDialog({ children, volume, app, nodesInfo }: { children: React.ReactNode; - volume?: AppVolume; + volume?: AppVolumeWithSharing; app: AppExtendedModel; nodesInfo: NodeInfoModel[]; }) { const [isOpen, setIsOpen] = useState(false); + const [useExistingVolume, setUseExistingVolume] = useState(false); + const [shareableVolumes, setShareableVolumes] = useState<{ id: string; containerMountPath: string; size: number; storageClassName: string; accessMode: string; app: { name: string } }[]>([]); const form = useForm({ @@ -67,9 +72,16 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: { ...volume, accessMode: volume?.accessMode ?? (app.replicas > 1 ? "ReadWriteMany" : "ReadWriteOnce"), storageClassName: (volume?.storageClassName ?? "longhorn") as 'longhorn' | 'local-path', + shareWithOtherApps: volume?.shareWithOtherApps ?? false, + sharedVolumeId: volume?.sharedVolumeId ?? null, } }); + const selectedAccessMode = form.watch("accessMode"); + const selectedSharedVolumeId = form.watch("sharedVolumeId"); + const selectedSharedVolume = useMemo(() => shareableVolumes.find(item => item.id === selectedSharedVolumeId), [shareableVolumes, selectedSharedVolumeId]); + const hasShareableVolumes = shareableVolumes.length > 0; + const [state, formAction] = useFormState((state: ServerActionResult, payload: AppVolumeEditModel) => saveVolume(state, { ...payload, @@ -93,9 +105,49 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: { ...volume, accessMode: volume?.accessMode ?? (app.replicas > 1 ? "ReadWriteMany" : "ReadWriteOnce"), storageClassName: (volume?.storageClassName ?? "longhorn") as 'longhorn' | 'local-path', + shareWithOtherApps: volume?.shareWithOtherApps ?? false, + sharedVolumeId: volume?.sharedVolumeId ?? null, }); + setUseExistingVolume(false); }, [volume]); + useEffect(() => { + if (!isOpen || volume) { + return; + } + const loadShareableVolumes = async () => { + const response = await getShareableVolumes(app.id); + if (response.status === 'success' && response.data) { + setShareableVolumes(response.data); + } else { + setShareableVolumes([]); + } + }; + loadShareableVolumes(); + }, [app.id, isOpen, volume]); + + useEffect(() => { + if (!useExistingVolume) { + form.setValue("sharedVolumeId", null); + return; + } + if (selectedSharedVolume) { + form.setValue("size", selectedSharedVolume.size); + form.setValue("accessMode", selectedSharedVolume.accessMode); + form.setValue("storageClassName", selectedSharedVolume.storageClassName as 'longhorn' | 'local-path'); + form.setValue("shareWithOtherApps", false); + } + }, [form, selectedSharedVolume, useExistingVolume]); + + useEffect(() => { + if (!useExistingVolume || selectedSharedVolumeId) { + return; + } + if (shareableVolumes.length > 0) { + form.setValue("sharedVolumeId", shareableVolumes[0].id); + } + }, [form, selectedSharedVolumeId, shareableVolumes, useExistingVolume]); + return ( <>
setIsOpen(true)}> @@ -114,6 +166,86 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: { return formAction(data); })()}>
+ {!volume && ( +
+ setUseExistingVolume(!!checked)} + disabled={!hasShareableVolumes} + /> + Use existing shared volume +
+ )} + {!volume && !hasShareableVolumes && ( +

+ No shared volumes are available from other apps in this project. +

+ )} + {!volume && useExistingVolume && ( + ( + + Shared Volume + + + + + + + + + + + {shareableVolumes.map((shareableVolume) => ( + { + form.setValue("sharedVolumeId", shareableVolume.id); + }} + > +
+ {shareableVolume.app.name} + {shareableVolume.containerMountPath} · {shareableVolume.size} MB +
+ +
+ ))} +
+
+
+
+
+ + Select a ReadWriteMany volume shared by another app in this project. + + +
+ )} + /> + )} Size in MB - + @@ -145,7 +277,7 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: { ( @@ -223,6 +355,29 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: { )} /> + {!useExistingVolume && !volume?.sharedVolumeId && selectedAccessMode === 'ReadWriteMany' && ( + ( + +
+ + field.onChange(!!checked)} + /> + + Share with other apps in project +
+ + Allow other apps in this project to mount this volume at their own paths. + + +
+ )} + /> + )} {nodesInfo.length === 1 && {field.value ? storageClasses.find( diff --git a/src/app/project/app/[appId]/volumes/storages.tsx b/src/app/project/app/[appId]/volumes/storages.tsx index 581e9fc..0d70221 100644 --- a/src/app/project/app/[appId]/volumes/storages.tsx +++ b/src/app/project/app/[appId]/volumes/storages.tsx @@ -24,7 +24,8 @@ import { KubeSizeConverter } from "@/shared/utils/kubernetes-size-converter.util import { Progress } from "@/components/ui/progress"; import { NodeInfoModel } from "@/shared/model/node-info.model"; -type AppVolumeWithCapacity = (AppVolume & { usedBytes?: number; capacityBytes?: number; usedPercentage?: number }); +type AppVolumeWithSharing = AppVolume & { sharedVolumeId?: string | null; shareWithOtherApps?: boolean }; +type AppVolumeWithCapacity = (AppVolumeWithSharing & { usedBytes?: number; capacityBytes?: number; usedPercentage?: number }); export default function StorageList({ app, readonly, nodesInfo }: { app: AppExtendedModel; @@ -42,7 +43,8 @@ export default function StorageList({ app, readonly, nodesInfo }: { 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.id)); + const pvcVolumeId = item.sharedVolumeId ?? item.id; + const volume = response.data.find(x => x.pvcName === KubeObjectNameUtils.toPvcName(pvcVolumeId)); if (volume) { item.usedBytes = volume.usedBytes; item.capacityBytes = KubeSizeConverter.fromMegabytesToBytes(item.size); diff --git a/src/server/services/app.service.ts b/src/server/services/app.service.ts index c74bb3d..fb2e351 100644 --- a/src/server/services/app.service.ts +++ b/src/server/services/app.service.ts @@ -267,6 +267,28 @@ class AppService { }); } + async getShareableVolumesByProjectId(projectId: string, appId: string) { + return await dataAccess.client.appVolume.findMany({ + where: { + app: { + projectId + }, + appId: { + not: appId + }, + shareWithOtherApps: true, + accessMode: 'ReadWriteMany', + sharedVolumeId: null + }, + include: { + app: true + }, + orderBy: { + createdAt: 'desc' + } + }); + } + async saveVolume(volumeToBeSaved: Prisma.AppVolumeUncheckedCreateInput | Prisma.AppVolumeUncheckedUpdateInput) { let savedItem: AppVolume; const existingApp = await this.getExtendedById(volumeToBeSaved.appId as string); diff --git a/src/server/services/file-browser-service.ts b/src/server/services/file-browser-service.ts index 889f1b2..ee7ccbf 100644 --- a/src/server/services/file-browser-service.ts +++ b/src/server/services/file-browser-service.ts @@ -39,7 +39,8 @@ class FileBrowserService { console.log(`Deploying filebrowser for volume ${volumeId}`); const traefikHostname = await hostnameDnsProviderService.getDomainForApp(volume.id); - const pvcName = KubeObjectNameUtils.toPvcName(volume.id); + const sharedVolumeId = (volume as { sharedVolumeId?: string | null }).sharedVolumeId; + const pvcName = KubeObjectNameUtils.toPvcName(sharedVolumeId ?? volume.id); console.log(`Creating filebrowser deployment for volume ${volumeId}`); @@ -232,4 +233,4 @@ class FileBrowserService { } const fileBrowserService = new FileBrowserService(); -export default fileBrowserService; \ No newline at end of file +export default fileBrowserService; diff --git a/src/server/services/monitoring.service.ts b/src/server/services/monitoring.service.ts index 675dcb3..ddf753c 100644 --- a/src/server/services/monitoring.service.ts +++ b/src/server/services/monitoring.service.ts @@ -34,10 +34,13 @@ class MonitorService { ]); const appVolumesWithUsage: AppVolumeMonitoringUsageModel[] = []; + const volumeMap = new Map(appVolumes.map(volume => [volume.id, volume])); for (const appVolume of appVolumes) { - - const pvc = pvcs.find(pvc => pvc.metadata?.name === KubeObjectNameUtils.toPvcName(appVolume.id)); + const sharedVolumeId = (appVolume as { sharedVolumeId?: string | null }).sharedVolumeId; + const baseVolumeId = sharedVolumeId ?? appVolume.id; + const baseVolume = volumeMap.get(baseVolumeId); + const pvc = pvcs.find(pvc => pvc.metadata?.name === KubeObjectNameUtils.toPvcName(baseVolumeId)); if (!pvc) { continue; } @@ -54,7 +57,7 @@ class MonitorService { appId: appVolume.appId, mountPath: appVolume.containerMountPath, usedBytes: longhornVolume.actualSizeBytes, - capacityBytes: KubeSizeConverter.fromMegabytesToBytes(appVolume.size), + capacityBytes: KubeSizeConverter.fromMegabytesToBytes(baseVolume?.size ?? appVolume.size), }); } @@ -156,15 +159,28 @@ class MonitorService { } async getPvcUsageFromApp(appId: string, projectId: string): Promise> { - const pvcFromApp = await pvcService.getAllPvcForApp(projectId, appId); + const appVolumes = await dataAccess.client.appVolume.findMany({ + where: { + appId + }, + select: { + id: true, + sharedVolumeId: true + } + }); + if (appVolumes.length === 0) { + return []; + } + const baseVolumeIds = Array.from(new Set(appVolumes.map(volume => (volume as { sharedVolumeId?: string | null }).sharedVolumeId ?? volume.id))); + const pvcNames = new Set(baseVolumeIds.map(id => KubeObjectNameUtils.toPvcName(id))); + const pvcFromProject = await k3s.core.listNamespacedPersistentVolumeClaim(projectId); const pvcUsageData: Array<{ pvcName: string, usedBytes: number }> = []; - for (const pvc of pvcFromApp) { + for (const pvc of pvcFromProject.body.items) { const pvcName = pvc.metadata?.name; const volumeName = pvc.spec?.volumeName; - if (pvcName && volumeName) { - + if (pvcName && volumeName && pvcNames.has(pvcName)) { const usedBytes = await longhornApiAdapter.getLonghornVolume(volumeName); pvcUsageData.push({ pvcName, usedBytes }); } diff --git a/src/server/services/pvc.service.ts b/src/server/services/pvc.service.ts index 92b0ca8..a80bf62 100644 --- a/src/server/services/pvc.service.ts +++ b/src/server/services/pvc.service.ts @@ -12,6 +12,8 @@ import path from "path"; import { KubeSizeConverter } from "../../shared/utils/kubernetes-size-converter.utils"; import { AppVolume } from "@prisma/client"; +type AppVolumeWithSharing = AppVolume & { sharedVolumeId?: string | null }; + class PvcService { static readonly SHARED_PVC_NAME = 'qs-shared-pvc'; @@ -45,9 +47,11 @@ class PvcService { } async doesAppConfigurationIncreaseAnyPvcSize(app: AppExtendedModel) { - const existingPvcs = await this.getAllPvcForApp(app.projectId, app.id); + const existingPvcsResponse = await k3s.core.listNamespacedPersistentVolumeClaim(app.projectId); + const existingPvcs = existingPvcsResponse.body.items; + const baseVolumes = await this.getBaseVolumes(app); - for (const appVolume of app.appVolumes) { + for (const appVolume of baseVolumes) { const pvcName = KubeObjectNameUtils.toPvcName(appVolume.id); const existingPvc = existingPvcs.find(pvc => pvc.metadata?.name === pvcName); if (existingPvc && existingPvc.spec!.resources!.requests!.storage !== KubeSizeConverter.megabytesToKubeFormat(appVolume.size)) { @@ -95,24 +99,33 @@ class PvcService { } } - async createPvcForVolumeIfNotExists(projectId: string, app: AppVolume) { - const pvcName = KubeObjectNameUtils.toPvcName(app.id); - const existingPvc = await this.getExistingPvcByVolumeId(projectId, app.id); + async createPvcForVolumeIfNotExists(projectId: string, app: AppVolumeWithSharing) { + const baseVolume = app.sharedVolumeId + ? await dataAccess.client.appVolume.findFirstOrThrow({ + where: { + id: app.sharedVolumeId + } + }) + : app; + const pvcName = KubeObjectNameUtils.toPvcName(baseVolume.id); + const existingPvc = await this.getExistingPvcByVolumeId(projectId, baseVolume.id); if (existingPvc) { console.log(`PVC ${pvcName} for app ${app.id} already exists, no need to create it`); return; } - const pvcDefinition = this.mapVolumeToPvcDefinition(projectId, app); + const pvcDefinition = this.mapVolumeToPvcDefinition(projectId, baseVolume); await k3s.core.createNamespacedPersistentVolumeClaim(projectId, pvcDefinition); console.log(`Created PVC ${pvcName} for app ${app.id}`); } async createOrUpdatePvc(app: AppExtendedModel) { - const existingPvcs = await this.getAllPvcForApp(app.projectId, app.id); + const existingPvcsResponse = await k3s.core.listNamespacedPersistentVolumeClaim(app.projectId); + const existingPvcs = existingPvcsResponse.body.items; + const baseVolumes = await this.getBaseVolumes(app); - for (const appVolume of app.appVolumes) { + for (const appVolume of baseVolumes) { const pvcName = KubeObjectNameUtils.toPvcName(appVolume.id); const pvcDefinition = this.mapVolumeToPvcDefinition(app.projectId, appVolume); const desiredStorageClassName = appVolume.storageClassName ?? 'longhorn'; @@ -143,21 +156,24 @@ class PvcService { } } - const volumes = app.appVolumes - .filter(pvcObj => pvcObj.appId === app.id) - .map(pvcObj => ({ - name: KubeObjectNameUtils.toPvcName(pvcObj.id), - persistentVolumeClaim: { - claimName: KubeObjectNameUtils.toPvcName(pvcObj.id) - }, - })); + const volumesMap = new Map(); + for (const pvcObj of app.appVolumes) { + const baseVolumeId = pvcObj.sharedVolumeId ?? pvcObj.id; + if (!volumesMap.has(baseVolumeId)) { + volumesMap.set(baseVolumeId, { + name: KubeObjectNameUtils.toPvcName(baseVolumeId), + persistentVolumeClaim: { + claimName: KubeObjectNameUtils.toPvcName(baseVolumeId) + }, + }); + } + } + const volumes = Array.from(volumesMap.values()); - const volumeMounts = app.appVolumes - .filter(pvcObj => pvcObj.appId === app.id) - .map(pvcObj => ({ - name: KubeObjectNameUtils.toPvcName(pvcObj.id), - mountPath: pvcObj.containerMountPath, - })); + const volumeMounts = app.appVolumes.map(pvcObj => ({ + name: KubeObjectNameUtils.toPvcName(pvcObj.sharedVolumeId ?? pvcObj.id), + mountPath: pvcObj.containerMountPath, + })); return { volumes, volumeMounts }; } @@ -201,6 +217,20 @@ class PvcService { iterationCount++; } } + + private async getBaseVolumes(app: AppExtendedModel): Promise { + const baseVolumeIds = Array.from(new Set(app.appVolumes.map(volume => volume.sharedVolumeId ?? volume.id))); + if (baseVolumeIds.length === 0) { + return []; + } + return await dataAccess.client.appVolume.findMany({ + where: { + id: { + in: baseVolumeIds + } + } + }); + } } const pvcService = new PvcService(); diff --git a/src/server/services/restore.service.ts b/src/server/services/restore.service.ts index 5590d84..fe65c18 100644 --- a/src/server/services/restore.service.ts +++ b/src/server/services/restore.service.ts @@ -57,8 +57,16 @@ class RestoreService { } async startAplineImageInNamespace(namespace: string, volumeId: string) { + const volume = await dataAccess.client.appVolume.findFirstOrThrow({ + where: { + id: volumeId + }, + select: { + sharedVolumeId: true + } + }); const name = KubeObjectNameUtils.toRestorePodName(volumeId); - const pvcName = KubeObjectNameUtils.toPvcName(volumeId); + const pvcName = KubeObjectNameUtils.toPvcName(volume.sharedVolumeId ?? volumeId); const existingPods = await k3s.core.listNamespacedPod(namespace); const pod = existingPods.body.items.find((item) => item.metadata?.labels?.app === name); @@ -98,4 +106,4 @@ class RestoreService { } const restoreService = new RestoreService(); -export default restoreService; \ No newline at end of file +export default restoreService; diff --git a/src/shared/model/generated-zod/appvolume.ts b/src/shared/model/generated-zod/appvolume.ts index f85522a..120bb71 100644 --- a/src/shared/model/generated-zod/appvolume.ts +++ b/src/shared/model/generated-zod/appvolume.ts @@ -8,6 +8,8 @@ export const AppVolumeModel = z.object({ size: z.number().int(), accessMode: z.string(), storageClassName: z.string(), + shareWithOtherApps: z.boolean(), + sharedVolumeId: z.string().nullish(), appId: z.string(), createdAt: z.date(), updatedAt: z.date(), diff --git a/src/shared/model/volume-edit.model.ts b/src/shared/model/volume-edit.model.ts index 0c23dc3..0c6c670 100644 --- a/src/shared/model/volume-edit.model.ts +++ b/src/shared/model/volume-edit.model.ts @@ -9,6 +9,8 @@ export const appVolumeEditZodModel = z.object({ size: stringToNumber, accessMode: appVolumeTypeZodModel.nullish().or(z.string().nullish()), storageClassName: storageClassNameZodModel.default("longhorn"), + shareWithOtherApps: z.boolean().optional().default(false), + sharedVolumeId: z.string().nullish(), }); export type AppVolumeEditModel = z.infer; From bcd9e8a7b27ad8e286efef165e501c01a21e9501 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Fri, 30 Jan 2026 09:43:28 +0000 Subject: [PATCH 2/3] feat: enhanced ui for shareable AppVolumes and update related components --- .../20260130080723_migration/migration.sql | 23 +++ .../volumes/shared-storage-edit-overlay.tsx | 173 ++++++++++++++++ .../[appId]/volumes/storage-edit-overlay.tsx | 186 +++--------------- .../project/app/[appId]/volumes/storages.tsx | 126 ++++++++---- .../volumes/volume-backup-edit-overlay.tsx | 2 +- .../app/[appId]/volumes/volume-backup.tsx | 8 +- src/server/services/monitoring.service.ts | 4 +- src/shared/model/generated-zod/appvolume.ts | 4 + .../templates/apps/wordpress.template.ts | 4 +- .../templates/databases/mariadb.template.ts | 1 + .../templates/databases/mongodb.template.ts | 1 + .../templates/databases/mysql.template.ts | 1 + .../templates/databases/postgres.template.ts | 1 + 13 files changed, 331 insertions(+), 203 deletions(-) create mode 100644 prisma/migrations/20260130080723_migration/migration.sql create mode 100644 src/app/project/app/[appId]/volumes/shared-storage-edit-overlay.tsx diff --git a/prisma/migrations/20260130080723_migration/migration.sql b/prisma/migrations/20260130080723_migration/migration.sql new file mode 100644 index 0000000..b36a51b --- /dev/null +++ b/prisma/migrations/20260130080723_migration/migration.sql @@ -0,0 +1,23 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_AppVolume" ( + "id" TEXT NOT NULL PRIMARY KEY, + "containerMountPath" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "accessMode" TEXT NOT NULL DEFAULT 'rwo', + "storageClassName" TEXT NOT NULL DEFAULT 'longhorn', + "shareWithOtherApps" BOOLEAN NOT NULL DEFAULT false, + "sharedVolumeId" TEXT, + "appId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "AppVolume_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "AppVolume_sharedVolumeId_fkey" FOREIGN KEY ("sharedVolumeId") REFERENCES "AppVolume" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_AppVolume" ("accessMode", "appId", "containerMountPath", "createdAt", "id", "size", "storageClassName", "updatedAt") SELECT "accessMode", "appId", "containerMountPath", "createdAt", "id", "size", "storageClassName", "updatedAt" FROM "AppVolume"; +DROP TABLE "AppVolume"; +ALTER TABLE "new_AppVolume" RENAME TO "AppVolume"; +CREATE UNIQUE INDEX "AppVolume_appId_containerMountPath_key" ON "AppVolume"("appId", "containerMountPath"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/src/app/project/app/[appId]/volumes/shared-storage-edit-overlay.tsx b/src/app/project/app/[appId]/volumes/shared-storage-edit-overlay.tsx new file mode 100644 index 0000000..ef51964 --- /dev/null +++ b/src/app/project/app/[appId]/volumes/shared-storage-edit-overlay.tsx @@ -0,0 +1,173 @@ +'use client' + +import { Dialog, DialogContent, 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 { useFormState } from 'react-dom' +import { 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" + +type ShareableVolume = { + id: string; + containerMountPath: string; + size: number; + storageClassName: string; + accessMode: string; + app: { name: string }; +}; + +export default function SharedStorageEditDialog({ children, app }: { + children: React.ReactNode; + app: AppExtendedModel; +}) { + + const [isOpen, setIsOpen] = useState(false); + const [shareableVolumes, setShareableVolumes] = useState([]); + const [isLoadingVolumes, setIsLoadingVolumes] = useState(false); + + const form = useForm({ + resolver: zodResolver(appVolumeEditZodModel), + defaultValues: { + containerMountPath: '', + size: 0, + accessMode: 'ReadWriteMany', + storageClassName: 'longhorn', + sharedVolumeId: undefined, + } + }); + + const [state, formAction] = useFormState((state: ServerActionResult, payload: AppVolumeEditModel) => + saveVolume(state, { + ...payload, + appId: app.id, + id: undefined + }), FormUtils.getInitialFormState()); + + // Fetch shareable volumes when dialog opens + useEffect(() => { + if (isOpen) { + setIsLoadingVolumes(true); + getShareableVolumes(app.id).then(result => { + if (result.status === 'success' && result.data) { + setShareableVolumes(result.data); + } else { + setShareableVolumes([]); + } + setIsLoadingVolumes(false); + }); + } + }, [isOpen, app.id]); + + // 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 as 'longhorn' | 'local-path'); + } + } + }, [watchedSharedVolumeId, shareableVolumes]); + + useEffect(() => { + if (state.status === 'success') { + form.reset(); + toast.success('Shared volume mounted successfully', { + description: "Click \"deploy\" to apply the changes to your app.", + }); + setIsOpen(false); + } + FormUtils.mapValidationErrorsToForm(state, form); + }, [state]); + + return ( + <> +
setIsOpen(true)}> + {children} +
+ setIsOpen(false)}> + + + 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}MB)` + ])} + placeholder="Select volume to share..." + /> + + ( + + Mount Path in This Container + + + + + + )} + /> + +
+

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

+

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

+
+ + )} + +

{state.message}

+ {shareableVolumes.length > 0 && Mount Shared Volume} +
+
+ +
+
+ + ) +} 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 cadcf93..adc07ab 100644 --- a/src/app/project/app/[appId]/volumes/storage-edit-overlay.tsx +++ b/src/app/project/app/[appId]/volumes/storage-edit-overlay.tsx @@ -28,19 +28,19 @@ import { Check, ChevronsUpDown } from "lucide-react" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" import { useFormState } from 'react-dom' -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } 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 { getShareableVolumes, saveVolume } from "./actions" +import { saveVolume } from "./actions" 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" -import { Checkbox } from "@/components/ui/checkbox" +import CheckboxFormField from "@/components/custom/checkbox-form-field" const accessModes = [ { label: "ReadWriteOnce", value: "ReadWriteOnce" }, @@ -52,35 +52,33 @@ const storageClasses = [ { 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 -type AppVolumeWithSharing = AppVolume & { sharedVolumeId?: string | null; shareWithOtherApps?: boolean }; - -export default function DialogEditDialog({ children, volume, app, nodesInfo }: { +export default function StorageEditDialog({ children, volume, app, nodesInfo }: { children: React.ReactNode; - volume?: AppVolumeWithSharing; + volume?: AppVolume; app: AppExtendedModel; nodesInfo: NodeInfoModel[]; }) { const [isOpen, setIsOpen] = useState(false); - const [useExistingVolume, setUseExistingVolume] = useState(false); - const [shareableVolumes, setShareableVolumes] = useState<{ id: string; containerMountPath: string; size: number; storageClassName: string; accessMode: string; app: { name: string } }[]>([]); - const form = useForm({ resolver: zodResolver(appVolumeEditZodModel), defaultValues: { - ...volume, + containerMountPath: volume?.containerMountPath ?? '', + size: volume?.size ?? 0, accessMode: volume?.accessMode ?? (app.replicas > 1 ? "ReadWriteMany" : "ReadWriteOnce"), storageClassName: (volume?.storageClassName ?? "longhorn") as 'longhorn' | 'local-path', shareWithOtherApps: volume?.shareWithOtherApps ?? false, - sharedVolumeId: volume?.sharedVolumeId ?? null, + sharedVolumeId: volume?.sharedVolumeId ?? undefined, } }); - const selectedAccessMode = form.watch("accessMode"); - const selectedSharedVolumeId = form.watch("sharedVolumeId"); - const selectedSharedVolume = useMemo(() => shareableVolumes.find(item => item.id === selectedSharedVolumeId), [shareableVolumes, selectedSharedVolumeId]); - const hasShareableVolumes = shareableVolumes.length > 0; + // 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 [state, formAction] = useFormState((state: ServerActionResult, payload: AppVolumeEditModel) => saveVolume(state, { @@ -106,48 +104,10 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: { accessMode: volume?.accessMode ?? (app.replicas > 1 ? "ReadWriteMany" : "ReadWriteOnce"), storageClassName: (volume?.storageClassName ?? "longhorn") as 'longhorn' | 'local-path', shareWithOtherApps: volume?.shareWithOtherApps ?? false, - sharedVolumeId: volume?.sharedVolumeId ?? null, + sharedVolumeId: volume?.sharedVolumeId ?? undefined, }); - setUseExistingVolume(false); }, [volume]); - useEffect(() => { - if (!isOpen || volume) { - return; - } - const loadShareableVolumes = async () => { - const response = await getShareableVolumes(app.id); - if (response.status === 'success' && response.data) { - setShareableVolumes(response.data); - } else { - setShareableVolumes([]); - } - }; - loadShareableVolumes(); - }, [app.id, isOpen, volume]); - - useEffect(() => { - if (!useExistingVolume) { - form.setValue("sharedVolumeId", null); - return; - } - if (selectedSharedVolume) { - form.setValue("size", selectedSharedVolume.size); - form.setValue("accessMode", selectedSharedVolume.accessMode); - form.setValue("storageClassName", selectedSharedVolume.storageClassName as 'longhorn' | 'local-path'); - form.setValue("shareWithOtherApps", false); - } - }, [form, selectedSharedVolume, useExistingVolume]); - - useEffect(() => { - if (!useExistingVolume || selectedSharedVolumeId) { - return; - } - if (shareableVolumes.length > 0) { - form.setValue("sharedVolumeId", shareableVolumes[0].id); - } - }, [form, selectedSharedVolumeId, shareableVolumes, useExistingVolume]); - return ( <>
setIsOpen(true)}> @@ -166,86 +126,6 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: { return formAction(data); })()}>
- {!volume && ( -
- setUseExistingVolume(!!checked)} - disabled={!hasShareableVolumes} - /> - Use existing shared volume -
- )} - {!volume && !hasShareableVolumes && ( -

- No shared volumes are available from other apps in this project. -

- )} - {!volume && useExistingVolume && ( - ( - - Shared Volume - - - - - - - - - - - {shareableVolumes.map((shareableVolume) => ( - { - form.setValue("sharedVolumeId", shareableVolume.id); - }} - > -
- {shareableVolume.app.name} - {shareableVolume.containerMountPath} · {shareableVolume.size} MB -
- -
- ))} -
-
-
-
-
- - Select a ReadWriteMany volume shared by another app in this project. - - -
- )} - /> - )} Size in MB - + @@ -277,7 +157,7 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: { ( @@ -355,29 +235,6 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: { )} /> - {!useExistingVolume && !volume?.sharedVolumeId && selectedAccessMode === 'ReadWriteMany' && ( - ( - -
- - field.onChange(!!checked)} - /> - - Share with other apps in project -
- - Allow other apps in this project to mount this volume at their own paths. - - -
- )} - /> - )} {nodesInfo.length === 1 && {field.value ? storageClasses.find( @@ -460,6 +317,13 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: { )} />} + {canBeShared && ( + + )}

{state.message}

Save
diff --git a/src/app/project/app/[appId]/volumes/storages.tsx b/src/app/project/app/[appId]/volumes/storages.tsx index 0d70221..1754fcd 100644 --- a/src/app/project/app/[appId]/volumes/storages.tsx +++ b/src/app/project/app/[appId]/volumes/storages.tsx @@ -4,8 +4,9 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } 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 } from "lucide-react"; +import { Download, EditIcon, Folder, TrashIcon, Share2 } 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 } from "@/frontend/states/zustand.states"; @@ -24,8 +25,11 @@ import { KubeSizeConverter } from "@/shared/utils/kubernetes-size-converter.util import { Progress } from "@/components/ui/progress"; import { NodeInfoModel } from "@/shared/model/node-info.model"; -type AppVolumeWithSharing = AppVolume & { sharedVolumeId?: string | null; shareWithOtherApps?: boolean }; -type AppVolumeWithCapacity = (AppVolumeWithSharing & { usedBytes?: number; capacityBytes?: number; usedPercentage?: number }); +type AppVolumeWithCapacity = (AppVolume & { + usedBytes?: number; + capacityBytes?: number; + usedPercentage?: number; +}); export default function StorageList({ app, readonly, nodesInfo }: { app: AppExtendedModel; @@ -33,7 +37,7 @@ export default function StorageList({ app, readonly, nodesInfo }: { readonly: boolean; }) { - const [volumesWithStorage, setVolumesWithStorage] = React.useState(app.appVolumes); + const [volumesWithStorage, setVolumesWithStorage] = React.useState(app.appVolumes as AppVolumeWithCapacity[]); const [isLoading, setIsLoading] = React.useState(false); const loadAndMapStorageData = async () => { @@ -43,8 +47,7 @@ export default function StorageList({ app, readonly, nodesInfo }: { if (response.status === 'success' && response.data) { const mappedVolumeData = [...app.appVolumes] as AppVolumeWithCapacity[]; for (let item of mappedVolumeData) { - const pvcVolumeId = item.sharedVolumeId ?? item.id; - const volume = response.data.find(x => x.pvcName === KubeObjectNameUtils.toPvcName(pvcVolumeId)); + const volume = response.data.find(x => x.pvcName === KubeObjectNameUtils.toPvcName(item.id)); if (volume) { item.usedBytes = volume.usedBytes; item.capacityBytes = KubeSizeConverter.fromMegabytesToBytes(item.size); @@ -156,6 +159,7 @@ export default function StorageList({ app, readonly, nodesInfo }: { Storage Used Storage Class Access Mode + Shared Action @@ -175,31 +179,65 @@ export default function StorageList({ app, readonly, nodesInfo }: { {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

+
+
+
+ )} +
- - - - - - -

Download volume content

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

View content of Volume

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

Download volume content

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

View content of Volume

+
+
+
} + } {/* @@ -215,18 +253,31 @@ export default function StorageList({ app, readonly, nodesInfo }: { */} {!readonly && <> - + {volume.sharedVolumeId ? ( - + -

Edit volume settings

+

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

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

Edit volume settings

+
+
+
+
+ )} @@ -246,11 +297,14 @@ export default function StorageList({ app, readonly, nodesInfo }: { - {!readonly && + {!readonly && + + + } ; -} +} \ No newline at end of file diff --git a/src/app/project/app/[appId]/volumes/volume-backup-edit-overlay.tsx b/src/app/project/app/[appId]/volumes/volume-backup-edit-overlay.tsx index a1a051e..b5cc423 100644 --- a/src/app/project/app/[appId]/volumes/volume-backup-edit-overlay.tsx +++ b/src/app/project/app/[appId]/volumes/volume-backup-edit-overlay.tsx @@ -93,7 +93,7 @@ export default function VolumeBackupEditDialog({ Edit Backup Configuration - Configure your custom volume for this container. + Configure the backup settings for this volume.
diff --git a/src/app/project/app/[appId]/volumes/volume-backup.tsx b/src/app/project/app/[appId]/volumes/volume-backup.tsx index 2e58038..4432525 100644 --- a/src/app/project/app/[appId]/volumes/volume-backup.tsx +++ b/src/app/project/app/[appId]/volumes/volume-backup.tsx @@ -13,6 +13,7 @@ import React from "react"; import { formatDateTime } from "@/frontend/utils/format.utils"; import VolumeBackupEditDialog from "./volume-backup-edit-overlay"; import { VolumeBackupExtendedModel } from "@/shared/model/volume-backup-extended.model"; +import { AppVolume } from "@prisma/client"; export default function VolumeBackupList({ app, @@ -29,6 +30,9 @@ export default function VolumeBackupList({ const { openConfirmDialog: openDialog } = useConfirmDialog(); const [isLoading, setIsLoading] = React.useState(false); + // Filter out shared volumes (volumes that are mounted from other apps) + const ownVolumes = app.appVolumes.filter(volume => !volume.sharedVolumeId) as AppVolume[]; + const asyncDeleteBackupVolume = async (volumeId: string) => { const confirm = await openDialog({ title: "Delete Backup Schedule", @@ -92,7 +96,7 @@ export default function VolumeBackupList({ + s3Targets={s3Targets} volumes={ownVolumes as AppVolume[]} app={app}> } diff --git a/src/server/services/monitoring.service.ts b/src/server/services/monitoring.service.ts index ddf753c..b2bf4da 100644 --- a/src/server/services/monitoring.service.ts +++ b/src/server/services/monitoring.service.ts @@ -171,7 +171,7 @@ class MonitorService { if (appVolumes.length === 0) { return []; } - const baseVolumeIds = Array.from(new Set(appVolumes.map(volume => (volume as { sharedVolumeId?: string | null }).sharedVolumeId ?? volume.id))); + const baseVolumeIds = Array.from(new Set(appVolumes.map(volume => (volume as { sharedVolumeId?: string | null }).sharedVolumeId || volume.id))); const pvcNames = new Set(baseVolumeIds.map(id => KubeObjectNameUtils.toPvcName(id))); const pvcFromProject = await k3s.core.listNamespacedPersistentVolumeClaim(projectId); const pvcUsageData: Array<{ pvcName: string, usedBytes: number }> = []; @@ -180,7 +180,7 @@ class MonitorService { const pvcName = pvc.metadata?.name; const volumeName = pvc.spec?.volumeName; - if (pvcName && volumeName && pvcNames.has(pvcName)) { + if (pvcName && volumeName && pvcNames.has(pvcName as `pvc-${string}`)) { const usedBytes = await longhornApiAdapter.getLonghornVolume(volumeName); pvcUsageData.push({ pvcName, usedBytes }); } diff --git a/src/shared/model/generated-zod/appvolume.ts b/src/shared/model/generated-zod/appvolume.ts index 120bb71..8255d96 100644 --- a/src/shared/model/generated-zod/appvolume.ts +++ b/src/shared/model/generated-zod/appvolume.ts @@ -18,6 +18,8 @@ export const AppVolumeModel = z.object({ export interface CompleteAppVolume extends z.infer { app: CompleteApp volumeBackups: CompleteVolumeBackup[] + sharedVolume?: CompleteAppVolume | null + sharedVolumes: CompleteAppVolume[] } /** @@ -28,4 +30,6 @@ export interface CompleteAppVolume extends z.infer { export const RelatedAppVolumeModel: z.ZodSchema = z.lazy(() => AppVolumeModel.extend({ app: RelatedAppModel, volumeBackups: RelatedVolumeBackupModel.array(), + sharedVolume: RelatedAppVolumeModel.nullish(), + sharedVolumes: RelatedAppVolumeModel.array(), })) diff --git a/src/shared/templates/apps/wordpress.template.ts b/src/shared/templates/apps/wordpress.template.ts index bc5d25c..7907a68 100644 --- a/src/shared/templates/apps/wordpress.template.ts +++ b/src/shared/templates/apps/wordpress.template.ts @@ -52,6 +52,7 @@ MYSQL_USER=wordpress containerMountPath: '/var/lib/mysql', accessMode: 'ReadWriteOnce', storageClassName: 'longhorn', + shareWithOtherApps: false, }], appFileMounts: [], appPorts: [{ @@ -93,7 +94,8 @@ WORDPRESS_TABLE_PREFIX=wp_ size: 500, containerMountPath: '/var/www/html', accessMode: 'ReadWriteMany', - storageClassName: 'longhorn' + storageClassName: 'longhorn', + shareWithOtherApps: false, }], appFileMounts: [{ containerMountPath: '/usr/local/etc/php/conf.d/custom.ini', diff --git a/src/shared/templates/databases/mariadb.template.ts b/src/shared/templates/databases/mariadb.template.ts index a1e135b..79ee5da 100644 --- a/src/shared/templates/databases/mariadb.template.ts +++ b/src/shared/templates/databases/mariadb.template.ts @@ -62,6 +62,7 @@ export const mariadbAppTemplate: AppTemplateModel = { containerMountPath: '/var/lib/mysql', accessMode: 'ReadWriteOnce', storageClassName: 'longhorn', + shareWithOtherApps: false, }], appFileMounts: [], appPorts: [{ diff --git a/src/shared/templates/databases/mongodb.template.ts b/src/shared/templates/databases/mongodb.template.ts index 3d13379..f3d6d27 100644 --- a/src/shared/templates/databases/mongodb.template.ts +++ b/src/shared/templates/databases/mongodb.template.ts @@ -55,6 +55,7 @@ export const mongodbAppTemplate: AppTemplateModel = { containerMountPath: '/data/db', accessMode: 'ReadWriteOnce', storageClassName: 'longhorn', + shareWithOtherApps: false, }], appFileMounts: [], appPorts: [{ diff --git a/src/shared/templates/databases/mysql.template.ts b/src/shared/templates/databases/mysql.template.ts index 72721e9..184173f 100644 --- a/src/shared/templates/databases/mysql.template.ts +++ b/src/shared/templates/databases/mysql.template.ts @@ -62,6 +62,7 @@ export const mysqlAppTemplate: AppTemplateModel = { containerMountPath: '/var/lib/mysql', accessMode: 'ReadWriteOnce', storageClassName: 'longhorn', + shareWithOtherApps: false, }], appFileMounts: [], appPorts: [{ diff --git a/src/shared/templates/databases/postgres.template.ts b/src/shared/templates/databases/postgres.template.ts index 06c7d78..67b575a 100644 --- a/src/shared/templates/databases/postgres.template.ts +++ b/src/shared/templates/databases/postgres.template.ts @@ -56,6 +56,7 @@ export const postgreAppTemplate: AppTemplateModel = { containerMountPath: '/var/lib/qs-postgres', accessMode: 'ReadWriteOnce', storageClassName: 'longhorn', + shareWithOtherApps: false, }], appFileMounts: [], appPorts: [{ From 05b1f1ee2ae4aac2d7a0a52f657f7ab85f94d68e Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Sat, 31 Jan 2026 15:14:51 +0000 Subject: [PATCH 3/3] fix: monitoring support for shared volumes and cache management for volumes in app detail --- src/app/monitoring/app-volumes-monitoring.tsx | 3 ++- src/app/monitoring/page.tsx | 2 ++ .../volumes/shared-storage-edit-overlay.tsx | 6 +++++- .../[appId]/volumes/storage-edit-overlay.tsx | 8 +++++++ .../project/app/[appId]/volumes/storages.tsx | 21 ++++++++++--------- src/server/services/app.service.ts | 12 +++++++++-- src/server/services/monitoring.service.ts | 2 +- src/server/services/pvc.service.ts | 12 +++++------ .../app-volume-monitoring-usage.model.ts | 3 ++- 9 files changed, 46 insertions(+), 23 deletions(-) diff --git a/src/app/monitoring/app-volumes-monitoring.tsx b/src/app/monitoring/app-volumes-monitoring.tsx index e628af5..bad7d9d 100644 --- a/src/app/monitoring/app-volumes-monitoring.tsx +++ b/src/app/monitoring/app-volumes-monitoring.tsx @@ -47,7 +47,8 @@ export default function AppVolumeMonitoring({ const fetchVolumeMonitoringUsage = async () => { try { - const data = await Actions.run(() => getVolumeMonitoringUsage()); + let data = await Actions.run(() => getVolumeMonitoringUsage()); + data = data?.filter((volume) => !!volume.isBaseVolume); setUpdatedVolumeUsage(convertToExtendedModel(data)); setUsedAndCapacityBytes(convertToExtendedModel(data)); } catch (ex) { diff --git a/src/app/monitoring/page.tsx b/src/app/monitoring/page.tsx index ed7286f..e46dc0e 100644 --- a/src/app/monitoring/page.tsx +++ b/src/app/monitoring/page.tsx @@ -30,6 +30,8 @@ export default async function ResourceNodesInfoPage() { // 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 ( diff --git a/src/app/project/app/[appId]/volumes/shared-storage-edit-overlay.tsx b/src/app/project/app/[appId]/volumes/shared-storage-edit-overlay.tsx index ef51964..9f5d4da 100644 --- a/src/app/project/app/[appId]/volumes/shared-storage-edit-overlay.tsx +++ b/src/app/project/app/[appId]/volumes/shared-storage-edit-overlay.tsx @@ -67,9 +67,13 @@ export default function SharedStorageEditDialog({ children, app }: { setIsLoadingVolumes(true); getShareableVolumes(app.id).then(result => { if (result.status === 'success' && result.data) { - setShareableVolumes(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); }); 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 adc07ab..db83818 100644 --- a/src/app/project/app/[appId]/volumes/storage-edit-overlay.tsx +++ b/src/app/project/app/[appId]/volumes/storage-edit-overlay.tsx @@ -108,6 +108,8 @@ export default function StorageEditDialog({ children, volume, app, nodesInfo }: }); }, [volume]); + const values = form.watch(); + return ( <>
setIsOpen(true)}> @@ -154,6 +156,12 @@ export default function StorageEditDialog({ children, volume, app, nodesInfo }: )} /> + {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. +

+ } + x.pvcName === KubeObjectNameUtils.toPvcName(item.id)); + const volume = response.data.find(x => x.pvcName === KubeObjectNameUtils.toPvcName(item.sharedVolumeId || item.id)); if (volume) { item.usedBytes = volume.usedBytes; item.capacityBytes = KubeSizeConverter.fromMegabytesToBytes(item.size); @@ -62,16 +62,17 @@ export default function StorageList({ app, readonly, nodesInfo }: { React.useEffect(() => { loadAndMapStorageData(); - }, [app.appVolumes]); + }, [app.appVolumes, app]); const { openConfirmDialog: openDialog } = useConfirmDialog(); - const asyncDeleteVolume = async (volumeId: string) => { + const asyncDeleteVolume = async (volumeId: string, isBaseVolume: boolean) => { try { const confirm = await openDialog({ - title: "Delete Volume", - description: "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?", - okButton: "Delete Volume" + 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); @@ -281,12 +282,12 @@ export default function StorageList({ app, readonly, nodesInfo }: { - -

Delete volume

+

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

diff --git a/src/server/services/app.service.ts b/src/server/services/app.service.ts index fb2e351..008b956 100644 --- a/src/server/services/app.service.ts +++ b/src/server/services/app.service.ts @@ -298,7 +298,8 @@ class AppService { } }); - if (existingAppWithSameVolumeMountPath.filter(x => x.id !== volumeToBeSaved.id) + if (existingAppWithSameVolumeMountPath + .filter(x => x.id !== volumeToBeSaved.id) .some(x => x.containerMountPath === volumeToBeSaved.containerMountPath)) { throw new ServiceException("Mount Path is already configured within the same app."); } @@ -329,12 +330,16 @@ class AppService { where: { id }, include: { - app: true + app: true, + sharedVolumes: true } }); if (!existingVolume) { return; } + + // get ids of all apps that use this volume as shared volume --> to reset cache + let additionalAppIds = existingVolume.sharedVolumes.map(v => v.appId); try { await dataAccess.client.appVolume.delete({ where: { @@ -344,6 +349,9 @@ class AppService { } finally { revalidateTag(Tags.app(existingVolume.appId)); revalidateTag(Tags.apps(existingVolume.app.projectId)); + for (const appId of additionalAppIds) { + revalidateTag(Tags.app(appId)); + } } } diff --git a/src/server/services/monitoring.service.ts b/src/server/services/monitoring.service.ts index b2bf4da..4c1588d 100644 --- a/src/server/services/monitoring.service.ts +++ b/src/server/services/monitoring.service.ts @@ -9,7 +9,6 @@ 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"; -import appService from "./app.service"; import projectService from "./project.service"; import { AppMonitoringUsageModel } from "@/shared/model/app-monitoring-usage.model"; @@ -58,6 +57,7 @@ class MonitorService { mountPath: appVolume.containerMountPath, usedBytes: longhornVolume.actualSizeBytes, capacityBytes: KubeSizeConverter.fromMegabytesToBytes(baseVolume?.size ?? appVolume.size), + isBaseVolume: !sharedVolumeId }); } diff --git a/src/server/services/pvc.service.ts b/src/server/services/pvc.service.ts index a80bf62..1af4711 100644 --- a/src/server/services/pvc.service.ts +++ b/src/server/services/pvc.service.ts @@ -100,13 +100,11 @@ class PvcService { } async createPvcForVolumeIfNotExists(projectId: string, app: AppVolumeWithSharing) { - const baseVolume = app.sharedVolumeId - ? await dataAccess.client.appVolume.findFirstOrThrow({ - where: { - id: app.sharedVolumeId - } - }) - : app; + const baseVolume = app.sharedVolumeId ? await dataAccess.client.appVolume.findFirstOrThrow({ + where: { + id: app.sharedVolumeId + } + }) : app; const pvcName = KubeObjectNameUtils.toPvcName(baseVolume.id); const existingPvc = await this.getExistingPvcByVolumeId(projectId, baseVolume.id); diff --git a/src/shared/model/app-volume-monitoring-usage.model.ts b/src/shared/model/app-volume-monitoring-usage.model.ts index 238aa1b..418036d 100644 --- a/src/shared/model/app-volume-monitoring-usage.model.ts +++ b/src/shared/model/app-volume-monitoring-usage.model.ts @@ -5,5 +5,6 @@ export interface AppVolumeMonitoringUsageModel { appId: string, mountPath: string, usedBytes: number, - capacityBytes: number + capacityBytes: number, + isBaseVolume: boolean }