feat: consolidate database management work

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

Keep active sensitive-generation status in a tooltip and indicator, and update the layout E2E to follow the history action in its new database-scoped location.
This commit is contained in:
Codex
2026-09-01 14:46:55 +02:00
parent f586152636
commit 076c9742c5
73 changed files with 6966 additions and 610 deletions
+39 -14
View File
@@ -1,4 +1,4 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import { act, 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";
@@ -45,24 +45,50 @@ beforeEach(() => {
});
describe("authenticated shell permissions", () => {
test("shows only read-safe workspace chrome to a session user", async () => {
test("hides administrative navigation from a session user", async () => {
renderShell({ subject: "user-1", isAdmin: false, roles: ["user"], permissions: ["session.use"] });
expect(await screen.findByRole("button", { name: "Workspace management" })).toBeInTheDocument();
expect(await screen.findByRole("button", { name: "New session" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Database management" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Workspace management" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "All sessions" })).not.toBeInTheDocument();
expect(screen.queryByRole("tab", { name: "All sessions" })).not.toBeInTheDocument();
});
test("shows management and all-session chrome only for exact permissions", async () => {
test("shows the ordered administrative accordion only to an admin", async () => {
const user = userEvent.setup();
renderShell({
subject: "admin-1",
isAdmin: true,
roles: ["admin"],
permissions: ["session.use", "session.read_all", "workspace.manage", "workspace.secrets.manage", "pi.manage"],
permissions: ["session.use", "session.read_all", "workspace.manage", "workspace.secrets.manage", "database.manage", "pi.manage"],
});
expect(await screen.findByRole("button", { name: "Pi management" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "All sessions" })).toBeInTheDocument();
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
const trigger = await within(sessionNavigation).findByRole("button", { name: "Administration" });
expect(trigger).toHaveAttribute("aria-expanded", "false");
expect(within(sessionNavigation).queryByRole("region", { name: "Administration" })).not.toBeInTheDocument();
trigger.focus();
await user.keyboard("{Enter}");
expect(trigger).toHaveAttribute("aria-expanded", "true");
const panel = within(sessionNavigation).getByRole("region", { name: "Administration" });
const database = within(panel).getByRole("button", { name: "Database management" });
const separator = within(panel).getByRole("separator");
const workspace = within(panel).getByRole("button", { name: "Workspace management" });
const pi = within(panel).getByRole("button", { name: "Pi management" });
expect(panel.children[0]).toBe(database);
expect(panel.children[1]).toBe(separator);
expect(panel.children[2]).toBe(workspace);
expect(panel.children[3]).toBe(pi);
expect(screen.getByRole("tab", { name: "All sessions" })).toBeInTheDocument();
await user.keyboard(" ");
expect(trigger).toHaveAttribute("aria-expanded", "false");
expect(within(sessionNavigation).queryByRole("region", { name: "Administration" })).not.toBeInTheDocument();
expect(within(sessionNavigation).queryByRole("button", { name: "Database management" })).not.toBeInTheDocument();
});
test("keeps identity but hides logout outside local authentication", async () => {
@@ -178,15 +204,14 @@ describe("authenticated shell permissions", () => {
subject: "admin-1", isAdmin: true, roles: ["admin"],
permissions: ["session.use", "session.read_all", "workspace.manage", "pi.manage"],
});
expect(await screen.findByRole("button", { name: "Pi management" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "All sessions" })).toBeInTheDocument();
expect(await screen.findByRole("button", { name: "Administration" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "All sessions" })).toBeInTheDocument();
act(() => clearAuthState());
expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Workspace management" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "All sessions" })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Workspace management" }));
expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Update workspace repository" })).not.toBeInTheDocument();
expect(screen.queryByRole("tab", { name: "All sessions" })).not.toBeInTheDocument();
});
test("remounts the real shell so user-A panel and transcript state cannot survive user-B", async () => {
@@ -17,6 +17,10 @@ function renderShell() {
);
}
async function expandAdministration() {
await userEvent.click(screen.getByRole("button", { name: "Administration" }));
}
beforeEach(() => {
clearAuthState();
setAuthState({
@@ -92,10 +96,19 @@ test("uses Fleet Ledger by default and gates the legacy fallback to non-producti
test("replaces the core conversation while keeping the session navigation", async () => {
renderShell();
await expandAdministration();
const conversationColumn = screen.getByTestId("conversation-column");
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
const newSession = within(sessionNavigation).getByRole("button", { name: "New session" });
const databaseManagement = within(sessionNavigation).getByRole("button", { name: "Database management" });
expect(newSession).toHaveAttribute("aria-current", "page");
expect(newSession).toHaveAttribute("data-navigation-state", "current");
expect(newSession).toHaveClass("bg-[oklch(var(--nav-active))]");
expect(databaseManagement).toHaveAttribute("data-navigation-state", "available");
expect(databaseManagement).not.toHaveAttribute("aria-current");
await userEvent.click(databaseManagement);
const manager = screen.getByRole("main", { name: "Database management" });
@@ -103,8 +116,13 @@ test("replaces the core conversation while keeping the session navigation", asyn
expect(conversationColumn).toContainElement(manager);
expect(screen.getByRole("complementary", { name: "Session navigation" })).toBe(sessionNavigation);
expect(sessionNavigation).toBeVisible();
expect(within(sessionNavigation).getByRole("button", { name: "New session" })).toBeVisible();
expect(newSession).toBeVisible();
expect(newSession).toHaveAttribute("data-navigation-state", "available");
expect(newSession).not.toHaveAttribute("aria-current");
expect(newSession).not.toHaveClass("bg-[oklch(var(--nav-active))]");
expect(databaseManagement).toHaveAttribute("aria-current", "page");
expect(databaseManagement).toHaveAttribute("data-navigation-state", "current");
expect(databaseManagement).toHaveClass("bg-[oklch(var(--nav-active))]");
expect(screen.queryByRole("button", { name: "Back to workspace" })).not.toBeInTheDocument();
expect(screen.queryByRole("textbox", { name: /new question/i })).not.toBeInTheDocument();
});
@@ -135,6 +153,7 @@ test("keeps a live core session connected while returning from database manageme
})),
);
renderShell();
await expandAdministration();
const session = await screen.findByTestId("session-item-s1");
await userEvent.click(session);
@@ -157,7 +176,7 @@ test("keeps a live core session connected while returning from database manageme
expect(FakeEventSource.instances).toHaveLength(1);
});
test("keeps read-safe workspace access but hides privileged management entries", () => {
test("hides the complete administrative navigation from a regular user", () => {
clearAuthState();
setAuthState({
issuer: "test",
@@ -171,7 +190,9 @@ test("keeps read-safe workspace access but hides privileged management entries",
renderShell();
expect(screen.getByRole("button", { name: "Workspace management" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "New session" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Workspace management" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Database management" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument();
});
@@ -182,6 +203,13 @@ test("does not leave database management without confirming a dirty form", async
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",
@@ -202,6 +230,7 @@ test("does not leave database management without confirming a dirty form", async
}])));
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
renderShell();
await expandAdministration();
await userEvent.click(screen.getByRole("button", { name: "Database management" }));
await userEvent.click(await screen.findByRole("button", { name: "Edit Policlinico San Donato" }));
@@ -156,6 +156,11 @@ test("model selector shows the three Pi-enabled models and stores the selected p
});
test("opens Workspace management from the right sidebar without interrupting the shell", async () => {
setAuthState({
issuer: "test", subject: "admin", roles: ["admin"],
permissions: ["session.use", "workspace.manage", "workspace.secrets.manage", "database.manage", "pi.manage"],
isAdmin: true, csrfToken: null, session: null,
});
server.use(
http.get("/api/workspace-registry/status", () =>
HttpResponse.json({ branch: "main", ahead: 0, behind: 0, degraded: false })),
@@ -163,15 +168,31 @@ test("opens Workspace management from the right sidebar without interrupting the
);
renderShell();
await userEvent.click(screen.getByRole("button", { name: "Administration" }));
await userEvent.click(screen.getByRole("button", { name: "Workspace management" }));
expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeVisible();
const dialog = screen.getByRole("dialog", { name: "Workspace management" });
const workArea = screen.getByTestId("conversation-column");
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
const workspaceManagement = within(sessionNavigation).getByRole("button", { name: "Workspace management" });
const newSession = within(sessionNavigation).getByRole("button", { name: "New session" });
expect(workArea).toContainElement(dialog);
expect(sessionNavigation).not.toContainElement(dialog);
expect(screen.getByTestId("app-shell")).toHaveAttribute("data-activity-layout", "closed");
expect(workspaceManagement).toHaveAttribute("aria-current", "page");
expect(workspaceManagement).toHaveAttribute("aria-expanded", "true");
expect(workspaceManagement).toHaveAttribute("data-navigation-state", "current");
expect(workspaceManagement).toHaveClass("bg-[oklch(var(--nav-active))]");
expect(newSession).toHaveAttribute("data-navigation-state", "available");
expect(newSession).not.toHaveAttribute("aria-current");
await userEvent.click(screen.getByRole("button", { name: "Close workspace management" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Workspace management" })).not.toBeInTheDocument());
expect(newSession).toHaveAttribute("aria-current", "page");
expect(newSession).toHaveAttribute("data-navigation-state", "current");
expect(workspaceManagement).toHaveAttribute("aria-expanded", "false");
expect(workspaceManagement).toHaveAttribute("data-navigation-state", "available");
});
test("does not block the shell when the DWH is unavailable at startup", async () => {
@@ -184,7 +205,8 @@ test("does not block the shell when the DWH is unavailable at startup", async ()
);
renderShell();
expect(await screen.findByRole("button", { name: "Workspace management" })).toBeVisible();
expect(await screen.findByRole("button", { name: "New session" })).toBeVisible();
expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Connection unavailable" })).not.toBeInTheDocument();
expect(healthChecks).toBe(0);
});
@@ -16,7 +16,7 @@ const regularUser: AuthenticatedUser = {
};
const adminUser: AuthenticatedUser = {
...regularUser, subject: "alice-id", roles: ["admin"] as const,
permissions: ["session.use", "session.read_all", "pi.manage"] as const, isAdmin: true,
permissions: ["session.use", "session.read_all", "workspace.manage", "workspace.secrets.manage", "database.manage", "pi.manage"] as const, isAdmin: true,
};
function wrap(user: AuthenticatedUser = regularUser) {
@@ -95,7 +95,8 @@ test("regular users load only their sessions and never see administrator control
wrap();
await screen.findByText("Attiva uno");
expect(scope).toBe("mine");
expect(screen.queryByRole("button", { name: "All sessions" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument();
expect(screen.queryByRole("tab", { name: "All sessions" })).not.toBeInTheDocument();
expect(screen.queryByText(/administrator view/i)).not.toBeInTheDocument();
});
@@ -115,17 +116,25 @@ test("Pi management preserves the open session summary and the model activity ti
http.post("/api/sessions/:id/resume", () => resumeResult("s1")),
http.get("/api/sessions/:id", () => HttpResponse.json({ phase: 4 })),
);
wrap();
wrap(adminUser);
await user.click(await screen.findByText("Attiva uno"));
expect(await screen.findByRole("complementary", { name: "Session summary" })).toHaveTextContent("Domanda originale");
await user.click(screen.getByRole("button", { name: "Administration" }));
await user.click(screen.getByRole("button", { name: "Pi management" }));
expect(await screen.findByRole("heading", { name: "Pi management" })).toBeVisible();
const dialog = screen.getByRole("dialog", { name: "Pi management" });
const workArea = screen.getByTestId("conversation-column");
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
const piManagement = within(sessionNavigation).getByRole("button", { name: "Pi management" });
const newSession = within(sessionNavigation).getByRole("button", { name: "New session" });
expect(workArea).toContainElement(dialog);
expect(sessionNavigation).not.toContainElement(dialog);
expect(piManagement).toHaveAttribute("aria-current", "page");
expect(piManagement).toHaveAttribute("aria-expanded", "true");
expect(piManagement).toHaveAttribute("data-navigation-state", "current");
expect(newSession).toHaveAttribute("data-navigation-state", "available");
expect(newSession).not.toHaveAttribute("aria-current");
const preservedSummary = screen.getByRole("complementary", { name: "Session summary" });
expect(preservedSummary).not.toHaveAttribute("aria-hidden", "true");
expect(preservedSummary).toHaveTextContent("Attiva uno");
@@ -133,6 +142,10 @@ test("Pi management preserves the open session summary and the model activity ti
await waitFor(() => {
expect(screen.getByRole("complementary", { name: "Session summary" })).toHaveTextContent("Domanda originale");
});
expect(newSession).toHaveAttribute("aria-current", "page");
expect(newSession).toHaveAttribute("data-navigation-state", "current");
expect(piManagement).toHaveAttribute("aria-expanded", "false");
expect(piManagement).toHaveAttribute("data-navigation-state", "available");
await user.click(screen.getByRole("button", { name: "Resume" }));
await screen.findByRole("button", { name: "Show model activity" });
@@ -160,17 +173,66 @@ test("administrators can explicitly switch to all sessions and see owners", asyn
}),
);
wrap(adminUser);
await screen.findByRole("button", { name: "All sessions" });
expect(screen.getByRole("button", { name: "My sessions" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "All sessions" })).toHaveAttribute("aria-pressed", "false");
await userEvent.click(screen.getByRole("button", { name: "All sessions" }));
const tablist = await screen.findByRole("tablist", { name: "Session scope" });
const mySessions = within(tablist).getByRole("tab", { name: "My sessions" });
const allSessions = within(tablist).getByRole("tab", { name: "All sessions" });
expect(mySessions).toHaveAttribute("aria-selected", "true");
expect(mySessions).toHaveAttribute("aria-controls", "session-scope-panel");
expect(mySessions).toHaveAttribute("tabindex", "0");
expect(mySessions).toHaveAttribute("data-tab-state", "active");
expect(mySessions).toHaveClass("bg-[oklch(var(--nav-active))]");
expect(allSessions).toHaveAttribute("aria-selected", "false");
expect(allSessions).toHaveAttribute("aria-controls", "session-scope-panel");
expect(allSessions).toHaveAttribute("tabindex", "-1");
expect(allSessions).toHaveAttribute("data-tab-state", "inactive");
expect(allSessions).toHaveClass("border-border", "bg-card");
expect(allSessions).not.toHaveClass("border-transparent", "bg-transparent");
expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "session-scope-mine-tab");
await userEvent.click(allSessions);
await waitFor(() => expect(scope).toBe("all"));
expect(screen.getByRole("button", { name: "My sessions" })).toHaveAttribute("aria-pressed", "false");
expect(screen.getByRole("button", { name: "All sessions" })).toHaveAttribute("aria-pressed", "true");
expect(mySessions).toHaveAttribute("aria-selected", "false");
expect(mySessions).toHaveAttribute("data-tab-state", "inactive");
expect(allSessions).toHaveAttribute("aria-selected", "true");
expect(allSessions).toHaveAttribute("data-tab-state", "active");
expect(allSessions).toHaveClass("bg-[oklch(var(--nav-active))]");
expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "session-scope-all-tab");
expect(await screen.findByText("Administrator view: all sessions")).toBeInTheDocument();
expect(screen.getByText("Owner: Bob")).toBeInTheDocument();
});
test("session scope tabs support roving keyboard navigation", async () => {
let scope = "";
server.use(http.get("/api/sessions", ({ request }) => {
scope = new URL(request.url).searchParams.get("scope") ?? "";
return HttpResponse.json(LIST);
}));
wrap(adminUser);
const user = userEvent.setup();
const tablist = await screen.findByRole("tablist", { name: "Session scope" });
const mySessions = within(tablist).getByRole("tab", { name: "My sessions" });
const allSessions = within(tablist).getByRole("tab", { name: "All sessions" });
mySessions.focus();
await user.keyboard("{ArrowRight}");
await waitFor(() => expect(scope).toBe("all"));
expect(allSessions).toHaveFocus();
expect(allSessions).toHaveAttribute("aria-selected", "true");
expect(mySessions).toHaveAttribute("tabindex", "-1");
await user.keyboard("{ArrowLeft}");
await waitFor(() => expect(scope).toBe("mine"));
expect(mySessions).toHaveFocus();
expect(mySessions).toHaveAttribute("aria-selected", "true");
await user.keyboard("{End}");
expect(allSessions).toHaveFocus();
expect(allSessions).toHaveAttribute("aria-selected", "true");
await user.keyboard("{Home}");
expect(mySessions).toHaveFocus();
expect(mySessions).toHaveAttribute("aria-selected", "true");
});
test("administrator confirms before deleting a same-named user's session", async () => {
let deletes = 0;
server.use(
@@ -187,7 +249,7 @@ test("administrator confirms before deleting a same-named user's session", async
}),
);
wrap(adminUser);
await userEvent.click(await screen.findByRole("button", { name: "All sessions" }));
await userEvent.click(await screen.findByRole("tab", { name: "All sessions" }));
await screen.findByText("Owner: Alice");
await userEvent.click(screen.getByRole("checkbox", { name: "Select Attiva uno" }));
await userEvent.click(screen.getByRole("button", { name: "Delete 1 selected sessions" }));
@@ -213,7 +275,7 @@ test("administrator confirms before archiving a same-named user's session", asyn
}),
);
wrap(adminUser);
await userEvent.click(await screen.findByRole("button", { name: "All sessions" }));
await userEvent.click(await screen.findByRole("tab", { name: "All sessions" }));
await screen.findByText("Owner: Alice");
await userEvent.click(screen.getByRole("button", { name: "Session actions" }));
await userEvent.click(await screen.findByText("Archive"));
+193 -90
View File
@@ -17,8 +17,9 @@ import { StopConfirmDialog } from "./StopConfirmDialog";
import { SteerInput, ComposerFooter } from "./SteerInput";
import { WorkflowBar } from "./WorkflowBar";
import { DatabaseManagementPage } from "./DatabaseManagementPage";
import { Pencil, ArrowLeft, ArrowRight, Trash2 } from "lucide-react";
import { Button } from "../components/ui/button";
import { Pencil, ArrowLeft, ArrowRight, ChevronDown, Trash2 } from "lucide-react";
import { Accordion } from "@base-ui/react/accordion";
import { Button, buttonVariants } from "../components/ui/button";
import { Checkbox } from "../components/ui/checkbox";
import { Toaster } from "../components/ui/sonner";
import { toast } from "sonner";
@@ -34,7 +35,7 @@ import type { SessionScope, SessionSummary } from "../api/types";
import { useAuthGeneration, useAuthUser } from "../auth/authState";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import type { CSSProperties, KeyboardEvent } from "react";
import { captureAuthOperation, isAuthOperationCurrent, StaleAuthOperationError, type AuthOperationGuard } from "../auth/authOperation";
interface AppShellProps {
@@ -43,6 +44,14 @@ interface AppShellProps {
type ActiveSurface = "core" | "database-management";
type ActiveManagementPanel = "workspace" | "pi" | null;
const SESSION_SCOPES: readonly SessionScope[] = ["mine", "all"];
function sessionScopeTabClass(selected: boolean): string {
const base = "relative -mb-px flex h-8 w-full cursor-pointer items-center justify-center rounded-t-md border border-b-2 px-2 text-xs font-semibold tracking-[0.005em] outline-none focus-visible:z-10 focus-visible:ring-3 focus-visible:ring-[oklch(var(--nav-active-border)/0.28)]";
return selected
? `${base} border-[oklch(var(--nav-active-border))] bg-[oklch(var(--nav-active))] text-[oklch(var(--nav-active-foreground))] hover:bg-[oklch(var(--nav-active-hover))]`
: `${base} border-border bg-card text-muted-foreground hover:border-muted-foreground/45 hover:bg-muted/55 hover:text-foreground`;
}
type DatabaseManagementPresentation = "legacy" | "fleet";
@@ -129,8 +138,13 @@ export function AppShell({ canLogout }: AppShellProps) {
const [creatingSession, setCreatingSession] = useState(false);
const [awaitingQuestion, setAwaitingQuestion] = useState(false);
const [sessionScope, setSessionScope] = useState<SessionScope>("mine");
const sessionScopeTabRefs = useRef<Record<SessionScope, HTMLButtonElement | null>>({
mine: null,
all: null,
});
const principal = authenticatedUser;
const permissions = authenticatedUser?.permissions ?? [];
const isAdmin = authenticatedUser?.isAdmin === true;
const canReadAllSessions = permissions.includes("session.read_all");
const canManageWorkspace = permissions.includes("workspace.manage");
const canManageWorkspaceSecrets = permissions.includes("workspace.secrets.manage");
@@ -164,6 +178,7 @@ export function AppShell({ canLogout }: AppShellProps) {
databaseNavigationRef.current = state;
}, []);
const [activeManagementPanel, setActiveManagementPanel] = useState<ActiveManagementPanel>(null);
const [adminNavigationValue, setAdminNavigationValue] = useState<string[]>([]);
const [activeOpen, setActiveOpen] = useState(true);
const [archiveOpen, setArchiveOpen] = useState(false);
const [renameTarget, setRenameTarget] = useState<SessionSummary | null>(null);
@@ -236,6 +251,22 @@ export function AppShell({ canLogout }: AppShellProps) {
setSelectedSessionIds(selected ? new Set(sessions.map((session) => session.id)) : new Set());
}
function handleSessionScopeTabKeyDown(
event: KeyboardEvent<HTMLButtonElement>,
current: SessionScope,
) {
const currentIndex = SESSION_SCOPES.indexOf(current);
let target: SessionScope | undefined;
if (event.key === "ArrowRight") target = SESSION_SCOPES[(currentIndex + 1) % SESSION_SCOPES.length];
else if (event.key === "ArrowLeft") target = SESSION_SCOPES[(currentIndex - 1 + SESSION_SCOPES.length) % SESSION_SCOPES.length];
else if (event.key === "Home") target = SESSION_SCOPES[0];
else if (event.key === "End") target = SESSION_SCOPES.at(-1);
if (!target) return;
event.preventDefault();
setSessionScope(target);
sessionScopeTabRefs.current[target]?.focus();
}
function canLeaveDatabaseManagement(): boolean {
if (activeSurface !== "database-management") return true;
if (databaseNavigationRef.current.busy) {
@@ -627,6 +658,12 @@ export function AppShell({ canLogout }: AppShellProps) {
await logoutUser();
}
const currentNavigation = activeSurface === "database-management"
? "database"
: activeManagementPanel ?? "core";
const adminNavigationOpen = adminNavigationValue.includes("administration");
const managementNavigationCurrent = currentNavigation !== "core";
return (
<div
ref={containerRef}
@@ -808,115 +845,180 @@ export function AppShell({ canLogout }: AppShellProps) {
<div className="flex flex-col gap-2 px-4 pb-3">
<Button
variant="default"
variant={currentNavigation === "core" ? "navigationActive" : "outline"}
size="sm"
className="w-full"
aria-current={currentNavigation === "core" ? "page" : undefined}
data-navigation-state={currentNavigation === "core" ? "current" : "available"}
onClick={startNewSession}
>
New session
</Button>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => {
if (!canLeaveDatabaseManagement()) return;
setActiveSurface("core");
setActiveManagementPanel("workspace");
}}
>
Workspace management
</Button>
{canManageDatabase && (
<Button
variant={activeSurface === "database-management" ? "secondary" : "outline"}
size="sm"
className="w-full"
aria-current={activeSurface === "database-management" ? "page" : undefined}
onClick={() => {
setActiveManagementPanel(null);
setActiveSurface("database-management");
}}
{isAdmin && (
<Accordion.Root
value={adminNavigationValue}
onValueChange={setAdminNavigationValue}
className="rounded-lg border border-border/80 bg-card/45 p-1.5"
>
Database management
</Button>
)}
{canManagePi && (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => {
if (!canLeaveDatabaseManagement()) return;
setActiveSurface("core");
setActiveManagementPanel("pi");
}}
>
Pi management
</Button>
<Accordion.Item value="administration">
<Accordion.Header className="m-0">
<Accordion.Trigger
className={buttonVariants({
variant: !adminNavigationOpen && managementNavigationCurrent ? "navigationActive" : "outline",
size: "sm",
className: "w-full justify-between px-2.5",
})}
data-navigation-state={!adminNavigationOpen && managementNavigationCurrent ? "current" : "available"}
>
<span>Administration</span>
<ChevronDown
aria-hidden="true"
data-icon="inline-end"
className={`transition-transform duration-150 motion-reduce:transition-none ${adminNavigationOpen ? "rotate-180" : ""}`}
/>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Panel className="grid gap-1.5 px-0.5 pt-1.5">
<Button
variant={currentNavigation === "database" ? "navigationActive" : "outline"}
size="sm"
className="w-full"
aria-current={currentNavigation === "database" ? "page" : undefined}
data-navigation-state={currentNavigation === "database" ? "current" : canManageDatabase ? "available" : "unavailable"}
disabled={!canManageDatabase}
title={canManageDatabase ? undefined : "Database management permission is required"}
onClick={() => {
setActiveManagementPanel(null);
setActiveSurface("database-management");
}}
>
Database management
</Button>
<div role="separator" aria-orientation="horizontal" className="mx-1 h-px bg-border/90" />
<Button
variant={currentNavigation === "workspace" ? "navigationActive" : "outline"}
size="sm"
className="w-full"
aria-current={currentNavigation === "workspace" ? "page" : undefined}
aria-expanded={activeManagementPanel === "workspace"}
data-navigation-state={currentNavigation === "workspace" ? "current" : "available"}
onClick={() => {
if (!canLeaveDatabaseManagement()) return;
setActiveSurface("core");
setActiveManagementPanel("workspace");
}}
>
Workspace management
</Button>
<Button
variant={currentNavigation === "pi" ? "navigationActive" : "outline"}
size="sm"
className="w-full"
aria-current={currentNavigation === "pi" ? "page" : undefined}
aria-expanded={activeManagementPanel === "pi"}
data-navigation-state={currentNavigation === "pi" ? "current" : canManagePi ? "available" : "unavailable"}
disabled={!canManagePi}
title={canManagePi ? undefined : "Pi management permission is required"}
onClick={() => {
if (!canLeaveDatabaseManagement()) return;
setActiveSurface("core");
setActiveManagementPanel("pi");
}}
>
Pi management
</Button>
</Accordion.Panel>
</Accordion.Item>
</Accordion.Root>
)}
</div>
{canReadAllSessions && (
<div className="px-4 pb-3">
<div className="grid grid-cols-2 gap-1 rounded-lg bg-muted p-1" aria-label="Session scope">
<Button
variant={sessionScope === "mine" ? "secondary" : "ghost"}
size="xs"
aria-pressed={sessionScope === "mine"}
<div className="px-4">
<div
role="tablist"
aria-label="Session scope"
aria-orientation="horizontal"
className="grid grid-cols-2 border-b border-border"
>
<button
ref={(node) => { sessionScopeTabRefs.current.mine = node; }}
id="session-scope-mine-tab"
type="button"
role="tab"
aria-selected={sessionScope === "mine"}
aria-controls="session-scope-panel"
tabIndex={sessionScope === "mine" ? 0 : -1}
data-tab-state={sessionScope === "mine" ? "active" : "inactive"}
className={sessionScopeTabClass(sessionScope === "mine")}
onClick={() => setSessionScope("mine")}
onKeyDown={(event) => handleSessionScopeTabKeyDown(event, "mine")}
>
My sessions
</Button>
<Button
variant={showingAllSessions ? "secondary" : "ghost"}
size="xs"
aria-pressed={showingAllSessions}
</button>
<button
ref={(node) => { sessionScopeTabRefs.current.all = node; }}
id="session-scope-all-tab"
type="button"
role="tab"
aria-selected={showingAllSessions}
aria-controls="session-scope-panel"
tabIndex={showingAllSessions ? 0 : -1}
data-tab-state={showingAllSessions ? "active" : "inactive"}
className={sessionScopeTabClass(showingAllSessions)}
onClick={() => setSessionScope("all")}
onKeyDown={(event) => handleSessionScopeTabKeyDown(event, "all")}
>
All sessions
</Button>
</button>
</div>
{showingAllSessions && (
<p className="mt-2 text-xs font-medium text-amber-700 dark:text-amber-400">
Administrator view: all sessions
</p>
)}
</div>
)}
{/* L1 — rail title */}
<div className="px-4 pb-1.5 pt-1">
<span className="thot-label text-[0.8rem] font-bold tracking-[0.18em] text-primary">
Sessions
</span>
</div>
<div className="flex items-center justify-between px-4 pb-2">
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-muted-foreground hover:text-foreground">
<Checkbox
checked={allSessionsSelected}
aria-label="Select all sessions"
disabled={sessions.length === 0}
onCheckedChange={(selected) => toggleAllSessions(selected === true)}
/>
<span>Select all</span>
</label>
{selectedSessions.length > 0 && (
<Button
variant="destructive"
size="xs"
aria-label={`Delete ${selectedSessions.length} selected sessions`}
onClick={() => {
requestDelete(selectedSessions);
}}
>
<Trash2 />
Delete ({selectedSessions.length})
</Button>
<div
id={canReadAllSessions ? "session-scope-panel" : undefined}
role={canReadAllSessions ? "tabpanel" : undefined}
aria-labelledby={canReadAllSessions ? `session-scope-${sessionScope}-tab` : undefined}
className="flex min-h-0 flex-1 flex-col"
>
{canReadAllSessions && showingAllSessions && (
<p className="mx-4 mb-2 mt-2 text-xs font-medium text-amber-700 dark:text-amber-400">
Administrator view: all sessions
</p>
)}
</div>
<div className="flex-1 overflow-y-auto px-2 pb-4">
{/* L1 — rail title */}
<div className="px-4 pb-1.5 pt-1">
<span className="thot-label text-[0.8rem] font-bold tracking-[0.18em] text-primary">
Sessions
</span>
</div>
<div className="flex items-center justify-between px-4 pb-2">
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-muted-foreground hover:text-foreground">
<Checkbox
checked={allSessionsSelected}
aria-label="Select all sessions"
disabled={sessions.length === 0}
onCheckedChange={(selected) => toggleAllSessions(selected === true)}
/>
<span>Select all</span>
</label>
{selectedSessions.length > 0 && (
<Button
variant="destructive"
size="xs"
aria-label={`Delete ${selectedSessions.length} selected sessions`}
onClick={() => {
requestDelete(selectedSessions);
}}
>
<Trash2 />
Delete ({selectedSessions.length})
</Button>
)}
</div>
<div className="flex-1 overflow-y-auto px-2 pb-4">
{/* L2 — section toggle */}
<button
type="button"
@@ -1002,6 +1104,7 @@ export function AppShell({ canLogout }: AppShellProps) {
showOwner={showingAllSessions}
/>
)}
</div>
</div>
</aside>
)}
@@ -29,6 +29,13 @@ function makeDatabase(overrides: Partial<CatalogDatabase> = {}): 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",
@@ -66,6 +73,9 @@ const orphan = makeDatabase({
workspaceId: "retired",
workspaceName: "Retired workspace",
workspaceAvailable: false,
workspaceRevision: null,
workspaceEvidence: { sourceType: null, state: "workspace_unavailable" },
runtimeBinding: null,
});
function renderPage({
@@ -192,7 +202,7 @@ const synchronizationScopes = [
{ scope: "all", label: "Synchronize all" },
] as const;
test("renders Fleet Ledger with real metrics, conceptual row tooltips, and contextual model selection", async () => {
test("renders Fleet Ledger with real metrics, conceptual row tooltips, and persistent model selection", async () => {
const user = userEvent.setup();
server.use(
http.get("/api/catalog/metrics", () => HttpResponse.json({
@@ -223,10 +233,15 @@ test("renders Fleet Ledger with real metrics, conceptual row tooltips, and conte
expect(screen.queryByText("Thoth catalog · Fleet ledger")).not.toBeInTheDocument();
expect(screen.queryByText("Inspect physical metadata, curate descriptions and control catalog operations.")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Back to workspace" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Add database/i })).not.toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: /Revision \/ Evidence/ })).toBeVisible();
expect(screen.getByRole("columnheader", { name: /NL→SQL runtime/ })).toBeVisible();
expect(screen.getByRole("columnheader", { name: /Metadata Catalog/ })).toBeVisible();
const summary = screen.getByRole("region", { name: "Fleet summary" });
expect(await within(summary).findByText("2,275")).toBeVisible();
expect(within(summary).getByText("75%")).toBeVisible();
expect(screen.queryByRole("combobox", { name: "Metadata description model" })).not.toBeInTheDocument();
const modelSelector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
expect(modelSelector).toHaveValue("local-qwen");
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
const tablesAction = within(databaseRow).getByRole("button", { name: "View tables for Policlinico San Donato" });
@@ -247,10 +262,103 @@ test("renders Fleet Ledger with real metrics, conceptual row tooltips, and conte
const actionPicker = screen.getByRole("combobox", { name: "Batch action" });
expect(within(actionPicker).getByRole("option", { name: /^Synchronize all(?:,|$)/ })).toBeVisible();
await user.selectOptions(actionPicker, "generate-missing");
expect(await screen.findByRole("combobox", { name: "Metadata description model" })).toHaveValue("local-qwen");
expect(screen.getAllByRole("combobox", { name: "Metadata-generation LLM model" })).toHaveLength(1);
expect(modelSelector).toHaveValue("local-qwen");
});
test("shows repository, NL→SQL runtime, and Metadata Catalog states independently", async () => {
const configured = makeDatabase({ connectionStatus: "reachable", testedVersion: 3 });
const needsRuntimeConfiguration = makeDatabase({
...unconfigured,
workspaceEvidence: { sourceType: "http", state: "configuration_required" },
runtimeBinding: {
transport: "rest_api",
configurationState: "configuration_required",
sessionTransportSupported: true,
},
});
renderPage({ rows: [configured, needsRuntimeConfiguration, orphan], presentation: "fleet" });
const configuredRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
expect(within(configuredRow).getByText("Active revision aaaaaaa")).toBeVisible();
expect(within(configuredRow).getByText("Evidence materialized · filesystem")).toBeVisible();
expect(within(configuredRow).getByText("Ready")).toBeVisible();
expect(within(configuredRow).getByText("Configured")).toBeVisible();
expect(within(configuredRow).getByText("Connection reachable")).toBeVisible();
const needsConfigurationRow = screen.getByRole("row", { name: /Research laboratory/ });
expect(within(needsConfigurationRow).getByText("Evidence credentials required · http")).toBeVisible();
expect(within(needsConfigurationRow).getByText("Configuration required")).toBeVisible();
expect(within(needsConfigurationRow).getByText("Not configured")).toBeVisible();
const orphanRow = screen.getByRole("row", { name: /Retired workspace/ });
expect(within(orphanRow).getByText("Workspace missing")).toBeVisible();
expect(within(orphanRow).getByText("Unavailable")).toBeVisible();
expect(within(orphanRow).getByText("Orphaned configuration")).toBeVisible();
});
test("keeps Fleet Ledger visible when a catalog response omits the Evidence projection", async () => {
const legacyDatabase = makeDatabase();
delete (legacyDatabase as Partial<CatalogDatabase>).workspaceEvidence;
renderPage({ rows: [legacyDatabase], presentation: "fleet" });
expect(await screen.findByRole("heading", { name: "Database management" })).toBeVisible();
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
expect(within(databaseRow).getByText("Evidence state unavailable")).toBeVisible();
});
test("opens the relationship map directly from a Fleet database and restores focus on return", async () => {
const user = userEvent.setup();
server.use(
http.get("/api/catalog/databases/:databaseId/relationships", () => HttpResponse.json([])),
);
renderPage({
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
presentation: "fleet",
});
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
const relationshipsAction = within(databaseRow).getByRole("button", {
name: "View relationships for Policlinico San Donato",
});
expect(relationshipsAction.parentElement).toHaveAttribute("data-tooltip", "Relationships");
expect(relationshipsAction).not.toHaveAttribute("title");
await user.click(relationshipsAction);
expect(await screen.findByRole("region", {
name: "Relationships for Policlinico San Donato",
})).toBeVisible();
expect(screen.getByText("Relationship map")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Back to databases" }));
await waitFor(() => expect(screen.getByRole("button", {
name: "View relationships for Policlinico San Donato",
})).toHaveFocus());
});
test("offers catalog configuration directly on an unconfigured Fleet workspace", async () => {
const user = userEvent.setup();
renderPage({ rows: [unconfigured], presentation: "fleet" });
const databaseRow = await screen.findByRole("row", { name: /Research laboratory/ });
expect(within(databaseRow).queryByRole("button", {
name: "View relationships for Research laboratory",
})).not.toBeInTheDocument();
expect(within(databaseRow).queryByRole("button", {
name: "View tables for Research laboratory",
})).not.toBeInTheDocument();
await user.click(within(databaseRow).getByRole("button", {
name: "Configure catalog for Research laboratory",
}));
const drawer = await screen.findByRole("dialog", { name: "Configure catalog" });
expect(within(drawer).getByLabelText("Workspace")).toHaveAttribute("readonly");
expect(within(drawer).getByLabelText("Workspace")).toHaveValue("Research laboratory");
});
test("keeps only the local back action while browsing Fleet Ledger columns", async () => {
const user = userEvent.setup();
server.use(
@@ -276,6 +384,32 @@ test("keeps only the local back action while browsing Fleet Ledger columns", asy
expect(screen.queryByRole("button", { name: "Back to tables" })).not.toBeInTheDocument();
});
test("uses the database back-action background treatment for the table back action", async () => {
const user = userEvent.setup();
server.use(
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => HttpResponse.json([])),
);
renderPage({ rows: [makeDatabase()], presentation: "fleet" });
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("button", {
name: "View tables for Policlinico San Donato",
}));
const databaseBackAction = await screen.findByRole("button", { name: "Back to databases" });
expect(databaseBackAction).toHaveClass("thot-fleet-back-action");
const backgroundTreatment = (element: HTMLElement) => element.className
.split(/\s+/)
.filter((className) => /(^|:)bg-/.test(className))
.sort();
await user.click(await screen.findByRole("button", { name: "View columns for patients" }));
const tableBackAction = await screen.findByRole("button", { name: "Back to tables" });
expect(tableBackAction).toHaveClass("thot-fleet-back-action");
expect(backgroundTreatment(tableBackAction)).toEqual(backgroundTreatment(databaseBackAction));
});
test("reopens database synchronization history when no run is active", async () => {
const user = userEvent.setup();
const completed: CatalogSyncRun = {
@@ -312,7 +446,7 @@ test("selects the configured metadata-description model by default", async () =>
})));
renderPage();
const selector = await screen.findByRole("combobox", { name: "Metadata description model" });
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
await waitFor(() => expect(selector).toHaveValue("local-qwen"));
});
@@ -320,7 +454,7 @@ test("keeps both run-history buttons visible beside the metadata-description sel
const user = userEvent.setup();
renderPage();
const selector = await screen.findByRole("combobox", { name: "Metadata description model" });
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
const toolbar = screen.getByRole("group", { name: "Database management controls" });
const metadataControls = screen.getByRole("group", { name: "Metadata description controls" });
const actions = screen.getByRole("group", { name: "Database management actions" });
@@ -344,7 +478,7 @@ test("keeps both run-history buttons visible beside the metadata-description sel
expect(toolbar.lastElementChild).toBe(actions);
expect(actions).toHaveClass("sm:justify-end");
expect(actions).toContainElement(screen.getByRole("button", { name: "Refresh" }));
expect(actions).toContainElement(screen.getByRole("button", { name: "Add database" }));
expect(within(actions).queryByRole("button", { name: /Add database/i })).not.toBeInTheDocument();
expect(screen.queryByRole("note", {
name: "Metadata generation source data disclosure",
})).not.toBeInTheDocument();
@@ -371,7 +505,7 @@ test("changes the metadata-description model in page-local state", async () => {
})));
renderPage();
const selector = await screen.findByRole("combobox", { name: "Metadata description model" });
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
await waitFor(() => expect(selector).toHaveValue("local-qwen"));
await user.selectOptions(selector, "openai-mini");
@@ -385,10 +519,10 @@ test("explains application setup when no metadata-description model is configure
})));
renderPage();
const selector = await screen.findByRole("combobox", { name: "Metadata description model" });
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
expect(selector).toBeDisabled();
expect(await screen.findByText(
"Configure a metadata-generation model in application setup to enable description generation.",
"No metadata-generation LLM model is configured for this installation.",
)).toBeVisible();
});
@@ -399,11 +533,11 @@ test("uses a safe disabled setup state when metadata-description models are unav
}, { status: 503 })));
renderPage();
const selector = await screen.findByRole("combobox", { name: "Metadata description model" });
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
await waitFor(() => expect(selector).toHaveDisplayValue("Model choices unavailable"));
expect(selector).toBeDisabled();
expect(screen.getByText(
"Configure a metadata-generation model in application setup to enable description generation.",
"Metadata-generation LLM model choices are unavailable.",
)).toBeVisible();
expect(screen.queryByText("internal model registry details")).not.toBeInTheDocument();
});
@@ -417,11 +551,11 @@ test.each([null, "missing-model"])(
})));
renderPage();
const selector = await screen.findByRole("combobox", { name: "Metadata description model" });
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
await waitFor(() => expect(selector).toHaveDisplayValue("No usable model configured"));
expect(selector).toBeDisabled();
expect(screen.getByText(
"Configure a metadata-generation model in application setup to enable description generation.",
"No metadata-generation LLM model is configured for this installation.",
)).toBeVisible();
},
);
@@ -441,7 +575,7 @@ test("renders only the public metadata-generation model contract", async () => {
})));
renderPage();
const selector = await screen.findByRole("combobox", { name: "Metadata description model" });
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
await waitFor(() => expect(selector).toHaveValue("approved-model"));
expect(within(selector).getAllByRole("option").map((option) => option.textContent))
.toEqual(["Approved model"]);
@@ -458,8 +592,9 @@ test("starts with a full-width list and applies the row action matrix", async ()
expect(screen.getByRole("button", { name: "Delete Policlinico San Donato" })).toBeEnabled();
expect(screen.getByRole("button", { name: "View Research laboratory" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Edit Research laboratory" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Delete Research laboratory" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Configure catalog for Research laboratory" })).toBeEnabled();
expect(screen.queryByRole("button", { name: "Edit Research laboratory" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Delete Research laboratory" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "View Retired workspace" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Edit Retired workspace" })).toBeDisabled();
@@ -558,7 +693,7 @@ test("keeps Fleet Generate Missing behind the source-data disclosure", async ()
"generate-missing",
);
await waitFor(() => expect(screen.getByRole("combobox", {
name: "Metadata description model",
name: "Metadata-generation LLM model",
})).toHaveValue("local-qwen"));
await user.click(screen.getByRole("button", { name: "Run action" }));
@@ -951,7 +1086,7 @@ test("replaces the list with View and returns focus to the originating action",
expect(screen.getByRole("button", { name: "View Policlinico San Donato" })).toBeVisible();
});
test("opens an unconfigured row as Edit while creating its first saved configuration", async () => {
test("configures the catalog for the selected workspace and locks that workspace", async () => {
const user = userEvent.setup();
let postBody: unknown;
const saved = makeDatabase({
@@ -971,16 +1106,16 @@ test("opens an unconfigured row as Edit while creating its first saved configura
);
renderPage({ rows: () => rows });
await user.click(await screen.findByRole("button", { name: "Edit Research laboratory" }));
await user.click(await screen.findByRole("button", { name: "Configure catalog for Research laboratory" }));
expect(screen.getByRole("heading", { name: "Edit database" })).toBeVisible();
expect(screen.getByLabelText("Workspace")).toBeDisabled();
expect(screen.getByRole("heading", { name: "Configure catalog" })).toBeVisible();
expect(screen.getByLabelText("Workspace")).toHaveAttribute("readonly");
await user.selectOptions(screen.getByLabelText("Transport"), "rest_api");
await user.type(screen.getByLabelText("Base URL"), "https://psd.example/api");
expect(screen.getByLabelText("Diagnostic endpoint")).toHaveValue("/health");
expect(screen.getByLabelText("Diagnostic endpoint")).toHaveAttribute("readonly");
await user.click(screen.getByRole("button", { name: "Save database" }));
await user.click(screen.getByRole("button", { name: "Save catalog configuration" }));
await waitFor(() => expect(postBody).toMatchObject({
workspaceId: "lab",
@@ -989,7 +1124,7 @@ test("opens an unconfigured row as Edit while creating its first saved configura
expect(await screen.findByRole("heading", { name: "Edit database" })).toBeVisible();
});
test("global Add offers only unconfigured workspaces and lets the operator choose one", async () => {
test("has no global database creation path and scopes configuration to the chosen row", async () => {
const user = userEvent.setup();
const second = makeDatabase({
...unconfigured,
@@ -999,14 +1134,13 @@ test("global Add offers only unconfigured workspaces and lets the operator choos
});
renderPage({ rows: [makeDatabase(), unconfigured, second] });
await user.click(await screen.findByRole("button", { name: "Add database" }));
expect(screen.queryByRole("button", { name: /Add database/i })).not.toBeInTheDocument();
await user.click(await screen.findByRole("button", { name: "Configure catalog for Radiology" }));
expect(screen.getByRole("heading", { name: "Add database" })).toBeVisible();
expect(screen.getByRole("button", { name: "Add database" })).toBeVisible();
const selector = screen.getByLabelText("Workspace");
expect(selector).toBeEnabled();
expect(screen.queryByRole("option", { name: "Policlinico San Donato" })).not.toBeInTheDocument();
await user.selectOptions(selector, "radiology");
expect(screen.getByRole("heading", { name: "Configure catalog" })).toBeVisible();
const workspace = screen.getByLabelText("Workspace");
expect(workspace).toHaveAttribute("readonly");
expect(workspace).toHaveValue("Radiology");
expect(screen.getByDisplayValue("radiology_dwh")).toBeVisible();
});
@@ -1064,7 +1198,8 @@ test("uses an in-page destructive form and returns the YAML workspace to Not con
await waitFor(() => expect(deleteCalled).toBe(true));
expect(deleteVersion).toBe("3");
expect(await screen.findByRole("button", { name: "Delete Policlinico San Donato" })).toBeDisabled();
expect(await screen.findByRole("button", { name: "Configure catalog for Policlinico San Donato" })).toBeEnabled();
expect(screen.queryByRole("button", { name: "Delete Policlinico San Donato" })).not.toBeInTheDocument();
expect(screen.getByText("Not configured")).toBeVisible();
});
@@ -2677,6 +2812,8 @@ test("navigates from a table to columns and from the database to physical relati
deferrable: false,
initiallyDeferred: false,
columns: [{ position: 1, sourceColumnId: "1", sourceColumnName: "patient_id", targetColumnId: idColumn.id, targetColumnName: "id" }],
origin: "physical",
status: "active",
lastSyncedDatabaseVersion: 3,
lastSyncedAt: "2026-08-27T10:00:00Z",
createdAt: "2026-08-27T10:00:00Z",
+73 -113
View File
@@ -6,7 +6,7 @@ import {
useState,
} from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { History, Plus, RefreshCw } from "lucide-react";
import { History, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { ApiError, apiErrorMessage } from "../api/client";
@@ -209,11 +209,7 @@ export function DatabaseManagementPage({
() => queryClient.invalidateQueries({ queryKey: ["catalog-metrics"] }),
[queryClient],
);
const availableWorkspaces = useMemo(
() => rows.filter((row) => row.workspaceAvailable && !row.configured),
[rows],
);
const editable = screen.kind === "add" || screen.kind === "edit";
const editable = screen.kind === "configure" || screen.kind === "edit";
const configurationDirty = Boolean(
editable
&& draft
@@ -221,8 +217,9 @@ export function DatabaseManagementPage({
);
const dirty = Boolean(editable && draft && (configurationDirty || hasSecretChanges(draft)));
const busy = busyAction !== null;
const navigationDirty = screen.kind === "tables" ? tablesNavigationState.dirty : dirty;
const navigationBusy = screen.kind === "tables" ? tablesNavigationState.busy : busy;
const catalogChildVisible = screen.kind === "tables" || screen.kind === "relationships";
const navigationDirty = catalogChildVisible ? tablesNavigationState.dirty : dirty;
const navigationBusy = catalogChildVisible ? tablesNavigationState.busy : busy;
useEffect(() => {
onNavigationStateChange?.({ dirty: navigationDirty, busy: navigationBusy });
@@ -263,9 +260,14 @@ export function DatabaseManagementPage({
}, [queryClient]);
const restoreListFocus = useCallback(() => {
const originLabel = originRef.current?.getAttribute("aria-label");
window.setTimeout(() => {
if (originRef.current?.isConnected) {
originRef.current.focus();
} else if (originLabel) {
const matchingAction = [...document.querySelectorAll<HTMLButtonElement>("button")]
.find((button) => button.getAttribute("aria-label") === originLabel);
(matchingAction ?? searchInputRef.current)?.focus();
} else {
searchInputRef.current?.focus();
}
@@ -307,7 +309,6 @@ export function DatabaseManagementPage({
mode: DatabaseFormMode,
row: CatalogDatabase,
origin: HTMLElement,
workspaceLocked = false,
) => {
const nextDraft = draftFrom(row);
originRef.current = origin;
@@ -320,7 +321,6 @@ export function DatabaseManagementPage({
setScreen({
kind: mode,
workspaceId: row.workspaceId,
...(mode === "add" ? { workspaceLocked } : {}),
});
}, []);
@@ -329,7 +329,7 @@ export function DatabaseManagementPage({
}, [openForm]);
const editRow = useCallback((row: CatalogDatabase, origin: HTMLButtonElement) => {
openForm(row.configured ? "edit" : "add", row, origin, !row.configured);
openForm(row.configured ? "edit" : "configure", row, origin);
}, [openForm]);
const deleteRow = useCallback((row: CatalogDatabase, origin: HTMLButtonElement) => {
@@ -349,8 +349,9 @@ export function DatabaseManagementPage({
setScreen({ kind: "tables", workspaceId: row.workspaceId });
}, []);
const openRelationships = useCallback((row: CatalogDatabase) => {
const openRelationships = useCallback((row: CatalogDatabase, origin?: HTMLElement) => {
if (!row.configured || !row.id) return;
if (origin) originRef.current = origin;
setDraft(null);
setBaseline("");
setFormSource(null);
@@ -372,25 +373,6 @@ export function DatabaseManagementPage({
setScreen({ kind: "view", workspaceId: row.workspaceId });
}, []);
const addDatabase = useCallback((origin: HTMLElement) => {
const workspace = availableWorkspaces[0];
if (!workspace || !canManage) return;
openForm("add", workspace, origin, false);
}, [availableWorkspaces, canManage, openForm]);
const changeWorkspace = useCallback((workspaceId: string) => {
const workspace = availableWorkspaces.find((row) => row.workspaceId === workspaceId);
if (!workspace) return;
if (dirty && !window.confirm("Discard changes and choose another workspace?")) return;
const nextDraft = draftFrom(workspace);
setDraft(nextDraft);
setBaseline(configurationFingerprint(nextDraft));
setFormSource({ id: workspace.id, version: workspace.version });
setStale(false);
setPartialSecretFailure(null);
setScreen({ kind: "add", workspaceId, workspaceLocked: false });
}, [availableWorkspaces, dirty]);
const changeField = useCallback((field: "databaseName" | "schema", value: string) => {
setDraft((current) => current ? { ...current, [field]: value } : current);
}, []);
@@ -624,7 +606,7 @@ export function DatabaseManagementPage({
showList();
toast.info("This database configuration has already been deleted");
} else if (screen.kind === "edit" && !latest.configured) {
setScreen({ kind: "add", workspaceId: latest.workspaceId, workspaceLocked: true });
setScreen({ kind: "configure", workspaceId: latest.workspaceId });
}
} catch (error) {
toast.error(apiErrorMessage(error));
@@ -669,22 +651,40 @@ export function DatabaseManagementPage({
}, [queryClient]);
const openDescriptionGenerationHistory = useCallback(() => {
const run = observedActiveDescriptionGenerationRun
?? descriptionGenerationRuns[0]
?? activeDescriptionGenerationRun;
const scopedRuns = activeRow ? descriptionGenerationRuns.filter((item) => item.databaseId === activeRow.id) : descriptionGenerationRuns;
const scopedActiveRun = observedActiveDescriptionGenerationRun
&& (!activeRow || observedActiveDescriptionGenerationRun.databaseId === activeRow.id)
? observedActiveDescriptionGenerationRun
: undefined;
const scopedSelectedRun = activeDescriptionGenerationRun
&& (!activeRow || activeDescriptionGenerationRun.databaseId === activeRow.id)
? activeDescriptionGenerationRun
: undefined;
const run = scopedActiveRun
?? scopedRuns[0]
?? scopedSelectedRun;
if (run) setActiveDescriptionGenerationRun(run);
setSensitiveDataSuggestionHistoryDrawerOpen(false);
setDescriptionGenerationDrawerOpen(true);
}, [activeDescriptionGenerationRun, descriptionGenerationRuns, observedActiveDescriptionGenerationRun]);
}, [activeDescriptionGenerationRun, activeRow, descriptionGenerationRuns, observedActiveDescriptionGenerationRun]);
const openSensitiveDataSuggestionHistory = useCallback(() => {
const run = observedActiveSensitiveDataSuggestionRun
?? sensitiveDataSuggestionRuns[0]
?? activeSensitiveDataSuggestionRun;
const scopedRuns = activeRow ? sensitiveDataSuggestionRuns.filter((item) => item.databaseId === activeRow.id) : sensitiveDataSuggestionRuns;
const scopedActiveRun = observedActiveSensitiveDataSuggestionRun
&& (!activeRow || observedActiveSensitiveDataSuggestionRun.databaseId === activeRow.id)
? observedActiveSensitiveDataSuggestionRun
: undefined;
const scopedSelectedRun = activeSensitiveDataSuggestionRun
&& (!activeRow || activeSensitiveDataSuggestionRun.databaseId === activeRow.id)
? activeSensitiveDataSuggestionRun
: undefined;
const run = scopedActiveRun
?? scopedRuns[0]
?? scopedSelectedRun;
if (run) setActiveSensitiveDataSuggestionRun(run);
setDescriptionGenerationDrawerOpen(false);
setSensitiveDataSuggestionHistoryDrawerOpen(true);
}, [activeSensitiveDataSuggestionRun, observedActiveSensitiveDataSuggestionRun, sensitiveDataSuggestionRuns]);
}, [activeRow, activeSensitiveDataSuggestionRun, observedActiveSensitiveDataSuggestionRun, sensitiveDataSuggestionRuns]);
const descriptionGenerationTerminated = useCallback(async (run: DescriptionGenerationRun) => {
await Promise.all([
@@ -902,6 +902,7 @@ export function DatabaseManagementPage({
isLoading={metadataModelsQuery.isLoading}
selectedModel={selectedMetadataModel}
onSelectedModelChange={setSelectedMetadataModel}
variant="compact"
/>
);
@@ -943,6 +944,7 @@ export function DatabaseManagementPage({
/>
<DescriptionGenerationDrawer
open={descriptionGenerationDrawerOpen}
databaseId={activeRow?.id ?? null}
run={activeDescriptionGenerationRun}
modelLabel={metadataModels.find(
(model) => model.id === activeDescriptionGenerationRun?.modelId,
@@ -953,6 +955,7 @@ export function DatabaseManagementPage({
/>
<SensitiveDataSuggestionHistoryDrawer
open={sensitiveDataSuggestionHistoryDrawerOpen}
databaseId={activeRow?.id ?? null}
run={activeSensitiveDataSuggestionRun}
modelLabel={metadataModels.find(
(model) => model.id === activeSensitiveDataSuggestionRun?.modelId,
@@ -980,7 +983,6 @@ export function DatabaseManagementPage({
eyebrow="Catalog configuration"
title={databaseFormTitle(
fleetFormMode,
fleetFormMode === "add" && Boolean(screen.kind === "add" && screen.workspaceLocked),
"drawer",
)}
description={`${activeRow.workspaceName} · ${activeRow.workspaceId}`}
@@ -993,8 +995,6 @@ export function DatabaseManagementPage({
presentation="drawer"
mode={fleetFormMode}
row={activeRow}
availableWorkspaces={availableWorkspaces}
workspaceLocked={fleetFormMode === "add" && Boolean(screen.kind === "add" && screen.workspaceLocked)}
draft={draft}
dirty={dirty}
canManage={canManage}
@@ -1005,7 +1005,6 @@ export function DatabaseManagementPage({
partialSecretFailure={partialSecretFailure}
headingRef={formHeadingRef}
onBack={backToList}
onWorkspaceChange={changeWorkspace}
onFieldChange={changeField}
onTransportChange={changeTransport}
onBindingChange={changeBinding}
@@ -1020,6 +1019,8 @@ export function DatabaseManagementPage({
onOpenRelationships={() => openRelationships(activeRow)}
onSync={(scope) => void syncDatabase(scope)}
onOpenSync={() => openSync(activeRow)}
onOpenDescriptionHistory={openDescriptionGenerationHistory}
onOpenSensitiveHistory={openSensitiveDataSuggestionHistory}
activeSyncRun={currentActiveRun}
/>
</FleetLedgerDrawer>
@@ -1040,10 +1041,11 @@ export function DatabaseManagementPage({
onNestedNavigationChange={setTableNestedNavigationActive}
onRunStarted={rememberSyncRun}
onOpenSync={() => openSync(activeRow)}
onOpenDescriptionHistory={openDescriptionGenerationHistory}
onOpenSensitiveHistory={openSensitiveDataSuggestionHistory}
onDescriptionGenerationRunStarted={rememberDescriptionGenerationRun}
onSuggestSensitive={suggestActiveDatabaseSensitiveFields}
onCatalogMetricsChanged={invalidateCatalogMetrics}
metadataModelControl={fleetMetadataModelControl}
/>
) : screen.kind === "relationships" && relationshipsVisible ? (
<DatabaseRelationships
@@ -1055,6 +1057,10 @@ export function DatabaseManagementPage({
onOpenTables={() => openTables(activeRow)}
onRunStarted={rememberSyncRun}
onOpenSync={() => openSync(activeRow)}
onOpenDescriptionHistory={openDescriptionGenerationHistory}
onOpenSensitiveHistory={openSensitiveDataSuggestionHistory}
onCatalogMetricsChanged={invalidateCatalogMetrics}
onNavigationStateChange={setTablesNavigationState}
/>
) : isError && rows.length === 0 ? (
<div className="grid h-full place-items-center p-6">
@@ -1078,6 +1084,7 @@ export function DatabaseManagementPage({
onSearchChange={setSearch}
onView={viewRow}
onOpenTables={(row, origin) => openTables(row, origin)}
onOpenRelationships={(row, origin) => openRelationships(row, origin)}
onEdit={editRow}
onDelete={deleteRow}
onOpenSync={openSync}
@@ -1088,7 +1095,6 @@ export function DatabaseManagementPage({
onGenerateDescriptions={generateDatabaseDescriptions}
onSuggestSensitive={suggestDatabaseSensitiveFields}
onDeleteMetadataSelected={deleteSelectedMetadata}
metadataModelControl={fleetMetadataModelControl}
onRefresh={refreshList}
/>
);
@@ -1118,28 +1124,7 @@ export function DatabaseManagementPage({
)}
actions={(
<>
<Button type="button" variant="outline" size="sm" className="thot-fleet-nav-action thot-fleet-nav-action--below" data-tooltip="Description history" data-fleet-action="secondary" onClick={openDescriptionGenerationHistory}>
<History aria-hidden="true" /> Description history
{observedActiveDescriptionGenerationRun ? <span className="size-1.5 rounded-full bg-warning" aria-hidden="true" /> : null}
</Button>
<Button type="button" variant="outline" size="sm" className="thot-fleet-nav-action thot-fleet-nav-action--below" data-tooltip="Sensitive history" data-fleet-action="secondary" onClick={openSensitiveDataSuggestionHistory}>
<History aria-hidden="true" /> Sensitive history
{observedActiveSensitiveDataSuggestionRun ? <span className="size-1.5 rounded-full bg-warning" aria-hidden="true" /> : null}
</Button>
{(screen.kind === "list" || formVisible) ? (
<Button
type="button"
size="sm"
className="thot-fleet-nav-action thot-fleet-nav-action--below"
data-tooltip="Add database"
data-fleet-action="primary"
disabled={!canManage || availableWorkspaces.length === 0}
title={availableWorkspaces.length === 0 ? "Every available workspace is already configured" : undefined}
onClick={(event) => addDatabase(event.currentTarget)}
>
<Plus aria-hidden="true" /> Add database
</Button>
) : null}
{fleetMetadataModelControl}
</>
)}
/>
@@ -1205,40 +1190,16 @@ export function DatabaseManagementPage({
selectedModel={selectedMetadataModel}
onSelectedModelChange={setSelectedMetadataModel}
/>
<Button
type="button"
variant="outline"
className="whitespace-nowrap disabled:opacity-70"
aria-label="Run descriptions generation history"
disabled={!canManage}
title={observedActiveDescriptionGenerationRun
? "Description generation is active"
: "View description generation history"}
onClick={openDescriptionGenerationHistory}
>
<History />
Run descriptions generation history
{observedActiveDescriptionGenerationRun ? (
<span aria-hidden="true" className="size-1.5 rounded-full bg-primary" />
) : null}
</Button>
<Button
type="button"
variant="outline"
className="whitespace-nowrap disabled:opacity-70"
aria-label="Run sensitive suggestions history"
disabled={!canManage}
title={observedActiveSensitiveDataSuggestionRun
? "Sensitive suggestion generation is active"
: "View sensitive suggestion history"}
onClick={openSensitiveDataSuggestionHistory}
>
<History />
Run sensitive suggestions history
{observedActiveSensitiveDataSuggestionRun ? (
<span aria-hidden="true" className="size-1.5 rounded-full bg-primary" />
) : null}
</Button>
{screen.kind === "list" ? <>
<Button type="button" variant="outline" className="whitespace-nowrap disabled:opacity-70" aria-label="Run descriptions generation history" disabled={!canManage} title="View description generation history" onClick={openDescriptionGenerationHistory}>
<History /> Run descriptions generation history
</Button>
<Button type="button" variant="outline" className="whitespace-nowrap disabled:opacity-70" aria-label="Run sensitive suggestions history" disabled={!canManage} title={observedActiveSensitiveDataSuggestionRun ? "Sensitive suggestion generation is active" : "View sensitive suggestion history"} onClick={openSensitiveDataSuggestionHistory}>
<History />
{observedActiveSensitiveDataSuggestionRun ? <span aria-hidden="true" className="size-2 rounded-full bg-primary" /> : null}
Run sensitive suggestions history
</Button>
</> : null}
</div>
{screen.kind === "list" ? (
<div
@@ -1249,14 +1210,6 @@ export function DatabaseManagementPage({
<Button type="button" variant="outline" disabled={isFetching} onClick={() => void refreshList()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} /> Refresh
</Button>
<Button
type="button"
disabled={!canManage || availableWorkspaces.length === 0}
title={availableWorkspaces.length === 0 ? "Every available workspace is already configured" : undefined}
onClick={(event) => addDatabase(event.currentTarget)}
>
<Plus /> Add database
</Button>
</div>
) : null}
</div>
@@ -1308,8 +1261,6 @@ export function DatabaseManagementPage({
<DatabaseForm
mode={screen.kind as DatabaseFormMode}
row={activeRow}
availableWorkspaces={availableWorkspaces}
workspaceLocked={screen.kind === "add" && Boolean(screen.workspaceLocked)}
draft={draft}
dirty={dirty}
canManage={canManage}
@@ -1320,7 +1271,6 @@ export function DatabaseManagementPage({
partialSecretFailure={partialSecretFailure}
headingRef={formHeadingRef}
onBack={backToList}
onWorkspaceChange={changeWorkspace}
onFieldChange={changeField}
onTransportChange={changeTransport}
onBindingChange={changeBinding}
@@ -1335,6 +1285,8 @@ export function DatabaseManagementPage({
onOpenRelationships={() => openRelationships(activeRow)}
onSync={(scope) => void syncDatabase(scope)}
onOpenSync={() => openSync(activeRow)}
onOpenDescriptionHistory={openDescriptionGenerationHistory}
onOpenSensitiveHistory={openSensitiveDataSuggestionHistory}
activeSyncRun={currentActiveRun}
/>
) : null}
@@ -1352,6 +1304,8 @@ export function DatabaseManagementPage({
onNavigationStateChange={setTablesNavigationState}
onRunStarted={rememberSyncRun}
onOpenSync={() => openSync(activeRow)}
onOpenDescriptionHistory={openDescriptionGenerationHistory}
onOpenSensitiveHistory={openSensitiveDataSuggestionHistory}
onDescriptionGenerationRunStarted={rememberDescriptionGenerationRun}
onSuggestSensitive={suggestActiveDatabaseSensitiveFields}
onCatalogMetricsChanged={invalidateCatalogMetrics}
@@ -1367,6 +1321,10 @@ export function DatabaseManagementPage({
onOpenTables={() => openTables(activeRow)}
onRunStarted={rememberSyncRun}
onOpenSync={() => openSync(activeRow)}
onOpenDescriptionHistory={openDescriptionGenerationHistory}
onOpenSensitiveHistory={openSensitiveDataSuggestionHistory}
onCatalogMetricsChanged={invalidateCatalogMetrics}
onNavigationStateChange={setTablesNavigationState}
/>
) : null}
</div>
@@ -1381,6 +1339,7 @@ export function DatabaseManagementPage({
/>
<DescriptionGenerationDrawer
open={descriptionGenerationDrawerOpen}
databaseId={activeRow?.id ?? null}
run={activeDescriptionGenerationRun}
modelLabel={metadataModels.find(
(model) => model.id === activeDescriptionGenerationRun?.modelId,
@@ -1391,6 +1350,7 @@ export function DatabaseManagementPage({
/>
<SensitiveDataSuggestionHistoryDrawer
open={sensitiveDataSuggestionHistoryDrawerOpen}
databaseId={activeRow?.id ?? null}
run={activeSensitiveDataSuggestionRun}
modelLabel={metadataModels.find(
(model) => model.id === activeSensitiveDataSuggestionRun?.modelId,
@@ -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;