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