style: redesign volume details page to match repository

This commit is contained in:
Nicolas Meienberger 2026-04-02 18:35:27 +02:00 committed by Nico
parent 475bfb59ae
commit ae8277ab23
5 changed files with 805 additions and 572 deletions

View file

@ -60,7 +60,6 @@ export function CompressionStatsChart({ repositoryShortId, initialStats }: Props
const rawCompressionProgress = toSafeNumber(stats?.compression_progress);
const compressionProgressPercent = Math.min(100, Math.max(0, rawCompressionProgress));
const spaceSavingPercent = toSafeNumber(stats?.compression_space_saving);
const snapshotsCount = Math.round(toSafeNumber(stats?.snapshots_count));
const hasStats = !!stats && (storedSize > 0 || uncompressedSize > 0 || snapshotsCount > 0);
@ -94,61 +93,53 @@ export function CompressionStatsChart({ repositoryShortId, initialStats }: Props
Stats will be populated after your first backup. You can also refresh them manually.
</p>
<div className={cn({ hidden: isPending || !!error || !hasStats })}>
<div className="space-y-4 mb-6">
<div>
<div className="flex items-center justify-between text-sm mb-1.5">
<span className="text-muted-foreground">Stored</span>
<ByteSize base={1024} bytes={storedSize} className="font-mono font-semibold text-sm" />
<div className="mb-6">
<div className="flex items-center justify-between text-sm mb-3">
<span className="text-muted-foreground">
<ByteSize base={1024} bytes={uncompressedSize} className="font-mono font-semibold text-foreground" /> of
data across <span className="font-mono font-semibold text-foreground">{snapshotsCount}</span> snapshots
</span>
</div>
<div className="h-9 rounded overflow-hidden flex">
<div
className="h-full bg-strong-accent/80 flex items-center px-3 text-xs font-medium text-white transition-all"
style={{ width: `${storedPercent}%` }}
>
On disk
</div>
<div className="h-3 bg-muted/50 rounded-full overflow-hidden">
<div
className="h-full bg-strong-accent/80 rounded-full transition-all"
style={{ width: `${storedPercent}%` }}
/>
<div
className={cn(
"h-full bg-muted-foreground/10 flex items-center px-3 text-xs font-medium text-muted-foreground transition-all border-l border-border/30",
)}
style={{ width: `${100 - storedPercent}%` }}
>
<span className={cn({ hidden: storedPercent >= 80 })}>Freed by compression</span>
</div>
</div>
<div>
<div className="flex items-center justify-between text-sm mb-1.5">
<span className="text-muted-foreground">Uncompressed</span>
<ByteSize base={1024} bytes={uncompressedSize} className="font-mono font-semibold text-sm" />
</div>
<div className="h-3 bg-muted/50 rounded-full overflow-hidden">
<div className="h-full bg-muted-foreground/30 rounded-full" style={{ width: "100%" }} />
</div>
<div className="flex items-center justify-between mt-2 text-sm">
<ByteSize base={1024} bytes={storedSize} className="font-mono font-semibold" />
<span className="font-mono text-muted-foreground">
<ByteSize base={1024} bytes={savedSize} /> freed
</span>
</div>
</div>
<Separator className="mb-4" />
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Space Saved</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold text-foreground font-mono">{spaceSavingPercent.toFixed(1)}%</span>
<ByteSize base={1024} bytes={savedSize} className="text-xs text-muted-foreground font-mono" />
</div>
<div className="flex items-center gap-6 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Ratio</span>
<span className="font-mono font-semibold">
{compressionRatio > 0 ? `${compressionRatio.toFixed(2)}x` : "-"}
</span>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Ratio</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold text-foreground font-mono">
{compressionRatio > 0 ? `${compressionRatio.toFixed(2)}x` : "-"}
</span>
</div>
<Separator orientation="vertical" className="h-4" />
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Snapshots</span>
<span className="font-mono font-semibold">{snapshotsCount.toLocaleString(locale)}</span>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Snapshots</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold text-foreground font-mono">
{snapshotsCount.toLocaleString(locale)}
</span>
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Compressed</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold text-foreground font-mono">
{compressionProgressPercent.toFixed(1)}%
</span>
</div>
<Separator orientation="vertical" className="h-4" />
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Compressed</span>
<span className="font-mono font-semibold">{compressionProgressPercent.toFixed(0)}%</span>
</div>
</div>
</div>

View file

@ -1,12 +1,43 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { Suspense } from "react";
import { getRepositoryOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
import { RepositoryInfoTabContent } from "../tabs/info";
import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { Suspense, useState } from "react";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { toast } from "sonner";
import { ChevronDown, Database, Pencil, Square, Stethoscope, Trash2, Unlock } from "lucide-react";
import {
cancelDoctorMutation,
deleteRepositoryMutation,
getRepositoryOptions,
startDoctorMutation,
unlockRepositoryMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import { Badge } from "~/client/components/ui/badge";
import { Button } from "~/client/components/ui/button";
import { Card } from "~/client/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/client/components/ui/dropdown-menu";
import { Separator } from "~/client/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
import { formatDateTime, formatTimeAgo } from "~/client/lib/datetime";
import { parseError } from "~/client/lib/errors";
import { cn } from "~/client/lib/utils";
import type { BackupSchedule, Snapshot } from "~/client/lib/types";
import type { GetRepositoryStatsResponse } from "~/client/api-client/types.gen";
import { RepositoryInfoTabContent } from "../tabs/info";
import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
export default function RepositoryDetailsPage({
repositoryId,
@ -23,30 +54,209 @@ export default function RepositoryDetailsPage({
const { tab } = useSearch({ from: "/(dashboard)/repositories/$repositoryId/" });
const activeTab = tab || "info";
const { data } = useSuspenseQuery({
const { data: repository } = useSuspenseQuery({
...getRepositoryOptions({ path: { shortId: repositoryId } }),
});
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const deleteRepo = useMutation({
...deleteRepositoryMutation(),
onSuccess: () => {
toast.success("Repository deleted successfully");
void navigate({ to: "/repositories" });
},
onError: (error) => {
toast.error("Failed to delete repository", {
description: parseError(error)?.message,
});
},
});
const startDoctor = useMutation({
...startDoctorMutation(),
onError: (error) => {
toast.error("Failed to start doctor", {
description: parseError(error)?.message,
});
},
});
const cancelDoctor = useMutation({
...cancelDoctorMutation(),
onSuccess: () => {
toast.info("Doctor operation cancelled");
},
onError: (error) => {
toast.error("Failed to cancel doctor", {
description: parseError(error)?.message,
});
},
});
const unlockRepo = useMutation({
...unlockRepositoryMutation(),
});
const handleConfirmDelete = () => {
setShowDeleteConfirm(false);
deleteRepo.mutate({ path: { shortId: repository.shortId } });
};
const isDoctorRunning = repository.status === "doctor";
return (
<>
<Tabs value={activeTab} onValueChange={(value) => navigate({ to: ".", search: (s) => ({ ...s, tab: value }) })}>
<TabsList className="mb-2">
<TabsTrigger value="info">Configuration</TabsTrigger>
<TabsTrigger value="snapshots">Snapshots</TabsTrigger>
</TabsList>
<TabsContent value="info">
<RepositoryInfoTabContent repository={data} initialStats={initialStats} />
</TabsContent>
<TabsContent value="snapshots">
<Suspense>
<RepositorySnapshotsTabContent
repository={data}
initialSnapshots={initialSnapshots}
initialBackupSchedules={initialBackupSchedules}
/>
</Suspense>
</TabsContent>
</Tabs>
<div className="flex flex-col gap-6 @container">
<Card className="px-6 py-5">
<div className="flex flex-col @wide:flex-row @wide:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="hidden @medium:flex items-center justify-center w-10 h-10 rounded-lg bg-muted/50 border border-border/50">
<Database className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold tracking-tight">{repository.name}</h2>
<Separator orientation="vertical" className="h-4 mx-1" />
<Badge variant="outline" className="capitalize gap-1.5">
<span
className={cn("w-2 h-2 rounded-full shrink-0", {
"bg-success": repository.status === "healthy",
"bg-red-500": repository.status === "error",
"bg-amber-500": repository.status !== "healthy" && repository.status !== "error",
"animate-pulse": repository.status === "doctor",
})}
/>
{repository.status || "Unknown"}
</Badge>
<Badge variant="secondary">{repository.type}</Badge>
{repository.provisioningId && <Badge variant="secondary">Managed</Badge>}
</div>
<p className="text-sm text-muted-foreground mt-0.5">
Created {formatDateTime(repository.createdAt)} &middot; Last checked{" "}
{formatTimeAgo(repository.lastChecked)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{isDoctorRunning ? (
<Button
type="button"
variant="destructive"
loading={cancelDoctor.isPending}
onClick={() => cancelDoctor.mutate({ path: { shortId: repository.shortId } })}
>
<Square className="h-4 w-4 mr-2" />
Cancel doctor
</Button>
) : (
<Button
type="button"
variant="outline"
onClick={() => startDoctor.mutate({ path: { shortId: repository.shortId } })}
disabled={startDoctor.isPending}
>
<Stethoscope className="h-4 w-4 mr-2" />
Run doctor
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
Actions
<ChevronDown className="h-4 w-4 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate({ to: `/repositories/${repository.shortId}/edit` })}>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
toast.promise(unlockRepo.mutateAsync({ path: { shortId: repository.shortId } }), {
loading: "Unlocking repo",
success: "Repository unlocked successfully",
error: (e) =>
toast.error("Failed to unlock repository", {
description: parseError(e)?.message,
}),
})
}
disabled={unlockRepo.isPending}
>
<Unlock />
Unlock
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
disabled={deleteRepo.isPending}
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Card>
{repository.lastError && (
<Card className="px-6 py-6">
<div className="space-y-2">
<p className="text-sm font-medium text-destructive">Last Error</p>
<p className="text-sm text-muted-foreground wrap-break-word">{repository.lastError}</p>
</div>
</Card>
)}
<Tabs value={activeTab} onValueChange={(value) => navigate({ to: ".", search: () => ({ tab: value }) })}>
<TabsList className="mb-2">
<TabsTrigger value="info">Configuration</TabsTrigger>
<TabsTrigger value="snapshots">Snapshots</TabsTrigger>
</TabsList>
<TabsContent value="info">
<RepositoryInfoTabContent repository={repository} initialStats={initialStats} />
</TabsContent>
<TabsContent value="snapshots">
<Suspense>
<RepositorySnapshotsTabContent
repository={repository}
initialSnapshots={initialSnapshots}
initialBackupSchedules={initialBackupSchedules}
/>
</Suspense>
</TabsContent>
</Tabs>
</div>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete repository?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the repository <strong>{repository.name}</strong>? This will not remove
the actual data from the backend storage, only the repository configuration will be deleted.
<br />
<br />
All backup schedules associated with this repository will also be removed.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-3 justify-end">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={deleteRepo.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete repository
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -1,56 +1,10 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import {
Archive,
ChevronDown,
Clock,
Database,
FolderOpen,
Globe,
HardDrive,
Lock,
Pencil,
Settings,
Shield,
Square,
Stethoscope,
Trash2,
Unlock,
} from "lucide-react";
import { Archive, Clock, FolderOpen, HardDrive, Lock, Settings, Shield } from "lucide-react";
import { Card, CardContent, CardTitle } from "~/client/components/ui/card";
import { Badge } from "~/client/components/ui/badge";
import { Button } from "~/client/components/ui/button";
import { Separator } from "~/client/components/ui/separator";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/client/components/ui/dropdown-menu";
import type { Repository } from "~/client/lib/types";
import type { GetRepositoryStatsResponse } from "~/client/api-client/types.gen";
import {
cancelDoctorMutation,
deleteRepositoryMutation,
startDoctorMutation,
unlockRepositoryMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { formatDateTime, formatTimeAgo } from "~/client/lib/datetime";
import type { RepositoryConfig } from "@zerobyte/core/restic";
import { useTimeFormat } from "~/client/lib/datetime";
import { DoctorReport } from "../components/doctor-report";
import { parseError } from "~/client/lib/errors";
import { useNavigate } from "@tanstack/react-router";
import { CompressionStatsChart } from "../components/compression-stats-chart";
import { cn } from "~/client/lib/utils";
@ -78,281 +32,93 @@ function ConfigRow({ icon, label, value, mono, valueClassName }: ConfigRowProps)
}
export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) => {
const { formatDateTime, formatTimeAgo } = useTimeFormat();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const navigate = useNavigate();
const effectiveLocalPath = getEffectiveLocalPath(repository);
const deleteRepo = useMutation({
...deleteRepositoryMutation(),
onSuccess: () => {
toast.success("Repository deleted successfully");
void navigate({ to: "/repositories" });
},
onError: (error) => {
toast.error("Failed to delete repository", {
description: parseError(error)?.message,
});
},
});
const startDoctor = useMutation({
...startDoctorMutation(),
onError: (error) => {
toast.error("Failed to start doctor", {
description: parseError(error)?.message,
});
},
});
const cancelDoctor = useMutation({
...cancelDoctorMutation(),
onSuccess: () => {
toast.info("Doctor operation cancelled");
},
onError: (error) => {
toast.error("Failed to cancel doctor", {
description: parseError(error)?.message,
});
},
});
const unlockRepo = useMutation({
...unlockRepositoryMutation(),
});
const handleConfirmDelete = () => {
setShowDeleteConfirm(false);
deleteRepo.mutate({ path: { shortId: repository.shortId } });
};
const config = repository.config as RepositoryConfig;
const isDoctorRunning = repository.status === "doctor";
const hasLocalPath = Boolean(effectiveLocalPath);
const hasCaCert = Boolean(config.cacert);
const hasLastError = Boolean(repository.lastError);
const hasInsecureTlsConfig = config.insecureTls !== undefined;
return (
<>
<div className="flex flex-col gap-6 @container">
<Card className="px-6 py-5">
<div className="flex flex-col @wide:flex-row @wide:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="hidden @medium:flex items-center justify-center w-10 h-10 rounded-lg bg-muted/50 border border-border/50">
<Database className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold tracking-tight">{repository.name}</h2>
<Separator orientation="vertical" className="h-4 mx-1" />
<Badge variant="outline" className="capitalize gap-1.5">
<span
className={cn("w-2 h-2 rounded-full shrink-0", {
"bg-success": repository.status === "healthy",
"bg-red-500": repository.status === "error",
"bg-amber-500": repository.status !== "healthy" && repository.status !== "error",
"animate-pulse": repository.status === "doctor",
})}
/>
{repository.status || "Unknown"}
</Badge>
<Badge variant="secondary">{repository.type}</Badge>
{repository.provisioningId && <Badge variant="secondary">Managed</Badge>}
</div>
<p className="text-sm text-muted-foreground mt-0.5">
Created {formatDateTime(repository.createdAt)} &middot; Last checked&nbsp;
{formatTimeAgo(repository.lastChecked)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{isDoctorRunning ? (
<Button
type="button"
variant="destructive"
loading={cancelDoctor.isPending}
onClick={() => cancelDoctor.mutate({ path: { shortId: repository.shortId } })}
>
<Square className="h-4 w-4 mr-2" />
Cancel doctor
</Button>
) : (
<Button
type="button"
variant="outline"
onClick={() => startDoctor.mutate({ path: { shortId: repository.shortId } })}
disabled={startDoctor.isPending}
>
<Stethoscope className="h-4 w-4 mr-2" />
Run doctor
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
Actions
<ChevronDown className="h-4 w-4 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate({ to: `/repositories/${repository.shortId}/edit` })}>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
toast.promise(unlockRepo.mutateAsync({ path: { shortId: repository.shortId } }), {
loading: "Unlocking repo",
success: "Repository unlocked successfully",
error: (e) => parseError(e)?.message || "Failed to unlock repository",
})
}
disabled={unlockRepo.isPending}
>
<Unlock />
Unlock
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
disabled={deleteRepo.isPending}
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Card>
{hasLastError && (
<Card className="px-6 py-6">
<div className="space-y-2">
<p className="text-sm font-medium text-destructive">Last Error</p>
<p className="text-sm text-muted-foreground wrap-break-word">{repository.lastError}</p>
</div>
</Card>
)}
<div className="grid grid-cols-1 @wide:grid-cols-2 gap-6">
<CompressionStatsChart repositoryShortId={repository.shortId} initialStats={initialStats} />
<Card className="px-6 py-6">
<CardTitle className="mb-4">Overview</CardTitle>
<CardContent className="grid grid-cols-2 gap-y-4 gap-x-6 px-0">
<div className="flex flex-col gap-1">
<div className="text-sm font-medium text-muted-foreground">Name</div>
<p className="text-sm">{repository.name}</p>
</div>
<div className="flex flex-col gap-1">
<div className="text-sm font-medium text-muted-foreground">Backend</div>
<p className="text-sm">{repository.type}</p>
</div>
<div className="flex flex-col gap-1">
<div className="text-sm font-medium text-muted-foreground">Management</div>
<p className="text-sm">{repository.provisioningId ? "Provisioned" : "Manual"}</p>
</div>
<div className="flex flex-col gap-1">
<div className="text-sm font-medium text-muted-foreground">Compression Mode</div>
<p className="text-sm">{repository.compressionMode || "off"}</p>
</div>
<div className="flex flex-col gap-1">
<div className="text-sm font-medium text-muted-foreground">Created</div>
<p className="text-sm">{formatDateTime(repository.createdAt)}</p>
</div>
<div className="flex flex-col gap-1">
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
<p className="text-sm flex items-center gap-1.5">
<Clock className="h-3 w-3 text-muted-foreground" />
{formatTimeAgo(repository.lastChecked)}
</p>
</div>
{hasLocalPath && (
<div className="flex flex-col gap-1 col-span-2">
<div className="text-sm font-medium text-muted-foreground">Local Path</div>
<p className="text-sm font-mono bg-muted/50 p-2 rounded-md break-all">{effectiveLocalPath}</p>
</div>
)}
</CardContent>
</Card>
</div>
<div className="flex flex-col gap-6 @container">
<div className="grid grid-cols-1 @wide:grid-cols-2 gap-6">
<CompressionStatsChart repositoryShortId={repository.shortId} initialStats={initialStats} />
<Card className="px-6 py-6">
<CardTitle className="flex items-center gap-2 mb-5">
<Settings className="h-4 w-4 text-muted-foreground" />
Configuration
</CardTitle>
<div className="space-y-0 divide-y divide-border/50">
<ConfigRow icon={<HardDrive className="h-4 w-4" />} label="Backend" value={repository.type} />
<CardTitle className="mb-4">Overview</CardTitle>
<CardContent className="grid grid-cols-2 gap-y-4 gap-x-6 px-0">
<div className="flex flex-col gap-1">
<div className="text-sm font-medium text-muted-foreground">Name</div>
<p className="text-sm">{repository.name}</p>
</div>
<div className="flex flex-col gap-1">
<div className="text-sm font-medium text-muted-foreground">Backend</div>
<p className="text-sm">{repository.type}</p>
</div>
<div className="flex flex-col gap-1">
<div className="text-sm font-medium text-muted-foreground">Management</div>
<p className="text-sm">{repository.provisioningId ? "Provisioned" : "Manual"}</p>
</div>
<div className="flex flex-col gap-1">
<div className="text-sm font-medium text-muted-foreground">Compression Mode</div>
<p className="text-sm">{repository.compressionMode || "off"}</p>
</div>
<div className="flex flex-col gap-1">
<div className="text-sm font-medium text-muted-foreground">Created</div>
<p className="text-sm">{formatDateTime(repository.createdAt)}</p>
</div>
<div className="flex flex-col gap-1">
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
<p className="text-sm flex items-center gap-1.5">
<Clock className="h-3 w-3 text-muted-foreground" />
{formatTimeAgo(repository.lastChecked)}
</p>
</div>
{hasLocalPath && (
<ConfigRow
icon={<FolderOpen className="h-4 w-4" />}
label="Local Path"
value={effectiveLocalPath!}
mono
/>
<div className="flex flex-col gap-1 col-span-2">
<div className="text-sm font-medium text-muted-foreground">Local Path</div>
<p className="text-sm font-mono bg-muted/50 p-2 rounded-md break-all">{effectiveLocalPath}</p>
</div>
)}
<ConfigRow
icon={<Archive className="h-4 w-4" />}
label="Compression Mode"
value={repository.compressionMode || "off"}
/>
<ConfigRow
icon={<Globe className="h-4 w-4" />}
label="Management"
value={repository.provisioningId ? "Provisioned" : "Manual"}
/>
{hasCaCert && (
<ConfigRow
icon={<Lock className="h-4 w-4" />}
label="CA Certificate"
value="Configured"
valueClassName="text-success"
/>
)}
{hasInsecureTlsConfig && (
<ConfigRow
icon={<Shield className="h-4 w-4" />}
label="TLS Validation"
value={config.insecureTls ? "Disabled" : "Enabled"}
valueClassName={config.insecureTls ? "text-red-500" : "text-success"}
/>
)}
</div>
</CardContent>
</Card>
<DoctorReport repositoryStatus={repository.status} result={repository.doctorResult} />
</div>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete repository?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the repository <strong>{repository.name}</strong>? This will not remove
the actual data from the backend storage, only the repository configuration will be deleted.
<br />
<br />
All backup schedules associated with this repository will also be removed.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-3 justify-end">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={deleteRepo.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete repository
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</>
<Card className="px-6 py-6">
<CardTitle className="flex items-center gap-2 mb-5">
<Settings className="h-4 w-4 text-muted-foreground" />
Configuration
</CardTitle>
<div className="space-y-0 divide-y divide-border/50">
<ConfigRow icon={<HardDrive className="h-4 w-4" />} label="Backend" value={repository.type} />
{hasLocalPath && (
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Local Path" value={effectiveLocalPath!} mono />
)}
<ConfigRow
icon={<Archive className="h-4 w-4" />}
label="Compression Mode"
value={repository.compressionMode || "off"}
/>
{hasCaCert && (
<ConfigRow
icon={<Lock className="h-4 w-4" />}
label="CA Certificate"
value="Configured"
valueClassName="text-success"
/>
)}
{hasInsecureTlsConfig && (
<ConfigRow
icon={<Shield className="h-4 w-4" />}
label="TLS Validation"
value={config.insecureTls ? "Disabled" : "Enabled"}
valueClassName={config.insecureTls ? "text-red-500" : "text-success"}
/>
)}
</div>
</Card>
<DoctorReport repositoryStatus={repository.status} result={repository.doctorResult} />
</div>
);
};

View file

@ -1,16 +1,49 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { useSearch } from "@tanstack/react-router";
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
import { Activity, ChevronDown, HardDrive, HeartIcon, Pencil, Plug, Trash2, Unplug } from "lucide-react";
import {
deleteVolumeMutation,
getVolumeOptions,
healthCheckVolumeMutation,
mountVolumeMutation,
unmountVolumeMutation,
updateVolumeMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import { Badge } from "~/client/components/ui/badge";
import { Button } from "~/client/components/ui/button";
import { Card } from "~/client/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/client/components/ui/dropdown-menu";
import { Separator } from "~/client/components/ui/separator";
import { Switch } from "~/client/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
import { ManagedBadge } from "~/client/components/managed-badge";
import { formatDateTime, formatTimeAgo } from "~/client/lib/datetime";
import { parseError } from "~/client/lib/errors";
import { cn } from "~/client/lib/utils";
import { VolumeInfoTabContent } from "../tabs/info";
import { FilesTabContent } from "../tabs/files";
import { getVolumeOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { useNavigate } from "@tanstack/react-router";
export function VolumeDetails({ volumeId }: { volumeId: string }) {
const navigate = useNavigate();
const searchParams = useSearch({ from: "/(dashboard)/volumes/$volumeId/" });
const activeTab = searchParams.tab || "info";
const { data } = useSuspenseQuery({
@ -19,20 +52,248 @@ export function VolumeDetails({ volumeId }: { volumeId: string }) {
const { volume, statfs } = data;
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const mountVol = useMutation({
...mountVolumeMutation(),
});
const unmountVol = useMutation({
...unmountVolumeMutation(),
});
const deleteVol = useMutation({
...deleteVolumeMutation(),
onSuccess: () => {
toast.success("Volume deleted successfully");
void navigate({ to: "/volumes" });
},
onError: (error) => {
toast.error("Failed to delete volume", {
description: parseError(error)?.message,
});
},
});
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 });
},
});
const handleConfirmDelete = () => {
setShowDeleteConfirm(false);
deleteVol.mutate({ path: { shortId: volume.shortId } });
};
const isMounted = volume.status === "mounted";
const isError = volume.status === "error";
return (
<>
<Tabs value={activeTab} onValueChange={(value) => navigate({ to: ".", search: () => ({ tab: value }) })}>
<TabsList className="mb-2">
<TabsTrigger value="info">Configuration</TabsTrigger>
<TabsTrigger value="files">Files</TabsTrigger>
</TabsList>
<TabsContent value="info">
<VolumeInfoTabContent volume={volume} statfs={statfs} />
</TabsContent>
<TabsContent value="files">
<FilesTabContent volume={volume} />
</TabsContent>
</Tabs>
<div className="flex flex-col gap-6 @container">
<Card className="px-6 py-5">
<div className="flex flex-col @wide:flex-row @wide:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="hidden @medium:flex items-center justify-center w-10 h-10 shrink-0 rounded-lg bg-muted/50 border border-border/50">
<HardDrive className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold tracking-tight">{volume.name}</h2>
<Separator orientation="vertical" className="h-4 mx-1" />
<Badge variant="outline" className="capitalize gap-1.5">
<span
className={cn("w-2 h-2 rounded-full shrink-0", {
"bg-success": volume.status === "mounted",
"bg-red-500": volume.status === "error",
"bg-amber-500": volume.status === "unmounted",
})}
/>
{volume.status}
</Badge>
<Badge variant="secondary">{volume.type}</Badge>
{volume.provisioningId && <ManagedBadge />}
</div>
<p className="text-sm text-muted-foreground mt-0.5">Created {formatDateTime(volume.createdAt)}</p>
</div>
</div>
<div className="flex items-center gap-2">
{isMounted ? (
<Button
variant="secondary"
onClick={() =>
toast.promise(unmountVol.mutateAsync({ path: { shortId: volume.shortId } }), {
loading: "Unmounting volume...",
success: "Volume unmounted successfully",
error: (error) => parseError(error)?.message || "Failed to unmount volume",
})
}
loading={unmountVol.isPending}
>
<Unplug className="h-4 w-4 mr-2" />
Unmount
</Button>
) : (
<Button
onClick={() =>
toast.promise(mountVol.mutateAsync({ path: { shortId: volume.shortId } }), {
loading: "Mounting volume...",
success: "Volume mounted successfully",
error: (error) => parseError(error)?.message || "Failed to mount volume",
})
}
loading={mountVol.isPending}
>
<Plug className="h-4 w-4 mr-2" />
Mount
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
Actions
<ChevronDown className="h-4 w-4 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate({ to: `/volumes/${volume.shortId}/edit` })}>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
disabled={deleteVol.isPending}
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Card>
<Card className="px-6 py-4">
<div className="flex flex-col @lg:flex-row @lg:items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
<div className="flex items-center gap-2">
<HeartIcon className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Health</span>
{isError ? (
<Badge variant="destructive" className="ml-1">
Error
</Badge>
) : isMounted ? (
<Badge variant="outline" className="text-success border-success/30 bg-success/10 ml-1">
Healthy
</Badge>
) : (
<Badge variant="secondary" className="ml-1">
Unmounted
</Badge>
)}
</div>
<Separator orientation="vertical" className="h-4 hidden @lg:block" />
<span className="text-sm text-muted-foreground">Checked {formatTimeAgo(volume.lastHealthCheck)}</span>
<Separator orientation="vertical" className="h-4 hidden @lg:block" />
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Auto-remount</span>
<Switch
checked={volume.autoRemount}
onCheckedChange={() =>
toggleAutoRemount.mutate({
path: { shortId: volume.shortId },
body: { autoRemount: !volume.autoRemount },
})
}
disabled={toggleAutoRemount.isPending}
/>
</div>
</div>
<Button
variant="outline"
size="sm"
className="shrink-0"
disabled={volume.status === "unmounted"}
loading={healthcheck.isPending}
onClick={() => healthcheck.mutate({ path: { shortId: volume.shortId } })}
>
<Activity className="h-4 w-4 mr-2" />
Check Now
</Button>
</div>
</Card>
{volume.lastError && (
<Card className="px-6 py-6">
<div className="space-y-2">
<p className="text-sm font-medium text-destructive">Last Error</p>
<p className="text-sm text-muted-foreground wrap-break-word">{volume.lastError}</p>
</div>
</Card>
)}
<Tabs value={activeTab} onValueChange={(value) => navigate({ to: ".", search: () => ({ tab: value }) })}>
<TabsList className="mb-2">
<TabsTrigger value="info">Configuration</TabsTrigger>
<TabsTrigger value="files">Files</TabsTrigger>
</TabsList>
<TabsContent value="info">
<VolumeInfoTabContent volume={volume} statfs={statfs} />
</TabsContent>
<TabsContent value="files">
<FilesTabContent volume={volume} />
</TabsContent>
</Tabs>
</div>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete volume?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the volume <strong>{volume.name}</strong>? This action cannot be undone.
<br />
<br />
All backup schedules associated with this volume will also be removed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteVol.isPending}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete volume
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -1,196 +1,201 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { ChevronDown, Pencil, Plug, Trash2, Unplug } from "lucide-react";
import { CreateVolumeForm } from "~/client/modules/volumes/components/create-volume-form";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import { Button } from "~/client/components/ui/button";
import { Card } from "~/client/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/client/components/ui/dropdown-menu";
import { useMemo } from "react";
import { FolderOpen, HardDrive, Settings, Unplug } from "lucide-react";
import { Label, Pie, PieChart } from "recharts";
import { ByteSize } from "~/client/components/bytes-size";
import { Card, CardTitle } from "~/client/components/ui/card";
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "~/client/components/ui/chart";
import type { StatFs, Volume } from "~/client/lib/types";
import { HealthchecksCard } from "../components/healthchecks-card";
import { StorageChart } from "../components/storage-chart";
import {
deleteVolumeMutation,
mountVolumeMutation,
unmountVolumeMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { useNavigate } from "@tanstack/react-router";
import { parseError } from "~/client/lib/errors";
import { ManagedBadge } from "~/client/components/managed-badge";
import { cn } from "~/client/lib/utils";
type Props = {
volume: Volume;
statfs: StatFs;
};
export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
const navigate = useNavigate();
const backendLabels: Record<Volume["type"], string> = {
directory: "Directory",
nfs: "NFS",
smb: "SMB",
webdav: "WebDAV",
rclone: "rclone",
sftp: "SFTP",
};
const mountVol = useMutation({
...mountVolumeMutation(),
});
function ConfigRow({
icon,
label,
value,
mono,
}: {
icon: React.ReactNode;
label: string;
value: string;
mono?: boolean;
}) {
return (
<div className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
<span className="text-muted-foreground shrink-0">{icon}</span>
<span className="text-sm text-muted-foreground w-40 shrink-0">{label}</span>
<span className={cn("text-sm break-all", { "font-mono bg-muted/50 px-2 py-0.5 rounded": mono })}>{value}</span>
</div>
);
}
const unmountVol = useMutation({
...unmountVolumeMutation(),
});
function BackendConfigRows({ volume }: { volume: Volume }) {
const config = volume.config;
const deleteVol = useMutation({
...deleteVolumeMutation(),
onSuccess: async () => {
toast.success("Volume deleted successfully");
await navigate({ to: "/volumes" });
},
onError: (error) => {
toast.error("Failed to delete volume", {
description: parseError(error)?.message,
});
},
});
switch (config.backend) {
case "directory":
return <ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Directory Path" value={config.path} mono />;
case "nfs":
return (
<>
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Server" value={config.server} mono />
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Export Path" value={config.exportPath} mono />
</>
);
case "smb":
return (
<>
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Server" value={config.server} mono />
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Share" value={config.share} mono />
</>
);
case "webdav":
return (
<>
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Server" value={config.server} mono />
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Path" value={config.path} mono />
</>
);
case "rclone":
return (
<>
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Remote" value={config.remote} mono />
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Path" value={config.path} mono />
</>
);
case "sftp":
return (
<>
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Host" value={config.host} mono />
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Username" value={config.username} />
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Path" value={config.path} mono />
</>
);
}
}
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
function DonutChart({ statfs }: { statfs: StatFs }) {
const chartData = useMemo(
() => [
{ name: "Used", value: statfs.used, fill: "var(--strong-accent)" },
{ name: "Free", value: statfs.free, fill: "lightgray" },
],
[statfs],
);
const handleConfirmDelete = () => {
setShowDeleteConfirm(false);
deleteVol.mutate({ path: { shortId: volume.shortId } });
};
const chartConfig = {} satisfies ChartConfig;
const hasLastError = Boolean(volume.lastError);
const usagePercentage = useMemo(() => {
return Math.round((statfs.used / statfs.total) * 100);
}, [statfs]);
return (
<>
<div className="grid gap-4 xl:grid-cols-[minmax(0,2.3fr)_minmax(320px,1fr)]">
<div className="flex flex-col gap-4">
<Card className="p-6 @container">
<div className="flex flex-col @xl:flex-row items-start @xl:items-center justify-between gap-4 mb-6">
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold tracking-tight">Volume Configuration</h2>
{volume.provisioningId && <ManagedBadge />}
<ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[200px]">
<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={50} 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-2xl font-bold">
{usagePercentage}%
</tspan>
<tspan x={viewBox.cx} y={(viewBox.cy || 0) + 20} className="fill-muted-foreground text-xs">
Used
</tspan>
</text>
);
}
}}
/>
</Pie>
</PieChart>
</ChartContainer>
);
}
export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
const hasStorage = statfs.total > 0;
return (
<Card className="px-6 py-6 @container/inner">
<div className="grid grid-cols-1 @3xl/inner:grid-cols-[1fr_280px] gap-8">
<div>
<CardTitle className="flex items-center gap-2 mb-5">
<Settings className="h-4 w-4 text-muted-foreground" />
Configuration
</CardTitle>
<div className="space-y-0 divide-y divide-border/50">
<ConfigRow icon={<HardDrive className="h-4 w-4" />} label="Name" value={volume.name} />
<ConfigRow icon={<HardDrive className="h-4 w-4" />} label="Backend" value={backendLabels[volume.type]} />
<BackendConfigRows volume={volume} />
</div>
</div>
{hasStorage ? (
<div className="@3xl/inner:border-l @3xl/inner:border-border/50 @3xl/inner:pl-8">
<CardTitle className="flex items-center gap-2 mb-2 text-center @3xl/inner:text-left">
<HardDrive className="h-4 w-4" />
Storage
</CardTitle>
<DonutChart statfs={statfs} />
<div className="grid gap-2 mt-2">
<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="text-sm font-medium">Total</span>
</div>
<ByteSize bytes={statfs.total} className="font-mono text-sm" />
</div>
<div className="flex items-center gap-2">
{volume.status !== "mounted" ? (
<Button
type="button"
onClick={() =>
toast.promise(mountVol.mutateAsync({ path: { shortId: volume.shortId } }), {
loading: "Mounting volume...",
success: "Volume mounted successfully",
error: (error) => parseError(error)?.message || "Failed to mount volume",
})
}
loading={mountVol.isPending}
>
<Plug className="h-4 w-4 mr-2" />
Mount
</Button>
) : (
<Button
type="button"
variant="secondary"
onClick={() =>
toast.promise(unmountVol.mutateAsync({ path: { shortId: volume.shortId } }), {
loading: "Unmounting volume...",
success: "Volume unmounted successfully",
error: (error) => parseError(error)?.message || "Failed to unmount volume",
})
}
loading={unmountVol.isPending}
>
<Unplug className="h-4 w-4 mr-2" />
Unmount
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
Actions
<ChevronDown className="h-4 w-4 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate({ to: `/volumes/${volume.shortId}/edit` })}>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
disabled={deleteVol.isPending}
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<div className="flex items-center gap-3">
<div className="h-3 w-3 rounded-full bg-strong-accent" />
<span className="text-sm font-medium">Used</span>
</div>
<ByteSize bytes={statfs.used} className="font-mono text-sm" />
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<div className="flex items-center gap-3">
<div className="h-3 w-3 rounded-full bg-primary" />
<span className="text-sm font-medium">Free</span>
</div>
<ByteSize bytes={statfs.free} className="font-mono text-sm" />
</div>
</div>
<CreateVolumeForm
initialValues={{ ...volume, ...volume.config }}
onSubmit={() => {}}
mode="update"
readOnly
/>
</Card>
{hasLastError && (
<Card className="p-6">
<div className="space-y-2">
<p className="text-sm font-medium text-destructive">Last Error</p>
<p className="text-sm text-muted-foreground wrap-break-word">{volume.lastError}</p>
</div>
</Card>
)}
</div>
<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 className="@3xl/inner:border-l @3xl/inner:border-border/50 @3xl/inner:pl-8 flex flex-col items-center justify-center text-center py-8">
<Unplug className="mb-4 h-5 w-5 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
No storage data available.
<br />
Mount the volume to see usage.
</p>
</div>
</div>
)}
</div>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete volume?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the volume <strong>{volume.name}</strong>? This action cannot be undone.
<br />
<br />
All backup schedules associated with this volume will also be removed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteVol.isPending}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete volume
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
</Card>
);
};