feat: refine metadata catalog workflows

This commit is contained in:
Codex
2026-09-02 15:58:23 +02:00
parent 4531746038
commit ae053961a3
31 changed files with 848 additions and 219 deletions
@@ -278,16 +278,16 @@ export function FleetSelectionActions({ props, direction = "row" }: { props: Var
{ id: "databases-delete-relationships", group: "Cleanup", label: "Delete all relationships", icon: <Trash2 size={14} />, executeLabel: "Review deletion", destructive: true, disabled, onClick: run("Delete relationships confirmation opened", "changes") },
];
if (state.inventoryLevel === "tables") actions = [
{ id: "tables-generate", group: "Descriptions", label: "Generate descriptions", icon: <Sparkles size={14} />, disabled, onClick: run(`Description generation started for ${count} table${count === 1 ? "" : "s"}`) },
{ id: "tables-generate", group: "Descriptions", label: "Generate table descriptions", icon: <Sparkles size={14} />, disabled, onClick: run(`Description generation started for ${count} table${count === 1 ? "" : "s"}`) },
{ id: "tables-sync-columns", group: "Synchronization", label: "Synchronize columns", icon: <RefreshCw size={14} />, disabled, onClick: run("Selected table column synchronization started", "running") },
{ id: "tables-consolidate", group: "Descriptions", label: "Move generated to Description", icon: <Save size={14} />, disabled, onClick: run("Generated descriptions moved to curated Description") },
{ id: "tables-consolidate", group: "Descriptions", label: "Copy generated descriptions to Description", icon: <Save size={14} />, disabled, onClick: run("Generated descriptions copied to curated Description") },
{ id: "tables-suggest-sensitive", group: "Sensitive data", label: "Suggest sensitive fields", icon: <ShieldCheck size={14} />, executeLabel: "Open review", disabled, onClick: () => onOpenPanel("sensitive-review") },
{ id: "tables-delete-columns", group: "Cleanup", label: "Delete all columns", icon: <Trash2 size={14} />, executeLabel: "Review deletion", destructive: true, disabled, onClick: run("Delete table columns confirmation opened", "changes") },
{ id: "tables-delete-relationships", group: "Cleanup", label: "Delete all relationships", icon: <Trash2 size={14} />, executeLabel: "Review deletion", destructive: true, disabled, onClick: run("Delete table relationships confirmation opened", "changes") },
];
if (state.inventoryLevel === "columns") actions = [
{ id: "columns-generate", group: "Descriptions", label: "Generate descriptions", icon: <Sparkles size={14} />, disabled, onClick: run(`Description generation started for ${count} column${count === 1 ? "" : "s"}`) },
{ id: "columns-consolidate", group: "Descriptions", label: "Move generated to Description", icon: <Save size={14} />, disabled, onClick: run("Generated column descriptions consolidated") },
{ id: "columns-generate", group: "Descriptions", label: "Generate column descriptions", icon: <Sparkles size={14} />, disabled, onClick: run(`Description generation started for ${count} column${count === 1 ? "" : "s"}`) },
{ id: "columns-consolidate", group: "Descriptions", label: "Copy generated descriptions to Description", icon: <Save size={14} />, disabled, onClick: run("Generated column descriptions copied") },
{ id: "columns-suggest-sensitive", group: "Sensitive data", label: "Suggest sensitive fields", icon: <ShieldCheck size={14} />, executeLabel: "Open review", disabled, onClick: () => onOpenPanel("sensitive-review") },
{ id: "columns-save-sensitive", group: "Sensitive data", label: `Save sensitive fields (${Object.keys(state.sensitiveDrafts).length})`, icon: <LockKeyhole size={14} />, executeLabel: "Save changes", disabled: Object.keys(state.sensitiveDrafts).length === 0, onClick: onSaveSensitive },
];
+1 -1
View File
@@ -78,7 +78,7 @@ export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps)
<div className="mx-auto grid min-h-[calc(100vh-4rem)] max-w-5xl items-center gap-12 lg:grid-cols-[minmax(0,1fr)_26rem]">
<section className="hidden max-w-xl lg:block">
<h1 className="max-w-lg font-heading text-5xl font-semibold leading-[1.03] tracking-tight sm:text-6xl">
From intent to SQL, with Human In The Loop
From intent to SQL, with a human in the loop
</h1>
<p className="mt-6 max-w-md text-base leading-7 text-muted-foreground">AI generates, you guide and approve.</p>
</section>
+2 -2
View File
@@ -777,7 +777,7 @@ export function AppShell({ canLogout }: AppShellProps) {
<>
{activeSession?.question && (
<header className="sticky top-0 z-10 -mx-6 -mt-8 border-b border-border/60 bg-background/95 px-6 pb-3 pt-8 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<p className="thot-label mb-0.5 text-muted-foreground">Domanda</p>
<p className="thot-label mb-0.5 text-muted-foreground">Question</p>
<h2 className="font-heading text-[0.95rem] font-semibold leading-snug text-foreground break-words">
{activeSession.question}
</h2>
@@ -849,7 +849,7 @@ export function AppShell({ canLogout }: AppShellProps) {
<p className="thot-label mt-1.5">
Datamart Builder with
<br />
Human In The Loop
Human-in-the-loop review
</p>
{authenticatedUser && (
<div className="mt-4 flex items-center justify-between gap-2 border-t border-border/70 pt-3 text-left">
@@ -321,7 +321,7 @@ test("shows the configured access type and endpoint in catalog status", async ()
renderPage({ rows: [direct, rest, ssh], presentation: "fleet" });
const directRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
expect(within(directRow).getByText("Direct PostgreSQL")).toBeVisible();
expect(within(directRow).getByText("Direct PostgreSQL connection")).toBeVisible();
expect(within(directRow).getByText("db.internal:5432")).toBeVisible();
const restRow = screen.getByRole("row", { name: /REST laboratory/ });
@@ -502,10 +502,10 @@ test("keeps both run-history buttons visible beside the metadata-description sel
const metadataControls = screen.getByRole("group", { name: "Metadata description controls" });
const actions = screen.getByRole("group", { name: "Database management actions" });
const descriptionHistoryButton = within(metadataControls).getByRole("button", {
name: "Run descriptions generation history",
name: "View description generation history",
});
const suggestionHistoryButton = within(metadataControls).getByRole("button", {
name: "Run sensitive suggestions history",
name: "View sensitive suggestion history",
});
expect(toolbar).toHaveClass("sm:items-end", "sm:justify-between");
@@ -513,11 +513,11 @@ test("keeps both run-history buttons visible beside the metadata-description sel
expect(metadataControls).toContainElement(selector);
expect(descriptionHistoryButton).toBeVisible();
expect(descriptionHistoryButton).toBeEnabled();
expect(descriptionHistoryButton).toHaveTextContent("Run descriptions generation history");
expect(descriptionHistoryButton).toHaveTextContent("View description generation history");
expect(descriptionHistoryButton).toHaveClass("disabled:opacity-70");
expect(suggestionHistoryButton).toBeVisible();
expect(suggestionHistoryButton).toBeEnabled();
expect(suggestionHistoryButton).toHaveTextContent("Run sensitive suggestions history");
expect(suggestionHistoryButton).toHaveTextContent("View sensitive suggestion history");
expect(toolbar.lastElementChild).toBe(actions);
expect(actions).toHaveClass("sm:justify-end");
expect(actions).toContainElement(screen.getByRole("button", { name: "Refresh" }));
@@ -675,7 +675,7 @@ test.each(synchronizationScopes)(
},
);
test("discloses source sampling before starting Generate Missing", async () => {
test("discloses source sampling before starting Generate missing descriptions", async () => {
const user = userEvent.setup();
const queuedRun = makeDescriptionGenerationRun({ scope: "missing", total: 3 });
let startBody: unknown;
@@ -696,7 +696,7 @@ test("discloses source sampling before starting Generate Missing", async () => {
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate Missing" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate missing descriptions" }));
expect(screen.getByText("Generate missing descriptions for Policlinico San Donato?")).toBeVisible();
expect(screen.getByRole("note", {
@@ -706,7 +706,7 @@ test("discloses source sampling before starting Generate Missing", async () => {
);
expect(startBody).toBeUndefined();
await user.click(screen.getByRole("button", { name: "Generate Missing" }));
await user.click(screen.getByRole("button", { name: "Generate missing descriptions" }));
await waitFor(() => expect(startBody).toEqual({
modelId: "local-qwen",
scope: "missing",
@@ -715,7 +715,7 @@ test("discloses source sampling before starting Generate Missing", async () => {
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
});
test("keeps Fleet Generate Missing behind the source-data disclosure", async () => {
test("keeps Fleet Generate missing descriptions behind the source-data disclosure", async () => {
const user = userEvent.setup();
let generationStarts = 0;
server.use(
@@ -753,7 +753,7 @@ test("keeps Fleet Generate Missing behind the source-data disclosure", async ()
expect(generationStarts).toBe(0);
});
test("confirms Generate All replacement, supports cancel, and sends the database-wide scope", async () => {
test("confirms generating all descriptions, supports cancel, and sends the database-wide scope", async () => {
const user = userEvent.setup();
const queuedRun = makeDescriptionGenerationRun({ scope: "all", total: 6 });
let startBody: unknown;
@@ -774,7 +774,7 @@ test("confirms Generate All replacement, supports cancel, and sends the database
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate All" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate all descriptions" }));
expect(screen.getByText("Replace generated descriptions for Policlinico San Donato?")).toBeVisible();
expect(screen.getByText(/existing generated descriptions for eligible tables and columns will be replaced/i)).toBeVisible();
@@ -788,8 +788,8 @@ test("confirms Generate All replacement, supports cancel, and sends the database
expect(startBody).toBeUndefined();
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate All" }));
await user.click(screen.getByRole("button", { name: "Generate All" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate all descriptions" }));
await user.click(screen.getByRole("button", { name: "Generate all descriptions" }));
await waitFor(() => expect(startBody).toEqual({
modelId: "local-qwen",
@@ -815,8 +815,8 @@ test("keeps the database selected and safely explains when no descriptions are e
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate Missing" }));
await user.click(screen.getByRole("button", { name: "Generate Missing" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate missing descriptions" }));
await user.click(screen.getByRole("button", { name: "Generate missing descriptions" }));
expect(await screen.findByText(
"No eligible catalog tables or columns need description generation.",
@@ -876,10 +876,10 @@ test.each([
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", {
name: scope === "all" ? "Generate All" : "Generate Missing",
name: scope === "all" ? "Generate all descriptions" : "Generate missing descriptions",
}));
await user.click(screen.getByRole("button", {
name: scope === "all" ? "Generate All" : "Generate Missing",
name: scope === "all" ? "Generate all descriptions" : "Generate missing descriptions",
}));
expect(await screen.findByRole("heading", {
@@ -954,9 +954,9 @@ test.each([
}
await user.click(screen.getByRole("button", { name: "Actions" }));
expect(await screen.findByRole("menuitem", { name: "Generate All" }))
expect(await screen.findByRole("menuitem", { name: "Generate all descriptions" }))
.toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("menuitem", { name: "Generate Missing" }))
expect(screen.getByRole("menuitem", { name: "Generate missing descriptions" }))
.toHaveAttribute("aria-disabled", "true");
});
@@ -983,16 +983,16 @@ test("disables database-wide generation while a description generation is active
let databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate Missing" }));
await user.click(screen.getByRole("button", { name: "Generate Missing" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate missing descriptions" }));
await user.click(screen.getByRole("button", { name: "Generate missing descriptions" }));
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
expect(await screen.findByRole("menuitem", { name: "Generate All" }))
expect(await screen.findByRole("menuitem", { name: "Generate all descriptions" }))
.toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("menuitem", { name: "Generate Missing" }))
expect(screen.getByRole("menuitem", { name: "Generate missing descriptions" }))
.toHaveAttribute("aria-disabled", "true");
});
@@ -1472,7 +1472,7 @@ test("filters catalog tables as the operator types", async () => {
});
});
test("selected table Actions exposes cleanup commands and sends synchronization ids", async () => {
test("selected table Actions expose table commands and send synchronization ids", async () => {
const user = userEvent.setup();
let startBody: unknown;
const run = makeSyncRun("columns", [patientsTable.id]);
@@ -1494,10 +1494,11 @@ test("selected table Actions exposes cleanup commands and sends synchronization
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
const synchronizeColumns = await screen.findByRole("menuitem", { name: "Synchronize columns" });
const synchronizeColumns = await screen.findByRole("menuitem", { name: "Synchronize columns for selected tables" });
expect(synchronizeColumns).toBeEnabled();
expect(screen.getByRole("menuitem", { name: "Delete all columns" })).toBeEnabled();
expect(screen.getByRole("menuitem", { name: "Delete all relationships" })).toBeEnabled();
expect(screen.getByRole("menuitem", { name: "Synchronize database tables" })).toBeEnabled();
expect(screen.getByRole("menuitem", { name: "Remove synchronized columns" })).toBeEnabled();
expect(screen.queryByRole("menuitem", { name: "Delete all relationships" })).not.toBeInTheDocument();
await user.click(synchronizeColumns);
await waitFor(() => expect(startBody).toEqual({
@@ -1546,10 +1547,10 @@ test("starts description generation for multiple selected tables", async () => {
await user.click(within(await screen.findByRole("row", { name: /visits/ })).getByRole("checkbox"));
await user.click(screen.getByRole("button", { name: "Actions" }));
const generate = await screen.findByRole("menuitem", { name: "Generate descriptions" });
const generate = await screen.findByRole("menuitem", { name: "Generate table description" });
expect(generate).toBeEnabled();
expect(screen.getByRole("menuitem", {
name: "Move generated description to Description",
name: "Copy generated descriptions to Description",
})).toBeEnabled();
await user.click(generate);
@@ -1628,9 +1629,9 @@ test("observes an active run from another browser and reopens a terminal run fro
renderPage();
const observe = await screen.findByRole("button", {
name: "Run descriptions generation history",
name: "View description generation history",
});
expect(observe).toHaveTextContent("Run descriptions generation history");
expect(observe).toHaveTextContent("View description generation history");
expect(requestedLimit).toBe("50");
await user.click(observe);
@@ -1654,9 +1655,9 @@ test("keeps the sensitive suggestion history label stable while showing active s
renderPage();
const historyButton = await screen.findByRole("button", {
name: "Run sensitive suggestions history",
name: "View sensitive suggestion history",
});
expect(historyButton).toHaveTextContent("Run sensitive suggestions history");
expect(historyButton).toHaveTextContent("View sensitive suggestion history");
await waitFor(() => expect(historyButton).toHaveAttribute(
"title",
"Sensitive suggestion generation is active",
@@ -1692,7 +1693,7 @@ test.each([
client.setQueryData(unrelatedColumnKey, []);
await user.click(await screen.findByRole("button", {
name: "Run descriptions generation history",
name: "View description generation history",
}));
await waitFor(() => expect(client.getQueryState(tableKey)?.isInvalidated).toBe(true));
@@ -1731,7 +1732,7 @@ test("keeps selected tables when description generation cannot start", async ()
await user.click(within(await screen.findByRole("row", { name: /patients/ })).getByRole("checkbox"));
await user.click(within(await screen.findByRole("row", { name: /visits/ })).getByRole("checkbox"));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate descriptions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate table description" }));
expect(await screen.findByText("A description generation run is already active.")).toBeVisible();
expect(screen.getByText("2 selected")).toBeVisible();
@@ -1801,7 +1802,7 @@ test("refreshes Catalog Tables and Catalog Columns after table generation comple
await user.click(within(await screen.findByRole("row", { name: /patients/ })).getByRole("checkbox"));
await user.click(within(await screen.findByRole("row", { name: /visits/ })).getByRole("checkbox"));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate descriptions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate table description" }));
expect(await screen.findByRole("heading", { name: "Completed" })).toBeVisible();
await waitFor(() => expect(tableReads).toBe(2));
@@ -1857,7 +1858,7 @@ test("moves selected generated table descriptions and reports copied and skipped
await user.click(within(visitsRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", {
name: "Move generated description to Description",
name: "Copy generated descriptions to Description",
}));
await waitFor(() => expect(consolidationBody).toEqual({
@@ -1945,8 +1946,9 @@ test("selects columns, moves generated descriptions, and refreshes only the affe
await user.click(within(idRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(within(nameRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
expect(await screen.findByRole("menuitem", { name: "Synchronize columns for this table" })).toBeEnabled();
await user.click(await screen.findByRole("menuitem", {
name: "Move generated description to Description",
name: "Copy generated descriptions to Description",
}));
await waitFor(() => expect(consolidationBody).toEqual({
@@ -2000,7 +2002,7 @@ test("starts one selected column with the configured default model", async () =>
expect(columnRow).toBeDefined();
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
await waitFor(() => expect(startBody).toEqual({
modelId: "local-qwen",
@@ -2354,7 +2356,7 @@ test("polls a description run and renders its events in sequence order", async (
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
expect(within(drawer).getByText(/local-qwen/)).toBeVisible();
@@ -2436,7 +2438,7 @@ test("refreshes Catalog Tables and Catalog Columns after column generation compl
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
expect(await screen.findByRole("heading", { name: "Completed" })).toBeVisible();
await waitFor(() => expect(columnReads).toBe(2));
@@ -2469,7 +2471,7 @@ test("keeps the selected column and shows a safe message when generation cannot
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
expect(await screen.findByText("A description generation run is already active.")).toBeVisible();
expect(screen.getByText("1 selected")).toBeVisible();
@@ -2536,7 +2538,7 @@ test("shows a basic failed run without exposing private model or provider fields
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
expect(await within(drawer).findByRole("heading", { name: "Failed" })).toBeVisible();
@@ -2590,18 +2592,18 @@ test("offers generation and consolidation for multiple selected columns", async
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
await user.click(within(idRow!).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
expect(await screen.findByRole("menuitem", { name: "Generate description" })).toBeEnabled();
expect(await screen.findByRole("menuitem", { name: "Generate column description" })).toBeEnabled();
expect(screen.getByRole("menuitem", {
name: "Move generated description to Description",
name: "Copy generated descriptions to Description",
})).toBeEnabled();
await user.keyboard("{Escape}");
await user.click(within(nameRow!).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
const generate = await screen.findByRole("menuitem", { name: "Generate descriptions" });
const generate = await screen.findByRole("menuitem", { name: "Generate column descriptions" });
expect(generate).toBeEnabled();
expect(await screen.findByRole("menuitem", {
name: "Move generated description to Description",
name: "Copy generated descriptions to Description",
})).toBeEnabled();
await user.click(generate);
@@ -2637,7 +2639,7 @@ test.each([
const tableRow = await screen.findByRole("row", { name: /patients/ });
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
expect(await screen.findByRole("menuitem", { name: "Generate description" }))
expect(await screen.findByRole("menuitem", { name: "Generate descriptions for all columns" }))
.toHaveAttribute("aria-disabled", "true");
await user.keyboard("{Escape}");
await user.click(screen.getByRole("button", { name: "Clear" }));
@@ -2647,7 +2649,7 @@ test.each([
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
expect(await screen.findByRole("menuitem", { name: "Generate description" }))
expect(await screen.findByRole("menuitem", { name: "Generate column description" }))
.toHaveAttribute("aria-disabled", "true");
});
@@ -2680,11 +2682,11 @@ test("disables another generation start while the selected-column run is active"
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
await user.click(screen.getByRole("button", { name: "Actions" }));
expect(await screen.findByRole("menuitem", { name: "Generate description" }))
expect(await screen.findByRole("menuitem", { name: "Generate column description" }))
.toHaveAttribute("aria-disabled", "true");
expect(screen.getByText("1 selected")).toBeVisible();
@@ -2692,7 +2694,7 @@ test("disables another generation start while the selected-column run is active"
const tableRow = await screen.findByRole("row", { name: /patients/ });
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
expect(await screen.findByRole("menuitem", { name: "Generate description" }))
expect(await screen.findByRole("menuitem", { name: "Generate descriptions for all columns" }))
.toHaveAttribute("aria-disabled", "true");
});
@@ -2720,7 +2722,7 @@ test("disables selected description consolidation without database.manage", asyn
await user.click(screen.getByRole("button", { name: "Actions" }));
expect(await screen.findByRole("menuitem", {
name: "Move generated description to Description",
name: "Copy generated descriptions to Description",
})).toHaveAttribute("aria-disabled", "true");
expect(consolidationCalls).toBe(0);
});
@@ -2748,7 +2750,7 @@ test("shows an operation conflict without clearing selected descriptions or refr
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", {
name: "Move generated description to Description",
name: "Copy generated descriptions to Description",
}));
expect(await screen.findByText("A database operation is already in progress.")).toBeVisible();
@@ -2756,52 +2758,22 @@ test("shows an operation conflict without clearing selected descriptions or refr
expect(tableReads).toBe(1);
});
test("selected table Actions confirms and deletes incoming and outgoing relationships", async () => {
test("selected table Actions do not expose relationship cleanup", async () => {
const user = userEvent.setup();
let cleanupBody: unknown;
server.use(
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
http.post("/api/catalog/databases/:databaseId/tables/metadata-cleanup", async ({ request }) => {
cleanupBody = await request.json();
return HttpResponse.json({ tables: 0, columns: 0, relationships: 2 });
}),
);
const { client } = renderPage({
renderPage({
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
});
const unselectedColumnQuery = [
"catalog-columns",
patientsTable.databaseId,
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
] as const;
client.setQueryData(unselectedColumnQuery, []);
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
await user.click(screen.getByRole("tab", { name: "Tables" }));
const tableRow = await screen.findByRole("row", { name: /patients/ });
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Delete all relationships" }));
expect(screen.getByText("Delete all relationships for 1 table?")).toBeVisible();
expect(screen.getByText(/incoming and outgoing relationships/i)).toBeVisible();
expect(cleanupBody).toBeUndefined();
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
expect(screen.queryByText("Delete all relationships for 1 table?")).not.toBeInTheDocument();
expect(cleanupBody).toBeUndefined();
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Delete all relationships" }));
await user.click(screen.getByRole("button", { name: "Delete relationships" }));
await waitFor(() => expect(cleanupBody).toEqual({
tableIds: [patientsTable.id],
target: "relationships",
}));
await waitFor(() => expect(screen.queryByText("1 selected")).not.toBeInTheDocument());
expect(client.getQueryState(unselectedColumnQuery)?.isInvalidated).toBe(true);
await waitFor(() => expect(screen.getByRole("textbox", { name: "Search tables" })).toHaveFocus());
expect(screen.queryByRole("menuitem", { name: "Delete all relationships" })).not.toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: "Clear catalog relationships" })).not.toBeInTheDocument();
});
test("navigates purely from a database to its tables and edits review metadata", async () => {
@@ -2974,9 +2946,10 @@ test("opens the durable job drawer and confirms its exact destructive plan", asy
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
});
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
await user.click(screen.getByRole("tab", { name: "Tables" }));
await user.click(await screen.findByRole("button", { name: "Sync tables" }));
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Synchronize tables" }));
const synchronizationDrawer = await screen.findByRole("dialog", {
name: "Schema synchronization",
@@ -779,7 +779,7 @@ export function DatabaseManagementPage({
scope,
);
rememberDescriptionGenerationRun(run);
toast.success(`${scope === "all" ? "Generate All" : "Generate Missing"} started for ${database.workspaceName}`);
toast.success(`${scope === "all" ? "Generate all descriptions" : "Generate missing descriptions"} started for ${database.workspaceName}`);
} catch (error) {
toast.error(apiErrorMessage(error));
throw error;
@@ -1274,13 +1274,13 @@ export function DatabaseManagementPage({
onSelectedModelChange={setSelectedMetadataModel}
/>
{screen.kind === "list" ? <>
<Button type="button" variant="outline" className="whitespace-nowrap disabled:opacity-70" aria-label="Run descriptions generation history" disabled={!canManage} title="View description generation history" onClick={openDescriptionGenerationHistory}>
<History /> Run descriptions generation history
<Button type="button" variant="outline" className="whitespace-nowrap disabled:opacity-70" aria-label="View description generation history" disabled={!canManage} title="View description generation history" onClick={openDescriptionGenerationHistory}>
<History /> View description generation history
</Button>
<Button type="button" variant="outline" className="whitespace-nowrap disabled:opacity-70" aria-label="Run sensitive suggestions history" disabled={!canManage} title={observedActiveSensitiveDataSuggestionRun ? "Sensitive suggestion generation is active" : "View sensitive suggestion history"} onClick={openSensitiveDataSuggestionHistory}>
<Button type="button" variant="outline" className="whitespace-nowrap disabled:opacity-70" aria-label="View sensitive suggestion history" disabled={!canManage} title={observedActiveSensitiveDataSuggestionRun ? "Sensitive suggestion generation is active" : "View sensitive suggestion history"} onClick={openSensitiveDataSuggestionHistory}>
<History />
{observedActiveSensitiveDataSuggestionRun ? <span aria-hidden="true" className="size-2 rounded-full bg-primary" /> : null}
Run sensitive suggestions history
View sensitive suggestion history
</Button>
</> : null}
</div>
+1 -1
View File
@@ -356,7 +356,7 @@ test("shows an explicit recoverable incomplete state when no provider model is a
renderManagement();
const incomplete = await screen.findByRole("alert", { name: "Pi configuration incomplete" });
expect(incomplete).toHaveTextContent("No enabled provider and model choices are available");
expect(incomplete).toHaveTextContent("No provider or model options are available");
expect(incomplete).toHaveTextContent("Check the host-managed Pi model configuration");
expect(screen.queryByText("Loading Pi management…")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save defaults" })).toBeDisabled();
+2 -2
View File
@@ -78,7 +78,7 @@ function ReadinessRail({ ready, configured, credentials, smokeState }: {
state={credentials === "present" ? "ready" : "attention"}
/>
<RailItem
label="Saved-config test"
label="Configuration test"
value={smokeState === "passed" ? "Saved configuration test passed" : smokeState === "failed" ? "Saved configuration test failed" : "Saved configuration test not run"}
state={smokeState === "passed" ? "ready" : smokeState === "failed" ? "attention" : "idle"}
/>
@@ -505,7 +505,7 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
) : (
<div className="mt-4">
<div role="alert" aria-label="Pi configuration incomplete" className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-900 dark:text-amber-200">
<p className="font-semibold">No enabled provider and model choices are available.</p>
<p className="font-semibold">No provider or model options are available.</p>
<p className="mt-1">Check the host-managed Pi model configuration, then retry this panel.</p>
</div>
<div className="mt-4 flex flex-wrap gap-2">
+1 -1
View File
@@ -296,7 +296,7 @@ export function ComposerFooter() {
</FooterSelect>
<span
className="whitespace-nowrap font-mono text-[0.7rem] tabular-nums text-muted-foreground"
aria-label="Input non-cached, cached, and output tokens"
aria-label="Input, cached, and output tokens"
>
{formatTokenUsage(tokenUsage)}
</span>
@@ -10,9 +10,11 @@ import { ApiError, apiErrorMessage } from "../../api/client";
import {
consolidateCatalogDescriptions,
listCatalogColumns,
startCatalogSync,
startDescriptionGenerationRun,
updateCatalogColumnMetadata,
type CatalogColumn,
type CatalogSyncRun,
type CatalogTable,
type DescriptionGenerationRun,
type SensitiveDataSuggestionRequest,
@@ -25,10 +27,12 @@ import { NO_METADATA_GENERATION_LLM_MODEL_MESSAGE } from "./MetadataGenerationMo
interface Props {
databaseId: string;
table: CatalogTable;
databaseVersion: number;
canManage: boolean;
selectedMetadataModel: string | null;
descriptionGenerationActive: boolean;
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
onCatalogSyncRunStarted?: (run: CatalogSyncRun) => void;
onNavigationStateChange: (state: DatabaseNavigationState) => void;
onSuggestSensitive: (selection: SensitiveDataSuggestionRequest, scopeLabel: string) => Promise<void>;
catalogOperationActive?: boolean;
@@ -88,10 +92,12 @@ function SensitiveCell({ data, context }: ICellRendererParams<CatalogColumn, unk
export function DatabaseColumns({
databaseId,
table,
databaseVersion,
canManage,
selectedMetadataModel,
descriptionGenerationActive,
onDescriptionGenerationRunStarted,
onCatalogSyncRunStarted,
onNavigationStateChange,
onSuggestSensitive,
catalogOperationActive = false,
@@ -285,15 +291,33 @@ export function DatabaseColumns({
};
type FleetColumnAction =
| "sync-columns"
| "generate-descriptions"
| "consolidate-descriptions"
| "suggest-sensitive"
| "save-sensitive";
const fleetActions: readonly FleetActionOption<FleetColumnAction>[] = [
{
id: "sync-columns",
label: "Synchronize columns for this table",
group: "Synchronization",
scopeLabel: "Selected table",
runLabel: "Synchronize",
disabled: !canManage || selectedIds.length === 0 || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to synchronize the catalog."
: selectedIds.length === 0
? "Select at least one column."
: catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
? "Another action is running."
: undefined,
},
{
id: "generate-descriptions",
label: "Generate descriptions",
label: "Generate column descriptions",
group: "Descriptions",
runLabel: "Generate",
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || catalogOperationActive || busy,
@@ -311,7 +335,7 @@ export function DatabaseColumns({
},
{
id: "consolidate-descriptions",
label: "Move generated to Description",
label: "Copy generated descriptions to Description",
group: "Descriptions",
runLabel: "Move",
disabled: !canManage || selectedIds.length === 0 || catalogOperationActive || busy,
@@ -345,14 +369,14 @@ export function DatabaseColumns({
},
{
id: "save-sensitive",
label: "Save sensitive-field changes",
label: "Save sensitive field changes",
group: "Sensitive data",
runLabel: "Save",
disabled: !canManage || changedSensitiveColumns.length === 0 || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to update sensitive fields."
: changedSensitiveColumns.length === 0
? "No sensitive-field changes are waiting to be saved."
? "No sensitive field changes to save."
: catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
@@ -362,7 +386,8 @@ export function DatabaseColumns({
];
const runFleetAction = async (action: FleetColumnAction) => {
if (action === "generate-descriptions") await generateDescriptions();
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();
@@ -373,6 +398,22 @@ export function DatabaseColumns({
setSelectedIds([]);
};
const synchronizeColumns = async () => {
if (selectedIds.length === 0) return;
setBusy(true);
try {
const run = await startCatalogSync(databaseId, databaseVersion, "columns", [table.id]);
onCatalogSyncRunStarted?.(run);
gridRef.current?.api.deselectAll();
setSelectedIds([]);
toast.success("Column synchronization started");
} catch (error) {
toast.error(apiErrorMessage(error));
} finally {
setBusy(false);
}
};
const columns = useMemo<ColDef<CatalogColumn>[]>(() => [
{ field: "ordinalPosition", headerName: "#", width: 64, maxWidth: 64, filter: "agNumberColumnFilter" },
{ field: "sensitive", headerName: "Sensitive", headerTooltip: "Mark this column as sensitive; this does not select it for actions.", minWidth: 110, width: 110, sortable: false, filter: false, resizable: false, cellRenderer: SensitiveCell },
@@ -398,7 +439,7 @@ export function DatabaseColumns({
<p className="font-semibold text-destructive">Catalog columns could not be loaded.</p>
<p className="mt-1 leading-5 text-muted-foreground">{apiErrorMessage(error)}</p>
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refetch()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry columns
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry loading columns
</Button>
</div>
</div>
@@ -456,8 +497,9 @@ export function DatabaseColumns({
<Menu.Portal>
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy} onClick={() => void generateDescriptions()}>Generate {selectedIds.length === 1 ? "description" : "descriptions"}</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy} onClick={() => void consolidateDescriptions()}>Move generated description to Description</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedIds.length || catalogOperationActive || busy} onClick={() => void synchronizeColumns()}>Synchronize columns for this table</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} onClick={() => void generateDescriptions()}>Generate {selectedIds.length === 1 ? "column description" : "column descriptions"}</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy} onClick={() => void consolidateDescriptions()}>Copy generated descriptions to Description</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
@@ -110,6 +110,7 @@ test("Fleet columns distinguish a failed query from an empty catalog and retry i
presentation="fleet"
databaseId={database.id!}
table={table}
databaseVersion={database.version}
canManage
selectedMetadataModel={null}
descriptionGenerationActive={false}
@@ -124,7 +125,7 @@ test("Fleet columns distinguish a failed query from an empty catalog and retry i
expect(alert).toHaveTextContent("The database catalog is unavailable.");
expect(screen.queryByText(/No columns synchronized/i)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Retry columns" }));
await user.click(screen.getByRole("button", { name: "Retry loading columns" }));
await waitFor(() => expect(requests).toBe(2));
await waitFor(() => expect(screen.queryByText("Catalog columns could not be loaded.")).not.toBeInTheDocument());
});
@@ -281,7 +281,7 @@ export function DatabaseForm({
disabled={readOnly || busy}
onChange={(event) => onTransportChange(event.target.value as DatabaseTransport)}
>
<option value="postgres_direct">Direct PostgreSQL</option>
<option value="postgres_direct">Direct PostgreSQL connection</option>
<option value="rest_api">REST API</option>
<option value="ssh_tunnel">SSH tunnel</option>
</select>
@@ -149,13 +149,13 @@ function accessSummary(row: CatalogDatabase): { label: string; detail: string }
return { label: "SSH", detail: endpoint(row.binding.sshHost, row.binding.sshPort) };
}
return {
label: "Direct PostgreSQL",
label: "Direct PostgreSQL connection",
detail: endpoint(row.binding.host, row.binding.port),
};
}
function coverageStatus(total: number, complete: number): { label: string; detail: string; tone: StateTone } {
if (total === 0) return { label: "Not started", detail: "No catalog items", tone: "danger" };
if (total === 0) return { label: "Not started", detail: "No catalog objects", tone: "danger" };
if (complete >= total) return { label: "Complete", detail: `${complete}/${total}`, tone: "success" };
if (complete > 0) return { label: "Partial", detail: `${complete}/${total}`, tone: "warning" };
return { label: "Not started", detail: `0/${total}`, tone: "danger" };
@@ -172,7 +172,7 @@ function CatalogStatusCells({ row, metrics, sensitiveRun }: { row: CatalogDataba
const descriptions = coverageStatus(metrics?.descriptionTargets ?? 0, metrics?.describedTargets ?? 0);
const sensitiveProcessed = sensitiveRun ? sensitiveRun.suggestedSensitive + sensitiveRun.suggestedNonSensitive : 0;
const sensitive = !sensitiveRun
? { label: "Not started", detail: "No analysis run", tone: "danger" as const }
? { label: "Not started", detail: "No analysis has been run", tone: "danger" as const }
: sensitiveRun.status === "completed" && sensitiveProcessed >= sensitiveRun.total
? { label: "Complete", detail: `${sensitiveProcessed}/${sensitiveRun.total}`, tone: "success" as const }
: ["failed", "interrupted"].includes(sensitiveRun.status)
@@ -608,7 +608,7 @@ export function DatabaseGrid({
() => onGenerateDescriptions(selectedRows, pendingGenerationScope),
)}
>
{pendingGenerationScope === "all" ? "Generate All" : "Generate Missing"}
{pendingGenerationScope === "all" ? "Generate all descriptions" : "Generate missing descriptions"}
</Button>
</div>
) : pendingDelete ? (
@@ -688,14 +688,14 @@ export function DatabaseGrid({
disabled={!canGenerateDescriptions}
onClick={() => setPendingGenerationScope("all")}
>
Generate All
Generate all 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={!canGenerateDescriptions}
onClick={() => setPendingGenerationScope("missing")}
>
Generate Missing
Generate missing descriptions
</Menu.Item>
<Menu.Separator className="my-1 h-px bg-border" />
<Menu.Item
@@ -389,7 +389,7 @@ export function DatabaseRelationships({
</optgroup>
<optgroup label="Synchronization">
<option value="relationships">Synchronize physical relationships</option>
<option value="all">Synchronize the full schema for this database</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>
@@ -414,7 +414,7 @@ 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 />Sync relationships</Button>}
) : <Button type="button" disabled={!canManage || !bindingReady || busy || Boolean(database.activeSyncRun)} title={!bindingReady ? "Test the current database binding before synchronizing relationships" : undefined} onClick={() => void synchronize("relationships")}><RefreshCw />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">
@@ -430,7 +430,7 @@ export function DatabaseRelationships({
title="Add relationship"
description="Connect one source column to one primary-key target column."
onClose={closeAdd}
closeLabel="Close add relationship"
closeLabel="Close relationship form"
busy={busy}
footer={(
<>
@@ -330,13 +330,12 @@ export function DatabaseTables({
| "sync-columns"
| "consolidate-descriptions"
| "suggest-sensitive"
| "clear-columns"
| "clear-relationships";
| "clear-columns";
const fleetActions: readonly FleetActionOption<FleetTableAction>[] = [
{
id: "generate-descriptions",
label: "Generate descriptions",
label: "Generate table description",
group: "Descriptions",
runLabel: "Generate",
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun),
@@ -372,7 +371,7 @@ export function DatabaseTables({
},
{
id: "consolidate-descriptions",
label: "Move generated to Description",
label: "Copy generated descriptions to Description",
group: "Descriptions",
runLabel: "Move",
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
@@ -386,22 +385,24 @@ export function DatabaseTables({
},
{
id: "sync-tables",
label: "Synchronize tables",
label: "Synchronize database tables",
group: "Synchronization",
scopeLabel: "All tables in this database",
scopeLabel: "Selected database",
runLabel: "Synchronize",
disabled: !canManage || !bindingReady || busy !== null || Boolean(currentRun),
disabled: !canManage || selectedIds.length === 0 || !bindingReady || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to synchronize the catalog."
: !bindingReady
? "Test the current database binding first."
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: undefined,
: selectedIds.length === 0
? "Select at least one table."
: !bindingReady
? "Test the current database binding first."
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: undefined,
},
{
id: "sync-columns",
label: "Synchronize columns",
label: "Synchronize columns for selected tables",
group: "Synchronization",
runLabel: "Synchronize",
disabled: !canManage || selectedIds.length === 0 || !bindingReady || busy !== null || Boolean(currentRun),
@@ -435,22 +436,7 @@ export function DatabaseTables({
},
{
id: "clear-columns",
label: "Clear catalog columns",
group: "Catalog cleanup",
tone: "destructive",
runLabel: "Review cleanup",
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to clear catalog metadata."
: selectedIds.length === 0
? "Select at least one table."
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: undefined,
},
{
id: "clear-relationships",
label: "Clear catalog relationships",
label: "Remove synchronized columns",
group: "Catalog cleanup",
tone: "destructive",
runLabel: "Review cleanup",
@@ -473,7 +459,6 @@ export function DatabaseTables({
else if (action === "sync-columns") await synchronize("columns", selectedIds);
else if (action === "suggest-sensitive") await suggestSensitive();
else if (action === "clear-columns") setPendingDelete("columns");
else setPendingDelete("relationships");
};
const clearSelection = () => {
@@ -544,10 +529,12 @@ export function DatabaseTables({
<DatabaseColumns
databaseId={databaseId}
table={activeTable}
databaseVersion={database.version}
canManage={canManage}
selectedMetadataModel={selectedMetadataModel}
descriptionGenerationActive={descriptionGenerationActive}
onDescriptionGenerationRunStarted={onDescriptionGenerationRunStarted}
onCatalogSyncRunStarted={onRunStarted}
onNavigationStateChange={setColumnNavigation}
onSuggestSensitive={onSuggestSensitive}
catalogOperationActive={Boolean(currentRun)}
@@ -594,7 +581,7 @@ export function DatabaseTables({
rowHeight={44}
headerHeight={38}
animateRows={false}
overlayNoRowsTemplate="No catalog tables. Test the binding, then sync tables."
overlayNoRowsTemplate="No catalog tables. Synchronize the schema from the database view."
/>
</div>
</div>}
@@ -655,10 +642,12 @@ export function DatabaseTables({
<DatabaseColumns
databaseId={databaseId}
table={activeTable}
databaseVersion={database.version}
canManage={canManage}
selectedMetadataModel={selectedMetadataModel}
descriptionGenerationActive={descriptionGenerationActive}
onDescriptionGenerationRunStarted={onDescriptionGenerationRunStarted}
onCatalogSyncRunStarted={onRunStarted}
onNavigationStateChange={setColumnNavigation}
onSuggestSensitive={onSuggestSensitive}
onCatalogMetricsChanged={onCatalogMetricsChanged}
@@ -710,19 +699,11 @@ export function DatabaseTables({
<div className="mr-auto min-w-56">
<p id="table-cleanup-confirmation" className="text-sm font-semibold">
{presentation === "fleet"
? `Clear catalog ${pendingDelete} for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`
: pendingDelete === "columns"
? `Delete all catalog columns for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`
: `Delete all relationships for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`}
? `Remove synchronized columns for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`
: `Remove synchronized columns for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`}
</p>
<p className="text-xs text-muted-foreground">
{presentation === "fleet"
? pendingDelete === "columns"
? "Only synchronized catalog columns for these tables will be cleared. The source database is not modified."
: "Only synchronized catalog relationships for these tables will be cleared. The source database is not modified."
: pendingDelete === "columns"
? "Only columns belonging to the selected catalog tables will be removed."
: "All incoming and outgoing relationships for the selected catalog tables will be removed."}
Only synchronized catalog columns for these tables will be removed. The source database is not modified.
</p>
</div>
<Button type="button" variant="ghost" disabled={busy !== null} onClick={closeDeleteConfirmation}>Cancel</Button>
@@ -733,7 +714,7 @@ export function DatabaseTables({
disabled={busy !== null}
onClick={() => void deleteMetadata(pendingDelete)}
>
<Trash2 />{presentation === "fleet" ? "Clear catalog" : pendingDelete === "columns" ? "Delete columns" : "Delete relationships"}
<Trash2 />Remove synchronized columns
</Button>
</div>
) : (
@@ -760,13 +741,13 @@ export function DatabaseTables({
<Menu.Portal>
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateDescriptions()}>Generate {selectedIds.length === 1 ? "description" : "descriptions"}</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !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("columns", selectedIds)}>Synchronize columns</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void consolidateDescriptions()}>Move generated description to Description</Menu.Item>
<Menu.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 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")}>Delete all columns</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => setPendingDelete("relationships")}>Delete all relationships</Menu.Item>
<Menu.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>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
@@ -799,12 +780,11 @@ export function DatabaseTables({
<input ref={searchInputRef} className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search tables" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span aria-live="polite" className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">{search.trim() ? `${displayedCount} of ${data.length}` : displayedCount}</span>
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
<Button type="button" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} title={!bindingReady ? "Test the current database binding before synchronizing tables" : undefined} onClick={() => void synchronize("tables")}><RefreshCw className={busy === "sync" ? "animate-spin" : ""} />Sync tables</Button>
</>
)
)}
</div>
{!bindingReady ? <div className="border-b border-border bg-muted/35 px-4 py-3 text-sm text-muted-foreground">Test the current database binding from Overview before synchronizing tables.</div> : null}
{!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>
@@ -825,7 +805,7 @@ export function DatabaseTables({
rowHeight={44}
headerHeight={38}
animateRows={false}
overlayNoRowsTemplate="No catalog tables. Test the binding, then sync tables."
overlayNoRowsTemplate="No catalog tables. Synchronize the schema from the database view."
/>
</div>
</div>}
@@ -8,7 +8,7 @@ const actions = [
{ id: "sync", label: "Synchronize tables", group: "Synchronization", scopeLabel: "All tables in this database" },
{
id: "generate",
label: "Generate descriptions",
label: "Generate column descriptions",
group: "Descriptions",
disabled: true,
disabledReason: "Choose a metadata model first",
@@ -27,4 +27,10 @@ test("uses operational body text for every available-command list", () => {
expect(styles).toMatch(
/\.thot-fleet-action-selector__select\s*\{[^}]*font-size:\s*0\.9375rem;/s,
);
expect(styles).toMatch(
/\.thot-fleet-action-selector\s*\{[^}]*flex:\s*0 0 auto;/s,
);
expect(styles).toMatch(
/\.thot-fleet-action-selector__select\s*\{[^}]*width:\s*max-content;/s,
);
});
@@ -381,7 +381,8 @@
.thot-fleet-action-selector {
display: flex;
min-width: 0;
flex: 1;
max-width: 100%;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
@@ -402,9 +403,10 @@
}
.thot-fleet-action-selector__select {
flex: 1 1 13rem;
width: min(100%, 22rem);
min-width: 13rem;
flex: 0 0 auto;
width: max-content;
min-width: 0;
max-width: 100%;
height: 2rem;
padding: 0 2rem 0 0.625rem;
border: 1px solid oklch(var(--input));
@@ -613,8 +615,8 @@
@media (min-width: 48rem) {
.thot-fleet-grid-toolbar > .thot-fleet-action-selector {
flex: 1 1 33rem;
min-width: 33rem;
flex: 0 0 auto;
min-width: 0;
}
}
@@ -963,9 +965,6 @@
width: 100%;
}
.thot-fleet-action-selector__select {
width: 100%;
}
}
@media (max-height: 44rem) and (min-width: 48rem) {
@@ -12,8 +12,13 @@ describe("history action placement", () => {
expect(fleetHeader).not.toContain("Sensitive history");
});
test("database views expose metadata histories beside database commands", () => {
for (const file of ["DatabaseForm.tsx", "DatabaseTables.tsx", "DatabaseRelationships.tsx"]) {
test("database tables do not own metadata history actions", () => {
for (const file of ["DatabaseTables.tsx"]) {
const source = componentSource(file);
expect(source, file).not.toContain("Description history");
expect(source, file).not.toContain("Sensitive history");
}
for (const file of ["DatabaseForm.tsx", "DatabaseRelationships.tsx"]) {
const source = componentSource(file);
expect(source, file).toContain("Description history");
expect(source, file).toContain("Sensitive history");
+1 -1
View File
@@ -176,7 +176,7 @@ test("compacts table, filter, detail, and rationale rows", () => {
expect(within(rationale!).getByText("Rationale")).toHaveClass("mb-1");
});
test("shows 'no dependencies' for a CTE with an empty depends_on", () => {
test("shows 'No dependencies' for a CTE with an empty depends_on", () => {
render(<CtePlanViewer plan={plan} />);
expect(screen.getByText(/no dependencies/i)).toBeInTheDocument();
});
+1 -1
View File
@@ -93,7 +93,7 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
))}
</div>
) : (
<p className="text-xs text-muted-foreground">no dependencies</p>
<p className="text-xs text-muted-foreground">No dependencies</p>
)}
</section>
@@ -52,7 +52,7 @@ test("(b) clicking toggle switches to table listing a promoted table name", asyn
test("(c) oversized linking (>45) shows cap notice", () => {
render(<SchemaLinkingViewer linking={bigLinking} />);
expect(
screen.getByText(/too many elements for the graph/i)
screen.getByText(/too many nodes to display in the graph/i)
).toBeInTheDocument();
});
+1 -1
View File
@@ -147,7 +147,7 @@ export function SchemaLinkingViewer({ linking }: { linking: SchemaLinking }) {
{oversized && (
<p className="text-sm text-amber-600">
Too many elements for the graph, use the table
Too many nodes to display in the graph. Use the table instead.
</p>
)}
+1 -1
View File
@@ -40,7 +40,7 @@ export function ArtifactGateWidget({ descriptor, onRespond, sessionId }: WidgetP
{descriptor.artifact ? (
<GateArtifactBody artifact={descriptor.artifact} sessionId={sessionId} />
) : (
<p className="text-sm text-muted-foreground">No artifact.</p>
<p className="text-sm text-muted-foreground">No artifact available.</p>
)}
</div>
+2 -2
View File
@@ -68,7 +68,7 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
{o.rationale && (
<div className="mt-3 border-l-2 border-border/70 pl-3 text-xs text-foreground/80">
<div className="mb-1 font-semibold uppercase tracking-wide text-muted-foreground">
Motivo
Reason
</div>
<div className="thot-prose leading-relaxed [&>p:first-child]:mt-0 [&>p:last-child]:mb-0">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{o.rationale}</ReactMarkdown>
@@ -78,7 +78,7 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
{typeof o.meta?.question_context === "string" && o.meta.question_context && (
<div className="mt-3 border-l-2 border-border/50 pl-3 text-xs text-muted-foreground">
<div className="mb-1 font-semibold uppercase tracking-wide">
Domanda di contesto
Context question
</div>
<div className="thot-prose leading-relaxed [&>p:first-child]:mt-0 [&>p:last-child]:mb-0">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
+1 -1
View File
@@ -5,7 +5,7 @@ import { SelectWidget } from "./SelectWidget";
test("picking an option responds with its id", async () => {
const onRespond = vi.fn();
render(<SelectWidget descriptor={{ id: "u1", widget: "select", options: [{ id: "a", label: "A" }, { id: "b", label: "B", recommended: true }] }} onRespond={onRespond} />);
expect(screen.getByText(/consigliato/i)).toBeInTheDocument();
expect(screen.getByText(/recommended/i)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /A/ }));
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "select", choices: ["a"] });
});
+1 -1
View File
@@ -25,7 +25,7 @@ export function SelectWidget({ descriptor, onRespond }: WidgetProps) {
<span>{o.label}</span>
{o.recommended && (
<span className="shrink-0 rounded-md bg-[oklch(var(--success)/0.16)] px-2 py-0.5 text-[0.65rem] font-medium text-[oklch(0.5_0.13_165)]">
consigliato
Recommended
</span>
)}
</button>