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
+32 -22
View File
@@ -126,7 +126,7 @@ export interface CatalogColumn {
updatedAt: string;
}
export interface SensitiveDataSuggestion {
export interface SensitivityReviewItem {
columnId: string;
tableId: string;
tableName: string;
@@ -134,29 +134,40 @@ export interface SensitiveDataSuggestion {
version: number;
currentSensitive: boolean;
sensitive: boolean;
assessment: "sensitive" | "non_sensitive" | "unknown";
evidence: Array<{
kind: "metadata" | "content" | "length" | "ner" | "coverage";
ruleId: string;
label?: string;
confidence?: number;
}>;
observedValues: number;
}
export interface SensitiveDataSuggestions {
suggestions: SensitiveDataSuggestion[];
run: SensitiveDataSuggestionRun;
export interface SensitivityAnalysisResult {
suggestions: SensitivityReviewItem[];
run: SensitivityAnalysisRun;
}
export type SensitiveDataSuggestionRequest =
export type SensitivityAnalysisRequest =
| { scope: "all" }
| { scope: "selected_tables"; targetIds: string[] }
| { scope: "selected_columns"; targetIds: string[] };
export type SensitiveDataSuggestionStatus = "running" | "completed" | "failed" | "interrupted";
export type SensitivityAnalysisStatus = "running" | "completed" | "failed" | "interrupted";
export interface SensitiveDataSuggestionRun {
export interface SensitivityAnalysisRun {
id: string;
databaseId: string;
scope: SensitiveDataSuggestionRequest["scope"];
modelId: string;
status: SensitiveDataSuggestionStatus;
scope: SensitivityAnalysisRequest["scope"];
engine: "llm" | "local";
modelId: string | null;
policyVersion: string | null;
status: SensitivityAnalysisStatus;
total: number;
suggestedSensitive: number;
suggestedNonSensitive: number;
unknown: number;
inputTokens?: number;
cacheReadTokens?: number;
outputTokens?: number;
@@ -167,7 +178,7 @@ export interface SensitiveDataSuggestionRun {
errorSummary: string | null;
}
export interface SensitiveDataSuggestionEvent {
export interface SensitivityAnalysisEvent {
runId: string;
sequence: number;
level: "info" | "warning" | "error";
@@ -458,28 +469,27 @@ export const updateCatalogColumnSensitive = (
{ method: "PATCH", body: JSON.stringify({ version, sensitive }) },
);
export const suggestSensitiveFields = (
export const runSensitivityAnalysis = (
databaseId: string,
modelId: string,
selection: SensitiveDataSuggestionRequest,
selection: SensitivityAnalysisRequest,
) =>
apiFetch<SensitiveDataSuggestions>(
apiFetch<SensitivityAnalysisResult>(
`/catalog/databases/${encodeURIComponent(databaseId)}/sensitive-data-suggestions`,
{ method: "POST", body: JSON.stringify({ modelId, ...selection }) },
{ method: "POST", body: JSON.stringify(selection) },
);
export const getSensitiveDataSuggestionRun = (runId: string) =>
apiFetch<SensitiveDataSuggestionRun>(
export const getSensitivityAnalysisRun = (runId: string) =>
apiFetch<SensitivityAnalysisRun>(
`/catalog/sensitive-data-suggestion-runs/${encodeURIComponent(runId)}`,
);
export const listSensitiveDataSuggestionRuns = (limit = 50) =>
apiFetch<SensitiveDataSuggestionRun[]>(
export const listSensitivityAnalysisRuns = (limit = 50) =>
apiFetch<SensitivityAnalysisRun[]>(
`/catalog/sensitive-data-suggestion-runs?limit=${encodeURIComponent(String(limit))}`,
);
export const listSensitiveDataSuggestionEvents = (runId: string, after = 0) =>
apiFetch<SensitiveDataSuggestionEvent[]>(
export const listSensitivityAnalysisEvents = (runId: string, after = 0) =>
apiFetch<SensitivityAnalysisEvent[]>(
`/catalog/sensitive-data-suggestion-runs/${encodeURIComponent(runId)}/events-list?after=${after}`,
);
@@ -3,13 +3,13 @@ import { server } from "../test/msw";
import {
cancelDescriptionGenerationRun,
descriptionGenerationEventsUrl,
getSensitiveDataSuggestionRun,
getSensitivityAnalysisRun,
listDescriptionGenerationRuns,
listSensitiveDataSuggestionEvents,
listSensitiveDataSuggestionRuns,
listSensitivityAnalysisEvents,
listSensitivityAnalysisRuns,
unlockDescriptionGenerationRun,
type DescriptionGenerationRun,
type SensitiveDataSuggestionRun,
type SensitivityAnalysisRun,
} from "./catalog-databases";
const historicalRun: DescriptionGenerationRun = {
@@ -31,15 +31,18 @@ const historicalRun: DescriptionGenerationRun = {
errorSummary: null,
};
const sensitiveSuggestionRun: SensitiveDataSuggestionRun = {
const sensitiveSuggestionRun: 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: "completed",
total: 4,
suggestedSensitive: 2,
suggestedNonSensitive: 2,
unknown: 0,
createdAt: "2026-08-28T09:00:00Z",
startedAt: "2026-08-28T09:00:00Z",
updatedAt: "2026-08-28T09:00:01Z",
@@ -119,9 +122,9 @@ test("lists, reads, and replays persisted sensitive-suggestion history", async (
}),
);
await expect(listSensitiveDataSuggestionRuns(50)).resolves.toEqual([sensitiveSuggestionRun]);
await expect(getSensitiveDataSuggestionRun(sensitiveSuggestionRun.id)).resolves.toEqual(sensitiveSuggestionRun);
await expect(listSensitiveDataSuggestionEvents(sensitiveSuggestionRun.id, 2)).resolves.toEqual([event]);
await expect(listSensitivityAnalysisRuns(50)).resolves.toEqual([sensitiveSuggestionRun]);
await expect(getSensitivityAnalysisRun(sensitiveSuggestionRun.id)).resolves.toEqual(sensitiveSuggestionRun);
await expect(listSensitivityAnalysisEvents(sensitiveSuggestionRun.id, 2)).resolves.toEqual([event]);
expect(requestedLimit).toBe("50");
expect(requestedAfter).toBe("2");
});
+5 -5
View File
@@ -147,11 +147,11 @@ test.each([
["description_generation_target_ids_duplicate", "Description generation target IDs must be unique."],
["description_generation_no_eligible_targets", "No eligible catalog tables or columns need description generation."],
["catalog_table_not_found", "One or more selected catalog tables were not found."],
["sensitive_data_suggestion_invalid_response", "The model returned an incomplete or invalid classification. No suggestions were applied."],
["sensitive_data_suggestion_provider_unavailable", "The selected model could not complete the request. No suggestions were applied."],
["sensitive_data_suggestion_history_request_invalid", "Sensitive suggestion history parameters are invalid."],
["sensitive_data_suggestion_history_failed", "Sensitive suggestion history could not be loaded."],
["sensitive_data_suggestion_run_not_found", "The sensitive suggestion run was not found."],
["sensitivity_source_unavailable", "The database content could not be read for sensitivity analysis. No assessments were applied."],
["sensitivity_analysis_timeout", "Sensitivity analysis reached its time limit. No assessments were applied."],
["sensitive_data_suggestion_history_request_invalid", "Sensitivity analysis history parameters are invalid."],
["sensitive_data_suggestion_history_failed", "Sensitivity analysis history could not be loaded."],
["sensitive_data_suggestion_run_not_found", "The sensitivity analysis run was not found."],
["relationship_not_found", "The relationship no longer exists. Refresh and try again."],
["relationship_duplicate", "This relationship already exists."],
["relationship_target_not_unique", "The target column must be the only primary-key column of its table."],
+10 -12
View File
@@ -26,9 +26,8 @@ const safeErrorCodes = new Set([
"sensitive_data_suggestion_request_invalid",
"sensitive_data_suggestion_target_ids_duplicate",
"sensitive_data_suggestion_no_columns",
"sensitive_data_suggestion_payload_too_large",
"sensitive_data_suggestion_invalid_response",
"sensitive_data_suggestion_provider_unavailable",
"sensitivity_source_unavailable",
"sensitivity_analysis_timeout",
"sensitive_data_suggestion_failed",
"sensitive_data_suggestion_history_request_invalid",
"sensitive_data_suggestion_history_failed",
@@ -90,16 +89,15 @@ const localCodeMessages: Record<string, string> = {
catalog_column_not_found: "The selected catalog column was not found.",
catalog_table_not_found: "One or more selected catalog tables were not found.",
workspace_configuration_unavailable: "The database workspace configuration is unavailable.",
sensitive_data_suggestion_request_invalid: "Select a database, one or more tables, or one or more columns before requesting sensitive-field suggestions.",
sensitive_data_suggestion_request_invalid: "Select a database, one or more tables, or one or more columns before running sensitivity analysis.",
sensitive_data_suggestion_target_ids_duplicate: "Each selected table or column can be included only once.",
sensitive_data_suggestion_no_columns: "The selected scope contains no catalog columns to classify.",
sensitive_data_suggestion_payload_too_large: "The selected structural metadata cannot be divided into safe model requests.",
sensitive_data_suggestion_invalid_response: "The model returned an incomplete or invalid classification. No suggestions were applied.",
sensitive_data_suggestion_provider_unavailable: "The selected model could not complete the request. No suggestions were applied.",
sensitive_data_suggestion_failed: "Sensitive-field suggestions failed before review. No changes were applied.",
sensitive_data_suggestion_history_request_invalid: "Sensitive suggestion history parameters are invalid.",
sensitive_data_suggestion_history_failed: "Sensitive suggestion history could not be loaded.",
sensitive_data_suggestion_run_not_found: "The sensitive suggestion run was not found.",
sensitive_data_suggestion_no_columns: "The selected scope contains no catalog columns to assess.",
sensitivity_source_unavailable: "The database content could not be read for sensitivity analysis. No assessments were applied.",
sensitivity_analysis_timeout: "Sensitivity analysis reached its time limit. No assessments were applied.",
sensitive_data_suggestion_failed: "Sensitivity analysis failed before review. No changes were applied.",
sensitive_data_suggestion_history_request_invalid: "Sensitivity analysis history parameters are invalid.",
sensitive_data_suggestion_history_failed: "Sensitivity analysis history could not be loaded.",
sensitive_data_suggestion_run_not_found: "The sensitivity analysis run was not found.",
schema_sync_conflict: "A schema synchronization is already active or no longer current.",
schema_introspection_failed: "The database schema could not be read safely.",
schema_request_invalid: "The schema request is invalid.",
@@ -9,7 +9,7 @@ import type {
CatalogSyncRun,
CatalogTable,
DescriptionGenerationRun,
SensitiveDataSuggestionRun,
SensitivityAnalysisRun,
} from "../api/catalog-databases";
import { Toaster } from "../components/ui/sonner";
import { DatabaseManagementPage } from "./DatabaseManagementPage";
@@ -159,18 +159,21 @@ function makeDescriptionGenerationRun(
};
}
function makeSensitiveDataSuggestionRun(
overrides: Partial<SensitiveDataSuggestionRun> = {},
): SensitiveDataSuggestionRun {
function makeSensitivityAnalysisRun(
overrides: Partial<SensitivityAnalysisRun> = {},
): SensitivityAnalysisRun {
return {
id: "99999999-9999-4999-8999-999999999999",
databaseId: "11111111-1111-4111-8111-111111111111",
modelId: "local-qwen",
engine: "local",
modelId: null,
policyVersion: "sensitivity-v1",
scope: "selected_columns",
status: "completed",
total: 2,
suggestedSensitive: 1,
suggestedNonSensitive: 1,
unknown: 0,
createdAt: "2026-08-28T11:00:00Z",
startedAt: "2026-08-28T11:00:00Z",
updatedAt: "2026-08-28T11:00:01Z",
@@ -505,7 +508,7 @@ test("keeps both run-history buttons visible beside the metadata-description sel
name: "View description generation history",
});
const suggestionHistoryButton = within(metadataControls).getByRole("button", {
name: "View sensitive suggestion history",
name: "View sensitivity analysis history",
});
expect(toolbar).toHaveClass("sm:items-end", "sm:justify-between");
@@ -517,7 +520,7 @@ test("keeps both run-history buttons visible beside the metadata-description sel
expect(descriptionHistoryButton).toHaveClass("disabled:opacity-70");
expect(suggestionHistoryButton).toBeVisible();
expect(suggestionHistoryButton).toBeEnabled();
expect(suggestionHistoryButton).toHaveTextContent("View sensitive suggestion history");
expect(suggestionHistoryButton).toHaveTextContent("View sensitivity analysis history");
expect(toolbar.lastElementChild).toBe(actions);
expect(actions).toHaveClass("sm:justify-end");
expect(actions).toContainElement(screen.getByRole("button", { name: "Refresh" }));
@@ -533,8 +536,8 @@ test("keeps both run-history buttons visible beside the metadata-description sel
await user.click(suggestionHistoryButton);
expect(screen.queryByRole("dialog", { name: "Description generation" })).not.toBeInTheDocument();
const suggestionDrawer = await screen.findByRole("dialog", { name: "Sensitive suggestion history" });
expect(within(suggestionDrawer).getByText("No sensitive suggestion runs yet.")).toBeVisible();
const suggestionDrawer = await screen.findByRole("dialog", { name: "Sensitivity analysis history" });
expect(within(suggestionDrawer).getByText("No sensitivity analysis runs yet.")).toBeVisible();
});
test("changes the metadata-description model in page-local state", async () => {
@@ -1642,8 +1645,8 @@ test("observes an active run from another browser and reopens a terminal run fro
expect(await within(drawer).findByRole("heading", { name: "Completed" })).toBeVisible();
});
test("keeps the sensitive suggestion history label stable while showing active status separately", async () => {
const runningRun = makeSensitiveDataSuggestionRun({
test("keeps the sensitivity analysis history label stable while showing active status separately", async () => {
const runningRun = makeSensitivityAnalysisRun({
status: "running",
finishedAt: null,
updatedAt: new Date().toISOString(),
@@ -1655,12 +1658,12 @@ test("keeps the sensitive suggestion history label stable while showing active s
renderPage();
const historyButton = await screen.findByRole("button", {
name: "View sensitive suggestion history",
name: "View sensitivity analysis history",
});
expect(historyButton).toHaveTextContent("View sensitive suggestion history");
expect(historyButton).toHaveTextContent("View sensitivity analysis history");
await waitFor(() => expect(historyButton).toHaveAttribute(
"title",
"Sensitive suggestion generation is active",
"Sensitivity analysis is active",
));
expect(historyButton.querySelector("[aria-hidden='true'].bg-primary")).not.toBeNull();
});
@@ -2015,7 +2018,7 @@ test("starts one selected column with the configured default model", async () =>
expect(await screen.findByText("Description generation started for 1 column")).toBeVisible();
});
test("shows database sensitive suggestions only for a selection and rejects multiple databases clearly", async () => {
test("shows database sensitivity analysis only for a selection and rejects multiple databases clearly", async () => {
const user = userEvent.setup();
let suggestionCalls = 0;
const radiology = makeDatabase({
@@ -2035,24 +2038,24 @@ test("shows database sensitive suggestions only for a selection and rejects mult
);
renderPage({ rows: [makeDatabase(), radiology] });
expect(screen.queryByRole("button", { name: "Suggest sensitive fields" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Analyze sensitive fields" })).not.toBeInTheDocument();
const psdRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
const radiologyRow = await screen.findByRole("row", { name: /Radiology/ });
await user.click(within(psdRow).getByRole("checkbox", { name: /toggle row selection/i }));
expect(screen.getByRole("button", { name: "Suggest sensitive fields" })).toBeVisible();
expect(screen.getByRole("button", { name: "Analyze sensitive fields" })).toBeVisible();
await user.click(within(radiologyRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Suggest sensitive fields" }));
await user.click(screen.getByRole("button", { name: "Analyze sensitive fields" }));
expect(await screen.findByText("Sensitive-field suggestions can be requested for only one database at a time. Select one database and try again.")).toBeVisible();
expect(await screen.findByText("Sensitivity analysis can run for only one database at a time. Select one database and try again.")).toBeVisible();
expect(suggestionCalls).toBe(0);
});
test("requests database-level sensitive suggestions for the only selected database", async () => {
test("requests database-level sensitivity analysis for the only selected database", async () => {
const user = userEvent.setup();
let suggestionBody: unknown;
let suggestionFinished = false;
let historyCalls = 0;
const run = makeSensitiveDataSuggestionRun({ scope: "all", total: 1 });
const run = makeSensitivityAnalysisRun({ scope: "all", total: 1 });
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
@@ -2075,6 +2078,9 @@ test("requests database-level sensitive suggestions for the only selected databa
version: patientIdColumn.version,
currentSensitive: false,
sensitive: true,
assessment: "sensitive",
evidence: [{ kind: "content", ruleId: "pii.email" }],
observedValues: 1,
}],
});
}),
@@ -2083,14 +2089,14 @@ test("requests database-level sensitive suggestions for the only selected databa
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Suggest sensitive fields" }));
await user.click(screen.getByRole("button", { name: "Analyze sensitive fields" }));
await waitFor(() => expect(suggestionBody).toEqual({ modelId: "local-qwen", scope: "all" }));
await waitFor(() => expect(suggestionBody).toEqual({ scope: "all" }));
expect(await screen.findByRole("dialog", { name: "Sensitive field review" })).toBeVisible();
await waitFor(() => expect(historyCalls).toBeGreaterThanOrEqual(2));
});
test("refetches sensitive suggestion history after a failed request", async () => {
test("refetches sensitivity analysis history after a failed request", async () => {
const user = userEvent.setup();
let historyCalls = 0;
server.use(
@@ -2104,8 +2110,8 @@ test("refetches sensitive suggestion history after a failed request", async () =
}),
http.post("/api/catalog/databases/:databaseId/sensitive-data-suggestions", () => (
HttpResponse.json({
code: "sensitive_data_suggestion_invalid_response",
message: "The LLM returned an incomplete or invalid classification. No suggestions were applied.",
code: "sensitivity_source_scan_failed",
message: "Sensitivity analysis could not read the source. No assessments were applied.",
}, { status: 502 })
)),
);
@@ -2113,13 +2119,13 @@ test("refetches sensitive suggestion history after a failed request", async () =
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Suggest sensitive fields" }));
await user.click(screen.getByRole("button", { name: "Analyze sensitive fields" }));
await waitFor(() => expect(historyCalls).toBeGreaterThanOrEqual(2));
expect(screen.queryByRole("dialog", { name: "Sensitive field review" })).not.toBeInTheDocument();
});
test("requests sensitive suggestions only for selected tables", async () => {
test("requests sensitivity analysis only for selected tables", async () => {
const user = userEvent.setup();
const visitsTable: CatalogTable = {
...patientsTable,
@@ -2153,6 +2159,9 @@ test("requests sensitive suggestions only for selected tables", async () => {
version: visitColumn.version,
currentSensitive: false,
sensitive: true,
assessment: "sensitive",
evidence: [{ kind: "metadata", ruleId: "metadata.health" }],
observedValues: 0,
}],
});
}),
@@ -2163,17 +2172,16 @@ test("requests sensitive suggestions only for selected tables", async () => {
await user.click(screen.getByRole("tab", { name: "Tables" }));
const visitsRow = await screen.findByRole("row", { name: /visits/ });
await user.click(within(visitsRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Suggest sensitive fields" }));
await user.click(screen.getByRole("button", { name: "Analyze sensitive fields" }));
await waitFor(() => expect(suggestionBody).toEqual({
modelId: "local-qwen",
scope: "selected_tables",
targetIds: [visitsTable.id],
}));
expect(await screen.findByRole("dialog", { name: "Sensitive field review" })).toBeVisible();
});
test("reviews AI-sensitive-field suggestions as an editable draft and saves only changed columns", async () => {
test("allows a human downgrade and saves only explicit sensitivity changes", async () => {
const user = userEvent.setup();
const idColumn = { ...patientIdColumn, sensitive: false };
const nameColumn = {
@@ -2185,7 +2193,7 @@ test("reviews AI-sensitive-field suggestions as an editable draft and saves only
isPrimaryKey: false,
description: "Patient name",
generatedDescription: "Name of the patient",
sensitive: false,
sensitive: true,
};
const unselectedColumn = {
...patientIdColumn,
@@ -2225,6 +2233,9 @@ test("reviews AI-sensitive-field suggestions as an editable draft and saves only
version: idColumn.version,
currentSensitive: false,
sensitive: true,
assessment: "sensitive",
evidence: [{ kind: "content", ruleId: "pii.email" }],
observedValues: 1,
},
{
columnId: nameColumn.id,
@@ -2232,8 +2243,11 @@ test("reviews AI-sensitive-field suggestions as an editable draft and saves only
tableName: patientsTable.name,
columnName: nameColumn.name,
version: nameColumn.version,
currentSensitive: false,
sensitive: true,
currentSensitive: true,
sensitive: false,
assessment: "non_sensitive",
evidence: [],
observedValues: 2,
},
],
});
@@ -2263,8 +2277,8 @@ test("reviews AI-sensitive-field suggestions as an editable draft and saves only
const idSensitive = await screen.findByRole("checkbox", { name: "Sensitive data for id" });
const nameSensitive = await screen.findByRole("checkbox", { name: "Sensitive data for name" });
expect(idSensitive).not.toBeChecked();
expect(nameSensitive).not.toBeChecked();
expect(screen.queryByRole("button", { name: "Suggest sensitive fields" })).not.toBeInTheDocument();
expect(nameSensitive).toBeChecked();
expect(screen.queryByRole("button", { name: "Analyze sensitive fields" })).not.toBeInTheDocument();
const selectableRow = async (name: RegExp) => {
const rows = await screen.findAllByRole("row", { name });
@@ -2274,31 +2288,30 @@ test("reviews AI-sensitive-field suggestions as an editable draft and saves only
.getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(within(await selectableRow(/Patient name/))
.getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Suggest sensitive fields" }));
await user.click(screen.getByRole("button", { name: "Analyze sensitive fields" }));
await waitFor(() => expect(suggestionBody).toEqual({
modelId: "local-qwen",
scope: "selected_columns",
targetIds: [idColumn.id, nameColumn.id],
}));
const review = await screen.findByRole("dialog", { name: "Sensitive field review" });
expect(within(review).getByRole("checkbox", { name: "Protect patients.id" })).toBeChecked();
expect(within(review).getByRole("checkbox", { name: "Protect patients.name" })).toBeChecked();
expect(within(review).getByRole("checkbox", { name: "Protect patients.name" })).not.toBeChecked();
expect(patches).toHaveLength(0);
await user.click(within(review).getByRole("checkbox", { name: "Protect patients.name" }));
await user.click(within(review).getByRole("checkbox", { name: "Protect patients.id" }));
await user.click(within(review).getByRole("button", { name: "Save 1" }));
await waitFor(() => expect(patches).toEqual([{
columnId: idColumn.id,
columnId: nameColumn.id,
body: {
version: idColumn.version,
sensitive: true,
version: nameColumn.version,
sensitive: false,
},
}]));
expect(await screen.findByText("Saved 1 sensitive flag")).toBeVisible();
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sensitive field review" })).not.toBeInTheDocument());
await waitFor(() => expect(screen.getByRole("checkbox", { name: "Sensitive data for id" })).toBeChecked());
await waitFor(() => expect(screen.getByRole("checkbox", { name: "Sensitive data for id" })).not.toBeChecked());
expect(screen.getByRole("checkbox", { name: "Sensitive data for name" })).not.toBeChecked();
});
+58 -68
View File
@@ -18,11 +18,11 @@ import {
listCatalogDatabases,
listDescriptionGenerationRuns,
listMetadataGenerationModels,
listSensitiveDataSuggestionRuns,
listSensitivityAnalysisRuns,
replaceCatalogDatabaseSecrets,
startCatalogSync,
startDescriptionGenerationRun,
suggestSensitiveFields,
runSensitivityAnalysis,
testCatalogDatabase,
updateCatalogDatabase,
type CatalogDatabase,
@@ -34,9 +34,9 @@ import {
type DatabaseBinding,
type DatabaseTransport,
type DescriptionGenerationRun,
type SensitiveDataSuggestion,
type SensitiveDataSuggestionRequest,
type SensitiveDataSuggestionRun,
type SensitivityReviewItem,
type SensitivityAnalysisRequest,
type SensitivityAnalysisRun,
} from "../api/catalog-databases";
import { DatabaseGrid } from "./database-management/DatabaseGrid";
import { DatabaseForm } from "./database-management/DatabaseForm";
@@ -46,7 +46,7 @@ import { DatabaseRelationships } from "./database-management/DatabaseRelationshi
import { CatalogSyncDrawer } from "./database-management/CatalogSyncDrawer";
import { MetadataGenerationModelSelector } from "./database-management/MetadataGenerationModelSelector";
import { DescriptionGenerationDrawer } from "./database-management/DescriptionGenerationDrawer";
import { SensitiveDataSuggestionHistoryDrawer } from "./database-management/SensitiveDataSuggestionHistoryDrawer";
import { SensitivityAnalysisHistoryDrawer } from "./database-management/SensitivityAnalysisHistoryDrawer";
import { SensitiveDataReviewDrawer } from "./database-management/SensitiveDataReviewDrawer";
import {
FleetLedgerBreadcrumb,
@@ -158,15 +158,15 @@ export function DatabaseManagementPage({
const observedActiveDescriptionGenerationRun = descriptionGenerationRuns.find(
(run) => isDescriptionGenerationActive(run),
);
const sensitiveDataSuggestionRunsQuery = useQuery({
const sensitivityAnalysisRunsQuery = useQuery({
queryKey: SENSITIVE_DATA_SUGGESTION_HISTORY_QUERY_KEY,
queryFn: () => listSensitiveDataSuggestionRuns(50),
queryFn: () => listSensitivityAnalysisRuns(50),
enabled: canManage,
retry: false,
refetchInterval: 5_000,
});
const sensitiveDataSuggestionRuns = sensitiveDataSuggestionRunsQuery.data ?? [];
const observedActiveSensitiveDataSuggestionRun = sensitiveDataSuggestionRuns.find(
const sensitivityAnalysisRuns = sensitivityAnalysisRunsQuery.data ?? [];
const observedActiveSensitivityAnalysisRun = sensitivityAnalysisRuns.find(
(run) => run.status === "running",
);
@@ -196,12 +196,12 @@ export function DatabaseManagementPage({
const [syncDrawerOpen, setSyncDrawerOpen] = useState(false);
const [activeDescriptionGenerationRun, setActiveDescriptionGenerationRun] = useState<DescriptionGenerationRun | null>(null);
const [descriptionGenerationDrawerOpen, setDescriptionGenerationDrawerOpen] = useState(false);
const [activeSensitiveDataSuggestionRun, setActiveSensitiveDataSuggestionRun] = useState<SensitiveDataSuggestionRun | null>(null);
const [sensitiveDataSuggestionHistoryDrawerOpen, setSensitiveDataSuggestionHistoryDrawerOpen] = useState(false);
const [activeSensitivityAnalysisRun, setActiveSensitivityAnalysisRun] = useState<SensitivityAnalysisRun | null>(null);
const [sensitivityAnalysisHistoryDrawerOpen, setSensitivityAnalysisHistoryDrawerOpen] = useState(false);
const [sensitiveReview, setSensitiveReview] = useState<{
databaseId: string;
scopeLabel: string;
suggestions: SensitiveDataSuggestion[];
suggestions: SensitivityReviewItem[];
} | null>(null);
useEffect(() => {
@@ -694,27 +694,27 @@ export function DatabaseManagementPage({
?? scopedRuns[0]
?? scopedSelectedRun;
if (run) setActiveDescriptionGenerationRun(run);
setSensitiveDataSuggestionHistoryDrawerOpen(false);
setSensitivityAnalysisHistoryDrawerOpen(false);
setDescriptionGenerationDrawerOpen(true);
}, [activeDescriptionGenerationRun, activeRow, descriptionGenerationRuns, observedActiveDescriptionGenerationRun]);
const openSensitiveDataSuggestionHistory = useCallback(() => {
const scopedRuns = activeRow ? sensitiveDataSuggestionRuns.filter((item) => item.databaseId === activeRow.id) : sensitiveDataSuggestionRuns;
const scopedActiveRun = observedActiveSensitiveDataSuggestionRun
&& (!activeRow || observedActiveSensitiveDataSuggestionRun.databaseId === activeRow.id)
? observedActiveSensitiveDataSuggestionRun
const openSensitivityReviewItemHistory = useCallback(() => {
const scopedRuns = activeRow ? sensitivityAnalysisRuns.filter((item) => item.databaseId === activeRow.id) : sensitivityAnalysisRuns;
const scopedActiveRun = observedActiveSensitivityAnalysisRun
&& (!activeRow || observedActiveSensitivityAnalysisRun.databaseId === activeRow.id)
? observedActiveSensitivityAnalysisRun
: undefined;
const scopedSelectedRun = activeSensitiveDataSuggestionRun
&& (!activeRow || activeSensitiveDataSuggestionRun.databaseId === activeRow.id)
? activeSensitiveDataSuggestionRun
const scopedSelectedRun = activeSensitivityAnalysisRun
&& (!activeRow || activeSensitivityAnalysisRun.databaseId === activeRow.id)
? activeSensitivityAnalysisRun
: undefined;
const run = scopedActiveRun
?? scopedRuns[0]
?? scopedSelectedRun;
if (run) setActiveSensitiveDataSuggestionRun(run);
if (run) setActiveSensitivityAnalysisRun(run);
setDescriptionGenerationDrawerOpen(false);
setSensitiveDataSuggestionHistoryDrawerOpen(true);
}, [activeRow, activeSensitiveDataSuggestionRun, observedActiveSensitiveDataSuggestionRun, sensitiveDataSuggestionRuns]);
setSensitivityAnalysisHistoryDrawerOpen(true);
}, [activeRow, activeSensitivityAnalysisRun, observedActiveSensitivityAnalysisRun, sensitivityAnalysisRuns]);
const descriptionGenerationTerminated = useCallback(async (run: DescriptionGenerationRun) => {
await Promise.all([
@@ -788,39 +788,35 @@ export function DatabaseManagementPage({
const requestSensitiveSuggestions = useCallback(async (
database: CatalogDatabase,
selection: SensitiveDataSuggestionRequest,
selection: SensitivityAnalysisRequest,
scopeLabel: string,
) => {
if (!database.id) {
toast.error("The selected database is not configured, so sensitive-field suggestions were not requested.");
toast.error("The selected database is not configured, so sensitivity analysis was not started.");
throw new Error("database is not configured");
}
if (!selectedMetadataModel) {
toast.error("Select a metadata-generation model before requesting sensitive-field suggestions.");
throw new Error("metadata-generation model is not selected");
}
try {
const result = await suggestSensitiveFields(database.id, selectedMetadataModel, selection);
const result = await runSensitivityAnalysis(database.id, selection);
if (result.run) {
setActiveSensitiveDataSuggestionRun(result.run);
queryClient.setQueryData<SensitiveDataSuggestionRun[]>(
setActiveSensitivityAnalysisRun(result.run);
queryClient.setQueryData<SensitivityAnalysisRun[]>(
SENSITIVE_DATA_SUGGESTION_HISTORY_QUERY_KEY,
(current = []) => [result.run, ...current.filter((item) => item.id !== result.run.id)],
);
}
setSensitiveReview({ databaseId: database.id, scopeLabel, suggestions: result.suggestions });
toast.success(`Prepared ${result.suggestions.length} sensitive-field suggestion${result.suggestions.length === 1 ? "" : "s"} for review`);
toast.success(`Prepared ${result.suggestions.length} local sensitivity assessment${result.suggestions.length === 1 ? "" : "s"} for review`);
} catch (error) {
toast.error(apiErrorMessage(error));
throw error;
} finally {
await queryClient.invalidateQueries({ queryKey: SENSITIVE_DATA_SUGGESTION_HISTORY_QUERY_KEY });
}
}, [queryClient, selectedMetadataModel]);
}, [queryClient]);
const suggestDatabaseSensitiveFields = useCallback(async (selected: CatalogDatabase[]) => {
if (selected.length !== 1) {
toast.error("Sensitive-field suggestions can be requested for only one database at a time. Select one database and try again.");
toast.error("Sensitivity analysis can run for only one database at a time. Select one database and try again.");
throw new Error("more than one database selected");
}
const database = selected[0]!;
@@ -828,11 +824,11 @@ export function DatabaseManagementPage({
}, [requestSensitiveSuggestions]);
const suggestActiveDatabaseSensitiveFields = useCallback(async (
selection: SensitiveDataSuggestionRequest,
selection: SensitivityAnalysisRequest,
scopeLabel: string,
) => {
if (!activeRow) {
toast.error("The database is no longer available, so sensitive-field suggestions were not requested.");
toast.error("The database is no longer available, so sensitivity analysis was not started.");
throw new Error("database is no longer available");
}
await requestSensitiveSuggestions(activeRow, selection, scopeLabel);
@@ -952,10 +948,10 @@ export function DatabaseManagementPage({
? `Synchronization: ${currentActiveRun.phase.replaceAll("_", " ")}`
: descriptionGenerationActive
? "Description generation active"
: observedActiveSensitiveDataSuggestionRun
: observedActiveSensitivityAnalysisRun
? "Sensitive analysis active"
: "Idle";
const operationTone = currentActiveRun || descriptionGenerationActive || observedActiveSensitiveDataSuggestionRun
const operationTone = currentActiveRun || descriptionGenerationActive || observedActiveSensitivityAnalysisRun
? "warning" as const
: "neutral" as const;
const fleetBreadcrumb = screen.kind === "tables" || screen.kind === "relationships"
@@ -987,15 +983,12 @@ export function DatabaseManagementPage({
onRunUpdate={setActiveDescriptionGenerationRun}
onTerminal={(run) => void descriptionGenerationTerminated(run)}
/>
<SensitiveDataSuggestionHistoryDrawer
open={sensitiveDataSuggestionHistoryDrawerOpen}
<SensitivityAnalysisHistoryDrawer
open={sensitivityAnalysisHistoryDrawerOpen}
databaseId={activeRow?.id ?? null}
run={activeSensitiveDataSuggestionRun}
modelLabel={metadataModels.find(
(model) => model.id === activeSensitiveDataSuggestionRun?.modelId,
)?.label ?? activeSensitiveDataSuggestionRun?.modelId ?? ""}
onClose={() => setSensitiveDataSuggestionHistoryDrawerOpen(false)}
onRunUpdate={setActiveSensitiveDataSuggestionRun}
run={activeSensitivityAnalysisRun}
onClose={() => setSensitivityAnalysisHistoryDrawerOpen(false)}
onRunUpdate={setActiveSensitivityAnalysisRun}
/>
<SensitiveDataReviewDrawer
open={Boolean(sensitiveReview)}
@@ -1054,7 +1047,7 @@ export function DatabaseManagementPage({
onSync={(scope) => void syncDatabase(scope)}
onOpenSync={() => openSync(activeRow)}
onOpenDescriptionHistory={openDescriptionGenerationHistory}
onOpenSensitiveHistory={openSensitiveDataSuggestionHistory}
onOpenSensitiveHistory={openSensitivityReviewItemHistory}
activeSyncRun={currentActiveRun}
/>
</FleetLedgerDrawer>
@@ -1091,7 +1084,7 @@ export function DatabaseManagementPage({
onRunStarted={rememberSyncRun}
onOpenSync={() => openSync(activeRow)}
onOpenDescriptionHistory={openDescriptionGenerationHistory}
onOpenSensitiveHistory={openSensitiveDataSuggestionHistory}
onOpenSensitiveHistory={openSensitivityReviewItemHistory}
onCatalogMetricsChanged={invalidateCatalogMetrics}
onNavigationStateChange={setTablesNavigationState}
/>
@@ -1128,7 +1121,7 @@ export function DatabaseManagementPage({
descriptionGenerationActive={descriptionGenerationActive}
onGenerateDescriptions={generateDatabaseDescriptions}
onSuggestSensitive={suggestDatabaseSensitiveFields}
sensitiveDataSuggestionRuns={sensitiveDataSuggestionRuns}
sensitivityAnalysisRuns={sensitivityAnalysisRuns}
onDeleteMetadataSelected={deleteSelectedMetadata}
onRefresh={refreshList}
/>
@@ -1194,10 +1187,10 @@ export function DatabaseManagementPage({
aria-label="View sensitive analysis progress"
title="View sensitive analysis progress and history"
disabled={!canManage}
onClick={openSensitiveDataSuggestionHistory}
onClick={openSensitivityReviewItemHistory}
>
<History aria-hidden="true" />
{observedActiveSensitiveDataSuggestionRun ? "Sensitive analysis active" : "Sensitive analysis history"}
{observedActiveSensitivityAnalysisRun ? "Sensitive analysis active" : "Sensitive analysis history"}
</Button>
</>
)}
@@ -1276,10 +1269,10 @@ export function DatabaseManagementPage({
<Button type="button" variant="outline" className="whitespace-nowrap disabled:opacity-70" aria-label="View description generation history" disabled={!canManage} title="View description generation history" onClick={openDescriptionGenerationHistory}>
<History /> View description generation history
</Button>
<Button type="button" variant="outline" className="whitespace-nowrap disabled:opacity-70" aria-label="View sensitive suggestion history" disabled={!canManage} title={observedActiveSensitiveDataSuggestionRun ? "Sensitive suggestion generation is active" : "View sensitive suggestion history"} onClick={openSensitiveDataSuggestionHistory}>
<Button type="button" variant="outline" className="whitespace-nowrap disabled:opacity-70" aria-label="View sensitivity analysis history" disabled={!canManage} title={observedActiveSensitivityAnalysisRun ? "Sensitivity analysis is active" : "View sensitivity analysis history"} onClick={openSensitivityReviewItemHistory}>
<History />
{observedActiveSensitiveDataSuggestionRun ? <span aria-hidden="true" className="size-2 rounded-full bg-primary" /> : null}
View sensitive suggestion history
{observedActiveSensitivityAnalysisRun ? <span aria-hidden="true" className="size-2 rounded-full bg-primary" /> : null}
View sensitivity analysis history
</Button>
</> : null}
</div>
@@ -1333,7 +1326,7 @@ export function DatabaseManagementPage({
onSyncSelected={syncSelected}
selectedMetadataModel={selectedMetadataModelAvailable ? selectedMetadataModel : null}
descriptionGenerationActive={descriptionGenerationActive}
sensitiveDataSuggestionRuns={sensitiveDataSuggestionRuns}
sensitivityAnalysisRuns={sensitivityAnalysisRuns}
onGenerateDescriptions={generateDatabaseDescriptions}
onSuggestSensitive={suggestDatabaseSensitiveFields}
onDeleteMetadataSelected={deleteSelectedMetadata}
@@ -1370,7 +1363,7 @@ export function DatabaseManagementPage({
onSync={(scope) => void syncDatabase(scope)}
onOpenSync={() => openSync(activeRow)}
onOpenDescriptionHistory={openDescriptionGenerationHistory}
onOpenSensitiveHistory={openSensitiveDataSuggestionHistory}
onOpenSensitiveHistory={openSensitivityReviewItemHistory}
activeSyncRun={currentActiveRun}
/>
) : null}
@@ -1405,7 +1398,7 @@ export function DatabaseManagementPage({
onRunStarted={rememberSyncRun}
onOpenSync={() => openSync(activeRow)}
onOpenDescriptionHistory={openDescriptionGenerationHistory}
onOpenSensitiveHistory={openSensitiveDataSuggestionHistory}
onOpenSensitiveHistory={openSensitivityReviewItemHistory}
onCatalogMetricsChanged={invalidateCatalogMetrics}
onNavigationStateChange={setTablesNavigationState}
/>
@@ -1431,15 +1424,12 @@ export function DatabaseManagementPage({
onRunUpdate={setActiveDescriptionGenerationRun}
onTerminal={(run) => void descriptionGenerationTerminated(run)}
/>
<SensitiveDataSuggestionHistoryDrawer
open={sensitiveDataSuggestionHistoryDrawerOpen}
<SensitivityAnalysisHistoryDrawer
open={sensitivityAnalysisHistoryDrawerOpen}
databaseId={activeRow?.id ?? null}
run={activeSensitiveDataSuggestionRun}
modelLabel={metadataModels.find(
(model) => model.id === activeSensitiveDataSuggestionRun?.modelId,
)?.label ?? activeSensitiveDataSuggestionRun?.modelId ?? ""}
onClose={() => setSensitiveDataSuggestionHistoryDrawerOpen(false)}
onRunUpdate={setActiveSensitiveDataSuggestionRun}
run={activeSensitivityAnalysisRun}
onClose={() => setSensitivityAnalysisHistoryDrawerOpen(false)}
onRunUpdate={setActiveSensitivityAnalysisRun}
/>
<SensitiveDataReviewDrawer
open={Boolean(sensitiveReview)}
@@ -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>