feat: complete catalog sensitivity enhancements
This commit is contained in:
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user