feat: implement metadata catalog database management
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
databaseId: string | null;
|
||||
runId: string | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onRunChange: (run: CatalogSyncRun) => void;
|
||||
onRunUpdate: (run: CatalogSyncRun) => void;
|
||||
onCatalogChanged: () => void;
|
||||
}
|
||||
|
||||
const phases: CatalogSyncPhase[] = [
|
||||
"queued", "connecting", "scanning_tables", "scanning_columns",
|
||||
"scanning_relationships", "planning", "awaiting_confirmation", "applying", "completed",
|
||||
];
|
||||
|
||||
const phaseEnteredByEvent: Record<string, CatalogSyncPhase> = {
|
||||
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",
|
||||
succeeded: "completed",
|
||||
};
|
||||
|
||||
const phaseLabels: Record<CatalogSyncPhase, { pending: string; current: string; completed: string }> = {
|
||||
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" },
|
||||
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 run.state.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
export function CatalogSyncDrawer({
|
||||
databaseId,
|
||||
runId,
|
||||
open,
|
||||
onClose,
|
||||
onRunChange,
|
||||
onRunUpdate,
|
||||
onCatalogChanged,
|
||||
}: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [events, setEvents] = useState<CatalogSyncEvent[]>([]);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const [action, setAction] = useState<"confirm" | "cancel" | "retry" | null>(null);
|
||||
const lastSequence = useRef(0);
|
||||
const notifiedRun = useRef<string | null>(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,
|
||||
});
|
||||
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 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<string>) => {
|
||||
try { mergeEvents([JSON.parse(event.data) as CatalogSyncEvent]); } catch { /* polling remains authoritative */ }
|
||||
};
|
||||
const update = (event: MessageEvent<string>) => {
|
||||
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<CatalogSyncRun[]>(["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();
|
||||
}, [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]);
|
||||
|
||||
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(apiErrorMessage(error));
|
||||
} finally {
|
||||
setAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<aside
|
||||
aria-label="Schema synchronization"
|
||||
className="fixed inset-y-2 right-0 z-40 flex w-full max-w-[440px] flex-col border-l border-border bg-background shadow-2xl sm:inset-y-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<p className="thot-label">Schema synchronization</p>
|
||||
<h2 className="mt-1 font-heading text-xl font-semibold">{run ? `${stateLabel(run)[0].toUpperCase()}${stateLabel(run).slice(1)}` : "Loading"}</h2>
|
||||
{run ? <p className="mt-1 text-sm text-muted-foreground">{run.scope} · {elapsed(run, now)}</p> : null}
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon-lg" aria-label="Close synchronization drawer" onClick={onClose}>
|
||||
<X aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||
{runQuery.isLoading ? <p className="text-sm text-muted-foreground">Loading synchronization…</p> : null}
|
||||
{run ? (
|
||||
<>
|
||||
<section aria-labelledby="synchronization-steps-heading">
|
||||
<h3 id="synchronization-steps-heading" className="thot-label mb-3">Synchronization steps</h3>
|
||||
<ol className="grid grid-cols-2 gap-x-4 gap-y-2.5 sm:grid-cols-3" aria-label="Synchronization steps">
|
||||
{phases.map((phase, index) => {
|
||||
const confirmationUnknown = phase === "awaiting_confirmation"
|
||||
&& passedConfirmation
|
||||
&& !eventQuery.isFetched
|
||||
&& !confirmationWasRequired;
|
||||
const confirmationSkipped = phase === "awaiting_confirmation"
|
||||
&& passedConfirmation
|
||||
&& eventQuery.isFetched
|
||||
&& !confirmationWasRequired;
|
||||
const terminalOutcome = phase === "completed" && terminal(run) && run.state !== "succeeded";
|
||||
const failedOutcome = terminalOutcome && run.state === "failed";
|
||||
const interruptedOutcome = terminalOutcome && run.state === "interrupted";
|
||||
const priorPhaseCompleted = terminal(run)
|
||||
? index < furthestEventPhase
|
||||
: index < currentPhase;
|
||||
const complete = !confirmationUnknown && !confirmationSkipped && !terminalOutcome
|
||||
&& (phase === "awaiting_confirmation"
|
||||
? confirmationWasReceived
|
||||
: run.state === "succeeded" || priorPhaseCompleted);
|
||||
const current = index === currentPhase && !terminal(run);
|
||||
const label = phase === "awaiting_confirmation"
|
||||
? confirmationSkipped
|
||||
? "Confirmation not required"
|
||||
: confirmationUnknown
|
||||
? "Checking confirmation"
|
||||
: confirmationWasReceived
|
||||
? phaseLabels[phase].completed
|
||||
: current
|
||||
? phaseLabels[phase].current
|
||||
: complete
|
||||
? phaseLabels[phase].completed
|
||||
: phaseLabels[phase].pending
|
||||
: terminalOutcome
|
||||
? `${stateLabel(run)[0].toUpperCase()}${stateLabel(run).slice(1)}`
|
||||
: complete
|
||||
? phaseLabels[phase].completed
|
||||
: current
|
||||
? phaseLabels[phase].current
|
||||
: phaseLabels[phase].pending;
|
||||
const status = terminalOutcome
|
||||
? run.state
|
||||
: confirmationSkipped
|
||||
? "not required"
|
||||
: complete
|
||||
? "completed"
|
||||
: current
|
||||
? "in progress"
|
||||
: "pending";
|
||||
return (
|
||||
<li
|
||||
key={phase}
|
||||
aria-current={current ? "step" : undefined}
|
||||
aria-label={`${label}, ${status}`}
|
||||
className={`flex items-center gap-2 text-xs ${failedOutcome ? "font-semibold text-destructive" : interruptedOutcome ? "font-semibold text-amber-600" : terminalOutcome || current ? "font-semibold text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
{confirmationSkipped ? (
|
||||
<Minus aria-hidden="true" className="size-3.5 text-muted-foreground" />
|
||||
) : failedOutcome ? (
|
||||
<X aria-hidden="true" className="size-3.5 text-destructive" />
|
||||
) : interruptedOutcome ? (
|
||||
<AlertTriangle aria-hidden="true" className="size-3.5 text-amber-600" />
|
||||
) : terminalOutcome ? (
|
||||
<Minus aria-hidden="true" className="size-3.5 text-muted-foreground" />
|
||||
) : complete ? (
|
||||
<CheckCircle2 aria-hidden="true" className="size-3.5 text-[oklch(var(--success))]" />
|
||||
) : current ? (
|
||||
<LoaderCircle aria-hidden="true" className="size-3.5 animate-spin text-amber-600" />
|
||||
) : (
|
||||
<Circle aria-hidden="true" className="size-3.5" />
|
||||
)}
|
||||
{label}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<div className="mt-5 grid grid-cols-3 gap-2">
|
||||
{(["tables", "columns", "relationships"] as const).map((name) => (
|
||||
<div key={name} className="rounded-md border border-border bg-muted/25 px-3 py-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{name}</p>
|
||||
<p className="mt-1 text-lg font-semibold tabular-nums">{run.counts[name] ?? ""}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{run.state === "awaiting_confirmation" && run.plannedDiff ? (
|
||||
<section className="mt-5 rounded-md border border-amber-500/50 bg-amber-500/8 p-4" aria-label="Destructive changes">
|
||||
<div className="flex gap-3">
|
||||
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-amber-600" />
|
||||
<div>
|
||||
<h3 className="font-semibold">Confirm {deletionCount} removals</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">The database will be scanned again before anything is removed.</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="mt-3 max-h-40 space-y-1 overflow-y-auto font-mono text-xs">
|
||||
{run.plannedDiff.deletedTables.map((name) => <li key={`t-${name}`}>table · {name}</li>)}
|
||||
{run.plannedDiff.deletedColumns.map((item) => <li key={`c-${item.tableName}-${item.columnName}`}>column · {item.tableName}.{item.columnName}</li>)}
|
||||
{run.plannedDiff.deletedRelationships.map((item) => <li key={`r-${item.sourceTableName}-${item.constraintName}`}>relationship · {item.sourceTableName}.{item.constraintName}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{run.errorMessage ? (
|
||||
<div className="mt-5 rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{run.errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="mt-5" aria-label="Synchronization log">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="thot-label">Live log</h3>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{events.length}</span>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto rounded-md bg-zinc-950 p-3 font-mono text-xs leading-5 text-zinc-200">
|
||||
{events.length === 0 ? <p className="text-zinc-500">Waiting for events…</p> : events.map((event) => (
|
||||
<p key={event.sequence} className={event.level === "error" ? "text-red-300" : event.level === "warning" ? "text-amber-300" : undefined}>
|
||||
<span className="mr-2 text-zinc-500">{new Date(event.createdAt).toLocaleTimeString()}</span>{event.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-5" aria-label="Synchronization history">
|
||||
<h3 className="thot-label mb-2">Recent runs</h3>
|
||||
<div className="divide-y divide-border rounded-md border border-border">
|
||||
{(historyQuery.data ?? []).slice(0, 5).map((item) => (
|
||||
<button key={item.id} type="button" className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50" onClick={() => onRunChange(item)}>
|
||||
<span>{item.scope}</span>
|
||||
<span className="text-xs text-muted-foreground">{stateLabel(item)} · {new Date(item.createdAt).toLocaleString()}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{run ? (
|
||||
<div className="flex justify-end gap-2 border-t border-border px-5 py-4">
|
||||
{run.state === "awaiting_confirmation" ? (
|
||||
<Button type="button" disabled={action !== null} onClick={() => void perform("confirm")}>
|
||||
{action === "confirm" ? <LoaderCircle className="animate-spin" /> : <Check />} Confirm removals
|
||||
</Button>
|
||||
) : null}
|
||||
{["queued", "running", "awaiting_confirmation"].includes(run.state) ? (
|
||||
<Button type="button" variant="outline" disabled={action !== null} onClick={() => void perform("cancel")}>Cancel</Button>
|
||||
) : null}
|
||||
{["failed", "cancelled", "interrupted"].includes(run.state) ? (
|
||||
<Button type="button" disabled={action !== null} onClick={() => void perform("retry")}>
|
||||
<RotateCcw className={action === "retry" ? "animate-spin" : ""} /> Retry
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user