import { getLocale, translate, useI18n } from "../../i18n"; import { useEffect, useMemo, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, Check, CheckCircle2, Circle, LoaderCircle, Minus, RotateCcw, X } from "lucide-react"; import { toast } from "sonner"; import { Button } from "../../components/ui/button"; import { apiErrorMessage } from "../../api/client"; import { cancelCatalogSync, catalogSyncEventsUrl, confirmCatalogSync, getCatalogSyncRun, listCatalogSyncEvents, listCatalogSyncRuns, retryCatalogSync, type CatalogSyncEvent, type CatalogSyncPhase, type CatalogSyncRun, } from "../../api/catalog-databases"; import { useTailFollowing } from "../activityScroll"; import { FleetLedgerDrawer } from "./FleetLedgerShell"; interface Props { databaseId: string | null; runId: string | null; open: boolean; onClose: () => void; onRunChange: (run: CatalogSyncRun) => void; onRunUpdate: (run: CatalogSyncRun) => void; onCatalogChanged: (run: CatalogSyncRun) => void; } const phases: CatalogSyncPhase[] = [ "queued", "connecting", "scanning_tables", "scanning_columns", "scanning_relationships", "planning", "awaiting_confirmation", "applying", "memory_cleanup", "completed", ]; const phaseEnteredByEvent: Record = { queued: "queued", started: "connecting", connecting: "connecting", scanning_tables: "scanning_tables", scanning_columns: "scanning_columns", scanning_relationships: "scanning_relationships", planning: "planning", confirmation_required: "awaiting_confirmation", confirmation_received: "awaiting_confirmation", applying: "applying", memory_cleanup: "memory_cleanup", succeeded: "completed", }; const phaseLabels: Record = { queued: { pending: "Queue", current: "Queued", completed: "Queued" }, connecting: { pending: "Connect", current: "Connecting", completed: "Connected" }, scanning_tables: { pending: "Read tables", current: "Reading tables", completed: "Tables read" }, scanning_columns: { pending: "Read columns", current: "Reading columns", completed: "Columns read" }, scanning_relationships: { pending: "Read relationships", current: "Reading relationships", completed: "Relationships read", }, planning: { pending: "Plan changes", current: "Planning changes", completed: "Changes planned" }, awaiting_confirmation: { pending: "Confirm changes if needed", current: "Confirmation required", completed: "Changes confirmed", }, applying: { pending: "Apply changes", current: "Applying changes", completed: "Changes applied" }, memory_cleanup: { pending: "Clean dependent Memory", current: "Cleaning dependent Memory", completed: "Memory updated" }, completed: { pending: "Complete", current: "Completing", completed: "Completed" }, }; function terminal(run?: CatalogSyncRun): boolean { return Boolean(run && ["succeeded", "failed", "cancelled", "interrupted"].includes(run.state)); } function elapsed(run: CatalogSyncRun, now: number): string { const start = new Date(run.startedAt ?? run.createdAt).getTime(); const end = run.finishedAt ? new Date(run.finishedAt).getTime() : now; const seconds = Math.max(0, Math.floor((end - start) / 1000)); const minutes = Math.floor(seconds / 60); return minutes ? `${minutes}m ${seconds % 60}s` : `${seconds}s`; } function stateLabel(run: CatalogSyncRun): string { return translate(run.state.replaceAll("_", " ")); } export function CatalogSyncDrawer({ databaseId, runId, open, onClose, onRunChange, onRunUpdate, onCatalogChanged, }: Props) { const { t } = useI18n(); const queryClient = useQueryClient(); const [events, setEvents] = useState([]); const [now, setNow] = useState(Date.now()); const [action, setAction] = useState<"confirm" | "cancel" | "retry" | null>(null); const lastSequence = useRef(0); const notifiedRun = useRef(null); const runQuery = useQuery({ queryKey: ["catalog-sync-run", runId], queryFn: () => getCatalogSyncRun(runId!), enabled: Boolean(runId), retry: false, refetchInterval: (query) => terminal(query.state.data) ? false : 1_000, }); const run = runQuery.data; const historyQuery = useQuery({ queryKey: ["catalog-sync-runs", databaseId], queryFn: () => listCatalogSyncRuns(databaseId!), enabled: Boolean(databaseId && open), retry: false, refetchInterval: run && !terminal(run) ? 2_000 : false, }); useEffect(() => { const latestRun = historyQuery.data?.[0]; if (!open || runId || !latestRun) return; onRunChange(latestRun); }, [historyQuery.data, onRunChange, open, runId]); const eventQuery = useQuery({ queryKey: ["catalog-sync-events", runId], queryFn: () => listCatalogSyncEvents(runId!, lastSequence.current), enabled: Boolean(runId && open), retry: false, refetchInterval: run && terminal(run) ? false : 1_500, }); const latestEventSequence = events.at(-1)?.sequence ?? 0; const { followingTail: followingEventTail, handleScroll: handleEventLogScroll, jumpToTail: jumpToLatestEvent, viewportRef: eventLogRef, } = useTailFollowing({ active: open && Boolean(run), resetKey: runId, tail: latestEventSequence, }); const mergeEvents = (incoming: CatalogSyncEvent[]) => { if (incoming.length === 0) return; setEvents((current) => { const bySequence = new Map(current.map((event) => [event.sequence, event])); for (const event of incoming) bySequence.set(event.sequence, event); const merged = [...bySequence.values()].sort((a, b) => a.sequence - b.sequence); lastSequence.current = merged.at(-1)?.sequence ?? lastSequence.current; return merged; }); }; useEffect(() => { setEvents([]); lastSequence.current = 0; }, [runId]); useEffect(() => { mergeEvents(eventQuery.data ?? []); }, [eventQuery.data]); useEffect(() => { if (!runId || !open || typeof EventSource === "undefined") return; const source = new EventSource(catalogSyncEventsUrl(runId)); const log = (event: MessageEvent) => { try { mergeEvents([JSON.parse(event.data) as CatalogSyncEvent]); } catch { /* polling remains authoritative */ } }; const update = (event: MessageEvent) => { try { const next = JSON.parse(event.data) as CatalogSyncRun; queryClient.setQueryData(["catalog-sync-run", runId], next); } catch { /* polling remains authoritative */ } }; source.addEventListener("log", log as EventListener); source.addEventListener("run", update as EventListener); source.onerror = () => source.close(); return () => source.close(); }, [open, queryClient, runId]); useEffect(() => { if (!run || terminal(run)) return; const timer = window.setInterval(() => setNow(Date.now()), 1_000); return () => window.clearInterval(timer); }, [run]); useEffect(() => { if (!run) return; onRunUpdate(run); queryClient.setQueryData(["catalog-sync-runs", run.databaseId], (current) => { if (!current) return [run]; const found = current.some((item) => item.id === run.id); return found ? current.map((item) => item.id === run.id ? run : item) : [run, ...current]; }); }, [onRunUpdate, queryClient, run]); useEffect(() => { if (!run || run.state !== "succeeded" || notifiedRun.current === run.id) return; notifiedRun.current = run.id; onCatalogChanged(run); }, [onCatalogChanged, run]); const currentPhase = run ? phases.indexOf(run.phase) : -1; const knownEvents = [...events, ...(eventQuery.data ?? [])]; const confirmationWasRequired = run?.state === "awaiting_confirmation" || knownEvents.some((event) => ( event.eventType === "confirmation_required" || event.eventType === "confirmation_received" )); const confirmationWasReceived = knownEvents.some((event) => event.eventType === "confirmation_received") || (run?.state === "succeeded" && confirmationWasRequired); const furthestEventPhase = knownEvents.reduce((furthest, event) => { const phase = phaseEnteredByEvent[event.eventType]; return phase ? Math.max(furthest, phases.indexOf(phase)) : furthest; }, -1); const confirmationPhase = phases.indexOf("awaiting_confirmation"); const passedConfirmation = run?.state === "succeeded" || (run && !terminal(run) ? currentPhase > confirmationPhase : furthestEventPhase > confirmationPhase); const deletionCount = useMemo(() => run?.plannedDiff ? run.plannedDiff.deletedTables.length + run.plannedDiff.deletedColumns.length + run.plannedDiff.deletedRelationships.length : 0, [run, t]); const perform = async (kind: "confirm" | "cancel" | "retry") => { if (!run) return; setAction(kind); try { const next = kind === "confirm" ? await confirmCatalogSync(run.id, run.confirmationToken!) : kind === "cancel" ? await cancelCatalogSync(run.id) : await retryCatalogSync(run.id); onRunChange(next); queryClient.setQueryData(["catalog-sync-run", next.id], next); await historyQuery.refetch(); } catch (error) { toast.error(t(apiErrorMessage(error))); } finally { setAction(null); } }; const historySection = (

{t("Recent runs")}

{historyQuery.isLoading ?

{t("Loading recent runs…")}

: null} {historyQuery.isError ? (

{t("Synchronization history could not be loaded.")}

) : null} {!historyQuery.isLoading && !historyQuery.isError && (historyQuery.data ?? []).length === 0 ? (
{t("No synchronization runs yet.")}
) : null} {(historyQuery.data ?? []).length > 0 ? (
{(historyQuery.data ?? []).slice(0, 5).map((item) => ( ))}
) : null}
); if (!open) return null; const footer = run ? ( <> {run.state === "awaiting_confirmation" ? ( ) : null} {["queued", "running", "awaiting_confirmation"].includes(run.state) ? ( ) : null} {["failed", "cancelled", "interrupted"].includes(run.state) ? ( ) : null} ) : undefined; return ( {!terminal(run) ? ); }