style: redesign volume details page to match repository (#740)

This commit is contained in:
Nico
2026-04-02 22:55:23 +02:00
committed by GitHub
parent 475bfb59ae
commit 731ebad1ee
13 changed files with 840 additions and 623 deletions

View File

@@ -1,6 +1,11 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import {
DEFAULT_TIME_FORMAT,
import { DEFAULT_TIME_FORMAT, inferDateTimePreferences, rawFormatters } from "../datetime";
afterEach(() => {
vi.restoreAllMocks();
});
const {
formatDate,
formatDateTime,
formatDateWithMonth,
@@ -8,12 +13,7 @@ import {
formatShortDateTime,
formatTime,
formatTimeAgo,
inferDateTimePreferences,
} from "../datetime";
afterEach(() => {
vi.restoreAllMocks();
});
} = rawFormatters;
const sampleDate = new Date("2026-01-10T14:30:00.000Z");

View File

@@ -149,7 +149,7 @@ export function inferDateTimePreferences(locale?: string) {
}
// 1/10/2026, 2:30 PM
export function formatDateTime(date: DateInput, options: DateFormatOptions = {}): string {
function formatDateTime(date: DateInput, options: DateFormatOptions = {}): string {
return formatValidDate(
date,
(validDate) => `${formatConfiguredDate(validDate, options, true)}, ${formatConfiguredTime(validDate, options)}`,
@@ -157,22 +157,22 @@ export function formatDateTime(date: DateInput, options: DateFormatOptions = {})
}
// Jan 10, 2026
export function formatDateWithMonth(date: DateInput, options: DateFormatOptions = {}): string {
function formatDateWithMonth(date: DateInput, options: DateFormatOptions = {}): string {
return formatValidDate(date, (validDate) => formatConfiguredDateWithMonth(validDate, options));
}
// 1/10/2026
export function formatDate(date: DateInput, options: DateFormatOptions = {}): string {
function formatDate(date: DateInput, options: DateFormatOptions = {}): string {
return formatValidDate(date, (validDate) => formatConfiguredDate(validDate, options, true));
}
// 1/10
export function formatShortDate(date: DateInput, options: DateFormatOptions = {}): string {
function formatShortDate(date: DateInput, options: DateFormatOptions = {}): string {
return formatValidDate(date, (validDate) => formatConfiguredDate(validDate, options, false));
}
// 1/10, 2:30 PM
export function formatShortDateTime(date: DateInput, options: DateFormatOptions = {}): string {
function formatShortDateTime(date: DateInput, options: DateFormatOptions = {}): string {
return formatValidDate(
date,
(validDate) => `${formatConfiguredDate(validDate, options, false)}, ${formatConfiguredTime(validDate, options)}`,
@@ -180,12 +180,12 @@ export function formatShortDateTime(date: DateInput, options: DateFormatOptions
}
// 2:30 PM
export function formatTime(date: DateInput, options: DateFormatOptions = {}): string {
function formatTime(date: DateInput, options: DateFormatOptions = {}): string {
return formatValidDate(date, (validDate) => formatConfiguredTime(validDate, options));
}
// 5 minutes ago
export function formatTimeAgo(date: DateInput, now = Date.now()): string {
function formatTimeAgo(date: DateInput, now = Date.now()): string {
return formatValidDate(date, (validDate) => {
if (Math.abs(now - validDate.getTime()) < 120_000) {
return "just now";
@@ -222,3 +222,13 @@ export function useTimeFormat() {
[locale, timeZone, currentNow, dateFormat, timeFormat],
);
}
export const rawFormatters = {
formatDateTime,
formatDateWithMonth,
formatDate,
formatShortDate,
formatShortDateTime,
formatTime,
formatTimeAgo,
};

View File

@@ -60,12 +60,11 @@ 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);
const storedPercent = uncompressedSize > 0 ? (storedSize / uncompressedSize) * 100 : 0;
const storedPercent = Math.min(100, Math.max(0, uncompressedSize > 0 ? (storedSize / uncompressedSize) * 100 : 0));
return (
<Card className="flex flex-col px-6 py-6">
@@ -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 { 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 { 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";
import { useTimeFormat } from "~/client/lib/datetime";
export default function RepositoryDetailsPage({
repositoryId,
@@ -19,34 +50,212 @@ export default function RepositoryDetailsPage({
initialBackupSchedules?: BackupSchedule[];
initialStats?: GetRepositoryStatsResponse;
}) {
const { formatDateTime, formatTimeAgo } = useTimeFormat();
const navigate = useNavigate();
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>
<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) =>
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">
<RepositorySnapshotsTabContent
repository={data}
repository={repository}
initialSnapshots={initialSnapshots}
initialBackupSchedules={initialBackupSchedules}
/>
</Suspense>
</TabsContent>
</Tabs>
</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,58 +1,12 @@
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 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";
import { useTimeFormat } from "~/client/lib/datetime";
type Props = {
repository: Repository;
@@ -78,281 +32,94 @@ 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 { formatDateTime, formatTimeAgo } = useTimeFormat();
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

@@ -24,9 +24,9 @@ import { authClient } from "~/client/lib/auth-client";
import {
DATE_FORMATS,
type DateFormatPreference,
formatDateTime,
TIME_FORMATS,
type TimeFormatPreference,
useTimeFormat,
} from "~/client/lib/datetime";
import { logger } from "~/client/lib/logger";
import { type AppContext } from "~/context";
@@ -50,7 +50,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
const [downloadPassword, setDownloadPassword] = useState("");
const [isChangingPassword, setIsChangingPassword] = useState(false);
const { locale, dateFormat, timeFormat } = useRootLoaderData();
const { dateFormat, timeFormat } = useRootLoaderData();
const { tab } = useSearch({ from: "/(dashboard)/settings/" });
const activeTab = tab || "account";
@@ -58,12 +58,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
const navigate = useNavigate();
const { activeMember, activeOrganization } = useOrganizationContext();
const isOrgAdmin = activeMember?.role === "owner" || activeMember?.role === "admin";
const dateTimePreview = formatDateTime("2026-01-10T14:30:00.000Z", {
locale,
timeZone: "UTC",
dateFormat,
timeFormat,
});
const { formatDateTime } = useTimeFormat();
const handleLogout = async () => {
await authClient.signOut({
@@ -275,7 +270,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
</Select>
</div>
</div>
<p className="text-sm text-muted-foreground">Preview: {dateTimePreview}</p>
<p className="text-sm text-muted-foreground">Preview: {formatDateTime(new Date())}</p>
</div>
</CardContent>

View File

@@ -72,15 +72,7 @@ const defaultValuesForType = {
sftp: { backend: "sftp" as const, port: 22, path: "/", skipHostKeyCheck: false },
};
export const CreateVolumeForm = ({
onSubmit,
mode = "create",
initialValues,
formId,
loading,
className,
readOnly = false,
}: Props) => {
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema, undefined, { raw: true }),
defaultValues: initialValues || {
@@ -146,7 +138,7 @@ export const CreateVolumeForm = ({
onSubmit={form.handleSubmit(onSubmit, scrollToFirstError)}
className={cn("space-y-4", className)}
>
<fieldset disabled={readOnly} className="space-y-4">
<fieldset className="space-y-4">
<FormField
control={form.control}
name="name"
@@ -169,7 +161,6 @@ export const CreateVolumeForm = ({
<FormItem>
<FormLabel>Backend</FormLabel>
<Select
disabled={readOnly}
onValueChange={(value) => {
field.onChange(value);
if (mode === "create") {
@@ -259,13 +250,13 @@ export const CreateVolumeForm = ({
)}
/>
{watchedBackend === "directory" && <DirectoryForm form={form} />}
{watchedBackend === "nfs" && <NFSForm form={form} readOnly={readOnly} />}
{watchedBackend === "nfs" && <NFSForm form={form} />}
{watchedBackend === "webdav" && <WebDAVForm form={form} />}
{watchedBackend === "smb" && <SMBForm form={form} readOnly={readOnly} />}
{watchedBackend === "rclone" && <RcloneForm form={form} readOnly={readOnly} />}
{watchedBackend === "sftp" && <SFTPForm form={form} readOnly={readOnly} />}
{watchedBackend === "smb" && <SMBForm form={form} />}
{watchedBackend === "rclone" && <RcloneForm form={form} />}
{watchedBackend === "sftp" && <SFTPForm form={form} />}
</fieldset>
{!readOnly && watchedBackend && watchedBackend !== "directory" && watchedBackend !== "rclone" && (
{watchedBackend && watchedBackend !== "directory" && watchedBackend !== "rclone" && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button
@@ -304,7 +295,7 @@ export const CreateVolumeForm = ({
)}
</div>
)}
{!readOnly && mode === "update" && !formId && (
{mode === "update" && !formId && (
<Button type="submit" className="w-full" loading={loading}>
<Save className="h-4 w-4 mr-2" />
Save changes

View File

@@ -13,10 +13,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
type Props = {
form: UseFormReturn<FormValues>;
readOnly?: boolean;
};
export const NFSForm = ({ form, readOnly = false }: Props) => {
export const NFSForm = ({ form }: Props) => {
return (
<>
<FormField
@@ -74,7 +73,7 @@ export const NFSForm = ({ form, readOnly = false }: Props) => {
render={({ field }) => (
<FormItem>
<FormLabel>Version</FormLabel>
<Select disabled={readOnly} onValueChange={field.onChange} value={field.value}>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select NFS version" />

View File

@@ -18,10 +18,9 @@ import { useQuery } from "@tanstack/react-query";
type Props = {
form: UseFormReturn<FormValues>;
readOnly?: boolean;
};
export const RcloneForm = ({ form, readOnly = false }: Props) => {
export const RcloneForm = ({ form }: Props) => {
const { capabilities } = useSystemInfo();
const { data: rcloneRemotes, isPending } = useQuery({
@@ -29,7 +28,7 @@ export const RcloneForm = ({ form, readOnly = false }: Props) => {
enabled: capabilities.rclone,
});
if (!readOnly && !isPending && !rcloneRemotes?.length) {
if (!isPending && !rcloneRemotes?.length) {
return (
<Alert>
<AlertDescription className="space-y-2">
@@ -59,7 +58,7 @@ export const RcloneForm = ({ form, readOnly = false }: Props) => {
render={({ field }) => (
<FormItem>
<FormLabel>Remote</FormLabel>
<Select disabled={readOnly} onValueChange={(v) => field.onChange(v)} value={field.value ?? ""}>
<Select onValueChange={(v) => field.onChange(v)} value={field.value ?? ""}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an rclone remote" />

View File

@@ -15,10 +15,9 @@ import { Switch } from "../../../../components/ui/switch";
type Props = {
form: UseFormReturn<FormValues>;
readOnly?: boolean;
};
export const SFTPForm = ({ form, readOnly = false }: Props) => {
export const SFTPForm = ({ form }: Props) => {
const skipHostKeyCheck = useWatch({ control: form.control, name: "skipHostKeyCheck" });
return (
@@ -130,7 +129,7 @@ export const SFTPForm = ({ form, readOnly = false }: Props) => {
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} disabled={readOnly} onCheckedChange={field.onChange} />
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}

View File

@@ -14,10 +14,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
type Props = {
form: UseFormReturn<FormValues>;
readOnly?: boolean;
};
export const SMBForm = ({ form, readOnly = false }: Props) => {
export const SMBForm = ({ form }: Props) => {
const guest = useWatch({ control: form.control, name: "guest" });
return (
@@ -117,7 +116,7 @@ export const SMBForm = ({ form, readOnly = false }: Props) => {
render={({ field }) => (
<FormItem>
<FormLabel>SMB Version</FormLabel>
<Select disabled={readOnly} onValueChange={field.onChange} value={field.value}>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select SMB version" />

View File

@@ -1,17 +1,51 @@
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 { 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";
import { useTimeFormat } from "~/client/lib/datetime";
export function VolumeDetails({ volumeId }: { volumeId: string }) {
const navigate = useNavigate();
const searchParams = useSearch({ from: "/(dashboard)/volumes/$volumeId/" });
const activeTab = searchParams.tab || "info";
const { formatDateTime, formatTimeAgo } = useTimeFormat();
const { data } = useSuspenseQuery({
...getVolumeOptions({ path: { shortId: volumeId } }),
@@ -19,20 +53,244 @@ 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">
<Button
className={cn({ hidden: !isMounted })}
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
className={cn({ hidden: isMounted })}
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>
<Badge variant="destructive" className={cn("ml-1", { hidden: !isError })}>
Error
</Badge>
<Badge
variant="outline"
className={cn("text-success border-success/30 bg-success/10 ml-1", { hidden: !isMounted })}
>
Healthy
</Badge>
<Badge variant="secondary" className={cn("ml-1", { hidden: isMounted || isError })}>
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>
<Card className={cn("px-6 py-6", { hidden: !volume.lastError })}>
<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,196 @@
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 { 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(),
});
type ConfigRowProps = {
icon: React.ReactNode;
label: string;
value: string;
mono?: boolean;
};
const unmountVol = useMutation({
...unmountVolumeMutation(),
});
function ConfigRow({ icon, label, value, mono }: ConfigRowProps) {
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 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,
});
},
});
function BackendConfigRows({ volume }: { volume: Volume }) {
const config = volume.config;
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
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 handleConfirmDelete = () => {
setShowDeleteConfirm(false);
deleteVol.mutate({ path: { shortId: volume.shortId } });
};
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 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={{}} 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>
);
};