Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
810 lines
48 KiB
TypeScript
810 lines
48 KiB
TypeScript
import { useAdminGridLocale } from "./useAdminGridLocale";
|
|
import { adminMessage, type AdminMessage } from "./messages";
|
|
import { useI18n } from "../../i18n";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { Menu } from "@base-ui/react/menu";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { AgGridReact } from "ag-grid-react";
|
|
import type { ColDef, ICellRendererParams } from "ag-grid-community";
|
|
import { ArrowLeft, ChevronDown, Columns3, Pencil, RefreshCw, Save, Sparkles, Trash2, X } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { Button } from "../../components/ui/button";
|
|
import { ApiError, apiErrorMessage } from "../../api/client";
|
|
import {
|
|
consolidateCatalogDescriptions,
|
|
deleteCatalogTableMetadata,
|
|
listCatalogColumns,
|
|
listCatalogTables,
|
|
startCatalogSync,
|
|
startDescriptionGenerationRun,
|
|
updateCatalogTableMetadata,
|
|
type CatalogDatabase,
|
|
type CatalogSyncRun,
|
|
type CatalogTable,
|
|
type CatalogTableMetadataDeleteTarget,
|
|
type DescriptionGenerationRun,
|
|
type SensitivityAnalysisRequest,
|
|
} from "../../api/catalog-databases";
|
|
import type { DatabaseNavigationState } from "./model";
|
|
import { DatabaseColumns } from "./DatabaseColumns";
|
|
import { FleetActionSelector, type FleetActionOption } from "./FleetActionSelector";
|
|
import { FleetLedgerDrawer } from "./FleetLedgerShell";
|
|
import { NO_METADATA_GENERATION_LLM_MODEL_MESSAGE } from "./MetadataGenerationModelSelector";
|
|
|
|
interface Props {
|
|
database: CatalogDatabase;
|
|
canManage: boolean;
|
|
activeRun?: CatalogSyncRun;
|
|
selectedMetadataModel: string | null;
|
|
descriptionGenerationActive: boolean;
|
|
onBackToDatabases: () => void;
|
|
onOpenOverview: () => void;
|
|
onOpenRelationships: () => void;
|
|
onNavigationStateChange: (state: DatabaseNavigationState) => void;
|
|
onNestedNavigationChange?: (active: boolean, onBack?: () => void) => void;
|
|
onRunStarted: (run: CatalogSyncRun) => void;
|
|
onOpenSync: () => void;
|
|
onClearCatalogTables?: () => Promise<void>;
|
|
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
|
|
onSuggestSensitive: (selection: SensitivityAnalysisRequest, scopeLabel: AdminMessage) => Promise<void>;
|
|
onCatalogMetricsChanged?: () => void | Promise<void>;
|
|
presentation?: "legacy" | "fleet";
|
|
}
|
|
|
|
interface TableGridContext {
|
|
canManage: boolean;
|
|
presentation: "legacy" | "fleet";
|
|
onOpen: (table: CatalogTable, section: "overview" | "columns", origin: HTMLButtonElement) => void;
|
|
}
|
|
|
|
function TableActionsCell({ data, context }: ICellRendererParams<CatalogTable, unknown, TableGridContext>) {
|
|
const { t } = useI18n();
|
|
if (!data || !context) return null;
|
|
return (
|
|
<div className="flex h-full items-center justify-end gap-0.5" onClick={(event) => event.stopPropagation()}>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-lg"
|
|
className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined}
|
|
data-tooltip={context.presentation === "fleet" ? t("Columns") : undefined}
|
|
aria-label={t("View columns for {name}", { name: data.name })}
|
|
title={context.presentation === "legacy" ? t("View columns for {name}", { name: data.name }) : undefined}
|
|
onClick={(event) => context.onOpen(data, "columns", event.currentTarget)}
|
|
>
|
|
<Columns3 aria-hidden="true" />
|
|
</Button>
|
|
<span
|
|
className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined}
|
|
data-tooltip={context.presentation === "fleet" ? t("Edit metadata") : undefined}
|
|
>
|
|
<Button type="button" variant="ghost" size="icon-lg" aria-label={t("Edit description for {name}", { name: data.name })} title={context.presentation === "legacy" ? t("Edit metadata for {name}", { name: data.name }) : undefined} disabled={!context.canManage} onClick={(event) => context.onOpen(data, "overview", event.currentTarget)}>
|
|
<Pencil aria-hidden="true" />
|
|
</Button>
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function DatabaseTables({
|
|
database,
|
|
canManage,
|
|
activeRun,
|
|
selectedMetadataModel,
|
|
descriptionGenerationActive,
|
|
onBackToDatabases,
|
|
onOpenOverview,
|
|
onOpenRelationships,
|
|
onNavigationStateChange,
|
|
onNestedNavigationChange,
|
|
onRunStarted,
|
|
onOpenSync,
|
|
onClearCatalogTables,
|
|
onDescriptionGenerationRunStarted,
|
|
onSuggestSensitive,
|
|
onCatalogMetricsChanged,
|
|
presentation = "legacy",
|
|
}: Props) {
|
|
const { t } = useI18n();
|
|
const { locale, gridLocaleProps } = useAdminGridLocale();
|
|
const databaseId = database.id!;
|
|
const queryClient = useQueryClient();
|
|
const queryKey = ["catalog-tables", databaseId] as const;
|
|
const { data = [], error, isError, isLoading, isFetching, refetch } = useQuery({ queryKey, queryFn: () => listCatalogTables(databaseId), retry: false });
|
|
const [search, setSearch] = useState("");
|
|
const [displayedCount, setDisplayedCount] = useState(0);
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
const [activeTableId, setActiveTableId] = useState<string | null>(null);
|
|
const [tableSection, setTableSection] = useState<"overview" | "columns">("overview");
|
|
const [description, setDescription] = useState("");
|
|
const [generatedDescription, setGeneratedDescription] = useState("");
|
|
const [baseline, setBaseline] = useState("");
|
|
const [editorVersion, setEditorVersion] = useState<number | null>(null);
|
|
const [stale, setStale] = useState(false);
|
|
const [staleBannerOpen, setStaleBannerOpen] = useState(true);
|
|
const [busy, setBusy] = useState<"sync" | "save" | "delete" | "consolidate" | "generate" | "suggest" | null>(null);
|
|
const [pendingDelete, setPendingDelete] = useState<CatalogTableMetadataDeleteTarget | null>(null);
|
|
const [columnNavigation, setColumnNavigation] = useState<DatabaseNavigationState>({ dirty: false, busy: false });
|
|
const gridRef = useRef<AgGridReact<CatalogTable>>(null);
|
|
const originRef = useRef<HTMLButtonElement | null>(null);
|
|
const headingRef = useRef<HTMLHeadingElement>(null);
|
|
const actionsTriggerRef = useRef<HTMLButtonElement>(null);
|
|
const fleetActionSelectRef = useRef<HTMLSelectElement>(null);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
const activeTable = activeTableId ? data.find((table) => table.id === activeTableId) : undefined;
|
|
const editorFingerprint = JSON.stringify([description, generatedDescription]);
|
|
const editorDirty = Boolean(activeTableId && editorFingerprint !== baseline);
|
|
const dirty = tableSection === "columns" ? columnNavigation.dirty : editorDirty;
|
|
const navigationBusy = busy !== null || (tableSection === "columns" && columnNavigation.busy);
|
|
const nestedNavigationActive = presentation === "fleet" && Boolean(activeTable) && tableSection === "columns";
|
|
const currentRun = activeRun ?? database.activeSyncRun;
|
|
const clearAllCatalogTables = async () => {
|
|
if (!onClearCatalogTables || !window.confirm(t("Clear all catalog tables, columns, and relationships for this database? The source database will not be changed."))) return;
|
|
await onClearCatalogTables();
|
|
};
|
|
const leaveTable = useCallback(() => {
|
|
if (navigationBusy) return;
|
|
if (dirty && !window.confirm(t("Discard unsaved metadata?"))) return;
|
|
setActiveTableId(null);
|
|
setTableSection("overview");
|
|
window.setTimeout(() => originRef.current?.focus(), 0);
|
|
}, [dirty, navigationBusy, t]);
|
|
|
|
useEffect(() => { onNavigationStateChange({ dirty, busy: navigationBusy }); }, [dirty, navigationBusy, onNavigationStateChange]);
|
|
useEffect(() => {
|
|
onNestedNavigationChange?.(nestedNavigationActive, nestedNavigationActive ? leaveTable : undefined);
|
|
}, [leaveTable, nestedNavigationActive, onNestedNavigationChange]);
|
|
useEffect(() => () => { onNestedNavigationChange?.(false); }, [onNestedNavigationChange]);
|
|
useEffect(() => {
|
|
if (!activeTableId || !activeTable || editorVersion === activeTable.version || busy) return;
|
|
setStale(true);
|
|
setStaleBannerOpen(true);
|
|
}, [activeTable, activeTableId, busy, editorVersion]);
|
|
useEffect(() => {
|
|
if (!pendingDelete) return;
|
|
const timer = window.setTimeout(() => document.getElementById("table-cleanup-confirm-button")?.focus(), 0);
|
|
return () => window.clearTimeout(timer);
|
|
}, [pendingDelete]);
|
|
const closeDeleteConfirmation = () => {
|
|
setPendingDelete(null);
|
|
window.setTimeout(() => {
|
|
if (presentation === "fleet") fleetActionSelectRef.current?.focus();
|
|
else actionsTriggerRef.current?.focus();
|
|
}, 0);
|
|
};
|
|
|
|
const openTable = (table: CatalogTable, section: "overview" | "columns", origin: HTMLButtonElement) => {
|
|
originRef.current = origin;
|
|
setActiveTableId(table.id);
|
|
setTableSection(section);
|
|
setDescription(table.description ?? "");
|
|
setGeneratedDescription(table.generatedDescription ?? "");
|
|
setBaseline(JSON.stringify([table.description ?? "", table.generatedDescription ?? ""]));
|
|
setEditorVersion(table.version);
|
|
setStale(false);
|
|
setStaleBannerOpen(true);
|
|
setColumnNavigation({ dirty: false, busy: false });
|
|
};
|
|
const navigateDatabase = (target: "databases" | "overview" | "relationships") => {
|
|
if (navigationBusy) return;
|
|
if (dirty && !window.confirm(t("Discard unsaved metadata?"))) return;
|
|
if (target === "databases") onBackToDatabases();
|
|
else if (target === "overview") onOpenOverview();
|
|
else onOpenRelationships();
|
|
};
|
|
const save = async () => {
|
|
if (!activeTable || editorVersion === null) return;
|
|
setBusy("save");
|
|
try {
|
|
const updated = await updateCatalogTableMetadata(
|
|
databaseId, activeTable.id, editorVersion,
|
|
description.trim() || null, generatedDescription.trim() || null,
|
|
);
|
|
queryClient.setQueryData<CatalogTable[]>(queryKey, (current = []) => current.map((table) => table.id === updated.id ? updated : table));
|
|
setDescription(updated.description ?? "");
|
|
setGeneratedDescription(updated.generatedDescription ?? "");
|
|
setBaseline(JSON.stringify([updated.description ?? "", updated.generatedDescription ?? ""]));
|
|
setEditorVersion(updated.version);
|
|
setStale(false);
|
|
void onCatalogMetricsChanged?.();
|
|
toast.success(t("Table metadata saved"));
|
|
} catch (error) {
|
|
if (error instanceof ApiError && error.code === "table_stale") {
|
|
await refetch();
|
|
setStale(true);
|
|
setStaleBannerOpen(true);
|
|
} else toast.error(t(apiErrorMessage(error)));
|
|
} finally { setBusy(null); }
|
|
};
|
|
const reloadTable = async () => {
|
|
if (!activeTableId) return;
|
|
setBusy("sync");
|
|
try {
|
|
const result = await refetch();
|
|
const latest = result.data?.find((table) => table.id === activeTableId);
|
|
if (!latest) { leaveTable(); return; }
|
|
setDescription(latest.description ?? "");
|
|
setGeneratedDescription(latest.generatedDescription ?? "");
|
|
setBaseline(JSON.stringify([latest.description ?? "", latest.generatedDescription ?? ""]));
|
|
setEditorVersion(latest.version);
|
|
setStale(false);
|
|
setStaleBannerOpen(true);
|
|
} catch (error) { toast.error(t(apiErrorMessage(error))); } finally { setBusy(null); }
|
|
};
|
|
const synchronize = async (scope: "tables" | "columns" | "all", tableIds: string[] = []) => {
|
|
setBusy("sync");
|
|
try {
|
|
const run = await startCatalogSync(databaseId, database.version, scope, tableIds);
|
|
onRunStarted(run);
|
|
gridRef.current?.api.deselectAll();
|
|
setSelectedIds([]);
|
|
toast.success(scope === "all" ? t("Full schema synchronization started") : scope === "columns" ? t("Column synchronization started") : t("Table synchronization started"));
|
|
} catch (error) { toast.error(t(apiErrorMessage(error))); } finally { setBusy(null); }
|
|
};
|
|
const deleteMetadata = async (target: CatalogTableMetadataDeleteTarget) => {
|
|
setBusy("delete");
|
|
try {
|
|
const counts = await deleteCatalogTableMetadata(databaseId, selectedIds, target);
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ["catalog-columns", databaseId] }),
|
|
queryClient.invalidateQueries({ queryKey: ["catalog-relationships", databaseId] }),
|
|
queryClient.invalidateQueries({ queryKey: ["catalog-databases"] }),
|
|
]);
|
|
gridRef.current?.api.deselectAll();
|
|
setSelectedIds([]);
|
|
setPendingDelete(null);
|
|
window.setTimeout(() => searchInputRef.current?.focus(), 0);
|
|
void onCatalogMetricsChanged?.();
|
|
toast.success(target === "columns"
|
|
? t("{value} {columns} catalog columns", { value: presentation === "fleet" ? t("Cleared") : t("Deleted"), columns: counts.columns })
|
|
: t("{value} {relationships} catalog relationships", { value: presentation === "fleet" ? t("Cleared") : t("Deleted"), relationships: counts.relationships }));
|
|
} catch (error) {
|
|
toast.error(t(apiErrorMessage(error)));
|
|
} finally { setBusy(null); }
|
|
};
|
|
const consolidateDescriptions = async () => {
|
|
setBusy("consolidate");
|
|
try {
|
|
const counts = await consolidateCatalogDescriptions(databaseId, "tables", selectedIds);
|
|
await queryClient.invalidateQueries({ queryKey, exact: true });
|
|
gridRef.current?.api.deselectAll();
|
|
setSelectedIds([]);
|
|
void onCatalogMetricsChanged?.();
|
|
toast.success((counts.copied === 1 ? t("Copied {copied} description; skipped {skipped}", { copied: counts.copied, skipped: counts.skipped }) : t("Copied {copied} descriptions; skipped {skipped}", { copied: counts.copied, skipped: counts.skipped })));
|
|
} catch (error) {
|
|
toast.error(t(apiErrorMessage(error)));
|
|
} finally { setBusy(null); }
|
|
};
|
|
const generateDescriptions = async () => {
|
|
if (selectedIds.length === 0 || !selectedMetadataModel) return;
|
|
setBusy("generate");
|
|
try {
|
|
const run = await startDescriptionGenerationRun(
|
|
databaseId,
|
|
selectedMetadataModel,
|
|
"selected_tables",
|
|
selectedIds,
|
|
);
|
|
onDescriptionGenerationRunStarted(run);
|
|
toast.success((selectedIds.length === 1 ? t("Description generation started for {count} table", { count: selectedIds.length }) : t("Description generation started for {count} tables", { count: selectedIds.length })));
|
|
} catch (error) {
|
|
toast.error(t(apiErrorMessage(error)));
|
|
} finally { setBusy(null); }
|
|
};
|
|
const generateColumnDescriptions = async () => {
|
|
if (selectedIds.length === 0 || !selectedMetadataModel) return;
|
|
setBusy("generate");
|
|
try {
|
|
const columns = (await Promise.all(selectedIds.map((tableId) => listCatalogColumns(databaseId, tableId)))).flat();
|
|
const columnIds = columns.map((column) => column.id);
|
|
if (columnIds.length === 0) {
|
|
toast.error(t("The selected tables have no synchronized catalog columns."));
|
|
return;
|
|
}
|
|
const run = await startDescriptionGenerationRun(
|
|
databaseId,
|
|
selectedMetadataModel,
|
|
"selected_columns",
|
|
columnIds,
|
|
);
|
|
onDescriptionGenerationRunStarted(run);
|
|
toast.success((columnIds.length === 1 ? t("Column description generation started for {count} column", { count: columnIds.length }) : t("Column description generation started for {count} columns", { count: columnIds.length })));
|
|
} catch (error) {
|
|
toast.error(t(apiErrorMessage(error)));
|
|
} finally { setBusy(null); }
|
|
};
|
|
const suggestSensitive = async () => {
|
|
if (selectedIds.length === 0) return;
|
|
setBusy("suggest");
|
|
try {
|
|
await onSuggestSensitive(
|
|
{ scope: "selected_tables", targetIds: selectedIds },
|
|
(selectedIds.length === 1 ? adminMessage("{count} selected table", { count: selectedIds.length }) : adminMessage("{count} selected tables", { count: selectedIds.length })),
|
|
);
|
|
} catch {
|
|
// The page-level request reports the safe, specific reason and preserves the selection.
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
type FleetTableAction =
|
|
| "generate-descriptions"
|
|
| "generate-column-descriptions"
|
|
| "sync-tables"
|
|
| "sync-columns"
|
|
| "consolidate-descriptions"
|
|
| "suggest-sensitive"
|
|
| "clear-columns";
|
|
|
|
const fleetActions: readonly FleetActionOption<FleetTableAction>[] = [
|
|
{
|
|
id: "generate-descriptions",
|
|
label: t("Generate table description"),
|
|
group: t("Descriptions"),
|
|
runLabel: t("Generate"),
|
|
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun),
|
|
disabledReason: !canManage
|
|
? t("You do not have permission to generate descriptions.")
|
|
: selectedIds.length === 0
|
|
? t("Select at least one table.")
|
|
: !selectedMetadataModel
|
|
? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE
|
|
: descriptionGenerationActive || Boolean(currentRun)
|
|
? t("Wait for the active catalog operation to finish.")
|
|
: busy !== null
|
|
? t("Another action is running.")
|
|
: undefined,
|
|
},
|
|
{
|
|
id: "generate-column-descriptions",
|
|
label: t("Generate column descriptions"),
|
|
group: t("Descriptions"),
|
|
runLabel: t("Generate"),
|
|
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun),
|
|
disabledReason: !canManage
|
|
? t("You do not have permission to generate descriptions.")
|
|
: selectedIds.length === 0
|
|
? t("Select at least one table.")
|
|
: !selectedMetadataModel
|
|
? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE
|
|
: descriptionGenerationActive || Boolean(currentRun)
|
|
? t("Wait for the active catalog operation to finish.")
|
|
: busy !== null
|
|
? t("Another action is running.")
|
|
: undefined,
|
|
},
|
|
{
|
|
id: "consolidate-descriptions",
|
|
label: t("Copy generated descriptions to Description"),
|
|
group: t("Descriptions"),
|
|
runLabel: t("Move"),
|
|
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
|
|
disabledReason: !canManage
|
|
? t("You do not have permission to update metadata.")
|
|
: selectedIds.length === 0
|
|
? t("Select at least one table.")
|
|
: busy !== null || Boolean(currentRun)
|
|
? t("Wait for the active catalog operation to finish.")
|
|
: undefined,
|
|
},
|
|
{
|
|
id: "sync-tables",
|
|
label: t("Synchronize database tables"),
|
|
group: t("Synchronization"),
|
|
scopeLabel: t("Selected database"),
|
|
runLabel: t("Synchronize"),
|
|
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
|
|
disabledReason: !canManage
|
|
? t("You do not have permission to synchronize the catalog.")
|
|
: selectedIds.length === 0
|
|
? t("Select at least one table.")
|
|
: busy !== null || Boolean(currentRun)
|
|
? t("Wait for the active catalog operation to finish.")
|
|
: undefined,
|
|
},
|
|
{
|
|
id: "sync-columns",
|
|
label: t("Synchronize columns for selected tables"),
|
|
group: t("Synchronization"),
|
|
runLabel: t("Synchronize"),
|
|
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
|
|
disabledReason: !canManage
|
|
? t("You do not have permission to synchronize the catalog.")
|
|
: selectedIds.length === 0
|
|
? t("Select at least one table.")
|
|
: busy !== null || Boolean(currentRun)
|
|
? t("Wait for the active catalog operation to finish.")
|
|
: undefined,
|
|
},
|
|
{
|
|
id: "suggest-sensitive",
|
|
label: t("Analyze sensitive fields"),
|
|
group: t("Sensitive data"),
|
|
runLabel: t("Analyze"),
|
|
disabled: !canManage || selectedIds.length === 0 || descriptionGenerationActive || busy !== null || Boolean(currentRun),
|
|
disabledReason: !canManage
|
|
? t("You do not have permission to review sensitive data.")
|
|
: selectedIds.length === 0
|
|
? t("Select at least one table.")
|
|
: descriptionGenerationActive || Boolean(currentRun)
|
|
? t("Wait for the active catalog operation to finish.")
|
|
: busy !== null
|
|
? t("Another action is running.")
|
|
: undefined,
|
|
},
|
|
{
|
|
id: "clear-columns",
|
|
label: t("Remove synchronized columns"),
|
|
group: t("Catalog cleanup"),
|
|
tone: "destructive",
|
|
runLabel: t("Review cleanup"),
|
|
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
|
|
disabledReason: !canManage
|
|
? t("You do not have permission to clear catalog metadata.")
|
|
: selectedIds.length === 0
|
|
? t("Select at least one table.")
|
|
: busy !== null || Boolean(currentRun)
|
|
? t("Wait for the active catalog operation to finish.")
|
|
: undefined,
|
|
},
|
|
];
|
|
|
|
const runFleetAction = async (action: FleetTableAction) => {
|
|
if (action === "generate-descriptions") await generateDescriptions();
|
|
else if (action === "generate-column-descriptions") await generateColumnDescriptions();
|
|
else if (action === "sync-tables") await synchronize("tables");
|
|
else if (action === "consolidate-descriptions") await consolidateDescriptions();
|
|
else if (action === "sync-columns") await synchronize("columns", selectedIds);
|
|
else if (action === "suggest-sensitive") await suggestSensitive();
|
|
else if (action === "clear-columns") setPendingDelete("columns");
|
|
};
|
|
|
|
const clearSelection = () => {
|
|
gridRef.current?.api.deselectAll();
|
|
setSelectedIds([]);
|
|
};
|
|
|
|
const columns = useMemo<ColDef<CatalogTable>[]>(() => [
|
|
{ field: "name", headerName: t("Name"), minWidth: 250, flex: 1, cellClass: "font-mono text-xs" },
|
|
{ field: "sourceComment", headerName: t("Source comment"), minWidth: 260, flex: 1.25, valueFormatter: ({ value }) => value ?? "" },
|
|
{ field: "generatedDescription", headerName: t("Generated description"), minWidth: 280, flex: 1.25, valueFormatter: ({ value }) => value ?? "" },
|
|
{ field: "description", headerName: t("Description"), minWidth: 280, flex: 1.25, valueFormatter: ({ value }) => value ?? "" },
|
|
{ colId: "actions", headerName: t("Actions"), width: 104, minWidth: 104, maxWidth: 104, pinned: "right", lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: TableActionsCell },
|
|
], [t]);
|
|
const context = useMemo<TableGridContext>(() => ({ canManage, presentation, onOpen: openTable }), [canManage, data, presentation, t]);
|
|
const visibleSelected = useMemo(() => {
|
|
const term = search.trim().toLocaleLowerCase();
|
|
if (!term) return selectedIds.length;
|
|
const selected = new Set(selectedIds);
|
|
return data.filter((table) => selected.has(table.id) && [table.name, table.sourceComment, table.generatedDescription, table.description]
|
|
.some((value) => value?.toLocaleLowerCase().includes(term))).length;
|
|
}, [data, search, selectedIds, t]);
|
|
const fleetQueryError = presentation === "fleet" && isError ? (
|
|
<div className="grid min-h-[280px] flex-1 place-items-center p-6" role="alert">
|
|
<div className="max-w-lg rounded-lg border border-destructive/30 bg-destructive/8 p-5 text-sm">
|
|
<p className="font-semibold text-destructive">{t("Catalog tables could not be loaded.")}</p>
|
|
<p className="mt-1 leading-5 text-muted-foreground">{t(apiErrorMessage(error))}</p>
|
|
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refetch()}>
|
|
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> {t("Retry tables")}</Button>
|
|
</div>
|
|
</div>
|
|
) : null;
|
|
|
|
const databaseHeader = (
|
|
<div className="px-4 pt-5 sm:px-5">
|
|
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={navigationBusy} onClick={() => navigateDatabase("databases")}><ArrowLeft />{t("Back to databases")}</Button>
|
|
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5">
|
|
<div>
|
|
<p className="thot-label mb-1">{t("Database management")}</p>
|
|
<h2 ref={headingRef} tabIndex={-1} className="font-heading text-2xl font-semibold tracking-tight outline-none">{database.workspaceName}</h2>
|
|
<p className="mt-1 text-sm text-muted-foreground">{database.databaseName} · {database.schema}</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{currentRun ? <Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />{t("View sync")}</Button> : null}
|
|
{onClearCatalogTables ? <Button type="button" variant="outline" className="text-destructive" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void clearAllCatalogTables()}>{t("Clear catalog tables")}</Button> : null}
|
|
<Button type="button" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("all")}><RefreshCw />{t("Sync all")}</Button>
|
|
</div>
|
|
</div>
|
|
<nav aria-label={t("Database sections")} className="flex gap-1 border-b border-border" role="tablist">
|
|
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={() => navigateDatabase("overview")}>{t("Overview")}</button>
|
|
<button type="button" role="tab" aria-selected="true" className="border-b-2 border-primary px-3 py-2 text-sm font-semibold text-foreground">{t("Tables")}</button>
|
|
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={() => navigateDatabase("relationships")}>{t("Relationships")}</button>
|
|
</nav>
|
|
</div>
|
|
);
|
|
|
|
if (activeTable && presentation === "fleet") {
|
|
if (tableSection === "columns") {
|
|
return (
|
|
<section aria-label={t("Columns for {name}", { name: activeTable.name })} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
|
|
<div className="flex min-h-12 flex-wrap items-center gap-3 border-b border-border bg-muted/25 px-3 py-2">
|
|
<div className="min-w-0">
|
|
<p className="thot-label">{t("Physical table")}</p>
|
|
<h3 className="truncate font-heading text-lg font-semibold">{activeTable.name}</h3>
|
|
</div>
|
|
</div>
|
|
<DatabaseColumns
|
|
databaseId={databaseId}
|
|
table={activeTable}
|
|
databaseVersion={database.version}
|
|
canManage={canManage}
|
|
selectedMetadataModel={selectedMetadataModel}
|
|
descriptionGenerationActive={descriptionGenerationActive}
|
|
onDescriptionGenerationRunStarted={onDescriptionGenerationRunStarted}
|
|
onCatalogSyncRunStarted={onRunStarted}
|
|
onNavigationStateChange={setColumnNavigation}
|
|
onSuggestSensitive={onSuggestSensitive}
|
|
catalogOperationActive={Boolean(currentRun)}
|
|
onCatalogMetricsChanged={onCatalogMetricsChanged}
|
|
presentation="fleet"
|
|
/>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section aria-label={t("Tables for {workspaceName}", { workspaceName: database.workspaceName })} className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
|
|
<div className="thot-fleet-grid-toolbar flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2">
|
|
<span className="thot-label whitespace-nowrap">{t("Catalog tables")}</span>
|
|
<input
|
|
ref={searchInputRef}
|
|
className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
|
|
aria-label={t("Search tables")}
|
|
placeholder={t("Search")}
|
|
value={search}
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
/>
|
|
<span className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">
|
|
{search.trim() ? t("{count} of {total}", { count: displayedCount, total: data.length }) : displayedCount}
|
|
</span>
|
|
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}>
|
|
<RefreshCw className={isFetching ? "animate-spin" : ""} /> {t("Refresh")}</Button>
|
|
</div>
|
|
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
|
|
<div className="thot-database-grid thot-fleet-ledger-grid ag-theme-alpine absolute inset-0 h-full w-full">
|
|
<AgGridReact<CatalogTable> key={locale} {...gridLocaleProps}
|
|
ref={gridRef}
|
|
rowData={data}
|
|
columnDefs={columns}
|
|
context={context}
|
|
loading={isLoading}
|
|
quickFilterText={search}
|
|
defaultColDef={{ sortable: true, filter: true, resizable: true }}
|
|
getRowId={({ data: table }) => table.id}
|
|
rowSelection={{ mode: "multiRow", selectAll: "filtered", enableClickSelection: false }}
|
|
onSelectionChanged={({ api }) => setSelectedIds(api.getSelectedRows().map((table) => table.id))}
|
|
onModelUpdated={({ api }) => setDisplayedCount(api.getDisplayedRowCount())}
|
|
rowHeight={44}
|
|
headerHeight={38}
|
|
animateRows={false}
|
|
overlayNoRowsTemplate={t("No catalog tables. Synchronize the schema from the database view.")}
|
|
/>
|
|
</div>
|
|
</div>}
|
|
<FleetLedgerDrawer
|
|
open
|
|
eyebrow={t("Catalog metadata")}
|
|
title={t("Review table description")}
|
|
description={t("Physical schema fields are read-only. Review fields can be edited.")}
|
|
onClose={leaveTable}
|
|
closeLabel={t("Close table metadata")}
|
|
busy={busy !== null}
|
|
footer={(
|
|
<>
|
|
<Button type="button" variant="outline" disabled={busy !== null} onClick={leaveTable}>{t("Cancel")}</Button>
|
|
<Button type="button" disabled={!canManage || !editorDirty || busy !== null || stale} onClick={() => void save()}>
|
|
<Save aria-hidden="true" />{busy === "save" ? t("Saving…") : t("Save metadata")}
|
|
</Button>
|
|
</>
|
|
)}
|
|
>
|
|
<p className="thot-label">{t("Physical table")}</p>
|
|
<h3 className="mt-1 break-words font-heading text-xl font-semibold">{activeTable.name}</h3>
|
|
{stale ? (
|
|
staleBannerOpen ? (
|
|
<div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm">
|
|
<p className="font-semibold">{t("A newer table description is available.")}</p>
|
|
<p className="mt-1 text-muted-foreground">{t("Your draft is preserved until you reload the current metadata.")}</p>
|
|
<div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadTable()}>{t("Reload latest")}</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>{t("Keep editing")}</Button></div>
|
|
</div>
|
|
) : <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this description can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>{t("Reload latest")}</Button></div>
|
|
) : null}
|
|
<div className="mt-5 grid gap-4">
|
|
<label className="grid gap-1.5 text-sm font-semibold">{t("Physical table name")}<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={activeTable.name} readOnly /></label>
|
|
<label className="grid gap-1.5 text-sm font-semibold">{t("Source comment")}<textarea className="min-h-24 rounded-md border border-input bg-muted/35 px-3 py-2 text-sm" value={activeTable.sourceComment ?? ""} readOnly /></label>
|
|
<label className="grid gap-1.5 text-sm font-semibold">{t("Generated description")}<textarea className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={generatedDescription} disabled={!canManage || busy !== null} onChange={(event) => setGeneratedDescription(event.target.value)} /></label>
|
|
<label className="grid gap-1.5 text-sm font-semibold">{t("Description")}<textarea className="min-h-36 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={description} disabled={!canManage || busy !== null} onChange={(event) => setDescription(event.target.value)} /><span className="font-normal text-muted-foreground">{t("Leave blank to keep the curated description empty.")}</span></label>
|
|
</div>
|
|
</FleetLedgerDrawer>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (activeTable) {
|
|
return (
|
|
<section aria-label={t("Table {name}", { name: activeTable.name })} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
|
|
{databaseHeader}
|
|
<div className="mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5">
|
|
<div className="border-b border-border px-4 pt-4">
|
|
<Button type="button" variant="ghost" className="-ml-2 mb-2" onClick={leaveTable}><ArrowLeft />{t("Back to tables")}</Button>
|
|
<p className="thot-label">{t("Physical table")}</p>
|
|
<h3 className="mt-1 font-heading text-xl font-semibold">{activeTable.name}</h3>
|
|
<nav aria-label={t("Table sections")} className="mt-3 flex gap-1" role="tablist">
|
|
<button type="button" role="tab" aria-selected={tableSection === "overview"} className={`border-b-2 px-3 py-2 text-sm font-semibold ${tableSection === "overview" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`} onClick={() => setTableSection("overview")}>{t("Overview")}</button>
|
|
<button type="button" role="tab" aria-selected={tableSection === "columns"} className={`border-b-2 px-3 py-2 text-sm font-semibold ${tableSection === "columns" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`} onClick={() => setTableSection("columns")}>{t("Columns")}</button>
|
|
</nav>
|
|
</div>
|
|
{tableSection === "columns" ? (
|
|
<DatabaseColumns
|
|
databaseId={databaseId}
|
|
table={activeTable}
|
|
databaseVersion={database.version}
|
|
canManage={canManage}
|
|
selectedMetadataModel={selectedMetadataModel}
|
|
descriptionGenerationActive={descriptionGenerationActive}
|
|
onDescriptionGenerationRunStarted={onDescriptionGenerationRunStarted}
|
|
onCatalogSyncRunStarted={onRunStarted}
|
|
onNavigationStateChange={setColumnNavigation}
|
|
onSuggestSensitive={onSuggestSensitive}
|
|
onCatalogMetricsChanged={onCatalogMetricsChanged}
|
|
/>
|
|
) : (
|
|
<div className="min-h-0 overflow-y-auto px-4 py-5">
|
|
<p className="text-sm text-muted-foreground">{t("Only review metadata can be changed.")}</p>
|
|
{stale ? (
|
|
staleBannerOpen ? (
|
|
<div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm">
|
|
<p className="font-semibold">{t("A newer table description is available.")}</p>
|
|
<p className="mt-1 text-muted-foreground">{t("Your draft is preserved until you reload the current metadata.")}</p>
|
|
<div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadTable()}>{t("Reload latest")}</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>{t("Keep editing")}</Button></div>
|
|
</div>
|
|
) : <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this description can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>{t("Reload latest")}</Button></div>
|
|
) : null}
|
|
<div className="mt-5 grid gap-4">
|
|
<label className="grid gap-1.5 text-sm font-semibold">{t("Physical table name")}<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={activeTable.name} readOnly /></label>
|
|
<label className="grid gap-1.5 text-sm font-semibold">{t("Source comment")}<textarea className="min-h-24 rounded-md border border-input bg-muted/35 px-3 py-2 text-sm" value={activeTable.sourceComment ?? ""} readOnly /></label>
|
|
<label className="grid gap-1.5 text-sm font-semibold">{t("Generated description")}<textarea className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={generatedDescription} disabled={!canManage || busy !== null} onChange={(event) => setGeneratedDescription(event.target.value)} /></label>
|
|
<label className="grid gap-1.5 text-sm font-semibold">{t("Description")}<textarea className="min-h-36 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={description} disabled={!canManage || busy !== null} onChange={(event) => setDescription(event.target.value)} /><span className="font-normal text-muted-foreground">{t("Leave blank to keep the curated description empty.")}</span></label>
|
|
</div>
|
|
<div className="mt-5 flex justify-end gap-2 border-t border-border pt-4">
|
|
<Button type="button" variant="outline" disabled={busy !== null} onClick={leaveTable}>{t("Cancel")}</Button>
|
|
<Button type="button" disabled={!canManage || !editorDirty || busy !== null || stale} onClick={() => void save()}><Save />{busy === "save" ? t("Saving…") : t("Save metadata")}</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section aria-label={t("Tables for {workspaceName}", { workspaceName: database.workspaceName })} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
|
|
{presentation === "legacy" ? databaseHeader : null}
|
|
<div className={presentation === "fleet" ? "thot-fleet-grid-surface flex min-h-0 flex-1 flex-col overflow-hidden bg-card" : "mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5"}>
|
|
<div className={`${presentation === "fleet" ? "thot-fleet-grid-toolbar " : ""}flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2`}>
|
|
{selectedIds.length > 0 ? (
|
|
pendingDelete ? (
|
|
<div
|
|
role="group"
|
|
aria-labelledby="table-cleanup-confirmation"
|
|
className="flex min-w-0 flex-1 flex-wrap items-center gap-2"
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Escape" && busy === null) closeDeleteConfirmation();
|
|
}}
|
|
>
|
|
<div className="mr-auto min-w-56">
|
|
<p id="table-cleanup-confirmation" className="text-sm font-semibold">
|
|
{presentation === "fleet"
|
|
? (selectedIds.length === 1 ? t("Remove synchronized columns for {count} table?", { count: selectedIds.length }) : t("Remove synchronized columns for {count} tables?", { count: selectedIds.length }))
|
|
: (selectedIds.length === 1 ? t("Remove synchronized columns for {count} table?", { count: selectedIds.length }) : t("Remove synchronized columns for {count} tables?", { count: selectedIds.length }))}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("Only synchronized catalog columns for these tables will be removed. The source database is not modified.")}</p>
|
|
</div>
|
|
<Button type="button" variant="ghost" disabled={busy !== null} onClick={closeDeleteConfirmation}>{t("Cancel")}</Button>
|
|
<Button
|
|
id="table-cleanup-confirm-button"
|
|
type="button"
|
|
variant="destructive"
|
|
disabled={busy !== null}
|
|
onClick={() => void deleteMetadata(pendingDelete)}
|
|
>
|
|
<Trash2 />{t("Remove synchronized columns")}</Button>
|
|
</div>
|
|
) : (
|
|
presentation === "fleet" ? (
|
|
<>
|
|
<FleetActionSelector
|
|
actions={fleetActions}
|
|
selectedCount={selectedIds.length}
|
|
hiddenSelectedCount={selectedIds.length - visibleSelected}
|
|
busy={busy !== null}
|
|
busyLabel={busy === "generate" ? t("Generating…") : busy === "suggest" ? t("Suggesting…") : t("Running…")}
|
|
selectRef={fleetActionSelectRef}
|
|
onRun={runFleetAction}
|
|
onClear={clearSelection}
|
|
label={t("Table action")}
|
|
/>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="text-sm font-semibold">{selectedIds.length} {t("selected")}</span>
|
|
{visibleSelected !== selectedIds.length ? <span className="text-xs text-muted-foreground">{selectedIds.length - visibleSelected} {t("hidden by filter")}</span> : null}
|
|
<Menu.Root>
|
|
<Menu.Trigger ref={actionsTriggerRef} className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={busy !== null}>{t("Actions")} <ChevronDown className="size-4" /></Menu.Trigger>
|
|
<Menu.Portal>
|
|
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
|
|
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
|
|
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateDescriptions()}>{t("Generate table description")}</Menu.Item>
|
|
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateColumnDescriptions()}>{t("Generate descriptions for all columns")}</Menu.Item>
|
|
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("tables")}>{t("Synchronize database tables")}</Menu.Item>
|
|
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("columns", selectedIds)}>{t("Synchronize columns for selected tables")}</Menu.Item>
|
|
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void consolidateDescriptions()}>{t("Copy generated descriptions to Description")}</Menu.Item>
|
|
<Menu.Separator className="my-1 h-px bg-border" />
|
|
<Menu.Item className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => setPendingDelete("columns")}>{t("Remove synchronized columns")}</Menu.Item>
|
|
</Menu.Popup>
|
|
</Menu.Positioner>
|
|
</Menu.Portal>
|
|
</Menu.Root>
|
|
<Button type="button" variant="outline" disabled={!canManage || descriptionGenerationActive || busy !== null} title={descriptionGenerationActive ? t("Wait for the active description generation to finish") : undefined} onClick={() => void suggestSensitive()}><Sparkles />{busy === "suggest" ? t("Analyzing…") : t("Analyze sensitive fields")}</Button>
|
|
<Button type="button" variant="ghost" onClick={clearSelection}><X />{t("Clear")}</Button>
|
|
</>
|
|
)
|
|
)
|
|
) : (
|
|
presentation === "fleet" ? (
|
|
<>
|
|
<FleetActionSelector
|
|
actions={fleetActions}
|
|
selectedCount={0}
|
|
busy={busy !== null}
|
|
busyLabel={busy === "generate" ? t("Generating…") : busy === "suggest" ? t("Suggesting…") : t("Running…")}
|
|
selectRef={fleetActionSelectRef}
|
|
onRun={runFleetAction}
|
|
onClear={clearSelection}
|
|
label={t("Table action")}
|
|
/>
|
|
<input ref={searchInputRef} className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label={t("Search tables")} placeholder={t("Search")} value={search} onChange={(event) => setSearch(event.target.value)} />
|
|
<span aria-live="polite" className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">{search.trim() ? t("{count} of {total}", { count: displayedCount, total: data.length }) : displayedCount}</span>
|
|
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />{t("Refresh")}</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="thot-label whitespace-nowrap">{t("Catalog tables")}</span>
|
|
<input ref={searchInputRef} className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label={t("Search tables")} placeholder={t("Search")} value={search} onChange={(event) => setSearch(event.target.value)} />
|
|
<span aria-live="polite" className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">{search.trim() ? t("{count} of {total}", { count: displayedCount, total: data.length }) : displayedCount}</span>
|
|
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />{t("Refresh")}</Button>
|
|
</>
|
|
)
|
|
)}
|
|
</div>
|
|
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
|
|
<div className={`thot-database-grid ${presentation === "fleet" ? "thot-fleet-ledger-grid " : ""}ag-theme-alpine absolute inset-0 h-full w-full`}>
|
|
<AgGridReact<CatalogTable> key={locale} {...gridLocaleProps}
|
|
ref={gridRef}
|
|
rowData={data}
|
|
columnDefs={columns}
|
|
context={context}
|
|
loading={isLoading}
|
|
quickFilterText={search}
|
|
defaultColDef={{ sortable: true, filter: true, resizable: true }}
|
|
getRowId={({ data: table }) => table.id}
|
|
rowSelection={{ mode: "multiRow", selectAll: "filtered", enableClickSelection: false }}
|
|
onSelectionChanged={({ api }) => {
|
|
if (pendingDelete && busy === null) setPendingDelete(null);
|
|
setSelectedIds(api.getSelectedRows().map((table) => table.id));
|
|
}}
|
|
onModelUpdated={({ api }) => setDisplayedCount(api.getDisplayedRowCount())}
|
|
rowHeight={44}
|
|
headerHeight={38}
|
|
animateRows={false}
|
|
overlayNoRowsTemplate={t("No catalog tables. Synchronize the schema from the database view.")}
|
|
/>
|
|
</div>
|
|
</div>}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|