Files
ThothII/frontend/src/shell/DatabaseManagementPage.tsx
T

926 lines
35 KiB
TypeScript

import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { History, Plus, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { ApiError, apiErrorMessage } from "../api/client";
import {
createCatalogDatabase,
deleteCatalogDatabase,
deleteCatalogDatabaseMetadata,
listCatalogDatabases,
listDescriptionGenerationRuns,
listMetadataGenerationModels,
replaceCatalogDatabaseSecrets,
startCatalogSync,
startDescriptionGenerationRun,
testCatalogDatabase,
updateCatalogDatabase,
type CatalogDatabase,
type CatalogDatabaseMetadataDeleteTarget,
type CatalogSecretName,
type CatalogSyncScope,
type CatalogSyncRun,
type DatabaseBinding,
type DatabaseTransport,
type DescriptionGenerationRun,
} 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 { MetadataGenerationModelSelector } from "./database-management/MetadataGenerationModelSelector";
import { DescriptionGenerationDrawer } from "./database-management/DescriptionGenerationDrawer";
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 DESCRIPTION_GENERATION_HISTORY_QUERY_KEY = ["description-generation-runs", 50] as const;
const DESCRIPTION_GENERATION_ACTIVE_STATUSES = ["queued", "running"] 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";
}
function isDescriptionGenerationActive(run?: DescriptionGenerationRun | null): boolean {
return Boolean(run && DESCRIPTION_GENERATION_ACTIVE_STATUSES.some(
(status) => status === run.status,
));
}
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 metadataModelsQuery = useQuery({
queryKey: ["metadata-generation-models"],
queryFn: listMetadataGenerationModels,
retry: false,
});
const metadataModels = metadataModelsQuery.data?.models ?? [];
const configuredMetadataDefault = metadataModelsQuery.data?.default ?? "";
const defaultMetadataModel = configuredMetadataDefault
&& metadataModels.some((model) => model.id === configuredMetadataDefault)
? configuredMetadataDefault
: "";
const descriptionGenerationRunsQuery = useQuery({
queryKey: DESCRIPTION_GENERATION_HISTORY_QUERY_KEY,
queryFn: () => listDescriptionGenerationRuns(50),
enabled: canManage,
retry: false,
refetchInterval: 5_000,
});
const descriptionGenerationRuns = descriptionGenerationRunsQuery.data ?? [];
const observedActiveDescriptionGenerationRun = descriptionGenerationRuns.find(
(run) => isDescriptionGenerationActive(run),
);
const [screen, setScreen] = useState<DatabaseScreen>({ kind: "list" });
const [selectedMetadataModel, setSelectedMetadataModel] = useState("");
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 [activeDescriptionGenerationRun, setActiveDescriptionGenerationRun] = useState<DescriptionGenerationRun | null>(null);
const [descriptionGenerationDrawerOpen, setDescriptionGenerationDrawerOpen] = useState(false);
const originRef = useRef<HTMLElement | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const formHeadingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
setSelectedMetadataModel(defaultMetadataModel);
}, [defaultMetadataModel]);
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 rememberDescriptionGenerationRun = useCallback((run: DescriptionGenerationRun) => {
setActiveDescriptionGenerationRun(run);
queryClient.setQueryData<DescriptionGenerationRun[]>(
DESCRIPTION_GENERATION_HISTORY_QUERY_KEY,
(current = []) => [run, ...current.filter((item) => item.id !== run.id)],
);
setDescriptionGenerationDrawerOpen(true);
}, [queryClient]);
const openDescriptionGenerationHistory = useCallback(() => {
const run = observedActiveDescriptionGenerationRun
?? descriptionGenerationRuns[0]
?? activeDescriptionGenerationRun;
if (!run) return;
setActiveDescriptionGenerationRun(run);
setDescriptionGenerationDrawerOpen(true);
}, [activeDescriptionGenerationRun, descriptionGenerationRuns, observedActiveDescriptionGenerationRun]);
const descriptionGenerationTerminated = useCallback(async (run: DescriptionGenerationRun) => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: ["catalog-tables", run.databaseId],
exact: true,
}),
queryClient.invalidateQueries({
queryKey: ["catalog-columns", run.databaseId],
}),
]);
}, [queryClient]);
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 generateDatabaseDescriptions = useCallback(async (
selected: CatalogDatabase[],
scope: "all" | "missing",
) => {
const database = selected.length === 1 ? selected[0] : undefined;
if (!database?.id || !selectedMetadataModel) return;
try {
const run = await startDescriptionGenerationRun(
database.id,
selectedMetadataModel,
scope,
);
rememberDescriptionGenerationRun(run);
toast.success(`${scope === "all" ? "Generate All" : "Generate Missing"} started for ${database.workspaceName}`);
} catch (error) {
toast.error(apiErrorMessage(error));
throw error;
}
}, [rememberDescriptionGenerationRun, selectedMetadataModel]);
const deleteSelectedMetadata = useCallback(async (
selected: CatalogDatabase[],
target: CatalogDatabaseMetadataDeleteTarget,
) => {
try {
const databaseIds = selected.map((row) => row.id!);
const counts = await deleteCatalogDatabaseMetadata(databaseIds, target);
await Promise.all(databaseIds.flatMap((databaseId) => [
queryClient.invalidateQueries({ queryKey: ["catalog-tables", databaseId] }),
queryClient.invalidateQueries({ queryKey: ["catalog-columns", databaseId] }),
queryClient.invalidateQueries({ queryKey: ["catalog-relationships", databaseId] }),
]));
await queryClient.invalidateQueries({ queryKey: DATABASE_QUERY_KEY });
toast.success(target === "tables"
? `Deleted ${counts.tables} catalog tables, ${counts.columns} columns, and ${counts.relationships} relationships`
: `Deleted ${counts.relationships} catalog relationships`);
} catch (error) {
toast.error(apiErrorMessage(error));
throw error;
}
}, [queryClient]);
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;
const selectedMetadataModelAvailable = Boolean(
selectedMetadataModel
&& metadataModels.some((model) => model.id === selectedMetadataModel),
);
const descriptionGenerationActive = isDescriptionGenerationActive(
observedActiveDescriptionGenerationRun ?? activeDescriptionGenerationRun,
);
const descriptionGenerationHistoryAvailable = Boolean(
descriptionGenerationRuns.length > 0 || activeDescriptionGenerationRun,
);
return (
<main aria-label="Database management" className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-border bg-card px-4 py-4 sm:px-5">
<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>
<div className="flex flex-wrap items-end justify-end gap-3">
<MetadataGenerationModelSelector
canManage={canManage}
data={metadataModelsQuery.data}
isError={metadataModelsQuery.isError}
isLoading={metadataModelsQuery.isLoading}
selectedModel={selectedMetadataModel}
onSelectedModelChange={setSelectedMetadataModel}
/>
<Button
type="button"
variant="outline"
aria-label={observedActiveDescriptionGenerationRun
? "Observe active description generation"
: "View description generation history"}
disabled={!canManage || !descriptionGenerationHistoryAvailable}
title={!descriptionGenerationHistoryAvailable && !descriptionGenerationRunsQuery.isLoading
? "No description generation runs yet"
: undefined}
onClick={openDescriptionGenerationHistory}
>
<History />
{observedActiveDescriptionGenerationRun ? "Active run" : "Run history"}
</Button>
{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}
</div>
</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}
selectedMetadataModel={selectedMetadataModelAvailable ? selectedMetadataModel : null}
descriptionGenerationActive={descriptionGenerationActive}
onGenerateDescriptions={generateDatabaseDescriptions}
onDeleteMetadataSelected={deleteSelectedMetadata}
/>
)}
</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}
selectedMetadataModel={selectedMetadataModelAvailable ? selectedMetadataModel : null}
descriptionGenerationActive={descriptionGenerationActive}
onBackToDatabases={showList}
onOpenOverview={() => openOverview(activeRow)}
onOpenRelationships={() => openRelationships(activeRow)}
onNavigationStateChange={setTablesNavigationState}
onRunStarted={rememberSyncRun}
onOpenSync={() => openSync(activeRow)}
onDescriptionGenerationRunStarted={rememberDescriptionGenerationRun}
/>
) : 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()}
/>
<DescriptionGenerationDrawer
open={descriptionGenerationDrawerOpen}
run={activeDescriptionGenerationRun}
modelLabel={metadataModels.find(
(model) => model.id === activeDescriptionGenerationRun?.modelId,
)?.label ?? activeDescriptionGenerationRun?.modelId ?? ""}
onClose={() => setDescriptionGenerationDrawerOpen(false)}
onRunUpdate={setActiveDescriptionGenerationRun}
onTerminal={(run) => void descriptionGenerationTerminated(run)}
/>
</main>
);
}