Files
ThothII/frontend/src/shell/database-management/DatabaseColumns.tsx
T

672 lines
32 KiB
TypeScript

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 { ChevronDown, KeyRound, Link2, Pencil, RefreshCw, Save, Sparkles, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { ApiError, apiErrorMessage } from "../../api/client";
import {
consolidateCatalogDescriptions,
listCatalogColumns,
startCatalogSync,
startDescriptionGenerationRun,
updateCatalogColumnMetadata,
updateCatalogColumnSensitive,
type CatalogColumn,
type CatalogSyncRun,
type CatalogTable,
type DescriptionGenerationRun,
type SensitivityAnalysisRequest,
} from "../../api/catalog-databases";
import type { DatabaseNavigationState } from "./model";
import { FleetActionSelector, type FleetActionOption } from "./FleetActionSelector";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
import { NO_METADATA_GENERATION_LLM_MODEL_MESSAGE } from "./MetadataGenerationModelSelector";
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: SensitivityAnalysisRequest, scopeLabel: string) => Promise<void>;
catalogOperationActive?: boolean;
onCatalogMetricsChanged?: () => void | Promise<void>;
presentation?: "legacy" | "fleet";
}
interface GridContext {
canManage: boolean;
busy: boolean;
presentation: "legacy" | "fleet";
onEdit: (column: CatalogColumn, origin: HTMLButtonElement) => void;
onSensitiveChange: (column: CatalogColumn, sensitive: boolean) => void;
}
function KeyCell({ data }: ICellRendererParams<CatalogColumn>) {
if (!data) return null;
return (
<div className="flex h-full items-center gap-1.5">
{data.isPrimaryKey ? <span className="inline-flex items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 text-xs font-semibold text-primary"><KeyRound className="size-3" />PK{data.primaryKeyPosition && data.primaryKeyPosition > 1 ? ` ${data.primaryKeyPosition}` : ""}</span> : null}
{data.isForeignKey ? <span className="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-xs font-semibold text-muted-foreground"><Link2 className="size-3" />FK{data.foreignKeyCount > 1 ? ` ${data.foreignKeyCount}` : ""}</span> : null}
</div>
);
}
function ActionCell({ data, context }: ICellRendererParams<CatalogColumn, unknown, GridContext>) {
if (!data || !context) return null;
return (
<div className="flex h-full items-center justify-end" onClick={(event) => event.stopPropagation()}>
<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>
);
}
function SensitiveCell({ data, context }: ICellRendererParams<CatalogColumn, unknown, GridContext>) {
if (!data || !context) return null;
return (
<div className="flex h-full items-center justify-center" onClick={(event) => event.stopPropagation()}>
<input
type="checkbox"
className="size-4 accent-primary outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
checked={data.sensitive}
disabled={!context.canManage || context.busy}
aria-label={`Sensitive data for ${data.name}`}
title="Mark this column as sensitive; this does not select it for actions."
onChange={(event) => context.onSensitiveChange(data, event.target.checked)}
onClick={(event) => event.stopPropagation()}
/>
</div>
);
}
function ColumnMetadataFieldMatrix({
column,
description,
generatedDescription,
canManage,
busy,
onDescriptionChange,
onGeneratedDescriptionChange,
}: {
column: CatalogColumn;
description: string;
generatedDescription: string;
canManage: boolean;
busy: boolean;
onDescriptionChange: (value: string) => void;
onGeneratedDescriptionChange: (value: string) => void;
}) {
const fieldClass = [
"thot-column-metadata-field flex min-h-0 flex-col gap-1.5",
"text-sm font-semibold",
].join(" ");
const editableClass = [
"min-h-28 flex-1 resize-none rounded-md border border-input bg-card px-3 py-2",
"text-sm font-normal outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15",
].join(" ");
const readOnlyClass = [
"min-h-28 flex-1 resize-none rounded-md border border-input bg-muted/35 px-3 py-2",
"text-sm font-normal",
].join(" ");
return (
<div
role="group"
aria-label="Column metadata fields"
className="thot-column-metadata-matrix mt-5 grid min-h-0 gap-4 sm:grid-cols-2"
>
<label className={fieldClass}>
Source comment
<textarea className={readOnlyClass} value={column.sourceComment ?? ""} readOnly />
</label>
<label className={fieldClass}>
Description
<textarea
className={editableClass}
value={description}
disabled={!canManage || busy}
onChange={(event) => onDescriptionChange(event.target.value)}
/>
</label>
<label className={fieldClass}>
Generated description
<textarea
className={editableClass}
value={generatedDescription}
disabled={!canManage || busy}
onChange={(event) => onGeneratedDescriptionChange(event.target.value)}
/>
</label>
<label className={fieldClass}>
Sensitive motivation
<textarea
className={readOnlyClass}
value={column.sensitivityReason ?? ""}
placeholder="No sensitivity motivation recorded."
readOnly
/>
</label>
</div>
);
}
export function DatabaseColumns({
databaseId,
table,
databaseVersion,
canManage,
selectedMetadataModel,
descriptionGenerationActive,
onDescriptionGenerationRunStarted,
onCatalogSyncRunStarted,
onNavigationStateChange,
onSuggestSensitive,
catalogOperationActive = false,
onCatalogMetricsChanged,
presentation = "legacy",
}: Props) {
const queryClient = useQueryClient();
const queryKey = ["catalog-columns", databaseId, table.id] as const;
const { data = [], error, isError, isLoading, isFetching, refetch } = useQuery({
queryKey,
queryFn: () => listCatalogColumns(databaseId, table.id),
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("");
const [sensitiveDrafts, setSensitiveDrafts] = useState<Record<string, boolean>>({});
const [sensitiveAction, setSensitiveAction] = useState<"suggest" | "save" | null>(null);
const [baseline, setBaseline] = useState("");
const [version, setVersion] = useState<number | null>(null);
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]);
const editorDirty = Boolean(editingId && fingerprint !== baseline);
const changedSensitiveColumns = data.filter((column) => (
Object.hasOwn(sensitiveDrafts, column.id)
&& sensitiveDrafts[column.id] !== column.sensitive
));
const dirty = editorDirty || changedSensitiveColumns.length > 0;
const displayedColumns = useMemo(() => data.map((column) => (
Object.hasOwn(sensitiveDrafts, column.id)
? { ...column, sensitive: sensitiveDrafts[column.id]! }
: column
)), [data, sensitiveDrafts]);
useEffect(() => { onNavigationStateChange({ dirty, busy }); }, [busy, dirty, onNavigationStateChange]);
useEffect(() => {
if (!editingId || !active || version === active.version || busy) return;
setStale(true);
setStaleBannerOpen(true);
}, [active, busy, editingId, version]);
const edit = (column: CatalogColumn, origin: HTMLButtonElement) => {
originRef.current = origin;
setEditingId(column.id);
setDescription(column.description ?? "");
setGeneratedDescription(column.generatedDescription ?? "");
setBaseline(JSON.stringify([column.description ?? "", column.generatedDescription ?? ""]));
setVersion(column.version);
setStale(false);
setStaleBannerOpen(true);
};
const closeEditor = () => {
if (busy) return;
if (editorDirty && !window.confirm("Discard unsaved column metadata?")) return;
setEditingId(null);
window.setTimeout(() => originRef.current?.focus(), 0);
};
const save = async () => {
if (!active || version === null) return;
setBusy(true);
try {
const updated = await updateCatalogColumnMetadata(
databaseId, table.id, active.id, version,
description.trim() || null, generatedDescription.trim() || null,
);
queryClient.setQueryData<CatalogColumn[]>(queryKey, (current = []) => current.map((column) => column.id === updated.id ? updated : column));
setDescription(updated.description ?? "");
setGeneratedDescription(updated.generatedDescription ?? "");
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") {
await refetch();
setStale(true);
setStaleBannerOpen(true);
} else toast.error(apiErrorMessage(error));
} finally { setBusy(false); }
};
const reloadColumn = async () => {
if (!editingId) return;
setBusy(true);
try {
const result = await refetch();
const latest = result.data?.find((column) => column.id === editingId);
if (!latest) { closeEditor(); return; }
setDescription(latest.description ?? "");
setGeneratedDescription(latest.generatedDescription ?? "");
setBaseline(JSON.stringify([latest.description ?? "", latest.generatedDescription ?? ""]));
setVersion(latest.version);
setStale(false);
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([]);
void onCatalogMetricsChanged?.();
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 changeSensitive = (column: CatalogColumn, sensitive: boolean) => {
const persisted = data.find((candidate) => candidate.id === column.id);
if (!persisted) return;
setSensitiveDrafts((current) => {
const next = { ...current };
if (sensitive === persisted.sensitive) delete next[column.id];
else next[column.id] = sensitive;
return next;
});
};
const suggestSensitive = async () => {
if (selectedIds.length === 0) return;
setBusy(true);
setSensitiveAction("suggest");
try {
await onSuggestSensitive(
{ scope: "selected_columns", targetIds: selectedIds },
`${selectedIds.length} selected column${selectedIds.length === 1 ? "" : "s"} in ${table.name}`,
);
} catch {
// The page-level request reports the safe, specific reason and preserves the selection.
} finally {
setSensitiveAction(null);
setBusy(false);
}
};
const saveSensitive = async () => {
if (changedSensitiveColumns.length === 0) return;
setBusy(true);
setSensitiveAction("save");
try {
const saved: CatalogColumn[] = [];
const failed: unknown[] = [];
for (const column of changedSensitiveColumns) {
try {
saved.push(await updateCatalogColumnSensitive(
databaseId,
table.id,
column.id,
column.version,
sensitiveDrafts[column.id]!,
));
} catch (error) {
failed.push(error);
}
}
if (saved.length > 0) {
const savedById = new Map(saved.map((column) => [column.id, column]));
queryClient.setQueryData<CatalogColumn[]>(queryKey, (current = []) => current.map(
(column) => savedById.get(column.id) ?? column,
));
setSensitiveDrafts((current) => {
const next = { ...current };
for (const column of saved) delete next[column.id];
return next;
});
void onCatalogMetricsChanged?.();
}
if (failed.length > 0) {
toast.error(`${failed.length} sensitive flag${failed.length === 1 ? "" : "s"} could not be saved: ${apiErrorMessage(failed[0])}`);
} else {
toast.success("Sensitive fields saved");
}
} finally {
setSensitiveAction(null);
setBusy(false);
}
};
type FleetColumnAction =
| "sync-columns"
| "generate-descriptions"
| "consolidate-descriptions"
| "suggest-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 column 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
? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE
: descriptionGenerationActive || catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
? "Another action is running."
: undefined,
},
{
id: "consolidate-descriptions",
label: "Copy generated descriptions 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: "Analyze sensitive fields",
group: "Sensitive data",
runLabel: "Analyze",
disabled: !canManage || selectedIds.length === 0 || descriptionGenerationActive || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to review sensitive data."
: selectedIds.length === 0
? "Select at least one column."
: descriptionGenerationActive || catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
? "Another action is running."
: undefined,
},
];
const runFleetAction = async (action: FleetColumnAction) => {
if (action === "sync-columns") await synchronizeColumns();
else if (action === "generate-descriptions") await generateDescriptions();
else if (action === "consolidate-descriptions") await consolidateDescriptions();
else await suggestSensitive();
};
const clearSelection = () => {
gridRef.current?.api.deselectAll();
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 },
{ field: "name", headerName: "Name", minWidth: 190, flex: 1, cellClass: "font-mono text-xs" },
{ field: "dataType", headerName: "Type", minWidth: 150, flex: 0.8, cellClass: "font-mono text-xs" },
{ headerName: "Keys", minWidth: 125, width: 125, sortable: false, filter: false, cellRenderer: KeyCell },
{ headerName: "Nullable", minWidth: 100, width: 100, valueGetter: ({ data: row }) => row ? (row.isNullable ? "Yes" : "No") : "" },
{ 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: "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 || catalogOperationActive,
presentation,
onEdit: edit,
onSensitiveChange: changeSensitive,
}), [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 loading columns
</Button>
</div>
</div>
) : null;
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>
<p className="thot-label">{table.name} · column</p>
<h3 className="mt-1 font-heading text-2xl font-semibold">{active.name}</h3>
<p className="mt-1 text-sm text-muted-foreground">Physical schema fields are read-only. Review fields can be edited.</p>
{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}
<ColumnMetadataFieldMatrix
column={active}
description={description}
generatedDescription={generatedDescription}
canManage={canManage}
busy={busy}
onDescriptionChange={setDescription}
onGeneratedDescriptionChange={setGeneratedDescription}
/>
<div className="mt-5 flex justify-end gap-2 border-t border-border pt-4">
<Button type="button" variant="outline" disabled={busy} onClick={closeEditor}>Cancel</Button>
<Button type="button" disabled={!canManage || !editorDirty || busy || stale} onClick={() => void save()}><Save />{busy ? "Saving…" : "Save metadata"}</Button>
</div>
</div>
);
}
return (
<div className="flex min-h-0 flex-1 flex-col">
<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 ? (
presentation === "fleet" ? (
<>
<FleetActionSelector
actions={fleetActions}
selectedCount={selectedIds.length}
busy={busy}
busyLabel={sensitiveAction === "suggest" ? "Suggesting…" : sensitiveAction === "save" ? "Saving…" : "Running…"}
onRun={runFleetAction}
onClear={clearSelection}
label="Column action"
/>
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || catalogOperationActive || busy} onClick={() => void saveSensitive()}><Save aria-hidden="true" />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
</>
) : (
<>
<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 || !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>
</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" ? "Analyzing…" : "Analyze 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>
</>
)
) : (
presentation === "fleet" ? (
<>
<FleetActionSelector
actions={fleetActions}
selectedCount={0}
busy={busy}
busyLabel={sensitiveAction === "save" ? "Saving…" : "Running…"}
onRun={runFleetAction}
onClear={clearSelection}
label="Column action"
/>
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || catalogOperationActive || busy} onClick={() => void saveSensitive()}><Save aria-hidden="true" />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
<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>
</>
)
)}
</div>
{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}
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 }}
selectionColumnDef={{
headerName: "Select",
headerTooltip: "Select columns for actions",
width: 72,
minWidth: 72,
maxWidth: 72,
pinned: "left",
sortable: false,
resizable: false,
suppressMovable: true,
}}
onSelectionChanged={({ api }) => setSelectedIds(api.getSelectedRows().map((column) => column.id))}
rowHeight={44}
headerHeight={38}
animateRows={false}
overlayNoRowsTemplate="No columns synchronized for this table."
/>
</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}
bodyClassName="thot-column-metadata-drawer-body"
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}
<ColumnMetadataFieldMatrix
column={active}
description={description}
generatedDescription={generatedDescription}
canManage={canManage}
busy={busy}
onDescriptionChange={setDescription}
onGeneratedDescriptionChange={setGeneratedDescription}
/>
</FleetLedgerDrawer>
) : null}
</div>
);
}