feat: classify sensitive columns locally

This commit is contained in:
Codex
2026-09-03 02:11:13 +02:00
parent 7b87e95427
commit f114d0065a
57 changed files with 4038 additions and 1149 deletions
@@ -17,7 +17,7 @@ import {
type CatalogSyncRun,
type CatalogTable,
type DescriptionGenerationRun,
type SensitiveDataSuggestionRequest,
type SensitivityAnalysisRequest,
} from "../../api/catalog-databases";
import type { DatabaseNavigationState } from "./model";
import { FleetActionSelector, type FleetActionOption } from "./FleetActionSelector";
@@ -34,7 +34,7 @@ interface Props {
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
onCatalogSyncRunStarted?: (run: CatalogSyncRun) => void;
onNavigationStateChange: (state: DatabaseNavigationState) => void;
onSuggestSensitive: (selection: SensitiveDataSuggestionRequest, scopeLabel: string) => Promise<void>;
onSuggestSensitive: (selection: SensitivityAnalysisRequest, scopeLabel: string) => Promise<void>;
catalogOperationActive?: boolean;
onCatalogMetricsChanged?: () => void | Promise<void>;
presentation?: "legacy" | "fleet";
@@ -351,17 +351,15 @@ export function DatabaseColumns({
},
{
id: "suggest-sensitive",
label: "Suggest sensitive fields",
label: "Analyze sensitive fields",
group: "Sensitive data",
runLabel: "Suggest",
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || catalogOperationActive || busy,
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."
: !selectedMetadataModel
? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE
: descriptionGenerationActive || catalogOperationActive
: descriptionGenerationActive || catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
? "Another action is running."
@@ -504,7 +502,7 @@ export function DatabaseColumns({
</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>
<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>
</>
@@ -16,7 +16,7 @@ import { getCatalogMetrics } from "../../api/catalog-databases";
import type {
CatalogDatabase,
CatalogMetrics,
SensitiveDataSuggestionRun,
SensitivityAnalysisRun,
CatalogDatabaseMetadataDeleteTarget,
DescriptionGenerationScope,
} from "../../api/catalog-databases";
@@ -45,7 +45,7 @@ interface DatabaseGridProps {
onSyncSelected: (rows: CatalogDatabase[], scope: DatabaseSyncScope) => Promise<void>;
selectedMetadataModel: string | null;
descriptionGenerationActive: boolean;
sensitiveDataSuggestionRuns?: SensitiveDataSuggestionRun[];
sensitivityAnalysisRuns?: SensitivityAnalysisRun[];
onGenerateDescriptions: (
rows: CatalogDatabase[],
scope: Extract<DescriptionGenerationScope, "all" | "missing">,
@@ -161,7 +161,7 @@ function coverageStatus(total: number, complete: number): { label: string; detai
return { label: "Not started", detail: `0/${total}`, tone: "danger" };
}
function CatalogStatusCells({ row, metrics, sensitiveRun }: { row: CatalogDatabase; metrics?: CatalogMetrics; sensitiveRun?: SensitiveDataSuggestionRun }) {
function CatalogStatusCells({ row, metrics, sensitiveRun }: { row: CatalogDatabase; metrics?: CatalogMetrics; sensitiveRun?: SensitivityAnalysisRun }) {
const access = accessSummary(row);
const synchronized = row.schemaSyncedVersion === row.version;
const syncStatus = !row.schemaSyncedVersion
@@ -337,7 +337,7 @@ export function DatabaseGrid({
onSyncSelected,
selectedMetadataModel,
descriptionGenerationActive,
sensitiveDataSuggestionRuns = [],
sensitivityAnalysisRuns = [],
onGenerateDescriptions,
onSuggestSensitive,
onDeleteMetadataSelected,
@@ -361,8 +361,8 @@ export function DatabaseGrid({
return result;
}, [metricQueries, rows]);
const sensitiveRunsByDatabase = useMemo(() => new Map(
rows.filter((row) => row.id).map((row) => [row.id!, sensitiveDataSuggestionRuns.find((run) => run.databaseId === row.id)]),
), [rows, sensitiveDataSuggestionRuns]);
rows.filter((row) => row.id).map((row) => [row.id!, sensitivityAnalysisRuns.find((run) => run.databaseId === row.id)]),
), [rows, sensitivityAnalysisRuns]);
const compact = useCompactViewport();
const gridRef = useRef<AgGridReact<CatalogDatabase>>(null);
const actionsTriggerRef = useRef<HTMLButtonElement>(null);
@@ -432,7 +432,6 @@ export function DatabaseGrid({
&& selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun);
const canSuggestSensitive = canManage
&& selectedRows.length === 1
&& Boolean(selectedMetadataModel)
&& !descriptionGenerationActive
&& selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun);
const canDeleteMetadataSelection = canManage && selectedRows.length > 0
@@ -462,7 +461,6 @@ export function DatabaseGrid({
: undefined);
const sensitiveReason = permissionReason
?? (selectedRows.length !== 1 ? "Select exactly one database" : undefined)
?? (!selectedMetadataModel ? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE : undefined)
?? (descriptionGenerationActive ? "Wait for the active metadata operation" : undefined)
?? (selectedRows.some((row) => !row.configured || !row.id || row.activeSyncRun)
? "The selected database must be configured and idle"
@@ -479,7 +477,7 @@ export function DatabaseGrid({
{ id: "sync-all", label: "Synchronize all", group: "Synchronization", disabled: !canSyncSelection, disabledReason: syncReason },
{ id: "generate-missing", label: "Generate missing descriptions", group: "Descriptions", disabled: !canGenerateDescriptions, disabledReason: generationReason },
{ id: "generate-all", label: "Generate all descriptions", group: "Descriptions", disabled: !canGenerateDescriptions, disabledReason: generationReason, runLabel: "Review generation" },
{ id: "suggest-sensitive", label: "Suggest sensitive fields", group: "Sensitive data", disabled: !canSuggestSensitive, disabledReason: sensitiveReason, runLabel: "Open review" },
{ id: "suggest-sensitive", label: "Analyze sensitive fields", group: "Sensitive data", disabled: !canSuggestSensitive, disabledReason: sensitiveReason, runLabel: "Open review" },
{ id: "clear-tables", label: "Clear catalog tables", group: "Cleanup", disabled: !canDeleteMetadataSelection, disabledReason: cleanupReason, tone: "destructive", runLabel: "Review cleanup" },
{ id: "clear-relationships", label: "Clear catalog relationships", group: "Cleanup", disabled: !canDeleteMetadataSelection, disabledReason: cleanupReason, tone: "destructive", runLabel: "Review cleanup" },
];
@@ -723,7 +721,7 @@ export function DatabaseGrid({
title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined}
onClick={() => void perform("suggest", () => onSuggestSensitive(selectedRows))}
>
<Sparkles />{action === "suggest" ? "Suggesting…" : "Suggest sensitive fields"}
<Sparkles />{action === "suggest" ? "Analyzing…" : "Analyze sensitive fields"}
</Button>
<Button type="button" variant="ghost" disabled={action !== null} onClick={() => { gridRef.current?.api.deselectAll(); setSelectedRows([]); }}><X />Clear</Button>
</>
@@ -20,7 +20,7 @@ import {
type CatalogTable,
type CatalogTableMetadataDeleteTarget,
type DescriptionGenerationRun,
type SensitiveDataSuggestionRequest,
type SensitivityAnalysisRequest,
} from "../../api/catalog-databases";
import type { DatabaseNavigationState } from "./model";
import { DatabaseColumns } from "./DatabaseColumns";
@@ -43,7 +43,7 @@ interface Props {
onOpenSync: () => void;
onClearCatalogTables?: () => Promise<void>;
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
onSuggestSensitive: (selection: SensitiveDataSuggestionRequest, scopeLabel: string) => Promise<void>;
onSuggestSensitive: (selection: SensitivityAnalysisRequest, scopeLabel: string) => Promise<void>;
onCatalogMetricsChanged?: () => void | Promise<void>;
presentation?: "legacy" | "fleet";
}
@@ -418,17 +418,15 @@ export function DatabaseTables({
},
{
id: "suggest-sensitive",
label: "Suggest sensitive fields",
label: "Analyze sensitive fields",
group: "Sensitive data",
runLabel: "Suggest",
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun),
runLabel: "Analyze",
disabled: !canManage || selectedIds.length === 0 || descriptionGenerationActive || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to review sensitive data."
: selectedIds.length === 0
? "Select at least one table."
: !selectedMetadataModel
? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE
: descriptionGenerationActive || Boolean(currentRun)
: descriptionGenerationActive || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: busy !== null
? "Another action is running."
@@ -752,7 +750,7 @@ export function DatabaseTables({
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<Button type="button" variant="outline" disabled={!canManage || descriptionGenerationActive || busy !== null} title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined} onClick={() => void suggestSensitive()}><Sparkles />{busy === "suggest" ? "Suggesting…" : "Suggest sensitive fields"}</Button>
<Button type="button" variant="outline" disabled={!canManage || descriptionGenerationActive || busy !== null} title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined} onClick={() => void suggestSensitive()}><Sparkles />{busy === "suggest" ? "Analyzing…" : "Analyze sensitive fields"}</Button>
<Button type="button" variant="ghost" onClick={clearSelection}><X />Clear</Button>
</>
)
@@ -8,7 +8,7 @@ describe("Recent runs success styling contract", () => {
test("every history renderer exposes its run status to the shared styles", () => {
const renderers = [
"DescriptionGenerationDrawer.tsx",
"SensitiveDataSuggestionHistoryDrawer.tsx",
"SensitivityAnalysisHistoryDrawer.tsx",
"CatalogSyncDrawer.tsx",
];
@@ -0,0 +1,97 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { vi } from "vitest";
import type { CatalogColumn, SensitivityReviewItem } from "../../api/catalog-databases";
import { SensitiveDataReviewDrawer } from "./SensitiveDataReviewDrawer";
const updateCatalogColumnSensitive = vi.hoisted(() => vi.fn());
vi.mock("../../api/catalog-databases", () => ({ updateCatalogColumnSensitive }));
const suggestions: SensitivityReviewItem[] = [
{
columnId: "11111111-1111-4111-8111-111111111111",
tableId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
tableName: "patients",
columnName: "email",
version: 3,
currentSensitive: false,
sensitive: true,
assessment: "sensitive",
evidence: [{ kind: "content", ruleId: "pii.email" }],
observedValues: 1,
},
{
columnId: "22222222-2222-4222-8222-222222222222",
tableId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
tableName: "patients",
columnName: "notes",
version: 7,
currentSensitive: true,
sensitive: true,
assessment: "sensitive",
evidence: [{ kind: "metadata", ruleId: "metadata.health" }],
observedValues: 0,
},
];
function savedColumn(suggestion: SensitivityReviewItem): CatalogColumn {
return {
id: suggestion.columnId,
tableId: suggestion.tableId,
name: suggestion.columnName,
ordinalPosition: 1,
dataType: "text",
isNullable: true,
defaultExpression: null,
primaryKeyPosition: null,
isPrimaryKey: false,
isForeignKey: false,
foreignKeyCount: 0,
sourceComment: null,
description: null,
generatedDescription: null,
sensitive: true,
lastSyncedDatabaseVersion: 1,
lastSyncedAt: "2026-09-02T08:00:00Z",
version: suggestion.version + 1,
createdAt: "2026-09-02T08:00:00Z",
updatedAt: "2026-09-02T08:00:01Z",
};
}
test("preserves failed human choices when another flag in the same save succeeds", async () => {
updateCatalogColumnSensitive
.mockResolvedValueOnce(savedColumn(suggestions[0]!))
.mockRejectedValueOnce(new Error("write failed"));
function Harness() {
const [current, setCurrent] = useState(suggestions);
return (
<SensitiveDataReviewDrawer
open
databaseId="bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
scopeLabel="All columns"
suggestions={current}
canManage
onClose={() => undefined}
onSaved={(columns) => setCurrent((items) => items.map((item) => {
const saved = columns.find((column) => column.id === item.columnId);
return saved ? { ...item, currentSensitive: saved.sensitive, version: saved.version } : item;
}))}
/>
);
}
const user = userEvent.setup();
render(<Harness />);
await user.click(screen.getByRole("checkbox", { name: "Show all 2 assessed columns" }));
const failedChoice = screen.getByRole("checkbox", { name: "Protect patients.notes" });
await user.click(failedChoice);
await user.click(screen.getByRole("button", { name: "Save 2" }));
await waitFor(() => expect(updateCatalogColumnSensitive).toHaveBeenCalledTimes(2));
await waitFor(() => expect(screen.getByRole("button", { name: "Save 1" })).toBeEnabled());
expect(failedChoice).not.toBeChecked();
});
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
@@ -6,7 +6,7 @@ import { apiErrorMessage } from "../../api/client";
import {
updateCatalogColumnSensitive,
type CatalogColumn,
type SensitiveDataSuggestion,
type SensitivityReviewItem,
} from "../../api/catalog-databases";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
@@ -14,7 +14,7 @@ interface Props {
open: boolean;
databaseId: string | null;
scopeLabel: string;
suggestions: SensitiveDataSuggestion[];
suggestions: SensitivityReviewItem[];
canManage: boolean;
onClose: () => void;
onSaved: (columns: CatalogColumn[]) => void;
@@ -33,16 +33,26 @@ export function SensitiveDataReviewDrawer({
const [search, setSearch] = useState("");
const [showAll, setShowAll] = useState(false);
const [saving, setSaving] = useState(false);
const initializedReview = useRef<string | null>(null);
const reviewKey = useMemo(
() => suggestions.map((suggestion) => suggestion.columnId).join(":"),
[suggestions],
);
useEffect(() => {
if (!open) return;
if (!open) {
initializedReview.current = null;
return;
}
if (initializedReview.current === reviewKey) return;
initializedReview.current = reviewKey;
setDrafts(Object.fromEntries(suggestions.map((suggestion) => [
suggestion.columnId,
suggestion.sensitive,
])));
setSearch("");
setShowAll(false);
}, [open, suggestions]);
}, [open, reviewKey, suggestions]);
const changed = useMemo(() => suggestions.filter((suggestion) => (
drafts[suggestion.columnId] !== undefined
@@ -92,8 +102,8 @@ export function SensitiveDataReviewDrawer({
open
ariaLabel="Sensitive field review"
eyebrow="Sensitive data"
title="Review suggested flags"
description={`${scopeLabel}. The model proposed values, but only your save changes the catalog.`}
title="Review local assessments"
description={`${scopeLabel}. Local rules proposed values, but only your save changes the catalog.`}
onClose={close}
closeLabel="Close sensitive field review"
busy={saving}
@@ -101,7 +111,7 @@ export function SensitiveDataReviewDrawer({
footerClassName="thot-catalog-drawer__footer--split"
footer={(
<>
<p className="text-xs text-muted-foreground">Unsaved suggestions never change the catalog.</p>
<p className="text-xs text-muted-foreground">Unsaved assessments never change the catalog.</p>
<div className="flex gap-2">
<Button type="button" variant="outline" disabled={saving} onClick={close}>Cancel</Button>
<Button type="button" disabled={!canManage || saving || changed.length === 0} onClick={() => void save()}>
@@ -115,7 +125,7 @@ export function SensitiveDataReviewDrawer({
<div className="flex items-center gap-3">
<input
className="h-9 min-w-0 flex-1 rounded-md border border-input bg-background px-3 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label="Search sensitive field suggestions"
aria-label="Search sensitivity assessments"
placeholder="Search table or column"
value={search}
onChange={(event) => setSearch(event.target.value)}
@@ -131,7 +141,7 @@ export function SensitiveDataReviewDrawer({
checked={showAll}
onChange={(event) => setShowAll(event.target.checked)}
/>
Show all {suggestions.length} classified columns
Show all {suggestions.length} assessed columns
</label>
</div>
@@ -144,7 +154,7 @@ export function SensitiveDataReviewDrawer({
</p>
</div>
) : (
<ul className="divide-y divide-border" aria-label="Sensitive field suggestions">
<ul className="divide-y divide-border" aria-label="Sensitivity assessments">
{visible.map((suggestion) => {
const proposed = drafts[suggestion.columnId] ?? suggestion.sensitive;
const changedFromCurrent = proposed !== suggestion.currentSensitive;
@@ -168,6 +178,13 @@ export function SensitiveDataReviewDrawer({
<p className="mt-1 text-xs text-muted-foreground">
Current: {suggestion.currentSensitive ? "protected" : "allowed"}. Proposed: {proposed ? "protected" : "allowed"}.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Assessment: {suggestion.assessment.replace("_", " ")}. Evidence: {suggestion.evidence.length > 0
? suggestion.evidence.map((item) => item.label
? `${item.ruleId} (${item.label}${item.confidence === undefined ? "" : ` ${Math.round(item.confidence * 100)}%`})`
: item.ruleId).join(", ")
: "no sensitive match"}. Observed values: {suggestion.observedValues}.
</p>
</div>
<span className={`rounded px-2 py-0.5 text-[11px] font-semibold ${changedFromCurrent ? "bg-amber-500/12 text-amber-800 dark:text-amber-300" : "bg-muted text-muted-foreground"}`}>
{changedFromCurrent ? "Change" : "No change"}
@@ -3,19 +3,22 @@ import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import { useState } from "react";
import type { SensitiveDataSuggestionRun } from "../../api/catalog-databases";
import type { SensitivityAnalysisRun } from "../../api/catalog-databases";
import { server } from "../../test/msw";
import { SensitiveDataSuggestionHistoryDrawer } from "./SensitiveDataSuggestionHistoryDrawer";
import { SensitivityAnalysisHistoryDrawer } from "./SensitivityAnalysisHistoryDrawer";
const runningRun: SensitiveDataSuggestionRun = {
const runningRun: SensitivityAnalysisRun = {
id: "99999999-9999-4999-8999-999999999999",
databaseId: "11111111-1111-4111-8111-111111111111",
scope: "selected_columns",
modelId: "local-qwen",
engine: "local",
modelId: null,
policyVersion: "sensitivity-v1",
status: "running",
total: 4,
suggestedSensitive: 1,
suggestedNonSensitive: 1,
unknown: 2,
createdAt: "2026-08-28T09:00:00Z",
startedAt: "2026-08-28T09:00:00Z",
updatedAt: "2026-08-28T09:00:01Z",
@@ -23,7 +26,7 @@ const runningRun: SensitiveDataSuggestionRun = {
errorSummary: null,
};
function renderDrawer(initialRun: SensitiveDataSuggestionRun | null) {
function renderDrawer(initialRun: SensitivityAnalysisRun | null) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: Infinity } },
});
@@ -31,10 +34,9 @@ function renderDrawer(initialRun: SensitiveDataSuggestionRun | null) {
function Harness() {
const [run, setRun] = useState(initialRun);
return (
<SensitiveDataSuggestionHistoryDrawer
<SensitivityAnalysisHistoryDrawer
open
run={run}
modelLabel="Local Qwen"
onClose={() => undefined}
onRunUpdate={setRun}
/>
@@ -56,13 +58,13 @@ test("shows an explicit empty state before any sensitive suggestion runs exist",
renderDrawer(null);
const drawer = await screen.findByRole("dialog", { name: "Sensitive suggestion history" });
expect(within(drawer).getByRole("heading", { name: "Suggestion run history" })).toBeVisible();
expect(await within(drawer).findByText("No sensitive suggestion runs yet.")).toBeVisible();
const drawer = await screen.findByRole("dialog", { name: "Sensitivity analysis history" });
expect(within(drawer).getByRole("heading", { name: "Analysis run history" })).toBeVisible();
expect(await within(drawer).findByText("No sensitivity analysis runs yet.")).toBeVisible();
});
test("shows sensitive suggestion results, safe events, and lets operators inspect an older run", async () => {
const completedRun: SensitiveDataSuggestionRun = {
const completedRun: SensitivityAnalysisRun = {
...runningRun,
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
scope: "all",
@@ -70,6 +72,7 @@ test("shows sensitive suggestion results, safe events, and lets operators inspec
total: 6,
suggestedSensitive: 2,
suggestedNonSensitive: 4,
unknown: 0,
createdAt: "2026-08-28T08:00:00Z",
startedAt: "2026-08-28T08:00:00Z",
updatedAt: "2026-08-28T08:00:01Z",
@@ -95,18 +98,18 @@ test("shows sensitive suggestion results, safe events, and lets operators inspec
const user = userEvent.setup();
renderDrawer(runningRun);
const drawer = await screen.findByRole("dialog", { name: "Sensitive suggestion history" });
const drawer = await screen.findByRole("dialog", { name: "Sensitivity analysis history" });
expect(within(drawer).getByRole("heading", { name: "Running" })).toBeVisible();
const progress = within(drawer).getByRole("table", { name: "Sensitive suggestion progress" });
expect(within(progress).getAllByRole("columnheader")).toHaveLength(3);
expect(within(drawer).getByRole("log", { name: "Sensitive suggestion events" })).toHaveClass(
const progress = within(drawer).getByRole("table", { name: "Sensitivity analysis progress" });
expect(within(progress).getAllByRole("columnheader")).toHaveLength(4);
expect(within(drawer).getByRole("log", { name: "Sensitivity analysis events" })).toHaveClass(
"thot-catalog-drawer__event-log",
);
expect(await within(drawer).findByText("Classifying columns")).toBeVisible();
expect(within(drawer).queryByRole("button", { name: "Stop" })).not.toBeInTheDocument();
expect(within(drawer).queryByRole("button", { name: "Unlock stale run" })).not.toBeInTheDocument();
const history = within(drawer).getByRole("region", { name: "Sensitive suggestion run history" });
const history = within(drawer).getByRole("region", { name: "Sensitivity analysis run history" });
await user.click(within(history).getByRole("button", { name: /Completed.*all/i }));
expect(await within(drawer).findByRole("heading", { name: "Completed" })).toBeVisible();
@@ -116,7 +119,7 @@ test("shows sensitive suggestion results, safe events, and lets operators inspec
});
test("does not expose unsafe provider details from a final error summary", async () => {
const failedRun: SensitiveDataSuggestionRun = {
const failedRun: SensitivityAnalysisRun = {
...runningRun,
status: "failed",
finishedAt: "2026-08-28T09:00:02Z",
@@ -3,35 +3,34 @@ import { LoaderCircle } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { apiErrorMessage } from "../../api/client";
import {
getSensitiveDataSuggestionRun,
listSensitiveDataSuggestionEvents,
listSensitiveDataSuggestionRuns,
type SensitiveDataSuggestionRun,
getSensitivityAnalysisRun,
listSensitivityAnalysisEvents,
listSensitivityAnalysisRuns,
type SensitivityAnalysisRun,
} from "../../api/catalog-databases";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
interface Props {
open: boolean;
databaseId?: string | null;
run: SensitiveDataSuggestionRun | null;
modelLabel: string;
run: SensitivityAnalysisRun | null;
onClose: () => void;
onRunUpdate: (run: SensitiveDataSuggestionRun) => void;
onRunUpdate: (run: SensitivityAnalysisRun) => void;
}
const HISTORY_QUERY_KEY = ["sensitive-data-suggestion-runs", 50] as const;
const SAFE_FINAL_ERROR = "The run ended before all columns were classified. Review the event log for safe details.";
function statusLabel(run: SensitiveDataSuggestionRun): string {
function statusLabel(run: SensitivityAnalysisRun): string {
const label = run.status.replaceAll("_", " ");
return `${label[0].toUpperCase()}${label.slice(1)}`;
}
function isTerminal(run?: SensitiveDataSuggestionRun): boolean {
function isTerminal(run?: SensitivityAnalysisRun): boolean {
return Boolean(run && ["completed", "failed", "interrupted"].includes(run.status));
}
function outcomeClass(run: SensitiveDataSuggestionRun): string {
function outcomeClass(run: SensitivityAnalysisRun): string {
if (run.status === "completed") return "thot-sensitive-run--success";
if (["failed", "interrupted"].includes(run.status)) return "thot-sensitive-run--failure";
return "";
@@ -52,11 +51,10 @@ function timestamp(value: string | null): string {
return value ? new Date(value).toLocaleString() : "Not available";
}
export function SensitiveDataSuggestionHistoryDrawer({
export function SensitivityAnalysisHistoryDrawer({
open,
databaseId = null,
run: initialRun,
modelLabel,
onClose,
onRunUpdate,
}: Props) {
@@ -64,7 +62,7 @@ export function SensitiveDataSuggestionHistoryDrawer({
const runId = initialRun?.id ?? null;
const runQuery = useQuery({
queryKey: ["sensitive-data-suggestion-run", runId],
queryFn: () => getSensitiveDataSuggestionRun(runId!),
queryFn: () => getSensitivityAnalysisRun(runId!),
enabled: open && Boolean(runId),
initialData: initialRun ?? undefined,
retry: false,
@@ -73,14 +71,14 @@ export function SensitiveDataSuggestionHistoryDrawer({
const run = runQuery.data;
const eventQuery = useQuery({
queryKey: ["sensitive-data-suggestion-events", runId],
queryFn: () => listSensitiveDataSuggestionEvents(runId!),
queryFn: () => listSensitivityAnalysisEvents(runId!),
enabled: open && Boolean(runId),
retry: false,
refetchInterval: run && isTerminal(run) ? false : 1_500,
});
const historyQuery = useQuery({
queryKey: HISTORY_QUERY_KEY,
queryFn: () => listSensitiveDataSuggestionRuns(50),
queryFn: () => listSensitivityAnalysisRuns(50),
enabled: open,
retry: false,
refetchInterval: (query) => query.state.data?.some((item) => !isTerminal(item))
@@ -100,7 +98,7 @@ export function SensitiveDataSuggestionHistoryDrawer({
useEffect(() => {
if (!run) return;
onRunUpdate(run);
queryClient.setQueryData<SensitiveDataSuggestionRun[]>(HISTORY_QUERY_KEY, (current) => {
queryClient.setQueryData<SensitivityAnalysisRun[]>(HISTORY_QUERY_KEY, (current) => {
if (!current) return [run];
return current.some((item) => item.id === run.id)
? current.map((item) => item.id === run.id ? run : item)
@@ -113,11 +111,11 @@ export function SensitiveDataSuggestionHistoryDrawer({
return (
<FleetLedgerDrawer
open
ariaLabel="Sensitive suggestion history"
ariaLabel="Sensitivity analysis history"
eyebrow="Sensitive data"
title="Suggestion run history"
title="Analysis run history"
onClose={onClose}
closeLabel="Close sensitive suggestion history"
closeLabel="Close sensitivity analysis history"
bodyClassName="thot-catalog-drawer__body--empty"
>
{historyQuery.isError ? (
@@ -125,12 +123,12 @@ export function SensitiveDataSuggestionHistoryDrawer({
{apiErrorMessage(historyQuery.error)}
</div>
) : historyQuery.isLoading || (historyQuery.data?.length ?? 0) > 0 ? (
<p className="text-sm text-muted-foreground">Loading suggestion history…</p>
<p className="text-sm text-muted-foreground">Loading analysis history…</p>
) : (
<div className="w-full rounded-md border border-border bg-muted/25 px-4 py-5 text-sm">
<p className="font-semibold">No sensitive suggestion runs yet.</p>
<p className="font-semibold">No sensitivity analysis runs yet.</p>
<p className="mt-1 text-muted-foreground">
Request sensitive-field suggestions to create the first history entry.
Analyze sensitive fields to create the first history entry.
</p>
</div>
)}
@@ -140,20 +138,21 @@ export function SensitiveDataSuggestionHistoryDrawer({
const events = eventQuery.data ?? [];
const finalError = safeFinalError(run.errorSummary);
const modelDisplay = modelLabel && modelLabel !== run.modelId
? `${modelLabel} (${run.modelId})`
: run.modelId;
const engineDisplay = run.engine === "local"
? `Local rules (${run.policyVersion ?? "unknown policy"})`
: `Legacy LLM (${run.modelId ?? "unknown model"})`;
const progressCounters = [
["total", "Total columns"],
["suggestedSensitive", "Sensitive"],
["suggestedNonSensitive", "Not sensitive"],
["unknown", "Unknown"],
] as const;
return (
<FleetLedgerDrawer
open
className={outcomeClass(run)}
ariaLabel="Sensitive suggestion history"
eyebrow="Sensitive data suggestions"
ariaLabel="Sensitivity analysis history"
eyebrow="Sensitivity analysis"
title={(
<span className="inline-flex items-center gap-2">
{!isTerminal(run) ? <LoaderCircle className="size-5 animate-spin" aria-hidden="true" /> : null}
@@ -162,7 +161,7 @@ export function SensitiveDataSuggestionHistoryDrawer({
)}
description={run.scope.replaceAll("_", " ")}
onClose={onClose}
closeLabel="Close sensitive suggestion history"
closeLabel="Close sensitivity analysis history"
bodyClassName="thot-catalog-drawer__history-layout thot-catalog-drawer__run-layout"
>
<div className="thot-catalog-drawer__run-main">
@@ -173,16 +172,18 @@ export function SensitiveDataSuggestionHistoryDrawer({
) : null}
<div className={`grid gap-4 sm:grid-cols-2${runQuery.isError ? " mt-4" : ""}`}>
<section aria-label="AI usage">
<h3 className="thot-label mb-2">AI usage</h3>
<section aria-label="Analysis engine">
<h3 className="thot-label mb-2">Analysis engine</h3>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
<dt>Model</dt><dd className="truncate text-right" title={modelDisplay}>{modelDisplay}</dd>
<dt>Input tokens</dt><dd className="text-right tabular-nums">{(run.inputTokens ?? 0).toLocaleString("en-US")}</dd>
<dt>Cache tokens</dt><dd className="text-right tabular-nums">{(run.cacheReadTokens ?? 0).toLocaleString("en-US")}</dd>
<dt>Output tokens</dt><dd className="text-right tabular-nums">{(run.outputTokens ?? 0).toLocaleString("en-US")}</dd>
<dt>Engine</dt><dd className="truncate text-right" title={engineDisplay}>{engineDisplay}</dd>
{run.engine === "llm" ? <>
<dt>Input tokens</dt><dd className="text-right tabular-nums">{(run.inputTokens ?? 0).toLocaleString("en-US")}</dd>
<dt>Cache tokens</dt><dd className="text-right tabular-nums">{(run.cacheReadTokens ?? 0).toLocaleString("en-US")}</dd>
<dt>Output tokens</dt><dd className="text-right tabular-nums">{(run.outputTokens ?? 0).toLocaleString("en-US")}</dd>
</> : null}
</dl>
</section>
<section aria-label="Sensitive suggestion timestamps">
<section aria-label="Sensitivity analysis timestamps">
<h3 className="thot-label mb-2">Timestamps</h3>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
<dt className="text-muted-foreground">Created</dt><dd className="text-right tabular-nums">{timestamp(run.createdAt)}</dd>
@@ -193,10 +194,10 @@ export function SensitiveDataSuggestionHistoryDrawer({
</section>
</div>
<section className="mt-4" aria-label="Sensitive suggestion counters">
<section className="mt-4" aria-label="Sensitivity analysis counters">
<h3 className="thot-label mb-2">Results</h3>
<div className="overflow-x-auto rounded-md border border-border bg-muted/20">
<table className="w-full min-w-[20rem] table-fixed" aria-label="Sensitive suggestion progress">
<table className="w-full min-w-[20rem] table-fixed" aria-label="Sensitivity analysis progress">
<thead>
<tr className="border-b border-border bg-muted/45">
{progressCounters.map(([name, label]) => (
@@ -225,14 +226,14 @@ export function SensitiveDataSuggestionHistoryDrawer({
</div>
) : null}
<section className="thot-catalog-drawer__events mt-3" aria-label="Sensitive suggestion log">
<section className="thot-catalog-drawer__events mt-3" aria-label="Sensitivity analysis log">
<div className="mb-2 flex items-center justify-between">
<h3 className="thot-label">Events</h3>
<span className="text-xs tabular-nums text-muted-foreground">{events.length}</span>
</div>
<div
role="log"
aria-label="Sensitive suggestion events"
aria-label="Sensitivity analysis events"
className="thot-catalog-drawer__event-log overflow-y-auto rounded-md bg-zinc-950 p-3 font-mono text-xs leading-5 text-zinc-200"
>
{events.length === 0 ? <p className="text-zinc-500">Waiting for events…</p> : events.map((event) => (
@@ -245,7 +246,7 @@ export function SensitiveDataSuggestionHistoryDrawer({
</section>
</div>
<section className="thot-catalog-drawer__history" aria-label="Sensitive suggestion run history">
<section className="thot-catalog-drawer__history" aria-label="Sensitivity analysis run history">
<div className="mb-2 flex items-center justify-between gap-3">
<h3 className="thot-label">Recent runs</h3>
<span className="text-xs tabular-nums text-muted-foreground">
@@ -255,9 +256,9 @@ export function SensitiveDataSuggestionHistoryDrawer({
{historyQuery.isError ? (
<p role="alert" className="text-sm text-destructive">{apiErrorMessage(historyQuery.error)}</p>
) : historyQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading suggestion history…</p>
<p className="text-sm text-muted-foreground">Loading analysis history…</p>
) : visibleHistory.length === 0 ? (
<p className="text-sm text-muted-foreground">No sensitive suggestion runs yet.</p>
<p className="text-sm text-muted-foreground">No sensitivity analysis runs yet.</p>
) : (
<div className="thot-catalog-drawer__history-list divide-y divide-border overflow-y-auto rounded-md border border-border">
{visibleHistory.map((item) => (
@@ -272,7 +273,7 @@ export function SensitiveDataSuggestionHistoryDrawer({
>
<span className="font-medium">{statusLabel(item)}</span>
<span className="shrink-0 text-right text-xs text-muted-foreground">
{item.suggestedSensitive + item.suggestedNonSensitive}/{item.total}<br />
{item.suggestedSensitive + item.suggestedNonSensitive + item.unknown}/{item.total}<br />
{new Date(item.createdAt).toLocaleString()}
</span>
</button>