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( 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>(() => buildAssignments(initialData)); const [hasChanges, setHasChanges] = useState(false); const [isAddingNew, setIsAddingNew] = useState(false); const [syncDialogMirrorId, setSyncDialogMirrorId] = useState(null); const [selectedSnapshotIds, setSelectedSnapshotIds] = useState>(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(); 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 (
Mirror Repositories Configure secondary repositories where snapshots will be automatically copied after each backup
{!isAddingNew && selectableRepositories.length > 0 && ( )}
{isAddingNew && (
)} {assignedRepositories.length === 0 ? (

No mirror repositories configured for this schedule.

Click "Add mirror" to replicate backups to additional repositories.

) : (
Repository Enabled Last Copy {assignedRepositories.map((repository) => { const assignment = assignments.get(repository.shortId); if (!assignment) return null; return (
{repository.name} {repository.type}
toggleEnabled(repository.shortId)} />
{getLabel(assignment)}
Sync more snapshots
); })}
)} {hasChanges && (
)} !open && closeSyncDialog()}> Sync snapshots {`Sync missing snapshots to ${selectedMirrorRepo?.name || "mirror repository"}.`} {isSyncStatusLoading && !syncStatus ? (
Loading snapshot status...
) : syncStatus && syncStatus.missingSnapshots.length === 0 ? (
All {syncStatus.sourceCount} snapshots are already synced to this mirror.
) : syncStatus ? (

{syncStatus.missingSnapshots.length} of {syncStatus.sourceCount} snapshots are missing in this mirror.

ID Date Size {syncStatus.missingSnapshots.map((snapshot) => ( toggleSnapshotSelection(snapshot.short_id)} > e.stopPropagation()}> toggleSnapshotSelection(snapshot.short_id)} /> {snapshot.short_id} {formatDateTime(new Date(snapshot.time))} ))}
) : null}
); };