feat: complete catalog fleet management workflow
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
@@ -6,7 +6,7 @@ import { server } from "../test/msw";
|
||||
import { FakeEventSource } from "../test/fakeEventSource";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
import { clearAuthState, setAuthState } from "../auth/authState";
|
||||
import { AppShell } from "./AppShell";
|
||||
import { AppShell, resolveDatabaseManagementPresentation } from "./AppShell";
|
||||
|
||||
function renderShell() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
@@ -29,6 +29,7 @@ beforeEach(() => {
|
||||
session: null,
|
||||
});
|
||||
localStorage.clear();
|
||||
window.history.replaceState(null, "", "/");
|
||||
FakeEventSource.instances = [];
|
||||
(globalThis as any).EventSource = FakeEventSource;
|
||||
useSessionStore.getState().resetSession();
|
||||
@@ -49,33 +50,66 @@ beforeEach(() => {
|
||||
http.get("/api/workspaces", () => HttpResponse.json([])),
|
||||
http.get("/api/models", () => HttpResponse.json({ models: [] })),
|
||||
http.post("/api/runtime/prewarm", () => new HttpResponse(null, { status: 202 })),
|
||||
http.get("/api/catalog/metrics", () => HttpResponse.json({
|
||||
scope: "global",
|
||||
databaseId: null,
|
||||
tables: 0,
|
||||
columns: 0,
|
||||
sensitiveColumns: 0,
|
||||
relationships: 0,
|
||||
descriptionTargets: 0,
|
||||
describedTargets: 0,
|
||||
descriptionCoverage: 0,
|
||||
updatedAt: null,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
test("opens the database management surface and returns to the core", async () => {
|
||||
renderShell();
|
||||
|
||||
const composer = screen.getByRole("textbox", { name: /new question/i });
|
||||
await userEvent.type(composer, "Unsent draft");
|
||||
|
||||
const databaseManagement = screen.getByRole("button", { name: "Database management" });
|
||||
await userEvent.click(databaseManagement);
|
||||
|
||||
expect(screen.getByRole("main", { name: "Database management" })).toBeVisible();
|
||||
expect(databaseManagement).toHaveAttribute("aria-current", "page");
|
||||
expect(databaseManagement).toHaveClass("w-full");
|
||||
expect(databaseManagement).not.toHaveClass("bg-sidebar-accent");
|
||||
expect(screen.queryByRole("textbox", { name: /new question/i })).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "New session" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("main", { name: "Database management" })).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole("textbox", { name: /new question/i })).toHaveValue("Unsent draft");
|
||||
test("uses Fleet Ledger by default and gates the legacy fallback to non-production environments", () => {
|
||||
expect(resolveDatabaseManagementPresentation({
|
||||
isDevelopment: true,
|
||||
mode: "development",
|
||||
search: "",
|
||||
})).toBe("fleet");
|
||||
expect(resolveDatabaseManagementPresentation({
|
||||
isDevelopment: true,
|
||||
mode: "development",
|
||||
search: "?db-ui=legacy",
|
||||
})).toBe("legacy");
|
||||
expect(resolveDatabaseManagementPresentation({
|
||||
isDevelopment: false,
|
||||
mode: "staging",
|
||||
legacyFlag: "true",
|
||||
search: "",
|
||||
})).toBe("legacy");
|
||||
expect(resolveDatabaseManagementPresentation({
|
||||
isDevelopment: false,
|
||||
mode: "production",
|
||||
legacyFlag: "true",
|
||||
search: "?db-ui=legacy",
|
||||
})).toBe("fleet");
|
||||
});
|
||||
|
||||
test("keeps a live core session connected and returns when that session is opened", async () => {
|
||||
test("replaces the core conversation while keeping the session navigation", async () => {
|
||||
renderShell();
|
||||
|
||||
const conversationColumn = screen.getByTestId("conversation-column");
|
||||
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
|
||||
const databaseManagement = within(sessionNavigation).getByRole("button", { name: "Database management" });
|
||||
await userEvent.click(databaseManagement);
|
||||
|
||||
const manager = screen.getByRole("main", { name: "Database management" });
|
||||
expect(manager).toBeVisible();
|
||||
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(databaseManagement).toHaveAttribute("aria-current", "page");
|
||||
expect(screen.queryByRole("button", { name: "Back to workspace" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("textbox", { name: /new question/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps a live core session connected while returning from database management", async () => {
|
||||
server.use(
|
||||
http.get("/api/sessions", () => HttpResponse.json([{
|
||||
id: "s1",
|
||||
@@ -113,6 +147,7 @@ test("keeps a live core session connected and returns when that session is opene
|
||||
expect(source.closed).toBe(false);
|
||||
expect(FakeEventSource.instances).toHaveLength(1);
|
||||
|
||||
expect(session).toBeVisible();
|
||||
await userEvent.click(session);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -174,14 +209,15 @@ test("does not leave database management without confirming a dirty form", async
|
||||
await userEvent.clear(schema);
|
||||
await userEvent.type(schema, "reporting");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "New session" }));
|
||||
const newSession = screen.getByRole("button", { name: "New session" });
|
||||
await userEvent.click(newSession);
|
||||
|
||||
expect(confirm).toHaveBeenCalledWith("Discard unsaved database changes and leave database management?");
|
||||
expect(screen.getByRole("main", { name: "Database management" })).toBeVisible();
|
||||
expect(schema).toHaveValue("reporting");
|
||||
|
||||
confirm.mockReturnValue(true);
|
||||
await userEvent.click(screen.getByRole("button", { name: "New session" }));
|
||||
await userEvent.click(newSession);
|
||||
await waitFor(() => expect(screen.queryByRole("main", { name: "Database management" })).not.toBeInTheDocument());
|
||||
confirm.mockRestore();
|
||||
});
|
||||
|
||||
@@ -166,6 +166,11 @@ test("opens Workspace management from the right sidebar without interrupting the
|
||||
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" });
|
||||
expect(workArea).toContainElement(dialog);
|
||||
expect(sessionNavigation).not.toContainElement(dialog);
|
||||
expect(screen.getByTestId("app-shell")).toHaveAttribute("data-activity-layout", "closed");
|
||||
});
|
||||
|
||||
|
||||
@@ -121,9 +121,14 @@ test("Pi management preserves the open session summary and the model activity ti
|
||||
expect(await screen.findByRole("complementary", { name: "Session summary" })).toHaveTextContent("Domanda originale");
|
||||
await user.click(screen.getByRole("button", { name: "Pi management" }));
|
||||
expect(await screen.findByRole("heading", { name: "Pi management" })).toBeVisible();
|
||||
const hiddenSummary = document.querySelector<HTMLElement>('aside[aria-label="Session summary"]');
|
||||
expect(hiddenSummary).toHaveAttribute("aria-hidden", "true");
|
||||
expect(hiddenSummary).toHaveTextContent("Attiva uno");
|
||||
const dialog = screen.getByRole("dialog", { name: "Pi management" });
|
||||
const workArea = screen.getByTestId("conversation-column");
|
||||
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
|
||||
expect(workArea).toContainElement(dialog);
|
||||
expect(sessionNavigation).not.toContainElement(dialog);
|
||||
const preservedSummary = screen.getByRole("complementary", { name: "Session summary" });
|
||||
expect(preservedSummary).not.toHaveAttribute("aria-hidden", "true");
|
||||
expect(preservedSummary).toHaveTextContent("Attiva uno");
|
||||
await user.click(screen.getByRole("button", { name: "Close Pi management" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("complementary", { name: "Session summary" })).toHaveTextContent("Domanda originale");
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CentralStatus } from "./CentralStatus";
|
||||
import { ModelActivityPanel } from "./ModelActivityPanel";
|
||||
import { WorkspaceManager } from "./WorkspaceManager";
|
||||
import { PiManagement } from "./PiManagement";
|
||||
import { WorkAreaPanelHost } from "./WorkAreaPanel";
|
||||
import { useActivityPanelResize } from "./useActivityPanelResize";
|
||||
import { useSessionPanelResize } from "./useSessionPanelResize";
|
||||
import { NavSessions } from "./NavSessions";
|
||||
@@ -41,6 +42,35 @@ interface AppShellProps {
|
||||
}
|
||||
|
||||
type ActiveSurface = "core" | "database-management";
|
||||
type ActiveManagementPanel = "workspace" | "pi" | null;
|
||||
|
||||
type DatabaseManagementPresentation = "legacy" | "fleet";
|
||||
|
||||
interface DatabaseManagementPresentationEnvironment {
|
||||
isDevelopment: boolean;
|
||||
mode: string;
|
||||
legacyFlag?: string | boolean;
|
||||
search: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fleet Ledger is the release-safe default. The old surface can only be
|
||||
* requested explicitly from a development server or a staging build, so an
|
||||
* accidentally retained flag can never switch the production UI back.
|
||||
*/
|
||||
export function resolveDatabaseManagementPresentation({
|
||||
isDevelopment,
|
||||
mode,
|
||||
legacyFlag,
|
||||
search,
|
||||
}: DatabaseManagementPresentationEnvironment): DatabaseManagementPresentation {
|
||||
const legacyAllowed = isDevelopment || mode.trim().toLowerCase() === "staging";
|
||||
if (!legacyAllowed) return "fleet";
|
||||
|
||||
const legacyRequestedByQuery = new URLSearchParams(search).get("db-ui")?.toLowerCase() === "legacy";
|
||||
const legacyRequestedByEnvironment = String(legacyFlag ?? "").trim().toLowerCase() === "true";
|
||||
return legacyRequestedByQuery || legacyRequestedByEnvironment ? "legacy" : "fleet";
|
||||
}
|
||||
|
||||
export function AppShell({ canLogout }: AppShellProps) {
|
||||
const authenticatedUser = useAuthUser();
|
||||
@@ -123,12 +153,17 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [showActivity, setShowActivity] = useState(false);
|
||||
const [activeSurface, setActiveSurface] = useState<ActiveSurface>("core");
|
||||
const databaseManagementPresentation = resolveDatabaseManagementPresentation({
|
||||
isDevelopment: import.meta.env.DEV,
|
||||
mode: import.meta.env.MODE,
|
||||
legacyFlag: import.meta.env.VITE_DB_MANAGEMENT_LEGACY,
|
||||
search: window.location.search,
|
||||
});
|
||||
const databaseNavigationRef = useRef({ dirty: false, busy: false });
|
||||
const updateDatabaseNavigationState = useCallback((state: { dirty: boolean; busy: boolean }) => {
|
||||
databaseNavigationRef.current = state;
|
||||
}, []);
|
||||
const [workspaceManagerOpen, setWorkspaceManagerOpen] = useState(false);
|
||||
const [piManagementOpen, setPiManagementOpen] = useState(false);
|
||||
const [activeManagementPanel, setActiveManagementPanel] = useState<ActiveManagementPanel>(null);
|
||||
const [activeOpen, setActiveOpen] = useState(true);
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
const [renameTarget, setRenameTarget] = useState<SessionSummary | null>(null);
|
||||
@@ -218,6 +253,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
const s = sessions.find((x) => x.id === id);
|
||||
if (!s) return;
|
||||
setActiveSurface("core");
|
||||
setActiveManagementPanel(null);
|
||||
// A session with a live Pi runtime opens straight into its live view: doResume
|
||||
// reconnects to the already-active runtime and replays its pending gate, so an
|
||||
// in-progress session never shows an empty screen that reads as "stopped". Cold or
|
||||
@@ -239,6 +275,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
async function doResume(id: string, databaseExitApproved = false) {
|
||||
if (!databaseExitApproved && !canLeaveDatabaseManagement()) return;
|
||||
setActiveSurface("core");
|
||||
setActiveManagementPanel(null);
|
||||
const guard = captureAuthOperation({ sessionId: id, disposalEpoch: operationEpochRef.current });
|
||||
if (!guard) return;
|
||||
const token = ++resumeInvocationRef.current;
|
||||
@@ -522,6 +559,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
function startNewSession() {
|
||||
if (!canLeaveDatabaseManagement()) return;
|
||||
setActiveSurface("core");
|
||||
setActiveManagementPanel(null);
|
||||
invalidateResumeIntent();
|
||||
newSessionOperationRef.current = null;
|
||||
resetSession();
|
||||
@@ -623,7 +661,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showActivity && <ModelActivityPanel desktopSplit={desktopSplit} onClose={() => setShowActivity(false)} onOpenWorkspaceManager={() => setWorkspaceManagerOpen(true)} hidden={activeSurface !== "core"} />}
|
||||
{showActivity && <ModelActivityPanel desktopSplit={desktopSplit} onClose={() => setShowActivity(false)} onOpenWorkspaceManager={() => setActiveManagementPanel("workspace")} hidden={activeSurface !== "core"} />}
|
||||
{activeSurface === "core" && showActivity && desktopSplit && (
|
||||
<div className="relative w-0 shrink-0">
|
||||
<div
|
||||
@@ -638,12 +676,13 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
)}
|
||||
|
||||
{/* Conversation column */}
|
||||
<div data-testid="conversation-column" className="flex min-w-0 flex-1 flex-col">
|
||||
<WorkAreaPanelHost data-testid="conversation-column" className="flex min-w-0 flex-1 flex-col">
|
||||
{activeSurface === "database-management" && (
|
||||
<DatabaseManagementPage
|
||||
canManage={canManageDatabase}
|
||||
canManageSecrets={canManageWorkspaceSecrets}
|
||||
onNavigationStateChange={updateDatabaseNavigationState}
|
||||
presentation={databaseManagementPresentation}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
@@ -727,11 +766,23 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<WorkspaceManager
|
||||
open={activeManagementPanel === "workspace"}
|
||||
onClose={() => setActiveManagementPanel(null)}
|
||||
canManageWorkspace={canManageWorkspace}
|
||||
canManageSecrets={canManageWorkspaceSecrets}
|
||||
/>
|
||||
{canManagePi && (
|
||||
<PiManagement
|
||||
open={activeManagementPanel === "pi"}
|
||||
onClose={() => setActiveManagementPanel(null)}
|
||||
/>
|
||||
)}
|
||||
</WorkAreaPanelHost>
|
||||
|
||||
{/* Right session rail */}
|
||||
{!showActivity && (
|
||||
<aside className="flex w-[15vw] shrink-0 flex-col border-l border-border bg-sidebar">
|
||||
{(activeSurface === "database-management" || !showActivity) && (
|
||||
<aside aria-label="Session navigation" className="flex w-[15vw] shrink-0 flex-col bg-sidebar">
|
||||
<div className="relative px-4 pb-3 pt-5 text-center">
|
||||
<h1 className="font-heading text-xl font-semibold leading-none tracking-tight text-foreground">
|
||||
Thoth<span className="text-primary">II</span>
|
||||
@@ -767,25 +818,28 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
if (!canLeaveDatabaseManagement()) return;
|
||||
setActiveSurface("core");
|
||||
setWorkspaceManagerOpen(true);
|
||||
}}
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
if (!canLeaveDatabaseManagement()) return;
|
||||
setActiveSurface("core");
|
||||
setActiveManagementPanel("workspace");
|
||||
}}
|
||||
>
|
||||
Workspace management
|
||||
</Button>
|
||||
{canManageDatabase && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
aria-current={activeSurface === "database-management" ? "page" : undefined}
|
||||
onClick={() => setActiveSurface("database-management")}
|
||||
>
|
||||
Database management
|
||||
</Button>
|
||||
<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");
|
||||
}}
|
||||
>
|
||||
Database management
|
||||
</Button>
|
||||
)}
|
||||
{canManagePi && (
|
||||
<Button
|
||||
@@ -795,7 +849,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
onClick={() => {
|
||||
if (!canLeaveDatabaseManagement()) return;
|
||||
setActiveSurface("core");
|
||||
setPiManagementOpen(true);
|
||||
setActiveManagementPanel("pi");
|
||||
}}
|
||||
>
|
||||
Pi management
|
||||
@@ -951,14 +1005,6 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
<WorkspaceManager
|
||||
open={workspaceManagerOpen}
|
||||
onClose={() => setWorkspaceManagerOpen(false)}
|
||||
canManageWorkspace={canManageWorkspace}
|
||||
canManageSecrets={canManageWorkspaceSecrets}
|
||||
/>
|
||||
{canManagePi && <PiManagement open={piManagementOpen} onClose={() => setPiManagementOpen(false)} />}
|
||||
|
||||
<Toaster />
|
||||
|
||||
<StopConfirmDialog
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
CatalogSyncRun,
|
||||
CatalogTable,
|
||||
DescriptionGenerationRun,
|
||||
SensitiveDataSuggestionRun,
|
||||
} from "../api/catalog-databases";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
import { DatabaseManagementPage } from "./DatabaseManagementPage";
|
||||
@@ -72,11 +73,13 @@ function renderPage({
|
||||
canManage = true,
|
||||
canManageSecrets = true,
|
||||
onNavigationStateChange,
|
||||
presentation = "legacy",
|
||||
}: {
|
||||
rows?: CatalogDatabase[] | (() => CatalogDatabase[]);
|
||||
canManage?: boolean;
|
||||
canManageSecrets?: boolean;
|
||||
onNavigationStateChange?: (state: { dirty: boolean; busy: boolean }) => void;
|
||||
presentation?: "legacy" | "fleet";
|
||||
} = {}) {
|
||||
server.use(http.get("/api/catalog/databases", () => HttpResponse.json(
|
||||
typeof rows === "function" ? rows() : rows,
|
||||
@@ -88,6 +91,7 @@ function renderPage({
|
||||
canManage={canManage}
|
||||
canManageSecrets={canManageSecrets}
|
||||
onNavigationStateChange={onNavigationStateChange}
|
||||
presentation={presentation}
|
||||
/>
|
||||
<Toaster duration={Infinity} />
|
||||
</QueryClientProvider>,
|
||||
@@ -145,6 +149,27 @@ function makeDescriptionGenerationRun(
|
||||
};
|
||||
}
|
||||
|
||||
function makeSensitiveDataSuggestionRun(
|
||||
overrides: Partial<SensitiveDataSuggestionRun> = {},
|
||||
): SensitiveDataSuggestionRun {
|
||||
return {
|
||||
id: "99999999-9999-4999-8999-999999999999",
|
||||
databaseId: "11111111-1111-4111-8111-111111111111",
|
||||
modelId: "local-qwen",
|
||||
scope: "selected_columns",
|
||||
status: "completed",
|
||||
total: 2,
|
||||
suggestedSensitive: 1,
|
||||
suggestedNonSensitive: 1,
|
||||
createdAt: "2026-08-28T11:00:00Z",
|
||||
startedAt: "2026-08-28T11:00:00Z",
|
||||
updatedAt: "2026-08-28T11:00:01Z",
|
||||
finishedAt: "2026-08-28T11:00:01Z",
|
||||
errorSummary: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function registerCompletedSyncRun(run: CatalogSyncRun) {
|
||||
const completed: CatalogSyncRun = {
|
||||
...run,
|
||||
@@ -167,6 +192,116 @@ const synchronizationScopes = [
|
||||
{ scope: "all", label: "Synchronize all" },
|
||||
] as const;
|
||||
|
||||
test("renders Fleet Ledger with real metrics, conceptual row tooltips, and contextual model selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get("/api/catalog/metrics", () => HttpResponse.json({
|
||||
scope: "global",
|
||||
databaseId: null,
|
||||
tables: 163,
|
||||
columns: 2_275,
|
||||
sensitiveColumns: 5,
|
||||
relationships: 18,
|
||||
descriptionTargets: 2_438,
|
||||
describedTargets: 1_828,
|
||||
descriptionCoverage: 75,
|
||||
updatedAt: "2026-08-31T09:00:00Z",
|
||||
})),
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
})),
|
||||
);
|
||||
renderPage({ rows: [makeDatabase()], presentation: "fleet" });
|
||||
|
||||
const pageHeading = await screen.findByRole("heading", { name: "Database management" });
|
||||
expect(pageHeading).toBeVisible();
|
||||
const applicationBar = pageHeading.closest("header");
|
||||
expect(applicationBar).not.toBeNull();
|
||||
expect(within(applicationBar!).getByRole("region", { name: "Catalog status" })).toBeVisible();
|
||||
expect(within(applicationBar!).getByRole("group", { name: "Database management actions" })).toBeVisible();
|
||||
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();
|
||||
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 databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
const tablesAction = within(databaseRow).getByRole("button", { name: "View tables for Policlinico San Donato" });
|
||||
const detailsAction = within(databaseRow).getByRole("button", { name: "View Policlinico San Donato" });
|
||||
expect(tablesAction.parentElement).toHaveAttribute("data-tooltip", "Tables");
|
||||
expect(tablesAction).not.toHaveAttribute("title");
|
||||
expect(detailsAction).toHaveAttribute("data-tooltip", "Details");
|
||||
|
||||
const manager = screen.getByRole("main", { name: "Database management" });
|
||||
await user.click(within(databaseRow).getByRole("button", { name: "Edit Policlinico San Donato" }));
|
||||
const editDrawer = await screen.findByRole("dialog", { name: "Edit database" });
|
||||
expect(manager).toContainElement(editDrawer);
|
||||
expect(editDrawer).toHaveAttribute("aria-modal", "false");
|
||||
await user.click(within(editDrawer).getByRole("button", { name: "Close database panel" }));
|
||||
|
||||
const restoredDatabaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
await user.click(within(restoredDatabaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
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");
|
||||
|
||||
});
|
||||
|
||||
test("keeps only the local back action while browsing Fleet Ledger columns", 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",
|
||||
}));
|
||||
expect(await screen.findByRole("button", { name: "Back to databases" })).toBeVisible();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "View columns for patients" }));
|
||||
expect(await screen.findByRole("button", { name: "Back to tables" })).toBeVisible();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("button", { name: "Back to databases" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Back to tables" }));
|
||||
expect(await screen.findByRole("button", { name: "Back to databases" })).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: "Back to tables" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("reopens database synchronization history when no run is active", async () => {
|
||||
const user = userEvent.setup();
|
||||
const completed: CatalogSyncRun = {
|
||||
...makeSyncRun("all"),
|
||||
state: "succeeded",
|
||||
phase: "completed",
|
||||
startedAt: "2026-08-27T10:00:00Z",
|
||||
finishedAt: "2026-08-27T10:00:01Z",
|
||||
};
|
||||
server.use(
|
||||
http.get("/api/catalog/databases/:databaseId/sync-runs", () => HttpResponse.json([completed])),
|
||||
http.get("/api/catalog/sync-runs/:runId", () => HttpResponse.json(completed)),
|
||||
http.get("/api/catalog/sync-runs/:runId/events-list", () => 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 synchronization history for Policlinico San Donato" }));
|
||||
|
||||
const drawer = await screen.findByRole("dialog", { name: "Schema synchronization" });
|
||||
expect(drawer).toBeVisible();
|
||||
expect(screen.getByRole("main", { name: "Database management" })).toContainElement(drawer);
|
||||
expect(await screen.findByRole("heading", { name: "Succeeded" })).toBeVisible();
|
||||
expect(screen.getByRole("region", { name: "Synchronization history" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("selects the configured metadata-description model by default", async () => {
|
||||
server.use(http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [
|
||||
@@ -181,6 +316,50 @@ test("selects the configured metadata-description model by default", async () =>
|
||||
await waitFor(() => expect(selector).toHaveValue("local-qwen"));
|
||||
});
|
||||
|
||||
test("keeps both run-history buttons visible beside the metadata-description selector", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
const selector = await screen.findByRole("combobox", { name: "Metadata description 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" });
|
||||
const descriptionHistoryButton = within(metadataControls).getByRole("button", {
|
||||
name: "Run descriptions generation history",
|
||||
});
|
||||
const suggestionHistoryButton = within(metadataControls).getByRole("button", {
|
||||
name: "Run sensitive suggestions history",
|
||||
});
|
||||
|
||||
expect(toolbar).toHaveClass("sm:items-end", "sm:justify-between");
|
||||
expect(toolbar.firstElementChild).toBe(metadataControls);
|
||||
expect(metadataControls).toContainElement(selector);
|
||||
expect(descriptionHistoryButton).toBeVisible();
|
||||
expect(descriptionHistoryButton).toBeEnabled();
|
||||
expect(descriptionHistoryButton).toHaveTextContent("Run descriptions generation history");
|
||||
expect(descriptionHistoryButton).toHaveClass("disabled:opacity-70");
|
||||
expect(suggestionHistoryButton).toBeVisible();
|
||||
expect(suggestionHistoryButton).toBeEnabled();
|
||||
expect(suggestionHistoryButton).toHaveTextContent("Run sensitive suggestions history");
|
||||
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(screen.queryByRole("note", {
|
||||
name: "Metadata generation source data disclosure",
|
||||
})).not.toBeInTheDocument();
|
||||
expect(document.getElementById("metadata-generation-source-data-disclosure")).toBeNull();
|
||||
|
||||
await user.click(descriptionHistoryButton);
|
||||
const descriptionDrawer = await screen.findByRole("dialog", { name: "Description generation" });
|
||||
expect(within(descriptionDrawer).getByText("No description generation runs yet.")).toBeVisible();
|
||||
|
||||
await user.click(suggestionHistoryButton);
|
||||
expect(screen.queryByRole("dialog", { name: "Description generation" })).not.toBeInTheDocument();
|
||||
const suggestionDrawer = await screen.findByRole("dialog", { name: "Sensitive suggestion history" });
|
||||
expect(within(suggestionDrawer).getByText("No sensitive suggestion runs yet.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("changes the metadata-description model in page-local state", async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
@@ -271,38 +450,6 @@ test("renders only the public metadata-generation model contract", async () => {
|
||||
expect(document.body).not.toHaveTextContent("raw-private-credential");
|
||||
});
|
||||
|
||||
test("discloses bounded transient source samples in database-wide and selected generation views", async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
})), http.get(
|
||||
"/api/catalog/databases/:databaseId/tables",
|
||||
() => HttpResponse.json([]),
|
||||
));
|
||||
renderPage();
|
||||
|
||||
const disclosure = await screen.findByRole("note", {
|
||||
name: "Metadata generation source data disclosure",
|
||||
});
|
||||
expect(within(disclosure).getByText(
|
||||
"Description generation may send up to five source rows and up to five representative non-null example values to the selected model provider. Values from columns marked sensitive are replaced with plausible synthetic values before the request.",
|
||||
)).toBeVisible();
|
||||
expect(within(disclosure).getByText(
|
||||
"Values from unmarked columns may be sent unchanged. Samples are transient and are not stored in run logs or catalog metadata.",
|
||||
)).toBeVisible();
|
||||
expect(within(disclosure).getByText(
|
||||
"Sensitive-field suggestions use structural metadata only and remain unsaved until you choose Save sensitive fields.",
|
||||
)).toBeVisible();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Tables" }));
|
||||
|
||||
expect(screen.getByRole("note", {
|
||||
name: "Metadata generation source data disclosure",
|
||||
})).toBeVisible();
|
||||
});
|
||||
|
||||
test("starts with a full-width list and applies the row action matrix", async () => {
|
||||
renderPage();
|
||||
|
||||
@@ -377,7 +524,7 @@ test("starts Generate Missing for one configured database without confirmation",
|
||||
scope: "missing",
|
||||
}));
|
||||
expect(screen.queryByText(/replace existing generated descriptions/i)).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("complementary", { name: "Description generation" })).toBeVisible();
|
||||
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("confirms Generate All replacement, supports cancel, and sends the database-wide scope", async () => {
|
||||
@@ -419,7 +566,7 @@ test("confirms Generate All replacement, supports cancel, and sends the database
|
||||
modelId: "local-qwen",
|
||||
scope: "all",
|
||||
}));
|
||||
expect(await screen.findByRole("complementary", { name: "Description generation" })).toBeVisible();
|
||||
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps the database selected and safely explains when no descriptions are eligible", async () => {
|
||||
@@ -445,7 +592,7 @@ test("keeps the database selected and safely explains when no descriptions are e
|
||||
"No eligible catalog tables or columns need description generation.",
|
||||
)).toBeVisible();
|
||||
expect(screen.getByText("1 selected")).toBeVisible();
|
||||
expect(screen.queryByRole("complementary", { name: "Description generation" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog", { name: "Description generation" })).not.toBeInTheDocument();
|
||||
expect(document.body).not.toHaveTextContent("private provider and catalog detail");
|
||||
});
|
||||
|
||||
@@ -605,7 +752,7 @@ test("disables database-wide generation while a description generation is active
|
||||
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate Missing" }));
|
||||
expect(await screen.findByRole("complementary", { name: "Description generation" })).toBeVisible();
|
||||
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
|
||||
|
||||
databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
@@ -1177,7 +1324,7 @@ test("starts description generation for multiple selected tables", async () => {
|
||||
scope: "selected_tables",
|
||||
targetIds: [patientsTable.id, visitsTable.id],
|
||||
}));
|
||||
expect(await screen.findByRole("complementary", {
|
||||
expect(await screen.findByRole("dialog", {
|
||||
name: "Description generation",
|
||||
})).toBeVisible();
|
||||
expect(await screen.findByText("Description generation started for 2 tables")).toBeVisible();
|
||||
@@ -1216,18 +1363,42 @@ test("observes an active run from another browser and reopens a terminal run fro
|
||||
renderPage();
|
||||
|
||||
const observe = await screen.findByRole("button", {
|
||||
name: "Observe active description generation",
|
||||
name: "Run descriptions generation history",
|
||||
});
|
||||
expect(observe).toHaveTextContent("Run descriptions generation history");
|
||||
expect(requestedLimit).toBe("50");
|
||||
await user.click(observe);
|
||||
|
||||
const drawer = await screen.findByRole("complementary", { name: "Description generation" });
|
||||
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
|
||||
expect(within(drawer).getByRole("heading", { name: "Running" })).toBeVisible();
|
||||
const history = within(drawer).getByRole("region", { name: "Description generation history" });
|
||||
await user.click(within(history).getByRole("button", { name: /Completed.*missing/i }));
|
||||
expect(await within(drawer).findByRole("heading", { name: "Completed" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps the sensitive suggestion history label stable while showing active status separately", async () => {
|
||||
const runningRun = makeSensitiveDataSuggestionRun({
|
||||
status: "running",
|
||||
finishedAt: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
server.use(http.get(
|
||||
"/api/catalog/sensitive-data-suggestion-runs",
|
||||
() => HttpResponse.json([runningRun]),
|
||||
));
|
||||
renderPage();
|
||||
|
||||
const historyButton = await screen.findByRole("button", {
|
||||
name: "Run sensitive suggestions history",
|
||||
});
|
||||
expect(historyButton).toHaveTextContent("Run sensitive suggestions history");
|
||||
await waitFor(() => expect(historyButton).toHaveAttribute(
|
||||
"title",
|
||||
"Sensitive suggestion generation is active",
|
||||
));
|
||||
expect(historyButton.querySelector("[aria-hidden='true'].bg-primary")).not.toBeNull();
|
||||
});
|
||||
|
||||
test.each([
|
||||
"completed",
|
||||
"completed_with_errors",
|
||||
@@ -1256,7 +1427,7 @@ test.each([
|
||||
client.setQueryData(unrelatedColumnKey, []);
|
||||
|
||||
await user.click(await screen.findByRole("button", {
|
||||
name: "View description generation history",
|
||||
name: "Run descriptions generation history",
|
||||
}));
|
||||
|
||||
await waitFor(() => expect(client.getQueryState(tableKey)?.isInvalidated).toBe(true));
|
||||
@@ -1299,7 +1470,7 @@ test("keeps selected tables when description generation cannot start", async ()
|
||||
|
||||
expect(await screen.findByText("A description generation run is already active.")).toBeVisible();
|
||||
expect(screen.getByText("2 selected")).toBeVisible();
|
||||
expect(screen.queryByRole("complementary", { name: "Description generation" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog", { name: "Description generation" })).not.toBeInTheDocument();
|
||||
expect(document.body).not.toHaveTextContent("private provider detail");
|
||||
});
|
||||
|
||||
@@ -1565,7 +1736,7 @@ test("starts one selected column with the configured default model", async () =>
|
||||
scope: "selected_columns",
|
||||
targetIds: [patientIdColumn.id],
|
||||
}));
|
||||
const drawer = await screen.findByRole("complementary", { name: "Description generation" });
|
||||
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
|
||||
expect(drawer).toBeVisible();
|
||||
expect(within(drawer).getByRole("heading", { name: "Queued" })).toBeVisible();
|
||||
expect(await screen.findByText("Description generation started for 1 column")).toBeVisible();
|
||||
@@ -1606,14 +1777,23 @@ test("shows database sensitive suggestions only for a selection and rejects mult
|
||||
test("requests database-level sensitive suggestions for the only selected database", async () => {
|
||||
const user = userEvent.setup();
|
||||
let suggestionBody: unknown;
|
||||
let suggestionFinished = false;
|
||||
let historyCalls = 0;
|
||||
const run = makeSensitiveDataSuggestionRun({ scope: "all", total: 1 });
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
})),
|
||||
http.get("/api/catalog/sensitive-data-suggestion-runs", () => {
|
||||
historyCalls += 1;
|
||||
return HttpResponse.json(suggestionFinished ? [run] : []);
|
||||
}),
|
||||
http.post("/api/catalog/databases/:databaseId/sensitive-data-suggestions", async ({ request }) => {
|
||||
suggestionBody = await request.json();
|
||||
suggestionFinished = true;
|
||||
return HttpResponse.json({
|
||||
run,
|
||||
suggestions: [{
|
||||
columnId: patientIdColumn.id,
|
||||
tableId: patientsTable.id,
|
||||
@@ -1633,7 +1813,37 @@ test("requests database-level sensitive suggestions for the only selected databa
|
||||
await user.click(screen.getByRole("button", { name: "Suggest sensitive fields" }));
|
||||
|
||||
await waitFor(() => expect(suggestionBody).toEqual({ modelId: "local-qwen", scope: "all" }));
|
||||
expect(await screen.findByRole("complementary", { name: "Sensitive field review" })).toBeVisible();
|
||||
expect(await screen.findByRole("dialog", { name: "Sensitive field review" })).toBeVisible();
|
||||
await waitFor(() => expect(historyCalls).toBeGreaterThanOrEqual(2));
|
||||
});
|
||||
|
||||
test("refetches sensitive suggestion history after a failed request", async () => {
|
||||
const user = userEvent.setup();
|
||||
let historyCalls = 0;
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
})),
|
||||
http.get("/api/catalog/sensitive-data-suggestion-runs", () => {
|
||||
historyCalls += 1;
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
http.post("/api/catalog/databases/:databaseId/sensitive-data-suggestions", () => (
|
||||
HttpResponse.json({
|
||||
code: "sensitive_data_suggestion_invalid_response",
|
||||
message: "The LLM returned an incomplete or invalid classification. No suggestions were applied.",
|
||||
}, { status: 502 })
|
||||
)),
|
||||
);
|
||||
renderPage({ rows: [makeDatabase()] });
|
||||
|
||||
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Suggest sensitive fields" }));
|
||||
|
||||
await waitFor(() => expect(historyCalls).toBeGreaterThanOrEqual(2));
|
||||
expect(screen.queryByRole("dialog", { name: "Sensitive field review" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("requests sensitive suggestions only for selected tables", async () => {
|
||||
@@ -1687,7 +1897,7 @@ test("requests sensitive suggestions only for selected tables", async () => {
|
||||
scope: "selected_tables",
|
||||
targetIds: [visitsTable.id],
|
||||
}));
|
||||
expect(await screen.findByRole("complementary", { name: "Sensitive field review" })).toBeVisible();
|
||||
expect(await screen.findByRole("dialog", { name: "Sensitive field review" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("reviews AI-sensitive-field suggestions as an editable draft and saves only changed columns", async () => {
|
||||
@@ -1798,7 +2008,7 @@ test("reviews AI-sensitive-field suggestions as an editable draft and saves only
|
||||
scope: "selected_columns",
|
||||
targetIds: [idColumn.id, nameColumn.id],
|
||||
}));
|
||||
const review = await screen.findByRole("complementary", { name: "Sensitive field review" });
|
||||
const review = await screen.findByRole("dialog", { name: "Sensitive field review" });
|
||||
expect(within(review).getByRole("checkbox", { name: "Protect patients.id" })).toBeChecked();
|
||||
expect(within(review).getByRole("checkbox", { name: "Protect patients.name" })).toBeChecked();
|
||||
expect(patches).toHaveLength(0);
|
||||
@@ -1814,7 +2024,7 @@ test("reviews AI-sensitive-field suggestions as an editable draft and saves only
|
||||
},
|
||||
}]));
|
||||
expect(await screen.findByText("Saved 1 sensitive flag")).toBeVisible();
|
||||
await waitFor(() => expect(screen.queryByRole("complementary", { name: "Sensitive field review" })).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sensitive field review" })).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("checkbox", { name: "Sensitive data for id" })).toBeChecked());
|
||||
expect(screen.getByRole("checkbox", { name: "Sensitive data for name" })).not.toBeChecked();
|
||||
});
|
||||
@@ -1875,7 +2085,7 @@ test("polls a description run and renders its events in sequence order", async (
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
|
||||
|
||||
const drawer = await screen.findByRole("complementary", { name: "Description generation" });
|
||||
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
|
||||
expect(within(drawer).getByText(/local-qwen/)).toBeVisible();
|
||||
expect(within(drawer).getByText(/selected columns/)).toBeVisible();
|
||||
expect(await within(drawer).findByRole("heading", { name: "Completed" }, { timeout: 4_000 })).toBeVisible();
|
||||
@@ -1992,7 +2202,7 @@ test("keeps the selected column and shows a safe message when generation cannot
|
||||
|
||||
expect(await screen.findByText("A description generation run is already active.")).toBeVisible();
|
||||
expect(screen.getByText("1 selected")).toBeVisible();
|
||||
expect(screen.queryByRole("complementary", { name: "Description generation" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog", { name: "Description generation" })).not.toBeInTheDocument();
|
||||
expect(document.body).not.toHaveTextContent(/private-provider|sk-private-key/);
|
||||
});
|
||||
|
||||
@@ -2057,7 +2267,7 @@ test("shows a basic failed run without exposing private model or provider fields
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
|
||||
|
||||
const drawer = await screen.findByRole("complementary", { name: "Description generation" });
|
||||
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
|
||||
expect(await within(drawer).findByRole("heading", { name: "Failed" })).toBeVisible();
|
||||
expect(within(drawer).getByText(/Approved model/)).toBeVisible();
|
||||
expect(within(drawer).getByText("The model request failed before a description was saved.")).toBeVisible();
|
||||
@@ -2200,7 +2410,7 @@ test("disables another generation start while the selected-column run is active"
|
||||
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
|
||||
expect(await screen.findByRole("complementary", { name: "Description generation" })).toBeVisible();
|
||||
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
expect(await screen.findByRole("menuitem", { name: "Generate description" }))
|
||||
@@ -2495,10 +2705,16 @@ test("opens the durable job drawer and confirms its exact destructive plan", asy
|
||||
await user.click(screen.getByRole("tab", { name: "Tables" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Sync tables" }));
|
||||
|
||||
const synchronizationDrawer = await screen.findByRole("complementary", {
|
||||
const synchronizationDrawer = await screen.findByRole("dialog", {
|
||||
name: "Schema synchronization",
|
||||
});
|
||||
expect(synchronizationDrawer).toHaveClass("inset-y-2", "sm:inset-y-4");
|
||||
expect(synchronizationDrawer).toHaveAttribute("data-catalog-panel");
|
||||
expect(synchronizationDrawer.querySelector(
|
||||
':scope > [data-catalog-panel-region="header"]',
|
||||
)).toBeInTheDocument();
|
||||
expect(synchronizationDrawer.querySelector(
|
||||
':scope > [data-catalog-panel-region="body"]',
|
||||
)).toBeInTheDocument();
|
||||
expect(within(synchronizationDrawer).getByRole("listitem", {
|
||||
name: "Confirmation required, in progress",
|
||||
})).toHaveAttribute("aria-current", "step");
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
createCatalogDatabase,
|
||||
deleteCatalogDatabase,
|
||||
deleteCatalogDatabaseMetadata,
|
||||
getCatalogMetrics,
|
||||
listCatalogDatabases,
|
||||
listDescriptionGenerationRuns,
|
||||
listMetadataGenerationModels,
|
||||
listSensitiveDataSuggestionRuns,
|
||||
replaceCatalogDatabaseSecrets,
|
||||
startCatalogSync,
|
||||
startDescriptionGenerationRun,
|
||||
@@ -34,15 +36,27 @@ import {
|
||||
type DescriptionGenerationRun,
|
||||
type SensitiveDataSuggestion,
|
||||
type SensitiveDataSuggestionRequest,
|
||||
type SensitiveDataSuggestionRun,
|
||||
} from "../api/catalog-databases";
|
||||
import { DatabaseGrid } from "./database-management/DatabaseGrid";
|
||||
import { DatabaseForm } from "./database-management/DatabaseForm";
|
||||
import { databaseFormTitle } from "./database-management/DatabaseForm";
|
||||
import { DatabaseTables } from "./database-management/DatabaseTables";
|
||||
import { DatabaseRelationships } from "./database-management/DatabaseRelationships";
|
||||
import { CatalogSyncDrawer } from "./database-management/CatalogSyncDrawer";
|
||||
import { MetadataGenerationModelSelector } from "./database-management/MetadataGenerationModelSelector";
|
||||
import { DescriptionGenerationDrawer } from "./database-management/DescriptionGenerationDrawer";
|
||||
import { SensitiveDataSuggestionHistoryDrawer } from "./database-management/SensitiveDataSuggestionHistoryDrawer";
|
||||
import { SensitiveDataReviewDrawer } from "./database-management/SensitiveDataReviewDrawer";
|
||||
import {
|
||||
FleetLedgerBreadcrumb,
|
||||
FleetLedgerContent,
|
||||
FleetLedgerDrawer,
|
||||
FleetLedgerHeader,
|
||||
FleetLedgerKpiStrip,
|
||||
FleetLedgerRealStatus,
|
||||
FleetLedgerShell,
|
||||
} from "./database-management/FleetLedgerShell";
|
||||
import {
|
||||
configurationFingerprint,
|
||||
configurationFromDraft,
|
||||
@@ -58,6 +72,7 @@ import {
|
||||
|
||||
const DATABASE_QUERY_KEY = ["catalog-databases"] as const;
|
||||
const DESCRIPTION_GENERATION_HISTORY_QUERY_KEY = ["description-generation-runs", 50] as const;
|
||||
const SENSITIVE_DATA_SUGGESTION_HISTORY_QUERY_KEY = ["sensitive-data-suggestion-runs", 50] as const;
|
||||
const DESCRIPTION_GENERATION_ACTIVE_STATUSES = ["queued", "running"] as const;
|
||||
const SYNC_STARTED_MESSAGES: Record<CatalogSyncScope, string> = {
|
||||
tables: "Table synchronization started",
|
||||
@@ -70,6 +85,7 @@ interface Props {
|
||||
canManage: boolean;
|
||||
canManageSecrets: boolean;
|
||||
onNavigationStateChange?: (state: DatabaseNavigationState) => void;
|
||||
presentation?: "legacy" | "fleet";
|
||||
}
|
||||
|
||||
interface FormSource {
|
||||
@@ -91,6 +107,7 @@ export function DatabaseManagementPage({
|
||||
canManage,
|
||||
canManageSecrets,
|
||||
onNavigationStateChange,
|
||||
presentation = "legacy",
|
||||
}: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
@@ -127,6 +144,17 @@ export function DatabaseManagementPage({
|
||||
const observedActiveDescriptionGenerationRun = descriptionGenerationRuns.find(
|
||||
(run) => isDescriptionGenerationActive(run),
|
||||
);
|
||||
const sensitiveDataSuggestionRunsQuery = useQuery({
|
||||
queryKey: SENSITIVE_DATA_SUGGESTION_HISTORY_QUERY_KEY,
|
||||
queryFn: () => listSensitiveDataSuggestionRuns(50),
|
||||
enabled: canManage,
|
||||
retry: false,
|
||||
refetchInterval: 5_000,
|
||||
});
|
||||
const sensitiveDataSuggestionRuns = sensitiveDataSuggestionRunsQuery.data ?? [];
|
||||
const observedActiveSensitiveDataSuggestionRun = sensitiveDataSuggestionRuns.find(
|
||||
(run) => run.status === "running",
|
||||
);
|
||||
|
||||
const [screen, setScreen] = useState<DatabaseScreen>({ kind: "list" });
|
||||
const [selectedMetadataModel, setSelectedMetadataModel] = useState("");
|
||||
@@ -142,10 +170,14 @@ export function DatabaseManagementPage({
|
||||
dirty: false,
|
||||
busy: false,
|
||||
});
|
||||
const [tableNestedNavigationActive, setTableNestedNavigationActive] = useState(false);
|
||||
const [activeSyncRun, setActiveSyncRun] = useState<CatalogSyncRun | null>(null);
|
||||
const [syncHistoryDatabaseId, setSyncHistoryDatabaseId] = useState<string | null>(null);
|
||||
const [syncDrawerOpen, setSyncDrawerOpen] = useState(false);
|
||||
const [activeDescriptionGenerationRun, setActiveDescriptionGenerationRun] = useState<DescriptionGenerationRun | null>(null);
|
||||
const [descriptionGenerationDrawerOpen, setDescriptionGenerationDrawerOpen] = useState(false);
|
||||
const [activeSensitiveDataSuggestionRun, setActiveSensitiveDataSuggestionRun] = useState<SensitiveDataSuggestionRun | null>(null);
|
||||
const [sensitiveDataSuggestionHistoryDrawerOpen, setSensitiveDataSuggestionHistoryDrawerOpen] = useState(false);
|
||||
const [sensitiveReview, setSensitiveReview] = useState<{
|
||||
databaseId: string;
|
||||
scopeLabel: string;
|
||||
@@ -163,6 +195,20 @@ export function DatabaseManagementPage({
|
||||
const activeRow = screen.kind === "list"
|
||||
? undefined
|
||||
: rows.find((row) => row.workspaceId === screen.workspaceId);
|
||||
const metricsDatabaseId = (screen.kind === "tables" || screen.kind === "relationships")
|
||||
? activeRow?.id
|
||||
: undefined;
|
||||
const catalogMetricsQuery = useQuery({
|
||||
queryKey: ["catalog-metrics", metricsDatabaseId ?? "global"],
|
||||
queryFn: () => getCatalogMetrics(metricsDatabaseId),
|
||||
enabled: canManage && (metricsDatabaseId === undefined || Boolean(activeRow?.id)),
|
||||
retry: false,
|
||||
refetchInterval: presentation === "fleet" ? 15_000 : false,
|
||||
});
|
||||
const invalidateCatalogMetrics = useCallback(
|
||||
() => queryClient.invalidateQueries({ queryKey: ["catalog-metrics"] }),
|
||||
[queryClient],
|
||||
);
|
||||
const availableWorkspaces = useMemo(
|
||||
() => rows.filter((row) => row.workspaceAvailable && !row.configured),
|
||||
[rows],
|
||||
@@ -235,6 +281,7 @@ export function DatabaseManagementPage({
|
||||
setStaleBannerOpen(true);
|
||||
setPartialSecretFailure(null);
|
||||
setTablesNavigationState({ dirty: false, busy: false });
|
||||
setTableNestedNavigationActive(false);
|
||||
restoreListFocus();
|
||||
}, [restoreListFocus]);
|
||||
|
||||
@@ -298,6 +345,7 @@ export function DatabaseManagementPage({
|
||||
setStale(false);
|
||||
setPartialSecretFailure(null);
|
||||
setTablesNavigationState({ dirty: false, busy: false });
|
||||
setTableNestedNavigationActive(false);
|
||||
setScreen({ kind: "tables", workspaceId: row.workspaceId });
|
||||
}, []);
|
||||
|
||||
@@ -590,10 +638,11 @@ export function DatabaseManagementPage({
|
||||
try {
|
||||
const result = await refetch();
|
||||
if (result.error) throw result.error;
|
||||
await invalidateCatalogMetrics();
|
||||
} catch (error) {
|
||||
toast.error(apiErrorMessage(error));
|
||||
}
|
||||
}, [isFetching, refetch]);
|
||||
}, [invalidateCatalogMetrics, isFetching, refetch]);
|
||||
|
||||
const updateTrackedSyncRun = useCallback((run: CatalogSyncRun) => {
|
||||
setActiveSyncRun(run);
|
||||
@@ -606,6 +655,7 @@ export function DatabaseManagementPage({
|
||||
|
||||
const rememberSyncRun = useCallback((run: CatalogSyncRun) => {
|
||||
updateTrackedSyncRun(run);
|
||||
setSyncHistoryDatabaseId(run.databaseId);
|
||||
setSyncDrawerOpen(true);
|
||||
}, [updateTrackedSyncRun]);
|
||||
|
||||
@@ -622,11 +672,20 @@ export function DatabaseManagementPage({
|
||||
const run = observedActiveDescriptionGenerationRun
|
||||
?? descriptionGenerationRuns[0]
|
||||
?? activeDescriptionGenerationRun;
|
||||
if (!run) return;
|
||||
setActiveDescriptionGenerationRun(run);
|
||||
if (run) setActiveDescriptionGenerationRun(run);
|
||||
setSensitiveDataSuggestionHistoryDrawerOpen(false);
|
||||
setDescriptionGenerationDrawerOpen(true);
|
||||
}, [activeDescriptionGenerationRun, descriptionGenerationRuns, observedActiveDescriptionGenerationRun]);
|
||||
|
||||
const openSensitiveDataSuggestionHistory = useCallback(() => {
|
||||
const run = observedActiveSensitiveDataSuggestionRun
|
||||
?? sensitiveDataSuggestionRuns[0]
|
||||
?? activeSensitiveDataSuggestionRun;
|
||||
if (run) setActiveSensitiveDataSuggestionRun(run);
|
||||
setDescriptionGenerationDrawerOpen(false);
|
||||
setSensitiveDataSuggestionHistoryDrawerOpen(true);
|
||||
}, [activeSensitiveDataSuggestionRun, observedActiveSensitiveDataSuggestionRun, sensitiveDataSuggestionRuns]);
|
||||
|
||||
const descriptionGenerationTerminated = useCallback(async (run: DescriptionGenerationRun) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
@@ -636,15 +695,19 @@ export function DatabaseManagementPage({
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["catalog-columns", run.databaseId],
|
||||
}),
|
||||
invalidateCatalogMetrics(),
|
||||
]);
|
||||
}, [queryClient]);
|
||||
}, [invalidateCatalogMetrics, queryClient]);
|
||||
|
||||
const openSync = useCallback((row?: CatalogDatabase) => {
|
||||
const run = row?.activeSyncRun ?? activeSyncRun;
|
||||
if (!run) return;
|
||||
const databaseId = row?.id ?? activeSyncRun?.databaseId ?? activeRow?.id ?? null;
|
||||
if (!databaseId) return;
|
||||
const run = row?.activeSyncRun
|
||||
?? (activeSyncRun?.databaseId === databaseId ? activeSyncRun : null);
|
||||
setActiveSyncRun(run);
|
||||
setSyncHistoryDatabaseId(databaseId);
|
||||
setSyncDrawerOpen(true);
|
||||
}, [activeSyncRun]);
|
||||
}, [activeRow?.id, activeSyncRun]);
|
||||
|
||||
const testSelected = useCallback(async (selected: CatalogDatabase[]) => {
|
||||
try {
|
||||
@@ -708,13 +771,22 @@ export function DatabaseManagementPage({
|
||||
}
|
||||
try {
|
||||
const result = await suggestSensitiveFields(database.id, selectedMetadataModel, selection);
|
||||
if (result.run) {
|
||||
setActiveSensitiveDataSuggestionRun(result.run);
|
||||
queryClient.setQueryData<SensitiveDataSuggestionRun[]>(
|
||||
SENSITIVE_DATA_SUGGESTION_HISTORY_QUERY_KEY,
|
||||
(current = []) => [result.run, ...current.filter((item) => item.id !== result.run.id)],
|
||||
);
|
||||
}
|
||||
setSensitiveReview({ databaseId: database.id, scopeLabel, suggestions: result.suggestions });
|
||||
toast.success(`Prepared ${result.suggestions.length} sensitive-field suggestion${result.suggestions.length === 1 ? "" : "s"} for review`);
|
||||
} catch (error) {
|
||||
toast.error(apiErrorMessage(error));
|
||||
throw error;
|
||||
} finally {
|
||||
await queryClient.invalidateQueries({ queryKey: SENSITIVE_DATA_SUGGESTION_HISTORY_QUERY_KEY });
|
||||
}
|
||||
}, [selectedMetadataModel]);
|
||||
}, [queryClient, selectedMetadataModel]);
|
||||
|
||||
const suggestDatabaseSensitiveFields = useCallback(async (selected: CatalogDatabase[]) => {
|
||||
if (selected.length !== 1) {
|
||||
@@ -751,8 +823,13 @@ export function DatabaseManagementPage({
|
||||
}),
|
||||
} : null);
|
||||
const databaseId = sensitiveReview?.databaseId;
|
||||
if (databaseId) void queryClient.invalidateQueries({ queryKey: ["catalog-columns", databaseId] });
|
||||
}, [queryClient, sensitiveReview?.databaseId]);
|
||||
if (databaseId) {
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["catalog-columns", databaseId] }),
|
||||
invalidateCatalogMetrics(),
|
||||
]);
|
||||
}
|
||||
}, [invalidateCatalogMetrics, queryClient, sensitiveReview?.databaseId]);
|
||||
|
||||
const deleteSelectedMetadata = useCallback(async (
|
||||
selected: CatalogDatabase[],
|
||||
@@ -766,15 +843,18 @@ export function DatabaseManagementPage({
|
||||
queryClient.invalidateQueries({ queryKey: ["catalog-columns", databaseId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["catalog-relationships", databaseId] }),
|
||||
]));
|
||||
await queryClient.invalidateQueries({ queryKey: DATABASE_QUERY_KEY });
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: DATABASE_QUERY_KEY }),
|
||||
invalidateCatalogMetrics(),
|
||||
]);
|
||||
toast.success(target === "tables"
|
||||
? `Deleted ${counts.tables} catalog tables, ${counts.columns} columns, and ${counts.relationships} relationships`
|
||||
: `Deleted ${counts.relationships} catalog relationships`);
|
||||
? `Cleared ${counts.tables} catalog tables, ${counts.columns} columns, and ${counts.relationships} relationships. The source database was not changed.`
|
||||
: `Cleared ${counts.relationships} catalog relationships. The source database was not changed.`);
|
||||
} catch (error) {
|
||||
toast.error(apiErrorMessage(error));
|
||||
throw error;
|
||||
}
|
||||
}, [queryClient]);
|
||||
}, [invalidateCatalogMetrics, queryClient]);
|
||||
|
||||
const syncDatabase = useCallback(async (scope: CatalogSyncScope) => {
|
||||
if (!activeRow?.id || !activeRow.configured) return;
|
||||
@@ -793,13 +873,16 @@ export function DatabaseManagementPage({
|
||||
queryClient.invalidateQueries({ queryKey: ["catalog-sync-runs", databaseId] }),
|
||||
]);
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: DATABASE_QUERY_KEY });
|
||||
}, [activeSyncRun, queryClient]);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: DATABASE_QUERY_KEY }),
|
||||
invalidateCatalogMetrics(),
|
||||
]);
|
||||
}, [activeSyncRun, invalidateCatalogMetrics, queryClient]);
|
||||
|
||||
const formVisible = screen.kind !== "list" && screen.kind !== "tables" && screen.kind !== "relationships" && activeRow && draft;
|
||||
const tablesVisible = screen.kind === "tables" && activeRow?.configured && activeRow.id;
|
||||
const relationshipsVisible = screen.kind === "relationships" && activeRow?.configured && activeRow.id;
|
||||
const currentActiveRun = activeSyncRun && ["queued", "running", "awaiting_confirmation", "applying"].includes(activeSyncRun.state)
|
||||
const currentActiveRun = activeSyncRun && activeSyncRun.databaseId === activeRow?.id && ["queued", "running", "awaiting_confirmation", "applying"].includes(activeSyncRun.state)
|
||||
? activeSyncRun
|
||||
: activeRow?.activeSyncRun && ["queued", "running", "awaiting_confirmation", "applying"].includes(activeRow.activeSyncRun.state)
|
||||
? activeRow.activeSyncRun
|
||||
@@ -811,13 +894,292 @@ export function DatabaseManagementPage({
|
||||
const descriptionGenerationActive = isDescriptionGenerationActive(
|
||||
observedActiveDescriptionGenerationRun ?? activeDescriptionGenerationRun,
|
||||
);
|
||||
const descriptionGenerationHistoryAvailable = Boolean(
|
||||
descriptionGenerationRuns.length > 0 || activeDescriptionGenerationRun,
|
||||
const fleetMetadataModelControl = (
|
||||
<MetadataGenerationModelSelector
|
||||
canManage={canManage}
|
||||
data={metadataModelsQuery.data}
|
||||
isError={metadataModelsQuery.isError}
|
||||
isLoading={metadataModelsQuery.isLoading}
|
||||
selectedModel={selectedMetadataModel}
|
||||
onSelectedModelChange={setSelectedMetadataModel}
|
||||
/>
|
||||
);
|
||||
|
||||
const metrics = catalogMetricsQuery.data;
|
||||
const metricsUnavailable = catalogMetricsQuery.isError;
|
||||
const metricsValue = (value?: number) => (
|
||||
catalogMetricsQuery.isLoading ? "…" : metricsUnavailable || value === undefined ? "—" : value.toLocaleString("en-US")
|
||||
);
|
||||
const latestCatalogUpdate = metrics?.updatedAt
|
||||
? new Date(metrics.updatedAt).toLocaleString()
|
||||
: "No synchronized metadata";
|
||||
const operationLabel = currentActiveRun
|
||||
? `Synchronization: ${currentActiveRun.phase.replaceAll("_", " ")}`
|
||||
: descriptionGenerationActive
|
||||
? "Description generation active"
|
||||
: observedActiveSensitiveDataSuggestionRun
|
||||
? "Sensitive analysis active"
|
||||
: "Idle";
|
||||
const operationTone = currentActiveRun || descriptionGenerationActive || observedActiveSensitiveDataSuggestionRun
|
||||
? "warning" as const
|
||||
: "neutral" as const;
|
||||
const fleetBreadcrumb = screen.kind === "tables" || screen.kind === "relationships"
|
||||
? [
|
||||
{ id: "databases", label: "Databases", onSelect: showList },
|
||||
{ id: "database", label: activeRow?.workspaceName ?? "Database" },
|
||||
{ id: screen.kind, label: screen.kind === "tables" ? "Tables" : "Relationships", current: true },
|
||||
]
|
||||
: [{ id: "databases", label: "Databases", current: true }];
|
||||
const catalogDrawers = (
|
||||
<>
|
||||
<CatalogSyncDrawer
|
||||
databaseId={syncHistoryDatabaseId}
|
||||
runId={activeSyncRun?.databaseId === syncHistoryDatabaseId ? activeSyncRun.id : null}
|
||||
open={syncDrawerOpen && Boolean(syncHistoryDatabaseId)}
|
||||
onClose={() => setSyncDrawerOpen(false)}
|
||||
onRunChange={rememberSyncRun}
|
||||
onRunUpdate={updateTrackedSyncRun}
|
||||
onCatalogChanged={() => void catalogChanged()}
|
||||
/>
|
||||
<DescriptionGenerationDrawer
|
||||
open={descriptionGenerationDrawerOpen}
|
||||
run={activeDescriptionGenerationRun}
|
||||
modelLabel={metadataModels.find(
|
||||
(model) => model.id === activeDescriptionGenerationRun?.modelId,
|
||||
)?.label ?? activeDescriptionGenerationRun?.modelId ?? ""}
|
||||
onClose={() => setDescriptionGenerationDrawerOpen(false)}
|
||||
onRunUpdate={setActiveDescriptionGenerationRun}
|
||||
onTerminal={(run) => void descriptionGenerationTerminated(run)}
|
||||
/>
|
||||
<SensitiveDataSuggestionHistoryDrawer
|
||||
open={sensitiveDataSuggestionHistoryDrawerOpen}
|
||||
run={activeSensitiveDataSuggestionRun}
|
||||
modelLabel={metadataModels.find(
|
||||
(model) => model.id === activeSensitiveDataSuggestionRun?.modelId,
|
||||
)?.label ?? activeSensitiveDataSuggestionRun?.modelId ?? ""}
|
||||
onClose={() => setSensitiveDataSuggestionHistoryDrawerOpen(false)}
|
||||
onRunUpdate={setActiveSensitiveDataSuggestionRun}
|
||||
/>
|
||||
<SensitiveDataReviewDrawer
|
||||
open={Boolean(sensitiveReview)}
|
||||
databaseId={sensitiveReview?.databaseId ?? null}
|
||||
scopeLabel={sensitiveReview?.scopeLabel ?? ""}
|
||||
suggestions={sensitiveReview?.suggestions ?? []}
|
||||
canManage={canManage}
|
||||
onClose={() => setSensitiveReview(null)}
|
||||
onSaved={sensitiveColumnsSaved}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
if (presentation === "fleet") {
|
||||
const fleetFormMode = formVisible ? screen.kind as DatabaseFormMode : null;
|
||||
const fleetDrawer = formVisible && fleetFormMode ? (
|
||||
<FleetLedgerDrawer
|
||||
open
|
||||
eyebrow="Catalog configuration"
|
||||
title={databaseFormTitle(
|
||||
fleetFormMode,
|
||||
fleetFormMode === "add" && Boolean(screen.kind === "add" && screen.workspaceLocked),
|
||||
"drawer",
|
||||
)}
|
||||
description={`${activeRow.workspaceName} · ${activeRow.workspaceId}`}
|
||||
onClose={backToList}
|
||||
closeLabel="Close database panel"
|
||||
busy={busy}
|
||||
width="wide"
|
||||
>
|
||||
<DatabaseForm
|
||||
presentation="drawer"
|
||||
mode={fleetFormMode}
|
||||
row={activeRow}
|
||||
availableWorkspaces={availableWorkspaces}
|
||||
workspaceLocked={fleetFormMode === "add" && Boolean(screen.kind === "add" && screen.workspaceLocked)}
|
||||
draft={draft}
|
||||
dirty={dirty}
|
||||
canManage={canManage}
|
||||
canManageSecrets={canManageSecrets}
|
||||
busyAction={busyAction}
|
||||
stale={stale}
|
||||
staleBannerOpen={staleBannerOpen}
|
||||
partialSecretFailure={partialSecretFailure}
|
||||
headingRef={formHeadingRef}
|
||||
onBack={backToList}
|
||||
onWorkspaceChange={changeWorkspace}
|
||||
onFieldChange={changeField}
|
||||
onTransportChange={changeTransport}
|
||||
onBindingChange={changeBinding}
|
||||
onSecretChange={changeSecret}
|
||||
onSave={() => void save()}
|
||||
onTest={() => void testConnection()}
|
||||
onDelete={() => void remove()}
|
||||
onReloadLatest={() => void reloadLatest()}
|
||||
onKeepEditing={() => setStaleBannerOpen(false)}
|
||||
onRetrySecrets={() => void retrySecrets()}
|
||||
onOpenTables={(origin) => openTables(activeRow, origin)}
|
||||
onOpenRelationships={() => openRelationships(activeRow)}
|
||||
onSync={(scope) => void syncDatabase(scope)}
|
||||
onOpenSync={() => openSync(activeRow)}
|
||||
activeSyncRun={currentActiveRun}
|
||||
/>
|
||||
</FleetLedgerDrawer>
|
||||
) : null;
|
||||
|
||||
const fleetContent = screen.kind === "tables" && tablesVisible ? (
|
||||
<DatabaseTables
|
||||
presentation="fleet"
|
||||
database={activeRow}
|
||||
canManage={canManage}
|
||||
activeRun={currentActiveRun}
|
||||
selectedMetadataModel={selectedMetadataModelAvailable ? selectedMetadataModel : null}
|
||||
descriptionGenerationActive={descriptionGenerationActive}
|
||||
onBackToDatabases={showList}
|
||||
onOpenOverview={() => openOverview(activeRow)}
|
||||
onOpenRelationships={() => openRelationships(activeRow)}
|
||||
onNavigationStateChange={setTablesNavigationState}
|
||||
onNestedNavigationChange={setTableNestedNavigationActive}
|
||||
onRunStarted={rememberSyncRun}
|
||||
onOpenSync={() => openSync(activeRow)}
|
||||
onDescriptionGenerationRunStarted={rememberDescriptionGenerationRun}
|
||||
onSuggestSensitive={suggestActiveDatabaseSensitiveFields}
|
||||
onCatalogMetricsChanged={invalidateCatalogMetrics}
|
||||
metadataModelControl={fleetMetadataModelControl}
|
||||
/>
|
||||
) : screen.kind === "relationships" && relationshipsVisible ? (
|
||||
<DatabaseRelationships
|
||||
presentation="fleet"
|
||||
database={{ ...activeRow, activeSyncRun: currentActiveRun }}
|
||||
canManage={canManage}
|
||||
onBackToDatabases={showList}
|
||||
onOpenOverview={() => openOverview(activeRow)}
|
||||
onOpenTables={() => openTables(activeRow)}
|
||||
onRunStarted={rememberSyncRun}
|
||||
onOpenSync={() => openSync(activeRow)}
|
||||
/>
|
||||
) : isError && rows.length === 0 ? (
|
||||
<div className="grid h-full place-items-center p-6">
|
||||
<div className="max-w-lg rounded-lg border border-destructive/30 bg-destructive/8 p-5 text-sm" role="alert">
|
||||
<p className="font-semibold text-destructive">The database catalog is unavailable.</p>
|
||||
<p className="mt-1 leading-5 text-muted-foreground">Verify the catalog service and database migrations, then try again.</p>
|
||||
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refreshList()}>
|
||||
<RefreshCw /> Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<DatabaseGrid
|
||||
presentation="fleet"
|
||||
rows={rows}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
search={search}
|
||||
canManage={canManage}
|
||||
searchInputRef={searchInputRef}
|
||||
onSearchChange={setSearch}
|
||||
onView={viewRow}
|
||||
onOpenTables={(row, origin) => openTables(row, origin)}
|
||||
onEdit={editRow}
|
||||
onDelete={deleteRow}
|
||||
onOpenSync={openSync}
|
||||
onTestSelected={testSelected}
|
||||
onSyncSelected={syncSelected}
|
||||
selectedMetadataModel={selectedMetadataModelAvailable ? selectedMetadataModel : null}
|
||||
descriptionGenerationActive={descriptionGenerationActive}
|
||||
onGenerateDescriptions={generateDatabaseDescriptions}
|
||||
onSuggestSensitive={suggestDatabaseSensitiveFields}
|
||||
onDeleteMetadataSelected={deleteSelectedMetadata}
|
||||
metadataModelControl={fleetMetadataModelControl}
|
||||
onRefresh={refreshList}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main aria-label="Database management" className="flex min-h-0 flex-1 overflow-hidden bg-background">
|
||||
<FleetLedgerShell
|
||||
header={(
|
||||
<FleetLedgerHeader
|
||||
title="Database management"
|
||||
status={(
|
||||
<FleetLedgerRealStatus
|
||||
title="Catalog status"
|
||||
items={[
|
||||
{
|
||||
id: "catalog",
|
||||
label: "Catalog",
|
||||
value: isLoading ? "Loading" : isError ? "Unavailable" : "Available",
|
||||
detail: isError ? "Retry from the current grid" : `${rows.length} workspace${rows.length === 1 ? "" : "s"}`,
|
||||
tone: isError ? "danger" : isLoading ? "warning" : "success",
|
||||
},
|
||||
{ id: "operation", label: "Operations", value: operationLabel, tone: operationTone },
|
||||
{ id: "updated", label: "Metadata updated", value: latestCatalogUpdate, tone: "info" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
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}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
kpis={(
|
||||
<FleetLedgerKpiStrip
|
||||
title={metricsDatabaseId ? "Database summary" : "Fleet summary"}
|
||||
items={[
|
||||
{ id: "tables", label: "Tables", value: metricsValue(metrics?.tables) },
|
||||
{ id: "columns", label: "Columns", value: metricsValue(metrics?.columns) },
|
||||
{ id: "sensitive", label: "Sensitive", value: metricsValue(metrics?.sensitiveColumns) },
|
||||
{ id: "relationships", label: "Relationships", value: metricsValue(metrics?.relationships) },
|
||||
{ id: "coverage", label: "Description coverage", value: metrics ? `${metrics.descriptionCoverage}%` : metricsValue() },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
navigation={(
|
||||
<FleetLedgerBreadcrumb
|
||||
items={fleetBreadcrumb}
|
||||
back={screen.kind === "relationships" || (screen.kind === "tables" && !tableNestedNavigationActive)
|
||||
? { label: "Back to databases", onBack: showList, disabled: navigationBusy }
|
||||
: undefined}
|
||||
/>
|
||||
)}
|
||||
children={<FleetLedgerContent busy={isLoading || isFetching}>{fleetContent}</FleetLedgerContent>}
|
||||
drawer={(
|
||||
<>
|
||||
{fleetDrawer}
|
||||
{catalogDrawers}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main aria-label="Database management" className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-border bg-card px-4 py-4 sm:px-5">
|
||||
<header className="grid gap-4 border-b border-border bg-card px-4 py-4 sm:px-5">
|
||||
<div>
|
||||
<p className="thot-label mb-1">Administration</p>
|
||||
<h1 className="font-heading text-xl font-semibold tracking-tight">Database management</h1>
|
||||
@@ -825,43 +1187,76 @@ export function DatabaseManagementPage({
|
||||
One database configuration for each repository workspace.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end justify-end gap-3">
|
||||
<MetadataGenerationModelSelector
|
||||
canManage={canManage}
|
||||
data={metadataModelsQuery.data}
|
||||
isError={metadataModelsQuery.isError}
|
||||
isLoading={metadataModelsQuery.isLoading}
|
||||
selectedModel={selectedMetadataModel}
|
||||
onSelectedModelChange={setSelectedMetadataModel}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-label={observedActiveDescriptionGenerationRun
|
||||
? "Observe active description generation"
|
||||
: "View description generation history"}
|
||||
disabled={!canManage || !descriptionGenerationHistoryAvailable}
|
||||
title={!descriptionGenerationHistoryAvailable && !descriptionGenerationRunsQuery.isLoading
|
||||
? "No description generation runs yet"
|
||||
: undefined}
|
||||
onClick={openDescriptionGenerationHistory}
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Database management controls"
|
||||
className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"
|
||||
>
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Metadata description controls"
|
||||
className="flex flex-wrap items-end gap-3"
|
||||
>
|
||||
<History />
|
||||
{observedActiveDescriptionGenerationRun ? "Active run" : "Run history"}
|
||||
</Button>
|
||||
<MetadataGenerationModelSelector
|
||||
canManage={canManage}
|
||||
data={metadataModelsQuery.data}
|
||||
isError={metadataModelsQuery.isError}
|
||||
isLoading={metadataModelsQuery.isLoading}
|
||||
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>
|
||||
</div>
|
||||
{screen.kind === "list" ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<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
|
||||
role="group"
|
||||
aria-label="Database management actions"
|
||||
className="flex flex-wrap items-center gap-2 sm:justify-end"
|
||||
>
|
||||
<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>
|
||||
@@ -959,6 +1354,7 @@ export function DatabaseManagementPage({
|
||||
onOpenSync={() => openSync(activeRow)}
|
||||
onDescriptionGenerationRunStarted={rememberDescriptionGenerationRun}
|
||||
onSuggestSensitive={suggestActiveDatabaseSensitiveFields}
|
||||
onCatalogMetricsChanged={invalidateCatalogMetrics}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -975,9 +1371,9 @@ export function DatabaseManagementPage({
|
||||
) : null}
|
||||
</div>
|
||||
<CatalogSyncDrawer
|
||||
databaseId={activeSyncRun?.databaseId ?? activeRow?.id ?? null}
|
||||
runId={activeSyncRun?.id ?? null}
|
||||
open={syncDrawerOpen && Boolean(activeSyncRun)}
|
||||
databaseId={syncHistoryDatabaseId}
|
||||
runId={activeSyncRun?.databaseId === syncHistoryDatabaseId ? activeSyncRun.id : null}
|
||||
open={syncDrawerOpen && Boolean(syncHistoryDatabaseId)}
|
||||
onClose={() => setSyncDrawerOpen(false)}
|
||||
onRunChange={rememberSyncRun}
|
||||
onRunUpdate={updateTrackedSyncRun}
|
||||
@@ -993,6 +1389,15 @@ export function DatabaseManagementPage({
|
||||
onRunUpdate={setActiveDescriptionGenerationRun}
|
||||
onTerminal={(run) => void descriptionGenerationTerminated(run)}
|
||||
/>
|
||||
<SensitiveDataSuggestionHistoryDrawer
|
||||
open={sensitiveDataSuggestionHistoryDrawerOpen}
|
||||
run={activeSensitiveDataSuggestionRun}
|
||||
modelLabel={metadataModels.find(
|
||||
(model) => model.id === activeSensitiveDataSuggestionRun?.modelId,
|
||||
)?.label ?? activeSensitiveDataSuggestionRun?.modelId ?? ""}
|
||||
onClose={() => setSensitiveDataSuggestionHistoryDrawerOpen(false)}
|
||||
onRunUpdate={setActiveSensitiveDataSuggestionRun}
|
||||
/>
|
||||
<SensitiveDataReviewDrawer
|
||||
open={Boolean(sensitiveReview)}
|
||||
databaseId={sensitiveReview?.databaseId ?? null}
|
||||
|
||||
@@ -41,7 +41,11 @@ test("loads Pi version and readiness as an accessible operational rail", async (
|
||||
expect(screen.getByText("Loading Pi management…")).toBeVisible();
|
||||
expect(await screen.findByRole("heading", { name: "Pi management" })).toBeVisible();
|
||||
await screen.findByLabelText("Provider");
|
||||
expect(screen.getByRole("dialog", { name: "Pi management" })).toHaveClass("sm:max-w-[min(94vw,58rem)]");
|
||||
const dialog = screen.getByRole("dialog", { name: "Pi management" });
|
||||
expect(dialog).toHaveAttribute("aria-modal", "false");
|
||||
expect(dialog).toHaveAttribute("data-work-area-panel");
|
||||
expect(dialog.querySelectorAll(':scope > [data-work-area-panel-region="header"]')).toHaveLength(1);
|
||||
expect(dialog.querySelectorAll(':scope > [data-work-area-panel-region="body"]')).toHaveLength(1);
|
||||
expect(screen.getByTestId("pi-readiness-rail")).toHaveTextContent("Runtime ready");
|
||||
expect(screen.getByRole("status", { name: "Pi readiness" })).toHaveTextContent("Ready");
|
||||
expect(screen.getByText("Pi 0.80.3")).toBeVisible();
|
||||
@@ -174,7 +178,7 @@ test("shows a seven-step host-terminal workflow in scrollable platform tabs", as
|
||||
const tablist = await screen.findByRole("tablist", { name: "Pi host platform" });
|
||||
const [linuxTab, macosTab, windowsTab] = within(tablist).getAllByRole("tab");
|
||||
expect([linuxTab, macosTab, windowsTab].map((tab) => tab.textContent)).toEqual(["Linux", "macOS", "Windows"]);
|
||||
expect(screen.getByRole("dialog", { name: "Pi management" })).toHaveClass("max-h-[calc(100vh-6rem)]");
|
||||
expect(screen.getByRole("dialog", { name: "Pi management" })).toHaveAttribute("data-width", "standard");
|
||||
expect(screen.getByTestId("pi-platform-instructions-scroll")).toHaveClass("overflow-y-scroll");
|
||||
expect(screen.queryByRole("tabpanel")).not.toBeInTheDocument();
|
||||
expect([linuxTab, macosTab, windowsTab].every((tab) => tab.getAttribute("aria-selected") === "false")).toBe(true);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckCircle2, CircleAlert, ClipboardCheck, FlaskConical, LoaderCircle, ScrollText, X } from "lucide-react";
|
||||
import { CheckCircle2, CircleAlert, ClipboardCheck, FlaskConical, LoaderCircle, ScrollText } from "lucide-react";
|
||||
import {
|
||||
asPiManagementApiError,
|
||||
getPiManagementLogs,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type PiManagementOptions,
|
||||
} from "../api/pi-management";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog";
|
||||
import { WorkAreaPanel } from "./WorkAreaPanel";
|
||||
|
||||
const fieldClass = "h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm shadow-xs outline-none focus-visible:ring-3 focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-60";
|
||||
|
||||
@@ -416,16 +416,17 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
||||
const unavailable = statusQuery.isError || optionsQuery.isError;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onClose(); }}>
|
||||
<DialogContent showCloseButton={false} className="max-h-[calc(100vh-6rem)] max-w-[min(94vw,58rem)] overflow-y-auto p-0 sm:max-w-[min(94vw,58rem)]">
|
||||
<DialogHeader className="border-b border-border/70 px-5 pb-4 pt-5 pr-12">
|
||||
<p className="thot-label">Installation controls</p>
|
||||
<DialogTitle>Pi management</DialogTitle>
|
||||
<DialogDescription>Review the bundled runtime, set safe defaults, and test the saved provider configuration.</DialogDescription>
|
||||
<PiPlatformInstructions />
|
||||
</DialogHeader>
|
||||
<Button aria-label="Close Pi management" className="absolute right-2 top-2" size="icon-sm" variant="ghost" onClick={onClose}><X /></Button>
|
||||
<div className="max-h-[calc(100vh-11rem)] overflow-y-auto px-5 py-5">
|
||||
<WorkAreaPanel
|
||||
open={open}
|
||||
ariaLabel="Pi management"
|
||||
eyebrow="Installation controls"
|
||||
title="Pi management"
|
||||
description="Review the bundled runtime, set safe defaults, and test the saved provider configuration."
|
||||
onClose={onClose}
|
||||
closeLabel="Close Pi management"
|
||||
>
|
||||
<PiPlatformInstructions />
|
||||
<div className="mt-5">
|
||||
{forbidden ? (
|
||||
<div role="alert" aria-label="Pi management unavailable" className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm">
|
||||
<p className="font-semibold">Pi management is not permitted</p>
|
||||
@@ -527,7 +528,6 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</WorkAreaPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
.thot-work-area-panel-host {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
container-name: work-area;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.thot-work-area-panel {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
inset-block: 0.75rem;
|
||||
inset-inline-start: 50%;
|
||||
inset-inline-end: auto;
|
||||
display: flex;
|
||||
width: 60%;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
border: 1px solid oklch(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
color: oklch(var(--card-foreground));
|
||||
background-color: oklch(var(--background));
|
||||
background-image: none;
|
||||
box-shadow:
|
||||
0 30px 84px oklch(0.12 0.02 20 / 0.3),
|
||||
0 8px 22px oklch(0.12 0.02 20 / 0.18);
|
||||
transform: translateX(-50%);
|
||||
container-name: work-area-panel;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.thot-work-area-panel:focus-visible {
|
||||
outline: 2px solid oklch(var(--ring));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.thot-work-area-panel__header {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1rem 0.875rem 1.25rem;
|
||||
border-bottom: 1px solid oklch(var(--primary-foreground) / 0.18);
|
||||
color: oklch(var(--primary-foreground));
|
||||
background-color: oklch(var(--primary));
|
||||
background-image: none;
|
||||
box-shadow: inset 0 -1px 0 oklch(var(--primary-foreground) / 0.18);
|
||||
}
|
||||
|
||||
.thot-work-area-panel__heading {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.thot-work-area-panel__eyebrow {
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1.35;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.thot-work-area-panel__title {
|
||||
margin: 0.25rem 0 0;
|
||||
color: inherit;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
.thot-work-area-panel__description {
|
||||
max-width: 68ch;
|
||||
margin: 0.45rem 0 0;
|
||||
color: inherit;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.thot-work-area-panel__close {
|
||||
flex: none;
|
||||
border-color: oklch(var(--primary-foreground) / 0.22);
|
||||
color: inherit;
|
||||
background: oklch(var(--primary-foreground) / 0.1);
|
||||
}
|
||||
|
||||
.thot-work-area-panel__close:hover {
|
||||
color: inherit;
|
||||
background: oklch(var(--primary-foreground) / 0.18);
|
||||
}
|
||||
|
||||
.thot-work-area-panel__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 1.25rem;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.thot-work-area-panel__body--empty {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.thot-work-area-panel__body--flush {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.thot-work-area-panel__footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0.875rem 1.25rem;
|
||||
border-top: 1px solid oklch(var(--border));
|
||||
background: oklch(var(--muted) / 0.32);
|
||||
}
|
||||
|
||||
.thot-work-area-panel__footer--split {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@container work-area (max-width: 47.999rem) {
|
||||
.thot-work-area-panel {
|
||||
inset-block: 0.5rem;
|
||||
width: calc(100% - 1rem);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from "react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { WorkAreaPanel, WorkAreaPanelHost } from "./WorkAreaPanel";
|
||||
|
||||
function PanelHarness() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<WorkAreaPanelHost data-testid="host">
|
||||
<button type="button" onClick={() => setOpen(true)}>Open management</button>
|
||||
<button type="button" onClick={() => setOpen(false)}>Navigate elsewhere</button>
|
||||
<WorkAreaPanel
|
||||
open={open}
|
||||
eyebrow="Controls"
|
||||
title="Shared management"
|
||||
description="Shared work-area chrome"
|
||||
onClose={() => setOpen(false)}
|
||||
closeLabel="Close shared management"
|
||||
>
|
||||
<p>Panel body</p>
|
||||
</WorkAreaPanel>
|
||||
</WorkAreaPanelHost>
|
||||
);
|
||||
}
|
||||
|
||||
test("portals into its nearest work-area host and exposes shared regions", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PanelHarness />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Open management" }));
|
||||
const panel = screen.getByRole("dialog", { name: "Shared management" });
|
||||
|
||||
expect(screen.getByTestId("host")).toContainElement(panel);
|
||||
expect(panel).toHaveAttribute("aria-modal", "false");
|
||||
expect(panel).toHaveAttribute("data-work-area-panel");
|
||||
expect(panel.querySelectorAll(':scope > [data-work-area-panel-region="header"]')).toHaveLength(1);
|
||||
expect(panel.querySelectorAll(':scope > [data-work-area-panel-region="body"]')).toHaveLength(1);
|
||||
expect(panel.querySelectorAll(':scope > [data-work-area-panel-region="footer"]')).toHaveLength(0);
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Close shared management" })).toHaveFocus());
|
||||
});
|
||||
|
||||
test("closes the focused panel with Escape and restores focus to its trigger", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PanelHarness />);
|
||||
const trigger = screen.getByRole("button", { name: "Open management" });
|
||||
|
||||
await user.click(trigger);
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Close shared management" })).toHaveFocus());
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Shared management" })).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
});
|
||||
|
||||
test("does not steal focus back when an external control closes the modeless panel", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PanelHarness />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Open management" }));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Close shared management" })).toHaveFocus());
|
||||
const externalControl = screen.getByRole("button", { name: "Navigate elsewhere" });
|
||||
await user.click(externalControl);
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Shared management" })).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(externalControl).toHaveFocus());
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { Button } from "../components/ui/button";
|
||||
|
||||
import "./WorkAreaPanel.css";
|
||||
|
||||
function classes(base: string, className?: string) {
|
||||
return className ? `${base} ${className}` : base;
|
||||
}
|
||||
|
||||
const WorkAreaPanelHostContext = createContext<HTMLElement | null>(null);
|
||||
|
||||
interface WorkAreaPanelHostProviderProps {
|
||||
host: HTMLElement | null;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function WorkAreaPanelHostProvider({
|
||||
host,
|
||||
children,
|
||||
}: WorkAreaPanelHostProviderProps) {
|
||||
return (
|
||||
<WorkAreaPanelHostContext.Provider value={host}>
|
||||
{children}
|
||||
</WorkAreaPanelHostContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WorkAreaPanelHostProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
/** Hosts modeless management panels inside the central application work area. */
|
||||
export const WorkAreaPanelHost = forwardRef<HTMLDivElement, WorkAreaPanelHostProps>(
|
||||
function WorkAreaPanelHost({ className, children, ...props }, forwardedRef) {
|
||||
const [host, setHost] = useState<HTMLDivElement | null>(null);
|
||||
const assignRef = useCallback((node: HTMLDivElement | null) => {
|
||||
setHost(node);
|
||||
if (typeof forwardedRef === "function") forwardedRef(node);
|
||||
else if (forwardedRef) forwardedRef.current = node;
|
||||
}, [forwardedRef]);
|
||||
|
||||
return (
|
||||
<WorkAreaPanelHostContext.Provider value={host}>
|
||||
<div
|
||||
{...props}
|
||||
ref={assignRef}
|
||||
className={classes("thot-work-area-panel-host", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</WorkAreaPanelHostContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Keeps a modeless work-area panel keyboard-reachable without trapping focus.
|
||||
* Only a panel containing focus responds to Escape, so stacked panels close one
|
||||
* at a time and focus returns to the control that opened the panel.
|
||||
*/
|
||||
export function useModelessPanel(open: boolean, onClose: () => void, busy = false) {
|
||||
const panelRef = useRef<HTMLElement | null>(null);
|
||||
const stateRef = useRef({ busy, onClose });
|
||||
stateRef.current = { busy, onClose };
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const origin = document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
const focusTimer = window.setTimeout(() => {
|
||||
const panel = panelRef.current;
|
||||
const target = panel?.querySelector<HTMLElement>("[data-work-area-panel-close]") ?? panel;
|
||||
target?.focus();
|
||||
}, 0);
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const panel = panelRef.current;
|
||||
if (
|
||||
event.key !== "Escape"
|
||||
|| event.defaultPrevented
|
||||
|| stateRef.current.busy
|
||||
|| !panel?.contains(document.activeElement)
|
||||
) return;
|
||||
event.preventDefault();
|
||||
stateRef.current.onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
const activeElement = document.activeElement;
|
||||
const shouldRestoreFocus = activeElement === document.body
|
||||
|| Boolean(activeElement && panelRef.current?.contains(activeElement));
|
||||
window.clearTimeout(focusTimer);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
if (shouldRestoreFocus) {
|
||||
window.setTimeout(() => {
|
||||
if (origin?.isConnected) origin.focus();
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return panelRef;
|
||||
}
|
||||
|
||||
export interface WorkAreaPanelProps {
|
||||
open: boolean;
|
||||
ariaLabel?: string;
|
||||
eyebrow?: ReactNode;
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
onClose: () => void;
|
||||
closeLabel?: string;
|
||||
children: ReactNode;
|
||||
bodyClassName?: string;
|
||||
footer?: ReactNode;
|
||||
footerClassName?: string;
|
||||
busy?: boolean;
|
||||
width?: "standard" | "wide";
|
||||
className?: string;
|
||||
catalogMarkers?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared presentational shell for management, history, review and edit panels
|
||||
* that live inside the application's central work area.
|
||||
*/
|
||||
export const WorkAreaPanel = forwardRef<HTMLElement, WorkAreaPanelProps>(
|
||||
function WorkAreaPanel(
|
||||
{
|
||||
open,
|
||||
ariaLabel,
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
onClose,
|
||||
closeLabel = "Close panel",
|
||||
children,
|
||||
bodyClassName,
|
||||
footer,
|
||||
footerClassName,
|
||||
busy = false,
|
||||
width = "standard",
|
||||
className,
|
||||
catalogMarkers = false,
|
||||
},
|
||||
forwardedRef,
|
||||
) {
|
||||
const panelHost = useContext(WorkAreaPanelHostContext);
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const panelRef = useModelessPanel(open, onClose, busy);
|
||||
const assignRef = (node: HTMLElement | null) => {
|
||||
panelRef.current = node;
|
||||
if (typeof forwardedRef === "function") forwardedRef(node);
|
||||
else if (forwardedRef) forwardedRef.current = node;
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const panel = (
|
||||
<aside
|
||||
ref={assignRef}
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
className={classes("thot-work-area-panel", className)}
|
||||
data-work-area-panel=""
|
||||
data-catalog-panel={catalogMarkers ? "" : undefined}
|
||||
data-width={width}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabel ? undefined : titleId}
|
||||
aria-describedby={description ? descriptionId : undefined}
|
||||
aria-busy={busy}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<header
|
||||
className="thot-work-area-panel__header"
|
||||
data-work-area-panel-region="header"
|
||||
data-catalog-panel-region={catalogMarkers ? "header" : undefined}
|
||||
>
|
||||
<div className="thot-work-area-panel__heading">
|
||||
{eyebrow ? <p className="thot-work-area-panel__eyebrow">{eyebrow}</p> : null}
|
||||
<h2 id={titleId} className="thot-work-area-panel__title">
|
||||
{title}
|
||||
</h2>
|
||||
{description ? (
|
||||
<p id={descriptionId} className="thot-work-area-panel__description">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
className="thot-work-area-panel__close"
|
||||
data-work-area-panel-close
|
||||
data-drawer-close
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
aria-label={closeLabel}
|
||||
title={closeLabel}
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</Button>
|
||||
</header>
|
||||
<div
|
||||
className={classes("thot-work-area-panel__body", bodyClassName)}
|
||||
data-work-area-panel-region="body"
|
||||
data-catalog-panel-region={catalogMarkers ? "body" : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{footer ? (
|
||||
<footer
|
||||
className={classes("thot-work-area-panel__footer", footerClassName)}
|
||||
data-work-area-panel-region="footer"
|
||||
data-catalog-panel-region={catalogMarkers ? "footer" : undefined}
|
||||
>
|
||||
{footer}
|
||||
</footer>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
|
||||
return panelHost ? createPortal(panel, panelHost) : panel;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
.thot-workspace-manager__layout {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
grid-template-columns: 15rem minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.thot-workspace-manager__navigation {
|
||||
border-inline-end: 1px solid oklch(var(--border));
|
||||
}
|
||||
|
||||
@container work-area-panel (max-width: 45rem) {
|
||||
.thot-workspace-manager__layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(8rem, 13rem) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.thot-workspace-manager__navigation {
|
||||
border-inline-end: 0;
|
||||
border-block-end: 1px solid oklch(var(--border));
|
||||
}
|
||||
|
||||
.thot-workspace-manager__content {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -136,17 +136,16 @@ test("defaults workspace mutations and secrets to denied", async () => {
|
||||
expect(screen.queryByRole("heading", { name: "Runtime secrets" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("uses a tall viewport area and keeps the workspace content scrollable", () => {
|
||||
test("uses the shared modeless work-area panel and keeps workspace content scrollable", () => {
|
||||
renderManager();
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog).toHaveClass(
|
||||
"h-[86vh]",
|
||||
"w-[94vw]",
|
||||
"sm:w-[70vw]",
|
||||
"max-w-[94vw]",
|
||||
);
|
||||
expect(screen.getByRole("main")).toHaveClass("overflow-y-auto");
|
||||
const dialog = screen.getByRole("dialog", { name: "Workspace management" });
|
||||
expect(dialog).toHaveAttribute("aria-modal", "false");
|
||||
expect(dialog).toHaveAttribute("data-work-area-panel");
|
||||
expect(dialog.querySelectorAll(':scope > [data-work-area-panel-region="header"]')).toHaveLength(1);
|
||||
expect(dialog.querySelectorAll(':scope > [data-work-area-panel-region="body"]')).toHaveLength(1);
|
||||
expect(dialog.querySelectorAll(':scope > [data-work-area-panel-region="footer"]')).toHaveLength(0);
|
||||
expect(screen.getByRole("region", { name: "Workspace configuration" })).toHaveClass("overflow-y-auto");
|
||||
});
|
||||
|
||||
test("offers a button above the workspace list that returns to Level 1", async () => {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
GitPullRequest,
|
||||
KeyRound,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -33,13 +32,9 @@ import {
|
||||
type AuthOperationGuard,
|
||||
} from "../auth/authOperation";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../components/ui/dialog";
|
||||
import { WorkAreaPanel } from "./WorkAreaPanel";
|
||||
|
||||
import "./WorkspaceManager.css";
|
||||
|
||||
function QueryError({ name, message, retryLabel, onRetry }: {
|
||||
name: string;
|
||||
@@ -393,23 +388,18 @@ export function WorkspaceManager({
|
||||
const hasEnteredSecrets = Object.values(secretValues).some((value) => value.length > 0);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) close(); }}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="h-[86vh] w-[94vw] max-h-[92vh] max-w-[94vw] grid-rows-[auto_minmax(0,1fr)] gap-0 overflow-hidden p-0 sm:w-[70vw] sm:max-w-[94vw]"
|
||||
>
|
||||
<DialogHeader className="border-b border-border/70 px-6 pb-4 pt-5 pr-12">
|
||||
<DialogTitle>Workspace management</DialogTitle>
|
||||
<DialogDescription>
|
||||
Read, validate, and complete the runtime configuration of workspaces supplied by the installation repository.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<button type="button" aria-label="Close workspace management" onClick={close} className="absolute right-3 top-3 grid size-7 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
|
||||
<div className="grid min-h-0 grid-cols-[15rem_minmax(0,1fr)] overflow-hidden">
|
||||
<nav aria-label="Workspaces" className="flex min-h-0 flex-col border-r border-border/70 bg-muted/30 p-3">
|
||||
<WorkAreaPanel
|
||||
open={open}
|
||||
ariaLabel="Workspace management"
|
||||
eyebrow="Workspace configuration"
|
||||
title="Workspace management"
|
||||
description="Read, validate, and complete the runtime configuration of workspaces supplied by the installation repository."
|
||||
onClose={close}
|
||||
closeLabel="Close workspace management"
|
||||
bodyClassName="thot-work-area-panel__body--flush"
|
||||
>
|
||||
<div className="thot-workspace-manager__layout">
|
||||
<nav aria-label="Workspaces" className="thot-workspace-manager__navigation flex min-h-0 flex-col bg-muted/30 p-3">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -442,7 +432,10 @@ export function WorkspaceManager({
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="min-h-0 min-w-0 overflow-y-auto overscroll-contain px-6 py-5">
|
||||
<section
|
||||
aria-label="Workspace configuration"
|
||||
className="thot-workspace-manager__content min-h-0 min-w-0 overflow-y-auto overscroll-contain px-6 py-5"
|
||||
>
|
||||
{notice && (
|
||||
<p role="status" className="mb-4 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-3 py-2 text-sm">
|
||||
<CheckCircle2 className="size-4 text-primary" />{notice}
|
||||
@@ -689,9 +682,8 @@ export function WorkspaceManager({
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</section>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</WorkAreaPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type CatalogSyncPhase,
|
||||
type CatalogSyncRun,
|
||||
} from "../../api/catalog-databases";
|
||||
import { FleetLedgerDrawer } from "./FleetLedgerShell";
|
||||
|
||||
interface Props {
|
||||
databaseId: string | null;
|
||||
@@ -101,7 +102,7 @@ export function CatalogSyncDrawer({
|
||||
const runQuery = useQuery({
|
||||
queryKey: ["catalog-sync-run", runId],
|
||||
queryFn: () => getCatalogSyncRun(runId!),
|
||||
enabled: Boolean(runId),
|
||||
enabled: Boolean(runId && open),
|
||||
retry: false,
|
||||
refetchInterval: (query) => terminal(query.state.data) ? false : 1_000,
|
||||
});
|
||||
@@ -113,6 +114,12 @@ export function CatalogSyncDrawer({
|
||||
retry: false,
|
||||
refetchInterval: run && !terminal(run) ? 2_000 : false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const latestRun = historyQuery.data?.[0];
|
||||
if (!open || runId || !latestRun) return;
|
||||
onRunChange(latestRun);
|
||||
}, [historyQuery.data, onRunChange, open, runId]);
|
||||
const eventQuery = useQuery({
|
||||
queryKey: ["catalog-sync-events", runId],
|
||||
queryFn: () => listCatalogSyncEvents(runId!, lastSequence.current),
|
||||
@@ -218,25 +225,74 @@ export function CatalogSyncDrawer({
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<aside
|
||||
aria-label="Schema synchronization"
|
||||
className="fixed inset-y-2 right-0 z-40 flex w-full max-w-[440px] flex-col border-l border-border bg-background shadow-2xl sm:inset-y-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<p className="thot-label">Schema synchronization</p>
|
||||
<h2 className="mt-1 font-heading text-xl font-semibold">{run ? `${stateLabel(run)[0].toUpperCase()}${stateLabel(run).slice(1)}` : "Loading"}</h2>
|
||||
{run ? <p className="mt-1 text-sm text-muted-foreground">{run.scope} · {elapsed(run, now)}</p> : null}
|
||||
const historySection = (
|
||||
<section className="thot-catalog-drawer__history mt-5" aria-label="Synchronization history">
|
||||
<h3 className="thot-label mb-2">Recent runs</h3>
|
||||
{historyQuery.isLoading ? <p className="text-sm text-muted-foreground">Loading recent runs…</p> : null}
|
||||
{historyQuery.isError ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm" role="alert">
|
||||
<p className="font-semibold text-destructive">Synchronization history could not be loaded.</p>
|
||||
<Button type="button" variant="outline" size="sm" className="mt-3" disabled={historyQuery.isFetching} onClick={() => void historyQuery.refetch()}>
|
||||
<RotateCcw className={historyQuery.isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry history
|
||||
</Button>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon-lg" aria-label="Close synchronization drawer" onClick={onClose}>
|
||||
<X aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{!historyQuery.isLoading && !historyQuery.isError && (historyQuery.data ?? []).length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/20 px-3 py-4 text-sm text-muted-foreground">
|
||||
No synchronization runs yet.
|
||||
</div>
|
||||
) : null}
|
||||
{(historyQuery.data ?? []).length > 0 ? (
|
||||
<div className="divide-y divide-border rounded-md border border-border">
|
||||
{(historyQuery.data ?? []).slice(0, 5).map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
aria-current={item.id === run?.id ? "true" : undefined}
|
||||
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)}
|
||||
>
|
||||
<span>{item.scope}</span>
|
||||
<span className="text-xs text-muted-foreground">{stateLabel(item)} · {new Date(item.createdAt).toLocaleString()}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||
{runQuery.isLoading ? <p className="text-sm text-muted-foreground">Loading synchronization…</p> : null}
|
||||
if (!open) return null;
|
||||
const footer = run ? (
|
||||
<>
|
||||
{run.state === "awaiting_confirmation" ? (
|
||||
<Button type="button" disabled={action !== null} onClick={() => void perform("confirm")}>
|
||||
{action === "confirm" ? <LoaderCircle className="animate-spin" /> : <Check />} Confirm removals
|
||||
</Button>
|
||||
) : null}
|
||||
{["queued", "running", "awaiting_confirmation"].includes(run.state) ? (
|
||||
<Button type="button" variant="outline" disabled={action !== null} onClick={() => void perform("cancel")}>Cancel</Button>
|
||||
) : null}
|
||||
{["failed", "cancelled", "interrupted"].includes(run.state) ? (
|
||||
<Button type="button" disabled={action !== null} onClick={() => void perform("retry")}>
|
||||
<RotateCcw className={action === "retry" ? "animate-spin" : ""} /> Retry
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : undefined;
|
||||
return (
|
||||
<FleetLedgerDrawer
|
||||
open
|
||||
ariaLabel="Schema synchronization"
|
||||
eyebrow="Schema synchronization"
|
||||
title={run ? `${stateLabel(run)[0].toUpperCase()}${stateLabel(run).slice(1)}` : "Synchronization history"}
|
||||
description={run ? `${run.scope} · ${elapsed(run, now)}` : undefined}
|
||||
onClose={onClose}
|
||||
closeLabel="Close synchronization drawer"
|
||||
busy={action !== null}
|
||||
bodyClassName="thot-catalog-drawer__history-layout"
|
||||
footer={footer}
|
||||
>
|
||||
{runQuery.isLoading ? <p className="text-sm text-muted-foreground">Loading synchronization…</p> : null}
|
||||
{run ? (
|
||||
<>
|
||||
<section aria-labelledby="synchronization-steps-heading">
|
||||
@@ -365,38 +421,22 @@ export function CatalogSyncDrawer({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-5" aria-label="Synchronization history">
|
||||
<h3 className="thot-label mb-2">Recent runs</h3>
|
||||
<div className="divide-y divide-border rounded-md border border-border">
|
||||
{(historyQuery.data ?? []).slice(0, 5).map((item) => (
|
||||
<button key={item.id} type="button" className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50" onClick={() => onRunChange(item)}>
|
||||
<span>{item.scope}</span>
|
||||
<span className="text-xs text-muted-foreground">{stateLabel(item)} · {new Date(item.createdAt).toLocaleString()}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{historySection}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{run ? (
|
||||
<div className="flex justify-end gap-2 border-t border-border px-5 py-4">
|
||||
{run.state === "awaiting_confirmation" ? (
|
||||
<Button type="button" disabled={action !== null} onClick={() => void perform("confirm")}>
|
||||
{action === "confirm" ? <LoaderCircle className="animate-spin" /> : <Check />} Confirm removals
|
||||
</Button>
|
||||
) : null}
|
||||
{["queued", "running", "awaiting_confirmation"].includes(run.state) ? (
|
||||
<Button type="button" variant="outline" disabled={action !== null} onClick={() => void perform("cancel")}>Cancel</Button>
|
||||
) : null}
|
||||
{["failed", "cancelled", "interrupted"].includes(run.state) ? (
|
||||
<Button type="button" disabled={action !== null} onClick={() => void perform("retry")}>
|
||||
<RotateCcw className={action === "retry" ? "animate-spin" : ""} /> Retry
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-lg border border-dashed border-primary/30 bg-primary/5 px-5 py-6 text-sm">
|
||||
<p className="font-semibold">{runQuery.isError ? "This synchronization run could not be loaded." : historyQuery.isLoading ? "Loading synchronization history…" : "No run selected."}</p>
|
||||
<p className="mt-1 text-muted-foreground">Choose a recent run to inspect its steps and event log.</p>
|
||||
{runQuery.isError ? (
|
||||
<Button type="button" variant="outline" size="sm" className="mt-3" disabled={runQuery.isFetching} onClick={() => void runQuery.refetch()}>
|
||||
<RotateCcw className={runQuery.isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry run
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{historySection}
|
||||
</>
|
||||
)}
|
||||
</FleetLedgerDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { Menu } from "@base-ui/react/menu";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
type SensitiveDataSuggestionRequest,
|
||||
} from "../../api/catalog-databases";
|
||||
import type { DatabaseNavigationState } from "./model";
|
||||
import { FleetActionSelector, type FleetActionOption } from "./FleetActionSelector";
|
||||
import { FleetLedgerDrawer } from "./FleetLedgerShell";
|
||||
|
||||
interface Props {
|
||||
databaseId: string;
|
||||
@@ -29,11 +31,17 @@ interface Props {
|
||||
onNavigationStateChange: (state: DatabaseNavigationState) => void;
|
||||
onSync: () => void;
|
||||
onSuggestSensitive: (selection: SensitiveDataSuggestionRequest, scopeLabel: string) => Promise<void>;
|
||||
bindingReady?: boolean;
|
||||
catalogOperationActive?: boolean;
|
||||
onCatalogMetricsChanged?: () => void | Promise<void>;
|
||||
metadataModelControl?: ReactNode;
|
||||
presentation?: "legacy" | "fleet";
|
||||
}
|
||||
|
||||
interface GridContext {
|
||||
canManage: boolean;
|
||||
busy: boolean;
|
||||
presentation: "legacy" | "fleet";
|
||||
onEdit: (column: CatalogColumn, origin: HTMLButtonElement) => void;
|
||||
onSensitiveChange: (column: CatalogColumn, sensitive: boolean) => void;
|
||||
}
|
||||
@@ -52,9 +60,11 @@ function ActionCell({ data, context }: ICellRendererParams<CatalogColumn, unknow
|
||||
if (!data || !context) return null;
|
||||
return (
|
||||
<div className="flex h-full items-center justify-end" onClick={(event) => event.stopPropagation()}>
|
||||
<Button type="button" variant="ghost" size="icon-lg" disabled={!context.canManage} aria-label={`Edit metadata for ${data.name}`} onClick={(event) => context.onEdit(data, event.currentTarget)}>
|
||||
<Pencil aria-hidden="true" />
|
||||
</Button>
|
||||
<span className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? "Edit metadata" : undefined}>
|
||||
<Button type="button" variant="ghost" size="icon-lg" disabled={!context.canManage} aria-label={`Edit metadata for ${data.name}`} title={context.presentation === "legacy" ? `Edit metadata for ${data.name}` : undefined} onClick={(event) => context.onEdit(data, event.currentTarget)}>
|
||||
<Pencil aria-hidden="true" />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -86,10 +96,15 @@ export function DatabaseColumns({
|
||||
onNavigationStateChange,
|
||||
onSync,
|
||||
onSuggestSensitive,
|
||||
bindingReady = true,
|
||||
catalogOperationActive = false,
|
||||
onCatalogMetricsChanged,
|
||||
metadataModelControl,
|
||||
presentation = "legacy",
|
||||
}: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = ["catalog-columns", databaseId, table.id] as const;
|
||||
const { data = [], isLoading, isFetching, refetch } = useQuery({
|
||||
const { data = [], error, isError, isLoading, isFetching, refetch } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => listCatalogColumns(databaseId, table.id),
|
||||
retry: false,
|
||||
@@ -159,6 +174,7 @@ export function DatabaseColumns({
|
||||
setBaseline(JSON.stringify([updated.description ?? "", updated.generatedDescription ?? ""]));
|
||||
setVersion(updated.version);
|
||||
setStale(false);
|
||||
void onCatalogMetricsChanged?.();
|
||||
toast.success("Column metadata saved");
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.code === "column_stale") {
|
||||
@@ -190,6 +206,7 @@ export function DatabaseColumns({
|
||||
await queryClient.invalidateQueries({ queryKey, exact: true });
|
||||
gridRef.current?.api.deselectAll();
|
||||
setSelectedIds([]);
|
||||
void onCatalogMetricsChanged?.();
|
||||
toast.success(`Copied ${counts.copied} description${counts.copied === 1 ? "" : "s"}; skipped ${counts.skipped}`);
|
||||
} catch (error) {
|
||||
toast.error(apiErrorMessage(error));
|
||||
@@ -261,6 +278,7 @@ export function DatabaseColumns({
|
||||
(column) => updatedById.get(column.id) ?? column,
|
||||
));
|
||||
setSensitiveDrafts({});
|
||||
void onCatalogMetricsChanged?.();
|
||||
toast.success("Sensitive fields saved");
|
||||
} catch (error) {
|
||||
toast.error(apiErrorMessage(error));
|
||||
@@ -270,6 +288,114 @@ export function DatabaseColumns({
|
||||
}
|
||||
};
|
||||
|
||||
type FleetColumnAction =
|
||||
| "generate-descriptions"
|
||||
| "consolidate-descriptions"
|
||||
| "suggest-sensitive"
|
||||
| "sync-columns"
|
||||
| "save-sensitive";
|
||||
|
||||
const fleetActions: readonly FleetActionOption<FleetColumnAction>[] = [
|
||||
{
|
||||
id: "generate-descriptions",
|
||||
label: "Generate descriptions",
|
||||
group: "Descriptions",
|
||||
runLabel: "Generate",
|
||||
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || catalogOperationActive || busy,
|
||||
disabledReason: !canManage
|
||||
? "You do not have permission to generate descriptions."
|
||||
: selectedIds.length === 0
|
||||
? "Select at least one column."
|
||||
: !selectedMetadataModel
|
||||
? "Choose a metadata model first."
|
||||
: descriptionGenerationActive || catalogOperationActive
|
||||
? "Wait for the active catalog operation to finish."
|
||||
: busy
|
||||
? "Another action is running."
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: "consolidate-descriptions",
|
||||
label: "Move generated to Description",
|
||||
group: "Descriptions",
|
||||
runLabel: "Move",
|
||||
disabled: !canManage || selectedIds.length === 0 || catalogOperationActive || busy,
|
||||
disabledReason: !canManage
|
||||
? "You do not have permission to update metadata."
|
||||
: selectedIds.length === 0
|
||||
? "Select at least one column."
|
||||
: catalogOperationActive
|
||||
? "Wait for the active catalog operation to finish."
|
||||
: busy
|
||||
? "Another action is running."
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: "suggest-sensitive",
|
||||
label: "Suggest sensitive fields",
|
||||
group: "Sensitive data",
|
||||
runLabel: "Suggest",
|
||||
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || catalogOperationActive || busy,
|
||||
disabledReason: !canManage
|
||||
? "You do not have permission to review sensitive data."
|
||||
: selectedIds.length === 0
|
||||
? "Select at least one column."
|
||||
: !selectedMetadataModel
|
||||
? "Choose a metadata model first."
|
||||
: descriptionGenerationActive || catalogOperationActive
|
||||
? "Wait for the active catalog operation to finish."
|
||||
: busy
|
||||
? "Another action is running."
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: "sync-columns",
|
||||
label: "Synchronize columns",
|
||||
group: "Synchronization",
|
||||
scopeLabel: "All columns in this table",
|
||||
runLabel: "Synchronize",
|
||||
disabled: !canManage || !bindingReady || catalogOperationActive || busy,
|
||||
disabledReason: !canManage
|
||||
? "You do not have permission to synchronize the catalog."
|
||||
: !bindingReady
|
||||
? "Test the current database binding first."
|
||||
: catalogOperationActive
|
||||
? "Wait for the active catalog operation to finish."
|
||||
: busy
|
||||
? "Another action is running."
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: "save-sensitive",
|
||||
label: "Save sensitive-field changes",
|
||||
group: "Sensitive data",
|
||||
runLabel: "Save",
|
||||
disabled: !canManage || changedSensitiveColumns.length === 0 || catalogOperationActive || busy,
|
||||
disabledReason: !canManage
|
||||
? "You do not have permission to update sensitive fields."
|
||||
: changedSensitiveColumns.length === 0
|
||||
? "No sensitive-field changes are waiting to be saved."
|
||||
: catalogOperationActive
|
||||
? "Wait for the active catalog operation to finish."
|
||||
: busy
|
||||
? "Another action is running."
|
||||
: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const runFleetAction = async (action: FleetColumnAction) => {
|
||||
if (action === "generate-descriptions") await generateDescriptions();
|
||||
else if (action === "consolidate-descriptions") await consolidateDescriptions();
|
||||
else if (action === "suggest-sensitive") await suggestSensitive();
|
||||
else if (action === "sync-columns") onSync();
|
||||
else await saveSensitive();
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
gridRef.current?.api.deselectAll();
|
||||
setSelectedIds([]);
|
||||
};
|
||||
|
||||
const columns = useMemo<ColDef<CatalogColumn>[]>(() => [
|
||||
{ field: "ordinalPosition", headerName: "#", width: 64, maxWidth: 64, filter: "agNumberColumnFilter" },
|
||||
{ field: "sensitive", headerName: "Sensitive", minWidth: 110, width: 110, sortable: false, filter: false, resizable: false, cellRenderer: SensitiveCell },
|
||||
@@ -280,16 +406,28 @@ export function DatabaseColumns({
|
||||
{ field: "sourceComment", headerName: "Source comment", minWidth: 220, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
|
||||
{ field: "generatedDescription", headerName: "Generated description", minWidth: 230, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
|
||||
{ field: "description", headerName: "Description", minWidth: 230, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
|
||||
{ colId: "actions", headerName: "", width: 64, maxWidth: 64, pinned: "right", sortable: false, filter: false, resizable: false, cellRenderer: ActionCell },
|
||||
{ colId: "actions", headerName: "Actions", width: 64, maxWidth: 64, pinned: "right", lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: ActionCell },
|
||||
], []);
|
||||
const context = useMemo<GridContext>(() => ({
|
||||
canManage,
|
||||
busy,
|
||||
busy: busy || catalogOperationActive,
|
||||
presentation,
|
||||
onEdit: edit,
|
||||
onSensitiveChange: changeSensitive,
|
||||
}), [busy, canManage, data]);
|
||||
}), [busy, canManage, catalogOperationActive, data, presentation]);
|
||||
const fleetQueryError = presentation === "fleet" && isError ? (
|
||||
<div className="grid min-h-[280px] flex-1 place-items-center p-6" role="alert">
|
||||
<div className="max-w-lg rounded-lg border border-destructive/30 bg-destructive/8 p-5 text-sm">
|
||||
<p className="font-semibold text-destructive">Catalog columns could not be loaded.</p>
|
||||
<p className="mt-1 leading-5 text-muted-foreground">{apiErrorMessage(error)}</p>
|
||||
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refetch()}>
|
||||
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry columns
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (editingId && active) {
|
||||
if (editingId && active && presentation === "legacy") {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl px-4 py-6 sm:px-5">
|
||||
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={busy} onClick={closeEditor}>← Back to columns</Button>
|
||||
@@ -319,66 +457,71 @@ export function DatabaseColumns({
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2">
|
||||
<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`}>
|
||||
{selectedIds.length > 0 ? (
|
||||
<>
|
||||
<span className="text-sm font-semibold">{selectedIds.length} selected</span>
|
||||
<Menu.Root>
|
||||
<Menu.Trigger className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={busy}>Actions <ChevronDown className="size-4" /></Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
|
||||
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
|
||||
<Menu.Item
|
||||
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
|
||||
disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy}
|
||||
onClick={() => void generateDescriptions()}
|
||||
>
|
||||
Generate {selectedIds.length === 1 ? "description" : "descriptions"}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
|
||||
disabled={!canManage || busy}
|
||||
onClick={() => void consolidateDescriptions()}
|
||||
>
|
||||
Move generated description to Description
|
||||
</Menu.Item>
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!canManage || descriptionGenerationActive || busy}
|
||||
title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined}
|
||||
onClick={() => void suggestSensitive()}
|
||||
>
|
||||
<Sparkles />{sensitiveAction === "suggest" ? "Suggesting…" : "Suggest sensitive fields"}
|
||||
</Button>
|
||||
{changedSensitiveColumns.length > 0 ? (
|
||||
<Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}>
|
||||
<Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" variant="ghost" onClick={() => { gridRef.current?.api.deselectAll(); setSelectedIds([]); }}><X />Clear</Button>
|
||||
</>
|
||||
presentation === "fleet" ? (
|
||||
<>
|
||||
<FleetActionSelector
|
||||
actions={fleetActions}
|
||||
selectedCount={selectedIds.length}
|
||||
busy={busy}
|
||||
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"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm font-semibold">{selectedIds.length} selected</span>
|
||||
<Menu.Root>
|
||||
<Menu.Trigger className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={busy}>Actions <ChevronDown className="size-4" /></Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
|
||||
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy} onClick={() => void generateDescriptions()}>Generate {selectedIds.length === 1 ? "description" : "descriptions"}</Menu.Item>
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy} onClick={() => void consolidateDescriptions()}>Move generated description to Description</Menu.Item>
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
<Button type="button" variant="outline" disabled={!canManage || descriptionGenerationActive || busy} title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined} onClick={() => void suggestSensitive()}><Sparkles />{sensitiveAction === "suggest" ? "Suggesting…" : "Suggest sensitive fields"}</Button>
|
||||
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}><Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
|
||||
<Button type="button" variant="ghost" onClick={clearSelection}><X />Clear</Button>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<span className="thot-label whitespace-nowrap">Catalog columns</span>
|
||||
<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)} />
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{data.length}</span>
|
||||
{changedSensitiveColumns.length > 0 ? (
|
||||
<Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}>
|
||||
<Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
|
||||
<Button type="button" disabled={!canManage || busy} onClick={onSync}><RefreshCw />Sync columns</Button>
|
||||
</>
|
||||
presentation === "fleet" ? (
|
||||
<>
|
||||
<FleetActionSelector
|
||||
actions={fleetActions}
|
||||
selectedCount={0}
|
||||
busy={busy}
|
||||
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)} />
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{data.length}</span>
|
||||
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="thot-label whitespace-nowrap">Catalog columns</span>
|
||||
<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)} />
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{data.length}</span>
|
||||
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}><Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
|
||||
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
|
||||
<Button type="button" disabled={!canManage || busy} onClick={onSync}><RefreshCw />Sync columns</Button>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="relative min-h-[280px] flex-1">
|
||||
<div className="thot-database-grid ag-theme-alpine absolute inset-0 h-full w-full">
|
||||
{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<CatalogColumn>
|
||||
ref={gridRef}
|
||||
rowData={displayedColumns}
|
||||
@@ -396,7 +539,40 @@ export function DatabaseColumns({
|
||||
overlayNoRowsTemplate="No columns synchronized for this table."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
{presentation === "fleet" && active ? (
|
||||
<FleetLedgerDrawer
|
||||
open
|
||||
eyebrow="Catalog metadata"
|
||||
title="Review column description"
|
||||
description="Physical schema fields are read-only. Review fields can be edited."
|
||||
onClose={closeEditor}
|
||||
closeLabel="Close column metadata"
|
||||
busy={busy}
|
||||
footer={(
|
||||
<>
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={closeEditor}>Cancel</Button>
|
||||
<Button type="button" disabled={!canManage || !editorDirty || busy || stale} onClick={() => void save()}><Save aria-hidden="true" />{busy ? "Saving…" : "Save metadata"}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<p className="thot-label">{table.name} · column</p>
|
||||
<h3 className="mt-1 break-words font-heading text-xl font-semibold">{active.name}</h3>
|
||||
{stale ? (
|
||||
staleBannerOpen ? <div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm"><p className="font-semibold">Newer column metadata is available.</p><p className="mt-1 text-muted-foreground">Your draft is preserved until you reload.</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>Keep editing</Button></div></div>
|
||||
: <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>Reload the latest value before this metadata can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button></div>
|
||||
) : null}
|
||||
<div className="mt-5 grid gap-4 sm:grid-cols-2">
|
||||
<label className="grid gap-1.5 text-sm font-semibold">Physical column name<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={active.name} readOnly /></label>
|
||||
<label className="grid gap-1.5 text-sm font-semibold">Native type<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={active.dataType} readOnly /></label>
|
||||
<label className="grid gap-1.5 text-sm font-semibold">Ordinal position<input className="h-9 rounded-md border border-input bg-muted/35 px-3" value={active.ordinalPosition} readOnly /></label>
|
||||
<label className="grid gap-1.5 text-sm font-semibold">Default expression<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={active.defaultExpression ?? ""} readOnly /></label>
|
||||
<label className="grid gap-1.5 text-sm font-semibold sm:col-span-2">Source comment<textarea className="min-h-24 rounded-md border border-input bg-muted/35 px-3 py-2 text-sm" value={active.sourceComment ?? ""} readOnly /></label>
|
||||
<label className="grid gap-1.5 text-sm font-semibold sm:col-span-2">Generated description<textarea className="min-h-28 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={generatedDescription} disabled={!canManage || busy} onChange={(event) => setGeneratedDescription(event.target.value)} /></label>
|
||||
<label className="grid gap-1.5 text-sm font-semibold sm:col-span-2">Description<textarea className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={description} disabled={!canManage || busy} onChange={(event) => setDescription(event.target.value)} /></label>
|
||||
</div>
|
||||
</FleetLedgerDrawer>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { http, HttpResponse } from "msw";
|
||||
|
||||
import type { CatalogDatabase, CatalogTable } from "../../api/catalog-databases";
|
||||
import { server } from "../../test/msw";
|
||||
import { DatabaseColumns } from "./DatabaseColumns";
|
||||
import { DatabaseRelationships } from "./DatabaseRelationships";
|
||||
import { DatabaseTables } from "./DatabaseTables";
|
||||
|
||||
const database: CatalogDatabase = {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
workspaceId: "psd-clinical",
|
||||
workspaceName: "Policlinico San Donato",
|
||||
workspaceAvailable: true,
|
||||
configured: true,
|
||||
engine: "postgres",
|
||||
databaseName: "warehouse",
|
||||
schema: "datawarehouse",
|
||||
version: 3,
|
||||
createdAt: "2026-08-27T08:00:00Z",
|
||||
updatedAt: "2026-08-27T09:00:00Z",
|
||||
binding: { transport: "postgres_direct", host: "db.internal", port: 5432, username: "reader" },
|
||||
connectionStatus: "reachable",
|
||||
testedVersion: 3,
|
||||
secrets: {
|
||||
password: false,
|
||||
apiKey: false,
|
||||
sshPrivateKey: false,
|
||||
sshPrivateKeyPassphrase: false,
|
||||
sshKnownHosts: false,
|
||||
tlsCa: false,
|
||||
},
|
||||
};
|
||||
|
||||
const table: CatalogTable = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
databaseId: database.id!,
|
||||
name: "patients",
|
||||
sourceComment: "Patients imported from the clinical source",
|
||||
description: null,
|
||||
generatedDescription: null,
|
||||
version: 1,
|
||||
createdAt: "2026-08-27T08:00:00Z",
|
||||
updatedAt: "2026-08-27T09:00:00Z",
|
||||
};
|
||||
|
||||
function renderWithQueryClient(node: React.ReactNode) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={client}>{node}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
const noopAsync = async () => {};
|
||||
|
||||
test("Fleet tables distinguish a failed query from an empty catalog and retry it", async () => {
|
||||
const user = userEvent.setup();
|
||||
let requests = 0;
|
||||
server.use(http.get("/api/catalog/databases/:databaseId/tables", () => {
|
||||
requests += 1;
|
||||
return requests === 1
|
||||
? HttpResponse.json({ code: "catalog_unavailable" }, { status: 503 })
|
||||
: HttpResponse.json([]);
|
||||
}));
|
||||
|
||||
renderWithQueryClient(
|
||||
<DatabaseTables
|
||||
presentation="fleet"
|
||||
database={database}
|
||||
canManage
|
||||
selectedMetadataModel={null}
|
||||
descriptionGenerationActive={false}
|
||||
onBackToDatabases={noop}
|
||||
onOpenOverview={noop}
|
||||
onOpenRelationships={noop}
|
||||
onNavigationStateChange={noop}
|
||||
onRunStarted={noop}
|
||||
onOpenSync={noop}
|
||||
onDescriptionGenerationRunStarted={noop}
|
||||
onSuggestSensitive={noopAsync}
|
||||
/>,
|
||||
);
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent("Catalog tables could not be loaded.");
|
||||
expect(alert).toHaveTextContent("The database catalog is unavailable.");
|
||||
expect(screen.queryByText(/No catalog tables/i)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Retry tables" }));
|
||||
await waitFor(() => expect(requests).toBe(2));
|
||||
await waitFor(() => expect(screen.queryByText("Catalog tables could not be loaded.")).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
test("Fleet columns distinguish a failed query from an empty catalog and retry it", async () => {
|
||||
const user = userEvent.setup();
|
||||
let requests = 0;
|
||||
server.use(http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => {
|
||||
requests += 1;
|
||||
return requests === 1
|
||||
? HttpResponse.json({ code: "catalog_unavailable" }, { status: 503 })
|
||||
: HttpResponse.json([]);
|
||||
}));
|
||||
|
||||
renderWithQueryClient(
|
||||
<DatabaseColumns
|
||||
presentation="fleet"
|
||||
databaseId={database.id!}
|
||||
table={table}
|
||||
canManage
|
||||
selectedMetadataModel={null}
|
||||
descriptionGenerationActive={false}
|
||||
onDescriptionGenerationRunStarted={noop}
|
||||
onNavigationStateChange={noop}
|
||||
onSync={noop}
|
||||
onSuggestSensitive={noopAsync}
|
||||
/>,
|
||||
);
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent("Catalog columns could not be loaded.");
|
||||
expect(alert).toHaveTextContent("The database catalog is unavailable.");
|
||||
expect(screen.queryByText(/No columns synchronized/i)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Retry columns" }));
|
||||
await waitFor(() => expect(requests).toBe(2));
|
||||
await waitFor(() => expect(screen.queryByText("Catalog columns could not be loaded.")).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
test("Fleet relationships distinguish a failed query from an empty catalog and retry it", async () => {
|
||||
const user = userEvent.setup();
|
||||
let requests = 0;
|
||||
server.use(http.get("/api/catalog/databases/:databaseId/relationships", () => {
|
||||
requests += 1;
|
||||
return requests === 1
|
||||
? HttpResponse.json({ code: "catalog_unavailable" }, { status: 503 })
|
||||
: HttpResponse.json([]);
|
||||
}));
|
||||
|
||||
renderWithQueryClient(
|
||||
<DatabaseRelationships
|
||||
presentation="fleet"
|
||||
database={database}
|
||||
canManage
|
||||
onBackToDatabases={noop}
|
||||
onOpenOverview={noop}
|
||||
onOpenTables={noop}
|
||||
onRunStarted={noop}
|
||||
onOpenSync={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent("Catalog relationships could not be loaded.");
|
||||
expect(alert).toHaveTextContent("The database catalog is unavailable.");
|
||||
expect(screen.queryByText(/No physical relationships synchronized/i)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Retry relationships" }));
|
||||
await waitFor(() => expect(requests).toBe(2));
|
||||
await waitFor(() => expect(screen.queryByText("Catalog relationships could not be loaded.")).not.toBeInTheDocument());
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import type { RefObject, ReactNode } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Database,
|
||||
History,
|
||||
RefreshCw,
|
||||
Save,
|
||||
TestTube2,
|
||||
@@ -140,12 +141,17 @@ interface DatabaseFormProps {
|
||||
onSync: (scope: CatalogSyncScope) => void;
|
||||
onOpenSync: () => void;
|
||||
activeSyncRun?: CatalogSyncRun;
|
||||
presentation?: "page" | "drawer";
|
||||
}
|
||||
|
||||
function formTitle(mode: DatabaseFormMode, workspaceLocked: boolean): string {
|
||||
export function databaseFormTitle(
|
||||
mode: DatabaseFormMode,
|
||||
workspaceLocked: boolean,
|
||||
presentation: "page" | "drawer" = "page",
|
||||
): string {
|
||||
if (mode === "add") return workspaceLocked ? "Edit database" : "Add database";
|
||||
if (mode === "edit") return "Edit database";
|
||||
if (mode === "delete") return "Delete database";
|
||||
if (mode === "delete") return presentation === "drawer" ? "Remove configuration" : "Delete database";
|
||||
return "Database details";
|
||||
}
|
||||
|
||||
@@ -180,8 +186,9 @@ export function DatabaseForm({
|
||||
onSync,
|
||||
onOpenSync,
|
||||
activeSyncRun,
|
||||
presentation = "page",
|
||||
}: DatabaseFormProps) {
|
||||
const title = formTitle(mode, workspaceLocked);
|
||||
const title = databaseFormTitle(mode, workspaceLocked, presentation);
|
||||
const readOnly = mode === "view" || mode === "delete";
|
||||
const editable = mode === "add" || mode === "edit";
|
||||
const busy = busyAction !== null;
|
||||
@@ -212,7 +219,7 @@ export function DatabaseForm({
|
||||
{row.workspaceAvailable
|
||||
? "The repository workspace remains available and will return to the list as Not configured. "
|
||||
: "This orphaned catalog row will disappear from the list. "}
|
||||
Secret references associated with this configuration are removed too.
|
||||
Secret references associated with this configuration are removed too. The source database is not modified.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -425,13 +432,50 @@ export function DatabaseForm({
|
||||
) : null}
|
||||
{mode === "delete" ? (
|
||||
<Button type="button" variant="destructive" disabled={!canManage || !row.configured || !row.id || busy || stale} onClick={onDelete}>
|
||||
<Trash2 /> {busyAction === "delete" ? "Deleting…" : "Delete database"}
|
||||
<Trash2 /> {busyAction === "delete" ? "Removing…" : presentation === "drawer" ? "Remove configuration" : "Delete database"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
if (presentation === "drawer") {
|
||||
return (
|
||||
<section aria-label={`${title} form`} className="thot-fleet-drawer-form">
|
||||
<h2 ref={headingRef} tabIndex={-1} className="sr-only">{title}</h2>
|
||||
<div className="thot-fleet-drawer-form__context">
|
||||
<div className="min-w-0">
|
||||
<p className="thot-label">Workspace</p>
|
||||
<p className="mt-1 truncate text-sm font-semibold">{row.workspaceName}</p>
|
||||
<p className="mt-0.5 truncate font-mono text-xs text-muted-foreground">{row.workspaceId}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Database className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="rounded-md border border-border bg-muted px-2.5 py-1 text-xs font-semibold text-muted-foreground">
|
||||
{statusLabel(row)}
|
||||
</span>
|
||||
{mode === "view" && row.configured ? (
|
||||
<Button type="button" variant="outline" size="sm" className="thot-fleet-nav-action thot-fleet-nav-action--below" data-tooltip="Synchronization history" onClick={onOpenSync}>
|
||||
{activeSyncRun ? <RefreshCw className="animate-spin" /> : <History />} Sync history
|
||||
</Button>
|
||||
) : null}
|
||||
{mode === "view" ? (
|
||||
<DatabaseSyncMenu
|
||||
disabled={!canManage || !bindingReady || busy || Boolean(activeSyncRun)}
|
||||
disabledReason={!bindingReady ? "Test the current database binding before synchronizing the schema" : undefined}
|
||||
onSelect={onSync}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{row.lastTestedAt ? (
|
||||
<p className="mb-4 text-xs text-muted-foreground">Last tested {new Date(row.lastTestedAt).toLocaleString()}</p>
|
||||
) : null}
|
||||
{form}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label={`${title} form`} className="min-h-0 flex-1 overflow-y-auto bg-background">
|
||||
<div className="mx-auto w-full max-w-[900px] px-4 py-5 sm:px-6 sm:py-6">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode, 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, Pencil, RefreshCw, Sparkles, Trash2, X } from "lucide-react";
|
||||
import { ChevronDown, Eye, History, Info, Pencil, RefreshCw, Rows3, Sparkles, Trash2, X } from "lucide-react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import type {
|
||||
CatalogDatabase,
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
} from "../../api/catalog-databases";
|
||||
import { statusLabel } from "./model";
|
||||
import { databaseSyncItemClass, databaseSyncScopes } from "./DatabaseSyncMenu";
|
||||
import { FleetActionSelector, type FleetActionOption } from "./FleetActionSelector";
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
@@ -31,6 +32,7 @@ interface DatabaseGridProps {
|
||||
searchInputRef: RefObject<HTMLInputElement>;
|
||||
onSearchChange: (value: string) => void;
|
||||
onView: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
|
||||
onOpenTables?: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
|
||||
onEdit: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
|
||||
onDelete: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
|
||||
onOpenSync: (row: CatalogDatabase) => void;
|
||||
@@ -47,16 +49,33 @@ interface DatabaseGridProps {
|
||||
rows: CatalogDatabase[],
|
||||
target: CatalogDatabaseMetadataDeleteTarget,
|
||||
) => Promise<void>;
|
||||
metadataModelControl?: ReactNode;
|
||||
onRefresh?: () => void | Promise<void>;
|
||||
presentation?: "legacy" | "fleet";
|
||||
}
|
||||
|
||||
interface DatabaseGridContext {
|
||||
canManage: boolean;
|
||||
presentation: "legacy" | "fleet";
|
||||
onView: DatabaseGridProps["onView"];
|
||||
onOpenTables?: DatabaseGridProps["onOpenTables"];
|
||||
onEdit: DatabaseGridProps["onEdit"];
|
||||
onDelete: DatabaseGridProps["onDelete"];
|
||||
onOpenSync: DatabaseGridProps["onOpenSync"];
|
||||
}
|
||||
|
||||
type DatabaseFleetAction =
|
||||
| "test"
|
||||
| "sync-tables"
|
||||
| "sync-columns"
|
||||
| "sync-relationships"
|
||||
| "sync-all"
|
||||
| "generate-all"
|
||||
| "generate-missing"
|
||||
| "suggest-sensitive"
|
||||
| "clear-tables"
|
||||
| "clear-relationships";
|
||||
|
||||
function useCompactViewport(): boolean {
|
||||
const [compact, setCompact] = useState(false);
|
||||
|
||||
@@ -100,22 +119,38 @@ function DatabaseActionsCell({
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-end gap-0.5" onClick={(event) => event.stopPropagation()}>
|
||||
{data.activeSyncRun ? (
|
||||
<Button type="button" variant="ghost" size="icon-lg" title={`View synchronization for ${data.workspaceName}`} aria-label={`View synchronization for ${data.workspaceName}`} onClick={() => context.onOpenSync(data)}>
|
||||
<RefreshCw className="animate-spin" aria-hidden="true" />
|
||||
{data.activeSyncRun || (context.presentation === "fleet" && data.configured && data.id) ? (
|
||||
<Button type="button" variant="ghost" size="icon-lg" className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? "Synchronization history" : undefined} title={context.presentation === "legacy" ? `View synchronization for ${data.workspaceName}` : undefined} aria-label={`View synchronization history for ${data.workspaceName}`} onClick={() => context.onOpenSync(data)}>
|
||||
{data.activeSyncRun ? <RefreshCw className="animate-spin" aria-hidden="true" /> : <History aria-hidden="true" />}
|
||||
</Button>
|
||||
) : null}
|
||||
{context.presentation === "fleet" && context.onOpenTables ? (
|
||||
<span className="thot-fleet-icon-action inline-flex" data-tooltip="Tables">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
aria-label={`View tables for ${data.workspaceName}`}
|
||||
disabled={!data.configured || !data.id}
|
||||
onClick={(event) => context.onOpenTables?.(data, event.currentTarget)}
|
||||
>
|
||||
<Rows3 aria-hidden="true" />
|
||||
</Button>
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
title={`View ${data.workspaceName}`}
|
||||
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)}
|
||||
>
|
||||
<Eye aria-hidden="true" />
|
||||
{context.presentation === "fleet" ? <Info aria-hidden="true" /> : <Eye aria-hidden="true" />}
|
||||
</Button>
|
||||
<span title={editDisabled ? "This database configuration cannot be changed" : editLabel}>
|
||||
<span className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? "Edit configuration" : undefined} title={context.presentation === "legacy" ? (editDisabled ? "This database configuration cannot be changed" : editLabel) : undefined}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -129,7 +164,7 @@ function DatabaseActionsCell({
|
||||
</Button>
|
||||
{editDisabled ? <span id={editReasonId} className="sr-only">This database configuration cannot be changed.</span> : null}
|
||||
</span>
|
||||
<span title={deleteDisabled ? "Save this database configuration before deleting it" : `Delete ${data.workspaceName}`}>
|
||||
<span className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? "Remove configuration" : undefined} title={context.presentation === "legacy" ? (deleteDisabled ? "Save this database configuration before deleting it" : `Delete ${data.workspaceName}`) : undefined}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -157,6 +192,7 @@ export function DatabaseGrid({
|
||||
searchInputRef,
|
||||
onSearchChange,
|
||||
onView,
|
||||
onOpenTables,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onOpenSync,
|
||||
@@ -167,17 +203,21 @@ export function DatabaseGrid({
|
||||
onGenerateDescriptions,
|
||||
onSuggestSensitive,
|
||||
onDeleteMetadataSelected,
|
||||
metadataModelControl,
|
||||
onRefresh,
|
||||
presentation = "legacy",
|
||||
}: DatabaseGridProps) {
|
||||
const compact = useCompactViewport();
|
||||
const gridRef = useRef<AgGridReact<CatalogDatabase>>(null);
|
||||
const actionsTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const actionSelectRef = useRef<HTMLSelectElement>(null);
|
||||
const [selectedRows, setSelectedRows] = useState<CatalogDatabase[]>([]);
|
||||
const [action, setAction] = useState<"test" | "sync" | "generate" | "suggest" | "delete" | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<CatalogDatabaseMetadataDeleteTarget | null>(null);
|
||||
const [pendingGenerateAll, setPendingGenerateAll] = useState(false);
|
||||
const context = useMemo<DatabaseGridContext>(
|
||||
() => ({ canManage, onView, onEdit, onDelete, onOpenSync }),
|
||||
[canManage, onView, onEdit, onDelete, onOpenSync],
|
||||
() => ({ canManage, presentation, onView, onOpenTables, onEdit, onDelete, onOpenSync }),
|
||||
[canManage, presentation, onView, onOpenTables, onEdit, onDelete, onOpenSync],
|
||||
);
|
||||
|
||||
const columnDefs = useMemo<ColDef<CatalogDatabase>[]>(() => {
|
||||
@@ -230,7 +270,7 @@ export function DatabaseGrid({
|
||||
flex: 0.9,
|
||||
valueFormatter: ({ value }) => value ? new Date(String(value)).toLocaleString() : "",
|
||||
};
|
||||
const actionsWidth = compact ? 148 : 164;
|
||||
const actionsWidth = presentation === "fleet" ? 196 : compact ? 148 : 164;
|
||||
const actions: ColDef<CatalogDatabase> = {
|
||||
colId: "actions",
|
||||
headerName: "Actions",
|
||||
@@ -251,7 +291,7 @@ export function DatabaseGrid({
|
||||
return compact
|
||||
? [workspace, status, database, schema, transport, endpoint, updated, actions]
|
||||
: [workspace, database, schema, transport, endpoint, status, updated, actions];
|
||||
}, [compact]);
|
||||
}, [compact, presentation]);
|
||||
|
||||
const configuredCount = rows.filter((row) => row.configured).length;
|
||||
const hiddenSelected = selectedRows.filter((row) => {
|
||||
@@ -265,8 +305,71 @@ export function DatabaseGrid({
|
||||
&& Boolean(selectedMetadataModel)
|
||||
&& !descriptionGenerationActive
|
||||
&& selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun);
|
||||
const canSuggestSensitive = canManage
|
||||
&& selectedRows.length === 1
|
||||
&& Boolean(selectedMetadataModel)
|
||||
&& !descriptionGenerationActive
|
||||
&& selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun);
|
||||
const canDeleteMetadataSelection = canManage && selectedRows.length > 0
|
||||
&& selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun);
|
||||
const fleetActions = useMemo<readonly FleetActionOption<DatabaseFleetAction>[]>(() => {
|
||||
const permissionReason = !canManage ? "Database management permission is required" : undefined;
|
||||
const testReason = permissionReason
|
||||
?? (selectedRows.some((row) => !row.configured || !row.id)
|
||||
? "Every selected workspace must be configured"
|
||||
: selectedRows.some((row) => row.activeSyncRun)
|
||||
? "Wait for active synchronization"
|
||||
: undefined);
|
||||
const syncReason = permissionReason
|
||||
?? (selectedRows.some((row) => !row.configured || !row.id)
|
||||
? "Every selected workspace must be configured"
|
||||
: selectedRows.some((row) => row.connectionStatus !== "reachable" || row.testedVersion !== row.version)
|
||||
? "Test every current binding before synchronization"
|
||||
: selectedRows.some((row) => row.activeSyncRun)
|
||||
? "Wait for active synchronization"
|
||||
: undefined);
|
||||
const generationReason = permissionReason
|
||||
?? (selectedRows.length !== 1 ? "Select exactly one database" : undefined)
|
||||
?? (!selectedMetadataModel ? "Choose a metadata model in the generation flow" : 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)
|
||||
?? (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"
|
||||
: undefined);
|
||||
const cleanupReason = permissionReason
|
||||
?? (selectedRows.some((row) => !row.configured || !row.id || row.activeSyncRun)
|
||||
? "Every selected database must be configured and idle"
|
||||
: undefined);
|
||||
|
||||
return [
|
||||
{ id: "test", label: "Test connections", group: "Connection", disabled: !canTestSelection, disabledReason: testReason },
|
||||
{ id: "sync-tables", label: "Synchronize tables", group: "Synchronization", disabled: !canSyncSelection, disabledReason: syncReason },
|
||||
{ id: "sync-columns", label: "Synchronize all columns", group: "Synchronization", disabled: !canSyncSelection, disabledReason: syncReason },
|
||||
{ id: "sync-relationships", label: "Synchronize relationships", group: "Synchronization", disabled: !canSyncSelection, disabledReason: syncReason },
|
||||
{ id: "sync-all", label: "Synchronize all", group: "Synchronization", disabled: !canSyncSelection, disabledReason: syncReason },
|
||||
{ id: "generate-missing", label: "Generate missing descriptions", group: "Descriptions", disabled: !canGenerateDescriptions, disabledReason: generationReason },
|
||||
{ id: "generate-all", label: "Generate all descriptions", group: "Descriptions", disabled: !canGenerateDescriptions, disabledReason: generationReason, runLabel: "Review generation" },
|
||||
{ id: "suggest-sensitive", label: "Suggest sensitive fields", group: "Sensitive data", disabled: !canSuggestSensitive, disabledReason: sensitiveReason, runLabel: "Open review" },
|
||||
{ id: "clear-tables", label: "Clear catalog tables", group: "Cleanup", disabled: !canDeleteMetadataSelection, disabledReason: cleanupReason, tone: "destructive", runLabel: "Review cleanup" },
|
||||
{ id: "clear-relationships", label: "Clear catalog relationships", group: "Cleanup", disabled: !canDeleteMetadataSelection, disabledReason: cleanupReason, tone: "destructive", runLabel: "Review cleanup" },
|
||||
];
|
||||
}, [
|
||||
canDeleteMetadataSelection,
|
||||
canGenerateDescriptions,
|
||||
canManage,
|
||||
canSuggestSensitive,
|
||||
canSyncSelection,
|
||||
canTestSelection,
|
||||
descriptionGenerationActive,
|
||||
selectedMetadataModel,
|
||||
selectedRows,
|
||||
]);
|
||||
useEffect(() => {
|
||||
if (!pendingDelete) return;
|
||||
const timer = window.setTimeout(() => document.getElementById("database-cleanup-confirm-button")?.focus(), 0);
|
||||
@@ -279,11 +382,17 @@ export function DatabaseGrid({
|
||||
}, [pendingGenerateAll]);
|
||||
const closeDeleteConfirmation = () => {
|
||||
setPendingDelete(null);
|
||||
window.setTimeout(() => actionsTriggerRef.current?.focus(), 0);
|
||||
window.setTimeout(() => {
|
||||
if (presentation === "fleet") actionSelectRef.current?.focus();
|
||||
else actionsTriggerRef.current?.focus();
|
||||
}, 0);
|
||||
};
|
||||
const closeGenerateAllConfirmation = () => {
|
||||
setPendingGenerateAll(false);
|
||||
window.setTimeout(() => actionsTriggerRef.current?.focus(), 0);
|
||||
window.setTimeout(() => {
|
||||
if (presentation === "fleet") actionSelectRef.current?.focus();
|
||||
else actionsTriggerRef.current?.focus();
|
||||
}, 0);
|
||||
};
|
||||
const perform = async (
|
||||
kind: "test" | "sync" | "generate" | "suggest" | "delete",
|
||||
@@ -305,9 +414,43 @@ export function DatabaseGrid({
|
||||
} finally { setAction(null); }
|
||||
};
|
||||
|
||||
const runFleetAction = async (selectedAction: DatabaseFleetAction) => {
|
||||
if (selectedAction === "generate-all") {
|
||||
setPendingGenerateAll(true);
|
||||
return;
|
||||
}
|
||||
if (selectedAction === "clear-tables" || selectedAction === "clear-relationships") {
|
||||
setPendingDelete(selectedAction === "clear-tables" ? "tables" : "relationships");
|
||||
return;
|
||||
}
|
||||
if (selectedAction === "test") {
|
||||
await perform("test", () => onTestSelected(selectedRows));
|
||||
return;
|
||||
}
|
||||
if (selectedAction.startsWith("sync-")) {
|
||||
const scope = selectedAction.slice("sync-".length) as CatalogSyncScope;
|
||||
await perform("sync", () => onSyncSelected(selectedRows, scope));
|
||||
return;
|
||||
}
|
||||
if (selectedAction === "generate-missing") {
|
||||
await perform("generate", () => onGenerateDescriptions(selectedRows, "missing"));
|
||||
return;
|
||||
}
|
||||
await perform("suggest", () => onSuggestSensitive(selectedRows), false);
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Workspace databases" className="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="flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2">
|
||||
<section
|
||||
aria-label="Workspace databases"
|
||||
data-presentation={presentation}
|
||||
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"
|
||||
: "flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2"}
|
||||
>
|
||||
{selectedRows.length > 0 ? (
|
||||
pendingGenerateAll ? (
|
||||
<div
|
||||
@@ -352,8 +495,8 @@ export function DatabaseGrid({
|
||||
<div className="mr-auto min-w-56">
|
||||
<p id="database-cleanup-confirmation" className="text-sm font-semibold">
|
||||
{pendingDelete === "tables"
|
||||
? `Delete all catalog tables for ${selectedRows.length} database${selectedRows.length === 1 ? "" : "s"}?`
|
||||
: `Delete all relationships for ${selectedRows.length} database${selectedRows.length === 1 ? "" : "s"}?`}
|
||||
? `${presentation === "fleet" ? "Clear" : "Delete all"} catalog tables for ${selectedRows.length} database${selectedRows.length === 1 ? "" : "s"}?`
|
||||
: `${presentation === "fleet" ? "Clear catalog" : "Delete all"} relationships for ${selectedRows.length} database${selectedRows.length === 1 ? "" : "s"}?`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pendingDelete === "tables"
|
||||
@@ -369,9 +512,33 @@ export function DatabaseGrid({
|
||||
disabled={action !== null}
|
||||
onClick={() => void perform("delete", () => onDeleteMetadataSelected(selectedRows, pendingDelete))}
|
||||
>
|
||||
<Trash2 />{pendingDelete === "tables" ? "Delete catalog tables" : "Delete relationships"}
|
||||
<Trash2 />{pendingDelete === "tables"
|
||||
? presentation === "fleet" ? "Clear catalog tables" : "Delete catalog tables"
|
||||
: presentation === "fleet" ? "Clear catalog relationships" : "Delete relationships"}
|
||||
</Button>
|
||||
</div>
|
||||
) : presentation === "fleet" ? (
|
||||
<>
|
||||
<FleetActionSelector
|
||||
actions={fleetActions}
|
||||
selectedCount={selectedRows.length}
|
||||
hiddenSelectedCount={hiddenSelected}
|
||||
busy={action !== null}
|
||||
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([]);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm font-semibold">{selectedRows.length} selected</span>
|
||||
@@ -449,7 +616,12 @@ export function DatabaseGrid({
|
||||
placeholder="Search"
|
||||
value={search}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
/></>}
|
||||
/>
|
||||
{presentation === "fleet" && onRefresh ? (
|
||||
<Button type="button" variant="outline" size="sm" disabled={isFetching} onClick={() => void onRefresh()}>
|
||||
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Refresh
|
||||
</Button>
|
||||
) : null}</>}
|
||||
<span className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">
|
||||
{configuredCount}/{rows.length}
|
||||
</span>
|
||||
@@ -458,7 +630,7 @@ export function DatabaseGrid({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="relative min-h-[280px] flex-1">
|
||||
<div className="thot-database-grid ag-theme-alpine absolute inset-0 h-full w-full">
|
||||
<div className={`thot-database-grid ag-theme-alpine absolute inset-0 h-full w-full${presentation === "fleet" ? " thot-fleet-ledger-grid" : ""}`}>
|
||||
<AgGridReact<CatalogDatabase>
|
||||
ref={gridRef}
|
||||
rowData={rows}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
import type { ColDef } from "ag-grid-community";
|
||||
import { ArrowLeft, RefreshCw } from "lucide-react";
|
||||
import type { ColDef, ICellRendererParams } from "ag-grid-community";
|
||||
import { ArrowLeft, History, Info, Play, RefreshCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { apiErrorMessage } from "../../api/client";
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type CatalogRelationship,
|
||||
type CatalogSyncRun,
|
||||
} from "../../api/catalog-databases";
|
||||
import { FleetLedgerDrawer } from "./FleetLedgerShell";
|
||||
|
||||
interface Props {
|
||||
database: CatalogDatabase;
|
||||
@@ -22,6 +23,32 @@ interface Props {
|
||||
onOpenTables: () => void;
|
||||
onRunStarted: (run: CatalogSyncRun) => void;
|
||||
onOpenSync: () => void;
|
||||
metadataModelControl?: ReactNode;
|
||||
presentation?: "legacy" | "fleet";
|
||||
}
|
||||
|
||||
interface RelationshipGridContext {
|
||||
presentation: "legacy" | "fleet";
|
||||
onView: (relationship: CatalogRelationship, origin: HTMLButtonElement) => void;
|
||||
}
|
||||
|
||||
function RelationshipActionsCell({ data, context }: ICellRendererParams<CatalogRelationship, unknown, RelationshipGridContext>) {
|
||||
if (!data || !context || context.presentation !== "fleet") return null;
|
||||
return (
|
||||
<div className="flex h-full items-center justify-end" onClick={(event) => event.stopPropagation()}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
className="thot-fleet-icon-action"
|
||||
data-tooltip="Details"
|
||||
aria-label={`View relationship ${data.constraintName}`}
|
||||
onClick={(event) => context.onView(data, event.currentTarget)}
|
||||
>
|
||||
<Info aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DatabaseRelationships({
|
||||
@@ -32,16 +59,32 @@ export function DatabaseRelationships({
|
||||
onOpenTables,
|
||||
onRunStarted,
|
||||
onOpenSync,
|
||||
presentation = "legacy",
|
||||
}: Props) {
|
||||
const databaseId = database.id!;
|
||||
const { data = [], isLoading, isFetching, refetch } = useQuery({
|
||||
const { data = [], error, isError, isLoading, isFetching, refetch } = useQuery({
|
||||
queryKey: ["catalog-relationships", databaseId],
|
||||
queryFn: () => listCatalogRelationships(databaseId),
|
||||
retry: false,
|
||||
});
|
||||
const [search, setSearch] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [selectedAction, setSelectedAction] = useState<"" | "relationships" | "all">("");
|
||||
const [activeRelationshipId, setActiveRelationshipId] = useState<string | null>(null);
|
||||
const originRef = useRef<HTMLButtonElement | null>(null);
|
||||
const bindingReady = database.connectionStatus === "reachable" && database.testedVersion === database.version;
|
||||
const activeRelationship = activeRelationshipId
|
||||
? data.find((relationship) => relationship.id === activeRelationshipId)
|
||||
: undefined;
|
||||
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."
|
||||
: null;
|
||||
|
||||
const synchronize = async (scope: "relationships" | "all") => {
|
||||
setBusy(true);
|
||||
@@ -52,6 +95,22 @@ export function DatabaseRelationships({
|
||||
} catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const openRelationship = (relationship: CatalogRelationship, origin: HTMLButtonElement) => {
|
||||
originRef.current = origin;
|
||||
setActiveRelationshipId(relationship.id);
|
||||
};
|
||||
|
||||
const closeRelationship = () => {
|
||||
setActiveRelationshipId(null);
|
||||
window.setTimeout(() => originRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
const runSelectedAction = async () => {
|
||||
if (!selectedAction) return;
|
||||
await synchronize(selectedAction);
|
||||
setSelectedAction("");
|
||||
};
|
||||
|
||||
const columns = useMemo<ColDef<CatalogRelationship>[]>(() => [
|
||||
{ field: "constraintName", headerName: "Constraint", minWidth: 220, flex: 1, cellClass: "font-mono text-xs" },
|
||||
{ field: "sourceTableName", headerName: "Source table", minWidth: 200, flex: 1 },
|
||||
@@ -60,11 +119,24 @@ export function DatabaseRelationships({
|
||||
{ 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 },
|
||||
], []);
|
||||
...(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]);
|
||||
const fleetQueryError = presentation === "fleet" && isError ? (
|
||||
<div className="grid min-h-[280px] flex-1 place-items-center p-6" role="alert">
|
||||
<div className="max-w-lg rounded-lg border border-destructive/30 bg-destructive/8 p-5 text-sm">
|
||||
<p className="font-semibold text-destructive">Catalog relationships could not be loaded.</p>
|
||||
<p className="mt-1 leading-5 text-muted-foreground">{apiErrorMessage(error)}</p>
|
||||
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refetch()}>
|
||||
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry relationships
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section aria-label={`Relationships for ${database.workspaceName}`} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
|
||||
<div className="px-4 pt-5 sm:px-5">
|
||||
<section aria-label={`Relationships for ${database.workspaceName}`} className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
|
||||
{presentation === "legacy" ? <div className="px-4 pt-5 sm:px-5">
|
||||
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={busy} onClick={onBackToDatabases}><ArrowLeft />Back to databases</Button>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5">
|
||||
<div>
|
||||
@@ -82,22 +154,76 @@ export function DatabaseRelationships({
|
||||
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={onOpenTables}>Tables</button>
|
||||
<button type="button" role="tab" aria-selected="true" className="border-b-2 border-primary px-3 py-2 text-sm font-semibold text-foreground">Relationships</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="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="flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2">
|
||||
</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>
|
||||
<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>
|
||||
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
|
||||
<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>
|
||||
{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>
|
||||
<span>Action scope</span>
|
||||
</div>
|
||||
<label className="sr-only" htmlFor="relationship-action">Relationship action</label>
|
||||
<select
|
||||
id="relationship-action"
|
||||
className="thot-fleet-action-selector__select"
|
||||
value={selectedAction}
|
||||
disabled={busy}
|
||||
onChange={(event) => setSelectedAction(event.target.value as "" | "relationships" | "all")}
|
||||
>
|
||||
<option value="">Choose an action…</option>
|
||||
<optgroup label="Synchronization">
|
||||
<option value="relationships">Synchronize all relationships in this database</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" 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>
|
||||
{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>}
|
||||
</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 relationships.</div> : null}
|
||||
<div className="relative min-h-[280px] flex-1">
|
||||
<div className="thot-database-grid ag-theme-alpine absolute inset-0 h-full w-full">
|
||||
<AgGridReact<CatalogRelationship> rowData={data} columnDefs={columns} 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." />
|
||||
{!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." />
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
{presentation === "fleet" && activeRelationship ? (
|
||||
<FleetLedgerDrawer
|
||||
open
|
||||
eyebrow="Physical relationship"
|
||||
title="Relationship details"
|
||||
description="This information is synchronized from the source schema and is read-only."
|
||||
onClose={closeRelationship}
|
||||
closeLabel="Close relationship details"
|
||||
footer={<Button type="button" variant="outline" onClick={closeRelationship}>Close</Button>}
|
||||
>
|
||||
<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">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>
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="thot-label">Column mapping</dt>
|
||||
<dd className="mt-2 overflow-hidden rounded-md border border-border">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-muted/60 font-mono uppercase tracking-wide text-muted-foreground"><tr><th className="px-3 py-2">Source</th><th className="px-3 py-2">Target</th></tr></thead>
|
||||
<tbody>{activeRelationship.columns.map((pair) => <tr key={`${pair.sourceColumnName}:${pair.targetColumnName}`} className="border-t border-border"><td className="px-3 py-2 font-mono">{pair.sourceColumnName}</td><td className="px-3 py-2 font-mono">{pair.targetColumnName}</td></tr>)}</tbody>
|
||||
</table>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</FleetLedgerDrawer>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { Menu } from "@base-ui/react/menu";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
import type { ColDef, ICellRendererParams } from "ag-grid-community";
|
||||
import { ArrowLeft, ChevronDown, Columns3, Pencil, RefreshCw, Save, Sparkles, Trash2, X } from "lucide-react";
|
||||
import { ArrowLeft, ChevronDown, Columns3, History, Pencil, RefreshCw, Save, Sparkles, Trash2, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { ApiError, apiErrorMessage } from "../../api/client";
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
} from "../../api/catalog-databases";
|
||||
import type { DatabaseNavigationState } from "./model";
|
||||
import { DatabaseColumns } from "./DatabaseColumns";
|
||||
import { FleetActionSelector, type FleetActionOption } from "./FleetActionSelector";
|
||||
import { FleetLedgerDrawer } from "./FleetLedgerShell";
|
||||
|
||||
interface Props {
|
||||
database: CatalogDatabase;
|
||||
@@ -34,27 +36,46 @@ interface Props {
|
||||
onOpenOverview: () => void;
|
||||
onOpenRelationships: () => void;
|
||||
onNavigationStateChange: (state: DatabaseNavigationState) => void;
|
||||
onNestedNavigationChange?: (active: boolean) => void;
|
||||
onRunStarted: (run: CatalogSyncRun) => void;
|
||||
onOpenSync: () => void;
|
||||
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
|
||||
onSuggestSensitive: (selection: SensitiveDataSuggestionRequest, scopeLabel: string) => Promise<void>;
|
||||
onCatalogMetricsChanged?: () => void | Promise<void>;
|
||||
metadataModelControl?: ReactNode;
|
||||
presentation?: "legacy" | "fleet";
|
||||
}
|
||||
|
||||
interface TableGridContext {
|
||||
canManage: boolean;
|
||||
presentation: "legacy" | "fleet";
|
||||
onOpen: (table: CatalogTable, section: "overview" | "columns", origin: HTMLButtonElement) => void;
|
||||
}
|
||||
|
||||
function TableActionsCell({ data, context }: ICellRendererParams<CatalogTable, unknown, TableGridContext>) {
|
||||
if (!data || !context) return null;
|
||||
return (
|
||||
<div className="flex h-full items-center justify-end" onClick={(event) => event.stopPropagation()}>
|
||||
<Button type="button" variant="ghost" size="icon-lg" aria-label={`View columns for ${data.name}`} title={`View columns for ${data.name}`} onClick={(event) => context.onOpen(data, "columns", event.currentTarget)}>
|
||||
<div className="flex h-full items-center justify-end gap-0.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" ? "Columns" : undefined}
|
||||
aria-label={`View columns for ${data.name}`}
|
||||
title={context.presentation === "legacy" ? `View columns for ${data.name}` : undefined}
|
||||
onClick={(event) => context.onOpen(data, "columns", event.currentTarget)}
|
||||
>
|
||||
<Columns3 aria-hidden="true" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon-lg" aria-label={`Edit description for ${data.name}`} title={`Edit metadata for ${data.name}`} disabled={!context.canManage} onClick={(event) => context.onOpen(data, "overview", event.currentTarget)}>
|
||||
<span
|
||||
className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined}
|
||||
data-tooltip={context.presentation === "fleet" ? "Edit metadata" : undefined}
|
||||
>
|
||||
<Button type="button" variant="ghost" size="icon-lg" aria-label={`Edit description for ${data.name}`} title={context.presentation === "legacy" ? `Edit metadata for ${data.name}` : undefined} disabled={!context.canManage} onClick={(event) => context.onOpen(data, "overview", event.currentTarget)}>
|
||||
<Pencil aria-hidden="true" />
|
||||
</Button>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -69,15 +90,19 @@ export function DatabaseTables({
|
||||
onOpenOverview,
|
||||
onOpenRelationships,
|
||||
onNavigationStateChange,
|
||||
onNestedNavigationChange,
|
||||
onRunStarted,
|
||||
onOpenSync,
|
||||
onDescriptionGenerationRunStarted,
|
||||
onSuggestSensitive,
|
||||
onCatalogMetricsChanged,
|
||||
metadataModelControl,
|
||||
presentation = "legacy",
|
||||
}: Props) {
|
||||
const databaseId = database.id!;
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = ["catalog-tables", databaseId] as const;
|
||||
const { data = [], isLoading, isFetching, refetch } = useQuery({ queryKey, queryFn: () => listCatalogTables(databaseId), retry: false });
|
||||
const { data = [], error, isError, isLoading, isFetching, refetch } = useQuery({ queryKey, queryFn: () => listCatalogTables(databaseId), retry: false });
|
||||
const [search, setSearch] = useState("");
|
||||
const [displayedCount, setDisplayedCount] = useState(0);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
@@ -96,16 +121,20 @@ export function DatabaseTables({
|
||||
const originRef = useRef<HTMLButtonElement | null>(null);
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
const actionsTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const fleetActionSelectRef = useRef<HTMLSelectElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const activeTable = activeTableId ? data.find((table) => table.id === activeTableId) : undefined;
|
||||
const editorFingerprint = JSON.stringify([description, generatedDescription]);
|
||||
const editorDirty = Boolean(activeTableId && editorFingerprint !== baseline);
|
||||
const dirty = tableSection === "columns" ? columnNavigation.dirty : editorDirty;
|
||||
const navigationBusy = busy !== null || (tableSection === "columns" && columnNavigation.busy);
|
||||
const nestedNavigationActive = presentation === "fleet" && Boolean(activeTable) && tableSection === "columns";
|
||||
const bindingReady = database.connectionStatus === "reachable" && database.testedVersion === database.version;
|
||||
const currentRun = activeRun ?? database.activeSyncRun;
|
||||
|
||||
useEffect(() => { onNavigationStateChange({ dirty, busy: navigationBusy }); }, [dirty, navigationBusy, onNavigationStateChange]);
|
||||
useEffect(() => { onNestedNavigationChange?.(nestedNavigationActive); }, [nestedNavigationActive, onNestedNavigationChange]);
|
||||
useEffect(() => () => { onNestedNavigationChange?.(false); }, [onNestedNavigationChange]);
|
||||
useEffect(() => {
|
||||
if (!activeTableId || !activeTable || editorVersion === activeTable.version || busy) return;
|
||||
setStale(true);
|
||||
@@ -118,7 +147,10 @@ export function DatabaseTables({
|
||||
}, [pendingDelete]);
|
||||
const closeDeleteConfirmation = () => {
|
||||
setPendingDelete(null);
|
||||
window.setTimeout(() => actionsTriggerRef.current?.focus(), 0);
|
||||
window.setTimeout(() => {
|
||||
if (presentation === "fleet") fleetActionSelectRef.current?.focus();
|
||||
else actionsTriggerRef.current?.focus();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const openTable = (table: CatalogTable, section: "overview" | "columns", origin: HTMLButtonElement) => {
|
||||
@@ -161,6 +193,7 @@ export function DatabaseTables({
|
||||
setBaseline(JSON.stringify([updated.description ?? "", updated.generatedDescription ?? ""]));
|
||||
setEditorVersion(updated.version);
|
||||
setStale(false);
|
||||
void onCatalogMetricsChanged?.();
|
||||
toast.success("Table metadata saved");
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.code === "table_stale") {
|
||||
@@ -208,9 +241,10 @@ export function DatabaseTables({
|
||||
setSelectedIds([]);
|
||||
setPendingDelete(null);
|
||||
window.setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||
void onCatalogMetricsChanged?.();
|
||||
toast.success(target === "columns"
|
||||
? `Deleted ${counts.columns} catalog columns`
|
||||
: `Deleted ${counts.relationships} catalog relationships`);
|
||||
? `${presentation === "fleet" ? "Cleared" : "Deleted"} ${counts.columns} catalog columns`
|
||||
: `${presentation === "fleet" ? "Cleared" : "Deleted"} ${counts.relationships} catalog relationships`);
|
||||
} catch (error) {
|
||||
toast.error(apiErrorMessage(error));
|
||||
} finally { setBusy(null); }
|
||||
@@ -222,6 +256,7 @@ export function DatabaseTables({
|
||||
await queryClient.invalidateQueries({ queryKey, exact: true });
|
||||
gridRef.current?.api.deselectAll();
|
||||
setSelectedIds([]);
|
||||
void onCatalogMetricsChanged?.();
|
||||
toast.success(`Copied ${counts.copied} description${counts.copied === 1 ? "" : "s"}; skipped ${counts.skipped}`);
|
||||
} catch (error) {
|
||||
toast.error(apiErrorMessage(error));
|
||||
@@ -258,14 +293,152 @@ export function DatabaseTables({
|
||||
}
|
||||
};
|
||||
|
||||
type FleetTableAction =
|
||||
| "generate-descriptions"
|
||||
| "sync-tables"
|
||||
| "sync-columns"
|
||||
| "consolidate-descriptions"
|
||||
| "suggest-sensitive"
|
||||
| "clear-columns"
|
||||
| "clear-relationships";
|
||||
|
||||
const fleetActions: readonly FleetActionOption<FleetTableAction>[] = [
|
||||
{
|
||||
id: "generate-descriptions",
|
||||
label: "Generate descriptions",
|
||||
group: "Descriptions",
|
||||
runLabel: "Generate",
|
||||
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun),
|
||||
disabledReason: !canManage
|
||||
? "You do not have permission to generate descriptions."
|
||||
: selectedIds.length === 0
|
||||
? "Select at least one table."
|
||||
: !selectedMetadataModel
|
||||
? "Choose a metadata model first."
|
||||
: descriptionGenerationActive || Boolean(currentRun)
|
||||
? "Wait for the active catalog operation to finish."
|
||||
: busy !== null
|
||||
? "Another action is running."
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: "consolidate-descriptions",
|
||||
label: "Move generated to Description",
|
||||
group: "Descriptions",
|
||||
runLabel: "Move",
|
||||
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
|
||||
disabledReason: !canManage
|
||||
? "You do not have permission to update metadata."
|
||||
: selectedIds.length === 0
|
||||
? "Select at least one table."
|
||||
: busy !== null || Boolean(currentRun)
|
||||
? "Wait for the active catalog operation to finish."
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: "sync-tables",
|
||||
label: "Synchronize tables",
|
||||
group: "Synchronization",
|
||||
scopeLabel: "All tables in this database",
|
||||
runLabel: "Synchronize",
|
||||
disabled: !canManage || !bindingReady || busy !== null || Boolean(currentRun),
|
||||
disabledReason: !canManage
|
||||
? "You do not have permission to synchronize the catalog."
|
||||
: !bindingReady
|
||||
? "Test the current database binding first."
|
||||
: busy !== null || Boolean(currentRun)
|
||||
? "Wait for the active catalog operation to finish."
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: "sync-columns",
|
||||
label: "Synchronize columns",
|
||||
group: "Synchronization",
|
||||
runLabel: "Synchronize",
|
||||
disabled: !canManage || selectedIds.length === 0 || !bindingReady || busy !== null || Boolean(currentRun),
|
||||
disabledReason: !canManage
|
||||
? "You do not have permission to synchronize the catalog."
|
||||
: selectedIds.length === 0
|
||||
? "Select at least one table."
|
||||
: !bindingReady
|
||||
? "Test the current database binding first."
|
||||
: busy !== null || Boolean(currentRun)
|
||||
? "Wait for the active catalog operation to finish."
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: "suggest-sensitive",
|
||||
label: "Suggest sensitive fields",
|
||||
group: "Sensitive data",
|
||||
runLabel: "Suggest",
|
||||
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun),
|
||||
disabledReason: !canManage
|
||||
? "You do not have permission to review sensitive data."
|
||||
: selectedIds.length === 0
|
||||
? "Select at least one table."
|
||||
: !selectedMetadataModel
|
||||
? "Choose a metadata model first."
|
||||
: descriptionGenerationActive || Boolean(currentRun)
|
||||
? "Wait for the active catalog operation to finish."
|
||||
: busy !== null
|
||||
? "Another action is running."
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: "clear-columns",
|
||||
label: "Clear catalog columns",
|
||||
group: "Catalog cleanup",
|
||||
tone: "destructive",
|
||||
runLabel: "Review cleanup",
|
||||
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
|
||||
disabledReason: !canManage
|
||||
? "You do not have permission to clear catalog metadata."
|
||||
: selectedIds.length === 0
|
||||
? "Select at least one table."
|
||||
: busy !== null || Boolean(currentRun)
|
||||
? "Wait for the active catalog operation to finish."
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: "clear-relationships",
|
||||
label: "Clear catalog relationships",
|
||||
group: "Catalog cleanup",
|
||||
tone: "destructive",
|
||||
runLabel: "Review cleanup",
|
||||
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
|
||||
disabledReason: !canManage
|
||||
? "You do not have permission to clear catalog metadata."
|
||||
: selectedIds.length === 0
|
||||
? "Select at least one table."
|
||||
: busy !== null || Boolean(currentRun)
|
||||
? "Wait for the active catalog operation to finish."
|
||||
: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const runFleetAction = async (action: FleetTableAction) => {
|
||||
if (action === "generate-descriptions") await generateDescriptions();
|
||||
else if (action === "sync-tables") await synchronize("tables");
|
||||
else if (action === "consolidate-descriptions") await consolidateDescriptions();
|
||||
else if (action === "sync-columns") await synchronize("columns", selectedIds);
|
||||
else if (action === "suggest-sensitive") await suggestSensitive();
|
||||
else if (action === "clear-columns") setPendingDelete("columns");
|
||||
else setPendingDelete("relationships");
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
gridRef.current?.api.deselectAll();
|
||||
setSelectedIds([]);
|
||||
};
|
||||
|
||||
const columns = useMemo<ColDef<CatalogTable>[]>(() => [
|
||||
{ field: "name", headerName: "Name", minWidth: 250, flex: 1, cellClass: "font-mono text-xs" },
|
||||
{ field: "sourceComment", headerName: "Source comment", minWidth: 260, flex: 1.25, valueFormatter: ({ value }) => value ?? "" },
|
||||
{ field: "generatedDescription", headerName: "Generated description", minWidth: 280, flex: 1.25, valueFormatter: ({ value }) => value ?? "" },
|
||||
{ field: "description", headerName: "Description", minWidth: 280, flex: 1.25, valueFormatter: ({ value }) => value ?? "" },
|
||||
{ colId: "actions", headerName: "", width: 104, minWidth: 104, maxWidth: 104, pinned: "right", sortable: false, filter: false, resizable: false, cellRenderer: TableActionsCell },
|
||||
{ colId: "actions", headerName: "Actions", width: 104, minWidth: 104, maxWidth: 104, pinned: "right", lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: TableActionsCell },
|
||||
], []);
|
||||
const context = useMemo<TableGridContext>(() => ({ canManage, onOpen: openTable }), [canManage, data]);
|
||||
const context = useMemo<TableGridContext>(() => ({ canManage, presentation, onOpen: openTable }), [canManage, data, presentation]);
|
||||
const visibleSelected = useMemo(() => {
|
||||
const term = search.trim().toLocaleLowerCase();
|
||||
if (!term) return selectedIds.length;
|
||||
@@ -273,6 +446,17 @@ export function DatabaseTables({
|
||||
return data.filter((table) => selected.has(table.id) && [table.name, table.sourceComment, table.generatedDescription, table.description]
|
||||
.some((value) => value?.toLocaleLowerCase().includes(term))).length;
|
||||
}, [data, search, selectedIds]);
|
||||
const fleetQueryError = presentation === "fleet" && isError ? (
|
||||
<div className="grid min-h-[280px] flex-1 place-items-center p-6" role="alert">
|
||||
<div className="max-w-lg rounded-lg border border-destructive/30 bg-destructive/8 p-5 text-sm">
|
||||
<p className="font-semibold text-destructive">Catalog tables could not be loaded.</p>
|
||||
<p className="mt-1 leading-5 text-muted-foreground">{apiErrorMessage(error)}</p>
|
||||
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refetch()}>
|
||||
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry tables
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const databaseHeader = (
|
||||
<div className="px-4 pt-5 sm:px-5">
|
||||
@@ -296,6 +480,126 @@ export function DatabaseTables({
|
||||
</div>
|
||||
);
|
||||
|
||||
if (activeTable && presentation === "fleet") {
|
||||
if (tableSection === "columns") {
|
||||
return (
|
||||
<section aria-label={`Columns for ${activeTable.name}`} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
|
||||
<div className="flex min-h-12 flex-wrap items-center gap-3 border-b border-border bg-muted/25 px-3 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="thot-fleet-icon-action border-primary/35 bg-primary/8 shadow-sm"
|
||||
disabled={navigationBusy}
|
||||
data-tooltip="Tables"
|
||||
aria-label="Back to tables"
|
||||
onClick={leaveTable}
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" /> Back to tables
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<p className="thot-label">Physical table</p>
|
||||
<h3 className="truncate font-heading text-lg font-semibold">{activeTable.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<DatabaseColumns
|
||||
databaseId={databaseId}
|
||||
table={activeTable}
|
||||
canManage={canManage}
|
||||
selectedMetadataModel={selectedMetadataModel}
|
||||
descriptionGenerationActive={descriptionGenerationActive}
|
||||
onDescriptionGenerationRunStarted={onDescriptionGenerationRunStarted}
|
||||
onNavigationStateChange={setColumnNavigation}
|
||||
onSync={() => void synchronize("columns", [activeTable.id])}
|
||||
onSuggestSensitive={onSuggestSensitive}
|
||||
bindingReady={bindingReady}
|
||||
catalogOperationActive={Boolean(currentRun)}
|
||||
onCatalogMetricsChanged={onCatalogMetricsChanged}
|
||||
metadataModelControl={metadataModelControl}
|
||||
presentation="fleet"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label={`Tables for ${database.workspaceName}`} className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
|
||||
<div className="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">Catalog tables</span>
|
||||
<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)}
|
||||
/>
|
||||
<span className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">
|
||||
{search.trim() ? `${displayedCount} of ${data.length}` : displayedCount}
|
||||
</span>
|
||||
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}>
|
||||
<RefreshCw className={isFetching ? "animate-spin" : ""} /> Refresh
|
||||
</Button>
|
||||
</div>
|
||||
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
|
||||
<div className="thot-database-grid thot-fleet-ledger-grid ag-theme-alpine absolute inset-0 h-full w-full">
|
||||
<AgGridReact<CatalogTable>
|
||||
ref={gridRef}
|
||||
rowData={data}
|
||||
columnDefs={columns}
|
||||
context={context}
|
||||
loading={isLoading}
|
||||
quickFilterText={search}
|
||||
defaultColDef={{ sortable: true, filter: true, resizable: true }}
|
||||
getRowId={({ data: table }) => table.id}
|
||||
rowSelection={{ mode: "multiRow", selectAll: "filtered", enableClickSelection: false }}
|
||||
onSelectionChanged={({ api }) => setSelectedIds(api.getSelectedRows().map((table) => table.id))}
|
||||
onModelUpdated={({ api }) => setDisplayedCount(api.getDisplayedRowCount())}
|
||||
rowHeight={44}
|
||||
headerHeight={38}
|
||||
animateRows={false}
|
||||
overlayNoRowsTemplate="No catalog tables. Test the binding, then sync tables."
|
||||
/>
|
||||
</div>
|
||||
</div>}
|
||||
<FleetLedgerDrawer
|
||||
open
|
||||
eyebrow="Catalog metadata"
|
||||
title="Review table description"
|
||||
description="Physical schema fields are read-only. Review fields can be edited."
|
||||
onClose={leaveTable}
|
||||
closeLabel="Close table metadata"
|
||||
busy={busy !== null}
|
||||
footer={(
|
||||
<>
|
||||
<Button type="button" variant="outline" disabled={busy !== null} onClick={leaveTable}>Cancel</Button>
|
||||
<Button type="button" disabled={!canManage || !editorDirty || busy !== null || stale} onClick={() => void save()}>
|
||||
<Save aria-hidden="true" />{busy === "save" ? "Saving…" : "Save metadata"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<p className="thot-label">Physical table</p>
|
||||
<h3 className="mt-1 break-words font-heading text-xl font-semibold">{activeTable.name}</h3>
|
||||
{stale ? (
|
||||
staleBannerOpen ? (
|
||||
<div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm">
|
||||
<p className="font-semibold">A newer table description is available.</p>
|
||||
<p className="mt-1 text-muted-foreground">Your draft is preserved until you reload the current metadata.</p>
|
||||
<div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadTable()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>Keep editing</Button></div>
|
||||
</div>
|
||||
) : <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>Reload the latest value before this description can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>Reload latest</Button></div>
|
||||
) : null}
|
||||
<div className="mt-5 grid gap-4">
|
||||
<label className="grid gap-1.5 text-sm font-semibold">Physical table name<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={activeTable.name} readOnly /></label>
|
||||
<label className="grid gap-1.5 text-sm font-semibold">Source comment<textarea className="min-h-24 rounded-md border border-input bg-muted/35 px-3 py-2 text-sm" value={activeTable.sourceComment ?? ""} readOnly /></label>
|
||||
<label className="grid gap-1.5 text-sm font-semibold">Generated description<textarea className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={generatedDescription} disabled={!canManage || busy !== null} onChange={(event) => setGeneratedDescription(event.target.value)} /></label>
|
||||
<label className="grid gap-1.5 text-sm font-semibold">Description<textarea className="min-h-36 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={description} disabled={!canManage || busy !== null} onChange={(event) => setDescription(event.target.value)} /><span className="font-normal text-muted-foreground">Leave blank to keep the curated description empty.</span></label>
|
||||
</div>
|
||||
</FleetLedgerDrawer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTable) {
|
||||
return (
|
||||
<section aria-label={`Table ${activeTable.name}`} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
|
||||
@@ -321,6 +625,7 @@ export function DatabaseTables({
|
||||
onNavigationStateChange={setColumnNavigation}
|
||||
onSync={() => void synchronize("columns", [activeTable.id])}
|
||||
onSuggestSensitive={onSuggestSensitive}
|
||||
onCatalogMetricsChanged={onCatalogMetricsChanged}
|
||||
/>
|
||||
) : (
|
||||
<div className="min-h-0 overflow-y-auto px-4 py-5">
|
||||
@@ -353,9 +658,9 @@ export function DatabaseTables({
|
||||
|
||||
return (
|
||||
<section aria-label={`Tables for ${database.workspaceName}`} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
|
||||
{databaseHeader}
|
||||
<div className="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="flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2">
|
||||
{presentation === "legacy" ? databaseHeader : 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`}>
|
||||
{selectedIds.length > 0 ? (
|
||||
pendingDelete ? (
|
||||
<div
|
||||
@@ -368,14 +673,20 @@ export function DatabaseTables({
|
||||
>
|
||||
<div className="mr-auto min-w-56">
|
||||
<p id="table-cleanup-confirmation" className="text-sm font-semibold">
|
||||
{pendingDelete === "columns"
|
||||
{presentation === "fleet"
|
||||
? `Clear catalog ${pendingDelete} for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`
|
||||
: pendingDelete === "columns"
|
||||
? `Delete all catalog columns for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`
|
||||
: `Delete all relationships for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pendingDelete === "columns"
|
||||
? "Only columns belonging to the selected catalog tables will be removed."
|
||||
: "All incoming and outgoing relationships for the selected catalog tables will be removed."}
|
||||
{presentation === "fleet"
|
||||
? pendingDelete === "columns"
|
||||
? "Only synchronized catalog columns for these tables will be cleared. The source database is not modified."
|
||||
: "Only synchronized catalog relationships for these tables will be cleared. The source database is not modified."
|
||||
: pendingDelete === "columns"
|
||||
? "Only columns belonging to the selected catalog tables will be removed."
|
||||
: "All incoming and outgoing relationships for the selected catalog tables will be removed."}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" disabled={busy !== null} onClick={closeDeleteConfirmation}>Cancel</Button>
|
||||
@@ -386,88 +697,86 @@ export function DatabaseTables({
|
||||
disabled={busy !== null}
|
||||
onClick={() => void deleteMetadata(pendingDelete)}
|
||||
>
|
||||
<Trash2 />{pendingDelete === "columns" ? "Delete columns" : "Delete relationships"}
|
||||
<Trash2 />{presentation === "fleet" ? "Clear catalog" : pendingDelete === "columns" ? "Delete columns" : "Delete relationships"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm font-semibold">{selectedIds.length} selected</span>
|
||||
{visibleSelected !== selectedIds.length ? <span className="text-xs text-muted-foreground">{selectedIds.length - visibleSelected} hidden by filter</span> : null}
|
||||
<Menu.Root>
|
||||
<Menu.Trigger ref={actionsTriggerRef} className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={busy !== null}>Actions <ChevronDown className="size-4" /></Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
|
||||
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
|
||||
<Menu.Item
|
||||
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
|
||||
disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)}
|
||||
onClick={() => void generateDescriptions()}
|
||||
>
|
||||
Generate {selectedIds.length === 1 ? "description" : "descriptions"}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
|
||||
disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)}
|
||||
onClick={() => void synchronize("columns", selectedIds)}
|
||||
>
|
||||
Synchronize columns
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
|
||||
disabled={!canManage || busy !== null || Boolean(currentRun)}
|
||||
onClick={() => void consolidateDescriptions()}
|
||||
>
|
||||
Move generated description to Description
|
||||
</Menu.Item>
|
||||
<Menu.Separator className="my-1 h-px bg-border" />
|
||||
<Menu.Item
|
||||
className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45"
|
||||
disabled={!canManage || busy !== null || Boolean(currentRun)}
|
||||
onClick={() => setPendingDelete("columns")}
|
||||
>
|
||||
Delete all columns
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45"
|
||||
disabled={!canManage || busy !== null || Boolean(currentRun)}
|
||||
onClick={() => setPendingDelete("relationships")}
|
||||
>
|
||||
Delete all relationships
|
||||
</Menu.Item>
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!canManage || descriptionGenerationActive || busy !== null}
|
||||
title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined}
|
||||
onClick={() => void suggestSensitive()}
|
||||
>
|
||||
<Sparkles />{busy === "suggest" ? "Suggesting…" : "Suggest sensitive fields"}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => { gridRef.current?.api.deselectAll(); setSelectedIds([]); }}><X />Clear</Button>
|
||||
</>
|
||||
presentation === "fleet" ? (
|
||||
<>
|
||||
<FleetActionSelector
|
||||
actions={fleetActions}
|
||||
selectedCount={selectedIds.length}
|
||||
hiddenSelectedCount={selectedIds.length - visibleSelected}
|
||||
busy={busy !== null}
|
||||
busyLabel={busy === "generate" ? "Generating…" : busy === "suggest" ? "Suggesting…" : "Running…"}
|
||||
selectRef={fleetActionSelectRef}
|
||||
onRun={runFleetAction}
|
||||
onClear={clearSelection}
|
||||
renderContext={(action) => action === "generate-descriptions" || action === "suggest-sensitive" ? metadataModelControl : null}
|
||||
label="Table action"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm font-semibold">{selectedIds.length} selected</span>
|
||||
{visibleSelected !== selectedIds.length ? <span className="text-xs text-muted-foreground">{selectedIds.length - visibleSelected} hidden by filter</span> : null}
|
||||
<Menu.Root>
|
||||
<Menu.Trigger ref={actionsTriggerRef} className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={busy !== null}>Actions <ChevronDown className="size-4" /></Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
|
||||
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateDescriptions()}>Generate {selectedIds.length === 1 ? "description" : "descriptions"}</Menu.Item>
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("columns", selectedIds)}>Synchronize columns</Menu.Item>
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void consolidateDescriptions()}>Move generated description to Description</Menu.Item>
|
||||
<Menu.Separator className="my-1 h-px bg-border" />
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => setPendingDelete("columns")}>Delete all columns</Menu.Item>
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => setPendingDelete("relationships")}>Delete all relationships</Menu.Item>
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
<Button type="button" variant="outline" disabled={!canManage || descriptionGenerationActive || busy !== null} title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined} onClick={() => void suggestSensitive()}><Sparkles />{busy === "suggest" ? "Suggesting…" : "Suggest sensitive fields"}</Button>
|
||||
<Button type="button" variant="ghost" onClick={clearSelection}><X />Clear</Button>
|
||||
</>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<span className="thot-label whitespace-nowrap">Catalog tables</span>
|
||||
<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)} />
|
||||
<span
|
||||
aria-live="polite"
|
||||
className="whitespace-nowrap text-xs tabular-nums text-muted-foreground"
|
||||
>
|
||||
{search.trim() ? `${displayedCount} of ${data.length}` : displayedCount}
|
||||
</span>
|
||||
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
|
||||
<Button type="button" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} title={!bindingReady ? "Test the current database binding before synchronizing tables" : undefined} onClick={() => void synchronize("tables")}><RefreshCw className={busy === "sync" ? "animate-spin" : ""} />Sync tables</Button>
|
||||
</>
|
||||
presentation === "fleet" ? (
|
||||
<>
|
||||
<FleetActionSelector
|
||||
actions={fleetActions}
|
||||
selectedCount={0}
|
||||
busy={busy !== null}
|
||||
busyLabel={busy === "generate" ? "Generating…" : busy === "suggest" ? "Suggesting…" : "Running…"}
|
||||
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)} />
|
||||
<span aria-live="polite" className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">{search.trim() ? `${displayedCount} of ${data.length}` : displayedCount}</span>
|
||||
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="thot-label whitespace-nowrap">Catalog tables</span>
|
||||
<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)} />
|
||||
<span aria-live="polite" className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">{search.trim() ? `${displayedCount} of ${data.length}` : displayedCount}</span>
|
||||
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
|
||||
<Button type="button" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} title={!bindingReady ? "Test the current database binding before synchronizing tables" : undefined} onClick={() => void synchronize("tables")}><RefreshCw className={busy === "sync" ? "animate-spin" : ""} />Sync tables</Button>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{presentation === "fleet" ? (
|
||||
<Button type="button" size="sm" variant="outline" className="thot-fleet-nav-action thot-fleet-nav-action--below" data-tooltip="Synchronization history" onClick={onOpenSync}>
|
||||
{currentRun ? <RefreshCw className="animate-spin" aria-hidden="true" /> : <History aria-hidden="true" />} Sync history
|
||||
</Button>
|
||||
) : null}
|
||||
</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}
|
||||
<div className="relative min-h-[280px] flex-1">
|
||||
<div className="thot-database-grid ag-theme-alpine absolute inset-0 h-full w-full">
|
||||
{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<CatalogTable>
|
||||
ref={gridRef}
|
||||
rowData={data}
|
||||
@@ -489,7 +798,7 @@ export function DatabaseTables({
|
||||
overlayNoRowsTemplate="No catalog tables. Test the binding, then sync tables."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -172,7 +172,7 @@ test("stops queued or running work without hiding earlier results or events", as
|
||||
);
|
||||
renderDrawer();
|
||||
|
||||
const drawer = await screen.findByRole("complementary", { name: "Description generation" });
|
||||
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
|
||||
expect(await within(drawer).findByText("First description remains saved")).toBeVisible();
|
||||
await user.click(within(drawer).getByRole("button", { name: "Stop" }));
|
||||
|
||||
@@ -243,7 +243,7 @@ test("reopens a terminal run from newest-first persisted history", async () => {
|
||||
name: /Completed.*selected tables/i,
|
||||
}));
|
||||
|
||||
const drawer = screen.getByRole("complementary", { name: "Description generation" });
|
||||
const drawer = screen.getByRole("dialog", { name: "Description generation" });
|
||||
expect(await within(drawer).findByRole("heading", { name: "Completed" })).toBeVisible();
|
||||
expect(within(drawer).getByText("3", { selector: "[data-count='generated']" })).toBeVisible();
|
||||
});
|
||||
@@ -269,7 +269,7 @@ test("offers Unlock only for an apparently stale active run and explains the int
|
||||
);
|
||||
renderDrawer({ initialRun: staleRun });
|
||||
|
||||
const drawer = await screen.findByRole("complementary", { name: "Description generation" });
|
||||
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
|
||||
expect(within(drawer).getByText(
|
||||
"Unlock interrupts apparently stale work and marks the run interrupted. A live worker will prevent it.",
|
||||
)).toBeVisible();
|
||||
@@ -286,7 +286,7 @@ test("does not offer Unlock while an active run still appears current", async ()
|
||||
);
|
||||
renderDrawer();
|
||||
|
||||
const drawer = await screen.findByRole("complementary", { name: "Description generation" });
|
||||
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
|
||||
expect(within(drawer).queryByRole("button", { name: "Unlock stale run" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -307,7 +307,7 @@ test("explains when Unlock is refused because a live worker still exists", async
|
||||
);
|
||||
renderDrawer({ initialRun: staleRun });
|
||||
|
||||
const drawer = await screen.findByRole("complementary", { name: "Description generation" });
|
||||
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
|
||||
await user.click(within(drawer).getByRole("button", { name: "Unlock stale run" }));
|
||||
|
||||
expect(await screen.findByText(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { LoaderCircle, LockOpen, Square, X } from "lucide-react";
|
||||
import { LoaderCircle, LockOpen, Square } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { ApiError, apiErrorMessage } from "../../api/client";
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type DescriptionGenerationRun,
|
||||
unlockDescriptionGenerationRun,
|
||||
} from "../../api/catalog-databases";
|
||||
import { FleetLedgerDrawer } from "./FleetLedgerShell";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -100,6 +101,11 @@ export function DescriptionGenerationDrawer({
|
||||
: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || initialRun || !historyQuery.data?.[0]) return;
|
||||
onRunUpdate(historyQuery.data[0]);
|
||||
}, [historyQuery.data, initialRun, onRunUpdate, open]);
|
||||
|
||||
const mergeEvents = useCallback((incoming: DescriptionGenerationEvent[]) => {
|
||||
if (incoming.length === 0) return;
|
||||
setEvents((current) => {
|
||||
@@ -256,28 +262,88 @@ export function DescriptionGenerationDrawer({
|
||||
}
|
||||
};
|
||||
|
||||
if (!open || !run) return null;
|
||||
if (!open) return null;
|
||||
if (!run) {
|
||||
return (
|
||||
<FleetLedgerDrawer
|
||||
open
|
||||
ariaLabel="Description generation"
|
||||
eyebrow="Description generation"
|
||||
title="Run history"
|
||||
onClose={onClose}
|
||||
closeLabel="Close description generation"
|
||||
busy={action !== null}
|
||||
bodyClassName="thot-catalog-drawer__body--empty"
|
||||
>
|
||||
{historyQuery.isError ? (
|
||||
<div role="alert" className="w-full rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{apiErrorMessage(historyQuery.error)}
|
||||
</div>
|
||||
) : historyQuery.isLoading || (historyQuery.data?.length ?? 0) > 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Loading run history…</p>
|
||||
) : (
|
||||
<div className="w-full rounded-md border border-border bg-muted/25 px-4 py-5 text-sm">
|
||||
<p className="font-semibold">No description generation runs yet.</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Start a description generation to create the first history entry.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</FleetLedgerDrawer>
|
||||
);
|
||||
}
|
||||
const apparentlyStale = isApparentlyStale(run);
|
||||
const finalError = safeFinalError(run.errorSummary);
|
||||
return (
|
||||
<aside
|
||||
aria-label="Description generation"
|
||||
className="fixed inset-y-2 right-0 z-50 flex w-full max-w-[400px] flex-col border-l border-border bg-background shadow-2xl sm:inset-y-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<p className="thot-label">Description generation</p>
|
||||
<h2 className="mt-1 font-heading text-xl font-semibold">{statusLabel(run)}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{modelLabel}{modelLabel !== run.modelId ? ` (${run.modelId})` : ""} · {run.scope.replaceAll("_", " ")}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon-lg" aria-label="Close description generation" onClick={onClose}>
|
||||
<X aria-hidden="true" />
|
||||
const footer = ["queued", "running"].includes(run.status) ? (
|
||||
<div className="w-full">
|
||||
{apparentlyStale ? (
|
||||
<p
|
||||
id="description-generation-unlock-help"
|
||||
className="mb-3 rounded-md border border-amber-500/35 bg-amber-500/8 px-3 py-2 text-xs leading-5 text-muted-foreground"
|
||||
>
|
||||
Unlock interrupts apparently stale work and marks the run interrupted. A live worker will prevent it.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{apparentlyStale ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-label="Unlock stale run"
|
||||
aria-describedby="description-generation-unlock-help"
|
||||
disabled={action !== null}
|
||||
onClick={() => void unlock()}
|
||||
>
|
||||
{action === "unlock" ? <LoaderCircle className="animate-spin" /> : <LockOpen />}
|
||||
{action === "unlock" ? "Unlocking…" : "Unlock"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={action !== null}
|
||||
onClick={() => void stop()}
|
||||
>
|
||||
{action === "stop" ? <LoaderCircle className="animate-spin" /> : <Square />}
|
||||
{action === "stop" ? "Stopping…" : "Stop"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||
{runQuery.isError ? (
|
||||
</div>
|
||||
) : undefined;
|
||||
return (
|
||||
<FleetLedgerDrawer
|
||||
open
|
||||
ariaLabel="Description generation"
|
||||
eyebrow="Description generation"
|
||||
title={statusLabel(run)}
|
||||
description={`${modelLabel}${modelLabel !== run.modelId ? ` (${run.modelId})` : ""} · ${run.scope.replaceAll("_", " ")}`}
|
||||
onClose={onClose}
|
||||
closeLabel="Close description generation"
|
||||
busy={action !== null}
|
||||
bodyClassName="thot-catalog-drawer__history-layout"
|
||||
footer={footer}
|
||||
>
|
||||
{runQuery.isError ? (
|
||||
<div role="alert" className="rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{apiErrorMessage(runQuery.error)}
|
||||
</div>
|
||||
@@ -336,7 +402,7 @@ export function DescriptionGenerationDrawer({
|
||||
{eventQuery.isError ? <p role="alert" className="mt-2 text-sm text-destructive">{apiErrorMessage(eventQuery.error)}</p> : null}
|
||||
</section>
|
||||
|
||||
<section className="mt-5" aria-label="Description generation history">
|
||||
<section className="thot-catalog-drawer__history mt-5" aria-label="Description generation history">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<h3 className="thot-label">Recent runs</h3>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
@@ -371,43 +437,6 @@ export function DescriptionGenerationDrawer({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
{["queued", "running"].includes(run.status) ? (
|
||||
<div className="border-t border-border px-5 py-4">
|
||||
{apparentlyStale ? (
|
||||
<p
|
||||
id="description-generation-unlock-help"
|
||||
className="mb-3 rounded-md border border-amber-500/35 bg-amber-500/8 px-3 py-2 text-xs leading-5 text-muted-foreground"
|
||||
>
|
||||
Unlock interrupts apparently stale work and marks the run interrupted. A live worker will prevent it.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{apparentlyStale ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-label="Unlock stale run"
|
||||
aria-describedby="description-generation-unlock-help"
|
||||
disabled={action !== null}
|
||||
onClick={() => void unlock()}
|
||||
>
|
||||
{action === "unlock" ? <LoaderCircle className="animate-spin" /> : <LockOpen />}
|
||||
{action === "unlock" ? "Unlocking…" : "Unlock"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={action !== null}
|
||||
onClick={() => void stop()}
|
||||
>
|
||||
{action === "stop" ? <LoaderCircle className="animate-spin" /> : <Square />}
|
||||
{action === "stop" ? "Stopping…" : "Stop"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
</FleetLedgerDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { FleetActionSelector } from "./FleetActionSelector";
|
||||
|
||||
const actions = [
|
||||
{ id: "sync", label: "Synchronize tables", group: "Synchronization", scopeLabel: "All tables in this database" },
|
||||
{
|
||||
id: "generate",
|
||||
label: "Generate descriptions",
|
||||
group: "Descriptions",
|
||||
disabled: true,
|
||||
disabledReason: "Choose a metadata model first",
|
||||
},
|
||||
] as const;
|
||||
|
||||
test("keeps unavailable commands discoverable and explains why Run is disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FleetActionSelector
|
||||
actions={actions}
|
||||
selectedCount={1}
|
||||
onRun={vi.fn()}
|
||||
onClear={vi.fn()}
|
||||
renderContext={(action) => action === "generate" ? <span>Model selection</span> : null}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByRole("combobox", { name: "Batch action" }), "generate");
|
||||
|
||||
expect(screen.getByText("Choose a metadata model first")).toBeVisible();
|
||||
expect(screen.getByText("Model selection")).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Run action" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("runs an enabled command explicitly and resets the picker", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRun = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
<FleetActionSelector
|
||||
actions={actions}
|
||||
selectedCount={2}
|
||||
onRun={onRun}
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const picker = screen.getByRole("combobox", { name: "Batch action" });
|
||||
await user.selectOptions(picker, "sync");
|
||||
expect(screen.getByText("All tables in this database")).toBeVisible();
|
||||
expect(screen.getByText("Action scope")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "Run action" }));
|
||||
|
||||
expect(onRun).toHaveBeenCalledWith("sync");
|
||||
expect(picker).toHaveValue("");
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { Play, X } from "lucide-react";
|
||||
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
export interface FleetActionOption<ActionId extends string = string> {
|
||||
id: ActionId;
|
||||
label: string;
|
||||
group: string;
|
||||
scopeLabel?: string;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
tone?: "default" | "destructive";
|
||||
runLabel?: string;
|
||||
}
|
||||
|
||||
interface FleetActionSelectorProps<ActionId extends string> {
|
||||
actions: readonly FleetActionOption<ActionId>[];
|
||||
selectedCount: number;
|
||||
hiddenSelectedCount?: number;
|
||||
busy?: boolean;
|
||||
busyLabel?: string;
|
||||
selectRef?: RefObject<HTMLSelectElement>;
|
||||
onRun: (action: ActionId) => void | Promise<void>;
|
||||
onClear: () => void;
|
||||
renderContext?: (action: ActionId | "") => ReactNode;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection-aware command picker shared by all Fleet Ledger grids. Eligibility
|
||||
* remains data owned by the calling grid; this module owns only presentation,
|
||||
* explicit execution and accessible unavailable reasons.
|
||||
*/
|
||||
export function FleetActionSelector<ActionId extends string>({
|
||||
actions,
|
||||
selectedCount,
|
||||
hiddenSelectedCount = 0,
|
||||
busy = false,
|
||||
busyLabel = "Running…",
|
||||
selectRef,
|
||||
onRun,
|
||||
onClear,
|
||||
renderContext,
|
||||
label = "Batch action",
|
||||
}: FleetActionSelectorProps<ActionId>) {
|
||||
const [selectedActionId, setSelectedActionId] = useState<ActionId | "">("");
|
||||
const hintId = useId();
|
||||
const groups = useMemo(() => {
|
||||
const order: string[] = [];
|
||||
const grouped = new Map<string, FleetActionOption<ActionId>[]>();
|
||||
for (const action of actions) {
|
||||
if (!grouped.has(action.group)) {
|
||||
order.push(action.group);
|
||||
grouped.set(action.group, []);
|
||||
}
|
||||
grouped.get(action.group)!.push(action);
|
||||
}
|
||||
return order.map((group) => ({ group, actions: grouped.get(group)! }));
|
||||
}, [actions]);
|
||||
const selectedAction = actions.find((action) => action.id === selectedActionId);
|
||||
const unavailableReason = selectedAction?.disabled
|
||||
? selectedAction.disabledReason ?? "This action is not available for the current selection."
|
||||
: null;
|
||||
const actionScope = selectedAction?.scopeLabel;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedActionId && !actions.some((action) => action.id === selectedActionId)) {
|
||||
setSelectedActionId("");
|
||||
}
|
||||
}, [actions, selectedActionId]);
|
||||
|
||||
const run = async () => {
|
||||
if (!selectedAction || selectedAction.disabled || busy) return;
|
||||
await onRun(selectedAction.id);
|
||||
setSelectedActionId("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="thot-fleet-action-selector">
|
||||
<div className="thot-fleet-action-selector__scope" aria-live="polite">
|
||||
<strong>{actionScope ?? `${selectedCount} selected`}</strong>
|
||||
{actionScope ? (
|
||||
<span>Action scope</span>
|
||||
) : hiddenSelectedCount > 0 ? (
|
||||
<span>{hiddenSelectedCount} hidden by filter</span>
|
||||
) : (
|
||||
<span>Selected rows</span>
|
||||
)}
|
||||
</div>
|
||||
<label className="sr-only" htmlFor={hintId}>{label}</label>
|
||||
<select
|
||||
ref={selectRef}
|
||||
id={hintId}
|
||||
className="thot-fleet-action-selector__select"
|
||||
aria-label={label}
|
||||
value={selectedActionId}
|
||||
disabled={busy}
|
||||
onChange={(event) => setSelectedActionId(event.target.value as ActionId | "")}
|
||||
>
|
||||
<option value="">Choose an action…</option>
|
||||
{groups.map(({ group, actions: groupActions }) => (
|
||||
<optgroup key={group} label={group}>
|
||||
{groupActions.map((action) => (
|
||||
<option key={action.id} value={action.id}>
|
||||
{action.label}
|
||||
{action.disabled && action.disabledReason
|
||||
? `, unavailable: ${action.disabledReason}`
|
||||
: ""}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
{renderContext ? renderContext(selectedActionId) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selectedAction?.tone === "destructive" ? "destructive" : "default"}
|
||||
disabled={!selectedAction || selectedAction.disabled || busy}
|
||||
aria-describedby={unavailableReason ? `${hintId}-reason` : undefined}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
<Play aria-hidden="true" />
|
||||
{busy ? busyLabel : selectedAction?.runLabel ?? "Run action"}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={onClear}>
|
||||
<X aria-hidden="true" /> Clear
|
||||
</Button>
|
||||
{unavailableReason ? (
|
||||
<span id={`${hintId}-reason`} className="thot-fleet-action-selector__reason" role="status">
|
||||
{unavailableReason}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,907 @@
|
||||
.thot-fleet-ledger {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
color: oklch(var(--foreground));
|
||||
background: oklch(var(--background));
|
||||
font-family: var(--font-sans);
|
||||
container-name: work-area fleet-surface;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__workspace {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background-color: oklch(var(--sidebar));
|
||||
container-name: fleet-workspace;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__header {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"heading actions"
|
||||
"status status";
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.75rem 1.5rem;
|
||||
align-items: center;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-bottom: 1px solid oklch(var(--border));
|
||||
background: oklch(var(--muted) / 0.42);
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__heading-copy {
|
||||
grid-area: heading;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__drawer-heading {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__section-label,
|
||||
.thot-fleet-ledger__status-label,
|
||||
.thot-fleet-ledger__kpi-label {
|
||||
margin: 0;
|
||||
color: oklch(var(--muted-foreground));
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1.35;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__title,
|
||||
.thot-fleet-ledger__drawer-title {
|
||||
margin: 0.25rem 0 0;
|
||||
color: oklch(var(--foreground));
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__title {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.0125em;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__drawer-description {
|
||||
max-width: 68ch;
|
||||
margin: 0.45rem 0 0;
|
||||
color: oklch(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__header-actions {
|
||||
grid-area: actions;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__header-status {
|
||||
grid-area: status;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__header-status > .thot-fleet-ledger__status {
|
||||
display: block;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__header-status .thot-fleet-ledger__section-label {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__header-status .thot-fleet-ledger__status-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.5rem 1.25rem;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__header-status .thot-fleet-ledger__status-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__status {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid oklch(var(--border));
|
||||
background: oklch(var(--muted) / 0.42);
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__status-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__status-item {
|
||||
--fleet-status-color: oklch(var(--muted-foreground));
|
||||
display: flex;
|
||||
min-width: min(100%, 10rem);
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__status-item[data-tone="success"] {
|
||||
--fleet-status-color: oklch(var(--success));
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__status-item[data-tone="warning"] {
|
||||
--fleet-status-color: oklch(var(--warning));
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__status-item[data-tone="danger"] {
|
||||
--fleet-status-color: oklch(var(--destructive));
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__status-item[data-tone="info"] {
|
||||
--fleet-status-color: oklch(var(--info));
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__status-mark {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
margin-top: 0.33rem;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
background: var(--fleet-status-color);
|
||||
box-shadow: 0 0 0 3px color-mix(in oklch, var(--fleet-status-color) 16%, transparent);
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__status-copy {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
gap: 0.05rem 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__status-label {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__status-value {
|
||||
overflow: hidden;
|
||||
color: oklch(var(--foreground));
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__status-detail {
|
||||
overflow: hidden;
|
||||
color: oklch(var(--muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__kpi-section {
|
||||
display: grid;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem 1.25rem 0;
|
||||
border-bottom: 1px solid oklch(var(--border));
|
||||
background: oklch(var(--card));
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__kpi-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 8.5rem), 1fr));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__kpi {
|
||||
min-width: 0;
|
||||
padding: 0 1rem 0.75rem 0;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__kpi + .thot-fleet-ledger__kpi {
|
||||
padding-left: 1rem;
|
||||
border-left: 1px solid oklch(var(--border));
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__kpi-value,
|
||||
.thot-fleet-ledger__kpi-detail {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__kpi-value {
|
||||
margin-top: 0.2rem;
|
||||
color: oklch(var(--foreground));
|
||||
font-family: var(--font-mono);
|
||||
font-size: clamp(1.25rem, 2vw, 1.625rem);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.035em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__kpi-detail {
|
||||
margin-top: 0.15rem;
|
||||
color: oklch(var(--muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__navigation {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 3rem;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid oklch(var(--border));
|
||||
background: oklch(var(--card));
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__navigation > button:first-child {
|
||||
min-height: 2.25rem;
|
||||
border: 1px solid oklch(var(--primary) / 0.28);
|
||||
color: oklch(var(--primary));
|
||||
background: oklch(var(--primary) / 0.08);
|
||||
box-shadow: 0 1px 2px oklch(var(--primary) / 0.1);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__navigation > button:first-child:not(:disabled):hover {
|
||||
border-color: oklch(var(--primary) / 0.48);
|
||||
color: oklch(var(--primary));
|
||||
background: oklch(var(--primary) / 0.14);
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__breadcrumb {
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__breadcrumb-list {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
align-items: center;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__breadcrumb-item {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__breadcrumb-separator {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
margin-inline: 0.2rem;
|
||||
flex: 0 0 auto;
|
||||
color: oklch(var(--muted-foreground) / 0.65);
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__breadcrumb-button {
|
||||
max-width: 13rem;
|
||||
overflow: hidden;
|
||||
padding: 0.2rem 0.25rem;
|
||||
border: 0;
|
||||
border-radius: calc(var(--radius) - 0.25rem);
|
||||
color: oklch(var(--muted-foreground));
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__breadcrumb-button:hover {
|
||||
color: oklch(var(--foreground));
|
||||
background: oklch(var(--muted));
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__breadcrumb-button:focus-visible {
|
||||
outline: 2px solid oklch(var(--ring));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__breadcrumb-label {
|
||||
max-width: 18rem;
|
||||
overflow: hidden;
|
||||
color: oklch(var(--foreground));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__navigation-trailing,
|
||||
.thot-fleet-ledger__command-trailing {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__command-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
min-height: 3.25rem;
|
||||
gap: 0.625rem;
|
||||
align-items: center;
|
||||
padding: 0.625rem 1rem;
|
||||
border-bottom: 1px solid oklch(var(--border));
|
||||
background: oklch(var(--muted) / 0.24);
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__command-picker {
|
||||
display: flex;
|
||||
min-width: min(100%, 12rem);
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.thot-fleet-action-selector {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.thot-fleet-action-selector__scope {
|
||||
display: grid;
|
||||
min-width: 7.5rem;
|
||||
color: oklch(var(--foreground));
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.thot-fleet-action-selector__scope span {
|
||||
color: oklch(var(--muted-foreground));
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.thot-fleet-action-selector__select {
|
||||
width: min(100%, 22rem);
|
||||
min-width: 13rem;
|
||||
height: 2rem;
|
||||
padding: 0 2rem 0 0.625rem;
|
||||
border: 1px solid oklch(var(--input));
|
||||
border-radius: var(--radius);
|
||||
color: oklch(var(--foreground));
|
||||
background: oklch(var(--background));
|
||||
font-size: 0.8125rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.thot-fleet-action-selector__select:focus-visible {
|
||||
border-color: oklch(var(--primary) / 0.65);
|
||||
box-shadow: 0 0 0 3px oklch(var(--ring) / 0.15);
|
||||
}
|
||||
|
||||
.thot-fleet-action-selector__select:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.thot-fleet-action-selector__reason {
|
||||
flex-basis: 100%;
|
||||
color: oklch(var(--muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
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;
|
||||
color: oklch(var(--muted-foreground));
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.025em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 18rem;
|
||||
margin-block-end: 30px;
|
||||
margin-inline-start: 20px;
|
||||
overflow: hidden;
|
||||
border-inline-end: 1px solid oklch(var(--border));
|
||||
background: oklch(var(--card));
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__drawer {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
inset-block: 0.75rem;
|
||||
inset-inline-start: 50%;
|
||||
inset-inline-end: auto;
|
||||
display: flex;
|
||||
width: 60%;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid oklch(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
color: oklch(var(--card-foreground));
|
||||
background: oklch(var(--card));
|
||||
box-shadow:
|
||||
0 28px 72px oklch(0.12 0.02 20 / 0.28),
|
||||
0 7px 20px oklch(0.12 0.02 20 / 0.16);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.thot-fleet-ledger .thot-fleet-ledger__context-panel {
|
||||
position: absolute;
|
||||
inset-block: 0.75rem;
|
||||
inset-inline-start: 50%;
|
||||
inset-inline-end: auto;
|
||||
width: 60%;
|
||||
max-width: none;
|
||||
min-width: 0;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.thot-fleet-drawer-form {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.thot-fleet-drawer-form__context {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid oklch(var(--border));
|
||||
}
|
||||
|
||||
.thot-catalog-drawer {
|
||||
isolation: isolate;
|
||||
background-color: oklch(var(--background));
|
||||
background-image: none;
|
||||
box-shadow:
|
||||
0 30px 84px oklch(0.12 0.02 20 / 0.3),
|
||||
0 8px 22px oklch(0.12 0.02 20 / 0.18);
|
||||
}
|
||||
|
||||
.thot-catalog-drawer__body--empty {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.thot-catalog-drawer__body--flush {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.thot-catalog-drawer__footer--split {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__drawer-header,
|
||||
.thot-catalog-drawer__header {
|
||||
color: oklch(var(--primary-foreground));
|
||||
background-color: oklch(var(--primary));
|
||||
background-image: none;
|
||||
border-color: oklch(var(--primary-foreground) / 0.18);
|
||||
box-shadow: inset 0 -1px 0 oklch(var(--primary-foreground) / 0.18);
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__drawer-header .thot-fleet-ledger__drawer-title,
|
||||
.thot-fleet-ledger__drawer-header .thot-fleet-ledger__drawer-description,
|
||||
.thot-fleet-ledger__drawer-header .thot-fleet-ledger__eyebrow,
|
||||
.thot-catalog-drawer__header .thot-label,
|
||||
.thot-catalog-drawer__header .text-muted-foreground {
|
||||
color: oklch(var(--primary-foreground));
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__drawer-header button,
|
||||
.thot-catalog-drawer__header button {
|
||||
border-color: oklch(var(--primary-foreground) / 0.22);
|
||||
color: inherit;
|
||||
background: oklch(var(--primary-foreground) / 0.1);
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__drawer-header button:hover,
|
||||
.thot-catalog-drawer__header button:hover {
|
||||
color: inherit;
|
||||
background: oklch(var(--primary-foreground) / 0.18);
|
||||
}
|
||||
|
||||
.thot-catalog-drawer__history button[aria-current="true"] {
|
||||
color: oklch(var(--foreground));
|
||||
background: oklch(var(--primary) / 0.09);
|
||||
box-shadow: inset 3px 0 0 oklch(var(--primary));
|
||||
}
|
||||
|
||||
.thot-fleet-grid-surface {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.thot-fleet-grid-toolbar {
|
||||
background: oklch(var(--muted) / 0.32);
|
||||
}
|
||||
|
||||
.thot-fleet-ledger-grid {
|
||||
--ag-header-background-color: oklch(var(--muted) / 0.84);
|
||||
--ag-odd-row-background-color: oklch(var(--card));
|
||||
--ag-row-hover-color: oklch(var(--primary) / 0.045);
|
||||
--ag-selected-row-background-color: oklch(var(--primary) / 0.085);
|
||||
}
|
||||
|
||||
.thot-fleet-ledger-grid .ag-header-cell-text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.055em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger-grid .ag-row {
|
||||
background: oklch(var(--card));
|
||||
}
|
||||
|
||||
.thot-fleet-ledger-grid .ag-row-even {
|
||||
background: oklch(var(--muted) / 0.12);
|
||||
}
|
||||
|
||||
.thot-fleet-icon-action {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.thot-fleet-nav-action {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.thot-fleet-icon-action::after {
|
||||
position: absolute;
|
||||
z-index: 80;
|
||||
inset-inline-end: calc(100% + 5px);
|
||||
inset-block-start: 50%;
|
||||
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);
|
||||
content: attr(data-tooltip);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
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);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.thot-fleet-nav-action::after {
|
||||
position: absolute;
|
||||
z-index: 90;
|
||||
inset-inline-start: 50%;
|
||||
inset-block-end: calc(100% + 5px);
|
||||
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);
|
||||
content: attr(data-tooltip);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 2px);
|
||||
transition: opacity 60ms cubic-bezier(0.22, 1, 0.36, 1), transform 60ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.thot-fleet-nav-action--below::after {
|
||||
inset-block: calc(100% + 5px) auto;
|
||||
transform: translate(-50%, -2px);
|
||||
}
|
||||
|
||||
.thot-fleet-icon-action:hover::after,
|
||||
.thot-fleet-icon-action:focus-visible::after,
|
||||
.thot-fleet-icon-action:focus-within::after {
|
||||
opacity: 1;
|
||||
transform: translate(0, -50%);
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.thot-fleet-nav-action:hover,
|
||||
.thot-fleet-nav-action:focus-visible {
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
.thot-fleet-nav-action:hover::after,
|
||||
.thot-fleet-nav-action:focus-visible::after {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger-grid .ag-row:has(.thot-fleet-icon-action:hover),
|
||||
.thot-fleet-ledger-grid .ag-row:has(.thot-fleet-icon-action:focus-visible),
|
||||
.thot-fleet-ledger-grid .ag-row:has(.thot-fleet-icon-action:focus-within),
|
||||
.thot-fleet-ledger-grid .ag-grid-pinned-right-cells:has(.thot-fleet-icon-action:hover),
|
||||
.thot-fleet-ledger-grid .ag-grid-pinned-right-cells:has(.thot-fleet-icon-action:focus-visible),
|
||||
.thot-fleet-ledger-grid .ag-grid-pinned-right-cells:has(.thot-fleet-icon-action:focus-within),
|
||||
.thot-fleet-ledger-grid .ag-cell:has(.thot-fleet-icon-action:hover),
|
||||
.thot-fleet-ledger-grid .ag-cell:has(.thot-fleet-icon-action:focus-visible),
|
||||
.thot-fleet-ledger-grid .ag-cell:has(.thot-fleet-icon-action:focus-within) {
|
||||
z-index: 8;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__drawer:focus-visible {
|
||||
outline: 2px solid oklch(var(--ring));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__drawer-header {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1rem 0.875rem 1.25rem;
|
||||
border-bottom: 1px solid oklch(var(--primary-foreground) / 0.18);
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__drawer-title {
|
||||
font-size: 1.35rem;
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__drawer-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 1.25rem;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__drawer-body.thot-catalog-drawer__body--empty {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__drawer-body.thot-catalog-drawer__body--flush {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__drawer-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0.875rem 1.25rem;
|
||||
border-top: 1px solid oklch(var(--border));
|
||||
background: oklch(var(--muted) / 0.32);
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__drawer-footer.thot-catalog-drawer__footer--split {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@media (min-width: 64rem) {
|
||||
.thot-fleet-ledger__header {
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__status {
|
||||
grid-template-columns: 9rem minmax(0, 1fr);
|
||||
align-items: start;
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__kpi-section {
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__navigation,
|
||||
.thot-fleet-ledger__command-bar {
|
||||
padding-inline: 1.25rem;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@container fleet-surface (max-width: 47.999rem) {
|
||||
.thot-fleet-ledger__drawer,
|
||||
.thot-fleet-ledger .thot-fleet-ledger__context-panel {
|
||||
inset-block: 0.5rem;
|
||||
width: calc(100% - 1rem);
|
||||
}
|
||||
}
|
||||
|
||||
@container fleet-workspace (min-width: 82rem) {
|
||||
.thot-fleet-ledger__header {
|
||||
grid-template-areas: "heading status actions";
|
||||
grid-template-columns: max-content minmax(0, 1fr) max-content;
|
||||
row-gap: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@container fleet-workspace (max-width: 47.999rem) {
|
||||
.thot-fleet-ledger__header {
|
||||
grid-template-areas:
|
||||
"heading"
|
||||
"actions"
|
||||
"status";
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__header-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__header-status .thot-fleet-ledger__status-list {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 40rem) {
|
||||
.thot-catalog-drawer__history-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 13.5rem;
|
||||
gap: 0 1rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.thot-catalog-drawer__history-layout > * {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.thot-catalog-drawer__history-layout > .thot-catalog-drawer__history {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 40;
|
||||
align-self: start;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 47.999rem) {
|
||||
.thot-fleet-ledger__workspace {
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__content {
|
||||
flex: 0 0 28rem;
|
||||
min-height: 28rem;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__kpi-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__kpi:nth-child(odd) {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__kpi:nth-child(n + 3) {
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid oklch(var(--border));
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__navigation {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__breadcrumb {
|
||||
order: 2;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__command-picker,
|
||||
.thot-fleet-ledger__selection-summary {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.thot-fleet-action-selector__select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 44rem) and (min-width: 48rem) {
|
||||
.thot-fleet-ledger__workspace {
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__status,
|
||||
.thot-fleet-ledger__kpi-strip {
|
||||
padding-block: 0.5rem;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__content {
|
||||
flex: 0 0 24rem;
|
||||
min-height: 24rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.thot-fleet-ledger__breadcrumb-button {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from "react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { FleetLedgerDrawer } from "./FleetLedgerShell";
|
||||
|
||||
function DrawerHarness() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}>Open details</button>
|
||||
<FleetLedgerDrawer
|
||||
open={open}
|
||||
eyebrow="Catalog configuration"
|
||||
title="Database details"
|
||||
description="Inspect and edit the selected database."
|
||||
onClose={() => setOpen(false)}
|
||||
footer={<button type="button">Save details</button>}
|
||||
>
|
||||
<p>Drawer content</p>
|
||||
</FleetLedgerDrawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
test("renders the shared catalog-panel regions and preserves modeless focus behavior", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DrawerHarness />);
|
||||
|
||||
const trigger = screen.getByRole("button", { name: "Open details" });
|
||||
await user.click(trigger);
|
||||
|
||||
const drawer = await screen.findByRole("dialog", { name: "Database details" });
|
||||
expect(drawer).toHaveAttribute("aria-modal", "false");
|
||||
expect(drawer).toHaveAttribute("data-catalog-panel");
|
||||
|
||||
const labelledBy = drawer.getAttribute("aria-labelledby");
|
||||
const describedBy = drawer.getAttribute("aria-describedby");
|
||||
expect(labelledBy).toBeTruthy();
|
||||
expect(describedBy).toBeTruthy();
|
||||
expect(document.getElementById(labelledBy!)).toHaveTextContent("Database details");
|
||||
expect(document.getElementById(describedBy!)).toHaveTextContent(
|
||||
"Inspect and edit the selected database.",
|
||||
);
|
||||
|
||||
const directRegions = (name: "header" | "body" | "footer") => Array.from(
|
||||
drawer.children,
|
||||
).filter((element): element is HTMLElement => (
|
||||
element instanceof HTMLElement
|
||||
&& element.getAttribute("data-catalog-panel-region") === name
|
||||
));
|
||||
const header = directRegions("header");
|
||||
const body = directRegions("body");
|
||||
const footer = directRegions("footer");
|
||||
|
||||
expect(header).toHaveLength(1);
|
||||
expect(body).toHaveLength(1);
|
||||
expect(footer).toHaveLength(1);
|
||||
expect(header[0]!).toContainElement(document.getElementById(labelledBy!));
|
||||
expect(header[0]!).toContainElement(document.getElementById(describedBy!));
|
||||
expect(body[0]!).toContainElement(screen.getByText("Drawer content"));
|
||||
expect(footer[0]!).toContainElement(screen.getByRole("button", { name: "Save details" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Close panel" })).toHaveFocus());
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
expect(screen.queryByRole("dialog", { name: "Database details" })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
import { forwardRef, useId, useState, type ReactNode } from "react"
|
||||
import { ArrowLeft, ChevronRight } from "lucide-react"
|
||||
|
||||
import { Button } from "../../components/ui/button"
|
||||
import {
|
||||
WorkAreaPanel,
|
||||
WorkAreaPanelHostProvider,
|
||||
type WorkAreaPanelProps,
|
||||
} from "../WorkAreaPanel"
|
||||
|
||||
import "./FleetLedgerShell.css"
|
||||
|
||||
export type FleetLedgerStatusTone =
|
||||
| "neutral"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "danger"
|
||||
| "info"
|
||||
|
||||
export interface FleetLedgerStatusItem {
|
||||
id: string
|
||||
label: ReactNode
|
||||
value: ReactNode
|
||||
detail?: ReactNode
|
||||
tone?: FleetLedgerStatusTone
|
||||
}
|
||||
|
||||
export interface FleetLedgerKpi {
|
||||
id: string
|
||||
label: ReactNode
|
||||
value: ReactNode
|
||||
detail?: ReactNode
|
||||
}
|
||||
|
||||
export interface FleetLedgerBreadcrumbItem {
|
||||
id: string
|
||||
label: ReactNode
|
||||
current?: boolean
|
||||
onSelect?: () => void
|
||||
}
|
||||
|
||||
export interface FleetLedgerBackAction {
|
||||
label: string
|
||||
onBack: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface WithClassName {
|
||||
className?: string
|
||||
}
|
||||
|
||||
function classes(base: string, className?: string) {
|
||||
return className ? `${base} ${className}` : base
|
||||
}
|
||||
|
||||
export interface FleetLedgerShellProps extends WithClassName {
|
||||
ariaLabel?: string
|
||||
header: ReactNode
|
||||
status?: ReactNode
|
||||
kpis?: ReactNode
|
||||
navigation?: ReactNode
|
||||
commands?: ReactNode
|
||||
children: ReactNode
|
||||
drawer?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Presentational frame for a single fleet-inventory surface. Data fetching,
|
||||
* navigation state and mutations stay with the caller.
|
||||
*/
|
||||
export function FleetLedgerShell({
|
||||
ariaLabel = "Database fleet inventory",
|
||||
header,
|
||||
status,
|
||||
kpis,
|
||||
navigation,
|
||||
commands,
|
||||
children,
|
||||
drawer,
|
||||
className,
|
||||
}: FleetLedgerShellProps) {
|
||||
const [panelHost, setPanelHost] = useState<HTMLElement | null>(null)
|
||||
|
||||
return (
|
||||
<WorkAreaPanelHostProvider host={panelHost}>
|
||||
<section
|
||||
ref={setPanelHost}
|
||||
className={classes("thot-fleet-ledger", className)}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<div className="thot-fleet-ledger__workspace">
|
||||
{header}
|
||||
{status}
|
||||
{kpis}
|
||||
{navigation}
|
||||
{commands}
|
||||
{children}
|
||||
</div>
|
||||
{drawer}
|
||||
</section>
|
||||
</WorkAreaPanelHostProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export interface FleetLedgerHeaderProps extends WithClassName {
|
||||
title: ReactNode
|
||||
status?: ReactNode
|
||||
actions?: ReactNode
|
||||
actionsLabel?: string
|
||||
}
|
||||
|
||||
export function FleetLedgerHeader({
|
||||
title,
|
||||
status,
|
||||
actions,
|
||||
actionsLabel = "Database management actions",
|
||||
className,
|
||||
}: FleetLedgerHeaderProps) {
|
||||
return (
|
||||
<header className={classes("thot-fleet-ledger__header", className)}>
|
||||
<div className="thot-fleet-ledger__heading-copy">
|
||||
<h1 className="thot-fleet-ledger__title">{title}</h1>
|
||||
</div>
|
||||
{status ? <div className="thot-fleet-ledger__header-status">{status}</div> : null}
|
||||
{actions ? (
|
||||
<div className="thot-fleet-ledger__header-actions" role="group" aria-label={actionsLabel}>
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export interface FleetLedgerRealStatusProps extends WithClassName {
|
||||
title?: ReactNode
|
||||
items: readonly FleetLedgerStatusItem[]
|
||||
live?: "off" | "polite"
|
||||
}
|
||||
|
||||
export function FleetLedgerRealStatus({
|
||||
title = "Live catalog status",
|
||||
items,
|
||||
live = "polite",
|
||||
className,
|
||||
}: FleetLedgerRealStatusProps) {
|
||||
const titleId = useId()
|
||||
|
||||
return (
|
||||
<section
|
||||
className={classes("thot-fleet-ledger__status", className)}
|
||||
aria-labelledby={titleId}
|
||||
aria-live={live}
|
||||
aria-atomic="false"
|
||||
>
|
||||
<h2 id={titleId} className="thot-fleet-ledger__section-label">
|
||||
{title}
|
||||
</h2>
|
||||
<ul className="thot-fleet-ledger__status-list">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="thot-fleet-ledger__status-item"
|
||||
data-tone={item.tone ?? "neutral"}
|
||||
>
|
||||
<span className="thot-fleet-ledger__status-mark" aria-hidden="true" />
|
||||
<span className="thot-fleet-ledger__status-copy">
|
||||
<span className="thot-fleet-ledger__status-label">{item.label}</span>
|
||||
<strong className="thot-fleet-ledger__status-value">{item.value}</strong>
|
||||
{item.detail ? (
|
||||
<span className="thot-fleet-ledger__status-detail">{item.detail}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export interface FleetLedgerKpiStripProps extends WithClassName {
|
||||
title?: ReactNode
|
||||
items: readonly FleetLedgerKpi[]
|
||||
}
|
||||
|
||||
export function FleetLedgerKpiStrip({
|
||||
title = "Fleet summary",
|
||||
items,
|
||||
className,
|
||||
}: FleetLedgerKpiStripProps) {
|
||||
const titleId = useId()
|
||||
|
||||
return (
|
||||
<section
|
||||
className={classes("thot-fleet-ledger__kpi-section", className)}
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<h2 id={titleId} className="thot-fleet-ledger__section-label">
|
||||
{title}
|
||||
</h2>
|
||||
<dl className="thot-fleet-ledger__kpi-strip">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="thot-fleet-ledger__kpi">
|
||||
<dt className="thot-fleet-ledger__kpi-label">{item.label}</dt>
|
||||
<dd className="thot-fleet-ledger__kpi-value">{item.value}</dd>
|
||||
{item.detail ? (
|
||||
<dd className="thot-fleet-ledger__kpi-detail">{item.detail}</dd>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export interface FleetLedgerBreadcrumbProps extends WithClassName {
|
||||
items: readonly FleetLedgerBreadcrumbItem[]
|
||||
back?: FleetLedgerBackAction
|
||||
trailing?: ReactNode
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
export function FleetLedgerBreadcrumb({
|
||||
items,
|
||||
back,
|
||||
trailing,
|
||||
ariaLabel = "Database hierarchy",
|
||||
className,
|
||||
}: FleetLedgerBreadcrumbProps) {
|
||||
return (
|
||||
<div className={classes("thot-fleet-ledger__navigation", className)}>
|
||||
{back ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="thot-fleet-nav-action"
|
||||
data-tooltip={back.label.replace(/^Back to\s+/i, "")}
|
||||
onClick={back.onBack}
|
||||
disabled={back.disabled}
|
||||
data-icon="inline-start"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" />
|
||||
{back.label}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<nav className="thot-fleet-ledger__breadcrumb" aria-label={ariaLabel}>
|
||||
<ol className="thot-fleet-ledger__breadcrumb-list">
|
||||
{items.map((item, index) => {
|
||||
const isCurrent = item.current ?? index === items.length - 1
|
||||
|
||||
return (
|
||||
<li key={item.id} className="thot-fleet-ledger__breadcrumb-item">
|
||||
{index > 0 ? (
|
||||
<ChevronRight
|
||||
className="thot-fleet-ledger__breadcrumb-separator"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{item.onSelect && !isCurrent ? (
|
||||
<button
|
||||
type="button"
|
||||
className="thot-fleet-ledger__breadcrumb-button thot-fleet-nav-action thot-fleet-nav-action--below"
|
||||
data-tooltip={item.label}
|
||||
onClick={item.onSelect}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="thot-fleet-ledger__breadcrumb-label"
|
||||
aria-current={isCurrent ? "page" : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{trailing ? <div className="thot-fleet-ledger__navigation-trailing">{trailing}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface FleetLedgerCommandBarProps extends WithClassName {
|
||||
picker: ReactNode
|
||||
selectionSummary?: ReactNode
|
||||
trailing?: ReactNode
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
export function FleetLedgerCommandBar({
|
||||
picker,
|
||||
selectionSummary,
|
||||
trailing,
|
||||
ariaLabel = "Fleet commands",
|
||||
className,
|
||||
}: FleetLedgerCommandBarProps) {
|
||||
return (
|
||||
<div
|
||||
className={classes("thot-fleet-ledger__command-bar", className)}
|
||||
role="group"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<div className="thot-fleet-ledger__command-picker">{picker}</div>
|
||||
{selectionSummary ? (
|
||||
<div className="thot-fleet-ledger__selection-summary">{selectionSummary}</div>
|
||||
) : null}
|
||||
{trailing ? <div className="thot-fleet-ledger__command-trailing">{trailing}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface FleetLedgerContentProps extends WithClassName {
|
||||
children: ReactNode
|
||||
ariaLabel?: string
|
||||
busy?: boolean
|
||||
}
|
||||
|
||||
/** Keeps AG Grid or another real-data view as the only primary work surface. */
|
||||
export function FleetLedgerContent({
|
||||
children,
|
||||
ariaLabel = "Fleet inventory results",
|
||||
busy = false,
|
||||
className,
|
||||
}: FleetLedgerContentProps) {
|
||||
return (
|
||||
<section
|
||||
className={classes("thot-fleet-ledger__content", className)}
|
||||
aria-label={ariaLabel}
|
||||
aria-busy={busy}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export type FleetLedgerDrawerProps = Omit<WorkAreaPanelProps, "catalogMarkers">
|
||||
|
||||
/**
|
||||
* A modeless detail/action drawer. It owns focus entry/return and Escape;
|
||||
* mutation-specific confirmation and dirty-state policy stay with the caller.
|
||||
*/
|
||||
export const FleetLedgerDrawer = forwardRef<HTMLElement, FleetLedgerDrawerProps>(
|
||||
function FleetLedgerDrawer({ className, bodyClassName, footerClassName, ...props }, ref) {
|
||||
return (
|
||||
<WorkAreaPanel
|
||||
{...props}
|
||||
ref={ref}
|
||||
catalogMarkers
|
||||
className={classes(
|
||||
"thot-fleet-ledger__drawer thot-fleet-ledger__context-panel thot-catalog-drawer",
|
||||
className,
|
||||
)}
|
||||
bodyClassName={classes(
|
||||
"thot-fleet-ledger__drawer-body thot-catalog-drawer__body",
|
||||
bodyClassName,
|
||||
)}
|
||||
footerClassName={classes(
|
||||
"thot-fleet-ledger__drawer-footer thot-catalog-drawer__footer",
|
||||
footerClassName,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -23,10 +23,6 @@ export function MetadataGenerationModelSelector({
|
||||
&& models.some((model) => model.id === configuredDefault);
|
||||
const noUsableModel = !isLoading && !isError && Boolean(data) && !hasUsableDefault;
|
||||
const unavailable = noUsableModel || (!isLoading && isError);
|
||||
const describedBy = [
|
||||
"metadata-generation-source-data-disclosure",
|
||||
unavailable ? "metadata-generation-model-help" : null,
|
||||
].filter(Boolean).join(" ");
|
||||
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">
|
||||
@@ -34,7 +30,7 @@ export function MetadataGenerationModelSelector({
|
||||
<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={describedBy}
|
||||
aria-describedby={unavailable ? "metadata-generation-model-help" : undefined}
|
||||
value={selectedModel}
|
||||
disabled={!canManage || isLoading || isError || !hasUsableDefault}
|
||||
onChange={(event) => onSelectedModelChange(event.target.value)}
|
||||
@@ -54,26 +50,6 @@ export function MetadataGenerationModelSelector({
|
||||
Configure a metadata-generation model in application setup to enable description generation.
|
||||
</p>
|
||||
) : null}
|
||||
<div
|
||||
id="metadata-generation-source-data-disclosure"
|
||||
role="note"
|
||||
aria-label="Metadata generation source data disclosure"
|
||||
className="space-y-1 text-xs font-normal leading-4 text-muted-foreground"
|
||||
>
|
||||
<p>
|
||||
Description generation may send up to five source rows and up to five representative
|
||||
non-null example values to the selected model provider. Values from columns marked
|
||||
sensitive are replaced with plausible synthetic values before the request.
|
||||
</p>
|
||||
<p>
|
||||
Values from unmarked columns may be sent unchanged. Samples are transient and are not
|
||||
stored in run logs or catalog metadata.
|
||||
</p>
|
||||
<p>
|
||||
Sensitive-field suggestions use structural metadata only and remain unsaved until you
|
||||
choose Save sensitive fields.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Save, X } from "lucide-react";
|
||||
import { Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { apiErrorMessage } from "../../api/client";
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type CatalogColumn,
|
||||
type SensitiveDataSuggestion,
|
||||
} from "../../api/catalog-databases";
|
||||
import { FleetLedgerDrawer } from "./FleetLedgerShell";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -87,25 +88,30 @@ export function SensitiveDataReviewDrawer({
|
||||
|
||||
if (!open || !databaseId) return null;
|
||||
return (
|
||||
<aside
|
||||
aria-label="Sensitive field review"
|
||||
className="fixed inset-y-2 right-0 z-50 flex w-full max-w-[480px] flex-col border-l border-border bg-background shadow-2xl sm:inset-y-4"
|
||||
<FleetLedgerDrawer
|
||||
open
|
||||
ariaLabel="Sensitive field review"
|
||||
eyebrow="Sensitive data"
|
||||
title="Review suggested flags"
|
||||
description={`${scopeLabel}. The model proposed values, but only your save changes the catalog.`}
|
||||
onClose={close}
|
||||
closeLabel="Close sensitive field review"
|
||||
busy={saving}
|
||||
bodyClassName="thot-catalog-drawer__body--flush"
|
||||
footerClassName="thot-catalog-drawer__footer--split"
|
||||
footer={(
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">Unsaved suggestions never change the catalog.</p>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" disabled={saving} onClick={close}>Cancel</Button>
|
||||
<Button type="button" disabled={!canManage || saving || changed.length === 0} onClick={() => void save()}>
|
||||
<Save />{saving ? "Saving…" : `Save ${changed.length}`}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<p className="thot-label">Sensitive data</p>
|
||||
<h2 className="mt-1 font-heading text-xl font-semibold">Review suggested flags</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{scopeLabel}. The model proposed values, but only your save changes the catalog.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon-lg" aria-label="Close sensitive field review" disabled={saving} onClick={close}>
|
||||
<X aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="border-b border-border px-5 py-4">
|
||||
<div className="border-b border-border px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
className="h-9 min-w-0 flex-1 rounded-md border border-input bg-background px-3 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
|
||||
@@ -127,9 +133,9 @@ export function SensitiveDataReviewDrawer({
|
||||
/>
|
||||
Show all {suggestions.length} classified columns
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
{visible.length === 0 ? (
|
||||
<div className="rounded-md border border-border bg-muted/25 px-4 py-5 text-sm">
|
||||
<p className="font-semibold">No proposed changes in this view.</p>
|
||||
@@ -171,18 +177,7 @@ export function SensitiveDataReviewDrawer({
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4">
|
||||
<p className="text-xs text-muted-foreground">Unsaved suggestions never change the catalog.</p>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" disabled={saving} onClick={close}>Cancel</Button>
|
||||
<Button type="button" disabled={!canManage || saving || changed.length === 0} onClick={() => void save()}>
|
||||
<Save />{saving ? "Saving…" : `Save ${changed.length}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</FleetLedgerDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
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 { useState } from "react";
|
||||
import type { SensitiveDataSuggestionRun } from "../../api/catalog-databases";
|
||||
import { server } from "../../test/msw";
|
||||
import { SensitiveDataSuggestionHistoryDrawer } from "./SensitiveDataSuggestionHistoryDrawer";
|
||||
|
||||
const runningRun: SensitiveDataSuggestionRun = {
|
||||
id: "99999999-9999-4999-8999-999999999999",
|
||||
databaseId: "11111111-1111-4111-8111-111111111111",
|
||||
scope: "selected_columns",
|
||||
modelId: "local-qwen",
|
||||
status: "running",
|
||||
total: 4,
|
||||
suggestedSensitive: 1,
|
||||
suggestedNonSensitive: 1,
|
||||
createdAt: "2026-08-28T09:00:00Z",
|
||||
startedAt: "2026-08-28T09:00:00Z",
|
||||
updatedAt: "2026-08-28T09:00:01Z",
|
||||
finishedAt: null,
|
||||
errorSummary: null,
|
||||
};
|
||||
|
||||
function renderDrawer(initialRun: SensitiveDataSuggestionRun | null) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: Infinity } },
|
||||
});
|
||||
|
||||
function Harness() {
|
||||
const [run, setRun] = useState(initialRun);
|
||||
return (
|
||||
<SensitiveDataSuggestionHistoryDrawer
|
||||
open
|
||||
run={run}
|
||||
modelLabel="Local Qwen"
|
||||
onClose={() => undefined}
|
||||
onRunUpdate={setRun}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<Harness />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
test("shows an explicit empty state before any sensitive suggestion runs exist", async () => {
|
||||
server.use(http.get(
|
||||
"/api/catalog/sensitive-data-suggestion-runs",
|
||||
() => HttpResponse.json([]),
|
||||
));
|
||||
|
||||
renderDrawer(null);
|
||||
|
||||
const drawer = await screen.findByRole("dialog", { name: "Sensitive suggestion history" });
|
||||
expect(within(drawer).getByRole("heading", { name: "Suggestion run history" })).toBeVisible();
|
||||
expect(await within(drawer).findByText("No sensitive suggestion runs yet.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows sensitive suggestion results, safe events, and lets operators inspect an older run", async () => {
|
||||
const completedRun: SensitiveDataSuggestionRun = {
|
||||
...runningRun,
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
scope: "all",
|
||||
status: "completed",
|
||||
total: 6,
|
||||
suggestedSensitive: 2,
|
||||
suggestedNonSensitive: 4,
|
||||
createdAt: "2026-08-28T08:00:00Z",
|
||||
startedAt: "2026-08-28T08:00:00Z",
|
||||
updatedAt: "2026-08-28T08:00:01Z",
|
||||
finishedAt: "2026-08-28T08:00:01Z",
|
||||
};
|
||||
server.use(
|
||||
http.get("/api/catalog/sensitive-data-suggestion-runs", () => (
|
||||
HttpResponse.json([runningRun, completedRun])
|
||||
)),
|
||||
http.get("/api/catalog/sensitive-data-suggestion-runs/:runId", ({ params }) => (
|
||||
HttpResponse.json(params.runId === completedRun.id ? completedRun : runningRun)
|
||||
)),
|
||||
http.get("/api/catalog/sensitive-data-suggestion-runs/:runId/events-list", ({ params }) => (
|
||||
HttpResponse.json([{
|
||||
runId: params.runId,
|
||||
sequence: 1,
|
||||
level: "info",
|
||||
message: params.runId === completedRun.id ? "Classified 6 columns" : "Classifying columns",
|
||||
createdAt: "2026-08-28T09:00:01Z",
|
||||
}])
|
||||
)),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderDrawer(runningRun);
|
||||
|
||||
const drawer = await screen.findByRole("dialog", { name: "Sensitive suggestion history" });
|
||||
expect(within(drawer).getByRole("heading", { name: "Running" })).toBeVisible();
|
||||
expect(await within(drawer).findByText("Classifying columns")).toBeVisible();
|
||||
expect(within(drawer).queryByRole("button", { name: "Stop" })).not.toBeInTheDocument();
|
||||
expect(within(drawer).queryByRole("button", { name: "Unlock stale run" })).not.toBeInTheDocument();
|
||||
|
||||
const history = within(drawer).getByRole("region", { name: "Sensitive suggestion run history" });
|
||||
await user.click(within(history).getByRole("button", { name: /Completed.*all/i }));
|
||||
|
||||
expect(await within(drawer).findByRole("heading", { name: "Completed" })).toBeVisible();
|
||||
expect(within(drawer).getByText("2", { selector: "[data-count='suggestedSensitive']" })).toBeVisible();
|
||||
expect(within(drawer).getByText("4", { selector: "[data-count='suggestedNonSensitive']" })).toBeVisible();
|
||||
await waitFor(() => expect(within(drawer).getByText("Classified 6 columns")).toBeVisible());
|
||||
});
|
||||
|
||||
test("does not expose unsafe provider details from a final error summary", async () => {
|
||||
const failedRun: SensitiveDataSuggestionRun = {
|
||||
...runningRun,
|
||||
status: "failed",
|
||||
finishedAt: "2026-08-28T09:00:02Z",
|
||||
errorSummary: "stack trace at provider.ts:42 private source sample sk-secret",
|
||||
};
|
||||
server.use(
|
||||
http.get("/api/catalog/sensitive-data-suggestion-runs", () => HttpResponse.json([failedRun])),
|
||||
http.get("/api/catalog/sensitive-data-suggestion-runs/:runId", () => HttpResponse.json(failedRun)),
|
||||
http.get("/api/catalog/sensitive-data-suggestion-runs/:runId/events-list", () => HttpResponse.json([])),
|
||||
);
|
||||
|
||||
renderDrawer(failedRun);
|
||||
|
||||
expect(await screen.findByText(
|
||||
"The run ended before all columns were classified. Review the event log for safe details.",
|
||||
)).toBeVisible();
|
||||
expect(screen.queryByText(/sk-secret/i)).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useEffect } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiErrorMessage } from "../../api/client";
|
||||
import {
|
||||
getSensitiveDataSuggestionRun,
|
||||
listSensitiveDataSuggestionEvents,
|
||||
listSensitiveDataSuggestionRuns,
|
||||
type SensitiveDataSuggestionRun,
|
||||
} from "../../api/catalog-databases";
|
||||
import { FleetLedgerDrawer } from "./FleetLedgerShell";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
run: SensitiveDataSuggestionRun | null;
|
||||
modelLabel: string;
|
||||
onClose: () => void;
|
||||
onRunUpdate: (run: SensitiveDataSuggestionRun) => void;
|
||||
}
|
||||
|
||||
const HISTORY_QUERY_KEY = ["sensitive-data-suggestion-runs", 50] as const;
|
||||
const SAFE_FINAL_ERROR = "The run ended before all columns were classified. Review the event log for safe details.";
|
||||
|
||||
function statusLabel(run: SensitiveDataSuggestionRun): string {
|
||||
const label = run.status.replaceAll("_", " ");
|
||||
return `${label[0].toUpperCase()}${label.slice(1)}`;
|
||||
}
|
||||
|
||||
function isTerminal(run?: SensitiveDataSuggestionRun): boolean {
|
||||
return Boolean(run && ["completed", "failed", "interrupted"].includes(run.status));
|
||||
}
|
||||
|
||||
function safeFinalError(summary: string | null): string | null {
|
||||
const value = summary?.trim();
|
||||
if (!value) return null;
|
||||
if (
|
||||
value.length > 500
|
||||
|| /[\r\n]/.test(value)
|
||||
|| /traceback|stack trace|(?:^|\s)at\s+\S+[:(]|prompt|provider\s+payload|source\s+sample|bearer\s+\S+|api[_ -]?key|sk-[a-z0-9_-]+/i.test(value)
|
||||
) return SAFE_FINAL_ERROR;
|
||||
return value;
|
||||
}
|
||||
|
||||
function timestamp(value: string | null): string {
|
||||
return value ? new Date(value).toLocaleString() : "Not available";
|
||||
}
|
||||
|
||||
export function SensitiveDataSuggestionHistoryDrawer({
|
||||
open,
|
||||
run: initialRun,
|
||||
modelLabel,
|
||||
onClose,
|
||||
onRunUpdate,
|
||||
}: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const runId = initialRun?.id ?? null;
|
||||
const runQuery = useQuery({
|
||||
queryKey: ["sensitive-data-suggestion-run", runId],
|
||||
queryFn: () => getSensitiveDataSuggestionRun(runId!),
|
||||
enabled: open && Boolean(runId),
|
||||
initialData: initialRun ?? undefined,
|
||||
retry: false,
|
||||
refetchInterval: (query) => isTerminal(query.state.data) ? false : 1_000,
|
||||
});
|
||||
const run = runQuery.data;
|
||||
const eventQuery = useQuery({
|
||||
queryKey: ["sensitive-data-suggestion-events", runId],
|
||||
queryFn: () => listSensitiveDataSuggestionEvents(runId!),
|
||||
enabled: open && Boolean(runId),
|
||||
retry: false,
|
||||
refetchInterval: run && isTerminal(run) ? false : 1_500,
|
||||
});
|
||||
const historyQuery = useQuery({
|
||||
queryKey: HISTORY_QUERY_KEY,
|
||||
queryFn: () => listSensitiveDataSuggestionRuns(50),
|
||||
enabled: open,
|
||||
retry: false,
|
||||
refetchInterval: (query) => query.state.data?.some((item) => !isTerminal(item))
|
||||
? 3_000
|
||||
: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || initialRun || !historyQuery.data?.[0]) return;
|
||||
onRunUpdate(historyQuery.data[0]);
|
||||
}, [historyQuery.data, initialRun, onRunUpdate, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!run) return;
|
||||
onRunUpdate(run);
|
||||
queryClient.setQueryData<SensitiveDataSuggestionRun[]>(HISTORY_QUERY_KEY, (current) => {
|
||||
if (!current) return [run];
|
||||
return current.some((item) => item.id === run.id)
|
||||
? current.map((item) => item.id === run.id ? run : item)
|
||||
: [run, ...current];
|
||||
});
|
||||
}, [onRunUpdate, queryClient, run]);
|
||||
|
||||
if (!open) return null;
|
||||
if (!run) {
|
||||
return (
|
||||
<FleetLedgerDrawer
|
||||
open
|
||||
ariaLabel="Sensitive suggestion history"
|
||||
eyebrow="Sensitive data"
|
||||
title="Suggestion run history"
|
||||
onClose={onClose}
|
||||
closeLabel="Close sensitive suggestion history"
|
||||
bodyClassName="thot-catalog-drawer__body--empty"
|
||||
>
|
||||
{historyQuery.isError ? (
|
||||
<div role="alert" className="w-full rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{apiErrorMessage(historyQuery.error)}
|
||||
</div>
|
||||
) : historyQuery.isLoading || (historyQuery.data?.length ?? 0) > 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Loading suggestion history…</p>
|
||||
) : (
|
||||
<div className="w-full rounded-md border border-border bg-muted/25 px-4 py-5 text-sm">
|
||||
<p className="font-semibold">No sensitive suggestion runs yet.</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Request sensitive-field suggestions to create the first history entry.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</FleetLedgerDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
const events = eventQuery.data ?? [];
|
||||
const finalError = safeFinalError(run.errorSummary);
|
||||
return (
|
||||
<FleetLedgerDrawer
|
||||
open
|
||||
ariaLabel="Sensitive suggestion history"
|
||||
eyebrow="Sensitive data suggestions"
|
||||
title={statusLabel(run)}
|
||||
description={`${modelLabel}${modelLabel !== run.modelId ? ` (${run.modelId})` : ""} · ${run.scope.replaceAll("_", " ")}`}
|
||||
onClose={onClose}
|
||||
closeLabel="Close sensitive suggestion history"
|
||||
bodyClassName="thot-catalog-drawer__history-layout"
|
||||
>
|
||||
{runQuery.isError ? (
|
||||
<div role="alert" className="rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{apiErrorMessage(runQuery.error)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section aria-label="Sensitive suggestion counters">
|
||||
<h3 className="thot-label mb-2">Results</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{([
|
||||
["total", "Total columns"],
|
||||
["suggestedSensitive", "Sensitive"],
|
||||
["suggestedNonSensitive", "Not sensitive"],
|
||||
] as const).map(([name, label]) => (
|
||||
<div key={name} className="rounded-md border border-border bg-muted/25 px-3 py-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</p>
|
||||
<p data-count={name} className="mt-1 text-lg font-semibold tabular-nums">{run[name]}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-5" aria-label="Sensitive suggestion timestamps">
|
||||
<h3 className="thot-label mb-2">Timestamps</h3>
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
|
||||
<dt className="text-muted-foreground">Created</dt><dd className="text-right">{timestamp(run.createdAt)}</dd>
|
||||
<dt className="text-muted-foreground">Started</dt><dd className="text-right">{timestamp(run.startedAt)}</dd>
|
||||
<dt className="text-muted-foreground">Updated</dt><dd className="text-right">{timestamp(run.updatedAt)}</dd>
|
||||
<dt className="text-muted-foreground">Finished</dt><dd className="text-right">{timestamp(run.finishedAt)}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{finalError ? (
|
||||
<div className="mt-5 rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{finalError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="mt-5" aria-label="Sensitive suggestion log">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="thot-label">Events</h3>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{events.length}</span>
|
||||
</div>
|
||||
<div
|
||||
role="log"
|
||||
aria-label="Sensitive suggestion events"
|
||||
className="max-h-72 overflow-y-auto rounded-md bg-zinc-950 p-3 font-mono text-xs leading-5 text-zinc-200"
|
||||
>
|
||||
{events.length === 0 ? <p className="text-zinc-500">Waiting for events…</p> : events.map((event) => (
|
||||
<p key={event.sequence} className={event.level === "error" ? "text-red-300" : event.level === "warning" ? "text-amber-300" : undefined}>
|
||||
<span className="mr-2 text-zinc-500">{new Date(event.createdAt).toLocaleTimeString()}</span>{event.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
{eventQuery.isError ? <p role="alert" className="mt-2 text-sm text-destructive">{apiErrorMessage(eventQuery.error)}</p> : null}
|
||||
</section>
|
||||
|
||||
<section className="thot-catalog-drawer__history mt-5" aria-label="Sensitive suggestion run history">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<h3 className="thot-label">Recent runs</h3>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{historyQuery.data?.length ?? 0}
|
||||
</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 ? (
|
||||
<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) => (
|
||||
<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"
|
||||
onClick={() => onRunUpdate(item)}
|
||||
>
|
||||
<span className="font-medium">{statusLabel(item)}</span>
|
||||
<span className="shrink-0 text-right text-xs text-muted-foreground">
|
||||
{item.suggestedSensitive + item.suggestedNonSensitive}/{item.total}<br />
|
||||
{new Date(item.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</FleetLedgerDrawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { useModelessPanel as useModelessDrawer } from "../WorkAreaPanel";
|
||||
Reference in New Issue
Block a user