1457 lines
60 KiB
TypeScript
1457 lines
60 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { History, RefreshCw } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { Button } from "../components/ui/button";
|
|
import { ApiError, apiErrorMessage } from "../api/client";
|
|
import {
|
|
createCatalogDatabase,
|
|
deleteCatalogDatabase,
|
|
deleteCatalogDatabaseMetadata,
|
|
getCatalogMetrics,
|
|
listCatalogDatabases,
|
|
listDescriptionGenerationRuns,
|
|
listMetadataGenerationModels,
|
|
listSensitiveDataSuggestionRuns,
|
|
replaceCatalogDatabaseSecrets,
|
|
startCatalogSync,
|
|
startDescriptionGenerationRun,
|
|
suggestSensitiveFields,
|
|
testCatalogDatabase,
|
|
updateCatalogDatabase,
|
|
type CatalogDatabase,
|
|
type CatalogColumn,
|
|
type CatalogDatabaseMetadataDeleteTarget,
|
|
type CatalogSecretName,
|
|
type CatalogSyncScope,
|
|
type CatalogSyncRun,
|
|
type DatabaseBinding,
|
|
type DatabaseTransport,
|
|
type DescriptionGenerationRun,
|
|
type SensitiveDataSuggestion,
|
|
type SensitiveDataSuggestionRequest,
|
|
type SensitiveDataSuggestionRun,
|
|
} from "../api/catalog-databases";
|
|
import { DatabaseGrid } from "./database-management/DatabaseGrid";
|
|
import { DatabaseForm } from "./database-management/DatabaseForm";
|
|
import { databaseFormTitle } from "./database-management/DatabaseForm";
|
|
import { DatabaseTables } from "./database-management/DatabaseTables";
|
|
import { DatabaseRelationships } from "./database-management/DatabaseRelationships";
|
|
import { CatalogSyncDrawer } from "./database-management/CatalogSyncDrawer";
|
|
import { MetadataGenerationModelSelector } from "./database-management/MetadataGenerationModelSelector";
|
|
import { DescriptionGenerationDrawer } from "./database-management/DescriptionGenerationDrawer";
|
|
import { SensitiveDataSuggestionHistoryDrawer } from "./database-management/SensitiveDataSuggestionHistoryDrawer";
|
|
import { SensitiveDataReviewDrawer } from "./database-management/SensitiveDataReviewDrawer";
|
|
import {
|
|
FleetLedgerBreadcrumb,
|
|
FleetLedgerContent,
|
|
FleetLedgerDrawer,
|
|
FleetLedgerHeader,
|
|
FleetLedgerKpiStrip,
|
|
FleetLedgerRealStatus,
|
|
FleetLedgerShell,
|
|
} from "./database-management/FleetLedgerShell";
|
|
import {
|
|
configurationFingerprint,
|
|
configurationFromDraft,
|
|
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 SENSITIVE_DATA_SUGGESTION_HISTORY_QUERY_KEY = ["sensitive-data-suggestion-runs", 50] as const;
|
|
const DESCRIPTION_GENERATION_ACTIVE_STATUSES = ["queued", "running"] as const;
|
|
const SYNC_STARTED_MESSAGES: Record<CatalogSyncScope, string> = {
|
|
tables: "Table synchronization started",
|
|
columns: "Column synchronization started",
|
|
relationships: "Relationship synchronization started",
|
|
all: "Full schema synchronization started",
|
|
};
|
|
const DATABASE_SCREEN_STORAGE_KEY = "thothii:database-management-screen";
|
|
|
|
function readPersistedDatabaseScreen(): DatabaseScreen {
|
|
if (import.meta.env.MODE === "test") return { kind: "list" };
|
|
try {
|
|
const parsed = JSON.parse(window.localStorage.getItem(DATABASE_SCREEN_STORAGE_KEY) ?? "null") as { kind?: unknown; workspaceId?: unknown } | null;
|
|
if ((parsed?.kind === "tables" || parsed?.kind === "relationships") && typeof parsed.workspaceId === "string") {
|
|
return { kind: parsed.kind, workspaceId: parsed.workspaceId };
|
|
}
|
|
} catch {
|
|
// Ignore malformed persisted navigation and return to the safe list view.
|
|
}
|
|
return { kind: "list" };
|
|
}
|
|
|
|
interface Props {
|
|
canManage: boolean;
|
|
canManageSecrets: boolean;
|
|
onNavigationStateChange?: (state: DatabaseNavigationState) => void;
|
|
presentation?: "legacy" | "fleet";
|
|
}
|
|
|
|
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,
|
|
presentation = "legacy",
|
|
}: 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 sensitiveDataSuggestionRunsQuery = useQuery({
|
|
queryKey: SENSITIVE_DATA_SUGGESTION_HISTORY_QUERY_KEY,
|
|
queryFn: () => listSensitiveDataSuggestionRuns(50),
|
|
enabled: canManage,
|
|
retry: false,
|
|
refetchInterval: 5_000,
|
|
});
|
|
const sensitiveDataSuggestionRuns = sensitiveDataSuggestionRunsQuery.data ?? [];
|
|
const observedActiveSensitiveDataSuggestionRun = sensitiveDataSuggestionRuns.find(
|
|
(run) => run.status === "running",
|
|
);
|
|
|
|
const [screen, setScreen] = useState<DatabaseScreen>(readPersistedDatabaseScreen);
|
|
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 [selectedDatabaseId, setSelectedDatabaseId] = useState<string | null>(null);
|
|
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 [tableNestedNavigationActive, setTableNestedNavigationActive] = useState(false);
|
|
const [tableNestedNavigationBack, setTableNestedNavigationBack] = useState<(() => void) | null>(null);
|
|
const handleTableNestedNavigationChange = useCallback((active: boolean, onBack?: () => void) => {
|
|
setTableNestedNavigationActive(active);
|
|
setTableNestedNavigationBack(() => (active ? onBack ?? null : null));
|
|
}, []);
|
|
const [activeSyncRun, setActiveSyncRun] = useState<CatalogSyncRun | null>(null);
|
|
const [syncHistoryDatabaseId, setSyncHistoryDatabaseId] = useState<string | null>(null);
|
|
const [syncDrawerOpen, setSyncDrawerOpen] = useState(false);
|
|
const [activeDescriptionGenerationRun, setActiveDescriptionGenerationRun] = useState<DescriptionGenerationRun | null>(null);
|
|
const [descriptionGenerationDrawerOpen, setDescriptionGenerationDrawerOpen] = useState(false);
|
|
const [activeSensitiveDataSuggestionRun, setActiveSensitiveDataSuggestionRun] = useState<SensitiveDataSuggestionRun | null>(null);
|
|
const [sensitiveDataSuggestionHistoryDrawerOpen, setSensitiveDataSuggestionHistoryDrawerOpen] = useState(false);
|
|
const [sensitiveReview, setSensitiveReview] = useState<{
|
|
databaseId: string;
|
|
scopeLabel: string;
|
|
suggestions: SensitiveDataSuggestion[];
|
|
} | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (import.meta.env.MODE === "test") return;
|
|
const persisted = screen.kind === "tables" || screen.kind === "relationships" ? screen : { kind: "list" };
|
|
window.localStorage.setItem(DATABASE_SCREEN_STORAGE_KEY, JSON.stringify(persisted));
|
|
}, [screen]);
|
|
|
|
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 metricsDatabaseId = (screen.kind === "tables" || screen.kind === "relationships")
|
|
? activeRow?.id
|
|
: undefined;
|
|
const catalogMetricsQuery = useQuery({
|
|
queryKey: ["catalog-metrics", metricsDatabaseId ?? "global"],
|
|
queryFn: () => getCatalogMetrics(metricsDatabaseId),
|
|
enabled: canManage && (metricsDatabaseId === undefined || Boolean(activeRow?.id)),
|
|
retry: false,
|
|
refetchInterval: presentation === "fleet" ? 15_000 : false,
|
|
});
|
|
const invalidateCatalogMetrics = useCallback(
|
|
() => queryClient.invalidateQueries({ queryKey: ["catalog-metrics"] }),
|
|
[queryClient],
|
|
);
|
|
const editable = screen.kind === "configure" || screen.kind === "edit";
|
|
const configurationDirty = Boolean(
|
|
editable
|
|
&& draft
|
|
&& configurationFingerprint(draft) !== baseline,
|
|
);
|
|
const dirty = Boolean(editable && draft && (configurationDirty || hasSecretChanges(draft)));
|
|
const busy = busyAction !== null;
|
|
const catalogChildVisible = screen.kind === "tables" || screen.kind === "relationships";
|
|
const navigationDirty = catalogChildVisible ? tablesNavigationState.dirty : dirty;
|
|
const navigationBusy = catalogChildVisible ? 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(() => {
|
|
const originLabel = originRef.current?.getAttribute("aria-label");
|
|
window.setTimeout(() => {
|
|
if (originRef.current?.isConnected) {
|
|
originRef.current.focus();
|
|
} else if (originLabel) {
|
|
const matchingAction = [...document.querySelectorAll<HTMLButtonElement>("button")]
|
|
.find((button) => button.getAttribute("aria-label") === originLabel);
|
|
(matchingAction ?? searchInputRef.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 });
|
|
setTableNestedNavigationActive(false);
|
|
setTableNestedNavigationBack(null);
|
|
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,
|
|
) => {
|
|
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,
|
|
});
|
|
}, []);
|
|
|
|
const viewRow = useCallback((row: CatalogDatabase, origin: HTMLButtonElement) => {
|
|
openForm("view", row, origin);
|
|
}, [openForm]);
|
|
|
|
const editRow = useCallback((row: CatalogDatabase, origin: HTMLButtonElement) => {
|
|
openForm(row.configured ? "edit" : "configure", row, origin);
|
|
}, [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 });
|
|
setTableNestedNavigationActive(false);
|
|
setTableNestedNavigationBack(null);
|
|
setScreen({ kind: "tables", workspaceId: row.workspaceId });
|
|
}, []);
|
|
|
|
const openRelationships = 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 });
|
|
setTableNestedNavigationActive(false);
|
|
setTableNestedNavigationBack(null);
|
|
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 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: "configure", workspaceId: latest.workspaceId });
|
|
}
|
|
} 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;
|
|
await invalidateCatalogMetrics();
|
|
} catch (error) {
|
|
toast.error(apiErrorMessage(error));
|
|
}
|
|
}, [invalidateCatalogMetrics, 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);
|
|
setSyncHistoryDatabaseId(run.databaseId);
|
|
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 scopedRuns = activeRow ? descriptionGenerationRuns.filter((item) => item.databaseId === activeRow.id) : descriptionGenerationRuns;
|
|
const scopedActiveRun = observedActiveDescriptionGenerationRun
|
|
&& (!activeRow || observedActiveDescriptionGenerationRun.databaseId === activeRow.id)
|
|
? observedActiveDescriptionGenerationRun
|
|
: undefined;
|
|
const scopedSelectedRun = activeDescriptionGenerationRun
|
|
&& (!activeRow || activeDescriptionGenerationRun.databaseId === activeRow.id)
|
|
? activeDescriptionGenerationRun
|
|
: undefined;
|
|
const run = scopedActiveRun
|
|
?? scopedRuns[0]
|
|
?? scopedSelectedRun;
|
|
if (run) setActiveDescriptionGenerationRun(run);
|
|
setSensitiveDataSuggestionHistoryDrawerOpen(false);
|
|
setDescriptionGenerationDrawerOpen(true);
|
|
}, [activeDescriptionGenerationRun, activeRow, descriptionGenerationRuns, observedActiveDescriptionGenerationRun]);
|
|
|
|
const openSensitiveDataSuggestionHistory = useCallback(() => {
|
|
const scopedRuns = activeRow ? sensitiveDataSuggestionRuns.filter((item) => item.databaseId === activeRow.id) : sensitiveDataSuggestionRuns;
|
|
const scopedActiveRun = observedActiveSensitiveDataSuggestionRun
|
|
&& (!activeRow || observedActiveSensitiveDataSuggestionRun.databaseId === activeRow.id)
|
|
? observedActiveSensitiveDataSuggestionRun
|
|
: undefined;
|
|
const scopedSelectedRun = activeSensitiveDataSuggestionRun
|
|
&& (!activeRow || activeSensitiveDataSuggestionRun.databaseId === activeRow.id)
|
|
? activeSensitiveDataSuggestionRun
|
|
: undefined;
|
|
const run = scopedActiveRun
|
|
?? scopedRuns[0]
|
|
?? scopedSelectedRun;
|
|
if (run) setActiveSensitiveDataSuggestionRun(run);
|
|
setDescriptionGenerationDrawerOpen(false);
|
|
setSensitiveDataSuggestionHistoryDrawerOpen(true);
|
|
}, [activeRow, activeSensitiveDataSuggestionRun, observedActiveSensitiveDataSuggestionRun, sensitiveDataSuggestionRuns]);
|
|
|
|
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],
|
|
}),
|
|
invalidateCatalogMetrics(),
|
|
]);
|
|
}, [invalidateCatalogMetrics, queryClient]);
|
|
|
|
const openSync = useCallback((row?: CatalogDatabase) => {
|
|
const databaseId = row?.id ?? activeSyncRun?.databaseId ?? activeRow?.id ?? null;
|
|
if (!databaseId) return;
|
|
const run = row?.activeSyncRun
|
|
?? (activeSyncRun?.databaseId === databaseId ? activeSyncRun : null);
|
|
setActiveSyncRun(run);
|
|
setSyncHistoryDatabaseId(databaseId);
|
|
setSyncDrawerOpen(true);
|
|
}, [activeRow?.id, 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 descriptions" : "Generate missing descriptions"} started for ${database.workspaceName}`);
|
|
} catch (error) {
|
|
toast.error(apiErrorMessage(error));
|
|
throw error;
|
|
}
|
|
}, [rememberDescriptionGenerationRun, selectedMetadataModel]);
|
|
|
|
const requestSensitiveSuggestions = useCallback(async (
|
|
database: CatalogDatabase,
|
|
selection: SensitiveDataSuggestionRequest,
|
|
scopeLabel: string,
|
|
) => {
|
|
if (!database.id) {
|
|
toast.error("The selected database is not configured, so sensitive-field suggestions were not requested.");
|
|
throw new Error("database is not configured");
|
|
}
|
|
if (!selectedMetadataModel) {
|
|
toast.error("Select a metadata-generation model before requesting sensitive-field suggestions.");
|
|
throw new Error("metadata-generation model is not selected");
|
|
}
|
|
try {
|
|
const result = await suggestSensitiveFields(database.id, selectedMetadataModel, selection);
|
|
if (result.run) {
|
|
setActiveSensitiveDataSuggestionRun(result.run);
|
|
queryClient.setQueryData<SensitiveDataSuggestionRun[]>(
|
|
SENSITIVE_DATA_SUGGESTION_HISTORY_QUERY_KEY,
|
|
(current = []) => [result.run, ...current.filter((item) => item.id !== result.run.id)],
|
|
);
|
|
}
|
|
setSensitiveReview({ databaseId: database.id, scopeLabel, suggestions: result.suggestions });
|
|
toast.success(`Prepared ${result.suggestions.length} sensitive-field suggestion${result.suggestions.length === 1 ? "" : "s"} for review`);
|
|
} catch (error) {
|
|
toast.error(apiErrorMessage(error));
|
|
throw error;
|
|
} finally {
|
|
await queryClient.invalidateQueries({ queryKey: SENSITIVE_DATA_SUGGESTION_HISTORY_QUERY_KEY });
|
|
}
|
|
}, [queryClient, selectedMetadataModel]);
|
|
|
|
const suggestDatabaseSensitiveFields = useCallback(async (selected: CatalogDatabase[]) => {
|
|
if (selected.length !== 1) {
|
|
toast.error("Sensitive-field suggestions can be requested for only one database at a time. Select one database and try again.");
|
|
throw new Error("more than one database selected");
|
|
}
|
|
const database = selected[0]!;
|
|
await requestSensitiveSuggestions(database, { scope: "all" }, `Entire database ${database.workspaceName}`);
|
|
}, [requestSensitiveSuggestions]);
|
|
|
|
const suggestActiveDatabaseSensitiveFields = useCallback(async (
|
|
selection: SensitiveDataSuggestionRequest,
|
|
scopeLabel: string,
|
|
) => {
|
|
if (!activeRow) {
|
|
toast.error("The database is no longer available, so sensitive-field suggestions were not requested.");
|
|
throw new Error("database is no longer available");
|
|
}
|
|
await requestSensitiveSuggestions(activeRow, selection, scopeLabel);
|
|
}, [activeRow, requestSensitiveSuggestions]);
|
|
|
|
const sensitiveColumnsSaved = useCallback((columns: CatalogColumn[]) => {
|
|
const savedById = new Map(columns.map((column) => [column.id, column]));
|
|
setSensitiveReview((current) => current ? {
|
|
...current,
|
|
suggestions: current.suggestions.map((suggestion) => {
|
|
const saved = savedById.get(suggestion.columnId);
|
|
return saved ? {
|
|
...suggestion,
|
|
version: saved.version,
|
|
currentSensitive: saved.sensitive,
|
|
sensitive: saved.sensitive,
|
|
} : suggestion;
|
|
}),
|
|
} : null);
|
|
const databaseId = sensitiveReview?.databaseId;
|
|
if (databaseId) {
|
|
void Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ["catalog-columns", databaseId] }),
|
|
invalidateCatalogMetrics(),
|
|
]);
|
|
}
|
|
}, [invalidateCatalogMetrics, queryClient, sensitiveReview?.databaseId]);
|
|
|
|
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 Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: DATABASE_QUERY_KEY }),
|
|
invalidateCatalogMetrics(),
|
|
]);
|
|
toast.success(target === "tables"
|
|
? `Cleared ${counts.tables} catalog tables, ${counts.columns} columns, and ${counts.relationships} relationships. The source database was not changed.`
|
|
: `Cleared ${counts.relationships} catalog relationships. The source database was not changed.`);
|
|
} catch (error) {
|
|
toast.error(apiErrorMessage(error));
|
|
throw error;
|
|
}
|
|
}, [invalidateCatalogMetrics, queryClient]);
|
|
|
|
const clearActiveCatalogTables = useCallback(async () => {
|
|
if (!activeRow?.id) return;
|
|
await deleteSelectedMetadata([activeRow], "tables");
|
|
}, [activeRow, deleteSelectedMetadata]);
|
|
|
|
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 Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: DATABASE_QUERY_KEY }),
|
|
invalidateCatalogMetrics(),
|
|
]);
|
|
}, [activeSyncRun, invalidateCatalogMetrics, queryClient]);
|
|
|
|
const formVisible = screen.kind !== "list" && screen.kind !== "tables" && screen.kind !== "relationships" && activeRow && draft;
|
|
const tablesVisible = screen.kind === "tables" && activeRow?.configured && activeRow.id;
|
|
const relationshipsVisible = screen.kind === "relationships" && activeRow?.configured && activeRow.id;
|
|
const currentActiveRun = activeSyncRun && activeSyncRun.databaseId === activeRow?.id && ["queued", "running", "awaiting_confirmation", "applying"].includes(activeSyncRun.state)
|
|
? activeSyncRun
|
|
: activeRow?.activeSyncRun && ["queued", "running", "awaiting_confirmation", "applying"].includes(activeRow.activeSyncRun.state)
|
|
? activeRow.activeSyncRun
|
|
: undefined;
|
|
const selectedMetadataModelAvailable = Boolean(
|
|
selectedMetadataModel
|
|
&& metadataModels.some((model) => model.id === selectedMetadataModel),
|
|
);
|
|
const descriptionGenerationActive = isDescriptionGenerationActive(
|
|
observedActiveDescriptionGenerationRun ?? activeDescriptionGenerationRun,
|
|
);
|
|
const fleetMetadataModelControl = (
|
|
<MetadataGenerationModelSelector
|
|
canManage={canManage}
|
|
data={metadataModelsQuery.data}
|
|
isError={metadataModelsQuery.isError}
|
|
isLoading={metadataModelsQuery.isLoading}
|
|
selectedModel={selectedMetadataModel}
|
|
onSelectedModelChange={setSelectedMetadataModel}
|
|
variant="compact"
|
|
/>
|
|
);
|
|
|
|
const metrics = catalogMetricsQuery.data;
|
|
const metricsUnavailable = catalogMetricsQuery.isError;
|
|
const metricsValue = (value?: number) => (
|
|
catalogMetricsQuery.isLoading ? "…" : metricsUnavailable || value === undefined ? "—" : value.toLocaleString("en-US")
|
|
);
|
|
const latestCatalogUpdate = metrics?.updatedAt
|
|
? new Date(metrics.updatedAt).toLocaleString()
|
|
: "No synchronized metadata";
|
|
const operationLabel = currentActiveRun
|
|
? `Synchronization: ${currentActiveRun.phase.replaceAll("_", " ")}`
|
|
: descriptionGenerationActive
|
|
? "Description generation active"
|
|
: observedActiveSensitiveDataSuggestionRun
|
|
? "Sensitive analysis active"
|
|
: "Idle";
|
|
const operationTone = currentActiveRun || descriptionGenerationActive || observedActiveSensitiveDataSuggestionRun
|
|
? "warning" as const
|
|
: "neutral" as const;
|
|
const fleetBreadcrumb = screen.kind === "tables" || screen.kind === "relationships"
|
|
? [
|
|
{ id: "databases", label: "Databases", onSelect: showList },
|
|
{ id: "database", label: activeRow?.workspaceName ?? "Database" },
|
|
{ id: screen.kind, label: screen.kind === "tables" ? "Tables" : "Relationships", current: true },
|
|
]
|
|
: [{ id: "databases", label: "Databases", current: true }];
|
|
const catalogDrawers = (
|
|
<>
|
|
<CatalogSyncDrawer
|
|
databaseId={syncHistoryDatabaseId}
|
|
runId={activeSyncRun?.databaseId === syncHistoryDatabaseId ? activeSyncRun.id : null}
|
|
open={syncDrawerOpen && Boolean(syncHistoryDatabaseId)}
|
|
onClose={() => setSyncDrawerOpen(false)}
|
|
onRunChange={rememberSyncRun}
|
|
onRunUpdate={updateTrackedSyncRun}
|
|
onCatalogChanged={() => void catalogChanged()}
|
|
/>
|
|
<DescriptionGenerationDrawer
|
|
open={descriptionGenerationDrawerOpen}
|
|
databaseId={activeRow?.id ?? null}
|
|
run={activeDescriptionGenerationRun}
|
|
modelLabel={metadataModels.find(
|
|
(model) => model.id === activeDescriptionGenerationRun?.modelId,
|
|
)?.label ?? activeDescriptionGenerationRun?.modelId ?? ""}
|
|
onClose={() => setDescriptionGenerationDrawerOpen(false)}
|
|
onRunUpdate={setActiveDescriptionGenerationRun}
|
|
onTerminal={(run) => void descriptionGenerationTerminated(run)}
|
|
/>
|
|
<SensitiveDataSuggestionHistoryDrawer
|
|
open={sensitiveDataSuggestionHistoryDrawerOpen}
|
|
databaseId={activeRow?.id ?? null}
|
|
run={activeSensitiveDataSuggestionRun}
|
|
modelLabel={metadataModels.find(
|
|
(model) => model.id === activeSensitiveDataSuggestionRun?.modelId,
|
|
)?.label ?? activeSensitiveDataSuggestionRun?.modelId ?? ""}
|
|
onClose={() => setSensitiveDataSuggestionHistoryDrawerOpen(false)}
|
|
onRunUpdate={setActiveSensitiveDataSuggestionRun}
|
|
/>
|
|
<SensitiveDataReviewDrawer
|
|
open={Boolean(sensitiveReview)}
|
|
databaseId={sensitiveReview?.databaseId ?? null}
|
|
scopeLabel={sensitiveReview?.scopeLabel ?? ""}
|
|
suggestions={sensitiveReview?.suggestions ?? []}
|
|
canManage={canManage}
|
|
onClose={() => setSensitiveReview(null)}
|
|
onSaved={sensitiveColumnsSaved}
|
|
/>
|
|
</>
|
|
);
|
|
|
|
if (presentation === "fleet") {
|
|
const fleetFormMode = formVisible ? screen.kind as DatabaseFormMode : null;
|
|
const fleetDrawer = formVisible && fleetFormMode ? (
|
|
<FleetLedgerDrawer
|
|
open
|
|
eyebrow="Catalog configuration"
|
|
title={databaseFormTitle(
|
|
fleetFormMode,
|
|
"drawer",
|
|
)}
|
|
description={`${activeRow.workspaceName} · ${activeRow.workspaceId}`}
|
|
onClose={backToList}
|
|
closeLabel="Close database panel"
|
|
busy={busy}
|
|
width="wide"
|
|
>
|
|
<DatabaseForm
|
|
presentation="drawer"
|
|
mode={fleetFormMode}
|
|
row={activeRow}
|
|
draft={draft}
|
|
dirty={dirty}
|
|
canManage={canManage}
|
|
canManageSecrets={canManageSecrets}
|
|
busyAction={busyAction}
|
|
stale={stale}
|
|
staleBannerOpen={staleBannerOpen}
|
|
partialSecretFailure={partialSecretFailure}
|
|
headingRef={formHeadingRef}
|
|
onBack={backToList}
|
|
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)}
|
|
onOpenDescriptionHistory={openDescriptionGenerationHistory}
|
|
onOpenSensitiveHistory={openSensitiveDataSuggestionHistory}
|
|
activeSyncRun={currentActiveRun}
|
|
/>
|
|
</FleetLedgerDrawer>
|
|
) : null;
|
|
|
|
const fleetContent = screen.kind === "tables" && tablesVisible ? (
|
|
<DatabaseTables
|
|
presentation="fleet"
|
|
database={activeRow}
|
|
canManage={canManage}
|
|
activeRun={currentActiveRun}
|
|
selectedMetadataModel={selectedMetadataModelAvailable ? selectedMetadataModel : null}
|
|
descriptionGenerationActive={descriptionGenerationActive}
|
|
onBackToDatabases={showList}
|
|
onOpenOverview={() => openOverview(activeRow)}
|
|
onOpenRelationships={() => openRelationships(activeRow)}
|
|
onNavigationStateChange={setTablesNavigationState}
|
|
onNestedNavigationChange={handleTableNestedNavigationChange}
|
|
onRunStarted={rememberSyncRun}
|
|
onOpenSync={() => openSync(activeRow)}
|
|
onDescriptionGenerationRunStarted={rememberDescriptionGenerationRun}
|
|
onSuggestSensitive={suggestActiveDatabaseSensitiveFields}
|
|
onClearCatalogTables={clearActiveCatalogTables}
|
|
onCatalogMetricsChanged={invalidateCatalogMetrics}
|
|
/>
|
|
) : screen.kind === "relationships" && relationshipsVisible ? (
|
|
<DatabaseRelationships
|
|
presentation="fleet"
|
|
database={{ ...activeRow, activeSyncRun: currentActiveRun }}
|
|
canManage={canManage}
|
|
onBackToDatabases={showList}
|
|
onOpenOverview={() => openOverview(activeRow)}
|
|
onOpenTables={() => openTables(activeRow)}
|
|
onRunStarted={rememberSyncRun}
|
|
onOpenSync={() => openSync(activeRow)}
|
|
onOpenDescriptionHistory={openDescriptionGenerationHistory}
|
|
onOpenSensitiveHistory={openSensitiveDataSuggestionHistory}
|
|
onCatalogMetricsChanged={invalidateCatalogMetrics}
|
|
onNavigationStateChange={setTablesNavigationState}
|
|
/>
|
|
) : isError && rows.length === 0 ? (
|
|
<div className="grid h-full place-items-center p-6">
|
|
<div className="max-w-lg rounded-lg border border-destructive/30 bg-destructive/8 p-5 text-sm" role="alert">
|
|
<p className="font-semibold text-destructive">The database catalog is unavailable.</p>
|
|
<p className="mt-1 leading-5 text-muted-foreground">Verify the catalog service and database migrations, then try again.</p>
|
|
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refreshList()}>
|
|
<RefreshCw /> Try again
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<DatabaseGrid
|
|
presentation="fleet"
|
|
rows={rows}
|
|
isLoading={isLoading}
|
|
isFetching={isFetching}
|
|
search={search}
|
|
canManage={canManage}
|
|
searchInputRef={searchInputRef}
|
|
onSearchChange={setSearch}
|
|
onView={viewRow}
|
|
onOpenTables={(row, origin) => openTables(row, origin)}
|
|
onOpenRelationships={(row, origin) => openRelationships(row, origin)}
|
|
onEdit={editRow}
|
|
onDelete={deleteRow}
|
|
onOpenSync={openSync}
|
|
onSelectionChange={(selected) => setSelectedDatabaseId(selected.length === 1 ? selected[0].id ?? null : null)}
|
|
onTestSelected={testSelected}
|
|
onSyncSelected={syncSelected}
|
|
selectedMetadataModel={selectedMetadataModelAvailable ? selectedMetadataModel : null}
|
|
descriptionGenerationActive={descriptionGenerationActive}
|
|
onGenerateDescriptions={generateDatabaseDescriptions}
|
|
onSuggestSensitive={suggestDatabaseSensitiveFields}
|
|
sensitiveDataSuggestionRuns={sensitiveDataSuggestionRuns}
|
|
onDeleteMetadataSelected={deleteSelectedMetadata}
|
|
onRefresh={refreshList}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<main aria-label="Database management" className="flex min-h-0 flex-1 overflow-hidden bg-background">
|
|
<FleetLedgerShell
|
|
header={(
|
|
<FleetLedgerHeader
|
|
title="Database management"
|
|
status={(
|
|
<FleetLedgerRealStatus
|
|
title="Catalog status"
|
|
items={[
|
|
{
|
|
id: "catalog",
|
|
label: "Catalog",
|
|
value: isLoading ? "Loading" : isError ? "Unavailable" : "Available",
|
|
detail: isError ? "Retry from the current grid" : `${rows.length} workspace${rows.length === 1 ? "" : "s"}`,
|
|
tone: isError ? "danger" : isLoading ? "warning" : "success",
|
|
},
|
|
{ id: "operation", label: "Operations", value: operationLabel, tone: operationTone },
|
|
{ id: "updated", label: "Metadata updated", value: latestCatalogUpdate, tone: "info" },
|
|
]}
|
|
/>
|
|
)}
|
|
actions={(
|
|
<>
|
|
{fleetMetadataModelControl}
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="whitespace-nowrap"
|
|
aria-label="View schema synchronization logs"
|
|
title="View schema synchronization progress and history"
|
|
disabled={!canManage || (!selectedDatabaseId && !activeSyncRun?.databaseId)}
|
|
onClick={() => openSync(selectedDatabaseId ? rows.find((row) => row.id === selectedDatabaseId) : undefined)}
|
|
>
|
|
<RefreshCw aria-hidden="true" />
|
|
{currentActiveRun ? "Schema sync active" : "Schema sync logs"}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="whitespace-nowrap"
|
|
aria-label="View AI description generation logs"
|
|
title="View AI description generation progress and history"
|
|
disabled={!canManage}
|
|
onClick={openDescriptionGenerationHistory}
|
|
>
|
|
<History aria-hidden="true" />
|
|
{descriptionGenerationActive ? "AI descriptions active" : "AI description logs"}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="whitespace-nowrap"
|
|
aria-label="View sensitive analysis progress"
|
|
title="View sensitive analysis progress and history"
|
|
disabled={!canManage}
|
|
onClick={openSensitiveDataSuggestionHistory}
|
|
>
|
|
<History aria-hidden="true" />
|
|
{observedActiveSensitiveDataSuggestionRun ? "Sensitive analysis active" : "Sensitive analysis history"}
|
|
</Button>
|
|
</>
|
|
)}
|
|
/>
|
|
)}
|
|
kpis={(
|
|
<FleetLedgerKpiStrip
|
|
title={metricsDatabaseId ? "Database summary" : "Fleet summary"}
|
|
items={[
|
|
{ id: "tables", label: "Tables", value: metricsValue(metrics?.tables) },
|
|
{ id: "columns", label: "Columns", value: metricsValue(metrics?.columns) },
|
|
{ id: "sensitive", label: "Sensitive", value: metricsValue(metrics?.sensitiveColumns) },
|
|
{ id: "relationships", label: "Relationships", value: metricsValue(metrics?.relationships) },
|
|
{ id: "coverage", label: "Description coverage", value: metrics ? `${metrics.descriptionCoverage}%` : metricsValue() },
|
|
]}
|
|
/>
|
|
)}
|
|
navigation={(
|
|
<FleetLedgerBreadcrumb
|
|
items={fleetBreadcrumb}
|
|
back={screen.kind === "relationships"
|
|
? { label: "Back to databases", onBack: showList, disabled: navigationBusy }
|
|
: screen.kind === "tables" && tableNestedNavigationActive
|
|
? {
|
|
label: "Back to tables",
|
|
onBack: () => tableNestedNavigationBack?.(),
|
|
disabled: navigationBusy || !tableNestedNavigationBack,
|
|
}
|
|
: screen.kind === "tables"
|
|
? { label: "Back to databases", onBack: showList, disabled: navigationBusy }
|
|
: undefined}
|
|
/>
|
|
)}
|
|
children={<FleetLedgerContent busy={isLoading || isFetching}>{fleetContent}</FleetLedgerContent>}
|
|
drawer={(
|
|
<>
|
|
{fleetDrawer}
|
|
{catalogDrawers}
|
|
</>
|
|
)}
|
|
/>
|
|
</main>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<main aria-label="Database management" className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
|
|
<header className="grid gap-4 border-b border-border bg-card px-4 py-4 sm:px-5">
|
|
<div>
|
|
<p className="thot-label mb-1">Administration</p>
|
|
<h1 className="font-heading text-xl font-semibold tracking-tight">Database management</h1>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
One database configuration for each repository workspace.
|
|
</p>
|
|
</div>
|
|
<div
|
|
role="group"
|
|
aria-label="Database management controls"
|
|
className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"
|
|
>
|
|
<div
|
|
role="group"
|
|
aria-label="Metadata description controls"
|
|
className="flex flex-wrap items-end gap-3"
|
|
>
|
|
<MetadataGenerationModelSelector
|
|
canManage={canManage}
|
|
data={metadataModelsQuery.data}
|
|
isError={metadataModelsQuery.isError}
|
|
isLoading={metadataModelsQuery.isLoading}
|
|
selectedModel={selectedMetadataModel}
|
|
onSelectedModelChange={setSelectedMetadataModel}
|
|
/>
|
|
{screen.kind === "list" ? <>
|
|
<Button type="button" variant="outline" className="whitespace-nowrap disabled:opacity-70" aria-label="View description generation history" disabled={!canManage} title="View description generation history" onClick={openDescriptionGenerationHistory}>
|
|
<History /> View description generation history
|
|
</Button>
|
|
<Button type="button" variant="outline" className="whitespace-nowrap disabled:opacity-70" aria-label="View sensitive suggestion history" disabled={!canManage} title={observedActiveSensitiveDataSuggestionRun ? "Sensitive suggestion generation is active" : "View sensitive suggestion history"} onClick={openSensitiveDataSuggestionHistory}>
|
|
<History />
|
|
{observedActiveSensitiveDataSuggestionRun ? <span aria-hidden="true" className="size-2 rounded-full bg-primary" /> : null}
|
|
View sensitive suggestion history
|
|
</Button>
|
|
</> : null}
|
|
</div>
|
|
{screen.kind === "list" ? (
|
|
<div
|
|
role="group"
|
|
aria-label="Database management actions"
|
|
className="flex flex-wrap items-center gap-2 sm:justify-end"
|
|
>
|
|
<Button type="button" variant="outline" disabled={isFetching} onClick={() => void refreshList()}>
|
|
<RefreshCw className={isFetching ? "animate-spin" : ""} /> Refresh
|
|
</Button>
|
|
</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}
|
|
onSelectionChange={(selected) => setSelectedDatabaseId(selected.length === 1 ? selected[0].id ?? null : null)}
|
|
onTestSelected={testSelected}
|
|
onSyncSelected={syncSelected}
|
|
selectedMetadataModel={selectedMetadataModelAvailable ? selectedMetadataModel : null}
|
|
descriptionGenerationActive={descriptionGenerationActive}
|
|
sensitiveDataSuggestionRuns={sensitiveDataSuggestionRuns}
|
|
onGenerateDescriptions={generateDatabaseDescriptions}
|
|
onSuggestSensitive={suggestDatabaseSensitiveFields}
|
|
onDeleteMetadataSelected={deleteSelectedMetadata}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{formVisible ? (
|
|
<DatabaseForm
|
|
mode={screen.kind as DatabaseFormMode}
|
|
row={activeRow}
|
|
draft={draft}
|
|
dirty={dirty}
|
|
canManage={canManage}
|
|
canManageSecrets={canManageSecrets}
|
|
busyAction={busyAction}
|
|
stale={stale}
|
|
staleBannerOpen={staleBannerOpen}
|
|
partialSecretFailure={partialSecretFailure}
|
|
headingRef={formHeadingRef}
|
|
onBack={backToList}
|
|
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)}
|
|
onOpenDescriptionHistory={openDescriptionGenerationHistory}
|
|
onOpenSensitiveHistory={openSensitiveDataSuggestionHistory}
|
|
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}
|
|
onSuggestSensitive={suggestActiveDatabaseSensitiveFields}
|
|
onClearCatalogTables={clearActiveCatalogTables}
|
|
onCatalogMetricsChanged={invalidateCatalogMetrics}
|
|
/>
|
|
) : null}
|
|
|
|
{relationshipsVisible ? (
|
|
<DatabaseRelationships
|
|
database={{ ...activeRow, activeSyncRun: currentActiveRun }}
|
|
canManage={canManage}
|
|
onBackToDatabases={showList}
|
|
onOpenOverview={() => openOverview(activeRow)}
|
|
onOpenTables={() => openTables(activeRow)}
|
|
onRunStarted={rememberSyncRun}
|
|
onOpenSync={() => openSync(activeRow)}
|
|
onOpenDescriptionHistory={openDescriptionGenerationHistory}
|
|
onOpenSensitiveHistory={openSensitiveDataSuggestionHistory}
|
|
onCatalogMetricsChanged={invalidateCatalogMetrics}
|
|
onNavigationStateChange={setTablesNavigationState}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
<CatalogSyncDrawer
|
|
databaseId={syncHistoryDatabaseId}
|
|
runId={activeSyncRun?.databaseId === syncHistoryDatabaseId ? activeSyncRun.id : null}
|
|
open={syncDrawerOpen && Boolean(syncHistoryDatabaseId)}
|
|
onClose={() => setSyncDrawerOpen(false)}
|
|
onRunChange={rememberSyncRun}
|
|
onRunUpdate={updateTrackedSyncRun}
|
|
onCatalogChanged={() => void catalogChanged()}
|
|
/>
|
|
<DescriptionGenerationDrawer
|
|
open={descriptionGenerationDrawerOpen}
|
|
databaseId={activeRow?.id ?? null}
|
|
run={activeDescriptionGenerationRun}
|
|
modelLabel={metadataModels.find(
|
|
(model) => model.id === activeDescriptionGenerationRun?.modelId,
|
|
)?.label ?? activeDescriptionGenerationRun?.modelId ?? ""}
|
|
onClose={() => setDescriptionGenerationDrawerOpen(false)}
|
|
onRunUpdate={setActiveDescriptionGenerationRun}
|
|
onTerminal={(run) => void descriptionGenerationTerminated(run)}
|
|
/>
|
|
<SensitiveDataSuggestionHistoryDrawer
|
|
open={sensitiveDataSuggestionHistoryDrawerOpen}
|
|
databaseId={activeRow?.id ?? null}
|
|
run={activeSensitiveDataSuggestionRun}
|
|
modelLabel={metadataModels.find(
|
|
(model) => model.id === activeSensitiveDataSuggestionRun?.modelId,
|
|
)?.label ?? activeSensitiveDataSuggestionRun?.modelId ?? ""}
|
|
onClose={() => setSensitiveDataSuggestionHistoryDrawerOpen(false)}
|
|
onRunUpdate={setActiveSensitiveDataSuggestionRun}
|
|
/>
|
|
<SensitiveDataReviewDrawer
|
|
open={Boolean(sensitiveReview)}
|
|
databaseId={sensitiveReview?.databaseId ?? null}
|
|
scopeLabel={sensitiveReview?.scopeLabel ?? ""}
|
|
suggestions={sensitiveReview?.suggestions ?? []}
|
|
canManage={canManage}
|
|
onClose={() => setSensitiveReview(null)}
|
|
onSaved={sensitiveColumnsSaved}
|
|
/>
|
|
</main>
|
|
);
|
|
}
|