Files
ThothII/frontend/src/shell/database-management/CatalogSyncDrawer.tsx
T
Codex d8a29bfbdd Add full shell, replaceable Omics adapter and bilingual interaction
Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
2026-09-13 14:26:39 +02:00

478 lines
22 KiB
TypeScript

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<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",
memory_cleanup: "memory_cleanup",
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" },
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<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,
});
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<HTMLDivElement>({
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<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(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 = (
<section className="thot-catalog-drawer__history mt-5" aria-label={t("Synchronization history")}>
<h3 className="thot-label mb-2">{t("Recent runs")}</h3>
{historyQuery.isLoading ? <p className="text-sm text-muted-foreground">{t("Loading recent runs…")}</p> : null}
{historyQuery.isError ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm" role="alert">
<p className="font-semibold text-destructive">{t("Synchronization history could not be loaded.")}</p>
<Button type="button" variant="outline" size="sm" className="mt-3" disabled={historyQuery.isFetching} onClick={() => void historyQuery.refetch()}>
<RotateCcw className={historyQuery.isFetching ? "animate-spin" : ""} aria-hidden="true" /> {t("Retry history")}</Button>
</div>
) : null}
{!historyQuery.isLoading && !historyQuery.isError && (historyQuery.data ?? []).length === 0 ? (
<div className="rounded-md border border-dashed border-border bg-muted/20 px-3 py-4 text-sm text-muted-foreground">
{t("No synchronization runs yet.")}</div>
) : null}
{(historyQuery.data ?? []).length > 0 ? (
<div className="divide-y divide-border rounded-md border border-border">
{(historyQuery.data ?? []).slice(0, 5).map((item) => (
<button
key={item.id}
type="button"
aria-current={item.id === run?.id ? "true" : undefined}
data-status={item.state}
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50 aria-[current=true]:bg-primary/8"
onClick={() => onRunChange(item)}
>
<span>{t(item.scope)}</span>
<span className="text-xs text-muted-foreground">{t(stateLabel(item))} · {new Date(item.createdAt).toLocaleString(getLocale())}</span>
</button>
))}
</div>
) : null}
</section>
);
if (!open) return null;
const footer = run ? (
<>
{run.state === "awaiting_confirmation" ? (
<Button type="button" disabled={action !== null} onClick={() => void perform("confirm")}>
{action === "confirm" ? <LoaderCircle className="animate-spin" /> : <Check />} {t("Confirm removals")}</Button>
) : null}
{["queued", "running", "awaiting_confirmation"].includes(run.state) ? (
<Button type="button" variant="outline" disabled={action !== null} onClick={() => void perform("cancel")}>{t("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" : ""} /> {t("Retry")}</Button>
) : null}
</>
) : undefined;
return (
<FleetLedgerDrawer
open
ariaLabel={t("Schema synchronization")}
eyebrow={t("Schema synchronization")}
title={run ? (
<span className="inline-flex items-center gap-2">
{!terminal(run) ? <LoaderCircle className="size-5 animate-spin" aria-hidden="true" /> : null}
{t(stateLabel(run)[0].toUpperCase())}{t(stateLabel(run).slice(1))}
</span>
) : t("Synchronization history")}
description={run ? `${t(run.scope.replaceAll("_", " "))} · ${elapsed(run, now)}` : undefined}
onClose={onClose}
closeLabel={t("Close synchronization drawer")}
busy={action !== null}
bodyClassName="thot-catalog-drawer__history-layout"
footer={footer}
>
{runQuery.isLoading ? <p className="text-sm text-muted-foreground">{t("Loading synchronization…")}</p> : null}
{run ? (
<>
<section aria-labelledby="synchronization-steps-heading">
<h3 id="synchronization-steps-heading" className="thot-label mb-3">{t("Synchronization steps")}</h3>
<ol className="grid grid-cols-2 gap-x-4 gap-y-2.5 sm:grid-cols-3" aria-label={t("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
? t("Confirmation not required")
: confirmationUnknown
? t("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
? t("not required")
: complete
? "completed"
: current
? t("in progress")
: "pending";
return (
<li
key={phase}
aria-current={current ? "step" : undefined}
aria-label={`${t(label)}, ${t(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" />
)}
{t(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={t("Destructive changes")}>
<div className="flex gap-3">
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-amber-600" />
<div>
<h3 className="font-semibold">{t("Confirm")} {deletionCount} {t("removals")}</h3>
<p className="mt-1 text-sm text-muted-foreground">{t("The database will be scanned again before anything is removed.")}</p>
<p className="mt-1 text-sm text-muted-foreground">{t("Memory cards that depend on removed tables or columns will also be deleted, including their links and search entries.")}</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}`}>{t("table ·")} {name}</li>)}
{run.plannedDiff.deletedColumns.map((item) => <li key={`c-${item.tableName}-${item.columnName}`}>{t("column ·")} {item.tableName}.{item.columnName}</li>)}
{run.plannedDiff.deletedRelationships.map((item) => <li key={`r-${item.sourceTableName}-${item.constraintName}`}>{t("relationship ·")} {item.sourceTableName}.{item.constraintName}</li>)}
</ul>
</section>
) : null}
{run.counts.memoryDeleted !== undefined && <p className="mt-4 text-sm text-muted-foreground">
{t("Dependent Memory cards removed:")} {run.counts.memoryDeleted}
</p>}
{run.errorMessage ? (
<div className="mt-5 rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{t(run.errorMessage)}
</div>
) : null}
<section className="mt-5" aria-label={t("Synchronization log")}>
<div className="mb-2 flex items-center justify-between">
<h3 className="thot-label">{t("Live log")}</h3>
<div className="flex items-center gap-2">
{!followingEventTail && events.length > 0 ? (
<Button type="button" variant="outline" size="xs" onClick={jumpToLatestEvent}>
{t("Jump to latest")}</Button>
) : null}
<span className="text-xs tabular-nums text-muted-foreground">{events.length}</span>
</div>
</div>
<div
ref={eventLogRef}
role="log"
aria-label={t("Synchronization events")}
aria-relevant="additions"
tabIndex={0}
onScroll={handleEventLogScroll}
className="thot-catalog-drawer__event-log 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">{t("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(getLocale())}</span>{t(event.message)}
</p>
))}
</div>
</section>
{historySection}
</>
) : (
<>
<div className="rounded-lg border border-dashed border-primary/30 bg-primary/5 px-5 py-6 text-sm">
<p className="font-semibold">{runQuery.isError ? t("This synchronization run could not be loaded.") : historyQuery.isLoading ? t("Loading synchronization history…") : t("No run selected.")}</p>
<p className="mt-1 text-muted-foreground">{t("Choose a recent run to inspect its steps and event log.")}</p>
{runQuery.isError ? (
<Button type="button" variant="outline" size="sm" className="mt-3" disabled={runQuery.isFetching} onClick={() => void runQuery.refetch()}>
<RotateCcw className={runQuery.isFetching ? "animate-spin" : ""} aria-hidden="true" /> {t("Retry run")}</Button>
) : null}
</div>
{historySection}
</>
)}
</FleetLedgerDrawer>
);
}