import { useAdminGridLocale } from "./useAdminGridLocale"; import { adminMessage, type AdminMessage } from "./messages"; import { useI18n } from "../../i18n"; import { useCallback, 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 { ArrowLeft, ChevronDown, Columns3, Pencil, RefreshCw, Save, Sparkles, Trash2, X } from "lucide-react"; import { toast } from "sonner"; import { Button } from "../../components/ui/button"; import { ApiError, apiErrorMessage } from "../../api/client"; import { consolidateCatalogDescriptions, deleteCatalogTableMetadata, listCatalogColumns, listCatalogTables, startCatalogSync, startDescriptionGenerationRun, updateCatalogTableMetadata, type CatalogDatabase, type CatalogSyncRun, type CatalogTable, type CatalogTableMetadataDeleteTarget, type DescriptionGenerationRun, type SensitivityAnalysisRequest, } from "../../api/catalog-databases"; import type { DatabaseNavigationState } from "./model"; import { DatabaseColumns } from "./DatabaseColumns"; import { FleetActionSelector, type FleetActionOption } from "./FleetActionSelector"; import { FleetLedgerDrawer } from "./FleetLedgerShell"; import { NO_METADATA_GENERATION_LLM_MODEL_MESSAGE } from "./MetadataGenerationModelSelector"; interface Props { database: CatalogDatabase; canManage: boolean; activeRun?: CatalogSyncRun; selectedMetadataModel: string | null; descriptionGenerationActive: boolean; onBackToDatabases: () => void; onOpenOverview: () => void; onOpenRelationships: () => void; onNavigationStateChange: (state: DatabaseNavigationState) => void; onNestedNavigationChange?: (active: boolean, onBack?: () => void) => void; onRunStarted: (run: CatalogSyncRun) => void; onOpenSync: () => void; onClearCatalogTables?: () => Promise; onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void; onSuggestSensitive: (selection: SensitivityAnalysisRequest, scopeLabel: AdminMessage) => Promise; onCatalogMetricsChanged?: () => void | Promise; presentation?: "legacy" | "fleet"; } interface TableGridContext { canManage: boolean; presentation: "legacy" | "fleet"; onOpen: (table: CatalogTable, section: "overview" | "columns", origin: HTMLButtonElement) => void; } function TableActionsCell({ data, context }: ICellRendererParams) { const { t } = useI18n(); if (!data || !context) return null; return (
event.stopPropagation()}>
); } export function DatabaseTables({ database, canManage, activeRun, selectedMetadataModel, descriptionGenerationActive, onBackToDatabases, onOpenOverview, onOpenRelationships, onNavigationStateChange, onNestedNavigationChange, onRunStarted, onOpenSync, onClearCatalogTables, onDescriptionGenerationRunStarted, onSuggestSensitive, onCatalogMetricsChanged, presentation = "legacy", }: Props) { const { t } = useI18n(); const { locale, gridLocaleProps } = useAdminGridLocale(); const databaseId = database.id!; const queryClient = useQueryClient(); const queryKey = ["catalog-tables", databaseId] as const; const { data = [], error, isError, isLoading, isFetching, refetch } = useQuery({ queryKey, queryFn: () => listCatalogTables(databaseId), retry: false }); const [search, setSearch] = useState(""); const [displayedCount, setDisplayedCount] = useState(0); const [selectedIds, setSelectedIds] = useState([]); const [activeTableId, setActiveTableId] = useState(null); const [tableSection, setTableSection] = useState<"overview" | "columns">("overview"); const [description, setDescription] = useState(""); const [generatedDescription, setGeneratedDescription] = useState(""); const [baseline, setBaseline] = useState(""); const [editorVersion, setEditorVersion] = useState(null); const [stale, setStale] = useState(false); const [staleBannerOpen, setStaleBannerOpen] = useState(true); const [busy, setBusy] = useState<"sync" | "save" | "delete" | "consolidate" | "generate" | "suggest" | null>(null); const [pendingDelete, setPendingDelete] = useState(null); const [columnNavigation, setColumnNavigation] = useState({ dirty: false, busy: false }); const gridRef = useRef>(null); const originRef = useRef(null); const headingRef = useRef(null); const actionsTriggerRef = useRef(null); const fleetActionSelectRef = useRef(null); const searchInputRef = useRef(null); const activeTable = activeTableId ? data.find((table) => table.id === activeTableId) : undefined; const editorFingerprint = JSON.stringify([description, generatedDescription]); const editorDirty = Boolean(activeTableId && editorFingerprint !== baseline); const dirty = tableSection === "columns" ? columnNavigation.dirty : editorDirty; const navigationBusy = busy !== null || (tableSection === "columns" && columnNavigation.busy); const nestedNavigationActive = presentation === "fleet" && Boolean(activeTable) && tableSection === "columns"; const currentRun = activeRun ?? database.activeSyncRun; const clearAllCatalogTables = async () => { if (!onClearCatalogTables || !window.confirm(t("Clear all catalog tables, columns, and relationships for this database? The source database will not be changed."))) return; await onClearCatalogTables(); }; const leaveTable = useCallback(() => { if (navigationBusy) return; if (dirty && !window.confirm(t("Discard unsaved metadata?"))) return; setActiveTableId(null); setTableSection("overview"); window.setTimeout(() => originRef.current?.focus(), 0); }, [dirty, navigationBusy, t]); useEffect(() => { onNavigationStateChange({ dirty, busy: navigationBusy }); }, [dirty, navigationBusy, onNavigationStateChange]); useEffect(() => { onNestedNavigationChange?.(nestedNavigationActive, nestedNavigationActive ? leaveTable : undefined); }, [leaveTable, nestedNavigationActive, onNestedNavigationChange]); useEffect(() => () => { onNestedNavigationChange?.(false); }, [onNestedNavigationChange]); useEffect(() => { if (!activeTableId || !activeTable || editorVersion === activeTable.version || busy) return; setStale(true); setStaleBannerOpen(true); }, [activeTable, activeTableId, busy, editorVersion]); useEffect(() => { if (!pendingDelete) return; const timer = window.setTimeout(() => document.getElementById("table-cleanup-confirm-button")?.focus(), 0); return () => window.clearTimeout(timer); }, [pendingDelete]); const closeDeleteConfirmation = () => { setPendingDelete(null); window.setTimeout(() => { if (presentation === "fleet") fleetActionSelectRef.current?.focus(); else actionsTriggerRef.current?.focus(); }, 0); }; const openTable = (table: CatalogTable, section: "overview" | "columns", origin: HTMLButtonElement) => { originRef.current = origin; setActiveTableId(table.id); setTableSection(section); setDescription(table.description ?? ""); setGeneratedDescription(table.generatedDescription ?? ""); setBaseline(JSON.stringify([table.description ?? "", table.generatedDescription ?? ""])); setEditorVersion(table.version); setStale(false); setStaleBannerOpen(true); setColumnNavigation({ dirty: false, busy: false }); }; const navigateDatabase = (target: "databases" | "overview" | "relationships") => { if (navigationBusy) return; if (dirty && !window.confirm(t("Discard unsaved metadata?"))) return; if (target === "databases") onBackToDatabases(); else if (target === "overview") onOpenOverview(); else onOpenRelationships(); }; const save = async () => { if (!activeTable || editorVersion === null) return; setBusy("save"); try { const updated = await updateCatalogTableMetadata( databaseId, activeTable.id, editorVersion, description.trim() || null, generatedDescription.trim() || null, ); queryClient.setQueryData(queryKey, (current = []) => current.map((table) => table.id === updated.id ? updated : table)); setDescription(updated.description ?? ""); setGeneratedDescription(updated.generatedDescription ?? ""); setBaseline(JSON.stringify([updated.description ?? "", updated.generatedDescription ?? ""])); setEditorVersion(updated.version); setStale(false); void onCatalogMetricsChanged?.(); toast.success(t("Table metadata saved")); } catch (error) { if (error instanceof ApiError && error.code === "table_stale") { await refetch(); setStale(true); setStaleBannerOpen(true); } else toast.error(t(apiErrorMessage(error))); } finally { setBusy(null); } }; const reloadTable = async () => { if (!activeTableId) return; setBusy("sync"); try { const result = await refetch(); const latest = result.data?.find((table) => table.id === activeTableId); if (!latest) { leaveTable(); return; } setDescription(latest.description ?? ""); setGeneratedDescription(latest.generatedDescription ?? ""); setBaseline(JSON.stringify([latest.description ?? "", latest.generatedDescription ?? ""])); setEditorVersion(latest.version); setStale(false); setStaleBannerOpen(true); } catch (error) { toast.error(t(apiErrorMessage(error))); } finally { setBusy(null); } }; const synchronize = async (scope: "tables" | "columns" | "all", tableIds: string[] = []) => { setBusy("sync"); try { const run = await startCatalogSync(databaseId, database.version, scope, tableIds); onRunStarted(run); gridRef.current?.api.deselectAll(); setSelectedIds([]); toast.success(scope === "all" ? t("Full schema synchronization started") : scope === "columns" ? t("Column synchronization started") : t("Table synchronization started")); } catch (error) { toast.error(t(apiErrorMessage(error))); } finally { setBusy(null); } }; const deleteMetadata = async (target: CatalogTableMetadataDeleteTarget) => { setBusy("delete"); try { const counts = await deleteCatalogTableMetadata(databaseId, selectedIds, target); await Promise.all([ queryClient.invalidateQueries({ queryKey: ["catalog-columns", databaseId] }), queryClient.invalidateQueries({ queryKey: ["catalog-relationships", databaseId] }), queryClient.invalidateQueries({ queryKey: ["catalog-databases"] }), ]); gridRef.current?.api.deselectAll(); setSelectedIds([]); setPendingDelete(null); window.setTimeout(() => searchInputRef.current?.focus(), 0); void onCatalogMetricsChanged?.(); toast.success(target === "columns" ? t("{value} {columns} catalog columns", { value: presentation === "fleet" ? t("Cleared") : t("Deleted"), columns: counts.columns }) : t("{value} {relationships} catalog relationships", { value: presentation === "fleet" ? t("Cleared") : t("Deleted"), relationships: counts.relationships })); } catch (error) { toast.error(t(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([]); void onCatalogMetricsChanged?.(); toast.success((counts.copied === 1 ? t("Copied {copied} description; skipped {skipped}", { copied: counts.copied, skipped: counts.skipped }) : t("Copied {copied} descriptions; skipped {skipped}", { copied: counts.copied, skipped: counts.skipped }))); } catch (error) { toast.error(t(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((selectedIds.length === 1 ? t("Description generation started for {count} table", { count: selectedIds.length }) : t("Description generation started for {count} tables", { count: selectedIds.length }))); } catch (error) { toast.error(t(apiErrorMessage(error))); } finally { setBusy(null); } }; const generateColumnDescriptions = async () => { if (selectedIds.length === 0 || !selectedMetadataModel) return; setBusy("generate"); try { const columns = (await Promise.all(selectedIds.map((tableId) => listCatalogColumns(databaseId, tableId)))).flat(); const columnIds = columns.map((column) => column.id); if (columnIds.length === 0) { toast.error(t("The selected tables have no synchronized catalog columns.")); return; } const run = await startDescriptionGenerationRun( databaseId, selectedMetadataModel, "selected_columns", columnIds, ); onDescriptionGenerationRunStarted(run); toast.success((columnIds.length === 1 ? t("Column description generation started for {count} column", { count: columnIds.length }) : t("Column description generation started for {count} columns", { count: columnIds.length }))); } catch (error) { toast.error(t(apiErrorMessage(error))); } finally { setBusy(null); } }; const suggestSensitive = async () => { if (selectedIds.length === 0) return; setBusy("suggest"); try { await onSuggestSensitive( { scope: "selected_tables", targetIds: selectedIds }, (selectedIds.length === 1 ? adminMessage("{count} selected table", { count: selectedIds.length }) : adminMessage("{count} selected tables", { count: selectedIds.length })), ); } catch { // The page-level request reports the safe, specific reason and preserves the selection. } finally { setBusy(null); } }; type FleetTableAction = | "generate-descriptions" | "generate-column-descriptions" | "sync-tables" | "sync-columns" | "consolidate-descriptions" | "suggest-sensitive" | "clear-columns"; const fleetActions: readonly FleetActionOption[] = [ { id: "generate-descriptions", label: t("Generate table description"), group: t("Descriptions"), runLabel: t("Generate"), disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun), disabledReason: !canManage ? t("You do not have permission to generate descriptions.") : selectedIds.length === 0 ? t("Select at least one table.") : !selectedMetadataModel ? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE : descriptionGenerationActive || Boolean(currentRun) ? t("Wait for the active catalog operation to finish.") : busy !== null ? t("Another action is running.") : undefined, }, { id: "generate-column-descriptions", label: t("Generate column descriptions"), group: t("Descriptions"), runLabel: t("Generate"), disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun), disabledReason: !canManage ? t("You do not have permission to generate descriptions.") : selectedIds.length === 0 ? t("Select at least one table.") : !selectedMetadataModel ? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE : descriptionGenerationActive || Boolean(currentRun) ? t("Wait for the active catalog operation to finish.") : busy !== null ? t("Another action is running.") : undefined, }, { id: "consolidate-descriptions", label: t("Copy generated descriptions to Description"), group: t("Descriptions"), runLabel: t("Move"), disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun), disabledReason: !canManage ? t("You do not have permission to update metadata.") : selectedIds.length === 0 ? t("Select at least one table.") : busy !== null || Boolean(currentRun) ? t("Wait for the active catalog operation to finish.") : undefined, }, { id: "sync-tables", label: t("Synchronize database tables"), group: t("Synchronization"), scopeLabel: t("Selected database"), runLabel: t("Synchronize"), disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun), disabledReason: !canManage ? t("You do not have permission to synchronize the catalog.") : selectedIds.length === 0 ? t("Select at least one table.") : busy !== null || Boolean(currentRun) ? t("Wait for the active catalog operation to finish.") : undefined, }, { id: "sync-columns", label: t("Synchronize columns for selected tables"), group: t("Synchronization"), runLabel: t("Synchronize"), disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun), disabledReason: !canManage ? t("You do not have permission to synchronize the catalog.") : selectedIds.length === 0 ? t("Select at least one table.") : busy !== null || Boolean(currentRun) ? t("Wait for the active catalog operation to finish.") : undefined, }, { id: "suggest-sensitive", label: t("Analyze sensitive fields"), group: t("Sensitive data"), runLabel: t("Analyze"), disabled: !canManage || selectedIds.length === 0 || descriptionGenerationActive || busy !== null || Boolean(currentRun), disabledReason: !canManage ? t("You do not have permission to review sensitive data.") : selectedIds.length === 0 ? t("Select at least one table.") : descriptionGenerationActive || Boolean(currentRun) ? t("Wait for the active catalog operation to finish.") : busy !== null ? t("Another action is running.") : undefined, }, { id: "clear-columns", label: t("Remove synchronized columns"), group: t("Catalog cleanup"), tone: "destructive", runLabel: t("Review cleanup"), disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun), disabledReason: !canManage ? t("You do not have permission to clear catalog metadata.") : selectedIds.length === 0 ? t("Select at least one table.") : busy !== null || Boolean(currentRun) ? t("Wait for the active catalog operation to finish.") : undefined, }, ]; const runFleetAction = async (action: FleetTableAction) => { if (action === "generate-descriptions") await generateDescriptions(); else if (action === "generate-column-descriptions") await generateColumnDescriptions(); else if (action === "sync-tables") await synchronize("tables"); else if (action === "consolidate-descriptions") await consolidateDescriptions(); else if (action === "sync-columns") await synchronize("columns", selectedIds); else if (action === "suggest-sensitive") await suggestSensitive(); else if (action === "clear-columns") setPendingDelete("columns"); }; const clearSelection = () => { gridRef.current?.api.deselectAll(); setSelectedIds([]); }; const columns = useMemo[]>(() => [ { field: "name", headerName: t("Name"), minWidth: 250, flex: 1, cellClass: "font-mono text-xs" }, { field: "sourceComment", headerName: t("Source comment"), minWidth: 260, flex: 1.25, valueFormatter: ({ value }) => value ?? "" }, { field: "generatedDescription", headerName: t("Generated description"), minWidth: 280, flex: 1.25, valueFormatter: ({ value }) => value ?? "" }, { field: "description", headerName: t("Description"), minWidth: 280, flex: 1.25, valueFormatter: ({ value }) => value ?? "" }, { colId: "actions", headerName: t("Actions"), width: 104, minWidth: 104, maxWidth: 104, pinned: "right", lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: TableActionsCell }, ], [t]); const context = useMemo(() => ({ canManage, presentation, onOpen: openTable }), [canManage, data, presentation, t]); const visibleSelected = useMemo(() => { const term = search.trim().toLocaleLowerCase(); if (!term) return selectedIds.length; const selected = new Set(selectedIds); return data.filter((table) => selected.has(table.id) && [table.name, table.sourceComment, table.generatedDescription, table.description] .some((value) => value?.toLocaleLowerCase().includes(term))).length; }, [data, search, selectedIds, t]); const fleetQueryError = presentation === "fleet" && isError ? (

{t("Catalog tables could not be loaded.")}

{t(apiErrorMessage(error))}

) : null; const databaseHeader = (

{t("Database management")}

{database.workspaceName}

{database.databaseName} · {database.schema}

{currentRun ? : null} {onClearCatalogTables ? : null}
); if (activeTable && presentation === "fleet") { if (tableSection === "columns") { return (

{t("Physical table")}

{activeTable.name}

); } return (
{t("Catalog tables")} setSearch(event.target.value)} /> {search.trim() ? t("{count} of {total}", { count: displayedCount, total: data.length }) : displayedCount}
{fleetQueryError ??
key={locale} {...gridLocaleProps} ref={gridRef} rowData={data} columnDefs={columns} context={context} loading={isLoading} quickFilterText={search} defaultColDef={{ sortable: true, filter: true, resizable: true }} getRowId={({ data: table }) => table.id} rowSelection={{ mode: "multiRow", selectAll: "filtered", enableClickSelection: false }} onSelectionChanged={({ api }) => setSelectedIds(api.getSelectedRows().map((table) => table.id))} onModelUpdated={({ api }) => setDisplayedCount(api.getDisplayedRowCount())} rowHeight={44} headerHeight={38} animateRows={false} overlayNoRowsTemplate={t("No catalog tables. Synchronize the schema from the database view.")} />
} )} >

{t("Physical table")}

{activeTable.name}

{stale ? ( staleBannerOpen ? (

{t("A newer table description is available.")}

{t("Your draft is preserved until you reload the current metadata.")}

) :
{t("Reload the latest value before this description can be saved.")}
) : null}