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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user