feat: implement metadata catalog database management

This commit is contained in:
Codex
2026-08-27 22:43:54 +02:00
parent 705af3aeb2
commit 79c4c925b5
86 changed files with 12566 additions and 135 deletions
+265
View File
@@ -0,0 +1,265 @@
import { apiFetch, assertSameOriginRequestUrl, BASE } from "./client";
import { joinBackendPath } from "./runtime-config";
export type DatabaseTransport = "postgres_direct" | "rest_api" | "ssh_tunnel";
export type ConnectionStatus = "untested" | "reachable" | "failed";
export type CatalogSecretName =
| "password"
| "apiKey"
| "sshPrivateKey"
| "sshPrivateKeyPassphrase"
| "sshKnownHosts"
| "tlsCa";
export interface DatabaseBinding {
transport: DatabaseTransport;
host?: string;
port?: number;
username?: string;
baseUrl?: string;
restPath?: string;
restAuth?: "none" | "bearer" | "x-api-key";
tlsServername?: string;
sshHost?: string;
sshPort?: number;
sshUsername?: string;
sshTargetHost?: string;
sshTargetPort?: number;
}
export interface CatalogDatabase {
id?: string;
workspaceId: string;
workspaceName: string;
workspaceDescription?: string;
workspaceAvailable: boolean;
configured: boolean;
engine: "postgres";
databaseName: string;
schema: string;
version: number;
createdAt: string;
updatedAt: string;
binding: DatabaseBinding;
connectionStatus: ConnectionStatus;
testedVersion?: number;
lastTestedAt?: string;
lastErrorCode?: string;
lastErrorMessage?: string;
schemaSyncedVersion?: number;
schemaSyncedAt?: string;
activeSyncRun?: CatalogSyncRun;
secrets: Record<CatalogSecretName, boolean>;
}
export interface DatabaseConfiguration {
workspaceId: string;
engine: "postgres";
databaseName: string;
schema: string;
binding: DatabaseBinding;
}
export interface CatalogTable {
id: string;
databaseId: string;
name: string;
sourceComment: string | null;
description: string | null;
generatedDescription: string | null;
lastSyncedDatabaseVersion?: number | null;
lastSyncedAt?: string | null;
version: number;
createdAt: string;
updatedAt: string;
}
export interface CatalogColumn {
id: string;
tableId: string;
name: string;
ordinalPosition: number;
dataType: string;
isNullable: boolean;
defaultExpression: string | null;
primaryKeyPosition: number | null;
isPrimaryKey: boolean;
isForeignKey: boolean;
foreignKeyCount: number;
sourceComment: string | null;
description: string | null;
generatedDescription: string | null;
lastSyncedDatabaseVersion: number | null;
lastSyncedAt: string | null;
version: number;
createdAt: string;
updatedAt: string;
}
export interface CatalogRelationshipColumn {
position: number;
sourceColumnId: string;
sourceColumnName: string;
targetColumnId: string;
targetColumnName: string;
}
export interface CatalogRelationship {
id: string;
databaseId: string;
constraintName: string;
sourceTableId: string;
sourceTableName: string;
targetTableId: string;
targetTableName: string;
updateRule: string;
deleteRule: string;
deferrable: boolean;
initiallyDeferred: boolean;
columns: CatalogRelationshipColumn[];
lastSyncedDatabaseVersion: number | null;
lastSyncedAt: string | null;
createdAt: string;
updatedAt: string;
}
export type CatalogSyncScope = "tables" | "columns" | "relationships" | "all";
export type CatalogSyncState = "queued" | "running" | "awaiting_confirmation" | "applying"
| "succeeded" | "failed" | "cancelled" | "interrupted";
export type CatalogSyncPhase = "queued" | "connecting" | "scanning_tables" | "scanning_columns"
| "scanning_relationships" | "planning" | "awaiting_confirmation" | "applying" | "completed";
export interface CatalogSchemaDiff {
deletedTables: string[];
deletedColumns: Array<{ tableName: string; columnName: string }>;
deletedRelationships: Array<{ sourceTableName: string; constraintName: string }>;
}
export interface CatalogSyncRun {
id: string;
databaseId: string;
scope: CatalogSyncScope;
tableIds: string[];
state: CatalogSyncState;
phase: CatalogSyncPhase;
requestedDatabaseVersion: number;
plannedDiff: CatalogSchemaDiff | null;
confirmationToken: string | null;
counts: { tables?: number; columns?: number; relationships?: number; created?: number; updated?: number; deleted?: number };
errorCode: string | null;
errorMessage: string | null;
cancelRequested: boolean;
createdAt: string;
startedAt: string | null;
updatedAt: string;
finishedAt: string | null;
heartbeatAt: string | null;
}
export interface CatalogSyncEvent {
id: number;
runId: string;
sequence: number;
level: "info" | "warning" | "error";
eventType: string;
message: string;
data: Record<string, unknown>;
createdAt: string;
}
export const listCatalogDatabases = () => apiFetch<CatalogDatabase[]>("/catalog/databases");
export const createCatalogDatabase = (input: DatabaseConfiguration) =>
apiFetch<CatalogDatabase>("/catalog/databases", { method: "POST", body: JSON.stringify(input) });
export const updateCatalogDatabase = (id: string, version: number, input: DatabaseConfiguration) =>
apiFetch<CatalogDatabase>(`/catalog/databases/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify({ ...input, version }),
});
export const replaceCatalogDatabaseSecrets = (
id: string,
version: number,
values: Partial<Record<CatalogSecretName, string>>,
) => apiFetch<CatalogDatabase>(`/catalog/databases/${encodeURIComponent(id)}/secrets`, {
method: "PUT",
body: JSON.stringify({ version, values }),
});
export const testCatalogDatabase = (id: string, version: number) =>
apiFetch<CatalogDatabase>(`/catalog/databases/${encodeURIComponent(id)}/test`, {
method: "POST",
body: JSON.stringify({ version }),
});
export const deleteCatalogDatabase = (id: string, version: number) =>
apiFetch<void>(`/catalog/databases/${encodeURIComponent(id)}?version=${version}`, { method: "DELETE" });
export const listCatalogTables = (databaseId: string) =>
apiFetch<CatalogTable[]>(`/catalog/databases/${encodeURIComponent(databaseId)}/tables`);
export const updateCatalogTableMetadata = (
databaseId: string,
tableId: string,
version: number,
description: string | null,
generatedDescription: string | null,
) => apiFetch<CatalogTable>(
`/catalog/databases/${encodeURIComponent(databaseId)}/tables/${encodeURIComponent(tableId)}`,
{ method: "PATCH", body: JSON.stringify({ version, description, generatedDescription }) },
);
export const listCatalogColumns = (databaseId: string, tableId: string) =>
apiFetch<CatalogColumn[]>(`/catalog/databases/${encodeURIComponent(databaseId)}/tables/${encodeURIComponent(tableId)}/columns`);
export const updateCatalogColumnMetadata = (
databaseId: string,
tableId: string,
columnId: string,
version: number,
description: string | null,
generatedDescription: string | null,
) => apiFetch<CatalogColumn>(
`/catalog/databases/${encodeURIComponent(databaseId)}/tables/${encodeURIComponent(tableId)}/columns/${encodeURIComponent(columnId)}`,
{ method: "PATCH", body: JSON.stringify({ version, description, generatedDescription }) },
);
export const listCatalogRelationships = (databaseId: string) =>
apiFetch<CatalogRelationship[]>(`/catalog/databases/${encodeURIComponent(databaseId)}/relationships`);
export const startCatalogSync = (
databaseId: string,
version: number,
scope: CatalogSyncScope,
tableIds: string[] = [],
) => apiFetch<CatalogSyncRun>(`/catalog/databases/${encodeURIComponent(databaseId)}/sync-runs`, {
method: "POST",
body: JSON.stringify({ version, scope, tableIds }),
});
export const listCatalogSyncRuns = (databaseId: string) =>
apiFetch<CatalogSyncRun[]>(`/catalog/databases/${encodeURIComponent(databaseId)}/sync-runs`);
export const getCatalogSyncRun = (runId: string) =>
apiFetch<CatalogSyncRun>(`/catalog/sync-runs/${encodeURIComponent(runId)}`);
export const listCatalogSyncEvents = (runId: string, after = 0) =>
apiFetch<CatalogSyncEvent[]>(`/catalog/sync-runs/${encodeURIComponent(runId)}/events-list?after=${after}`);
export const confirmCatalogSync = (runId: string, confirmationToken: string) =>
apiFetch<CatalogSyncRun>(`/catalog/sync-runs/${encodeURIComponent(runId)}/confirm`, {
method: "POST", body: JSON.stringify({ confirmationToken }),
});
export const cancelCatalogSync = (runId: string) =>
apiFetch<CatalogSyncRun>(`/catalog/sync-runs/${encodeURIComponent(runId)}/cancel`, { method: "POST" });
export const retryCatalogSync = (runId: string) =>
apiFetch<CatalogSyncRun>(`/catalog/sync-runs/${encodeURIComponent(runId)}/retry`, { method: "POST" });
export function catalogSyncEventsUrl(runId: string, after = 0): string {
const url = joinBackendPath(BASE, `/catalog/sync-runs/${encodeURIComponent(runId)}/events?after=${after}`);
assertSameOriginRequestUrl(url);
return url;
}
+17
View File
@@ -14,6 +14,10 @@ const safeErrorCodes = new Set([
"git_unavailable", "git_auth_failed", "git_non_fast_forward", "connector_unavailable",
"semantic_index_incompatible", "pi_management_forbidden", "pi_management_unavailable",
"pi_management_invalid_config", "pi_management_write_failed",
"catalog_unavailable", "database_conflict", "database_invalid", "database_not_found",
"database_stale", "database_operation_failed",
"schema_sync_conflict", "schema_introspection_failed", "schema_request_invalid",
"schema_operation_failed", "sync_run_not_found", "table_stale", "column_stale",
]);
type SafeErrorPayload = {
@@ -46,6 +50,19 @@ const localCodeMessages: Record<string, string> = {
pi_management_unavailable: "Pi management is unavailable.",
pi_management_invalid_config: "The Pi configuration is invalid.",
pi_management_write_failed: "The Pi configuration could not be saved.",
catalog_unavailable: "The database catalog is unavailable.",
database_conflict: "This workspace already has a database configuration.",
database_invalid: "The database configuration is invalid.",
database_not_found: "The database configuration was not found.",
database_stale: "The database configuration changed. Reload and try again.",
database_operation_failed: "The database operation failed.",
schema_sync_conflict: "A schema synchronization is already active or no longer current.",
schema_introspection_failed: "The database schema could not be read safely.",
schema_request_invalid: "The schema request is invalid.",
schema_operation_failed: "The schema operation failed.",
sync_run_not_found: "The synchronization run was not found.",
table_stale: "Table metadata changed. Reload and try again.",
column_stale: "Column metadata changed. Reload and try again.",
};
const localStatusMessages: Record<number, string> = {
+49
View File
@@ -296,3 +296,52 @@
0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 oklch(var(--primary) / 0.4); }
50% { transform: scale(1.15); box-shadow: 0 0 0 5px oklch(var(--primary) / 0); }
}
@layer components {
.thot-database-grid {
--ag-font-family: var(--font-sans);
--ag-font-size: 0.8125rem;
--ag-background-color: oklch(var(--card));
--ag-foreground-color: oklch(var(--foreground));
--ag-header-background-color: oklch(var(--muted) / 0.72);
--ag-header-foreground-color: oklch(var(--foreground));
--ag-border-color: oklch(var(--border));
--ag-row-border-color: oklch(var(--border) / 0.72);
--ag-odd-row-background-color: oklch(var(--muted) / 0.18);
--ag-row-hover-color: oklch(var(--muted) / 0.55);
--ag-selected-row-background-color: oklch(var(--primary) / 0.07);
--ag-input-focus-border-color: oklch(var(--primary) / 0.6);
--ag-range-selection-border-color: oklch(var(--primary) / 0.6);
--ag-header-column-separator-color: oklch(var(--border));
--ag-header-column-separator-display: block;
--ag-wrapper-border-radius: 0;
--ag-cell-horizontal-padding: 12px;
}
.thot-database-grid .ag-root-wrapper {
border: 0;
}
.thot-database-grid .ag-cell {
display: flex;
align-items: center;
}
.thot-database-grid .ag-header-cell-label {
font-weight: 700;
}
.thot-database-grid .ag-cell-focus:not(.ag-cell-range-selected) {
outline: 2px solid oklch(var(--ring) / 0.45);
outline-offset: -2px;
}
.thot-database-grid .ag-cell.thot-database-actions-cell {
padding-inline: 2px;
}
.thot-database-grid .ag-cell.thot-database-actions-cell.ag-cell-focus:not(.ag-cell-range-selected) {
outline: none;
}
.thot-database-grid .ag-cell.thot-database-status-cell {
padding-inline: 4px;
}
.thot-database-grid .ag-pinned-right-header,
.thot-database-grid .ag-pinned-right-cols-container {
box-shadow: -1px 0 0 oklch(var(--border));
}
}
@@ -23,7 +23,7 @@ beforeEach(() => {
issuer: "test",
subject: "test",
roles: ["admin"],
permissions: ["session.use", "workspace.manage", "workspace.secrets.manage", "pi.manage"],
permissions: ["session.use", "workspace.manage", "workspace.secrets.manage", "database.manage", "pi.manage"],
isAdmin: true,
csrfToken: null,
session: null,
@@ -52,7 +52,7 @@ beforeEach(() => {
);
});
test("opens the blank database management surface and returns to the core", async () => {
test("opens the database management surface and returns to the core", async () => {
renderShell();
const composer = screen.getByRole("textbox", { name: /new question/i });
@@ -122,7 +122,7 @@ test("keeps a live core session connected and returns when that session is opene
expect(FakeEventSource.instances).toHaveLength(1);
});
test("hides all management entries from non-admin users", () => {
test("keeps read-safe workspace access but hides privileged management entries", () => {
clearAuthState();
setAuthState({
issuer: "test",
@@ -136,7 +136,52 @@ test("hides all management entries from non-admin users", () => {
renderShell();
expect(screen.queryByRole("button", { name: "Workspace management" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Workspace management" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Database management" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument();
});
test("does not leave database management without confirming a dirty form", async () => {
server.use(http.get("/api/catalog/databases", () => HttpResponse.json([{
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: "untested",
secrets: {
password: false,
apiKey: false,
sshPrivateKey: false,
sshPrivateKeyPassphrase: false,
sshKnownHosts: false,
tlsCa: false,
},
}])));
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
renderShell();
await userEvent.click(screen.getByRole("button", { name: "Database management" }));
await userEvent.click(await screen.findByRole("button", { name: "Edit Policlinico San Donato" }));
const schema = screen.getByLabelText("Schema");
await userEvent.clear(schema);
await userEvent.type(schema, "reporting");
await userEvent.click(screen.getByRole("button", { name: "New session" }));
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 waitFor(() => expect(screen.queryByRole("main", { name: "Database management" })).not.toBeInTheDocument());
confirm.mockRestore();
});
+49 -17
View File
@@ -32,7 +32,7 @@ import {
import type { SessionScope, SessionSummary } from "../api/types";
import { useAuthGeneration, useAuthUser } from "../auth/authState";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import { captureAuthOperation, isAuthOperationCurrent, StaleAuthOperationError, type AuthOperationGuard } from "../auth/authOperation";
@@ -104,8 +104,8 @@ export function AppShell({ canLogout }: AppShellProps) {
const canReadAllSessions = permissions.includes("session.read_all");
const canManageWorkspace = permissions.includes("workspace.manage");
const canManageWorkspaceSecrets = permissions.includes("workspace.secrets.manage");
const canManageDatabase = permissions.includes("database.manage");
const canManagePi = permissions.includes("pi.manage");
const isAdmin = authenticatedUser?.isAdmin === true;
const authGeneration = useAuthGeneration();
const { data: sessions = [] } = useQuery<SessionSummary[]>({
queryKey: ["sessions", sessionScope],
@@ -123,6 +123,10 @@ export function AppShell({ canLogout }: AppShellProps) {
const queryClient = useQueryClient();
const [showActivity, setShowActivity] = useState(false);
const [activeSurface, setActiveSurface] = useState<ActiveSurface>("core");
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 [activeOpen, setActiveOpen] = useState(true);
@@ -197,7 +201,20 @@ export function AppShell({ canLogout }: AppShellProps) {
setSelectedSessionIds(selected ? new Set(sessions.map((session) => session.id)) : new Set());
}
function canLeaveDatabaseManagement(): boolean {
if (activeSurface !== "database-management") return true;
if (databaseNavigationRef.current.busy) {
toast.info("Wait for the database operation to finish before leaving this page");
return false;
}
if (databaseNavigationRef.current.dirty) {
return window.confirm("Discard unsaved database changes and leave database management?");
}
return true;
}
function openPanel(id: string) {
if (!canLeaveDatabaseManagement()) return;
const s = sessions.find((x) => x.id === id);
if (!s) return;
setActiveSurface("core");
@@ -207,7 +224,7 @@ export function AppShell({ canLogout }: AppShellProps) {
// completed sessions keep the read-only documents panel (with its explicit Resume),
// so a mere click never spawns a runtime.
if (s.active && s.status === "open" && !s.archived && !isForeignSession(s)) {
void doResume(id);
void doResume(id, true);
return;
}
setPanelSession(s); setShowActivity(false);
@@ -219,7 +236,8 @@ export function AppShell({ canLogout }: AppShellProps) {
return next;
});
}
async function doResume(id: string) {
async function doResume(id: string, databaseExitApproved = false) {
if (!databaseExitApproved && !canLeaveDatabaseManagement()) return;
setActiveSurface("core");
const guard = captureAuthOperation({ sessionId: id, disposalEpoch: operationEpochRef.current });
if (!guard) return;
@@ -502,6 +520,7 @@ export function AppShell({ canLogout }: AppShellProps) {
}, [lastSystemEvent]);
function startNewSession() {
if (!canLeaveDatabaseManagement()) return;
setActiveSurface("core");
invalidateResumeIntent();
newSessionOperationRef.current = null;
@@ -566,6 +585,7 @@ export function AppShell({ canLogout }: AppShellProps) {
}
async function signOut() {
if (!canLeaveDatabaseManagement()) return;
await logoutUser();
}
@@ -619,7 +639,13 @@ export function AppShell({ canLogout }: AppShellProps) {
{/* Conversation column */}
<div data-testid="conversation-column" className="flex min-w-0 flex-1 flex-col">
{activeSurface === "database-management" && <DatabaseManagementPage />}
{activeSurface === "database-management" && (
<DatabaseManagementPage
canManage={canManageDatabase}
canManageSecrets={canManageWorkspaceSecrets}
onNavigationStateChange={updateDatabaseNavigationState}
/>
)}
<div
aria-hidden={activeSurface !== "core"}
className={activeSurface === "core" ? "contents" : "hidden"}
@@ -738,16 +764,19 @@ export function AppShell({ canLogout }: AppShellProps) {
>
New session
</Button>
{isAdmin && (
<>
<Button
variant="outline"
size="sm"
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setWorkspaceManagerOpen(true)}
>
Workspace management
</Button>
onClick={() => {
if (!canLeaveDatabaseManagement()) return;
setActiveSurface("core");
setWorkspaceManagerOpen(true);
}}
>
Workspace management
</Button>
{canManageDatabase && (
<Button
variant="outline"
size="sm"
@@ -757,14 +786,17 @@ export function AppShell({ canLogout }: AppShellProps) {
>
Database management
</Button>
</>
)}
{isAdmin && canManagePi && (
{canManagePi && (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setPiManagementOpen(true)}
onClick={() => {
if (!canLeaveDatabaseManagement()) return;
setActiveSurface("core");
setPiManagementOpen(true);
}}
>
Pi management
</Button>
@@ -0,0 +1,842 @@
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import { server } from "../test/msw";
import type { CatalogColumn, CatalogDatabase, CatalogSyncRun, CatalogTable } from "../api/catalog-databases";
import { DatabaseManagementPage } from "./DatabaseManagementPage";
const noSecrets = {
password: false,
apiKey: false,
sshPrivateKey: false,
sshPrivateKeyPassphrase: false,
sshKnownHosts: false,
tlsCa: false,
};
function makeDatabase(overrides: Partial<CatalogDatabase> = {}): CatalogDatabase {
return {
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: "untested",
secrets: { ...noSecrets },
...overrides,
};
}
const unconfigured = makeDatabase({
id: undefined,
workspaceId: "lab",
workspaceName: "Research laboratory",
configured: false,
databaseName: "lab_warehouse",
schema: "analytics",
version: 0,
createdAt: "",
updatedAt: "",
binding: { transport: "postgres_direct", port: 5432 },
});
const orphan = makeDatabase({
id: "22222222-2222-4222-8222-222222222222",
workspaceId: "retired",
workspaceName: "Retired workspace",
workspaceAvailable: false,
});
function renderPage({
rows = [makeDatabase(), unconfigured, orphan],
canManage = true,
canManageSecrets = true,
onNavigationStateChange,
}: {
rows?: CatalogDatabase[] | (() => CatalogDatabase[]);
canManage?: boolean;
canManageSecrets?: boolean;
onNavigationStateChange?: (state: { dirty: boolean; busy: boolean }) => void;
} = {}) {
server.use(http.get("/api/catalog/databases", () => HttpResponse.json(
typeof rows === "function" ? rows() : rows,
)));
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(
<QueryClientProvider client={client}>
<DatabaseManagementPage
canManage={canManage}
canManageSecrets={canManageSecrets}
onNavigationStateChange={onNavigationStateChange}
/>
</QueryClientProvider>,
);
return { ...view, client };
}
function makeSyncRun(
scope: CatalogSyncRun["scope"],
tableIds: string[] = [],
): CatalogSyncRun {
return {
id: `sync-${scope}`,
databaseId: "11111111-1111-4111-8111-111111111111",
scope,
tableIds,
state: "queued",
phase: "queued",
requestedDatabaseVersion: 3,
plannedDiff: null,
confirmationToken: null,
counts: {},
errorCode: null,
errorMessage: null,
cancelRequested: false,
createdAt: "2026-08-27T10:00:00Z",
startedAt: null,
updatedAt: "2026-08-27T10:00:00Z",
finishedAt: null,
heartbeatAt: null,
};
}
function registerCompletedSyncRun(run: CatalogSyncRun) {
const completed: CatalogSyncRun = {
...run,
state: "succeeded",
phase: "completed",
startedAt: "2026-08-27T10:00:00Z",
finishedAt: "2026-08-27T10:00:01Z",
};
server.use(
http.get("/api/catalog/sync-runs/:runId", () => HttpResponse.json(completed)),
http.get("/api/catalog/sync-runs/:runId/events-list", () => HttpResponse.json([])),
http.get("/api/catalog/databases/:databaseId/sync-runs", () => HttpResponse.json([completed])),
);
}
const synchronizationScopes = [
{ scope: "tables", label: "Synchronize tables" },
{ scope: "columns", label: "Synchronize all columns" },
{ scope: "relationships", label: "Synchronize relationships" },
{ scope: "all", label: "Synchronize all" },
] as const;
test("starts with a full-width list and applies the row action matrix", async () => {
renderPage();
expect(await screen.findByRole("button", { name: "View Policlinico San Donato" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Edit Policlinico San Donato" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Delete Policlinico San Donato" })).toBeEnabled();
expect(screen.getByRole("button", { name: "View Research laboratory" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Edit Research laboratory" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Delete Research laboratory" })).toBeDisabled();
expect(screen.getByRole("button", { name: "View Retired workspace" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Edit Retired workspace" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Delete Retired workspace" })).toBeEnabled();
expect(screen.queryByRole("heading", { name: "Database details" })).not.toBeInTheDocument();
});
test.each(synchronizationScopes)(
"selected database Actions offers and starts $scope synchronization",
async ({ scope, label }) => {
const user = userEvent.setup();
let startBody: unknown;
const run = makeSyncRun(scope);
registerCompletedSyncRun(run);
server.use(http.post("/api/catalog/databases/:databaseId/sync-runs", async ({ request }) => {
startBody = await request.json();
return HttpResponse.json(run, { status: 202 });
}));
renderPage({
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
});
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("checkbox"));
await user.click(screen.getByRole("button", { name: "Actions" }));
expect(await screen.findByRole("menuitem", { name: synchronizationScopes[0].label })).toBeEnabled();
for (const option of synchronizationScopes.slice(1)) {
expect(screen.getByRole("menuitem", { name: option.label })).toBeEnabled();
}
await user.click(screen.getByRole("menuitem", { name: label }));
await waitFor(() => expect(startBody).toEqual({ version: 3, scope, tableIds: [] }));
},
);
test("presents completed synchronization steps as success and skips unneeded confirmation", async () => {
const user = userEvent.setup();
const run = {
...makeSyncRun("columns"),
counts: { tables: 163, columns: 2_275 },
};
registerCompletedSyncRun(run);
server.use(http.post("/api/catalog/databases/:databaseId/sync-runs", () => (
HttpResponse.json(run, { status: 202 })
)));
renderPage({
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
});
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("checkbox"));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Synchronize all columns" }));
expect(await screen.findByRole("heading", { name: "Succeeded" })).toBeVisible();
const steps = screen.getByRole("list", { name: "Synchronization steps" });
expect(steps).toHaveClass("grid-cols-2", "sm:grid-cols-3");
const connected = within(steps).getByRole("listitem", { name: "Connected, completed" });
expect(connected.querySelector("svg")).toHaveClass("text-[oklch(var(--success))]");
expect(within(steps).getByText("Tables read")).toBeVisible();
const confirmation = within(steps).getByRole("listitem", {
name: "Confirmation not required, not required",
});
expect(confirmation.querySelector("svg")).toHaveClass("text-muted-foreground");
const completed = within(steps).getByRole("listitem", { name: "Completed, completed" });
expect(completed.querySelector("svg")).toHaveClass("text-[oklch(var(--success))]");
expect(steps.querySelectorAll(".text-primary")).toHaveLength(0);
});
test("database Overview exposes every synchronization scope", async () => {
const user = userEvent.setup();
let startBody: unknown;
const run = makeSyncRun("relationships");
registerCompletedSyncRun(run);
server.use(http.post("/api/catalog/databases/:databaseId/sync-runs", async ({ request }) => {
startBody = await request.json();
return HttpResponse.json(run, { status: 202 });
}));
renderPage({
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
});
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
await user.click(screen.getByRole("button", { name: "Synchronize database schema" }));
expect(await screen.findByRole("menuitem", { name: synchronizationScopes[0].label })).toBeEnabled();
for (const option of synchronizationScopes.slice(1)) {
expect(screen.getByRole("menuitem", { name: option.label })).toBeEnabled();
}
await user.click(screen.getByRole("menuitem", { name: "Synchronize relationships" }));
await waitFor(() => expect(startBody).toEqual({
version: 3,
scope: "relationships",
tableIds: [],
}));
});
test("replaces the list with View and returns focus to the originating action", async () => {
const user = userEvent.setup();
renderPage({ rows: [makeDatabase()] });
const view = await screen.findByRole("button", { name: "View Policlinico San Donato" });
await user.click(view);
expect(screen.getByRole("heading", { name: "Database details" })).toBeVisible();
expect(screen.getByDisplayValue("db.internal")).toHaveAttribute("readonly");
expect(screen.getByRole("button", { name: "Test connection" })).toBeEnabled();
expect(screen.queryByRole("button", { name: "Save changes" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Back to list" }));
await waitFor(() => expect(view).toHaveFocus());
expect(screen.getByRole("button", { name: "View Policlinico San Donato" })).toBeVisible();
});
test("opens an unconfigured row as Edit while creating its first saved configuration", async () => {
const user = userEvent.setup();
let postBody: unknown;
const saved = makeDatabase({
...unconfigured,
id: "33333333-3333-4333-8333-333333333333",
configured: true,
version: 1,
binding: { transport: "rest_api", baseUrl: "https://psd.example/api", restPath: "/health", restAuth: "x-api-key" },
});
let rows = [unconfigured];
server.use(
http.post("/api/catalog/databases", async ({ request }) => {
postBody = await request.json();
rows = [saved];
return HttpResponse.json(saved, { status: 201 });
}),
);
renderPage({ rows: () => rows });
await user.click(await screen.findByRole("button", { name: "Edit Research laboratory" }));
expect(screen.getByRole("heading", { name: "Edit database" })).toBeVisible();
expect(screen.getByLabelText("Workspace")).toBeDisabled();
await user.selectOptions(screen.getByLabelText("Transport"), "rest_api");
await user.type(screen.getByLabelText("Base URL"), "https://psd.example/api");
expect(screen.getByLabelText("Diagnostic endpoint")).toHaveValue("/health");
expect(screen.getByLabelText("Diagnostic endpoint")).toHaveAttribute("readonly");
await user.click(screen.getByRole("button", { name: "Save database" }));
await waitFor(() => expect(postBody).toMatchObject({
workspaceId: "lab",
binding: expect.objectContaining({ transport: "rest_api", baseUrl: "https://psd.example/api" }),
}));
expect(await screen.findByRole("heading", { name: "Edit database" })).toBeVisible();
});
test("global Add offers only unconfigured workspaces and lets the operator choose one", async () => {
const user = userEvent.setup();
const second = makeDatabase({
...unconfigured,
workspaceId: "radiology",
workspaceName: "Radiology",
databaseName: "radiology_dwh",
});
renderPage({ rows: [makeDatabase(), unconfigured, second] });
await user.click(await screen.findByRole("button", { name: "Add database" }));
expect(screen.getByRole("heading", { name: "Add database" })).toBeVisible();
expect(screen.getByRole("button", { name: "Add database" })).toBeVisible();
const selector = screen.getByLabelText("Workspace");
expect(selector).toBeEnabled();
expect(screen.queryByRole("option", { name: "Policlinico San Donato" })).not.toBeInTheDocument();
await user.selectOptions(selector, "radiology");
expect(screen.getByDisplayValue("radiology_dwh")).toBeVisible();
});
test("guards a dirty Edit, disables testing, and reports navigation state", async () => {
const user = userEvent.setup();
const navigation = vi.fn();
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
renderPage({ rows: [makeDatabase()], onNavigationStateChange: navigation });
await user.click(await screen.findByRole("button", { name: "Edit Policlinico San Donato" }));
const schema = screen.getByLabelText("Schema");
await user.clear(schema);
await user.type(schema, "reporting");
expect(screen.getByRole("button", { name: "Test connection" })).toBeDisabled();
await waitFor(() => expect(navigation).toHaveBeenLastCalledWith({ dirty: true, busy: false }));
await user.click(screen.getByRole("button", { name: "Back to list" }));
expect(confirm).toHaveBeenCalledWith("Discard unsaved database changes?");
expect(screen.getByRole("heading", { name: "Edit database" })).toBeVisible();
confirm.mockReturnValue(true);
await user.click(screen.getByRole("button", { name: "Back to list" }));
expect(await screen.findByRole("button", { name: "Edit Policlinico San Donato" })).toBeVisible();
});
test("uses an in-page destructive form and returns the YAML workspace to Not configured", async () => {
const user = userEvent.setup();
let row = makeDatabase();
let deleteCalled = false;
let deleteVersion: string | null = null;
server.use(
http.delete("/api/catalog/databases/:id", ({ request }) => {
deleteCalled = true;
deleteVersion = new URL(request.url).searchParams.get("version");
row = {
...row,
id: undefined,
configured: false,
version: 0,
updatedAt: "",
connectionStatus: "untested",
secrets: { ...noSecrets },
};
return new HttpResponse(null, { status: 204 });
}),
);
renderPage({ rows: () => [row] });
await user.click(await screen.findByRole("button", { name: "Delete Policlinico San Donato" }));
expect(screen.getByRole("heading", { name: "Delete database" })).toBeVisible();
expect(screen.getByText("This removes the local database configuration.")).toBeVisible();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Delete database" }));
await waitFor(() => expect(deleteCalled).toBe(true));
expect(deleteVersion).toBe("3");
expect(await screen.findByRole("button", { name: "Delete Policlinico San Donato" })).toBeDisabled();
expect(screen.getByText("Not configured")).toBeVisible();
});
test("tests only the persisted version and updates the visible connection status", async () => {
const user = userEvent.setup();
let testBody: unknown;
let row = makeDatabase();
server.use(http.post("/api/catalog/databases/:id/test", async ({ request }) => {
testBody = await request.json();
row = makeDatabase({ version: 4, connectionStatus: "reachable", testedVersion: 4 });
return HttpResponse.json(row);
}));
renderPage({ rows: () => [row] });
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
await user.click(screen.getByRole("button", { name: "Test connection" }));
await waitFor(() => expect(testBody).toEqual({ version: 3 }));
expect(within(screen.getByRole("region", { name: "Database details form" })).getByText("Reachable")).toBeVisible();
});
test("retains a stale draft and requires an explicit reload", async () => {
const user = userEvent.setup();
server.use(
http.patch("/api/catalog/databases/:id", () => HttpResponse.json({
code: "database_stale",
message: "The database changed",
}, { status: 409 })),
);
renderPage({ rows: [makeDatabase()] });
await user.click(await screen.findByRole("button", { name: "Edit Policlinico San Donato" }));
const schema = screen.getByLabelText("Schema");
await user.clear(schema);
await user.type(schema, "draft_schema");
await user.click(screen.getByRole("button", { name: "Save changes" }));
expect(await screen.findByText("A newer database configuration is available.")).toBeVisible();
expect(schema).toHaveValue("draft_schema");
expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Keep editing" }));
expect(screen.getByText("Reload the latest values before this configuration can be changed.")).toBeVisible();
expect(schema).toHaveValue("draft_schema");
});
test("marks an open form stale when a background refetch advances the catalog version", async () => {
const user = userEvent.setup();
const { client } = renderPage({ rows: [makeDatabase()] });
await user.click(await screen.findByRole("button", { name: "Edit Policlinico San Donato" }));
act(() => {
client.setQueryData(["catalog-databases"], [makeDatabase({ version: 4, schema: "server_schema" })]);
});
expect(await screen.findByText("A newer database configuration is available.")).toBeVisible();
expect(screen.getByLabelText("Schema")).toHaveValue("datawarehouse");
expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled();
});
test("does not submit credentials hidden by a transport or authentication change", async () => {
const user = userEvent.setup();
let putCalled = false;
let patchBody: Record<string, unknown> | undefined;
let row = makeDatabase();
server.use(
http.patch("/api/catalog/databases/:id", async ({ request }) => {
patchBody = await request.json() as Record<string, unknown>;
row = makeDatabase({
version: 4,
binding: { transport: "rest_api", baseUrl: "https://psd.example/api", restPath: "/health", restAuth: "none" },
});
return HttpResponse.json(row);
}),
http.put("/api/catalog/databases/:id/secrets", () => {
putCalled = true;
return HttpResponse.json(row);
}),
);
renderPage({ rows: () => [row] });
await user.click(await screen.findByRole("button", { name: "Edit Policlinico San Donato" }));
await user.type(screen.getByLabelText("Password"), "must-not-be-sent");
await user.selectOptions(screen.getByLabelText("Transport"), "rest_api");
await user.type(screen.getByLabelText("Base URL"), "https://psd.example/api");
await user.selectOptions(screen.getByLabelText("Authentication"), "none");
await user.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(patchBody).toBeDefined());
expect(putCalled).toBe(false);
expect(patchBody).toMatchObject({
version: 3,
binding: {
transport: "rest_api",
baseUrl: "https://psd.example/api",
restPath: "/health",
restAuth: "none",
},
});
expect((patchBody?.binding as Record<string, unknown>).host).toBeUndefined();
});
test("preserves typed secrets and offers a retry when the configuration save is only partial", async () => {
const user = userEvent.setup();
let putCalls = 0;
let row = makeDatabase();
server.use(
http.put("/api/catalog/databases/:id/secrets", async ({ request }) => {
putCalls += 1;
const body = await request.json();
expect(body).toEqual({ version: 3, values: { password: "transient-secret" } });
if (putCalls === 1) {
return HttpResponse.json({
code: "catalog_unavailable",
message: "Secret store unavailable",
}, { status: 503 });
}
row = makeDatabase({ version: 4, secrets: { ...noSecrets, password: true } });
return HttpResponse.json(row);
}),
);
renderPage({ rows: () => [row] });
await user.click(await screen.findByRole("button", { name: "Edit Policlinico San Donato" }));
await user.type(screen.getByLabelText("Password"), "transient-secret");
await user.click(screen.getByRole("button", { name: "Save changes" }));
expect(await screen.findByText("Database configuration saved; secret update could not be confirmed.")).toBeVisible();
expect(screen.getByLabelText("Password")).toHaveValue("transient-secret");
expect(screen.getByRole("button", { name: "Retry secrets" })).toBeEnabled();
await user.clear(screen.getByLabelText("Schema"));
await user.type(screen.getByLabelText("Schema"), "reporting_after_retry");
await user.click(screen.getByRole("button", { name: "Retry secrets" }));
await waitFor(() => expect(putCalls).toBe(2));
expect(screen.queryByText("Database configuration saved; secret update could not be confirmed.")).not.toBeInTheDocument();
expect(screen.getByLabelText("Schema")).toHaveValue("reporting_after_retry");
expect(screen.getByRole("button", { name: "Save changes" })).toBeEnabled();
});
test("shows secret status without exposing or enabling values when permission is absent", async () => {
const user = userEvent.setup();
renderPage({
rows: [makeDatabase({ secrets: { ...noSecrets, password: true } })],
canManageSecrets: false,
});
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
expect(screen.getByLabelText("Password")).toHaveValue("Configured");
expect(screen.getByLabelText("Password")).toHaveAttribute("readonly");
await user.click(screen.getByRole("button", { name: "Back to list" }));
await user.click(screen.getByRole("button", { name: "Edit Policlinico San Donato" }));
expect(await screen.findByRole("heading", { name: "Edit database" })).toBeVisible();
expect(screen.getByLabelText("Password")).toBeDisabled();
expect(screen.getByText(/requires the workspace\.secrets\.manage permission/i)).toBeVisible();
});
const patientsTable: CatalogTable = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
databaseId: "11111111-1111-4111-8111-111111111111",
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",
};
test("filters catalog tables as the operator types", async () => {
const user = userEvent.setup();
const mediciTable: CatalogTable = {
...patientsTable,
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
name: "bridge_medici",
sourceComment: "Doctors participating in clinical care",
};
server.use(
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([
patientsTable,
mediciTable,
])),
);
renderPage({
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
});
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
await user.click(screen.getByRole("tab", { name: "Tables" }));
const tablesRegion = await screen.findByRole("region", { name: "Tables for Policlinico San Donato" });
expect(await screen.findByRole("button", { name: "Edit description for patients" })).toBeVisible();
expect(screen.getByRole("button", { name: "Edit description for bridge_medici" })).toBeVisible();
await user.type(screen.getByRole("textbox", { name: "Search tables" }), "medici");
expect(screen.getByRole("button", { name: "Edit description for bridge_medici" })).toBeVisible();
expect(await within(tablesRegion).findByText("1 of 2")).toBeVisible();
await waitFor(() => {
expect(screen.queryByRole("button", { name: "Edit description for patients" })).not.toBeInTheDocument();
});
});
test("selected table exposes a direct Synchronize columns action and sends its id", async () => {
const user = userEvent.setup();
let startBody: unknown;
const run = makeSyncRun("columns", [patientsTable.id]);
registerCompletedSyncRun(run);
server.use(
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
http.post("/api/catalog/databases/:databaseId/sync-runs", async ({ request }) => {
startBody = await request.json();
return HttpResponse.json(run, { status: 202 });
}),
);
renderPage({
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
});
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
await user.click(screen.getByRole("tab", { name: "Tables" }));
const tableRow = await screen.findByRole("row", { name: /patients/ });
await user.click(within(tableRow).getByRole("checkbox"));
const synchronizeColumns = screen.getByRole("button", { name: "Synchronize columns" });
expect(synchronizeColumns).toBeVisible();
expect(synchronizeColumns).toBeEnabled();
expect(screen.queryByRole("button", { name: "Actions" })).not.toBeInTheDocument();
await user.click(synchronizeColumns);
await waitFor(() => expect(startBody).toEqual({
version: 3,
scope: "columns",
tableIds: [patientsTable.id],
}));
await waitFor(() => expect(screen.queryByText("1 selected")).not.toBeInTheDocument());
});
test("navigates purely from a database to its tables and edits review metadata", async () => {
const user = userEvent.setup();
let patchBody: unknown;
server.use(
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
http.patch("/api/catalog/databases/:databaseId/tables/:tableId", async ({ request }) => {
patchBody = await request.json();
return HttpResponse.json({
...patientsTable,
description: "Registry used for longitudinal patient analysis",
version: 2,
});
}),
);
renderPage({
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
});
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
await user.click(screen.getByRole("tab", { name: "Tables" }));
expect(await screen.findByRole("region", { name: "Tables for Policlinico San Donato" })).toBeVisible();
expect(screen.getByRole("tab", { name: "Tables" })).toHaveAttribute("aria-selected", "true");
expect(screen.queryByLabelText("Database filter")).not.toBeInTheDocument();
await user.click(await screen.findByRole("button", { name: "Edit description for patients" }));
expect(screen.getByLabelText("Physical table name")).toHaveValue("patients");
expect(screen.getByLabelText("Physical table name")).toHaveAttribute("readonly");
expect(screen.getByLabelText("Source comment")).toHaveAttribute("readonly");
await user.type(
screen.getByLabelText(/^Description/),
"Registry used for longitudinal patient analysis",
);
await user.click(screen.getByRole("button", { name: "Save metadata" }));
await waitFor(() => expect(patchBody).toEqual({
version: 1,
description: "Registry used for longitudinal patient analysis",
generatedDescription: null,
}));
await user.click(screen.getByRole("button", { name: "Back to tables" }));
await user.click(screen.getByRole("tab", { name: "Overview" }));
expect(await screen.findByRole("heading", { name: "Database details" })).toBeVisible();
});
test("navigates from a table to columns and from the database to physical relationships", async () => {
const user = userEvent.setup();
let columnPatch: unknown;
const idColumn: CatalogColumn = {
id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
tableId: patientsTable.id,
name: "id",
ordinalPosition: 1,
dataType: "bigint",
isNullable: false,
defaultExpression: null,
primaryKeyPosition: 1,
isPrimaryKey: true,
isForeignKey: true,
foreignKeyCount: 1,
sourceComment: "Patient identifier",
description: null,
generatedDescription: null,
lastSyncedDatabaseVersion: 3,
lastSyncedAt: "2026-08-27T10:00:00Z",
version: 1,
createdAt: "2026-08-27T10:00:00Z",
updatedAt: "2026-08-27T10:00:00Z",
};
server.use(
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => HttpResponse.json([idColumn])),
http.patch("/api/catalog/databases/:databaseId/tables/:tableId/columns/:columnId", async ({ request }) => {
columnPatch = await request.json();
return HttpResponse.json({ ...idColumn, generatedDescription: "Generated identifier draft", version: 2 });
}),
http.get("/api/catalog/databases/:databaseId/relationships", () => HttpResponse.json([{
id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
databaseId: patientsTable.databaseId,
constraintName: "visits_patient_id_fkey",
sourceTableId: "ffffffff-ffff-4fff-8fff-ffffffffffff",
sourceTableName: "visits",
targetTableId: patientsTable.id,
targetTableName: "patients",
updateRule: "NO ACTION",
deleteRule: "CASCADE",
deferrable: false,
initiallyDeferred: false,
columns: [{ position: 1, sourceColumnId: "1", sourceColumnName: "patient_id", targetColumnId: idColumn.id, targetColumnName: "id" }],
lastSyncedDatabaseVersion: 3,
lastSyncedAt: "2026-08-27T10:00:00Z",
createdAt: "2026-08-27T10:00:00Z",
updatedAt: "2026-08-27T10:00:00Z",
}])),
);
renderPage({ rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })] });
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
await user.click(screen.getByRole("tab", { name: "Tables" }));
await user.click(await screen.findByRole("button", { name: "View columns for patients" }));
expect(screen.getByRole("tab", { name: "Columns" })).toHaveAttribute("aria-selected", "true");
expect(await screen.findByText("PK")).toBeVisible();
expect(screen.getByText("FK")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Edit metadata for id" }));
await user.type(screen.getByLabelText("Generated description"), "Generated identifier draft");
await user.click(screen.getByRole("button", { name: "Save metadata" }));
await waitFor(() => expect(columnPatch).toEqual({
version: 1,
description: null,
generatedDescription: "Generated identifier draft",
}));
await user.click(screen.getByRole("tab", { name: "Relationships" }));
expect(await screen.findByText("visits_patient_id_fkey")).toBeVisible();
});
test("opens the durable job drawer and confirms its exact destructive plan", async () => {
const user = userEvent.setup();
const startBodies: unknown[] = [];
const confirmationBodies: unknown[] = [];
const queued: CatalogSyncRun = {
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
databaseId: "11111111-1111-4111-8111-111111111111",
scope: "tables",
tableIds: [],
state: "queued",
phase: "queued",
requestedDatabaseVersion: 3,
plannedDiff: null,
confirmationToken: null,
counts: {},
errorCode: null,
errorMessage: null,
cancelRequested: false,
createdAt: "2026-08-27T10:00:00Z",
startedAt: null,
updatedAt: "2026-08-27T10:00:00Z",
finishedAt: null,
heartbeatAt: null,
};
const waiting: CatalogSyncRun = {
...queued,
state: "awaiting_confirmation",
phase: "awaiting_confirmation",
confirmationToken: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
plannedDiff: { deletedTables: ["legacy_visits"], deletedColumns: [], deletedRelationships: [] },
counts: { tables: 1 },
startedAt: "2026-08-27T10:00:00Z",
updatedAt: "2026-08-27T10:00:01Z",
};
server.use(
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
http.post("/api/catalog/databases/:databaseId/sync-runs", async ({ request }) => {
startBodies.push(await request.json());
return HttpResponse.json(queued, { status: 202 });
}),
http.get("/api/catalog/sync-runs/:runId", () => HttpResponse.json(waiting)),
http.get("/api/catalog/sync-runs/:runId/events-list", () => HttpResponse.json([])),
http.get("/api/catalog/databases/:databaseId/sync-runs", () => HttpResponse.json([waiting])),
http.post("/api/catalog/sync-runs/:runId/confirm", async ({ request }) => {
confirmationBodies.push(await request.json());
return HttpResponse.json({ ...waiting, state: "queued", phase: "queued", confirmationToken: null });
}),
);
renderPage({
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
});
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
await user.click(screen.getByRole("tab", { name: "Tables" }));
await user.click(await screen.findByRole("button", { name: "Sync tables" }));
const synchronizationDrawer = await screen.findByRole("complementary", {
name: "Schema synchronization",
});
expect(synchronizationDrawer).toHaveClass("inset-y-2", "sm:inset-y-4");
expect(within(synchronizationDrawer).getByRole("listitem", {
name: "Confirmation required, in progress",
})).toHaveAttribute("aria-current", "step");
expect(await screen.findByText("table · legacy_visits")).toBeVisible();
expect(startBodies).toEqual([{ version: 3, scope: "tables", tableIds: [] }]);
await user.click(screen.getByRole("button", { name: "Confirm removals" }));
await waitFor(() => expect(confirmationBodies).toEqual([
{ confirmationToken: "cccccccc-cccc-4ccc-8ccc-cccccccccccc" },
]));
});
test("keeps a stale table draft but requires an explicit reload before another save", async () => {
const user = userEvent.setup();
const current = {
...patientsTable,
description: "Description saved by another editor",
version: 2,
};
let latest = patientsTable;
server.use(
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([latest])),
http.patch("/api/catalog/databases/:databaseId/tables/:tableId", () => {
latest = current;
return HttpResponse.json({
code: "table_stale",
message: "Table description changed. Reload and try again.",
}, { status: 409 });
}),
);
renderPage({
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
});
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
await user.click(screen.getByRole("tab", { name: "Tables" }));
await user.click(await screen.findByRole("button", { name: "Edit description for patients" }));
const description = screen.getByLabelText(/^Description/);
await user.type(description, "My unsaved draft");
await user.click(screen.getByRole("button", { name: "Save metadata" }));
expect(await screen.findByText("A newer table description is available.")).toBeVisible();
expect(description).toHaveValue("My unsaved draft");
expect(screen.getByRole("button", { name: "Save metadata" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Keep editing" }));
expect(screen.getByText("Reload the latest value before this description can be saved.")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Reload latest" }));
await waitFor(() => expect(description).toHaveValue("Description saved by another editor"));
expect(screen.getByRole("button", { name: "Save metadata" })).toBeDisabled();
});
+753 -5
View File
@@ -1,8 +1,756 @@
export function DatabaseManagementPage() {
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { ApiError, apiErrorMessage } from "../api/client";
import {
createCatalogDatabase,
deleteCatalogDatabase,
listCatalogDatabases,
replaceCatalogDatabaseSecrets,
startCatalogSync,
testCatalogDatabase,
updateCatalogDatabase,
type CatalogDatabase,
type CatalogSecretName,
type CatalogSyncScope,
type CatalogSyncRun,
type DatabaseBinding,
type DatabaseTransport,
} from "../api/catalog-databases";
import { DatabaseGrid } from "./database-management/DatabaseGrid";
import { DatabaseForm } from "./database-management/DatabaseForm";
import { DatabaseTables } from "./database-management/DatabaseTables";
import { DatabaseRelationships } from "./database-management/DatabaseRelationships";
import { CatalogSyncDrawer } from "./database-management/CatalogSyncDrawer";
import {
configurationFingerprint,
configurationFromDraft,
draftFrom,
hasSecretChanges,
secretReplacements,
type DatabaseBusyAction,
type DatabaseFormDraft,
type DatabaseFormMode,
type DatabaseNavigationState,
type DatabaseScreen,
} from "./database-management/model";
const DATABASE_QUERY_KEY = ["catalog-databases"] as const;
const SYNC_STARTED_MESSAGES: Record<CatalogSyncScope, string> = {
tables: "Table synchronization started",
columns: "Column synchronization started",
relationships: "Relationship synchronization started",
all: "Full schema synchronization started",
};
interface Props {
canManage: boolean;
canManageSecrets: boolean;
onNavigationStateChange?: (state: DatabaseNavigationState) => void;
}
interface FormSource {
id?: string;
version: number;
}
function isStaleError(error: unknown): boolean {
return error instanceof ApiError && error.code === "database_stale";
}
export function DatabaseManagementPage({
canManage,
canManageSecrets,
onNavigationStateChange,
}: Props) {
const queryClient = useQueryClient();
const {
data,
isLoading,
isError,
isFetching,
refetch,
} = useQuery({
queryKey: DATABASE_QUERY_KEY,
queryFn: listCatalogDatabases,
retry: false,
});
const rows = data ?? [];
const [screen, setScreen] = useState<DatabaseScreen>({ kind: "list" });
const [draft, setDraft] = useState<DatabaseFormDraft | null>(null);
const [baseline, setBaseline] = useState("");
const [formSource, setFormSource] = useState<FormSource | null>(null);
const [search, setSearch] = useState("");
const [busyAction, setBusyAction] = useState<DatabaseBusyAction>(null);
const [stale, setStale] = useState(false);
const [staleBannerOpen, setStaleBannerOpen] = useState(true);
const [partialSecretFailure, setPartialSecretFailure] = useState<string | null>(null);
const [tablesNavigationState, setTablesNavigationState] = useState<DatabaseNavigationState>({
dirty: false,
busy: false,
});
const [activeSyncRun, setActiveSyncRun] = useState<CatalogSyncRun | null>(null);
const [syncDrawerOpen, setSyncDrawerOpen] = useState(false);
const originRef = useRef<HTMLElement | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const formHeadingRef = useRef<HTMLHeadingElement>(null);
const activeRow = screen.kind === "list"
? undefined
: rows.find((row) => row.workspaceId === screen.workspaceId);
const availableWorkspaces = useMemo(
() => rows.filter((row) => row.workspaceAvailable && !row.configured),
[rows],
);
const editable = screen.kind === "add" || screen.kind === "edit";
const configurationDirty = Boolean(
editable
&& draft
&& configurationFingerprint(draft) !== baseline,
);
const dirty = Boolean(editable && draft && (configurationDirty || hasSecretChanges(draft)));
const busy = busyAction !== null;
const navigationDirty = screen.kind === "tables" ? tablesNavigationState.dirty : dirty;
const navigationBusy = screen.kind === "tables" ? tablesNavigationState.busy : busy;
useEffect(() => {
onNavigationStateChange?.({ dirty: navigationDirty, busy: navigationBusy });
}, [navigationBusy, navigationDirty, onNavigationStateChange]);
useEffect(() => () => {
onNavigationStateChange?.({ dirty: false, busy: false });
}, [onNavigationStateChange]);
useEffect(() => {
const warn = (event: BeforeUnloadEvent) => {
if (!navigationDirty && !navigationBusy) return;
event.preventDefault();
};
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [navigationBusy, navigationDirty]);
useEffect(() => {
if (screen.kind === "list" || !activeRow || !formSource || busy) return;
if (activeRow.id === formSource.id && activeRow.version === formSource.version) return;
setStale(true);
setStaleBannerOpen(true);
}, [activeRow, busy, formSource, screen.kind]);
useEffect(() => {
if (screen.kind === "list") return;
const timer = window.setTimeout(() => formHeadingRef.current?.focus(), 0);
return () => window.clearTimeout(timer);
}, [screen.kind, screen.kind === "list" ? "" : screen.workspaceId]);
const cacheSavedRow = useCallback((saved: CatalogDatabase) => {
queryClient.setQueryData<CatalogDatabase[]>(DATABASE_QUERY_KEY, (current = []) => {
const existing = current.findIndex((row) => row.workspaceId === saved.workspaceId);
if (existing < 0) return [...current, saved];
return current.map((row, index) => index === existing ? saved : row);
});
}, [queryClient]);
const restoreListFocus = useCallback(() => {
window.setTimeout(() => {
if (originRef.current?.isConnected) {
originRef.current.focus();
} else {
searchInputRef.current?.focus();
}
}, 0);
}, []);
const showList = useCallback(() => {
setScreen({ kind: "list" });
setDraft(null);
setBaseline("");
setFormSource(null);
setStale(false);
setStaleBannerOpen(true);
setPartialSecretFailure(null);
setTablesNavigationState({ dirty: false, busy: false });
restoreListFocus();
}, [restoreListFocus]);
useEffect(() => {
if (screen.kind === "list" || isLoading || activeRow) return;
showList();
toast.info("This database configuration is no longer available");
}, [activeRow, isLoading, screen.kind, showList]);
useEffect(() => {
if (!["tables", "relationships"].includes(screen.kind) || !activeRow || (activeRow.configured && activeRow.id)) return;
showList();
toast.info("Save the database configuration before managing tables");
}, [activeRow, screen.kind, showList]);
const backToList = useCallback(() => {
if (busy) return;
if (dirty && !window.confirm("Discard unsaved database changes?")) return;
showList();
}, [busy, dirty, showList]);
const openForm = useCallback((
mode: DatabaseFormMode,
row: CatalogDatabase,
origin: HTMLElement,
workspaceLocked = false,
) => {
const nextDraft = draftFrom(row);
originRef.current = origin;
setDraft(nextDraft);
setBaseline(configurationFingerprint(nextDraft));
setFormSource({ id: row.id, version: row.version });
setStale(false);
setStaleBannerOpen(true);
setPartialSecretFailure(null);
setScreen({
kind: mode,
workspaceId: row.workspaceId,
...(mode === "add" ? { workspaceLocked } : {}),
});
}, []);
const viewRow = useCallback((row: CatalogDatabase, origin: HTMLButtonElement) => {
openForm("view", row, origin);
}, [openForm]);
const editRow = useCallback((row: CatalogDatabase, origin: HTMLButtonElement) => {
openForm(row.configured ? "edit" : "add", row, origin, !row.configured);
}, [openForm]);
const deleteRow = useCallback((row: CatalogDatabase, origin: HTMLButtonElement) => {
openForm("delete", row, origin);
}, [openForm]);
const openTables = useCallback((row: CatalogDatabase, origin?: HTMLElement) => {
if (!row.configured || !row.id) return;
if (origin) originRef.current = origin;
setDraft(null);
setBaseline("");
setFormSource(null);
setStale(false);
setPartialSecretFailure(null);
setTablesNavigationState({ dirty: false, busy: false });
setScreen({ kind: "tables", workspaceId: row.workspaceId });
}, []);
const openRelationships = useCallback((row: CatalogDatabase) => {
if (!row.configured || !row.id) return;
setDraft(null);
setBaseline("");
setFormSource(null);
setStale(false);
setPartialSecretFailure(null);
setTablesNavigationState({ dirty: false, busy: false });
setScreen({ kind: "relationships", workspaceId: row.workspaceId });
}, []);
const openOverview = useCallback((row: CatalogDatabase) => {
const nextDraft = draftFrom(row);
setDraft(nextDraft);
setBaseline(configurationFingerprint(nextDraft));
setFormSource({ id: row.id, version: row.version });
setStale(false);
setStaleBannerOpen(true);
setPartialSecretFailure(null);
setTablesNavigationState({ dirty: false, busy: false });
setScreen({ kind: "view", workspaceId: row.workspaceId });
}, []);
const addDatabase = useCallback((origin: HTMLElement) => {
const workspace = availableWorkspaces[0];
if (!workspace || !canManage) return;
openForm("add", workspace, origin, false);
}, [availableWorkspaces, canManage, openForm]);
const changeWorkspace = useCallback((workspaceId: string) => {
const workspace = availableWorkspaces.find((row) => row.workspaceId === workspaceId);
if (!workspace) return;
if (dirty && !window.confirm("Discard changes and choose another workspace?")) return;
const nextDraft = draftFrom(workspace);
setDraft(nextDraft);
setBaseline(configurationFingerprint(nextDraft));
setFormSource({ id: workspace.id, version: workspace.version });
setStale(false);
setPartialSecretFailure(null);
setScreen({ kind: "add", workspaceId, workspaceLocked: false });
}, [availableWorkspaces, dirty]);
const changeField = useCallback((field: "databaseName" | "schema", value: string) => {
setDraft((current) => current ? { ...current, [field]: value } : current);
}, []);
const changeTransport = useCallback((transport: DatabaseTransport) => {
setDraft((current) => current
? { ...current, binding: { ...current.binding, transport } }
: current);
}, []);
const changeBinding = useCallback(<K extends keyof DatabaseBinding>(
key: K,
value: DatabaseBinding[K],
) => {
setDraft((current) => current
? { ...current, binding: { ...current.binding, [key]: value } }
: current);
}, []);
const changeSecret = useCallback((name: CatalogSecretName, value: string) => {
setDraft((current) => current
? { ...current, secrets: { ...current.secrets, [name]: value } }
: current);
}, []);
const markStale = useCallback(() => {
setStale(true);
setStaleBannerOpen(true);
}, []);
const save = useCallback(async () => {
if (!activeRow || !formSource || !draft || !editable || !canManage || stale || busy) return;
setBusyAction("save");
setPartialSecretFailure(null);
try {
const input = configurationFromDraft(draft);
let saved = activeRow;
const shouldPersistConfiguration = !formSource.id || configurationDirty;
if (shouldPersistConfiguration) {
saved = formSource.id
? await updateCatalogDatabase(formSource.id, formSource.version, input)
: await createCatalogDatabase(input);
setFormSource({ id: saved.id, version: saved.version });
cacheSavedRow(saved);
}
const replacements = secretReplacements(draft);
if (Object.keys(replacements).length > 0) {
if (!canManageSecrets) throw new Error("Secret replacement is not permitted");
try {
saved = await replaceCatalogDatabaseSecrets(
saved.id ?? formSource.id!,
shouldPersistConfiguration ? saved.version : formSource.version,
replacements,
);
setFormSource({ id: saved.id, version: saved.version });
cacheSavedRow(saved);
} catch (error) {
setBaseline(configurationFingerprint(draft));
setScreen({ kind: "edit", workspaceId: saved.workspaceId });
setPartialSecretFailure(apiErrorMessage(error));
if (isStaleError(error)) {
markStale();
} else {
await queryClient.invalidateQueries({ queryKey: DATABASE_QUERY_KEY });
}
toast.warning("Database configuration saved, but secrets still need attention");
return;
}
}
const nextDraft = draftFrom(saved);
setDraft(nextDraft);
setBaseline(configurationFingerprint(nextDraft));
setFormSource({ id: saved.id, version: saved.version });
setScreen({ kind: "edit", workspaceId: saved.workspaceId });
setStale(false);
setPartialSecretFailure(null);
await queryClient.invalidateQueries({ queryKey: DATABASE_QUERY_KEY });
toast.success(activeRow.configured ? "Database configuration saved" : "Database configuration added");
} catch (error) {
if (isStaleError(error)) {
markStale();
} else {
toast.error(apiErrorMessage(error));
}
} finally {
setBusyAction(null);
}
}, [
activeRow,
busy,
cacheSavedRow,
canManage,
canManageSecrets,
configurationDirty,
draft,
editable,
formSource,
markStale,
queryClient,
stale,
]);
const retrySecrets = useCallback(async () => {
if (!activeRow?.configured || !formSource?.id || !draft || !canManageSecrets || busy || stale) return;
const replacements = secretReplacements(draft);
if (Object.keys(replacements).length === 0) {
setPartialSecretFailure(null);
return;
}
setBusyAction("retry-secrets");
try {
const saved = await replaceCatalogDatabaseSecrets(formSource.id, formSource.version, replacements);
setFormSource({ id: saved.id, version: saved.version });
cacheSavedRow(saved);
setDraft({ ...draft, secrets: {} });
setPartialSecretFailure(null);
await queryClient.invalidateQueries({ queryKey: DATABASE_QUERY_KEY });
toast.success("Database secrets saved");
} catch (error) {
setPartialSecretFailure(apiErrorMessage(error));
if (isStaleError(error)) markStale();
toast.error(apiErrorMessage(error));
} finally {
setBusyAction(null);
}
}, [activeRow, busy, cacheSavedRow, canManageSecrets, draft, formSource, markStale, queryClient, stale]);
const testConnection = useCallback(async () => {
if (
!activeRow?.configured
|| !formSource?.id
|| !draft
|| !canManage
|| dirty
|| stale
|| busy
) return;
setBusyAction("test");
try {
const tested = await testCatalogDatabase(formSource.id, formSource.version);
setFormSource({ id: tested.id, version: tested.version });
cacheSavedRow(tested);
const nextDraft = draftFrom(tested);
setDraft(nextDraft);
setBaseline(configurationFingerprint(nextDraft));
await queryClient.invalidateQueries({ queryKey: DATABASE_QUERY_KEY });
if (tested.connectionStatus === "reachable") {
toast.success("Database connection is reachable");
} else {
toast.error(tested.lastErrorMessage ?? "Database connection failed");
}
} catch (error) {
if (isStaleError(error)) markStale();
else toast.error(apiErrorMessage(error));
} finally {
setBusyAction(null);
}
}, [activeRow, busy, cacheSavedRow, canManage, dirty, draft, formSource, markStale, queryClient, stale]);
const remove = useCallback(async () => {
if (!activeRow?.configured || !formSource?.id || !canManage || busy || stale) return;
setBusyAction("delete");
try {
await deleteCatalogDatabase(formSource.id, formSource.version);
queryClient.setQueryData<CatalogDatabase[]>(DATABASE_QUERY_KEY, (current = []) => {
if (!activeRow.workspaceAvailable) {
return current.filter((row) => row.workspaceId !== activeRow.workspaceId);
}
return current.map((row) => row.workspaceId === activeRow.workspaceId
? {
...row,
id: undefined,
configured: false,
version: 0,
createdAt: "",
updatedAt: "",
connectionStatus: "untested",
testedVersion: undefined,
lastTestedAt: undefined,
lastErrorCode: undefined,
lastErrorMessage: undefined,
secrets: {
password: false,
apiKey: false,
sshPrivateKey: false,
sshPrivateKeyPassphrase: false,
sshKnownHosts: false,
tlsCa: false,
},
}
: row);
});
setBusyAction(null);
showList();
await queryClient.invalidateQueries({ queryKey: DATABASE_QUERY_KEY });
toast.success("Database configuration deleted");
} catch (error) {
if (isStaleError(error)) markStale();
else toast.error(apiErrorMessage(error));
} finally {
setBusyAction(null);
}
}, [activeRow, busy, canManage, formSource, markStale, queryClient, showList, stale]);
const reloadLatest = useCallback(async () => {
if (busy || screen.kind === "list") return;
setBusyAction("reload");
try {
const result = await refetch();
if (result.error) throw result.error;
const latest = result.data?.find((row) => row.workspaceId === screen.workspaceId);
if (!latest) {
showList();
toast.info("This database configuration is no longer available");
return;
}
const nextDraft = draftFrom(latest);
setDraft(nextDraft);
setBaseline(configurationFingerprint(nextDraft));
setFormSource({ id: latest.id, version: latest.version });
setStale(false);
setStaleBannerOpen(true);
setPartialSecretFailure(null);
if (!latest.configured && screen.kind === "delete") {
showList();
toast.info("This database configuration has already been deleted");
} else if (screen.kind === "edit" && !latest.configured) {
setScreen({ kind: "add", workspaceId: latest.workspaceId, workspaceLocked: true });
}
} catch (error) {
toast.error(apiErrorMessage(error));
} finally {
setBusyAction(null);
}
}, [busy, refetch, screen, showList]);
const refreshList = useCallback(async () => {
if (isFetching) return;
try {
const result = await refetch();
if (result.error) throw result.error;
} catch (error) {
toast.error(apiErrorMessage(error));
}
}, [isFetching, refetch]);
const updateTrackedSyncRun = useCallback((run: CatalogSyncRun) => {
setActiveSyncRun(run);
queryClient.setQueryData<CatalogDatabase[]>(DATABASE_QUERY_KEY, (current = []) => current.map((row) => (
row.id === run.databaseId
? { ...row, activeSyncRun: ["queued", "running", "awaiting_confirmation", "applying"].includes(run.state) ? run : undefined }
: row
)));
}, [queryClient]);
const rememberSyncRun = useCallback((run: CatalogSyncRun) => {
updateTrackedSyncRun(run);
setSyncDrawerOpen(true);
}, [updateTrackedSyncRun]);
const openSync = useCallback((row?: CatalogDatabase) => {
const run = row?.activeSyncRun ?? activeSyncRun;
if (!run) return;
setActiveSyncRun(run);
setSyncDrawerOpen(true);
}, [activeSyncRun]);
const testSelected = useCallback(async (selected: CatalogDatabase[]) => {
try {
const tested = await Promise.all(selected.map((row) => testCatalogDatabase(row.id!, row.version)));
for (const row of tested) cacheSavedRow(row);
await queryClient.invalidateQueries({ queryKey: DATABASE_QUERY_KEY });
toast.success(`${tested.length} connection${tested.length === 1 ? "" : "s"} tested`);
} catch (error) {
toast.error(apiErrorMessage(error));
throw error;
}
}, [cacheSavedRow, queryClient]);
const syncSelected = useCallback(async (selected: CatalogDatabase[], scope: CatalogSyncScope) => {
try {
const runs = await Promise.all(selected.map((row) => startCatalogSync(row.id!, row.version, scope)));
queryClient.setQueryData<CatalogDatabase[]>(DATABASE_QUERY_KEY, (current = []) => current.map((row) => {
const run = runs.find((candidate) => candidate.databaseId === row.id);
return run ? { ...row, activeSyncRun: run } : row;
}));
if (runs[0]) rememberSyncRun(runs[0]);
toast.success(`${runs.length} schema synchronization${runs.length === 1 ? "" : "s"} started`);
} catch (error) {
toast.error(apiErrorMessage(error));
throw error;
}
}, [queryClient, rememberSyncRun]);
const syncDatabase = useCallback(async (scope: CatalogSyncScope) => {
if (!activeRow?.id || !activeRow.configured) return;
try {
rememberSyncRun(await startCatalogSync(activeRow.id, activeRow.version, scope));
toast.success(SYNC_STARTED_MESSAGES[scope]);
} catch (error) { toast.error(apiErrorMessage(error)); }
}, [activeRow, rememberSyncRun]);
const catalogChanged = useCallback(async () => {
const databaseId = activeSyncRun?.databaseId;
if (databaseId) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["catalog-tables", databaseId] }),
queryClient.invalidateQueries({ queryKey: ["catalog-relationships", databaseId] }),
queryClient.invalidateQueries({ queryKey: ["catalog-sync-runs", databaseId] }),
]);
}
await queryClient.invalidateQueries({ queryKey: DATABASE_QUERY_KEY });
}, [activeSyncRun, 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)
? activeSyncRun
: activeRow?.activeSyncRun && ["queued", "running", "awaiting_confirmation", "applying"].includes(activeRow.activeSyncRun.state)
? activeRow.activeSyncRun
: undefined;
return (
<main
aria-label="Database management"
className="flex-1 overflow-y-auto bg-background"
/>
<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">
<div>
<p className="thot-label mb-1">Administration</p>
<h1 className="font-heading text-xl font-semibold tracking-tight">Database management</h1>
<p className="mt-1 text-sm text-muted-foreground">
One database configuration for each repository workspace.
</p>
</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>
) : null}
</header>
<div className="relative flex min-h-0 flex-1">
<div
aria-hidden={screen.kind !== "list"}
className={screen.kind === "list"
? "flex min-h-0 flex-1"
: "pointer-events-none invisible absolute inset-0 flex min-h-0"
}
>
{isError && rows.length === 0 ? (
<div className="grid flex-1 place-items-center p-6">
<div className="max-w-lg rounded-md 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
rows={rows}
isLoading={isLoading}
isFetching={isFetching}
search={search}
canManage={canManage}
searchInputRef={searchInputRef}
onSearchChange={setSearch}
onView={viewRow}
onEdit={editRow}
onDelete={deleteRow}
onOpenSync={openSync}
onTestSelected={testSelected}
onSyncSelected={syncSelected}
/>
)}
</div>
{formVisible ? (
<DatabaseForm
mode={screen.kind as DatabaseFormMode}
row={activeRow}
availableWorkspaces={availableWorkspaces}
workspaceLocked={screen.kind === "add" && Boolean(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}
/>
) : null}
{tablesVisible ? (
<DatabaseTables
database={activeRow}
canManage={canManage}
activeRun={currentActiveRun}
onBackToDatabases={showList}
onOpenOverview={() => openOverview(activeRow)}
onOpenRelationships={() => openRelationships(activeRow)}
onNavigationStateChange={setTablesNavigationState}
onRunStarted={rememberSyncRun}
onOpenSync={() => openSync(activeRow)}
/>
) : null}
{relationshipsVisible ? (
<DatabaseRelationships
database={{ ...activeRow, activeSyncRun: currentActiveRun }}
canManage={canManage}
onBackToDatabases={showList}
onOpenOverview={() => openOverview(activeRow)}
onOpenTables={() => openTables(activeRow)}
onRunStarted={rememberSyncRun}
onOpenSync={() => openSync(activeRow)}
/>
) : null}
</div>
<CatalogSyncDrawer
databaseId={activeSyncRun?.databaseId ?? activeRow?.id ?? null}
runId={activeSyncRun?.id ?? null}
open={syncDrawerOpen && Boolean(activeSyncRun)}
onClose={() => setSyncDrawerOpen(false)}
onRunChange={rememberSyncRun}
onRunUpdate={updateTrackedSyncRun}
onCatalogChanged={() => void catalogChanged()}
/>
</main>
);
}
@@ -0,0 +1,402 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Check, CheckCircle2, Circle, LoaderCircle, Minus, RotateCcw, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { apiErrorMessage } from "../../api/client";
import {
cancelCatalogSync,
catalogSyncEventsUrl,
confirmCatalogSync,
getCatalogSyncRun,
listCatalogSyncEvents,
listCatalogSyncRuns,
retryCatalogSync,
type CatalogSyncEvent,
type CatalogSyncPhase,
type CatalogSyncRun,
} from "../../api/catalog-databases";
interface Props {
databaseId: string | null;
runId: string | null;
open: boolean;
onClose: () => void;
onRunChange: (run: CatalogSyncRun) => void;
onRunUpdate: (run: CatalogSyncRun) => void;
onCatalogChanged: () => void;
}
const phases: CatalogSyncPhase[] = [
"queued", "connecting", "scanning_tables", "scanning_columns",
"scanning_relationships", "planning", "awaiting_confirmation", "applying", "completed",
];
const phaseEnteredByEvent: Record<string, CatalogSyncPhase> = {
queued: "queued",
started: "connecting",
connecting: "connecting",
scanning_tables: "scanning_tables",
scanning_columns: "scanning_columns",
scanning_relationships: "scanning_relationships",
planning: "planning",
confirmation_required: "awaiting_confirmation",
confirmation_received: "awaiting_confirmation",
applying: "applying",
succeeded: "completed",
};
const phaseLabels: Record<CatalogSyncPhase, { pending: string; current: string; completed: string }> = {
queued: { pending: "Queue", current: "Queued", completed: "Queued" },
connecting: { pending: "Connect", current: "Connecting", completed: "Connected" },
scanning_tables: { pending: "Read tables", current: "Reading tables", completed: "Tables read" },
scanning_columns: { pending: "Read columns", current: "Reading columns", completed: "Columns read" },
scanning_relationships: {
pending: "Read relationships",
current: "Reading relationships",
completed: "Relationships read",
},
planning: { pending: "Plan changes", current: "Planning changes", completed: "Changes planned" },
awaiting_confirmation: {
pending: "Confirm changes if needed",
current: "Confirmation required",
completed: "Changes confirmed",
},
applying: { pending: "Apply changes", current: "Applying changes", completed: "Changes applied" },
completed: { pending: "Complete", current: "Completing", completed: "Completed" },
};
function terminal(run?: CatalogSyncRun): boolean {
return Boolean(run && ["succeeded", "failed", "cancelled", "interrupted"].includes(run.state));
}
function elapsed(run: CatalogSyncRun, now: number): string {
const start = new Date(run.startedAt ?? run.createdAt).getTime();
const end = run.finishedAt ? new Date(run.finishedAt).getTime() : now;
const seconds = Math.max(0, Math.floor((end - start) / 1000));
const minutes = Math.floor(seconds / 60);
return minutes ? `${minutes}m ${seconds % 60}s` : `${seconds}s`;
}
function stateLabel(run: CatalogSyncRun): string {
return run.state.replaceAll("_", " ");
}
export function CatalogSyncDrawer({
databaseId,
runId,
open,
onClose,
onRunChange,
onRunUpdate,
onCatalogChanged,
}: Props) {
const queryClient = useQueryClient();
const [events, setEvents] = useState<CatalogSyncEvent[]>([]);
const [now, setNow] = useState(Date.now());
const [action, setAction] = useState<"confirm" | "cancel" | "retry" | null>(null);
const lastSequence = useRef(0);
const notifiedRun = useRef<string | null>(null);
const runQuery = useQuery({
queryKey: ["catalog-sync-run", runId],
queryFn: () => getCatalogSyncRun(runId!),
enabled: Boolean(runId),
retry: false,
refetchInterval: (query) => terminal(query.state.data) ? false : 1_000,
});
const run = runQuery.data;
const historyQuery = useQuery({
queryKey: ["catalog-sync-runs", databaseId],
queryFn: () => listCatalogSyncRuns(databaseId!),
enabled: Boolean(databaseId && open),
retry: false,
refetchInterval: run && !terminal(run) ? 2_000 : false,
});
const eventQuery = useQuery({
queryKey: ["catalog-sync-events", runId],
queryFn: () => listCatalogSyncEvents(runId!, lastSequence.current),
enabled: Boolean(runId && open),
retry: false,
refetchInterval: run && terminal(run) ? false : 1_500,
});
const mergeEvents = (incoming: CatalogSyncEvent[]) => {
if (incoming.length === 0) return;
setEvents((current) => {
const bySequence = new Map(current.map((event) => [event.sequence, event]));
for (const event of incoming) bySequence.set(event.sequence, event);
const merged = [...bySequence.values()].sort((a, b) => a.sequence - b.sequence);
lastSequence.current = merged.at(-1)?.sequence ?? lastSequence.current;
return merged;
});
};
useEffect(() => {
setEvents([]);
lastSequence.current = 0;
}, [runId]);
useEffect(() => { mergeEvents(eventQuery.data ?? []); }, [eventQuery.data]);
useEffect(() => {
if (!runId || !open || typeof EventSource === "undefined") return;
const source = new EventSource(catalogSyncEventsUrl(runId));
const log = (event: MessageEvent<string>) => {
try { mergeEvents([JSON.parse(event.data) as CatalogSyncEvent]); } catch { /* polling remains authoritative */ }
};
const update = (event: MessageEvent<string>) => {
try {
const next = JSON.parse(event.data) as CatalogSyncRun;
queryClient.setQueryData(["catalog-sync-run", runId], next);
} catch { /* polling remains authoritative */ }
};
source.addEventListener("log", log as EventListener);
source.addEventListener("run", update as EventListener);
source.onerror = () => source.close();
return () => source.close();
}, [open, queryClient, runId]);
useEffect(() => {
if (!run || terminal(run)) return;
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
return () => window.clearInterval(timer);
}, [run]);
useEffect(() => {
if (!run) return;
onRunUpdate(run);
queryClient.setQueryData<CatalogSyncRun[]>(["catalog-sync-runs", run.databaseId], (current) => {
if (!current) return [run];
const found = current.some((item) => item.id === run.id);
return found
? current.map((item) => item.id === run.id ? run : item)
: [run, ...current];
});
}, [onRunUpdate, queryClient, run]);
useEffect(() => {
if (!run || run.state !== "succeeded" || notifiedRun.current === run.id) return;
notifiedRun.current = run.id;
onCatalogChanged();
}, [onCatalogChanged, run]);
const currentPhase = run ? phases.indexOf(run.phase) : -1;
const knownEvents = [...events, ...(eventQuery.data ?? [])];
const confirmationWasRequired = run?.state === "awaiting_confirmation" || knownEvents.some((event) => (
event.eventType === "confirmation_required" || event.eventType === "confirmation_received"
));
const confirmationWasReceived = knownEvents.some((event) => event.eventType === "confirmation_received")
|| (run?.state === "succeeded" && confirmationWasRequired);
const furthestEventPhase = knownEvents.reduce((furthest, event) => {
const phase = phaseEnteredByEvent[event.eventType];
return phase ? Math.max(furthest, phases.indexOf(phase)) : furthest;
}, -1);
const confirmationPhase = phases.indexOf("awaiting_confirmation");
const passedConfirmation = run?.state === "succeeded"
|| (run && !terminal(run) ? currentPhase > confirmationPhase : furthestEventPhase > confirmationPhase);
const deletionCount = useMemo(() => run?.plannedDiff
? run.plannedDiff.deletedTables.length + run.plannedDiff.deletedColumns.length + run.plannedDiff.deletedRelationships.length
: 0, [run]);
const perform = async (kind: "confirm" | "cancel" | "retry") => {
if (!run) return;
setAction(kind);
try {
const next = kind === "confirm"
? await confirmCatalogSync(run.id, run.confirmationToken!)
: kind === "cancel"
? await cancelCatalogSync(run.id)
: await retryCatalogSync(run.id);
onRunChange(next);
queryClient.setQueryData(["catalog-sync-run", next.id], next);
await historyQuery.refetch();
} catch (error) {
toast.error(apiErrorMessage(error));
} finally {
setAction(null);
}
};
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}
</div>
<Button type="button" variant="ghost" size="icon-lg" aria-label="Close synchronization drawer" onClick={onClose}>
<X aria-hidden="true" />
</Button>
</div>
<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}
{run ? (
<>
<section aria-labelledby="synchronization-steps-heading">
<h3 id="synchronization-steps-heading" className="thot-label mb-3">Synchronization steps</h3>
<ol className="grid grid-cols-2 gap-x-4 gap-y-2.5 sm:grid-cols-3" aria-label="Synchronization steps">
{phases.map((phase, index) => {
const confirmationUnknown = phase === "awaiting_confirmation"
&& passedConfirmation
&& !eventQuery.isFetched
&& !confirmationWasRequired;
const confirmationSkipped = phase === "awaiting_confirmation"
&& passedConfirmation
&& eventQuery.isFetched
&& !confirmationWasRequired;
const terminalOutcome = phase === "completed" && terminal(run) && run.state !== "succeeded";
const failedOutcome = terminalOutcome && run.state === "failed";
const interruptedOutcome = terminalOutcome && run.state === "interrupted";
const priorPhaseCompleted = terminal(run)
? index < furthestEventPhase
: index < currentPhase;
const complete = !confirmationUnknown && !confirmationSkipped && !terminalOutcome
&& (phase === "awaiting_confirmation"
? confirmationWasReceived
: run.state === "succeeded" || priorPhaseCompleted);
const current = index === currentPhase && !terminal(run);
const label = phase === "awaiting_confirmation"
? confirmationSkipped
? "Confirmation not required"
: confirmationUnknown
? "Checking confirmation"
: confirmationWasReceived
? phaseLabels[phase].completed
: current
? phaseLabels[phase].current
: complete
? phaseLabels[phase].completed
: phaseLabels[phase].pending
: terminalOutcome
? `${stateLabel(run)[0].toUpperCase()}${stateLabel(run).slice(1)}`
: complete
? phaseLabels[phase].completed
: current
? phaseLabels[phase].current
: phaseLabels[phase].pending;
const status = terminalOutcome
? run.state
: confirmationSkipped
? "not required"
: complete
? "completed"
: current
? "in progress"
: "pending";
return (
<li
key={phase}
aria-current={current ? "step" : undefined}
aria-label={`${label}, ${status}`}
className={`flex items-center gap-2 text-xs ${failedOutcome ? "font-semibold text-destructive" : interruptedOutcome ? "font-semibold text-amber-600" : terminalOutcome || current ? "font-semibold text-foreground" : "text-muted-foreground"}`}
>
{confirmationSkipped ? (
<Minus aria-hidden="true" className="size-3.5 text-muted-foreground" />
) : failedOutcome ? (
<X aria-hidden="true" className="size-3.5 text-destructive" />
) : interruptedOutcome ? (
<AlertTriangle aria-hidden="true" className="size-3.5 text-amber-600" />
) : terminalOutcome ? (
<Minus aria-hidden="true" className="size-3.5 text-muted-foreground" />
) : complete ? (
<CheckCircle2 aria-hidden="true" className="size-3.5 text-[oklch(var(--success))]" />
) : current ? (
<LoaderCircle aria-hidden="true" className="size-3.5 animate-spin text-amber-600" />
) : (
<Circle aria-hidden="true" className="size-3.5" />
)}
{label}
</li>
);
})}
</ol>
</section>
<div className="mt-5 grid grid-cols-3 gap-2">
{(["tables", "columns", "relationships"] as const).map((name) => (
<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">{name}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">{run.counts[name] ?? ""}</p>
</div>
))}
</div>
{run.state === "awaiting_confirmation" && run.plannedDiff ? (
<section className="mt-5 rounded-md border border-amber-500/50 bg-amber-500/8 p-4" aria-label="Destructive changes">
<div className="flex gap-3">
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-amber-600" />
<div>
<h3 className="font-semibold">Confirm {deletionCount} removals</h3>
<p className="mt-1 text-sm text-muted-foreground">The database will be scanned again before anything is removed.</p>
</div>
</div>
<ul className="mt-3 max-h-40 space-y-1 overflow-y-auto font-mono text-xs">
{run.plannedDiff.deletedTables.map((name) => <li key={`t-${name}`}>table · {name}</li>)}
{run.plannedDiff.deletedColumns.map((item) => <li key={`c-${item.tableName}-${item.columnName}`}>column · {item.tableName}.{item.columnName}</li>)}
{run.plannedDiff.deletedRelationships.map((item) => <li key={`r-${item.sourceTableName}-${item.constraintName}`}>relationship · {item.sourceTableName}.{item.constraintName}</li>)}
</ul>
</section>
) : null}
{run.errorMessage ? (
<div className="mt-5 rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{run.errorMessage}
</div>
) : null}
<section className="mt-5" aria-label="Synchronization log">
<div className="mb-2 flex items-center justify-between">
<h3 className="thot-label">Live log</h3>
<span className="text-xs tabular-nums text-muted-foreground">{events.length}</span>
</div>
<div className="max-h-64 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>
</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>
</>
) : 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>
);
}
@@ -0,0 +1,192 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AgGridReact } from "ag-grid-react";
import type { ColDef, ICellRendererParams } from "ag-grid-community";
import { KeyRound, Link2, Pencil, RefreshCw, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { ApiError, apiErrorMessage } from "../../api/client";
import {
listCatalogColumns,
updateCatalogColumnMetadata,
type CatalogColumn,
type CatalogTable,
} from "../../api/catalog-databases";
import type { DatabaseNavigationState } from "./model";
interface Props {
databaseId: string;
table: CatalogTable;
canManage: boolean;
onNavigationStateChange: (state: DatabaseNavigationState) => void;
onSync: () => void;
}
interface GridContext {
canManage: boolean;
onEdit: (column: CatalogColumn, origin: HTMLButtonElement) => void;
}
function KeyCell({ data }: ICellRendererParams<CatalogColumn>) {
if (!data) return null;
return (
<div className="flex h-full items-center gap-1.5">
{data.isPrimaryKey ? <span className="inline-flex items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 text-xs font-semibold text-primary"><KeyRound className="size-3" />PK{data.primaryKeyPosition && data.primaryKeyPosition > 1 ? ` ${data.primaryKeyPosition}` : ""}</span> : null}
{data.isForeignKey ? <span className="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-xs font-semibold text-muted-foreground"><Link2 className="size-3" />FK{data.foreignKeyCount > 1 ? ` ${data.foreignKeyCount}` : ""}</span> : null}
</div>
);
}
function ActionCell({ data, context }: ICellRendererParams<CatalogColumn, unknown, GridContext>) {
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>
</div>
);
}
export function DatabaseColumns({ databaseId, table, canManage, onNavigationStateChange, onSync }: Props) {
const queryClient = useQueryClient();
const queryKey = ["catalog-columns", databaseId, table.id] as const;
const { data = [], isLoading, isFetching, refetch } = useQuery({
queryKey,
queryFn: () => listCatalogColumns(databaseId, table.id),
retry: false,
});
const [search, setSearch] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [description, setDescription] = useState("");
const [generatedDescription, setGeneratedDescription] = useState("");
const [baseline, setBaseline] = useState("");
const [version, setVersion] = useState<number | null>(null);
const [stale, setStale] = useState(false);
const [staleBannerOpen, setStaleBannerOpen] = useState(true);
const [busy, setBusy] = useState(false);
const originRef = useRef<HTMLButtonElement | null>(null);
const active = editingId ? data.find((column) => column.id === editingId) : undefined;
const fingerprint = JSON.stringify([description, generatedDescription]);
const dirty = Boolean(editingId && fingerprint !== baseline);
useEffect(() => { onNavigationStateChange({ dirty, busy }); }, [busy, dirty, onNavigationStateChange]);
useEffect(() => {
if (!editingId || !active || version === active.version || busy) return;
setStale(true);
setStaleBannerOpen(true);
}, [active, busy, editingId, version]);
const edit = (column: CatalogColumn, origin: HTMLButtonElement) => {
originRef.current = origin;
setEditingId(column.id);
setDescription(column.description ?? "");
setGeneratedDescription(column.generatedDescription ?? "");
setBaseline(JSON.stringify([column.description ?? "", column.generatedDescription ?? ""]));
setVersion(column.version);
setStale(false);
setStaleBannerOpen(true);
};
const closeEditor = () => {
if (busy) return;
if (dirty && !window.confirm("Discard unsaved column metadata?")) return;
setEditingId(null);
window.setTimeout(() => originRef.current?.focus(), 0);
};
const save = async () => {
if (!active || version === null) return;
setBusy(true);
try {
const updated = await updateCatalogColumnMetadata(
databaseId, table.id, active.id, version,
description.trim() || null, generatedDescription.trim() || null,
);
queryClient.setQueryData<CatalogColumn[]>(queryKey, (current = []) => current.map((column) => column.id === updated.id ? updated : column));
setDescription(updated.description ?? "");
setGeneratedDescription(updated.generatedDescription ?? "");
setBaseline(JSON.stringify([updated.description ?? "", updated.generatedDescription ?? ""]));
setVersion(updated.version);
setStale(false);
toast.success("Column metadata saved");
} catch (error) {
if (error instanceof ApiError && error.code === "column_stale") {
await refetch();
setStale(true);
setStaleBannerOpen(true);
} else toast.error(apiErrorMessage(error));
} finally { setBusy(false); }
};
const reloadColumn = async () => {
if (!editingId) return;
setBusy(true);
try {
const result = await refetch();
const latest = result.data?.find((column) => column.id === editingId);
if (!latest) { closeEditor(); return; }
setDescription(latest.description ?? "");
setGeneratedDescription(latest.generatedDescription ?? "");
setBaseline(JSON.stringify([latest.description ?? "", latest.generatedDescription ?? ""]));
setVersion(latest.version);
setStale(false);
setStaleBannerOpen(true);
} catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(false); }
};
const columns = useMemo<ColDef<CatalogColumn>[]>(() => [
{ field: "ordinalPosition", headerName: "#", width: 64, maxWidth: 64, filter: "agNumberColumnFilter" },
{ field: "name", headerName: "Name", minWidth: 190, flex: 1, cellClass: "font-mono text-xs" },
{ field: "dataType", headerName: "Type", minWidth: 150, flex: 0.8, cellClass: "font-mono text-xs" },
{ headerName: "Keys", minWidth: 125, width: 125, sortable: false, filter: false, cellRenderer: KeyCell },
{ headerName: "Nullable", minWidth: 100, width: 100, valueGetter: ({ data: row }) => row ? (row.isNullable ? "Yes" : "No") : "" },
{ 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 },
], []);
const context = useMemo<GridContext>(() => ({ canManage, onEdit: edit }), [canManage, data]);
if (editingId && active) {
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>
<p className="thot-label">{table.name} · column</p>
<h3 className="mt-1 font-heading text-2xl font-semibold">{active.name}</h3>
<p className="mt-1 text-sm text-muted-foreground">Physical schema fields are read-only. Review fields can be edited.</p>
{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 border-t border-border pt-5 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>
<div className="mt-5 flex justify-end gap-2 border-t border-border pt-4">
<Button type="button" variant="outline" disabled={busy} onClick={closeEditor}>Cancel</Button>
<Button type="button" disabled={!canManage || !dirty || busy || stale} onClick={() => void save()}><Save />{busy ? "Saving…" : "Save metadata"}</Button>
</div>
</div>
);
}
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">
<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>
<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">
<AgGridReact<CatalogColumn> 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 columns synchronized for this table." />
</div>
</div>
</div>
);
}
@@ -0,0 +1,510 @@
import type { RefObject, ReactNode } from "react";
import {
ArrowLeft,
Database,
RefreshCw,
Save,
TestTube2,
Trash2,
} from "lucide-react";
import { Button } from "../../components/ui/button";
import type {
CatalogDatabase,
CatalogSecretName,
CatalogSyncScope,
CatalogSyncRun,
DatabaseBinding,
DatabaseTransport,
} from "../../api/catalog-databases";
import type {
DatabaseBusyAction,
DatabaseFormDraft,
DatabaseFormMode,
} from "./model";
import { statusLabel } from "./model";
import { DatabaseSyncMenu } from "./DatabaseSyncMenu";
const inputClass = "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 read-only:bg-muted/40 read-only:text-muted-foreground disabled:bg-muted disabled:text-muted-foreground";
const labelClass = "grid min-w-0 gap-1.5 text-xs font-medium text-foreground";
interface FieldProps {
label: string;
hint?: string;
children: ReactNode;
}
function Field({ label, hint, children }: FieldProps) {
return (
<label className={labelClass}>
<span>{label}</span>
{children}
{hint ? <span className="font-normal leading-4 text-muted-foreground">{hint}</span> : null}
</label>
);
}
interface SecretFieldProps {
label: string;
name: CatalogSecretName;
row: CatalogDatabase;
draft: DatabaseFormDraft;
readOnly: boolean;
canManageSecrets: boolean;
onChange: (name: CatalogSecretName, value: string) => void;
multiline?: boolean;
}
function SecretField({
label,
name,
row,
draft,
readOnly,
canManageSecrets,
onChange,
multiline = false,
}: SecretFieldProps) {
if (readOnly) {
return (
<Field label={label} hint="Secret values are never displayed.">
<input
className={inputClass}
value={row.secrets[name] ? "Configured" : "Not configured"}
readOnly
aria-label={label}
/>
</Field>
);
}
const permissionHint = canManageSecrets
? undefined
: "You do not have permission to replace secret values.";
return (
<Field label={label} hint={permissionHint}>
{multiline ? (
<textarea
className={`${inputClass} min-h-24 resize-y py-2 font-mono text-xs leading-5`}
aria-label={label}
autoComplete="off"
value={draft.secrets[name] ?? ""}
disabled={!canManageSecrets}
placeholder={row.secrets[name] ? "Configured; leave blank to keep" : "Paste value"}
onChange={(event) => onChange(name, event.target.value)}
/>
) : (
<input
className={inputClass}
type="password"
aria-label={label}
autoComplete="new-password"
value={draft.secrets[name] ?? ""}
disabled={!canManageSecrets}
placeholder={row.secrets[name] ? "Configured; leave blank to keep" : "Not configured"}
onChange={(event) => onChange(name, event.target.value)}
/>
)}
</Field>
);
}
interface DatabaseFormProps {
mode: DatabaseFormMode;
row: CatalogDatabase;
availableWorkspaces: CatalogDatabase[];
workspaceLocked: boolean;
draft: DatabaseFormDraft;
dirty: boolean;
canManage: boolean;
canManageSecrets: boolean;
busyAction: DatabaseBusyAction;
stale: boolean;
staleBannerOpen: boolean;
partialSecretFailure: string | null;
headingRef: RefObject<HTMLHeadingElement>;
onBack: () => void;
onWorkspaceChange: (workspaceId: string) => void;
onFieldChange: (field: "databaseName" | "schema", value: string) => void;
onTransportChange: (transport: DatabaseTransport) => void;
onBindingChange: <K extends keyof DatabaseBinding>(key: K, value: DatabaseBinding[K]) => void;
onSecretChange: (name: CatalogSecretName, value: string) => void;
onSave: () => void;
onTest: () => void;
onDelete: () => void;
onReloadLatest: () => void;
onKeepEditing: () => void;
onRetrySecrets: () => void;
onOpenTables: (origin: HTMLButtonElement) => void;
onOpenRelationships: () => void;
onSync: (scope: CatalogSyncScope) => void;
onOpenSync: () => void;
activeSyncRun?: CatalogSyncRun;
}
function formTitle(mode: DatabaseFormMode, workspaceLocked: boolean): string {
if (mode === "add") return workspaceLocked ? "Edit database" : "Add database";
if (mode === "edit") return "Edit database";
if (mode === "delete") return "Delete database";
return "Database details";
}
export function DatabaseForm({
mode,
row,
availableWorkspaces,
workspaceLocked,
draft,
dirty,
canManage,
canManageSecrets,
busyAction,
stale,
staleBannerOpen,
partialSecretFailure,
headingRef,
onBack,
onWorkspaceChange,
onFieldChange,
onTransportChange,
onBindingChange,
onSecretChange,
onSave,
onTest,
onDelete,
onReloadLatest,
onKeepEditing,
onRetrySecrets,
onOpenTables,
onOpenRelationships,
onSync,
onOpenSync,
activeSyncRun,
}: DatabaseFormProps) {
const title = formTitle(mode, workspaceLocked);
const readOnly = mode === "view" || mode === "delete";
const editable = mode === "add" || mode === "edit";
const busy = busyAction !== null;
const testDisabled = !canManage
|| !row.configured
|| !row.id
|| dirty
|| stale
|| busy;
const saveDisabled = !canManage
|| stale
|| busy
|| (mode === "edit" && !dirty);
const bindingReady = row.connectionStatus === "reachable" && row.testedVersion === row.version;
const form = (
<form
className="grid gap-5"
onSubmit={(event) => {
event.preventDefault();
if (editable) onSave();
}}
>
{mode === "delete" ? (
<div className="rounded-md border border-destructive/35 bg-destructive/8 p-4 text-sm text-foreground" role="alert">
<p className="font-semibold text-destructive">This removes the local database configuration.</p>
<p className="mt-1 leading-5 text-muted-foreground">
{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.
</p>
</div>
) : null}
{stale ? (
staleBannerOpen ? (
<div className="rounded-md border border-amber-300/70 bg-amber-50 p-4 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100" role="alert">
<p className="font-semibold">A newer database configuration is available.</p>
<p className="mt-1 leading-5">Reload the latest values before saving, testing, or deleting. Your draft is still intact.</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={onReloadLatest}>
<RefreshCw /> Reload latest
</Button>
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={onKeepEditing}>
Keep editing
</Button>
</div>
</div>
) : (
<div className="rounded-md border border-amber-300/70 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100" role="status">
Reload the latest values before this configuration can be changed.
</div>
)
) : null}
{partialSecretFailure ? (
<div className="rounded-md border border-amber-300/70 bg-amber-50 p-4 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100" role="alert">
<p className="font-semibold">Database configuration saved; secret update could not be confirmed.</p>
<p className="mt-1 leading-5">{partialSecretFailure} The values you entered are retained in this form.</p>
<Button type="button" size="sm" variant="outline" className="mt-3" disabled={busy || stale} onClick={onRetrySecrets}>
<RefreshCw /> {busyAction === "retry-secrets" ? "Retrying…" : "Retry secrets"}
</Button>
</div>
) : null}
{editable && !canManageSecrets ? (
<div className="rounded-md border border-border bg-muted/45 px-4 py-3 text-sm text-muted-foreground" role="note">
Secret status is visible, but replacing secret values requires the workspace.secrets.manage permission.
</div>
) : null}
{!row.workspaceAvailable ? (
<div className="rounded-md border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive" role="alert">
This configuration references a workspace that is no longer present in the repository YAML.
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2">
{mode === "add" ? (
<Field label="Workspace">
<select
className={inputClass}
aria-label="Workspace"
value={draft.workspaceId}
disabled={workspaceLocked || busy}
onChange={(event) => onWorkspaceChange(event.target.value)}
>
{availableWorkspaces.map((workspace) => (
<option key={workspace.workspaceId} value={workspace.workspaceId}>
{workspace.workspaceName}
</option>
))}
</select>
</Field>
) : (
<Field label="Workspace">
<input className={inputClass} value={row.workspaceName} readOnly />
</Field>
)}
<Field label="Workspace ID">
<input className={`${inputClass} font-mono text-xs`} value={row.workspaceId} readOnly />
</Field>
<Field label="Database engine">
<input className={inputClass} value="PostgreSQL" readOnly />
</Field>
<Field label="Transport">
<select
className={inputClass}
aria-label="Transport"
value={draft.binding.transport}
disabled={readOnly || busy}
onChange={(event) => onTransportChange(event.target.value as DatabaseTransport)}
>
<option value="postgres_direct">Direct PostgreSQL</option>
<option value="rest_api">REST API</option>
<option value="ssh_tunnel">SSH tunnel</option>
</select>
</Field>
<Field label="Database name">
<input
className={inputClass}
required={editable}
value={draft.databaseName}
readOnly={readOnly}
disabled={busy}
onChange={(event) => onFieldChange("databaseName", event.target.value)}
/>
</Field>
<Field label="Schema">
<input
className={inputClass}
required={editable}
value={draft.schema}
readOnly={readOnly}
disabled={busy}
onChange={(event) => onFieldChange("schema", event.target.value)}
/>
</Field>
</div>
<div className="border-t border-border pt-5">
<p className="thot-label mb-4">Installation binding</p>
{draft.binding.transport === "postgres_direct" ? (
<div className="grid gap-4 md:grid-cols-2">
<Field label="Host">
<input className={inputClass} required={editable} value={draft.binding.host ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("host", event.target.value)} />
</Field>
<Field label="Port">
<input className={inputClass} type="number" min={1} max={65535} required={editable} value={draft.binding.port ?? 5432} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("port", Number(event.target.value))} />
</Field>
<Field label="Username">
<input className={inputClass} required={editable} value={draft.binding.username ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("username", event.target.value)} />
</Field>
<Field label="TLS server name">
<input className={inputClass} value={draft.binding.tlsServername ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("tlsServername", event.target.value || undefined)} />
</Field>
<SecretField label="Password" name="password" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
<SecretField label="TLS CA certificate" name="tlsCa" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
</div>
) : null}
{draft.binding.transport === "rest_api" ? (
<div className="grid gap-4 md:grid-cols-2">
<Field label="Base URL">
<input className={inputClass} type="url" required={editable} placeholder="https://dwh.example/api" value={draft.binding.baseUrl ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("baseUrl", event.target.value)} />
</Field>
<Field label="Diagnostic endpoint" hint="This relative path is fixed by the runtime contract.">
<input className={inputClass} value={draft.binding.restPath ?? "/health"} readOnly aria-label="Diagnostic endpoint" />
</Field>
<Field label="Authentication">
<select className={inputClass} aria-label="Authentication" value={draft.binding.restAuth ?? "x-api-key"} disabled={readOnly || busy} onChange={(event) => onBindingChange("restAuth", event.target.value as DatabaseBinding["restAuth"])}>
<option value="x-api-key">X-API-Key</option>
<option value="bearer">Bearer token</option>
<option value="none">None</option>
</select>
</Field>
{draft.binding.restAuth !== "none" ? (
<SecretField label="API key" name="apiKey" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
) : null}
</div>
) : null}
{draft.binding.transport === "ssh_tunnel" ? (
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-md border border-border bg-muted/45 px-4 py-3 text-sm text-muted-foreground md:col-span-2" role="note">
SSH host verification is strict. Provide the trusted known_hosts entry before testing this binding.
</div>
<Field label="Database username">
<input className={inputClass} required={editable} value={draft.binding.username ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("username", event.target.value)} />
</Field>
<SecretField label="Database password" name="password" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
<Field label="SSH host">
<input className={inputClass} required={editable} value={draft.binding.sshHost ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshHost", event.target.value)} />
</Field>
<Field label="SSH port">
<input className={inputClass} type="number" min={1} max={65535} required={editable} value={draft.binding.sshPort ?? 22} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshPort", Number(event.target.value))} />
</Field>
<Field label="SSH username">
<input className={inputClass} required={editable} value={draft.binding.sshUsername ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshUsername", event.target.value)} />
</Field>
<SecretField label="SSH private key" name="sshPrivateKey" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} multiline />
<SecretField label="Private key passphrase" name="sshPrivateKeyPassphrase" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
<SecretField label="SSH known hosts" name="sshKnownHosts" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} multiline />
<Field label="Target host">
<input className={inputClass} required={editable} value={draft.binding.sshTargetHost ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshTargetHost", event.target.value)} />
</Field>
<Field label="Target port">
<input className={inputClass} type="number" min={1} max={65535} required={editable} value={draft.binding.sshTargetPort ?? 5432} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshTargetPort", Number(event.target.value))} />
</Field>
<Field label="TLS server name">
<input className={inputClass} value={draft.binding.tlsServername ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("tlsServername", event.target.value || undefined)} />
</Field>
<SecretField label="TLS CA certificate" name="tlsCa" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} multiline />
</div>
) : null}
</div>
{row.lastErrorMessage ? (
<div className="rounded-md border border-destructive/25 bg-destructive/8 px-4 py-3 text-sm text-destructive" role="status">
Last connection error: {row.lastErrorMessage}
</div>
) : null}
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-border pt-4">
{mode === "view" || mode === "edit" ? (
<Button type="button" variant="outline" disabled={testDisabled} onClick={onTest}>
<TestTube2 /> {busyAction === "test" ? "Testing…" : "Test connection"}
</Button>
) : null}
{mode === "add" || mode === "edit" ? (
<Button type="submit" disabled={saveDisabled}>
<Save />
{busyAction === "save"
? "Saving…"
: mode === "add"
? workspaceLocked ? "Save database" : "Add database"
: "Save changes"}
</Button>
) : null}
{mode === "delete" ? (
<Button type="button" variant="destructive" disabled={!canManage || !row.configured || !row.id || busy || stale} onClick={onDelete}>
<Trash2 /> {busyAction === "delete" ? "Deleting…" : "Delete database"}
</Button>
) : null}
</div>
</form>
);
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">
<Button type="button" variant="ghost" className="-ml-2 mb-5" disabled={busy} onClick={onBack}>
<ArrowLeft /> Back to list
</Button>
<div className="mb-6 flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5">
<div className="min-w-0">
<p className="thot-label mb-1">Database management</p>
<h2 ref={headingRef} tabIndex={-1} className="font-heading text-2xl font-semibold tracking-tight outline-none">
{title}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{row.workspaceName} · {row.workspaceId}
</p>
{row.lastTestedAt ? (
<p className="mt-1 text-xs text-muted-foreground">Last tested {new Date(row.lastTestedAt).toLocaleString()}</p>
) : null}
</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-full border border-border bg-muted px-2.5 py-1 text-xs font-semibold text-muted-foreground">
{statusLabel(row)}
</span>
{mode === "view" && activeSyncRun ? (
<Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />View sync</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>
{mode === "view" ? (
<nav aria-label="Database sections" className="mb-6 flex gap-1 border-b border-border" role="tablist">
<button
type="button"
role="tab"
aria-selected="true"
className="border-b-2 border-primary px-3 py-2 text-sm font-semibold text-foreground"
>
Overview
</button>
<button
type="button"
role="tab"
aria-selected="false"
disabled={!row.configured || !row.id}
className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-45"
onClick={(event) => onOpenTables(event.currentTarget)}
>
Tables
</button>
<button
type="button"
role="tab"
aria-selected="false"
disabled={!row.configured || !row.id}
className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-45"
onClick={onOpenRelationships}
>
Relationships
</button>
</nav>
) : null}
{form}
</div>
</section>
);
}
@@ -0,0 +1,329 @@
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { Menu } from "@base-ui/react/menu";
import { AgGridReact } from "ag-grid-react";
import {
AllCommunityModule,
ModuleRegistry,
type ColDef,
type ICellRendererParams,
} 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, Trash2, X } from "lucide-react";
import { Button } from "../../components/ui/button";
import type { CatalogDatabase, CatalogSyncScope } from "../../api/catalog-databases";
import { statusLabel } from "./model";
import { databaseSyncItemClass, databaseSyncScopes } from "./DatabaseSyncMenu";
ModuleRegistry.registerModules([AllCommunityModule]);
interface DatabaseGridProps {
rows: CatalogDatabase[];
isLoading: boolean;
isFetching: boolean;
search: string;
canManage: boolean;
searchInputRef: RefObject<HTMLInputElement>;
onSearchChange: (value: string) => void;
onView: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
onEdit: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
onDelete: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
onOpenSync: (row: CatalogDatabase) => void;
onTestSelected: (rows: CatalogDatabase[]) => Promise<void>;
onSyncSelected: (rows: CatalogDatabase[], scope: CatalogSyncScope) => Promise<void>;
}
interface DatabaseGridContext {
canManage: boolean;
onView: DatabaseGridProps["onView"];
onEdit: DatabaseGridProps["onEdit"];
onDelete: DatabaseGridProps["onDelete"];
onOpenSync: DatabaseGridProps["onOpenSync"];
}
function useCompactViewport(): boolean {
const [compact, setCompact] = useState(false);
useEffect(() => {
if (typeof window.matchMedia !== "function") return;
const query = window.matchMedia("(max-width: 767px)");
const update = () => setCompact(query.matches);
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
return compact;
}
function StatusPill({ row }: { row: CatalogDatabase }) {
const tone = !row.workspaceAvailable
? "border-destructive/30 bg-destructive/10 text-destructive"
: !row.configured
? "border-border bg-muted text-muted-foreground"
: row.connectionStatus === "reachable"
? "border-emerald-300/70 bg-emerald-50 text-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-300"
: row.connectionStatus === "failed"
? "border-destructive/30 bg-destructive/10 text-destructive"
: "border-amber-300/70 bg-amber-50 text-amber-900 dark:bg-amber-950/30 dark:text-amber-300";
return <div className="flex items-center gap-1.5"><span className={`inline-flex max-w-full truncate rounded-full border px-2 py-0.5 text-[11px] font-semibold ${tone}`}>{statusLabel(row)}</span>{row.activeSyncRun ? <span className="inline-flex rounded-full border border-primary/30 bg-primary/8 px-2 py-0.5 text-[11px] font-semibold text-primary">Syncing</span> : null}</div>;
}
function DatabaseActionsCell({
data,
context,
}: ICellRendererParams<CatalogDatabase, unknown, DatabaseGridContext>) {
if (!data || !context) return null;
const editDisabled = !context.canManage || !data.workspaceAvailable;
const deleteDisabled = !context.canManage || !data.configured;
const editLabel = `Edit ${data.workspaceName}`;
const editReasonId = `database-edit-reason-${data.workspaceId}`;
const deleteReasonId = `database-delete-reason-${data.workspaceId}`;
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" />
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon-lg"
title={`View ${data.workspaceName}`}
aria-label={`View ${data.workspaceName}`}
onClick={(event) => context.onView(data, event.currentTarget)}
>
<Eye aria-hidden="true" />
</Button>
<span title={editDisabled ? "This database configuration cannot be changed" : editLabel}>
<Button
type="button"
variant="ghost"
size="icon-lg"
aria-label={editLabel}
aria-describedby={editDisabled ? editReasonId : undefined}
disabled={editDisabled}
onClick={(event) => context.onEdit(data, event.currentTarget)}
>
<Pencil aria-hidden="true" />
</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}`}>
<Button
type="button"
variant="ghost"
size="icon-lg"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
aria-label={`Delete ${data.workspaceName}`}
aria-describedby={deleteDisabled ? deleteReasonId : undefined}
disabled={deleteDisabled}
onClick={(event) => context.onDelete(data, event.currentTarget)}
>
<Trash2 aria-hidden="true" />
</Button>
{deleteDisabled ? <span id={deleteReasonId} className="sr-only">Save this database configuration before deleting it.</span> : null}
</span>
</div>
);
}
export function DatabaseGrid({
rows,
isLoading,
isFetching,
search,
canManage,
searchInputRef,
onSearchChange,
onView,
onEdit,
onDelete,
onOpenSync,
onTestSelected,
onSyncSelected,
}: DatabaseGridProps) {
const compact = useCompactViewport();
const gridRef = useRef<AgGridReact<CatalogDatabase>>(null);
const [selectedRows, setSelectedRows] = useState<CatalogDatabase[]>([]);
const [action, setAction] = useState<"test" | "sync" | null>(null);
const context = useMemo<DatabaseGridContext>(
() => ({ canManage, onView, onEdit, onDelete, onOpenSync }),
[canManage, onView, onEdit, onDelete, onOpenSync],
);
const columnDefs = useMemo<ColDef<CatalogDatabase>[]>(() => {
const workspace: ColDef<CatalogDatabase> = {
field: "workspaceName",
headerName: "Workspace",
minWidth: compact ? 112 : 190,
flex: compact ? undefined : 1.35,
width: compact ? 112 : undefined,
};
const database: ColDef<CatalogDatabase> = {
field: "databaseName", headerName: "Database", minWidth: 145, flex: 1,
};
const schema: ColDef<CatalogDatabase> = {
field: "schema", headerName: "Schema", minWidth: 140, flex: 0.9,
};
const transport: ColDef<CatalogDatabase> = {
field: "binding.transport",
headerName: "Transport",
minWidth: 135,
flex: 0.9,
valueFormatter: ({ value }) => String(value ?? "").replaceAll("_", " "),
};
const endpoint: ColDef<CatalogDatabase> = {
headerName: "Endpoint",
minWidth: 190,
flex: 1.2,
valueGetter: ({ data }) => {
if (!data) return "";
if (data.binding.transport === "rest_api") return data.binding.baseUrl ?? "";
if (data.binding.transport === "ssh_tunnel") return data.binding.sshHost ?? "";
return data.binding.host ? `${data.binding.host}:${data.binding.port ?? 5432}` : "";
},
};
const status: ColDef<CatalogDatabase> = {
headerName: "Status",
minWidth: compact ? 96 : 130,
width: compact ? 96 : undefined,
flex: compact ? undefined : 0.8,
cellClass: compact ? "thot-database-status-cell" : undefined,
valueGetter: ({ data }) => (data ? statusLabel(data) : ""),
cellRenderer: ({ data }: ICellRendererParams<CatalogDatabase>) => (
data ? <StatusPill row={data} /> : null
),
};
const updated: ColDef<CatalogDatabase> = {
field: "updatedAt",
headerName: "Updated",
minWidth: 170,
flex: 0.9,
valueFormatter: ({ value }) => value ? new Date(String(value)).toLocaleString() : "",
};
const actionsWidth = compact ? 148 : 164;
const actions: ColDef<CatalogDatabase> = {
colId: "actions",
headerName: "Actions",
width: actionsWidth,
minWidth: actionsWidth,
maxWidth: actionsWidth,
pinned: "right",
lockPinned: true,
sortable: false,
filter: false,
resizable: false,
suppressMovable: true,
suppressHeaderMenuButton: true,
cellClass: "thot-database-actions-cell",
cellRenderer: DatabaseActionsCell,
};
return compact
? [workspace, status, database, schema, transport, endpoint, updated, actions]
: [workspace, database, schema, transport, endpoint, status, updated, actions];
}, [compact]);
const configuredCount = rows.filter((row) => row.configured).length;
const hiddenSelected = selectedRows.filter((row) => {
const term = search.trim().toLocaleLowerCase();
return term && ![row.workspaceName, row.databaseName, row.schema].some((value) => value.toLocaleLowerCase().includes(term));
}).length;
const canTestSelection = canManage && selectedRows.length > 0 && selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun);
const canSyncSelection = canManage && selectedRows.length > 0 && selectedRows.every((row) => row.configured && row.id && row.connectionStatus === "reachable" && row.testedVersion === row.version && !row.activeSyncRun);
const perform = async (kind: "test" | "sync", operation: () => Promise<void>) => {
setAction(kind);
try {
await operation();
gridRef.current?.api.deselectAll();
setSelectedRows([]);
} finally { setAction(null); }
};
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">
{selectedRows.length > 0 ? (
<>
<span className="text-sm font-semibold">{selectedRows.length} selected</span>
{hiddenSelected ? <span className="text-xs text-muted-foreground">{hiddenSelected} hidden by filter</span> : null}
<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={action !== 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={!canTestSelection} onClick={() => void perform("test", () => onTestSelected(selectedRows))}>Test connections</Menu.Item>
<Menu.Separator className="my-1 h-px bg-border" />
{databaseSyncScopes.map(({ scope, label }) => (
<Menu.Item
key={scope}
className={databaseSyncItemClass}
disabled={!canSyncSelection}
onClick={() => void perform("sync", () => onSyncSelected(selectedRows, scope))}
>
{label}
</Menu.Item>
))}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<Button type="button" variant="ghost" disabled={action !== null} onClick={() => { gridRef.current?.api.deselectAll(); setSelectedRows([]); }}><X />Clear</Button>
</>
) : <><span className="thot-label whitespace-nowrap">Workspace databases</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 transition focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label="Search databases"
placeholder="Search"
value={search}
onChange={(event) => onSearchChange(event.target.value)}
/></>}
<span className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">
{configuredCount}/{rows.length}
</span>
{isFetching && !isLoading ? (
<span className="text-xs text-muted-foreground" role="status">Refreshing</span>
) : 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">
<AgGridReact<CatalogDatabase>
ref={gridRef}
rowData={rows}
columnDefs={columnDefs}
context={context}
loading={isLoading}
quickFilterText={search}
defaultColDef={{
sortable: true,
filter: true,
resizable: true,
suppressHeaderMenuButton: false,
}}
getRowId={({ data }) => data.workspaceId}
rowSelection={{ mode: "multiRow", selectAll: "filtered", enableClickSelection: false }}
selectionColumnDef={{ width: 44, maxWidth: 44, pinned: "left" }}
onSelectionChanged={({ api }) => setSelectedRows(api.getSelectedRows())}
rowHeight={44}
headerHeight={38}
animateRows={false}
/>
</div>
{isLoading ? (
<div className="pointer-events-none absolute inset-0 grid place-items-center bg-card/80 text-sm text-muted-foreground" role="status">
Loading database configurations…
</div>
) : rows.length === 0 ? (
<div className="pointer-events-none absolute inset-0 grid place-items-center bg-card px-6 text-center text-sm text-muted-foreground" role="status">
No repository workspaces are available for database configuration.
</div>
) : null}
</div>
</section>
);
}
@@ -0,0 +1,103 @@
import { useMemo, useState } 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 { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { apiErrorMessage } from "../../api/client";
import {
listCatalogRelationships,
startCatalogSync,
type CatalogDatabase,
type CatalogRelationship,
type CatalogSyncRun,
} from "../../api/catalog-databases";
interface Props {
database: CatalogDatabase;
canManage: boolean;
onBackToDatabases: () => void;
onOpenOverview: () => void;
onOpenTables: () => void;
onRunStarted: (run: CatalogSyncRun) => void;
onOpenSync: () => void;
}
export function DatabaseRelationships({
database,
canManage,
onBackToDatabases,
onOpenOverview,
onOpenTables,
onRunStarted,
onOpenSync,
}: Props) {
const databaseId = database.id!;
const { data = [], isLoading, isFetching, refetch } = useQuery({
queryKey: ["catalog-relationships", databaseId],
queryFn: () => listCatalogRelationships(databaseId),
retry: false,
});
const [search, setSearch] = useState("");
const [busy, setBusy] = useState(false);
const bindingReady = database.connectionStatus === "reachable" && database.testedVersion === database.version;
const synchronize = async (scope: "relationships" | "all") => {
setBusy(true);
try {
const run = await startCatalogSync(databaseId, database.version, scope);
onRunStarted(run);
toast.success(scope === "all" ? "Full schema synchronization started" : "Relationship synchronization started");
} catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(false); }
};
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 },
{ headerName: "Source columns", minWidth: 200, flex: 1, valueGetter: ({ data: row }) => row?.columns.map((pair) => pair.sourceColumnName).join(", ") ?? "" },
{ field: "targetTableName", headerName: "Target table", minWidth: 200, flex: 1 },
{ headerName: "Target columns", minWidth: 200, flex: 1, valueGetter: ({ data: row }) => row?.columns.map((pair) => pair.targetColumnName).join(", ") ?? "" },
{ field: "updateRule", headerName: "On update", minWidth: 125, width: 125 },
{ field: "deleteRule", headerName: "On delete", minWidth: 125, width: 125 },
], []);
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">
<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>
<p className="thot-label mb-1">Database management</p>
<h2 className="font-heading text-2xl font-semibold tracking-tight">{database.workspaceName}</h2>
<p className="mt-1 text-sm text-muted-foreground">{database.databaseName} · {database.schema}</p>
</div>
<div className="flex gap-2">
{database.activeSyncRun ? <Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />View sync</Button> : null}
<Button type="button" disabled={!canManage || !bindingReady || busy || Boolean(database.activeSyncRun)} onClick={() => void synchronize("all")}><RefreshCw />Sync all</Button>
</div>
</div>
<nav aria-label="Database sections" className="flex gap-1 border-b border-border" role="tablist">
<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={onOpenOverview}>Overview</button>
<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">
<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>
</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." />
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,54 @@
import { Menu } from "@base-ui/react/menu";
import { ChevronDown, RefreshCw } from "lucide-react";
import { buttonVariants } from "../../components/ui/button";
import type { CatalogSyncScope } from "../../api/catalog-databases";
export const databaseSyncItemClass = [
"flex cursor-default select-none items-center rounded-md px-3 py-2 text-sm outline-none",
"data-[highlighted]:bg-muted data-[disabled]:opacity-45",
].join(" ");
export const databaseSyncScopes: ReadonlyArray<{ scope: CatalogSyncScope; label: string }> = [
{ scope: "tables", label: "Synchronize tables" },
{ scope: "columns", label: "Synchronize all columns" },
{ scope: "relationships", label: "Synchronize relationships" },
{ scope: "all", label: "Synchronize all" },
];
interface DatabaseSyncMenuProps {
disabled: boolean;
disabledReason?: string;
onSelect: (scope: CatalogSyncScope) => void;
}
export function DatabaseSyncMenu({ disabled, disabledReason, onSelect }: DatabaseSyncMenuProps) {
return (
<Menu.Root>
<Menu.Trigger
type="button"
className={buttonVariants()}
disabled={disabled}
title={disabledReason}
aria-label="Synchronize database schema"
>
<RefreshCw />Synchronize<ChevronDown />
</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner side="bottom" align="end" 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">
{databaseSyncScopes.map(({ scope, label }) => (
<Menu.Item
key={scope}
className={databaseSyncItemClass}
disabled={disabled}
onClick={() => onSelect(scope)}
>
{label}
</Menu.Item>
))}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
);
}
@@ -0,0 +1,315 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AgGridReact } from "ag-grid-react";
import type { ColDef, ICellRendererParams } from "ag-grid-community";
import { ArrowLeft, Columns3, Pencil, RefreshCw, Save, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { ApiError, apiErrorMessage } from "../../api/client";
import {
listCatalogTables,
startCatalogSync,
updateCatalogTableMetadata,
type CatalogDatabase,
type CatalogSyncRun,
type CatalogTable,
} from "../../api/catalog-databases";
import type { DatabaseNavigationState } from "./model";
import { DatabaseColumns } from "./DatabaseColumns";
interface Props {
database: CatalogDatabase;
canManage: boolean;
activeRun?: CatalogSyncRun;
onBackToDatabases: () => void;
onOpenOverview: () => void;
onOpenRelationships: () => void;
onNavigationStateChange: (state: DatabaseNavigationState) => void;
onRunStarted: (run: CatalogSyncRun) => void;
onOpenSync: () => void;
}
interface TableGridContext {
canManage: boolean;
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)}>
<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)}>
<Pencil aria-hidden="true" />
</Button>
</div>
);
}
export function DatabaseTables({
database,
canManage,
activeRun,
onBackToDatabases,
onOpenOverview,
onOpenRelationships,
onNavigationStateChange,
onRunStarted,
onOpenSync,
}: 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 [search, setSearch] = useState("");
const [displayedCount, setDisplayedCount] = useState(0);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [activeTableId, setActiveTableId] = useState<string | null>(null);
const [tableSection, setTableSection] = useState<"overview" | "columns">("overview");
const [description, setDescription] = useState("");
const [generatedDescription, setGeneratedDescription] = useState("");
const [baseline, setBaseline] = useState("");
const [editorVersion, setEditorVersion] = useState<number | null>(null);
const [stale, setStale] = useState(false);
const [staleBannerOpen, setStaleBannerOpen] = useState(true);
const [busy, setBusy] = useState<"sync" | "save" | null>(null);
const [columnNavigation, setColumnNavigation] = useState<DatabaseNavigationState>({ dirty: false, busy: false });
const gridRef = useRef<AgGridReact<CatalogTable>>(null);
const originRef = useRef<HTMLButtonElement | null>(null);
const headingRef = useRef<HTMLHeadingElement>(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 bindingReady = database.connectionStatus === "reachable" && database.testedVersion === database.version;
const currentRun = activeRun ?? database.activeSyncRun;
useEffect(() => { onNavigationStateChange({ dirty, busy: navigationBusy }); }, [dirty, navigationBusy, onNavigationStateChange]);
useEffect(() => {
if (!activeTableId || !activeTable || editorVersion === activeTable.version || busy) return;
setStale(true);
setStaleBannerOpen(true);
}, [activeTable, activeTableId, busy, editorVersion]);
const openTable = (table: CatalogTable, section: "overview" | "columns", origin: HTMLButtonElement) => {
originRef.current = origin;
setActiveTableId(table.id);
setTableSection(section);
setDescription(table.description ?? "");
setGeneratedDescription(table.generatedDescription ?? "");
setBaseline(JSON.stringify([table.description ?? "", table.generatedDescription ?? ""]));
setEditorVersion(table.version);
setStale(false);
setStaleBannerOpen(true);
setColumnNavigation({ dirty: false, busy: false });
};
const leaveTable = () => {
if (navigationBusy) return;
if (dirty && !window.confirm("Discard unsaved metadata?")) return;
setActiveTableId(null);
setTableSection("overview");
window.setTimeout(() => originRef.current?.focus(), 0);
};
const navigateDatabase = (target: "databases" | "overview" | "relationships") => {
if (navigationBusy) return;
if (dirty && !window.confirm("Discard unsaved metadata?")) return;
if (target === "databases") onBackToDatabases();
else if (target === "overview") onOpenOverview();
else onOpenRelationships();
};
const save = async () => {
if (!activeTable || editorVersion === null) return;
setBusy("save");
try {
const updated = await updateCatalogTableMetadata(
databaseId, activeTable.id, editorVersion,
description.trim() || null, generatedDescription.trim() || null,
);
queryClient.setQueryData<CatalogTable[]>(queryKey, (current = []) => current.map((table) => table.id === updated.id ? updated : table));
setDescription(updated.description ?? "");
setGeneratedDescription(updated.generatedDescription ?? "");
setBaseline(JSON.stringify([updated.description ?? "", updated.generatedDescription ?? ""]));
setEditorVersion(updated.version);
setStale(false);
toast.success("Table metadata saved");
} catch (error) {
if (error instanceof ApiError && error.code === "table_stale") {
await refetch();
setStale(true);
setStaleBannerOpen(true);
} else toast.error(apiErrorMessage(error));
} finally { setBusy(null); }
};
const reloadTable = async () => {
if (!activeTableId) return;
setBusy("sync");
try {
const result = await refetch();
const latest = result.data?.find((table) => table.id === activeTableId);
if (!latest) { leaveTable(); return; }
setDescription(latest.description ?? "");
setGeneratedDescription(latest.generatedDescription ?? "");
setBaseline(JSON.stringify([latest.description ?? "", latest.generatedDescription ?? ""]));
setEditorVersion(latest.version);
setStale(false);
setStaleBannerOpen(true);
} catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(null); }
};
const synchronize = async (scope: "tables" | "columns" | "all", tableIds: string[] = []) => {
setBusy("sync");
try {
const run = await startCatalogSync(databaseId, database.version, scope, tableIds);
onRunStarted(run);
gridRef.current?.api.deselectAll();
setSelectedIds([]);
toast.success(scope === "all" ? "Full schema synchronization started" : scope === "columns" ? "Column synchronization started" : "Table synchronization started");
} catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(null); }
};
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 },
], []);
const context = useMemo<TableGridContext>(() => ({ canManage, onOpen: openTable }), [canManage, data]);
const visibleSelected = useMemo(() => {
const term = search.trim().toLocaleLowerCase();
if (!term) return selectedIds.length;
const selected = new Set(selectedIds);
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 databaseHeader = (
<div className="px-4 pt-5 sm:px-5">
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={navigationBusy} onClick={() => navigateDatabase("databases")}><ArrowLeft />Back to databases</Button>
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5">
<div>
<p className="thot-label mb-1">Database management</p>
<h2 ref={headingRef} tabIndex={-1} className="font-heading text-2xl font-semibold tracking-tight outline-none">{database.workspaceName}</h2>
<p className="mt-1 text-sm text-muted-foreground">{database.databaseName} · {database.schema}</p>
</div>
<div className="flex gap-2">
{currentRun ? <Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />View sync</Button> : null}
<Button type="button" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("all")}><RefreshCw />Sync all</Button>
</div>
</div>
<nav aria-label="Database sections" className="flex gap-1 border-b border-border" role="tablist">
<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={() => navigateDatabase("overview")}>Overview</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">Tables</button>
<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={() => navigateDatabase("relationships")}>Relationships</button>
</nav>
</div>
);
if (activeTable) {
return (
<section aria-label={`Table ${activeTable.name}`} 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="border-b border-border px-4 pt-4">
<Button type="button" variant="ghost" className="-ml-2 mb-2" onClick={leaveTable}><ArrowLeft />Back to tables</Button>
<p className="thot-label">Physical table</p>
<h3 className="mt-1 font-heading text-xl font-semibold">{activeTable.name}</h3>
<nav aria-label="Table sections" className="mt-3 flex gap-1" role="tablist">
<button type="button" role="tab" aria-selected={tableSection === "overview"} className={`border-b-2 px-3 py-2 text-sm font-semibold ${tableSection === "overview" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`} onClick={() => setTableSection("overview")}>Overview</button>
<button type="button" role="tab" aria-selected={tableSection === "columns"} className={`border-b-2 px-3 py-2 text-sm font-semibold ${tableSection === "columns" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`} onClick={() => setTableSection("columns")}>Columns</button>
</nav>
</div>
{tableSection === "columns" ? (
<DatabaseColumns databaseId={databaseId} table={activeTable} canManage={canManage} onNavigationStateChange={setColumnNavigation} onSync={() => void synchronize("columns", [activeTable.id])} />
) : (
<div className="min-h-0 overflow-y-auto px-4 py-5">
<p className="text-sm text-muted-foreground">Only review metadata can be changed.</p>
{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>
<div className="mt-5 flex justify-end gap-2 border-t border-border pt-4">
<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 />{busy === "save" ? "Saving…" : "Save metadata"}</Button>
</div>
</div>
)}
</div>
</section>
);
}
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">
{selectedIds.length > 0 ? (
<>
<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}
<Button
type="button"
variant="outline"
disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)}
title={!bindingReady ? "Test the current database binding before synchronizing columns" : undefined}
onClick={() => void synchronize("columns", selectedIds)}
>
<RefreshCw className={busy === "sync" ? "animate-spin" : ""} />Synchronize columns
</Button>
<Button type="button" variant="ghost" onClick={() => { gridRef.current?.api.deselectAll(); setSelectedIds([]); }}><X />Clear</Button>
</>
) : (
<>
<span className="thot-label whitespace-nowrap">Catalog tables</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 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>
</>
)}
</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">
<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>
</div>
</section>
);
}
@@ -0,0 +1,120 @@
import type {
CatalogDatabase,
CatalogSecretName,
DatabaseConfiguration,
} from "../../api/catalog-databases";
export type DatabaseFormMode = "add" | "view" | "edit" | "delete";
export type DatabaseScreen =
| { kind: "list" }
| { kind: "tables"; workspaceId: string }
| { kind: "relationships"; workspaceId: string }
| {
kind: DatabaseFormMode;
workspaceId: string;
workspaceLocked?: boolean;
};
export type DatabaseBusyAction = "save" | "test" | "delete" | "retry-secrets" | "reload" | null;
export type SecretDraft = Partial<Record<CatalogSecretName, string>>;
export interface DatabaseFormDraft extends DatabaseConfiguration {
secrets: SecretDraft;
}
export interface DatabaseNavigationState {
dirty: boolean;
busy: boolean;
}
export function draftFrom(row: CatalogDatabase): DatabaseFormDraft {
return {
workspaceId: row.workspaceId,
engine: "postgres",
databaseName: row.databaseName,
schema: row.schema,
binding: {
port: 5432,
restPath: "/health",
restAuth: "x-api-key",
sshPort: 22,
sshTargetPort: 5432,
...row.binding,
},
secrets: {},
};
}
export function configurationFromDraft(draft: DatabaseFormDraft): DatabaseConfiguration {
const binding = draft.binding.transport === "postgres_direct"
? {
transport: draft.binding.transport,
host: draft.binding.host,
port: draft.binding.port,
username: draft.binding.username,
tlsServername: draft.binding.tlsServername,
}
: draft.binding.transport === "rest_api"
? {
transport: draft.binding.transport,
baseUrl: draft.binding.baseUrl,
restPath: draft.binding.restPath,
restAuth: draft.binding.restAuth,
}
: {
transport: draft.binding.transport,
username: draft.binding.username,
tlsServername: draft.binding.tlsServername,
sshHost: draft.binding.sshHost,
sshPort: draft.binding.sshPort,
sshUsername: draft.binding.sshUsername,
sshTargetHost: draft.binding.sshTargetHost,
sshTargetPort: draft.binding.sshTargetPort,
};
return {
workspaceId: draft.workspaceId,
engine: draft.engine,
databaseName: draft.databaseName,
schema: draft.schema,
binding,
};
}
export function configurationFingerprint(draft: DatabaseFormDraft): string {
return JSON.stringify(configurationFromDraft(draft));
}
export function hasSecretChanges(draft: DatabaseFormDraft | null): boolean {
return Boolean(draft && Object.keys(secretReplacements(draft)).length);
}
export function secretReplacements(draft: DatabaseFormDraft): SecretDraft {
const allowed: CatalogSecretName[] = draft.binding.transport === "postgres_direct"
? ["password", "tlsCa"]
: draft.binding.transport === "rest_api"
? draft.binding.restAuth === "none" ? [] : ["apiKey"]
: [
"password",
"sshPrivateKey",
"sshPrivateKeyPassphrase",
"sshKnownHosts",
"tlsCa",
];
return Object.fromEntries(
Object.entries(draft.secrets).filter(([name, value]) => (
allowed.includes(name as CatalogSecretName) && Boolean(value)
)),
) as SecretDraft;
}
export function statusLabel(row: CatalogDatabase): string {
if (!row.workspaceAvailable) return "Workspace missing";
if (!row.configured) return "Not configured";
if (row.connectionStatus === "reachable") return "Reachable";
if (row.connectionStatus === "failed") return "Failed";
return "Untested";
}
+2 -1
View File
@@ -6,8 +6,9 @@ export const server = setupServer(
issuer: "mock", subject: "test-user", displayName: "Test user", roles: ["admin"],
permissions: [
"session.use", "session.read_all", "session.manage_all", "settings.manage",
"workspace.manage", "workspace.secrets.manage", "pi.manage", "auth.diagnostics.read",
"workspace.manage", "workspace.secrets.manage", "database.manage", "pi.manage", "auth.diagnostics.read",
], isAdmin: true, csrfToken: "c".repeat(43), session: null,
})),
http.get("/api/health/dwh", () => HttpResponse.json({ ok: true, detail: "ok" })),
http.get("/api/catalog/databases", () => HttpResponse.json([])),
);