feat: complete catalog sensitivity enhancements

This commit is contained in:
Codex
2026-09-04 15:11:18 +02:00
parent b891246664
commit 7b1d69a65b
72 changed files with 33866 additions and 358 deletions
@@ -0,0 +1,128 @@
import { act, fireEvent, 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 { CatalogSyncEvent, CatalogSyncRun } from "../../api/catalog-databases";
import { FakeEventSource } from "../../test/fakeEventSource";
import { server } from "../../test/msw";
import { CatalogSyncDrawer } from "./CatalogSyncDrawer";
const runningRun: CatalogSyncRun = {
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
databaseId: "11111111-1111-4111-8111-111111111111",
scope: "all",
tableIds: [],
state: "running",
phase: "scanning_columns",
requestedDatabaseVersion: 3,
plannedDiff: null,
confirmationToken: null,
counts: { tables: 2, columns: 12 },
errorCode: null,
errorMessage: null,
cancelRequested: false,
createdAt: "2026-08-27T10:00:00Z",
startedAt: "2026-08-27T10:00:01Z",
updatedAt: "2026-08-27T10:00:02Z",
finishedAt: null,
heartbeatAt: "2026-08-27T10:00:02Z",
};
function event(sequence: number, message: string): CatalogSyncEvent {
return {
id: sequence,
runId: runningRun.id,
sequence,
level: "info",
eventType: sequence === 1 ? "started" : "scanning_columns",
message,
data: {},
createdAt: `2026-08-27T10:00:0${sequence}Z`,
};
}
function renderDrawer() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: Infinity } },
});
function Harness() {
const [runId, setRunId] = useState(runningRun.id);
return (
<CatalogSyncDrawer
databaseId={runningRun.databaseId}
runId={runId}
open
onClose={() => undefined}
onRunChange={(run) => setRunId(run.id)}
onRunUpdate={() => undefined}
onCatalogChanged={() => undefined}
/>
);
}
return render(
<QueryClientProvider client={client}>
<Harness />
</QueryClientProvider>,
);
}
const nativeEventSource = globalThis.EventSource;
beforeEach(() => {
FakeEventSource.instances = [];
(globalThis as unknown as { EventSource: typeof EventSource }).EventSource = (
FakeEventSource as unknown as typeof EventSource
);
});
afterEach(() => {
(globalThis as unknown as { EventSource: typeof EventSource }).EventSource = nativeEventSource;
});
test("follows the synchronization log tail until the operator scrolls upward", async () => {
const user = userEvent.setup();
let logHeight = 100;
server.use(
http.get("/api/catalog/sync-runs/:runId", () => HttpResponse.json(runningRun)),
http.get("/api/catalog/sync-runs/:runId/events-list", () => (
HttpResponse.json([event(1, "Synchronization started")])
)),
http.get("/api/catalog/databases/:databaseId/sync-runs", () => (
HttpResponse.json([runningRun])
)),
);
renderDrawer();
const log = await screen.findByRole("log", { name: "Synchronization events" });
expect(await within(log).findByText("Synchronization started")).toBeVisible();
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
Object.defineProperties(log, {
clientHeight: { configurable: true, value: 100 },
scrollHeight: { configurable: true, get: () => logHeight },
scrollTop: { configurable: true, value: 0, writable: true },
});
logHeight = 200;
act(() => {
FakeEventSource.instances[0].emitNamed("log", event(2, "Reading source columns"), "2");
});
expect(await within(log).findByText("Reading source columns")).toBeVisible();
await waitFor(() => expect(log.scrollTop).toBe(log.scrollHeight));
log.scrollTop = 0;
fireEvent.scroll(log);
const jumpToLatest = await screen.findByRole("button", { name: "Jump to latest" });
logHeight = 300;
act(() => {
FakeEventSource.instances[0].emitNamed("log", event(3, "Reading source relationships"), "3");
});
expect(await within(log).findByText("Reading source relationships")).toBeVisible();
expect(log.scrollTop).toBe(0);
await user.click(jumpToLatest);
expect(log.scrollTop).toBe(log.scrollHeight);
expect(screen.queryByRole("button", { name: "Jump to latest" })).not.toBeInTheDocument();
});
@@ -16,6 +16,7 @@ import {
type CatalogSyncPhase,
type CatalogSyncRun,
} from "../../api/catalog-databases";
import { useTailFollowing } from "../activityScroll";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
interface Props {
@@ -127,6 +128,17 @@ export function CatalogSyncDrawer({
retry: false,
refetchInterval: run && terminal(run) ? false : 1_500,
});
const latestEventSequence = events.at(-1)?.sequence ?? 0;
const {
followingTail: followingEventTail,
handleScroll: handleEventLogScroll,
jumpToTail: jumpToLatestEvent,
viewportRef: eventLogRef,
} = useTailFollowing<HTMLDivElement>({
active: open && Boolean(run),
resetKey: runId,
tail: latestEventSequence,
});
const mergeEvents = (incoming: CatalogSyncEvent[]) => {
if (incoming.length === 0) return;
@@ -416,9 +428,24 @@ export function CatalogSyncDrawer({
<section className="mt-5" aria-label="Synchronization log">
<div className="mb-2 flex items-center justify-between">
<h3 className="thot-label">Live log</h3>
<span className="text-xs tabular-nums text-muted-foreground">{events.length}</span>
<div className="flex items-center gap-2">
{!followingEventTail && events.length > 0 ? (
<Button type="button" variant="outline" size="xs" onClick={jumpToLatestEvent}>
Jump to latest
</Button>
) : null}
<span className="text-xs tabular-nums text-muted-foreground">{events.length}</span>
</div>
</div>
<div className="max-h-64 overflow-y-auto rounded-md bg-zinc-950 p-3 font-mono text-xs leading-5 text-zinc-200">
<div
ref={eventLogRef}
role="log"
aria-label="Synchronization events"
aria-relevant="additions"
tabIndex={0}
onScroll={handleEventLogScroll}
className="thot-catalog-drawer__event-log max-h-64 overflow-y-auto rounded-md bg-zinc-950 p-3 font-mono text-xs leading-5 text-zinc-200"
>
{events.length === 0 ? <p className="text-zinc-500">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}
@@ -13,6 +13,7 @@ import {
startCatalogSync,
startDescriptionGenerationRun,
updateCatalogColumnMetadata,
updateCatalogColumnSensitive,
type CatalogColumn,
type CatalogSyncRun,
type CatalogTable,
@@ -89,6 +90,76 @@ function SensitiveCell({ data, context }: ICellRendererParams<CatalogColumn, unk
);
}
function ColumnMetadataFieldMatrix({
column,
description,
generatedDescription,
canManage,
busy,
onDescriptionChange,
onGeneratedDescriptionChange,
}: {
column: CatalogColumn;
description: string;
generatedDescription: string;
canManage: boolean;
busy: boolean;
onDescriptionChange: (value: string) => void;
onGeneratedDescriptionChange: (value: string) => void;
}) {
const fieldClass = [
"thot-column-metadata-field flex min-h-0 flex-col gap-1.5",
"text-sm font-semibold",
].join(" ");
const editableClass = [
"min-h-28 flex-1 resize-none rounded-md border border-input bg-card px-3 py-2",
"text-sm font-normal outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15",
].join(" ");
const readOnlyClass = [
"min-h-28 flex-1 resize-none rounded-md border border-input bg-muted/35 px-3 py-2",
"text-sm font-normal",
].join(" ");
return (
<div
role="group"
aria-label="Column metadata fields"
className="thot-column-metadata-matrix mt-5 grid min-h-0 gap-4 sm:grid-cols-2"
>
<label className={fieldClass}>
Source comment
<textarea className={readOnlyClass} value={column.sourceComment ?? ""} readOnly />
</label>
<label className={fieldClass}>
Description
<textarea
className={editableClass}
value={description}
disabled={!canManage || busy}
onChange={(event) => onDescriptionChange(event.target.value)}
/>
</label>
<label className={fieldClass}>
Generated description
<textarea
className={editableClass}
value={generatedDescription}
disabled={!canManage || busy}
onChange={(event) => onGeneratedDescriptionChange(event.target.value)}
/>
</label>
<label className={fieldClass}>
Sensitive motivation
<textarea
className={readOnlyClass}
value={column.sensitivityReason ?? ""}
placeholder="No sensitivity motivation recorded."
readOnly
/>
</label>
</div>
);
}
export function DatabaseColumns({
databaseId,
table,
@@ -264,26 +335,38 @@ export function DatabaseColumns({
setBusy(true);
setSensitiveAction("save");
try {
const updated = await Promise.all(changedSensitiveColumns.map((column) => (
updateCatalogColumnMetadata(
databaseId,
table.id,
column.id,
column.version,
column.description,
column.generatedDescription,
sensitiveDrafts[column.id],
)
)));
const updatedById = new Map(updated.map((column) => [column.id, column]));
queryClient.setQueryData<CatalogColumn[]>(queryKey, (current = []) => current.map(
(column) => updatedById.get(column.id) ?? column,
));
setSensitiveDrafts({});
void onCatalogMetricsChanged?.();
toast.success("Sensitive fields saved");
} catch (error) {
toast.error(apiErrorMessage(error));
const saved: CatalogColumn[] = [];
const failed: unknown[] = [];
for (const column of changedSensitiveColumns) {
try {
saved.push(await updateCatalogColumnSensitive(
databaseId,
table.id,
column.id,
column.version,
sensitiveDrafts[column.id]!,
));
} catch (error) {
failed.push(error);
}
}
if (saved.length > 0) {
const savedById = new Map(saved.map((column) => [column.id, column]));
queryClient.setQueryData<CatalogColumn[]>(queryKey, (current = []) => current.map(
(column) => savedById.get(column.id) ?? column,
));
setSensitiveDrafts((current) => {
const next = { ...current };
for (const column of saved) delete next[column.id];
return next;
});
void onCatalogMetricsChanged?.();
}
if (failed.length > 0) {
toast.error(`${failed.length} sensitive flag${failed.length === 1 ? "" : "s"} could not be saved: ${apiErrorMessage(failed[0])}`);
} else {
toast.success("Sensitive fields saved");
}
} finally {
setSensitiveAction(null);
setBusy(false);
@@ -294,8 +377,7 @@ export function DatabaseColumns({
| "sync-columns"
| "generate-descriptions"
| "consolidate-descriptions"
| "suggest-sensitive"
| "save-sensitive";
| "suggest-sensitive";
const fleetActions: readonly FleetActionOption<FleetColumnAction>[] = [
{
@@ -365,30 +447,13 @@ export function DatabaseColumns({
? "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 to save."
: catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
? "Another action is running."
: undefined,
},
];
const runFleetAction = async (action: FleetColumnAction) => {
if (action === "sync-columns") await synchronizeColumns();
else if (action === "generate-descriptions") await generateDescriptions();
else if (action === "consolidate-descriptions") await consolidateDescriptions();
else if (action === "suggest-sensitive") await suggestSensitive();
else await saveSensitive();
else await suggestSensitive();
};
const clearSelection = () => {
@@ -454,15 +519,15 @@ export function DatabaseColumns({
staleBannerOpen ? <div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm"><p className="font-semibold">Newer column metadata is available.</p><p className="mt-1 text-muted-foreground">Your draft is preserved until you reload.</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>Keep editing</Button></div></div>
: <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>Reload the latest value before this metadata can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button></div>
) : null}
<div className="mt-5 grid gap-4 border-t border-border pt-5 sm:grid-cols-2">
<label className="grid gap-1.5 text-sm font-semibold">Physical column name<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={active.name} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">Native type<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={active.dataType} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">Ordinal position<input className="h-9 rounded-md border border-input bg-muted/35 px-3" value={active.ordinalPosition} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">Default expression<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={active.defaultExpression ?? ""} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold sm:col-span-2">Source comment<textarea className="min-h-24 rounded-md border border-input bg-muted/35 px-3 py-2 text-sm" value={active.sourceComment ?? ""} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold sm:col-span-2">Generated description<textarea className="min-h-28 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={generatedDescription} disabled={!canManage || busy} onChange={(event) => setGeneratedDescription(event.target.value)} /></label>
<label className="grid gap-1.5 text-sm font-semibold sm:col-span-2">Description<textarea className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={description} disabled={!canManage || busy} onChange={(event) => setDescription(event.target.value)} /></label>
</div>
<ColumnMetadataFieldMatrix
column={active}
description={description}
generatedDescription={generatedDescription}
canManage={canManage}
busy={busy}
onDescriptionChange={setDescription}
onGeneratedDescriptionChange={setGeneratedDescription}
/>
<div className="mt-5 flex justify-end gap-2 border-t border-border pt-4">
<Button type="button" variant="outline" disabled={busy} onClick={closeEditor}>Cancel</Button>
<Button type="button" disabled={!canManage || !editorDirty || busy || stale} onClick={() => void save()}><Save />{busy ? "Saving…" : "Save metadata"}</Button>
@@ -486,6 +551,7 @@ export function DatabaseColumns({
onClear={clearSelection}
label="Column action"
/>
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || catalogOperationActive || busy} onClick={() => void saveSensitive()}><Save aria-hidden="true" />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
</>
) : (
<>
@@ -519,6 +585,7 @@ export function DatabaseColumns({
onClear={clearSelection}
label="Column action"
/>
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || catalogOperationActive || busy} onClick={() => void saveSensitive()}><Save aria-hidden="true" />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
<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>
@@ -574,6 +641,7 @@ export function DatabaseColumns({
onClose={closeEditor}
closeLabel="Close column metadata"
busy={busy}
bodyClassName="thot-column-metadata-drawer-body"
footer={(
<>
<Button type="button" variant="outline" disabled={busy} onClick={closeEditor}>Cancel</Button>
@@ -587,15 +655,15 @@ export function DatabaseColumns({
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>
<ColumnMetadataFieldMatrix
column={active}
description={description}
generatedDescription={generatedDescription}
canManage={canManage}
busy={busy}
onDescriptionChange={setDescription}
onGeneratedDescriptionChange={setGeneratedDescription}
/>
</FleetLedgerDrawer>
) : null}
</div>
@@ -198,8 +198,6 @@ export function DatabaseForm({
|| stale
|| busy
|| (mode === "edit" && !dirty);
const bindingReady = row.connectionStatus === "reachable" && row.testedVersion === row.version;
const form = (
<form
className="grid gap-5"
@@ -449,8 +447,7 @@ export function DatabaseForm({
) : null}
{mode === "view" ? (
<DatabaseSyncMenu
disabled={!canManage || !bindingReady || busy || Boolean(activeSyncRun)}
disabledReason={!bindingReady ? "Test the current database binding before synchronizing the schema" : undefined}
disabled={!canManage || busy || Boolean(activeSyncRun)}
onSelect={onSync}
/>
) : null}
@@ -496,8 +493,7 @@ export function DatabaseForm({
) : null}
{mode === "view" ? (
<DatabaseSyncMenu
disabled={!canManage || !bindingReady || busy || Boolean(activeSyncRun)}
disabledReason={!bindingReady ? "Test the current database binding before synchronizing the schema" : undefined}
disabled={!canManage || busy || Boolean(activeSyncRun)}
onSelect={onSync}
/>
) : null}
@@ -50,6 +50,7 @@ interface DatabaseGridProps {
rows: CatalogDatabase[],
scope: Extract<DescriptionGenerationScope, "all" | "missing">,
) => Promise<void>;
onConsolidateColumnDescriptions: (rows: CatalogDatabase[]) => Promise<void>;
onSuggestSensitive: (rows: CatalogDatabase[]) => Promise<void>;
onDeleteMetadataSelected: (
rows: CatalogDatabase[],
@@ -77,6 +78,7 @@ type DatabaseFleetAction =
| "sync-all"
| "generate-all"
| "generate-missing"
| "consolidate-columns"
| "suggest-sensitive"
| "clear-tables"
| "clear-relationships";
@@ -339,6 +341,7 @@ export function DatabaseGrid({
descriptionGenerationActive,
sensitivityAnalysisRuns = [],
onGenerateDescriptions,
onConsolidateColumnDescriptions,
onSuggestSensitive,
onDeleteMetadataSelected,
onRefresh,
@@ -368,11 +371,12 @@ export function DatabaseGrid({
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 [action, setAction] = useState<"test" | "sync" | "generate" | "consolidate" | "suggest" | "delete" | null>(null);
const [pendingDelete, setPendingDelete] = useState<CatalogDatabaseMetadataDeleteTarget | null>(null);
const [pendingGenerationScope, setPendingGenerationScope] = useState<
Extract<DescriptionGenerationScope, "all" | "missing"> | null
>(null);
const [pendingConsolidation, setPendingConsolidation] = useState(false);
const context = useMemo<DatabaseGridContext>(
() => ({ canManage, presentation, onView, onOpenTables, onOpenRelationships, onEdit, onDelete, onOpenSync }),
[canManage, presentation, onView, onOpenTables, onOpenRelationships, onEdit, onDelete, onOpenSync],
@@ -424,12 +428,17 @@ export function DatabaseGrid({
return term && ![row.workspaceName, row.databaseName, row.schema].some((value) => value.toLocaleLowerCase().includes(term));
}).length;
const canTestSelection = canManage && selectedRows.length > 0 && selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun);
const canSyncSelection = canManage && selectedRows.length > 0 && selectedRows.every((row) => row.configured && row.id && row.connectionStatus === "reachable" && row.testedVersion === row.version && !row.activeSyncRun);
const canSyncSelection = canManage && selectedRows.length > 0
&& selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun);
const canGenerateDescriptions = canManage
&& selectedRows.length === 1
&& Boolean(selectedMetadataModel)
&& !descriptionGenerationActive
&& selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun);
const canConsolidateColumnDescriptions = canManage
&& selectedRows.length === 1
&& !descriptionGenerationActive
&& selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun);
const canSuggestSensitive = canManage
&& selectedRows.length === 1
&& !descriptionGenerationActive
@@ -447,11 +456,9 @@ export function DatabaseGrid({
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);
: selectedRows.some((row) => row.activeSyncRun)
? "Wait for active synchronization"
: undefined);
const generationReason = permissionReason
?? (selectedRows.length !== 1 ? "Select exactly one database" : undefined)
?? (!selectedMetadataModel ? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE : undefined)
@@ -459,6 +466,12 @@ export function DatabaseGrid({
?? (selectedRows.some((row) => !row.configured || !row.id || row.activeSyncRun)
? "The selected database must be configured and idle"
: undefined);
const consolidationReason = permissionReason
?? (selectedRows.length !== 1 ? "Select exactly one database" : 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)
?? (descriptionGenerationActive ? "Wait for the active metadata operation" : undefined)
@@ -477,11 +490,13 @@ export function DatabaseGrid({
{ 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: "consolidate-columns", label: "Copy generated descriptions to all columns", group: "Descriptions", disabled: !canConsolidateColumnDescriptions, disabledReason: consolidationReason, runLabel: "Review copy" },
{ id: "suggest-sensitive", label: "Analyze 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-tables", label: "Clear catalog tables and relationships", 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" },
];
}, [
canConsolidateColumnDescriptions,
canDeleteMetadataSelection,
canGenerateDescriptions,
canManage,
@@ -502,6 +517,11 @@ export function DatabaseGrid({
const timer = window.setTimeout(() => document.getElementById("database-generation-confirm-button")?.focus(), 0);
return () => window.clearTimeout(timer);
}, [pendingGenerationScope]);
useEffect(() => {
if (!pendingConsolidation) return;
const timer = window.setTimeout(() => document.getElementById("database-consolidation-confirm-button")?.focus(), 0);
return () => window.clearTimeout(timer);
}, [pendingConsolidation]);
const closeDeleteConfirmation = () => {
setPendingDelete(null);
window.setTimeout(() => {
@@ -516,8 +536,15 @@ export function DatabaseGrid({
else actionsTriggerRef.current?.focus();
}, 0);
};
const closeConsolidationConfirmation = () => {
setPendingConsolidation(false);
window.setTimeout(() => {
if (presentation === "fleet") actionSelectRef.current?.focus();
else actionsTriggerRef.current?.focus();
}, 0);
};
const perform = async (
kind: "test" | "sync" | "generate" | "suggest" | "delete",
kind: "test" | "sync" | "generate" | "consolidate" | "suggest" | "delete",
operation: () => Promise<void>,
) => {
setAction(kind);
@@ -525,6 +552,7 @@ export function DatabaseGrid({
await operation();
setPendingDelete(null);
setPendingGenerationScope(null);
setPendingConsolidation(false);
if (kind === "delete") window.setTimeout(() => searchInputRef.current?.focus(), 0);
} catch {
// The page-level operation owns safe error feedback. Preserve the selection for retry.
@@ -541,6 +569,10 @@ export function DatabaseGrid({
setPendingDelete(selectedAction === "clear-tables" ? "tables" : "relationships");
return;
}
if (selectedAction === "consolidate-columns") {
setPendingConsolidation(true);
return;
}
if (selectedAction === "test") {
await perform("test", () => onTestSelected(selectedRows));
return;
@@ -609,6 +641,37 @@ export function DatabaseGrid({
{pendingGenerationScope === "all" ? "Generate all descriptions" : "Generate missing descriptions"}
</Button>
</div>
) : pendingConsolidation ? (
<div
role="group"
aria-labelledby="database-consolidation-confirmation"
className="flex min-w-0 flex-1 flex-wrap items-center gap-2"
onKeyDown={(event) => {
if (event.key === "Escape" && action === null) closeConsolidationConfirmation();
}}
>
<div className="mr-auto min-w-56">
<p id="database-consolidation-confirmation" className="text-sm font-semibold">
Copy generated descriptions to all columns in {selectedRows[0]?.workspaceName}?
</p>
<p className="text-xs text-muted-foreground">
Every non-empty AI-generated column description will replace the current Description value. Generated descriptions are retained.
</p>
</div>
<Button type="button" variant="ghost" disabled={action !== null} onClick={closeConsolidationConfirmation}>Cancel</Button>
<Button
id="database-consolidation-confirm-button"
type="button"
variant="destructive"
disabled={action !== null}
onClick={() => void perform(
"consolidate",
() => onConsolidateColumnDescriptions(selectedRows),
)}
>
Copy to all columns
</Button>
</div>
) : pendingDelete ? (
<div
role="group"
@@ -695,6 +758,13 @@ export function DatabaseGrid({
>
Generate missing 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={!canConsolidateColumnDescriptions}
onClick={() => setPendingConsolidation(true)}
>
Copy generated descriptions to all columns
</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"
@@ -767,6 +837,7 @@ export function DatabaseGrid({
onSelectionChanged={({ api }) => {
if (pendingDelete && action === null) setPendingDelete(null);
if (pendingGenerationScope && action === null) setPendingGenerationScope(null);
if (pendingConsolidation && action === null) setPendingConsolidation(false);
const rows = api.getSelectedRows();
setSelectedRows(rows);
onSelectionChange?.(rows);
@@ -133,6 +133,21 @@ test("shows one relationship map and filters active, excluded, and all relations
expect(screen.getByText("legacy_orders")).toBeVisible();
});
test("offers only relationship-scoped actions and uses build wording for generation", async () => {
renderRelationships([]);
const actionPicker = await screen.findByRole("combobox", { name: "Relationship action" });
expect(within(actionPicker).getByRole("option", {
name: "Build generated relationships",
})).toBeVisible();
expect(within(actionPicker).getByRole("option", {
name: "Synchronize physical relationships",
})).toBeVisible();
expect(within(actionPicker).queryByRole("option", {
name: "Synchronize the full database schema",
})).not.toBeInTheDocument();
});
test("adds a manual relationship from four catalog selections", async () => {
const user = userEvent.setup();
const orders: CatalogTable = {
@@ -232,11 +247,11 @@ test("rebuilds generated relationships with progress and a result summary", asyn
"rebuild-generated",
);
await user.click(screen.getByRole("button", { name: "Run action" }));
expect(await screen.findByRole("button", { name: "Rebuilding…" })).toBeDisabled();
expect(await screen.findByRole("button", { name: "Building…" })).toBeDisabled();
finish?.();
expect(await screen.findByText(
"Rebuild complete: 12 added, 7 already present, 3 excluded, 2 ambiguous.",
"Build complete: 12 added, 7 already present, 3 excluded, 2 ambiguous.",
)).toBeVisible();
});
@@ -253,7 +268,7 @@ test("reports when rebuilding finds no new relationships", async () => {
"rebuild-generated",
);
await user.click(screen.getByRole("button", { name: "Run action" }));
expect(await screen.findByText("Rebuild complete: no new relationships found.")).toBeVisible();
expect(await screen.findByText("Build complete: no new relationships found.")).toBeVisible();
});
test("excludes a generated relationship after an explanatory drawer confirmation", async () => {
@@ -276,7 +291,7 @@ test("excludes a generated relationship after an explanatory drawer confirmation
expect(within(drawer).getByText("Generated")).toBeVisible();
await user.click(within(drawer).getByRole("button", { name: "Exclude" }));
expect(within(drawer).getByText(
"The relationship will move to Excluded. Rebuilds will not recreate it, and you can restore it later.",
"The relationship will move to Excluded. Builds will not recreate it, and you can restore it later.",
)).toBeVisible();
const confirmExclude = within(drawer).getByRole("button", { name: "Exclude relationship" });
await waitFor(() => expect(confirmExclude).toHaveFocus());
@@ -284,7 +299,7 @@ test("excludes a generated relationship after an explanatory drawer confirmation
await waitFor(() => expect(requestBody).toEqual({ status: "excluded" }));
expect(await screen.findByText(
"Relationship excluded. Future rebuilds will leave it excluded.",
"Relationship excluded. Future builds will leave it excluded.",
)).toBeVisible();
});
@@ -321,13 +336,13 @@ test("permanently deletes a logical relationship after warning that rebuild may
const drawer = await screen.findByRole("dialog", { name: "Relationship details" });
await user.click(within(drawer).getByRole("button", { name: "Delete permanently" }));
expect(within(drawer).getByText(
"The relationship record will be erased. A future rebuild may infer it again.",
"The relationship record will be erased. A future build may infer it again.",
)).toBeVisible();
await user.click(within(drawer).getByRole("button", { name: "Delete permanently" }));
await waitFor(() => expect(deletes).toBe(1));
expect(await screen.findByText(
"Relationship deleted permanently. A future rebuild may infer it again.",
"Relationship deleted permanently. A future build may infer it again.",
)).toBeVisible();
});
@@ -98,7 +98,7 @@ export function DatabaseRelationships({
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<RelationshipStatusFilter>("active");
const [busy, setBusy] = useState(false);
const [selectedAction, setSelectedAction] = useState<"" | "rebuild-generated" | "relationships" | "all">("");
const [selectedAction, setSelectedAction] = useState<"" | "rebuild-generated" | "relationships">("");
const [activeRelationshipId, setActiveRelationshipId] = useState<string | null>(null);
const [addOpen, setAddOpen] = useState(false);
const [sourceTableId, setSourceTableId] = useState("");
@@ -110,7 +110,6 @@ export function DatabaseRelationships({
const [pendingMutation, setPendingMutation] = useState<"exclude" | "delete" | null>(null);
const originRef = useRef<HTMLButtonElement | null>(null);
const statusFilterRef = useRef<HTMLSelectElement | null>(null);
const bindingReady = database.connectionStatus === "reachable" && database.testedVersion === database.version;
const activeRelationship = activeRelationshipId
? data.find((relationship) => relationship.id === activeRelationshipId)
: undefined;
@@ -152,9 +151,7 @@ export function DatabaseRelationships({
? "You do not have permission to manage catalog relationships."
: database.activeSyncRun
? "Wait for the active synchronization to finish."
: selectedAction !== "rebuild-generated" && !bindingReady
? "Test the current database binding first."
: null;
: null;
const addUnavailable = !canManage
? "You do not have permission to add catalog relationships."
: database.activeSyncRun
@@ -163,12 +160,12 @@ export function DatabaseRelationships({
? "Wait for the current relationship operation to finish."
: null;
const synchronize = async (scope: "relationships" | "all") => {
const synchronize = async (scope: "relationships") => {
setBusy(true);
try {
const run = await startCatalogSync(databaseId, database.version, scope);
onRunStarted(run);
toast.success(scope === "all" ? "Full schema synchronization started" : "Relationship synchronization started");
toast.success("Relationship synchronization started");
} catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(false); }
};
@@ -203,7 +200,7 @@ export function DatabaseRelationships({
else statusFilterRef.current?.focus();
}, 0);
toast.success(status === "excluded"
? "Relationship excluded. Future rebuilds will leave it excluded."
? "Relationship excluded. Future builds will leave it excluded."
: "Relationship restored.");
} catch (error) {
toast.error(apiErrorMessage(error));
@@ -225,7 +222,7 @@ export function DatabaseRelationships({
if (originRef.current?.isConnected) originRef.current.focus();
else statusFilterRef.current?.focus();
}, 0);
toast.success("Relationship deleted permanently. A future rebuild may infer it again.");
toast.success("Relationship deleted permanently. A future build may infer it again.");
} catch (error) {
toast.error(apiErrorMessage(error));
} finally {
@@ -289,8 +286,8 @@ export function DatabaseRelationships({
&& result.excluded === 0
&& result.ambiguous === 0;
toast.success(noFindings
? "Rebuild complete: no new relationships found."
: `Rebuild complete: ${result.added} added, ${result.alreadyPresent} already present, ${result.excluded} excluded, ${result.ambiguous} ambiguous.`);
? "Build complete: no new relationships found."
: `Build complete: ${result.added} added, ${result.alreadyPresent} already present, ${result.excluded} excluded, ${result.ambiguous} ambiguous.`);
} catch (error) {
toast.error(apiErrorMessage(error));
} finally {
@@ -341,7 +338,7 @@ export function DatabaseRelationships({
</div>
<div className="flex gap-2">
{database.activeSyncRun ? <Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />View sync</Button> : null}
<Button type="button" disabled={!canManage || !bindingReady || busy || Boolean(database.activeSyncRun)} onClick={() => void synchronize("all")}><RefreshCw />Sync all</Button>
<Button type="button" disabled={!canManage || busy || Boolean(database.activeSyncRun)} onClick={() => void synchronize("relationships")}><RefreshCw />Synchronize relationships</Button>
</div>
</div>
<nav aria-label="Database sections" className="flex gap-1 border-b border-border" role="tablist">
@@ -381,18 +378,17 @@ export function DatabaseRelationships({
className="thot-fleet-action-selector__select"
value={selectedAction}
disabled={busy}
onChange={(event) => setSelectedAction(event.target.value as "" | "rebuild-generated" | "relationships" | "all")}
onChange={(event) => setSelectedAction(event.target.value as "" | "rebuild-generated" | "relationships")}
>
<option value="">Choose an action…</option>
<optgroup label="Logical relationships">
<option value="rebuild-generated">Rebuild generated relationships</option>
<option value="rebuild-generated">Build generated relationships</option>
</optgroup>
<optgroup label="Synchronization">
<option value="relationships">Synchronize physical relationships</option>
<option value="all">Synchronize the full database schema</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 ? selectedAction === "rebuild-generated" ? "Rebuilding…" : "Running…" : "Run action"}</Button>
<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 ? selectedAction === "rebuild-generated" ? "Building…" : "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>
@@ -414,12 +410,11 @@ export function DatabaseRelationships({
{addUnavailable ? <span id="relationship-add-reason" className="sr-only">{addUnavailable}</span> : null}
{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 />Synchronize relationships</Button>}
) : <Button type="button" disabled={!canManage || busy || Boolean(database.activeSyncRun)} onClick={() => void synchronize("relationships")}><RefreshCw />Synchronize relationships</Button>}
</div>
{!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={visibleRelationships} 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={statusFilter === "active" ? "No relationships yet. Rebuild from column names or add one manually." : statusFilter === "excluded" ? "No excluded relationships." : "No relationships for this database."} />
<AgGridReact<CatalogRelationship> rowData={visibleRelationships} 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={statusFilter === "active" ? "No relationships yet. Build from column names or add one manually." : statusFilter === "excluded" ? "No excluded relationships." : "No relationships for this database."} />
</div>
</div>}
</div>
@@ -549,8 +544,8 @@ export function DatabaseRelationships({
>
<p id="relationship-mutation-confirmation" className="mr-auto max-w-xl text-sm text-muted-foreground">
{pendingMutation === "exclude"
? "The relationship will move to Excluded. Rebuilds will not recreate it, and you can restore it later."
: "The relationship record will be erased. A future rebuild may infer it again."}
? "The relationship will move to Excluded. Builds will not recreate it, and you can restore it later."
: "The relationship record will be erased. A future build may infer it again."}
</p>
<Button type="button" variant="ghost" disabled={busy} onClick={() => setPendingMutation(null)}>Cancel</Button>
<Button
@@ -131,7 +131,6 @@ export function DatabaseTables({
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;
const clearAllCatalogTables = async () => {
if (!onClearCatalogTables || !window.confirm("Clear all catalog tables, columns, and relationships for this database? The source database will not be changed.")) return;
@@ -389,32 +388,28 @@ export function DatabaseTables({
group: "Synchronization",
scopeLabel: "Selected database",
runLabel: "Synchronize",
disabled: !canManage || selectedIds.length === 0 || !bindingReady || busy !== null || Boolean(currentRun),
disabled: !canManage || selectedIds.length === 0 || 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,
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: undefined,
},
{
id: "sync-columns",
label: "Synchronize columns for selected tables",
group: "Synchronization",
runLabel: "Synchronize",
disabled: !canManage || selectedIds.length === 0 || !bindingReady || busy !== null || Boolean(currentRun),
disabled: !canManage || selectedIds.length === 0 || 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,
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: undefined,
},
{
id: "suggest-sensitive",
@@ -503,7 +498,7 @@ export function DatabaseTables({
<div className="flex gap-2">
{currentRun ? <Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />View sync</Button> : null}
{onClearCatalogTables ? <Button type="button" variant="outline" className="text-destructive" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void clearAllCatalogTables()}>Clear catalog tables</Button> : null}
<Button type="button" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("all")}><RefreshCw />Sync all</Button>
<Button type="button" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("all")}><RefreshCw />Sync all</Button>
</div>
</div>
<nav aria-label="Database sections" className="flex gap-1 border-b border-border" role="tablist">
@@ -741,8 +736,8 @@ export function DatabaseTables({
<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 table description</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 || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateColumnDescriptions()}>Generate descriptions for all 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 || !bindingReady || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("tables")}>Synchronize database tables</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 for selected tables</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 synchronize("tables")}>Synchronize database tables</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 synchronize("columns", selectedIds)}>Synchronize columns for selected tables</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()}>Copy generated descriptions 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")}>Remove synchronized columns</Menu.Item>
@@ -782,7 +777,6 @@ export function DatabaseTables({
)
)}
</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 columns.</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<CatalogTable>
@@ -1,4 +1,4 @@
import { act, render, screen, waitFor, within } from "@testing-library/react";
import { act, fireEvent, 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";
@@ -94,7 +94,9 @@ afterEach(() => {
});
test("replays ordered SSE events, reconnects from the latest sequence, and keeps polling as a deduplicated fallback", async () => {
const user = userEvent.setup();
let eventReads = 0;
let logHeight = 100;
server.use(
http.get("/api/catalog/description-generation-runs", () => HttpResponse.json([runningRun])),
http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(runningRun)),
@@ -117,13 +119,21 @@ test("replays ordered SSE events, reconnects from the latest sequence, and keeps
expect(FakeEventSource.instances[0].url).toBe(
`/api/catalog/description-generation-runs/${runningRun.id}/events?after=1`,
);
Object.defineProperties(log, {
clientHeight: { configurable: true, value: 100 },
scrollHeight: { configurable: true, get: () => logHeight },
scrollTop: { configurable: true, value: 0, writable: true },
});
logHeight = 200;
act(() => {
FakeEventSource.instances[0].emitNamed("log", event(2, "First description saved"), "2");
FakeEventSource.instances[0].emitNamed("log", event(2, "First description saved"), "2");
FakeEventSource.instances[0].onerror?.();
});
await waitFor(() => expect(log.scrollTop).toBe(log.scrollHeight));
logHeight = 300;
expect(await within(log).findByText("Polling recovered this event")).toBeVisible();
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2));
expect(FakeEventSource.instances[1].url).toBe(
@@ -135,6 +145,10 @@ test("replays ordered SSE events, reconnects from the latest sequence, and keeps
expect(FakeEventSource.instances).toHaveLength(2);
expect(FakeEventSource.instances[1].closed).toBe(false);
log.scrollTop = 0;
fireEvent.scroll(log);
const jumpToLatest = await screen.findByRole("button", { name: "Jump to latest" });
logHeight = 400;
act(() => {
FakeEventSource.instances[1].emitNamed("log", event(3, "Polling recovered this event"), "3");
FakeEventSource.instances[1].emitNamed("log", event(4, "Replay continued in order"), "4");
@@ -143,6 +157,10 @@ test("replays ordered SSE events, reconnects from the latest sequence, and keeps
await waitFor(() => expect(within(log).getAllByText("First description saved")).toHaveLength(1));
expect(within(log).getAllByText("Polling recovered this event")).toHaveLength(1);
expect(within(log).getByText("Replay continued in order")).toBeVisible();
expect(log.scrollTop).toBe(0);
await user.click(jumpToLatest);
expect(log.scrollTop).toBe(log.scrollHeight);
expect(screen.queryByRole("button", { name: "Jump to latest" })).not.toBeInTheDocument();
expect(log.textContent!.indexOf("Run queued")).toBeLessThan(
log.textContent!.indexOf("First description saved"),
);
@@ -14,6 +14,7 @@ import {
type DescriptionGenerationRun,
unlockDescriptionGenerationRun,
} from "../../api/catalog-databases";
import { useTailFollowing } from "../activityScroll";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
interface Props {
@@ -106,6 +107,17 @@ export function DescriptionGenerationDrawer({
() => (historyQuery.data ?? []).filter((item) => !databaseId || item.databaseId === databaseId),
[databaseId, historyQuery.data],
);
const latestEventSequence = events.at(-1)?.sequence ?? 0;
const {
followingTail: followingEventTail,
handleScroll: handleEventLogScroll,
jumpToTail: jumpToLatestEvent,
viewportRef: eventLogRef,
} = useTailFollowing<HTMLDivElement>({
active: open && Boolean(run),
resetKey: runId,
tail: latestEventSequence,
});
useEffect(() => {
if (!open || initialRun || !visibleHistory[0]) return;
@@ -427,11 +439,22 @@ export function DescriptionGenerationDrawer({
<section className="thot-catalog-drawer__events mt-3" aria-label="Description generation 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 className="flex items-center gap-2">
{!followingEventTail && events.length > 0 ? (
<Button type="button" variant="outline" size="xs" onClick={jumpToLatestEvent}>
Jump to latest
</Button>
) : null}
<span className="text-xs tabular-nums text-muted-foreground">{events.length}</span>
</div>
</div>
<div
ref={eventLogRef}
role="log"
aria-label="Description generation events"
aria-relevant="additions"
tabIndex={0}
onScroll={handleEventLogScroll}
className="thot-catalog-drawer__event-log 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) => (
@@ -924,6 +924,26 @@
max-height: none;
flex: 1;
}
.thot-fleet-ledger__drawer-body.thot-column-metadata-drawer-body {
display: flex;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.thot-column-metadata-matrix {
flex: 1;
grid-template-rows: repeat(2, minmax(0, 1fr));
}
.thot-column-metadata-field textarea {
min-height: 0;
}
}
.thot-column-metadata-matrix textarea {
overflow-y: auto;
}
@media (max-width: 47.999rem) {
@@ -91,9 +91,66 @@ test("preserves failed human choices when another flag in the same save succeeds
await user.click(screen.getByRole("checkbox", { name: "Show all 2 assessed columns" }));
const failedChoice = screen.getByRole("checkbox", { name: "Protect patients.notes" });
await user.click(failedChoice);
await user.click(screen.getByRole("button", { name: "Save 2" }));
await user.click(screen.getByRole("button", { name: "Save all 2 changes" }));
await waitFor(() => expect(updateCatalogColumnSensitive).toHaveBeenCalledTimes(2));
await waitFor(() => expect(screen.getByRole("button", { name: "Save 1" })).toBeEnabled());
await waitFor(() => expect(screen.getByRole("button", { name: "Save all 1 change" })).toBeEnabled());
expect(failedChoice).not.toBeChecked();
});
test("saves changed assessments serially so catalog operations do not overlap", async () => {
updateCatalogColumnSensitive.mockReset();
let resolveFirst!: (column: CatalogColumn) => void;
updateCatalogColumnSensitive
.mockImplementationOnce(() => new Promise<CatalogColumn>((resolve) => {
resolveFirst = resolve;
}))
.mockResolvedValueOnce({ ...savedColumn(suggestions[1]!), sensitive: false });
const onClose = vi.fn();
const onSaved = vi.fn();
const user = userEvent.setup();
render(
<SensitiveDataReviewDrawer
open
databaseId="bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
scopeLabel="All columns"
suggestions={suggestions}
canManage
onClose={onClose}
onSaved={onSaved}
/>,
);
await user.click(screen.getByRole("checkbox", { name: "Show all 2 assessed columns" }));
await user.click(screen.getByRole("checkbox", { name: "Protect patients.notes" }));
await user.click(screen.getByRole("button", { name: "Save all 2 changes" }));
expect(updateCatalogColumnSensitive).toHaveBeenCalledTimes(1);
expect(screen.getByRole("button", { name: "Saving…" })).toBeDisabled();
resolveFirst(savedColumn(suggestions[0]!));
await waitFor(() => expect(updateCatalogColumnSensitive).toHaveBeenCalledTimes(2));
expect(updateCatalogColumnSensitive).toHaveBeenNthCalledWith(
1,
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
suggestions[0]!.tableId,
suggestions[0]!.columnId,
suggestions[0]!.version,
true,
"Local assessment: sensitive. Evidence: pii.email. Coverage: sampled; observed values: 1.",
);
expect(updateCatalogColumnSensitive).toHaveBeenNthCalledWith(
2,
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
suggestions[1]!.tableId,
suggestions[1]!.columnId,
suggestions[1]!.version,
false,
null,
);
await waitFor(() => expect(onSaved).toHaveBeenCalledWith([
savedColumn(suggestions[0]!),
{ ...savedColumn(suggestions[1]!), sensitive: false },
]));
expect(onClose).toHaveBeenCalledOnce();
});
@@ -20,6 +20,26 @@ interface Props {
onSaved: (columns: CatalogColumn[]) => void;
}
function sensitivityReasonFor(
suggestion: SensitivityReviewItem,
sensitive: boolean,
): string | null {
if (!sensitive) return null;
if (suggestion.assessment !== "sensitive") {
return "Marked sensitive during human review despite a non-sensitive local assessment.";
}
const evidence = suggestion.evidence.map((item) => {
const confidence = item.confidence === undefined
? ""
: `, confidence ${Math.round(item.confidence * 100)}%`;
return item.label
? `${item.label} (${item.ruleId}${confidence})`
: `${item.ruleId}${confidence}`;
}).join(", ");
const coverage = suggestion.coverage.replaceAll("_", " ");
return `Local assessment: sensitive. Evidence: ${evidence || "policy match"}. Coverage: ${coverage}; observed values: ${suggestion.observedValues}.`;
}
export function SensitiveDataReviewDrawer({
open,
databaseId,
@@ -75,17 +95,22 @@ export function SensitiveDataReviewDrawer({
const save = async () => {
if (!databaseId || changed.length === 0) return;
setSaving(true);
const results = await Promise.allSettled(changed.map((suggestion) => (
updateCatalogColumnSensitive(
databaseId,
suggestion.tableId,
suggestion.columnId,
suggestion.version,
drafts[suggestion.columnId]!,
)
)));
const saved = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
const failed = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
const saved: CatalogColumn[] = [];
const failed: unknown[] = [];
for (const suggestion of changed) {
try {
saved.push(await updateCatalogColumnSensitive(
databaseId,
suggestion.tableId,
suggestion.columnId,
suggestion.version,
drafts[suggestion.columnId]!,
sensitivityReasonFor(suggestion, drafts[suggestion.columnId]!),
));
} catch (error) {
failed.push(error);
}
}
if (saved.length > 0) onSaved(saved);
if (failed.length > 0) {
toast.error(`${failed.length} sensitive flag${failed.length === 1 ? "" : "s"} could not be saved: ${apiErrorMessage(failed[0])}`);
@@ -115,7 +140,7 @@ export function SensitiveDataReviewDrawer({
<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}`}
<Save />{saving ? "Saving…" : `Save all ${changed.length} change${changed.length === 1 ? "" : "s"}`}
</Button>
</div>
</>
@@ -2,18 +2,21 @@ import { useEffect, useMemo } from "react";
import { LoaderCircle } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { apiErrorMessage } from "../../api/client";
import { Button } from "../../components/ui/button";
import {
getSensitivityAnalysisRun,
listSensitivityAnalysisEvents,
listSensitivityAnalysisRuns,
type SensitivityAnalysisRun,
} from "../../api/catalog-databases";
import { useTailFollowing } from "../activityScroll";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
interface Props {
open: boolean;
databaseId?: string | null;
run: SensitivityAnalysisRun | null;
awaitingRun?: boolean;
onClose: () => void;
onRunUpdate: (run: SensitivityAnalysisRun) => void;
}
@@ -55,6 +58,7 @@ export function SensitivityAnalysisHistoryDrawer({
open,
databaseId = null,
run: initialRun,
awaitingRun = false,
onClose,
onRunUpdate,
}: Props) {
@@ -66,7 +70,7 @@ export function SensitivityAnalysisHistoryDrawer({
enabled: open && Boolean(runId),
initialData: initialRun ?? undefined,
retry: false,
refetchInterval: (query) => isTerminal(query.state.data) ? false : 1_000,
refetchInterval: (query) => isTerminal(query.state.data) ? false : 750,
});
const run = runQuery.data;
const eventQuery = useQuery({
@@ -74,26 +78,42 @@ export function SensitivityAnalysisHistoryDrawer({
queryFn: () => listSensitivityAnalysisEvents(runId!),
enabled: open && Boolean(runId),
retry: false,
refetchInterval: run && isTerminal(run) ? false : 1_500,
refetchInterval: run && isTerminal(run) ? false : 750,
});
const historyQuery = useQuery({
queryKey: HISTORY_QUERY_KEY,
queryFn: () => listSensitivityAnalysisRuns(50),
enabled: open,
retry: false,
refetchInterval: (query) => query.state.data?.some((item) => !isTerminal(item))
? 3_000
: false,
refetchInterval: (query) => awaitingRun
? 500
: query.state.data?.some((item) => !isTerminal(item)) ? 3_000 : false,
});
const visibleHistory = useMemo(
() => (historyQuery.data ?? []).filter((item) => !databaseId || item.databaseId === databaseId),
[databaseId, historyQuery.data],
);
const events = eventQuery.data ?? [];
const latestEventSequence = events.at(-1)?.sequence ?? 0;
const {
followingTail: followingEventTail,
handleScroll: handleEventLogScroll,
jumpToTail: jumpToLatestEvent,
viewportRef: eventLogRef,
} = useTailFollowing<HTMLDivElement>({
active: open && Boolean(run),
resetKey: runId,
tail: latestEventSequence,
});
useEffect(() => {
if (!open || initialRun || !visibleHistory[0]) return;
onRunUpdate(visibleHistory[0]);
}, [initialRun, onRunUpdate, open, visibleHistory]);
if (!open) return;
const candidate = awaitingRun
? visibleHistory.find((item) => !isTerminal(item))
: visibleHistory[0];
if (!candidate || candidate.id === initialRun?.id) return;
onRunUpdate(candidate);
}, [awaitingRun, initialRun?.id, onRunUpdate, open, visibleHistory]);
useEffect(() => {
if (!run) return;
@@ -107,6 +127,52 @@ export function SensitivityAnalysisHistoryDrawer({
}, [onRunUpdate, queryClient, run]);
if (!open) return null;
if (!run && awaitingRun && !historyQuery.isError) {
return (
<FleetLedgerDrawer
open
ariaLabel="Sensitivity analysis history"
eyebrow="Sensitivity analysis"
title={(
<span className="inline-flex items-center gap-2">
<LoaderCircle className="size-5 animate-spin" aria-hidden="true" />
Starting sensitivity analysis
</span>
)}
description="Preparing the local policy and source scan."
onClose={onClose}
closeLabel="Close sensitivity analysis history"
bodyClassName="thot-catalog-drawer__history-layout thot-catalog-drawer__run-layout"
>
<div className="thot-catalog-drawer__run-main">
<section aria-label="Sensitivity analysis startup progress">
<h3 className="thot-label mb-2">Progress</h3>
<div
role="progressbar"
aria-label="Sensitivity analysis is starting"
aria-valuetext="Preparing analysis run"
className="h-2 overflow-hidden rounded-full bg-muted"
>
<div className="h-full w-1/3 animate-pulse rounded-full bg-primary" />
</div>
</section>
<section className="thot-catalog-drawer__events mt-4" aria-label="Sensitivity analysis 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">1</span>
</div>
<div
role="log"
aria-label="Sensitivity analysis events"
className="thot-catalog-drawer__event-log overflow-y-auto rounded-md bg-zinc-950 p-3 font-mono text-xs leading-5 text-zinc-200"
>
<p>Preparing sensitivity analysis…</p>
</div>
</section>
</div>
</FleetLedgerDrawer>
);
}
if (!run) {
return (
<FleetLedgerDrawer
@@ -122,8 +188,10 @@ export function SensitivityAnalysisHistoryDrawer({
<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 analysis history…</p>
) : historyQuery.isLoading || awaitingRun || (historyQuery.data?.length ?? 0) > 0 ? (
<p className="text-sm text-muted-foreground">
{awaitingRun ? "Starting sensitivity analysis…" : "Loading analysis 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 sensitivity analysis runs yet.</p>
@@ -136,7 +204,6 @@ export function SensitivityAnalysisHistoryDrawer({
);
}
const events = eventQuery.data ?? [];
const finalError = safeFinalError(run.errorSummary);
const engineDisplay = run.engine === "local"
? `Local rules (${run.policyVersion ?? "unknown policy"})`
@@ -229,11 +296,27 @@ export function SensitivityAnalysisHistoryDrawer({
<section className="thot-catalog-drawer__events mt-3" aria-label="Sensitivity analysis 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 className="flex items-center gap-2">
{!followingEventTail && events.length > 0 ? (
<Button
type="button"
variant="outline"
size="xs"
onClick={jumpToLatestEvent}
>
Jump to latest
</Button>
) : null}
<span className="text-xs tabular-nums text-muted-foreground">{events.length}</span>
</div>
</div>
<div
ref={eventLogRef}
role="log"
aria-label="Sensitivity analysis events"
aria-relevant="additions"
tabIndex={0}
onScroll={handleEventLogScroll}
className="thot-catalog-drawer__event-log 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) => (