feat: refine metadata catalog workflows
This commit is contained in:
@@ -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