zerobyte/app/client/modules/backups/components/schedule-mirrors-config.tsx
Eric Hess d2f65716fe
feat(mirrors): add selective snapshot sync to mirror repositories (#755)
* feat(mirrors): add selective snapshot sync to mirror repositories

Allow users to sync missing snapshots from the source repository to a
mirror. A new sync button per mirror opens a dialog showing which
snapshots are missing, with checkboxes to select which ones to copy.

- Add GET /:shortId/mirrors/:mirrorShortId/status endpoint to compare
  snapshots between source and mirror repositories
- Add POST /:shortId/mirrors/:mirrorShortId/sync endpoint accepting
  selected snapshotIds in the request body
- Extend restic copy command to accept multiple snapshotIds
- Add sync preview dialog with snapshot selection to the frontend

* refactor: stylistic changes

---------

Co-authored-by: Nicolas Meienberger <github@thisprops.com>
2026-04-16 21:28:48 +02:00

574 lines
19 KiB
TypeScript

import { useMutation, useQuery, useSuspenseQuery } from "@tanstack/react-query";
import { Copy, Plus, RefreshCw, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Switch } from "~/client/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { Badge } from "~/client/components/ui/badge";
import { Checkbox } from "~/client/components/ui/checkbox";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/client/components/ui/dialog";
import {
getScheduleMirrorsOptions,
getMirrorCompatibilityOptions,
getMirrorSyncStatusOptions,
updateScheduleMirrorsMutation,
syncMirrorMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors";
import type { Repository } from "~/client/lib/types";
import { RepositoryIcon } from "~/client/components/repository-icon";
import { StatusDot } from "~/client/components/status-dot";
import { ByteSize } from "~/client/components/bytes-size";
import { useServerEvents } from "~/client/hooks/use-server-events";
import { cn } from "~/client/lib/utils";
import type { GetScheduleMirrorsResponse } from "~/client/api-client";
import { Link } from "@tanstack/react-router";
import { useTimeFormat } from "~/client/lib/datetime";
type Props = {
scheduleShortId: string;
primaryRepositoryId: string;
repositories: Repository[];
initialData: GetScheduleMirrorsResponse;
};
type MirrorAssignment = {
repositoryId: string;
enabled: boolean;
lastCopyAt: number | null;
lastCopyStatus: "success" | "error" | "in_progress" | null;
lastCopyError: string | null;
};
const isSyncing = (assignment: MirrorAssignment) => assignment.lastCopyStatus === "in_progress";
const buildAssignments = (mirrors: GetScheduleMirrorsResponse) =>
new Map<string, MirrorAssignment>(
mirrors.map((mirror) => [
mirror.repositoryId,
{
repositoryId: mirror.repositoryId,
enabled: mirror.enabled,
lastCopyAt: mirror.lastCopyAt,
lastCopyStatus: mirror.lastCopyStatus,
lastCopyError: mirror.lastCopyError,
},
]),
);
export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, repositories, initialData }: Props) => {
const { formatDateTime, formatTimeAgo } = useTimeFormat();
const [assignments, setAssignments] = useState<Map<string, MirrorAssignment>>(() => buildAssignments(initialData));
const [hasChanges, setHasChanges] = useState(false);
const [isAddingNew, setIsAddingNew] = useState(false);
const [syncDialogMirrorId, setSyncDialogMirrorId] = useState<string | null>(null);
const [selectedSnapshotIds, setSelectedSnapshotIds] = useState<Set<string>>(new Set());
const [syncDialogOpen, setSyncDialogOpen] = useState(false);
const { addEventListener } = useServerEvents();
const closeSyncDialog = () => {
setSyncDialogOpen(false);
setTimeout(() => {
setSyncDialogMirrorId(null);
setSelectedSnapshotIds(new Set());
}, 300);
};
const openSyncDialog = (mirrorShortId: string) => {
setSyncDialogOpen(true);
setSelectedSnapshotIds(new Set());
setSyncDialogMirrorId(mirrorShortId);
};
const { data: currentMirrors } = useSuspenseQuery({
...getScheduleMirrorsOptions({ path: { shortId: scheduleShortId } }),
});
useEffect(() => {
if (!hasChanges) {
setAssignments(buildAssignments(currentMirrors));
}
}, [currentMirrors, hasChanges]);
const { data: compatibility } = useQuery({
...getMirrorCompatibilityOptions({ path: { shortId: scheduleShortId } }),
});
const updateMirrors = useMutation({
...updateScheduleMirrorsMutation(),
onSuccess: () => {
toast.success("Mirror settings saved successfully");
setHasChanges(false);
},
onError: (error) => {
toast.error("Failed to save mirror settings", {
description: parseError(error)?.message,
});
},
});
const { data: syncStatus, isLoading: isSyncStatusLoading } = useQuery({
...getMirrorSyncStatusOptions({
path: { shortId: scheduleShortId, mirrorShortId: syncDialogMirrorId ?? "" },
}),
enabled: syncDialogMirrorId !== null,
});
const toggleSnapshotSelection = (shortId: string) => {
setSelectedSnapshotIds((prev) => {
const next = new Set(prev);
if (next.has(shortId)) {
next.delete(shortId);
} else {
next.add(shortId);
}
return next;
});
};
const toggleAllSnapshots = () => {
if (!syncStatus) return;
if (selectedSnapshotIds.size === syncStatus.missingSnapshots.length) {
setSelectedSnapshotIds(new Set());
} else {
setSelectedSnapshotIds(new Set(syncStatus.missingSnapshots.map((s) => s.short_id)));
}
};
const triggerSync = useMutation({
...syncMirrorMutation(),
onSuccess: () => {
toast.success("Full sync started");
closeSyncDialog();
},
onError: (error) => {
toast.error("Failed to start sync", {
description: parseError(error)?.message,
});
},
});
const compatibilityMap = useMemo(() => {
const map = new Map<string, { compatible: boolean; reason: string | null }>();
if (compatibility) {
for (const item of compatibility) {
map.set(item.repositoryId, { compatible: item.compatible, reason: item.reason });
}
}
return map;
}, [compatibility]);
useEffect(() => {
const abortController = new AbortController();
addEventListener(
"mirror:started",
(event) => {
if (event.scheduleId !== scheduleShortId) return;
setAssignments((prev) => {
const next = new Map(prev);
const existing = next.get(event.repositoryId);
if (!existing) return prev;
next.set(event.repositoryId, { ...existing, lastCopyStatus: "in_progress", lastCopyError: null });
return next;
});
},
{ signal: abortController.signal },
);
addEventListener(
"mirror:completed",
(event) => {
if (event.scheduleId !== scheduleShortId) return;
setAssignments((prev) => {
const next = new Map(prev);
const existing = next.get(event.repositoryId);
if (!existing) return prev;
next.set(event.repositoryId, {
...existing,
lastCopyStatus: event.status ?? existing.lastCopyStatus,
lastCopyError: event.error ?? null,
lastCopyAt: Date.now(),
});
return next;
});
},
{ signal: abortController.signal },
);
return () => abortController.abort();
}, [addEventListener, scheduleShortId]);
const addRepository = (repositoryId: string) => {
const newAssignments = new Map(assignments);
newAssignments.set(repositoryId, {
repositoryId,
enabled: true,
lastCopyAt: null,
lastCopyStatus: null,
lastCopyError: null,
});
setAssignments(newAssignments);
setHasChanges(true);
setIsAddingNew(false);
};
const removeRepository = (repositoryId: string) => {
const newAssignments = new Map(assignments);
newAssignments.delete(repositoryId);
setAssignments(newAssignments);
setHasChanges(true);
};
const toggleEnabled = (repositoryId: string) => {
const assignment = assignments.get(repositoryId);
if (!assignment) return;
const newAssignments = new Map(assignments);
newAssignments.set(repositoryId, {
...assignment,
enabled: !assignment.enabled,
});
setAssignments(newAssignments);
setHasChanges(true);
};
const handleSave = () => {
const mirrorsList = Array.from(assignments.values()).map((a) => ({
repositoryId: a.repositoryId,
enabled: a.enabled,
}));
updateMirrors.mutate({
path: { shortId: scheduleShortId },
body: {
mirrors: mirrorsList,
},
});
};
const handleReset = () => {
setAssignments(buildAssignments(currentMirrors));
setHasChanges(false);
};
const selectableRepositories =
repositories?.filter((r) => {
if (r.shortId === primaryRepositoryId) return false;
if (assignments.has(r.shortId)) return false;
return true;
}) || [];
const hasAvailableRepositories = selectableRepositories.some((r) => {
const compat = compatibilityMap.get(r.shortId);
return compat?.compatible !== false;
});
const assignedRepositories = Array.from(assignments.keys())
.map((id) => repositories?.find((r) => r.shortId === id))
.filter((r) => r !== undefined);
const getStatusVariant = (status: string | null) => {
if (status === "success") return "success";
if (status === "error") return "error";
if (status === "in_progress") return "info";
return "neutral";
};
const getLabel = (assignment: MirrorAssignment) => {
if (isSyncing(assignment)) {
return "Syncing...";
}
if (assignment.lastCopyAt) {
return formatTimeAgo(assignment.lastCopyAt);
}
return "Never";
};
const getStatusLabel = (assignment: MirrorAssignment) => {
if (isSyncing(assignment)) {
return "Mirror sync in progress";
}
if (assignment.lastCopyStatus === "error" && assignment.lastCopyError) {
return assignment.lastCopyError;
}
if (assignment.lastCopyStatus === "success") {
return "Last copy successful";
}
return "Never copied";
};
const selectedMirrorRepo = repositories.find((r) => r.shortId === syncDialogMirrorId);
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Copy className="h-5 w-5" />
Mirror Repositories
</CardTitle>
<CardDescription className="hidden @md:block mt-1">
Configure secondary repositories where snapshots will be automatically copied after each backup
</CardDescription>
</div>
{!isAddingNew && selectableRepositories.length > 0 && (
<Button variant="outline" size="sm" onClick={() => setIsAddingNew(true)}>
<Plus className="h-4 w-4 mr-2" />
Add mirror
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isAddingNew && (
<div className="mb-6 flex items-center gap-2 max-w-md">
<Select onValueChange={addRepository}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a repository to mirror to..." />
</SelectTrigger>
<SelectContent>
{selectableRepositories.map((repository) => {
const compat = compatibilityMap.get(repository.shortId);
return (
<Tooltip key={repository.shortId}>
<TooltipTrigger asChild>
<div>
<SelectItem value={repository.shortId} disabled={!compat?.compatible}>
<div className="flex items-center gap-2">
<RepositoryIcon backend={repository.type} className="h-4 w-4" />
<span>{repository.name}</span>
<span className="text-xs uppercase text-muted-foreground">({repository.type})</span>
</div>
</SelectItem>
</div>
</TooltipTrigger>
<TooltipContent side="right" className={cn("max-w-xs", { hidden: compat?.compatible })}>
<p>{compat?.reason || "This repository is not compatible for mirroring."}</p>
<p className="mt-1 text-xs text-muted-foreground">
Consider creating a new backup scheduler with the desired destination instead.
</p>
</TooltipContent>
</Tooltip>
);
})}
{!hasAvailableRepositories && selectableRepositories.length > 0 && (
<div className="px-2 py-3 text-sm text-muted-foreground text-center">
All available repositories have conflicting backends.
<br />
<span className="text-xs">
Consider creating a new backup scheduler with the desired destination instead.
</span>
</div>
)}
</SelectContent>
</Select>
<Button variant="ghost" size="sm" onClick={() => setIsAddingNew(false)}>
Cancel
</Button>
</div>
)}
{assignedRepositories.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
<Copy className="h-8 w-8 mb-2 opacity-20" />
<p className="text-sm">No mirror repositories configured for this schedule.</p>
<p className="text-xs mt-1">Click "Add mirror" to replicate backups to additional repositories.</p>
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Repository</TableHead>
<TableHead className="text-center w-25">Enabled</TableHead>
<TableHead className="w-45">Last Copy</TableHead>
<TableHead className="w-12.5"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignedRepositories.map((repository) => {
const assignment = assignments.get(repository.shortId);
if (!assignment) return null;
return (
<TableRow key={repository.shortId}>
<TableCell>
<div className="flex items-center gap-2">
<Link
to="/repositories/$repositoryId"
params={{ repositoryId: repository.shortId }}
className="hover:underline flex items-center gap-2"
>
<RepositoryIcon backend={repository.type} className="h-4 w-4" />
<span className="font-medium">{repository.name}</span>
</Link>
<Badge variant="outline" className="text-[10px] align-middle">
{repository.type}
</Badge>
</div>
</TableCell>
<TableCell className="text-center">
<Switch
className="align-middle"
checked={assignment.enabled}
onCheckedChange={() => toggleEnabled(repository.shortId)}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="w-3 shrink-0 mr-1">
<StatusDot
variant={getStatusVariant(assignment.lastCopyStatus)}
label={getStatusLabel(assignment)}
animated={isSyncing(assignment)}
/>
</div>
<span className="text-sm text-muted-foreground">{getLabel(assignment)}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => openSyncDialog(repository.shortId)}
disabled={isSyncing(assignment) || hasChanges}
className="h-8 w-8 text-muted-foreground hover:text-foreground"
>
<RefreshCw className={cn("h-4 w-4", isSyncing(assignment) && "animate-spin")} />
</Button>
</TooltipTrigger>
<TooltipContent>Sync more snapshots</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
onClick={() => removeRepository(repository.shortId)}
className="h-8 w-8 text-muted-foreground hover:text-destructive align-baseline"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
{hasChanges && (
<div className="flex gap-2 justify-end mt-4 pt-4">
<Button variant="outline" size="sm" onClick={handleReset}>
Cancel
</Button>
<Button variant="default" size="sm" onClick={handleSave} loading={updateMirrors.isPending}>
Save changes
</Button>
</div>
)}
<Dialog open={syncDialogOpen} onOpenChange={(open) => !open && closeSyncDialog()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Sync snapshots</DialogTitle>
<DialogDescription>
{`Sync missing snapshots to ${selectedMirrorRepo?.name || "mirror repository"}.`}
</DialogDescription>
</DialogHeader>
{isSyncStatusLoading && !syncStatus ? (
<div className="py-6 text-center text-muted-foreground text-sm">Loading snapshot status...</div>
) : syncStatus && syncStatus.missingSnapshots.length === 0 ? (
<div className="py-6 text-center text-muted-foreground text-sm">
All {syncStatus.sourceCount} snapshots are already synced to this mirror.
</div>
) : syncStatus ? (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
{syncStatus.missingSnapshots.length} of {syncStatus.sourceCount} snapshots are missing in this mirror.
</p>
<div className="rounded-md border max-h-64 overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={selectedSnapshotIds.size === syncStatus.missingSnapshots.length}
onCheckedChange={toggleAllSnapshots}
/>
</TableHead>
<TableHead>ID</TableHead>
<TableHead>Date</TableHead>
<TableHead className="text-right">Size</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{syncStatus.missingSnapshots.map((snapshot) => (
<TableRow
key={snapshot.short_id}
className="cursor-pointer"
onClick={() => toggleSnapshotSelection(snapshot.short_id)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedSnapshotIds.has(snapshot.short_id)}
onCheckedChange={() => toggleSnapshotSelection(snapshot.short_id)}
/>
</TableCell>
<TableCell className="font-mono text-xs">{snapshot.short_id}</TableCell>
<TableCell className="text-sm">{formatDateTime(new Date(snapshot.time))}</TableCell>
<TableCell className="text-right text-sm">
<ByteSize bytes={snapshot.size} base={1024} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={closeSyncDialog}>
Cancel
</Button>
<Button
onClick={() => {
if (syncDialogMirrorId) {
triggerSync.mutate({
path: { shortId: scheduleShortId, mirrorShortId: syncDialogMirrorId },
body: { snapshotIds: Array.from(selectedSnapshotIds) },
});
}
}}
loading={triggerSync.isPending}
disabled={selectedSnapshotIds.size === 0}
>
Sync {selectedSnapshotIds.size} snapshots
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
);
};