diff --git a/app/client/lib/__tests__/datetime.test.ts b/app/client/lib/__tests__/datetime.test.ts
index 10580407..12ed04f5 100644
--- a/app/client/lib/__tests__/datetime.test.ts
+++ b/app/client/lib/__tests__/datetime.test.ts
@@ -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");
diff --git a/app/client/lib/datetime.ts b/app/client/lib/datetime.ts
index 8c212390..f77bf4be 100644
--- a/app/client/lib/datetime.ts
+++ b/app/client/lib/datetime.ts
@@ -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,
+};
diff --git a/app/client/modules/repositories/components/compression-stats-chart.tsx b/app/client/modules/repositories/components/compression-stats-chart.tsx
index 7523df77..c1ef7431 100644
--- a/app/client/modules/repositories/components/compression-stats-chart.tsx
+++ b/app/client/modules/repositories/components/compression-stats-chart.tsx
@@ -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 (
@@ -94,61 +93,53 @@ export function CompressionStatsChart({ repositoryShortId, initialStats }: Props
Stats will be populated after your first backup. You can also refresh them manually.
-
-
-
-
Stored
-
+
+
+
+ of
+ data across {snapshotsCount} snapshots
+
+
+
+
+ On disk
-
-
+
+ = 80 })}>Freed by compression
-
-
- Uncompressed
-
-
-
+
+
+
+ freed
+
-
-
-
Space Saved
-
- {spaceSavingPercent.toFixed(1)}%
-
-
+
+
+ Ratio
+
+ {compressionRatio > 0 ? `${compressionRatio.toFixed(2)}x` : "-"}
+
-
-
Ratio
-
-
- {compressionRatio > 0 ? `${compressionRatio.toFixed(2)}x` : "-"}
-
-
+
+
+ Snapshots
+ {snapshotsCount.toLocaleString(locale)}
-
-
Snapshots
-
-
- {snapshotsCount.toLocaleString(locale)}
-
-
-
-
-
Compressed
-
-
- {compressionProgressPercent.toFixed(1)}%
-
-
+
+
+ Compressed
+ {compressionProgressPercent.toFixed(0)}%
diff --git a/app/client/modules/repositories/routes/repository-details.tsx b/app/client/modules/repositories/routes/repository-details.tsx
index b34087b2..07c7cf5c 100644
--- a/app/client/modules/repositories/routes/repository-details.tsx
+++ b/app/client/modules/repositories/routes/repository-details.tsx
@@ -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 (
<>
-
navigate({ to: ".", search: (s) => ({ ...s, tab: value }) })}>
-
- Configuration
- Snapshots
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
{repository.name}
+
+
+
+ {repository.status || "Unknown"}
+
+ {repository.type}
+ {repository.provisioningId && Managed }
+
+
+ Created {formatDateTime(repository.createdAt)} · Last checked
+ {formatTimeAgo(repository.lastChecked)}
+
+
+
+
+ {isDoctorRunning ? (
+
cancelDoctor.mutate({ path: { shortId: repository.shortId } })}
+ >
+
+ Cancel doctor
+
+ ) : (
+
startDoctor.mutate({ path: { shortId: repository.shortId } })}
+ disabled={startDoctor.isPending}
+ >
+
+ Run doctor
+
+ )}
+
+
+
+ Actions
+
+
+
+
+ navigate({ to: `/repositories/${repository.shortId}/edit` })}>
+
+ Edit
+
+
+ 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
+
+
+ setShowDeleteConfirm(true)}
+ disabled={deleteRepo.isPending}
+ >
+
+ Delete
+
+
+
+
+
+
+
+ {repository.lastError && (
+
+
+
Last Error
+
{repository.lastError}
+
+
+ )}
+
+
navigate({ to: ".", search: () => ({ tab: value }) })}>
+
+ Configuration
+ Snapshots
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+ Delete repository?
+
+ Are you sure you want to delete the repository {repository.name} ? This will not remove
+ the actual data from the backend storage, only the repository configuration will be deleted.
+
+
+ All backup schedules associated with this repository will also be removed.
+
+
+
+
Cancel
+
+
+ Delete repository
+
+
+
+
>
);
}
diff --git a/app/client/modules/repositories/tabs/info.tsx b/app/client/modules/repositories/tabs/info.tsx
index 8753037d..89c9b144 100644
--- a/app/client/modules/repositories/tabs/info.tsx
+++ b/app/client/modules/repositories/tabs/info.tsx
@@ -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 (
- <>
-
-
-
-
-
-
-
-
-
-
{repository.name}
-
-
-
- {repository.status || "Unknown"}
-
- {repository.type}
- {repository.provisioningId && Managed }
-
-
- Created {formatDateTime(repository.createdAt)} · Last checked
- {formatTimeAgo(repository.lastChecked)}
-
-
-
-
- {isDoctorRunning ? (
-
cancelDoctor.mutate({ path: { shortId: repository.shortId } })}
- >
-
- Cancel doctor
-
- ) : (
-
startDoctor.mutate({ path: { shortId: repository.shortId } })}
- disabled={startDoctor.isPending}
- >
-
- Run doctor
-
- )}
-
-
-
- Actions
-
-
-
-
- navigate({ to: `/repositories/${repository.shortId}/edit` })}>
-
- Edit
-
-
- 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
-
-
- setShowDeleteConfirm(true)}
- disabled={deleteRepo.isPending}
- >
-
- Delete
-
-
-
-
-
-
-
- {hasLastError && (
-
-
-
Last Error
-
{repository.lastError}
-
-
- )}
-
-
-
-
-
- Overview
-
-
-
Name
-
{repository.name}
-
-
-
Backend
-
{repository.type}
-
-
-
Management
-
{repository.provisioningId ? "Provisioned" : "Manual"}
-
-
-
Compression Mode
-
{repository.compressionMode || "off"}
-
-
-
Created
-
{formatDateTime(repository.createdAt)}
-
-
-
Last Checked
-
-
- {formatTimeAgo(repository.lastChecked)}
-
-
- {hasLocalPath && (
-
-
Local Path
-
{effectiveLocalPath}
-
- )}
-
-
-
+
+
+
-
-
- Configuration
-
-
-
} label="Backend" value={repository.type} />
+
Overview
+
+
+
Name
+
{repository.name}
+
+
+
Backend
+
{repository.type}
+
+
+
Management
+
{repository.provisioningId ? "Provisioned" : "Manual"}
+
+
+
Compression Mode
+
{repository.compressionMode || "off"}
+
+
+
Created
+
{formatDateTime(repository.createdAt)}
+
+
+
Last Checked
+
+
+ {formatTimeAgo(repository.lastChecked)}
+
+
{hasLocalPath && (
- }
- label="Local Path"
- value={effectiveLocalPath!}
- mono
- />
+
+
Local Path
+
{effectiveLocalPath}
+
)}
- }
- label="Compression Mode"
- value={repository.compressionMode || "off"}
- />
- }
- label="Management"
- value={repository.provisioningId ? "Provisioned" : "Manual"}
- />
- {hasCaCert && (
- }
- label="CA Certificate"
- value="Configured"
- valueClassName="text-success"
- />
- )}
- {hasInsecureTlsConfig && (
- }
- label="TLS Validation"
- value={config.insecureTls ? "Disabled" : "Enabled"}
- valueClassName={config.insecureTls ? "text-red-500" : "text-success"}
- />
- )}
-
+
-
-
-
-
-
- Delete repository?
-
- Are you sure you want to delete the repository {repository.name} ? This will not remove
- the actual data from the backend storage, only the repository configuration will be deleted.
-
-
- All backup schedules associated with this repository will also be removed.
-
-
-
-
Cancel
-
-
- Delete repository
-
-
-
-
- >
+
+
+
+ Configuration
+
+
+ } label="Backend" value={repository.type} />
+ {hasLocalPath && (
+ } label="Local Path" value={effectiveLocalPath!} mono />
+ )}
+ }
+ label="Compression Mode"
+ value={repository.compressionMode || "off"}
+ />
+ {hasCaCert && (
+ }
+ label="CA Certificate"
+ value="Configured"
+ valueClassName="text-success"
+ />
+ )}
+ {hasInsecureTlsConfig && (
+ }
+ label="TLS Validation"
+ value={config.insecureTls ? "Disabled" : "Enabled"}
+ valueClassName={config.insecureTls ? "text-red-500" : "text-success"}
+ />
+ )}
+
+
+
+
+
);
};
diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx
index fb30ef9e..dfe3c5be 100644
--- a/app/client/modules/settings/routes/settings.tsx
+++ b/app/client/modules/settings/routes/settings.tsx
@@ -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
-
Preview: {dateTimePreview}
+
Preview: {formatDateTime(new Date())}
diff --git a/app/client/modules/volumes/components/create-volume-form.tsx b/app/client/modules/volumes/components/create-volume-form.tsx
index 42fea7d9..7f61f146 100644
--- a/app/client/modules/volumes/components/create-volume-form.tsx
+++ b/app/client/modules/volumes/components/create-volume-form.tsx
@@ -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
({
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)}
>
-
+
Backend
{
field.onChange(value);
if (mode === "create") {
@@ -259,13 +250,13 @@ export const CreateVolumeForm = ({
)}
/>
{watchedBackend === "directory" && }
- {watchedBackend === "nfs" && }
+ {watchedBackend === "nfs" && }
{watchedBackend === "webdav" && }
- {watchedBackend === "smb" && }
- {watchedBackend === "rclone" && }
- {watchedBackend === "sftp" && }
+ {watchedBackend === "smb" && }
+ {watchedBackend === "rclone" && }
+ {watchedBackend === "sftp" && }
- {!readOnly && watchedBackend && watchedBackend !== "directory" && watchedBackend !== "rclone" && (
+ {watchedBackend && watchedBackend !== "directory" && watchedBackend !== "rclone" && (
)}
- {!readOnly && mode === "update" && !formId && (
+ {mode === "update" && !formId && (
Save changes
diff --git a/app/client/modules/volumes/components/volume-forms/nfs-form.tsx b/app/client/modules/volumes/components/volume-forms/nfs-form.tsx
index 23606dc8..b70e619a 100644
--- a/app/client/modules/volumes/components/volume-forms/nfs-form.tsx
+++ b/app/client/modules/volumes/components/volume-forms/nfs-form.tsx
@@ -13,10 +13,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
type Props = {
form: UseFormReturn;
- readOnly?: boolean;
};
-export const NFSForm = ({ form, readOnly = false }: Props) => {
+export const NFSForm = ({ form }: Props) => {
return (
<>
{
render={({ field }) => (
Version
-
+
diff --git a/app/client/modules/volumes/components/volume-forms/rclone-form.tsx b/app/client/modules/volumes/components/volume-forms/rclone-form.tsx
index 7107c7be..5f6aed13 100644
--- a/app/client/modules/volumes/components/volume-forms/rclone-form.tsx
+++ b/app/client/modules/volumes/components/volume-forms/rclone-form.tsx
@@ -18,10 +18,9 @@ import { useQuery } from "@tanstack/react-query";
type Props = {
form: UseFormReturn;
- 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 (
@@ -59,7 +58,7 @@ export const RcloneForm = ({ form, readOnly = false }: Props) => {
render={({ field }) => (
Remote
- field.onChange(v)} value={field.value ?? ""}>
+ field.onChange(v)} value={field.value ?? ""}>
diff --git a/app/client/modules/volumes/components/volume-forms/sftp-form.tsx b/app/client/modules/volumes/components/volume-forms/sftp-form.tsx
index cfb1bc25..869d9ead 100644
--- a/app/client/modules/volumes/components/volume-forms/sftp-form.tsx
+++ b/app/client/modules/volumes/components/volume-forms/sftp-form.tsx
@@ -15,10 +15,9 @@ import { Switch } from "../../../../components/ui/switch";
type Props = {
form: UseFormReturn;
- 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) => {
-
+
)}
diff --git a/app/client/modules/volumes/components/volume-forms/smb-form.tsx b/app/client/modules/volumes/components/volume-forms/smb-form.tsx
index ad49f1ac..89e64b52 100644
--- a/app/client/modules/volumes/components/volume-forms/smb-form.tsx
+++ b/app/client/modules/volumes/components/volume-forms/smb-form.tsx
@@ -14,10 +14,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
type Props = {
form: UseFormReturn
;
- 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 }) => (
SMB Version
-
+
diff --git a/app/client/modules/volumes/routes/volume-details.tsx b/app/client/modules/volumes/routes/volume-details.tsx
index 63cd2585..6e3d6392 100644
--- a/app/client/modules/volumes/routes/volume-details.tsx
+++ b/app/client/modules/volumes/routes/volume-details.tsx
@@ -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 (
<>
- navigate({ to: ".", search: () => ({ tab: value }) })}>
-
- Configuration
- Files
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
{volume.name}
+
+
+
+ {volume.status}
+
+ {volume.type}
+ {volume.provisioningId && }
+
+
Created {formatDateTime(volume.createdAt)}
+
+
+
+
+ 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}
+ >
+
+ Unmount
+
+
+ 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}
+ >
+
+ Mount
+
+
+
+
+ Actions
+
+
+
+
+ navigate({ to: `/volumes/${volume.shortId}/edit` })}>
+
+ Edit
+
+
+ setShowDeleteConfirm(true)}
+ disabled={deleteVol.isPending}
+ >
+
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+
+ Health
+
+ Error
+
+
+ Healthy
+
+
+ Unmounted
+
+
+
+
Checked {formatTimeAgo(volume.lastHealthCheck)}
+
+
+ Auto-remount
+
+ toggleAutoRemount.mutate({
+ path: { shortId: volume.shortId },
+ body: { autoRemount: !volume.autoRemount },
+ })
+ }
+ disabled={toggleAutoRemount.isPending}
+ />
+
+
+
healthcheck.mutate({ path: { shortId: volume.shortId } })}
+ >
+
+ Check Now
+
+
+
+
+
+
+
Last Error
+
{volume.lastError}
+
+
+
+
navigate({ to: ".", search: () => ({ tab: value }) })}>
+
+ Configuration
+ Files
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Delete volume?
+
+ Are you sure you want to delete the volume {volume.name} ? This action cannot be undone.
+
+
+ All backup schedules associated with this volume will also be removed.
+
+
+
+ Cancel
+
+
+ Delete volume
+
+
+
+
>
);
}
diff --git a/app/client/modules/volumes/tabs/info.tsx b/app/client/modules/volumes/tabs/info.tsx
index 3b48d123..7529c7ec 100644
--- a/app/client/modules/volumes/tabs/info.tsx
+++ b/app/client/modules/volumes/tabs/info.tsx
@@ -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 = {
+ 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 (
+
+ {icon}
+ {label}
+ {value}
+
+ );
+}
- 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 } label="Directory Path" value={config.path} mono />;
+ case "nfs":
+ return (
+ <>
+ } label="Server" value={config.server} mono />
+ } label="Export Path" value={config.exportPath} mono />
+ >
+ );
+ case "smb":
+ return (
+ <>
+ } label="Server" value={config.server} mono />
+ } label="Share" value={config.share} mono />
+ >
+ );
+ case "webdav":
+ return (
+ <>
+ } label="Server" value={config.server} mono />
+ } label="Path" value={config.path} mono />
+ >
+ );
+ case "rclone":
+ return (
+ <>
+ } label="Remote" value={config.remote} mono />
+ } label="Path" value={config.path} mono />
+ >
+ );
+ case "sftp":
+ return (
+ <>
+ } label="Host" value={config.host} mono />
+ } label="Username" value={config.username} />
+ } 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 (
- <>
-
-
-
-
-
-
-
Volume Configuration
- {volume.provisioningId &&
}
+
+
+ [ , name]}
+ />
+ }
+ />
+
+ {
+ if (viewBox && "cx" in viewBox && "cy" in viewBox) {
+ return (
+
+
+ {usagePercentage}%
+
+
+ Used
+
+
+ );
+ }
+ }}
+ />
+
+
+
+ );
+}
+
+export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
+ const hasStorage = statfs.total > 0;
+
+ return (
+
+
+
+
+
+ Configuration
+
+
+ } label="Name" value={volume.name} />
+ } label="Backend" value={backendLabels[volume.type]} />
+
+
+
+
+ {hasStorage ? (
+
+
+
+ Storage
+
+
+
+
-
- {volume.status !== "mounted" ? (
-
- 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}
- >
-
- Mount
-
- ) : (
-
- 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}
- >
-
- Unmount
-
- )}
-
-
-
- Actions
-
-
-
-
- navigate({ to: `/volumes/${volume.shortId}/edit` })}>
-
- Edit
-
-
- setShowDeleteConfirm(true)}
- disabled={deleteVol.isPending}
- >
-
- Delete
-
-
-
+
+
-
{}}
- mode="update"
- readOnly
- />
-
- {hasLastError && (
-
-
-
Last Error
-
{volume.lastError}
-
-
- )}
-
-
-
-
-
-
+ ) : (
+
+
+
+ No storage data available.
+
+ Mount the volume to see usage.
+
-
+ )}
-
-
-
- Delete volume?
-
- Are you sure you want to delete the volume {volume.name} ? This action cannot be undone.
-
-
- All backup schedules associated with this volume will also be removed.
-
-
-
- Cancel
-
-
- Delete volume
-
-
-
-
- >
+
);
};