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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions prisma/migrations/20260130080723_migration/migration.sql
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/app/monitoring/app-volumes-monitoring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/app/monitoring/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
48 changes: 47 additions & 1 deletion src/app/project/app/[appId]/volumes/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,39 @@ export const saveVolume = async (prevState: any, inputData: z.infer<typeof actio
await isAuthorizedWriteForApp(validatedData.appId);
const existingApp = await appService.getExtendedById(validatedData.appId);
const existingVolume = validatedData.id ? await appService.getVolumeById(validatedData.id) : undefined;
const sharedVolumeId = existingVolume?.sharedVolumeId ?? validatedData.sharedVolumeId ?? undefined;

if (sharedVolumeId) {
const sharedVolume = await dataAccess.client.appVolume.findFirstOrThrow({
where: {
id: sharedVolumeId
},
include: {
app: true
}
});
if (sharedVolume.app.projectId !== existingApp.projectId) {
throw new ServiceException('Shared volumes must belong to the same project.');
}
if (sharedVolume.appId === validatedData.appId) {
throw new ServiceException('Shared volumes must belong to a different app.');
}
if (!sharedVolume.shareWithOtherApps || sharedVolume.accessMode !== 'ReadWriteMany') {
throw new ServiceException('This volume is not available for sharing.');
}
await appService.saveVolume({
appId: validatedData.appId,
id: validatedData.id ?? undefined,
containerMountPath: validatedData.containerMountPath,
size: sharedVolume.size,
accessMode: sharedVolume.accessMode,
storageClassName: sharedVolume.storageClassName,
shareWithOtherApps: false,
sharedVolumeId: sharedVolume.id
});
return;
}

if (existingVolume && existingVolume.size > validatedData.size) {
throw new ServiceException('Volume size cannot be decreased');
}
Expand All @@ -56,11 +89,16 @@ export const saveVolume = async (prevState: any, inputData: z.infer<typeof actio
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.');
}
if (validatedData.shareWithOtherApps && (existingVolume?.accessMode ?? validatedData.accessMode) !== 'ReadWriteMany') {
throw new ServiceException('Only ReadWriteMany volumes can be shared with other apps.');
}
await appService.saveVolume({
...validatedData,
id: validatedData.id ?? undefined,
accessMode: existingVolume?.accessMode ?? validatedData.accessMode as string,
storageClassName: existingVolume?.storageClassName ?? validatedData.storageClassName
storageClassName: existingVolume?.storageClassName ?? validatedData.storageClassName,
shareWithOtherApps: validatedData.shareWithOtherApps ?? false,
sharedVolumeId: null
});
});

Expand All @@ -77,6 +115,14 @@ export const getPvcUsage = async (appId: string, projectId: string) =>
return monitoringService.getPvcUsageFromApp(appId, projectId);
}) as Promise<ServerActionResult<any, { pvcName: string, usedBytes: number }[]>>;

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<ServerActionResult<any, { id: string; containerMountPath: string; size: number; storageClassName: string; accessMode: string; app: { name: string } }[]>>;

export const downloadPvcData = async (volumeId: string) =>
simpleAction(async () => {
await validateVolumeReadAuthorization(volumeId);
Expand Down
177 changes: 177 additions & 0 deletions src/app/project/app/[appId]/volumes/shared-storage-edit-overlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
'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<boolean>(false);
const [shareableVolumes, setShareableVolumes] = useState<ShareableVolume[]>([]);
const [isLoadingVolumes, setIsLoadingVolumes] = useState(false);

const form = useForm<AppVolumeEditModel>({
resolver: zodResolver(appVolumeEditZodModel),
defaultValues: {
containerMountPath: '',
size: 0,
accessMode: 'ReadWriteMany',
storageClassName: 'longhorn',
sharedVolumeId: undefined,
}
});

const [state, formAction] = useFormState((state: ServerActionResult<any, any>, payload: AppVolumeEditModel) =>
saveVolume(state, {
...payload,
appId: app.id,
id: undefined
}), FormUtils.getInitialFormState<typeof appVolumeEditZodModel>());

// Fetch shareable volumes when dialog opens
useEffect(() => {
if (isOpen) {
setIsLoadingVolumes(true);
getShareableVolumes(app.id).then(result => {
if (result.status === 'success' && 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);
});
}
}, [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<typeof appVolumeEditZodModel>(state, form);
}, [state]);

return (
<>
<div onClick={() => setIsOpen(true)}>
{children}
</div>
<Dialog open={!!isOpen} onOpenChange={(isOpened) => setIsOpen(false)}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Mount Shared Volume</DialogTitle>
<DialogDescription>
Mount an existing ReadWriteMany volume from another app in this project.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form action={(e) => form.handleSubmit((data) => {
return formAction(data);
})()}>
<div className="space-y-4">
{isLoadingVolumes ? (
<div className="text-sm text-muted-foreground">Loading shareable volumes...</div>
) : shareableVolumes.length === 0 ? (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
No shareable volumes available. Create a ReadWriteMany volume in another app and enable sharing first.
</AlertDescription>
</Alert>
) : (
<>
<SelectFormField
form={form}
name="sharedVolumeId"
label="Select Shared Volume"
values={shareableVolumes.map(v => [
v.id,
`${v.app.name} - ${v.containerMountPath} (${v.size}MB)`
])}
placeholder="Select volume to share..."
/>

<FormField
control={form.control}
name="containerMountPath"
render={({ field }) => (
<FormItem>
<FormLabel>Mount Path in This Container</FormLabel>
<FormControl>
<Input placeholder="ex. /shared-data" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<div className="text-sm text-muted-foreground space-y-1">
<p><strong>Size:</strong> {form.watch("size")} MB (inherited from shared volume)</p>
<p><strong>Storage Class:</strong> {form.watch("storageClassName")} (inherited from shared volume)</p>
</div>
</>
)}

<p className="text-red-500">{state.message}</p>
{shareableVolumes.length > 0 && <SubmitButton>Mount Shared Volume</SubmitButton>}
</div>
</form>
</Form >
</DialogContent>
</Dialog>
</>
)
}
33 changes: 30 additions & 3 deletions src/app/project/app/[appId]/volumes/storage-edit-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { QuestionMarkCircledIcon } from "@radix-ui/react-icons"
import { AppExtendedModel } from "@/shared/model/app-extended.model"
import { NodeInfoModel } from "@/shared/model/node-info.model"
import CheckboxFormField from "@/components/custom/checkbox-form-field"

const accessModes = [
{ label: "ReadWriteOnce", value: "ReadWriteOnce" },
Expand All @@ -51,7 +52,7 @@ 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

export default function DialogEditDialog({ children, volume, app, nodesInfo }: {
export default function StorageEditDialog({ children, volume, app, nodesInfo }: {
children: React.ReactNode;
volume?: AppVolume;
app: AppExtendedModel;
Expand All @@ -60,16 +61,25 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: {

const [isOpen, setIsOpen] = useState<boolean>(false);


const form = useForm<AppVolumeEditModel>({
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 ?? undefined,
}
});

// 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<any, any>, payload: AppVolumeEditModel) =>
saveVolume(state, {
...payload,
Expand All @@ -93,9 +103,13 @@ 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 ?? undefined,
});
}, [volume]);

const values = form.watch();

return (
<>
<div onClick={() => setIsOpen(true)}>
Expand Down Expand Up @@ -142,6 +156,12 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: {
)}
/>

{volume && volume.size !== values.size && volume.shareWithOtherApps && <>
<p className="text-sm text-yellow-600">
When changing the size of a shared volume, ensure that all apps using this volume are shut down before deploying the changes.
</p>
</>}

<FormField
control={form.control}
name="accessMode"
Expand Down Expand Up @@ -305,6 +325,13 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: {
</FormItem>
)}
/>}
{canBeShared && (
<CheckboxFormField
form={form}
name="shareWithOtherApps"
label="Allow other apps to attach this volume"
/>
)}
<p className="text-red-500">{state.message}</p>
<SubmitButton>Save</SubmitButton>
</div>
Expand Down
Loading