feat(ui): Data Sources UI — enterprise browser for all 5 source databases

New "Data Sources UI" tab with per-database Browser (catalog + sample data),
Query Console (SqlWorkbench) and embedded interactive Shell (DbShell via SSH).

Backend:
- Extend sql_console.py with Cassandra (CQL) + Neo4j (Cypher) engines
- Add GET /api/sql/catalog/{engine} and GET /api/sql/sample/{engine}
- ssh_terminal: optional initial_command for auto-launching DB CLIs

Frontend:
- DataSourcesView with 5-DB rail, health dots, Browser/Console/Shell sub-tabs
- DbShell embedded xterm terminal with docker exec CLI per engine
- Deep-link topology DB nodes to Data Sources UI (no SQL dock on platform)
- WorkbenchPanel restricted to agent mode only — frees dashboard space
This commit is contained in:
mo
2026-06-27 15:42:37 +00:00
parent b8a5b10bc4
commit a4c9b60079
10 changed files with 1027 additions and 76 deletions
+318 -38
View File
@@ -1,4 +1,4 @@
"""Live SQL console — PostgreSQL, MySQL, MongoDB + Trino with benchmark."""
"""Live SQL console — PostgreSQL, MySQL, MongoDB, Cassandra, Neo4j + Trino."""
from __future__ import annotations
@@ -10,8 +10,11 @@ from typing import Any
import httpx
import psycopg2
import pymysql
from fastapi import APIRouter
from cassandra.auth import PlainTextAuthProvider
from cassandra.cluster import Cluster
from fastapi import APIRouter, Query
from fastapi.responses import JSONResponse
from neo4j import GraphDatabase
from pydantic import BaseModel, Field
from pymongo import MongoClient
@@ -30,9 +33,21 @@ MONGO_HOST = os.getenv("MONGO_HOST", DB_HOST)
MONGO_PORT = int(os.getenv("MONGO_PORT", "27017"))
MONGO_DB = os.getenv("MONGO_DATABASE", "supplychain")
CASS_PORT = int(os.getenv("CASSANDRA_PORT", "9042"))
CASS_USER = os.getenv("CASSANDRA_USER", "cassandra")
CASS_PASS = os.getenv("CASSANDRA_PASSWORD", "cassandra")
CASS_KS = os.getenv("CASSANDRA_KEYSPACE", "telemetry")
NEO4J_URI = os.getenv("NEO4J_URI", f"bolt://{DB_HOST}:7687")
NEO4J_USER = os.getenv("NEO4J_USER", "neo4j")
NEO4J_PASS = os.getenv("NEO4J_PASSWORD", "testpwd")
TRINO_URL = os.getenv("TRINO_URL", "http://10.0.21.50:8089").rstrip("/")
TRINO_USER = os.getenv("TRINO_USER", "atc")
SOURCE_ENGINES = ("postgres", "mysql", "mongodb", "cassandra", "neo4j")
ENGINES = SOURCE_ENGINES + ("trino",)
router = APIRouter(prefix="/api/sql", tags=["sql"])
SAMPLES: dict[str, list[dict[str, str]]] = {
@@ -45,7 +60,7 @@ SAMPLES: dict[str, list[dict[str, str]]] = {
{"id": "pg6", "label": "Database sizes", "sql": "SELECT datname, pg_size_pretty(pg_database_size(datname)) AS size FROM pg_database ORDER BY pg_database_size(datname) DESC LIMIT 10;"},
{"id": "pg7", "label": "Row counts (stats)", "sql": "SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC NULLS LAST LIMIT 10;"},
{"id": "pg8", "label": "Connections", "sql": "SELECT count(*) AS connections, state FROM pg_stat_activity GROUP BY state;"},
{"id": "pg9", "label": "Explain scan 100k", "sql": "EXPLAIN ANALYZE SELECT count(*) FROM generate_series(1, 100000);"},
{"id": "pg9", "label": "Sales orders sample", "sql": "SELECT order_id, customer_id, region, total_amount FROM sales_orders ORDER BY order_id DESC LIMIT 10;"},
{"id": "pg10", "label": "Memory settings", "sql": "SELECT name, setting, unit FROM pg_settings WHERE name IN ('max_connections','shared_buffers','work_mem','effective_cache_size');"},
],
"mysql": [
@@ -72,6 +87,30 @@ SAMPLES: dict[str, list[dict[str, str]]] = {
{"id": "mg9", "label": "Indexes on events", "sql": "INDEXES supplychain.events"},
{"id": "mg10", "label": "Recent events", "sql": "FIND supplychain.events SORT timestamp DESC LIMIT 5"},
],
"cassandra": [
{"id": "cs1", "label": "Cluster release", "sql": "SELECT release_version FROM system.local"},
{"id": "cs2", "label": "Keyspaces", "sql": "SELECT keyspace_name FROM system_schema.keyspaces"},
{"id": "cs3", "label": "Telemetry tables", "sql": "SELECT table_name FROM system_schema.tables WHERE keyspace_name='telemetry'"},
{"id": "cs4", "label": "Device metrics sample", "sql": "SELECT device_id, metric_name, metric_value, recorded_at FROM telemetry.device_metrics LIMIT 10"},
{"id": "cs5", "label": "Metrics by device", "sql": "SELECT device_id, count(*) AS cnt FROM telemetry.device_metrics GROUP BY device_id LIMIT 10"},
{"id": "cs6", "label": "Distinct metric names", "sql": "SELECT DISTINCT metric_name FROM telemetry.device_metrics LIMIT 20"},
{"id": "cs7", "label": "Table columns", "sql": "SELECT column_name, type FROM system_schema.columns WHERE keyspace_name='telemetry' AND table_name='device_metrics'"},
{"id": "cs8", "label": "Recent metrics", "sql": "SELECT device_id, metric_name, metric_value FROM telemetry.device_metrics LIMIT 15"},
{"id": "cs9", "label": "Partition token sample", "sql": "SELECT token(device_id) AS tok, device_id FROM telemetry.device_metrics LIMIT 5"},
{"id": "cs10", "label": "Cluster peers", "sql": "SELECT peer, data_center, rack FROM system.peers"},
],
"neo4j": [
{"id": "nj1", "label": "Node labels", "sql": "CALL db.labels() YIELD label RETURN label"},
{"id": "nj2", "label": "Relationship types", "sql": "CALL db.relationshipTypes() YIELD relationshipType RETURN relationshipType"},
{"id": "nj3", "label": "Total nodes", "sql": "MATCH (n) RETURN count(n) AS node_count"},
{"id": "nj4", "label": "Total relationships", "sql": "MATCH ()-[r]->() RETURN count(r) AS rel_count"},
{"id": "nj5", "label": "Products sample", "sql": "MATCH (p:Product) RETURN p.product_id AS id, p.name AS name, p.category AS category LIMIT 10"},
{"id": "nj6", "label": "Suppliers sample", "sql": "MATCH (s:Supplier) RETURN s.supplier_id AS id, s.name AS name, s.country AS country LIMIT 10"},
{"id": "nj7", "label": "Supply chain links", "sql": "MATCH (s:Supplier)-[r:SUPPLIES]->(p:Product) RETURN s.name AS supplier, p.name AS product LIMIT 10"},
{"id": "nj8", "label": "Products by category", "sql": "MATCH (p:Product) RETURN p.category AS category, count(*) AS cnt ORDER BY cnt DESC LIMIT 10"},
{"id": "nj9", "label": "Schema visualization", "sql": "CALL db.schema.visualization()"},
{"id": "nj10", "label": "Constraint info", "sql": "SHOW CONSTRAINTS"},
],
"trino": [
{"id": "tq1", "label": "Trino version", "sql": "SELECT version()"},
{"id": "tq2", "label": "Catalogs", "sql": "SHOW CATALOGS"},
@@ -104,6 +143,12 @@ def _tabular(columns: list[str], rows: list[list[Any]], elapsed_ms: int, **extra
}
def _fmt(val: Any) -> Any:
if val is None or isinstance(val, (str, int, float, bool)):
return val
return str(val)[:200]
def _run_postgres(sql: str, limit: int = 200) -> dict[str, Any]:
t0 = time.perf_counter()
conn = psycopg2.connect(
@@ -116,7 +161,7 @@ def _run_postgres(sql: str, limit: int = 200) -> dict[str, Any]:
elapsed_ms = int((time.perf_counter() - t0) * 1000)
if cur.description:
columns = [d[0] for d in cur.description]
rows = [list(r) for r in cur.fetchmany(limit)]
rows = [[_fmt(r) for r in row] for row in cur.fetchmany(limit)]
return _tabular(columns, rows, elapsed_ms, truncated=len(rows) >= limit)
return _tabular([], [], elapsed_ms, message="OK")
finally:
@@ -135,7 +180,7 @@ def _run_mysql(sql: str, limit: int = 200) -> dict[str, Any]:
elapsed_ms = int((time.perf_counter() - t0) * 1000)
if cur.description:
columns = [d[0] for d in cur.description]
rows = [list(r) for r in cur.fetchmany(limit)]
rows = [[_fmt(r) for r in row] for row in cur.fetchmany(limit)]
return _tabular(columns, rows, elapsed_ms, truncated=len(rows) >= limit)
return _tabular([], [], elapsed_ms, message="OK")
finally:
@@ -146,14 +191,6 @@ def _mongo_client() -> MongoClient:
return MongoClient(f"mongodb://{MONGO_HOST}:{MONGO_PORT}/", serverSelectionTimeoutMS=8000)
def _fmt_mongo_value(val: Any) -> Any:
if val is None:
return None
if isinstance(val, (str, int, float, bool)):
return val
return str(val)[:200]
def _run_mongo(query: str, limit: int = 200) -> dict[str, Any]:
t0 = time.perf_counter()
q = query.strip()
@@ -185,12 +222,12 @@ def _run_mongo(query: str, limit: int = 200) -> dict[str, Any]:
rows = [["(empty)"]]
else:
columns = sorted({k for d in docs for k in d if k != "_id"})
rows = [[_fmt_mongo_value(d.get(c)) for c in columns] for d in docs]
rows = [[_fmt(d.get(c)) for c in columns] for d in docs]
elif m := re.match(r"^DISTINCT\s+(\w+)\.(\w+)\s+(\w+)$", q, re.I):
db_name, coll, field = m.group(1), m.group(2), m.group(3)
columns = [field]
vals = client[db_name][coll].distinct(field)[:limit]
rows = [[_fmt_mongo_value(v)] for v in vals]
rows = [[_fmt(v)] for v in vals]
elif m := re.match(r"^AGGREGATE\s+(\w+)\.(\w+)\s+GROUP\s+(\w+)\s+TOP\s+(\d+)$", q, re.I):
db_name, coll, field, top_n = m.group(1), m.group(2), m.group(3), int(m.group(4))
pipe = [
@@ -222,6 +259,47 @@ def _run_mongo(query: str, limit: int = 200) -> dict[str, Any]:
client.close()
def _cass_cluster() -> Cluster:
auth = PlainTextAuthProvider(CASS_USER, CASS_PASS) if CASS_USER else None
return Cluster([DB_HOST], port=CASS_PORT, auth_provider=auth, connect_timeout=8)
def _run_cassandra(cql: str, limit: int = 200) -> dict[str, Any]:
t0 = time.perf_counter()
cluster = _cass_cluster()
session = cluster.connect()
try:
rows_raw = list(session.execute(cql, timeout=30))
elapsed_ms = int((time.perf_counter() - t0) * 1000)
if not rows_raw:
return _tabular([], [], elapsed_ms, message="OK")
columns = list(rows_raw[0]._fields)
rows = [[_fmt(getattr(r, c)) for c in columns] for r in rows_raw[:limit]]
return _tabular(columns, rows, elapsed_ms, truncated=len(rows_raw) > limit)
finally:
cluster.shutdown()
def _neo4j_driver():
return GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASS))
def _run_neo4j(cypher: str, limit: int = 200) -> dict[str, Any]:
t0 = time.perf_counter()
driver = _neo4j_driver()
try:
with driver.session() as session:
result = session.run(cypher)
keys = result.keys()
columns = list(keys)
rows_raw = [record.values() for record in result]
elapsed_ms = int((time.perf_counter() - t0) * 1000)
rows = [[_fmt(v) for v in row] for row in rows_raw[:limit]]
return _tabular(columns, rows, elapsed_ms, truncated=len(rows_raw) > limit)
finally:
driver.close()
def _run_trino(sql: str, limit: int = 200) -> dict[str, Any]:
t0 = time.perf_counter()
headers = {"X-Trino-User": TRINO_USER, "Content-Type": "text/plain"}
@@ -253,14 +331,210 @@ def _run_trino(sql: str, limit: int = 200) -> dict[str, Any]:
return _tabular(columns, rows, elapsed_ms, engine_stats_ms=stats.get("elapsedTimeMillis"), truncated=len(rows) >= limit)
ENGINES = ("postgres", "mysql", "mongodb", "trino")
class SqlRequest(BaseModel):
engine: str = Field(..., pattern="^(postgres|mysql|mongodb|trino)$")
engine: str = Field(..., pattern="^(postgres|mysql|mongodb|trino|cassandra|neo4j)$")
sql: str = Field(..., min_length=1, max_length=8000)
def _connection_info(engine: str) -> dict[str, str]:
if engine == "postgres":
return {"host": DB_HOST, "port": str(PG_PORT), "database": PG_DB, "user": PG_USER}
if engine == "mysql":
return {"host": DB_HOST, "port": str(MYSQL_PORT), "database": MYSQL_DB, "user": MYSQL_USER}
if engine == "mongodb":
return {"host": MONGO_HOST, "port": str(MONGO_PORT), "database": MONGO_DB}
if engine == "cassandra":
return {"host": DB_HOST, "port": str(CASS_PORT), "keyspace": CASS_KS, "user": CASS_USER}
if engine == "neo4j":
return {"uri": NEO4J_URI, "user": NEO4J_USER}
return {"url": TRINO_URL, "user": TRINO_USER}
def _dispatch(engine: str, sql: str, limit: int = 200) -> dict[str, Any]:
if engine == "postgres":
return _run_postgres(sql, limit)
if engine == "mysql":
return _run_mysql(sql, limit)
if engine == "mongodb":
return _run_mongo(sql, limit)
if engine == "cassandra":
return _run_cassandra(sql, limit)
if engine == "neo4j":
return _run_neo4j(sql, limit)
return _run_trino(sql, limit)
def _catalog_postgres() -> dict[str, Any]:
conn = psycopg2.connect(host=DB_HOST, port=PG_PORT, user=PG_USER, password=PG_PASS, dbname=PG_DB, connect_timeout=8)
try:
conn.set_session(readonly=True, autocommit=True)
cur = conn.cursor()
cur.execute(
"SELECT schemaname, relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC NULLS LAST LIMIT 50"
)
objects = []
for schema, table, rows in cur.fetchall():
objects.append({"type": "table", "schema": schema, "name": table, "fqn": f"{schema}.{table}", "row_count": int(rows or 0)})
cur.execute("SELECT version()")
version = cur.fetchone()[0]
return {"engine": "postgres", "version": version, "objects": objects}
finally:
conn.close()
def _catalog_mysql() -> dict[str, Any]:
conn = pymysql.connect(host=DB_HOST, port=MYSQL_PORT, user=MYSQL_USER, password=MYSQL_PASS, database=MYSQL_DB, connect_timeout=8)
try:
cur = conn.cursor()
cur.execute(
"SELECT table_name, table_rows, ROUND((data_length+index_length)/1024/1024,1) AS mb "
"FROM information_schema.tables WHERE table_schema=%s ORDER BY table_rows DESC",
(MYSQL_DB,),
)
objects = [{"type": "table", "schema": MYSQL_DB, "name": r[0], "fqn": f"{MYSQL_DB}.{r[0]}", "row_count": int(r[1] or 0), "size_mb": float(r[2] or 0)} for r in cur.fetchall()]
cur.execute("SELECT VERSION()")
version = cur.fetchone()[0]
return {"engine": "mysql", "version": version, "objects": objects}
finally:
conn.close()
def _catalog_mongodb() -> dict[str, Any]:
client = _mongo_client()
try:
objects = []
for db_name in client.list_database_names():
if db_name in ("admin", "config", "local"):
continue
for coll in client[db_name].list_collection_names():
try:
cnt = client[db_name][coll].estimated_document_count()
except Exception:
cnt = None
objects.append({"type": "collection", "schema": db_name, "name": coll, "fqn": f"{db_name}.{coll}", "row_count": cnt})
build = client.admin.command("buildInfo")
return {"engine": "mongodb", "version": build.get("version"), "objects": objects}
finally:
client.close()
def _catalog_cassandra() -> dict[str, Any]:
cluster = _cass_cluster()
session = cluster.connect()
try:
objects = []
kss = [
r.keyspace_name for r in session.execute("SELECT keyspace_name FROM system_schema.keyspaces")
if not r.keyspace_name.startswith("system")
]
for ks in kss:
tables = session.execute(
"SELECT table_name FROM system_schema.tables WHERE keyspace_name=%s", (ks,)
)
for t in tables:
objects.append({"type": "table", "schema": ks, "name": t.table_name, "fqn": f"{ks}.{t.table_name}", "row_count": None})
ver = list(session.execute("SELECT release_version FROM system.local"))[0].release_version
return {"engine": "cassandra", "version": ver, "objects": objects}
finally:
cluster.shutdown()
def _catalog_neo4j() -> dict[str, Any]:
driver = _neo4j_driver()
try:
with driver.session() as session:
labels = [r["label"] for r in session.run("CALL db.labels() YIELD label RETURN label")]
rels = [r["relationshipType"] for r in session.run("CALL db.relationshipTypes() YIELD relationshipType RETURN relationshipType")]
objects = []
for label in labels:
cnt = session.run(f"MATCH (n:`{label}`) RETURN count(n) AS c").single()["c"]
objects.append({"type": "node_label", "schema": "graph", "name": label, "fqn": label, "row_count": cnt})
for rel in rels:
cnt = session.run(f"MATCH ()-[r:`{rel}`]->() RETURN count(r) AS c").single()["c"]
objects.append({"type": "relationship", "schema": "graph", "name": rel, "fqn": rel, "row_count": cnt})
total = session.run("MATCH (n) RETURN count(n) AS c").single()["c"]
return {"engine": "neo4j", "version": "4.4", "total_nodes": total, "objects": objects}
finally:
driver.close()
def _catalog(engine: str) -> dict[str, Any]:
if engine == "postgres":
return _catalog_postgres()
if engine == "mysql":
return _catalog_mysql()
if engine == "mongodb":
return _catalog_mongodb()
if engine == "cassandra":
return _catalog_cassandra()
if engine == "neo4j":
return _catalog_neo4j()
raise ValueError(f"Catalog not supported for {engine}")
def _sample_postgres(object_name: str, limit: int) -> dict[str, Any]:
if "." in object_name:
schema, table = object_name.split(".", 1)
sql = f'SELECT * FROM "{schema}"."{table}" LIMIT {limit}'
else:
sql = f'SELECT * FROM public."{object_name}" LIMIT {limit}'
return _run_postgres(sql, limit)
def _sample_mysql(object_name: str, limit: int) -> dict[str, Any]:
table = object_name.split(".")[-1]
return _run_mysql(f"SELECT * FROM `{table}` LIMIT {limit}", limit)
def _sample_mongodb(object_name: str, limit: int) -> dict[str, Any]:
if "." in object_name:
db_name, coll = object_name.split(".", 1)
else:
db_name, coll = MONGO_DB, object_name
return _run_mongo(f"FIND {db_name}.{coll} LIMIT {limit}", limit)
def _sample_cassandra(object_name: str, limit: int) -> dict[str, Any]:
if "." in object_name:
ks, table = object_name.split(".", 1)
else:
ks, table = CASS_KS, object_name
return _run_cassandra(f"SELECT * FROM {ks}.{table} LIMIT {limit}", limit)
def _sample_neo4j(object_name: str, limit: int) -> dict[str, Any]:
label = object_name.split(".")[-1]
cypher = f"MATCH (n:`{label}`) RETURN n LIMIT {limit}"
t0 = time.perf_counter()
driver = _neo4j_driver()
try:
with driver.session() as session:
result = session.run(cypher)
docs = [dict(record["n"]) for record in result]
elapsed_ms = int((time.perf_counter() - t0) * 1000)
if not docs:
return _tabular(["result"], [["(empty)"]], elapsed_ms)
columns = sorted({k for d in docs for k in d})
rows = [[_fmt(d.get(c)) for c in columns] for d in docs]
return _tabular(columns, rows, elapsed_ms, object=object_name)
finally:
driver.close()
def _sample(engine: str, object_name: str, limit: int) -> dict[str, Any]:
if engine == "postgres":
return _sample_postgres(object_name, limit)
if engine == "mysql":
return _sample_mysql(object_name, limit)
if engine == "mongodb":
return _sample_mongodb(object_name, limit)
if engine == "cassandra":
return _sample_cassandra(object_name, limit)
if engine == "neo4j":
return _sample_neo4j(object_name, limit)
raise ValueError(f"Sample not supported for {engine}")
@router.get("/samples/{engine}")
async def get_samples(engine: str):
if engine not in SAMPLES:
@@ -271,7 +545,7 @@ async def get_samples(engine: str):
@router.get("/health")
async def sql_health():
out: dict[str, Any] = {}
for eng in ENGINES:
for eng in SOURCE_ENGINES:
try:
if eng == "postgres":
_run_postgres("SELECT 1")
@@ -282,32 +556,38 @@ async def sql_health():
elif eng == "mongodb":
_run_mongo("SHOW DATABASES")
out[eng] = {"ok": True, "host": MONGO_HOST, "database": MONGO_DB, "error": None}
elif eng == "cassandra":
_run_cassandra("SELECT release_version FROM system.local LIMIT 1")
out[eng] = {"ok": True, "host": DB_HOST, "keyspace": CASS_KS, "error": None}
else:
r = _run_trino("SELECT 1")
out[eng] = {"ok": bool(r.get("ok")), "url": TRINO_URL, "user": TRINO_USER, "error": r.get("error")}
_run_neo4j("RETURN 1 AS n")
out[eng] = {"ok": True, "uri": NEO4J_URI, "user": NEO4J_USER, "error": None}
except Exception as exc:
out[eng] = {"ok": False, "error": str(exc)[:200]}
return out
def _connection_info(engine: str) -> dict[str, str]:
if engine == "postgres":
return {"host": DB_HOST, "port": str(PG_PORT), "database": PG_DB, "user": PG_USER}
if engine == "mysql":
return {"host": DB_HOST, "port": str(MYSQL_PORT), "database": MYSQL_DB, "user": MYSQL_USER}
if engine == "mongodb":
return {"host": MONGO_HOST, "port": str(MONGO_PORT), "database": MONGO_DB}
return {"url": TRINO_URL, "user": TRINO_USER}
@router.get("/catalog/{engine}")
async def get_catalog(engine: str):
if engine not in SOURCE_ENGINES:
return JSONResponse({"error": "unknown engine"}, status_code=404)
try:
return _catalog(engine)
except Exception as exc:
return JSONResponse({"error": str(exc)[:500]}, status_code=502)
def _dispatch(engine: str, sql: str) -> dict[str, Any]:
if engine == "postgres":
return _run_postgres(sql)
if engine == "mysql":
return _run_mysql(sql)
if engine == "mongodb":
return _run_mongo(sql)
return _run_trino(sql)
@router.get("/sample/{engine}")
async def get_sample(engine: str, object: str = Query(..., min_length=1), limit: int = Query(50, ge=1, le=200)):
if engine not in SOURCE_ENGINES:
return JSONResponse({"error": "unknown engine"}, status_code=404)
try:
result = _sample(engine, object, limit)
if not result.get("ok"):
return JSONResponse(result, status_code=422)
return {**result, "engine": engine, "object": object}
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)[:500]}, status_code=502)
@router.post("/execute")
+7
View File
@@ -86,6 +86,13 @@ async def ssh_session(ws: WebSocket) -> None:
await _send(ws, "connected", message="connected")
initial_cmd = cfg.get("initial_command")
if initial_cmd and chan and not chan.closed:
try:
chan.send(initial_cmd if initial_cmd.endswith("\n") else initial_cmd + "\n")
except Exception:
pass
async def pump_out() -> None:
assert chan is not None
while True:
+7 -3
View File
@@ -21,6 +21,7 @@ import { DataGenView } from './components/features/DataGenView'
import { ChangesView } from './components/features/ChangesView'
import { DataFlowView } from './components/features/DataFlowView'
import { SearchView } from './components/features/SearchView'
import { DataSourcesView } from './components/features/DataSourcesView'
import { SshTerminal } from './components/features/SshTerminal'
import { TerminalDock } from './components/features/TerminalDock'
import { WorkbenchPanel } from './components/features/WorkbenchPanel'
@@ -38,6 +39,7 @@ export default function App() {
const mainScrollRef = useRef<HTMLDivElement>(null)
const isPlatform = cc.mainView === 'platform'
const isDataSources = cc.mainView === 'datasources'
const openApprovals = () => {
cc.setMainView('approvals')
@@ -79,9 +81,9 @@ export default function App() {
onOpenSsh={() => setSshOpen(true)}
/>
<div ref={mainScrollRef} className={cn('flex min-h-0 min-w-0 flex-1 flex-col bg-surface', isPlatform ? 'overflow-hidden' : 'scrollbar-thin overflow-y-auto')}>
<div ref={mainScrollRef} className={cn('flex min-h-0 min-w-0 flex-1 flex-col bg-surface', (isPlatform || isDataSources) ? 'overflow-hidden' : 'scrollbar-thin overflow-y-auto')}>
<div className={cn('flex min-h-0 flex-1', isPlatform ? '' : 'flex-col')}>
<div className={cn('flex min-h-0 min-w-0 flex-1 flex-col', isPlatform ? 'gap-1 overflow-hidden p-1.5' : 'min-h-0 gap-2 p-3')}>
<div className={cn('flex min-h-0 min-w-0 flex-1 flex-col', (isPlatform || isDataSources) ? 'gap-1 overflow-hidden p-1.5' : 'min-h-0 gap-2 p-3')}>
{isPlatform && (
<>
<div className="grid shrink-0 grid-cols-1 gap-2 xl:grid-cols-[1fr_auto]">
@@ -119,7 +121,7 @@ export default function App() {
</>
)}
<div className={cn('flex min-h-0 flex-col', isPlatform ? 'min-h-0 flex-1 overflow-hidden' : 'min-h-0 flex-1')}>
<div className={cn('flex min-h-0 flex-col', (isPlatform || isDataSources) ? 'min-h-0 flex-1 overflow-hidden' : 'min-h-0 flex-1')}>
{cc.mainView === 'platform' ? (
<PlatformTopology
workload={cc.workload}
@@ -128,6 +130,8 @@ export default function App() {
onNodeClick={cc.selectNode}
pulse={cc.genPulse}
/>
) : cc.mainView === 'datasources' ? (
<DataSourcesView focusEngine={cc.dataSourceFocus} />
) : cc.mainView === 'datagen' ? (
<DataGenView onPulse={cc.pulseFlow} onOpenPlatform={() => cc.setMainView('platform')} />
) : cc.mainView === 'changes' ? (
@@ -0,0 +1,311 @@
import { useCallback, useEffect, useState } from 'react'
import {
Activity,
ChevronRight,
Database,
FolderTree,
Loader2,
RefreshCw,
Server,
Table2,
TerminalSquare,
} from 'lucide-react'
import { Badge } from '../ui/Badge'
import { DbShell } from './DbShell'
import { SqlWorkbench } from './SqlWorkbench'
import {
getSourceMeta,
SOURCE_CATALOG,
type CatalogObject,
type SourceEngine,
type SourceSubTab,
} from '../../lib/dataSourceCatalog'
import { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive'
type HealthMap = Record<string, { ok: boolean; error?: string | null }>
type CatalogResponse = {
engine: string
version?: string
total_nodes?: number
objects: CatalogObject[]
}
type SampleResponse = {
ok: boolean
columns?: string[]
rows?: unknown[][]
row_count?: number
elapsed_ms?: number
error?: string
}
type Props = {
focusEngine?: SourceEngine | null
}
const SUB_TABS: { id: SourceSubTab; label: string; icon: typeof FolderTree }[] = [
{ id: 'browser', label: 'Browser', icon: FolderTree },
{ id: 'console', label: 'Query Console', icon: Database },
{ id: 'shell', label: 'Shell', icon: TerminalSquare },
]
function fmtCount(n?: number | null) {
if (n == null) return '—'
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return String(n)
}
export function DataSourcesView({ focusEngine }: Props) {
const [active, setActive] = useState<SourceEngine>(focusEngine || 'postgres')
const [subTab, setSubTab] = useState<SourceSubTab>('browser')
const [health, setHealth] = useState<HealthMap>({})
const [catalog, setCatalog] = useState<CatalogResponse | null>(null)
const [catalogLoading, setCatalogLoading] = useState(false)
const [selectedObject, setSelectedObject] = useState<CatalogObject | null>(null)
const [sample, setSample] = useState<SampleResponse | null>(null)
const [sampleLoading, setSampleLoading] = useState(false)
const meta = getSourceMeta(active)
useEffect(() => {
if (focusEngine) setActive(focusEngine)
}, [focusEngine])
const loadHealth = useCallback(async () => {
try {
const r = await fetch('/api/sql/health')
if (r.ok) setHealth(await r.json())
} catch { /* */ }
}, [])
const loadCatalog = useCallback(async (engine: SourceEngine) => {
setCatalogLoading(true)
setCatalog(null)
setSelectedObject(null)
setSample(null)
try {
const r = await fetch(`/api/sql/catalog/${engine}`)
if (r.ok) {
const j: CatalogResponse = await r.json()
setCatalog(j)
const first = j.objects?.find((o) => o.type === 'table' || o.type === 'collection' || o.type === 'node_label')
if (first) setSelectedObject(first)
}
} catch { /* */ } finally {
setCatalogLoading(false)
}
}, [])
const loadSample = useCallback(async (engine: SourceEngine, obj: CatalogObject) => {
setSampleLoading(true)
setSample(null)
try {
const r = await fetch(`/api/sql/sample/${engine}?object=${encodeURIComponent(obj.fqn)}&limit=50`)
const j = await r.json()
setSample(j)
} catch {
setSample({ ok: false, error: 'Sample API unavailable' })
} finally {
setSampleLoading(false)
}
}, [])
useEffect(() => { loadHealth() }, [loadHealth])
useEffect(() => {
if (subTab === 'browser') loadCatalog(active)
}, [active, subTab, loadCatalog])
useEffect(() => {
if (selectedObject && subTab === 'browser') loadSample(active, selectedObject)
}, [selectedObject, active, subTab, loadSample])
const refreshAll = () => {
loadHealth()
if (subTab === 'browser') loadCatalog(active)
else if (selectedObject) loadSample(active, selectedObject)
}
return (
<div className="flex h-full min-h-0 flex-col gap-2 p-3">
{/* Header */}
<header className="panel flex shrink-0 flex-wrap items-center justify-between gap-3 px-4 py-3">
<div>
<h1 className="flex items-center gap-2 text-base font-semibold text-foreground">
<Server className="h-5 w-5 text-docker" />
Data Sources UI
</h1>
<p className="text-[11px] text-foreground-muted">
Enterprise data browser schema exploration, query console &amp; interactive shells for all source databases
</p>
</div>
<button type="button" onClick={refreshAll} className={cn('inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
<RefreshCw className="h-3.5 w-3.5" /> Refresh
</button>
</header>
<div className="flex min-h-0 flex-1 gap-2 overflow-hidden">
{/* Left rail — database cards */}
<aside className="panel flex w-[220px] shrink-0 flex-col overflow-hidden">
<div className="shrink-0 border-b border-border px-3 py-2">
<p className="text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">Source Databases</p>
</div>
<div className="scrollbar-thin min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
{SOURCE_CATALOG.map((src) => {
const Icon = src.icon
const up = health[src.engine]?.ok
const selected = active === src.engine
return (
<button
key={src.engine}
type="button"
onClick={() => { setActive(src.engine); setSubTab('browser') }}
className={cn(
'flex w-full flex-col gap-1 rounded-lg border p-2.5 text-left transition-all',
selected ? cn(src.border, src.accentBg, 'shadow-sm') : 'border-transparent hover:border-border hover:bg-surface-overlay',
)}
>
<div className="flex items-center justify-between">
<span className={cn('flex items-center gap-1.5 text-[12px] font-semibold', selected ? src.accent : 'text-foreground')}>
<Icon className="h-4 w-4" />
{src.label}
</span>
<span className={cn('h-2 w-2 rounded-full', up === true ? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.6)]' : up === false ? 'bg-red-400' : 'bg-foreground-faint')} title={up ? 'Online' : up === false ? 'Offline' : 'Unknown'} />
</div>
<p className="text-[9px] leading-snug text-foreground-muted">{src.description}</p>
<div className="flex flex-wrap gap-1">
<Badge variant="default">{src.host}:{src.port}</Badge>
{src.cdc && <Badge variant="accent">CDC</Badge>}
</div>
</button>
)
})}
</div>
</aside>
{/* Main panel */}
<div className="panel flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{/* Engine header */}
<div className={cn('flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-2.5', meta.accentBg)}>
<div>
<h2 className={cn('flex items-center gap-2 text-sm font-semibold', meta.accent)}>
<meta.icon className="h-4 w-4" />
{meta.label}
{catalog?.version && (
<span className="font-mono text-[10px] font-normal text-foreground-muted">v{catalog.version.split(' ')[0]?.slice(0, 20)}</span>
)}
</h2>
<p className="font-mono text-[10px] text-foreground-muted">
{meta.host}:{meta.port} · {meta.database} · container {meta.container}
</p>
</div>
<div className="flex gap-1">
{SUB_TABS.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => setSubTab(id)}
className={cn('inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-[10px] font-medium', subTab === id ? subTabActive : subTabIdle)}
>
<Icon className="h-3 w-3" /> {label}
</button>
))}
</div>
</div>
{/* Sub-tab content */}
<div className="min-h-0 flex-1 overflow-hidden">
{subTab === 'browser' && (
<div className="flex h-full min-h-0">
{/* Object tree */}
<div className="flex w-[280px] shrink-0 flex-col border-r border-border/60">
<div className="flex shrink-0 items-center justify-between border-b border-border/60 px-3 py-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Objects</span>
{catalogLoading && <Loader2 className="h-3 w-3 animate-spin text-foreground-muted" />}
</div>
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto p-1.5">
{catalog?.objects?.map((obj) => (
<button
key={obj.fqn}
type="button"
onClick={() => setSelectedObject(obj)}
className={cn(
'mb-0.5 flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-left text-[10px] transition-colors',
selectedObject?.fqn === obj.fqn ? subTabActive : 'hover:bg-surface-overlay',
)}
>
{obj.type === 'node_label' ? <Activity className="h-3 w-3 shrink-0 text-pink-400" /> : <Table2 className="h-3 w-3 shrink-0 text-foreground-muted" />}
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">{obj.name}</p>
<p className="truncate font-mono text-[8px] text-foreground-faint">{obj.schema}{obj.type === 'relationship' ? ' · rel' : ''}</p>
</div>
<span className="shrink-0 font-mono text-[9px] text-foreground-muted">{fmtCount(obj.row_count)}</span>
<ChevronRight className="h-3 w-3 shrink-0 text-foreground-faint" />
</button>
))}
{!catalogLoading && !catalog?.objects?.length && (
<p className="p-4 text-center text-[10px] text-foreground-faint">No objects found</p>
)}
</div>
</div>
{/* Sample data grid */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className="flex shrink-0 items-center justify-between border-b border-border/60 px-3 py-2">
<span className="text-[10px] font-semibold text-foreground">
{selectedObject ? (
<>Sample: <span className="font-mono text-docker">{selectedObject.fqn}</span></>
) : 'Select an object'}
</span>
{sampleLoading && <Loader2 className="h-3 w-3 animate-spin" />}
</div>
<div className="scrollbar-thin min-h-0 flex-1 overflow-auto p-2">
{sample?.ok && sample.columns && (
<>
<p className="mb-1 font-mono text-[9px] text-foreground-faint">
{sample.row_count} rows · {sample.elapsed_ms}ms
</p>
<table className="w-full text-left font-mono text-[10px]">
<thead>
<tr className="sticky top-0 border-b border-border bg-surface-raised text-docker">
{sample.columns.map((c) => <th key={c} className="px-2 py-1">{c}</th>)}
</tr>
</thead>
<tbody>
{sample.rows?.map((row, i) => (
<tr key={i} className="border-b border-border/30 hover:bg-white/5">
{row.map((cell, j) => (
<td key={j} className="max-w-[200px] truncate px-2 py-1 text-foreground-muted">
{cell === null || cell === undefined ? 'NULL' : String(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</>
)}
{sample && !sample.ok && (
<p className="p-4 text-[11px] text-danger">{sample.error || 'Failed to load sample'}</p>
)}
{!selectedObject && !sampleLoading && (
<p className="py-8 text-center text-[11px] text-foreground-faint">Select a table, collection or label to preview data</p>
)}
</div>
</div>
</div>
)}
{subTab === 'console' && (
<SqlWorkbench engine={active} />
)}
{subTab === 'shell' && (
<DbShell initialCommand={meta.shellCommand} />
)}
</div>
</div>
</div>
</div>
)
}
+210
View File
@@ -0,0 +1,210 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Loader2, Plug, RotateCcw, TerminalSquare } from 'lucide-react'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
import { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive'
type Props = {
host?: string
port?: string
username?: string
initialCommand?: string
className?: string
}
type ConnState = 'form' | 'connecting' | 'connected' | 'closed'
export function DbShell({
host = '10.0.21.51',
port = '22',
username = 'root',
initialCommand,
className,
}: Props) {
const [password, setPassword] = useState('')
const [state, setState] = useState<ConnState>('form')
const [statusMsg, setStatusMsg] = useState<string | null>(null)
const [remember, setRemember] = useState(false)
const termRef = useRef<HTMLDivElement | null>(null)
const term = useRef<Terminal | null>(null)
const fit = useRef<FitAddon | null>(null)
const ws = useRef<WebSocket | null>(null)
const teardown = useCallback(() => {
try { ws.current?.close() } catch { /* */ }
ws.current = null
try { term.current?.dispose() } catch { /* */ }
term.current = null
fit.current = null
}, [])
const connect = useCallback(() => {
if (!password) return
setState('connecting')
setStatusMsg(`Connecting to ${username}@${host}:${port}`)
const t = new Terminal({
cursorBlink: true,
fontSize: 12,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
theme: { background: '#0a0e14', foreground: '#d6deeb', cursor: '#7ee787' },
})
const fitAddon = new FitAddon()
t.loadAddon(fitAddon)
term.current = t
fit.current = fitAddon
requestAnimationFrame(() => {
if (!termRef.current) return
t.open(termRef.current)
try { fitAddon.fit() } catch { /* */ }
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const socket = new WebSocket(`${proto}://${window.location.host}/api/ws/ssh`)
ws.current = socket
socket.onopen = () => {
socket.send(JSON.stringify({
type: 'connect',
host,
port: Number(port) || 22,
username,
password,
cols: t.cols,
rows: t.rows,
initial_command: initialCommand,
}))
}
socket.onmessage = (ev) => {
let msg: { type?: string; data?: string; message?: string }
try { msg = JSON.parse(ev.data) } catch { return }
if (msg.type === 'data') {
t.write(msg.data || '')
} else if (msg.type === 'status') {
setStatusMsg(msg.message || null)
} else if (msg.type === 'connected') {
setState('connected')
setStatusMsg(null)
if (remember) sessionStorage.setItem('ds-ssh-pass', password)
t.focus()
} else if (msg.type === 'error') {
setState('closed')
setStatusMsg(msg.message || 'Error')
t.writeln(`\r\n\x1b[31m${msg.message}\x1b[0m`)
} else if (msg.type === 'closed') {
setState('closed')
t.writeln('\r\n\x1b[33m*** Session closed ***\x1b[0m')
}
}
socket.onclose = () => {
setState((s) => (s === 'connected' ? 'closed' : s))
}
t.onData((d) => {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'data', data: d }))
})
})
}, [host, port, username, password, initialCommand, remember])
const disconnect = useCallback(() => {
try { ws.current?.send(JSON.stringify({ type: 'disconnect' })) } catch { /* */ }
teardown()
setState('form')
setStatusMsg(null)
}, [teardown])
useEffect(() => {
const saved = sessionStorage.getItem('ds-ssh-pass')
if (saved) setPassword(saved)
}, [])
useEffect(() => {
if (state !== 'connected' && state !== 'closed') return
const onResize = () => {
try {
fit.current?.fit()
const tt = term.current
if (tt && ws.current?.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({ type: 'resize', cols: tt.cols, rows: tt.rows }))
}
} catch { /* */ }
}
const ro = new ResizeObserver(onResize)
if (termRef.current) ro.observe(termRef.current)
window.addEventListener('resize', onResize)
onResize()
return () => { ro.disconnect(); window.removeEventListener('resize', onResize) }
}, [state])
useEffect(() => () => teardown(), [teardown])
return (
<div className={cn('flex h-full min-h-0 flex-col bg-[#0a0e14]', className)}>
<header className="flex shrink-0 items-center justify-between border-b border-border/60 px-3 py-2">
<span className="flex items-center gap-2 text-[11px] font-semibold text-foreground">
<TerminalSquare className="h-4 w-4 text-emerald-400" />
Interactive Shell
{state === 'connected' && (
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-normal text-emerald-400">live</span>
)}
{initialCommand && (
<span className="max-w-[420px] truncate font-mono text-[9px] font-normal text-foreground-muted" title={initialCommand}>
{initialCommand}
</span>
)}
</span>
{(state === 'connected' || state === 'closed') && (
<button type="button" onClick={disconnect} className="text-foreground-muted hover:text-foreground" title="Disconnect">
<RotateCcw className="h-3.5 w-3.5" />
</button>
)}
</header>
{state === 'form' ? (
<form
className="flex flex-1 flex-col justify-center gap-3 p-6"
onSubmit={(e) => { e.preventDefault(); connect() }}
>
<p className="text-[11px] text-foreground-muted">
SSH to <span className="font-mono text-foreground">{username}@{host}:{port}</span> and launch the database CLI.
Password is used for this session only.
</p>
<label className="flex max-w-sm flex-col gap-1 text-[10px] uppercase tracking-wide text-foreground-faint">
SSH Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
className="rounded border border-border bg-background px-3 py-2 text-[12px] text-foreground"
placeholder="root password for DB Vault"
/>
</label>
<label className="flex items-center gap-2 text-[10px] text-foreground-muted">
<input type="checkbox" checked={remember} onChange={(e) => setRemember(e.target.checked)} />
Remember for this browser session
</label>
<button
type="submit"
disabled={!password}
className={cn('inline-flex max-w-sm items-center justify-center gap-2 rounded-md px-4 py-2 text-[12px] font-semibold', subTabActive, 'disabled:opacity-40')}
>
<Plug className="h-4 w-4" /> Connect &amp; Launch CLI
</button>
</form>
) : (
<div className="relative min-h-0 flex-1">
{state === 'connecting' && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[#0a0e14]/80 text-[12px] text-foreground-muted">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {statusMsg || 'Connecting…'}
</div>
)}
<div ref={termRef} className="h-full p-2" />
</div>
)}
</div>
)
}
+18 -5
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { Database, Loader2, Play, Zap } from 'lucide-react'
import type { SourceEngine } from '../../lib/dataSourceCatalog'
import { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive'
@@ -14,7 +15,7 @@ type SqlResult = {
sql?: string
}
type Engine = 'postgres' | 'mysql' | 'mongodb' | 'trino'
type Engine = SourceEngine | 'trino'
type Props = {
engine: Engine
@@ -25,7 +26,7 @@ const ENGINE_META: Record<Engine, { title: string; sub: string; accent: string;
postgres: {
title: 'PostgreSQL SQL Console',
sub: 'DB Vault · 10.0.21.51:5432 · user mo',
accent: 'text-blue-400',
accent: 'text-sky-400',
queryLabel: 'SQL',
},
mysql: {
@@ -40,6 +41,18 @@ const ENGINE_META: Record<Engine, { title: string; sub: string; accent: string;
accent: 'text-emerald-400',
queryLabel: 'Command',
},
cassandra: {
title: 'Cassandra CQL Console',
sub: 'DB Vault · 10.0.21.51:9042 · keyspace telemetry',
accent: 'text-cyan-400',
queryLabel: 'CQL',
},
neo4j: {
title: 'Neo4j Cypher Console',
sub: 'DB Vault · 10.0.21.51:7687 · graph database',
accent: 'text-pink-400',
queryLabel: 'Cypher',
},
trino: {
title: 'Trino SQL Console',
sub: 'Lakehouse · 10.0.21.50:8089 · federated queries',
@@ -51,7 +64,7 @@ const ENGINE_META: Record<Engine, { title: string; sub: string; accent: string;
export function SqlWorkbench({ engine, compact }: Props) {
const meta = ENGINE_META[engine]
const [samples, setSamples] = useState<Sample[]>([])
const [sql, setSql] = useState('SELECT version();')
const [sql, setSql] = useState('')
const [result, setResult] = useState<SqlResult | null>(null)
const [benchmark, setBenchmark] = useState<{
comparison?: { postgres_ms?: number; trino_ms?: number; faster?: string; speedup_factor?: number }
@@ -146,7 +159,7 @@ export function SqlWorkbench({ engine, compact }: Props) {
<div className="flex min-h-0 flex-1 overflow-hidden">
<aside className={cn('flex min-h-0 shrink-0 flex-col border-r border-border/60 p-1.5', compact ? 'w-44' : 'w-52')}>
<p className="mb-0.5 text-[8px] font-semibold uppercase tracking-wider text-foreground-faint">10 demo commands</p>
<p className="mb-0.5 text-[8px] font-semibold uppercase tracking-wider text-foreground-faint">Demo queries</p>
<div className="scrollbar-thin min-h-0 flex-1 space-y-0.5 overflow-y-auto">
{samples.map((s) => (
<button
@@ -167,7 +180,7 @@ export function SqlWorkbench({ engine, compact }: Props) {
onChange={(e) => setSql(e.target.value)}
className={cn(
'shrink-0 resize-y border-b border-border/60 bg-[#0d1117] p-1.5 font-mono text-[10px] text-emerald-100 outline-none focus:ring-1 focus:ring-docker/40',
compact ? 'min-h-[48px] max-h-[96px]' : 'min-h-[56px] max-h-[120px]',
compact ? 'min-h-[48px] max-h-[96px]' : 'min-h-[72px] max-h-[140px]',
)}
spellCheck={false}
placeholder={`${meta.queryLabel}`}
+3 -10
View File
@@ -2,10 +2,9 @@ import { useEffect, useRef, useState } from 'react'
import { GripHorizontal } from 'lucide-react'
import type { Agent, TerminalLine } from '../../types'
import { AgentWorkbench } from './AgentWorkbench'
import { SqlWorkbench } from './SqlWorkbench'
type Props = {
mode: 'agent' | 'sql-postgres' | 'sql-mysql' | 'sql-mongodb' | 'sql-trino' | null
mode: 'agent' | null
agent: Agent | null
lines: TerminalLine[]
busy: boolean
@@ -38,7 +37,7 @@ export function WorkbenchPanel({ mode, agent, lines, busy, onSendPrompt }: Props
}
}, [])
if (!mode) return null
if (!mode || mode !== 'agent' || !agent) return null
return (
<section
@@ -61,13 +60,7 @@ export function WorkbenchPanel({ mode, agent, lines, busy, onSendPrompt }: Props
</div>
<div className="min-h-0 flex-1 overflow-hidden">
{mode === 'agent' && agent && (
<AgentWorkbench agent={agent} lines={lines} busy={busy} onSendPrompt={onSendPrompt} compact />
)}
{mode === 'sql-postgres' && <SqlWorkbench engine="postgres" compact />}
{mode === 'sql-mysql' && <SqlWorkbench engine="mysql" compact />}
{mode === 'sql-mongodb' && <SqlWorkbench engine="mongodb" compact />}
{mode === 'sql-trino' && <SqlWorkbench engine="trino" compact />}
<AgentWorkbench agent={agent} lines={lines} busy={busy} onSendPrompt={onSendPrompt} compact />
</div>
</section>
)
+3 -2
View File
@@ -1,4 +1,4 @@
import { DatabaseZap, HardDrive, Search, LayoutDashboard, MessageSquare, Presentation, Server, TerminalSquare, Cpu, Activity, GitBranch } from 'lucide-react'
import { Database, DatabaseZap, HardDrive, Search, LayoutDashboard, MessageSquare, Presentation, Server, TerminalSquare, Cpu, Activity, GitBranch } from 'lucide-react'
import type { GpuStatus, WorkloadData } from '../../types'
import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
import { cn } from '../../lib/utils'
@@ -6,7 +6,7 @@ import { viewTabActive, viewTabIdle } from '../../lib/tabActive'
import { GpuMatrixPanel } from '../features/GpuMatrixPanel'
import { LabHealthPanel } from '../features/LabHealthPanel'
type MainView = 'platform' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'hdfs' | 'search' | 'datagen' | 'approvals' | 'changes' | 'dataflow'
type MainView = 'platform' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'hdfs' | 'search' | 'datagen' | 'approvals' | 'changes' | 'dataflow' | 'datasources'
type Props = {
workload: WorkloadData | null
@@ -24,6 +24,7 @@ type Props = {
const VIEWS: { id: MainView; label: string; icon: typeof LayoutDashboard }[] = [
{ id: 'platform', label: 'Data Platform', icon: LayoutDashboard },
{ id: 'datasources', label: 'Data Sources UI', icon: Database },
{ id: 'datagen', label: 'Data Generation', icon: Cpu },
{ id: 'changes', label: 'Live Changes', icon: Activity },
{ id: 'dataflow', label: 'Data Flow', icon: GitBranch },
+14 -18
View File
@@ -15,6 +15,7 @@ import {
} from '../lib/api'
import { AGENT_NODE, NODE_ALIASES, wsUrl } from '../lib/constants'
import { resolveInfraNode } from '../lib/infraCatalog'
import { resolveSourceEngine, type SourceEngine } from '../lib/dataSourceCatalog'
import type {
Agent,
AgentAnim,
@@ -37,19 +38,9 @@ function resolveProbeId(nodeId: string) {
}
const SQL_NODE_ENGINES: Record<string, 'postgres' | 'mysql' | 'mongodb' | 'trino'> = {
postgresql: 'postgres',
'src-postgres': 'postgres',
mysql: 'mysql',
'src-mysql': 'mysql',
mongodb: 'mongodb',
'src-mongo': 'mongodb',
trino: 'trino',
'query-trino': 'trino',
}
function resolveSqlEngine(nodeId: string): 'postgres' | 'mysql' | 'mongodb' | 'trino' | null {
return SQL_NODE_ENGINES[nodeId] || SQL_NODE_ENGINES[resolveProbeId(nodeId)] || null
function resolveSourceFromNode(nodeId: string): SourceEngine | null {
const probeId = resolveProbeId(nodeId)
return resolveSourceEngine(nodeId, probeId)
}
export function useCommandCenter() {
@@ -69,7 +60,7 @@ export function useCommandCenter() {
const [selectedNode, setSelectedNode] = useState<TopologyNode | null>(null)
const [nodeDetail, setNodeDetail] = useState<NodeDetail | null>(null)
const [nodeBusy, setNodeBusy] = useState(false)
const [mainView, setMainView] = useState<'platform' | 'approvals' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'hdfs' | 'search' | 'datagen' | 'changes' | 'dataflow'>('platform')
const [mainView, setMainView] = useState<'platform' | 'approvals' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'hdfs' | 'search' | 'datagen' | 'changes' | 'dataflow' | 'datasources'>('platform')
const [changes, setChanges] = useState<CdcChange[]>([])
const [genPulse, setGenPulse] = useState(false)
const genPulseTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -79,7 +70,8 @@ export function useCommandCenter() {
genPulseTimer.current = setTimeout(() => setGenPulse(false), 60000)
}, [])
const [approvalHighlight, setApprovalHighlight] = useState(false)
const [workbenchMode, setWorkbenchMode] = useState<'agent' | 'sql-postgres' | 'sql-mysql' | 'sql-mongodb' | 'sql-trino' | null>(null)
const [workbenchMode, setWorkbenchMode] = useState<'agent' | null>(null)
const [dataSourceFocus, setDataSourceFocus] = useState<SourceEngine | null>(null)
const [chatExpanded, setChatExpanded] = useState(false)
const promptTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [terminalExpanded, setTerminalExpanded] = useState(true)
@@ -237,9 +229,11 @@ export function useCommandCenter() {
const stub = findNodeStub(nodeId)
if (!stub) return
const probeId = resolveProbeId(nodeId)
const sqlEng = resolveSqlEngine(nodeId)
if (sqlEng) {
setWorkbenchMode(`sql-${sqlEng}` as 'sql-postgres' | 'sql-mysql' | 'sql-mongodb' | 'sql-trino')
const sourceEng = resolveSourceFromNode(nodeId)
if (sourceEng) {
setMainView('datasources')
setDataSourceFocus(sourceEng)
setWorkbenchMode(null)
setSelectedAgentId(null)
} else if (options?.keepWorkbench) {
setWorkbenchMode(options.keepWorkbench)
@@ -352,6 +346,8 @@ export function useCommandCenter() {
approvalHighlight,
setApprovalHighlight,
workbenchMode,
dataSourceFocus,
setDataSourceFocus,
inspectorLines,
terminalSubjectId,
terminalExpanded,
+136
View File
@@ -0,0 +1,136 @@
import type { LucideIcon } from 'lucide-react'
import { Activity, Boxes, Database, Network } from 'lucide-react'
export type SourceEngine = 'postgres' | 'mysql' | 'mongodb' | 'cassandra' | 'neo4j'
export type SourceSubTab = 'browser' | 'console' | 'shell'
export type CatalogObject = {
type: string
schema: string
name: string
fqn: string
row_count?: number | null
size_mb?: number
}
export type SourceMeta = {
engine: SourceEngine
label: string
shortLabel: string
icon: LucideIcon
accent: string
accentBg: string
border: string
host: string
port: string
database: string
container: string
shellCommand: string
description: string
cdc: boolean
}
export const SOURCE_CATALOG: SourceMeta[] = [
{
engine: 'postgres',
label: 'PostgreSQL',
shortLabel: 'PG',
icon: Database,
accent: 'text-sky-400',
accentBg: 'bg-sky-500/10',
border: 'border-sky-500/30',
host: '10.0.21.51',
port: '5432',
database: 'postgres',
container: 'postgres_sales',
shellCommand: 'docker exec -it postgres_sales psql -U mo -d postgres',
description: 'Operational sales orders — CDC source via Debezium',
cdc: true,
},
{
engine: 'mysql',
label: 'MySQL',
shortLabel: 'MY',
icon: Database,
accent: 'text-amber-400',
accentBg: 'bg-amber-500/10',
border: 'border-amber-500/30',
host: '10.0.21.51',
port: '3306',
database: 'hr',
container: 'mysql_hr',
shellCommand: 'docker exec -it mysql_hr mysql -umo -pDell2026! hr',
description: 'HR employee events — CDC source via Debezium',
cdc: true,
},
{
engine: 'mongodb',
label: 'MongoDB',
shortLabel: 'MG',
icon: Boxes,
accent: 'text-emerald-400',
accentBg: 'bg-emerald-500/10',
border: 'border-emerald-500/30',
host: '10.0.21.51',
port: '27017',
database: 'supplychain',
container: 'mongodb_supplychain',
shellCommand: 'docker exec -it mongodb_supplychain mongo supplychain',
description: 'Supply chain events — CDC source via Debezium',
cdc: true,
},
{
engine: 'cassandra',
label: 'Cassandra',
shortLabel: 'CS',
icon: Activity,
accent: 'text-cyan-400',
accentBg: 'bg-cyan-500/10',
border: 'border-cyan-500/30',
host: '10.0.21.51',
port: '9042',
database: 'telemetry',
container: 'cassandra_telemetry',
shellCommand: 'docker exec -it cassandra_telemetry cqlsh',
description: 'Device telemetry time-series — queryable via Trino',
cdc: false,
},
{
engine: 'neo4j',
label: 'Neo4j',
shortLabel: 'NJ',
icon: Network,
accent: 'text-pink-400',
accentBg: 'bg-pink-500/10',
border: 'border-pink-500/30',
host: '10.0.21.51',
port: '7687',
database: 'graph',
container: 'neo4j_graph',
shellCommand: 'docker exec -it neo4j_graph cypher-shell -u neo4j -p testpwd',
description: 'Product/supplier graph — 4.5M nodes',
cdc: false,
},
]
export const SOURCE_NODE_ENGINES: Record<string, SourceEngine> = {
postgresql: 'postgres',
'src-postgres': 'postgres',
mysql: 'mysql',
'src-mysql': 'mysql',
mongodb: 'mongodb',
'src-mongo': 'mongodb',
cassandra: 'cassandra',
'src-cassandra': 'cassandra',
neo4j: 'neo4j',
'src-neo4j': 'neo4j',
}
export function resolveSourceEngine(nodeId: string, probeId?: string): SourceEngine | null {
return SOURCE_NODE_ENGINES[nodeId] || (probeId ? SOURCE_NODE_ENGINES[probeId] : null) || null
}
export function getSourceMeta(engine: SourceEngine): SourceMeta {
return SOURCE_CATALOG.find((s) => s.engine === engine) || SOURCE_CATALOG[0]
}