feat: classify sensitive columns locally
This commit is contained in:
@@ -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"}
|
||||
|
||||
+20
-17
@@ -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",
|
||||
+44
-43
@@ -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>
|
||||
Reference in New Issue
Block a user