feat: implement metadata catalog database management

This commit is contained in:
Codex
2026-08-27 22:43:54 +02:00
parent 705af3aeb2
commit 79c4c925b5
86 changed files with 12566 additions and 135 deletions
@@ -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>
);
}
@@ -0,0 +1,192 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AgGridReact } from "ag-grid-react";
import type { ColDef, ICellRendererParams } from "ag-grid-community";
import { KeyRound, Link2, Pencil, RefreshCw, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { ApiError, apiErrorMessage } from "../../api/client";
import {
listCatalogColumns,
updateCatalogColumnMetadata,
type CatalogColumn,
type CatalogTable,
} from "../../api/catalog-databases";
import type { DatabaseNavigationState } from "./model";
interface Props {
databaseId: string;
table: CatalogTable;
canManage: boolean;
onNavigationStateChange: (state: DatabaseNavigationState) => void;
onSync: () => void;
}
interface GridContext {
canManage: boolean;
onEdit: (column: CatalogColumn, origin: HTMLButtonElement) => void;
}
function KeyCell({ data }: ICellRendererParams<CatalogColumn>) {
if (!data) return null;
return (
<div className="flex h-full items-center gap-1.5">
{data.isPrimaryKey ? <span className="inline-flex items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 text-xs font-semibold text-primary"><KeyRound className="size-3" />PK{data.primaryKeyPosition && data.primaryKeyPosition > 1 ? ` ${data.primaryKeyPosition}` : ""}</span> : null}
{data.isForeignKey ? <span className="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-xs font-semibold text-muted-foreground"><Link2 className="size-3" />FK{data.foreignKeyCount > 1 ? ` ${data.foreignKeyCount}` : ""}</span> : null}
</div>
);
}
function ActionCell({ data, context }: ICellRendererParams<CatalogColumn, unknown, GridContext>) {
if (!data || !context) return null;
return (
<div className="flex h-full items-center justify-end" onClick={(event) => event.stopPropagation()}>
<Button type="button" variant="ghost" size="icon-lg" disabled={!context.canManage} aria-label={`Edit metadata for ${data.name}`} onClick={(event) => context.onEdit(data, event.currentTarget)}>
<Pencil aria-hidden="true" />
</Button>
</div>
);
}
export function DatabaseColumns({ databaseId, table, canManage, onNavigationStateChange, onSync }: Props) {
const queryClient = useQueryClient();
const queryKey = ["catalog-columns", databaseId, table.id] as const;
const { data = [], isLoading, isFetching, refetch } = useQuery({
queryKey,
queryFn: () => listCatalogColumns(databaseId, table.id),
retry: false,
});
const [search, setSearch] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [description, setDescription] = useState("");
const [generatedDescription, setGeneratedDescription] = useState("");
const [baseline, setBaseline] = useState("");
const [version, setVersion] = useState<number | null>(null);
const [stale, setStale] = useState(false);
const [staleBannerOpen, setStaleBannerOpen] = useState(true);
const [busy, setBusy] = useState(false);
const originRef = useRef<HTMLButtonElement | null>(null);
const active = editingId ? data.find((column) => column.id === editingId) : undefined;
const fingerprint = JSON.stringify([description, generatedDescription]);
const dirty = Boolean(editingId && fingerprint !== baseline);
useEffect(() => { onNavigationStateChange({ dirty, busy }); }, [busy, dirty, onNavigationStateChange]);
useEffect(() => {
if (!editingId || !active || version === active.version || busy) return;
setStale(true);
setStaleBannerOpen(true);
}, [active, busy, editingId, version]);
const edit = (column: CatalogColumn, origin: HTMLButtonElement) => {
originRef.current = origin;
setEditingId(column.id);
setDescription(column.description ?? "");
setGeneratedDescription(column.generatedDescription ?? "");
setBaseline(JSON.stringify([column.description ?? "", column.generatedDescription ?? ""]));
setVersion(column.version);
setStale(false);
setStaleBannerOpen(true);
};
const closeEditor = () => {
if (busy) return;
if (dirty && !window.confirm("Discard unsaved column metadata?")) return;
setEditingId(null);
window.setTimeout(() => originRef.current?.focus(), 0);
};
const save = async () => {
if (!active || version === null) return;
setBusy(true);
try {
const updated = await updateCatalogColumnMetadata(
databaseId, table.id, active.id, version,
description.trim() || null, generatedDescription.trim() || null,
);
queryClient.setQueryData<CatalogColumn[]>(queryKey, (current = []) => current.map((column) => column.id === updated.id ? updated : column));
setDescription(updated.description ?? "");
setGeneratedDescription(updated.generatedDescription ?? "");
setBaseline(JSON.stringify([updated.description ?? "", updated.generatedDescription ?? ""]));
setVersion(updated.version);
setStale(false);
toast.success("Column metadata saved");
} catch (error) {
if (error instanceof ApiError && error.code === "column_stale") {
await refetch();
setStale(true);
setStaleBannerOpen(true);
} else toast.error(apiErrorMessage(error));
} finally { setBusy(false); }
};
const reloadColumn = async () => {
if (!editingId) return;
setBusy(true);
try {
const result = await refetch();
const latest = result.data?.find((column) => column.id === editingId);
if (!latest) { closeEditor(); return; }
setDescription(latest.description ?? "");
setGeneratedDescription(latest.generatedDescription ?? "");
setBaseline(JSON.stringify([latest.description ?? "", latest.generatedDescription ?? ""]));
setVersion(latest.version);
setStale(false);
setStaleBannerOpen(true);
} catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(false); }
};
const columns = useMemo<ColDef<CatalogColumn>[]>(() => [
{ field: "ordinalPosition", headerName: "#", width: 64, maxWidth: 64, filter: "agNumberColumnFilter" },
{ field: "name", headerName: "Name", minWidth: 190, flex: 1, cellClass: "font-mono text-xs" },
{ field: "dataType", headerName: "Type", minWidth: 150, flex: 0.8, cellClass: "font-mono text-xs" },
{ headerName: "Keys", minWidth: 125, width: 125, sortable: false, filter: false, cellRenderer: KeyCell },
{ headerName: "Nullable", minWidth: 100, width: 100, valueGetter: ({ data: row }) => row ? (row.isNullable ? "Yes" : "No") : "" },
{ field: "sourceComment", headerName: "Source comment", minWidth: 220, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
{ field: "generatedDescription", headerName: "Generated description", minWidth: 230, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
{ field: "description", headerName: "Description", minWidth: 230, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
{ colId: "actions", headerName: "", width: 64, maxWidth: 64, pinned: "right", sortable: false, filter: false, resizable: false, cellRenderer: ActionCell },
], []);
const context = useMemo<GridContext>(() => ({ canManage, onEdit: edit }), [canManage, data]);
if (editingId && active) {
return (
<div className="mx-auto w-full max-w-4xl px-4 py-6 sm:px-5">
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={busy} onClick={closeEditor}>← Back to columns</Button>
<p className="thot-label">{table.name} · column</p>
<h3 className="mt-1 font-heading text-2xl font-semibold">{active.name}</h3>
<p className="mt-1 text-sm text-muted-foreground">Physical schema fields are read-only. Review fields can be edited.</p>
{stale ? (
staleBannerOpen ? <div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm"><p className="font-semibold">Newer column metadata is available.</p><p className="mt-1 text-muted-foreground">Your draft is preserved until you reload.</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>Keep editing</Button></div></div>
: <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>Reload the latest value before this metadata can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button></div>
) : null}
<div className="mt-5 grid gap-4 border-t border-border pt-5 sm:grid-cols-2">
<label className="grid gap-1.5 text-sm font-semibold">Physical column name<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={active.name} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">Native type<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={active.dataType} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">Ordinal position<input className="h-9 rounded-md border border-input bg-muted/35 px-3" value={active.ordinalPosition} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">Default expression<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={active.defaultExpression ?? ""} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold sm:col-span-2">Source comment<textarea className="min-h-24 rounded-md border border-input bg-muted/35 px-3 py-2 text-sm" value={active.sourceComment ?? ""} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold sm:col-span-2">Generated description<textarea className="min-h-28 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={generatedDescription} disabled={!canManage || busy} onChange={(event) => setGeneratedDescription(event.target.value)} /></label>
<label className="grid gap-1.5 text-sm font-semibold sm:col-span-2">Description<textarea className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={description} disabled={!canManage || busy} onChange={(event) => setDescription(event.target.value)} /></label>
</div>
<div className="mt-5 flex justify-end gap-2 border-t border-border pt-4">
<Button type="button" variant="outline" disabled={busy} onClick={closeEditor}>Cancel</Button>
<Button type="button" disabled={!canManage || !dirty || busy || stale} onClick={() => void save()}><Save />{busy ? "Saving…" : "Save metadata"}</Button>
</div>
</div>
);
}
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2">
<span className="thot-label whitespace-nowrap">Catalog columns</span>
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search columns" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span className="text-xs tabular-nums text-muted-foreground">{data.length}</span>
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
<Button type="button" disabled={!canManage || busy} onClick={onSync}><RefreshCw />Sync columns</Button>
</div>
<div className="relative min-h-[280px] flex-1">
<div className="thot-database-grid ag-theme-alpine absolute inset-0 h-full w-full">
<AgGridReact<CatalogColumn> rowData={data} columnDefs={columns} context={context} loading={isLoading} quickFilterText={search} defaultColDef={{ sortable: true, filter: true, resizable: true }} getRowId={({ data: row }) => row.id} rowHeight={44} headerHeight={38} animateRows={false} overlayNoRowsTemplate="No columns synchronized for this table." />
</div>
</div>
</div>
);
}
@@ -0,0 +1,510 @@
import type { RefObject, ReactNode } from "react";
import {
ArrowLeft,
Database,
RefreshCw,
Save,
TestTube2,
Trash2,
} from "lucide-react";
import { Button } from "../../components/ui/button";
import type {
CatalogDatabase,
CatalogSecretName,
CatalogSyncScope,
CatalogSyncRun,
DatabaseBinding,
DatabaseTransport,
} from "../../api/catalog-databases";
import type {
DatabaseBusyAction,
DatabaseFormDraft,
DatabaseFormMode,
} from "./model";
import { statusLabel } from "./model";
import { DatabaseSyncMenu } from "./DatabaseSyncMenu";
const inputClass = "h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none transition focus:border-primary/60 focus:ring-3 focus:ring-ring/15 read-only:bg-muted/40 read-only:text-muted-foreground disabled:bg-muted disabled:text-muted-foreground";
const labelClass = "grid min-w-0 gap-1.5 text-xs font-medium text-foreground";
interface FieldProps {
label: string;
hint?: string;
children: ReactNode;
}
function Field({ label, hint, children }: FieldProps) {
return (
<label className={labelClass}>
<span>{label}</span>
{children}
{hint ? <span className="font-normal leading-4 text-muted-foreground">{hint}</span> : null}
</label>
);
}
interface SecretFieldProps {
label: string;
name: CatalogSecretName;
row: CatalogDatabase;
draft: DatabaseFormDraft;
readOnly: boolean;
canManageSecrets: boolean;
onChange: (name: CatalogSecretName, value: string) => void;
multiline?: boolean;
}
function SecretField({
label,
name,
row,
draft,
readOnly,
canManageSecrets,
onChange,
multiline = false,
}: SecretFieldProps) {
if (readOnly) {
return (
<Field label={label} hint="Secret values are never displayed.">
<input
className={inputClass}
value={row.secrets[name] ? "Configured" : "Not configured"}
readOnly
aria-label={label}
/>
</Field>
);
}
const permissionHint = canManageSecrets
? undefined
: "You do not have permission to replace secret values.";
return (
<Field label={label} hint={permissionHint}>
{multiline ? (
<textarea
className={`${inputClass} min-h-24 resize-y py-2 font-mono text-xs leading-5`}
aria-label={label}
autoComplete="off"
value={draft.secrets[name] ?? ""}
disabled={!canManageSecrets}
placeholder={row.secrets[name] ? "Configured; leave blank to keep" : "Paste value"}
onChange={(event) => onChange(name, event.target.value)}
/>
) : (
<input
className={inputClass}
type="password"
aria-label={label}
autoComplete="new-password"
value={draft.secrets[name] ?? ""}
disabled={!canManageSecrets}
placeholder={row.secrets[name] ? "Configured; leave blank to keep" : "Not configured"}
onChange={(event) => onChange(name, event.target.value)}
/>
)}
</Field>
);
}
interface DatabaseFormProps {
mode: DatabaseFormMode;
row: CatalogDatabase;
availableWorkspaces: CatalogDatabase[];
workspaceLocked: boolean;
draft: DatabaseFormDraft;
dirty: boolean;
canManage: boolean;
canManageSecrets: boolean;
busyAction: DatabaseBusyAction;
stale: boolean;
staleBannerOpen: boolean;
partialSecretFailure: string | null;
headingRef: RefObject<HTMLHeadingElement>;
onBack: () => void;
onWorkspaceChange: (workspaceId: string) => void;
onFieldChange: (field: "databaseName" | "schema", value: string) => void;
onTransportChange: (transport: DatabaseTransport) => void;
onBindingChange: <K extends keyof DatabaseBinding>(key: K, value: DatabaseBinding[K]) => void;
onSecretChange: (name: CatalogSecretName, value: string) => void;
onSave: () => void;
onTest: () => void;
onDelete: () => void;
onReloadLatest: () => void;
onKeepEditing: () => void;
onRetrySecrets: () => void;
onOpenTables: (origin: HTMLButtonElement) => void;
onOpenRelationships: () => void;
onSync: (scope: CatalogSyncScope) => void;
onOpenSync: () => void;
activeSyncRun?: CatalogSyncRun;
}
function formTitle(mode: DatabaseFormMode, workspaceLocked: boolean): string {
if (mode === "add") return workspaceLocked ? "Edit database" : "Add database";
if (mode === "edit") return "Edit database";
if (mode === "delete") return "Delete database";
return "Database details";
}
export function DatabaseForm({
mode,
row,
availableWorkspaces,
workspaceLocked,
draft,
dirty,
canManage,
canManageSecrets,
busyAction,
stale,
staleBannerOpen,
partialSecretFailure,
headingRef,
onBack,
onWorkspaceChange,
onFieldChange,
onTransportChange,
onBindingChange,
onSecretChange,
onSave,
onTest,
onDelete,
onReloadLatest,
onKeepEditing,
onRetrySecrets,
onOpenTables,
onOpenRelationships,
onSync,
onOpenSync,
activeSyncRun,
}: DatabaseFormProps) {
const title = formTitle(mode, workspaceLocked);
const readOnly = mode === "view" || mode === "delete";
const editable = mode === "add" || mode === "edit";
const busy = busyAction !== null;
const testDisabled = !canManage
|| !row.configured
|| !row.id
|| dirty
|| stale
|| busy;
const saveDisabled = !canManage
|| stale
|| busy
|| (mode === "edit" && !dirty);
const bindingReady = row.connectionStatus === "reachable" && row.testedVersion === row.version;
const form = (
<form
className="grid gap-5"
onSubmit={(event) => {
event.preventDefault();
if (editable) onSave();
}}
>
{mode === "delete" ? (
<div className="rounded-md border border-destructive/35 bg-destructive/8 p-4 text-sm text-foreground" role="alert">
<p className="font-semibold text-destructive">This removes the local database configuration.</p>
<p className="mt-1 leading-5 text-muted-foreground">
{row.workspaceAvailable
? "The repository workspace remains available and will return to the list as Not configured. "
: "This orphaned catalog row will disappear from the list. "}
Secret references associated with this configuration are removed too.
</p>
</div>
) : null}
{stale ? (
staleBannerOpen ? (
<div className="rounded-md border border-amber-300/70 bg-amber-50 p-4 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100" role="alert">
<p className="font-semibold">A newer database configuration is available.</p>
<p className="mt-1 leading-5">Reload the latest values before saving, testing, or deleting. Your draft is still intact.</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={onReloadLatest}>
<RefreshCw /> Reload latest
</Button>
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={onKeepEditing}>
Keep editing
</Button>
</div>
</div>
) : (
<div className="rounded-md border border-amber-300/70 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100" role="status">
Reload the latest values before this configuration can be changed.
</div>
)
) : null}
{partialSecretFailure ? (
<div className="rounded-md border border-amber-300/70 bg-amber-50 p-4 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100" role="alert">
<p className="font-semibold">Database configuration saved; secret update could not be confirmed.</p>
<p className="mt-1 leading-5">{partialSecretFailure} The values you entered are retained in this form.</p>
<Button type="button" size="sm" variant="outline" className="mt-3" disabled={busy || stale} onClick={onRetrySecrets}>
<RefreshCw /> {busyAction === "retry-secrets" ? "Retrying…" : "Retry secrets"}
</Button>
</div>
) : null}
{editable && !canManageSecrets ? (
<div className="rounded-md border border-border bg-muted/45 px-4 py-3 text-sm text-muted-foreground" role="note">
Secret status is visible, but replacing secret values requires the workspace.secrets.manage permission.
</div>
) : null}
{!row.workspaceAvailable ? (
<div className="rounded-md border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive" role="alert">
This configuration references a workspace that is no longer present in the repository YAML.
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2">
{mode === "add" ? (
<Field label="Workspace">
<select
className={inputClass}
aria-label="Workspace"
value={draft.workspaceId}
disabled={workspaceLocked || busy}
onChange={(event) => onWorkspaceChange(event.target.value)}
>
{availableWorkspaces.map((workspace) => (
<option key={workspace.workspaceId} value={workspace.workspaceId}>
{workspace.workspaceName}
</option>
))}
</select>
</Field>
) : (
<Field label="Workspace">
<input className={inputClass} value={row.workspaceName} readOnly />
</Field>
)}
<Field label="Workspace ID">
<input className={`${inputClass} font-mono text-xs`} value={row.workspaceId} readOnly />
</Field>
<Field label="Database engine">
<input className={inputClass} value="PostgreSQL" readOnly />
</Field>
<Field label="Transport">
<select
className={inputClass}
aria-label="Transport"
value={draft.binding.transport}
disabled={readOnly || busy}
onChange={(event) => onTransportChange(event.target.value as DatabaseTransport)}
>
<option value="postgres_direct">Direct PostgreSQL</option>
<option value="rest_api">REST API</option>
<option value="ssh_tunnel">SSH tunnel</option>
</select>
</Field>
<Field label="Database name">
<input
className={inputClass}
required={editable}
value={draft.databaseName}
readOnly={readOnly}
disabled={busy}
onChange={(event) => onFieldChange("databaseName", event.target.value)}
/>
</Field>
<Field label="Schema">
<input
className={inputClass}
required={editable}
value={draft.schema}
readOnly={readOnly}
disabled={busy}
onChange={(event) => onFieldChange("schema", event.target.value)}
/>
</Field>
</div>
<div className="border-t border-border pt-5">
<p className="thot-label mb-4">Installation binding</p>
{draft.binding.transport === "postgres_direct" ? (
<div className="grid gap-4 md:grid-cols-2">
<Field label="Host">
<input className={inputClass} required={editable} value={draft.binding.host ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("host", event.target.value)} />
</Field>
<Field label="Port">
<input className={inputClass} type="number" min={1} max={65535} required={editable} value={draft.binding.port ?? 5432} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("port", Number(event.target.value))} />
</Field>
<Field label="Username">
<input className={inputClass} required={editable} value={draft.binding.username ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("username", event.target.value)} />
</Field>
<Field label="TLS server name">
<input className={inputClass} value={draft.binding.tlsServername ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("tlsServername", event.target.value || undefined)} />
</Field>
<SecretField label="Password" name="password" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
<SecretField label="TLS CA certificate" name="tlsCa" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
</div>
) : null}
{draft.binding.transport === "rest_api" ? (
<div className="grid gap-4 md:grid-cols-2">
<Field label="Base URL">
<input className={inputClass} type="url" required={editable} placeholder="https://dwh.example/api" value={draft.binding.baseUrl ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("baseUrl", event.target.value)} />
</Field>
<Field label="Diagnostic endpoint" hint="This relative path is fixed by the runtime contract.">
<input className={inputClass} value={draft.binding.restPath ?? "/health"} readOnly aria-label="Diagnostic endpoint" />
</Field>
<Field label="Authentication">
<select className={inputClass} aria-label="Authentication" value={draft.binding.restAuth ?? "x-api-key"} disabled={readOnly || busy} onChange={(event) => onBindingChange("restAuth", event.target.value as DatabaseBinding["restAuth"])}>
<option value="x-api-key">X-API-Key</option>
<option value="bearer">Bearer token</option>
<option value="none">None</option>
</select>
</Field>
{draft.binding.restAuth !== "none" ? (
<SecretField label="API key" name="apiKey" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
) : null}
</div>
) : null}
{draft.binding.transport === "ssh_tunnel" ? (
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-md border border-border bg-muted/45 px-4 py-3 text-sm text-muted-foreground md:col-span-2" role="note">
SSH host verification is strict. Provide the trusted known_hosts entry before testing this binding.
</div>
<Field label="Database username">
<input className={inputClass} required={editable} value={draft.binding.username ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("username", event.target.value)} />
</Field>
<SecretField label="Database password" name="password" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
<Field label="SSH host">
<input className={inputClass} required={editable} value={draft.binding.sshHost ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshHost", event.target.value)} />
</Field>
<Field label="SSH port">
<input className={inputClass} type="number" min={1} max={65535} required={editable} value={draft.binding.sshPort ?? 22} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshPort", Number(event.target.value))} />
</Field>
<Field label="SSH username">
<input className={inputClass} required={editable} value={draft.binding.sshUsername ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshUsername", event.target.value)} />
</Field>
<SecretField label="SSH private key" name="sshPrivateKey" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} multiline />
<SecretField label="Private key passphrase" name="sshPrivateKeyPassphrase" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
<SecretField label="SSH known hosts" name="sshKnownHosts" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} multiline />
<Field label="Target host">
<input className={inputClass} required={editable} value={draft.binding.sshTargetHost ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshTargetHost", event.target.value)} />
</Field>
<Field label="Target port">
<input className={inputClass} type="number" min={1} max={65535} required={editable} value={draft.binding.sshTargetPort ?? 5432} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshTargetPort", Number(event.target.value))} />
</Field>
<Field label="TLS server name">
<input className={inputClass} value={draft.binding.tlsServername ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("tlsServername", event.target.value || undefined)} />
</Field>
<SecretField label="TLS CA certificate" name="tlsCa" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} multiline />
</div>
) : null}
</div>
{row.lastErrorMessage ? (
<div className="rounded-md border border-destructive/25 bg-destructive/8 px-4 py-3 text-sm text-destructive" role="status">
Last connection error: {row.lastErrorMessage}
</div>
) : null}
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-border pt-4">
{mode === "view" || mode === "edit" ? (
<Button type="button" variant="outline" disabled={testDisabled} onClick={onTest}>
<TestTube2 /> {busyAction === "test" ? "Testing…" : "Test connection"}
</Button>
) : null}
{mode === "add" || mode === "edit" ? (
<Button type="submit" disabled={saveDisabled}>
<Save />
{busyAction === "save"
? "Saving…"
: mode === "add"
? workspaceLocked ? "Save database" : "Add database"
: "Save changes"}
</Button>
) : null}
{mode === "delete" ? (
<Button type="button" variant="destructive" disabled={!canManage || !row.configured || !row.id || busy || stale} onClick={onDelete}>
<Trash2 /> {busyAction === "delete" ? "Deleting…" : "Delete database"}
</Button>
) : null}
</div>
</form>
);
return (
<section aria-label={`${title} form`} className="min-h-0 flex-1 overflow-y-auto bg-background">
<div className="mx-auto w-full max-w-[900px] px-4 py-5 sm:px-6 sm:py-6">
<Button type="button" variant="ghost" className="-ml-2 mb-5" disabled={busy} onClick={onBack}>
<ArrowLeft /> Back to list
</Button>
<div className="mb-6 flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5">
<div className="min-w-0">
<p className="thot-label mb-1">Database management</p>
<h2 ref={headingRef} tabIndex={-1} className="font-heading text-2xl font-semibold tracking-tight outline-none">
{title}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{row.workspaceName} · {row.workspaceId}
</p>
{row.lastTestedAt ? (
<p className="mt-1 text-xs text-muted-foreground">Last tested {new Date(row.lastTestedAt).toLocaleString()}</p>
) : null}
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<Database className="size-4 text-muted-foreground" aria-hidden="true" />
<span className="rounded-full border border-border bg-muted px-2.5 py-1 text-xs font-semibold text-muted-foreground">
{statusLabel(row)}
</span>
{mode === "view" && activeSyncRun ? (
<Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />View sync</Button>
) : null}
{mode === "view" ? (
<DatabaseSyncMenu
disabled={!canManage || !bindingReady || busy || Boolean(activeSyncRun)}
disabledReason={!bindingReady ? "Test the current database binding before synchronizing the schema" : undefined}
onSelect={onSync}
/>
) : null}
</div>
</div>
{mode === "view" ? (
<nav aria-label="Database sections" className="mb-6 flex gap-1 border-b border-border" role="tablist">
<button
type="button"
role="tab"
aria-selected="true"
className="border-b-2 border-primary px-3 py-2 text-sm font-semibold text-foreground"
>
Overview
</button>
<button
type="button"
role="tab"
aria-selected="false"
disabled={!row.configured || !row.id}
className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-45"
onClick={(event) => onOpenTables(event.currentTarget)}
>
Tables
</button>
<button
type="button"
role="tab"
aria-selected="false"
disabled={!row.configured || !row.id}
className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-45"
onClick={onOpenRelationships}
>
Relationships
</button>
</nav>
) : null}
{form}
</div>
</section>
);
}
@@ -0,0 +1,329 @@
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { Menu } from "@base-ui/react/menu";
import { AgGridReact } from "ag-grid-react";
import {
AllCommunityModule,
ModuleRegistry,
type ColDef,
type ICellRendererParams,
} from "ag-grid-community";
import "ag-grid-community/styles/ag-grid.css";
import "ag-grid-community/styles/ag-theme-alpine.css";
import { ChevronDown, Eye, Pencil, RefreshCw, Trash2, X } from "lucide-react";
import { Button } from "../../components/ui/button";
import type { CatalogDatabase, CatalogSyncScope } from "../../api/catalog-databases";
import { statusLabel } from "./model";
import { databaseSyncItemClass, databaseSyncScopes } from "./DatabaseSyncMenu";
ModuleRegistry.registerModules([AllCommunityModule]);
interface DatabaseGridProps {
rows: CatalogDatabase[];
isLoading: boolean;
isFetching: boolean;
search: string;
canManage: boolean;
searchInputRef: RefObject<HTMLInputElement>;
onSearchChange: (value: string) => void;
onView: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
onEdit: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
onDelete: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
onOpenSync: (row: CatalogDatabase) => void;
onTestSelected: (rows: CatalogDatabase[]) => Promise<void>;
onSyncSelected: (rows: CatalogDatabase[], scope: CatalogSyncScope) => Promise<void>;
}
interface DatabaseGridContext {
canManage: boolean;
onView: DatabaseGridProps["onView"];
onEdit: DatabaseGridProps["onEdit"];
onDelete: DatabaseGridProps["onDelete"];
onOpenSync: DatabaseGridProps["onOpenSync"];
}
function useCompactViewport(): boolean {
const [compact, setCompact] = useState(false);
useEffect(() => {
if (typeof window.matchMedia !== "function") return;
const query = window.matchMedia("(max-width: 767px)");
const update = () => setCompact(query.matches);
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
return compact;
}
function StatusPill({ row }: { row: CatalogDatabase }) {
const tone = !row.workspaceAvailable
? "border-destructive/30 bg-destructive/10 text-destructive"
: !row.configured
? "border-border bg-muted text-muted-foreground"
: row.connectionStatus === "reachable"
? "border-emerald-300/70 bg-emerald-50 text-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-300"
: row.connectionStatus === "failed"
? "border-destructive/30 bg-destructive/10 text-destructive"
: "border-amber-300/70 bg-amber-50 text-amber-900 dark:bg-amber-950/30 dark:text-amber-300";
return <div className="flex items-center gap-1.5"><span className={`inline-flex max-w-full truncate rounded-full border px-2 py-0.5 text-[11px] font-semibold ${tone}`}>{statusLabel(row)}</span>{row.activeSyncRun ? <span className="inline-flex rounded-full border border-primary/30 bg-primary/8 px-2 py-0.5 text-[11px] font-semibold text-primary">Syncing</span> : null}</div>;
}
function DatabaseActionsCell({
data,
context,
}: ICellRendererParams<CatalogDatabase, unknown, DatabaseGridContext>) {
if (!data || !context) return null;
const editDisabled = !context.canManage || !data.workspaceAvailable;
const deleteDisabled = !context.canManage || !data.configured;
const editLabel = `Edit ${data.workspaceName}`;
const editReasonId = `database-edit-reason-${data.workspaceId}`;
const deleteReasonId = `database-delete-reason-${data.workspaceId}`;
return (
<div className="flex h-full items-center justify-end gap-0.5" onClick={(event) => event.stopPropagation()}>
{data.activeSyncRun ? (
<Button type="button" variant="ghost" size="icon-lg" title={`View synchronization for ${data.workspaceName}`} aria-label={`View synchronization for ${data.workspaceName}`} onClick={() => context.onOpenSync(data)}>
<RefreshCw className="animate-spin" aria-hidden="true" />
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon-lg"
title={`View ${data.workspaceName}`}
aria-label={`View ${data.workspaceName}`}
onClick={(event) => context.onView(data, event.currentTarget)}
>
<Eye aria-hidden="true" />
</Button>
<span title={editDisabled ? "This database configuration cannot be changed" : editLabel}>
<Button
type="button"
variant="ghost"
size="icon-lg"
aria-label={editLabel}
aria-describedby={editDisabled ? editReasonId : undefined}
disabled={editDisabled}
onClick={(event) => context.onEdit(data, event.currentTarget)}
>
<Pencil aria-hidden="true" />
</Button>
{editDisabled ? <span id={editReasonId} className="sr-only">This database configuration cannot be changed.</span> : null}
</span>
<span title={deleteDisabled ? "Save this database configuration before deleting it" : `Delete ${data.workspaceName}`}>
<Button
type="button"
variant="ghost"
size="icon-lg"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
aria-label={`Delete ${data.workspaceName}`}
aria-describedby={deleteDisabled ? deleteReasonId : undefined}
disabled={deleteDisabled}
onClick={(event) => context.onDelete(data, event.currentTarget)}
>
<Trash2 aria-hidden="true" />
</Button>
{deleteDisabled ? <span id={deleteReasonId} className="sr-only">Save this database configuration before deleting it.</span> : null}
</span>
</div>
);
}
export function DatabaseGrid({
rows,
isLoading,
isFetching,
search,
canManage,
searchInputRef,
onSearchChange,
onView,
onEdit,
onDelete,
onOpenSync,
onTestSelected,
onSyncSelected,
}: DatabaseGridProps) {
const compact = useCompactViewport();
const gridRef = useRef<AgGridReact<CatalogDatabase>>(null);
const [selectedRows, setSelectedRows] = useState<CatalogDatabase[]>([]);
const [action, setAction] = useState<"test" | "sync" | null>(null);
const context = useMemo<DatabaseGridContext>(
() => ({ canManage, onView, onEdit, onDelete, onOpenSync }),
[canManage, onView, onEdit, onDelete, onOpenSync],
);
const columnDefs = useMemo<ColDef<CatalogDatabase>[]>(() => {
const workspace: ColDef<CatalogDatabase> = {
field: "workspaceName",
headerName: "Workspace",
minWidth: compact ? 112 : 190,
flex: compact ? undefined : 1.35,
width: compact ? 112 : undefined,
};
const database: ColDef<CatalogDatabase> = {
field: "databaseName", headerName: "Database", minWidth: 145, flex: 1,
};
const schema: ColDef<CatalogDatabase> = {
field: "schema", headerName: "Schema", minWidth: 140, flex: 0.9,
};
const transport: ColDef<CatalogDatabase> = {
field: "binding.transport",
headerName: "Transport",
minWidth: 135,
flex: 0.9,
valueFormatter: ({ value }) => String(value ?? "").replaceAll("_", " "),
};
const endpoint: ColDef<CatalogDatabase> = {
headerName: "Endpoint",
minWidth: 190,
flex: 1.2,
valueGetter: ({ data }) => {
if (!data) return "";
if (data.binding.transport === "rest_api") return data.binding.baseUrl ?? "";
if (data.binding.transport === "ssh_tunnel") return data.binding.sshHost ?? "";
return data.binding.host ? `${data.binding.host}:${data.binding.port ?? 5432}` : "";
},
};
const status: ColDef<CatalogDatabase> = {
headerName: "Status",
minWidth: compact ? 96 : 130,
width: compact ? 96 : undefined,
flex: compact ? undefined : 0.8,
cellClass: compact ? "thot-database-status-cell" : undefined,
valueGetter: ({ data }) => (data ? statusLabel(data) : ""),
cellRenderer: ({ data }: ICellRendererParams<CatalogDatabase>) => (
data ? <StatusPill row={data} /> : null
),
};
const updated: ColDef<CatalogDatabase> = {
field: "updatedAt",
headerName: "Updated",
minWidth: 170,
flex: 0.9,
valueFormatter: ({ value }) => value ? new Date(String(value)).toLocaleString() : "",
};
const actionsWidth = compact ? 148 : 164;
const actions: ColDef<CatalogDatabase> = {
colId: "actions",
headerName: "Actions",
width: actionsWidth,
minWidth: actionsWidth,
maxWidth: actionsWidth,
pinned: "right",
lockPinned: true,
sortable: false,
filter: false,
resizable: false,
suppressMovable: true,
suppressHeaderMenuButton: true,
cellClass: "thot-database-actions-cell",
cellRenderer: DatabaseActionsCell,
};
return compact
? [workspace, status, database, schema, transport, endpoint, updated, actions]
: [workspace, database, schema, transport, endpoint, status, updated, actions];
}, [compact]);
const configuredCount = rows.filter((row) => row.configured).length;
const hiddenSelected = selectedRows.filter((row) => {
const term = search.trim().toLocaleLowerCase();
return term && ![row.workspaceName, row.databaseName, row.schema].some((value) => value.toLocaleLowerCase().includes(term));
}).length;
const canTestSelection = canManage && selectedRows.length > 0 && selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun);
const canSyncSelection = canManage && selectedRows.length > 0 && selectedRows.every((row) => row.configured && row.id && row.connectionStatus === "reachable" && row.testedVersion === row.version && !row.activeSyncRun);
const perform = async (kind: "test" | "sync", operation: () => Promise<void>) => {
setAction(kind);
try {
await operation();
gridRef.current?.api.deselectAll();
setSelectedRows([]);
} finally { setAction(null); }
};
return (
<section aria-label="Workspace databases" className="mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5">
<div className="flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2">
{selectedRows.length > 0 ? (
<>
<span className="text-sm font-semibold">{selectedRows.length} selected</span>
{hiddenSelected ? <span className="text-xs text-muted-foreground">{hiddenSelected} hidden by filter</span> : null}
<Menu.Root>
<Menu.Trigger className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={action !== null}>Actions <ChevronDown className="size-4" /></Menu.Trigger>
<Menu.Portal>
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canTestSelection} onClick={() => void perform("test", () => onTestSelected(selectedRows))}>Test connections</Menu.Item>
<Menu.Separator className="my-1 h-px bg-border" />
{databaseSyncScopes.map(({ scope, label }) => (
<Menu.Item
key={scope}
className={databaseSyncItemClass}
disabled={!canSyncSelection}
onClick={() => void perform("sync", () => onSyncSelected(selectedRows, scope))}
>
{label}
</Menu.Item>
))}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<Button type="button" variant="ghost" disabled={action !== null} onClick={() => { gridRef.current?.api.deselectAll(); setSelectedRows([]); }}><X />Clear</Button>
</>
) : <><span className="thot-label whitespace-nowrap">Workspace databases</span><input
ref={searchInputRef}
className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none transition focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label="Search databases"
placeholder="Search"
value={search}
onChange={(event) => onSearchChange(event.target.value)}
/></>}
<span className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">
{configuredCount}/{rows.length}
</span>
{isFetching && !isLoading ? (
<span className="text-xs text-muted-foreground" role="status">Refreshing</span>
) : null}
</div>
<div className="relative min-h-[280px] flex-1">
<div className="thot-database-grid ag-theme-alpine absolute inset-0 h-full w-full">
<AgGridReact<CatalogDatabase>
ref={gridRef}
rowData={rows}
columnDefs={columnDefs}
context={context}
loading={isLoading}
quickFilterText={search}
defaultColDef={{
sortable: true,
filter: true,
resizable: true,
suppressHeaderMenuButton: false,
}}
getRowId={({ data }) => data.workspaceId}
rowSelection={{ mode: "multiRow", selectAll: "filtered", enableClickSelection: false }}
selectionColumnDef={{ width: 44, maxWidth: 44, pinned: "left" }}
onSelectionChanged={({ api }) => setSelectedRows(api.getSelectedRows())}
rowHeight={44}
headerHeight={38}
animateRows={false}
/>
</div>
{isLoading ? (
<div className="pointer-events-none absolute inset-0 grid place-items-center bg-card/80 text-sm text-muted-foreground" role="status">
Loading database configurations…
</div>
) : rows.length === 0 ? (
<div className="pointer-events-none absolute inset-0 grid place-items-center bg-card px-6 text-center text-sm text-muted-foreground" role="status">
No repository workspaces are available for database configuration.
</div>
) : null}
</div>
</section>
);
}
@@ -0,0 +1,103 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { AgGridReact } from "ag-grid-react";
import type { ColDef } from "ag-grid-community";
import { ArrowLeft, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { apiErrorMessage } from "../../api/client";
import {
listCatalogRelationships,
startCatalogSync,
type CatalogDatabase,
type CatalogRelationship,
type CatalogSyncRun,
} from "../../api/catalog-databases";
interface Props {
database: CatalogDatabase;
canManage: boolean;
onBackToDatabases: () => void;
onOpenOverview: () => void;
onOpenTables: () => void;
onRunStarted: (run: CatalogSyncRun) => void;
onOpenSync: () => void;
}
export function DatabaseRelationships({
database,
canManage,
onBackToDatabases,
onOpenOverview,
onOpenTables,
onRunStarted,
onOpenSync,
}: Props) {
const databaseId = database.id!;
const { data = [], isLoading, isFetching, refetch } = useQuery({
queryKey: ["catalog-relationships", databaseId],
queryFn: () => listCatalogRelationships(databaseId),
retry: false,
});
const [search, setSearch] = useState("");
const [busy, setBusy] = useState(false);
const bindingReady = database.connectionStatus === "reachable" && database.testedVersion === database.version;
const synchronize = async (scope: "relationships" | "all") => {
setBusy(true);
try {
const run = await startCatalogSync(databaseId, database.version, scope);
onRunStarted(run);
toast.success(scope === "all" ? "Full schema synchronization started" : "Relationship synchronization started");
} catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(false); }
};
const columns = useMemo<ColDef<CatalogRelationship>[]>(() => [
{ field: "constraintName", headerName: "Constraint", minWidth: 220, flex: 1, cellClass: "font-mono text-xs" },
{ field: "sourceTableName", headerName: "Source table", minWidth: 200, flex: 1 },
{ headerName: "Source columns", minWidth: 200, flex: 1, valueGetter: ({ data: row }) => row?.columns.map((pair) => pair.sourceColumnName).join(", ") ?? "" },
{ field: "targetTableName", headerName: "Target table", minWidth: 200, flex: 1 },
{ headerName: "Target columns", minWidth: 200, flex: 1, valueGetter: ({ data: row }) => row?.columns.map((pair) => pair.targetColumnName).join(", ") ?? "" },
{ field: "updateRule", headerName: "On update", minWidth: 125, width: 125 },
{ field: "deleteRule", headerName: "On delete", minWidth: 125, width: 125 },
], []);
return (
<section aria-label={`Relationships for ${database.workspaceName}`} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<div className="px-4 pt-5 sm:px-5">
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={busy} onClick={onBackToDatabases}><ArrowLeft />Back to databases</Button>
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5">
<div>
<p className="thot-label mb-1">Database management</p>
<h2 className="font-heading text-2xl font-semibold tracking-tight">{database.workspaceName}</h2>
<p className="mt-1 text-sm text-muted-foreground">{database.databaseName} · {database.schema}</p>
</div>
<div className="flex gap-2">
{database.activeSyncRun ? <Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />View sync</Button> : null}
<Button type="button" disabled={!canManage || !bindingReady || busy || Boolean(database.activeSyncRun)} onClick={() => void synchronize("all")}><RefreshCw />Sync all</Button>
</div>
</div>
<nav aria-label="Database sections" className="flex gap-1 border-b border-border" role="tablist">
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={onOpenOverview}>Overview</button>
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={onOpenTables}>Tables</button>
<button type="button" role="tab" aria-selected="true" className="border-b-2 border-primary px-3 py-2 text-sm font-semibold text-foreground">Relationships</button>
</nav>
</div>
<div className="mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5">
<div className="flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2">
<span className="thot-label whitespace-nowrap">Physical relationships</span>
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search relationships" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span className="text-xs tabular-nums text-muted-foreground">{data.length}</span>
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
<Button type="button" disabled={!canManage || !bindingReady || busy || Boolean(database.activeSyncRun)} title={!bindingReady ? "Test the current database binding before synchronizing relationships" : undefined} onClick={() => void synchronize("relationships")}><RefreshCw />Sync relationships</Button>
</div>
{!bindingReady ? <div className="border-b border-border bg-muted/35 px-4 py-3 text-sm text-muted-foreground">Test the current database binding from Overview before synchronizing relationships.</div> : null}
<div className="relative min-h-[280px] flex-1">
<div className="thot-database-grid ag-theme-alpine absolute inset-0 h-full w-full">
<AgGridReact<CatalogRelationship> rowData={data} columnDefs={columns} loading={isLoading} quickFilterText={search} defaultColDef={{ sortable: true, filter: true, resizable: true }} getRowId={({ data: row }) => row.id} rowHeight={44} headerHeight={38} animateRows={false} overlayNoRowsTemplate="No physical relationships synchronized for this database." />
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,54 @@
import { Menu } from "@base-ui/react/menu";
import { ChevronDown, RefreshCw } from "lucide-react";
import { buttonVariants } from "../../components/ui/button";
import type { CatalogSyncScope } from "../../api/catalog-databases";
export const databaseSyncItemClass = [
"flex cursor-default select-none items-center rounded-md px-3 py-2 text-sm outline-none",
"data-[highlighted]:bg-muted data-[disabled]:opacity-45",
].join(" ");
export const databaseSyncScopes: ReadonlyArray<{ scope: CatalogSyncScope; label: string }> = [
{ scope: "tables", label: "Synchronize tables" },
{ scope: "columns", label: "Synchronize all columns" },
{ scope: "relationships", label: "Synchronize relationships" },
{ scope: "all", label: "Synchronize all" },
];
interface DatabaseSyncMenuProps {
disabled: boolean;
disabledReason?: string;
onSelect: (scope: CatalogSyncScope) => void;
}
export function DatabaseSyncMenu({ disabled, disabledReason, onSelect }: DatabaseSyncMenuProps) {
return (
<Menu.Root>
<Menu.Trigger
type="button"
className={buttonVariants()}
disabled={disabled}
title={disabledReason}
aria-label="Synchronize database schema"
>
<RefreshCw />Synchronize<ChevronDown />
</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner side="bottom" align="end" sideOffset={4}>
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
{databaseSyncScopes.map(({ scope, label }) => (
<Menu.Item
key={scope}
className={databaseSyncItemClass}
disabled={disabled}
onClick={() => onSelect(scope)}
>
{label}
</Menu.Item>
))}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
);
}
@@ -0,0 +1,315 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AgGridReact } from "ag-grid-react";
import type { ColDef, ICellRendererParams } from "ag-grid-community";
import { ArrowLeft, Columns3, Pencil, RefreshCw, Save, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { ApiError, apiErrorMessage } from "../../api/client";
import {
listCatalogTables,
startCatalogSync,
updateCatalogTableMetadata,
type CatalogDatabase,
type CatalogSyncRun,
type CatalogTable,
} from "../../api/catalog-databases";
import type { DatabaseNavigationState } from "./model";
import { DatabaseColumns } from "./DatabaseColumns";
interface Props {
database: CatalogDatabase;
canManage: boolean;
activeRun?: CatalogSyncRun;
onBackToDatabases: () => void;
onOpenOverview: () => void;
onOpenRelationships: () => void;
onNavigationStateChange: (state: DatabaseNavigationState) => void;
onRunStarted: (run: CatalogSyncRun) => void;
onOpenSync: () => void;
}
interface TableGridContext {
canManage: boolean;
onOpen: (table: CatalogTable, section: "overview" | "columns", origin: HTMLButtonElement) => void;
}
function TableActionsCell({ data, context }: ICellRendererParams<CatalogTable, unknown, TableGridContext>) {
if (!data || !context) return null;
return (
<div className="flex h-full items-center justify-end" onClick={(event) => event.stopPropagation()}>
<Button type="button" variant="ghost" size="icon-lg" aria-label={`View columns for ${data.name}`} title={`View columns for ${data.name}`} onClick={(event) => context.onOpen(data, "columns", event.currentTarget)}>
<Columns3 aria-hidden="true" />
</Button>
<Button type="button" variant="ghost" size="icon-lg" aria-label={`Edit description for ${data.name}`} title={`Edit metadata for ${data.name}`} disabled={!context.canManage} onClick={(event) => context.onOpen(data, "overview", event.currentTarget)}>
<Pencil aria-hidden="true" />
</Button>
</div>
);
}
export function DatabaseTables({
database,
canManage,
activeRun,
onBackToDatabases,
onOpenOverview,
onOpenRelationships,
onNavigationStateChange,
onRunStarted,
onOpenSync,
}: Props) {
const databaseId = database.id!;
const queryClient = useQueryClient();
const queryKey = ["catalog-tables", databaseId] as const;
const { data = [], isLoading, isFetching, refetch } = useQuery({ queryKey, queryFn: () => listCatalogTables(databaseId), retry: false });
const [search, setSearch] = useState("");
const [displayedCount, setDisplayedCount] = useState(0);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [activeTableId, setActiveTableId] = useState<string | null>(null);
const [tableSection, setTableSection] = useState<"overview" | "columns">("overview");
const [description, setDescription] = useState("");
const [generatedDescription, setGeneratedDescription] = useState("");
const [baseline, setBaseline] = useState("");
const [editorVersion, setEditorVersion] = useState<number | null>(null);
const [stale, setStale] = useState(false);
const [staleBannerOpen, setStaleBannerOpen] = useState(true);
const [busy, setBusy] = useState<"sync" | "save" | null>(null);
const [columnNavigation, setColumnNavigation] = useState<DatabaseNavigationState>({ dirty: false, busy: false });
const gridRef = useRef<AgGridReact<CatalogTable>>(null);
const originRef = useRef<HTMLButtonElement | null>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
const activeTable = activeTableId ? data.find((table) => table.id === activeTableId) : undefined;
const editorFingerprint = JSON.stringify([description, generatedDescription]);
const editorDirty = Boolean(activeTableId && editorFingerprint !== baseline);
const dirty = tableSection === "columns" ? columnNavigation.dirty : editorDirty;
const navigationBusy = busy !== null || (tableSection === "columns" && columnNavigation.busy);
const bindingReady = database.connectionStatus === "reachable" && database.testedVersion === database.version;
const currentRun = activeRun ?? database.activeSyncRun;
useEffect(() => { onNavigationStateChange({ dirty, busy: navigationBusy }); }, [dirty, navigationBusy, onNavigationStateChange]);
useEffect(() => {
if (!activeTableId || !activeTable || editorVersion === activeTable.version || busy) return;
setStale(true);
setStaleBannerOpen(true);
}, [activeTable, activeTableId, busy, editorVersion]);
const openTable = (table: CatalogTable, section: "overview" | "columns", origin: HTMLButtonElement) => {
originRef.current = origin;
setActiveTableId(table.id);
setTableSection(section);
setDescription(table.description ?? "");
setGeneratedDescription(table.generatedDescription ?? "");
setBaseline(JSON.stringify([table.description ?? "", table.generatedDescription ?? ""]));
setEditorVersion(table.version);
setStale(false);
setStaleBannerOpen(true);
setColumnNavigation({ dirty: false, busy: false });
};
const leaveTable = () => {
if (navigationBusy) return;
if (dirty && !window.confirm("Discard unsaved metadata?")) return;
setActiveTableId(null);
setTableSection("overview");
window.setTimeout(() => originRef.current?.focus(), 0);
};
const navigateDatabase = (target: "databases" | "overview" | "relationships") => {
if (navigationBusy) return;
if (dirty && !window.confirm("Discard unsaved metadata?")) return;
if (target === "databases") onBackToDatabases();
else if (target === "overview") onOpenOverview();
else onOpenRelationships();
};
const save = async () => {
if (!activeTable || editorVersion === null) return;
setBusy("save");
try {
const updated = await updateCatalogTableMetadata(
databaseId, activeTable.id, editorVersion,
description.trim() || null, generatedDescription.trim() || null,
);
queryClient.setQueryData<CatalogTable[]>(queryKey, (current = []) => current.map((table) => table.id === updated.id ? updated : table));
setDescription(updated.description ?? "");
setGeneratedDescription(updated.generatedDescription ?? "");
setBaseline(JSON.stringify([updated.description ?? "", updated.generatedDescription ?? ""]));
setEditorVersion(updated.version);
setStale(false);
toast.success("Table metadata saved");
} catch (error) {
if (error instanceof ApiError && error.code === "table_stale") {
await refetch();
setStale(true);
setStaleBannerOpen(true);
} else toast.error(apiErrorMessage(error));
} finally { setBusy(null); }
};
const reloadTable = async () => {
if (!activeTableId) return;
setBusy("sync");
try {
const result = await refetch();
const latest = result.data?.find((table) => table.id === activeTableId);
if (!latest) { leaveTable(); return; }
setDescription(latest.description ?? "");
setGeneratedDescription(latest.generatedDescription ?? "");
setBaseline(JSON.stringify([latest.description ?? "", latest.generatedDescription ?? ""]));
setEditorVersion(latest.version);
setStale(false);
setStaleBannerOpen(true);
} catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(null); }
};
const synchronize = async (scope: "tables" | "columns" | "all", tableIds: string[] = []) => {
setBusy("sync");
try {
const run = await startCatalogSync(databaseId, database.version, scope, tableIds);
onRunStarted(run);
gridRef.current?.api.deselectAll();
setSelectedIds([]);
toast.success(scope === "all" ? "Full schema synchronization started" : scope === "columns" ? "Column synchronization started" : "Table synchronization started");
} catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(null); }
};
const columns = useMemo<ColDef<CatalogTable>[]>(() => [
{ field: "name", headerName: "Name", minWidth: 250, flex: 1, cellClass: "font-mono text-xs" },
{ field: "sourceComment", headerName: "Source comment", minWidth: 260, flex: 1.25, valueFormatter: ({ value }) => value ?? "" },
{ field: "generatedDescription", headerName: "Generated description", minWidth: 280, flex: 1.25, valueFormatter: ({ value }) => value ?? "" },
{ field: "description", headerName: "Description", minWidth: 280, flex: 1.25, valueFormatter: ({ value }) => value ?? "" },
{ colId: "actions", headerName: "", width: 104, minWidth: 104, maxWidth: 104, pinned: "right", sortable: false, filter: false, resizable: false, cellRenderer: TableActionsCell },
], []);
const context = useMemo<TableGridContext>(() => ({ canManage, onOpen: openTable }), [canManage, data]);
const visibleSelected = useMemo(() => {
const term = search.trim().toLocaleLowerCase();
if (!term) return selectedIds.length;
const selected = new Set(selectedIds);
return data.filter((table) => selected.has(table.id) && [table.name, table.sourceComment, table.generatedDescription, table.description]
.some((value) => value?.toLocaleLowerCase().includes(term))).length;
}, [data, search, selectedIds]);
const databaseHeader = (
<div className="px-4 pt-5 sm:px-5">
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={navigationBusy} onClick={() => navigateDatabase("databases")}><ArrowLeft />Back to databases</Button>
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5">
<div>
<p className="thot-label mb-1">Database management</p>
<h2 ref={headingRef} tabIndex={-1} className="font-heading text-2xl font-semibold tracking-tight outline-none">{database.workspaceName}</h2>
<p className="mt-1 text-sm text-muted-foreground">{database.databaseName} · {database.schema}</p>
</div>
<div className="flex gap-2">
{currentRun ? <Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />View sync</Button> : null}
<Button type="button" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("all")}><RefreshCw />Sync all</Button>
</div>
</div>
<nav aria-label="Database sections" className="flex gap-1 border-b border-border" role="tablist">
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={() => navigateDatabase("overview")}>Overview</button>
<button type="button" role="tab" aria-selected="true" className="border-b-2 border-primary px-3 py-2 text-sm font-semibold text-foreground">Tables</button>
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={() => navigateDatabase("relationships")}>Relationships</button>
</nav>
</div>
);
if (activeTable) {
return (
<section aria-label={`Table ${activeTable.name}`} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
{databaseHeader}
<div className="mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5">
<div className="border-b border-border px-4 pt-4">
<Button type="button" variant="ghost" className="-ml-2 mb-2" onClick={leaveTable}><ArrowLeft />Back to tables</Button>
<p className="thot-label">Physical table</p>
<h3 className="mt-1 font-heading text-xl font-semibold">{activeTable.name}</h3>
<nav aria-label="Table sections" className="mt-3 flex gap-1" role="tablist">
<button type="button" role="tab" aria-selected={tableSection === "overview"} className={`border-b-2 px-3 py-2 text-sm font-semibold ${tableSection === "overview" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`} onClick={() => setTableSection("overview")}>Overview</button>
<button type="button" role="tab" aria-selected={tableSection === "columns"} className={`border-b-2 px-3 py-2 text-sm font-semibold ${tableSection === "columns" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`} onClick={() => setTableSection("columns")}>Columns</button>
</nav>
</div>
{tableSection === "columns" ? (
<DatabaseColumns databaseId={databaseId} table={activeTable} canManage={canManage} onNavigationStateChange={setColumnNavigation} onSync={() => void synchronize("columns", [activeTable.id])} />
) : (
<div className="min-h-0 overflow-y-auto px-4 py-5">
<p className="text-sm text-muted-foreground">Only review metadata can be changed.</p>
{stale ? (
staleBannerOpen ? (
<div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm">
<p className="font-semibold">A newer table description is available.</p>
<p className="mt-1 text-muted-foreground">Your draft is preserved until you reload the current metadata.</p>
<div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadTable()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>Keep editing</Button></div>
</div>
) : <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>Reload the latest value before this description can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>Reload latest</Button></div>
) : null}
<div className="mt-5 grid gap-4">
<label className="grid gap-1.5 text-sm font-semibold">Physical table name<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={activeTable.name} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">Source comment<textarea className="min-h-24 rounded-md border border-input bg-muted/35 px-3 py-2 text-sm" value={activeTable.sourceComment ?? ""} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">Generated description<textarea className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={generatedDescription} disabled={!canManage || busy !== null} onChange={(event) => setGeneratedDescription(event.target.value)} /></label>
<label className="grid gap-1.5 text-sm font-semibold">Description<textarea className="min-h-36 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={description} disabled={!canManage || busy !== null} onChange={(event) => setDescription(event.target.value)} /><span className="font-normal text-muted-foreground">Leave blank to keep the curated description empty.</span></label>
</div>
<div className="mt-5 flex justify-end gap-2 border-t border-border pt-4">
<Button type="button" variant="outline" disabled={busy !== null} onClick={leaveTable}>Cancel</Button>
<Button type="button" disabled={!canManage || !editorDirty || busy !== null || stale} onClick={() => void save()}><Save />{busy === "save" ? "Saving…" : "Save metadata"}</Button>
</div>
</div>
)}
</div>
</section>
);
}
return (
<section aria-label={`Tables for ${database.workspaceName}`} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
{databaseHeader}
<div className="mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5">
<div className="flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2">
{selectedIds.length > 0 ? (
<>
<span className="text-sm font-semibold">{selectedIds.length} selected</span>
{visibleSelected !== selectedIds.length ? <span className="text-xs text-muted-foreground">{selectedIds.length - visibleSelected} hidden by filter</span> : null}
<Button
type="button"
variant="outline"
disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)}
title={!bindingReady ? "Test the current database binding before synchronizing columns" : undefined}
onClick={() => void synchronize("columns", selectedIds)}
>
<RefreshCw className={busy === "sync" ? "animate-spin" : ""} />Synchronize columns
</Button>
<Button type="button" variant="ghost" onClick={() => { gridRef.current?.api.deselectAll(); setSelectedIds([]); }}><X />Clear</Button>
</>
) : (
<>
<span className="thot-label whitespace-nowrap">Catalog tables</span>
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search tables" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span
aria-live="polite"
className="whitespace-nowrap text-xs tabular-nums text-muted-foreground"
>
{search.trim() ? `${displayedCount} of ${data.length}` : displayedCount}
</span>
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
<Button type="button" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} title={!bindingReady ? "Test the current database binding before synchronizing tables" : undefined} onClick={() => void synchronize("tables")}><RefreshCw className={busy === "sync" ? "animate-spin" : ""} />Sync tables</Button>
</>
)}
</div>
{!bindingReady ? <div className="border-b border-border bg-muted/35 px-4 py-3 text-sm text-muted-foreground">Test the current database binding from Overview before synchronizing tables.</div> : null}
<div className="relative min-h-[280px] flex-1">
<div className="thot-database-grid ag-theme-alpine absolute inset-0 h-full w-full">
<AgGridReact<CatalogTable>
ref={gridRef}
rowData={data}
columnDefs={columns}
context={context}
loading={isLoading}
quickFilterText={search}
defaultColDef={{ sortable: true, filter: true, resizable: true }}
getRowId={({ data: table }) => table.id}
rowSelection={{ mode: "multiRow", selectAll: "filtered", enableClickSelection: false }}
onSelectionChanged={({ api }) => setSelectedIds(api.getSelectedRows().map((table) => table.id))}
onModelUpdated={({ api }) => setDisplayedCount(api.getDisplayedRowCount())}
rowHeight={44}
headerHeight={38}
animateRows={false}
overlayNoRowsTemplate="No catalog tables. Test the binding, then sync tables."
/>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,120 @@
import type {
CatalogDatabase,
CatalogSecretName,
DatabaseConfiguration,
} from "../../api/catalog-databases";
export type DatabaseFormMode = "add" | "view" | "edit" | "delete";
export type DatabaseScreen =
| { kind: "list" }
| { kind: "tables"; workspaceId: string }
| { kind: "relationships"; workspaceId: string }
| {
kind: DatabaseFormMode;
workspaceId: string;
workspaceLocked?: boolean;
};
export type DatabaseBusyAction = "save" | "test" | "delete" | "retry-secrets" | "reload" | null;
export type SecretDraft = Partial<Record<CatalogSecretName, string>>;
export interface DatabaseFormDraft extends DatabaseConfiguration {
secrets: SecretDraft;
}
export interface DatabaseNavigationState {
dirty: boolean;
busy: boolean;
}
export function draftFrom(row: CatalogDatabase): DatabaseFormDraft {
return {
workspaceId: row.workspaceId,
engine: "postgres",
databaseName: row.databaseName,
schema: row.schema,
binding: {
port: 5432,
restPath: "/health",
restAuth: "x-api-key",
sshPort: 22,
sshTargetPort: 5432,
...row.binding,
},
secrets: {},
};
}
export function configurationFromDraft(draft: DatabaseFormDraft): DatabaseConfiguration {
const binding = draft.binding.transport === "postgres_direct"
? {
transport: draft.binding.transport,
host: draft.binding.host,
port: draft.binding.port,
username: draft.binding.username,
tlsServername: draft.binding.tlsServername,
}
: draft.binding.transport === "rest_api"
? {
transport: draft.binding.transport,
baseUrl: draft.binding.baseUrl,
restPath: draft.binding.restPath,
restAuth: draft.binding.restAuth,
}
: {
transport: draft.binding.transport,
username: draft.binding.username,
tlsServername: draft.binding.tlsServername,
sshHost: draft.binding.sshHost,
sshPort: draft.binding.sshPort,
sshUsername: draft.binding.sshUsername,
sshTargetHost: draft.binding.sshTargetHost,
sshTargetPort: draft.binding.sshTargetPort,
};
return {
workspaceId: draft.workspaceId,
engine: draft.engine,
databaseName: draft.databaseName,
schema: draft.schema,
binding,
};
}
export function configurationFingerprint(draft: DatabaseFormDraft): string {
return JSON.stringify(configurationFromDraft(draft));
}
export function hasSecretChanges(draft: DatabaseFormDraft | null): boolean {
return Boolean(draft && Object.keys(secretReplacements(draft)).length);
}
export function secretReplacements(draft: DatabaseFormDraft): SecretDraft {
const allowed: CatalogSecretName[] = draft.binding.transport === "postgres_direct"
? ["password", "tlsCa"]
: draft.binding.transport === "rest_api"
? draft.binding.restAuth === "none" ? [] : ["apiKey"]
: [
"password",
"sshPrivateKey",
"sshPrivateKeyPassphrase",
"sshKnownHosts",
"tlsCa",
];
return Object.fromEntries(
Object.entries(draft.secrets).filter(([name, value]) => (
allowed.includes(name as CatalogSecretName) && Boolean(value)
)),
) as SecretDraft;
}
export function statusLabel(row: CatalogDatabase): string {
if (!row.workspaceAvailable) return "Workspace missing";
if (!row.configured) return "Not configured";
if (row.connectionStatus === "reachable") return "Reachable";
if (row.connectionStatus === "failed") return "Failed";
return "Untested";
}