mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-02-09 21:19:07 -06:00
feat: enhanced ui for shareable AppVolumes and update related components
This commit is contained in:
23
prisma/migrations/20260130080723_migration/migration.sql
Normal file
23
prisma/migrations/20260130080723_migration/migration.sql
Normal 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;
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 >
|
||||
</>;
|
||||
}
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}))
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -62,6 +62,7 @@ export const mariadbAppTemplate: AppTemplateModel = {
|
||||
containerMountPath: '/var/lib/mysql',
|
||||
accessMode: 'ReadWriteOnce',
|
||||
storageClassName: 'longhorn',
|
||||
shareWithOtherApps: false,
|
||||
}],
|
||||
appFileMounts: [],
|
||||
appPorts: [{
|
||||
|
||||
@@ -55,6 +55,7 @@ export const mongodbAppTemplate: AppTemplateModel = {
|
||||
containerMountPath: '/data/db',
|
||||
accessMode: 'ReadWriteOnce',
|
||||
storageClassName: 'longhorn',
|
||||
shareWithOtherApps: false,
|
||||
}],
|
||||
appFileMounts: [],
|
||||
appPorts: [{
|
||||
|
||||
@@ -62,6 +62,7 @@ export const mysqlAppTemplate: AppTemplateModel = {
|
||||
containerMountPath: '/var/lib/mysql',
|
||||
accessMode: 'ReadWriteOnce',
|
||||
storageClassName: 'longhorn',
|
||||
shareWithOtherApps: false,
|
||||
}],
|
||||
appFileMounts: [],
|
||||
appPorts: [{
|
||||
|
||||
@@ -56,6 +56,7 @@ export const postgreAppTemplate: AppTemplateModel = {
|
||||
containerMountPath: '/var/lib/qs-postgres',
|
||||
accessMode: 'ReadWriteOnce',
|
||||
storageClassName: 'longhorn',
|
||||
shareWithOtherApps: false,
|
||||
}],
|
||||
appFileMounts: [],
|
||||
appPorts: [{
|
||||
|
||||
Reference in New Issue
Block a user