feat: enhanced ui for shareable AppVolumes and update related components

This commit is contained in:
biersoeckli
2026-01-30 09:43:28 +00:00
parent 5054f4b525
commit bcd9e8a7b2
13 changed files with 331 additions and 203 deletions

View File

@@ -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;

View File

@@ -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<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) {
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<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>
</>
)
}

View File

@@ -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<boolean>(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<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 ?? 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<any, any>, 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 (
<>
<div onClick={() => setIsOpen(true)}>
@@ -166,86 +126,6 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: {
return formAction(data);
})()}>
<div className="space-y-4">
{!volume && (
<div className="flex items-center gap-2">
<Checkbox
id="use-existing-volume"
checked={useExistingVolume}
onCheckedChange={(checked) => setUseExistingVolume(!!checked)}
disabled={!hasShareableVolumes}
/>
<FormLabel htmlFor="use-existing-volume">Use existing shared volume</FormLabel>
</div>
)}
{!volume && !hasShareableVolumes && (
<p className="text-xs text-muted-foreground">
No shared volumes are available from other apps in this project.
</p>
)}
{!volume && useExistingVolume && (
<FormField
control={form.control}
name="sharedVolumeId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Shared Volume</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!field.value && "text-muted-foreground"
)}
>
{selectedSharedVolume
? `${selectedSharedVolume.app.name} · ${selectedSharedVolume.containerMountPath}`
: "Select a shared volume"}
<ChevronsUpDown className="opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="max-w-[320px] p-0">
<Command>
<CommandList>
<CommandGroup>
{shareableVolumes.map((shareableVolume) => (
<CommandItem
value={`${shareableVolume.app.name}-${shareableVolume.containerMountPath}`}
key={shareableVolume.id}
onSelect={() => {
form.setValue("sharedVolumeId", shareableVolume.id);
}}
>
<div className="flex flex-col gap-1">
<span>{shareableVolume.app.name}</span>
<span className="text-xs text-muted-foreground">{shareableVolume.containerMountPath} · {shareableVolume.size} MB</span>
</div>
<Check
className={cn(
"ml-auto",
shareableVolume.id === field.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
Select a ReadWriteMany volume shared by another app in this project.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="containerMountPath"
@@ -267,7 +147,7 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: {
<FormItem>
<FormLabel>Size in MB</FormLabel>
<FormControl>
<Input type="number" placeholder="ex. 20" {...field} disabled={useExistingVolume || !!volume?.sharedVolumeId} />
<Input type="number" placeholder="ex. 20" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -277,7 +157,7 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: {
<FormField
control={form.control}
name="accessMode"
disabled={!!volume || useExistingVolume || !!volume?.sharedVolumeId}
disabled={!!volume}
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="flex gap-2">
@@ -355,29 +235,6 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: {
</FormItem>
)}
/>
{!useExistingVolume && !volume?.sharedVolumeId && selectedAccessMode === 'ReadWriteMany' && (
<FormField
control={form.control}
name="shareWithOtherApps"
render={({ field }) => (
<FormItem className="space-y-2">
<div className="flex items-center gap-2">
<FormControl>
<Checkbox
checked={field.value ?? false}
onCheckedChange={(checked) => field.onChange(!!checked)}
/>
</FormControl>
<FormLabel>Share with other apps in project</FormLabel>
</div>
<FormDescription>
Allow other apps in this project to mount this volume at their own paths.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{nodesInfo.length === 1 &&
<FormField
control={form.control}
@@ -411,7 +268,7 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: {
"w-full justify-between",
!field.value && "text-muted-foreground"
)}
disabled={!!volume || useExistingVolume || !!volume?.sharedVolumeId}
disabled={!!volume}
>
{field.value
? storageClasses.find(
@@ -460,6 +317,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>

View File

@@ -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<AppVolumeWithCapacity[]>(app.appVolumes);
const [volumesWithStorage, setVolumesWithStorage] = React.useState<AppVolumeWithCapacity[]>(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 }: {
<TableHead>Storage Used</TableHead>
<TableHead>Storage Class</TableHead>
<TableHead>Access Mode</TableHead>
<TableHead>Shared</TableHead>
<TableHead className="w-[100px]">Action</TableHead>
</TableRow>
</TableHeader>
@@ -175,31 +179,65 @@ export default function StorageList({ app, readonly, nodesInfo }: {
</TableCell>
<TableCell className="font-medium capitalize">{volume.storageClassName?.replace('-', ' ')}</TableCell>
<TableCell className="font-medium">{volume.accessMode}</TableCell>
<TableCell className="font-medium">
{volume.shareWithOtherApps && (
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger>
<span className="px-2 py-1 rounded-lg text-xs font-semibold bg-green-100 text-green-800 inline-flex items-center gap-1">
<Share2 className="h-3 w-3" />
Shareable
</span>
</TooltipTrigger>
<TooltipContent>
<p>This volume can be mounted by other apps in this project</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{volume.sharedVolumeId && (
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger>
<span className="px-2 py-1 rounded-lg text-xs font-semibold bg-blue-100 text-blue-800 inline-flex items-center gap-1">
<Share2 className="h-3 w-3" />
Shared
</span>
</TooltipTrigger>
<TooltipContent>
<p>This volume is mounted from another app's volume</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</TableCell>
<TableCell className="font-medium flex gap-2">
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger>
<Button variant="ghost" onClick={() => asyncDownloadPvcData(volume.id)} disabled={isLoading}>
<Download />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download volume content</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{!readonly && <TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger>
<Button variant="ghost" onClick={() => openFileBrowserForVolumeAsync(volume.id)} disabled={isLoading}>
<Folder />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>View content of Volume</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>}
{!volume.sharedVolumeId && <>
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger>
<Button variant="ghost" onClick={() => asyncDownloadPvcData(volume.id)} disabled={isLoading}>
<Download />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download volume content</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{!readonly && <TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger>
<Button variant="ghost" onClick={() => openFileBrowserForVolumeAsync(volume.id)} disabled={isLoading}>
<Folder />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>View content of Volume</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>}
</>}
{/*<StorageRestoreDialog app={app} volume={volume}>
<TooltipProvider>
<Tooltip delayDuration={200}>
@@ -215,18 +253,31 @@ export default function StorageList({ app, readonly, nodesInfo }: {
</TooltipProvider>
</StorageRestoreDialog>*/}
{!readonly && <>
<DialogEditDialog app={app} volume={volume} nodesInfo={nodesInfo}>
{volume.sharedVolumeId ? (
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger>
<Button variant="ghost" disabled={isLoading}><EditIcon /></Button>
<Button variant="ghost" disabled={true}><EditIcon /></Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit volume settings</p>
<p>Shared volumes cannot be edited (size and storage class are inherited)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</DialogEditDialog>
) : (
<DialogEditDialog app={app} volume={volume} nodesInfo={nodesInfo}>
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger>
<Button variant="ghost" disabled={isLoading}><EditIcon /></Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit volume settings</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</DialogEditDialog>
)}
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger>
@@ -246,11 +297,14 @@ export default function StorageList({ app, readonly, nodesInfo }: {
</TableBody>
</Table>
</CardContent>
{!readonly && <CardFooter>
{!readonly && <CardFooter className="flex gap-2">
<DialogEditDialog app={app} nodesInfo={nodesInfo}>
<Button>Add Volume</Button>
</DialogEditDialog>
<SharedStorageEditDialog app={app}>
<Button variant="outline">Add Shared Volume</Button>
</SharedStorageEditDialog>
</CardFooter>}
</Card >
</>;
}
}

View File

@@ -93,7 +93,7 @@ export default function VolumeBackupEditDialog({
<DialogHeader>
<DialogTitle>Edit Backup Configuration</DialogTitle>
<DialogDescription>
Configure your custom volume for this container.
Configure the backup settings for this volume.
</DialogDescription>
</DialogHeader>
<Form {...form}>

View File

@@ -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({
<Play />
</Button>
<VolumeBackupEditDialog volumeBackup={volumeBackup}
s3Targets={s3Targets} volumes={app.appVolumes} app={app}>
s3Targets={s3Targets} volumes={ownVolumes as AppVolume[]} app={app}>
<Button disabled={isLoading} variant="ghost"><EditIcon /></Button>
</VolumeBackupEditDialog>
<Button disabled={isLoading} variant="ghost" onClick={() => asyncDeleteBackupVolume(volumeBackup.id)}>
@@ -105,7 +109,7 @@ export default function VolumeBackupList({
</Table>
</CardContent>
{!readonly && <CardFooter>
<VolumeBackupEditDialog s3Targets={s3Targets} volumes={app.appVolumes} app={app}>
<VolumeBackupEditDialog s3Targets={s3Targets} volumes={ownVolumes as AppVolume[]} app={app}>
<Button>Add Backup Schedule</Button>
</VolumeBackupEditDialog>
</CardFooter>}

View File

@@ -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 });
}

View File

@@ -18,6 +18,8 @@ export const AppVolumeModel = z.object({
export interface CompleteAppVolume extends z.infer<typeof AppVolumeModel> {
app: CompleteApp
volumeBackups: CompleteVolumeBackup[]
sharedVolume?: CompleteAppVolume | null
sharedVolumes: CompleteAppVolume[]
}
/**
@@ -28,4 +30,6 @@ export interface CompleteAppVolume extends z.infer<typeof AppVolumeModel> {
export const RelatedAppVolumeModel: z.ZodSchema<CompleteAppVolume> = z.lazy(() => AppVolumeModel.extend({
app: RelatedAppModel,
volumeBackups: RelatedVolumeBackupModel.array(),
sharedVolume: RelatedAppVolumeModel.nullish(),
sharedVolumes: RelatedAppVolumeModel.array(),
}))

View File

@@ -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',

View File

@@ -62,6 +62,7 @@ export const mariadbAppTemplate: AppTemplateModel = {
containerMountPath: '/var/lib/mysql',
accessMode: 'ReadWriteOnce',
storageClassName: 'longhorn',
shareWithOtherApps: false,
}],
appFileMounts: [],
appPorts: [{

View File

@@ -55,6 +55,7 @@ export const mongodbAppTemplate: AppTemplateModel = {
containerMountPath: '/data/db',
accessMode: 'ReadWriteOnce',
storageClassName: 'longhorn',
shareWithOtherApps: false,
}],
appFileMounts: [],
appPorts: [{

View File

@@ -62,6 +62,7 @@ export const mysqlAppTemplate: AppTemplateModel = {
containerMountPath: '/var/lib/mysql',
accessMode: 'ReadWriteOnce',
storageClassName: 'longhorn',
shareWithOtherApps: false,
}],
appFileMounts: [],
appPorts: [{

View File

@@ -56,6 +56,7 @@ export const postgreAppTemplate: AppTemplateModel = {
containerMountPath: '/var/lib/qs-postgres',
accessMode: 'ReadWriteOnce',
storageClassName: 'longhorn',
shareWithOtherApps: false,
}],
appFileMounts: [],
appPorts: [{