mirror of
https://github.com/nicotsx/zerobyte.git
synced 2026-04-21 07:22:47 -04:00
style: redesign volume details page to match repository (#740)
This commit is contained in:
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)} · 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">
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)} · 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) => 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user