diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index fb1c8367..aa9319dc 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -407,7 +407,7 @@ export const deleteRepositoryMutation = (options?: Partial) => createQueryKey('getRepository', options); /** - * Get a single repository by name + * Get a single repository by ID */ export const getRepositoryOptions = (options: Options) => queryOptions>({ queryFn: async ({ queryKey, signal }) => { diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 58fecf40..8af17c3e 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -170,18 +170,18 @@ export const listRcloneRemotes = (options? /** * Delete a repository */ -export const deleteRepository = (options: Options) => (options.client ?? client).delete({ url: '/api/v1/repositories/{name}', ...options }); +export const deleteRepository = (options: Options) => (options.client ?? client).delete({ url: '/api/v1/repositories/{id}', ...options }); /** - * Get a single repository by name + * Get a single repository by ID */ -export const getRepository = (options: Options) => (options.client ?? client).get({ url: '/api/v1/repositories/{name}', ...options }); +export const getRepository = (options: Options) => (options.client ?? client).get({ url: '/api/v1/repositories/{id}', ...options }); /** * Update a repository's name or settings */ export const updateRepository = (options: Options) => (options.client ?? client).patch({ - url: '/api/v1/repositories/{name}', + url: '/api/v1/repositories/{id}', ...options, headers: { 'Content-Type': 'application/json', @@ -192,28 +192,28 @@ export const updateRepository = (options: /** * List all snapshots in a repository */ -export const listSnapshots = (options: Options) => (options.client ?? client).get({ url: '/api/v1/repositories/{name}/snapshots', ...options }); +export const listSnapshots = (options: Options) => (options.client ?? client).get({ url: '/api/v1/repositories/{id}/snapshots', ...options }); /** * Delete a specific snapshot from a repository */ -export const deleteSnapshot = (options: Options) => (options.client ?? client).delete({ url: '/api/v1/repositories/{name}/snapshots/{snapshotId}', ...options }); +export const deleteSnapshot = (options: Options) => (options.client ?? client).delete({ url: '/api/v1/repositories/{id}/snapshots/{snapshotId}', ...options }); /** * Get details of a specific snapshot */ -export const getSnapshotDetails = (options: Options) => (options.client ?? client).get({ url: '/api/v1/repositories/{name}/snapshots/{snapshotId}', ...options }); +export const getSnapshotDetails = (options: Options) => (options.client ?? client).get({ url: '/api/v1/repositories/{id}/snapshots/{snapshotId}', ...options }); /** * List files and directories in a snapshot */ -export const listSnapshotFiles = (options: Options) => (options.client ?? client).get({ url: '/api/v1/repositories/{name}/snapshots/{snapshotId}/files', ...options }); +export const listSnapshotFiles = (options: Options) => (options.client ?? client).get({ url: '/api/v1/repositories/{id}/snapshots/{snapshotId}/files', ...options }); /** * Restore a snapshot to a target path on the filesystem */ export const restoreSnapshot = (options: Options) => (options.client ?? client).post({ - url: '/api/v1/repositories/{name}/restore', + url: '/api/v1/repositories/{id}/restore', ...options, headers: { 'Content-Type': 'application/json', @@ -224,7 +224,7 @@ export const restoreSnapshot = (options: O /** * Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors. */ -export const doctorRepository = (options: Options) => (options.client ?? client).post({ url: '/api/v1/repositories/{name}/doctor', ...options }); +export const doctorRepository = (options: Options) => (options.client ?? client).post({ url: '/api/v1/repositories/{id}/doctor', ...options }); /** * List all backup schedules diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index ffbbce57..8a9c24d2 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -870,6 +870,7 @@ export type CreateRepositoryResponses = { repository: { id: string; name: string; + shortId: string; }; }; }; @@ -898,10 +899,10 @@ export type ListRcloneRemotesResponse = ListRcloneRemotesResponses[keyof ListRcl export type DeleteRepositoryData = { body?: never; path: { - name: string; + id: string; }; query?: never; - url: '/api/v1/repositories/{name}'; + url: '/api/v1/repositories/{id}'; }; export type DeleteRepositoryResponses = { @@ -918,10 +919,10 @@ export type DeleteRepositoryResponse = DeleteRepositoryResponses[keyof DeleteRep export type GetRepositoryData = { body?: never; path: { - name: string; + id: string; }; query?: never; - url: '/api/v1/repositories/{name}'; + url: '/api/v1/repositories/{id}'; }; export type GetRepositoryResponses = { @@ -1011,10 +1012,10 @@ export type UpdateRepositoryData = { name?: string; }; path: { - name: string; + id: string; }; query?: never; - url: '/api/v1/repositories/{name}'; + url: '/api/v1/repositories/{id}'; }; export type UpdateRepositoryErrors = { @@ -1112,12 +1113,12 @@ export type UpdateRepositoryResponse = UpdateRepositoryResponses[keyof UpdateRep export type ListSnapshotsData = { body?: never; path: { - name: string; + id: string; }; query?: { backupId?: string; }; - url: '/api/v1/repositories/{name}/snapshots'; + url: '/api/v1/repositories/{id}/snapshots'; }; export type ListSnapshotsResponses = { @@ -1139,11 +1140,11 @@ export type ListSnapshotsResponse = ListSnapshotsResponses[keyof ListSnapshotsRe export type DeleteSnapshotData = { body?: never; path: { - name: string; + id: string; snapshotId: string; }; query?: never; - url: '/api/v1/repositories/{name}/snapshots/{snapshotId}'; + url: '/api/v1/repositories/{id}/snapshots/{snapshotId}'; }; export type DeleteSnapshotResponses = { @@ -1160,11 +1161,11 @@ export type DeleteSnapshotResponse = DeleteSnapshotResponses[keyof DeleteSnapsho export type GetSnapshotDetailsData = { body?: never; path: { - name: string; + id: string; snapshotId: string; }; query?: never; - url: '/api/v1/repositories/{name}/snapshots/{snapshotId}'; + url: '/api/v1/repositories/{id}/snapshots/{snapshotId}'; }; export type GetSnapshotDetailsResponses = { @@ -1186,13 +1187,13 @@ export type GetSnapshotDetailsResponse = GetSnapshotDetailsResponses[keyof GetSn export type ListSnapshotFilesData = { body?: never; path: { - name: string; + id: string; snapshotId: string; }; query?: { path?: string; }; - url: '/api/v1/repositories/{name}/snapshots/{snapshotId}/files'; + url: '/api/v1/repositories/{id}/snapshots/{snapshotId}/files'; }; export type ListSnapshotFilesResponses = { @@ -1235,10 +1236,10 @@ export type RestoreSnapshotData = { targetPath?: string; }; path: { - name: string; + id: string; }; query?: never; - url: '/api/v1/repositories/{name}/restore'; + url: '/api/v1/repositories/{id}/restore'; }; export type RestoreSnapshotResponses = { @@ -1258,10 +1259,10 @@ export type RestoreSnapshotResponse = RestoreSnapshotResponses[keyof RestoreSnap export type DoctorRepositoryData = { body?: never; path: { - name: string; + id: string; }; query?: never; - url: '/api/v1/repositories/{name}/doctor'; + url: '/api/v1/repositories/{id}/doctor'; }; export type DoctorRepositoryResponses = { diff --git a/app/client/components/cron-input.tsx b/app/client/components/cron-input.tsx new file mode 100644 index 00000000..eb268fd7 --- /dev/null +++ b/app/client/components/cron-input.tsx @@ -0,0 +1,89 @@ +import { CronExpressionParser } from "cron-parser"; +import { format } from "date-fns"; +import { AlertCircle, CheckCircle2 } from "lucide-react"; +import { useMemo } from "react"; +import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form"; +import { Input } from "~/client/components/ui/input"; +import { cn } from "~/client/lib/utils"; + +interface CronInputProps { + value: string; + onChange: (value: string) => void; + error?: string; +} + +export function CronInput({ value, onChange, error }: CronInputProps) { + const { isValid, nextRuns, parseError } = useMemo(() => { + if (!value) { + return { isValid: false, nextRuns: [], parseError: null }; + } + + const parts = value.trim().split(/\s+/); + + if (parts.length !== 5) { + return { + isValid: false, + nextRuns: [], + parseError: "Expression must have exactly 5 fields (minute, hour, day, month, day-of-week)", + }; + } + + try { + const interval = CronExpressionParser.parse(value); + const runs: Date[] = []; + + for (let i = 0; i < 5; i++) { + runs.push(interval.next().toDate()); + } + + return { isValid: true, nextRuns: runs, parseError: null }; + } catch (e) { + return { isValid: false, nextRuns: [], parseError: (e as Error).message }; + } + }, [value]); + + return ( + + Cron expression + +
+ onChange(e.target.value)} + className={cn("font-mono", { "border-destructive": error || (value && !isValid) })} + /> +
+ {value && ( +
+ {isValid ? ( + + ) : ( + + )} +
+ )} +
+
+
+ + Standard cron format: minute hour day month day-of-week. + + {value && !isValid && parseError &&

{parseError}

} + {isValid && nextRuns.length > 0 && ( +
+

Next 5 executions:

+
    + {nextRuns.map((date, i) => ( +
  • + {i + 1}. + {format(date, "PPP p")} +
  • + ))} +
+
+ )} + +
+ ); +} diff --git a/app/client/components/file-tree.tsx b/app/client/components/file-tree.tsx index f7636629..7789c821 100644 --- a/app/client/components/file-tree.tsx +++ b/app/client/components/file-tree.tsx @@ -12,6 +12,7 @@ import { ChevronDown, ChevronRight, File as FileIcon, Folder as FolderIcon, Fold import { memo, type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { cn } from "~/client/lib/utils"; import { Checkbox } from "~/client/components/ui/checkbox"; +import { ByteSize } from "~/client/components/bytes-size"; const NODE_PADDING_LEFT = 12; @@ -431,7 +432,7 @@ interface FileProps { } const File = memo(({ file, onFileSelect, selected, withCheckbox, checked, onCheckboxChange }: FileProps) => { - const { depth, name, fullPath } = file; + const { depth, name, fullPath, size } = file; const handleClick = useCallback(() => { onFileSelect(fullPath); @@ -458,6 +459,11 @@ const File = memo(({ file, onFileSelect, selected, withCheckbox, checked, onChec e.stopPropagation()} /> )} {name} + {typeof size === "number" && ( + + + + )} ); }); @@ -499,6 +505,7 @@ interface BaseNode { interface FileNode extends BaseNode { kind: "file"; + size?: number; } interface FolderNode extends BaseNode { @@ -518,12 +525,14 @@ function buildFileList(files: FileEntry[], foldersOnly = false): Node[] { const name = segments[segments.length - 1]; if (!fileMap.has(file.path)) { + const isFile = file.type === "file"; fileMap.set(file.path, { - kind: file.type === "file" ? "file" : "folder", + kind: isFile ? "file" : "folder", id: fileMap.size, name, fullPath: file.path, depth, + size: file.size, }); } } diff --git a/app/client/components/restore-form.tsx b/app/client/components/restore-form.tsx index 1891da87..55a6d0ce 100644 --- a/app/client/components/restore-form.tsx +++ b/app/client/components/restore-form.tsx @@ -14,18 +14,18 @@ import { FileTree } from "~/client/components/file-tree"; import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { useFileBrowser } from "~/client/hooks/use-file-browser"; import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic"; -import type { Snapshot } from "~/client/lib/types"; +import type { Repository, Snapshot } from "~/client/lib/types"; type RestoreLocation = "original" | "custom"; interface RestoreFormProps { snapshot: Snapshot; - repositoryName: string; + repository: Repository; snapshotId: string; returnPath: string; } -export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }: RestoreFormProps) { +export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: RestoreFormProps) { const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -42,10 +42,9 @@ export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath } const { data: filesData, isLoading: filesLoading } = useQuery({ ...listSnapshotFilesOptions({ - path: { name: repositoryName, snapshotId }, + path: { id: repository.id, snapshotId }, query: { path: volumeBasePath }, }), - enabled: !!repositoryName && !!snapshotId, }); const stripBasePath = useCallback( @@ -78,7 +77,7 @@ export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath } fetchFolder: async (path) => { return await queryClient.ensureQueryData( listSnapshotFilesOptions({ - path: { name: repositoryName, snapshotId }, + path: { id: repository.id, snapshotId }, query: { path }, }), ); @@ -86,7 +85,7 @@ export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath } prefetchFolder: (path) => { queryClient.prefetchQuery( listSnapshotFilesOptions({ - path: { name: repositoryName, snapshotId }, + path: { id: repository.id, snapshotId }, query: { path }, }), ); @@ -111,8 +110,6 @@ export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath } }); const handleRestore = useCallback(() => { - if (!repositoryName || !snapshotId) return; - const excludeXattrArray = excludeXattr ?.split(",") .map((s) => s.trim()) @@ -125,7 +122,7 @@ export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath } const includePaths = pathsArray.map((path) => addBasePath(path)); restoreSnapshot({ - path: { name: repositoryName }, + path: { id: repository.id }, body: { snapshotId, include: includePaths.length > 0 ? includePaths : undefined, @@ -136,7 +133,7 @@ export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath } }, }); }, [ - repositoryName, + repository.id, snapshotId, excludeXattr, restoreLocation, @@ -156,7 +153,7 @@ export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }

Restore Snapshot

- {repositoryName} / {snapshotId} + {repository.name} / {snapshotId}

diff --git a/app/client/components/snapshots-table.tsx b/app/client/components/snapshots-table.tsx index ebd80fb4..97a934b6 100644 --- a/app/client/components/snapshots-table.tsx +++ b/app/client/components/snapshots-table.tsx @@ -1,11 +1,10 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { Calendar, Clock, Database, FolderTree, HardDrive, Trash2 } from "lucide-react"; +import { Calendar, Clock, Database, HardDrive, Server, Trash2 } from "lucide-react"; import { Link, useNavigate } from "react-router"; import { toast } from "sonner"; import { ByteSize } from "~/client/components/bytes-size"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table"; -import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip"; import { Button } from "~/client/components/ui/button"; import { AlertDialog, @@ -21,14 +20,15 @@ import { formatDuration } from "~/utils/utils"; import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { parseError } from "~/client/lib/errors"; import type { BackupSchedule, Snapshot } from "../lib/types"; +import { cn } from "../lib/utils"; type Props = { snapshots: Snapshot[]; backups: BackupSchedule[]; - repositoryName: string; + repositoryId: string; }; -export const SnapshotsTable = ({ snapshots, repositoryName, backups }: Props) => { +export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => { const navigate = useNavigate(); const queryClient = useQueryClient(); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); @@ -53,7 +53,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName, backups }: Props) => if (snapshotToDelete) { toast.promise( deleteSnapshot.mutateAsync({ - path: { name: repositoryName, snapshotId: snapshotToDelete }, + path: { id: repositoryId, snapshotId: snapshotToDelete }, }), { loading: "Deleting snapshot...", @@ -65,7 +65,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName, backups }: Props) => }; const handleRowClick = (snapshotId: string) => { - navigate(`/repositories/${repositoryName}/${snapshotId}`); + navigate(`/repositories/${repositoryId}/${snapshotId}`); }; return ( @@ -79,7 +79,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName, backups }: Props) => Date & Time Size Duration - Paths + Volume Actions @@ -108,7 +108,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName, backups }: Props) => onClick={(e) => e.stopPropagation()} className="hover:underline" > - {backup ? backup.id : "-"} + {backup ? backup.name : "-"}