feat: implement metadata catalog database management
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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([])),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user