feat: classify sensitive columns locally
This commit is contained in:
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+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