chore: preserve database management prototype
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
**/__pycache__
|
||||
**/.pytest_cache
|
||||
**/dist
|
||||
frontend/prototypes/
|
||||
frontend/vite.database-management-prototype.config.ts
|
||||
**/*.pyc
|
||||
.git
|
||||
.worktrees
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, string | null>): 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<ScenarioKey, { label: string; detail: string }> = {
|
||||
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<VariantKey>(initialVariant);
|
||||
const [framed, setFramed] = useState(initialFrame);
|
||||
const [snapshot, setSnapshot] = useState(FALLBACK_SNAPSHOT);
|
||||
const [catalogLoading, setCatalogLoading] = useState(true);
|
||||
const [catalogError, setCatalogError] = useState<string | null>(null);
|
||||
const [state, setState] = useState<PrototypeState>(INITIAL_STATE);
|
||||
const [notice, setNotice] = useState<string | null>(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 (
|
||||
<div className={`prototype-shell ${framed ? "" : "prototype-shell--unframed"}`}>
|
||||
{framed ? <aside className="prototype-rail" aria-label="Prototype controls">
|
||||
<div className="prototype-identity"><div className="prototype-mark" aria-hidden="true"><FlaskConical size={17} /></div><div><p className="prototype-kicker">UI lab</p><strong>Fleet inventory</strong></div></div>
|
||||
<section className="prototype-question"><span>Question</span><p>Where should real fleet actions, sensitive review, and operation history live?</p></section>
|
||||
<nav className="prototype-variants" aria-label="Design hypotheses"><p className="prototype-nav-label">Fleet ideas</p>{VARIANTS.map((item) => <button key={item.key} type="button" data-tooltip={item.name} className="prototype-variant-button" aria-current={variant === item.key ? "page" : undefined} onClick={() => chooseVariant(item.key)}><span className="prototype-variant-key">{item.key}</span><span><strong>{item.name}</strong><small>{item.bestFor}</small></span></button>)}</nav>
|
||||
<section className="prototype-scenarios" aria-labelledby="scenario-heading"><p id="scenario-heading" className="prototype-nav-label">Operation preview</p><button type="button" data-tooltip="Live state" aria-pressed={state.scenario === "steady"} onClick={() => chooseScenario("steady")}><CheckCircle2 size={15} /> Live state</button><button type="button" data-tooltip="Synchronization" aria-pressed={state.scenario === "running"} onClick={() => chooseScenario("running")}><PlayCircle size={15} /> Sync running</button><button type="button" data-tooltip="Destructive changes" aria-pressed={state.scenario === "changes"} onClick={() => chooseScenario("changes")}><AlertTriangle size={15} /> Destructive diff</button></section>
|
||||
<section className="prototype-debug" aria-label="Current prototype state"><p className="prototype-nav-label">Current state</p><dl><div><dt>Source</dt><dd>{snapshot.source}</dd></div><div><dt>Database</dt><dd>{selectedDatabase.workspaceId}</dd></div><div><dt>Table</dt><dd>{selectedTable?.name ?? "None"}</dd></div><div><dt>View</dt><dd>{state.inventoryLevel}</dd></div><div><dt>Selected</dt><dd>{state.selectedRowIds.length}</dd></div><div><dt>Panel</dt><dd>{state.panel}</dd></div><div><dt>Status</dt><dd>{scenarioCopy[state.scenario].label}</dd></div></dl></section>
|
||||
<div className="prototype-rail-actions"><button type="button" data-tooltip="Reset prototype" onClick={resetState}><RotateCcw size={14} /> Reset state</button><button type="button" data-tooltip="Unframed view" onClick={toggleFrame}><ExternalLink size={14} /> Open unframed</button></div>
|
||||
<p className="prototype-footnote">Development only · {catalogLoading ? "loading live data" : catalogError ? "saved real snapshot" : "live local data"} · no API writes</p>
|
||||
</aside> : <button className="prototype-restore" type="button" data-tooltip="UI lab controls" onClick={toggleFrame}><FlaskConical size={15} /> Show UI lab</button>}
|
||||
|
||||
<main className="prototype-stage" aria-label={`${activeVariant.name} prototype`}><VariantComponent {...variantProps} /></main>
|
||||
{notice && <div className="prototype-toast" role="status">{notice}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <http://127.0.0.1:5173/?variant=A>. 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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
@@ -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<Record<string, unknown>>;
|
||||
tables: Array<Record<string, unknown>>;
|
||||
columns: Array<Record<string, unknown>>;
|
||||
relationships: Array<Record<string, unknown>>;
|
||||
relationshipColumns: Array<Record<string, unknown>>;
|
||||
syncRuns: Array<Record<string, unknown>>;
|
||||
syncEvents: Array<Record<string, unknown>>;
|
||||
descriptionRuns: Array<Record<string, unknown>>;
|
||||
descriptionEvents: Array<Record<string, unknown>>;
|
||||
sensitiveRuns: Array<Record<string, unknown>>;
|
||||
sensitiveEvents: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
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<Record<string, unknown>>): 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<string, CatalogColumn[]>();
|
||||
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<string, CatalogTable[]>();
|
||||
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<string, CatalogRelationship["columns"]>();
|
||||
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<string, CatalogRelationship[]>();
|
||||
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<string, number>,
|
||||
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<CatalogSnapshot> {
|
||||
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: [],
|
||||
};
|
||||
@@ -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 (
|
||||
<span className={`live-catalog-badge${live ? "" : " live-catalog-badge--fallback"}`}>
|
||||
<span className="live-catalog-dot" aria-hidden="true" />
|
||||
<span><strong>{live ? "Live local catalog" : "Saved catalog snapshot"}</strong><small>Read only · captured {formatMoment(snapshot.capturedAt)}</small></span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function FleetHeader({ snapshot, variantLabel, onOpenPanel }: {
|
||||
snapshot: CatalogSnapshot;
|
||||
variantLabel: string;
|
||||
onOpenPanel: VariantProps["onOpenPanel"];
|
||||
}) {
|
||||
return (
|
||||
<header className="fleet-header">
|
||||
<div className="fleet-title-block">
|
||||
<p className="eyebrow">Thoth catalog · {variantLabel}</p>
|
||||
<h1>Database management</h1>
|
||||
<LiveCatalogBadge snapshot={snapshot} />
|
||||
</div>
|
||||
<div className="fleet-global-controls">
|
||||
<button className="button" type="button" data-tooltip="Description history" onClick={() => onOpenPanel("description-history")}><FileClock size={15} /> Description history</button>
|
||||
<button className="button" type="button" data-tooltip="Sensitive history" onClick={() => onOpenPanel("sensitive-history")}><ShieldCheck size={15} /> Sensitive history</button>
|
||||
<button className="button button--primary" type="button" data-tooltip="Add database" onClick={() => onOpenPanel("database-form")}><Plus size={15} /> Add database</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="fleet-path-row">
|
||||
{parentLevel ? (
|
||||
<button className="inventory-back-button" type="button" onClick={() => onLevel(parentLevel)} aria-label={`Back to ${parentLevel}`} data-tooltip={`Back to ${parentLevel}`}>
|
||||
<ArrowLeft size={19} strokeWidth={2.4} />
|
||||
</button>
|
||||
) : null}
|
||||
<nav className="inventory-breadcrumb" aria-label="Catalog hierarchy">
|
||||
<button type="button" data-tooltip="Databases" aria-current={level === "databases" ? "page" : undefined} onClick={() => onLevel("databases")}>Databases</button>
|
||||
{level !== "databases" ? <><ChevronRight size={13} /><button type="button" data-tooltip="Tables" aria-label={`View tables in ${database.displayName}`} aria-current={level === "tables" || level === "relationships" ? "page" : undefined} onClick={() => onLevel("tables")}>{database.displayName}</button></> : null}
|
||||
{level === "columns" && table ? <><ChevronRight size={13} /><span aria-current="page">{table.name}</span></> : null}
|
||||
</nav>
|
||||
{level === "tables" || level === "relationships" ? (
|
||||
<div className="database-object-tabs" role="group" aria-label="Database objects">
|
||||
<button type="button" data-tooltip="Tables" aria-label={`Show tables in ${database.displayName}`} aria-pressed={level === "tables"} onClick={() => onLevel("tables")}><Table2 size={14} /> Tables</button>
|
||||
<button type="button" data-tooltip="Relationships" aria-label={`Show relationships in ${database.displayName}`} aria-pressed={level === "relationships"} onClick={() => onLevel("relationships")}><Activity size={14} /> Relationships</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button className={`row-action-button${destructive ? " row-action-button--destructive" : ""}`} type="button" aria-label={label} data-tooltip={tooltip} disabled={disabled} onClick={onClick}>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function RowActions({ label, children }: { label: string; children: ReactNode }) {
|
||||
return <div className="row-action-group" role="group" aria-label={label}>{children}</div>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="fleet-grid-region" aria-labelledby="fleet-grid-heading">
|
||||
<div className="fleet-grid-toolbar">
|
||||
<div>
|
||||
<p className="eyebrow">{level === "databases" ? "Fleet inventory" : level === "columns" ? `Table · ${selectedTable?.name ?? ""}` : `Workspace · ${selectedDatabase.workspaceId}`}</p>
|
||||
<h2 id="fleet-grid-heading">{heading}</h2>
|
||||
<span>{rows.length} of {total} objects shown</span>
|
||||
</div>
|
||||
<label className="search-field fleet-search">
|
||||
<Search size={15} aria-hidden="true" />
|
||||
<span className="sr-only">Search {level}</span>
|
||||
<input value={state.search} onChange={(event) => onSearch(event.target.value)} placeholder={`Search ${level}`} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="table-scroll fleet-table-scroll">
|
||||
{level === "databases" ? (
|
||||
<table className="data-table fleet-data-table database-table">
|
||||
<caption className="sr-only">Workspace databases</caption>
|
||||
<thead><tr><th><span className="sr-only">Select</span></th><th>Workspace</th><th>Database</th><th>Schema</th><th>Transport</th><th>Endpoint</th><th>Status</th><th>Updated</th><th className="inventory-actions-heading">Actions</th></tr></thead>
|
||||
<tbody>{(rows as CatalogDatabase[]).map((database) => <tr key={database.id} data-selected={state.selectedRowIds.includes(database.id) || undefined}>
|
||||
<td><input type="checkbox" aria-label={`Select ${database.displayName}`} checked={state.selectedRowIds.includes(database.id)} onChange={() => onToggleRow(database.id)} /></td>
|
||||
<td><span className="inventory-object-name"><span className="object-icon"><Database size={15} /></span><span><strong>{database.displayName}</strong><small>{database.workspaceId}</small></span></span></td>
|
||||
<td><code>{database.databaseName}</code></td><td><code>{database.schemaName}</code></td><td>{database.transport}</td>
|
||||
<td><span className="truncate-cell" title={database.endpoint}>{database.endpoint}</span></td><td><StatusBadge status={database.status} /></td>
|
||||
<td>{formatMoment(database.updatedAt)}</td>
|
||||
<td className="inventory-actions-cell"><RowActions label={`Actions for ${database.displayName}`}>
|
||||
{database.activeSyncRun ? <RowAction label={`View synchronization for ${database.displayName}`} tooltip="Synchronization" icon={<RefreshCw size={15} />} onClick={() => { onSelectDatabase(database.id); onOpenPanel("sync-history", database.activeSyncRun?.id); }} /> : null}
|
||||
<RowAction label={`View tables in ${database.displayName}`} tooltip="Tables" icon={<Rows3 size={15} />} onClick={() => { onSelectDatabase(database.id); onInventoryLevel("tables"); }} />
|
||||
<RowAction label={`Edit ${database.displayName}`} tooltip="Edit database" icon={<Pencil size={15} />} onClick={() => { onSelectDatabase(database.id); onOpenPanel("database-form", database.id); }} />
|
||||
<RowAction label={`Delete ${database.displayName}`} tooltip="Delete database" icon={<Trash2 size={15} />} destructive onClick={() => onAction(`Delete confirmation opened for ${database.displayName}`)} />
|
||||
</RowActions></td>
|
||||
</tr>)}</tbody>
|
||||
</table>
|
||||
) : null}
|
||||
|
||||
{level === "tables" ? (
|
||||
<table className="data-table fleet-data-table catalog-table-table">
|
||||
<caption className="sr-only">Catalog tables</caption>
|
||||
<thead><tr><th><span className="sr-only">Select</span></th><th>Name</th><th>Source comment</th><th>Generated description</th><th>Description</th><th className="inventory-actions-heading">Actions</th></tr></thead>
|
||||
<tbody>{(rows as CatalogTable[]).map((table) => <tr key={table.id} data-selected={state.selectedRowIds.includes(table.id) || undefined}>
|
||||
<td><input type="checkbox" aria-label={`Select ${table.name}`} checked={state.selectedRowIds.includes(table.id)} onChange={() => onToggleRow(table.id)} /></td>
|
||||
<td><span className="inventory-object-name"><span className="object-icon"><Table2 size={15} /></span><span><code>{table.name}</code><small>{table.columnCount} columns · {table.sensitiveCount} sensitive</small></span></span></td>
|
||||
<td><span className="catalog-copy">{table.sourceComment ?? <span className="quiet-value">No source comment</span>}</span></td>
|
||||
<td><span className="catalog-copy">{table.generatedDescription ?? <span className="quiet-value">Not generated</span>}</span></td>
|
||||
<td><span className="catalog-copy">{table.description ?? <span className="quiet-value">Not curated</span>}</span></td>
|
||||
<td className="inventory-actions-cell"><RowActions label={`Actions for table ${table.name}`}>
|
||||
<RowAction label={`View columns in ${table.name}`} tooltip="Columns" icon={<Columns3 size={15} />} onClick={() => { onSelectTable(table.id); onInventoryLevel("columns"); }} />
|
||||
<RowAction label={`Edit metadata for ${table.name}`} tooltip="Edit table metadata" icon={<Pencil size={15} />} onClick={() => { onSelectTable(table.id); onOpenPanel("metadata-editor", table.id); }} />
|
||||
</RowActions></td>
|
||||
</tr>)}</tbody>
|
||||
</table>
|
||||
) : null}
|
||||
|
||||
{level === "columns" ? (
|
||||
<table className="data-table fleet-data-table catalog-column-table">
|
||||
<caption className="sr-only">Catalog columns</caption>
|
||||
<thead><tr><th><span className="sr-only">Select</span></th><th>#</th><th>Sensitive</th><th>Name</th><th>Type</th><th>Keys</th><th>Nullable</th><th>Source comment</th><th>Generated description</th><th>Description</th><th className="inventory-actions-heading">Actions</th></tr></thead>
|
||||
<tbody>{(rows as CatalogColumn[]).map((column) => {
|
||||
const sensitive = state.sensitiveDrafts[column.id] ?? column.sensitive;
|
||||
return <tr key={column.id} data-selected={state.selectedRowIds.includes(column.id) || undefined}>
|
||||
<td><input type="checkbox" aria-label={`Select ${column.name}`} checked={state.selectedRowIds.includes(column.id)} onChange={() => onToggleRow(column.id)} /></td>
|
||||
<td>{column.ordinalPosition}</td>
|
||||
<td><label className="sensitive-toggle"><input type="checkbox" checked={sensitive} onChange={(event) => onToggleSensitive(column.id, event.target.checked)} /><span className="sr-only">Sensitive flag for {column.name}</span></label></td>
|
||||
<td><code>{column.name}</code></td>
|
||||
<td><code className="type-token">{column.dataType}</code></td>
|
||||
<td>{column.primaryKeyPosition ? <span className="column-key"><KeyRound size={13} /> PK {column.primaryKeyPosition}</span> : <span className="quiet-value">None</span>}</td>
|
||||
<td>{column.isNullable ? "Yes" : "No"}</td>
|
||||
<td><span className="catalog-copy">{column.sourceComment ?? <span className="quiet-value">No source comment</span>}</span></td>
|
||||
<td><span className="catalog-copy">{column.generatedDescription ?? <span className="quiet-value">Not generated</span>}</span></td>
|
||||
<td><span className="catalog-copy">{column.description ?? <span className="quiet-value">Not curated</span>}</span></td>
|
||||
<td className="inventory-actions-cell"><RowActions label={`Actions for column ${column.name}`}>
|
||||
<RowAction label={`Edit metadata for ${column.name}`} tooltip="Edit column metadata" icon={<Pencil size={15} />} onClick={() => onOpenPanel("metadata-editor", column.id)} />
|
||||
</RowActions></td>
|
||||
</tr>;
|
||||
})}</tbody>
|
||||
</table>
|
||||
) : null}
|
||||
|
||||
{level === "relationships" ? (
|
||||
<table className="data-table fleet-data-table relationship-table">
|
||||
<caption className="sr-only">Catalog relationships</caption>
|
||||
<thead><tr><th>Constraint</th><th>Source table</th><th>Source columns</th><th>Target table</th><th>Target columns</th><th>On update</th><th>On delete</th><th className="inventory-actions-heading">Actions</th></tr></thead>
|
||||
<tbody>{selectedDatabase.relationships.length ? selectedDatabase.relationships.map((relationship) => <tr key={relationship.id}>
|
||||
<td><code>{relationship.constraintName}</code></td><td>{relationship.sourceTableName}</td><td>{relationship.columns.map((pair) => pair.sourceColumnName).join(", ")}</td>
|
||||
<td>{relationship.targetTableName}</td><td>{relationship.columns.map((pair) => pair.targetColumnName).join(", ")}</td><td>{relationship.updateRule}</td><td>{relationship.deleteRule}</td>
|
||||
<td className="inventory-actions-cell"><span className="no-row-actions">No row actions</span></td>
|
||||
</tr>) : <tr><td colSpan={8}><div className="fleet-empty-state"><Activity size={20} /><strong>No relationships in the live catalog</strong><span>The current PSD catalog contains 163 tables and 0 synchronized relationships.</span></div></td></tr>}</tbody>
|
||||
</table>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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: <TestTube2 size={14} />, disabled, onClick: run(`Testing ${count} selected connection${count === 1 ? "" : "s"}`) },
|
||||
{ id: "databases-sync-tables", group: "Synchronization", label: "Synchronize tables", icon: <RefreshCw size={14} />, disabled, onClick: run("Table synchronization started", "running") },
|
||||
{ id: "databases-sync-columns", group: "Synchronization", label: "Synchronize all columns", icon: <Columns3 size={14} />, disabled, onClick: run("Column synchronization started", "running") },
|
||||
{ id: "databases-sync-relationships", group: "Synchronization", label: "Synchronize relationships", icon: <Activity size={14} />, disabled, onClick: run("Relationship synchronization started", "running") },
|
||||
{ id: "databases-sync-all", group: "Synchronization", label: "Synchronize all", icon: <RefreshCw size={14} />, disabled, onClick: run("Full schema synchronization started", "running") },
|
||||
{ id: "databases-generate-all", group: "Descriptions", label: "Generate all descriptions", icon: <Sparkles size={14} />, disabled, onClick: run("Generate all confirmation opened") },
|
||||
{ id: "databases-generate-missing", group: "Descriptions", label: "Generate missing descriptions", icon: <FileText size={14} />, disabled, onClick: run("Missing-description generation started") },
|
||||
{ id: "databases-suggest-sensitive", group: "Sensitive data", label: "Suggest sensitive fields", icon: <ShieldCheck size={14} />, executeLabel: "Open review", disabled: count !== 1, onClick: () => onOpenPanel("sensitive-review") },
|
||||
{ id: "databases-delete-tables", group: "Cleanup", label: "Delete all catalog tables", icon: <Trash2 size={14} />, 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: <Trash2 size={14} />, 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: <Sparkles size={14} />, disabled, onClick: run(`Description generation started for ${count} table${count === 1 ? "" : "s"}`) },
|
||||
{ id: "tables-sync-columns", group: "Synchronization", label: "Synchronize columns", icon: <RefreshCw size={14} />, disabled, onClick: run("Selected table column synchronization started", "running") },
|
||||
{ id: "tables-consolidate", group: "Descriptions", label: "Move generated to Description", icon: <Save size={14} />, disabled, onClick: run("Generated descriptions moved to curated Description") },
|
||||
{ id: "tables-suggest-sensitive", group: "Sensitive data", label: "Suggest sensitive fields", icon: <ShieldCheck size={14} />, executeLabel: "Open review", disabled, onClick: () => onOpenPanel("sensitive-review") },
|
||||
{ id: "tables-delete-columns", group: "Cleanup", label: "Delete all columns", icon: <Trash2 size={14} />, 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: <Trash2 size={14} />, 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: <Sparkles size={14} />, disabled, onClick: run(`Description generation started for ${count} column${count === 1 ? "" : "s"}`) },
|
||||
{ id: "columns-consolidate", group: "Descriptions", label: "Move generated to Description", icon: <Save size={14} />, disabled, onClick: run("Generated column descriptions consolidated") },
|
||||
{ id: "columns-suggest-sensitive", group: "Sensitive data", label: "Suggest sensitive fields", icon: <ShieldCheck size={14} />, 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: <LockKeyhole size={14} />, 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: <RefreshCw size={14} />, onClick: run("Relationships refreshed from the live catalog") },
|
||||
{ id: "relationships-sync", group: "Synchronization", label: "Synchronize relationships", icon: <Activity size={14} />, onClick: run("Relationship synchronization started", "running") },
|
||||
{ id: "relationships-sync-all", group: "Synchronization", label: "Synchronize all", icon: <RefreshCw size={14} />, 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 (
|
||||
<div className={`fleet-selection-actions fleet-selection-actions--${direction}`}>
|
||||
<div className="selection-summary"><strong>{count ? `${count} selected` : "Select rows to act"}</strong><small>{count ? `${humanize(state.inventoryLevel)} scope · ${selectedDatabase.displayName}` : "Batch actions match the production DB management"}</small></div>
|
||||
<div className="selection-command-picker">
|
||||
<label className="selection-command-field">
|
||||
<span>Command</span>
|
||||
<select aria-label={`Command for ${humanize(state.inventoryLevel)}`} value={selectedValue} onChange={(event) => setSelectedCommandId(event.target.value)}>
|
||||
<option value="">Choose a command…</option>
|
||||
{actionGroupOrder.map((group) => {
|
||||
const groupActions = actions.filter((action) => action.group === group);
|
||||
return groupActions.length ? <optgroup key={group} label={group}>{groupActions.map((action) => <option key={action.id} value={action.id} disabled={action.disabled}>{action.label}</option>)}</optgroup> : null;
|
||||
})}
|
||||
</select>
|
||||
<ChevronDown size={14} aria-hidden="true" />
|
||||
</label>
|
||||
<button
|
||||
className={`selection-execute-button${selectedAction?.destructive ? " destructive-action" : ""}`}
|
||||
type="button"
|
||||
disabled={!selectedAction || selectedAction.disabled}
|
||||
onClick={executeSelectedAction}
|
||||
>
|
||||
{selectedAction?.icon}
|
||||
<span>{selectedAction?.executeLabel ?? "Run command"}</span>
|
||||
</button>
|
||||
</div>
|
||||
{count ? <button className="clear-selection-button" type="button" onClick={() => { setSelectedCommandId(""); onClearSelection(); }}><X size={13} /> Clear</button> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelHeader({ eyebrow, title, onClose }: { eyebrow: string; title: string; onClose: () => void }) {
|
||||
return <header className="fleet-panel-header"><div><p className="eyebrow">{eyebrow}</p><h2>{title}</h2></div><button type="button" aria-label="Close panel" data-tooltip="Close panel" onClick={onClose}><X size={17} /></button></header>;
|
||||
}
|
||||
|
||||
function EmptyHistory({ kind, detail }: { kind: string; detail: string }) {
|
||||
return <div className="history-empty"><History size={24} /><strong>No {kind} runs yet</strong><p>{detail}</p><span>0 live records</span></div>;
|
||||
}
|
||||
|
||||
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 <EmptyHistory kind="synchronization" detail="Run a schema synchronization to populate this history." />;
|
||||
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 <div className="run-history-layout">
|
||||
<section className="run-detail">
|
||||
<div className="run-status-row"><span className={`run-state run-state--${selected.state}`}>{humanize(selected.state)}</span><span>{humanize(selected.scope)} scope</span></div>
|
||||
<div className="run-count-grid">{["tables", "columns", "relationships", "created", "updated", "deleted"].map((key) => <div key={key}><span>{humanize(key)}</span><strong>{selected.counts[key] ?? 0}</strong></div>)}</div>
|
||||
<dl className="run-timestamps"><div><dt>Started</dt><dd>{formatMoment(selected.startedAt ?? selected.createdAt)}</dd></div><div><dt>Finished</dt><dd>{formatMoment(selected.finishedAt)}</dd></div><div><dt>Phase</dt><dd>{humanize(selected.phase)}</dd></div></dl>
|
||||
{destructiveCount ? <div className="destructive-summary"><CircleAlert size={16} /><span><strong>{destructiveCount} removals require confirmation</strong><small>The source diff has not been applied.</small></span><button type="button" onClick={() => onAction("Destructive catalog changes confirmed in prototype", "steady")}>Confirm removals</button></div> : null}
|
||||
{selected.state === "running" || selected.state === "queued" ? <button className="button" type="button" onClick={() => onAction("Synchronization cancel requested in prototype", "steady")}>Cancel</button> : null}
|
||||
{["failed", "cancelled", "interrupted"].includes(selected.state) ? <button className="button" type="button" onClick={() => onAction("Synchronization retry started in prototype", "running")}>Retry</button> : null}
|
||||
<div className="event-log"><h3>Event log</h3>{events.length ? events.map((event) => <div key={`${event.runId}-${event.sequence}`} data-level={event.level}><span>{event.sequence}</span><code>{event.message}</code><time>{formatMoment(event.createdAt)}</time></div>) : <p>No persisted events for this run.</p>}</div>
|
||||
</section>
|
||||
<aside className="recent-runs"><h3>Recent runs</h3>{runs.slice(0, 10).map((run) => <button type="button" key={run.id} data-tooltip="View run" aria-label={`View ${humanize(run.scope).toLocaleLowerCase()} synchronization from ${formatMoment(run.createdAt)}`} aria-current={run.id === selected.id ? "true" : undefined} onClick={() => setSelectedId(run.id)}><span><strong>{humanize(run.scope)}</strong><small>{formatMoment(run.createdAt)}</small></span><span className={`run-state run-state--${run.state}`}>{humanize(run.state)}</span></button>)}</aside>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function DescriptionHistory({ snapshot, onAction }: { snapshot: CatalogSnapshot; onAction: VariantProps["onAction"] }) {
|
||||
if (!snapshot.descriptionRuns.length) return <><EmptyHistory kind="description generation" detail="The live catalog currently has no description-generation history. The empty state is real." /><button className="button history-unlock" type="button" onClick={() => onAction("Stale description-generation lock check requested") }><Unlock size={14} /> Unlock stale run</button></>;
|
||||
return <div className="simple-history-list">{snapshot.descriptionRuns.map((run) => <article key={run.id}><div><span className={`run-state run-state--${run.status}`}>{humanize(run.status)}</span><strong>{humanize(run.scope)}</strong><small>{run.modelId} · {run.language}</small></div><div className="history-progress"><span>{run.processed}/{run.total} processed</span><progress max={Math.max(1, run.total)} value={run.processed} /><small>{run.generated} generated · {run.nonGeneratable} not generated · {run.failed} failed</small></div>{["queued", "running"].includes(run.status) ? <button type="button" onClick={() => onAction("Description generation stop requested")}>Stop</button> : null}</article>)}</div>;
|
||||
}
|
||||
|
||||
function SensitiveHistory({ snapshot }: { snapshot: CatalogSnapshot }) {
|
||||
if (!snapshot.sensitiveRuns.length) return <EmptyHistory kind="sensitive suggestion" detail="The live catalog currently has no sensitive-data suggestion history. The empty state is real." />;
|
||||
return <div className="simple-history-list">{snapshot.sensitiveRuns.map((run) => <article key={run.id}><div><span className={`run-state run-state--${run.status}`}>{humanize(run.status)}</span><strong>{humanize(run.scope)}</strong><small>{run.modelId} · {formatMoment(run.createdAt)}</small></div><div className="run-count-grid"><div><span>Total</span><strong>{run.total}</strong></div><div><span>Sensitive</span><strong>{run.suggestedSensitive}</strong></div><div><span>Not sensitive</span><strong>{run.suggestedNonSensitive}</strong></div></div></article>)}</div>;
|
||||
}
|
||||
|
||||
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 <div className="sensitive-review-panel">
|
||||
<div className="review-controls"><label><Search size={14} /><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search table or column" /></label><label className="show-all-toggle"><input type="checkbox" checked={showAll} onChange={(event) => setShowAll(event.target.checked)} /> Show all classified columns</label></div>
|
||||
<div className="review-explainer"><ShieldCheck size={17} /><span><strong>Review suggested flags</strong><small>Actual catalog columns, prototype decisions. Nothing is written.</small></span></div>
|
||||
<div className="sensitive-review-list">{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 <label key={column.id}><input type="checkbox" checked={proposed} onChange={(event) => onToggleSensitive(column.id, event.target.checked)} /><span><code>{table.name}.{column.name}</code><small>{column.dataType} · {column.isNullable ? "nullable" : "required"}</small></span><span className="review-current">Current<strong>{current ? "Protected" : "Allowed"}</strong></span><span className="review-proposed">Proposed<strong>{proposed ? "Protect" : "Allow"}</strong></span></label>;
|
||||
})}{!candidates.length ? <div className="fleet-empty-state"><ShieldCheck size={20} /><strong>No proposed changes in this view</strong></div> : null}</div>
|
||||
<footer><button className="button" type="button" onClick={onClosePanel}>Cancel</button><button className="button button--primary" type="button" disabled={!Object.keys(state.sensitiveDrafts).length} onClick={onSaveSensitive}><Save size={14} /> Save {Object.keys(state.sensitiveDrafts).length || ""}</button></footer>
|
||||
</div>;
|
||||
}
|
||||
|
||||
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 <div className="fleet-empty-state"><FileText size={20} /><strong>Select a table or column to edit metadata</strong></div>;
|
||||
return <form className="metadata-editor" onSubmit={(event) => { event.preventDefault(); onAction(`${column ? "Column" : "Table"} metadata saved in prototype`); }}>
|
||||
<label><span>Physical name</span><input value={target.name} readOnly /><small>Physical fields are read only.</small></label>
|
||||
<label><span>Source comment</span><textarea value={target.sourceComment ?? ""} readOnly rows={4} /></label>
|
||||
<label><span>Generated description</span><textarea defaultValue={target.generatedDescription ?? ""} rows={4} placeholder="No generated description" /></label>
|
||||
<label><span>Description</span><textarea defaultValue={target.description ?? ""} rows={5} placeholder="Add the curated human description" /></label>
|
||||
<button className="button button--primary" type="submit"><Save size={14} /> Save metadata</button>
|
||||
</form>;
|
||||
}
|
||||
|
||||
function DatabaseForm({ database, onAction }: { database: CatalogDatabase; onAction: VariantProps["onAction"] }) {
|
||||
return <form className="metadata-editor" onSubmit={(event) => { event.preventDefault(); onAction("Database configuration validated in prototype"); }}>
|
||||
<label><span>Workspace</span><input defaultValue={database.workspaceId} /></label><label><span>Transport</span><select defaultValue={database.transport}><option>REST API</option><option>Direct PostgreSQL</option><option>SSH tunnel</option></select></label>
|
||||
<label><span>Database</span><input defaultValue={database.databaseName} /></label><label><span>Schema</span><input defaultValue={database.schemaName} /></label><label><span>Endpoint</span><input defaultValue={database.endpoint} /></label>
|
||||
<div className="prototype-write-warning"><LockKeyhole size={16} /><span><strong>Prototype, read-only</strong><small>Connection changes are not sent to the backend.</small></span></div>
|
||||
<div className="form-actions"><button className="button" type="button" onClick={() => onAction("Connection test passed in prototype")}><TestTube2 size={14} /> Test connection</button><button className="button button--primary" type="submit"><Save size={14} /> Validate configuration</button></div>
|
||||
</form>;
|
||||
}
|
||||
|
||||
export function FleetPanel({ props, includeHeader = true }: { props: VariantProps; includeHeader?: boolean }) {
|
||||
const { state, snapshot, selectedDatabase, onClosePanel, onAction } = props;
|
||||
if (state.panel === "none" || state.panel === "actions") return null;
|
||||
const titles: Record<Exclude<PanelKind, "none" | "actions">, [string, string]> = {
|
||||
"sync-history": ["Operations", "Synchronization history"],
|
||||
"description-history": ["Generation", "Description history"],
|
||||
"sensitive-history": ["Classification", "Sensitive suggestion history"],
|
||||
"sensitive-review": ["Human review", "Sensitive field review"],
|
||||
"database-form": ["Connection", state.panelTargetId ? `Edit ${selectedDatabase.displayName}` : "Add database"],
|
||||
"metadata-editor": ["Catalog metadata", "Review description"],
|
||||
};
|
||||
const [eyebrow, title] = titles[state.panel];
|
||||
return <section className="fleet-panel" aria-label={title}>
|
||||
{includeHeader ? <PanelHeader eyebrow={eyebrow} title={title} onClose={onClosePanel} /> : null}
|
||||
<div className="fleet-panel-body">
|
||||
{state.panel === "sync-history" ? <SyncHistory snapshot={snapshot} database={selectedDatabase} targetId={state.panelTargetId} onAction={onAction} /> : null}
|
||||
{state.panel === "description-history" ? <DescriptionHistory snapshot={snapshot} onAction={onAction} /> : null}
|
||||
{state.panel === "sensitive-history" ? <SensitiveHistory snapshot={snapshot} /> : null}
|
||||
{state.panel === "sensitive-review" ? <SensitiveReview props={props} /> : null}
|
||||
{state.panel === "metadata-editor" ? <MetadataEditor props={props} /> : null}
|
||||
{state.panel === "database-form" ? <DatabaseForm database={selectedDatabase} onAction={onAction} /> : null}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function FleetSummary({ database }: { database: CatalogDatabase }) {
|
||||
return <dl className="fleet-summary">
|
||||
<div><dt>Tables</dt><dd>{database.tableCount.toLocaleString("en-GB")}</dd></div>
|
||||
<div><dt>Columns</dt><dd>{database.columnCount.toLocaleString("en-GB")}</dd></div>
|
||||
<div><dt>Sensitive</dt><dd>{database.sensitiveCount.toLocaleString("en-GB")}</dd></div>
|
||||
<div><dt>Relationships</dt><dd>{database.relationshipCount.toLocaleString("en-GB")}</dd></div>
|
||||
<div><dt>Description coverage</dt><dd><Coverage value={database.descriptionCoverage} /></dd></div>
|
||||
</dl>;
|
||||
}
|
||||
|
||||
export function FleetPanelShortcut({ icon, label, detail, onClick }: { icon: ReactNode; label: string; detail: string; onClick: () => void }) {
|
||||
return <button className="fleet-panel-shortcut" type="button" data-tooltip={label} aria-label={`${label}: ${detail}`} onClick={onClick}><span>{icon}</span><span><strong>{label}</strong><small>{detail}</small></span><ChevronRight size={15} /></button>;
|
||||
}
|
||||
|
||||
export function ActiveRunShortcut({ run, onClick }: { run: CatalogSyncRun | null; onClick: () => void }) {
|
||||
return <FleetPanelShortcut icon={run ? <RefreshCw className="spin" size={16} /> : <CheckCircle2 size={16} />} label={run ? `${humanize(run.scope)} sync ${humanize(run.state)}` : "Synchronization history"} detail={run ? `Phase: ${humanize(run.phase)}` : "Inspect the 21 persisted local runs"} onClick={onClick} />;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>Database management UI prototype</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Fraunces:opsz,wght@9..144,500;9..144,600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { PrototypeApp } from "./App";
|
||||
import "./prototype.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<PrototypeApp />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,198 @@
|
||||
export type VariantKey = "A" | "B" | "C";
|
||||
export type ScenarioKey = "steady" | "running" | "changes";
|
||||
export type DatabaseStatus = "Ready" | "Attention" | "Not configured";
|
||||
export type InventoryLevel = "databases" | "tables" | "columns" | "relationships";
|
||||
export type PanelKind =
|
||||
| "none"
|
||||
| "actions"
|
||||
| "sync-history"
|
||||
| "description-history"
|
||||
| "sensitive-history"
|
||||
| "sensitive-review"
|
||||
| "database-form"
|
||||
| "metadata-editor";
|
||||
|
||||
export interface CatalogColumn {
|
||||
id: string;
|
||||
tableId: string;
|
||||
name: string;
|
||||
ordinalPosition: number;
|
||||
dataType: string;
|
||||
isNullable: boolean;
|
||||
defaultExpression: string | null;
|
||||
primaryKeyPosition: number | null;
|
||||
sourceComment: string | null;
|
||||
description: string | null;
|
||||
generatedDescription: string | null;
|
||||
sensitive: boolean;
|
||||
lastSyncedAt: string | null;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CatalogTable {
|
||||
id: string;
|
||||
databaseId: string;
|
||||
name: string;
|
||||
sourceComment: string | null;
|
||||
description: string | null;
|
||||
generatedDescription: string | null;
|
||||
lastSyncedAt: string | null;
|
||||
version: number;
|
||||
columns: CatalogColumn[];
|
||||
columnCount: number;
|
||||
sensitiveCount: number;
|
||||
descriptionCoverage: number;
|
||||
}
|
||||
|
||||
export interface CatalogRelationshipColumn {
|
||||
sourceColumnName: string;
|
||||
targetColumnName: string;
|
||||
ordinalPosition: number;
|
||||
}
|
||||
|
||||
export interface CatalogRelationship {
|
||||
id: string;
|
||||
databaseId: string;
|
||||
constraintName: string;
|
||||
sourceTableName: string;
|
||||
targetTableName: string;
|
||||
updateRule: string;
|
||||
deleteRule: string;
|
||||
columns: CatalogRelationshipColumn[];
|
||||
}
|
||||
|
||||
export interface CatalogSyncRun {
|
||||
id: string;
|
||||
databaseId: string;
|
||||
scope: "tables" | "columns" | "relationships" | "all";
|
||||
state: "queued" | "running" | "awaiting_confirmation" | "applying" | "succeeded" | "failed" | "cancelled" | "interrupted";
|
||||
phase: string;
|
||||
counts: Record<string, number>;
|
||||
plannedDiff: {
|
||||
deletedTables?: string[];
|
||||
deletedColumns?: Array<{ tableName: string; columnName: string }>;
|
||||
deletedRelationships?: Array<{ sourceTableName: string; constraintName: string }>;
|
||||
} | null;
|
||||
errorCode: string | null;
|
||||
errorMessage: string | null;
|
||||
createdAt: string;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CatalogRunEvent {
|
||||
runId: string;
|
||||
sequence: number;
|
||||
level: "info" | "warning" | "error";
|
||||
message: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DescriptionGenerationRun {
|
||||
id: string;
|
||||
databaseId: string;
|
||||
scope: "selected_columns" | "selected_tables" | "all" | "missing";
|
||||
modelId: string;
|
||||
language: string;
|
||||
status: "queued" | "running" | "completed" | "completed_with_errors" | "cancelled" | "failed" | "interrupted";
|
||||
total: number;
|
||||
processed: number;
|
||||
generated: number;
|
||||
nonGeneratable: number;
|
||||
failed: number;
|
||||
createdAt: string;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
errorSummary: string | null;
|
||||
}
|
||||
|
||||
export interface SensitiveSuggestionRun {
|
||||
id: string;
|
||||
databaseId: string;
|
||||
scope: "all" | "selected_tables" | "selected_columns";
|
||||
modelId: string;
|
||||
status: "running" | "completed" | "failed" | "interrupted";
|
||||
total: number;
|
||||
suggestedSensitive: number;
|
||||
suggestedNonSensitive: number;
|
||||
createdAt: string;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
errorSummary: string | null;
|
||||
}
|
||||
|
||||
export interface CatalogDatabase {
|
||||
id: string;
|
||||
displayName: string;
|
||||
workspaceId: string;
|
||||
engine: string;
|
||||
databaseName: string;
|
||||
schemaName: string;
|
||||
version: number;
|
||||
transport: "REST API" | "Direct PostgreSQL" | "SSH tunnel" | "Unconfigured";
|
||||
endpoint: string;
|
||||
connectionStatus: string;
|
||||
testedVersion: number | null;
|
||||
status: DatabaseStatus;
|
||||
lastSync: string | null;
|
||||
updatedAt: string;
|
||||
tableCount: number;
|
||||
columnCount: number;
|
||||
relationshipCount: number;
|
||||
sensitiveCount: number;
|
||||
descriptionCoverage: number;
|
||||
tables: CatalogTable[];
|
||||
relationships: CatalogRelationship[];
|
||||
activeSyncRun: CatalogSyncRun | null;
|
||||
}
|
||||
|
||||
export interface CatalogSnapshot {
|
||||
source: "live" | "fallback";
|
||||
capturedAt: string;
|
||||
databases: CatalogDatabase[];
|
||||
syncRuns: CatalogSyncRun[];
|
||||
syncEvents: CatalogRunEvent[];
|
||||
descriptionRuns: DescriptionGenerationRun[];
|
||||
descriptionEvents: CatalogRunEvent[];
|
||||
sensitiveRuns: SensitiveSuggestionRun[];
|
||||
sensitiveEvents: CatalogRunEvent[];
|
||||
}
|
||||
|
||||
export interface PrototypeState {
|
||||
scenario: ScenarioKey;
|
||||
inventoryLevel: InventoryLevel;
|
||||
selectedDatabaseId: string;
|
||||
selectedTableId: string | null;
|
||||
selectedRowIds: string[];
|
||||
search: string;
|
||||
panel: PanelKind;
|
||||
panelTargetId: string | null;
|
||||
sensitiveDrafts: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface VariantProps {
|
||||
snapshot: CatalogSnapshot;
|
||||
databases: CatalogDatabase[];
|
||||
state: PrototypeState;
|
||||
selectedDatabase: CatalogDatabase;
|
||||
selectedTable: CatalogTable | null;
|
||||
onSelectDatabase: (databaseId: string) => void;
|
||||
onSelectTable: (tableId: string) => void;
|
||||
onToggleRow: (rowId: string) => void;
|
||||
onClearSelection: () => void;
|
||||
onSearch: (value: string) => void;
|
||||
onInventoryLevel: (level: InventoryLevel) => void;
|
||||
onOpenPanel: (panel: PanelKind, targetId?: string) => void;
|
||||
onClosePanel: () => void;
|
||||
onToggleSensitive: (columnId: string, value: boolean) => void;
|
||||
onSaveSensitive: () => void;
|
||||
onScenario: (scenario: ScenarioKey) => void;
|
||||
onAction: (message: string, scenario?: ScenarioKey) => void;
|
||||
}
|
||||
|
||||
export interface VariantDefinition {
|
||||
key: VariantKey;
|
||||
name: string;
|
||||
premise: string;
|
||||
bestFor: string;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
LoaderCircle,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import type { DatabaseStatus, ScenarioKey } from "./model";
|
||||
|
||||
export function StatusBadge({ status }: { status: DatabaseStatus }) {
|
||||
const Icon = status === "Ready" ? CheckCircle2 : status === "Attention" ? AlertTriangle : Clock3;
|
||||
return <span className={`status-badge status-badge--${status.toLowerCase().replace(" ", "-")}`}><Icon size={13} />{status}</span>;
|
||||
}
|
||||
|
||||
export function SearchField({ value, onChange, placeholder = "Search catalog" }: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="search-field">
|
||||
<Search size={15} aria-hidden="true" />
|
||||
<span className="sr-only">{placeholder}</span>
|
||||
<input value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Coverage({ value }: { value: number }) {
|
||||
return (
|
||||
<span className="coverage" aria-label={`${value}% description coverage`}>
|
||||
<span className="coverage-track"><span style={{ width: `${value}%` }} /></span>
|
||||
<span>{value}%</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScenarioNotice({ scenario, onAction }: {
|
||||
scenario: ScenarioKey;
|
||||
onAction: (message: string, scenario?: ScenarioKey) => void;
|
||||
}) {
|
||||
if (scenario === "steady") return null;
|
||||
if (scenario === "running") {
|
||||
return (
|
||||
<div className="scenario-notice scenario-notice--running" role="status">
|
||||
<LoaderCircle className="spin" size={17} />
|
||||
<div><strong>Full database synchronization is running</strong><span>42% scanned · 18 of 42 tables · no catalog changes applied yet</span></div>
|
||||
<button type="button" onClick={() => onAction("Prototype run stopped", "steady")}>Stop run</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="scenario-notice scenario-notice--changes" role="alert">
|
||||
<AlertTriangle size={17} />
|
||||
<div><strong>Source changes need confirmation</strong><span>2 tables removed · 4 columns changed · 3 relationships affected</span></div>
|
||||
<button type="button" onClick={() => onAction("Diff review opened in prototype")}>Review changes</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyDatabase() {
|
||||
return (
|
||||
<div className="empty-database">
|
||||
<Clock3 size={22} />
|
||||
<strong>No database configured</strong>
|
||||
<p>Add a connection binding to inspect tables, relationships, and catalog operations.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import {
|
||||
Columns3,
|
||||
Database,
|
||||
FileClock,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
ShieldCheck,
|
||||
Table2,
|
||||
} from "lucide-react";
|
||||
import type { InventoryLevel, VariantProps } from "../model";
|
||||
import {
|
||||
ActiveRunShortcut,
|
||||
FleetBreadcrumb,
|
||||
FleetGrid,
|
||||
FleetHeader,
|
||||
FleetPanel,
|
||||
FleetPanelShortcut,
|
||||
FleetSelectionActions,
|
||||
FleetSummary,
|
||||
formatMoment,
|
||||
} from "../fleet";
|
||||
import { ScenarioNotice } from "../shared";
|
||||
|
||||
function contextCopy(level: InventoryLevel, props: VariantProps): {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
icon: typeof Database;
|
||||
} {
|
||||
const { databases, selectedDatabase, selectedTable } = props;
|
||||
|
||||
if (level === "databases") {
|
||||
return {
|
||||
eyebrow: "Fleet context",
|
||||
title: selectedDatabase.displayName,
|
||||
detail: `${databases.length} repository workspace${databases.length === 1 ? "" : "s"} in the read-only catalog`,
|
||||
icon: Database,
|
||||
};
|
||||
}
|
||||
|
||||
if (level === "tables") {
|
||||
return {
|
||||
eyebrow: `Workspace · ${selectedDatabase.workspaceId}`,
|
||||
title: selectedDatabase.displayName,
|
||||
detail: `${selectedDatabase.databaseName}.${selectedDatabase.schemaName} · ${selectedDatabase.tableCount.toLocaleString("en-GB")} tables`,
|
||||
icon: Table2,
|
||||
};
|
||||
}
|
||||
|
||||
if (level === "columns") {
|
||||
return {
|
||||
eyebrow: `Table · ${selectedDatabase.workspaceId}`,
|
||||
title: selectedTable?.name ?? "No table selected",
|
||||
detail: selectedTable
|
||||
? `${selectedTable.columnCount.toLocaleString("en-GB")} columns · last synchronized ${formatMoment(selectedTable.lastSyncedAt)}`
|
||||
: "Choose a table from the parent inventory",
|
||||
icon: Columns3,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
eyebrow: `Workspace · ${selectedDatabase.workspaceId}`,
|
||||
title: "Physical relationships",
|
||||
detail: `${selectedDatabase.relationshipCount.toLocaleString("en-GB")} synchronized constraints`,
|
||||
icon: RefreshCw,
|
||||
};
|
||||
}
|
||||
|
||||
export function ExplorerVariant(props: VariantProps) {
|
||||
const {
|
||||
snapshot,
|
||||
state,
|
||||
selectedDatabase,
|
||||
selectedTable,
|
||||
onInventoryLevel,
|
||||
onOpenPanel,
|
||||
onAction,
|
||||
} = props;
|
||||
const panelOpen = state.panel !== "none" && state.panel !== "actions";
|
||||
const context = contextCopy(state.inventoryLevel, props);
|
||||
const ContextIcon = context.icon;
|
||||
const syncRunCount = snapshot.syncRuns.filter((run) => run.databaseId === selectedDatabase.id).length;
|
||||
|
||||
return (
|
||||
<div className="candidate candidate--explorer candidate--fleet-workbench">
|
||||
<FleetHeader
|
||||
snapshot={snapshot}
|
||||
variantLabel="Fleet workbench"
|
||||
onOpenPanel={onOpenPanel}
|
||||
/>
|
||||
<ScenarioNotice scenario={state.scenario} onAction={onAction} />
|
||||
|
||||
<section className="fleet-workbench">
|
||||
<FleetBreadcrumb
|
||||
level={state.inventoryLevel}
|
||||
database={selectedDatabase}
|
||||
table={selectedTable}
|
||||
onLevel={onInventoryLevel}
|
||||
/>
|
||||
|
||||
<div className="fleet-workbench-layout">
|
||||
<main className="fleet-workbench-grid" aria-label="Fleet inventory">
|
||||
<FleetGrid {...props} />
|
||||
</main>
|
||||
|
||||
<aside className="fleet-workbench-rail" aria-label="Fleet context and actions">
|
||||
<header className="fleet-workbench-context">
|
||||
<span className="large-object-icon" aria-hidden="true"><ContextIcon size={20} /></span>
|
||||
<div>
|
||||
<p className="eyebrow">{context.eyebrow}</p>
|
||||
<h2>{context.title}</h2>
|
||||
<p>{context.detail}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<FleetSummary database={selectedDatabase} />
|
||||
|
||||
{panelOpen ? (
|
||||
<FleetPanel props={props} />
|
||||
) : (
|
||||
<>
|
||||
<section className="fleet-workbench-actions" aria-labelledby="fleet-workbench-actions-heading">
|
||||
<div className="fleet-workbench-section-heading">
|
||||
<p className="eyebrow">Selection</p>
|
||||
<h3 id="fleet-workbench-actions-heading">Batch actions</h3>
|
||||
</div>
|
||||
<FleetSelectionActions props={props} direction="column" />
|
||||
</section>
|
||||
|
||||
<section className="fleet-workbench-shortcuts" aria-labelledby="fleet-workbench-shortcuts-heading">
|
||||
<div className="fleet-workbench-section-heading">
|
||||
<p className="eyebrow">Read-only operations</p>
|
||||
<h3 id="fleet-workbench-shortcuts-heading">Inspect and review</h3>
|
||||
</div>
|
||||
|
||||
{selectedDatabase.activeSyncRun ? (
|
||||
<ActiveRunShortcut
|
||||
run={selectedDatabase.activeSyncRun}
|
||||
onClick={() => onOpenPanel("sync-history", selectedDatabase.activeSyncRun?.id)}
|
||||
/>
|
||||
) : (
|
||||
<FleetPanelShortcut
|
||||
icon={<RefreshCw size={16} />}
|
||||
label="Synchronization history"
|
||||
detail={`${syncRunCount} persisted run${syncRunCount === 1 ? "" : "s"} for this workspace`}
|
||||
onClick={() => onOpenPanel("sync-history")}
|
||||
/>
|
||||
)}
|
||||
<FleetPanelShortcut
|
||||
icon={<FileClock size={16} />}
|
||||
label="Description history"
|
||||
detail={`${snapshot.descriptionRuns.length} persisted run${snapshot.descriptionRuns.length === 1 ? "" : "s"}`}
|
||||
onClick={() => onOpenPanel("description-history")}
|
||||
/>
|
||||
<FleetPanelShortcut
|
||||
icon={<ShieldCheck size={16} />}
|
||||
label="Sensitive history"
|
||||
detail={`${snapshot.sensitiveRuns.length} persisted run${snapshot.sensitiveRuns.length === 1 ? "" : "s"}`}
|
||||
onClick={() => onOpenPanel("sensitive-history")}
|
||||
/>
|
||||
{state.inventoryLevel === "columns" && selectedTable ? (
|
||||
<FleetPanelShortcut
|
||||
icon={<Pencil size={16} />}
|
||||
label="Edit table metadata"
|
||||
detail={`Review generated and curated text for ${selectedTable.name}`}
|
||||
onClick={() => onOpenPanel("metadata-editor", selectedTable.id)}
|
||||
/>
|
||||
) : (
|
||||
<FleetPanelShortcut
|
||||
icon={<Settings2 size={16} />}
|
||||
label="Connection configuration"
|
||||
detail={`${selectedDatabase.transport} · prototype changes are not persisted`}
|
||||
onClick={() => onOpenPanel("database-form", selectedDatabase.id)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { FileClock, ShieldCheck } from "lucide-react";
|
||||
import type { VariantProps } from "../model";
|
||||
import {
|
||||
ActiveRunShortcut,
|
||||
FleetBreadcrumb,
|
||||
FleetGrid,
|
||||
FleetHeader,
|
||||
FleetPanel,
|
||||
FleetPanelShortcut,
|
||||
FleetSelectionActions,
|
||||
FleetSummary,
|
||||
} from "../fleet";
|
||||
import { ScenarioNotice } from "../shared";
|
||||
|
||||
export function InventoryVariant(props: VariantProps) {
|
||||
const { snapshot, state, selectedDatabase, selectedTable, onInventoryLevel, onOpenPanel, onAction } = props;
|
||||
const panelOpen = state.panel !== "none" && state.panel !== "actions";
|
||||
|
||||
return (
|
||||
<div className="candidate candidate--inventory candidate--fleet-ledger">
|
||||
<FleetHeader snapshot={snapshot} variantLabel="Fleet ledger" onOpenPanel={onOpenPanel} />
|
||||
<ScenarioNotice scenario={state.scenario} onAction={onAction} />
|
||||
|
||||
<section className="fleet-ledger-workbench">
|
||||
<FleetBreadcrumb level={state.inventoryLevel} database={selectedDatabase} table={selectedTable} onLevel={onInventoryLevel} />
|
||||
<div className="fleet-ledger-summary-row">
|
||||
<FleetSummary database={selectedDatabase} />
|
||||
<div className="fleet-ledger-shortcuts">
|
||||
<ActiveRunShortcut run={selectedDatabase.activeSyncRun} onClick={() => onOpenPanel("sync-history", selectedDatabase.activeSyncRun?.id)} />
|
||||
<FleetPanelShortcut icon={<FileClock size={16} />} label="Description history" detail={`${snapshot.descriptionRuns.length} persisted runs`} onClick={() => onOpenPanel("description-history")} />
|
||||
<FleetPanelShortcut icon={<ShieldCheck size={16} />} label="Sensitive history" detail={`${snapshot.sensitiveRuns.length} persisted runs`} onClick={() => onOpenPanel("sensitive-history")} />
|
||||
</div>
|
||||
</div>
|
||||
<FleetSelectionActions props={props} />
|
||||
<FleetGrid {...props} />
|
||||
</section>
|
||||
|
||||
{panelOpen ? <aside className="fleet-drawer"><FleetPanel props={props} /></aside> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Activity,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
FileClock,
|
||||
ShieldCheck,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
import type { VariantProps } from "../model";
|
||||
import {
|
||||
ActiveRunShortcut,
|
||||
FleetBreadcrumb,
|
||||
FleetGrid,
|
||||
FleetHeader,
|
||||
FleetPanel,
|
||||
FleetPanelShortcut,
|
||||
FleetSelectionActions,
|
||||
FleetSummary,
|
||||
} from "../fleet";
|
||||
import { ScenarioNotice } from "../shared";
|
||||
|
||||
const PANEL_TITLES = {
|
||||
"sync-history": "Synchronization history",
|
||||
"description-history": "Description history",
|
||||
"sensitive-history": "Sensitive suggestion history",
|
||||
"sensitive-review": "Sensitive field review",
|
||||
"database-form": "Database configuration",
|
||||
"metadata-editor": "Catalog metadata",
|
||||
} as const;
|
||||
|
||||
export function OperationsVariant(props: VariantProps) {
|
||||
const {
|
||||
snapshot,
|
||||
state,
|
||||
selectedDatabase,
|
||||
selectedTable,
|
||||
onInventoryLevel,
|
||||
onOpenPanel,
|
||||
onClosePanel,
|
||||
onAction,
|
||||
} = props;
|
||||
const [consoleExpanded, setConsoleExpanded] = useState(false);
|
||||
const panelActive = state.panel !== "none" && state.panel !== "actions";
|
||||
const consoleOpen = consoleExpanded || panelActive;
|
||||
const panelTitle = panelActive
|
||||
? PANEL_TITLES[state.panel as keyof typeof PANEL_TITLES]
|
||||
: null;
|
||||
const selectedCount = state.selectedRowIds.length;
|
||||
|
||||
const collapseConsole = () => {
|
||||
if (panelActive) onClosePanel();
|
||||
setConsoleExpanded(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="candidate candidate--operations candidate--fleet-deck fleet-shell fleet-operations-deck">
|
||||
<FleetHeader
|
||||
snapshot={snapshot}
|
||||
variantLabel="C · Fleet Operations Deck"
|
||||
onOpenPanel={onOpenPanel}
|
||||
/>
|
||||
|
||||
<ScenarioNotice scenario={state.scenario} onAction={onAction} />
|
||||
|
||||
<main className="operations-deck-main">
|
||||
<section className="operations-command-strip" aria-label="Fleet context">
|
||||
<div className="operations-command-copy">
|
||||
<p className="eyebrow">Fleet operations</p>
|
||||
<h2>One inventory surface, one operational console</h2>
|
||||
<p>
|
||||
Browse one catalog grid at a time. The live catalog remains read only;
|
||||
prototype actions are simulated locally and never modify source data.
|
||||
</p>
|
||||
</div>
|
||||
<FleetSummary database={selectedDatabase} />
|
||||
</section>
|
||||
|
||||
<FleetBreadcrumb
|
||||
level={state.inventoryLevel}
|
||||
database={selectedDatabase}
|
||||
table={selectedTable}
|
||||
onLevel={onInventoryLevel}
|
||||
/>
|
||||
|
||||
<section className="operations-grid-deck" aria-label="Fleet inventory work area">
|
||||
<FleetGrid {...props} />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<section
|
||||
className={`operations-console${consoleOpen ? " operations-console--expanded" : ""}`}
|
||||
aria-label="Fleet operations console"
|
||||
>
|
||||
<header className="operations-console-header">
|
||||
<div className="operations-console-title">
|
||||
<span className="operations-console-icon" aria-hidden="true">
|
||||
<TerminalSquare size={17} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>{panelTitle ?? "Operations console"}</strong>
|
||||
<small>
|
||||
{panelActive
|
||||
? "Read-only run detail and simulated prototype controls"
|
||||
: selectedCount
|
||||
? `${selectedCount} selected · actions use the current ${state.inventoryLevel} scope`
|
||||
: "Selection actions and persisted catalog histories"}
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="operations-console-toggle"
|
||||
type="button"
|
||||
aria-expanded={consoleOpen}
|
||||
aria-label={consoleOpen ? "Collapse operations console" : "Expand operations console"}
|
||||
title={consoleOpen ? "Collapse operations console" : "Expand operations console"}
|
||||
onClick={() => consoleOpen ? collapseConsole() : setConsoleExpanded(true)}
|
||||
>
|
||||
{consoleOpen ? <ChevronDown size={17} /> : <ChevronUp size={17} />}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="operations-console-actions">
|
||||
<FleetSelectionActions props={props} />
|
||||
</div>
|
||||
|
||||
{consoleOpen ? (
|
||||
<div className="operations-console-expanded-body">
|
||||
{panelActive ? (
|
||||
<FleetPanel props={props} includeHeader={false} />
|
||||
) : (
|
||||
<div className="operations-console-shortcuts" aria-label="Operation histories">
|
||||
<ActiveRunShortcut
|
||||
run={selectedDatabase.activeSyncRun}
|
||||
onClick={() => onOpenPanel("sync-history", selectedDatabase.activeSyncRun?.id)}
|
||||
/>
|
||||
<FleetPanelShortcut
|
||||
icon={<FileClock size={16} />}
|
||||
label="Description history"
|
||||
detail={snapshot.descriptionRuns.length
|
||||
? `${snapshot.descriptionRuns.length} persisted run${snapshot.descriptionRuns.length === 1 ? "" : "s"}`
|
||||
: "No persisted runs in the live catalog"}
|
||||
onClick={() => onOpenPanel("description-history")}
|
||||
/>
|
||||
<FleetPanelShortcut
|
||||
icon={<ShieldCheck size={16} />}
|
||||
label="Sensitive suggestion history"
|
||||
detail={snapshot.sensitiveRuns.length
|
||||
? `${snapshot.sensitiveRuns.length} persisted run${snapshot.sensitiveRuns.length === 1 ? "" : "s"}`
|
||||
: "No persisted runs in the live catalog"}
|
||||
onClick={() => onOpenPanel("sensitive-history")}
|
||||
/>
|
||||
<FleetPanelShortcut
|
||||
icon={<Activity size={16} />}
|
||||
label="Relationship operations"
|
||||
detail={`${selectedDatabase.relationshipCount.toLocaleString("en-GB")} synchronized relationships`}
|
||||
onClick={() => onInventoryLevel("relationships")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import path from "path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
|
||||
const CATALOG_CONTAINER = process.env.THT_PROTOTYPE_CATALOG_CONTAINER
|
||||
?? "thothii-catalog-db-1";
|
||||
|
||||
const LIVE_CATALOG_SQL = String.raw`
|
||||
select json_build_object(
|
||||
'capturedAt', now(),
|
||||
'databases', coalesce((select json_agg(json_build_object(
|
||||
'id', wd.id, 'workspaceId', wd.workspace_id, 'engine', wd.engine,
|
||||
'databaseName', wd.database_name, 'schemaName', wd.schema_name,
|
||||
'version', wd.version, 'schemaSyncedVersion', wd.schema_synced_version,
|
||||
'schemaSyncedAt', wd.schema_synced_at, 'updatedAt', wd.updated_at,
|
||||
'transport', b.transport, 'host', b.host, 'port', b.port,
|
||||
'baseUrl', b.base_url, 'restPath', b.rest_path,
|
||||
'connectionStatus', b.connection_status, 'testedVersion', b.tested_version,
|
||||
'lastTestedAt', b.last_tested_at
|
||||
) order by wd.workspace_id) from workspace_databases wd left join database_bindings b on b.database_id = wd.id), '[]'::json),
|
||||
'tables', coalesce((select json_agg(json_build_object(
|
||||
'id', t.id, 'databaseId', t.database_id, 'name', t.name,
|
||||
'sourceComment', t.source_comment, 'description', t.description,
|
||||
'generatedDescription', t.generated_description, 'version', t.version,
|
||||
'lastSyncedAt', t.last_synced_at
|
||||
) order by t.name) from catalog_tables t), '[]'::json),
|
||||
'columns', coalesce((select json_agg(json_build_object(
|
||||
'id', c.id, 'tableId', c.table_id, 'name', c.name,
|
||||
'ordinalPosition', c.ordinal_position, 'dataType', c.data_type,
|
||||
'isNullable', c.is_nullable, 'defaultExpression', c.default_expression,
|
||||
'primaryKeyPosition', c.primary_key_position, 'sourceComment', c.source_comment,
|
||||
'description', c.description, 'generatedDescription', c.generated_description,
|
||||
'sensitive', c.sensitive, 'lastSyncedAt', c.last_synced_at, 'version', c.version
|
||||
) order by c.table_id, c.ordinal_position) from catalog_columns c), '[]'::json),
|
||||
'relationships', coalesce((select json_agg(json_build_object(
|
||||
'id', r.id, 'databaseId', r.database_id, 'constraintName', r.constraint_name,
|
||||
'sourceTableName', st.name, 'targetTableName', tt.name,
|
||||
'updateRule', r.update_rule, 'deleteRule', r.delete_rule
|
||||
) order by r.constraint_name) from catalog_relationships r
|
||||
join catalog_tables st on st.id = r.source_table_id
|
||||
join catalog_tables tt on tt.id = r.target_table_id), '[]'::json),
|
||||
'relationshipColumns', coalesce((select json_agg(json_build_object(
|
||||
'relationshipId', rc.relationship_id, 'ordinalPosition', rc.position,
|
||||
'sourceColumnName', sc.name, 'targetColumnName', tc.name
|
||||
) order by rc.relationship_id, rc.position) from catalog_relationship_columns rc
|
||||
join catalog_columns sc on sc.id = rc.source_column_id
|
||||
join catalog_columns tc on tc.id = rc.target_column_id), '[]'::json),
|
||||
'syncRuns', coalesce((select json_agg(json_build_object(
|
||||
'id', r.id, 'databaseId', r.database_id, 'scope', r.scope,
|
||||
'state', r.state, 'phase', r.phase, 'counts', r.counts,
|
||||
'plannedDiff', r.planned_diff, 'errorCode', r.error_code,
|
||||
'errorMessage', r.error_message, 'createdAt', r.created_at,
|
||||
'startedAt', r.started_at, 'finishedAt', r.finished_at
|
||||
) order by r.created_at desc) from (select * from catalog_sync_runs order by created_at desc limit 50) r), '[]'::json),
|
||||
'syncEvents', coalesce((select json_agg(json_build_object(
|
||||
'runId', e.run_id, 'sequence', e.sequence, 'level', e.level,
|
||||
'message', e.message, 'createdAt', e.created_at
|
||||
) order by e.created_at desc) from (select * from catalog_sync_events order by created_at desc limit 300) e), '[]'::json),
|
||||
'descriptionRuns', coalesce((select json_agg(json_build_object(
|
||||
'id', r.id, 'databaseId', r.database_id, 'scope', r.scope,
|
||||
'modelId', r.model_id, 'language', r.language, 'status', r.status,
|
||||
'total', r.total, 'processed', r.processed, 'generated', r.generated,
|
||||
'nonGeneratable', r.non_generatable, 'failed', r.failed,
|
||||
'createdAt', r.created_at, 'startedAt', r.started_at,
|
||||
'finishedAt', r.finished_at, 'errorSummary', r.error_summary
|
||||
) order by r.created_at desc) from (select * from description_generation_runs order by created_at desc limit 50) r), '[]'::json),
|
||||
'descriptionEvents', coalesce((select json_agg(json_build_object(
|
||||
'runId', e.run_id, 'sequence', e.sequence, 'level', e.level,
|
||||
'message', e.message, 'createdAt', e.created_at
|
||||
) order by e.created_at desc) from (select * from description_generation_events order by created_at desc limit 300) e), '[]'::json),
|
||||
'sensitiveRuns', coalesce((select json_agg(json_build_object(
|
||||
'id', r.id, 'databaseId', r.database_id, 'scope', r.scope,
|
||||
'modelId', r.model_id, 'status', r.status, 'total', r.total,
|
||||
'suggestedSensitive', r.suggested_sensitive,
|
||||
'suggestedNonSensitive', r.suggested_non_sensitive,
|
||||
'createdAt', r.created_at, 'startedAt', r.started_at,
|
||||
'finishedAt', r.finished_at, 'errorSummary', r.error_summary
|
||||
) order by r.created_at desc) from (select * from sensitive_data_suggestion_runs order by created_at desc limit 50) r), '[]'::json),
|
||||
'sensitiveEvents', coalesce((select json_agg(json_build_object(
|
||||
'runId', e.run_id, 'sequence', e.sequence, 'level', e.level,
|
||||
'message', e.message, 'createdAt', e.created_at
|
||||
) order by e.created_at desc) from (select * from sensitive_data_suggestion_events order by created_at desc limit 300) e), '[]'::json)
|
||||
)::text;
|
||||
`;
|
||||
|
||||
function liveCatalogPlugin(): Plugin {
|
||||
return {
|
||||
name: "database-management-prototype-live-catalog",
|
||||
configureServer(server) {
|
||||
server.middlewares.use("/__prototype/catalog-snapshot", (_request, response) => {
|
||||
try {
|
||||
const payload = execFileSync("docker", [
|
||||
"exec",
|
||||
"--env",
|
||||
`THT_PROTOTYPE_SQL=${LIVE_CATALOG_SQL}`,
|
||||
CATALOG_CONTAINER,
|
||||
"sh",
|
||||
"-lc",
|
||||
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "$THT_PROTOTYPE_SQL"',
|
||||
], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }).trim();
|
||||
response.statusCode = 200;
|
||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.end(payload);
|
||||
} catch (error) {
|
||||
response.statusCode = 503;
|
||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
response.end(JSON.stringify({
|
||||
error: "live_catalog_unavailable",
|
||||
detail: error instanceof Error ? error.message.split("\n")[0] : "Unknown error",
|
||||
}));
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig(({ command }) => {
|
||||
if (command !== "serve") {
|
||||
throw new Error("The database-management prototype is dev-only. Run npm run prototype:database-management.");
|
||||
}
|
||||
|
||||
return {
|
||||
root: path.resolve(__dirname, "prototypes/database-management"),
|
||||
plugins: [react(), liveCatalogPlugin()],
|
||||
appType: "spa",
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user