feat: add AI catalog description generation

This commit is contained in:
Codex
2026-08-29 16:42:56 +02:00
parent b0afba81ca
commit 376dd5a09d
76 changed files with 14860 additions and 102 deletions
+121
View File
@@ -132,6 +132,13 @@ export interface CatalogMetadataDeleteCounts {
relationships: number;
}
export type CatalogDescriptionTarget = "tables" | "columns";
export interface CatalogDescriptionConsolidationCounts {
copied: number;
skipped: number;
}
export type CatalogSyncScope = "tables" | "columns" | "relationships" | "all";
export type CatalogSyncState = "queued" | "running" | "awaiting_confirmation" | "applying"
| "succeeded" | "failed" | "cancelled" | "interrupted";
@@ -176,8 +183,113 @@ export interface CatalogSyncEvent {
createdAt: string;
}
export interface MetadataGenerationModel {
id: string;
label: string;
}
export interface MetadataGenerationModels {
models: MetadataGenerationModel[];
default: string | null;
}
export type DescriptionGenerationScope = "selected_columns" | "selected_tables" | "all" | "missing";
export type DescriptionGenerationStatus = "queued" | "running" | "completed"
| "completed_with_errors" | "cancelled" | "failed" | "interrupted";
export interface DescriptionGenerationRun {
id: string;
databaseId: string;
scope: DescriptionGenerationScope;
modelId: string;
language: "en" | "it";
status: DescriptionGenerationStatus;
total: number;
processed: number;
generated: number;
nonGeneratable: number;
failed: number;
createdAt: string;
startedAt: string | null;
updatedAt: string;
finishedAt: string | null;
errorSummary: string | null;
}
export interface DescriptionGenerationEvent {
sequence: number;
level: "info" | "warning" | "error";
message: string;
createdAt: string;
}
export const listCatalogDatabases = () => apiFetch<CatalogDatabase[]>("/catalog/databases");
export const listMetadataGenerationModels = () =>
apiFetch<MetadataGenerationModels>("/catalog/metadata-generation/models");
export function startDescriptionGenerationRun(
databaseId: string,
modelId: string,
scope: Extract<DescriptionGenerationScope, "selected_columns" | "selected_tables">,
targetIds: string[],
): Promise<DescriptionGenerationRun>;
export function startDescriptionGenerationRun(
databaseId: string,
modelId: string,
scope: Extract<DescriptionGenerationScope, "all" | "missing">,
): Promise<DescriptionGenerationRun>;
export function startDescriptionGenerationRun(
databaseId: string,
modelId: string,
scope: DescriptionGenerationScope,
targetIds?: string[],
): Promise<DescriptionGenerationRun> {
const body = scope === "all" || scope === "missing"
? { modelId, scope }
: { modelId, scope, targetIds: targetIds ?? [] };
return apiFetch<DescriptionGenerationRun>(
`/catalog/databases/${encodeURIComponent(databaseId)}/description-generation-runs`,
{ method: "POST", body: JSON.stringify(body) },
);
}
export const getDescriptionGenerationRun = (runId: string) =>
apiFetch<DescriptionGenerationRun>(
`/catalog/description-generation-runs/${encodeURIComponent(runId)}`,
);
export const listDescriptionGenerationRuns = (limit = 50) =>
apiFetch<DescriptionGenerationRun[]>(
`/catalog/description-generation-runs?limit=${encodeURIComponent(String(limit))}`,
);
export const listDescriptionGenerationEvents = (runId: string, after = 0) =>
apiFetch<DescriptionGenerationEvent[]>(
`/catalog/description-generation-runs/${encodeURIComponent(runId)}/events-list?after=${after}`,
);
export const cancelDescriptionGenerationRun = (runId: string) =>
apiFetch<DescriptionGenerationRun>(
`/catalog/description-generation-runs/${encodeURIComponent(runId)}/cancel`,
{ method: "POST" },
);
export const unlockDescriptionGenerationRun = () =>
apiFetch<DescriptionGenerationRun>(
"/catalog/description-generation-runs/unlock",
{ method: "POST" },
);
export function descriptionGenerationEventsUrl(runId: string, after = 0): string {
const url = joinBackendPath(
BASE,
`/catalog/description-generation-runs/${encodeURIComponent(runId)}/events?after=${after}`,
);
assertSameOriginRequestUrl(url);
return url;
}
export const createCatalogDatabase = (input: DatabaseConfiguration) =>
apiFetch<CatalogDatabase>("/catalog/databases", { method: "POST", body: JSON.stringify(input) });
@@ -254,6 +366,15 @@ export const deleteCatalogTableMetadata = (
{ method: "POST", body: JSON.stringify({ tableIds, target }) },
);
export const consolidateCatalogDescriptions = (
databaseId: string,
target: CatalogDescriptionTarget,
targetIds: string[],
) => apiFetch<CatalogDescriptionConsolidationCounts>(
`/catalog/databases/${encodeURIComponent(databaseId)}/descriptions/consolidate`,
{ method: "POST", body: JSON.stringify({ target, targetIds }) },
);
export const startCatalogSync = (
databaseId: string,
version: number,
@@ -0,0 +1,76 @@
import { http, HttpResponse } from "msw";
import { server } from "../test/msw";
import {
cancelDescriptionGenerationRun,
descriptionGenerationEventsUrl,
listDescriptionGenerationRuns,
unlockDescriptionGenerationRun,
type DescriptionGenerationRun,
} from "./catalog-databases";
const historicalRun: DescriptionGenerationRun = {
id: "77777777-7777-4777-8777-777777777777",
databaseId: "11111111-1111-4111-8111-111111111111",
scope: "missing",
modelId: "local-qwen",
language: "it",
status: "interrupted",
total: 4,
processed: 2,
generated: 2,
nonGeneratable: 0,
failed: 0,
createdAt: "2026-08-28T08:00:00Z",
startedAt: "2026-08-28T08:00:01Z",
updatedAt: "2026-08-28T08:02:00Z",
finishedAt: "2026-08-28T08:02:00Z",
errorSummary: null,
};
test("lists newest-first persisted description-generation history with a bounded limit", async () => {
let requestedLimit: string | null = null;
server.use(http.get("/api/catalog/description-generation-runs", ({ request }) => {
requestedLimit = new URL(request.url).searchParams.get("limit");
return HttpResponse.json([historicalRun]);
}));
await expect(listDescriptionGenerationRuns(50)).resolves.toEqual([historicalRun]);
expect(requestedLimit).toBe("50");
});
test("stops a description-generation run with the run-scoped body-less endpoint", async () => {
let contentType: string | null = "unset";
server.use(http.post(
"/api/catalog/description-generation-runs/:runId/cancel",
({ request }) => {
contentType = request.headers.get("content-type");
return HttpResponse.json({ ...historicalRun, status: "cancelled" });
},
));
await expect(cancelDescriptionGenerationRun(historicalRun.id)).resolves.toMatchObject({
id: historicalRun.id,
status: "cancelled",
});
expect(contentType).toBeNull();
});
test("unlocks the installation-wide stale description-generation run without a request body", async () => {
let contentType: string | null = "unset";
server.use(http.post(
"/api/catalog/description-generation-runs/unlock",
({ request }) => {
contentType = request.headers.get("content-type");
return HttpResponse.json(historicalRun);
},
));
await expect(unlockDescriptionGenerationRun()).resolves.toEqual(historicalRun);
expect(contentType).toBeNull();
});
test("builds the same-origin description-generation SSE replay URL from an event sequence", () => {
expect(descriptionGenerationEventsUrl(historicalRun.id, 17)).toBe(
`/api/catalog/description-generation-runs/${historicalRun.id}/events?after=17`,
);
});
+21
View File
@@ -143,6 +143,27 @@ test("keeps only a known safe bounded JSON error payload", async () => {
}
});
test.each([
["description_generation_target_ids_duplicate", "Description generation target IDs must be unique."],
["description_generation_no_eligible_targets", "No eligible catalog tables or columns need description generation."],
["catalog_table_not_found", "One or more selected catalog tables were not found."],
])("maps the description-generation error code %s to safe local copy", async (code, message) => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ code, message: "provider detail must not be trusted" }), {
status: 400,
headers: { "content-type": "application/json" },
}),
);
try {
const failure = await apiFetch<unknown>("/description-generation-error")
.catch((error: unknown) => error) as ApiError;
expect(failure).toMatchObject({ code, message, payload: { code } });
} finally {
fetchSpy.mockRestore();
}
});
test("derives local messages without retaining a malicious known-code message", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({
+22 -1
View File
@@ -15,7 +15,14 @@ const safeErrorCodes = new Set([
"semantic_index_incompatible", "pi_management_forbidden", "pi_management_unavailable",
"pi_management_invalid_config", "pi_management_write_failed",
"catalog_unavailable", "database_conflict", "database_invalid", "database_not_found",
"database_stale", "database_operation_failed",
"database_stale", "database_operation_failed", "database_operation_in_progress",
"catalog_target_not_found", "description_consolidation_invalid", "description_consolidation_failed",
"description_generation_request_invalid", "description_generation_run_active",
"description_generation_failed", "description_generation_run_not_found",
"description_generation_no_eligible_targets",
"description_generation_target_ids_duplicate", "metadata_generation_model_unavailable",
"catalog_column_not_found", "catalog_table_not_found",
"workspace_configuration_unavailable",
"schema_sync_conflict", "schema_introspection_failed", "schema_request_invalid",
"schema_operation_failed", "sync_run_not_found", "table_stale", "column_stale",
]);
@@ -56,6 +63,20 @@ const localCodeMessages: Record<string, string> = {
database_not_found: "The database configuration was not found.",
database_stale: "The database configuration changed. Reload and try again.",
database_operation_failed: "The database operation failed.",
database_operation_in_progress: "A database operation is already in progress.",
catalog_target_not_found: "One or more selected catalog targets were not found.",
description_consolidation_invalid: "The description consolidation request is invalid.",
description_consolidation_failed: "Description consolidation failed.",
description_generation_request_invalid: "The description generation request is invalid.",
description_generation_run_active: "A description generation run is already active.",
description_generation_failed: "Description generation failed.",
description_generation_run_not_found: "The description generation run was not found.",
description_generation_no_eligible_targets: "No eligible catalog tables or columns need description generation.",
description_generation_target_ids_duplicate: "Description generation target IDs must be unique.",
metadata_generation_model_unavailable: "The selected description model is unavailable.",
catalog_column_not_found: "The selected catalog column was not found.",
catalog_table_not_found: "One or more selected catalog tables were not found.",
workspace_configuration_unavailable: "The database workspace configuration is unavailable.",
schema_sync_conflict: "A schema synchronization is already active or no longer current.",
schema_introspection_failed: "The database schema could not be read safely.",
schema_request_invalid: "The schema request is invalid.",
File diff suppressed because it is too large Load Diff
+160 -16
View File
@@ -6,7 +6,7 @@ import {
useState,
} from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, RefreshCw } from "lucide-react";
import { History, Plus, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { ApiError, apiErrorMessage } from "../api/client";
@@ -15,8 +15,11 @@ import {
deleteCatalogDatabase,
deleteCatalogDatabaseMetadata,
listCatalogDatabases,
listDescriptionGenerationRuns,
listMetadataGenerationModels,
replaceCatalogDatabaseSecrets,
startCatalogSync,
startDescriptionGenerationRun,
testCatalogDatabase,
updateCatalogDatabase,
type CatalogDatabase,
@@ -26,12 +29,15 @@ import {
type CatalogSyncRun,
type DatabaseBinding,
type DatabaseTransport,
type DescriptionGenerationRun,
} from "../api/catalog-databases";
import { DatabaseGrid } from "./database-management/DatabaseGrid";
import { DatabaseForm } from "./database-management/DatabaseForm";
import { DatabaseTables } from "./database-management/DatabaseTables";
import { DatabaseRelationships } from "./database-management/DatabaseRelationships";
import { CatalogSyncDrawer } from "./database-management/CatalogSyncDrawer";
import { MetadataGenerationModelSelector } from "./database-management/MetadataGenerationModelSelector";
import { DescriptionGenerationDrawer } from "./database-management/DescriptionGenerationDrawer";
import {
configurationFingerprint,
configurationFromDraft,
@@ -46,6 +52,8 @@ import {
} from "./database-management/model";
const DATABASE_QUERY_KEY = ["catalog-databases"] as const;
const DESCRIPTION_GENERATION_HISTORY_QUERY_KEY = ["description-generation-runs", 50] as const;
const DESCRIPTION_GENERATION_ACTIVE_STATUSES = ["queued", "running"] as const;
const SYNC_STARTED_MESSAGES: Record<CatalogSyncScope, string> = {
tables: "Table synchronization started",
columns: "Column synchronization started",
@@ -68,6 +76,12 @@ function isStaleError(error: unknown): boolean {
return error instanceof ApiError && error.code === "database_stale";
}
function isDescriptionGenerationActive(run?: DescriptionGenerationRun | null): boolean {
return Boolean(run && DESCRIPTION_GENERATION_ACTIVE_STATUSES.some(
(status) => status === run.status,
));
}
export function DatabaseManagementPage({
canManage,
canManageSecrets,
@@ -86,8 +100,31 @@ export function DatabaseManagementPage({
retry: false,
});
const rows = data ?? [];
const metadataModelsQuery = useQuery({
queryKey: ["metadata-generation-models"],
queryFn: listMetadataGenerationModels,
retry: false,
});
const metadataModels = metadataModelsQuery.data?.models ?? [];
const configuredMetadataDefault = metadataModelsQuery.data?.default ?? "";
const defaultMetadataModel = configuredMetadataDefault
&& metadataModels.some((model) => model.id === configuredMetadataDefault)
? configuredMetadataDefault
: "";
const descriptionGenerationRunsQuery = useQuery({
queryKey: DESCRIPTION_GENERATION_HISTORY_QUERY_KEY,
queryFn: () => listDescriptionGenerationRuns(50),
enabled: canManage,
retry: false,
refetchInterval: 5_000,
});
const descriptionGenerationRuns = descriptionGenerationRunsQuery.data ?? [];
const observedActiveDescriptionGenerationRun = descriptionGenerationRuns.find(
(run) => isDescriptionGenerationActive(run),
);
const [screen, setScreen] = useState<DatabaseScreen>({ kind: "list" });
const [selectedMetadataModel, setSelectedMetadataModel] = useState("");
const [draft, setDraft] = useState<DatabaseFormDraft | null>(null);
const [baseline, setBaseline] = useState("");
const [formSource, setFormSource] = useState<FormSource | null>(null);
@@ -102,11 +139,17 @@ export function DatabaseManagementPage({
});
const [activeSyncRun, setActiveSyncRun] = useState<CatalogSyncRun | null>(null);
const [syncDrawerOpen, setSyncDrawerOpen] = useState(false);
const [activeDescriptionGenerationRun, setActiveDescriptionGenerationRun] = useState<DescriptionGenerationRun | null>(null);
const [descriptionGenerationDrawerOpen, setDescriptionGenerationDrawerOpen] = useState(false);
const originRef = useRef<HTMLElement | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const formHeadingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
setSelectedMetadataModel(defaultMetadataModel);
}, [defaultMetadataModel]);
const activeRow = screen.kind === "list"
? undefined
: rows.find((row) => row.workspaceId === screen.workspaceId);
@@ -556,6 +599,36 @@ export function DatabaseManagementPage({
setSyncDrawerOpen(true);
}, [updateTrackedSyncRun]);
const rememberDescriptionGenerationRun = useCallback((run: DescriptionGenerationRun) => {
setActiveDescriptionGenerationRun(run);
queryClient.setQueryData<DescriptionGenerationRun[]>(
DESCRIPTION_GENERATION_HISTORY_QUERY_KEY,
(current = []) => [run, ...current.filter((item) => item.id !== run.id)],
);
setDescriptionGenerationDrawerOpen(true);
}, [queryClient]);
const openDescriptionGenerationHistory = useCallback(() => {
const run = observedActiveDescriptionGenerationRun
?? descriptionGenerationRuns[0]
?? activeDescriptionGenerationRun;
if (!run) return;
setActiveDescriptionGenerationRun(run);
setDescriptionGenerationDrawerOpen(true);
}, [activeDescriptionGenerationRun, descriptionGenerationRuns, observedActiveDescriptionGenerationRun]);
const descriptionGenerationTerminated = useCallback(async (run: DescriptionGenerationRun) => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: ["catalog-tables", run.databaseId],
exact: true,
}),
queryClient.invalidateQueries({
queryKey: ["catalog-columns", run.databaseId],
}),
]);
}, [queryClient]);
const openSync = useCallback((row?: CatalogDatabase) => {
const run = row?.activeSyncRun ?? activeSyncRun;
if (!run) return;
@@ -590,6 +663,26 @@ export function DatabaseManagementPage({
}
}, [queryClient, rememberSyncRun]);
const generateDatabaseDescriptions = useCallback(async (
selected: CatalogDatabase[],
scope: "all" | "missing",
) => {
const database = selected.length === 1 ? selected[0] : undefined;
if (!database?.id || !selectedMetadataModel) return;
try {
const run = await startDescriptionGenerationRun(
database.id,
selectedMetadataModel,
scope,
);
rememberDescriptionGenerationRun(run);
toast.success(`${scope === "all" ? "Generate All" : "Generate Missing"} started for ${database.workspaceName}`);
} catch (error) {
toast.error(apiErrorMessage(error));
throw error;
}
}, [rememberDescriptionGenerationRun, selectedMetadataModel]);
const deleteSelectedMetadata = useCallback(async (
selected: CatalogDatabase[],
target: CatalogDatabaseMetadataDeleteTarget,
@@ -640,6 +733,16 @@ export function DatabaseManagementPage({
: activeRow?.activeSyncRun && ["queued", "running", "awaiting_confirmation", "applying"].includes(activeRow.activeSyncRun.state)
? activeRow.activeSyncRun
: undefined;
const selectedMetadataModelAvailable = Boolean(
selectedMetadataModel
&& metadataModels.some((model) => model.id === selectedMetadataModel),
);
const descriptionGenerationActive = isDescriptionGenerationActive(
observedActiveDescriptionGenerationRun ?? activeDescriptionGenerationRun,
);
const descriptionGenerationHistoryAvailable = Boolean(
descriptionGenerationRuns.length > 0 || activeDescriptionGenerationRun,
);
return (
<main aria-label="Database management" className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
@@ -651,21 +754,46 @@ export function DatabaseManagementPage({
One database configuration for each repository workspace.
</p>
</div>
{screen.kind === "list" ? (
<div className="flex items-center gap-2">
<Button type="button" variant="outline" disabled={isFetching} onClick={() => void refreshList()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} /> Refresh
</Button>
<Button
type="button"
disabled={!canManage || availableWorkspaces.length === 0}
title={availableWorkspaces.length === 0 ? "Every available workspace is already configured" : undefined}
onClick={(event) => addDatabase(event.currentTarget)}
>
<Plus /> Add database
</Button>
</div>
) : null}
<div className="flex flex-wrap items-end justify-end gap-3">
<MetadataGenerationModelSelector
canManage={canManage}
data={metadataModelsQuery.data}
isError={metadataModelsQuery.isError}
isLoading={metadataModelsQuery.isLoading}
selectedModel={selectedMetadataModel}
onSelectedModelChange={setSelectedMetadataModel}
/>
<Button
type="button"
variant="outline"
aria-label={observedActiveDescriptionGenerationRun
? "Observe active description generation"
: "View description generation history"}
disabled={!canManage || !descriptionGenerationHistoryAvailable}
title={!descriptionGenerationHistoryAvailable && !descriptionGenerationRunsQuery.isLoading
? "No description generation runs yet"
: undefined}
onClick={openDescriptionGenerationHistory}
>
<History />
{observedActiveDescriptionGenerationRun ? "Active run" : "Run history"}
</Button>
{screen.kind === "list" ? (
<div className="flex items-center gap-2">
<Button type="button" variant="outline" disabled={isFetching} onClick={() => void refreshList()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} /> Refresh
</Button>
<Button
type="button"
disabled={!canManage || availableWorkspaces.length === 0}
title={availableWorkspaces.length === 0 ? "Every available workspace is already configured" : undefined}
onClick={(event) => addDatabase(event.currentTarget)}
>
<Plus /> Add database
</Button>
</div>
) : null}
</div>
</header>
<div className="relative flex min-h-0 flex-1">
@@ -701,6 +829,9 @@ export function DatabaseManagementPage({
onOpenSync={openSync}
onTestSelected={testSelected}
onSyncSelected={syncSelected}
selectedMetadataModel={selectedMetadataModelAvailable ? selectedMetadataModel : null}
descriptionGenerationActive={descriptionGenerationActive}
onGenerateDescriptions={generateDatabaseDescriptions}
onDeleteMetadataSelected={deleteSelectedMetadata}
/>
)}
@@ -746,12 +877,15 @@ export function DatabaseManagementPage({
database={activeRow}
canManage={canManage}
activeRun={currentActiveRun}
selectedMetadataModel={selectedMetadataModelAvailable ? selectedMetadataModel : null}
descriptionGenerationActive={descriptionGenerationActive}
onBackToDatabases={showList}
onOpenOverview={() => openOverview(activeRow)}
onOpenRelationships={() => openRelationships(activeRow)}
onNavigationStateChange={setTablesNavigationState}
onRunStarted={rememberSyncRun}
onOpenSync={() => openSync(activeRow)}
onDescriptionGenerationRunStarted={rememberDescriptionGenerationRun}
/>
) : null}
@@ -776,6 +910,16 @@ export function DatabaseManagementPage({
onRunUpdate={updateTrackedSyncRun}
onCatalogChanged={() => void catalogChanged()}
/>
<DescriptionGenerationDrawer
open={descriptionGenerationDrawerOpen}
run={activeDescriptionGenerationRun}
modelLabel={metadataModels.find(
(model) => model.id === activeDescriptionGenerationRun?.modelId,
)?.label ?? activeDescriptionGenerationRun?.modelId ?? ""}
onClose={() => setDescriptionGenerationDrawerOpen(false)}
onRunUpdate={setActiveDescriptionGenerationRun}
onTerminal={(run) => void descriptionGenerationTerminated(run)}
/>
</main>
);
}
@@ -1,16 +1,20 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Menu } from "@base-ui/react/menu";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AgGridReact } from "ag-grid-react";
import type { ColDef, ICellRendererParams } from "ag-grid-community";
import { KeyRound, Link2, Pencil, RefreshCw, Save } from "lucide-react";
import { ChevronDown, KeyRound, Link2, Pencil, RefreshCw, Save, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { ApiError, apiErrorMessage } from "../../api/client";
import {
consolidateCatalogDescriptions,
listCatalogColumns,
startDescriptionGenerationRun,
updateCatalogColumnMetadata,
type CatalogColumn,
type CatalogTable,
type DescriptionGenerationRun,
} from "../../api/catalog-databases";
import type { DatabaseNavigationState } from "./model";
@@ -18,6 +22,9 @@ interface Props {
databaseId: string;
table: CatalogTable;
canManage: boolean;
selectedMetadataModel: string | null;
descriptionGenerationActive: boolean;
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
onNavigationStateChange: (state: DatabaseNavigationState) => void;
onSync: () => void;
}
@@ -48,7 +55,16 @@ function ActionCell({ data, context }: ICellRendererParams<CatalogColumn, unknow
);
}
export function DatabaseColumns({ databaseId, table, canManage, onNavigationStateChange, onSync }: Props) {
export function DatabaseColumns({
databaseId,
table,
canManage,
selectedMetadataModel,
descriptionGenerationActive,
onDescriptionGenerationRunStarted,
onNavigationStateChange,
onSync,
}: Props) {
const queryClient = useQueryClient();
const queryKey = ["catalog-columns", databaseId, table.id] as const;
const { data = [], isLoading, isFetching, refetch } = useQuery({
@@ -57,6 +73,7 @@ export function DatabaseColumns({ databaseId, table, canManage, onNavigationStat
retry: false,
});
const [search, setSearch] = useState("");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [description, setDescription] = useState("");
const [generatedDescription, setGeneratedDescription] = useState("");
@@ -65,6 +82,7 @@ export function DatabaseColumns({ databaseId, table, canManage, onNavigationStat
const [stale, setStale] = useState(false);
const [staleBannerOpen, setStaleBannerOpen] = useState(true);
const [busy, setBusy] = useState(false);
const gridRef = useRef<AgGridReact<CatalogColumn>>(null);
const originRef = useRef<HTMLButtonElement | null>(null);
const active = editingId ? data.find((column) => column.id === editingId) : undefined;
const fingerprint = JSON.stringify([description, generatedDescription]);
@@ -131,6 +149,34 @@ export function DatabaseColumns({ databaseId, table, canManage, onNavigationStat
setStaleBannerOpen(true);
} catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(false); }
};
const consolidateDescriptions = async () => {
setBusy(true);
try {
const counts = await consolidateCatalogDescriptions(databaseId, "columns", selectedIds);
await queryClient.invalidateQueries({ queryKey, exact: true });
gridRef.current?.api.deselectAll();
setSelectedIds([]);
toast.success(`Copied ${counts.copied} description${counts.copied === 1 ? "" : "s"}; skipped ${counts.skipped}`);
} catch (error) {
toast.error(apiErrorMessage(error));
} finally { setBusy(false); }
};
const generateDescriptions = async () => {
if (selectedIds.length === 0 || !selectedMetadataModel) return;
setBusy(true);
try {
const run = await startDescriptionGenerationRun(
databaseId,
selectedMetadataModel,
"selected_columns",
selectedIds,
);
onDescriptionGenerationRunStarted(run);
toast.success(`Description generation started for ${selectedIds.length} column${selectedIds.length === 1 ? "" : "s"}`);
} catch (error) {
toast.error(apiErrorMessage(error));
} finally { setBusy(false); }
};
const columns = useMemo<ColDef<CatalogColumn>[]>(() => [
{ field: "ordinalPosition", headerName: "#", width: 64, maxWidth: 64, filter: "agNumberColumnFilter" },
@@ -176,15 +222,62 @@ export function DatabaseColumns({ databaseId, table, canManage, onNavigationStat
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2">
<span className="thot-label whitespace-nowrap">Catalog columns</span>
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search columns" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span className="text-xs tabular-nums text-muted-foreground">{data.length}</span>
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
<Button type="button" disabled={!canManage || busy} onClick={onSync}><RefreshCw />Sync columns</Button>
{selectedIds.length > 0 ? (
<>
<span className="text-sm font-semibold">{selectedIds.length} selected</span>
<Menu.Root>
<Menu.Trigger className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={busy}>Actions <ChevronDown className="size-4" /></Menu.Trigger>
<Menu.Portal>
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
<Menu.Item
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy}
onClick={() => void generateDescriptions()}
>
Generate {selectedIds.length === 1 ? "description" : "descriptions"}
</Menu.Item>
<Menu.Item
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
disabled={!canManage || busy}
onClick={() => void consolidateDescriptions()}
>
Move generated description to Description
</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<Button type="button" variant="ghost" onClick={() => { gridRef.current?.api.deselectAll(); setSelectedIds([]); }}><X />Clear</Button>
</>
) : (
<>
<span className="thot-label whitespace-nowrap">Catalog columns</span>
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search columns" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span className="text-xs tabular-nums text-muted-foreground">{data.length}</span>
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
<Button type="button" disabled={!canManage || busy} onClick={onSync}><RefreshCw />Sync columns</Button>
</>
)}
</div>
<div className="relative min-h-[280px] flex-1">
<div className="thot-database-grid ag-theme-alpine absolute inset-0 h-full w-full">
<AgGridReact<CatalogColumn> rowData={data} columnDefs={columns} context={context} loading={isLoading} quickFilterText={search} defaultColDef={{ sortable: true, filter: true, resizable: true }} getRowId={({ data: row }) => row.id} rowHeight={44} headerHeight={38} animateRows={false} overlayNoRowsTemplate="No columns synchronized for this table." />
<AgGridReact<CatalogColumn>
ref={gridRef}
rowData={data}
columnDefs={columns}
context={context}
loading={isLoading}
quickFilterText={search}
defaultColDef={{ sortable: true, filter: true, resizable: true }}
getRowId={({ data: row }) => row.id}
rowSelection={{ mode: "multiRow", selectAll: "filtered", enableClickSelection: false }}
onSelectionChanged={({ api }) => setSelectedIds(api.getSelectedRows().map((column) => column.id))}
rowHeight={44}
headerHeight={38}
animateRows={false}
overlayNoRowsTemplate="No columns synchronized for this table."
/>
</div>
</div>
</div>
@@ -15,6 +15,7 @@ import type {
CatalogDatabase,
CatalogDatabaseMetadataDeleteTarget,
CatalogSyncScope,
DescriptionGenerationScope,
} from "../../api/catalog-databases";
import { statusLabel } from "./model";
import { databaseSyncItemClass, databaseSyncScopes } from "./DatabaseSyncMenu";
@@ -35,6 +36,12 @@ interface DatabaseGridProps {
onOpenSync: (row: CatalogDatabase) => void;
onTestSelected: (rows: CatalogDatabase[]) => Promise<void>;
onSyncSelected: (rows: CatalogDatabase[], scope: CatalogSyncScope) => Promise<void>;
selectedMetadataModel: string | null;
descriptionGenerationActive: boolean;
onGenerateDescriptions: (
rows: CatalogDatabase[],
scope: Extract<DescriptionGenerationScope, "all" | "missing">,
) => Promise<void>;
onDeleteMetadataSelected: (
rows: CatalogDatabase[],
target: CatalogDatabaseMetadataDeleteTarget,
@@ -154,14 +161,18 @@ export function DatabaseGrid({
onOpenSync,
onTestSelected,
onSyncSelected,
selectedMetadataModel,
descriptionGenerationActive,
onGenerateDescriptions,
onDeleteMetadataSelected,
}: DatabaseGridProps) {
const compact = useCompactViewport();
const gridRef = useRef<AgGridReact<CatalogDatabase>>(null);
const actionsTriggerRef = useRef<HTMLButtonElement>(null);
const [selectedRows, setSelectedRows] = useState<CatalogDatabase[]>([]);
const [action, setAction] = useState<"test" | "sync" | "delete" | null>(null);
const [action, setAction] = useState<"test" | "sync" | "generate" | "delete" | null>(null);
const [pendingDelete, setPendingDelete] = useState<CatalogDatabaseMetadataDeleteTarget | null>(null);
const [pendingGenerateAll, setPendingGenerateAll] = useState(false);
const context = useMemo<DatabaseGridContext>(
() => ({ canManage, onView, onEdit, onDelete, onOpenSync }),
[canManage, onView, onEdit, onDelete, onOpenSync],
@@ -247,6 +258,11 @@ export function DatabaseGrid({
}).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 canGenerateDescriptions = canManage
&& selectedRows.length === 1
&& Boolean(selectedMetadataModel)
&& !descriptionGenerationActive
&& selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun);
const canDeleteMetadataSelection = canManage && selectedRows.length > 0
&& selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun);
useEffect(() => {
@@ -254,18 +270,30 @@ export function DatabaseGrid({
const timer = window.setTimeout(() => document.getElementById("database-cleanup-confirm-button")?.focus(), 0);
return () => window.clearTimeout(timer);
}, [pendingDelete]);
useEffect(() => {
if (!pendingGenerateAll) return;
const timer = window.setTimeout(() => document.getElementById("database-generate-all-confirm-button")?.focus(), 0);
return () => window.clearTimeout(timer);
}, [pendingGenerateAll]);
const closeDeleteConfirmation = () => {
setPendingDelete(null);
window.setTimeout(() => actionsTriggerRef.current?.focus(), 0);
};
const perform = async (kind: "test" | "sync" | "delete", operation: () => Promise<void>) => {
const closeGenerateAllConfirmation = () => {
setPendingGenerateAll(false);
window.setTimeout(() => actionsTriggerRef.current?.focus(), 0);
};
const perform = async (kind: "test" | "sync" | "generate" | "delete", operation: () => Promise<void>) => {
setAction(kind);
try {
await operation();
gridRef.current?.api.deselectAll();
setSelectedRows([]);
setPendingDelete(null);
setPendingGenerateAll(false);
if (kind === "delete") window.setTimeout(() => searchInputRef.current?.focus(), 0);
} catch {
// The page-level operation owns safe error feedback. Preserve the selection for retry.
} finally { setAction(null); }
};
@@ -273,7 +301,38 @@ export function DatabaseGrid({
<section aria-label="Workspace databases" className="mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5">
<div className="flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2">
{selectedRows.length > 0 ? (
pendingDelete ? (
pendingGenerateAll ? (
<div
role="group"
aria-labelledby="database-generate-all-confirmation"
className="flex min-w-0 flex-1 flex-wrap items-center gap-2"
onKeyDown={(event) => {
if (event.key === "Escape" && action === null) closeGenerateAllConfirmation();
}}
>
<div className="mr-auto min-w-56">
<p id="database-generate-all-confirmation" className="text-sm font-semibold">
Replace generated descriptions for {selectedRows[0]?.workspaceName}?
</p>
<p className="text-xs text-muted-foreground">
Existing generated descriptions for eligible tables and columns will be replaced.
</p>
</div>
<Button type="button" variant="ghost" disabled={action !== null} onClick={closeGenerateAllConfirmation}>Cancel</Button>
<Button
id="database-generate-all-confirm-button"
type="button"
variant="destructive"
disabled={action !== null}
onClick={() => void perform(
"generate",
() => onGenerateDescriptions(selectedRows, "all"),
)}
>
Generate All
</Button>
</div>
) : pendingDelete ? (
<div
role="group"
aria-labelledby="database-cleanup-confirmation"
@@ -327,6 +386,24 @@ export function DatabaseGrid({
</Menu.Item>
))}
<Menu.Separator className="my-1 h-px bg-border" />
<Menu.Item
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
disabled={!canGenerateDescriptions}
onClick={() => setPendingGenerateAll(true)}
>
Generate All
</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={() => void perform(
"generate",
() => onGenerateDescriptions(selectedRows, "missing"),
)}
>
Generate Missing
</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={!canDeleteMetadataSelection}
@@ -383,6 +460,7 @@ export function DatabaseGrid({
selectionColumnDef={{ width: 44, maxWidth: 44, pinned: "left" }}
onSelectionChanged={({ api }) => {
if (pendingDelete && action === null) setPendingDelete(null);
if (pendingGenerateAll && action === null) setPendingGenerateAll(false);
setSelectedRows(api.getSelectedRows());
}}
rowHeight={44}
@@ -8,14 +8,17 @@ import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { ApiError, apiErrorMessage } from "../../api/client";
import {
consolidateCatalogDescriptions,
deleteCatalogTableMetadata,
listCatalogTables,
startCatalogSync,
startDescriptionGenerationRun,
updateCatalogTableMetadata,
type CatalogDatabase,
type CatalogSyncRun,
type CatalogTable,
type CatalogTableMetadataDeleteTarget,
type DescriptionGenerationRun,
} from "../../api/catalog-databases";
import type { DatabaseNavigationState } from "./model";
import { DatabaseColumns } from "./DatabaseColumns";
@@ -24,12 +27,15 @@ interface Props {
database: CatalogDatabase;
canManage: boolean;
activeRun?: CatalogSyncRun;
selectedMetadataModel: string | null;
descriptionGenerationActive: boolean;
onBackToDatabases: () => void;
onOpenOverview: () => void;
onOpenRelationships: () => void;
onNavigationStateChange: (state: DatabaseNavigationState) => void;
onRunStarted: (run: CatalogSyncRun) => void;
onOpenSync: () => void;
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
}
interface TableGridContext {
@@ -55,12 +61,15 @@ export function DatabaseTables({
database,
canManage,
activeRun,
selectedMetadataModel,
descriptionGenerationActive,
onBackToDatabases,
onOpenOverview,
onOpenRelationships,
onNavigationStateChange,
onRunStarted,
onOpenSync,
onDescriptionGenerationRunStarted,
}: Props) {
const databaseId = database.id!;
const queryClient = useQueryClient();
@@ -77,7 +86,7 @@ export function DatabaseTables({
const [editorVersion, setEditorVersion] = useState<number | null>(null);
const [stale, setStale] = useState(false);
const [staleBannerOpen, setStaleBannerOpen] = useState(true);
const [busy, setBusy] = useState<"sync" | "save" | "delete" | null>(null);
const [busy, setBusy] = useState<"sync" | "save" | "delete" | "consolidate" | "generate" | null>(null);
const [pendingDelete, setPendingDelete] = useState<CatalogTableMetadataDeleteTarget | null>(null);
const [columnNavigation, setColumnNavigation] = useState<DatabaseNavigationState>({ dirty: false, busy: false });
const gridRef = useRef<AgGridReact<CatalogTable>>(null);
@@ -203,6 +212,34 @@ export function DatabaseTables({
toast.error(apiErrorMessage(error));
} finally { setBusy(null); }
};
const consolidateDescriptions = async () => {
setBusy("consolidate");
try {
const counts = await consolidateCatalogDescriptions(databaseId, "tables", selectedIds);
await queryClient.invalidateQueries({ queryKey, exact: true });
gridRef.current?.api.deselectAll();
setSelectedIds([]);
toast.success(`Copied ${counts.copied} description${counts.copied === 1 ? "" : "s"}; skipped ${counts.skipped}`);
} catch (error) {
toast.error(apiErrorMessage(error));
} finally { setBusy(null); }
};
const generateDescriptions = async () => {
if (selectedIds.length === 0 || !selectedMetadataModel) return;
setBusy("generate");
try {
const run = await startDescriptionGenerationRun(
databaseId,
selectedMetadataModel,
"selected_tables",
selectedIds,
);
onDescriptionGenerationRunStarted(run);
toast.success(`Description generation started for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}`);
} catch (error) {
toast.error(apiErrorMessage(error));
} finally { setBusy(null); }
};
const columns = useMemo<ColDef<CatalogTable>[]>(() => [
{ field: "name", headerName: "Name", minWidth: 250, flex: 1, cellClass: "font-mono text-xs" },
@@ -257,7 +294,16 @@ export function DatabaseTables({
</nav>
</div>
{tableSection === "columns" ? (
<DatabaseColumns databaseId={databaseId} table={activeTable} canManage={canManage} onNavigationStateChange={setColumnNavigation} onSync={() => void synchronize("columns", [activeTable.id])} />
<DatabaseColumns
databaseId={databaseId}
table={activeTable}
canManage={canManage}
selectedMetadataModel={selectedMetadataModel}
descriptionGenerationActive={descriptionGenerationActive}
onDescriptionGenerationRunStarted={onDescriptionGenerationRunStarted}
onNavigationStateChange={setColumnNavigation}
onSync={() => void synchronize("columns", [activeTable.id])}
/>
) : (
<div className="min-h-0 overflow-y-auto px-4 py-5">
<p className="text-sm text-muted-foreground">Only review metadata can be changed.</p>
@@ -334,6 +380,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 || !bindingReady || busy !== null || Boolean(currentRun)}
@@ -341,6 +394,13 @@ export function DatabaseTables({
>
Synchronize columns
</Menu.Item>
<Menu.Item
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
disabled={!canManage || busy !== null || Boolean(currentRun)}
onClick={() => void consolidateDescriptions()}
>
Move generated description to Description
</Menu.Item>
<Menu.Separator className="my-1 h-px bg-border" />
<Menu.Item
className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45"
@@ -0,0 +1,338 @@
import { act, 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 {
DescriptionGenerationEvent,
DescriptionGenerationRun,
} from "../../api/catalog-databases";
import { Toaster } from "../../components/ui/sonner";
import { FakeEventSource } from "../../test/fakeEventSource";
import { server } from "../../test/msw";
import { DescriptionGenerationDrawer } from "./DescriptionGenerationDrawer";
const runningRun: DescriptionGenerationRun = {
id: "77777777-7777-4777-8777-777777777777",
databaseId: "11111111-1111-4111-8111-111111111111",
scope: "missing",
modelId: "local-qwen",
language: "it",
status: "running",
total: 4,
processed: 1,
generated: 1,
nonGeneratable: 0,
failed: 0,
createdAt: "2026-08-28T08:00:00Z",
startedAt: "2026-08-28T08:00:01Z",
updatedAt: new Date().toISOString(),
finishedAt: null,
errorSummary: null,
};
function event(
sequence: number,
message: string,
level: DescriptionGenerationEvent["level"] = "info",
): DescriptionGenerationEvent {
return {
sequence,
level,
message,
createdAt: `2026-08-28T08:00:0${sequence}Z`,
};
}
function renderDrawer({
initialRun = runningRun,
onTerminal = vi.fn(),
}: {
initialRun?: DescriptionGenerationRun;
onTerminal?: (run: DescriptionGenerationRun) => void;
} = {}) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: Infinity } },
});
function Harness() {
const [run, setRun] = useState(initialRun);
return (
<DescriptionGenerationDrawer
open
run={run}
modelLabel="Local Qwen"
onClose={() => undefined}
onRunUpdate={setRun}
onTerminal={onTerminal}
/>
);
}
return {
client,
...render(
<QueryClientProvider client={client}>
<Harness />
<Toaster duration={Infinity} />
</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("replays ordered SSE events, reconnects from the latest sequence, and keeps polling as a deduplicated fallback", async () => {
let eventReads = 0;
server.use(
http.get("/api/catalog/description-generation-runs", () => HttpResponse.json([runningRun])),
http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(runningRun)),
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => {
eventReads += 1;
return HttpResponse.json(eventReads === 1 ? [event(1, "Run queued")] : [
event(2, "First description saved"),
event(3, "Polling recovered this event"),
]);
}),
);
renderDrawer();
const log = await screen.findByRole("log", { name: "Description generation events" });
expect(await within(log).findByText("Run queued")).toBeVisible();
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
expect(FakeEventSource.instances[0].url).toBe(
`/api/catalog/description-generation-runs/${runningRun.id}/events?after=1`,
);
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?.();
});
expect(await within(log).findByText("Polling recovered this event")).toBeVisible();
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2));
expect(FakeEventSource.instances[1].url).toBe(
`/api/catalog/description-generation-runs/${runningRun.id}/events?after=3`,
);
act(() => FakeEventSource.instances[0].onerror?.());
await new Promise((resolve) => window.setTimeout(resolve, 250));
expect(FakeEventSource.instances).toHaveLength(2);
expect(FakeEventSource.instances[1].closed).toBe(false);
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");
});
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.textContent!.indexOf("Run queued")).toBeLessThan(
log.textContent!.indexOf("First description saved"),
);
expect(log.textContent!.indexOf("First description saved")).toBeLessThan(
log.textContent!.indexOf("Polling recovered this event"),
);
});
test("stops queued or running work without hiding earlier results or events", async () => {
const user = userEvent.setup();
const cancelledRun: DescriptionGenerationRun = {
...runningRun,
status: "cancelled",
processed: 2,
generated: 2,
updatedAt: "2026-08-28T08:03:00Z",
finishedAt: "2026-08-28T08:03:00Z",
};
let cancelledRunId: string | undefined;
server.use(
http.get("/api/catalog/description-generation-runs", () => HttpResponse.json([cancelledRun])),
http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(runningRun)),
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([
event(1, "First description remains saved"),
])),
http.post("/api/catalog/description-generation-runs/:runId/cancel", ({ params }) => {
cancelledRunId = String(params.runId);
return HttpResponse.json(cancelledRun);
}),
);
renderDrawer();
const drawer = await screen.findByRole("complementary", { name: "Description generation" });
expect(await within(drawer).findByText("First description remains saved")).toBeVisible();
await user.click(within(drawer).getByRole("button", { name: "Stop" }));
expect(await within(drawer).findByRole("heading", { name: "Cancelled" })).toBeVisible();
expect(cancelledRunId).toBe(runningRun.id);
expect(within(drawer).getByText("2", { selector: "[data-count='generated']" })).toBeVisible();
expect(within(drawer).getByText("First description remains saved")).toBeVisible();
});
test.each([
"completed",
"completed_with_errors",
"failed",
"cancelled",
"interrupted",
] satisfies DescriptionGenerationRun["status"][])(
"notifies catalog consumers when a run becomes terminal with %s",
async (status) => {
const onTerminal = vi.fn();
const terminalRun: DescriptionGenerationRun = {
...runningRun,
status,
finishedAt: "2026-08-28T08:04:00Z",
};
server.use(
http.get("/api/catalog/description-generation-runs", () => HttpResponse.json([terminalRun])),
http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(terminalRun)),
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])),
);
renderDrawer({ initialRun: terminalRun, onTerminal });
await waitFor(() => expect(onTerminal).toHaveBeenCalledOnce());
expect(onTerminal).toHaveBeenCalledWith(terminalRun);
},
);
test("reopens a terminal run from newest-first persisted history", async () => {
const user = userEvent.setup();
const completedRun: DescriptionGenerationRun = {
...runningRun,
id: "88888888-8888-4888-8888-888888888888",
scope: "selected_tables",
status: "completed",
total: 3,
processed: 3,
generated: 3,
createdAt: "2026-08-28T07:00:00Z",
updatedAt: "2026-08-28T07:02:00Z",
finishedAt: "2026-08-28T07:02:00Z",
};
server.use(
http.get("/api/catalog/description-generation-runs", () => HttpResponse.json([
runningRun,
completedRun,
])),
http.get("/api/catalog/description-generation-runs/:runId", ({ params }) => (
HttpResponse.json(params.runId === completedRun.id ? completedRun : runningRun)
)),
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])),
);
renderDrawer();
const history = await screen.findByRole("region", {
name: "Description generation history",
});
await user.click(within(history).getByRole("button", {
name: /Completed.*selected tables/i,
}));
const drawer = screen.getByRole("complementary", { name: "Description generation" });
expect(await within(drawer).findByRole("heading", { name: "Completed" })).toBeVisible();
expect(within(drawer).getByText("3", { selector: "[data-count='generated']" })).toBeVisible();
});
test("offers Unlock only for an apparently stale active run and explains the interruption", async () => {
const user = userEvent.setup();
const staleRun: DescriptionGenerationRun = {
...runningRun,
updatedAt: "2020-01-01T00:00:00Z",
};
const interruptedRun: DescriptionGenerationRun = {
...staleRun,
status: "interrupted",
finishedAt: "2026-08-28T08:05:00Z",
};
server.use(
http.get("/api/catalog/description-generation-runs", () => HttpResponse.json([staleRun])),
http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(staleRun)),
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])),
http.post("/api/catalog/description-generation-runs/unlock", () => (
HttpResponse.json(interruptedRun)
)),
);
renderDrawer({ initialRun: staleRun });
const drawer = await screen.findByRole("complementary", { name: "Description generation" });
expect(within(drawer).getByText(
"Unlock interrupts apparently stale work and marks the run interrupted. A live worker will prevent it.",
)).toBeVisible();
await user.click(within(drawer).getByRole("button", { name: "Unlock stale run" }));
expect(await within(drawer).findByRole("heading", { name: "Interrupted" })).toBeVisible();
});
test("does not offer Unlock while an active run still appears current", async () => {
server.use(
http.get("/api/catalog/description-generation-runs", () => HttpResponse.json([runningRun])),
http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(runningRun)),
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])),
);
renderDrawer();
const drawer = await screen.findByRole("complementary", { name: "Description generation" });
expect(within(drawer).queryByRole("button", { name: "Unlock stale run" })).not.toBeInTheDocument();
});
test("explains when Unlock is refused because a live worker still exists", async () => {
const user = userEvent.setup();
const staleRun: DescriptionGenerationRun = {
...runningRun,
updatedAt: "2020-01-01T00:00:00Z",
};
server.use(
http.get("/api/catalog/description-generation-runs", () => HttpResponse.json([staleRun])),
http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(staleRun)),
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])),
http.post("/api/catalog/description-generation-runs/unlock", () => HttpResponse.json({
code: "description_generation_run_active",
message: "Private worker detail must not be shown.",
}, { status: 409 })),
);
renderDrawer({ initialRun: staleRun });
const drawer = await screen.findByRole("complementary", { name: "Description generation" });
await user.click(within(drawer).getByRole("button", { name: "Unlock stale run" }));
expect(await screen.findByText(
"Unlock was refused because a live description worker is still running.",
)).toBeVisible();
expect(screen.queryByText("Private worker detail must not be shown.")).not.toBeInTheDocument();
expect(within(drawer).getByRole("heading", { name: "Running" })).toBeVisible();
});
test("does not render stack traces or sensitive generation details from a final summary", async () => {
const failedRun: DescriptionGenerationRun = {
...runningRun,
status: "failed",
finishedAt: "2026-08-28T08:05:00Z",
errorSummary: "Traceback: provider payload included prompt and source sample\n at helper.py:42",
};
server.use(
http.get("/api/catalog/description-generation-runs", () => HttpResponse.json([failedRun])),
http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(failedRun)),
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])),
);
renderDrawer({ initialRun: failedRun });
expect(await screen.findByText(
"The run ended before all targets were processed. Review the event log for safe details.",
)).toBeVisible();
expect(screen.queryByText(/Traceback|provider payload|source sample/i)).not.toBeInTheDocument();
});
@@ -0,0 +1,413 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { LoaderCircle, LockOpen, Square, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { ApiError, apiErrorMessage } from "../../api/client";
import {
cancelDescriptionGenerationRun,
descriptionGenerationEventsUrl,
getDescriptionGenerationRun,
listDescriptionGenerationEvents,
listDescriptionGenerationRuns,
type DescriptionGenerationEvent,
type DescriptionGenerationRun,
unlockDescriptionGenerationRun,
} from "../../api/catalog-databases";
interface Props {
open: boolean;
run: DescriptionGenerationRun | null;
modelLabel: string;
onClose: () => void;
onRunUpdate: (run: DescriptionGenerationRun) => void;
onTerminal: (run: DescriptionGenerationRun) => void;
}
const APPARENTLY_STALE_AFTER_MS = 5 * 60 * 1_000;
const SAFE_FINAL_ERROR = "The run ended before all targets were processed. Review the event log for safe details.";
function statusLabel(run: DescriptionGenerationRun): string {
const label = run.status.replaceAll("_", " ");
return `${label[0].toUpperCase()}${label.slice(1)}`;
}
function isTerminal(run?: DescriptionGenerationRun): boolean {
return Boolean(run && [
"completed", "completed_with_errors", "cancelled", "failed", "interrupted",
].includes(run.status));
}
function isApparentlyStale(run: DescriptionGenerationRun): boolean {
if (!["queued", "running"].includes(run.status)) return false;
const updatedAt = new Date(run.updatedAt).getTime();
return Number.isFinite(updatedAt) && Date.now() - updatedAt >= APPARENTLY_STALE_AFTER_MS;
}
function safeFinalError(summary: string | null): string | null {
const value = summary?.trim();
if (!value) return null;
if (
value.length > 500
|| /[\r\n]/.test(value)
|| /traceback|stack trace|(?:^|\s)at\s+\S+[:(]|prompt|provider\s+payload|source\s+sample|bearer\s+\S+|api[_ -]?key|sk-[a-z0-9_-]+/i.test(value)
) return SAFE_FINAL_ERROR;
return value;
}
function timestamp(value: string | null): string {
return value ? new Date(value).toLocaleString() : "Not available";
}
export function DescriptionGenerationDrawer({
open,
run: initialRun,
modelLabel,
onClose,
onRunUpdate,
onTerminal,
}: Props) {
const queryClient = useQueryClient();
const runId = initialRun?.id ?? null;
const [events, setEvents] = useState<DescriptionGenerationEvent[]>([]);
const [action, setAction] = useState<"stop" | "unlock" | null>(null);
const lastSequence = useRef(0);
const terminalRuns = useRef(new Set<string>());
const finalEventsRun = useRef<string | null>(null);
const runQuery = useQuery({
queryKey: ["description-generation-run", runId],
queryFn: () => getDescriptionGenerationRun(runId!),
enabled: Boolean(runId),
initialData: initialRun ?? undefined,
retry: false,
refetchInterval: (query) => isTerminal(query.state.data) ? false : 1_000,
});
const run = runQuery.data;
const eventQuery = useQuery({
queryKey: ["description-generation-events", runId],
queryFn: () => listDescriptionGenerationEvents(runId!, lastSequence.current),
enabled: Boolean(runId),
retry: false,
refetchInterval: run && isTerminal(run) ? false : 1_500,
});
const historyQuery = useQuery({
queryKey: ["description-generation-runs", 50],
queryFn: () => listDescriptionGenerationRuns(50),
enabled: open,
retry: false,
refetchInterval: (query) => query.state.data?.some((item) => !isTerminal(item))
? 3_000
: false,
});
const mergeEvents = useCallback((incoming: DescriptionGenerationEvent[]) => {
if (incoming.length === 0) return;
setEvents((current) => {
const bySequence = new Map(current.map((event) => [event.sequence, event]));
for (const event of incoming) bySequence.set(event.sequence, event);
const merged = [...bySequence.values()].sort((left, right) => left.sequence - right.sequence);
lastSequence.current = merged.at(-1)?.sequence ?? lastSequence.current;
return merged;
});
}, []);
useEffect(() => {
setEvents([]);
lastSequence.current = 0;
finalEventsRun.current = null;
}, [runId]);
useEffect(() => {
mergeEvents(eventQuery.data ?? []);
}, [eventQuery.data, mergeEvents]);
const live = Boolean(run && !isTerminal(run));
useEffect(() => {
if (
!runId
|| !open
|| !live
|| !eventQuery.isFetched
|| typeof EventSource === "undefined"
) return;
let source: EventSource | null = null;
let reconnectTimer: number | null = null;
let disposed = false;
const connect = () => {
if (disposed) return;
const polledSequence = (eventQuery.data ?? []).reduce(
(latest, event) => Math.max(latest, event.sequence),
0,
);
lastSequence.current = Math.max(lastSequence.current, polledSequence);
let currentSource: EventSource;
try {
currentSource = new EventSource(descriptionGenerationEventsUrl(runId, lastSequence.current));
} catch {
return;
}
source = currentSource;
const isCurrent = () => !disposed && source === currentSource;
const log = (message: MessageEvent<string>) => {
if (!isCurrent()) return;
try {
mergeEvents([JSON.parse(message.data) as DescriptionGenerationEvent]);
} catch { /* ordered polling remains authoritative */ }
};
const update = (message: MessageEvent<string>) => {
if (!isCurrent()) return;
try {
queryClient.setQueryData(
["description-generation-run", runId],
JSON.parse(message.data) as DescriptionGenerationRun,
);
} catch { /* status polling remains authoritative */ }
};
const message = (incoming: MessageEvent<string>) => {
if (!isCurrent()) return;
try {
const parsed = JSON.parse(incoming.data) as DescriptionGenerationEvent | DescriptionGenerationRun;
if ("sequence" in parsed) mergeEvents([parsed]);
else if ("status" in parsed) queryClient.setQueryData(["description-generation-run", runId], parsed);
} catch { /* polling remains authoritative */ }
};
currentSource.addEventListener("log", log as EventListener);
currentSource.addEventListener("run", update as EventListener);
currentSource.onmessage = message;
currentSource.onerror = () => {
if (!isCurrent() || reconnectTimer !== null) return;
currentSource.close();
source = null;
void eventQuery.refetch().finally(() => {
if (disposed || reconnectTimer !== null) return;
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
connect();
}, 200);
});
};
};
connect();
return () => {
disposed = true;
source?.close();
if (reconnectTimer !== null) window.clearTimeout(reconnectTimer);
};
}, [eventQuery.isFetched, eventQuery.refetch, live, mergeEvents, open, queryClient, runId]);
useEffect(() => {
if (!run) return;
onRunUpdate(run);
queryClient.setQueryData<DescriptionGenerationRun[]>(
["description-generation-runs", 50],
(current) => {
if (!current) return [run];
return current.some((item) => item.id === run.id)
? current.map((item) => item.id === run.id ? run : item)
: [run, ...current];
},
);
}, [onRunUpdate, queryClient, run]);
useEffect(() => {
if (!run || !isTerminal(run) || finalEventsRun.current === run.id) return;
finalEventsRun.current = run.id;
void eventQuery.refetch();
}, [eventQuery.refetch, run]);
useEffect(() => {
if (!run || !isTerminal(run) || terminalRuns.current.has(run.id)) return;
terminalRuns.current.add(run.id);
onTerminal(run);
}, [onTerminal, run]);
const stop = async () => {
if (!run || isTerminal(run)) return;
setAction("stop");
try {
const next = await cancelDescriptionGenerationRun(run.id);
queryClient.setQueryData(["description-generation-run", run.id], next);
onRunUpdate(next);
await historyQuery.refetch();
} catch (error) {
toast.error(apiErrorMessage(error));
} finally {
setAction(null);
}
};
const unlock = async () => {
if (!run || !isApparentlyStale(run)) return;
setAction("unlock");
try {
const next = await unlockDescriptionGenerationRun();
queryClient.setQueryData(["description-generation-run", next.id], next);
onRunUpdate(next);
await historyQuery.refetch();
} catch (error) {
toast.error(error instanceof ApiError && error.status === 409
? "Unlock was refused because a live description worker is still running."
: apiErrorMessage(error));
} finally {
setAction(null);
}
};
if (!open || !run) return null;
const apparentlyStale = isApparentlyStale(run);
const finalError = safeFinalError(run.errorSummary);
return (
<aside
aria-label="Description generation"
className="fixed inset-y-2 right-0 z-50 flex w-full max-w-[400px] flex-col border-l border-border bg-background shadow-2xl sm:inset-y-4"
>
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
<p className="thot-label">Description generation</p>
<h2 className="mt-1 font-heading text-xl font-semibold">{statusLabel(run)}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{modelLabel}{modelLabel !== run.modelId ? ` (${run.modelId})` : ""} · {run.scope.replaceAll("_", " ")}
</p>
</div>
<Button type="button" variant="ghost" size="icon-lg" aria-label="Close description generation" onClick={onClose}>
<X aria-hidden="true" />
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
{runQuery.isError ? (
<div role="alert" className="rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{apiErrorMessage(runQuery.error)}
</div>
) : null}
<section aria-label="Description generation counters">
<h3 className="thot-label mb-2">Progress</h3>
<div className="grid grid-cols-2 gap-2">
{([
["total", "Total"],
["processed", "Processed"],
["generated", "Generated"],
["nonGeneratable", "Not generated"],
["failed", "Failed"],
] as const).map(([name, label]) => (
<div key={name} className="rounded-md border border-border bg-muted/25 px-3 py-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</p>
<p data-count={name} className="mt-1 text-lg font-semibold tabular-nums">{run[name]}</p>
</div>
))}
</div>
</section>
<section className="mt-5" aria-label="Description generation timestamps">
<h3 className="thot-label mb-2">Timestamps</h3>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
<dt className="text-muted-foreground">Created</dt><dd className="text-right">{timestamp(run.createdAt)}</dd>
<dt className="text-muted-foreground">Started</dt><dd className="text-right">{timestamp(run.startedAt)}</dd>
<dt className="text-muted-foreground">Updated</dt><dd className="text-right">{timestamp(run.updatedAt)}</dd>
<dt className="text-muted-foreground">Finished</dt><dd className="text-right">{timestamp(run.finishedAt)}</dd>
</dl>
</section>
{finalError ? (
<div className="mt-5 rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{finalError}
</div>
) : null}
<section className="mt-5" aria-label="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>
<div
role="log"
aria-label="Description generation events"
className="max-h-72 overflow-y-auto rounded-md bg-zinc-950 p-3 font-mono text-xs leading-5 text-zinc-200"
>
{events.length === 0 ? <p className="text-zinc-500">Waiting for events…</p> : events.map((event) => (
<p key={event.sequence} className={event.level === "error" ? "text-red-300" : event.level === "warning" ? "text-amber-300" : undefined}>
<span className="mr-2 text-zinc-500">{new Date(event.createdAt).toLocaleTimeString()}</span>{event.message}
</p>
))}
</div>
{eventQuery.isError ? <p role="alert" className="mt-2 text-sm text-destructive">{apiErrorMessage(eventQuery.error)}</p> : null}
</section>
<section className="mt-5" aria-label="Description generation history">
<div className="mb-2 flex items-center justify-between gap-3">
<h3 className="thot-label">Recent runs</h3>
<span className="text-xs tabular-nums text-muted-foreground">
{historyQuery.data?.length ?? 0}
</span>
</div>
{historyQuery.isError ? (
<p role="alert" className="text-sm text-destructive">
{apiErrorMessage(historyQuery.error)}
</p>
) : historyQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading run history…</p>
) : (historyQuery.data ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">No description generation runs yet.</p>
) : (
<div className="max-h-56 divide-y divide-border overflow-y-auto rounded-md border border-border">
{(historyQuery.data ?? []).map((item) => (
<button
key={item.id}
type="button"
aria-label={`${statusLabel(item)} · ${item.scope.replaceAll("_", " ")}`}
aria-current={item.id === run.id ? "true" : undefined}
className="flex w-full items-start justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/25"
onClick={() => onRunUpdate(item)}
>
<span className="font-medium">{statusLabel(item)}</span>
<span className="shrink-0 text-right text-xs text-muted-foreground">
{item.processed}/{item.total}<br />{new Date(item.createdAt).toLocaleString()}
</span>
</button>
))}
</div>
)}
</section>
</div>
{["queued", "running"].includes(run.status) ? (
<div className="border-t border-border px-5 py-4">
{apparentlyStale ? (
<p
id="description-generation-unlock-help"
className="mb-3 rounded-md border border-amber-500/35 bg-amber-500/8 px-3 py-2 text-xs leading-5 text-muted-foreground"
>
Unlock interrupts apparently stale work and marks the run interrupted. A live worker will prevent it.
</p>
) : null}
<div className="flex flex-wrap justify-end gap-2">
{apparentlyStale ? (
<Button
type="button"
variant="outline"
aria-label="Unlock stale run"
aria-describedby="description-generation-unlock-help"
disabled={action !== null}
onClick={() => void unlock()}
>
{action === "unlock" ? <LoaderCircle className="animate-spin" /> : <LockOpen />}
{action === "unlock" ? "Unlocking…" : "Unlock"}
</Button>
) : null}
<Button
type="button"
variant="destructive"
disabled={action !== null}
onClick={() => void stop()}
>
{action === "stop" ? <LoaderCircle className="animate-spin" /> : <Square />}
{action === "stop" ? "Stopping…" : "Stop"}
</Button>
</div>
</div>
) : null}
</aside>
);
}
@@ -0,0 +1,75 @@
import type { MetadataGenerationModels } from "../../api/catalog-databases";
interface Props {
canManage: boolean;
data?: MetadataGenerationModels;
isError: boolean;
isLoading: boolean;
selectedModel: string;
onSelectedModelChange: (modelId: string) => void;
}
export function MetadataGenerationModelSelector({
canManage,
data,
isError,
isLoading,
selectedModel,
onSelectedModelChange,
}: Props) {
const models = data?.models ?? [];
const configuredDefault = data?.default ?? "";
const hasUsableDefault = configuredDefault !== ""
&& models.some((model) => model.id === configuredDefault);
const noUsableModel = !isLoading && !isError && Boolean(data) && !hasUsableDefault;
const unavailable = noUsableModel || (!isLoading && isError);
const describedBy = [
"metadata-generation-source-data-disclosure",
unavailable ? "metadata-generation-model-help" : null,
].filter(Boolean).join(" ");
return (
<div className="grid min-w-56 max-w-lg gap-1.5">
<label className="grid gap-1.5 text-xs font-medium text-foreground">
<span>Metadata description model</span>
<select
className="h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none transition focus:border-primary/60 focus:ring-3 focus:ring-ring/15 disabled:bg-muted disabled:text-muted-foreground"
aria-label="Metadata description model"
aria-describedby={describedBy}
value={selectedModel}
disabled={!canManage || isLoading || isError || !hasUsableDefault}
onChange={(event) => onSelectedModelChange(event.target.value)}
>
{isLoading ? <option value="">Loading models…</option> : null}
{!isLoading && isError ? <option value="">Model choices unavailable</option> : null}
{noUsableModel ? (
<option value="">{models.length === 0 ? "No model configured" : "No usable model configured"}</option>
) : null}
{models.map((model) => (
<option key={model.id} value={model.id}>{model.label}</option>
))}
</select>
</label>
{unavailable ? (
<p id="metadata-generation-model-help" className="max-w-72 text-xs font-normal leading-4 text-muted-foreground">
Configure a metadata-generation model in application setup to enable description generation.
</p>
) : null}
<div
id="metadata-generation-source-data-disclosure"
role="note"
aria-label="Metadata generation source data disclosure"
className="space-y-1 text-xs font-normal leading-4 text-muted-foreground"
>
<p>
Description generation may send up to five real source rows and up to five representative
non-null example values to the selected model provider.
</p>
<p>
Samples are transient and are not stored in run logs or catalog metadata. Automated
Sensitive Data Policy filtering and anonymization are not currently provided; they are
planned for future work.
</p>
</div>
</div>
);
}
+2
View File
@@ -11,4 +11,6 @@ export const server = setupServer(
})),
http.get("/api/health/dwh", () => HttpResponse.json({ ok: true, detail: "ok" })),
http.get("/api/catalog/databases", () => HttpResponse.json([])),
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({ models: [], default: null })),
http.get("/api/catalog/description-generation-runs", () => HttpResponse.json([])),
);