refactor: unify backend and frontend servers (#3)

* refactor: unify backend and frontend servers

* refactor: correct paths for openapi & drizzle

* refactor: move api-client to client

* fix: drizzle paths

* chore: fix linting issues

* fix: form reset issue
This commit is contained in:
Nico
2025-11-13 20:11:46 +01:00
committed by GitHub
parent 8d7e50508d
commit 95a0d44b45
240 changed files with 5171 additions and 5875 deletions

View File

@@ -0,0 +1,90 @@
import { useMutation } from "@tanstack/react-query";
import { formatDistanceToNow } from "date-fns";
import { HeartIcon } from "lucide-react";
import { toast } from "sonner";
import { healthCheckVolumeMutation, updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { OnOff } from "~/client/components/onoff";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import type { Volume } from "~/client/lib/types";
type Props = {
volume: Volume;
};
export const HealthchecksCard = ({ volume }: Props) => {
const timeAgo = formatDistanceToNow(volume.lastHealthCheck, {
addSuffix: true,
});
const healthcheck = useMutation({
...healthCheckVolumeMutation(),
onSuccess: (d) => {
if (d.error) {
toast.error("Health check failed", { description: d.error });
return;
}
toast.success("Health check completed", { description: "The volume is healthy." });
},
onError: (error) => {
toast.error("Health check failed", { description: error.message });
},
});
const toggleAutoRemount = useMutation({
...updateVolumeMutation(),
onSuccess: (d) => {
toast.success("Volume updated", {
description: `Auto remount is now ${d.autoRemount ? "enabled" : "paused"}.`,
});
},
onError: (error) => {
toast.error("Update failed", { description: error.message });
},
});
return (
<Card className="flex-1 flex flex-col h-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<HeartIcon className="h-4 w-4" />
Health Checks
</CardTitle>
<CardDescription>Monitor and automatically remount volumes on errors to ensure availability.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col flex-1 justify-start">
{volume.lastError && <span className="text-sm text-red-500 wrap-break-word">{volume.lastError}</span>}
{volume.status === "mounted" && <span className="text-md text-emerald-500">Healthy</span>}
{volume.status !== "unmounted" && (
<span className="text-xs text-muted-foreground mb-4">Checked {timeAgo || "never"}</span>
)}
<span className="flex justify-between items-center gap-2">
<span className="text-sm">Remount on error</span>
<OnOff
isOn={volume.autoRemount}
toggle={() =>
toggleAutoRemount.mutate({ path: { name: volume.name }, body: { autoRemount: !volume.autoRemount } })
}
disabled={toggleAutoRemount.isPending}
enabledLabel="Enabled"
disabledLabel="Paused"
/>
</span>
</div>
{volume.status !== "unmounted" && (
<div className="flex justify-center">
<Button
variant="outline"
className="mt-4"
loading={healthcheck.isPending}
onClick={() => healthcheck.mutate({ path: { name: volume.name } })}
>
Run Health Check
</Button>
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,133 @@
"use client";
import { HardDrive, Unplug } from "lucide-react";
import * as React from "react";
import { Label, Pie, PieChart } from "recharts";
import { ByteSize } from "~/client/components/bytes-size";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "~/client/components/ui/chart";
import type { StatFs } from "~/client/lib/types";
type Props = {
statfs: StatFs;
};
export function StorageChart({ statfs }: Props) {
const chartData = React.useMemo(
() => [
{
name: "Used",
value: statfs.used,
fill: "#ff543a",
},
{
name: "Free",
value: statfs.free,
fill: "lightgray",
},
],
[statfs],
);
const chartConfig = {} satisfies ChartConfig;
const usagePercentage = React.useMemo(() => {
return Math.round((statfs.used / statfs.total) * 100);
}, [statfs]);
const isEmpty = !statfs.total;
if (isEmpty) {
return (
<Card className="flex flex-col h-full text-sm">
<CardHeader className="items-center pb-0">
<CardTitle className="flex items-center gap-2">
<HardDrive className="h-4 w-4" />
Storage Usage
</CardTitle>
</CardHeader>
<CardContent className="flex-1 pb-10 flex flex-col items-center justify-center text-center">
<Unplug className="mb-4 h-5 w-5 text-muted-foreground" />
<p className="text-muted-foreground">No storage data available. Mount the volume to see usage statistics.</p>
</CardContent>
</Card>
);
}
return (
<Card className="flex flex-col h-full text-sm">
<CardHeader className="items-center pb-0">
<CardTitle className="flex items-center gap-2">
<HardDrive className="h-4 w-4" />
Storage Usage
</CardTitle>
</CardHeader>
<CardContent className="flex-1 pb-0">
<div>
<ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[250px]">
<PieChart>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
hideLabel
formatter={(value, name) => [<ByteSize key={name} bytes={value as number} />, name]}
/>
}
/>
<Pie data={chartData} dataKey="value" nameKey="name" innerRadius={60} strokeWidth={5}>
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
<tspan x={viewBox.cx} y={viewBox.cy} className="fill-foreground text-3xl font-bold">
{usagePercentage}%
</tspan>
<tspan x={viewBox.cx} y={(viewBox.cy || 0) + 24} className="fill-muted-foreground">
Used
</tspan>
</text>
);
}
}}
/>
</Pie>
</PieChart>
</ChartContainer>
<div className="flex flex-col h-full justify-center">
<div className="grid gap-4 w-full">
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<div className="flex items-center gap-3">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Total Capacity</span>
</div>
<ByteSize bytes={statfs.total} className="font-mono text-sm" />
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-strong-accent/10">
<div className="flex items-center gap-3">
<div className="h-4 w-4 rounded-full bg-strong-accent" />
<span className="font-medium">Used Space</span>
</div>
<div className="text-right">
<ByteSize bytes={statfs.used} className="font-mono text-sm" />
</div>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-primary/10">
<div className="flex items-center gap-3">
<div className="h-4 w-4 rounded-full bg-primary" />
<span className="font-medium">Free Space</span>
</div>
<div className="text-right">
<ByteSize bytes={statfs.free} className="font-mono text-sm" />
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,197 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate, useParams, useSearchParams } from "react-router";
import { toast } from "sonner";
import { useState } from "react";
import { StatusDot } from "~/client/components/status-dot";
import { Button } from "~/client/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import { VolumeIcon } from "~/client/components/volume-icon";
import { parseError } from "~/client/lib/errors";
import { cn } from "~/client/lib/utils";
import type { Route } from "./+types/volume-details";
import { VolumeInfoTabContent } from "../tabs/info";
import { FilesTabContent } from "../tabs/files";
import { DockerTabContent } from "../tabs/docker";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info";
import { getVolume } from "~/client/api-client";
import {
deleteVolumeMutation,
getVolumeOptions,
mountVolumeMutation,
unmountVolumeMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
export function meta({ params }: Route.MetaArgs) {
return [
{ title: params.name },
{
name: "description",
content: "View and manage volume details, configuration, and files.",
},
];
}
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const volume = await getVolume({ path: { name: params.name } });
if (volume.data) return volume.data;
};
export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
const { name } = useParams<{ name: string }>();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get("tab") || "info";
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const { data } = useQuery({
...getVolumeOptions({ path: { name: name ?? "" } }),
initialData: loaderData,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
const { capabilities } = useSystemInfo();
const deleteVol = useMutation({
...deleteVolumeMutation(),
onSuccess: () => {
toast.success("Volume deleted successfully");
navigate("/volumes");
},
onError: (error) => {
toast.error("Failed to delete volume", {
description: parseError(error)?.message,
});
},
});
const mountVol = useMutation({
...mountVolumeMutation(),
onSuccess: () => {
toast.success("Volume mounted successfully");
},
onError: (error) => {
toast.error("Failed to mount volume", {
description: parseError(error)?.message,
});
},
});
const unmountVol = useMutation({
...unmountVolumeMutation(),
onSuccess: () => {
toast.success("Volume unmounted successfully");
},
onError: (error) => {
toast.error("Failed to unmount volume", {
description: parseError(error)?.message,
});
},
});
const handleConfirmDelete = () => {
setShowDeleteConfirm(false);
deleteVol.mutate({ path: { name: name ?? "" } });
};
if (!name) {
return <div>Volume not found</div>;
}
if (!data) {
return <div>Loading...</div>;
}
const { volume, statfs } = data;
const dockerAvailable = capabilities.docker;
return (
<>
<div className="flex flex-col items-start xs:items-center xs:flex-row xs:justify-between">
<div className="text-sm font-semibold mb-2 xs:mb-0 text-muted-foreground flex items-center gap-2">
<span className="flex items-center gap-2">
<StatusDot status={volume.status} /> {volume.status[0].toUpperCase() + volume.status.slice(1)}
</span>
<VolumeIcon size={14} backend={volume?.config.backend} />
</div>
<div className="flex gap-4">
<Button
onClick={() => mountVol.mutate({ path: { name } })}
loading={mountVol.isPending}
className={cn({ hidden: volume.status === "mounted" })}
>
Mount
</Button>
<Button
variant="secondary"
onClick={() => unmountVol.mutate({ path: { name } })}
loading={unmountVol.isPending}
className={cn({ hidden: volume.status !== "mounted" })}
>
Unmount
</Button>
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteVol.isPending}>
Delete
</Button>
</div>
</div>
<Tabs value={activeTab} onValueChange={(value) => setSearchParams({ tab: value })} className="mt-4">
<TabsList className="mb-2">
<TabsTrigger value="info">Configuration</TabsTrigger>
<TabsTrigger value="files">Files</TabsTrigger>
<Tooltip>
<TooltipTrigger>
<TabsTrigger disabled={!dockerAvailable} value="docker">
Docker
</TabsTrigger>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: dockerAvailable })}>
<p>Enable Docker support to access this tab.</p>
</TooltipContent>
</Tooltip>
</TabsList>
<TabsContent value="info">
<VolumeInfoTabContent volume={volume} statfs={statfs} />
</TabsContent>
<TabsContent value="files">
<FilesTabContent volume={volume} />
</TabsContent>
{dockerAvailable && (
<TabsContent value="docker">
<DockerTabContent volume={volume} />
</TabsContent>
)}
</Tabs>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete volume?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the volume <strong>{name}</strong>? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-3 justify-end">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete volume
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,170 @@
import { useQuery } from "@tanstack/react-query";
import { HardDrive, RotateCcw } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { CreateVolumeDialog } from "~/client/components/create-volume-dialog";
import { EmptyState } from "~/client/components/empty-state";
import { StatusDot } from "~/client/components/status-dot";
import { Button } from "~/client/components/ui/button";
import { Card } from "~/client/components/ui/card";
import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { VolumeIcon } from "~/client/components/volume-icon";
import type { Route } from "./+types/volumes";
import { listVolumes } from "~/client/api-client";
import { listVolumesOptions } from "~/client/api-client/@tanstack/react-query.gen";
export function meta(_: Route.MetaArgs) {
return [
{ title: "Volumes" },
{
name: "description",
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
},
];
}
export const clientLoader = async () => {
const volumes = await listVolumes();
if (volumes.data) return volumes.data;
return [];
};
export default function Volumes({ loaderData }: Route.ComponentProps) {
const [createVolumeOpen, setCreateVolumeOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [backendFilter, setBackendFilter] = useState("");
const clearFilters = () => {
setSearchQuery("");
setStatusFilter("");
setBackendFilter("");
};
const navigate = useNavigate();
const { data } = useQuery({
...listVolumesOptions(),
initialData: loaderData,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
const filteredVolumes =
data.filter((volume) => {
const matchesSearch = volume.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = !statusFilter || volume.status === statusFilter;
const matchesBackend = !backendFilter || volume.type === backendFilter;
return matchesSearch && matchesStatus && matchesBackend;
}) || [];
const hasNoVolumes = data.length === 0;
const hasNoFilteredVolumes = filteredVolumes.length === 0 && !hasNoVolumes;
if (hasNoVolumes) {
return (
<EmptyState
icon={HardDrive}
title="No volume"
description="Manage and monitor all your storage backends in one place with advanced features like automatic mounting and health checks."
button={<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />}
/>
);
}
return (
<Card className="p-0 gap-0">
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
<span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap ">
<Input
className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px"
placeholder="Search volumes…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px">
<SelectValue placeholder="All status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="mounted">Mounted</SelectItem>
<SelectItem value="unmounted">Unmounted</SelectItem>
<SelectItem value="error">Error</SelectItem>
</SelectContent>
</Select>
<Select value={backendFilter} onValueChange={setBackendFilter}>
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mt-px">
<SelectValue placeholder="All backends" />
</SelectTrigger>
<SelectContent>
<SelectItem value="directory">Directory</SelectItem>
<SelectItem value="nfs">NFS</SelectItem>
<SelectItem value="smb">SMB</SelectItem>
</SelectContent>
</Select>
{(searchQuery || statusFilter || backendFilter) && (
<Button onClick={clearFilters} className="w-full lg:w-auto mt-2 lg:mt-0 lg:ml-2">
<RotateCcw className="h-4 w-4 mr-2" />
Clear filters
</Button>
)}
</span>
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
</div>
<div className="overflow-x-auto">
<Table className="border-t">
<TableHeader className="bg-card-header">
<TableRow>
<TableHead className="w-[100px] uppercase">Name</TableHead>
<TableHead className="uppercase text-left">Backend</TableHead>
<TableHead className="uppercase text-center">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{hasNoFilteredVolumes ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-12">
<div className="flex flex-col items-center gap-3">
<p className="text-muted-foreground">No volumes match your filters.</p>
<Button onClick={clearFilters} variant="outline" size="sm">
<RotateCcw className="h-4 w-4 mr-2" />
Clear filters
</Button>
</div>
</TableCell>
</TableRow>
) : (
filteredVolumes.map((volume) => (
<TableRow
key={volume.name}
className="hover:bg-accent/50 hover:cursor-pointer"
onClick={() => navigate(`/volumes/${volume.name}`)}
>
<TableCell className="font-medium text-strong-accent">{volume.name}</TableCell>
<TableCell>
<VolumeIcon backend={volume.type} />
</TableCell>
<TableCell className="text-center">
<StatusDot status={volume.status} />
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-end border-t">
{hasNoFilteredVolumes ? (
"No volumes match filters."
) : (
<span>
<span className="text-strong-accent">{filteredVolumes.length}</span> volume
{filteredVolumes.length > 1 ? "s" : ""}
</span>
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,133 @@
import { useQuery } from "@tanstack/react-query";
import { Unplug } from "lucide-react";
import * as YML from "yaml";
import { getContainersUsingVolumeOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { CodeBlock } from "~/client/components/ui/code-block";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import type { Volume } from "~/client/lib/types";
type Props = {
volume: Volume;
};
export const DockerTabContent = ({ volume }: Props) => {
const yamlString = YML.stringify({
services: {
nginx: {
image: "nginx:latest",
volumes: [`im-${volume.name}:/path/in/container`],
},
},
volumes: {
[`im-${volume.name}`]: {
external: true,
},
},
});
const dockerRunCommand = `docker run -v im-${volume.name}:/path/in/container nginx:latest`;
const {
data: containersData,
isLoading,
error,
} = useQuery({
...getContainersUsingVolumeOptions({ path: { name: volume.name } }),
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
const containers = containersData || [];
const getStateClass = (state: string) => {
switch (state) {
case "running":
return "bg-green-100 text-green-800";
case "exited":
return "bg-orange-100 text-orange-800";
default:
return "bg-gray-100 text-gray-800";
}
};
return (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<Card>
<CardHeader>
<CardTitle>Plug-and-play Docker integration</CardTitle>
<CardDescription>
This volume can be used in your Docker Compose files by referencing it as an external volume. The example
demonstrates how to mount the volume to a service (nginx in this case). Make sure to adjust the path inside
the container to fit your application's needs
</CardDescription>
</CardHeader>
<CardContent>
<div className="relative space-y-6">
<div className="space-y-4">
<div className="flex flex-col gap-4">
<CodeBlock code={yamlString} language="yaml" filename="docker-compose.yml" />
</div>
<div className="text-sm text-muted-foreground">
Alternatively, you can use the following command to run a Docker container with the volume mounted
</div>
<div className="flex flex-col gap-4">
<CodeBlock code={dockerRunCommand} filename="CLI one-liner" />
</div>
</div>
</div>
</CardContent>
</Card>
<div className="grid">
<Card>
<CardHeader>
<CardTitle>Containers Using This Volume</CardTitle>
<CardDescription>List of Docker containers mounting this volume.</CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm h-full">
{isLoading && <div>Loading containers...</div>}
{error && <div className="text-destructive">Failed to load containers: {String(error)}</div>}
{!isLoading && !error && containers.length === 0 && (
<div className="flex flex-col items-center justify-center text-center h-full">
<Unplug className="mb-4 h-5 w-5 text-muted-foreground" />
<p className="text-muted-foreground">No Docker containers are currently using this volume.</p>
</div>
)}
{!isLoading && !error && containers.length > 0 && (
<div className="max-h-130 overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>ID</TableHead>
<TableHead>State</TableHead>
<TableHead>Image</TableHead>
</TableRow>
</TableHeader>
<TableBody className="text-sm">
{containers.map((container) => (
<TableRow key={container.id}>
<TableCell>{container.name}</TableCell>
<TableCell>{container.id.slice(0, 12)}</TableCell>
<TableCell>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${getStateClass(container.state)}`}
>
{container.state}
</span>
</TableCell>
<TableCell>{container.image}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
};

View File

@@ -0,0 +1,40 @@
import { FolderOpen } from "lucide-react";
import { VolumeFileBrowser } from "~/client/components/volume-file-browser";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import type { Volume } from "~/client/lib/types";
type Props = {
volume: Volume;
};
export const FilesTabContent = ({ volume }: Props) => {
if (volume.status !== "mounted") {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center text-center py-12">
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
<p className="text-muted-foreground">Volume must be mounted to browse files.</p>
<p className="text-sm text-muted-foreground mt-2">Mount the volume to explore its contents.</p>
</CardContent>
</Card>
);
}
return (
<Card className="h-[600px] flex flex-col">
<CardHeader>
<CardTitle>File Explorer</CardTitle>
<CardDescription>Browse the files and folders in this volume.</CardDescription>
</CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col">
<VolumeFileBrowser
volumeName={volume.name}
enabled={volume.status === "mounted"}
className="overflow-auto flex-1 border rounded-md bg-card p-2"
emptyMessage="This volume is empty."
emptyDescription="Files and folders will appear here once you add them."
/>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,95 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { CreateVolumeForm, type FormValues } from "~/client/components/create-volume-form";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import { Card } from "~/client/components/ui/card";
import type { StatFs, Volume } from "~/client/lib/types";
import { HealthchecksCard } from "../components/healthchecks-card";
import { StorageChart } from "../components/storage-chart";
import { updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
type Props = {
volume: Volume;
statfs: StatFs;
};
export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
const updateMutation = useMutation({
...updateVolumeMutation(),
onSuccess: (_) => {
toast.success("Volume updated successfully");
setOpen(false);
setPendingValues(null);
},
onError: (error) => {
toast.error("Failed to update volume", { description: error.message });
setOpen(false);
setPendingValues(null);
},
});
const [open, setOpen] = useState(false);
const [pendingValues, setPendingValues] = useState<FormValues | null>(null);
const handleSubmit = (values: FormValues) => {
setPendingValues(values);
setOpen(true);
};
const confirmUpdate = () => {
if (pendingValues) {
updateMutation.mutate({
path: { name: volume.name },
body: { config: pendingValues },
});
}
};
return (
<>
<div className="grid gap-4 xl:grid-cols-[minmax(0,2.3fr)_minmax(320px,1fr)]">
<Card className="p-6">
<CreateVolumeForm
initialValues={{ ...volume, ...volume.config }}
onSubmit={handleSubmit}
mode="update"
loading={updateMutation.isPending}
/>
</Card>
<div className="flex flex-col gap-4">
<div className="self-start w-full">
<HealthchecksCard volume={volume} />
</div>
<div className="flex-1 w-full">
<StorageChart statfs={statfs} />
</div>
</div>
</div>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Update Volume Configuration</AlertDialogTitle>
<AlertDialogDescription>
Editing the volume will remount it with the new config immediately. This may temporarily disrupt access to
the volume. Continue?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmUpdate}>Update</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};