feat: complete catalog fleet management workflow

This commit is contained in:
Codex
2026-08-31 15:58:43 +02:00
parent 866aee4249
commit 9c697dc062
56 changed files with 6692 additions and 611 deletions
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Menu } from "@base-ui/react/menu";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AgGridReact } from "ag-grid-react";
@@ -18,6 +18,8 @@ import {
type SensitiveDataSuggestionRequest,
} from "../../api/catalog-databases";
import type { DatabaseNavigationState } from "./model";
import { FleetActionSelector, type FleetActionOption } from "./FleetActionSelector";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
interface Props {
databaseId: string;
@@ -29,11 +31,17 @@ interface Props {
onNavigationStateChange: (state: DatabaseNavigationState) => void;
onSync: () => void;
onSuggestSensitive: (selection: SensitiveDataSuggestionRequest, scopeLabel: string) => Promise<void>;
bindingReady?: boolean;
catalogOperationActive?: boolean;
onCatalogMetricsChanged?: () => void | Promise<void>;
metadataModelControl?: ReactNode;
presentation?: "legacy" | "fleet";
}
interface GridContext {
canManage: boolean;
busy: boolean;
presentation: "legacy" | "fleet";
onEdit: (column: CatalogColumn, origin: HTMLButtonElement) => void;
onSensitiveChange: (column: CatalogColumn, sensitive: boolean) => void;
}
@@ -52,9 +60,11 @@ function ActionCell({ data, context }: ICellRendererParams<CatalogColumn, unknow
if (!data || !context) return null;
return (
<div className="flex h-full items-center justify-end" onClick={(event) => event.stopPropagation()}>
<Button type="button" variant="ghost" size="icon-lg" disabled={!context.canManage} aria-label={`Edit metadata for ${data.name}`} onClick={(event) => context.onEdit(data, event.currentTarget)}>
<Pencil aria-hidden="true" />
</Button>
<span className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? "Edit metadata" : undefined}>
<Button type="button" variant="ghost" size="icon-lg" disabled={!context.canManage} aria-label={`Edit metadata for ${data.name}`} title={context.presentation === "legacy" ? `Edit metadata for ${data.name}` : undefined} onClick={(event) => context.onEdit(data, event.currentTarget)}>
<Pencil aria-hidden="true" />
</Button>
</span>
</div>
);
}
@@ -86,10 +96,15 @@ export function DatabaseColumns({
onNavigationStateChange,
onSync,
onSuggestSensitive,
bindingReady = true,
catalogOperationActive = false,
onCatalogMetricsChanged,
metadataModelControl,
presentation = "legacy",
}: Props) {
const queryClient = useQueryClient();
const queryKey = ["catalog-columns", databaseId, table.id] as const;
const { data = [], isLoading, isFetching, refetch } = useQuery({
const { data = [], error, isError, isLoading, isFetching, refetch } = useQuery({
queryKey,
queryFn: () => listCatalogColumns(databaseId, table.id),
retry: false,
@@ -159,6 +174,7 @@ export function DatabaseColumns({
setBaseline(JSON.stringify([updated.description ?? "", updated.generatedDescription ?? ""]));
setVersion(updated.version);
setStale(false);
void onCatalogMetricsChanged?.();
toast.success("Column metadata saved");
} catch (error) {
if (error instanceof ApiError && error.code === "column_stale") {
@@ -190,6 +206,7 @@ export function DatabaseColumns({
await queryClient.invalidateQueries({ queryKey, exact: true });
gridRef.current?.api.deselectAll();
setSelectedIds([]);
void onCatalogMetricsChanged?.();
toast.success(`Copied ${counts.copied} description${counts.copied === 1 ? "" : "s"}; skipped ${counts.skipped}`);
} catch (error) {
toast.error(apiErrorMessage(error));
@@ -261,6 +278,7 @@ export function DatabaseColumns({
(column) => updatedById.get(column.id) ?? column,
));
setSensitiveDrafts({});
void onCatalogMetricsChanged?.();
toast.success("Sensitive fields saved");
} catch (error) {
toast.error(apiErrorMessage(error));
@@ -270,6 +288,114 @@ export function DatabaseColumns({
}
};
type FleetColumnAction =
| "generate-descriptions"
| "consolidate-descriptions"
| "suggest-sensitive"
| "sync-columns"
| "save-sensitive";
const fleetActions: readonly FleetActionOption<FleetColumnAction>[] = [
{
id: "generate-descriptions",
label: "Generate descriptions",
group: "Descriptions",
runLabel: "Generate",
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to generate descriptions."
: selectedIds.length === 0
? "Select at least one column."
: !selectedMetadataModel
? "Choose a metadata model first."
: descriptionGenerationActive || catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
? "Another action is running."
: undefined,
},
{
id: "consolidate-descriptions",
label: "Move generated to Description",
group: "Descriptions",
runLabel: "Move",
disabled: !canManage || selectedIds.length === 0 || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to update metadata."
: selectedIds.length === 0
? "Select at least one column."
: catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
? "Another action is running."
: undefined,
},
{
id: "suggest-sensitive",
label: "Suggest sensitive fields",
group: "Sensitive data",
runLabel: "Suggest",
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to review sensitive data."
: selectedIds.length === 0
? "Select at least one column."
: !selectedMetadataModel
? "Choose a metadata model first."
: descriptionGenerationActive || catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
? "Another action is running."
: undefined,
},
{
id: "sync-columns",
label: "Synchronize columns",
group: "Synchronization",
scopeLabel: "All columns in this table",
runLabel: "Synchronize",
disabled: !canManage || !bindingReady || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to synchronize the catalog."
: !bindingReady
? "Test the current database binding first."
: catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
? "Another action is running."
: undefined,
},
{
id: "save-sensitive",
label: "Save sensitive-field changes",
group: "Sensitive data",
runLabel: "Save",
disabled: !canManage || changedSensitiveColumns.length === 0 || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to update sensitive fields."
: changedSensitiveColumns.length === 0
? "No sensitive-field changes are waiting to be saved."
: catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
? "Another action is running."
: undefined,
},
];
const runFleetAction = async (action: FleetColumnAction) => {
if (action === "generate-descriptions") await generateDescriptions();
else if (action === "consolidate-descriptions") await consolidateDescriptions();
else if (action === "suggest-sensitive") await suggestSensitive();
else if (action === "sync-columns") onSync();
else await saveSensitive();
};
const clearSelection = () => {
gridRef.current?.api.deselectAll();
setSelectedIds([]);
};
const columns = useMemo<ColDef<CatalogColumn>[]>(() => [
{ field: "ordinalPosition", headerName: "#", width: 64, maxWidth: 64, filter: "agNumberColumnFilter" },
{ field: "sensitive", headerName: "Sensitive", minWidth: 110, width: 110, sortable: false, filter: false, resizable: false, cellRenderer: SensitiveCell },
@@ -280,16 +406,28 @@ export function DatabaseColumns({
{ field: "sourceComment", headerName: "Source comment", minWidth: 220, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
{ field: "generatedDescription", headerName: "Generated description", minWidth: 230, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
{ field: "description", headerName: "Description", minWidth: 230, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
{ colId: "actions", headerName: "", width: 64, maxWidth: 64, pinned: "right", sortable: false, filter: false, resizable: false, cellRenderer: ActionCell },
{ colId: "actions", headerName: "Actions", width: 64, maxWidth: 64, pinned: "right", lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: ActionCell },
], []);
const context = useMemo<GridContext>(() => ({
canManage,
busy,
busy: busy || catalogOperationActive,
presentation,
onEdit: edit,
onSensitiveChange: changeSensitive,
}), [busy, canManage, data]);
}), [busy, canManage, catalogOperationActive, data, presentation]);
const fleetQueryError = presentation === "fleet" && isError ? (
<div className="grid min-h-[280px] flex-1 place-items-center p-6" role="alert">
<div className="max-w-lg rounded-lg border border-destructive/30 bg-destructive/8 p-5 text-sm">
<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
</Button>
</div>
</div>
) : null;
if (editingId && active) {
if (editingId && active && presentation === "legacy") {
return (
<div className="mx-auto w-full max-w-4xl px-4 py-6 sm:px-5">
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={busy} onClick={closeEditor}>← Back to columns</Button>
@@ -319,66 +457,71 @@ export function DatabaseColumns({
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">
<div className={`${presentation === "fleet" ? "thot-fleet-grid-toolbar " : ""}flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2`}>
{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="outline"
disabled={!canManage || descriptionGenerationActive || busy}
title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined}
onClick={() => void suggestSensitive()}
>
<Sparkles />{sensitiveAction === "suggest" ? "Suggesting…" : "Suggest sensitive fields"}
</Button>
{changedSensitiveColumns.length > 0 ? (
<Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}>
<Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}
</Button>
) : null}
<Button type="button" variant="ghost" onClick={() => { gridRef.current?.api.deselectAll(); setSelectedIds([]); }}><X />Clear</Button>
</>
presentation === "fleet" ? (
<>
<FleetActionSelector
actions={fleetActions}
selectedCount={selectedIds.length}
busy={busy}
busyLabel={sensitiveAction === "suggest" ? "Suggesting…" : sensitiveAction === "save" ? "Saving…" : "Running…"}
onRun={runFleetAction}
onClear={clearSelection}
renderContext={(action) => action === "generate-descriptions" || action === "suggest-sensitive" ? metadataModelControl : null}
label="Column action"
/>
</>
) : (
<>
<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="outline" disabled={!canManage || descriptionGenerationActive || busy} title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined} onClick={() => void suggestSensitive()}><Sparkles />{sensitiveAction === "suggest" ? "Suggesting…" : "Suggest sensitive fields"}</Button>
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}><Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
<Button type="button" variant="ghost" onClick={clearSelection}><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>
{changedSensitiveColumns.length > 0 ? (
<Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}>
<Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}
</Button>
) : null}
<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>
</>
presentation === "fleet" ? (
<>
<FleetActionSelector
actions={fleetActions}
selectedCount={0}
busy={busy}
busyLabel={sensitiveAction === "save" ? "Saving…" : "Running…"}
onRun={runFleetAction}
onClear={clearSelection}
renderContext={(action) => action === "generate-descriptions" || action === "suggest-sensitive" ? metadataModelControl : null}
label="Column action"
/>
<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>
</>
) : (
<>
<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>
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}><Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
<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">
{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<CatalogColumn>
ref={gridRef}
rowData={displayedColumns}
@@ -396,7 +539,40 @@ export function DatabaseColumns({
overlayNoRowsTemplate="No columns synchronized for this table."
/>
</div>
</div>
</div>}
{presentation === "fleet" && active ? (
<FleetLedgerDrawer
open
eyebrow="Catalog metadata"
title="Review column description"
description="Physical schema fields are read-only. Review fields can be edited."
onClose={closeEditor}
closeLabel="Close column metadata"
busy={busy}
footer={(
<>
<Button type="button" variant="outline" disabled={busy} onClick={closeEditor}>Cancel</Button>
<Button type="button" disabled={!canManage || !editorDirty || busy || stale} onClick={() => void save()}><Save aria-hidden="true" />{busy ? "Saving…" : "Save metadata"}</Button>
</>
)}
>
<p className="thot-label">{table.name} · column</p>
<h3 className="mt-1 break-words font-heading text-xl font-semibold">{active.name}</h3>
{stale ? (
staleBannerOpen ? <div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm"><p className="font-semibold">Newer column metadata is available.</p><p className="mt-1 text-muted-foreground">Your draft is preserved until you reload.</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>Keep editing</Button></div></div>
: <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>Reload the latest value before this metadata can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button></div>
) : null}
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<label className="grid gap-1.5 text-sm font-semibold">Physical column name<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={active.name} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">Native type<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={active.dataType} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">Ordinal position<input className="h-9 rounded-md border border-input bg-muted/35 px-3" value={active.ordinalPosition} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">Default expression<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={active.defaultExpression ?? ""} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold sm:col-span-2">Source comment<textarea className="min-h-24 rounded-md border border-input bg-muted/35 px-3 py-2 text-sm" value={active.sourceComment ?? ""} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold sm:col-span-2">Generated description<textarea className="min-h-28 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={generatedDescription} disabled={!canManage || busy} onChange={(event) => setGeneratedDescription(event.target.value)} /></label>
<label className="grid gap-1.5 text-sm font-semibold sm:col-span-2">Description<textarea className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={description} disabled={!canManage || busy} onChange={(event) => setDescription(event.target.value)} /></label>
</div>
</FleetLedgerDrawer>
) : null}
</div>
);
}