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