feat: complete catalog fleet management workflow

This commit is contained in:
Codex
2026-08-31 15:58:43 +02:00
parent 866aee4249
commit 9c697dc062
56 changed files with 6692 additions and 611 deletions
@@ -16,6 +16,7 @@ import {
type CatalogSyncPhase,
type CatalogSyncRun,
} from "../../api/catalog-databases";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
interface Props {
databaseId: string | null;
@@ -101,7 +102,7 @@ export function CatalogSyncDrawer({
const runQuery = useQuery({
queryKey: ["catalog-sync-run", runId],
queryFn: () => getCatalogSyncRun(runId!),
enabled: Boolean(runId),
enabled: Boolean(runId && open),
retry: false,
refetchInterval: (query) => terminal(query.state.data) ? false : 1_000,
});
@@ -113,6 +114,12 @@ export function CatalogSyncDrawer({
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),
@@ -218,25 +225,74 @@ export function CatalogSyncDrawer({
}
};
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}
const historySection = (
<section className="thot-catalog-drawer__history mt-5" aria-label="Synchronization history">
<h3 className="thot-label mb-2">Recent runs</h3>
{historyQuery.isLoading ? <p className="text-sm text-muted-foreground">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">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" /> Retry history
</Button>
</div>
<Button type="button" variant="ghost" size="icon-lg" aria-label="Close synchronization drawer" onClick={onClose}>
<X aria-hidden="true" />
</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">
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}
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>{item.scope}</span>
<span className="text-xs text-muted-foreground">{stateLabel(item)} · {new Date(item.createdAt).toLocaleString()}</span>
</button>
))}
</div>
) : null}
</section>
);
<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}
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 />} 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}
</>
) : undefined;
return (
<FleetLedgerDrawer
open
ariaLabel="Schema synchronization"
eyebrow="Schema synchronization"
title={run ? `${stateLabel(run)[0].toUpperCase()}${stateLabel(run).slice(1)}` : "Synchronization history"}
description={run ? `${run.scope} · ${elapsed(run, now)}` : undefined}
onClose={onClose}
closeLabel="Close synchronization drawer"
busy={action !== null}
bodyClassName="thot-catalog-drawer__history-layout"
footer={footer}
>
{runQuery.isLoading ? <p className="text-sm text-muted-foreground">Loading synchronization…</p> : null}
{run ? (
<>
<section aria-labelledby="synchronization-steps-heading">
@@ -365,38 +421,22 @@ export function CatalogSyncDrawer({
</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>
{historySection}
</>
) : 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>
) : (
<>
<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 ? "This synchronization run could not be loaded." : historyQuery.isLoading ? "Loading synchronization history…" : "No run selected."}</p>
<p className="mt-1 text-muted-foreground">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" /> Retry run
</Button>
) : null}
</div>
{historySection}
</>
)}
</FleetLedgerDrawer>
);
}
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Menu } from "@base-ui/react/menu";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AgGridReact } from "ag-grid-react";
@@ -18,6 +18,8 @@ import {
type SensitiveDataSuggestionRequest,
} from "../../api/catalog-databases";
import type { DatabaseNavigationState } from "./model";
import { FleetActionSelector, type FleetActionOption } from "./FleetActionSelector";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
interface Props {
databaseId: string;
@@ -29,11 +31,17 @@ interface Props {
onNavigationStateChange: (state: DatabaseNavigationState) => void;
onSync: () => void;
onSuggestSensitive: (selection: SensitiveDataSuggestionRequest, scopeLabel: string) => Promise<void>;
bindingReady?: boolean;
catalogOperationActive?: boolean;
onCatalogMetricsChanged?: () => void | Promise<void>;
metadataModelControl?: ReactNode;
presentation?: "legacy" | "fleet";
}
interface GridContext {
canManage: boolean;
busy: boolean;
presentation: "legacy" | "fleet";
onEdit: (column: CatalogColumn, origin: HTMLButtonElement) => void;
onSensitiveChange: (column: CatalogColumn, sensitive: boolean) => void;
}
@@ -52,9 +60,11 @@ function ActionCell({ data, context }: ICellRendererParams<CatalogColumn, unknow
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>
<span className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? "Edit metadata" : undefined}>
<Button type="button" variant="ghost" size="icon-lg" disabled={!context.canManage} aria-label={`Edit metadata for ${data.name}`} title={context.presentation === "legacy" ? `Edit metadata for ${data.name}` : undefined} onClick={(event) => context.onEdit(data, event.currentTarget)}>
<Pencil aria-hidden="true" />
</Button>
</span>
</div>
);
}
@@ -86,10 +96,15 @@ export function DatabaseColumns({
onNavigationStateChange,
onSync,
onSuggestSensitive,
bindingReady = true,
catalogOperationActive = false,
onCatalogMetricsChanged,
metadataModelControl,
presentation = "legacy",
}: Props) {
const queryClient = useQueryClient();
const queryKey = ["catalog-columns", databaseId, table.id] as const;
const { data = [], isLoading, isFetching, refetch } = useQuery({
const { data = [], error, isError, isLoading, isFetching, refetch } = useQuery({
queryKey,
queryFn: () => listCatalogColumns(databaseId, table.id),
retry: false,
@@ -159,6 +174,7 @@ export function DatabaseColumns({
setBaseline(JSON.stringify([updated.description ?? "", updated.generatedDescription ?? ""]));
setVersion(updated.version);
setStale(false);
void onCatalogMetricsChanged?.();
toast.success("Column metadata saved");
} catch (error) {
if (error instanceof ApiError && error.code === "column_stale") {
@@ -190,6 +206,7 @@ export function DatabaseColumns({
await queryClient.invalidateQueries({ queryKey, exact: true });
gridRef.current?.api.deselectAll();
setSelectedIds([]);
void onCatalogMetricsChanged?.();
toast.success(`Copied ${counts.copied} description${counts.copied === 1 ? "" : "s"}; skipped ${counts.skipped}`);
} catch (error) {
toast.error(apiErrorMessage(error));
@@ -261,6 +278,7 @@ export function DatabaseColumns({
(column) => updatedById.get(column.id) ?? column,
));
setSensitiveDrafts({});
void onCatalogMetricsChanged?.();
toast.success("Sensitive fields saved");
} catch (error) {
toast.error(apiErrorMessage(error));
@@ -270,6 +288,114 @@ export function DatabaseColumns({
}
};
type FleetColumnAction =
| "generate-descriptions"
| "consolidate-descriptions"
| "suggest-sensitive"
| "sync-columns"
| "save-sensitive";
const fleetActions: readonly FleetActionOption<FleetColumnAction>[] = [
{
id: "generate-descriptions",
label: "Generate descriptions",
group: "Descriptions",
runLabel: "Generate",
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to generate descriptions."
: selectedIds.length === 0
? "Select at least one column."
: !selectedMetadataModel
? "Choose a metadata model first."
: descriptionGenerationActive || catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
? "Another action is running."
: undefined,
},
{
id: "consolidate-descriptions",
label: "Move generated to Description",
group: "Descriptions",
runLabel: "Move",
disabled: !canManage || selectedIds.length === 0 || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to update metadata."
: selectedIds.length === 0
? "Select at least one column."
: catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
? "Another action is running."
: undefined,
},
{
id: "suggest-sensitive",
label: "Suggest sensitive fields",
group: "Sensitive data",
runLabel: "Suggest",
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to review sensitive data."
: selectedIds.length === 0
? "Select at least one column."
: !selectedMetadataModel
? "Choose a metadata model first."
: descriptionGenerationActive || catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
? "Another action is running."
: undefined,
},
{
id: "sync-columns",
label: "Synchronize columns",
group: "Synchronization",
scopeLabel: "All columns in this table",
runLabel: "Synchronize",
disabled: !canManage || !bindingReady || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to synchronize the catalog."
: !bindingReady
? "Test the current database binding first."
: catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
? "Another action is running."
: undefined,
},
{
id: "save-sensitive",
label: "Save sensitive-field changes",
group: "Sensitive data",
runLabel: "Save",
disabled: !canManage || changedSensitiveColumns.length === 0 || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to update sensitive fields."
: changedSensitiveColumns.length === 0
? "No sensitive-field changes are waiting to be saved."
: catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
? "Another action is running."
: undefined,
},
];
const runFleetAction = async (action: FleetColumnAction) => {
if (action === "generate-descriptions") await generateDescriptions();
else if (action === "consolidate-descriptions") await consolidateDescriptions();
else if (action === "suggest-sensitive") await suggestSensitive();
else if (action === "sync-columns") onSync();
else await saveSensitive();
};
const clearSelection = () => {
gridRef.current?.api.deselectAll();
setSelectedIds([]);
};
const columns = useMemo<ColDef<CatalogColumn>[]>(() => [
{ field: "ordinalPosition", headerName: "#", width: 64, maxWidth: 64, filter: "agNumberColumnFilter" },
{ field: "sensitive", headerName: "Sensitive", minWidth: 110, width: 110, sortable: false, filter: false, resizable: false, cellRenderer: SensitiveCell },
@@ -280,16 +406,28 @@ export function DatabaseColumns({
{ 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 },
{ colId: "actions", headerName: "Actions", width: 64, maxWidth: 64, pinned: "right", lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: ActionCell },
], []);
const context = useMemo<GridContext>(() => ({
canManage,
busy,
busy: busy || catalogOperationActive,
presentation,
onEdit: edit,
onSensitiveChange: changeSensitive,
}), [busy, canManage, data]);
}), [busy, canManage, catalogOperationActive, data, presentation]);
const fleetQueryError = presentation === "fleet" && isError ? (
<div className="grid min-h-[280px] flex-1 place-items-center p-6" role="alert">
<div className="max-w-lg rounded-lg border border-destructive/30 bg-destructive/8 p-5 text-sm">
<p className="font-semibold text-destructive">Catalog columns could not be loaded.</p>
<p className="mt-1 leading-5 text-muted-foreground">{apiErrorMessage(error)}</p>
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refetch()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry columns
</Button>
</div>
</div>
) : null;
if (editingId && active) {
if (editingId && active && presentation === "legacy") {
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>
@@ -319,66 +457,71 @@ export function DatabaseColumns({
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">
<div className={`${presentation === "fleet" ? "thot-fleet-grid-toolbar " : ""}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>
<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={busy}>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={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy}
onClick={() => void generateDescriptions()}
>
Generate {selectedIds.length === 1 ? "description" : "descriptions"}
</Menu.Item>
<Menu.Item
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
disabled={!canManage || busy}
onClick={() => void consolidateDescriptions()}
>
Move generated description to Description
</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<Button
type="button"
variant="outline"
disabled={!canManage || descriptionGenerationActive || busy}
title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined}
onClick={() => void suggestSensitive()}
>
<Sparkles />{sensitiveAction === "suggest" ? "Suggesting…" : "Suggest sensitive fields"}
</Button>
{changedSensitiveColumns.length > 0 ? (
<Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}>
<Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}
</Button>
) : null}
<Button type="button" variant="ghost" onClick={() => { gridRef.current?.api.deselectAll(); setSelectedIds([]); }}><X />Clear</Button>
</>
presentation === "fleet" ? (
<>
<FleetActionSelector
actions={fleetActions}
selectedCount={selectedIds.length}
busy={busy}
busyLabel={sensitiveAction === "suggest" ? "Suggesting…" : sensitiveAction === "save" ? "Saving…" : "Running…"}
onRun={runFleetAction}
onClear={clearSelection}
renderContext={(action) => action === "generate-descriptions" || action === "suggest-sensitive" ? metadataModelControl : null}
label="Column action"
/>
</>
) : (
<>
<span className="text-sm font-semibold">{selectedIds.length} selected</span>
<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={busy}>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={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy} onClick={() => void generateDescriptions()}>Generate {selectedIds.length === 1 ? "description" : "descriptions"}</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy} onClick={() => void consolidateDescriptions()}>Move generated description to Description</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<Button type="button" variant="outline" disabled={!canManage || descriptionGenerationActive || busy} title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined} onClick={() => void suggestSensitive()}><Sparkles />{sensitiveAction === "suggest" ? "Suggesting…" : "Suggest sensitive fields"}</Button>
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}><Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
<Button type="button" variant="ghost" onClick={clearSelection}><X />Clear</Button>
</>
)
) : (
<>
<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>
{changedSensitiveColumns.length > 0 ? (
<Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}>
<Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}
</Button>
) : null}
<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>
</>
presentation === "fleet" ? (
<>
<FleetActionSelector
actions={fleetActions}
selectedCount={0}
busy={busy}
busyLabel={sensitiveAction === "save" ? "Saving…" : "Running…"}
onRun={runFleetAction}
onClear={clearSelection}
renderContext={(action) => action === "generate-descriptions" || action === "suggest-sensitive" ? metadataModelControl : null}
label="Column action"
/>
<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>
</>
) : (
<>
<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>
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}><Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
<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">
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
<div className={`thot-database-grid ${presentation === "fleet" ? "thot-fleet-ledger-grid " : ""}ag-theme-alpine absolute inset-0 h-full w-full`}>
<AgGridReact<CatalogColumn>
ref={gridRef}
rowData={displayedColumns}
@@ -396,7 +539,40 @@ export function DatabaseColumns({
overlayNoRowsTemplate="No columns synchronized for this table."
/>
</div>
</div>
</div>}
{presentation === "fleet" && active ? (
<FleetLedgerDrawer
open
eyebrow="Catalog metadata"
title="Review column description"
description="Physical schema fields are read-only. Review fields can be edited."
onClose={closeEditor}
closeLabel="Close column metadata"
busy={busy}
footer={(
<>
<Button type="button" variant="outline" disabled={busy} onClick={closeEditor}>Cancel</Button>
<Button type="button" disabled={!canManage || !editorDirty || busy || stale} onClick={() => void save()}><Save aria-hidden="true" />{busy ? "Saving…" : "Save metadata"}</Button>
</>
)}
>
<p className="thot-label">{table.name} · column</p>
<h3 className="mt-1 break-words font-heading text-xl font-semibold">{active.name}</h3>
{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 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>
</FleetLedgerDrawer>
) : null}
</div>
);
}
@@ -0,0 +1,161 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import type { CatalogDatabase, CatalogTable } from "../../api/catalog-databases";
import { server } from "../../test/msw";
import { DatabaseColumns } from "./DatabaseColumns";
import { DatabaseRelationships } from "./DatabaseRelationships";
import { DatabaseTables } from "./DatabaseTables";
const database: CatalogDatabase = {
id: "11111111-1111-4111-8111-111111111111",
workspaceId: "psd-clinical",
workspaceName: "Policlinico San Donato",
workspaceAvailable: true,
configured: true,
engine: "postgres",
databaseName: "warehouse",
schema: "datawarehouse",
version: 3,
createdAt: "2026-08-27T08:00:00Z",
updatedAt: "2026-08-27T09:00:00Z",
binding: { transport: "postgres_direct", host: "db.internal", port: 5432, username: "reader" },
connectionStatus: "reachable",
testedVersion: 3,
secrets: {
password: false,
apiKey: false,
sshPrivateKey: false,
sshPrivateKeyPassphrase: false,
sshKnownHosts: false,
tlsCa: false,
},
};
const table: CatalogTable = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
databaseId: database.id!,
name: "patients",
sourceComment: "Patients imported from the clinical source",
description: null,
generatedDescription: null,
version: 1,
createdAt: "2026-08-27T08:00:00Z",
updatedAt: "2026-08-27T09:00:00Z",
};
function renderWithQueryClient(node: React.ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{node}</QueryClientProvider>);
}
const noop = () => {};
const noopAsync = async () => {};
test("Fleet tables distinguish a failed query from an empty catalog and retry it", async () => {
const user = userEvent.setup();
let requests = 0;
server.use(http.get("/api/catalog/databases/:databaseId/tables", () => {
requests += 1;
return requests === 1
? HttpResponse.json({ code: "catalog_unavailable" }, { status: 503 })
: HttpResponse.json([]);
}));
renderWithQueryClient(
<DatabaseTables
presentation="fleet"
database={database}
canManage
selectedMetadataModel={null}
descriptionGenerationActive={false}
onBackToDatabases={noop}
onOpenOverview={noop}
onOpenRelationships={noop}
onNavigationStateChange={noop}
onRunStarted={noop}
onOpenSync={noop}
onDescriptionGenerationRunStarted={noop}
onSuggestSensitive={noopAsync}
/>,
);
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Catalog tables could not be loaded.");
expect(alert).toHaveTextContent("The database catalog is unavailable.");
expect(screen.queryByText(/No catalog tables/i)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Retry tables" }));
await waitFor(() => expect(requests).toBe(2));
await waitFor(() => expect(screen.queryByText("Catalog tables could not be loaded.")).not.toBeInTheDocument());
});
test("Fleet columns distinguish a failed query from an empty catalog and retry it", async () => {
const user = userEvent.setup();
let requests = 0;
server.use(http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => {
requests += 1;
return requests === 1
? HttpResponse.json({ code: "catalog_unavailable" }, { status: 503 })
: HttpResponse.json([]);
}));
renderWithQueryClient(
<DatabaseColumns
presentation="fleet"
databaseId={database.id!}
table={table}
canManage
selectedMetadataModel={null}
descriptionGenerationActive={false}
onDescriptionGenerationRunStarted={noop}
onNavigationStateChange={noop}
onSync={noop}
onSuggestSensitive={noopAsync}
/>,
);
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Catalog columns could not be loaded.");
expect(alert).toHaveTextContent("The database catalog is unavailable.");
expect(screen.queryByText(/No columns synchronized/i)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Retry columns" }));
await waitFor(() => expect(requests).toBe(2));
await waitFor(() => expect(screen.queryByText("Catalog columns could not be loaded.")).not.toBeInTheDocument());
});
test("Fleet relationships distinguish a failed query from an empty catalog and retry it", async () => {
const user = userEvent.setup();
let requests = 0;
server.use(http.get("/api/catalog/databases/:databaseId/relationships", () => {
requests += 1;
return requests === 1
? HttpResponse.json({ code: "catalog_unavailable" }, { status: 503 })
: HttpResponse.json([]);
}));
renderWithQueryClient(
<DatabaseRelationships
presentation="fleet"
database={database}
canManage
onBackToDatabases={noop}
onOpenOverview={noop}
onOpenTables={noop}
onRunStarted={noop}
onOpenSync={noop}
/>,
);
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Catalog relationships could not be loaded.");
expect(alert).toHaveTextContent("The database catalog is unavailable.");
expect(screen.queryByText(/No physical relationships synchronized/i)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Retry relationships" }));
await waitFor(() => expect(requests).toBe(2));
await waitFor(() => expect(screen.queryByText("Catalog relationships could not be loaded.")).not.toBeInTheDocument());
});
@@ -2,6 +2,7 @@ import type { RefObject, ReactNode } from "react";
import {
ArrowLeft,
Database,
History,
RefreshCw,
Save,
TestTube2,
@@ -140,12 +141,17 @@ interface DatabaseFormProps {
onSync: (scope: CatalogSyncScope) => void;
onOpenSync: () => void;
activeSyncRun?: CatalogSyncRun;
presentation?: "page" | "drawer";
}
function formTitle(mode: DatabaseFormMode, workspaceLocked: boolean): string {
export function databaseFormTitle(
mode: DatabaseFormMode,
workspaceLocked: boolean,
presentation: "page" | "drawer" = "page",
): string {
if (mode === "add") return workspaceLocked ? "Edit database" : "Add database";
if (mode === "edit") return "Edit database";
if (mode === "delete") return "Delete database";
if (mode === "delete") return presentation === "drawer" ? "Remove configuration" : "Delete database";
return "Database details";
}
@@ -180,8 +186,9 @@ export function DatabaseForm({
onSync,
onOpenSync,
activeSyncRun,
presentation = "page",
}: DatabaseFormProps) {
const title = formTitle(mode, workspaceLocked);
const title = databaseFormTitle(mode, workspaceLocked, presentation);
const readOnly = mode === "view" || mode === "delete";
const editable = mode === "add" || mode === "edit";
const busy = busyAction !== null;
@@ -212,7 +219,7 @@ export function DatabaseForm({
{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.
Secret references associated with this configuration are removed too. The source database is not modified.
</p>
</div>
) : null}
@@ -425,13 +432,50 @@ export function DatabaseForm({
) : null}
{mode === "delete" ? (
<Button type="button" variant="destructive" disabled={!canManage || !row.configured || !row.id || busy || stale} onClick={onDelete}>
<Trash2 /> {busyAction === "delete" ? "Deleting…" : "Delete database"}
<Trash2 /> {busyAction === "delete" ? "Removing…" : presentation === "drawer" ? "Remove configuration" : "Delete database"}
</Button>
) : null}
</div>
</form>
);
if (presentation === "drawer") {
return (
<section aria-label={`${title} form`} className="thot-fleet-drawer-form">
<h2 ref={headingRef} tabIndex={-1} className="sr-only">{title}</h2>
<div className="thot-fleet-drawer-form__context">
<div className="min-w-0">
<p className="thot-label">Workspace</p>
<p className="mt-1 truncate text-sm font-semibold">{row.workspaceName}</p>
<p className="mt-0.5 truncate font-mono text-xs text-muted-foreground">{row.workspaceId}</p>
</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-md border border-border bg-muted px-2.5 py-1 text-xs font-semibold text-muted-foreground">
{statusLabel(row)}
</span>
{mode === "view" && row.configured ? (
<Button type="button" variant="outline" size="sm" className="thot-fleet-nav-action thot-fleet-nav-action--below" data-tooltip="Synchronization history" onClick={onOpenSync}>
{activeSyncRun ? <RefreshCw className="animate-spin" /> : <History />} Sync history
</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>
{row.lastTestedAt ? (
<p className="mb-4 text-xs text-muted-foreground">Last tested {new Date(row.lastTestedAt).toLocaleString()}</p>
) : null}
{form}
</section>
);
}
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">
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
import { Menu } from "@base-ui/react/menu";
import { AgGridReact } from "ag-grid-react";
import {
@@ -9,7 +9,7 @@ import {
} 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, Sparkles, Trash2, X } from "lucide-react";
import { ChevronDown, Eye, History, Info, Pencil, RefreshCw, Rows3, Sparkles, Trash2, X } from "lucide-react";
import { Button } from "../../components/ui/button";
import type {
CatalogDatabase,
@@ -19,6 +19,7 @@ import type {
} from "../../api/catalog-databases";
import { statusLabel } from "./model";
import { databaseSyncItemClass, databaseSyncScopes } from "./DatabaseSyncMenu";
import { FleetActionSelector, type FleetActionOption } from "./FleetActionSelector";
ModuleRegistry.registerModules([AllCommunityModule]);
@@ -31,6 +32,7 @@ interface DatabaseGridProps {
searchInputRef: RefObject<HTMLInputElement>;
onSearchChange: (value: string) => void;
onView: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
onOpenTables?: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
onEdit: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
onDelete: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
onOpenSync: (row: CatalogDatabase) => void;
@@ -47,16 +49,33 @@ interface DatabaseGridProps {
rows: CatalogDatabase[],
target: CatalogDatabaseMetadataDeleteTarget,
) => Promise<void>;
metadataModelControl?: ReactNode;
onRefresh?: () => void | Promise<void>;
presentation?: "legacy" | "fleet";
}
interface DatabaseGridContext {
canManage: boolean;
presentation: "legacy" | "fleet";
onView: DatabaseGridProps["onView"];
onOpenTables?: DatabaseGridProps["onOpenTables"];
onEdit: DatabaseGridProps["onEdit"];
onDelete: DatabaseGridProps["onDelete"];
onOpenSync: DatabaseGridProps["onOpenSync"];
}
type DatabaseFleetAction =
| "test"
| "sync-tables"
| "sync-columns"
| "sync-relationships"
| "sync-all"
| "generate-all"
| "generate-missing"
| "suggest-sensitive"
| "clear-tables"
| "clear-relationships";
function useCompactViewport(): boolean {
const [compact, setCompact] = useState(false);
@@ -100,22 +119,38 @@ function DatabaseActionsCell({
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" />
{data.activeSyncRun || (context.presentation === "fleet" && data.configured && data.id) ? (
<Button type="button" variant="ghost" size="icon-lg" className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? "Synchronization history" : undefined} title={context.presentation === "legacy" ? `View synchronization for ${data.workspaceName}` : undefined} aria-label={`View synchronization history for ${data.workspaceName}`} onClick={() => context.onOpenSync(data)}>
{data.activeSyncRun ? <RefreshCw className="animate-spin" aria-hidden="true" /> : <History aria-hidden="true" />}
</Button>
) : null}
{context.presentation === "fleet" && context.onOpenTables ? (
<span className="thot-fleet-icon-action inline-flex" data-tooltip="Tables">
<Button
type="button"
variant="ghost"
size="icon-lg"
aria-label={`View tables for ${data.workspaceName}`}
disabled={!data.configured || !data.id}
onClick={(event) => context.onOpenTables?.(data, event.currentTarget)}
>
<Rows3 aria-hidden="true" />
</Button>
</span>
) : null}
<Button
type="button"
variant="ghost"
size="icon-lg"
title={`View ${data.workspaceName}`}
className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined}
data-tooltip={context.presentation === "fleet" ? "Details" : undefined}
title={context.presentation === "legacy" ? `View ${data.workspaceName}` : undefined}
aria-label={`View ${data.workspaceName}`}
onClick={(event) => context.onView(data, event.currentTarget)}
>
<Eye aria-hidden="true" />
{context.presentation === "fleet" ? <Info aria-hidden="true" /> : <Eye aria-hidden="true" />}
</Button>
<span title={editDisabled ? "This database configuration cannot be changed" : editLabel}>
<span className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? "Edit configuration" : undefined} title={context.presentation === "legacy" ? (editDisabled ? "This database configuration cannot be changed" : editLabel) : undefined}>
<Button
type="button"
variant="ghost"
@@ -129,7 +164,7 @@ function DatabaseActionsCell({
</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}`}>
<span className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? "Remove configuration" : undefined} title={context.presentation === "legacy" ? (deleteDisabled ? "Save this database configuration before deleting it" : `Delete ${data.workspaceName}`) : undefined}>
<Button
type="button"
variant="ghost"
@@ -157,6 +192,7 @@ export function DatabaseGrid({
searchInputRef,
onSearchChange,
onView,
onOpenTables,
onEdit,
onDelete,
onOpenSync,
@@ -167,17 +203,21 @@ export function DatabaseGrid({
onGenerateDescriptions,
onSuggestSensitive,
onDeleteMetadataSelected,
metadataModelControl,
onRefresh,
presentation = "legacy",
}: DatabaseGridProps) {
const compact = useCompactViewport();
const gridRef = useRef<AgGridReact<CatalogDatabase>>(null);
const actionsTriggerRef = useRef<HTMLButtonElement>(null);
const actionSelectRef = useRef<HTMLSelectElement>(null);
const [selectedRows, setSelectedRows] = useState<CatalogDatabase[]>([]);
const [action, setAction] = useState<"test" | "sync" | "generate" | "suggest" | "delete" | null>(null);
const [pendingDelete, setPendingDelete] = useState<CatalogDatabaseMetadataDeleteTarget | null>(null);
const [pendingGenerateAll, setPendingGenerateAll] = useState(false);
const context = useMemo<DatabaseGridContext>(
() => ({ canManage, onView, onEdit, onDelete, onOpenSync }),
[canManage, onView, onEdit, onDelete, onOpenSync],
() => ({ canManage, presentation, onView, onOpenTables, onEdit, onDelete, onOpenSync }),
[canManage, presentation, onView, onOpenTables, onEdit, onDelete, onOpenSync],
);
const columnDefs = useMemo<ColDef<CatalogDatabase>[]>(() => {
@@ -230,7 +270,7 @@ export function DatabaseGrid({
flex: 0.9,
valueFormatter: ({ value }) => value ? new Date(String(value)).toLocaleString() : "",
};
const actionsWidth = compact ? 148 : 164;
const actionsWidth = presentation === "fleet" ? 196 : compact ? 148 : 164;
const actions: ColDef<CatalogDatabase> = {
colId: "actions",
headerName: "Actions",
@@ -251,7 +291,7 @@ export function DatabaseGrid({
return compact
? [workspace, status, database, schema, transport, endpoint, updated, actions]
: [workspace, database, schema, transport, endpoint, status, updated, actions];
}, [compact]);
}, [compact, presentation]);
const configuredCount = rows.filter((row) => row.configured).length;
const hiddenSelected = selectedRows.filter((row) => {
@@ -265,8 +305,71 @@ export function DatabaseGrid({
&& Boolean(selectedMetadataModel)
&& !descriptionGenerationActive
&& selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun);
const canSuggestSensitive = canManage
&& selectedRows.length === 1
&& Boolean(selectedMetadataModel)
&& !descriptionGenerationActive
&& selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun);
const canDeleteMetadataSelection = canManage && selectedRows.length > 0
&& selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun);
const fleetActions = useMemo<readonly FleetActionOption<DatabaseFleetAction>[]>(() => {
const permissionReason = !canManage ? "Database management permission is required" : undefined;
const testReason = permissionReason
?? (selectedRows.some((row) => !row.configured || !row.id)
? "Every selected workspace must be configured"
: selectedRows.some((row) => row.activeSyncRun)
? "Wait for active synchronization"
: undefined);
const syncReason = permissionReason
?? (selectedRows.some((row) => !row.configured || !row.id)
? "Every selected workspace must be configured"
: selectedRows.some((row) => row.connectionStatus !== "reachable" || row.testedVersion !== row.version)
? "Test every current binding before synchronization"
: selectedRows.some((row) => row.activeSyncRun)
? "Wait for active synchronization"
: undefined);
const generationReason = permissionReason
?? (selectedRows.length !== 1 ? "Select exactly one database" : undefined)
?? (!selectedMetadataModel ? "Choose a metadata model in the generation flow" : undefined)
?? (descriptionGenerationActive ? "Wait for active description generation" : undefined)
?? (selectedRows.some((row) => !row.configured || !row.id || row.activeSyncRun)
? "The selected database must be configured and idle"
: undefined);
const sensitiveReason = permissionReason
?? (selectedRows.length !== 1 ? "Select exactly one database" : undefined)
?? (!selectedMetadataModel ? "Choose a metadata model in the suggestion flow" : undefined)
?? (descriptionGenerationActive ? "Wait for the active metadata operation" : undefined)
?? (selectedRows.some((row) => !row.configured || !row.id || row.activeSyncRun)
? "The selected database must be configured and idle"
: undefined);
const cleanupReason = permissionReason
?? (selectedRows.some((row) => !row.configured || !row.id || row.activeSyncRun)
? "Every selected database must be configured and idle"
: undefined);
return [
{ id: "test", label: "Test connections", group: "Connection", disabled: !canTestSelection, disabledReason: testReason },
{ id: "sync-tables", label: "Synchronize tables", group: "Synchronization", disabled: !canSyncSelection, disabledReason: syncReason },
{ id: "sync-columns", label: "Synchronize all columns", group: "Synchronization", disabled: !canSyncSelection, disabledReason: syncReason },
{ id: "sync-relationships", label: "Synchronize relationships", group: "Synchronization", disabled: !canSyncSelection, disabledReason: syncReason },
{ id: "sync-all", label: "Synchronize all", group: "Synchronization", disabled: !canSyncSelection, disabledReason: syncReason },
{ id: "generate-missing", label: "Generate missing descriptions", group: "Descriptions", disabled: !canGenerateDescriptions, disabledReason: generationReason },
{ id: "generate-all", label: "Generate all descriptions", group: "Descriptions", disabled: !canGenerateDescriptions, disabledReason: generationReason, runLabel: "Review generation" },
{ id: "suggest-sensitive", label: "Suggest sensitive fields", group: "Sensitive data", disabled: !canSuggestSensitive, disabledReason: sensitiveReason, runLabel: "Open review" },
{ id: "clear-tables", label: "Clear catalog tables", group: "Cleanup", disabled: !canDeleteMetadataSelection, disabledReason: cleanupReason, tone: "destructive", runLabel: "Review cleanup" },
{ id: "clear-relationships", label: "Clear catalog relationships", group: "Cleanup", disabled: !canDeleteMetadataSelection, disabledReason: cleanupReason, tone: "destructive", runLabel: "Review cleanup" },
];
}, [
canDeleteMetadataSelection,
canGenerateDescriptions,
canManage,
canSuggestSensitive,
canSyncSelection,
canTestSelection,
descriptionGenerationActive,
selectedMetadataModel,
selectedRows,
]);
useEffect(() => {
if (!pendingDelete) return;
const timer = window.setTimeout(() => document.getElementById("database-cleanup-confirm-button")?.focus(), 0);
@@ -279,11 +382,17 @@ export function DatabaseGrid({
}, [pendingGenerateAll]);
const closeDeleteConfirmation = () => {
setPendingDelete(null);
window.setTimeout(() => actionsTriggerRef.current?.focus(), 0);
window.setTimeout(() => {
if (presentation === "fleet") actionSelectRef.current?.focus();
else actionsTriggerRef.current?.focus();
}, 0);
};
const closeGenerateAllConfirmation = () => {
setPendingGenerateAll(false);
window.setTimeout(() => actionsTriggerRef.current?.focus(), 0);
window.setTimeout(() => {
if (presentation === "fleet") actionSelectRef.current?.focus();
else actionsTriggerRef.current?.focus();
}, 0);
};
const perform = async (
kind: "test" | "sync" | "generate" | "suggest" | "delete",
@@ -305,9 +414,43 @@ export function DatabaseGrid({
} finally { setAction(null); }
};
const runFleetAction = async (selectedAction: DatabaseFleetAction) => {
if (selectedAction === "generate-all") {
setPendingGenerateAll(true);
return;
}
if (selectedAction === "clear-tables" || selectedAction === "clear-relationships") {
setPendingDelete(selectedAction === "clear-tables" ? "tables" : "relationships");
return;
}
if (selectedAction === "test") {
await perform("test", () => onTestSelected(selectedRows));
return;
}
if (selectedAction.startsWith("sync-")) {
const scope = selectedAction.slice("sync-".length) as CatalogSyncScope;
await perform("sync", () => onSyncSelected(selectedRows, scope));
return;
}
if (selectedAction === "generate-missing") {
await perform("generate", () => onGenerateDescriptions(selectedRows, "missing"));
return;
}
await perform("suggest", () => onSuggestSensitive(selectedRows), false);
};
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">
<section
aria-label="Workspace databases"
data-presentation={presentation}
className={presentation === "fleet"
? "thot-fleet-grid-surface flex min-h-0 flex-1 flex-col overflow-hidden bg-card"
: "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={presentation === "fleet"
? "thot-fleet-grid-toolbar flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2"
: "flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2"}
>
{selectedRows.length > 0 ? (
pendingGenerateAll ? (
<div
@@ -352,8 +495,8 @@ export function DatabaseGrid({
<div className="mr-auto min-w-56">
<p id="database-cleanup-confirmation" className="text-sm font-semibold">
{pendingDelete === "tables"
? `Delete all catalog tables for ${selectedRows.length} database${selectedRows.length === 1 ? "" : "s"}?`
: `Delete all relationships for ${selectedRows.length} database${selectedRows.length === 1 ? "" : "s"}?`}
? `${presentation === "fleet" ? "Clear" : "Delete all"} catalog tables for ${selectedRows.length} database${selectedRows.length === 1 ? "" : "s"}?`
: `${presentation === "fleet" ? "Clear catalog" : "Delete all"} relationships for ${selectedRows.length} database${selectedRows.length === 1 ? "" : "s"}?`}
</p>
<p className="text-xs text-muted-foreground">
{pendingDelete === "tables"
@@ -369,9 +512,33 @@ export function DatabaseGrid({
disabled={action !== null}
onClick={() => void perform("delete", () => onDeleteMetadataSelected(selectedRows, pendingDelete))}
>
<Trash2 />{pendingDelete === "tables" ? "Delete catalog tables" : "Delete relationships"}
<Trash2 />{pendingDelete === "tables"
? presentation === "fleet" ? "Clear catalog tables" : "Delete catalog tables"
: presentation === "fleet" ? "Clear catalog relationships" : "Delete relationships"}
</Button>
</div>
) : presentation === "fleet" ? (
<>
<FleetActionSelector
actions={fleetActions}
selectedCount={selectedRows.length}
hiddenSelectedCount={hiddenSelected}
busy={action !== null}
busyLabel={action === "suggest" ? "Suggesting…" : "Running…"}
selectRef={actionSelectRef}
onRun={runFleetAction}
renderContext={(selectedAction) => (
metadataModelControl
&& ["generate-all", "generate-missing", "suggest-sensitive"].includes(selectedAction)
? <div className="thot-fleet-metadata-model">{metadataModelControl}</div>
: null
)}
onClear={() => {
gridRef.current?.api.deselectAll();
setSelectedRows([]);
}}
/>
</>
) : (
<>
<span className="text-sm font-semibold">{selectedRows.length} selected</span>
@@ -449,7 +616,12 @@ export function DatabaseGrid({
placeholder="Search"
value={search}
onChange={(event) => onSearchChange(event.target.value)}
/></>}
/>
{presentation === "fleet" && onRefresh ? (
<Button type="button" variant="outline" size="sm" disabled={isFetching} onClick={() => void onRefresh()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Refresh
</Button>
) : null}</>}
<span className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">
{configuredCount}/{rows.length}
</span>
@@ -458,7 +630,7 @@ export function DatabaseGrid({
) : 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">
<div className={`thot-database-grid ag-theme-alpine absolute inset-0 h-full w-full${presentation === "fleet" ? " thot-fleet-ledger-grid" : ""}`}>
<AgGridReact<CatalogDatabase>
ref={gridRef}
rowData={rows}
@@ -1,8 +1,8 @@
import { useMemo, useState } from "react";
import { useMemo, useRef, useState, type ReactNode } 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 type { ColDef, ICellRendererParams } from "ag-grid-community";
import { ArrowLeft, History, Info, Play, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { apiErrorMessage } from "../../api/client";
@@ -13,6 +13,7 @@ import {
type CatalogRelationship,
type CatalogSyncRun,
} from "../../api/catalog-databases";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
interface Props {
database: CatalogDatabase;
@@ -22,6 +23,32 @@ interface Props {
onOpenTables: () => void;
onRunStarted: (run: CatalogSyncRun) => void;
onOpenSync: () => void;
metadataModelControl?: ReactNode;
presentation?: "legacy" | "fleet";
}
interface RelationshipGridContext {
presentation: "legacy" | "fleet";
onView: (relationship: CatalogRelationship, origin: HTMLButtonElement) => void;
}
function RelationshipActionsCell({ data, context }: ICellRendererParams<CatalogRelationship, unknown, RelationshipGridContext>) {
if (!data || !context || context.presentation !== "fleet") return null;
return (
<div className="flex h-full items-center justify-end" onClick={(event) => event.stopPropagation()}>
<Button
type="button"
variant="ghost"
size="icon-lg"
className="thot-fleet-icon-action"
data-tooltip="Details"
aria-label={`View relationship ${data.constraintName}`}
onClick={(event) => context.onView(data, event.currentTarget)}
>
<Info aria-hidden="true" />
</Button>
</div>
);
}
export function DatabaseRelationships({
@@ -32,16 +59,32 @@ export function DatabaseRelationships({
onOpenTables,
onRunStarted,
onOpenSync,
presentation = "legacy",
}: Props) {
const databaseId = database.id!;
const { data = [], isLoading, isFetching, refetch } = useQuery({
const { data = [], error, isError, isLoading, isFetching, refetch } = useQuery({
queryKey: ["catalog-relationships", databaseId],
queryFn: () => listCatalogRelationships(databaseId),
retry: false,
});
const [search, setSearch] = useState("");
const [busy, setBusy] = useState(false);
const [selectedAction, setSelectedAction] = useState<"" | "relationships" | "all">("");
const [activeRelationshipId, setActiveRelationshipId] = useState<string | null>(null);
const originRef = useRef<HTMLButtonElement | null>(null);
const bindingReady = database.connectionStatus === "reachable" && database.testedVersion === database.version;
const activeRelationship = activeRelationshipId
? data.find((relationship) => relationship.id === activeRelationshipId)
: undefined;
const selectedActionUnavailable = !selectedAction
? null
: !canManage
? "You do not have permission to synchronize the catalog."
: !bindingReady
? "Test the current database binding first."
: database.activeSyncRun
? "Wait for the active synchronization to finish."
: null;
const synchronize = async (scope: "relationships" | "all") => {
setBusy(true);
@@ -52,6 +95,22 @@ export function DatabaseRelationships({
} catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(false); }
};
const openRelationship = (relationship: CatalogRelationship, origin: HTMLButtonElement) => {
originRef.current = origin;
setActiveRelationshipId(relationship.id);
};
const closeRelationship = () => {
setActiveRelationshipId(null);
window.setTimeout(() => originRef.current?.focus(), 0);
};
const runSelectedAction = async () => {
if (!selectedAction) return;
await synchronize(selectedAction);
setSelectedAction("");
};
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 },
@@ -60,11 +119,24 @@ export function DatabaseRelationships({
{ 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 },
], []);
...(presentation === "fleet" ? [{ colId: "actions", headerName: "Actions", width: 64, minWidth: 64, maxWidth: 64, pinned: "right" as const, lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: RelationshipActionsCell }] : []),
], [presentation]);
const context = useMemo<RelationshipGridContext>(() => ({ presentation, onView: openRelationship }), [data, presentation]);
const fleetQueryError = presentation === "fleet" && isError ? (
<div className="grid min-h-[280px] flex-1 place-items-center p-6" role="alert">
<div className="max-w-lg rounded-lg border border-destructive/30 bg-destructive/8 p-5 text-sm">
<p className="font-semibold text-destructive">Catalog relationships could not be loaded.</p>
<p className="mt-1 leading-5 text-muted-foreground">{apiErrorMessage(error)}</p>
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refetch()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry relationships
</Button>
</div>
</div>
) : null;
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">
<section aria-label={`Relationships for ${database.workspaceName}`} className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
{presentation === "legacy" ? <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>
@@ -82,22 +154,76 @@ export function DatabaseRelationships({
<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">
</div> : null}
<div className={presentation === "fleet" ? "thot-fleet-grid-surface flex min-h-0 flex-1 flex-col overflow-hidden bg-card" : "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={`${presentation === "fleet" ? "thot-fleet-grid-toolbar " : ""}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>
{presentation === "fleet" ? (
<div className="flex flex-wrap items-center gap-2">
<div className="thot-fleet-action-selector__scope" aria-live="polite">
<strong>{selectedAction === "all" ? "This database" : selectedAction === "relationships" ? "All relationships in this database" : "This database"}</strong>
<span>Action scope</span>
</div>
<label className="sr-only" htmlFor="relationship-action">Relationship action</label>
<select
id="relationship-action"
className="thot-fleet-action-selector__select"
value={selectedAction}
disabled={busy}
onChange={(event) => setSelectedAction(event.target.value as "" | "relationships" | "all")}
>
<option value="">Choose an action…</option>
<optgroup label="Synchronization">
<option value="relationships">Synchronize all relationships in this database</option>
<option value="all">Synchronize the full schema for this database</option>
</optgroup>
</select>
<Button type="button" size="sm" disabled={!selectedAction || Boolean(selectedActionUnavailable) || busy} aria-describedby={selectedActionUnavailable ? "relationship-action-reason" : undefined} onClick={() => void runSelectedAction()}><Play aria-hidden="true" />{busy ? "Running…" : "Run action"}</Button>
<Button type="button" size="sm" variant="outline" className="thot-fleet-nav-action thot-fleet-nav-action--below" data-tooltip="Synchronization history" onClick={onOpenSync}>
{database.activeSyncRun ? <RefreshCw className="animate-spin" aria-hidden="true" /> : <History aria-hidden="true" />} Sync history
</Button>
{selectedActionUnavailable ? <span id="relationship-action-reason" className="basis-full text-xs text-muted-foreground" role="status">{selectedActionUnavailable}</span> : null}
</div>
) : <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." />
{!bindingReady ? <div className="border-b border-border bg-muted/35 px-4 py-3 text-sm text-muted-foreground">{presentation === "fleet" ? "Test the current database binding in database details before synchronizing relationships." : "Test the current database binding from Overview before synchronizing relationships."}</div> : null}
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
<div className={`thot-database-grid ${presentation === "fleet" ? "thot-fleet-ledger-grid " : ""}ag-theme-alpine absolute inset-0 h-full w-full`}>
<AgGridReact<CatalogRelationship> 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 physical relationships synchronized for this database." />
</div>
</div>
</div>}
</div>
{presentation === "fleet" && activeRelationship ? (
<FleetLedgerDrawer
open
eyebrow="Physical relationship"
title="Relationship details"
description="This information is synchronized from the source schema and is read-only."
onClose={closeRelationship}
closeLabel="Close relationship details"
footer={<Button type="button" variant="outline" onClick={closeRelationship}>Close</Button>}
>
<dl className="grid gap-4 text-sm sm:grid-cols-2">
<div className="sm:col-span-2"><dt className="thot-label">Constraint</dt><dd className="mt-1 break-words font-mono text-xs">{activeRelationship.constraintName}</dd></div>
<div><dt className="thot-label">Source table</dt><dd className="mt-1 break-words">{activeRelationship.sourceTableName}</dd></div>
<div><dt className="thot-label">Target table</dt><dd className="mt-1 break-words">{activeRelationship.targetTableName}</dd></div>
<div><dt className="thot-label">On update</dt><dd className="mt-1">{activeRelationship.updateRule ?? "Not specified"}</dd></div>
<div><dt className="thot-label">On delete</dt><dd className="mt-1">{activeRelationship.deleteRule ?? "Not specified"}</dd></div>
<div className="sm:col-span-2">
<dt className="thot-label">Column mapping</dt>
<dd className="mt-2 overflow-hidden rounded-md border border-border">
<table className="w-full text-left text-xs">
<thead className="bg-muted/60 font-mono uppercase tracking-wide text-muted-foreground"><tr><th className="px-3 py-2">Source</th><th className="px-3 py-2">Target</th></tr></thead>
<tbody>{activeRelationship.columns.map((pair) => <tr key={`${pair.sourceColumnName}:${pair.targetColumnName}`} className="border-t border-border"><td className="px-3 py-2 font-mono">{pair.sourceColumnName}</td><td className="px-3 py-2 font-mono">{pair.targetColumnName}</td></tr>)}</tbody>
</table>
</dd>
</div>
</dl>
</FleetLedgerDrawer>
) : null}
</section>
);
}
@@ -1,9 +1,9 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Menu } from "@base-ui/react/menu";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AgGridReact } from "ag-grid-react";
import type { ColDef, ICellRendererParams } from "ag-grid-community";
import { ArrowLeft, ChevronDown, Columns3, Pencil, RefreshCw, Save, Sparkles, Trash2, X } from "lucide-react";
import { ArrowLeft, ChevronDown, Columns3, History, Pencil, RefreshCw, Save, Sparkles, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { ApiError, apiErrorMessage } from "../../api/client";
@@ -23,6 +23,8 @@ import {
} from "../../api/catalog-databases";
import type { DatabaseNavigationState } from "./model";
import { DatabaseColumns } from "./DatabaseColumns";
import { FleetActionSelector, type FleetActionOption } from "./FleetActionSelector";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
interface Props {
database: CatalogDatabase;
@@ -34,27 +36,46 @@ interface Props {
onOpenOverview: () => void;
onOpenRelationships: () => void;
onNavigationStateChange: (state: DatabaseNavigationState) => void;
onNestedNavigationChange?: (active: boolean) => void;
onRunStarted: (run: CatalogSyncRun) => void;
onOpenSync: () => void;
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
onSuggestSensitive: (selection: SensitiveDataSuggestionRequest, scopeLabel: string) => Promise<void>;
onCatalogMetricsChanged?: () => void | Promise<void>;
metadataModelControl?: ReactNode;
presentation?: "legacy" | "fleet";
}
interface TableGridContext {
canManage: boolean;
presentation: "legacy" | "fleet";
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)}>
<div className="flex h-full items-center justify-end gap-0.5" onClick={(event) => event.stopPropagation()}>
<Button
type="button"
variant="ghost"
size="icon-lg"
className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined}
data-tooltip={context.presentation === "fleet" ? "Columns" : undefined}
aria-label={`View columns for ${data.name}`}
title={context.presentation === "legacy" ? `View columns for ${data.name}` : undefined}
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)}>
<span
className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined}
data-tooltip={context.presentation === "fleet" ? "Edit metadata" : undefined}
>
<Button type="button" variant="ghost" size="icon-lg" aria-label={`Edit description for ${data.name}`} title={context.presentation === "legacy" ? `Edit metadata for ${data.name}` : undefined} disabled={!context.canManage} onClick={(event) => context.onOpen(data, "overview", event.currentTarget)}>
<Pencil aria-hidden="true" />
</Button>
</Button>
</span>
</div>
);
}
@@ -69,15 +90,19 @@ export function DatabaseTables({
onOpenOverview,
onOpenRelationships,
onNavigationStateChange,
onNestedNavigationChange,
onRunStarted,
onOpenSync,
onDescriptionGenerationRunStarted,
onSuggestSensitive,
onCatalogMetricsChanged,
metadataModelControl,
presentation = "legacy",
}: 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 { data = [], error, isError, isLoading, isFetching, refetch } = useQuery({ queryKey, queryFn: () => listCatalogTables(databaseId), retry: false });
const [search, setSearch] = useState("");
const [displayedCount, setDisplayedCount] = useState(0);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
@@ -96,16 +121,20 @@ export function DatabaseTables({
const originRef = useRef<HTMLButtonElement | null>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
const actionsTriggerRef = useRef<HTMLButtonElement>(null);
const fleetActionSelectRef = useRef<HTMLSelectElement>(null);
const searchInputRef = useRef<HTMLInputElement>(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 nestedNavigationActive = presentation === "fleet" && Boolean(activeTable) && tableSection === "columns";
const bindingReady = database.connectionStatus === "reachable" && database.testedVersion === database.version;
const currentRun = activeRun ?? database.activeSyncRun;
useEffect(() => { onNavigationStateChange({ dirty, busy: navigationBusy }); }, [dirty, navigationBusy, onNavigationStateChange]);
useEffect(() => { onNestedNavigationChange?.(nestedNavigationActive); }, [nestedNavigationActive, onNestedNavigationChange]);
useEffect(() => () => { onNestedNavigationChange?.(false); }, [onNestedNavigationChange]);
useEffect(() => {
if (!activeTableId || !activeTable || editorVersion === activeTable.version || busy) return;
setStale(true);
@@ -118,7 +147,10 @@ export function DatabaseTables({
}, [pendingDelete]);
const closeDeleteConfirmation = () => {
setPendingDelete(null);
window.setTimeout(() => actionsTriggerRef.current?.focus(), 0);
window.setTimeout(() => {
if (presentation === "fleet") fleetActionSelectRef.current?.focus();
else actionsTriggerRef.current?.focus();
}, 0);
};
const openTable = (table: CatalogTable, section: "overview" | "columns", origin: HTMLButtonElement) => {
@@ -161,6 +193,7 @@ export function DatabaseTables({
setBaseline(JSON.stringify([updated.description ?? "", updated.generatedDescription ?? ""]));
setEditorVersion(updated.version);
setStale(false);
void onCatalogMetricsChanged?.();
toast.success("Table metadata saved");
} catch (error) {
if (error instanceof ApiError && error.code === "table_stale") {
@@ -208,9 +241,10 @@ export function DatabaseTables({
setSelectedIds([]);
setPendingDelete(null);
window.setTimeout(() => searchInputRef.current?.focus(), 0);
void onCatalogMetricsChanged?.();
toast.success(target === "columns"
? `Deleted ${counts.columns} catalog columns`
: `Deleted ${counts.relationships} catalog relationships`);
? `${presentation === "fleet" ? "Cleared" : "Deleted"} ${counts.columns} catalog columns`
: `${presentation === "fleet" ? "Cleared" : "Deleted"} ${counts.relationships} catalog relationships`);
} catch (error) {
toast.error(apiErrorMessage(error));
} finally { setBusy(null); }
@@ -222,6 +256,7 @@ export function DatabaseTables({
await queryClient.invalidateQueries({ queryKey, exact: true });
gridRef.current?.api.deselectAll();
setSelectedIds([]);
void onCatalogMetricsChanged?.();
toast.success(`Copied ${counts.copied} description${counts.copied === 1 ? "" : "s"}; skipped ${counts.skipped}`);
} catch (error) {
toast.error(apiErrorMessage(error));
@@ -258,14 +293,152 @@ export function DatabaseTables({
}
};
type FleetTableAction =
| "generate-descriptions"
| "sync-tables"
| "sync-columns"
| "consolidate-descriptions"
| "suggest-sensitive"
| "clear-columns"
| "clear-relationships";
const fleetActions: readonly FleetActionOption<FleetTableAction>[] = [
{
id: "generate-descriptions",
label: "Generate descriptions",
group: "Descriptions",
runLabel: "Generate",
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to generate descriptions."
: selectedIds.length === 0
? "Select at least one table."
: !selectedMetadataModel
? "Choose a metadata model first."
: descriptionGenerationActive || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: busy !== null
? "Another action is running."
: undefined,
},
{
id: "consolidate-descriptions",
label: "Move generated to Description",
group: "Descriptions",
runLabel: "Move",
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to update metadata."
: selectedIds.length === 0
? "Select at least one table."
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: undefined,
},
{
id: "sync-tables",
label: "Synchronize tables",
group: "Synchronization",
scopeLabel: "All tables in this database",
runLabel: "Synchronize",
disabled: !canManage || !bindingReady || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to synchronize the catalog."
: !bindingReady
? "Test the current database binding first."
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: undefined,
},
{
id: "sync-columns",
label: "Synchronize columns",
group: "Synchronization",
runLabel: "Synchronize",
disabled: !canManage || selectedIds.length === 0 || !bindingReady || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to synchronize the catalog."
: selectedIds.length === 0
? "Select at least one table."
: !bindingReady
? "Test the current database binding first."
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: undefined,
},
{
id: "suggest-sensitive",
label: "Suggest sensitive fields",
group: "Sensitive data",
runLabel: "Suggest",
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to review sensitive data."
: selectedIds.length === 0
? "Select at least one table."
: !selectedMetadataModel
? "Choose a metadata model first."
: descriptionGenerationActive || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: busy !== null
? "Another action is running."
: undefined,
},
{
id: "clear-columns",
label: "Clear catalog columns",
group: "Catalog cleanup",
tone: "destructive",
runLabel: "Review cleanup",
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to clear catalog metadata."
: selectedIds.length === 0
? "Select at least one table."
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: undefined,
},
{
id: "clear-relationships",
label: "Clear catalog relationships",
group: "Catalog cleanup",
tone: "destructive",
runLabel: "Review cleanup",
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to clear catalog metadata."
: selectedIds.length === 0
? "Select at least one table."
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: undefined,
},
];
const runFleetAction = async (action: FleetTableAction) => {
if (action === "generate-descriptions") await generateDescriptions();
else if (action === "sync-tables") await synchronize("tables");
else if (action === "consolidate-descriptions") await consolidateDescriptions();
else if (action === "sync-columns") await synchronize("columns", selectedIds);
else if (action === "suggest-sensitive") await suggestSensitive();
else if (action === "clear-columns") setPendingDelete("columns");
else setPendingDelete("relationships");
};
const clearSelection = () => {
gridRef.current?.api.deselectAll();
setSelectedIds([]);
};
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 },
{ colId: "actions", headerName: "Actions", width: 104, minWidth: 104, maxWidth: 104, pinned: "right", lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: TableActionsCell },
], []);
const context = useMemo<TableGridContext>(() => ({ canManage, onOpen: openTable }), [canManage, data]);
const context = useMemo<TableGridContext>(() => ({ canManage, presentation, onOpen: openTable }), [canManage, data, presentation]);
const visibleSelected = useMemo(() => {
const term = search.trim().toLocaleLowerCase();
if (!term) return selectedIds.length;
@@ -273,6 +446,17 @@ export function DatabaseTables({
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 fleetQueryError = presentation === "fleet" && isError ? (
<div className="grid min-h-[280px] flex-1 place-items-center p-6" role="alert">
<div className="max-w-lg rounded-lg border border-destructive/30 bg-destructive/8 p-5 text-sm">
<p className="font-semibold text-destructive">Catalog tables could not be loaded.</p>
<p className="mt-1 leading-5 text-muted-foreground">{apiErrorMessage(error)}</p>
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refetch()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry tables
</Button>
</div>
</div>
) : null;
const databaseHeader = (
<div className="px-4 pt-5 sm:px-5">
@@ -296,6 +480,126 @@ export function DatabaseTables({
</div>
);
if (activeTable && presentation === "fleet") {
if (tableSection === "columns") {
return (
<section aria-label={`Columns for ${activeTable.name}`} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<div className="flex min-h-12 flex-wrap items-center gap-3 border-b border-border bg-muted/25 px-3 py-2">
<Button
type="button"
variant="outline"
className="thot-fleet-icon-action border-primary/35 bg-primary/8 shadow-sm"
disabled={navigationBusy}
data-tooltip="Tables"
aria-label="Back to tables"
onClick={leaveTable}
>
<ArrowLeft aria-hidden="true" /> Back to tables
</Button>
<div className="min-w-0">
<p className="thot-label">Physical table</p>
<h3 className="truncate font-heading text-lg font-semibold">{activeTable.name}</h3>
</div>
</div>
<DatabaseColumns
databaseId={databaseId}
table={activeTable}
canManage={canManage}
selectedMetadataModel={selectedMetadataModel}
descriptionGenerationActive={descriptionGenerationActive}
onDescriptionGenerationRunStarted={onDescriptionGenerationRunStarted}
onNavigationStateChange={setColumnNavigation}
onSync={() => void synchronize("columns", [activeTable.id])}
onSuggestSensitive={onSuggestSensitive}
bindingReady={bindingReady}
catalogOperationActive={Boolean(currentRun)}
onCatalogMetricsChanged={onCatalogMetricsChanged}
metadataModelControl={metadataModelControl}
presentation="fleet"
/>
</section>
);
}
return (
<section aria-label={`Tables for ${database.workspaceName}`} className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<div className="thot-fleet-grid-toolbar 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 tables</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 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 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>
</div>
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
<div className="thot-database-grid thot-fleet-ledger-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>}
<FleetLedgerDrawer
open
eyebrow="Catalog metadata"
title="Review table description"
description="Physical schema fields are read-only. Review fields can be edited."
onClose={leaveTable}
closeLabel="Close table metadata"
busy={busy !== null}
footer={(
<>
<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 aria-hidden="true" />{busy === "save" ? "Saving…" : "Save metadata"}
</Button>
</>
)}
>
<p className="thot-label">Physical table</p>
<h3 className="mt-1 break-words font-heading text-xl font-semibold">{activeTable.name}</h3>
{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>
</FleetLedgerDrawer>
</section>
);
}
if (activeTable) {
return (
<section aria-label={`Table ${activeTable.name}`} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
@@ -321,6 +625,7 @@ export function DatabaseTables({
onNavigationStateChange={setColumnNavigation}
onSync={() => void synchronize("columns", [activeTable.id])}
onSuggestSensitive={onSuggestSensitive}
onCatalogMetricsChanged={onCatalogMetricsChanged}
/>
) : (
<div className="min-h-0 overflow-y-auto px-4 py-5">
@@ -353,9 +658,9 @@ export function DatabaseTables({
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">
{presentation === "legacy" ? databaseHeader : null}
<div className={presentation === "fleet" ? "thot-fleet-grid-surface flex min-h-0 flex-1 flex-col overflow-hidden bg-card" : "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={`${presentation === "fleet" ? "thot-fleet-grid-toolbar " : ""}flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2`}>
{selectedIds.length > 0 ? (
pendingDelete ? (
<div
@@ -368,14 +673,20 @@ export function DatabaseTables({
>
<div className="mr-auto min-w-56">
<p id="table-cleanup-confirmation" className="text-sm font-semibold">
{pendingDelete === "columns"
{presentation === "fleet"
? `Clear catalog ${pendingDelete} for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`
: pendingDelete === "columns"
? `Delete all catalog columns for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`
: `Delete all relationships for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`}
</p>
<p className="text-xs text-muted-foreground">
{pendingDelete === "columns"
? "Only columns belonging to the selected catalog tables will be removed."
: "All incoming and outgoing relationships for the selected catalog tables will be removed."}
{presentation === "fleet"
? pendingDelete === "columns"
? "Only synchronized catalog columns for these tables will be cleared. The source database is not modified."
: "Only synchronized catalog relationships for these tables will be cleared. The source database is not modified."
: pendingDelete === "columns"
? "Only columns belonging to the selected catalog tables will be removed."
: "All incoming and outgoing relationships for the selected catalog tables will be removed."}
</p>
</div>
<Button type="button" variant="ghost" disabled={busy !== null} onClick={closeDeleteConfirmation}>Cancel</Button>
@@ -386,88 +697,86 @@ export function DatabaseTables({
disabled={busy !== null}
onClick={() => void deleteMetadata(pendingDelete)}
>
<Trash2 />{pendingDelete === "columns" ? "Delete columns" : "Delete relationships"}
<Trash2 />{presentation === "fleet" ? "Clear catalog" : pendingDelete === "columns" ? "Delete columns" : "Delete relationships"}
</Button>
</div>
) : (
<>
<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}
<Menu.Root>
<Menu.Trigger ref={actionsTriggerRef} 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={busy !== 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={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)}
onClick={() => void generateDescriptions()}
>
Generate {selectedIds.length === 1 ? "description" : "descriptions"}
</Menu.Item>
<Menu.Item
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)}
onClick={() => void synchronize("columns", selectedIds)}
>
Synchronize columns
</Menu.Item>
<Menu.Item
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
disabled={!canManage || busy !== null || Boolean(currentRun)}
onClick={() => void consolidateDescriptions()}
>
Move generated description to Description
</Menu.Item>
<Menu.Separator className="my-1 h-px bg-border" />
<Menu.Item
className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45"
disabled={!canManage || busy !== null || Boolean(currentRun)}
onClick={() => setPendingDelete("columns")}
>
Delete all columns
</Menu.Item>
<Menu.Item
className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45"
disabled={!canManage || busy !== null || Boolean(currentRun)}
onClick={() => setPendingDelete("relationships")}
>
Delete all relationships
</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<Button
type="button"
variant="outline"
disabled={!canManage || descriptionGenerationActive || busy !== null}
title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined}
onClick={() => void suggestSensitive()}
>
<Sparkles />{busy === "suggest" ? "Suggesting…" : "Suggest sensitive fields"}
</Button>
<Button type="button" variant="ghost" onClick={() => { gridRef.current?.api.deselectAll(); setSelectedIds([]); }}><X />Clear</Button>
</>
presentation === "fleet" ? (
<>
<FleetActionSelector
actions={fleetActions}
selectedCount={selectedIds.length}
hiddenSelectedCount={selectedIds.length - visibleSelected}
busy={busy !== null}
busyLabel={busy === "generate" ? "Generating…" : busy === "suggest" ? "Suggesting…" : "Running…"}
selectRef={fleetActionSelectRef}
onRun={runFleetAction}
onClear={clearSelection}
renderContext={(action) => action === "generate-descriptions" || action === "suggest-sensitive" ? metadataModelControl : null}
label="Table action"
/>
</>
) : (
<>
<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}
<Menu.Root>
<Menu.Trigger ref={actionsTriggerRef} 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={busy !== 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={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateDescriptions()}>Generate {selectedIds.length === 1 ? "description" : "descriptions"}</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("columns", selectedIds)}>Synchronize columns</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void consolidateDescriptions()}>Move generated description to Description</Menu.Item>
<Menu.Separator className="my-1 h-px bg-border" />
<Menu.Item className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => setPendingDelete("columns")}>Delete all columns</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => setPendingDelete("relationships")}>Delete all relationships</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<Button type="button" variant="outline" disabled={!canManage || descriptionGenerationActive || busy !== null} title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined} onClick={() => void suggestSensitive()}><Sparkles />{busy === "suggest" ? "Suggesting…" : "Suggest sensitive fields"}</Button>
<Button type="button" variant="ghost" onClick={clearSelection}><X />Clear</Button>
</>
)
)
) : (
<>
<span className="thot-label whitespace-nowrap">Catalog tables</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 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>
</>
presentation === "fleet" ? (
<>
<FleetActionSelector
actions={fleetActions}
selectedCount={0}
busy={busy !== null}
busyLabel={busy === "generate" ? "Generating…" : busy === "suggest" ? "Suggesting…" : "Running…"}
selectRef={fleetActionSelectRef}
onRun={runFleetAction}
onClear={clearSelection}
renderContext={(action) => action === "generate-descriptions" || action === "suggest-sensitive" ? metadataModelControl : null}
label="Table action"
/>
<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 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>
</>
) : (
<>
<span className="thot-label whitespace-nowrap">Catalog tables</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 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>
</>
)
)}
{presentation === "fleet" ? (
<Button type="button" size="sm" variant="outline" className="thot-fleet-nav-action thot-fleet-nav-action--below" data-tooltip="Synchronization history" onClick={onOpenSync}>
{currentRun ? <RefreshCw className="animate-spin" aria-hidden="true" /> : <History aria-hidden="true" />} Sync history
</Button>
) : null}
</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">
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
<div className={`thot-database-grid ${presentation === "fleet" ? "thot-fleet-ledger-grid " : ""}ag-theme-alpine absolute inset-0 h-full w-full`}>
<AgGridReact<CatalogTable>
ref={gridRef}
rowData={data}
@@ -489,7 +798,7 @@ export function DatabaseTables({
overlayNoRowsTemplate="No catalog tables. Test the binding, then sync tables."
/>
</div>
</div>
</div>}
</div>
</section>
);
@@ -172,7 +172,7 @@ test("stops queued or running work without hiding earlier results or events", as
);
renderDrawer();
const drawer = await screen.findByRole("complementary", { name: "Description generation" });
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
expect(await within(drawer).findByText("First description remains saved")).toBeVisible();
await user.click(within(drawer).getByRole("button", { name: "Stop" }));
@@ -243,7 +243,7 @@ test("reopens a terminal run from newest-first persisted history", async () => {
name: /Completed.*selected tables/i,
}));
const drawer = screen.getByRole("complementary", { name: "Description generation" });
const drawer = screen.getByRole("dialog", { name: "Description generation" });
expect(await within(drawer).findByRole("heading", { name: "Completed" })).toBeVisible();
expect(within(drawer).getByText("3", { selector: "[data-count='generated']" })).toBeVisible();
});
@@ -269,7 +269,7 @@ test("offers Unlock only for an apparently stale active run and explains the int
);
renderDrawer({ initialRun: staleRun });
const drawer = await screen.findByRole("complementary", { name: "Description generation" });
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
expect(within(drawer).getByText(
"Unlock interrupts apparently stale work and marks the run interrupted. A live worker will prevent it.",
)).toBeVisible();
@@ -286,7 +286,7 @@ test("does not offer Unlock while an active run still appears current", async ()
);
renderDrawer();
const drawer = await screen.findByRole("complementary", { name: "Description generation" });
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
expect(within(drawer).queryByRole("button", { name: "Unlock stale run" })).not.toBeInTheDocument();
});
@@ -307,7 +307,7 @@ test("explains when Unlock is refused because a live worker still exists", async
);
renderDrawer({ initialRun: staleRun });
const drawer = await screen.findByRole("complementary", { name: "Description generation" });
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
await user.click(within(drawer).getByRole("button", { name: "Unlock stale run" }));
expect(await screen.findByText(
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { LoaderCircle, LockOpen, Square, X } from "lucide-react";
import { LoaderCircle, LockOpen, Square } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { ApiError, apiErrorMessage } from "../../api/client";
@@ -14,6 +14,7 @@ import {
type DescriptionGenerationRun,
unlockDescriptionGenerationRun,
} from "../../api/catalog-databases";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
interface Props {
open: boolean;
@@ -100,6 +101,11 @@ export function DescriptionGenerationDrawer({
: false,
});
useEffect(() => {
if (!open || initialRun || !historyQuery.data?.[0]) return;
onRunUpdate(historyQuery.data[0]);
}, [historyQuery.data, initialRun, onRunUpdate, open]);
const mergeEvents = useCallback((incoming: DescriptionGenerationEvent[]) => {
if (incoming.length === 0) return;
setEvents((current) => {
@@ -256,28 +262,88 @@ export function DescriptionGenerationDrawer({
}
};
if (!open || !run) return null;
if (!open) return null;
if (!run) {
return (
<FleetLedgerDrawer
open
ariaLabel="Description generation"
eyebrow="Description generation"
title="Run history"
onClose={onClose}
closeLabel="Close description generation"
busy={action !== null}
bodyClassName="thot-catalog-drawer__body--empty"
>
{historyQuery.isError ? (
<div role="alert" className="w-full rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{apiErrorMessage(historyQuery.error)}
</div>
) : historyQuery.isLoading || (historyQuery.data?.length ?? 0) > 0 ? (
<p className="text-sm text-muted-foreground">Loading run history…</p>
) : (
<div className="w-full rounded-md border border-border bg-muted/25 px-4 py-5 text-sm">
<p className="font-semibold">No description generation runs yet.</p>
<p className="mt-1 text-muted-foreground">
Start a description generation to create the first history entry.
</p>
</div>
)}
</FleetLedgerDrawer>
);
}
const apparentlyStale = isApparentlyStale(run);
const finalError = safeFinalError(run.errorSummary);
return (
<aside
aria-label="Description generation"
className="fixed inset-y-2 right-0 z-50 flex w-full max-w-[400px] 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">Description generation</p>
<h2 className="mt-1 font-heading text-xl font-semibold">{statusLabel(run)}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{modelLabel}{modelLabel !== run.modelId ? ` (${run.modelId})` : ""} · {run.scope.replaceAll("_", " ")}
</p>
</div>
<Button type="button" variant="ghost" size="icon-lg" aria-label="Close description generation" onClick={onClose}>
<X aria-hidden="true" />
const footer = ["queued", "running"].includes(run.status) ? (
<div className="w-full">
{apparentlyStale ? (
<p
id="description-generation-unlock-help"
className="mb-3 rounded-md border border-amber-500/35 bg-amber-500/8 px-3 py-2 text-xs leading-5 text-muted-foreground"
>
Unlock interrupts apparently stale work and marks the run interrupted. A live worker will prevent it.
</p>
) : null}
<div className="flex flex-wrap justify-end gap-2">
{apparentlyStale ? (
<Button
type="button"
variant="outline"
aria-label="Unlock stale run"
aria-describedby="description-generation-unlock-help"
disabled={action !== null}
onClick={() => void unlock()}
>
{action === "unlock" ? <LoaderCircle className="animate-spin" /> : <LockOpen />}
{action === "unlock" ? "Unlocking…" : "Unlock"}
</Button>
) : null}
<Button
type="button"
variant="destructive"
disabled={action !== null}
onClick={() => void stop()}
>
{action === "stop" ? <LoaderCircle className="animate-spin" /> : <Square />}
{action === "stop" ? "Stopping…" : "Stop"}
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
{runQuery.isError ? (
</div>
) : undefined;
return (
<FleetLedgerDrawer
open
ariaLabel="Description generation"
eyebrow="Description generation"
title={statusLabel(run)}
description={`${modelLabel}${modelLabel !== run.modelId ? ` (${run.modelId})` : ""} · ${run.scope.replaceAll("_", " ")}`}
onClose={onClose}
closeLabel="Close description generation"
busy={action !== null}
bodyClassName="thot-catalog-drawer__history-layout"
footer={footer}
>
{runQuery.isError ? (
<div role="alert" className="rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{apiErrorMessage(runQuery.error)}
</div>
@@ -336,7 +402,7 @@ export function DescriptionGenerationDrawer({
{eventQuery.isError ? <p role="alert" className="mt-2 text-sm text-destructive">{apiErrorMessage(eventQuery.error)}</p> : null}
</section>
<section className="mt-5" aria-label="Description generation history">
<section className="thot-catalog-drawer__history mt-5" aria-label="Description generation history">
<div className="mb-2 flex items-center justify-between gap-3">
<h3 className="thot-label">Recent runs</h3>
<span className="text-xs tabular-nums text-muted-foreground">
@@ -371,43 +437,6 @@ export function DescriptionGenerationDrawer({
</div>
)}
</section>
</div>
{["queued", "running"].includes(run.status) ? (
<div className="border-t border-border px-5 py-4">
{apparentlyStale ? (
<p
id="description-generation-unlock-help"
className="mb-3 rounded-md border border-amber-500/35 bg-amber-500/8 px-3 py-2 text-xs leading-5 text-muted-foreground"
>
Unlock interrupts apparently stale work and marks the run interrupted. A live worker will prevent it.
</p>
) : null}
<div className="flex flex-wrap justify-end gap-2">
{apparentlyStale ? (
<Button
type="button"
variant="outline"
aria-label="Unlock stale run"
aria-describedby="description-generation-unlock-help"
disabled={action !== null}
onClick={() => void unlock()}
>
{action === "unlock" ? <LoaderCircle className="animate-spin" /> : <LockOpen />}
{action === "unlock" ? "Unlocking…" : "Unlock"}
</Button>
) : null}
<Button
type="button"
variant="destructive"
disabled={action !== null}
onClick={() => void stop()}
>
{action === "stop" ? <LoaderCircle className="animate-spin" /> : <Square />}
{action === "stop" ? "Stopping…" : "Stop"}
</Button>
</div>
</div>
) : null}
</aside>
</FleetLedgerDrawer>
);
}
@@ -0,0 +1,57 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
import { FleetActionSelector } from "./FleetActionSelector";
const actions = [
{ id: "sync", label: "Synchronize tables", group: "Synchronization", scopeLabel: "All tables in this database" },
{
id: "generate",
label: "Generate descriptions",
group: "Descriptions",
disabled: true,
disabledReason: "Choose a metadata model first",
},
] as const;
test("keeps unavailable commands discoverable and explains why Run is disabled", async () => {
const user = userEvent.setup();
render(
<FleetActionSelector
actions={actions}
selectedCount={1}
onRun={vi.fn()}
onClear={vi.fn()}
renderContext={(action) => action === "generate" ? <span>Model selection</span> : null}
/>,
);
await user.selectOptions(screen.getByRole("combobox", { name: "Batch action" }), "generate");
expect(screen.getByText("Choose a metadata model first")).toBeVisible();
expect(screen.getByText("Model selection")).toBeVisible();
expect(screen.getByRole("button", { name: "Run action" })).toBeDisabled();
});
test("runs an enabled command explicitly and resets the picker", async () => {
const user = userEvent.setup();
const onRun = vi.fn().mockResolvedValue(undefined);
render(
<FleetActionSelector
actions={actions}
selectedCount={2}
onRun={onRun}
onClear={vi.fn()}
/>,
);
const picker = screen.getByRole("combobox", { name: "Batch action" });
await user.selectOptions(picker, "sync");
expect(screen.getByText("All tables in this database")).toBeVisible();
expect(screen.getByText("Action scope")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Run action" }));
expect(onRun).toHaveBeenCalledWith("sync");
expect(picker).toHaveValue("");
});
@@ -0,0 +1,144 @@
import {
useEffect,
useId,
useMemo,
useState,
type ReactNode,
type RefObject,
} from "react";
import { Play, X } from "lucide-react";
import { Button } from "../../components/ui/button";
export interface FleetActionOption<ActionId extends string = string> {
id: ActionId;
label: string;
group: string;
scopeLabel?: string;
disabled?: boolean;
disabledReason?: string;
tone?: "default" | "destructive";
runLabel?: string;
}
interface FleetActionSelectorProps<ActionId extends string> {
actions: readonly FleetActionOption<ActionId>[];
selectedCount: number;
hiddenSelectedCount?: number;
busy?: boolean;
busyLabel?: string;
selectRef?: RefObject<HTMLSelectElement>;
onRun: (action: ActionId) => void | Promise<void>;
onClear: () => void;
renderContext?: (action: ActionId | "") => ReactNode;
label?: string;
}
/**
* Selection-aware command picker shared by all Fleet Ledger grids. Eligibility
* remains data owned by the calling grid; this module owns only presentation,
* explicit execution and accessible unavailable reasons.
*/
export function FleetActionSelector<ActionId extends string>({
actions,
selectedCount,
hiddenSelectedCount = 0,
busy = false,
busyLabel = "Running…",
selectRef,
onRun,
onClear,
renderContext,
label = "Batch action",
}: FleetActionSelectorProps<ActionId>) {
const [selectedActionId, setSelectedActionId] = useState<ActionId | "">("");
const hintId = useId();
const groups = useMemo(() => {
const order: string[] = [];
const grouped = new Map<string, FleetActionOption<ActionId>[]>();
for (const action of actions) {
if (!grouped.has(action.group)) {
order.push(action.group);
grouped.set(action.group, []);
}
grouped.get(action.group)!.push(action);
}
return order.map((group) => ({ group, actions: grouped.get(group)! }));
}, [actions]);
const selectedAction = actions.find((action) => action.id === selectedActionId);
const unavailableReason = selectedAction?.disabled
? selectedAction.disabledReason ?? "This action is not available for the current selection."
: null;
const actionScope = selectedAction?.scopeLabel;
useEffect(() => {
if (selectedActionId && !actions.some((action) => action.id === selectedActionId)) {
setSelectedActionId("");
}
}, [actions, selectedActionId]);
const run = async () => {
if (!selectedAction || selectedAction.disabled || busy) return;
await onRun(selectedAction.id);
setSelectedActionId("");
};
return (
<div className="thot-fleet-action-selector">
<div className="thot-fleet-action-selector__scope" aria-live="polite">
<strong>{actionScope ?? `${selectedCount} selected`}</strong>
{actionScope ? (
<span>Action scope</span>
) : hiddenSelectedCount > 0 ? (
<span>{hiddenSelectedCount} hidden by filter</span>
) : (
<span>Selected rows</span>
)}
</div>
<label className="sr-only" htmlFor={hintId}>{label}</label>
<select
ref={selectRef}
id={hintId}
className="thot-fleet-action-selector__select"
aria-label={label}
value={selectedActionId}
disabled={busy}
onChange={(event) => setSelectedActionId(event.target.value as ActionId | "")}
>
<option value="">Choose an action…</option>
{groups.map(({ group, actions: groupActions }) => (
<optgroup key={group} label={group}>
{groupActions.map((action) => (
<option key={action.id} value={action.id}>
{action.label}
{action.disabled && action.disabledReason
? `, unavailable: ${action.disabledReason}`
: ""}
</option>
))}
</optgroup>
))}
</select>
{renderContext ? renderContext(selectedActionId) : null}
<Button
type="button"
size="sm"
variant={selectedAction?.tone === "destructive" ? "destructive" : "default"}
disabled={!selectedAction || selectedAction.disabled || busy}
aria-describedby={unavailableReason ? `${hintId}-reason` : undefined}
onClick={() => void run()}
>
<Play aria-hidden="true" />
{busy ? busyLabel : selectedAction?.runLabel ?? "Run action"}
</Button>
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={onClear}>
<X aria-hidden="true" /> Clear
</Button>
{unavailableReason ? (
<span id={`${hintId}-reason`} className="thot-fleet-action-selector__reason" role="status">
{unavailableReason}
</span>
) : null}
</div>
);
}
@@ -0,0 +1,907 @@
.thot-fleet-ledger {
position: relative;
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
height: 100%;
overflow: hidden;
color: oklch(var(--foreground));
background: oklch(var(--background));
font-family: var(--font-sans);
container-name: work-area fleet-surface;
container-type: inline-size;
}
.thot-fleet-ledger__workspace {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
background-color: oklch(var(--sidebar));
container-name: fleet-workspace;
container-type: inline-size;
}
.thot-fleet-ledger__header {
position: relative;
z-index: 5;
display: grid;
grid-template-areas:
"heading actions"
"status status";
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem 1.5rem;
align-items: center;
padding: 0.625rem 1.25rem;
border-bottom: 1px solid oklch(var(--border));
background: oklch(var(--muted) / 0.42);
}
.thot-fleet-ledger__heading-copy {
grid-area: heading;
min-width: 0;
}
.thot-fleet-ledger__drawer-heading {
min-width: 0;
}
.thot-fleet-ledger__section-label,
.thot-fleet-ledger__status-label,
.thot-fleet-ledger__kpi-label {
margin: 0;
color: oklch(var(--muted-foreground));
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1.35;
text-transform: uppercase;
}
.thot-fleet-ledger__title,
.thot-fleet-ledger__drawer-title {
margin: 0.25rem 0 0;
color: oklch(var(--foreground));
font-family: var(--font-heading);
font-weight: 600;
letter-spacing: -0.025em;
}
.thot-fleet-ledger__title {
margin: 0;
font-family: var(--font-sans);
font-size: 1rem;
font-weight: 700;
letter-spacing: -0.0125em;
line-height: 1.25;
white-space: nowrap;
}
.thot-fleet-ledger__drawer-description {
max-width: 68ch;
margin: 0.45rem 0 0;
color: oklch(var(--muted-foreground));
font-size: 0.875rem;
line-height: 1.55;
}
.thot-fleet-ledger__header-actions {
grid-area: actions;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
justify-content: flex-end;
}
.thot-fleet-ledger__header-status {
grid-area: status;
min-width: 0;
}
.thot-fleet-ledger__header-status > .thot-fleet-ledger__status {
display: block;
padding: 0;
border: 0;
background: transparent;
}
.thot-fleet-ledger__header-status .thot-fleet-ledger__section-label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.thot-fleet-ledger__header-status .thot-fleet-ledger__status-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem 1.25rem;
}
.thot-fleet-ledger__header-status .thot-fleet-ledger__status-item {
min-width: 0;
}
.thot-fleet-ledger__status {
display: grid;
gap: 0.75rem;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid oklch(var(--border));
background: oklch(var(--muted) / 0.42);
}
.thot-fleet-ledger__status-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.thot-fleet-ledger__status-item {
--fleet-status-color: oklch(var(--muted-foreground));
display: flex;
min-width: min(100%, 10rem);
gap: 0.5rem;
align-items: flex-start;
}
.thot-fleet-ledger__status-item[data-tone="success"] {
--fleet-status-color: oklch(var(--success));
}
.thot-fleet-ledger__status-item[data-tone="warning"] {
--fleet-status-color: oklch(var(--warning));
}
.thot-fleet-ledger__status-item[data-tone="danger"] {
--fleet-status-color: oklch(var(--destructive));
}
.thot-fleet-ledger__status-item[data-tone="info"] {
--fleet-status-color: oklch(var(--info));
}
.thot-fleet-ledger__status-mark {
width: 0.45rem;
height: 0.45rem;
margin-top: 0.33rem;
flex: 0 0 auto;
border-radius: 999px;
background: var(--fleet-status-color);
box-shadow: 0 0 0 3px color-mix(in oklch, var(--fleet-status-color) 16%, transparent);
}
.thot-fleet-ledger__status-copy {
display: grid;
grid-template-columns: auto auto;
gap: 0.05rem 0.4rem;
min-width: 0;
}
.thot-fleet-ledger__status-label {
grid-column: 1 / -1;
font-size: 0.625rem;
}
.thot-fleet-ledger__status-value {
overflow: hidden;
color: oklch(var(--foreground));
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 700;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.thot-fleet-ledger__status-detail {
overflow: hidden;
color: oklch(var(--muted-foreground));
font-size: 0.75rem;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.thot-fleet-ledger__kpi-section {
display: grid;
gap: 0.625rem;
padding: 0.75rem 1.25rem 0;
border-bottom: 1px solid oklch(var(--border));
background: oklch(var(--card));
}
.thot-fleet-ledger__kpi-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 8.5rem), 1fr));
margin: 0;
}
.thot-fleet-ledger__kpi {
min-width: 0;
padding: 0 1rem 0.75rem 0;
}
.thot-fleet-ledger__kpi + .thot-fleet-ledger__kpi {
padding-left: 1rem;
border-left: 1px solid oklch(var(--border));
}
.thot-fleet-ledger__kpi-value,
.thot-fleet-ledger__kpi-detail {
margin: 0;
}
.thot-fleet-ledger__kpi-value {
margin-top: 0.2rem;
color: oklch(var(--foreground));
font-family: var(--font-mono);
font-size: clamp(1.25rem, 2vw, 1.625rem);
font-variant-numeric: tabular-nums;
font-weight: 700;
letter-spacing: -0.035em;
line-height: 1.2;
}
.thot-fleet-ledger__kpi-detail {
margin-top: 0.15rem;
color: oklch(var(--muted-foreground));
font-size: 0.75rem;
line-height: 1.4;
}
.thot-fleet-ledger__navigation {
position: relative;
z-index: 4;
display: flex;
min-width: 0;
min-height: 3rem;
gap: 0.5rem;
align-items: center;
padding: 0.5rem 1rem;
border-bottom: 1px solid oklch(var(--border));
background: oklch(var(--card));
}
.thot-fleet-ledger__navigation > button:first-child {
min-height: 2.25rem;
border: 1px solid oklch(var(--primary) / 0.28);
color: oklch(var(--primary));
background: oklch(var(--primary) / 0.08);
box-shadow: 0 1px 2px oklch(var(--primary) / 0.1);
font-weight: 700;
}
.thot-fleet-ledger__navigation > button:first-child:not(:disabled):hover {
border-color: oklch(var(--primary) / 0.48);
color: oklch(var(--primary));
background: oklch(var(--primary) / 0.14);
}
.thot-fleet-ledger__breadcrumb {
min-width: 0;
overflow: visible;
}
.thot-fleet-ledger__breadcrumb-list {
display: flex;
min-width: 0;
margin: 0;
padding: 0;
align-items: center;
list-style: none;
}
.thot-fleet-ledger__breadcrumb-item {
display: flex;
min-width: 0;
align-items: center;
}
.thot-fleet-ledger__breadcrumb-separator {
width: 0.9rem;
height: 0.9rem;
margin-inline: 0.2rem;
flex: 0 0 auto;
color: oklch(var(--muted-foreground) / 0.65);
}
.thot-fleet-ledger__breadcrumb-button {
max-width: 13rem;
overflow: hidden;
padding: 0.2rem 0.25rem;
border: 0;
border-radius: calc(var(--radius) - 0.25rem);
color: oklch(var(--muted-foreground));
background: transparent;
font: inherit;
font-size: 0.8125rem;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.thot-fleet-ledger__breadcrumb-button:hover {
color: oklch(var(--foreground));
background: oklch(var(--muted));
}
.thot-fleet-ledger__breadcrumb-button:focus-visible {
outline: 2px solid oklch(var(--ring));
outline-offset: 2px;
}
.thot-fleet-ledger__breadcrumb-label {
max-width: 18rem;
overflow: hidden;
color: oklch(var(--foreground));
font-size: 0.8125rem;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.thot-fleet-ledger__navigation-trailing,
.thot-fleet-ledger__command-trailing {
display: flex;
gap: 0.5rem;
align-items: center;
margin-left: auto;
}
.thot-fleet-ledger__command-bar {
display: flex;
flex-wrap: wrap;
min-height: 3.25rem;
gap: 0.625rem;
align-items: center;
padding: 0.625rem 1rem;
border-bottom: 1px solid oklch(var(--border));
background: oklch(var(--muted) / 0.24);
}
.thot-fleet-ledger__command-picker {
display: flex;
min-width: min(100%, 12rem);
gap: 0.5rem;
align-items: center;
}
.thot-fleet-action-selector {
display: flex;
min-width: 0;
flex: 1;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.thot-fleet-action-selector__scope {
display: grid;
min-width: 7.5rem;
color: oklch(var(--foreground));
font-family: var(--font-mono);
font-size: 0.6875rem;
line-height: 1.35;
}
.thot-fleet-action-selector__scope span {
color: oklch(var(--muted-foreground));
font-size: 0.625rem;
}
.thot-fleet-action-selector__select {
width: min(100%, 22rem);
min-width: 13rem;
height: 2rem;
padding: 0 2rem 0 0.625rem;
border: 1px solid oklch(var(--input));
border-radius: var(--radius);
color: oklch(var(--foreground));
background: oklch(var(--background));
font-size: 0.8125rem;
outline: none;
}
.thot-fleet-action-selector__select:focus-visible {
border-color: oklch(var(--primary) / 0.65);
box-shadow: 0 0 0 3px oklch(var(--ring) / 0.15);
}
.thot-fleet-action-selector__select:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.thot-fleet-action-selector__reason {
flex-basis: 100%;
color: oklch(var(--muted-foreground));
font-size: 0.75rem;
line-height: 1.4;
}
.thot-fleet-metadata-model {
min-width: min(100%, 14rem);
flex: 0 1 16rem;
}
.thot-fleet-metadata-model > div {
min-width: 0;
gap: 0.25rem;
}
.thot-fleet-metadata-model label {
gap: 0.25rem;
}
.thot-fleet-metadata-model select {
height: 2rem;
}
.thot-fleet-ledger__selection-summary {
min-width: 0;
overflow: hidden;
color: oklch(var(--muted-foreground));
font-family: var(--font-mono);
font-size: 0.6875rem;
letter-spacing: 0.025em;
text-overflow: ellipsis;
white-space: nowrap;
}
.thot-fleet-ledger__content {
position: relative;
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
min-height: 18rem;
margin-block-end: 30px;
margin-inline-start: 20px;
overflow: hidden;
border-inline-end: 1px solid oklch(var(--border));
background: oklch(var(--card));
}
.thot-fleet-ledger__drawer {
position: absolute;
z-index: 50;
inset-block: 0.75rem;
inset-inline-start: 50%;
inset-inline-end: auto;
display: flex;
width: 60%;
min-width: 0;
flex-direction: column;
overflow: hidden;
border: 1px solid oklch(var(--border));
border-radius: 0.75rem;
color: oklch(var(--card-foreground));
background: oklch(var(--card));
box-shadow:
0 28px 72px oklch(0.12 0.02 20 / 0.28),
0 7px 20px oklch(0.12 0.02 20 / 0.16);
transform: translateX(-50%);
}
.thot-fleet-ledger .thot-fleet-ledger__context-panel {
position: absolute;
inset-block: 0.75rem;
inset-inline-start: 50%;
inset-inline-end: auto;
width: 60%;
max-width: none;
min-width: 0;
transform: translateX(-50%);
}
.thot-fleet-drawer-form {
min-height: 0;
}
.thot-fleet-drawer-form__context {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid oklch(var(--border));
}
.thot-catalog-drawer {
isolation: isolate;
background-color: oklch(var(--background));
background-image: none;
box-shadow:
0 30px 84px oklch(0.12 0.02 20 / 0.3),
0 8px 22px oklch(0.12 0.02 20 / 0.18);
}
.thot-catalog-drawer__body--empty {
display: flex;
align-items: flex-start;
}
.thot-catalog-drawer__body--flush {
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
}
.thot-catalog-drawer__footer--split {
justify-content: space-between;
}
.thot-fleet-ledger__drawer-header,
.thot-catalog-drawer__header {
color: oklch(var(--primary-foreground));
background-color: oklch(var(--primary));
background-image: none;
border-color: oklch(var(--primary-foreground) / 0.18);
box-shadow: inset 0 -1px 0 oklch(var(--primary-foreground) / 0.18);
}
.thot-fleet-ledger__drawer-header .thot-fleet-ledger__drawer-title,
.thot-fleet-ledger__drawer-header .thot-fleet-ledger__drawer-description,
.thot-fleet-ledger__drawer-header .thot-fleet-ledger__eyebrow,
.thot-catalog-drawer__header .thot-label,
.thot-catalog-drawer__header .text-muted-foreground {
color: oklch(var(--primary-foreground));
}
.thot-fleet-ledger__drawer-header button,
.thot-catalog-drawer__header button {
border-color: oklch(var(--primary-foreground) / 0.22);
color: inherit;
background: oklch(var(--primary-foreground) / 0.1);
}
.thot-fleet-ledger__drawer-header button:hover,
.thot-catalog-drawer__header button:hover {
color: inherit;
background: oklch(var(--primary-foreground) / 0.18);
}
.thot-catalog-drawer__history button[aria-current="true"] {
color: oklch(var(--foreground));
background: oklch(var(--primary) / 0.09);
box-shadow: inset 3px 0 0 oklch(var(--primary));
}
.thot-fleet-grid-surface {
position: relative;
}
.thot-fleet-grid-toolbar {
background: oklch(var(--muted) / 0.32);
}
.thot-fleet-ledger-grid {
--ag-header-background-color: oklch(var(--muted) / 0.84);
--ag-odd-row-background-color: oklch(var(--card));
--ag-row-hover-color: oklch(var(--primary) / 0.045);
--ag-selected-row-background-color: oklch(var(--primary) / 0.085);
}
.thot-fleet-ledger-grid .ag-header-cell-text {
font-family: var(--font-mono);
font-size: 0.6875rem;
letter-spacing: 0.055em;
text-transform: uppercase;
}
.thot-fleet-ledger-grid .ag-row {
background: oklch(var(--card));
}
.thot-fleet-ledger-grid .ag-row-even {
background: oklch(var(--muted) / 0.12);
}
.thot-fleet-icon-action {
position: relative;
}
.thot-fleet-nav-action {
position: relative;
}
.thot-fleet-icon-action::after {
position: absolute;
z-index: 80;
inset-inline-end: calc(100% + 5px);
inset-block-start: 50%;
width: max-content;
max-width: 12rem;
padding: 0.3rem 0.45rem;
border: 1px solid oklch(var(--border));
border-radius: calc(var(--radius) - 0.125rem);
color: oklch(var(--popover-foreground));
background: oklch(var(--popover));
box-shadow: var(--shadow-sm);
content: attr(data-tooltip);
font-size: 0.6875rem;
font-weight: 650;
line-height: 1.2;
opacity: 0;
pointer-events: none;
transform: translate(2px, -50%);
transition: opacity 60ms cubic-bezier(0.22, 1, 0.36, 1), transform 60ms cubic-bezier(0.22, 1, 0.36, 1);
visibility: hidden;
}
.thot-fleet-nav-action::after {
position: absolute;
z-index: 90;
inset-inline-start: 50%;
inset-block-end: calc(100% + 5px);
width: max-content;
max-width: 12rem;
padding: 0.3rem 0.45rem;
border: 1px solid oklch(var(--border));
border-radius: calc(var(--radius) - 0.125rem);
color: oklch(var(--popover-foreground));
background: oklch(var(--popover));
box-shadow: var(--shadow-sm);
content: attr(data-tooltip);
font-size: 0.6875rem;
font-weight: 650;
line-height: 1.2;
opacity: 0;
pointer-events: none;
transform: translate(-50%, 2px);
transition: opacity 60ms cubic-bezier(0.22, 1, 0.36, 1), transform 60ms cubic-bezier(0.22, 1, 0.36, 1);
visibility: hidden;
}
.thot-fleet-nav-action--below::after {
inset-block: calc(100% + 5px) auto;
transform: translate(-50%, -2px);
}
.thot-fleet-icon-action:hover::after,
.thot-fleet-icon-action:focus-visible::after,
.thot-fleet-icon-action:focus-within::after {
opacity: 1;
transform: translate(0, -50%);
visibility: visible;
}
.thot-fleet-nav-action:hover,
.thot-fleet-nav-action:focus-visible {
z-index: 90;
}
.thot-fleet-nav-action:hover::after,
.thot-fleet-nav-action:focus-visible::after {
opacity: 1;
transform: translate(-50%, 0);
visibility: visible;
}
.thot-fleet-ledger-grid .ag-row:has(.thot-fleet-icon-action:hover),
.thot-fleet-ledger-grid .ag-row:has(.thot-fleet-icon-action:focus-visible),
.thot-fleet-ledger-grid .ag-row:has(.thot-fleet-icon-action:focus-within),
.thot-fleet-ledger-grid .ag-grid-pinned-right-cells:has(.thot-fleet-icon-action:hover),
.thot-fleet-ledger-grid .ag-grid-pinned-right-cells:has(.thot-fleet-icon-action:focus-visible),
.thot-fleet-ledger-grid .ag-grid-pinned-right-cells:has(.thot-fleet-icon-action:focus-within),
.thot-fleet-ledger-grid .ag-cell:has(.thot-fleet-icon-action:hover),
.thot-fleet-ledger-grid .ag-cell:has(.thot-fleet-icon-action:focus-visible),
.thot-fleet-ledger-grid .ag-cell:has(.thot-fleet-icon-action:focus-within) {
z-index: 8;
overflow: visible;
}
.thot-fleet-ledger__drawer:focus-visible {
outline: 2px solid oklch(var(--ring));
outline-offset: -2px;
}
.thot-fleet-ledger__drawer-header {
display: flex;
gap: 1rem;
align-items: flex-start;
justify-content: space-between;
padding: 1rem 1rem 0.875rem 1.25rem;
border-bottom: 1px solid oklch(var(--primary-foreground) / 0.18);
}
.thot-fleet-ledger__drawer-title {
font-size: 1.35rem;
line-height: 1.18;
}
.thot-fleet-ledger__drawer-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 1.25rem;
overscroll-behavior: contain;
}
.thot-fleet-ledger__drawer-body.thot-catalog-drawer__body--empty {
display: flex;
align-items: flex-start;
}
.thot-fleet-ledger__drawer-body.thot-catalog-drawer__body--flush {
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
}
.thot-fleet-ledger__drawer-footer {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
justify-content: flex-end;
padding: 0.875rem 1.25rem;
border-top: 1px solid oklch(var(--border));
background: oklch(var(--muted) / 0.32);
}
.thot-fleet-ledger__drawer-footer.thot-catalog-drawer__footer--split {
justify-content: space-between;
}
@media (min-width: 64rem) {
.thot-fleet-ledger__header {
padding-inline: 1.5rem;
}
.thot-fleet-ledger__status {
grid-template-columns: 9rem minmax(0, 1fr);
align-items: start;
padding-inline: 1.5rem;
}
.thot-fleet-ledger__kpi-section {
padding-inline: 1.5rem;
}
.thot-fleet-ledger__navigation,
.thot-fleet-ledger__command-bar {
padding-inline: 1.25rem;
}
}
@container fleet-surface (max-width: 47.999rem) {
.thot-fleet-ledger__drawer,
.thot-fleet-ledger .thot-fleet-ledger__context-panel {
inset-block: 0.5rem;
width: calc(100% - 1rem);
}
}
@container fleet-workspace (min-width: 82rem) {
.thot-fleet-ledger__header {
grid-template-areas: "heading status actions";
grid-template-columns: max-content minmax(0, 1fr) max-content;
row-gap: 0;
}
}
@container fleet-workspace (max-width: 47.999rem) {
.thot-fleet-ledger__header {
grid-template-areas:
"heading"
"actions"
"status";
grid-template-columns: minmax(0, 1fr);
padding-inline: 1rem;
}
.thot-fleet-ledger__header-actions {
justify-content: flex-start;
}
.thot-fleet-ledger__header-status .thot-fleet-ledger__status-list {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr));
}
}
@media (min-width: 40rem) {
.thot-catalog-drawer__history-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 13.5rem;
gap: 0 1rem;
align-content: start;
}
.thot-catalog-drawer__history-layout > * {
grid-column: 1;
}
.thot-catalog-drawer__history-layout > .thot-catalog-drawer__history {
position: sticky;
top: 0;
grid-column: 2;
grid-row: 1 / span 40;
align-self: start;
margin-top: 0;
}
}
@media (max-width: 47.999rem) {
.thot-fleet-ledger__workspace {
overflow-y: auto;
overscroll-behavior: contain;
}
.thot-fleet-ledger__content {
flex: 0 0 28rem;
min-height: 28rem;
}
.thot-fleet-ledger__kpi-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.thot-fleet-ledger__kpi:nth-child(odd) {
padding-left: 0;
border-left: 0;
}
.thot-fleet-ledger__kpi:nth-child(n + 3) {
padding-top: 0.75rem;
border-top: 1px solid oklch(var(--border));
}
.thot-fleet-ledger__navigation {
flex-wrap: wrap;
}
.thot-fleet-ledger__breadcrumb {
order: 2;
width: 100%;
}
.thot-fleet-ledger__command-picker,
.thot-fleet-ledger__selection-summary {
width: 100%;
}
.thot-fleet-action-selector__select {
width: 100%;
}
}
@media (max-height: 44rem) and (min-width: 48rem) {
.thot-fleet-ledger__workspace {
overflow-y: auto;
overscroll-behavior: contain;
}
.thot-fleet-ledger__status,
.thot-fleet-ledger__kpi-strip {
padding-block: 0.5rem;
}
.thot-fleet-ledger__content {
flex: 0 0 24rem;
min-height: 24rem;
}
}
@media (prefers-reduced-motion: reduce) {
.thot-fleet-ledger__breadcrumb-button {
transition: none;
}
}
@@ -0,0 +1,70 @@
import { useState } from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
function DrawerHarness() {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>Open details</button>
<FleetLedgerDrawer
open={open}
eyebrow="Catalog configuration"
title="Database details"
description="Inspect and edit the selected database."
onClose={() => setOpen(false)}
footer={<button type="button">Save details</button>}
>
<p>Drawer content</p>
</FleetLedgerDrawer>
</>
);
}
test("renders the shared catalog-panel regions and preserves modeless focus behavior", async () => {
const user = userEvent.setup();
render(<DrawerHarness />);
const trigger = screen.getByRole("button", { name: "Open details" });
await user.click(trigger);
const drawer = await screen.findByRole("dialog", { name: "Database details" });
expect(drawer).toHaveAttribute("aria-modal", "false");
expect(drawer).toHaveAttribute("data-catalog-panel");
const labelledBy = drawer.getAttribute("aria-labelledby");
const describedBy = drawer.getAttribute("aria-describedby");
expect(labelledBy).toBeTruthy();
expect(describedBy).toBeTruthy();
expect(document.getElementById(labelledBy!)).toHaveTextContent("Database details");
expect(document.getElementById(describedBy!)).toHaveTextContent(
"Inspect and edit the selected database.",
);
const directRegions = (name: "header" | "body" | "footer") => Array.from(
drawer.children,
).filter((element): element is HTMLElement => (
element instanceof HTMLElement
&& element.getAttribute("data-catalog-panel-region") === name
));
const header = directRegions("header");
const body = directRegions("body");
const footer = directRegions("footer");
expect(header).toHaveLength(1);
expect(body).toHaveLength(1);
expect(footer).toHaveLength(1);
expect(header[0]!).toContainElement(document.getElementById(labelledBy!));
expect(header[0]!).toContainElement(document.getElementById(describedBy!));
expect(body[0]!).toContainElement(screen.getByText("Drawer content"));
expect(footer[0]!).toContainElement(screen.getByRole("button", { name: "Save details" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Close panel" })).toHaveFocus());
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog", { name: "Database details" })).not.toBeInTheDocument();
await waitFor(() => expect(trigger).toHaveFocus());
});
@@ -0,0 +1,369 @@
import { forwardRef, useId, useState, type ReactNode } from "react"
import { ArrowLeft, ChevronRight } from "lucide-react"
import { Button } from "../../components/ui/button"
import {
WorkAreaPanel,
WorkAreaPanelHostProvider,
type WorkAreaPanelProps,
} from "../WorkAreaPanel"
import "./FleetLedgerShell.css"
export type FleetLedgerStatusTone =
| "neutral"
| "success"
| "warning"
| "danger"
| "info"
export interface FleetLedgerStatusItem {
id: string
label: ReactNode
value: ReactNode
detail?: ReactNode
tone?: FleetLedgerStatusTone
}
export interface FleetLedgerKpi {
id: string
label: ReactNode
value: ReactNode
detail?: ReactNode
}
export interface FleetLedgerBreadcrumbItem {
id: string
label: ReactNode
current?: boolean
onSelect?: () => void
}
export interface FleetLedgerBackAction {
label: string
onBack: () => void
disabled?: boolean
}
interface WithClassName {
className?: string
}
function classes(base: string, className?: string) {
return className ? `${base} ${className}` : base
}
export interface FleetLedgerShellProps extends WithClassName {
ariaLabel?: string
header: ReactNode
status?: ReactNode
kpis?: ReactNode
navigation?: ReactNode
commands?: ReactNode
children: ReactNode
drawer?: ReactNode
}
/**
* Presentational frame for a single fleet-inventory surface. Data fetching,
* navigation state and mutations stay with the caller.
*/
export function FleetLedgerShell({
ariaLabel = "Database fleet inventory",
header,
status,
kpis,
navigation,
commands,
children,
drawer,
className,
}: FleetLedgerShellProps) {
const [panelHost, setPanelHost] = useState<HTMLElement | null>(null)
return (
<WorkAreaPanelHostProvider host={panelHost}>
<section
ref={setPanelHost}
className={classes("thot-fleet-ledger", className)}
aria-label={ariaLabel}
>
<div className="thot-fleet-ledger__workspace">
{header}
{status}
{kpis}
{navigation}
{commands}
{children}
</div>
{drawer}
</section>
</WorkAreaPanelHostProvider>
)
}
export interface FleetLedgerHeaderProps extends WithClassName {
title: ReactNode
status?: ReactNode
actions?: ReactNode
actionsLabel?: string
}
export function FleetLedgerHeader({
title,
status,
actions,
actionsLabel = "Database management actions",
className,
}: FleetLedgerHeaderProps) {
return (
<header className={classes("thot-fleet-ledger__header", className)}>
<div className="thot-fleet-ledger__heading-copy">
<h1 className="thot-fleet-ledger__title">{title}</h1>
</div>
{status ? <div className="thot-fleet-ledger__header-status">{status}</div> : null}
{actions ? (
<div className="thot-fleet-ledger__header-actions" role="group" aria-label={actionsLabel}>
{actions}
</div>
) : null}
</header>
)
}
export interface FleetLedgerRealStatusProps extends WithClassName {
title?: ReactNode
items: readonly FleetLedgerStatusItem[]
live?: "off" | "polite"
}
export function FleetLedgerRealStatus({
title = "Live catalog status",
items,
live = "polite",
className,
}: FleetLedgerRealStatusProps) {
const titleId = useId()
return (
<section
className={classes("thot-fleet-ledger__status", className)}
aria-labelledby={titleId}
aria-live={live}
aria-atomic="false"
>
<h2 id={titleId} className="thot-fleet-ledger__section-label">
{title}
</h2>
<ul className="thot-fleet-ledger__status-list">
{items.map((item) => (
<li
key={item.id}
className="thot-fleet-ledger__status-item"
data-tone={item.tone ?? "neutral"}
>
<span className="thot-fleet-ledger__status-mark" aria-hidden="true" />
<span className="thot-fleet-ledger__status-copy">
<span className="thot-fleet-ledger__status-label">{item.label}</span>
<strong className="thot-fleet-ledger__status-value">{item.value}</strong>
{item.detail ? (
<span className="thot-fleet-ledger__status-detail">{item.detail}</span>
) : null}
</span>
</li>
))}
</ul>
</section>
)
}
export interface FleetLedgerKpiStripProps extends WithClassName {
title?: ReactNode
items: readonly FleetLedgerKpi[]
}
export function FleetLedgerKpiStrip({
title = "Fleet summary",
items,
className,
}: FleetLedgerKpiStripProps) {
const titleId = useId()
return (
<section
className={classes("thot-fleet-ledger__kpi-section", className)}
aria-labelledby={titleId}
>
<h2 id={titleId} className="thot-fleet-ledger__section-label">
{title}
</h2>
<dl className="thot-fleet-ledger__kpi-strip">
{items.map((item) => (
<div key={item.id} className="thot-fleet-ledger__kpi">
<dt className="thot-fleet-ledger__kpi-label">{item.label}</dt>
<dd className="thot-fleet-ledger__kpi-value">{item.value}</dd>
{item.detail ? (
<dd className="thot-fleet-ledger__kpi-detail">{item.detail}</dd>
) : null}
</div>
))}
</dl>
</section>
)
}
export interface FleetLedgerBreadcrumbProps extends WithClassName {
items: readonly FleetLedgerBreadcrumbItem[]
back?: FleetLedgerBackAction
trailing?: ReactNode
ariaLabel?: string
}
export function FleetLedgerBreadcrumb({
items,
back,
trailing,
ariaLabel = "Database hierarchy",
className,
}: FleetLedgerBreadcrumbProps) {
return (
<div className={classes("thot-fleet-ledger__navigation", className)}>
{back ? (
<Button
type="button"
variant="ghost"
size="sm"
className="thot-fleet-nav-action"
data-tooltip={back.label.replace(/^Back to\s+/i, "")}
onClick={back.onBack}
disabled={back.disabled}
data-icon="inline-start"
>
<ArrowLeft aria-hidden="true" />
{back.label}
</Button>
) : null}
<nav className="thot-fleet-ledger__breadcrumb" aria-label={ariaLabel}>
<ol className="thot-fleet-ledger__breadcrumb-list">
{items.map((item, index) => {
const isCurrent = item.current ?? index === items.length - 1
return (
<li key={item.id} className="thot-fleet-ledger__breadcrumb-item">
{index > 0 ? (
<ChevronRight
className="thot-fleet-ledger__breadcrumb-separator"
aria-hidden="true"
/>
) : null}
{item.onSelect && !isCurrent ? (
<button
type="button"
className="thot-fleet-ledger__breadcrumb-button thot-fleet-nav-action thot-fleet-nav-action--below"
data-tooltip={item.label}
onClick={item.onSelect}
>
{item.label}
</button>
) : (
<span
className="thot-fleet-ledger__breadcrumb-label"
aria-current={isCurrent ? "page" : undefined}
>
{item.label}
</span>
)}
</li>
)
})}
</ol>
</nav>
{trailing ? <div className="thot-fleet-ledger__navigation-trailing">{trailing}</div> : null}
</div>
)
}
export interface FleetLedgerCommandBarProps extends WithClassName {
picker: ReactNode
selectionSummary?: ReactNode
trailing?: ReactNode
ariaLabel?: string
}
export function FleetLedgerCommandBar({
picker,
selectionSummary,
trailing,
ariaLabel = "Fleet commands",
className,
}: FleetLedgerCommandBarProps) {
return (
<div
className={classes("thot-fleet-ledger__command-bar", className)}
role="group"
aria-label={ariaLabel}
>
<div className="thot-fleet-ledger__command-picker">{picker}</div>
{selectionSummary ? (
<div className="thot-fleet-ledger__selection-summary">{selectionSummary}</div>
) : null}
{trailing ? <div className="thot-fleet-ledger__command-trailing">{trailing}</div> : null}
</div>
)
}
export interface FleetLedgerContentProps extends WithClassName {
children: ReactNode
ariaLabel?: string
busy?: boolean
}
/** Keeps AG Grid or another real-data view as the only primary work surface. */
export function FleetLedgerContent({
children,
ariaLabel = "Fleet inventory results",
busy = false,
className,
}: FleetLedgerContentProps) {
return (
<section
className={classes("thot-fleet-ledger__content", className)}
aria-label={ariaLabel}
aria-busy={busy}
>
{children}
</section>
)
}
export type FleetLedgerDrawerProps = Omit<WorkAreaPanelProps, "catalogMarkers">
/**
* A modeless detail/action drawer. It owns focus entry/return and Escape;
* mutation-specific confirmation and dirty-state policy stay with the caller.
*/
export const FleetLedgerDrawer = forwardRef<HTMLElement, FleetLedgerDrawerProps>(
function FleetLedgerDrawer({ className, bodyClassName, footerClassName, ...props }, ref) {
return (
<WorkAreaPanel
{...props}
ref={ref}
catalogMarkers
className={classes(
"thot-fleet-ledger__drawer thot-fleet-ledger__context-panel thot-catalog-drawer",
className,
)}
bodyClassName={classes(
"thot-fleet-ledger__drawer-body thot-catalog-drawer__body",
bodyClassName,
)}
footerClassName={classes(
"thot-fleet-ledger__drawer-footer thot-catalog-drawer__footer",
footerClassName,
)}
/>
)
},
)
@@ -23,10 +23,6 @@ export function MetadataGenerationModelSelector({
&& models.some((model) => model.id === configuredDefault);
const noUsableModel = !isLoading && !isError && Boolean(data) && !hasUsableDefault;
const unavailable = noUsableModel || (!isLoading && isError);
const describedBy = [
"metadata-generation-source-data-disclosure",
unavailable ? "metadata-generation-model-help" : null,
].filter(Boolean).join(" ");
return (
<div className="grid min-w-56 max-w-lg gap-1.5">
<label className="grid gap-1.5 text-xs font-medium text-foreground">
@@ -34,7 +30,7 @@ export function MetadataGenerationModelSelector({
<select
className="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 disabled:bg-muted disabled:text-muted-foreground"
aria-label="Metadata description model"
aria-describedby={describedBy}
aria-describedby={unavailable ? "metadata-generation-model-help" : undefined}
value={selectedModel}
disabled={!canManage || isLoading || isError || !hasUsableDefault}
onChange={(event) => onSelectedModelChange(event.target.value)}
@@ -54,26 +50,6 @@ export function MetadataGenerationModelSelector({
Configure a metadata-generation model in application setup to enable description generation.
</p>
) : null}
<div
id="metadata-generation-source-data-disclosure"
role="note"
aria-label="Metadata generation source data disclosure"
className="space-y-1 text-xs font-normal leading-4 text-muted-foreground"
>
<p>
Description generation may send up to five source rows and up to five representative
non-null example values to the selected model provider. Values from columns marked
sensitive are replaced with plausible synthetic values before the request.
</p>
<p>
Values from unmarked columns may be sent unchanged. Samples are transient and are not
stored in run logs or catalog metadata.
</p>
<p>
Sensitive-field suggestions use structural metadata only and remain unsaved until you
choose Save sensitive fields.
</p>
</div>
</div>
);
}
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { Save, X } from "lucide-react";
import { Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { apiErrorMessage } from "../../api/client";
@@ -8,6 +8,7 @@ import {
type CatalogColumn,
type SensitiveDataSuggestion,
} from "../../api/catalog-databases";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
interface Props {
open: boolean;
@@ -87,25 +88,30 @@ export function SensitiveDataReviewDrawer({
if (!open || !databaseId) return null;
return (
<aside
aria-label="Sensitive field review"
className="fixed inset-y-2 right-0 z-50 flex w-full max-w-[480px] flex-col border-l border-border bg-background shadow-2xl sm:inset-y-4"
<FleetLedgerDrawer
open
ariaLabel="Sensitive field review"
eyebrow="Sensitive data"
title="Review suggested flags"
description={`${scopeLabel}. The model proposed values, but only your save changes the catalog.`}
onClose={close}
closeLabel="Close sensitive field review"
busy={saving}
bodyClassName="thot-catalog-drawer__body--flush"
footerClassName="thot-catalog-drawer__footer--split"
footer={(
<>
<p className="text-xs text-muted-foreground">Unsaved suggestions never change the catalog.</p>
<div className="flex gap-2">
<Button type="button" variant="outline" disabled={saving} onClick={close}>Cancel</Button>
<Button type="button" disabled={!canManage || saving || changed.length === 0} onClick={() => void save()}>
<Save />{saving ? "Saving…" : `Save ${changed.length}`}
</Button>
</div>
</>
)}
>
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
<p className="thot-label">Sensitive data</p>
<h2 className="mt-1 font-heading text-xl font-semibold">Review suggested flags</h2>
<p className="mt-1 text-sm text-muted-foreground">
{scopeLabel}. The model proposed values, but only your save changes the catalog.
</p>
</div>
<Button type="button" variant="ghost" size="icon-lg" aria-label="Close sensitive field review" disabled={saving} onClick={close}>
<X aria-hidden="true" />
</Button>
</div>
<div className="flex min-h-0 flex-1 flex-col">
<div className="border-b border-border px-5 py-4">
<div className="border-b border-border px-5 py-4">
<div className="flex items-center gap-3">
<input
className="h-9 min-w-0 flex-1 rounded-md border border-input bg-background px-3 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
@@ -127,9 +133,9 @@ export function SensitiveDataReviewDrawer({
/>
Show all {suggestions.length} classified columns
</label>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{visible.length === 0 ? (
<div className="rounded-md border border-border bg-muted/25 px-4 py-5 text-sm">
<p className="font-semibold">No proposed changes in this view.</p>
@@ -171,18 +177,7 @@ export function SensitiveDataReviewDrawer({
})}
</ul>
)}
</div>
</div>
<div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4">
<p className="text-xs text-muted-foreground">Unsaved suggestions never change the catalog.</p>
<div className="flex gap-2">
<Button type="button" variant="outline" disabled={saving} onClick={close}>Cancel</Button>
<Button type="button" disabled={!canManage || saving || changed.length === 0} onClick={() => void save()}>
<Save />{saving ? "Saving…" : `Save ${changed.length}`}
</Button>
</div>
</div>
</aside>
</FleetLedgerDrawer>
);
}
@@ -0,0 +1,132 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import { useState } from "react";
import type { SensitiveDataSuggestionRun } from "../../api/catalog-databases";
import { server } from "../../test/msw";
import { SensitiveDataSuggestionHistoryDrawer } from "./SensitiveDataSuggestionHistoryDrawer";
const runningRun: SensitiveDataSuggestionRun = {
id: "99999999-9999-4999-8999-999999999999",
databaseId: "11111111-1111-4111-8111-111111111111",
scope: "selected_columns",
modelId: "local-qwen",
status: "running",
total: 4,
suggestedSensitive: 1,
suggestedNonSensitive: 1,
createdAt: "2026-08-28T09:00:00Z",
startedAt: "2026-08-28T09:00:00Z",
updatedAt: "2026-08-28T09:00:01Z",
finishedAt: null,
errorSummary: null,
};
function renderDrawer(initialRun: SensitiveDataSuggestionRun | null) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: Infinity } },
});
function Harness() {
const [run, setRun] = useState(initialRun);
return (
<SensitiveDataSuggestionHistoryDrawer
open
run={run}
modelLabel="Local Qwen"
onClose={() => undefined}
onRunUpdate={setRun}
/>
);
}
return render(
<QueryClientProvider client={client}>
<Harness />
</QueryClientProvider>,
);
}
test("shows an explicit empty state before any sensitive suggestion runs exist", async () => {
server.use(http.get(
"/api/catalog/sensitive-data-suggestion-runs",
() => HttpResponse.json([]),
));
renderDrawer(null);
const drawer = await screen.findByRole("dialog", { name: "Sensitive suggestion history" });
expect(within(drawer).getByRole("heading", { name: "Suggestion run history" })).toBeVisible();
expect(await within(drawer).findByText("No sensitive suggestion runs yet.")).toBeVisible();
});
test("shows sensitive suggestion results, safe events, and lets operators inspect an older run", async () => {
const completedRun: SensitiveDataSuggestionRun = {
...runningRun,
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
scope: "all",
status: "completed",
total: 6,
suggestedSensitive: 2,
suggestedNonSensitive: 4,
createdAt: "2026-08-28T08:00:00Z",
startedAt: "2026-08-28T08:00:00Z",
updatedAt: "2026-08-28T08:00:01Z",
finishedAt: "2026-08-28T08:00:01Z",
};
server.use(
http.get("/api/catalog/sensitive-data-suggestion-runs", () => (
HttpResponse.json([runningRun, completedRun])
)),
http.get("/api/catalog/sensitive-data-suggestion-runs/:runId", ({ params }) => (
HttpResponse.json(params.runId === completedRun.id ? completedRun : runningRun)
)),
http.get("/api/catalog/sensitive-data-suggestion-runs/:runId/events-list", ({ params }) => (
HttpResponse.json([{
runId: params.runId,
sequence: 1,
level: "info",
message: params.runId === completedRun.id ? "Classified 6 columns" : "Classifying columns",
createdAt: "2026-08-28T09:00:01Z",
}])
)),
);
const user = userEvent.setup();
renderDrawer(runningRun);
const drawer = await screen.findByRole("dialog", { name: "Sensitive suggestion history" });
expect(within(drawer).getByRole("heading", { name: "Running" })).toBeVisible();
expect(await within(drawer).findByText("Classifying columns")).toBeVisible();
expect(within(drawer).queryByRole("button", { name: "Stop" })).not.toBeInTheDocument();
expect(within(drawer).queryByRole("button", { name: "Unlock stale run" })).not.toBeInTheDocument();
const history = within(drawer).getByRole("region", { name: "Sensitive suggestion run history" });
await user.click(within(history).getByRole("button", { name: /Completed.*all/i }));
expect(await within(drawer).findByRole("heading", { name: "Completed" })).toBeVisible();
expect(within(drawer).getByText("2", { selector: "[data-count='suggestedSensitive']" })).toBeVisible();
expect(within(drawer).getByText("4", { selector: "[data-count='suggestedNonSensitive']" })).toBeVisible();
await waitFor(() => expect(within(drawer).getByText("Classified 6 columns")).toBeVisible());
});
test("does not expose unsafe provider details from a final error summary", async () => {
const failedRun: SensitiveDataSuggestionRun = {
...runningRun,
status: "failed",
finishedAt: "2026-08-28T09:00:02Z",
errorSummary: "stack trace at provider.ts:42 private source sample sk-secret",
};
server.use(
http.get("/api/catalog/sensitive-data-suggestion-runs", () => HttpResponse.json([failedRun])),
http.get("/api/catalog/sensitive-data-suggestion-runs/:runId", () => HttpResponse.json(failedRun)),
http.get("/api/catalog/sensitive-data-suggestion-runs/:runId/events-list", () => HttpResponse.json([])),
);
renderDrawer(failedRun);
expect(await screen.findByText(
"The run ended before all columns were classified. Review the event log for safe details.",
)).toBeVisible();
expect(screen.queryByText(/sk-secret/i)).not.toBeInTheDocument();
});
@@ -0,0 +1,234 @@
import { useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { apiErrorMessage } from "../../api/client";
import {
getSensitiveDataSuggestionRun,
listSensitiveDataSuggestionEvents,
listSensitiveDataSuggestionRuns,
type SensitiveDataSuggestionRun,
} from "../../api/catalog-databases";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
interface Props {
open: boolean;
run: SensitiveDataSuggestionRun | null;
modelLabel: string;
onClose: () => void;
onRunUpdate: (run: SensitiveDataSuggestionRun) => void;
}
const HISTORY_QUERY_KEY = ["sensitive-data-suggestion-runs", 50] as const;
const SAFE_FINAL_ERROR = "The run ended before all columns were classified. Review the event log for safe details.";
function statusLabel(run: SensitiveDataSuggestionRun): string {
const label = run.status.replaceAll("_", " ");
return `${label[0].toUpperCase()}${label.slice(1)}`;
}
function isTerminal(run?: SensitiveDataSuggestionRun): boolean {
return Boolean(run && ["completed", "failed", "interrupted"].includes(run.status));
}
function safeFinalError(summary: string | null): string | null {
const value = summary?.trim();
if (!value) return null;
if (
value.length > 500
|| /[\r\n]/.test(value)
|| /traceback|stack trace|(?:^|\s)at\s+\S+[:(]|prompt|provider\s+payload|source\s+sample|bearer\s+\S+|api[_ -]?key|sk-[a-z0-9_-]+/i.test(value)
) return SAFE_FINAL_ERROR;
return value;
}
function timestamp(value: string | null): string {
return value ? new Date(value).toLocaleString() : "Not available";
}
export function SensitiveDataSuggestionHistoryDrawer({
open,
run: initialRun,
modelLabel,
onClose,
onRunUpdate,
}: Props) {
const queryClient = useQueryClient();
const runId = initialRun?.id ?? null;
const runQuery = useQuery({
queryKey: ["sensitive-data-suggestion-run", runId],
queryFn: () => getSensitiveDataSuggestionRun(runId!),
enabled: open && Boolean(runId),
initialData: initialRun ?? undefined,
retry: false,
refetchInterval: (query) => isTerminal(query.state.data) ? false : 1_000,
});
const run = runQuery.data;
const eventQuery = useQuery({
queryKey: ["sensitive-data-suggestion-events", runId],
queryFn: () => listSensitiveDataSuggestionEvents(runId!),
enabled: open && Boolean(runId),
retry: false,
refetchInterval: run && isTerminal(run) ? false : 1_500,
});
const historyQuery = useQuery({
queryKey: HISTORY_QUERY_KEY,
queryFn: () => listSensitiveDataSuggestionRuns(50),
enabled: open,
retry: false,
refetchInterval: (query) => query.state.data?.some((item) => !isTerminal(item))
? 3_000
: false,
});
useEffect(() => {
if (!open || initialRun || !historyQuery.data?.[0]) return;
onRunUpdate(historyQuery.data[0]);
}, [historyQuery.data, initialRun, onRunUpdate, open]);
useEffect(() => {
if (!run) return;
onRunUpdate(run);
queryClient.setQueryData<SensitiveDataSuggestionRun[]>(HISTORY_QUERY_KEY, (current) => {
if (!current) return [run];
return current.some((item) => item.id === run.id)
? current.map((item) => item.id === run.id ? run : item)
: [run, ...current];
});
}, [onRunUpdate, queryClient, run]);
if (!open) return null;
if (!run) {
return (
<FleetLedgerDrawer
open
ariaLabel="Sensitive suggestion history"
eyebrow="Sensitive data"
title="Suggestion run history"
onClose={onClose}
closeLabel="Close sensitive suggestion history"
bodyClassName="thot-catalog-drawer__body--empty"
>
{historyQuery.isError ? (
<div role="alert" className="w-full rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{apiErrorMessage(historyQuery.error)}
</div>
) : historyQuery.isLoading || (historyQuery.data?.length ?? 0) > 0 ? (
<p className="text-sm text-muted-foreground">Loading suggestion history…</p>
) : (
<div className="w-full rounded-md border border-border bg-muted/25 px-4 py-5 text-sm">
<p className="font-semibold">No sensitive suggestion runs yet.</p>
<p className="mt-1 text-muted-foreground">
Request sensitive-field suggestions to create the first history entry.
</p>
</div>
)}
</FleetLedgerDrawer>
);
}
const events = eventQuery.data ?? [];
const finalError = safeFinalError(run.errorSummary);
return (
<FleetLedgerDrawer
open
ariaLabel="Sensitive suggestion history"
eyebrow="Sensitive data suggestions"
title={statusLabel(run)}
description={`${modelLabel}${modelLabel !== run.modelId ? ` (${run.modelId})` : ""} · ${run.scope.replaceAll("_", " ")}`}
onClose={onClose}
closeLabel="Close sensitive suggestion history"
bodyClassName="thot-catalog-drawer__history-layout"
>
{runQuery.isError ? (
<div role="alert" className="rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{apiErrorMessage(runQuery.error)}
</div>
) : null}
<section aria-label="Sensitive suggestion counters">
<h3 className="thot-label mb-2">Results</h3>
<div className="grid grid-cols-2 gap-2">
{([
["total", "Total columns"],
["suggestedSensitive", "Sensitive"],
["suggestedNonSensitive", "Not sensitive"],
] as const).map(([name, label]) => (
<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">{label}</p>
<p data-count={name} className="mt-1 text-lg font-semibold tabular-nums">{run[name]}</p>
</div>
))}
</div>
</section>
<section className="mt-5" aria-label="Sensitive suggestion timestamps">
<h3 className="thot-label mb-2">Timestamps</h3>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
<dt className="text-muted-foreground">Created</dt><dd className="text-right">{timestamp(run.createdAt)}</dd>
<dt className="text-muted-foreground">Started</dt><dd className="text-right">{timestamp(run.startedAt)}</dd>
<dt className="text-muted-foreground">Updated</dt><dd className="text-right">{timestamp(run.updatedAt)}</dd>
<dt className="text-muted-foreground">Finished</dt><dd className="text-right">{timestamp(run.finishedAt)}</dd>
</dl>
</section>
{finalError ? (
<div className="mt-5 rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{finalError}
</div>
) : null}
<section className="mt-5" aria-label="Sensitive suggestion log">
<div className="mb-2 flex items-center justify-between">
<h3 className="thot-label">Events</h3>
<span className="text-xs tabular-nums text-muted-foreground">{events.length}</span>
</div>
<div
role="log"
aria-label="Sensitive suggestion events"
className="max-h-72 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>
{eventQuery.isError ? <p role="alert" className="mt-2 text-sm text-destructive">{apiErrorMessage(eventQuery.error)}</p> : null}
</section>
<section className="thot-catalog-drawer__history mt-5" aria-label="Sensitive suggestion run history">
<div className="mb-2 flex items-center justify-between gap-3">
<h3 className="thot-label">Recent runs</h3>
<span className="text-xs tabular-nums text-muted-foreground">
{historyQuery.data?.length ?? 0}
</span>
</div>
{historyQuery.isError ? (
<p role="alert" className="text-sm text-destructive">{apiErrorMessage(historyQuery.error)}</p>
) : historyQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading suggestion history…</p>
) : (historyQuery.data ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">No sensitive suggestion runs yet.</p>
) : (
<div className="max-h-56 divide-y divide-border overflow-y-auto rounded-md border border-border">
{(historyQuery.data ?? []).map((item) => (
<button
key={item.id}
type="button"
aria-label={`${statusLabel(item)} · ${item.scope.replaceAll("_", " ")}`}
aria-current={item.id === run.id ? "true" : undefined}
className="flex w-full items-start justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/25"
onClick={() => onRunUpdate(item)}
>
<span className="font-medium">{statusLabel(item)}</span>
<span className="shrink-0 text-right text-xs text-muted-foreground">
{item.suggestedSensitive + item.suggestedNonSensitive}/{item.total}<br />
{new Date(item.createdAt).toLocaleString()}
</span>
</button>
))}
</div>
)}
</section>
</FleetLedgerDrawer>
);
}
@@ -0,0 +1 @@
export { useModelessPanel as useModelessDrawer } from "../WorkAreaPanel";