diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index bbd14aff..9ac44460 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -3311,7 +3311,7 @@ export type GetScheduleMirrorsResponses = { enabled: boolean; lastCopyAt: number | null; lastCopyError: string | null; - lastCopyStatus: "error" | "success" | null; + lastCopyStatus: "error" | "in_progress" | "success" | null; repository: { compressionMode: "auto" | "max" | "off" | null; config: @@ -3531,7 +3531,7 @@ export type UpdateScheduleMirrorsResponses = { enabled: boolean; lastCopyAt: number | null; lastCopyError: string | null; - lastCopyStatus: "error" | "success" | null; + lastCopyStatus: "error" | "in_progress" | "success" | null; repository: { compressionMode: "auto" | "max" | "off" | null; config: diff --git a/app/client/hooks/use-server-events.ts b/app/client/hooks/use-server-events.ts index 1e1ae264..28eefd16 100644 --- a/app/client/hooks/use-server-events.ts +++ b/app/client/hooks/use-server-events.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { useQueryClient } from "@tanstack/react-query"; import type { BackupCompletedEventDto, @@ -29,7 +29,7 @@ export interface MirrorEvent { scheduleId: number; repositoryId: string; repositoryName: string; - status?: "success" | "error"; + status?: "success" | "error" | "in_progress"; error?: string; } @@ -205,7 +205,7 @@ export function useServerEvents() { }; }, [queryClient]); - const addEventListener = (event: ServerEventType, handler: EventHandler) => { + const addEventListener = useCallback((event: ServerEventType, handler: EventHandler) => { if (!handlersRef.current.has(event)) { handlersRef.current.set(event, new Set()); } @@ -214,7 +214,7 @@ export function useServerEvents() { return () => { handlersRef.current.get(event)?.delete(handler); }; - }; + }, []); return { addEventListener }; } diff --git a/app/client/modules/backups/components/schedule-mirrors-config.tsx b/app/client/modules/backups/components/schedule-mirrors-config.tsx index 5dd38c28..3fa4e940 100644 --- a/app/client/modules/backups/components/schedule-mirrors-config.tsx +++ b/app/client/modules/backups/components/schedule-mirrors-config.tsx @@ -18,6 +18,7 @@ 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 { useServerEvents } from "~/client/hooks/use-server-events"; import { formatDistanceToNow } from "date-fns"; import { cn } from "~/client/lib/utils"; import type { GetScheduleMirrorsResponse } from "~/client/api-client"; @@ -34,28 +35,31 @@ type MirrorAssignment = { repositoryId: string; enabled: boolean; lastCopyAt: number | null; - lastCopyStatus: "success" | "error" | null; + lastCopyStatus: "success" | "error" | "in_progress" | null; lastCopyError: string | null; }; -const buildAssignments = (mirrors: GetScheduleMirrorsResponse) => { - const map = new Map(); - for (const mirror of mirrors) { - map.set(mirror.repositoryId, { - repositoryId: mirror.repositoryId, - enabled: mirror.enabled, - lastCopyAt: mirror.lastCopyAt, - lastCopyStatus: mirror.lastCopyStatus, - lastCopyError: mirror.lastCopyError, - }); - } - return map; -}; +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 = ({ scheduleId, primaryRepositoryId, repositories, initialData }: Props) => { const [assignments, setAssignments] = useState>(() => buildAssignments(initialData)); const [hasChanges, setHasChanges] = useState(false); const [isAddingNew, setIsAddingNew] = useState(false); + const { addEventListener } = useServerEvents(); const { data: currentMirrors } = useSuspenseQuery({ ...getScheduleMirrorsOptions({ path: { scheduleId: scheduleId.toString() } }), @@ -94,6 +98,42 @@ export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, reposit return map; }, [compatibility]); + useEffect(() => { + const unsubscribeStarted = addEventListener("mirror:started", (data) => { + const event = data as { scheduleId: number; repositoryId: string }; + if (event.scheduleId !== scheduleId) 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; + }); + }); + + const unsubscribeCompleted = addEventListener("mirror:completed", (data) => { + const event = data as { scheduleId: number; repositoryId: string; status?: "success" | "error"; error?: string }; + if (event.scheduleId !== scheduleId) 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; + }); + }); + + return () => { + unsubscribeStarted(); + unsubscribeCompleted(); + }; + }, [addEventListener, scheduleId]); + const addRepository = (repositoryId: string) => { const newAssignments = new Map(assignments); newAssignments.set(repositoryId, { @@ -144,20 +184,8 @@ export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, reposit }; const handleReset = () => { - if (currentMirrors) { - const map = new Map(); - for (const mirror of currentMirrors) { - map.set(mirror.repositoryId, { - repositoryId: mirror.repositoryId, - enabled: mirror.enabled, - lastCopyAt: mirror.lastCopyAt, - lastCopyStatus: mirror.lastCopyStatus, - lastCopyError: mirror.lastCopyError, - }); - } - setAssignments(map); - setHasChanges(false); - } + setAssignments(buildAssignments(currentMirrors)); + setHasChanges(false); }; const selectableRepositories = @@ -176,13 +204,27 @@ export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, reposit .map((id) => repositories?.find((r) => r.id === id)) .filter((r) => r !== undefined); - const getStatusVariant = (status: "success" | "error" | null) => { + 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 formatDistanceToNow(new Date(assignment.lastCopyAt), { addSuffix: true }); + } + return "Never"; + }; + const getStatusLabel = (assignment: MirrorAssignment) => { + if (isSyncing(assignment)) { + return "Mirror sync in progress"; + } if (assignment.lastCopyStatus === "error" && assignment.lastCopyError) { return assignment.lastCopyError; } @@ -310,19 +352,14 @@ export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, reposit /> - {assignment.lastCopyAt ? ( -
- - - {formatDistanceToNow(new Date(assignment.lastCopyAt), { addSuffix: true })} - -
- ) : ( - Never - )} +
+ + {getLabel(assignment)} +