diff --git a/.dockerignore b/.dockerignore index 7ac93658..2330a18f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,8 @@ **/__pycache__ **/.pytest_cache **/dist +frontend/prototypes/ +frontend/vite.database-management-prototype.config.ts **/*.pyc .git .worktrees diff --git a/frontend/package.json b/frontend/package.json index 6b064826..41b69b4c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,6 +4,7 @@ "type": "module", "scripts": { "dev": "vite", + "prototype:database-management": "vite --config vite.database-management-prototype.config.ts", "build": "tsc -b && vite build", "preview": "vite preview", "test": "vitest run", diff --git a/frontend/prototypes/database-management/App.tsx b/frontend/prototypes/database-management/App.tsx new file mode 100644 index 00000000..1831db73 --- /dev/null +++ b/frontend/prototypes/database-management/App.tsx @@ -0,0 +1,173 @@ +import { useEffect, useMemo, useState } from "react"; +import { + AlertTriangle, + CheckCircle2, + ExternalLink, + FlaskConical, + PlayCircle, + RotateCcw, +} from "lucide-react"; +import { FALLBACK_SNAPSHOT, loadCatalogSnapshot, VARIANTS } from "./data"; +import type { PanelKind, PrototypeState, ScenarioKey, VariantKey } from "./model"; +import { ExplorerVariant } from "./variants/ExplorerVariant"; +import { InventoryVariant } from "./variants/InventoryVariant"; +import { OperationsVariant } from "./variants/OperationsVariant"; + +const VARIANT_COMPONENTS = { A: InventoryVariant, B: ExplorerVariant, C: OperationsVariant } as const; + +const INITIAL_STATE: PrototypeState = { + scenario: "steady", + inventoryLevel: "databases", + selectedDatabaseId: FALLBACK_SNAPSHOT.databases[0].id, + selectedTableId: FALLBACK_SNAPSHOT.databases[0].tables[0]?.id ?? null, + selectedRowIds: [], + search: "", + panel: "none", + panelTargetId: null, + sensitiveDrafts: {}, +}; + +function initialVariant(): VariantKey { + const candidate = new URLSearchParams(window.location.search).get("variant")?.toUpperCase(); + return candidate === "B" || candidate === "C" ? candidate : "A"; +} + +function initialFrame(): boolean { + return new URLSearchParams(window.location.search).get("frame") !== "off"; +} + +function updateQuery(updates: Record): void { + const url = new URL(window.location.href); + Object.entries(updates).forEach(([key, value]) => value === null ? url.searchParams.delete(key) : url.searchParams.set(key, value)); + window.history.replaceState({}, "", url); +} + +function isTypingTarget(target: EventTarget | null): boolean { + return target instanceof HTMLElement && target.matches("input, textarea, select, [contenteditable='true']"); +} + +const scenarioCopy: Record = { + steady: { label: "Live state", detail: "Current catalog snapshot" }, + running: { label: "Sync preview", detail: "Running operation" }, + changes: { label: "Diff preview", detail: "Destructive confirmation" }, +}; + +export function PrototypeApp() { + const [variant, setVariant] = useState(initialVariant); + const [framed, setFramed] = useState(initialFrame); + const [snapshot, setSnapshot] = useState(FALLBACK_SNAPSHOT); + const [catalogLoading, setCatalogLoading] = useState(true); + const [catalogError, setCatalogError] = useState(null); + const [state, setState] = useState(INITIAL_STATE); + const [notice, setNotice] = useState(null); + + const databases = snapshot.databases; + const selectedDatabase = useMemo( + () => databases.find((database) => database.id === state.selectedDatabaseId) ?? databases[0], + [databases, state.selectedDatabaseId], + ); + const selectedTable = useMemo( + () => selectedDatabase.tables.find((table) => table.id === state.selectedTableId) ?? selectedDatabase.tables[0] ?? null, + [selectedDatabase, state.selectedTableId], + ); + + useEffect(() => { + let active = true; + loadCatalogSnapshot().then((liveSnapshot) => { + if (!active || !liveSnapshot.databases.length) return; + setSnapshot(liveSnapshot); + setState((current) => { + const database = liveSnapshot.databases.find((item) => item.id === current.selectedDatabaseId) ?? liveSnapshot.databases[0]; + const table = database.tables.find((item) => item.id === current.selectedTableId) ?? database.tables[0] ?? null; + return { ...current, selectedDatabaseId: database.id, selectedTableId: table?.id ?? null, selectedRowIds: [] }; + }); + setCatalogError(null); + }).catch((error: unknown) => { + if (active) setCatalogError(error instanceof Error ? error.message : "Live catalog unavailable"); + }).finally(() => { if (active) setCatalogLoading(false); }); + return () => { active = false; }; + }, []); + + useEffect(() => { + const onPopState = () => { setVariant(initialVariant()); setFramed(initialFrame()); }; + window.addEventListener("popstate", onPopState); + return () => window.removeEventListener("popstate", onPopState); + }, []); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (isTypingTarget(event.target)) return; + const currentIndex = VARIANTS.findIndex((item) => item.key === variant); + let next: VariantKey | null = null; + if (event.key === "1") next = "A"; + if (event.key === "2") next = "B"; + if (event.key === "3") next = "C"; + if (event.key === "ArrowRight") next = VARIANTS[(currentIndex + 1) % VARIANTS.length].key; + if (event.key === "ArrowLeft") next = VARIANTS[(currentIndex - 1 + VARIANTS.length) % VARIANTS.length].key; + if (!next) return; + event.preventDefault(); + setVariant(next); + updateQuery({ variant: next }); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [variant]); + + useEffect(() => { + if (!notice) return; + const timeout = window.setTimeout(() => setNotice(null), 3000); + return () => window.clearTimeout(timeout); + }, [notice]); + + const chooseVariant = (next: VariantKey) => { setVariant(next); updateQuery({ variant: next }); }; + const chooseDatabase = (databaseId: string) => { + const database = databases.find((item) => item.id === databaseId) ?? databases[0]; + setState((current) => ({ ...current, selectedDatabaseId: database.id, selectedTableId: database.tables[0]?.id ?? null, selectedRowIds: [], search: "", panelTargetId: null })); + }; + const chooseTable = (tableId: string) => setState((current) => ({ ...current, selectedTableId: tableId, selectedRowIds: [], search: "" })); + const toggleRow = (rowId: string) => setState((current) => ({ ...current, selectedRowIds: current.selectedRowIds.includes(rowId) ? current.selectedRowIds.filter((id) => id !== rowId) : [...current.selectedRowIds, rowId] })); + const chooseScenario = (scenario: ScenarioKey) => setState((current) => ({ ...current, scenario })); + const simulateAction = (message: string, scenario?: ScenarioKey) => { if (scenario) chooseScenario(scenario); setNotice(message); }; + const openPanel = (panel: PanelKind, panelTargetId?: string) => setState((current) => ({ ...current, panel, panelTargetId: panelTargetId ?? null })); + const closePanel = () => setState((current) => ({ ...current, panel: "none", panelTargetId: null })); + const toggleSensitive = (columnId: string, value: boolean) => setState((current) => ({ ...current, sensitiveDrafts: { ...current.sensitiveDrafts, [columnId]: value } })); + const saveSensitive = () => { + const count = Object.keys(state.sensitiveDrafts).length; + setState((current) => ({ ...current, sensitiveDrafts: {}, panel: "none", panelTargetId: null })); + setNotice(`${count} sensitive-field decision${count === 1 ? "" : "s"} saved in prototype only`); + }; + const resetState = () => { + const database = databases[0]; + setState({ ...INITIAL_STATE, selectedDatabaseId: database.id, selectedTableId: database.tables[0]?.id ?? null }); + }; + const toggleFrame = () => { const next = !framed; setFramed(next); updateQuery({ frame: next ? null : "off" }); }; + + const VariantComponent = VARIANT_COMPONENTS[variant]; + const activeVariant = VARIANTS.find((item) => item.key === variant) ?? VARIANTS[0]; + const variantProps = { + snapshot, databases, state, selectedDatabase, selectedTable, + onSelectDatabase: chooseDatabase, onSelectTable: chooseTable, onToggleRow: toggleRow, + onClearSelection: () => setState((current) => ({ ...current, selectedRowIds: [] })), + onSearch: (search: string) => setState((current) => ({ ...current, search })), + onInventoryLevel: (inventoryLevel: PrototypeState["inventoryLevel"]) => setState((current) => ({ ...current, inventoryLevel, search: "", selectedRowIds: [], panel: "none", panelTargetId: null })), + onOpenPanel: openPanel, onClosePanel: closePanel, onToggleSensitive: toggleSensitive, onSaveSensitive: saveSensitive, + onScenario: chooseScenario, onAction: simulateAction, + }; + + return ( +
+ {framed ? : } + +
+ {notice &&
{notice}
} +
+ ); +} diff --git a/frontend/prototypes/database-management/README.md b/frontend/prototypes/database-management/README.md new file mode 100644 index 00000000..7dd3478b --- /dev/null +++ b/frontend/prototypes/database-management/README.md @@ -0,0 +1,39 @@ +# Database management UI prototype + +> Throwaway, development-only prototype. It reads a secret-excluding metadata snapshot from the +> local catalog PostgreSQL container and never persists changes. + +Question being tested: **where should the real database-management actions, sensitive-field +review, and operation histories live inside the Fleet inventory information architecture?** + +Run it from `frontend/`: + +```bash +npm run prototype:database-management +``` + +Then open . The left prototype rail switches among: + +- `A`, Fleet Ledger: one dense grid at a time, selection actions inline, detail in a right drawer +- `B`, Fleet Workbench: the same grid beside a persistent contextual rail +- `C`, Fleet Operations Deck: the same grid with actions and history in a bottom console + +Every variant uses the same live read-only dataset and the same real action vocabulary. Row actions +match production exactly; batch actions appear only after selection. Simulated actions, edits, and +sensitive decisions remain in memory and reset on reload. The current local catalog contains one +database, 163 tables, 2,275 columns, 5 sensitive flags, 0 relationships, and 21 sync runs. Description +and sensitive-suggestion histories are honestly empty because no runs are persisted yet. + +The active variant is stored in the URL. Edit files in this directory while Vite is running to see +changes immediately through hot reload. Set `THT_PROTOTYPE_CATALOG_CONTAINER` before starting Vite +to read a different local catalog container; if the container is unavailable, a small saved snapshot +is displayed and labelled as such. The live snapshot excludes credentials and source rows, but it +does include catalog host/base URL metadata, comments, descriptions, and sanitized operation errors. + +## Generated theme asset + +All three Fleet ideas use `assets/thoth-cosmic-archive.png`, generated with the built-in ImageGen tool in +`stylized-concept` mode. Final prompt: a restrained wide fantasy mural of Thoth as guardian of a +celestial archive, with papyrus, stone, and constellation paths suggesting schema relationships; +twilight lapis-charcoal and warm porcelain palette; quiet central negative space; no text, logos, +watermarks, interface elements, neon, excessive gold, or busy detail behind the data surface. diff --git a/frontend/prototypes/database-management/assets/thoth-cosmic-archive.png b/frontend/prototypes/database-management/assets/thoth-cosmic-archive.png new file mode 100644 index 00000000..bcaa9d71 Binary files /dev/null and b/frontend/prototypes/database-management/assets/thoth-cosmic-archive.png differ diff --git a/frontend/prototypes/database-management/data.ts b/frontend/prototypes/database-management/data.ts new file mode 100644 index 00000000..a14bd405 --- /dev/null +++ b/frontend/prototypes/database-management/data.ts @@ -0,0 +1,215 @@ +import type { + CatalogColumn, + CatalogDatabase, + CatalogRelationship, + CatalogRunEvent, + CatalogSnapshot, + CatalogSyncRun, + CatalogTable, + DescriptionGenerationRun, + SensitiveSuggestionRun, + VariantDefinition, +} from "./model"; + +export const VARIANTS: VariantDefinition[] = [ + { + key: "A", + name: "Fleet ledger", + premise: "Dense inventory with selection actions inline and operational detail in a drawer.", + bestFor: "Fast catalog administration", + }, + { + key: "B", + name: "Fleet workbench", + premise: "The inventory remains visible beside a persistent contextual action and history rail.", + bestFor: "Comparing data and consequences", + }, + { + key: "C", + name: "Fleet operations deck", + premise: "A full-width inventory with actions, review, and run history in a bottom console.", + bestFor: "Long-running operational work", + }, +]; + +interface RawSnapshot { + capturedAt: string; + databases: Array>; + tables: Array>; + columns: Array>; + relationships: Array>; + relationshipColumns: Array>; + syncRuns: Array>; + syncEvents: Array>; + descriptionRuns: Array>; + descriptionEvents: Array>; + sensitiveRuns: Array>; + sensitiveEvents: Array>; +} + +const text = (value: unknown, fallback = "") => typeof value === "string" ? value : fallback; +const nullableText = (value: unknown) => typeof value === "string" && value.length ? value : null; +const number = (value: unknown, fallback = 0) => typeof value === "number" ? value : Number(value) || fallback; +const boolean = (value: unknown) => value === true || value === "true"; + +function titleWorkspace(value: string): string { + if (value === "psd-clinical") return "Policlinico San Donato"; + return value.split(/[-_]/).map((part) => part.length <= 3 ? part.toUpperCase() : `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`).join(" "); +} + +function transportLabel(value: string): CatalogDatabase["transport"] { + if (value === "rest_api") return "REST API"; + if (value === "postgres_direct") return "Direct PostgreSQL"; + if (value === "ssh_tunnel") return "SSH tunnel"; + return "Unconfigured"; +} + +function runState(value: unknown): CatalogSyncRun["state"] { + const candidate = text(value) as CatalogSyncRun["state"]; + return ["queued", "running", "awaiting_confirmation", "applying", "succeeded", "failed", "cancelled", "interrupted"].includes(candidate) ? candidate : "failed"; +} + +function normalizeEvents(rows: Array>): CatalogRunEvent[] { + return rows.map((row) => ({ + runId: text(row.runId), + sequence: number(row.sequence), + level: (text(row.level, "info") as CatalogRunEvent["level"]), + message: text(row.message), + createdAt: text(row.createdAt), + })); +} + +export function normalizeSnapshot(raw: RawSnapshot): CatalogSnapshot { + const columnsByTable = new Map(); + raw.columns.forEach((row) => { + const tableId = text(row.tableId); + const column: CatalogColumn = { + id: text(row.id), tableId, name: text(row.name), ordinalPosition: number(row.ordinalPosition), + dataType: text(row.dataType), isNullable: boolean(row.isNullable), + defaultExpression: nullableText(row.defaultExpression), primaryKeyPosition: row.primaryKeyPosition === null ? null : number(row.primaryKeyPosition), + sourceComment: nullableText(row.sourceComment), description: nullableText(row.description), + generatedDescription: nullableText(row.generatedDescription), sensitive: boolean(row.sensitive), + lastSyncedAt: nullableText(row.lastSyncedAt), version: number(row.version, 1), + }; + const list = columnsByTable.get(tableId) ?? []; + list.push(column); + columnsByTable.set(tableId, list); + }); + + const tablesByDatabase = new Map(); + raw.tables.forEach((row) => { + const id = text(row.id); + const databaseId = text(row.databaseId); + const columns = (columnsByTable.get(id) ?? []).sort((a, b) => a.ordinalPosition - b.ordinalPosition); + const described = columns.filter((column) => column.description || column.generatedDescription).length; + const table: CatalogTable = { + id, databaseId, name: text(row.name), sourceComment: nullableText(row.sourceComment), + description: nullableText(row.description), generatedDescription: nullableText(row.generatedDescription), + lastSyncedAt: nullableText(row.lastSyncedAt), version: number(row.version, 1), columns, + columnCount: columns.length, sensitiveCount: columns.filter((column) => column.sensitive).length, + descriptionCoverage: columns.length ? Math.round((described / columns.length) * 100) : 0, + }; + const list = tablesByDatabase.get(databaseId) ?? []; + list.push(table); + tablesByDatabase.set(databaseId, list); + }); + + const relationshipColumns = new Map(); + raw.relationshipColumns.forEach((row) => { + const relationshipId = text(row.relationshipId); + const list = relationshipColumns.get(relationshipId) ?? []; + list.push({ sourceColumnName: text(row.sourceColumnName), targetColumnName: text(row.targetColumnName), ordinalPosition: number(row.ordinalPosition) }); + relationshipColumns.set(relationshipId, list); + }); + const relationshipsByDatabase = new Map(); + raw.relationships.forEach((row) => { + const id = text(row.id); + const databaseId = text(row.databaseId); + const relationship: CatalogRelationship = { + id, databaseId, constraintName: text(row.constraintName), sourceTableName: text(row.sourceTableName), + targetTableName: text(row.targetTableName), updateRule: text(row.updateRule), deleteRule: text(row.deleteRule), + columns: relationshipColumns.get(id) ?? [], + }; + const list = relationshipsByDatabase.get(databaseId) ?? []; + list.push(relationship); + relationshipsByDatabase.set(databaseId, list); + }); + + const syncRuns: CatalogSyncRun[] = raw.syncRuns.map((row) => ({ + id: text(row.id), databaseId: text(row.databaseId), scope: text(row.scope, "all") as CatalogSyncRun["scope"], + state: runState(row.state), phase: text(row.phase), counts: (row.counts ?? {}) as Record, + plannedDiff: (row.plannedDiff ?? null) as CatalogSyncRun["plannedDiff"], errorCode: nullableText(row.errorCode), + errorMessage: nullableText(row.errorMessage), createdAt: text(row.createdAt), startedAt: nullableText(row.startedAt), + finishedAt: nullableText(row.finishedAt), + })); + + const databases = raw.databases.map((row): CatalogDatabase => { + const id = text(row.id); + const tables = (tablesByDatabase.get(id) ?? []).sort((a, b) => a.name.localeCompare(b.name)); + const relationships = relationshipsByDatabase.get(id) ?? []; + const transport = transportLabel(text(row.transport)); + const connectionStatus = text(row.connectionStatus); + const version = number(row.version, 1); + const testedVersion = row.testedVersion === null ? null : number(row.testedVersion); + const status = transport === "Unconfigured" ? "Not configured" : connectionStatus === "reachable" && testedVersion === version ? "Ready" : "Attention"; + const describedTargets = tables.reduce((total, table) => total + (table.description || table.generatedDescription ? 1 : 0) + table.columns.filter((column) => column.description || column.generatedDescription).length, 0); + const totalTargets = tables.length + tables.reduce((total, table) => total + table.columns.length, 0); + const host = nullableText(row.host); + const baseUrl = nullableText(row.baseUrl); + const port = row.port === null ? null : number(row.port); + return { + id, displayName: titleWorkspace(text(row.workspaceId)), workspaceId: text(row.workspaceId), engine: text(row.engine), + databaseName: text(row.databaseName), schemaName: text(row.schemaName), version, transport, + endpoint: baseUrl ?? (host ? `${host}${port ? `:${port}` : ""}` : "No connection binding"), + connectionStatus, testedVersion, status, lastSync: nullableText(row.schemaSyncedAt), updatedAt: text(row.updatedAt), + tableCount: tables.length, columnCount: tables.reduce((total, table) => total + table.columns.length, 0), + relationshipCount: relationships.length, sensitiveCount: tables.reduce((total, table) => total + table.sensitiveCount, 0), + descriptionCoverage: totalTargets ? Math.round((describedTargets / totalTargets) * 100) : 0, + tables, relationships, activeSyncRun: syncRuns.find((run) => run.databaseId === id && ["queued", "running", "awaiting_confirmation", "applying"].includes(run.state)) ?? null, + }; + }); + + const descriptionRuns = raw.descriptionRuns as unknown as DescriptionGenerationRun[]; + const sensitiveRuns = raw.sensitiveRuns as unknown as SensitiveSuggestionRun[]; + return { + source: "live", capturedAt: raw.capturedAt, databases, syncRuns, + syncEvents: normalizeEvents(raw.syncEvents), descriptionRuns, descriptionEvents: normalizeEvents(raw.descriptionEvents), + sensitiveRuns, sensitiveEvents: normalizeEvents(raw.sensitiveEvents), + }; +} + +export async function loadCatalogSnapshot(): Promise { + const response = await fetch("/__prototype/catalog-snapshot", { headers: { Accept: "application/json" } }); + if (!response.ok) throw new Error(`Live catalog returned ${response.status}`); + return normalizeSnapshot(await response.json() as RawSnapshot); +} + +const fallbackColumns: CatalogColumn[] = [ + { id: "f1-cod-paz", tableId: "f1", name: "cod_paz", ordinalPosition: 1, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: 1, sourceComment: null, description: null, generatedDescription: null, sensitive: true, lastSyncedAt: "2026-08-27T23:11:39Z", version: 1 }, + { id: "f1-num", tableId: "f1", name: "num", ordinalPosition: 2, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: 2, sourceComment: null, description: null, generatedDescription: null, sensitive: false, lastSyncedAt: "2026-08-27T23:11:39Z", version: 1 }, + { id: "f1-note", tableId: "f1", name: "note", ordinalPosition: 8, dataType: "text", isNullable: true, defaultExpression: null, primaryKeyPosition: null, sourceComment: null, description: null, generatedDescription: null, sensitive: true, lastSyncedAt: "2026-08-27T23:11:39Z", version: 1 }, +]; + +const fallbackTable: CatalogTable = { + id: "f1", databaseId: "psd-live", name: "fact_chiusura_defibrillatore_impiantabile", + sourceComment: null, description: null, generatedDescription: null, lastSyncedAt: "2026-08-27T23:11:39Z", version: 1, + columns: fallbackColumns, columnCount: 12, sensitiveCount: 3, descriptionCoverage: 0, +}; + +const fallbackRun: CatalogSyncRun = { + id: "sync-2026-08-27", databaseId: "psd-live", scope: "all", state: "succeeded", phase: "completed", + counts: { tables: 163, columns: 2275, relationships: 0, created: 0, updated: 0, deleted: 0 }, plannedDiff: null, + errorCode: null, errorMessage: null, createdAt: "2026-08-27T23:11:39Z", startedAt: "2026-08-27T23:11:39Z", finishedAt: "2026-08-27T23:11:40Z", +}; + +export const FALLBACK_SNAPSHOT: CatalogSnapshot = { + source: "fallback", capturedAt: "2026-08-27T23:11:40Z", + databases: [{ + id: "psd-live", displayName: "Policlinico San Donato", workspaceId: "psd-clinical", engine: "postgres", databaseName: "postgres", + schemaName: "datawarehouse", version: 4, transport: "REST API", endpoint: "https://supabase-aritmolab.policlinicosandonato.it/dwh/", + connectionStatus: "reachable", testedVersion: 4, status: "Ready", lastSync: "2026-08-27T23:11:39Z", updatedAt: "2026-08-27T23:11:39Z", + tableCount: 163, columnCount: 2275, relationshipCount: 0, sensitiveCount: 5, descriptionCoverage: 0, + tables: [fallbackTable], relationships: [], activeSyncRun: null, + }], + syncRuns: [fallbackRun], syncEvents: [], descriptionRuns: [], descriptionEvents: [], sensitiveRuns: [], sensitiveEvents: [], +}; diff --git a/frontend/prototypes/database-management/fleet.tsx b/frontend/prototypes/database-management/fleet.tsx new file mode 100644 index 00000000..eff902e9 --- /dev/null +++ b/frontend/prototypes/database-management/fleet.tsx @@ -0,0 +1,469 @@ +import { useMemo, useState, type ReactNode } from "react"; +import { + Activity, + ArrowLeft, + CheckCircle2, + ChevronDown, + ChevronRight, + CircleAlert, + Clock3, + Columns3, + Database, + FileClock, + FileText, + History, + KeyRound, + LockKeyhole, + Pencil, + Plus, + RefreshCw, + Rows3, + Save, + Search, + Settings2, + ShieldCheck, + Sparkles, + Table2, + TestTube2, + Trash2, + Unlock, + X, +} from "lucide-react"; +import type { + CatalogColumn, + CatalogDatabase, + CatalogSnapshot, + CatalogSyncRun, + CatalogTable, + InventoryLevel, + PanelKind, + VariantProps, +} from "./model"; +import { Coverage, StatusBadge } from "./shared"; + +export function formatMoment(value: string | null): string { + if (!value) return "Never"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("en-GB", { + day: "2-digit", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit", + }).format(date); +} + +function humanize(value: string): string { + return value.replaceAll("_", " ").replace(/^./, (letter) => letter.toUpperCase()); +} + +export function LiveCatalogBadge({ snapshot }: { snapshot: CatalogSnapshot }) { + const live = snapshot.source === "live"; + return ( + + + ); +} + +export function FleetHeader({ snapshot, variantLabel, onOpenPanel }: { + snapshot: CatalogSnapshot; + variantLabel: string; + onOpenPanel: VariantProps["onOpenPanel"]; +}) { + return ( +
+
+

Thoth catalog · {variantLabel}

+

Database management

+ +
+
+ + + +
+
+ ); +} + +export function FleetBreadcrumb({ level, database, table, onLevel }: { + level: InventoryLevel; + database: CatalogDatabase; + table: CatalogTable | null; + onLevel: (level: InventoryLevel) => void; +}) { + const parentLevel: InventoryLevel | null = level === "columns" ? "tables" : level === "tables" || level === "relationships" ? "databases" : null; + return ( +
+ {parentLevel ? ( + + ) : null} + + {level === "tables" || level === "relationships" ? ( +
+ + +
+ ) : null} +
+ ); +} + +interface RowActionProps { + label: string; + tooltip: string; + icon: ReactNode; + onClick: () => void; + disabled?: boolean; + destructive?: boolean; +} + +function RowAction({ label, tooltip, icon, onClick, disabled = false, destructive = false }: RowActionProps) { + return ( + + ); +} + +function RowActions({ label, children }: { label: string; children: ReactNode }) { + return
{children}
; +} + +export function FleetGrid(props: VariantProps) { + const { + databases, state, selectedDatabase, selectedTable, onSelectDatabase, onSelectTable, + onToggleRow, onSearch, onInventoryLevel, onOpenPanel, onAction, onToggleSensitive, + } = props; + const query = state.search.trim().toLocaleLowerCase(); + const level = state.inventoryLevel; + const rows = useMemo(() => { + if (level === "databases") return databases.filter((database) => `${database.displayName} ${database.workspaceId} ${database.databaseName} ${database.schemaName} ${database.transport}`.toLocaleLowerCase().includes(query)); + if (level === "tables") return selectedDatabase.tables.filter((table) => `${table.name} ${table.sourceComment ?? ""} ${table.description ?? ""}`.toLocaleLowerCase().includes(query)); + if (level === "columns") return (selectedTable?.columns ?? []).filter((column) => `${column.name} ${column.dataType} ${column.sourceComment ?? ""}`.toLocaleLowerCase().includes(query)); + return selectedDatabase.relationships.filter((relationship) => `${relationship.constraintName} ${relationship.sourceTableName} ${relationship.targetTableName}`.toLocaleLowerCase().includes(query)); + }, [databases, level, query, selectedDatabase, selectedTable]); + const total = level === "databases" ? databases.length : level === "tables" ? selectedDatabase.tables.length : level === "columns" ? selectedTable?.columns.length ?? 0 : selectedDatabase.relationships.length; + const heading = level === "databases" ? "Workspace databases" : level === "tables" ? `Tables in ${selectedDatabase.displayName}` : level === "columns" ? `Columns in ${selectedTable?.name ?? "selected table"}` : `Relationships in ${selectedDatabase.displayName}`; + + return ( +
+
+
+

{level === "databases" ? "Fleet inventory" : level === "columns" ? `Table · ${selectedTable?.name ?? ""}` : `Workspace · ${selectedDatabase.workspaceId}`}

+

{heading}

+ {rows.length} of {total} objects shown +
+ +
+
+ {level === "databases" ? ( + + + + {(rows as CatalogDatabase[]).map((database) => + + + + + + + )} +
Workspace databases
SelectWorkspaceDatabaseSchemaTransportEndpointStatusUpdatedActions
onToggleRow(database.id)} />{database.displayName}{database.workspaceId}{database.databaseName}{database.schemaName}{database.transport}{database.endpoint}{formatMoment(database.updatedAt)} + {database.activeSyncRun ? } onClick={() => { onSelectDatabase(database.id); onOpenPanel("sync-history", database.activeSyncRun?.id); }} /> : null} + } onClick={() => { onSelectDatabase(database.id); onInventoryLevel("tables"); }} /> + } onClick={() => { onSelectDatabase(database.id); onOpenPanel("database-form", database.id); }} /> + } destructive onClick={() => onAction(`Delete confirmation opened for ${database.displayName}`)} /> +
+ ) : null} + + {level === "tables" ? ( + + + + {(rows as CatalogTable[]).map((table) => + + + + + + + )} +
Catalog tables
SelectNameSource commentGenerated descriptionDescriptionActions
onToggleRow(table.id)} />{table.name}{table.columnCount} columns · {table.sensitiveCount} sensitive{table.sourceComment ?? No source comment}{table.generatedDescription ?? Not generated}{table.description ?? Not curated} + } onClick={() => { onSelectTable(table.id); onInventoryLevel("columns"); }} /> + } onClick={() => { onSelectTable(table.id); onOpenPanel("metadata-editor", table.id); }} /> +
+ ) : null} + + {level === "columns" ? ( + + + + {(rows as CatalogColumn[]).map((column) => { + const sensitive = state.sensitiveDrafts[column.id] ?? column.sensitive; + return + + + + + + + + + + + + ; + })} +
Catalog columns
Select#SensitiveNameTypeKeysNullableSource commentGenerated descriptionDescriptionActions
onToggleRow(column.id)} />{column.ordinalPosition}{column.name}{column.dataType}{column.primaryKeyPosition ? PK {column.primaryKeyPosition} : None}{column.isNullable ? "Yes" : "No"}{column.sourceComment ?? No source comment}{column.generatedDescription ?? Not generated}{column.description ?? Not curated} + } onClick={() => onOpenPanel("metadata-editor", column.id)} /> +
+ ) : null} + + {level === "relationships" ? ( + + + + {selectedDatabase.relationships.length ? selectedDatabase.relationships.map((relationship) => + + + + ) : } +
Catalog relationships
ConstraintSource tableSource columnsTarget tableTarget columnsOn updateOn deleteActions
{relationship.constraintName}{relationship.sourceTableName}{relationship.columns.map((pair) => pair.sourceColumnName).join(", ")}{relationship.targetTableName}{relationship.columns.map((pair) => pair.targetColumnName).join(", ")}{relationship.updateRule}{relationship.deleteRule}No row actions
No relationships in the live catalogThe current PSD catalog contains 163 tables and 0 synchronized relationships.
+ ) : null} +
+
+ ); +} + +type ActionGroup = "Connection" | "Synchronization" | "Descriptions" | "Sensitive data" | "Cleanup"; +type ActionDefinition = { + id: string; + group: ActionGroup; + label: string; + icon: ReactNode; + onClick: () => void; + executeLabel?: string; + destructive?: boolean; + disabled?: boolean; +}; + +const actionGroupOrder: ActionGroup[] = ["Connection", "Synchronization", "Descriptions", "Sensitive data", "Cleanup"]; + +export function FleetSelectionActions({ props, direction = "row" }: { props: VariantProps; direction?: "row" | "column" }) { + const { state, selectedDatabase, onAction, onOpenPanel, onClearSelection, onSaveSensitive } = props; + const count = state.selectedRowIds.length; + const disabled = count === 0; + const [selectedCommandId, setSelectedCommandId] = useState(""); + const run = (message: string, scenario?: "running" | "changes") => () => onAction(message, scenario); + let actions: ActionDefinition[] = []; + if (state.inventoryLevel === "databases") actions = [ + { id: "databases-test", group: "Connection", label: "Test connections", icon: , disabled, onClick: run(`Testing ${count} selected connection${count === 1 ? "" : "s"}`) }, + { id: "databases-sync-tables", group: "Synchronization", label: "Synchronize tables", icon: , disabled, onClick: run("Table synchronization started", "running") }, + { id: "databases-sync-columns", group: "Synchronization", label: "Synchronize all columns", icon: , disabled, onClick: run("Column synchronization started", "running") }, + { id: "databases-sync-relationships", group: "Synchronization", label: "Synchronize relationships", icon: , disabled, onClick: run("Relationship synchronization started", "running") }, + { id: "databases-sync-all", group: "Synchronization", label: "Synchronize all", icon: , disabled, onClick: run("Full schema synchronization started", "running") }, + { id: "databases-generate-all", group: "Descriptions", label: "Generate all descriptions", icon: , disabled, onClick: run("Generate all confirmation opened") }, + { id: "databases-generate-missing", group: "Descriptions", label: "Generate missing descriptions", icon: , disabled, onClick: run("Missing-description generation started") }, + { id: "databases-suggest-sensitive", group: "Sensitive data", label: "Suggest sensitive fields", icon: , executeLabel: "Open review", disabled: count !== 1, onClick: () => onOpenPanel("sensitive-review") }, + { id: "databases-delete-tables", group: "Cleanup", label: "Delete all catalog tables", icon: , executeLabel: "Review deletion", destructive: true, disabled, onClick: run("Delete catalog tables confirmation opened", "changes") }, + { id: "databases-delete-relationships", group: "Cleanup", label: "Delete all relationships", icon: , executeLabel: "Review deletion", destructive: true, disabled, onClick: run("Delete relationships confirmation opened", "changes") }, + ]; + if (state.inventoryLevel === "tables") actions = [ + { id: "tables-generate", group: "Descriptions", label: "Generate descriptions", icon: , disabled, onClick: run(`Description generation started for ${count} table${count === 1 ? "" : "s"}`) }, + { id: "tables-sync-columns", group: "Synchronization", label: "Synchronize columns", icon: , disabled, onClick: run("Selected table column synchronization started", "running") }, + { id: "tables-consolidate", group: "Descriptions", label: "Move generated to Description", icon: , disabled, onClick: run("Generated descriptions moved to curated Description") }, + { id: "tables-suggest-sensitive", group: "Sensitive data", label: "Suggest sensitive fields", icon: , executeLabel: "Open review", disabled, onClick: () => onOpenPanel("sensitive-review") }, + { id: "tables-delete-columns", group: "Cleanup", label: "Delete all columns", icon: , executeLabel: "Review deletion", destructive: true, disabled, onClick: run("Delete table columns confirmation opened", "changes") }, + { id: "tables-delete-relationships", group: "Cleanup", label: "Delete all relationships", icon: , executeLabel: "Review deletion", destructive: true, disabled, onClick: run("Delete table relationships confirmation opened", "changes") }, + ]; + if (state.inventoryLevel === "columns") actions = [ + { id: "columns-generate", group: "Descriptions", label: "Generate descriptions", icon: , disabled, onClick: run(`Description generation started for ${count} column${count === 1 ? "" : "s"}`) }, + { id: "columns-consolidate", group: "Descriptions", label: "Move generated to Description", icon: , disabled, onClick: run("Generated column descriptions consolidated") }, + { id: "columns-suggest-sensitive", group: "Sensitive data", label: "Suggest sensitive fields", icon: , executeLabel: "Open review", disabled, onClick: () => onOpenPanel("sensitive-review") }, + { id: "columns-save-sensitive", group: "Sensitive data", label: `Save sensitive fields (${Object.keys(state.sensitiveDrafts).length})`, icon: , executeLabel: "Save changes", disabled: Object.keys(state.sensitiveDrafts).length === 0, onClick: onSaveSensitive }, + ]; + if (state.inventoryLevel === "relationships") actions = [ + { id: "relationships-refresh", group: "Synchronization", label: "Refresh relationships", icon: , onClick: run("Relationships refreshed from the live catalog") }, + { id: "relationships-sync", group: "Synchronization", label: "Synchronize relationships", icon: , onClick: run("Relationship synchronization started", "running") }, + { id: "relationships-sync-all", group: "Synchronization", label: "Synchronize all", icon: , onClick: run("Full schema synchronization started", "running") }, + ]; + const selectedAction = actions.find((action) => action.id === selectedCommandId); + const selectedValue = selectedAction ? selectedCommandId : ""; + const executeSelectedAction = () => { + if (!selectedAction || selectedAction.disabled) return; + selectedAction.onClick(); + setSelectedCommandId(""); + }; + return ( +
+
{count ? `${count} selected` : "Select rows to act"}{count ? `${humanize(state.inventoryLevel)} scope · ${selectedDatabase.displayName}` : "Batch actions match the production DB management"}
+
+ + +
+ {count ? : null} +
+ ); +} + +function PanelHeader({ eyebrow, title, onClose }: { eyebrow: string; title: string; onClose: () => void }) { + return

{eyebrow}

{title}

; +} + +function EmptyHistory({ kind, detail }: { kind: string; detail: string }) { + return
No {kind} runs yet

{detail}

0 live records
; +} + +function SyncHistory({ snapshot, database, targetId, onAction }: { snapshot: CatalogSnapshot; database: CatalogDatabase; targetId: string | null; onAction: VariantProps["onAction"] }) { + const runs = snapshot.syncRuns.filter((run) => run.databaseId === database.id); + const [selectedId, setSelectedId] = useState(targetId ?? runs[0]?.id ?? ""); + const selected = runs.find((run) => run.id === selectedId) ?? runs[0]; + if (!selected) return ; + const events = snapshot.syncEvents.filter((event) => event.runId === selected.id).sort((a, b) => a.sequence - b.sequence); + const destructiveCount = (selected.plannedDiff?.deletedTables?.length ?? 0) + (selected.plannedDiff?.deletedColumns?.length ?? 0) + (selected.plannedDiff?.deletedRelationships?.length ?? 0); + return
+
+
{humanize(selected.state)}{humanize(selected.scope)} scope
+
{["tables", "columns", "relationships", "created", "updated", "deleted"].map((key) =>
{humanize(key)}{selected.counts[key] ?? 0}
)}
+
Started
{formatMoment(selected.startedAt ?? selected.createdAt)}
Finished
{formatMoment(selected.finishedAt)}
Phase
{humanize(selected.phase)}
+ {destructiveCount ?
{destructiveCount} removals require confirmationThe source diff has not been applied.
: null} + {selected.state === "running" || selected.state === "queued" ? : null} + {["failed", "cancelled", "interrupted"].includes(selected.state) ? : null} +

Event log

{events.length ? events.map((event) =>
{event.sequence}{event.message}
) :

No persisted events for this run.

}
+
+ +
; +} + +function DescriptionHistory({ snapshot, onAction }: { snapshot: CatalogSnapshot; onAction: VariantProps["onAction"] }) { + if (!snapshot.descriptionRuns.length) return <>; + return
{snapshot.descriptionRuns.map((run) =>
{humanize(run.status)}{humanize(run.scope)}{run.modelId} · {run.language}
{run.processed}/{run.total} processed{run.generated} generated · {run.nonGeneratable} not generated · {run.failed} failed
{["queued", "running"].includes(run.status) ? : null}
)}
; +} + +function SensitiveHistory({ snapshot }: { snapshot: CatalogSnapshot }) { + if (!snapshot.sensitiveRuns.length) return ; + return
{snapshot.sensitiveRuns.map((run) =>
{humanize(run.status)}{humanize(run.scope)}{run.modelId} · {formatMoment(run.createdAt)}
Total{run.total}
Sensitive{run.suggestedSensitive}
Not sensitive{run.suggestedNonSensitive}
)}
; +} + +function SensitiveReview({ props }: { props: VariantProps }) { + const { selectedDatabase, selectedTable, state, onToggleSensitive, onSaveSensitive, onClosePanel } = props; + const [search, setSearch] = useState(""); + const [showAll, setShowAll] = useState(false); + const selected = new Set(state.selectedRowIds); + const candidates = selectedDatabase.tables.flatMap((table) => table.columns.map((column) => ({ table, column }))).filter(({ table, column }) => { + const inScope = state.inventoryLevel === "databases" + ? true + : state.inventoryLevel === "tables" + ? (!selected.size ? table.id === selectedTable?.id : selected.has(table.id)) + : state.inventoryLevel === "columns" + ? (!selected.size ? table.id === selectedTable?.id : selected.has(column.id)) + : true; + const changed = state.sensitiveDrafts[column.id] !== undefined; + return inScope && (showAll || column.sensitive || changed || /cod_paz|note|numero_serie|nome|email|telefono|indirizzo/i.test(column.name)); + }).filter(({ table, column }) => `${table.name}.${column.name}`.toLocaleLowerCase().includes(search.toLocaleLowerCase())).slice(0, 80); + return
+
+
Review suggested flagsActual catalog columns, prototype decisions. Nothing is written.
+
{candidates.map(({ table, column }) => { + const current = column.sensitive; + const proposed = state.sensitiveDrafts[column.id] ?? (current || /cod_paz|numero_serie|note/i.test(column.name)); + return ; + })}{!candidates.length ?
No proposed changes in this view
: null}
+
+
; +} + +function MetadataEditor({ props }: { props: VariantProps }) { + const { state, selectedDatabase, onAction } = props; + const table = selectedDatabase.tables.find((item) => item.id === state.panelTargetId); + const column = selectedDatabase.tables.flatMap((item) => item.columns).find((item) => item.id === state.panelTargetId); + const target = column ?? table; + if (!target) return
Select a table or column to edit metadata
; + return
{ event.preventDefault(); onAction(`${column ? "Column" : "Table"} metadata saved in prototype`); }}> + +