feat: complete catalog sensitivity enhancements

This commit is contained in:
Codex
2026-09-04 15:11:18 +02:00
parent b891246664
commit 7b1d69a65b
72 changed files with 33866 additions and 358 deletions
@@ -13,6 +13,7 @@ import {
startCatalogSync,
startDescriptionGenerationRun,
updateCatalogColumnMetadata,
updateCatalogColumnSensitive,
type CatalogColumn,
type CatalogSyncRun,
type CatalogTable,
@@ -89,6 +90,76 @@ function SensitiveCell({ data, context }: ICellRendererParams<CatalogColumn, unk
);
}
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,
@@ -264,26 +335,38 @@ export function DatabaseColumns({
setBusy(true);
setSensitiveAction("save");
try {
const updated = await Promise.all(changedSensitiveColumns.map((column) => (
updateCatalogColumnMetadata(
databaseId,
table.id,
column.id,
column.version,
column.description,
column.generatedDescription,
sensitiveDrafts[column.id],
)
)));
const updatedById = new Map(updated.map((column) => [column.id, column]));
queryClient.setQueryData<CatalogColumn[]>(queryKey, (current = []) => current.map(
(column) => updatedById.get(column.id) ?? column,
));
setSensitiveDrafts({});
void onCatalogMetricsChanged?.();
toast.success("Sensitive fields saved");
} catch (error) {
toast.error(apiErrorMessage(error));
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);
@@ -294,8 +377,7 @@ export function DatabaseColumns({
| "sync-columns"
| "generate-descriptions"
| "consolidate-descriptions"
| "suggest-sensitive"
| "save-sensitive";
| "suggest-sensitive";
const fleetActions: readonly FleetActionOption<FleetColumnAction>[] = [
{
@@ -365,30 +447,13 @@ export function DatabaseColumns({
? "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 to save."
: 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 if (action === "suggest-sensitive") await suggestSensitive();
else await saveSensitive();
else await suggestSensitive();
};
const clearSelection = () => {
@@ -454,15 +519,15 @@ export function DatabaseColumns({
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 border-t border-border pt-5 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>
<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>
@@ -486,6 +551,7 @@ export function DatabaseColumns({
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}
</>
) : (
<>
@@ -519,6 +585,7 @@ export function DatabaseColumns({
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>
@@ -574,6 +641,7 @@ export function DatabaseColumns({
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>
@@ -587,15 +655,15 @@ export function DatabaseColumns({
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>
<ColumnMetadataFieldMatrix
column={active}
description={description}
generatedDescription={generatedDescription}
canManage={canManage}
busy={busy}
onDescriptionChange={setDescription}
onGeneratedDescriptionChange={setGeneratedDescription}
/>
</FleetLedgerDrawer>
) : null}
</div>