feat: refine metadata catalog workflows

This commit is contained in:
Codex
2026-09-02 15:58:23 +02:00
parent 4531746038
commit ae053961a3
31 changed files with 848 additions and 219 deletions
@@ -10,9 +10,11 @@ import { ApiError, apiErrorMessage } from "../../api/client";
import {
consolidateCatalogDescriptions,
listCatalogColumns,
startCatalogSync,
startDescriptionGenerationRun,
updateCatalogColumnMetadata,
type CatalogColumn,
type CatalogSyncRun,
type CatalogTable,
type DescriptionGenerationRun,
type SensitiveDataSuggestionRequest,
@@ -25,10 +27,12 @@ import { NO_METADATA_GENERATION_LLM_MODEL_MESSAGE } from "./MetadataGenerationMo
interface Props {
databaseId: string;
table: CatalogTable;
databaseVersion: number;
canManage: boolean;
selectedMetadataModel: string | null;
descriptionGenerationActive: boolean;
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
onCatalogSyncRunStarted?: (run: CatalogSyncRun) => void;
onNavigationStateChange: (state: DatabaseNavigationState) => void;
onSuggestSensitive: (selection: SensitiveDataSuggestionRequest, scopeLabel: string) => Promise<void>;
catalogOperationActive?: boolean;
@@ -88,10 +92,12 @@ function SensitiveCell({ data, context }: ICellRendererParams<CatalogColumn, unk
export function DatabaseColumns({
databaseId,
table,
databaseVersion,
canManage,
selectedMetadataModel,
descriptionGenerationActive,
onDescriptionGenerationRunStarted,
onCatalogSyncRunStarted,
onNavigationStateChange,
onSuggestSensitive,
catalogOperationActive = false,
@@ -285,15 +291,33 @@ export function DatabaseColumns({
};
type FleetColumnAction =
| "sync-columns"
| "generate-descriptions"
| "consolidate-descriptions"
| "suggest-sensitive"
| "save-sensitive";
const fleetActions: readonly FleetActionOption<FleetColumnAction>[] = [
{
id: "sync-columns",
label: "Synchronize columns for this table",
group: "Synchronization",
scopeLabel: "Selected table",
runLabel: "Synchronize",
disabled: !canManage || selectedIds.length === 0 || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to synchronize the catalog."
: selectedIds.length === 0
? "Select at least one column."
: catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
? "Another action is running."
: undefined,
},
{
id: "generate-descriptions",
label: "Generate descriptions",
label: "Generate column descriptions",
group: "Descriptions",
runLabel: "Generate",
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || catalogOperationActive || busy,
@@ -311,7 +335,7 @@ export function DatabaseColumns({
},
{
id: "consolidate-descriptions",
label: "Move generated to Description",
label: "Copy generated descriptions to Description",
group: "Descriptions",
runLabel: "Move",
disabled: !canManage || selectedIds.length === 0 || catalogOperationActive || busy,
@@ -345,14 +369,14 @@ export function DatabaseColumns({
},
{
id: "save-sensitive",
label: "Save sensitive-field changes",
label: "Save sensitive field changes",
group: "Sensitive data",
runLabel: "Save",
disabled: !canManage || changedSensitiveColumns.length === 0 || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to update sensitive fields."
: changedSensitiveColumns.length === 0
? "No sensitive-field changes are waiting to be saved."
? "No sensitive field changes to save."
: catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
@@ -362,7 +386,8 @@ export function DatabaseColumns({
];
const runFleetAction = async (action: FleetColumnAction) => {
if (action === "generate-descriptions") await generateDescriptions();
if (action === "sync-columns") await synchronizeColumns();
else if (action === "generate-descriptions") await generateDescriptions();
else if (action === "consolidate-descriptions") await consolidateDescriptions();
else if (action === "suggest-sensitive") await suggestSensitive();
else await saveSensitive();
@@ -373,6 +398,22 @@ export function DatabaseColumns({
setSelectedIds([]);
};
const synchronizeColumns = async () => {
if (selectedIds.length === 0) return;
setBusy(true);
try {
const run = await startCatalogSync(databaseId, databaseVersion, "columns", [table.id]);
onCatalogSyncRunStarted?.(run);
gridRef.current?.api.deselectAll();
setSelectedIds([]);
toast.success("Column synchronization started");
} catch (error) {
toast.error(apiErrorMessage(error));
} finally {
setBusy(false);
}
};
const columns = useMemo<ColDef<CatalogColumn>[]>(() => [
{ field: "ordinalPosition", headerName: "#", width: 64, maxWidth: 64, filter: "agNumberColumnFilter" },
{ field: "sensitive", headerName: "Sensitive", headerTooltip: "Mark this column as sensitive; this does not select it for actions.", minWidth: 110, width: 110, sortable: false, filter: false, resizable: false, cellRenderer: SensitiveCell },
@@ -398,7 +439,7 @@ export function DatabaseColumns({
<p className="font-semibold text-destructive">Catalog columns could not be loaded.</p>
<p className="mt-1 leading-5 text-muted-foreground">{apiErrorMessage(error)}</p>
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refetch()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry columns
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry loading columns
</Button>
</div>
</div>
@@ -456,8 +497,9 @@ export function DatabaseColumns({
<Menu.Portal>
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy} onClick={() => void generateDescriptions()}>Generate {selectedIds.length === 1 ? "description" : "descriptions"}</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy} onClick={() => void consolidateDescriptions()}>Move generated description to Description</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedIds.length || catalogOperationActive || busy} onClick={() => void synchronizeColumns()}>Synchronize columns for this table</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy} onClick={() => void generateDescriptions()}>Generate {selectedIds.length === 1 ? "column description" : "column descriptions"}</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy} onClick={() => void consolidateDescriptions()}>Copy generated descriptions to Description</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
@@ -110,6 +110,7 @@ test("Fleet columns distinguish a failed query from an empty catalog and retry i
presentation="fleet"
databaseId={database.id!}
table={table}
databaseVersion={database.version}
canManage
selectedMetadataModel={null}
descriptionGenerationActive={false}
@@ -124,7 +125,7 @@ test("Fleet columns distinguish a failed query from an empty catalog and retry i
expect(alert).toHaveTextContent("The database catalog is unavailable.");
expect(screen.queryByText(/No columns synchronized/i)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Retry columns" }));
await user.click(screen.getByRole("button", { name: "Retry loading columns" }));
await waitFor(() => expect(requests).toBe(2));
await waitFor(() => expect(screen.queryByText("Catalog columns could not be loaded.")).not.toBeInTheDocument());
});
@@ -281,7 +281,7 @@ export function DatabaseForm({
disabled={readOnly || busy}
onChange={(event) => onTransportChange(event.target.value as DatabaseTransport)}
>
<option value="postgres_direct">Direct PostgreSQL</option>
<option value="postgres_direct">Direct PostgreSQL connection</option>
<option value="rest_api">REST API</option>
<option value="ssh_tunnel">SSH tunnel</option>
</select>
@@ -149,13 +149,13 @@ function accessSummary(row: CatalogDatabase): { label: string; detail: string }
return { label: "SSH", detail: endpoint(row.binding.sshHost, row.binding.sshPort) };
}
return {
label: "Direct PostgreSQL",
label: "Direct PostgreSQL connection",
detail: endpoint(row.binding.host, row.binding.port),
};
}
function coverageStatus(total: number, complete: number): { label: string; detail: string; tone: StateTone } {
if (total === 0) return { label: "Not started", detail: "No catalog items", tone: "danger" };
if (total === 0) return { label: "Not started", detail: "No catalog objects", tone: "danger" };
if (complete >= total) return { label: "Complete", detail: `${complete}/${total}`, tone: "success" };
if (complete > 0) return { label: "Partial", detail: `${complete}/${total}`, tone: "warning" };
return { label: "Not started", detail: `0/${total}`, tone: "danger" };
@@ -172,7 +172,7 @@ function CatalogStatusCells({ row, metrics, sensitiveRun }: { row: CatalogDataba
const descriptions = coverageStatus(metrics?.descriptionTargets ?? 0, metrics?.describedTargets ?? 0);
const sensitiveProcessed = sensitiveRun ? sensitiveRun.suggestedSensitive + sensitiveRun.suggestedNonSensitive : 0;
const sensitive = !sensitiveRun
? { label: "Not started", detail: "No analysis run", tone: "danger" as const }
? { label: "Not started", detail: "No analysis has been run", tone: "danger" as const }
: sensitiveRun.status === "completed" && sensitiveProcessed >= sensitiveRun.total
? { label: "Complete", detail: `${sensitiveProcessed}/${sensitiveRun.total}`, tone: "success" as const }
: ["failed", "interrupted"].includes(sensitiveRun.status)
@@ -608,7 +608,7 @@ export function DatabaseGrid({
() => onGenerateDescriptions(selectedRows, pendingGenerationScope),
)}
>
{pendingGenerationScope === "all" ? "Generate All" : "Generate Missing"}
{pendingGenerationScope === "all" ? "Generate all descriptions" : "Generate missing descriptions"}
</Button>
</div>
) : pendingDelete ? (
@@ -688,14 +688,14 @@ export function DatabaseGrid({
disabled={!canGenerateDescriptions}
onClick={() => setPendingGenerationScope("all")}
>
Generate All
Generate all descriptions
</Menu.Item>
<Menu.Item
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
disabled={!canGenerateDescriptions}
onClick={() => setPendingGenerationScope("missing")}
>
Generate Missing
Generate missing descriptions
</Menu.Item>
<Menu.Separator className="my-1 h-px bg-border" />
<Menu.Item
@@ -389,7 +389,7 @@ export function DatabaseRelationships({
</optgroup>
<optgroup label="Synchronization">
<option value="relationships">Synchronize physical relationships</option>
<option value="all">Synchronize the full schema for this database</option>
<option value="all">Synchronize the full database schema</option>
</optgroup>
</select>
<Button type="button" size="sm" disabled={!selectedAction || Boolean(selectedActionUnavailable) || busy} aria-describedby={selectedActionUnavailable ? "relationship-action-reason" : undefined} onClick={() => void runSelectedAction()}><Play aria-hidden="true" />{busy ? selectedAction === "rebuild-generated" ? "Rebuilding…" : "Running…" : "Run action"}</Button>
@@ -414,7 +414,7 @@ export function DatabaseRelationships({
{addUnavailable ? <span id="relationship-add-reason" className="sr-only">{addUnavailable}</span> : null}
{selectedActionUnavailable ? <span id="relationship-action-reason" className="basis-full text-xs text-muted-foreground" role="status">{selectedActionUnavailable}</span> : null}
</div>
) : <Button type="button" disabled={!canManage || !bindingReady || busy || Boolean(database.activeSyncRun)} title={!bindingReady ? "Test the current database binding before synchronizing relationships" : undefined} onClick={() => void synchronize("relationships")}><RefreshCw />Sync relationships</Button>}
) : <Button type="button" disabled={!canManage || !bindingReady || busy || Boolean(database.activeSyncRun)} title={!bindingReady ? "Test the current database binding before synchronizing relationships" : undefined} onClick={() => void synchronize("relationships")}><RefreshCw />Synchronize relationships</Button>}
</div>
{!bindingReady ? <div className="border-b border-border bg-muted/35 px-4 py-3 text-sm text-muted-foreground">{presentation === "fleet" ? "Test the current database binding in database details before synchronizing relationships." : "Test the current database binding from Overview before synchronizing relationships."}</div> : null}
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
@@ -430,7 +430,7 @@ export function DatabaseRelationships({
title="Add relationship"
description="Connect one source column to one primary-key target column."
onClose={closeAdd}
closeLabel="Close add relationship"
closeLabel="Close relationship form"
busy={busy}
footer={(
<>
@@ -330,13 +330,12 @@ export function DatabaseTables({
| "sync-columns"
| "consolidate-descriptions"
| "suggest-sensitive"
| "clear-columns"
| "clear-relationships";
| "clear-columns";
const fleetActions: readonly FleetActionOption<FleetTableAction>[] = [
{
id: "generate-descriptions",
label: "Generate descriptions",
label: "Generate table description",
group: "Descriptions",
runLabel: "Generate",
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun),
@@ -372,7 +371,7 @@ export function DatabaseTables({
},
{
id: "consolidate-descriptions",
label: "Move generated to Description",
label: "Copy generated descriptions to Description",
group: "Descriptions",
runLabel: "Move",
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
@@ -386,22 +385,24 @@ export function DatabaseTables({
},
{
id: "sync-tables",
label: "Synchronize tables",
label: "Synchronize database tables",
group: "Synchronization",
scopeLabel: "All tables in this database",
scopeLabel: "Selected database",
runLabel: "Synchronize",
disabled: !canManage || !bindingReady || busy !== null || Boolean(currentRun),
disabled: !canManage || selectedIds.length === 0 || !bindingReady || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to synchronize the catalog."
: !bindingReady
? "Test the current database binding first."
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: undefined,
: selectedIds.length === 0
? "Select at least one table."
: !bindingReady
? "Test the current database binding first."
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: undefined,
},
{
id: "sync-columns",
label: "Synchronize columns",
label: "Synchronize columns for selected tables",
group: "Synchronization",
runLabel: "Synchronize",
disabled: !canManage || selectedIds.length === 0 || !bindingReady || busy !== null || Boolean(currentRun),
@@ -435,22 +436,7 @@ export function DatabaseTables({
},
{
id: "clear-columns",
label: "Clear catalog columns",
group: "Catalog cleanup",
tone: "destructive",
runLabel: "Review cleanup",
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to clear catalog metadata."
: selectedIds.length === 0
? "Select at least one table."
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: undefined,
},
{
id: "clear-relationships",
label: "Clear catalog relationships",
label: "Remove synchronized columns",
group: "Catalog cleanup",
tone: "destructive",
runLabel: "Review cleanup",
@@ -473,7 +459,6 @@ export function DatabaseTables({
else if (action === "sync-columns") await synchronize("columns", selectedIds);
else if (action === "suggest-sensitive") await suggestSensitive();
else if (action === "clear-columns") setPendingDelete("columns");
else setPendingDelete("relationships");
};
const clearSelection = () => {
@@ -544,10 +529,12 @@ export function DatabaseTables({
<DatabaseColumns
databaseId={databaseId}
table={activeTable}
databaseVersion={database.version}
canManage={canManage}
selectedMetadataModel={selectedMetadataModel}
descriptionGenerationActive={descriptionGenerationActive}
onDescriptionGenerationRunStarted={onDescriptionGenerationRunStarted}
onCatalogSyncRunStarted={onRunStarted}
onNavigationStateChange={setColumnNavigation}
onSuggestSensitive={onSuggestSensitive}
catalogOperationActive={Boolean(currentRun)}
@@ -594,7 +581,7 @@ export function DatabaseTables({
rowHeight={44}
headerHeight={38}
animateRows={false}
overlayNoRowsTemplate="No catalog tables. Test the binding, then sync tables."
overlayNoRowsTemplate="No catalog tables. Synchronize the schema from the database view."
/>
</div>
</div>}
@@ -655,10 +642,12 @@ export function DatabaseTables({
<DatabaseColumns
databaseId={databaseId}
table={activeTable}
databaseVersion={database.version}
canManage={canManage}
selectedMetadataModel={selectedMetadataModel}
descriptionGenerationActive={descriptionGenerationActive}
onDescriptionGenerationRunStarted={onDescriptionGenerationRunStarted}
onCatalogSyncRunStarted={onRunStarted}
onNavigationStateChange={setColumnNavigation}
onSuggestSensitive={onSuggestSensitive}
onCatalogMetricsChanged={onCatalogMetricsChanged}
@@ -710,19 +699,11 @@ export function DatabaseTables({
<div className="mr-auto min-w-56">
<p id="table-cleanup-confirmation" className="text-sm font-semibold">
{presentation === "fleet"
? `Clear catalog ${pendingDelete} for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`
: pendingDelete === "columns"
? `Delete all catalog columns for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`
: `Delete all relationships for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`}
? `Remove synchronized columns for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`
: `Remove synchronized columns for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`}
</p>
<p className="text-xs text-muted-foreground">
{presentation === "fleet"
? pendingDelete === "columns"
? "Only synchronized catalog columns for these tables will be cleared. The source database is not modified."
: "Only synchronized catalog relationships for these tables will be cleared. The source database is not modified."
: pendingDelete === "columns"
? "Only columns belonging to the selected catalog tables will be removed."
: "All incoming and outgoing relationships for the selected catalog tables will be removed."}
Only synchronized catalog columns for these tables will be removed. The source database is not modified.
</p>
</div>
<Button type="button" variant="ghost" disabled={busy !== null} onClick={closeDeleteConfirmation}>Cancel</Button>
@@ -733,7 +714,7 @@ export function DatabaseTables({
disabled={busy !== null}
onClick={() => void deleteMetadata(pendingDelete)}
>
<Trash2 />{presentation === "fleet" ? "Clear catalog" : pendingDelete === "columns" ? "Delete columns" : "Delete relationships"}
<Trash2 />Remove synchronized columns
</Button>
</div>
) : (
@@ -760,13 +741,13 @@ export function DatabaseTables({
<Menu.Portal>
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateDescriptions()}>Generate {selectedIds.length === 1 ? "description" : "descriptions"}</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateDescriptions()}>Generate table description</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateColumnDescriptions()}>Generate descriptions for all columns</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("columns", selectedIds)}>Synchronize columns</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void consolidateDescriptions()}>Move generated description to Description</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("tables")}>Synchronize database tables</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("columns", selectedIds)}>Synchronize columns for selected tables</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void consolidateDescriptions()}>Copy generated descriptions to Description</Menu.Item>
<Menu.Separator className="my-1 h-px bg-border" />
<Menu.Item className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => setPendingDelete("columns")}>Delete all columns</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => setPendingDelete("relationships")}>Delete all relationships</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => setPendingDelete("columns")}>Remove synchronized columns</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
@@ -799,12 +780,11 @@ export function DatabaseTables({
<input ref={searchInputRef} className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search tables" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span aria-live="polite" className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">{search.trim() ? `${displayedCount} of ${data.length}` : displayedCount}</span>
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
<Button type="button" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} title={!bindingReady ? "Test the current database binding before synchronizing tables" : undefined} onClick={() => void synchronize("tables")}><RefreshCw className={busy === "sync" ? "animate-spin" : ""} />Sync tables</Button>
</>
)
)}
</div>
{!bindingReady ? <div className="border-b border-border bg-muted/35 px-4 py-3 text-sm text-muted-foreground">Test the current database binding from Overview before synchronizing tables.</div> : null}
{!bindingReady ? <div className="border-b border-border bg-muted/35 px-4 py-3 text-sm text-muted-foreground">Test the current database binding from Overview before synchronizing columns.</div> : null}
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
<div className={`thot-database-grid ${presentation === "fleet" ? "thot-fleet-ledger-grid " : ""}ag-theme-alpine absolute inset-0 h-full w-full`}>
<AgGridReact<CatalogTable>
@@ -825,7 +805,7 @@ export function DatabaseTables({
rowHeight={44}
headerHeight={38}
animateRows={false}
overlayNoRowsTemplate="No catalog tables. Test the binding, then sync tables."
overlayNoRowsTemplate="No catalog tables. Synchronize the schema from the database view."
/>
</div>
</div>}
@@ -8,7 +8,7 @@ const actions = [
{ id: "sync", label: "Synchronize tables", group: "Synchronization", scopeLabel: "All tables in this database" },
{
id: "generate",
label: "Generate descriptions",
label: "Generate column descriptions",
group: "Descriptions",
disabled: true,
disabledReason: "Choose a metadata model first",
@@ -27,4 +27,10 @@ test("uses operational body text for every available-command list", () => {
expect(styles).toMatch(
/\.thot-fleet-action-selector__select\s*\{[^}]*font-size:\s*0\.9375rem;/s,
);
expect(styles).toMatch(
/\.thot-fleet-action-selector\s*\{[^}]*flex:\s*0 0 auto;/s,
);
expect(styles).toMatch(
/\.thot-fleet-action-selector__select\s*\{[^}]*width:\s*max-content;/s,
);
});
@@ -381,7 +381,8 @@
.thot-fleet-action-selector {
display: flex;
min-width: 0;
flex: 1;
max-width: 100%;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
@@ -402,9 +403,10 @@
}
.thot-fleet-action-selector__select {
flex: 1 1 13rem;
width: min(100%, 22rem);
min-width: 13rem;
flex: 0 0 auto;
width: max-content;
min-width: 0;
max-width: 100%;
height: 2rem;
padding: 0 2rem 0 0.625rem;
border: 1px solid oklch(var(--input));
@@ -613,8 +615,8 @@
@media (min-width: 48rem) {
.thot-fleet-grid-toolbar > .thot-fleet-action-selector {
flex: 1 1 33rem;
min-width: 33rem;
flex: 0 0 auto;
min-width: 0;
}
}
@@ -963,9 +965,6 @@
width: 100%;
}
.thot-fleet-action-selector__select {
width: 100%;
}
}
@media (max-height: 44rem) and (min-width: 48rem) {
@@ -12,8 +12,13 @@ describe("history action placement", () => {
expect(fleetHeader).not.toContain("Sensitive history");
});
test("database views expose metadata histories beside database commands", () => {
for (const file of ["DatabaseForm.tsx", "DatabaseTables.tsx", "DatabaseRelationships.tsx"]) {
test("database tables do not own metadata history actions", () => {
for (const file of ["DatabaseTables.tsx"]) {
const source = componentSource(file);
expect(source, file).not.toContain("Description history");
expect(source, file).not.toContain("Sensitive history");
}
for (const file of ["DatabaseForm.tsx", "DatabaseRelationships.tsx"]) {
const source = componentSource(file);
expect(source, file).toContain("Description history");
expect(source, file).toContain("Sensitive history");