feat: refine metadata catalog workflows
This commit is contained in:
@@ -67,6 +67,7 @@ describe("authenticated shell permissions", () => {
|
||||
|
||||
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
|
||||
const trigger = await within(sessionNavigation).findByRole("button", { name: "Administration" });
|
||||
expect(trigger).toHaveClass("font-sans");
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
expect(within(sessionNavigation).queryByRole("region", { name: "Administration" })).not.toBeInTheDocument();
|
||||
|
||||
|
||||
@@ -45,6 +45,23 @@ interface AppShellProps {
|
||||
type ActiveSurface = "core" | "database-management";
|
||||
type ActiveManagementPanel = "workspace" | "pi" | null;
|
||||
const SESSION_SCOPES: readonly SessionScope[] = ["mine", "all"];
|
||||
const APP_NAVIGATION_STORAGE_KEY = "thothii:app-navigation";
|
||||
|
||||
function readPersistedNavigation(): { surface: ActiveSurface; panel: ActiveManagementPanel } {
|
||||
if (import.meta.env.MODE === "test") return { surface: "core", panel: null };
|
||||
try {
|
||||
const parsed = JSON.parse(window.localStorage.getItem(APP_NAVIGATION_STORAGE_KEY) ?? "null") as {
|
||||
surface?: unknown;
|
||||
panel?: unknown;
|
||||
} | null;
|
||||
return {
|
||||
surface: parsed?.surface === "database-management" ? "database-management" : "core",
|
||||
panel: parsed?.panel === "workspace" || parsed?.panel === "pi" ? parsed.panel : null,
|
||||
};
|
||||
} catch {
|
||||
return { surface: "core", panel: null };
|
||||
}
|
||||
}
|
||||
|
||||
function sessionScopeTabClass(selected: boolean): string {
|
||||
const base = "relative -mb-px flex h-8 w-full cursor-pointer items-center justify-center rounded-t-md border border-b-2 px-2 text-xs font-semibold tracking-[0.005em] outline-none focus-visible:z-10 focus-visible:ring-3 focus-visible:ring-[oklch(var(--nav-active-border)/0.28)]";
|
||||
@@ -166,7 +183,8 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [showActivity, setShowActivity] = useState(false);
|
||||
const [activeSurface, setActiveSurface] = useState<ActiveSurface>("core");
|
||||
const persistedNavigation = readPersistedNavigation();
|
||||
const [activeSurface, setActiveSurface] = useState<ActiveSurface>(persistedNavigation.surface);
|
||||
const databaseManagementPresentation = resolveDatabaseManagementPresentation({
|
||||
isDevelopment: import.meta.env.DEV,
|
||||
mode: import.meta.env.MODE,
|
||||
@@ -177,7 +195,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
const updateDatabaseNavigationState = useCallback((state: { dirty: boolean; busy: boolean }) => {
|
||||
databaseNavigationRef.current = state;
|
||||
}, []);
|
||||
const [activeManagementPanel, setActiveManagementPanel] = useState<ActiveManagementPanel>(null);
|
||||
const [activeManagementPanel, setActiveManagementPanel] = useState<ActiveManagementPanel>(persistedNavigation.panel);
|
||||
const [adminNavigationValue, setAdminNavigationValue] = useState<string[]>([]);
|
||||
const [activeOpen, setActiveOpen] = useState(true);
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
@@ -188,6 +206,10 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||
const [renameGroupTarget, setRenameGroupTarget] = useState<string | null>(null);
|
||||
const operationEpochRef = useRef(0);
|
||||
useEffect(() => {
|
||||
if (import.meta.env.MODE === "test") return;
|
||||
window.localStorage.setItem(APP_NAVIGATION_STORAGE_KEY, JSON.stringify({ surface: activeSurface, panel: activeManagementPanel }));
|
||||
}, [activeManagementPanel, activeSurface]);
|
||||
useEffect(() => () => { operationEpochRef.current += 1; }, []);
|
||||
|
||||
const groups = useMemo(
|
||||
|
||||
@@ -197,7 +197,6 @@ function registerCompletedSyncRun(run: CatalogSyncRun) {
|
||||
|
||||
const synchronizationScopes = [
|
||||
{ scope: "tables", label: "Synchronize tables" },
|
||||
{ scope: "columns", label: "Synchronize all columns" },
|
||||
{ scope: "relationships", label: "Synchronize relationships" },
|
||||
{ scope: "all", label: "Synchronize all" },
|
||||
] as const;
|
||||
@@ -234,9 +233,7 @@ test("renders Fleet Ledger with real metrics, conceptual row tooltips, and persi
|
||||
expect(screen.queryByText("Inspect physical metadata, curate descriptions and control catalog operations.")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Back to workspace" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Add database/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: /Revision \/ Evidence/ })).toBeVisible();
|
||||
expect(screen.getByRole("columnheader", { name: /NL→SQL runtime/ })).toBeVisible();
|
||||
expect(screen.getByRole("columnheader", { name: /Metadata Catalog/ })).toBeVisible();
|
||||
expect(screen.getByRole("columnheader", { name: /Catalog status/ })).toBeVisible();
|
||||
const summary = screen.getByRole("region", { name: "Fleet summary" });
|
||||
expect(await within(summary).findByText("2,275")).toBeVisible();
|
||||
expect(within(summary).getByText("75%")).toBeVisible();
|
||||
@@ -261,13 +258,14 @@ test("renders Fleet Ledger with real metrics, conceptual row tooltips, and persi
|
||||
await user.click(within(restoredDatabaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
const actionPicker = screen.getByRole("combobox", { name: "Batch action" });
|
||||
expect(within(actionPicker).getByRole("option", { name: /^Synchronize all(?:,|$)/ })).toBeVisible();
|
||||
expect(within(actionPicker).queryByRole("option", { name: "Synchronize all columns" })).not.toBeInTheDocument();
|
||||
await user.selectOptions(actionPicker, "generate-missing");
|
||||
expect(screen.getAllByRole("combobox", { name: "Metadata-generation LLM model" })).toHaveLength(1);
|
||||
expect(modelSelector).toHaveValue("local-qwen");
|
||||
|
||||
});
|
||||
|
||||
test("shows repository, NL→SQL runtime, and Metadata Catalog states independently", async () => {
|
||||
test("shows synchronization, description, and sensitive-data states independently", async () => {
|
||||
const configured = makeDatabase({ connectionStatus: "reachable", testedVersion: 3 });
|
||||
const needsRuntimeConfiguration = makeDatabase({
|
||||
...unconfigured,
|
||||
@@ -281,21 +279,58 @@ test("shows repository, NL→SQL runtime, and Metadata Catalog states independen
|
||||
renderPage({ rows: [configured, needsRuntimeConfiguration, orphan], presentation: "fleet" });
|
||||
|
||||
const configuredRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
expect(within(configuredRow).getByText("Active revision aaaaaaa")).toBeVisible();
|
||||
expect(within(configuredRow).getByText("Evidence materialized · filesystem")).toBeVisible();
|
||||
expect(within(configuredRow).getByText("Ready")).toBeVisible();
|
||||
expect(within(configuredRow).getByText("Configured")).toBeVisible();
|
||||
expect(within(configuredRow).getByText("Connection reachable")).toBeVisible();
|
||||
expect(within(configuredRow).getByText("Not synchronized")).toBeVisible();
|
||||
expect(within(configuredRow).getByText(/Descriptions ·/)).toBeVisible();
|
||||
expect(within(configuredRow).getByText(/Sensitive ·/)).toBeVisible();
|
||||
|
||||
const needsConfigurationRow = screen.getByRole("row", { name: /Research laboratory/ });
|
||||
expect(within(needsConfigurationRow).getByText("Evidence credentials required · http")).toBeVisible();
|
||||
expect(within(needsConfigurationRow).getByText("Configuration required")).toBeVisible();
|
||||
expect(within(needsConfigurationRow).getByText("Not configured")).toBeVisible();
|
||||
expect(within(needsConfigurationRow).getByText("Not synchronized")).toBeVisible();
|
||||
expect(within(needsConfigurationRow).getByText(/Descriptions ·/)).toBeVisible();
|
||||
|
||||
const orphanRow = screen.getByRole("row", { name: /Retired workspace/ });
|
||||
expect(within(orphanRow).getByText("Workspace missing")).toBeVisible();
|
||||
expect(within(orphanRow).getByText("Unavailable")).toBeVisible();
|
||||
expect(within(orphanRow).getByText("Orphaned configuration")).toBeVisible();
|
||||
expect(within(orphanRow).getByText("Not synchronized")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows the configured access type and endpoint in catalog status", async () => {
|
||||
const direct = makeDatabase();
|
||||
const rest = makeDatabase({
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
workspaceId: "rest-lab",
|
||||
workspaceName: "REST laboratory",
|
||||
binding: {
|
||||
transport: "rest_api",
|
||||
baseUrl: "https://203.0.113.18/dwh/",
|
||||
restPath: "/health",
|
||||
restAuth: "x-api-key",
|
||||
},
|
||||
});
|
||||
const ssh = makeDatabase({
|
||||
id: "44444444-4444-4444-8444-444444444444",
|
||||
workspaceId: "ssh-lab",
|
||||
workspaceName: "SSH laboratory",
|
||||
binding: {
|
||||
transport: "ssh_tunnel",
|
||||
sshHost: "bastion.example.test",
|
||||
sshPort: 2222,
|
||||
sshUsername: "operator",
|
||||
sshTargetHost: "postgres.internal",
|
||||
sshTargetPort: 5432,
|
||||
username: "reader",
|
||||
},
|
||||
});
|
||||
renderPage({ rows: [direct, rest, ssh], presentation: "fleet" });
|
||||
|
||||
const directRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
expect(within(directRow).getByText("Direct PostgreSQL")).toBeVisible();
|
||||
expect(within(directRow).getByText("db.internal:5432")).toBeVisible();
|
||||
|
||||
const restRow = screen.getByRole("row", { name: /REST laboratory/ });
|
||||
expect(within(restRow).getByText("REST")).toBeVisible();
|
||||
expect(within(restRow).getByText("203.0.113.18")).toBeVisible();
|
||||
|
||||
const sshRow = screen.getByRole("row", { name: /SSH laboratory/ });
|
||||
expect(within(sshRow).getByText("SSH")).toBeVisible();
|
||||
expect(within(sshRow).getByText("bastion.example.test:2222")).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps Fleet Ledger visible when a catalog response omits the Evidence projection", async () => {
|
||||
@@ -306,7 +341,7 @@ test("keeps Fleet Ledger visible when a catalog response omits the Evidence proj
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Database management" })).toBeVisible();
|
||||
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
expect(within(databaseRow).getByText("Evidence state unavailable")).toBeVisible();
|
||||
expect(within(databaseRow).getByText("Not synchronized")).toBeVisible();
|
||||
});
|
||||
|
||||
test("opens the relationship map directly from a Fleet database and restores focus on return", async () => {
|
||||
@@ -359,7 +394,7 @@ test("offers catalog configuration directly on an unconfigured Fleet workspace",
|
||||
expect(within(drawer).getByLabelText("Workspace")).toHaveValue("Research laboratory");
|
||||
});
|
||||
|
||||
test("keeps only the local back action while browsing Fleet Ledger columns", async () => {
|
||||
test("keeps the table back action in the Fleet Ledger breadcrumb while browsing columns", async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
||||
@@ -372,9 +407,17 @@ test("keeps only the local back action while browsing Fleet Ledger columns", asy
|
||||
name: "View tables for Policlinico San Donato",
|
||||
}));
|
||||
expect(await screen.findByRole("button", { name: "Back to databases" })).toBeVisible();
|
||||
expect(screen.getByRole("combobox", { name: "Table action" })).toBeDisabled();
|
||||
expect(screen.queryByRole("button", { name: "Sync history" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Description history" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Sensitive history" })).not.toBeInTheDocument();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "View columns for patients" }));
|
||||
expect(await screen.findByRole("button", { name: "Back to tables" })).toBeVisible();
|
||||
const tableBackAction = await screen.findByRole("button", { name: "Back to tables" });
|
||||
expect(tableBackAction).toBeVisible();
|
||||
expect(screen.getByRole("combobox", { name: "Column action" })).toBeDisabled();
|
||||
expect(screen.getByRole("navigation", { name: "Database hierarchy" }).parentElement)
|
||||
.toContainElement(tableBackAction);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("button", { name: "Back to databases" })).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -427,7 +470,7 @@ test("reopens database synchronization history when no run is active", async ()
|
||||
renderPage({ rows: [makeDatabase()], presentation: "fleet" });
|
||||
|
||||
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
await user.click(within(databaseRow).getByRole("button", { name: "View synchronization history for Policlinico San Donato" }));
|
||||
await user.click(within(databaseRow).getByText("Policlinico San Donato"));
|
||||
|
||||
const drawer = await screen.findByRole("dialog", { name: "Schema synchronization" });
|
||||
expect(drawer).toBeVisible();
|
||||
@@ -628,6 +671,7 @@ test.each(synchronizationScopes)(
|
||||
await user.click(screen.getByRole("menuitem", { name: label }));
|
||||
|
||||
await waitFor(() => expect(startBody).toEqual({ version: 3, scope, tableIds: [] }));
|
||||
expect(screen.getByText("1 selected")).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1001,15 +1045,16 @@ test("selected database Actions confirms and deletes catalog tables for the full
|
||||
],
|
||||
target: "tables",
|
||||
}));
|
||||
await waitFor(() => expect(screen.queryByText("2 selected")).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("textbox", { name: "Search databases" })).toHaveFocus());
|
||||
expect(screen.getByText("2 selected")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "Clear" }));
|
||||
expect(screen.queryByText("2 selected")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("presents completed synchronization steps as success and skips unneeded confirmation", async () => {
|
||||
test("presents completed database synchronization steps as success and skips unneeded confirmation", async () => {
|
||||
const user = userEvent.setup();
|
||||
const run = {
|
||||
...makeSyncRun("columns"),
|
||||
counts: { tables: 163, columns: 2_275 },
|
||||
...makeSyncRun("all"),
|
||||
counts: { tables: 163, columns: 2_275, relationships: 18 },
|
||||
};
|
||||
registerCompletedSyncRun(run);
|
||||
server.use(http.post("/api/catalog/databases/:databaseId/sync-runs", () => (
|
||||
@@ -1022,7 +1067,7 @@ test("presents completed synchronization steps as success and skips unneeded con
|
||||
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: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Synchronize all columns" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Synchronize all" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Succeeded" })).toBeVisible();
|
||||
const steps = screen.getByRole("list", { name: "Synchronization steps" });
|
||||
@@ -1039,7 +1084,7 @@ test("presents completed synchronization steps as success and skips unneeded con
|
||||
expect(steps.querySelectorAll(".text-primary")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("database Overview exposes every synchronization scope", async () => {
|
||||
test("database Overview exposes the database synchronization scopes", async () => {
|
||||
const user = userEvent.setup();
|
||||
let startBody: unknown;
|
||||
const run = makeSyncRun("relationships");
|
||||
@@ -1520,6 +1565,37 @@ test("starts description generation for multiple selected tables", async () => {
|
||||
expect(screen.getByText("2 selected")).toBeVisible();
|
||||
});
|
||||
|
||||
test("generates descriptions for every column in selected tables", async () => {
|
||||
const user = userEvent.setup();
|
||||
const queuedRun = makeDescriptionGenerationRun({ scope: "selected_columns", total: 1 });
|
||||
let startBody: unknown;
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({ models: [{ id: "local-qwen", label: "Local Qwen" }], default: "local-qwen" })),
|
||||
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
||||
http.get(`/api/catalog/databases/:databaseId/tables/${patientsTable.id}/columns`, () => HttpResponse.json([patientIdColumn])),
|
||||
http.post("/api/catalog/databases/:databaseId/description-generation-runs", async ({ request }) => {
|
||||
startBody = await request.json();
|
||||
return HttpResponse.json(queuedRun, { status: 202 });
|
||||
}),
|
||||
http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(queuedRun)),
|
||||
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])),
|
||||
);
|
||||
renderPage({ rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })] });
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Tables" }));
|
||||
await user.click(within(await screen.findByRole("row", { name: /patients/ })).getByRole("checkbox"));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate descriptions for all columns" }));
|
||||
|
||||
await waitFor(() => expect(startBody).toEqual({
|
||||
modelId: "local-qwen",
|
||||
scope: "selected_columns",
|
||||
targetIds: [patientIdColumn.id],
|
||||
}));
|
||||
expect(await screen.findByText("Column description generation started for 1 column")).toBeVisible();
|
||||
});
|
||||
|
||||
test("observes an active run from another browser and reopens a terminal run from history", async () => {
|
||||
const user = userEvent.setup();
|
||||
const activeRun = makeDescriptionGenerationRun({
|
||||
@@ -1913,6 +1989,12 @@ test("starts one selected column with the configured default model", async () =>
|
||||
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Tables" }));
|
||||
await user.click(await screen.findByRole("button", { name: "View columns for patients" }));
|
||||
expect(screen.queryByRole("button", { name: "Sync columns" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: /Select/i })).toBeVisible();
|
||||
expect(await screen.findByRole("checkbox", { name: "Sensitive data for id" })).toHaveAttribute(
|
||||
"title",
|
||||
"Mark this column as sensitive; this does not select it for actions.",
|
||||
);
|
||||
const columnRow = (await screen.findAllByRole("row", { name: /Patient identifier/ }))
|
||||
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
||||
expect(columnRow).toBeDefined();
|
||||
|
||||
@@ -80,6 +80,20 @@ const SYNC_STARTED_MESSAGES: Record<CatalogSyncScope, string> = {
|
||||
relationships: "Relationship synchronization started",
|
||||
all: "Full schema synchronization started",
|
||||
};
|
||||
const DATABASE_SCREEN_STORAGE_KEY = "thothii:database-management-screen";
|
||||
|
||||
function readPersistedDatabaseScreen(): DatabaseScreen {
|
||||
if (import.meta.env.MODE === "test") return { kind: "list" };
|
||||
try {
|
||||
const parsed = JSON.parse(window.localStorage.getItem(DATABASE_SCREEN_STORAGE_KEY) ?? "null") as { kind?: unknown; workspaceId?: unknown } | null;
|
||||
if ((parsed?.kind === "tables" || parsed?.kind === "relationships") && typeof parsed.workspaceId === "string") {
|
||||
return { kind: parsed.kind, workspaceId: parsed.workspaceId };
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed persisted navigation and return to the safe list view.
|
||||
}
|
||||
return { kind: "list" };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
canManage: boolean;
|
||||
@@ -156,12 +170,13 @@ export function DatabaseManagementPage({
|
||||
(run) => run.status === "running",
|
||||
);
|
||||
|
||||
const [screen, setScreen] = useState<DatabaseScreen>({ kind: "list" });
|
||||
const [screen, setScreen] = useState<DatabaseScreen>(readPersistedDatabaseScreen);
|
||||
const [selectedMetadataModel, setSelectedMetadataModel] = useState("");
|
||||
const [draft, setDraft] = useState<DatabaseFormDraft | null>(null);
|
||||
const [baseline, setBaseline] = useState("");
|
||||
const [formSource, setFormSource] = useState<FormSource | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedDatabaseId, setSelectedDatabaseId] = useState<string | null>(null);
|
||||
const [busyAction, setBusyAction] = useState<DatabaseBusyAction>(null);
|
||||
const [stale, setStale] = useState(false);
|
||||
const [staleBannerOpen, setStaleBannerOpen] = useState(true);
|
||||
@@ -171,6 +186,11 @@ export function DatabaseManagementPage({
|
||||
busy: false,
|
||||
});
|
||||
const [tableNestedNavigationActive, setTableNestedNavigationActive] = useState(false);
|
||||
const [tableNestedNavigationBack, setTableNestedNavigationBack] = useState<(() => void) | null>(null);
|
||||
const handleTableNestedNavigationChange = useCallback((active: boolean, onBack?: () => void) => {
|
||||
setTableNestedNavigationActive(active);
|
||||
setTableNestedNavigationBack(() => (active ? onBack ?? null : null));
|
||||
}, []);
|
||||
const [activeSyncRun, setActiveSyncRun] = useState<CatalogSyncRun | null>(null);
|
||||
const [syncHistoryDatabaseId, setSyncHistoryDatabaseId] = useState<string | null>(null);
|
||||
const [syncDrawerOpen, setSyncDrawerOpen] = useState(false);
|
||||
@@ -184,6 +204,12 @@ export function DatabaseManagementPage({
|
||||
suggestions: SensitiveDataSuggestion[];
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (import.meta.env.MODE === "test") return;
|
||||
const persisted = screen.kind === "tables" || screen.kind === "relationships" ? screen : { kind: "list" };
|
||||
window.localStorage.setItem(DATABASE_SCREEN_STORAGE_KEY, JSON.stringify(persisted));
|
||||
}, [screen]);
|
||||
|
||||
const originRef = useRef<HTMLElement | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const formHeadingRef = useRef<HTMLHeadingElement>(null);
|
||||
@@ -284,6 +310,7 @@ export function DatabaseManagementPage({
|
||||
setPartialSecretFailure(null);
|
||||
setTablesNavigationState({ dirty: false, busy: false });
|
||||
setTableNestedNavigationActive(false);
|
||||
setTableNestedNavigationBack(null);
|
||||
restoreListFocus();
|
||||
}, [restoreListFocus]);
|
||||
|
||||
@@ -346,6 +373,7 @@ export function DatabaseManagementPage({
|
||||
setPartialSecretFailure(null);
|
||||
setTablesNavigationState({ dirty: false, busy: false });
|
||||
setTableNestedNavigationActive(false);
|
||||
setTableNestedNavigationBack(null);
|
||||
setScreen({ kind: "tables", workspaceId: row.workspaceId });
|
||||
}, []);
|
||||
|
||||
@@ -358,6 +386,8 @@ export function DatabaseManagementPage({
|
||||
setStale(false);
|
||||
setPartialSecretFailure(null);
|
||||
setTablesNavigationState({ dirty: false, busy: false });
|
||||
setTableNestedNavigationActive(false);
|
||||
setTableNestedNavigationBack(null);
|
||||
setScreen({ kind: "relationships", workspaceId: row.workspaceId });
|
||||
}, []);
|
||||
|
||||
@@ -856,6 +886,11 @@ export function DatabaseManagementPage({
|
||||
}
|
||||
}, [invalidateCatalogMetrics, queryClient]);
|
||||
|
||||
const clearActiveCatalogTables = useCallback(async () => {
|
||||
if (!activeRow?.id) return;
|
||||
await deleteSelectedMetadata([activeRow], "tables");
|
||||
}, [activeRow, deleteSelectedMetadata]);
|
||||
|
||||
const syncDatabase = useCallback(async (scope: CatalogSyncScope) => {
|
||||
if (!activeRow?.id || !activeRow.configured) return;
|
||||
try {
|
||||
@@ -1038,13 +1073,12 @@ export function DatabaseManagementPage({
|
||||
onOpenOverview={() => openOverview(activeRow)}
|
||||
onOpenRelationships={() => openRelationships(activeRow)}
|
||||
onNavigationStateChange={setTablesNavigationState}
|
||||
onNestedNavigationChange={setTableNestedNavigationActive}
|
||||
onNestedNavigationChange={handleTableNestedNavigationChange}
|
||||
onRunStarted={rememberSyncRun}
|
||||
onOpenSync={() => openSync(activeRow)}
|
||||
onOpenDescriptionHistory={openDescriptionGenerationHistory}
|
||||
onOpenSensitiveHistory={openSensitiveDataSuggestionHistory}
|
||||
onDescriptionGenerationRunStarted={rememberDescriptionGenerationRun}
|
||||
onSuggestSensitive={suggestActiveDatabaseSensitiveFields}
|
||||
onClearCatalogTables={clearActiveCatalogTables}
|
||||
onCatalogMetricsChanged={invalidateCatalogMetrics}
|
||||
/>
|
||||
) : screen.kind === "relationships" && relationshipsVisible ? (
|
||||
@@ -1088,12 +1122,14 @@ export function DatabaseManagementPage({
|
||||
onEdit={editRow}
|
||||
onDelete={deleteRow}
|
||||
onOpenSync={openSync}
|
||||
onSelectionChange={(selected) => setSelectedDatabaseId(selected.length === 1 ? selected[0].id ?? null : null)}
|
||||
onTestSelected={testSelected}
|
||||
onSyncSelected={syncSelected}
|
||||
selectedMetadataModel={selectedMetadataModelAvailable ? selectedMetadataModel : null}
|
||||
descriptionGenerationActive={descriptionGenerationActive}
|
||||
onGenerateDescriptions={generateDatabaseDescriptions}
|
||||
onSuggestSensitive={suggestDatabaseSensitiveFields}
|
||||
sensitiveDataSuggestionRuns={sensitiveDataSuggestionRuns}
|
||||
onDeleteMetadataSelected={deleteSelectedMetadata}
|
||||
onRefresh={refreshList}
|
||||
/>
|
||||
@@ -1125,6 +1161,45 @@ export function DatabaseManagementPage({
|
||||
actions={(
|
||||
<>
|
||||
{fleetMetadataModelControl}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
aria-label="View schema synchronization logs"
|
||||
title="View schema synchronization progress and history"
|
||||
disabled={!canManage || (!selectedDatabaseId && !activeSyncRun?.databaseId)}
|
||||
onClick={() => openSync(selectedDatabaseId ? rows.find((row) => row.id === selectedDatabaseId) : undefined)}
|
||||
>
|
||||
<RefreshCw aria-hidden="true" />
|
||||
{currentActiveRun ? "Schema sync active" : "Schema sync logs"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
aria-label="View AI description generation logs"
|
||||
title="View AI description generation progress and history"
|
||||
disabled={!canManage}
|
||||
onClick={openDescriptionGenerationHistory}
|
||||
>
|
||||
<History aria-hidden="true" />
|
||||
{descriptionGenerationActive ? "AI descriptions active" : "AI description logs"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
aria-label="View sensitive analysis progress"
|
||||
title="View sensitive analysis progress and history"
|
||||
disabled={!canManage}
|
||||
onClick={openSensitiveDataSuggestionHistory}
|
||||
>
|
||||
<History aria-hidden="true" />
|
||||
{observedActiveSensitiveDataSuggestionRun ? "Sensitive analysis active" : "Sensitive analysis history"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
@@ -1144,9 +1219,17 @@ export function DatabaseManagementPage({
|
||||
navigation={(
|
||||
<FleetLedgerBreadcrumb
|
||||
items={fleetBreadcrumb}
|
||||
back={screen.kind === "relationships" || (screen.kind === "tables" && !tableNestedNavigationActive)
|
||||
back={screen.kind === "relationships"
|
||||
? { label: "Back to databases", onBack: showList, disabled: navigationBusy }
|
||||
: undefined}
|
||||
: screen.kind === "tables" && tableNestedNavigationActive
|
||||
? {
|
||||
label: "Back to tables",
|
||||
onBack: () => tableNestedNavigationBack?.(),
|
||||
disabled: navigationBusy || !tableNestedNavigationBack,
|
||||
}
|
||||
: screen.kind === "tables"
|
||||
? { label: "Back to databases", onBack: showList, disabled: navigationBusy }
|
||||
: undefined}
|
||||
/>
|
||||
)}
|
||||
children={<FleetLedgerContent busy={isLoading || isFetching}>{fleetContent}</FleetLedgerContent>}
|
||||
@@ -1246,10 +1329,12 @@ export function DatabaseManagementPage({
|
||||
onEdit={editRow}
|
||||
onDelete={deleteRow}
|
||||
onOpenSync={openSync}
|
||||
onSelectionChange={(selected) => setSelectedDatabaseId(selected.length === 1 ? selected[0].id ?? null : null)}
|
||||
onTestSelected={testSelected}
|
||||
onSyncSelected={syncSelected}
|
||||
selectedMetadataModel={selectedMetadataModelAvailable ? selectedMetadataModel : null}
|
||||
descriptionGenerationActive={descriptionGenerationActive}
|
||||
sensitiveDataSuggestionRuns={sensitiveDataSuggestionRuns}
|
||||
onGenerateDescriptions={generateDatabaseDescriptions}
|
||||
onSuggestSensitive={suggestDatabaseSensitiveFields}
|
||||
onDeleteMetadataSelected={deleteSelectedMetadata}
|
||||
@@ -1304,10 +1389,9 @@ export function DatabaseManagementPage({
|
||||
onNavigationStateChange={setTablesNavigationState}
|
||||
onRunStarted={rememberSyncRun}
|
||||
onOpenSync={() => openSync(activeRow)}
|
||||
onOpenDescriptionHistory={openDescriptionGenerationHistory}
|
||||
onOpenSensitiveHistory={openSensitiveDataSuggestionHistory}
|
||||
onDescriptionGenerationRunStarted={rememberDescriptionGenerationRun}
|
||||
onSuggestSensitive={suggestActiveDatabaseSensitiveFields}
|
||||
onClearCatalogTables={clearActiveCatalogTables}
|
||||
onCatalogMetricsChanged={invalidateCatalogMetrics}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -285,7 +285,12 @@ export function CatalogSyncDrawer({
|
||||
open
|
||||
ariaLabel="Schema synchronization"
|
||||
eyebrow="Schema synchronization"
|
||||
title={run ? `${stateLabel(run)[0].toUpperCase()}${stateLabel(run).slice(1)}` : "Synchronization history"}
|
||||
title={run ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{!terminal(run) ? <LoaderCircle className="size-5 animate-spin" aria-hidden="true" /> : null}
|
||||
{stateLabel(run)[0].toUpperCase()}{stateLabel(run).slice(1)}
|
||||
</span>
|
||||
) : "Synchronization history"}
|
||||
description={run ? `${run.scope} · ${elapsed(run, now)}` : undefined}
|
||||
onClose={onClose}
|
||||
closeLabel="Close synchronization drawer"
|
||||
|
||||
@@ -30,9 +30,7 @@ interface Props {
|
||||
descriptionGenerationActive: boolean;
|
||||
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
|
||||
onNavigationStateChange: (state: DatabaseNavigationState) => void;
|
||||
onSync: () => void;
|
||||
onSuggestSensitive: (selection: SensitiveDataSuggestionRequest, scopeLabel: string) => Promise<void>;
|
||||
bindingReady?: boolean;
|
||||
catalogOperationActive?: boolean;
|
||||
onCatalogMetricsChanged?: () => void | Promise<void>;
|
||||
presentation?: "legacy" | "fleet";
|
||||
@@ -79,6 +77,7 @@ function SensitiveCell({ data, context }: ICellRendererParams<CatalogColumn, unk
|
||||
checked={data.sensitive}
|
||||
disabled={!context.canManage || context.busy}
|
||||
aria-label={`Sensitive data for ${data.name}`}
|
||||
title="Mark this column as sensitive; this does not select it for actions."
|
||||
onChange={(event) => context.onSensitiveChange(data, event.target.checked)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
@@ -94,9 +93,7 @@ export function DatabaseColumns({
|
||||
descriptionGenerationActive,
|
||||
onDescriptionGenerationRunStarted,
|
||||
onNavigationStateChange,
|
||||
onSync,
|
||||
onSuggestSensitive,
|
||||
bindingReady = true,
|
||||
catalogOperationActive = false,
|
||||
onCatalogMetricsChanged,
|
||||
presentation = "legacy",
|
||||
@@ -291,7 +288,6 @@ export function DatabaseColumns({
|
||||
| "generate-descriptions"
|
||||
| "consolidate-descriptions"
|
||||
| "suggest-sensitive"
|
||||
| "sync-columns"
|
||||
| "save-sensitive";
|
||||
|
||||
const fleetActions: readonly FleetActionOption<FleetColumnAction>[] = [
|
||||
@@ -347,23 +343,6 @@ export function DatabaseColumns({
|
||||
? "Another action is running."
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: "sync-columns",
|
||||
label: "Synchronize columns",
|
||||
group: "Synchronization",
|
||||
scopeLabel: "All columns in this table",
|
||||
runLabel: "Synchronize",
|
||||
disabled: !canManage || !bindingReady || catalogOperationActive || busy,
|
||||
disabledReason: !canManage
|
||||
? "You do not have permission to synchronize the catalog."
|
||||
: !bindingReady
|
||||
? "Test the current database binding first."
|
||||
: catalogOperationActive
|
||||
? "Wait for the active catalog operation to finish."
|
||||
: busy
|
||||
? "Another action is running."
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: "save-sensitive",
|
||||
label: "Save sensitive-field changes",
|
||||
@@ -386,7 +365,6 @@ export function DatabaseColumns({
|
||||
if (action === "generate-descriptions") await generateDescriptions();
|
||||
else if (action === "consolidate-descriptions") await consolidateDescriptions();
|
||||
else if (action === "suggest-sensitive") await suggestSensitive();
|
||||
else if (action === "sync-columns") onSync();
|
||||
else await saveSensitive();
|
||||
};
|
||||
|
||||
@@ -397,7 +375,7 @@ export function DatabaseColumns({
|
||||
|
||||
const columns = useMemo<ColDef<CatalogColumn>[]>(() => [
|
||||
{ field: "ordinalPosition", headerName: "#", width: 64, maxWidth: 64, filter: "agNumberColumnFilter" },
|
||||
{ field: "sensitive", headerName: "Sensitive", minWidth: 110, width: 110, sortable: false, filter: false, resizable: false, cellRenderer: SensitiveCell },
|
||||
{ field: "sensitive", headerName: "Sensitive", headerTooltip: "Mark this column as sensitive; this does not select it for actions.", minWidth: 110, width: 110, sortable: false, filter: false, resizable: false, cellRenderer: SensitiveCell },
|
||||
{ field: "name", headerName: "Name", minWidth: 190, flex: 1, cellClass: "font-mono text-xs" },
|
||||
{ field: "dataType", headerName: "Type", minWidth: 150, flex: 0.8, cellClass: "font-mono text-xs" },
|
||||
{ headerName: "Keys", minWidth: 125, width: 125, sortable: false, filter: false, cellRenderer: KeyCell },
|
||||
@@ -512,7 +490,6 @@ export function DatabaseColumns({
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{data.length}</span>
|
||||
{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="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
|
||||
<Button type="button" disabled={!canManage || busy} onClick={onSync}><RefreshCw />Sync columns</Button>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
@@ -529,6 +506,17 @@ export function DatabaseColumns({
|
||||
defaultColDef={{ sortable: true, filter: true, resizable: true }}
|
||||
getRowId={({ data: row }) => row.id}
|
||||
rowSelection={{ mode: "multiRow", selectAll: "filtered", enableClickSelection: false }}
|
||||
selectionColumnDef={{
|
||||
headerName: "Select",
|
||||
headerTooltip: "Select columns for actions",
|
||||
width: 72,
|
||||
minWidth: 72,
|
||||
maxWidth: 72,
|
||||
pinned: "left",
|
||||
sortable: false,
|
||||
resizable: false,
|
||||
suppressMovable: true,
|
||||
}}
|
||||
onSelectionChanged={({ api }) => setSelectedIds(api.getSelectedRows().map((column) => column.id))}
|
||||
rowHeight={44}
|
||||
headerHeight={38}
|
||||
|
||||
@@ -115,7 +115,6 @@ test("Fleet columns distinguish a failed query from an empty catalog and retry i
|
||||
descriptionGenerationActive={false}
|
||||
onDescriptionGenerationRunStarted={noop}
|
||||
onNavigationStateChange={noop}
|
||||
onSync={noop}
|
||||
onSuggestSensitive={noopAsync}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Button } from "../../components/ui/button";
|
||||
import type {
|
||||
CatalogDatabase,
|
||||
CatalogSecretName,
|
||||
CatalogSyncScope,
|
||||
CatalogSyncRun,
|
||||
DatabaseBinding,
|
||||
DatabaseTransport,
|
||||
@@ -23,7 +22,7 @@ import type {
|
||||
DatabaseFormMode,
|
||||
} from "./model";
|
||||
import { statusLabel } from "./model";
|
||||
import { DatabaseSyncMenu } from "./DatabaseSyncMenu";
|
||||
import { DatabaseSyncMenu, type DatabaseSyncScope } from "./DatabaseSyncMenu";
|
||||
|
||||
const inputClass = "h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none transition focus:border-primary/60 focus:ring-3 focus:ring-ring/15 read-only:bg-muted/40 read-only:text-muted-foreground disabled:bg-muted disabled:text-muted-foreground";
|
||||
const labelClass = "grid min-w-0 gap-1.5 text-xs font-medium text-foreground";
|
||||
@@ -135,7 +134,7 @@ interface DatabaseFormProps {
|
||||
onRetrySecrets: () => void;
|
||||
onOpenTables: (origin: HTMLButtonElement) => void;
|
||||
onOpenRelationships: () => void;
|
||||
onSync: (scope: CatalogSyncScope) => void;
|
||||
onSync: (scope: DatabaseSyncScope) => void;
|
||||
onOpenSync: () => void;
|
||||
onOpenDescriptionHistory?: () => void;
|
||||
onOpenSensitiveHistory?: () => void;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { Menu } from "@base-ui/react/menu";
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
import {
|
||||
@@ -11,13 +12,15 @@ import "ag-grid-community/styles/ag-grid.css";
|
||||
import "ag-grid-community/styles/ag-theme-alpine.css";
|
||||
import { ChevronDown, Eye, History, Info, Link2, Pencil, RefreshCw, Rows3, Sparkles, Trash2, X } from "lucide-react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { getCatalogMetrics } from "../../api/catalog-databases";
|
||||
import type {
|
||||
CatalogDatabase,
|
||||
CatalogMetrics,
|
||||
SensitiveDataSuggestionRun,
|
||||
CatalogDatabaseMetadataDeleteTarget,
|
||||
CatalogSyncScope,
|
||||
DescriptionGenerationScope,
|
||||
} from "../../api/catalog-databases";
|
||||
import { databaseSyncItemClass, databaseSyncScopes } from "./DatabaseSyncMenu";
|
||||
import { databaseSyncItemClass, databaseSyncScopes, type DatabaseSyncScope } from "./DatabaseSyncMenu";
|
||||
import { FleetActionSelector, type FleetActionOption } from "./FleetActionSelector";
|
||||
import { NO_METADATA_GENERATION_LLM_MODEL_MESSAGE } from "./MetadataGenerationModelSelector";
|
||||
|
||||
@@ -37,10 +40,12 @@ interface DatabaseGridProps {
|
||||
onEdit: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
|
||||
onDelete: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
|
||||
onOpenSync: (row: CatalogDatabase) => void;
|
||||
onSelectionChange?: (rows: CatalogDatabase[]) => void;
|
||||
onTestSelected: (rows: CatalogDatabase[]) => Promise<void>;
|
||||
onSyncSelected: (rows: CatalogDatabase[], scope: CatalogSyncScope) => Promise<void>;
|
||||
onSyncSelected: (rows: CatalogDatabase[], scope: DatabaseSyncScope) => Promise<void>;
|
||||
selectedMetadataModel: string | null;
|
||||
descriptionGenerationActive: boolean;
|
||||
sensitiveDataSuggestionRuns?: SensitiveDataSuggestionRun[];
|
||||
onGenerateDescriptions: (
|
||||
rows: CatalogDatabase[],
|
||||
scope: Extract<DescriptionGenerationScope, "all" | "missing">,
|
||||
@@ -68,7 +73,6 @@ interface DatabaseGridContext {
|
||||
type DatabaseFleetAction =
|
||||
| "test"
|
||||
| "sync-tables"
|
||||
| "sync-columns"
|
||||
| "sync-relationships"
|
||||
| "sync-all"
|
||||
| "generate-all"
|
||||
@@ -124,87 +128,63 @@ function StateCell({
|
||||
);
|
||||
}
|
||||
|
||||
function evidenceStatus(row: CatalogDatabase): { label: string; tone: StateTone } {
|
||||
if (!row.workspaceEvidence) {
|
||||
return { label: "Evidence state unavailable", tone: "warning" };
|
||||
}
|
||||
switch (row.workspaceEvidence.state) {
|
||||
case "materialized_current_revision":
|
||||
return { label: "Evidence materialized", tone: "success" };
|
||||
case "configuration_required":
|
||||
return { label: "Evidence credentials required", tone: "warning" };
|
||||
case "configured_unverified":
|
||||
return { label: "Evidence configured, unverified", tone: "info" };
|
||||
case "workspace_unavailable":
|
||||
return { label: "Evidence unavailable", tone: "danger" };
|
||||
default:
|
||||
return { label: "No Evidence declared", tone: "neutral" };
|
||||
}
|
||||
function endpoint(host: string | undefined, port: number | undefined): string {
|
||||
if (!host) return "Endpoint not configured";
|
||||
return port ? `${host}:${port}` : host;
|
||||
}
|
||||
|
||||
function RevisionEvidenceCell({ row }: { row: CatalogDatabase }) {
|
||||
if (!row.workspaceRevision) {
|
||||
return <StateCell label="Workspace missing" detail="Revision and Evidence unavailable" tone="danger" />;
|
||||
function accessSummary(row: CatalogDatabase): { label: string; detail: string } {
|
||||
const transport = row.configured
|
||||
? row.binding.transport
|
||||
: row.runtimeBinding?.transport ?? row.binding.transport;
|
||||
if (transport === "rest_api") {
|
||||
if (!row.binding.baseUrl) return { label: "REST", detail: "Endpoint not configured" };
|
||||
try {
|
||||
return { label: "REST", detail: new URL(row.binding.baseUrl).host };
|
||||
} catch {
|
||||
return { label: "REST", detail: row.binding.baseUrl };
|
||||
}
|
||||
}
|
||||
const evidence = evidenceStatus(row);
|
||||
const source = row.workspaceEvidence?.sourceType ? ` · ${row.workspaceEvidence.sourceType}` : "";
|
||||
if (transport === "ssh_tunnel") {
|
||||
return { label: "SSH", detail: endpoint(row.binding.sshHost, row.binding.sshPort) };
|
||||
}
|
||||
return {
|
||||
label: "Direct PostgreSQL",
|
||||
detail: endpoint(row.binding.host, row.binding.port),
|
||||
};
|
||||
}
|
||||
|
||||
function coverageStatus(total: number, complete: number): { label: string; detail: string; tone: StateTone } {
|
||||
if (total === 0) return { label: "Not started", detail: "No catalog items", tone: "danger" };
|
||||
if (complete >= total) return { label: "Complete", detail: `${complete}/${total}`, tone: "success" };
|
||||
if (complete > 0) return { label: "Partial", detail: `${complete}/${total}`, tone: "warning" };
|
||||
return { label: "Not started", detail: `0/${total}`, tone: "danger" };
|
||||
}
|
||||
|
||||
function CatalogStatusCells({ row, metrics, sensitiveRun }: { row: CatalogDatabase; metrics?: CatalogMetrics; sensitiveRun?: SensitiveDataSuggestionRun }) {
|
||||
const access = accessSummary(row);
|
||||
const synchronized = row.schemaSyncedVersion === row.version;
|
||||
const syncStatus = !row.schemaSyncedVersion
|
||||
? { label: "Not synchronized", detail: "Schema not synchronized", tone: "danger" as const }
|
||||
: synchronized
|
||||
? { label: "Synchronized", detail: "Current schema", tone: "success" as const }
|
||||
: { label: "Partial", detail: "Schema revision changed", tone: "warning" as const };
|
||||
const descriptions = coverageStatus(metrics?.descriptionTargets ?? 0, metrics?.describedTargets ?? 0);
|
||||
const sensitiveProcessed = sensitiveRun ? sensitiveRun.suggestedSensitive + sensitiveRun.suggestedNonSensitive : 0;
|
||||
const sensitive = !sensitiveRun
|
||||
? { label: "Not started", detail: "No analysis run", tone: "danger" as const }
|
||||
: sensitiveRun.status === "completed" && sensitiveProcessed >= sensitiveRun.total
|
||||
? { label: "Complete", detail: `${sensitiveProcessed}/${sensitiveRun.total}`, tone: "success" as const }
|
||||
: ["failed", "interrupted"].includes(sensitiveRun.status)
|
||||
? { label: "Incomplete", detail: `${sensitiveProcessed}/${sensitiveRun.total}`, tone: "danger" as const }
|
||||
: { label: "Partial", detail: `${sensitiveProcessed}/${sensitiveRun.total}`, tone: "warning" as const };
|
||||
return (
|
||||
<StateCell
|
||||
label={`Active revision ${row.workspaceRevision.commit.slice(0, 7)}`}
|
||||
detail={`${evidence.label}${source}`}
|
||||
tone={evidence.tone}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function transportLabel(row: CatalogDatabase): string {
|
||||
const transport = row.runtimeBinding?.transport;
|
||||
if (transport === "postgres_direct") return "Direct PostgreSQL";
|
||||
if (transport === "rest_api") return "REST API";
|
||||
if (transport === "ssh_tunnel") return "SSH tunnel";
|
||||
return "No runtime binding";
|
||||
}
|
||||
|
||||
function RuntimeBindingCell({ row }: { row: CatalogDatabase }) {
|
||||
if (!row.runtimeBinding) {
|
||||
return <StateCell label="Unavailable" detail="Workspace is not in the active revision" tone="danger" />;
|
||||
}
|
||||
if (!row.runtimeBinding.sessionTransportSupported) {
|
||||
return <StateCell label="Unsupported for NL→SQL" detail={transportLabel(row)} tone="danger" />;
|
||||
}
|
||||
if (row.runtimeBinding.configurationState === "configuration_required") {
|
||||
return <StateCell label="Configuration required" detail={transportLabel(row)} tone="warning" />;
|
||||
}
|
||||
return <StateCell label="Ready" detail={transportLabel(row)} tone="success" />;
|
||||
}
|
||||
|
||||
function catalogConnectionDetail(row: CatalogDatabase): { label: string; className?: string } {
|
||||
if (row.activeSyncRun) return { label: "Metadata sync in progress", className: "text-primary" };
|
||||
if (row.testedVersion !== undefined && row.testedVersion !== row.version) {
|
||||
return { label: "Connection retest required", className: "text-amber-700 dark:text-amber-300" };
|
||||
}
|
||||
if (row.connectionStatus === "reachable") return { label: "Connection reachable" };
|
||||
if (row.connectionStatus === "failed") {
|
||||
return { label: "Connection test failed", className: "text-destructive" };
|
||||
}
|
||||
return { label: "Connection not tested" };
|
||||
}
|
||||
|
||||
function MetadataCatalogCell({ row }: { row: CatalogDatabase }) {
|
||||
if (!row.workspaceAvailable) {
|
||||
return <StateCell label="Orphaned configuration" detail="Workspace missing from repository" tone="danger" />;
|
||||
}
|
||||
if (!row.configured) {
|
||||
return <StateCell label="Not configured" detail="Configure this workspace" tone="neutral" />;
|
||||
}
|
||||
const connection = catalogConnectionDetail(row);
|
||||
return (
|
||||
<StateCell
|
||||
label="Configured"
|
||||
detail={connection.label}
|
||||
detailClassName={connection.className}
|
||||
tone="success"
|
||||
/>
|
||||
<div className="grid h-full min-w-[540px] grid-cols-4 gap-3 py-1">
|
||||
<StateCell label={access.label} detail={access.detail} tone="info" />
|
||||
<StateCell label={syncStatus.label} detail={syncStatus.detail} tone={syncStatus.tone} />
|
||||
<StateCell label={descriptions.label} detail={`Descriptions · ${descriptions.detail}`} tone={descriptions.tone} />
|
||||
<StateCell label={sensitive.label} detail={`Sensitive · ${sensitive.detail}`} tone={sensitive.tone} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -259,11 +239,6 @@ function DatabaseActionsCell({
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-end gap-0.5" onClick={(event) => event.stopPropagation()}>
|
||||
{data.activeSyncRun || (context.presentation === "fleet" && data.configured && data.id) ? (
|
||||
<Button type="button" variant="ghost" size="icon-lg" className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? "Synchronization history" : undefined} title={context.presentation === "legacy" ? `View synchronization for ${data.workspaceName}` : undefined} aria-label={`View synchronization history for ${data.workspaceName}`} onClick={() => context.onOpenSync(data)}>
|
||||
{data.activeSyncRun ? <RefreshCw className="animate-spin" aria-hidden="true" /> : <History aria-hidden="true" />}
|
||||
</Button>
|
||||
) : null}
|
||||
{context.presentation === "fleet" && context.onOpenTables ? (
|
||||
<span className="thot-fleet-icon-action inline-flex" data-tooltip="Tables">
|
||||
<Button
|
||||
@@ -357,16 +332,37 @@ export function DatabaseGrid({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onOpenSync,
|
||||
onSelectionChange,
|
||||
onTestSelected,
|
||||
onSyncSelected,
|
||||
selectedMetadataModel,
|
||||
descriptionGenerationActive,
|
||||
sensitiveDataSuggestionRuns = [],
|
||||
onGenerateDescriptions,
|
||||
onSuggestSensitive,
|
||||
onDeleteMetadataSelected,
|
||||
onRefresh,
|
||||
presentation = "legacy",
|
||||
}: DatabaseGridProps) {
|
||||
const metricQueries = useQueries({
|
||||
queries: rows.filter((row) => row.id).map((row) => ({
|
||||
queryKey: ["catalog-metrics", row.id],
|
||||
queryFn: () => getCatalogMetrics(row.id!),
|
||||
enabled: canManage,
|
||||
staleTime: 10_000,
|
||||
})),
|
||||
});
|
||||
const metricsByDatabase = useMemo(() => {
|
||||
const result: Record<string, CatalogMetrics> = {};
|
||||
rows.filter((row) => row.id).forEach((row, index) => {
|
||||
const metrics = metricQueries[index]?.data;
|
||||
if (metrics && row.id) result[row.id] = metrics;
|
||||
});
|
||||
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]);
|
||||
const compact = useCompactViewport();
|
||||
const gridRef = useRef<AgGridReact<CatalogDatabase>>(null);
|
||||
const actionsTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -390,68 +386,16 @@ export function DatabaseGrid({
|
||||
flex: compact ? undefined : 1.35,
|
||||
width: compact ? 112 : undefined,
|
||||
};
|
||||
const revisionEvidence: ColDef<CatalogDatabase> = {
|
||||
headerName: "Revision / Evidence",
|
||||
minWidth: 215,
|
||||
flex: 1.2,
|
||||
valueGetter: ({ data }) => {
|
||||
if (!data) return "";
|
||||
const revision = data.workspaceRevision?.commit.slice(0, 7) ?? "workspace missing";
|
||||
return `${revision} ${evidenceStatus(data).label}`;
|
||||
const status: ColDef<CatalogDatabase> = {
|
||||
headerName: "Catalog status",
|
||||
minWidth: 580,
|
||||
flex: 2.5,
|
||||
cellRenderer: ({ data }: ICellRendererParams<CatalogDatabase>) => {
|
||||
const databaseId = data?.id;
|
||||
return data
|
||||
? <CatalogStatusCells row={data} metrics={databaseId ? metricsByDatabase[databaseId] : undefined} sensitiveRun={databaseId ? sensitiveRunsByDatabase.get(databaseId) : undefined} />
|
||||
: null;
|
||||
},
|
||||
cellRenderer: ({ data }: ICellRendererParams<CatalogDatabase>) => (
|
||||
data ? <RevisionEvidenceCell row={data} /> : null
|
||||
),
|
||||
};
|
||||
const runtimeBinding: ColDef<CatalogDatabase> = {
|
||||
headerName: "NL→SQL runtime",
|
||||
minWidth: 175,
|
||||
flex: 1,
|
||||
valueGetter: ({ data }) => {
|
||||
if (!data?.runtimeBinding) return "Unavailable";
|
||||
if (!data.runtimeBinding.sessionTransportSupported) return "Unsupported for NL→SQL";
|
||||
return data.runtimeBinding.configurationState === "ready" ? "Ready" : "Configuration required";
|
||||
},
|
||||
cellRenderer: ({ data }: ICellRendererParams<CatalogDatabase>) => (
|
||||
data ? <RuntimeBindingCell row={data} /> : null
|
||||
),
|
||||
};
|
||||
const database: ColDef<CatalogDatabase> = {
|
||||
field: "databaseName", headerName: "Database", minWidth: 145, flex: 1,
|
||||
};
|
||||
const schema: ColDef<CatalogDatabase> = {
|
||||
field: "schema", headerName: "Schema", minWidth: 140, flex: 0.9,
|
||||
};
|
||||
const endpoint: ColDef<CatalogDatabase> = {
|
||||
headerName: "Endpoint",
|
||||
minWidth: 190,
|
||||
flex: 1.2,
|
||||
valueGetter: ({ data }) => {
|
||||
if (!data) return "";
|
||||
if (data.binding.transport === "rest_api") return data.binding.baseUrl ?? "";
|
||||
if (data.binding.transport === "ssh_tunnel") return data.binding.sshHost ?? "";
|
||||
return data.binding.host ? `${data.binding.host}:${data.binding.port ?? 5432}` : "";
|
||||
},
|
||||
};
|
||||
const metadataCatalog: ColDef<CatalogDatabase> = {
|
||||
headerName: "Metadata Catalog",
|
||||
minWidth: 185,
|
||||
flex: 1.05,
|
||||
valueGetter: ({ data }) => {
|
||||
if (!data) return "";
|
||||
if (!data.workspaceAvailable) return "Orphaned configuration";
|
||||
return data.configured ? `Configured ${catalogConnectionDetail(data).label}` : "Not configured";
|
||||
},
|
||||
cellRenderer: ({ data }: ICellRendererParams<CatalogDatabase>) => (
|
||||
data ? <MetadataCatalogCell row={data} /> : null
|
||||
),
|
||||
};
|
||||
const updated: ColDef<CatalogDatabase> = {
|
||||
field: "updatedAt",
|
||||
headerName: "Updated",
|
||||
minWidth: 170,
|
||||
flex: 0.9,
|
||||
valueFormatter: ({ value }) => value ? new Date(String(value)).toLocaleString() : "",
|
||||
};
|
||||
const actionsWidth = presentation === "fleet" ? 232 : 210;
|
||||
const actions: ColDef<CatalogDatabase> = {
|
||||
@@ -471,10 +415,8 @@ export function DatabaseGrid({
|
||||
cellRenderer: DatabaseActionsCell,
|
||||
};
|
||||
|
||||
return compact
|
||||
? [workspace, revisionEvidence, runtimeBinding, metadataCatalog, database, schema, endpoint, updated, actions]
|
||||
: [workspace, revisionEvidence, runtimeBinding, database, schema, endpoint, metadataCatalog, updated, actions];
|
||||
}, [compact, presentation]);
|
||||
return [workspace, status, actions];
|
||||
}, [compact, metricsByDatabase, presentation, sensitiveRunsByDatabase]);
|
||||
|
||||
const configuredCount = rows.filter((row) => row.configured).length;
|
||||
const hiddenSelected = selectedRows.filter((row) => {
|
||||
@@ -533,7 +475,6 @@ export function DatabaseGrid({
|
||||
return [
|
||||
{ id: "test", label: "Test connections", group: "Connection", disabled: !canTestSelection, disabledReason: testReason },
|
||||
{ id: "sync-tables", label: "Synchronize tables", group: "Synchronization", disabled: !canSyncSelection, disabledReason: syncReason },
|
||||
{ id: "sync-columns", label: "Synchronize all columns", group: "Synchronization", disabled: !canSyncSelection, disabledReason: syncReason },
|
||||
{ id: "sync-relationships", label: "Synchronize relationships", group: "Synchronization", disabled: !canSyncSelection, disabledReason: syncReason },
|
||||
{ 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 },
|
||||
@@ -580,15 +521,10 @@ export function DatabaseGrid({
|
||||
const perform = async (
|
||||
kind: "test" | "sync" | "generate" | "suggest" | "delete",
|
||||
operation: () => Promise<void>,
|
||||
clearSelection = true,
|
||||
) => {
|
||||
setAction(kind);
|
||||
try {
|
||||
await operation();
|
||||
if (clearSelection) {
|
||||
gridRef.current?.api.deselectAll();
|
||||
setSelectedRows([]);
|
||||
}
|
||||
setPendingDelete(null);
|
||||
setPendingGenerationScope(null);
|
||||
if (kind === "delete") window.setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||
@@ -612,11 +548,11 @@ export function DatabaseGrid({
|
||||
return;
|
||||
}
|
||||
if (selectedAction.startsWith("sync-")) {
|
||||
const scope = selectedAction.slice("sync-".length) as CatalogSyncScope;
|
||||
const scope = selectedAction.slice("sync-".length) as DatabaseSyncScope;
|
||||
await perform("sync", () => onSyncSelected(selectedRows, scope));
|
||||
return;
|
||||
}
|
||||
await perform("suggest", () => onSuggestSensitive(selectedRows), false);
|
||||
await perform("suggest", () => onSuggestSensitive(selectedRows));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -785,7 +721,7 @@ export function DatabaseGrid({
|
||||
variant="outline"
|
||||
disabled={!canManage || action !== null || descriptionGenerationActive}
|
||||
title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined}
|
||||
onClick={() => void perform("suggest", () => onSuggestSensitive(selectedRows), false)}
|
||||
onClick={() => void perform("suggest", () => onSuggestSensitive(selectedRows))}
|
||||
>
|
||||
<Sparkles />{action === "suggest" ? "Suggesting…" : "Suggest sensitive fields"}
|
||||
</Button>
|
||||
@@ -833,7 +769,9 @@ export function DatabaseGrid({
|
||||
onSelectionChanged={({ api }) => {
|
||||
if (pendingDelete && action === null) setPendingDelete(null);
|
||||
if (pendingGenerationScope && action === null) setPendingGenerationScope(null);
|
||||
setSelectedRows(api.getSelectedRows());
|
||||
const rows = api.getSelectedRows();
|
||||
setSelectedRows(rows);
|
||||
onSelectionChange?.(rows);
|
||||
}}
|
||||
rowHeight={52}
|
||||
headerHeight={38}
|
||||
|
||||
@@ -3,14 +3,15 @@ import { ChevronDown, RefreshCw } from "lucide-react";
|
||||
import { buttonVariants } from "../../components/ui/button";
|
||||
import type { CatalogSyncScope } from "../../api/catalog-databases";
|
||||
|
||||
export type DatabaseSyncScope = Exclude<CatalogSyncScope, "columns">;
|
||||
|
||||
export const databaseSyncItemClass = [
|
||||
"flex cursor-default select-none items-center rounded-md px-3 py-2 text-sm outline-none",
|
||||
"data-[highlighted]:bg-muted data-[disabled]:opacity-45",
|
||||
].join(" ");
|
||||
|
||||
export const databaseSyncScopes: ReadonlyArray<{ scope: CatalogSyncScope; label: string }> = [
|
||||
export const databaseSyncScopes: ReadonlyArray<{ scope: DatabaseSyncScope; label: string }> = [
|
||||
{ scope: "tables", label: "Synchronize tables" },
|
||||
{ scope: "columns", label: "Synchronize all columns" },
|
||||
{ scope: "relationships", label: "Synchronize relationships" },
|
||||
{ scope: "all", label: "Synchronize all" },
|
||||
];
|
||||
@@ -18,7 +19,7 @@ export const databaseSyncScopes: ReadonlyArray<{ scope: CatalogSyncScope; label:
|
||||
interface DatabaseSyncMenuProps {
|
||||
disabled: boolean;
|
||||
disabledReason?: string;
|
||||
onSelect: (scope: CatalogSyncScope) => void;
|
||||
onSelect: (scope: DatabaseSyncScope) => void;
|
||||
}
|
||||
|
||||
export function DatabaseSyncMenu({ disabled, disabledReason, onSelect }: DatabaseSyncMenuProps) {
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Menu } from "@base-ui/react/menu";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
import type { ColDef, ICellRendererParams } from "ag-grid-community";
|
||||
import { ArrowLeft, ChevronDown, Columns3, History, Pencil, RefreshCw, Save, Sparkles, Trash2, X } from "lucide-react";
|
||||
import { ArrowLeft, ChevronDown, Columns3, Pencil, RefreshCw, Save, Sparkles, Trash2, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { ApiError, apiErrorMessage } from "../../api/client";
|
||||
import {
|
||||
consolidateCatalogDescriptions,
|
||||
deleteCatalogTableMetadata,
|
||||
listCatalogColumns,
|
||||
listCatalogTables,
|
||||
startCatalogSync,
|
||||
startDescriptionGenerationRun,
|
||||
@@ -37,11 +38,10 @@ interface Props {
|
||||
onOpenOverview: () => void;
|
||||
onOpenRelationships: () => void;
|
||||
onNavigationStateChange: (state: DatabaseNavigationState) => void;
|
||||
onNestedNavigationChange?: (active: boolean) => void;
|
||||
onNestedNavigationChange?: (active: boolean, onBack?: () => void) => void;
|
||||
onRunStarted: (run: CatalogSyncRun) => void;
|
||||
onOpenSync: () => void;
|
||||
onOpenDescriptionHistory?: () => void;
|
||||
onOpenSensitiveHistory?: () => void;
|
||||
onClearCatalogTables?: () => Promise<void>;
|
||||
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
|
||||
onSuggestSensitive: (selection: SensitiveDataSuggestionRequest, scopeLabel: string) => Promise<void>;
|
||||
onCatalogMetricsChanged?: () => void | Promise<void>;
|
||||
@@ -95,8 +95,7 @@ export function DatabaseTables({
|
||||
onNestedNavigationChange,
|
||||
onRunStarted,
|
||||
onOpenSync,
|
||||
onOpenDescriptionHistory,
|
||||
onOpenSensitiveHistory,
|
||||
onClearCatalogTables,
|
||||
onDescriptionGenerationRunStarted,
|
||||
onSuggestSensitive,
|
||||
onCatalogMetricsChanged,
|
||||
@@ -134,9 +133,22 @@ export function DatabaseTables({
|
||||
const nestedNavigationActive = presentation === "fleet" && Boolean(activeTable) && tableSection === "columns";
|
||||
const bindingReady = database.connectionStatus === "reachable" && database.testedVersion === database.version;
|
||||
const currentRun = activeRun ?? database.activeSyncRun;
|
||||
const clearAllCatalogTables = async () => {
|
||||
if (!onClearCatalogTables || !window.confirm("Clear all catalog tables, columns, and relationships for this database? The source database will not be changed.")) return;
|
||||
await onClearCatalogTables();
|
||||
};
|
||||
const leaveTable = useCallback(() => {
|
||||
if (navigationBusy) return;
|
||||
if (dirty && !window.confirm("Discard unsaved metadata?")) return;
|
||||
setActiveTableId(null);
|
||||
setTableSection("overview");
|
||||
window.setTimeout(() => originRef.current?.focus(), 0);
|
||||
}, [dirty, navigationBusy]);
|
||||
|
||||
useEffect(() => { onNavigationStateChange({ dirty, busy: navigationBusy }); }, [dirty, navigationBusy, onNavigationStateChange]);
|
||||
useEffect(() => { onNestedNavigationChange?.(nestedNavigationActive); }, [nestedNavigationActive, onNestedNavigationChange]);
|
||||
useEffect(() => {
|
||||
onNestedNavigationChange?.(nestedNavigationActive, nestedNavigationActive ? leaveTable : undefined);
|
||||
}, [leaveTable, nestedNavigationActive, onNestedNavigationChange]);
|
||||
useEffect(() => () => { onNestedNavigationChange?.(false); }, [onNestedNavigationChange]);
|
||||
useEffect(() => {
|
||||
if (!activeTableId || !activeTable || editorVersion === activeTable.version || busy) return;
|
||||
@@ -168,13 +180,6 @@ export function DatabaseTables({
|
||||
setStaleBannerOpen(true);
|
||||
setColumnNavigation({ dirty: false, busy: false });
|
||||
};
|
||||
const leaveTable = () => {
|
||||
if (navigationBusy) return;
|
||||
if (dirty && !window.confirm("Discard unsaved metadata?")) return;
|
||||
setActiveTableId(null);
|
||||
setTableSection("overview");
|
||||
window.setTimeout(() => originRef.current?.focus(), 0);
|
||||
};
|
||||
const navigateDatabase = (target: "databases" | "overview" | "relationships") => {
|
||||
if (navigationBusy) return;
|
||||
if (dirty && !window.confirm("Discard unsaved metadata?")) return;
|
||||
@@ -281,6 +286,28 @@ export function DatabaseTables({
|
||||
toast.error(apiErrorMessage(error));
|
||||
} finally { setBusy(null); }
|
||||
};
|
||||
const generateColumnDescriptions = async () => {
|
||||
if (selectedIds.length === 0 || !selectedMetadataModel) return;
|
||||
setBusy("generate");
|
||||
try {
|
||||
const columns = (await Promise.all(selectedIds.map((tableId) => listCatalogColumns(databaseId, tableId)))).flat();
|
||||
const columnIds = columns.map((column) => column.id);
|
||||
if (columnIds.length === 0) {
|
||||
toast.error("The selected tables have no synchronized catalog columns.");
|
||||
return;
|
||||
}
|
||||
const run = await startDescriptionGenerationRun(
|
||||
databaseId,
|
||||
selectedMetadataModel,
|
||||
"selected_columns",
|
||||
columnIds,
|
||||
);
|
||||
onDescriptionGenerationRunStarted(run);
|
||||
toast.success(`Column description generation started for ${columnIds.length} column${columnIds.length === 1 ? "" : "s"}`);
|
||||
} catch (error) {
|
||||
toast.error(apiErrorMessage(error));
|
||||
} finally { setBusy(null); }
|
||||
};
|
||||
const suggestSensitive = async () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
setBusy("suggest");
|
||||
@@ -298,6 +325,7 @@ export function DatabaseTables({
|
||||
|
||||
type FleetTableAction =
|
||||
| "generate-descriptions"
|
||||
| "generate-column-descriptions"
|
||||
| "sync-tables"
|
||||
| "sync-columns"
|
||||
| "consolidate-descriptions"
|
||||
@@ -324,6 +352,24 @@ export function DatabaseTables({
|
||||
? "Another action is running."
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: "generate-column-descriptions",
|
||||
label: "Generate column descriptions",
|
||||
group: "Descriptions",
|
||||
runLabel: "Generate",
|
||||
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun),
|
||||
disabledReason: !canManage
|
||||
? "You do not have permission to generate descriptions."
|
||||
: selectedIds.length === 0
|
||||
? "Select at least one table."
|
||||
: !selectedMetadataModel
|
||||
? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE
|
||||
: descriptionGenerationActive || Boolean(currentRun)
|
||||
? "Wait for the active catalog operation to finish."
|
||||
: busy !== null
|
||||
? "Another action is running."
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: "consolidate-descriptions",
|
||||
label: "Move generated to Description",
|
||||
@@ -421,6 +467,7 @@ export function DatabaseTables({
|
||||
|
||||
const runFleetAction = async (action: FleetTableAction) => {
|
||||
if (action === "generate-descriptions") await generateDescriptions();
|
||||
else if (action === "generate-column-descriptions") await generateColumnDescriptions();
|
||||
else if (action === "sync-tables") await synchronize("tables");
|
||||
else if (action === "consolidate-descriptions") await consolidateDescriptions();
|
||||
else if (action === "sync-columns") await synchronize("columns", selectedIds);
|
||||
@@ -464,7 +511,7 @@ export function DatabaseTables({
|
||||
const databaseHeader = (
|
||||
<div className="px-4 pt-5 sm:px-5">
|
||||
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={navigationBusy} onClick={() => navigateDatabase("databases")}><ArrowLeft />Back to databases</Button>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5">
|
||||
<div>
|
||||
<p className="thot-label mb-1">Database management</p>
|
||||
<h2 ref={headingRef} tabIndex={-1} className="font-heading text-2xl font-semibold tracking-tight outline-none">{database.workspaceName}</h2>
|
||||
@@ -472,6 +519,7 @@ export function DatabaseTables({
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{currentRun ? <Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />View sync</Button> : null}
|
||||
{onClearCatalogTables ? <Button type="button" variant="outline" className="text-destructive" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void clearAllCatalogTables()}>Clear catalog tables</Button> : null}
|
||||
<Button type="button" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("all")}><RefreshCw />Sync all</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -488,18 +536,6 @@ export function DatabaseTables({
|
||||
return (
|
||||
<section aria-label={`Columns for ${activeTable.name}`} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
|
||||
<div className="flex min-h-12 flex-wrap items-center gap-3 border-b border-border bg-muted/25 px-3 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="thot-fleet-icon-action thot-fleet-back-action"
|
||||
disabled={navigationBusy}
|
||||
data-tooltip="Tables"
|
||||
aria-label="Back to tables"
|
||||
onClick={leaveTable}
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" /> Back to tables
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<p className="thot-label">Physical table</p>
|
||||
<h3 className="truncate font-heading text-lg font-semibold">{activeTable.name}</h3>
|
||||
@@ -513,9 +549,7 @@ export function DatabaseTables({
|
||||
descriptionGenerationActive={descriptionGenerationActive}
|
||||
onDescriptionGenerationRunStarted={onDescriptionGenerationRunStarted}
|
||||
onNavigationStateChange={setColumnNavigation}
|
||||
onSync={() => void synchronize("columns", [activeTable.id])}
|
||||
onSuggestSensitive={onSuggestSensitive}
|
||||
bindingReady={bindingReady}
|
||||
catalogOperationActive={Boolean(currentRun)}
|
||||
onCatalogMetricsChanged={onCatalogMetricsChanged}
|
||||
presentation="fleet"
|
||||
@@ -626,7 +660,6 @@ export function DatabaseTables({
|
||||
descriptionGenerationActive={descriptionGenerationActive}
|
||||
onDescriptionGenerationRunStarted={onDescriptionGenerationRunStarted}
|
||||
onNavigationStateChange={setColumnNavigation}
|
||||
onSync={() => void synchronize("columns", [activeTable.id])}
|
||||
onSuggestSensitive={onSuggestSensitive}
|
||||
onCatalogMetricsChanged={onCatalogMetricsChanged}
|
||||
/>
|
||||
@@ -728,6 +761,7 @@ export function DatabaseTables({
|
||||
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
|
||||
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateDescriptions()}>Generate {selectedIds.length === 1 ? "description" : "descriptions"}</Menu.Item>
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateColumnDescriptions()}>Generate descriptions for all columns</Menu.Item>
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("columns", selectedIds)}>Synchronize columns</Menu.Item>
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void consolidateDescriptions()}>Move generated description to Description</Menu.Item>
|
||||
<Menu.Separator className="my-1 h-px bg-border" />
|
||||
@@ -769,13 +803,6 @@ export function DatabaseTables({
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{presentation === "fleet" ? (
|
||||
<Button type="button" size="sm" variant="outline" className="thot-fleet-nav-action thot-fleet-nav-action--below" data-tooltip="Synchronization history" onClick={onOpenSync}>
|
||||
{currentRun ? <RefreshCw className="animate-spin" aria-hidden="true" /> : <History aria-hidden="true" />} Sync history
|
||||
</Button>
|
||||
) : null}
|
||||
{onOpenDescriptionHistory ? <Button type="button" size="sm" variant="outline" onClick={onOpenDescriptionHistory}><History aria-hidden="true" /> Description history</Button> : null}
|
||||
{onOpenSensitiveHistory ? <Button type="button" size="sm" variant="outline" onClick={onOpenSensitiveHistory}><History aria-hidden="true" /> Sensitive history</Button> : null}
|
||||
</div>
|
||||
{!bindingReady ? <div className="border-b border-border bg-muted/35 px-4 py-3 text-sm text-muted-foreground">Test the current database binding from Overview before synchronizing tables.</div> : null}
|
||||
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
|
||||
|
||||
@@ -109,6 +109,9 @@ test("replays ordered SSE events, reconnects from the latest sequence, and keeps
|
||||
renderDrawer();
|
||||
|
||||
const log = await screen.findByRole("log", { name: "Description generation events" });
|
||||
const progress = screen.getByRole("table", { name: "Description generation progress" });
|
||||
expect(within(progress).getAllByRole("columnheader")).toHaveLength(5);
|
||||
expect(log).toHaveClass("thot-catalog-drawer__event-log");
|
||||
expect(await within(log).findByText("Run queued")).toBeVisible();
|
||||
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
||||
expect(FakeEventSource.instances[0].url).toBe(
|
||||
|
||||
@@ -300,6 +300,16 @@ export function DescriptionGenerationDrawer({
|
||||
}
|
||||
const apparentlyStale = isApparentlyStale(run);
|
||||
const finalError = safeFinalError(run.errorSummary);
|
||||
const modelDisplay = modelLabel && modelLabel !== run.modelId
|
||||
? `${modelLabel} (${run.modelId})`
|
||||
: run.modelId;
|
||||
const progressCounters = [
|
||||
["total", "Total"],
|
||||
["processed", "Processed"],
|
||||
["generated", "Generated"],
|
||||
["nonGeneratable", "Not generated"],
|
||||
["failed", "Failed"],
|
||||
] as const;
|
||||
const footer = ["queued", "running"].includes(run.status) ? (
|
||||
<div className="w-full">
|
||||
{apparentlyStale ? (
|
||||
@@ -341,55 +351,80 @@ export function DescriptionGenerationDrawer({
|
||||
open
|
||||
ariaLabel="Description generation"
|
||||
eyebrow="Description generation"
|
||||
title={statusLabel(run)}
|
||||
description={`${modelLabel}${modelLabel !== run.modelId ? ` (${run.modelId})` : ""} · ${run.scope.replaceAll("_", " ")}`}
|
||||
title={(
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{!isTerminal(run) ? <LoaderCircle className="size-5 animate-spin" aria-hidden="true" /> : null}
|
||||
{statusLabel(run)}
|
||||
</span>
|
||||
)}
|
||||
description={run.scope.replaceAll("_", " ")}
|
||||
onClose={onClose}
|
||||
closeLabel="Close description generation"
|
||||
busy={action !== null}
|
||||
bodyClassName="thot-catalog-drawer__history-layout"
|
||||
bodyClassName="thot-catalog-drawer__history-layout thot-catalog-drawer__run-layout"
|
||||
footer={footer}
|
||||
>
|
||||
{runQuery.isError ? (
|
||||
<div className="thot-catalog-drawer__run-main">
|
||||
{runQuery.isError ? (
|
||||
<div role="alert" className="rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{apiErrorMessage(runQuery.error)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section aria-label="Description generation counters">
|
||||
<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>
|
||||
<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>
|
||||
</dl>
|
||||
</section>
|
||||
<section aria-label="Description generation 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>
|
||||
<dt className="text-muted-foreground">Started</dt><dd className="text-right tabular-nums">{timestamp(run.startedAt)}</dd>
|
||||
<dt className="text-muted-foreground">Updated</dt><dd className="text-right tabular-nums">{timestamp(run.updatedAt)}</dd>
|
||||
<dt className="text-muted-foreground">Finished</dt><dd className="text-right tabular-nums">{timestamp(run.finishedAt)}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="mt-4" aria-label="Description generation counters">
|
||||
<h3 className="thot-label mb-2">Progress</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{([
|
||||
["total", "Total"],
|
||||
["processed", "Processed"],
|
||||
["generated", "Generated"],
|
||||
["nonGeneratable", "Not generated"],
|
||||
["failed", "Failed"],
|
||||
] as const).map(([name, label]) => (
|
||||
<div key={name} className="rounded-md border border-border bg-muted/25 px-3 py-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</p>
|
||||
<p data-count={name} className="mt-1 text-lg font-semibold tabular-nums">{run[name]}</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="overflow-x-auto rounded-md border border-border bg-muted/20">
|
||||
<table className="w-full min-w-[28rem] table-fixed" aria-label="Description generation progress">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/45">
|
||||
{progressCounters.map(([name, label]) => (
|
||||
<th key={name} scope="col" className="border-l border-border px-2 py-1.5 text-left text-[10px] font-semibold uppercase tracking-wide text-muted-foreground first:border-l-0">
|
||||
{label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
{progressCounters.map(([name]) => (
|
||||
<td key={name} data-count={name} className="border-l border-border px-2 py-1.5 text-base font-semibold tabular-nums first:border-l-0">
|
||||
{run[name]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-5" aria-label="Description generation timestamps">
|
||||
<h3 className="thot-label mb-2">Timestamps</h3>
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
|
||||
<dt className="text-muted-foreground">Created</dt><dd className="text-right">{timestamp(run.createdAt)}</dd>
|
||||
<dt className="text-muted-foreground">Started</dt><dd className="text-right">{timestamp(run.startedAt)}</dd>
|
||||
<dt className="text-muted-foreground">Updated</dt><dd className="text-right">{timestamp(run.updatedAt)}</dd>
|
||||
<dt className="text-muted-foreground">Finished</dt><dd className="text-right">{timestamp(run.finishedAt)}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{finalError ? (
|
||||
<div className="mt-5 rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
<div className="mt-3 rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{finalError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="mt-5" aria-label="Description generation log">
|
||||
<section className="thot-catalog-drawer__events mt-3" aria-label="Description generation 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>
|
||||
@@ -397,7 +432,7 @@ export function DescriptionGenerationDrawer({
|
||||
<div
|
||||
role="log"
|
||||
aria-label="Description generation events"
|
||||
className="max-h-72 overflow-y-auto rounded-md bg-zinc-950 p-3 font-mono text-xs leading-5 text-zinc-200"
|
||||
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) => (
|
||||
<p key={event.sequence} className={event.level === "error" ? "text-red-300" : event.level === "warning" ? "text-amber-300" : undefined}>
|
||||
@@ -407,8 +442,9 @@ export function DescriptionGenerationDrawer({
|
||||
</div>
|
||||
{eventQuery.isError ? <p role="alert" className="mt-2 text-sm text-destructive">{apiErrorMessage(eventQuery.error)}</p> : null}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="thot-catalog-drawer__history mt-5" aria-label="Description generation history">
|
||||
<section className="thot-catalog-drawer__history" aria-label="Description generation 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">
|
||||
@@ -424,7 +460,7 @@ export function DescriptionGenerationDrawer({
|
||||
) : visibleHistory.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No description generation runs yet.</p>
|
||||
) : (
|
||||
<div className="max-h-56 divide-y divide-border overflow-y-auto rounded-md border border-border">
|
||||
<div className="thot-catalog-drawer__history-list divide-y divide-border overflow-y-auto rounded-md border border-border">
|
||||
{visibleHistory.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
|
||||
@@ -15,6 +15,19 @@ const actions = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
test("disables the picker when no rows are selected", () => {
|
||||
render(
|
||||
<FleetActionSelector
|
||||
actions={actions}
|
||||
selectedCount={0}
|
||||
onRun={vi.fn()}
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("combobox", { name: "Batch action" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("keeps unavailable commands discoverable and explains why Run is disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
@@ -50,6 +63,7 @@ test("runs an enabled command explicitly and resets the picker", async () => {
|
||||
await user.selectOptions(picker, "sync");
|
||||
expect(screen.getByText("All tables in this database")).toBeVisible();
|
||||
expect(screen.getByText("Action scope")).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Run action" })).toHaveClass("bg-destructive");
|
||||
await user.click(screen.getByRole("button", { name: "Run action" }));
|
||||
|
||||
expect(onRun).toHaveBeenCalledWith("sync");
|
||||
|
||||
@@ -44,7 +44,6 @@ export function FleetActionSelector<ActionId extends string>({
|
||||
selectedCount,
|
||||
hiddenSelectedCount = 0,
|
||||
busy = false,
|
||||
busyLabel = "Running…",
|
||||
selectRef,
|
||||
onRun,
|
||||
onClear,
|
||||
@@ -102,7 +101,7 @@ export function FleetActionSelector<ActionId extends string>({
|
||||
className="thot-fleet-action-selector__select"
|
||||
aria-label={label}
|
||||
value={selectedActionId}
|
||||
disabled={busy}
|
||||
disabled={busy || selectedCount === 0}
|
||||
onChange={(event) => setSelectedActionId(event.target.value as ActionId | "")}
|
||||
>
|
||||
<option value="">Choose an action…</option>
|
||||
@@ -123,13 +122,14 @@ export function FleetActionSelector<ActionId extends string>({
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selectedAction?.tone === "destructive" ? "destructive" : "default"}
|
||||
variant="destructive"
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={!selectedAction || selectedAction.disabled || busy}
|
||||
aria-describedby={unavailableReason ? `${hintId}-reason` : undefined}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
<Play aria-hidden="true" />
|
||||
{busy ? busyLabel : selectedAction?.runLabel ?? "Run action"}
|
||||
Run action
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={onClear}>
|
||||
<X aria-hidden="true" /> Clear
|
||||
|
||||
@@ -402,6 +402,7 @@
|
||||
}
|
||||
|
||||
.thot-fleet-action-selector__select {
|
||||
flex: 1 1 13rem;
|
||||
width: min(100%, 22rem);
|
||||
min-width: 13rem;
|
||||
height: 2rem;
|
||||
@@ -459,11 +460,11 @@
|
||||
.thot-fleet-ledger__drawer {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
inset-block: 0.75rem;
|
||||
inset-block: clamp(0.625rem, 2vh, 1rem);
|
||||
inset-inline-start: 50%;
|
||||
inset-inline-end: auto;
|
||||
display: flex;
|
||||
width: 60%;
|
||||
width: min(75rem, calc(100% - 2rem));
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@@ -479,10 +480,10 @@
|
||||
|
||||
.thot-fleet-ledger .thot-fleet-ledger__context-panel {
|
||||
position: absolute;
|
||||
inset-block: 0.75rem;
|
||||
inset-block: clamp(0.625rem, 2vh, 1rem);
|
||||
inset-inline-start: 50%;
|
||||
inset-inline-end: auto;
|
||||
width: 60%;
|
||||
width: min(75rem, calc(100% - 2rem));
|
||||
max-width: none;
|
||||
min-width: 0;
|
||||
transform: translateX(-50%);
|
||||
@@ -537,6 +538,20 @@
|
||||
box-shadow: inset 0 -1px 0 oklch(var(--primary-foreground) / 0.18);
|
||||
}
|
||||
|
||||
.thot-sensitive-run--success .thot-fleet-ledger__drawer-header,
|
||||
.thot-sensitive-run--success .thot-catalog-drawer__header {
|
||||
color: oklch(var(--primary-foreground));
|
||||
background-color: oklch(var(--success));
|
||||
border-color: oklch(var(--primary-foreground) / 0.18);
|
||||
}
|
||||
|
||||
.thot-sensitive-run--failure .thot-fleet-ledger__drawer-header,
|
||||
.thot-sensitive-run--failure .thot-catalog-drawer__header {
|
||||
color: oklch(var(--destructive-foreground));
|
||||
background-color: oklch(var(--destructive));
|
||||
border-color: oklch(var(--destructive-foreground) / 0.18);
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__drawer-header .thot-fleet-ledger__drawer-title,
|
||||
.thot-fleet-ledger__drawer-header .thot-fleet-ledger__drawer-description,
|
||||
.thot-fleet-ledger__drawer-header .thot-fleet-ledger__eyebrow,
|
||||
@@ -575,6 +590,19 @@
|
||||
box-shadow: inset 0 0 0 1px oklch(var(--success) / 0.5);
|
||||
}
|
||||
|
||||
.thot-catalog-drawer__event-log {
|
||||
min-height: 8rem;
|
||||
max-height: 18rem;
|
||||
}
|
||||
|
||||
.thot-catalog-drawer__history-list {
|
||||
max-height: 14rem;
|
||||
}
|
||||
|
||||
.thot-catalog-drawer__run-layout > .thot-catalog-drawer__history {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.thot-fleet-grid-surface {
|
||||
position: relative;
|
||||
}
|
||||
@@ -583,6 +611,13 @@
|
||||
background: oklch(var(--muted) / 0.32);
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.thot-fleet-grid-toolbar > .thot-fleet-action-selector {
|
||||
flex: 1 1 33rem;
|
||||
min-width: 33rem;
|
||||
}
|
||||
}
|
||||
|
||||
.thot-fleet-ledger-grid {
|
||||
--ag-header-background-color: oklch(var(--muted) / 0.84);
|
||||
--ag-odd-row-background-color: oklch(var(--card));
|
||||
@@ -605,6 +640,14 @@
|
||||
background: oklch(var(--muted) / 0.12);
|
||||
}
|
||||
|
||||
/* Keep selected rows readable without overpowering the status indicators. */
|
||||
.thot-fleet-ledger-grid .ag-row.ag-row-selected,
|
||||
.thot-fleet-ledger-grid .ag-row.ag-row-selected .ag-cell,
|
||||
.thot-fleet-ledger-grid .ag-row.ag-row-selected .ag-grid-pinned-left-cells,
|
||||
.thot-fleet-ledger-grid .ag-row.ag-row-selected .ag-grid-pinned-right-cells {
|
||||
background-color: oklch(93% 0.002 17.2) !important;
|
||||
}
|
||||
|
||||
.thot-fleet-icon-action {
|
||||
position: relative;
|
||||
}
|
||||
@@ -744,6 +787,10 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__drawer-body.thot-catalog-drawer__run-layout {
|
||||
padding: clamp(0.75rem, 1.5cqi, 1.25rem);
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__drawer-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -784,8 +831,8 @@
|
||||
@container fleet-surface (max-width: 47.999rem) {
|
||||
.thot-fleet-ledger__drawer,
|
||||
.thot-fleet-ledger .thot-fleet-ledger__context-panel {
|
||||
inset-block: 0.5rem;
|
||||
width: calc(100% - 1rem);
|
||||
inset-block: 0.625rem;
|
||||
width: calc(100% - 1.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,11 +863,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 40rem) {
|
||||
@container work-area-panel (min-width: 48rem) {
|
||||
.thot-catalog-drawer__history-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 13.5rem;
|
||||
gap: 0 1rem;
|
||||
grid-template-columns: minmax(0, 1fr) clamp(12.5rem, 25cqi, 15rem);
|
||||
gap: 0 clamp(0.75rem, 2cqi, 1.25rem);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
@@ -836,6 +883,45 @@
|
||||
align-self: start;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.thot-catalog-drawer__run-layout {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
align-content: stretch;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.thot-catalog-drawer__run-layout > .thot-catalog-drawer__run-main {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.thot-catalog-drawer__run-layout > .thot-catalog-drawer__history {
|
||||
position: static;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
align-self: stretch;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.thot-catalog-drawer__run-layout .thot-catalog-drawer__events {
|
||||
display: flex;
|
||||
min-height: 10rem;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.thot-catalog-drawer__run-layout .thot-catalog-drawer__event-log,
|
||||
.thot-catalog-drawer__run-layout .thot-catalog-drawer__history-list {
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 47.999rem) {
|
||||
|
||||
@@ -24,4 +24,17 @@ describe("Recent runs success styling contract", () => {
|
||||
expect(styles).toContain('button[data-status="succeeded"]');
|
||||
expect(styles).toMatch(/oklch\(var\(--success\)/);
|
||||
});
|
||||
|
||||
test("run drawers keep responsive gutters and switch columns from their own width", () => {
|
||||
const styles = readFileSync(resolve(sourceRoot, "FleetLedgerShell.css"), "utf8");
|
||||
|
||||
expect(styles).toContain("width: min(75rem, calc(100% - 2rem));");
|
||||
expect(styles).toContain(
|
||||
".thot-fleet-ledger__drawer-body.thot-catalog-drawer__run-layout",
|
||||
);
|
||||
expect(styles).toContain("padding: clamp(0.75rem, 1.5cqi, 1.25rem);");
|
||||
expect(styles).toContain("@container work-area-panel (min-width: 48rem)");
|
||||
expect(styles).toContain(".thot-catalog-drawer__history-list");
|
||||
expect(styles).toContain(".thot-catalog-drawer__event-log");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,6 +97,11 @@ test("shows sensitive suggestion results, safe events, and lets operators inspec
|
||||
|
||||
const drawer = await screen.findByRole("dialog", { name: "Sensitive suggestion 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(
|
||||
"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();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiErrorMessage } from "../../api/client";
|
||||
import {
|
||||
@@ -30,6 +31,12 @@ function isTerminal(run?: SensitiveDataSuggestionRun): boolean {
|
||||
return Boolean(run && ["completed", "failed", "interrupted"].includes(run.status));
|
||||
}
|
||||
|
||||
function outcomeClass(run: SensitiveDataSuggestionRun): string {
|
||||
if (run.status === "completed") return "thot-sensitive-run--success";
|
||||
if (["failed", "interrupted"].includes(run.status)) return "thot-sensitive-run--failure";
|
||||
return "";
|
||||
}
|
||||
|
||||
function safeFinalError(summary: string | null): string | null {
|
||||
const value = summary?.trim();
|
||||
if (!value) return null;
|
||||
@@ -133,56 +140,92 @@ export function SensitiveDataSuggestionHistoryDrawer({
|
||||
|
||||
const events = eventQuery.data ?? [];
|
||||
const finalError = safeFinalError(run.errorSummary);
|
||||
const modelDisplay = modelLabel && modelLabel !== run.modelId
|
||||
? `${modelLabel} (${run.modelId})`
|
||||
: run.modelId;
|
||||
const progressCounters = [
|
||||
["total", "Total columns"],
|
||||
["suggestedSensitive", "Sensitive"],
|
||||
["suggestedNonSensitive", "Not sensitive"],
|
||||
] as const;
|
||||
return (
|
||||
<FleetLedgerDrawer
|
||||
open
|
||||
className={outcomeClass(run)}
|
||||
ariaLabel="Sensitive suggestion history"
|
||||
eyebrow="Sensitive data suggestions"
|
||||
title={statusLabel(run)}
|
||||
description={`${modelLabel}${modelLabel !== run.modelId ? ` (${run.modelId})` : ""} · ${run.scope.replaceAll("_", " ")}`}
|
||||
title={(
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{!isTerminal(run) ? <LoaderCircle className="size-5 animate-spin" aria-hidden="true" /> : null}
|
||||
{statusLabel(run)}
|
||||
</span>
|
||||
)}
|
||||
description={run.scope.replaceAll("_", " ")}
|
||||
onClose={onClose}
|
||||
closeLabel="Close sensitive suggestion history"
|
||||
bodyClassName="thot-catalog-drawer__history-layout"
|
||||
bodyClassName="thot-catalog-drawer__history-layout thot-catalog-drawer__run-layout"
|
||||
>
|
||||
{runQuery.isError ? (
|
||||
<div className="thot-catalog-drawer__run-main">
|
||||
{runQuery.isError ? (
|
||||
<div role="alert" className="rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{apiErrorMessage(runQuery.error)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section aria-label="Sensitive suggestion counters">
|
||||
<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>
|
||||
<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>
|
||||
</dl>
|
||||
</section>
|
||||
<section aria-label="Sensitive suggestion 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>
|
||||
<dt className="text-muted-foreground">Started</dt><dd className="text-right tabular-nums">{timestamp(run.startedAt)}</dd>
|
||||
<dt className="text-muted-foreground">Updated</dt><dd className="text-right tabular-nums">{timestamp(run.updatedAt)}</dd>
|
||||
<dt className="text-muted-foreground">Finished</dt><dd className="text-right tabular-nums">{timestamp(run.finishedAt)}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="mt-4" aria-label="Sensitive suggestion counters">
|
||||
<h3 className="thot-label mb-2">Results</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{([
|
||||
["total", "Total columns"],
|
||||
["suggestedSensitive", "Sensitive"],
|
||||
["suggestedNonSensitive", "Not sensitive"],
|
||||
] as const).map(([name, label]) => (
|
||||
<div key={name} className="rounded-md border border-border bg-muted/25 px-3 py-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</p>
|
||||
<p data-count={name} className="mt-1 text-lg font-semibold tabular-nums">{run[name]}</p>
|
||||
</div>
|
||||
))}
|
||||
<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">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/45">
|
||||
{progressCounters.map(([name, label]) => (
|
||||
<th key={name} scope="col" className="border-l border-border px-2 py-1.5 text-left text-[10px] font-semibold uppercase tracking-wide text-muted-foreground first:border-l-0">
|
||||
{label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
{progressCounters.map(([name]) => (
|
||||
<td key={name} data-count={name} className="border-l border-border px-2 py-1.5 text-base font-semibold tabular-nums first:border-l-0">
|
||||
{run[name]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-5" aria-label="Sensitive suggestion timestamps">
|
||||
<h3 className="thot-label mb-2">Timestamps</h3>
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
|
||||
<dt className="text-muted-foreground">Created</dt><dd className="text-right">{timestamp(run.createdAt)}</dd>
|
||||
<dt className="text-muted-foreground">Started</dt><dd className="text-right">{timestamp(run.startedAt)}</dd>
|
||||
<dt className="text-muted-foreground">Updated</dt><dd className="text-right">{timestamp(run.updatedAt)}</dd>
|
||||
<dt className="text-muted-foreground">Finished</dt><dd className="text-right">{timestamp(run.finishedAt)}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{finalError ? (
|
||||
<div className="mt-5 rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
<div className="mt-3 rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{finalError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="mt-5" aria-label="Sensitive suggestion log">
|
||||
<section className="thot-catalog-drawer__events mt-3" aria-label="Sensitive suggestion 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>
|
||||
@@ -190,7 +233,7 @@ export function SensitiveDataSuggestionHistoryDrawer({
|
||||
<div
|
||||
role="log"
|
||||
aria-label="Sensitive suggestion events"
|
||||
className="max-h-72 overflow-y-auto rounded-md bg-zinc-950 p-3 font-mono text-xs leading-5 text-zinc-200"
|
||||
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) => (
|
||||
<p key={event.sequence} className={event.level === "error" ? "text-red-300" : event.level === "warning" ? "text-amber-300" : undefined}>
|
||||
@@ -200,8 +243,9 @@ export function SensitiveDataSuggestionHistoryDrawer({
|
||||
</div>
|
||||
{eventQuery.isError ? <p role="alert" className="mt-2 text-sm text-destructive">{apiErrorMessage(eventQuery.error)}</p> : null}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="thot-catalog-drawer__history mt-5" aria-label="Sensitive suggestion run history">
|
||||
<section className="thot-catalog-drawer__history" aria-label="Sensitive suggestion 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">
|
||||
@@ -215,7 +259,7 @@ export function SensitiveDataSuggestionHistoryDrawer({
|
||||
) : visibleHistory.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No sensitive suggestion runs yet.</p>
|
||||
) : (
|
||||
<div className="max-h-56 divide-y divide-border overflow-y-auto rounded-md border border-border">
|
||||
<div className="thot-catalog-drawer__history-list divide-y divide-border overflow-y-auto rounded-md border border-border">
|
||||
{visibleHistory.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
|
||||
Reference in New Issue
Block a user