feat: consolidate database management work

Add catalog-owned logical relationships and runtime snapshots, extend the database-management UI and validation coverage, and document the updated operational workflow.

Keep active sensitive-generation status in a tooltip and indicator, and update the layout E2E to follow the history action in its new database-scoped location.
This commit is contained in:
Codex
2026-09-01 14:46:55 +02:00
parent f586152636
commit 076c9742c5
73 changed files with 6966 additions and 610 deletions
@@ -249,6 +249,7 @@ export function CatalogSyncDrawer({
key={item.id}
type="button"
aria-current={item.id === run?.id ? "true" : undefined}
data-status={item.state}
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50 aria-[current=true]:bg-primary/8"
onClick={() => onRunChange(item)}
>
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { 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";
@@ -20,6 +20,7 @@ import {
import type { DatabaseNavigationState } from "./model";
import { FleetActionSelector, type FleetActionOption } from "./FleetActionSelector";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
import { NO_METADATA_GENERATION_LLM_MODEL_MESSAGE } from "./MetadataGenerationModelSelector";
interface Props {
databaseId: string;
@@ -34,7 +35,6 @@ interface Props {
bindingReady?: boolean;
catalogOperationActive?: boolean;
onCatalogMetricsChanged?: () => void | Promise<void>;
metadataModelControl?: ReactNode;
presentation?: "legacy" | "fleet";
}
@@ -99,7 +99,6 @@ export function DatabaseColumns({
bindingReady = true,
catalogOperationActive = false,
onCatalogMetricsChanged,
metadataModelControl,
presentation = "legacy",
}: Props) {
const queryClient = useQueryClient();
@@ -307,7 +306,7 @@ export function DatabaseColumns({
: selectedIds.length === 0
? "Select at least one column."
: !selectedMetadataModel
? "Choose a metadata model first."
? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE
: descriptionGenerationActive || catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
@@ -341,7 +340,7 @@ export function DatabaseColumns({
: selectedIds.length === 0
? "Select at least one column."
: !selectedMetadataModel
? "Choose a metadata model first."
? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE
: descriptionGenerationActive || catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
@@ -468,7 +467,6 @@ export function DatabaseColumns({
busyLabel={sensitiveAction === "suggest" ? "Suggesting…" : sensitiveAction === "save" ? "Saving…" : "Running…"}
onRun={runFleetAction}
onClear={clearSelection}
renderContext={(action) => action === "generate-descriptions" || action === "suggest-sensitive" ? metadataModelControl : null}
label="Column action"
/>
</>
@@ -501,7 +499,6 @@ export function DatabaseColumns({
busyLabel={sensitiveAction === "save" ? "Saving…" : "Running…"}
onRun={runFleetAction}
onClear={clearSelection}
renderContext={(action) => action === "generate-descriptions" || action === "suggest-sensitive" ? metadataModelControl : null}
label="Column action"
/>
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search columns" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
@@ -14,6 +14,9 @@ const database: CatalogDatabase = {
workspaceId: "psd-clinical",
workspaceName: "Policlinico San Donato",
workspaceAvailable: true,
workspaceRevision: { commit: "a".repeat(40), blob: "b".repeat(40) },
workspaceEvidence: { sourceType: "filesystem", state: "materialized_current_revision" },
runtimeBinding: { transport: "postgres_direct", configurationState: "ready", sessionTransportSupported: true },
configured: true,
engine: "postgres",
databaseName: "warehouse",
@@ -113,8 +113,6 @@ function SecretField({
interface DatabaseFormProps {
mode: DatabaseFormMode;
row: CatalogDatabase;
availableWorkspaces: CatalogDatabase[];
workspaceLocked: boolean;
draft: DatabaseFormDraft;
dirty: boolean;
canManage: boolean;
@@ -125,7 +123,6 @@ interface DatabaseFormProps {
partialSecretFailure: string | null;
headingRef: RefObject<HTMLHeadingElement>;
onBack: () => void;
onWorkspaceChange: (workspaceId: string) => void;
onFieldChange: (field: "databaseName" | "schema", value: string) => void;
onTransportChange: (transport: DatabaseTransport) => void;
onBindingChange: <K extends keyof DatabaseBinding>(key: K, value: DatabaseBinding[K]) => void;
@@ -140,16 +137,17 @@ interface DatabaseFormProps {
onOpenRelationships: () => void;
onSync: (scope: CatalogSyncScope) => void;
onOpenSync: () => void;
onOpenDescriptionHistory?: () => void;
onOpenSensitiveHistory?: () => void;
activeSyncRun?: CatalogSyncRun;
presentation?: "page" | "drawer";
}
export function databaseFormTitle(
mode: DatabaseFormMode,
workspaceLocked: boolean,
presentation: "page" | "drawer" = "page",
): string {
if (mode === "add") return workspaceLocked ? "Edit database" : "Add database";
if (mode === "configure") return "Configure catalog";
if (mode === "edit") return "Edit database";
if (mode === "delete") return presentation === "drawer" ? "Remove configuration" : "Delete database";
return "Database details";
@@ -158,8 +156,6 @@ export function databaseFormTitle(
export function DatabaseForm({
mode,
row,
availableWorkspaces,
workspaceLocked,
draft,
dirty,
canManage,
@@ -170,7 +166,6 @@ export function DatabaseForm({
partialSecretFailure,
headingRef,
onBack,
onWorkspaceChange,
onFieldChange,
onTransportChange,
onBindingChange,
@@ -185,12 +180,14 @@ export function DatabaseForm({
onOpenRelationships,
onSync,
onOpenSync,
onOpenDescriptionHistory,
onOpenSensitiveHistory,
activeSyncRun,
presentation = "page",
}: DatabaseFormProps) {
const title = databaseFormTitle(mode, workspaceLocked, presentation);
const title = databaseFormTitle(mode, presentation);
const readOnly = mode === "view" || mode === "delete";
const editable = mode === "add" || mode === "edit";
const editable = mode === "configure" || mode === "edit";
const busy = busyAction !== null;
const testDisabled = !canManage
|| !row.configured
@@ -268,27 +265,9 @@ export function DatabaseForm({
) : null}
<div className="grid gap-4 md:grid-cols-2">
{mode === "add" ? (
<Field label="Workspace">
<select
className={inputClass}
aria-label="Workspace"
value={draft.workspaceId}
disabled={workspaceLocked || busy}
onChange={(event) => onWorkspaceChange(event.target.value)}
>
{availableWorkspaces.map((workspace) => (
<option key={workspace.workspaceId} value={workspace.workspaceId}>
{workspace.workspaceName}
</option>
))}
</select>
</Field>
) : (
<Field label="Workspace">
<input className={inputClass} value={row.workspaceName} readOnly />
</Field>
)}
<Field label="Workspace">
<input className={inputClass} value={row.workspaceName} readOnly />
</Field>
<Field label="Workspace ID">
<input className={`${inputClass} font-mono text-xs`} value={row.workspaceId} readOnly />
</Field>
@@ -420,13 +399,13 @@ export function DatabaseForm({
<TestTube2 /> {busyAction === "test" ? "Testing…" : "Test connection"}
</Button>
) : null}
{mode === "add" || mode === "edit" ? (
{mode === "configure" || mode === "edit" ? (
<Button type="submit" disabled={saveDisabled}>
<Save />
{busyAction === "save"
? "Saving…"
: mode === "add"
? workspaceLocked ? "Save database" : "Add database"
: mode === "configure"
? "Save catalog configuration"
: "Save changes"}
</Button>
) : null}
@@ -459,6 +438,16 @@ export function DatabaseForm({
{activeSyncRun ? <RefreshCw className="animate-spin" /> : <History />} Sync history
</Button>
) : null}
{mode === "view" && row.configured && onOpenDescriptionHistory ? (
<Button type="button" variant="outline" size="sm" onClick={onOpenDescriptionHistory}>
<History /> Description history
</Button>
) : null}
{mode === "view" && row.configured && onOpenSensitiveHistory ? (
<Button type="button" variant="outline" size="sm" onClick={onOpenSensitiveHistory}>
<History /> Sensitive history
</Button>
) : null}
{mode === "view" ? (
<DatabaseSyncMenu
disabled={!canManage || !bindingReady || busy || Boolean(activeSyncRun)}
@@ -466,6 +455,8 @@ export function DatabaseForm({
onSelect={onSync}
/>
) : null}
{mode === "view" && row.configured && onOpenDescriptionHistory ? <Button type="button" variant="outline" onClick={onOpenDescriptionHistory}><History /> Description history</Button> : null}
{mode === "view" && row.configured && onOpenSensitiveHistory ? <Button type="button" variant="outline" onClick={onOpenSensitiveHistory}><History /> Sensitive history</Button> : null}
</div>
</div>
{row.lastTestedAt ? (
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { Menu } from "@base-ui/react/menu";
import { AgGridReact } from "ag-grid-react";
import {
@@ -9,7 +9,7 @@ import {
} from "ag-grid-community";
import "ag-grid-community/styles/ag-grid.css";
import "ag-grid-community/styles/ag-theme-alpine.css";
import { ChevronDown, Eye, History, Info, Pencil, RefreshCw, Rows3, Sparkles, Trash2, X } from "lucide-react";
import { ChevronDown, Eye, History, Info, Link2, Pencil, RefreshCw, Rows3, Sparkles, Trash2, X } from "lucide-react";
import { Button } from "../../components/ui/button";
import type {
CatalogDatabase,
@@ -17,9 +17,9 @@ import type {
CatalogSyncScope,
DescriptionGenerationScope,
} from "../../api/catalog-databases";
import { statusLabel } from "./model";
import { databaseSyncItemClass, databaseSyncScopes } from "./DatabaseSyncMenu";
import { FleetActionSelector, type FleetActionOption } from "./FleetActionSelector";
import { NO_METADATA_GENERATION_LLM_MODEL_MESSAGE } from "./MetadataGenerationModelSelector";
ModuleRegistry.registerModules([AllCommunityModule]);
@@ -33,6 +33,7 @@ interface DatabaseGridProps {
onSearchChange: (value: string) => void;
onView: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
onOpenTables?: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
onOpenRelationships?: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
onEdit: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
onDelete: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
onOpenSync: (row: CatalogDatabase) => void;
@@ -49,7 +50,6 @@ interface DatabaseGridProps {
rows: CatalogDatabase[],
target: CatalogDatabaseMetadataDeleteTarget,
) => Promise<void>;
metadataModelControl?: ReactNode;
onRefresh?: () => void | Promise<void>;
presentation?: "legacy" | "fleet";
}
@@ -59,6 +59,7 @@ interface DatabaseGridContext {
presentation: "legacy" | "fleet";
onView: DatabaseGridProps["onView"];
onOpenTables?: DatabaseGridProps["onOpenTables"];
onOpenRelationships?: DatabaseGridProps["onOpenRelationships"];
onEdit: DatabaseGridProps["onEdit"];
onDelete: DatabaseGridProps["onDelete"];
onOpenSync: DatabaseGridProps["onOpenSync"];
@@ -91,18 +92,120 @@ function useCompactViewport(): boolean {
return compact;
}
function StatusPill({ row }: { row: CatalogDatabase }) {
const tone = !row.workspaceAvailable
? "border-destructive/30 bg-destructive/10 text-destructive"
: !row.configured
? "border-border bg-muted text-muted-foreground"
: row.connectionStatus === "reachable"
? "border-emerald-300/70 bg-emerald-50 text-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-300"
: row.connectionStatus === "failed"
? "border-destructive/30 bg-destructive/10 text-destructive"
: "border-amber-300/70 bg-amber-50 text-amber-900 dark:bg-amber-950/30 dark:text-amber-300";
type StateTone = "neutral" | "info" | "success" | "warning" | "danger";
return <div className="flex items-center gap-1.5"><span className={`inline-flex max-w-full truncate rounded-full border px-2 py-0.5 text-[11px] font-semibold ${tone}`}>{statusLabel(row)}</span>{row.activeSyncRun ? <span className="inline-flex rounded-full border border-primary/30 bg-primary/8 px-2 py-0.5 text-[11px] font-semibold text-primary">Syncing</span> : null}</div>;
const stateToneClass: Record<StateTone, string> = {
neutral: "bg-muted-foreground/55",
info: "bg-sky-500",
success: "bg-emerald-500",
warning: "bg-amber-500",
danger: "bg-destructive",
};
function StateCell({
label,
detail,
tone,
detailClassName = "text-muted-foreground",
}: {
label: string;
detail: string;
tone: StateTone;
detailClassName?: string;
}) {
return (
<div className="flex h-full min-w-0 flex-col justify-center gap-0.5 py-1 leading-tight">
<span className="flex min-w-0 items-center gap-1.5 text-xs font-semibold text-foreground">
<span className={`size-1.5 shrink-0 rounded-full ${stateToneClass[tone]}`} aria-hidden="true" />
<span className="truncate">{label}</span>
</span>
<span className={`truncate pl-3 text-[11px] ${detailClassName}`}>{detail}</span>
</div>
);
}
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 RevisionEvidenceCell({ row }: { row: CatalogDatabase }) {
if (!row.workspaceRevision) {
return <StateCell label="Workspace missing" detail="Revision and Evidence unavailable" tone="danger" />;
}
const evidence = evidenceStatus(row);
const source = row.workspaceEvidence?.sourceType ? ` · ${row.workspaceEvidence.sourceType}` : "";
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"
/>
);
}
function DatabaseActionsCell({
@@ -111,11 +214,48 @@ function DatabaseActionsCell({
}: ICellRendererParams<CatalogDatabase, unknown, DatabaseGridContext>) {
if (!data || !context) return null;
if (data.workspaceAvailable && !data.configured) {
const configureReasonId = `database-configure-reason-${data.workspaceId}`;
return (
<div className="flex h-full items-center justify-end gap-1.5" onClick={(event) => event.stopPropagation()}>
<Button
type="button"
variant="ghost"
size="icon-lg"
className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined}
data-tooltip={context.presentation === "fleet" ? "Details" : undefined}
title={context.presentation === "legacy" ? `View ${data.workspaceName}` : undefined}
aria-label={`View ${data.workspaceName}`}
onClick={(event) => context.onView(data, event.currentTarget)}
>
{context.presentation === "fleet" ? <Info aria-hidden="true" /> : <Eye aria-hidden="true" />}
</Button>
<Button
type="button"
size="sm"
className="whitespace-nowrap"
aria-label={`Configure catalog for ${data.workspaceName}`}
aria-describedby={!context.canManage ? configureReasonId : undefined}
disabled={!context.canManage}
onClick={(event) => context.onEdit(data, event.currentTarget)}
>
Configure catalog
</Button>
{!context.canManage ? (
<span id={configureReasonId} className="sr-only">
Database management permission is required.
</span>
) : null}
</div>
);
}
const editDisabled = !context.canManage || !data.workspaceAvailable;
const deleteDisabled = !context.canManage || !data.configured;
const editLabel = `Edit ${data.workspaceName}`;
const editReasonId = `database-edit-reason-${data.workspaceId}`;
const deleteReasonId = `database-delete-reason-${data.workspaceId}`;
const relationshipsReasonId = `database-relationships-reason-${data.workspaceId}`;
return (
<div className="flex h-full items-center justify-end gap-0.5" onClick={(event) => event.stopPropagation()}>
@@ -138,6 +278,26 @@ function DatabaseActionsCell({
</Button>
</span>
) : null}
{context.presentation === "fleet" && context.onOpenRelationships ? (
<span className="thot-fleet-icon-action inline-flex" data-tooltip="Relationships">
<Button
type="button"
variant="ghost"
size="icon-lg"
aria-label={`View relationships for ${data.workspaceName}`}
aria-describedby={!data.configured || !data.id ? relationshipsReasonId : undefined}
disabled={!data.configured || !data.id}
onClick={(event) => context.onOpenRelationships?.(data, event.currentTarget)}
>
<Link2 aria-hidden="true" />
</Button>
{!data.configured || !data.id ? (
<span id={relationshipsReasonId} className="sr-only">
Save this database configuration before managing relationships.
</span>
) : null}
</span>
) : null}
<Button
type="button"
variant="ghost"
@@ -193,6 +353,7 @@ export function DatabaseGrid({
onSearchChange,
onView,
onOpenTables,
onOpenRelationships,
onEdit,
onDelete,
onOpenSync,
@@ -203,7 +364,6 @@ export function DatabaseGrid({
onGenerateDescriptions,
onSuggestSensitive,
onDeleteMetadataSelected,
metadataModelControl,
onRefresh,
presentation = "legacy",
}: DatabaseGridProps) {
@@ -218,8 +378,8 @@ export function DatabaseGrid({
Extract<DescriptionGenerationScope, "all" | "missing"> | null
>(null);
const context = useMemo<DatabaseGridContext>(
() => ({ canManage, presentation, onView, onOpenTables, onEdit, onDelete, onOpenSync }),
[canManage, presentation, onView, onOpenTables, onEdit, onDelete, onOpenSync],
() => ({ canManage, presentation, onView, onOpenTables, onOpenRelationships, onEdit, onDelete, onOpenSync }),
[canManage, presentation, onView, onOpenTables, onOpenRelationships, onEdit, onDelete, onOpenSync],
);
const columnDefs = useMemo<ColDef<CatalogDatabase>[]>(() => {
@@ -230,19 +390,38 @@ 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}`;
},
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 transport: ColDef<CatalogDatabase> = {
field: "binding.transport",
headerName: "Transport",
minWidth: 135,
flex: 0.9,
valueFormatter: ({ value }) => String(value ?? "").replaceAll("_", " "),
};
const endpoint: ColDef<CatalogDatabase> = {
headerName: "Endpoint",
minWidth: 190,
@@ -254,15 +433,17 @@ export function DatabaseGrid({
return data.binding.host ? `${data.binding.host}:${data.binding.port ?? 5432}` : "";
},
};
const status: ColDef<CatalogDatabase> = {
headerName: "Status",
minWidth: compact ? 96 : 130,
width: compact ? 96 : undefined,
flex: compact ? undefined : 0.8,
cellClass: compact ? "thot-database-status-cell" : undefined,
valueGetter: ({ data }) => (data ? statusLabel(data) : ""),
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 ? <StatusPill row={data} /> : null
data ? <MetadataCatalogCell row={data} /> : null
),
};
const updated: ColDef<CatalogDatabase> = {
@@ -272,7 +453,7 @@ export function DatabaseGrid({
flex: 0.9,
valueFormatter: ({ value }) => value ? new Date(String(value)).toLocaleString() : "",
};
const actionsWidth = presentation === "fleet" ? 196 : compact ? 148 : 164;
const actionsWidth = presentation === "fleet" ? 232 : 210;
const actions: ColDef<CatalogDatabase> = {
colId: "actions",
headerName: "Actions",
@@ -291,8 +472,8 @@ export function DatabaseGrid({
};
return compact
? [workspace, status, database, schema, transport, endpoint, updated, actions]
: [workspace, database, schema, transport, endpoint, status, updated, actions];
? [workspace, revisionEvidence, runtimeBinding, metadataCatalog, database, schema, endpoint, updated, actions]
: [workspace, revisionEvidence, runtimeBinding, database, schema, endpoint, metadataCatalog, updated, actions];
}, [compact, presentation]);
const configuredCount = rows.filter((row) => row.configured).length;
@@ -332,14 +513,14 @@ export function DatabaseGrid({
: undefined);
const generationReason = permissionReason
?? (selectedRows.length !== 1 ? "Select exactly one database" : undefined)
?? (!selectedMetadataModel ? "Choose a metadata model in the generation flow" : undefined)
?? (!selectedMetadataModel ? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE : undefined)
?? (descriptionGenerationActive ? "Wait for active description generation" : undefined)
?? (selectedRows.some((row) => !row.configured || !row.id || row.activeSyncRun)
? "The selected database must be configured and idle"
: undefined);
const sensitiveReason = permissionReason
?? (selectedRows.length !== 1 ? "Select exactly one database" : undefined)
?? (!selectedMetadataModel ? "Choose a metadata model in the suggestion flow" : undefined)
?? (!selectedMetadataModel ? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE : undefined)
?? (descriptionGenerationActive ? "Wait for the active metadata operation" : undefined)
?? (selectedRows.some((row) => !row.configured || !row.id || row.activeSyncRun)
? "The selected database must be configured and idle"
@@ -538,12 +719,6 @@ export function DatabaseGrid({
busyLabel={action === "suggest" ? "Suggesting…" : "Running…"}
selectRef={actionSelectRef}
onRun={runFleetAction}
renderContext={(selectedAction) => (
metadataModelControl
&& ["generate-all", "generate-missing", "suggest-sensitive"].includes(selectedAction)
? <div className="thot-fleet-metadata-model">{metadataModelControl}</div>
: null
)}
onClear={() => {
gridRef.current?.api.deselectAll();
setSelectedRows([]);
@@ -631,7 +806,7 @@ export function DatabaseGrid({
</Button>
) : null}</>}
<span className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">
{configuredCount}/{rows.length}
{configuredCount}/{rows.length} catalogs configured
</span>
{isFetching && !isLoading ? (
<span className="text-xs text-muted-foreground" role="status">Refreshing</span>
@@ -660,7 +835,7 @@ export function DatabaseGrid({
if (pendingGenerationScope && action === null) setPendingGenerationScope(null);
setSelectedRows(api.getSelectedRows());
}}
rowHeight={44}
rowHeight={52}
headerHeight={38}
animateRows={false}
/>
@@ -0,0 +1,379 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import { expect, test, vi } from "vitest";
import type {
CatalogColumn,
CatalogDatabase,
CatalogRelationship,
CatalogTable,
} from "../../api/catalog-databases";
import { Toaster } from "../../components/ui/sonner";
import { server } from "../../test/msw";
import { DatabaseRelationships } from "./DatabaseRelationships";
const databaseId = "11111111-1111-4111-8111-111111111111";
const database: CatalogDatabase = {
id: databaseId,
workspaceId: "psd",
workspaceName: "Policlinico San Donato",
workspaceAvailable: true,
workspaceRevision: { commit: "a".repeat(40), blob: "b".repeat(40) },
workspaceEvidence: { sourceType: "filesystem", state: "materialized_current_revision" },
runtimeBinding: { transport: "postgres_direct", configurationState: "ready", sessionTransportSupported: true },
configured: true,
engine: "postgres",
databaseName: "analytics",
schema: "public",
binding: { transport: "postgres_direct", port: 5432 },
connectionStatus: "reachable",
testedVersion: 3,
version: 3,
createdAt: "2026-08-31T12:00:00.000Z",
updatedAt: "2026-08-31T12:00:00.000Z",
activeSyncRun: undefined,
secrets: {
password: false,
apiKey: false,
sshPrivateKey: false,
sshPrivateKeyPassphrase: false,
sshKnownHosts: false,
tlsCa: false,
},
};
function relationship(
id: string,
origin: CatalogRelationship["origin"],
status: CatalogRelationship["status"],
sourceTableName: string,
): CatalogRelationship {
const physical = origin === "physical";
return {
id,
databaseId,
constraintName: physical ? `${sourceTableName}_user_id_fkey` : null,
sourceTableId: `${id}-source-table`,
sourceTableName,
targetTableId: `${id}-target-table`,
targetTableName: "users",
updateRule: physical ? "NO ACTION" : null,
deleteRule: physical ? "CASCADE" : null,
deferrable: false,
initiallyDeferred: false,
columns: [{
position: 1,
sourceColumnId: `${id}-source-column`,
sourceColumnName: "user_id",
targetColumnId: `${id}-target-column`,
targetColumnName: "id",
}],
origin,
status,
lastSyncedDatabaseVersion: physical ? 3 : null,
lastSyncedAt: physical ? "2026-08-31T12:00:00.000Z" : null,
createdAt: "2026-08-31T12:00:00.000Z",
updatedAt: "2026-08-31T12:00:00.000Z",
};
}
function renderRelationships(rows: CatalogRelationship[], canManage = true) {
server.use(http.get(
"/api/catalog/databases/:databaseId/relationships",
() => HttpResponse.json(rows),
));
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const callbacks = {
onBackToDatabases: vi.fn(),
onOpenOverview: vi.fn(),
onOpenTables: vi.fn(),
onRunStarted: vi.fn(),
onOpenSync: vi.fn(),
};
const view = render(
<QueryClientProvider client={client}>
<DatabaseRelationships
presentation="fleet"
database={database}
canManage={canManage}
{...callbacks}
/>
<Toaster duration={Infinity} />
</QueryClientProvider>,
);
return { ...view, client, callbacks };
}
test("shows one relationship map and filters active, excluded, and all relationships", async () => {
const user = userEvent.setup();
renderRelationships([
relationship("physical", "physical", "active", "visits"),
relationship("generated", "generated", "active", "orders"),
relationship("manual", "manual", "active", "payments"),
relationship("excluded", "generated", "excluded", "legacy_orders"),
]);
expect(await screen.findByText("visits")).toBeVisible();
expect(screen.getByText("orders")).toBeVisible();
expect(screen.getByText("payments")).toBeVisible();
expect(screen.queryByText("legacy_orders")).not.toBeInTheDocument();
expect(screen.getByText("Physical")).toBeVisible();
expect(screen.getByText("Generated")).toBeVisible();
expect(screen.getByText("Manual")).toBeVisible();
await user.selectOptions(screen.getByRole("combobox", { name: "Relationship status" }), "excluded");
expect(await screen.findByText("legacy_orders")).toBeVisible();
await waitFor(() => expect(screen.queryByText("visits")).not.toBeInTheDocument());
await user.selectOptions(screen.getByRole("combobox", { name: "Relationship status" }), "all");
expect(await screen.findByText("visits")).toBeVisible();
expect(screen.getByText("legacy_orders")).toBeVisible();
});
test("adds a manual relationship from four catalog selections", async () => {
const user = userEvent.setup();
const orders: CatalogTable = {
id: "22222222-2222-4222-8222-222222222222",
databaseId,
name: "orders",
sourceComment: null,
description: null,
generatedDescription: null,
version: 1,
createdAt: "2026-08-31T12:00:00.000Z",
updatedAt: "2026-08-31T12:00:00.000Z",
};
const users: CatalogTable = {
...orders,
id: "33333333-3333-4333-8333-333333333333",
name: "users",
};
const sourceColumn: CatalogColumn = {
id: "44444444-4444-4444-8444-444444444444",
tableId: orders.id,
name: "user_id",
ordinalPosition: 1,
dataType: "bigint",
isNullable: false,
defaultExpression: null,
primaryKeyPosition: null,
isPrimaryKey: false,
isForeignKey: false,
foreignKeyCount: 0,
sourceComment: null,
description: null,
generatedDescription: null,
sensitive: false,
lastSyncedDatabaseVersion: 3,
lastSyncedAt: "2026-08-31T12:00:00.000Z",
version: 1,
createdAt: "2026-08-31T12:00:00.000Z",
updatedAt: "2026-08-31T12:00:00.000Z",
};
const targetColumn: CatalogColumn = {
...sourceColumn,
id: "55555555-5555-4555-8555-555555555555",
tableId: users.id,
name: "id",
primaryKeyPosition: 1,
isPrimaryKey: true,
};
let requestBody: unknown;
server.use(
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([orders, users])),
http.get(
"/api/catalog/databases/:databaseId/tables/:tableId/columns",
({ params }) => HttpResponse.json(params.tableId === orders.id ? [sourceColumn] : [targetColumn]),
),
http.post("/api/catalog/databases/:databaseId/relationships", async ({ request }) => {
requestBody = await request.json();
return HttpResponse.json(relationship("created", "manual", "active", "orders"), { status: 201 });
}),
);
renderRelationships([]);
await user.click(await screen.findByRole("button", { name: "Add relationship" }));
const drawer = await screen.findByRole("dialog", { name: "Add relationship" });
expect(drawer).toHaveAttribute("aria-modal", "false");
const submit = within(drawer).getByRole("button", { name: "Add relationship" });
await user.selectOptions(screen.getByRole("combobox", { name: "Source table" }), orders.id);
await user.selectOptions(screen.getByRole("combobox", { name: "Source column" }), sourceColumn.id);
await user.selectOptions(screen.getByRole("combobox", { name: "Target table" }), users.id);
await user.selectOptions(screen.getByRole("combobox", { name: "Target column" }), targetColumn.id);
expect(submit).toBeEnabled();
await user.click(submit);
await waitFor(() => expect(requestBody).toEqual({
sourceColumnId: sourceColumn.id,
targetColumnId: targetColumn.id,
}));
expect(await screen.findByText("Relationship added.")).toBeVisible();
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Add relationship" })).not.toBeInTheDocument());
});
test("rebuilds generated relationships with progress and a result summary", async () => {
const user = userEvent.setup();
let finish: (() => void) | undefined;
server.use(http.post(
"/api/catalog/databases/:databaseId/relationships/rebuild-generated",
async () => {
await new Promise<void>((resolve) => { finish = resolve; });
return HttpResponse.json({ added: 12, alreadyPresent: 7, excluded: 3, ambiguous: 2 });
},
));
renderRelationships([]);
await user.selectOptions(
await screen.findByRole("combobox", { name: "Relationship action" }),
"rebuild-generated",
);
await user.click(screen.getByRole("button", { name: "Run action" }));
expect(await screen.findByRole("button", { name: "Rebuilding…" })).toBeDisabled();
finish?.();
expect(await screen.findByText(
"Rebuild complete: 12 added, 7 already present, 3 excluded, 2 ambiguous.",
)).toBeVisible();
});
test("reports when rebuilding finds no new relationships", async () => {
const user = userEvent.setup();
server.use(http.post(
"/api/catalog/databases/:databaseId/relationships/rebuild-generated",
() => HttpResponse.json({ added: 0, alreadyPresent: 0, excluded: 0, ambiguous: 0 }),
));
renderRelationships([]);
await user.selectOptions(
await screen.findByRole("combobox", { name: "Relationship action" }),
"rebuild-generated",
);
await user.click(screen.getByRole("button", { name: "Run action" }));
expect(await screen.findByText("Rebuild complete: no new relationships found.")).toBeVisible();
});
test("excludes a generated relationship after an explanatory drawer confirmation", async () => {
const user = userEvent.setup();
const generated = relationship("generated", "generated", "active", "orders");
let requestBody: unknown;
server.use(http.patch(
"/api/catalog/databases/:databaseId/relationships/:relationshipId",
async ({ request }) => {
requestBody = await request.json();
return HttpResponse.json({ ...generated, status: "excluded" });
},
));
renderRelationships([generated]);
await user.click(await screen.findByRole("button", {
name: "View relationship orders.user_id to users.id",
}));
const drawer = await screen.findByRole("dialog", { name: "Relationship details" });
expect(within(drawer).getByText("Generated")).toBeVisible();
await user.click(within(drawer).getByRole("button", { name: "Exclude" }));
expect(within(drawer).getByText(
"The relationship will move to Excluded. Rebuilds will not recreate it, and you can restore it later.",
)).toBeVisible();
const confirmExclude = within(drawer).getByRole("button", { name: "Exclude relationship" });
await waitFor(() => expect(confirmExclude).toHaveFocus());
await user.click(confirmExclude);
await waitFor(() => expect(requestBody).toEqual({ status: "excluded" }));
expect(await screen.findByText(
"Relationship excluded. Future rebuilds will leave it excluded.",
)).toBeVisible();
});
test("keeps physical relationships read-only in the details drawer", async () => {
const user = userEvent.setup();
renderRelationships([relationship("physical", "physical", "active", "visits")]);
await user.click(await screen.findByRole("button", {
name: "View relationship visits.user_id to users.id",
}));
const drawer = await screen.findByRole("dialog", { name: "Relationship details" });
expect(within(drawer).getByText("This information is synchronized from the source schema and is read-only.")).toBeVisible();
expect(within(drawer).queryByRole("button", { name: "Exclude" })).not.toBeInTheDocument();
expect(within(drawer).queryByRole("button", { name: "Restore" })).not.toBeInTheDocument();
expect(within(drawer).queryByRole("button", { name: "Delete permanently" })).not.toBeInTheDocument();
});
test("permanently deletes a logical relationship after warning that rebuild may recreate it", async () => {
const user = userEvent.setup();
const manual = relationship("manual", "manual", "active", "payments");
let deletes = 0;
server.use(http.delete(
"/api/catalog/databases/:databaseId/relationships/:relationshipId",
() => {
deletes += 1;
return new HttpResponse(null, { status: 204 });
},
));
renderRelationships([manual]);
await user.click(await screen.findByRole("button", {
name: "View relationship payments.user_id to users.id",
}));
const drawer = await screen.findByRole("dialog", { name: "Relationship details" });
await user.click(within(drawer).getByRole("button", { name: "Delete permanently" }));
expect(within(drawer).getByText(
"The relationship record will be erased. A future rebuild may infer it again.",
)).toBeVisible();
await user.click(within(drawer).getByRole("button", { name: "Delete permanently" }));
await waitFor(() => expect(deletes).toBe(1));
expect(await screen.findByText(
"Relationship deleted permanently. A future rebuild may infer it again.",
)).toBeVisible();
});
test("restores an excluded logical relationship", async () => {
const user = userEvent.setup();
const excluded = relationship("excluded", "generated", "excluded", "legacy_orders");
let requestBody: unknown;
server.use(http.patch(
"/api/catalog/databases/:databaseId/relationships/:relationshipId",
async ({ request }) => {
requestBody = await request.json();
return HttpResponse.json({ ...excluded, status: "active" });
},
));
renderRelationships([excluded]);
await user.selectOptions(
await screen.findByRole("combobox", { name: "Relationship status" }),
"excluded",
);
await user.click(await screen.findByRole("button", {
name: "View relationship legacy_orders.user_id to users.id",
}));
const drawer = await screen.findByRole("dialog", { name: "Relationship details" });
await user.click(within(drawer).getByRole("button", { name: "Restore" }));
await waitFor(() => expect(requestBody).toEqual({ status: "active" }));
expect(await screen.findByText("Relationship restored.")).toBeVisible();
});
test("keeps relationship context open when a logical mutation fails", async () => {
const user = userEvent.setup();
const generated = relationship("generated-error", "generated", "active", "orders");
server.use(http.patch(
"/api/catalog/databases/:databaseId/relationships/:relationshipId",
() => HttpResponse.json({ code: "relationship_not_found" }, { status: 404 }),
));
renderRelationships([generated]);
await user.click(await screen.findByRole("button", {
name: "View relationship orders.user_id to users.id",
}));
const drawer = await screen.findByRole("dialog", { name: "Relationship details" });
await user.click(within(drawer).getByRole("button", { name: "Exclude" }));
await user.click(within(drawer).getByRole("button", { name: "Exclude relationship" }));
expect(await screen.findByText("The relationship no longer exists. Refresh and try again.")).toBeVisible();
expect(screen.getByRole("dialog", { name: "Relationship details" })).toBeVisible();
});
@@ -1,19 +1,26 @@
import { useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AgGridReact } from "ag-grid-react";
import type { ColDef, ICellRendererParams } from "ag-grid-community";
import { ArrowLeft, History, Info, Play, RefreshCw } from "lucide-react";
import { ArrowLeft, History, Info, Play, Plus, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { apiErrorMessage } from "../../api/client";
import { ApiError, apiErrorMessage } from "../../api/client";
import {
createCatalogRelationship,
deleteCatalogRelationship,
listCatalogRelationships,
listCatalogColumns,
listCatalogTables,
rebuildGeneratedRelationships,
setCatalogRelationshipStatus,
startCatalogSync,
type CatalogDatabase,
type CatalogRelationship,
type CatalogSyncRun,
} from "../../api/catalog-databases";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
import type { DatabaseNavigationState } from "./model";
interface Props {
database: CatalogDatabase;
@@ -23,7 +30,10 @@ interface Props {
onOpenTables: () => void;
onRunStarted: (run: CatalogSyncRun) => void;
onOpenSync: () => void;
metadataModelControl?: ReactNode;
onOpenDescriptionHistory?: () => void;
onOpenSensitiveHistory?: () => void;
onCatalogMetricsChanged?: () => void | Promise<void>;
onNavigationStateChange?: (state: DatabaseNavigationState) => void;
presentation?: "legacy" | "fleet";
}
@@ -32,6 +42,19 @@ interface RelationshipGridContext {
onView: (relationship: CatalogRelationship, origin: HTMLButtonElement) => void;
}
type RelationshipStatusFilter = "active" | "excluded" | "all";
function titleCase(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
}
function relationshipLabel(relationship: CatalogRelationship): string {
const pair = relationship.columns[0];
const source = `${relationship.sourceTableName}${pair ? `.${pair.sourceColumnName}` : ""}`;
const target = `${relationship.targetTableName}${pair ? `.${pair.targetColumnName}` : ""}`;
return `${source} to ${target}`;
}
function RelationshipActionsCell({ data, context }: ICellRendererParams<CatalogRelationship, unknown, RelationshipGridContext>) {
if (!data || !context || context.presentation !== "fleet") return null;
return (
@@ -42,7 +65,7 @@ function RelationshipActionsCell({ data, context }: ICellRendererParams<CatalogR
size="icon-lg"
className="thot-fleet-icon-action"
data-tooltip="Details"
aria-label={`View relationship ${data.constraintName}`}
aria-label={`View relationship ${relationshipLabel(data)}`}
onClick={(event) => context.onView(data, event.currentTarget)}
>
<Info aria-hidden="true" />
@@ -59,32 +82,86 @@ export function DatabaseRelationships({
onOpenTables,
onRunStarted,
onOpenSync,
onOpenDescriptionHistory,
onOpenSensitiveHistory,
onCatalogMetricsChanged,
onNavigationStateChange,
presentation = "legacy",
}: Props) {
const databaseId = database.id!;
const queryClient = useQueryClient();
const { data = [], error, isError, isLoading, isFetching, refetch } = useQuery({
queryKey: ["catalog-relationships", databaseId],
queryFn: () => listCatalogRelationships(databaseId),
retry: false,
});
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<RelationshipStatusFilter>("active");
const [busy, setBusy] = useState(false);
const [selectedAction, setSelectedAction] = useState<"" | "relationships" | "all">("");
const [selectedAction, setSelectedAction] = useState<"" | "rebuild-generated" | "relationships" | "all">("");
const [activeRelationshipId, setActiveRelationshipId] = useState<string | null>(null);
const [addOpen, setAddOpen] = useState(false);
const [sourceTableId, setSourceTableId] = useState("");
const [sourceColumnId, setSourceColumnId] = useState("");
const [targetTableId, setTargetTableId] = useState("");
const [targetColumnId, setTargetColumnId] = useState("");
const [addError, setAddError] = useState<string | null>(null);
const [targetColumnError, setTargetColumnError] = useState<string | null>(null);
const [pendingMutation, setPendingMutation] = useState<"exclude" | "delete" | null>(null);
const originRef = useRef<HTMLButtonElement | null>(null);
const statusFilterRef = useRef<HTMLSelectElement | null>(null);
const bindingReady = database.connectionStatus === "reachable" && database.testedVersion === database.version;
const activeRelationship = activeRelationshipId
? data.find((relationship) => relationship.id === activeRelationshipId)
: undefined;
const visibleRelationships = useMemo(() => statusFilter === "all"
? data
: data.filter((relationship) => relationship.status === statusFilter), [data, statusFilter]);
const tablesQuery = useQuery({
queryKey: ["catalog-tables", databaseId],
queryFn: () => listCatalogTables(databaseId),
enabled: addOpen,
retry: false,
});
const sourceColumnsQuery = useQuery({
queryKey: ["catalog-columns", databaseId, sourceTableId],
queryFn: () => listCatalogColumns(databaseId, sourceTableId),
enabled: addOpen && Boolean(sourceTableId),
retry: false,
});
const targetColumnsQuery = useQuery({
queryKey: ["catalog-columns", databaseId, targetTableId],
queryFn: () => listCatalogColumns(databaseId, targetTableId),
enabled: addOpen && Boolean(targetTableId),
retry: false,
});
const targetColumns = (targetColumnsQuery.data ?? []).filter((column) => column.isPrimaryKey);
useEffect(() => {
if (!pendingMutation) return;
const timer = window.setTimeout(() => document
.getElementById("relationship-mutation-confirm-button")?.focus(), 0);
return () => window.clearTimeout(timer);
}, [pendingMutation]);
useEffect(() => {
onNavigationStateChange?.({ dirty: false, busy });
return () => onNavigationStateChange?.({ dirty: false, busy: false });
}, [busy, onNavigationStateChange]);
const selectedActionUnavailable = !selectedAction
? null
: !canManage
? "You do not have permission to synchronize the catalog."
: !bindingReady
? "Test the current database binding first."
: database.activeSyncRun
? "Wait for the active synchronization to finish."
? "You do not have permission to manage catalog relationships."
: database.activeSyncRun
? "Wait for the active synchronization to finish."
: selectedAction !== "rebuild-generated" && !bindingReady
? "Test the current database binding first."
: null;
const addUnavailable = !canManage
? "You do not have permission to add catalog relationships."
: database.activeSyncRun
? "Wait for the active synchronization to finish."
: busy
? "Wait for the current relationship operation to finish."
: null;
const synchronize = async (scope: "relationships" | "all") => {
setBusy(true);
@@ -97,28 +174,146 @@ export function DatabaseRelationships({
const openRelationship = (relationship: CatalogRelationship, origin: HTMLButtonElement) => {
originRef.current = origin;
setPendingMutation(null);
setAddOpen(false);
setActiveRelationshipId(relationship.id);
};
const closeRelationship = () => {
if (busy) return;
setPendingMutation(null);
setActiveRelationshipId(null);
window.setTimeout(() => originRef.current?.focus(), 0);
window.setTimeout(() => {
if (originRef.current?.isConnected) originRef.current.focus();
else statusFilterRef.current?.focus();
}, 0);
};
const changeRelationshipStatus = async (status: "active" | "excluded") => {
if (!activeRelationship || activeRelationship.origin === "physical") return;
setBusy(true);
try {
await setCatalogRelationshipStatus(databaseId, activeRelationship.id, status);
await queryClient.invalidateQueries({ queryKey: ["catalog-relationships", databaseId], exact: true });
await onCatalogMetricsChanged?.();
setPendingMutation(null);
setActiveRelationshipId(null);
window.setTimeout(() => {
if (originRef.current?.isConnected) originRef.current.focus();
else statusFilterRef.current?.focus();
}, 0);
toast.success(status === "excluded"
? "Relationship excluded. Future rebuilds will leave it excluded."
: "Relationship restored.");
} catch (error) {
toast.error(apiErrorMessage(error));
} finally {
setBusy(false);
}
};
const permanentlyDeleteRelationship = async () => {
if (!activeRelationship || activeRelationship.origin === "physical") return;
setBusy(true);
try {
await deleteCatalogRelationship(databaseId, activeRelationship.id);
await queryClient.invalidateQueries({ queryKey: ["catalog-relationships", databaseId], exact: true });
await onCatalogMetricsChanged?.();
setPendingMutation(null);
setActiveRelationshipId(null);
window.setTimeout(() => {
if (originRef.current?.isConnected) originRef.current.focus();
else statusFilterRef.current?.focus();
}, 0);
toast.success("Relationship deleted permanently. A future rebuild may infer it again.");
} catch (error) {
toast.error(apiErrorMessage(error));
} finally {
setBusy(false);
}
};
const resetAddForm = () => {
setSourceTableId("");
setSourceColumnId("");
setTargetTableId("");
setTargetColumnId("");
setAddError(null);
setTargetColumnError(null);
};
const closeAdd = () => {
if (busy) return;
setAddOpen(false);
resetAddForm();
};
const addRelationship = async () => {
if (!sourceColumnId || !targetColumnId) return;
setBusy(true);
setAddError(null);
setTargetColumnError(null);
try {
await createCatalogRelationship(databaseId, sourceColumnId, targetColumnId);
await queryClient.invalidateQueries({ queryKey: ["catalog-relationships", databaseId], exact: true });
await onCatalogMetricsChanged?.();
setAddOpen(false);
resetAddForm();
toast.success("Relationship added.");
} catch (error) {
if (error instanceof ApiError && error.code === "relationship_target_not_unique") {
setTargetColumnError("The target column must be the only primary-key column of its table.");
} else if (error instanceof ApiError && error.code === "relationship_type_incompatible") {
setTargetColumnError("Source and target column types are not compatible.");
} else if (error instanceof ApiError && error.code === "relationship_duplicate") {
setAddError("This relationship already exists.");
} else {
setAddError(apiErrorMessage(error));
}
toast.error(apiErrorMessage(error));
} finally {
setBusy(false);
}
};
const runSelectedAction = async () => {
if (!selectedAction) return;
await synchronize(selectedAction);
if (selectedAction === "rebuild-generated") {
setBusy(true);
try {
const result = await rebuildGeneratedRelationships(databaseId);
await queryClient.invalidateQueries({ queryKey: ["catalog-relationships", databaseId], exact: true });
await onCatalogMetricsChanged?.();
const noFindings = result.added === 0
&& result.alreadyPresent === 0
&& result.excluded === 0
&& result.ambiguous === 0;
toast.success(noFindings
? "Rebuild complete: no new relationships found."
: `Rebuild complete: ${result.added} added, ${result.alreadyPresent} already present, ${result.excluded} excluded, ${result.ambiguous} ambiguous.`);
} catch (error) {
toast.error(apiErrorMessage(error));
} finally {
setBusy(false);
}
} else {
await synchronize(selectedAction);
}
setSelectedAction("");
};
const columns = useMemo<ColDef<CatalogRelationship>[]>(() => [
{ field: "constraintName", headerName: "Constraint", minWidth: 220, flex: 1, cellClass: "font-mono text-xs" },
...(presentation === "legacy" ? [{ field: "constraintName", headerName: "Constraint", minWidth: 220, flex: 1, cellClass: "font-mono text-xs" } satisfies ColDef<CatalogRelationship>] : []),
{ field: "sourceTableName", headerName: "Source table", minWidth: 200, flex: 1 },
{ headerName: "Source columns", minWidth: 200, flex: 1, valueGetter: ({ data: row }) => row?.columns.map((pair) => pair.sourceColumnName).join(", ") ?? "" },
{ headerName: "Source column", minWidth: 180, flex: 1, valueGetter: ({ data: row }) => row?.columns.map((pair) => pair.sourceColumnName).join(", ") ?? "" },
{ field: "targetTableName", headerName: "Target table", minWidth: 200, flex: 1 },
{ headerName: "Target columns", minWidth: 200, flex: 1, valueGetter: ({ data: row }) => row?.columns.map((pair) => pair.targetColumnName).join(", ") ?? "" },
{ field: "updateRule", headerName: "On update", minWidth: 125, width: 125 },
{ field: "deleteRule", headerName: "On delete", minWidth: 125, width: 125 },
{ headerName: "Target column", minWidth: 180, flex: 1, valueGetter: ({ data: row }) => row?.columns.map((pair) => pair.targetColumnName).join(", ") ?? "" },
{ field: "origin", headerName: "Origin", minWidth: 120, width: 120, valueFormatter: ({ value }) => titleCase(String(value ?? "")) },
{ field: "status", headerName: "Status", minWidth: 110, width: 110, valueFormatter: ({ value }) => titleCase(String(value ?? "")) },
...(presentation === "legacy" ? [
{ field: "updateRule", headerName: "On update", minWidth: 125, width: 125 },
{ field: "deleteRule", headerName: "On delete", minWidth: 125, width: 125 },
] satisfies ColDef<CatalogRelationship>[] : []),
...(presentation === "fleet" ? [{ colId: "actions", headerName: "Actions", width: 64, minWidth: 64, maxWidth: 64, pinned: "right" as const, lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: RelationshipActionsCell }] : []),
], [presentation]);
const context = useMemo<RelationshipGridContext>(() => ({ presentation, onView: openRelationship }), [data, presentation]);
@@ -157,14 +352,27 @@ export function DatabaseRelationships({
</div> : null}
<div className={presentation === "fleet" ? "thot-fleet-grid-surface flex min-h-0 flex-1 flex-col overflow-hidden bg-card" : "mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5"}>
<div className={`${presentation === "fleet" ? "thot-fleet-grid-toolbar " : ""}flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2`}>
<span className="thot-label whitespace-nowrap">Physical relationships</span>
<span className="thot-label whitespace-nowrap">Relationship map</span>
<label className="sr-only" htmlFor="relationship-status-filter">Relationship status</label>
<select
ref={statusFilterRef}
id="relationship-status-filter"
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label="Relationship status"
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as RelationshipStatusFilter)}
>
<option value="active">Active</option>
<option value="excluded">Excluded</option>
<option value="all">All</option>
</select>
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search relationships" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span className="text-xs tabular-nums text-muted-foreground">{data.length}</span>
<span className="text-xs tabular-nums text-muted-foreground">{visibleRelationships.length}</span>
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
{presentation === "fleet" ? (
<div className="flex flex-wrap items-center gap-2">
<div className="thot-fleet-action-selector__scope" aria-live="polite">
<strong>{selectedAction === "all" ? "This database" : selectedAction === "relationships" ? "All relationships in this database" : "This database"}</strong>
<strong>{selectedAction === "relationships" ? "Physical relationships in this database" : "This database"}</strong>
<span>Action scope</span>
</div>
<label className="sr-only" htmlFor="relationship-action">Relationship action</label>
@@ -173,18 +381,37 @@ export function DatabaseRelationships({
className="thot-fleet-action-selector__select"
value={selectedAction}
disabled={busy}
onChange={(event) => setSelectedAction(event.target.value as "" | "relationships" | "all")}
onChange={(event) => setSelectedAction(event.target.value as "" | "rebuild-generated" | "relationships" | "all")}
>
<option value="">Choose an action…</option>
<optgroup label="Logical relationships">
<option value="rebuild-generated">Rebuild generated relationships</option>
</optgroup>
<optgroup label="Synchronization">
<option value="relationships">Synchronize all relationships in this database</option>
<option value="relationships">Synchronize physical relationships</option>
<option value="all">Synchronize the full schema for this database</option>
</optgroup>
</select>
<Button type="button" size="sm" disabled={!selectedAction || Boolean(selectedActionUnavailable) || busy} aria-describedby={selectedActionUnavailable ? "relationship-action-reason" : undefined} onClick={() => void runSelectedAction()}><Play aria-hidden="true" />{busy ? "Running…" : "Run action"}</Button>
<Button type="button" size="sm" disabled={!selectedAction || Boolean(selectedActionUnavailable) || busy} aria-describedby={selectedActionUnavailable ? "relationship-action-reason" : undefined} onClick={() => void runSelectedAction()}><Play aria-hidden="true" />{busy ? selectedAction === "rebuild-generated" ? "Rebuilding…" : "Running…" : "Run action"}</Button>
<Button type="button" size="sm" variant="outline" className="thot-fleet-nav-action thot-fleet-nav-action--below" data-tooltip="Synchronization history" onClick={onOpenSync}>
{database.activeSyncRun ? <RefreshCw className="animate-spin" aria-hidden="true" /> : <History aria-hidden="true" />} Sync history
</Button>
{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}
<Button
type="button"
size="sm"
disabled={Boolean(addUnavailable)}
aria-describedby={addUnavailable ? "relationship-add-reason" : undefined}
onClick={() => {
setActiveRelationshipId(null);
setPendingMutation(null);
setAddOpen(true);
}}
>
<Plus aria-hidden="true" /> Add relationship
</Button>
{addUnavailable ? <span id="relationship-add-reason" className="sr-only">{addUnavailable}</span> : null}
{selectedActionUnavailable ? <span id="relationship-action-reason" className="basis-full text-xs text-muted-foreground" role="status">{selectedActionUnavailable}</span> : null}
</div>
) : <Button type="button" disabled={!canManage || !bindingReady || busy || Boolean(database.activeSyncRun)} title={!bindingReady ? "Test the current database binding before synchronizing relationships" : undefined} onClick={() => void synchronize("relationships")}><RefreshCw />Sync relationships</Button>}
@@ -192,26 +419,174 @@ export function DatabaseRelationships({
{!bindingReady ? <div className="border-b border-border bg-muted/35 px-4 py-3 text-sm text-muted-foreground">{presentation === "fleet" ? "Test the current database binding in database details before synchronizing relationships." : "Test the current database binding from Overview before synchronizing relationships."}</div> : null}
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
<div className={`thot-database-grid ${presentation === "fleet" ? "thot-fleet-ledger-grid " : ""}ag-theme-alpine absolute inset-0 h-full w-full`}>
<AgGridReact<CatalogRelationship> rowData={data} columnDefs={columns} context={context} loading={isLoading} quickFilterText={search} defaultColDef={{ sortable: true, filter: true, resizable: true }} getRowId={({ data: row }) => row.id} rowHeight={44} headerHeight={38} animateRows={false} overlayNoRowsTemplate="No physical relationships synchronized for this database." />
<AgGridReact<CatalogRelationship> rowData={visibleRelationships} columnDefs={columns} context={context} loading={isLoading} quickFilterText={search} defaultColDef={{ sortable: true, filter: true, resizable: true }} getRowId={({ data: row }) => row.id} rowHeight={44} headerHeight={38} animateRows={false} overlayNoRowsTemplate={statusFilter === "active" ? "No relationships yet. Rebuild from column names or add one manually." : statusFilter === "excluded" ? "No excluded relationships." : "No relationships for this database."} />
</div>
</div>}
</div>
{presentation === "fleet" && addOpen ? (
<FleetLedgerDrawer
open
eyebrow="Logical relationship"
title="Add relationship"
description="Connect one source column to one primary-key target column."
onClose={closeAdd}
closeLabel="Close add relationship"
busy={busy}
footer={(
<>
<Button type="button" variant="outline" disabled={busy} onClick={closeAdd}>Cancel</Button>
<Button
type="button"
disabled={busy || !sourceTableId || !sourceColumnId || !targetTableId || !targetColumnId}
onClick={() => void addRelationship()}
>
{busy ? "Adding…" : "Add relationship"}
</Button>
</>
)}
>
<div className="grid gap-4 text-sm sm:grid-cols-2">
<label className="grid gap-1.5">
<span className="font-semibold">Source table</span>
<select
className="h-9 rounded-md border border-input bg-background px-3 outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label="Source table"
value={sourceTableId}
disabled={busy || tablesQuery.isLoading}
onChange={(event) => {
setSourceTableId(event.target.value);
setSourceColumnId("");
setAddError(null);
}}
>
<option value="">Select source table…</option>
{(tablesQuery.data ?? []).map((table) => <option key={table.id} value={table.id}>{table.name}</option>)}
</select>
</label>
<label className="grid gap-1.5">
<span className="font-semibold">Source column</span>
<select
className="h-9 rounded-md border border-input bg-background px-3 outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label="Source column"
value={sourceColumnId}
disabled={busy || !sourceTableId || sourceColumnsQuery.isLoading}
onChange={(event) => {
setSourceColumnId(event.target.value);
setAddError(null);
setTargetColumnError(null);
}}
>
<option value="">Select source column…</option>
{(sourceColumnsQuery.data ?? []).map((column) => <option key={column.id} value={column.id}>{column.name}</option>)}
</select>
</label>
<label className="grid gap-1.5">
<span className="font-semibold">Target table</span>
<select
className="h-9 rounded-md border border-input bg-background px-3 outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label="Target table"
value={targetTableId}
disabled={busy || tablesQuery.isLoading}
onChange={(event) => {
setTargetTableId(event.target.value);
setTargetColumnId("");
setAddError(null);
setTargetColumnError(null);
}}
>
<option value="">Select target table…</option>
{(tablesQuery.data ?? []).map((table) => <option key={table.id} value={table.id}>{table.name}</option>)}
</select>
</label>
<label className="grid gap-1.5">
<span className="font-semibold">Target column</span>
<select
className="h-9 rounded-md border border-input bg-background px-3 outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label="Target column"
aria-invalid={Boolean(targetColumnError)}
aria-describedby={targetColumnError ? "target-column-error" : undefined}
value={targetColumnId}
disabled={busy || !targetTableId || targetColumnsQuery.isLoading}
onChange={(event) => {
setTargetColumnId(event.target.value);
setAddError(null);
setTargetColumnError(null);
}}
>
<option value="">Select target column…</option>
{targetColumns.map((column) => <option key={column.id} value={column.id}>{column.name}</option>)}
</select>
{targetColumnError ? <span id="target-column-error" className="text-xs text-destructive" role="alert">{targetColumnError}</span> : null}
</label>
{tablesQuery.isError || sourceColumnsQuery.isError || targetColumnsQuery.isError ? (
<p className="sm:col-span-2 text-sm text-destructive" role="alert">Catalog tables or columns could not be loaded.</p>
) : null}
{addError ? <p className="sm:col-span-2 text-sm text-destructive" role="alert">{addError}</p> : null}
</div>
</FleetLedgerDrawer>
) : null}
{presentation === "fleet" && activeRelationship ? (
<FleetLedgerDrawer
open
eyebrow="Physical relationship"
eyebrow={`${titleCase(activeRelationship.origin)} relationship`}
title="Relationship details"
description="This information is synchronized from the source schema and is read-only."
description={activeRelationship.origin === "physical"
? "This information is synchronized from the source schema and is read-only."
: activeRelationship.origin === "generated"
? "This relationship was inferred from catalog column names."
: "This relationship was added manually."}
onClose={closeRelationship}
closeLabel="Close relationship details"
footer={<Button type="button" variant="outline" onClick={closeRelationship}>Close</Button>}
busy={busy}
footer={pendingMutation ? (
<div
className="flex min-w-0 flex-1 flex-wrap items-center gap-2"
role="group"
aria-labelledby="relationship-mutation-confirmation"
onKeyDown={(event) => {
if (event.key === "Escape" && !busy) setPendingMutation(null);
}}
>
<p id="relationship-mutation-confirmation" className="mr-auto max-w-xl text-sm text-muted-foreground">
{pendingMutation === "exclude"
? "The relationship will move to Excluded. Rebuilds will not recreate it, and you can restore it later."
: "The relationship record will be erased. A future rebuild may infer it again."}
</p>
<Button type="button" variant="ghost" disabled={busy} onClick={() => setPendingMutation(null)}>Cancel</Button>
<Button
id="relationship-mutation-confirm-button"
type="button"
variant={pendingMutation === "delete" ? "destructive" : "default"}
disabled={busy}
onClick={() => void (pendingMutation === "exclude"
? changeRelationshipStatus("excluded")
: permanentlyDeleteRelationship())}
>
{pendingMutation === "exclude" ? "Exclude relationship" : "Delete permanently"}
</Button>
</div>
) : (
<>
<Button type="button" variant="outline" disabled={busy} onClick={closeRelationship}>Close</Button>
{activeRelationship.origin !== "physical" && activeRelationship.status === "active" ? (
<Button type="button" variant="outline" disabled={!canManage || busy} onClick={() => setPendingMutation("exclude")}>Exclude</Button>
) : null}
{activeRelationship.origin !== "physical" && activeRelationship.status === "excluded" ? (
<Button type="button" disabled={!canManage || busy} onClick={() => void changeRelationshipStatus("active")}>Restore</Button>
) : null}
{activeRelationship.origin !== "physical" ? (
<Button type="button" variant="destructive" disabled={!canManage || busy} onClick={() => setPendingMutation("delete")}>Delete permanently</Button>
) : null}
</>
)}
>
<dl className="grid gap-4 text-sm sm:grid-cols-2">
<div className="sm:col-span-2"><dt className="thot-label">Constraint</dt><dd className="mt-1 break-words font-mono text-xs">{activeRelationship.constraintName}</dd></div>
<div><dt className="thot-label">Origin</dt><dd className="mt-1">{titleCase(activeRelationship.origin)}</dd></div>
<div><dt className="thot-label">Status</dt><dd className="mt-1">{titleCase(activeRelationship.status)}</dd></div>
{activeRelationship.constraintName ? <div className="sm:col-span-2"><dt className="thot-label">Constraint</dt><dd className="mt-1 break-words font-mono text-xs">{activeRelationship.constraintName}</dd></div> : null}
<div><dt className="thot-label">Source table</dt><dd className="mt-1 break-words">{activeRelationship.sourceTableName}</dd></div>
<div><dt className="thot-label">Target table</dt><dd className="mt-1 break-words">{activeRelationship.targetTableName}</dd></div>
<div><dt className="thot-label">On update</dt><dd className="mt-1">{activeRelationship.updateRule ?? "Not specified"}</dd></div>
<div><dt className="thot-label">On delete</dt><dd className="mt-1">{activeRelationship.deleteRule ?? "Not specified"}</dd></div>
{activeRelationship.origin === "physical" ? <><div><dt className="thot-label">On update</dt><dd className="mt-1">{activeRelationship.updateRule ?? "Not specified"}</dd></div><div><dt className="thot-label">On delete</dt><dd className="mt-1">{activeRelationship.deleteRule ?? "Not specified"}</dd></div></> : null}
<div className="sm:col-span-2">
<dt className="thot-label">Column mapping</dt>
<dd className="mt-2 overflow-hidden rounded-md border border-border">
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { 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";
@@ -25,6 +25,7 @@ import type { DatabaseNavigationState } from "./model";
import { DatabaseColumns } from "./DatabaseColumns";
import { FleetActionSelector, type FleetActionOption } from "./FleetActionSelector";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
import { NO_METADATA_GENERATION_LLM_MODEL_MESSAGE } from "./MetadataGenerationModelSelector";
interface Props {
database: CatalogDatabase;
@@ -39,10 +40,11 @@ interface Props {
onNestedNavigationChange?: (active: boolean) => void;
onRunStarted: (run: CatalogSyncRun) => void;
onOpenSync: () => void;
onOpenDescriptionHistory?: () => void;
onOpenSensitiveHistory?: () => void;
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
onSuggestSensitive: (selection: SensitiveDataSuggestionRequest, scopeLabel: string) => Promise<void>;
onCatalogMetricsChanged?: () => void | Promise<void>;
metadataModelControl?: ReactNode;
presentation?: "legacy" | "fleet";
}
@@ -93,10 +95,11 @@ export function DatabaseTables({
onNestedNavigationChange,
onRunStarted,
onOpenSync,
onOpenDescriptionHistory,
onOpenSensitiveHistory,
onDescriptionGenerationRunStarted,
onSuggestSensitive,
onCatalogMetricsChanged,
metadataModelControl,
presentation = "legacy",
}: Props) {
const databaseId = database.id!;
@@ -314,7 +317,7 @@ export function DatabaseTables({
: selectedIds.length === 0
? "Select at least one table."
: !selectedMetadataModel
? "Choose a metadata model first."
? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE
: descriptionGenerationActive || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: busy !== null
@@ -377,7 +380,7 @@ export function DatabaseTables({
: selectedIds.length === 0
? "Select at least one table."
: !selectedMetadataModel
? "Choose a metadata model first."
? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE
: descriptionGenerationActive || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: busy !== null
@@ -487,8 +490,9 @@ export function DatabaseTables({
<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="outline"
className="thot-fleet-icon-action border-primary/35 bg-primary/8 shadow-sm"
variant="ghost"
size="sm"
className="thot-fleet-icon-action thot-fleet-back-action"
disabled={navigationBusy}
data-tooltip="Tables"
aria-label="Back to tables"
@@ -514,7 +518,6 @@ export function DatabaseTables({
bindingReady={bindingReady}
catalogOperationActive={Boolean(currentRun)}
onCatalogMetricsChanged={onCatalogMetricsChanged}
metadataModelControl={metadataModelControl}
presentation="fleet"
/>
</section>
@@ -712,7 +715,6 @@ export function DatabaseTables({
selectRef={fleetActionSelectRef}
onRun={runFleetAction}
onClear={clearSelection}
renderContext={(action) => action === "generate-descriptions" || action === "suggest-sensitive" ? metadataModelControl : null}
label="Table action"
/>
</>
@@ -751,7 +753,6 @@ export function DatabaseTables({
selectRef={fleetActionSelectRef}
onRun={runFleetAction}
onClear={clearSelection}
renderContext={(action) => action === "generate-descriptions" || action === "suggest-sensitive" ? metadataModelControl : null}
label="Table action"
/>
<input ref={searchInputRef} className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search tables" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
@@ -773,6 +774,8 @@ export function DatabaseTables({
{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">
@@ -248,6 +248,33 @@ test("reopens a terminal run from newest-first persisted history", async () => {
expect(within(drawer).getByText("3", { selector: "[data-count='generated']" })).toBeVisible();
});
test("styles a successfully completed run with the success background", async () => {
const completedRun: DescriptionGenerationRun = {
...runningRun,
status: "completed",
total: 1,
processed: 1,
generated: 1,
updatedAt: "2026-08-28T08:02:00Z",
finishedAt: "2026-08-28T08:02:00Z",
};
server.use(
http.get("/api/catalog/description-generation-runs", () => HttpResponse.json([completedRun])),
http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(completedRun)),
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])),
);
renderDrawer({ initialRun: completedRun });
const history = await screen.findByRole("region", {
name: "Description generation history",
});
const completedHistoryItem = within(history).getByRole("button", {
name: /Completed.*missing/i,
});
expect(completedHistoryItem).toHaveAttribute("data-status", "completed");
expect(completedHistoryItem).toHaveClass("bg-[oklch(var(--success)/0.1)]");
});
test("offers Unlock only for an apparently stale active run and explains the interruption", async () => {
const user = userEvent.setup();
const staleRun: DescriptionGenerationRun = {
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { LoaderCircle, LockOpen, Square } from "lucide-react";
import { toast } from "sonner";
@@ -18,6 +18,7 @@ import { FleetLedgerDrawer } from "./FleetLedgerShell";
interface Props {
open: boolean;
databaseId?: string | null;
run: DescriptionGenerationRun | null;
modelLabel: string;
onClose: () => void;
@@ -62,6 +63,7 @@ function timestamp(value: string | null): string {
export function DescriptionGenerationDrawer({
open,
databaseId = null,
run: initialRun,
modelLabel,
onClose,
@@ -100,11 +102,15 @@ export function DescriptionGenerationDrawer({
? 3_000
: false,
});
const visibleHistory = useMemo(
() => (historyQuery.data ?? []).filter((item) => !databaseId || item.databaseId === databaseId),
[databaseId, historyQuery.data],
);
useEffect(() => {
if (!open || initialRun || !historyQuery.data?.[0]) return;
onRunUpdate(historyQuery.data[0]);
}, [historyQuery.data, initialRun, onRunUpdate, open]);
if (!open || initialRun || !visibleHistory[0]) return;
onRunUpdate(visibleHistory[0]);
}, [initialRun, onRunUpdate, open, visibleHistory]);
const mergeEvents = useCallback((incoming: DescriptionGenerationEvent[]) => {
if (incoming.length === 0) return;
@@ -406,7 +412,7 @@ export function DescriptionGenerationDrawer({
<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">
{historyQuery.data?.length ?? 0}
{visibleHistory.length}
</span>
</div>
{historyQuery.isError ? (
@@ -415,17 +421,18 @@ export function DescriptionGenerationDrawer({
</p>
) : historyQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading run history…</p>
) : (historyQuery.data ?? []).length === 0 ? (
) : 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">
{(historyQuery.data ?? []).map((item) => (
{visibleHistory.map((item) => (
<button
key={item.id}
type="button"
aria-label={`${statusLabel(item)} · ${item.scope.replaceAll("_", " ")}`}
aria-current={item.id === run.id ? "true" : undefined}
className="flex w-full items-start justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/25"
data-status={item.status}
className={`flex w-full items-start justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/25${item.status === "completed" ? " bg-[oklch(var(--success)/0.1)]" : ""}`}
onClick={() => onRunUpdate(item)}
>
<span className="font-medium">{statusLabel(item)}</span>
@@ -0,0 +1,30 @@
import { readFileSync, readdirSync } from "node:fs";
import { expect, test } from "vitest";
const sourceDirectory = "src/shell/database-management";
const styles = readFileSync(`${sourceDirectory}/FleetLedgerShell.css`, "utf8");
test("uses operational body text for every available-command list", () => {
const availableCommandLists = readdirSync(sourceDirectory)
.filter((fileName) => fileName.endsWith(".tsx") && !fileName.includes(".test."))
.flatMap((fileName) => {
const source = readFileSync(`${sourceDirectory}/${fileName}`, "utf8");
const matches = source.match(
/<select\b[\s\S]*?<option value="">Choose an action…<\/option>[\s\S]*?<\/select>/g,
);
return (matches ?? []).map((markup) => ({ fileName, markup }));
});
expect(availableCommandLists.map(({ fileName }) => fileName).sort()).toEqual([
"DatabaseRelationships.tsx",
"FleetActionSelector.tsx",
]);
for (const { markup } of availableCommandLists) {
expect(markup).toContain('className="thot-fleet-action-selector__select"');
}
expect(styles).toMatch(
/\.thot-fleet-action-selector__select\s*\{[^}]*font-size:\s*0\.9375rem;/s,
);
});
@@ -274,7 +274,7 @@
background: oklch(var(--card));
}
.thot-fleet-ledger__navigation > button:first-child {
.thot-fleet-back-action {
min-height: 2.25rem;
border: 1px solid oklch(var(--primary) / 0.28);
color: oklch(var(--primary));
@@ -283,7 +283,7 @@
font-weight: 700;
}
.thot-fleet-ledger__navigation > button:first-child:not(:disabled):hover {
.thot-fleet-back-action:not(:disabled):hover {
border-color: oklch(var(--primary) / 0.48);
color: oklch(var(--primary));
background: oklch(var(--primary) / 0.14);
@@ -410,7 +410,7 @@
border-radius: var(--radius);
color: oklch(var(--foreground));
background: oklch(var(--background));
font-size: 0.8125rem;
font-size: 0.9375rem;
outline: none;
}
@@ -431,24 +431,6 @@
line-height: 1.4;
}
.thot-fleet-metadata-model {
min-width: min(100%, 14rem);
flex: 0 1 16rem;
}
.thot-fleet-metadata-model > div {
min-width: 0;
gap: 0.25rem;
}
.thot-fleet-metadata-model label {
gap: 0.25rem;
}
.thot-fleet-metadata-model select {
height: 2rem;
}
.thot-fleet-ledger__selection-summary {
min-width: 0;
overflow: hidden;
@@ -582,6 +564,17 @@
box-shadow: inset 3px 0 0 oklch(var(--primary));
}
.thot-catalog-drawer__history button[data-status="completed"],
.thot-catalog-drawer__history button[data-status="succeeded"] {
background: oklch(var(--success) / 0.1);
}
.thot-catalog-drawer__history button[aria-current="true"][data-status="completed"],
.thot-catalog-drawer__history button[aria-current="true"][data-status="succeeded"] {
background: oklch(var(--success) / 0.1);
box-shadow: inset 0 0 0 1px oklch(var(--success) / 0.5);
}
.thot-fleet-grid-surface {
position: relative;
}
@@ -623,24 +616,29 @@
.thot-fleet-icon-action::after {
position: absolute;
z-index: 80;
inset-inline-end: calc(100% + 5px);
inset-block-start: 50%;
inset-inline-end: 0;
inset-block-start: calc(100% + 3px);
width: max-content;
max-width: 12rem;
padding: 0.3rem 0.45rem;
border: 1px solid oklch(var(--border));
border-radius: calc(var(--radius) - 0.125rem);
color: oklch(var(--popover-foreground));
background: oklch(var(--popover));
box-shadow: var(--shadow-sm);
padding: 5px 8px;
border: 1px solid oklch(42% 0.018 258);
border-radius: 6px;
color: oklch(96% 0.006 258);
background: oklch(24% 0.014 258);
box-shadow: 0 8px 20px -10px oklch(12% 0.02 258 / 0.72);
content: attr(data-tooltip);
font-size: 0.6875rem;
font-weight: 650;
line-height: 1.2;
font-weight: 600;
line-height: 1.25;
text-align: center;
white-space: normal;
opacity: 0;
pointer-events: none;
transform: translate(2px, -50%);
transition: opacity 60ms cubic-bezier(0.22, 1, 0.36, 1), transform 60ms cubic-bezier(0.22, 1, 0.36, 1);
transform: translateY(-2px);
transition:
opacity 90ms linear,
transform 120ms cubic-bezier(0.25, 1, 0.5, 1),
visibility 0ms linear 120ms;
visibility: hidden;
}
@@ -677,7 +675,8 @@
.thot-fleet-icon-action:focus-visible::after,
.thot-fleet-icon-action:focus-within::after {
opacity: 1;
transform: translate(0, -50%);
transform: translateY(0);
transition-delay: 60ms;
visibility: visible;
}
@@ -233,7 +233,7 @@ export function FleetLedgerBreadcrumb({
type="button"
variant="ghost"
size="sm"
className="thot-fleet-nav-action"
className="thot-fleet-nav-action thot-fleet-back-action"
data-tooltip={back.label.replace(/^Back to\s+/i, "")}
onClick={back.onBack}
disabled={back.disabled}
@@ -0,0 +1,22 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, test } from "vitest";
const pageSource = readFileSync(resolve(__dirname, "../DatabaseManagementPage.tsx"), "utf8");
const componentSource = (name: string) => readFileSync(resolve(__dirname, name), "utf8");
describe("history action placement", () => {
test("workspace header does not own database history actions", () => {
const fleetHeader = pageSource.slice(pageSource.indexOf('title="Database management"'), pageSource.indexOf("kpis={"));
expect(fleetHeader).not.toContain("Description history");
expect(fleetHeader).not.toContain("Sensitive history");
});
test("database views expose metadata histories beside database commands", () => {
for (const file of ["DatabaseForm.tsx", "DatabaseTables.tsx", "DatabaseRelationships.tsx"]) {
const source = componentSource(file);
expect(source, file).toContain("Description history");
expect(source, file).toContain("Sensitive history");
}
});
});
@@ -1,5 +1,9 @@
import { useId } from "react";
import type { MetadataGenerationModels } from "../../api/catalog-databases";
export const NO_METADATA_GENERATION_LLM_MODEL_MESSAGE =
"No metadata-generation LLM model is configured for this installation.";
interface Props {
canManage: boolean;
data?: MetadataGenerationModels;
@@ -7,6 +11,7 @@ interface Props {
isLoading: boolean;
selectedModel: string;
onSelectedModelChange: (modelId: string) => void;
variant?: "default" | "compact";
}
export function MetadataGenerationModelSelector({
@@ -16,21 +21,35 @@ export function MetadataGenerationModelSelector({
isLoading,
selectedModel,
onSelectedModelChange,
variant = "default",
}: Props) {
const helpId = useId();
const models = data?.models ?? [];
const configuredDefault = data?.default ?? "";
const hasUsableDefault = configuredDefault !== ""
&& models.some((model) => model.id === configuredDefault);
const noUsableModel = !isLoading && !isError && Boolean(data) && !hasUsableDefault;
const unavailable = noUsableModel || (!isLoading && isError);
const unavailableMessage = !isLoading && isError
? "Metadata-generation LLM model choices are unavailable."
: noUsableModel
? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE
: null;
const compact = variant === "compact";
return (
<div className="grid min-w-56 max-w-lg gap-1.5">
<label className="grid gap-1.5 text-xs font-medium text-foreground">
<span>Metadata description model</span>
<div className={compact
? "flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"
: "grid min-w-56 max-w-lg gap-1.5"}
>
<label className={compact
? "flex min-w-0 items-center gap-2 text-xs font-medium text-foreground"
: "grid gap-1.5 text-xs font-medium text-foreground"}
>
<span className={compact ? "whitespace-nowrap" : undefined}>Metadata-generation LLM model</span>
<select
className="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 disabled:bg-muted disabled:text-muted-foreground"
aria-label="Metadata description model"
aria-describedby={unavailable ? "metadata-generation-model-help" : undefined}
className={`${compact ? "h-8 min-w-52" : "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 disabled:bg-muted disabled:text-muted-foreground`}
aria-label="Metadata-generation LLM model"
aria-describedby={unavailableMessage ? helpId : undefined}
value={selectedModel}
disabled={!canManage || isLoading || isError || !hasUsableDefault}
onChange={(event) => onSelectedModelChange(event.target.value)}
@@ -45,9 +64,9 @@ export function MetadataGenerationModelSelector({
))}
</select>
</label>
{unavailable ? (
<p id="metadata-generation-model-help" className="max-w-72 text-xs font-normal leading-4 text-muted-foreground">
Configure a metadata-generation model in application setup to enable description generation.
{unavailableMessage ? (
<p id={helpId} className="max-w-72 text-xs font-normal leading-4 text-muted-foreground">
{unavailableMessage}
</p>
) : null}
</div>
@@ -0,0 +1,27 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, test } from "vitest";
const sourceRoot = resolve(__dirname);
describe("Recent runs success styling contract", () => {
test("every history renderer exposes its run status to the shared styles", () => {
const renderers = [
"DescriptionGenerationDrawer.tsx",
"SensitiveDataSuggestionHistoryDrawer.tsx",
"CatalogSyncDrawer.tsx",
];
for (const renderer of renderers) {
const source = readFileSync(resolve(sourceRoot, renderer), "utf8");
expect(source, renderer).toMatch(/data-status=\{item\.(?:status|state)\}/);
}
});
test("shared history styles cover both successful status vocabularies", () => {
const styles = readFileSync(resolve(sourceRoot, "FleetLedgerShell.css"), "utf8");
expect(styles).toContain('button[data-status="completed"]');
expect(styles).toContain('button[data-status="succeeded"]');
expect(styles).toMatch(/oklch\(var\(--success\)/);
});
});
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { apiErrorMessage } from "../../api/client";
import {
@@ -11,6 +11,7 @@ import { FleetLedgerDrawer } from "./FleetLedgerShell";
interface Props {
open: boolean;
databaseId?: string | null;
run: SensitiveDataSuggestionRun | null;
modelLabel: string;
onClose: () => void;
@@ -46,6 +47,7 @@ function timestamp(value: string | null): string {
export function SensitiveDataSuggestionHistoryDrawer({
open,
databaseId = null,
run: initialRun,
modelLabel,
onClose,
@@ -78,11 +80,15 @@ export function SensitiveDataSuggestionHistoryDrawer({
? 3_000
: false,
});
const visibleHistory = useMemo(
() => (historyQuery.data ?? []).filter((item) => !databaseId || item.databaseId === databaseId),
[databaseId, historyQuery.data],
);
useEffect(() => {
if (!open || initialRun || !historyQuery.data?.[0]) return;
onRunUpdate(historyQuery.data[0]);
}, [historyQuery.data, initialRun, onRunUpdate, open]);
if (!open || initialRun || !visibleHistory[0]) return;
onRunUpdate(visibleHistory[0]);
}, [initialRun, onRunUpdate, open, visibleHistory]);
useEffect(() => {
if (!run) return;
@@ -199,23 +205,24 @@ export function SensitiveDataSuggestionHistoryDrawer({
<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">
{historyQuery.data?.length ?? 0}
{visibleHistory.length}
</span>
</div>
{historyQuery.isError ? (
<p role="alert" className="text-sm text-destructive">{apiErrorMessage(historyQuery.error)}</p>
) : historyQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading suggestion history…</p>
) : (historyQuery.data ?? []).length === 0 ? (
) : 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">
{(historyQuery.data ?? []).map((item) => (
{visibleHistory.map((item) => (
<button
key={item.id}
type="button"
aria-label={`${statusLabel(item)} · ${item.scope.replaceAll("_", " ")}`}
aria-current={item.id === run.id ? "true" : undefined}
data-status={item.status}
className="flex w-full items-start justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/25"
onClick={() => onRunUpdate(item)}
>
@@ -4,7 +4,7 @@ import type {
DatabaseConfiguration,
} from "../../api/catalog-databases";
export type DatabaseFormMode = "add" | "view" | "edit" | "delete";
export type DatabaseFormMode = "configure" | "view" | "edit" | "delete";
export type DatabaseScreen =
| { kind: "list" }
@@ -13,7 +13,6 @@ export type DatabaseScreen =
| {
kind: DatabaseFormMode;
workspaceId: string;
workspaceLocked?: boolean;
};
export type DatabaseBusyAction = "save" | "test" | "delete" | "retry-secrets" | "reload" | null;