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 ? ( + + ) : ( + + )} + + + + + + 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 ? ( - - ) : ( - - )} - - - - - - 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(v)} value={field.value ?? ""}> + +