Add MySQL/MongoDB consoles, fit topology without scroll, longer flow lines.
Compact workbench panel, collapsible infra bar, narrower topology nodes with wider inter-stage gaps for visible connection lines.
This commit is contained in:
+196
-52
@@ -1,23 +1,35 @@
|
|||||||
"""Live SQL console — PostgreSQL + Trino with benchmark."""
|
"""Live SQL console — PostgreSQL, MySQL, MongoDB + Trino with benchmark."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import psycopg2
|
import psycopg2
|
||||||
|
import pymysql
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from pymongo import MongoClient
|
||||||
|
|
||||||
PG_HOST = os.getenv("DB_VAULT_HOST", "10.0.21.51")
|
DB_HOST = os.getenv("DB_VAULT_HOST", "10.0.21.51")
|
||||||
PG_PORT = int(os.getenv("PG_PORT", "5432"))
|
PG_PORT = int(os.getenv("PG_PORT", "5432"))
|
||||||
PG_USER = os.getenv("PG_USER", "mo")
|
PG_USER = os.getenv("PG_USER", "mo")
|
||||||
PG_PASS = os.getenv("PG_PASSWORD", "Dell2026!")
|
PG_PASS = os.getenv("PG_PASSWORD", "Dell2026!")
|
||||||
PG_DB = os.getenv("PG_DATABASE", "postgres")
|
PG_DB = os.getenv("PG_DATABASE", "postgres")
|
||||||
|
|
||||||
|
MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306"))
|
||||||
|
MYSQL_USER = os.getenv("MYSQL_USER", "mo")
|
||||||
|
MYSQL_PASS = os.getenv("MYSQL_PASSWORD", "Dell2026!")
|
||||||
|
MYSQL_DB = os.getenv("MYSQL_DATABASE", "hr")
|
||||||
|
|
||||||
|
MONGO_HOST = os.getenv("MONGO_HOST", DB_HOST)
|
||||||
|
MONGO_PORT = int(os.getenv("MONGO_PORT", "27017"))
|
||||||
|
MONGO_DB = os.getenv("MONGO_DATABASE", "supplychain")
|
||||||
|
|
||||||
TRINO_URL = os.getenv("TRINO_URL", "http://10.0.21.50:8089").rstrip("/")
|
TRINO_URL = os.getenv("TRINO_URL", "http://10.0.21.50:8089").rstrip("/")
|
||||||
TRINO_USER = os.getenv("TRINO_USER", "atc")
|
TRINO_USER = os.getenv("TRINO_USER", "atc")
|
||||||
|
|
||||||
@@ -36,6 +48,30 @@ SAMPLES: dict[str, list[dict[str, str]]] = {
|
|||||||
{"id": "pg9", "label": "Explain scan 100k", "sql": "EXPLAIN ANALYZE SELECT count(*) FROM generate_series(1, 100000);"},
|
{"id": "pg9", "label": "Explain scan 100k", "sql": "EXPLAIN ANALYZE SELECT count(*) FROM generate_series(1, 100000);"},
|
||||||
{"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');"},
|
{"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": [
|
||||||
|
{"id": "my1", "label": "Server version", "sql": "SELECT VERSION() AS version;"},
|
||||||
|
{"id": "my2", "label": "Current session", "sql": "SELECT DATABASE() AS db, USER() AS user, NOW() AS now_ts;"},
|
||||||
|
{"id": "my3", "label": "HR tables", "sql": "SHOW TABLES;"},
|
||||||
|
{"id": "my4", "label": "Table sizes", "sql": "SELECT table_name, table_rows, ROUND((data_length+index_length)/1024/1024,1) AS mb FROM information_schema.tables WHERE table_schema='hr' ORDER BY (data_length+index_length) DESC;"},
|
||||||
|
{"id": "my5", "label": "Employee events sample", "sql": "SELECT event_type, COUNT(*) AS cnt FROM employee_events GROUP BY event_type ORDER BY cnt DESC LIMIT 10;"},
|
||||||
|
{"id": "my6", "label": "Recent events", "sql": "SELECT employee_id, event_type, event_date FROM employee_events ORDER BY event_date DESC LIMIT 10;"},
|
||||||
|
{"id": "my7", "label": "Active connections", "sql": "SHOW STATUS LIKE 'Threads_connected';"},
|
||||||
|
{"id": "my8", "label": "InnoDB buffer pool", "sql": "SHOW STATUS LIKE 'Innodb_buffer_pool%';"},
|
||||||
|
{"id": "my9", "label": "Explain count scan", "sql": "EXPLAIN SELECT COUNT(*) FROM employee_events;"},
|
||||||
|
{"id": "my10", "label": "Departments overview", "sql": "SELECT department, COUNT(*) AS employees FROM employees GROUP BY department ORDER BY employees DESC LIMIT 10;"},
|
||||||
|
],
|
||||||
|
"mongodb": [
|
||||||
|
{"id": "mg1", "label": "List databases", "sql": "SHOW DATABASES"},
|
||||||
|
{"id": "mg2", "label": "Supplychain collections", "sql": "SHOW COLLECTIONS supplychain"},
|
||||||
|
{"id": "mg3", "label": "Count events", "sql": "COUNT supplychain.events"},
|
||||||
|
{"id": "mg4", "label": "Sample document", "sql": "FIND supplychain.events LIMIT 3"},
|
||||||
|
{"id": "mg5", "label": "Events by type", "sql": "AGGREGATE supplychain.events GROUP type TOP 10"},
|
||||||
|
{"id": "mg6", "label": "Events by region", "sql": "AGGREGATE supplychain.events GROUP region TOP 10"},
|
||||||
|
{"id": "mg7", "label": "Distinct event types", "sql": "DISTINCT supplychain.events type"},
|
||||||
|
{"id": "mg8", "label": "Server status", "sql": "SERVER STATUS"},
|
||||||
|
{"id": "mg9", "label": "Indexes on events", "sql": "INDEXES supplychain.events"},
|
||||||
|
{"id": "mg10", "label": "Recent events", "sql": "FIND supplychain.events SORT timestamp DESC LIMIT 5"},
|
||||||
|
],
|
||||||
"trino": [
|
"trino": [
|
||||||
{"id": "tq1", "label": "Trino version", "sql": "SELECT version()"},
|
{"id": "tq1", "label": "Trino version", "sql": "SELECT version()"},
|
||||||
{"id": "tq2", "label": "Catalogs", "sql": "SHOW CATALOGS"},
|
{"id": "tq2", "label": "Catalogs", "sql": "SHOW CATALOGS"},
|
||||||
@@ -56,10 +92,22 @@ BENCHMARK_SQL = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _tabular(columns: list[str], rows: list[list[Any]], elapsed_ms: int, **extra: Any) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"columns": columns,
|
||||||
|
"rows": rows,
|
||||||
|
"row_count": len(rows),
|
||||||
|
"elapsed_ms": elapsed_ms,
|
||||||
|
"truncated": extra.pop("truncated", False),
|
||||||
|
**extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _run_postgres(sql: str, limit: int = 200) -> dict[str, Any]:
|
def _run_postgres(sql: str, limit: int = 200) -> dict[str, Any]:
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
conn = psycopg2.connect(
|
conn = psycopg2.connect(
|
||||||
host=PG_HOST, port=PG_PORT, user=PG_USER, password=PG_PASS, dbname=PG_DB, connect_timeout=8,
|
host=DB_HOST, port=PG_PORT, user=PG_USER, password=PG_PASS, dbname=PG_DB, connect_timeout=8,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
conn.set_session(readonly=True, autocommit=True)
|
conn.set_session(readonly=True, autocommit=True)
|
||||||
@@ -68,14 +116,112 @@ def _run_postgres(sql: str, limit: int = 200) -> dict[str, Any]:
|
|||||||
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||||||
if cur.description:
|
if cur.description:
|
||||||
columns = [d[0] for d in cur.description]
|
columns = [d[0] for d in cur.description]
|
||||||
rows = cur.fetchmany(limit)
|
rows = [list(r) for r in cur.fetchmany(limit)]
|
||||||
data = [list(r) for r in rows]
|
return _tabular(columns, rows, elapsed_ms, truncated=len(rows) >= limit)
|
||||||
return {"ok": True, "columns": columns, "rows": data, "row_count": len(data), "elapsed_ms": elapsed_ms, "truncated": len(data) >= limit}
|
return _tabular([], [], elapsed_ms, message="OK")
|
||||||
return {"ok": True, "columns": [], "rows": [], "row_count": 0, "elapsed_ms": elapsed_ms, "message": "OK"}
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_mysql(sql: str, limit: int = 200) -> dict[str, Any]:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host=DB_HOST, port=MYSQL_PORT, user=MYSQL_USER, password=MYSQL_PASS,
|
||||||
|
database=MYSQL_DB, connect_timeout=8, read_timeout=30,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(sql)
|
||||||
|
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)]
|
||||||
|
return _tabular(columns, rows, elapsed_ms, truncated=len(rows) >= limit)
|
||||||
|
return _tabular([], [], elapsed_ms, message="OK")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
client = _mongo_client()
|
||||||
|
try:
|
||||||
|
columns: list[str] = []
|
||||||
|
rows: list[list[Any]] = []
|
||||||
|
|
||||||
|
if q == "SHOW DATABASES":
|
||||||
|
columns = ["database"]
|
||||||
|
rows = [[name] for name in client.list_database_names()]
|
||||||
|
elif m := re.match(r"^SHOW COLLECTIONS(?:\s+(\w+))?$", q, re.I):
|
||||||
|
db_name = m.group(1) or MONGO_DB
|
||||||
|
columns = ["collection"]
|
||||||
|
rows = [[name] for name in client[db_name].list_collection_names()]
|
||||||
|
elif m := re.match(r"^COUNT\s+(\w+)\.(\w+)$", q, re.I):
|
||||||
|
db_name, coll = m.group(1), m.group(2)
|
||||||
|
columns = ["count"]
|
||||||
|
rows = [[client[db_name][coll].estimated_document_count()]]
|
||||||
|
elif m := re.match(r"^FIND\s+(\w+)\.(\w+)(?:\s+SORT\s+(\w+)\s+DESC)?(?:\s+LIMIT\s+(\d+))?$", q, re.I):
|
||||||
|
db_name, coll, sort_field, lim = m.group(1), m.group(2), m.group(3), m.group(4)
|
||||||
|
n = min(int(lim) if lim else 10, limit)
|
||||||
|
cursor = client[db_name][coll].find({})
|
||||||
|
if sort_field:
|
||||||
|
cursor = cursor.sort(sort_field, -1)
|
||||||
|
docs = list(cursor.limit(n))
|
||||||
|
if not docs:
|
||||||
|
columns = ["result"]
|
||||||
|
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]
|
||||||
|
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]
|
||||||
|
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 = [
|
||||||
|
{"$group": {"_id": f"${field}", "count": {"$sum": 1}}},
|
||||||
|
{"$sort": {"count": -1}},
|
||||||
|
{"$limit": min(top_n, limit)},
|
||||||
|
]
|
||||||
|
columns = [field, "count"]
|
||||||
|
rows = [[r.get("_id"), r.get("count")] for r in client[db_name][coll].aggregate(pipe, maxTimeMS=15000)]
|
||||||
|
elif m := re.match(r"^INDEXES\s+(\w+)\.(\w+)$", q, re.I):
|
||||||
|
db_name, coll = m.group(1), m.group(2)
|
||||||
|
columns = ["name", "keys"]
|
||||||
|
rows = [[i.get("name"), str(i.get("key"))] for i in client[db_name][coll].list_indexes()]
|
||||||
|
elif q.upper() == "SERVER STATUS":
|
||||||
|
status = client.admin.command("serverStatus")
|
||||||
|
columns = ["metric", "value"]
|
||||||
|
rows = [
|
||||||
|
["version", status.get("version")],
|
||||||
|
["uptime_secs", status.get("uptime")],
|
||||||
|
["connections_current", status.get("connections", {}).get("current")],
|
||||||
|
["mem_resident_mb", status.get("mem", {}).get("resident")],
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
return {"ok": False, "error": f"Unknown MongoDB command: {q[:120]}"}
|
||||||
|
|
||||||
|
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||||||
|
return _tabular(columns, rows[:limit], elapsed_ms, truncated=len(rows) > limit)
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
def _run_trino(sql: str, limit: int = 200) -> dict[str, Any]:
|
def _run_trino(sql: str, limit: int = 200) -> dict[str, Any]:
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
headers = {"X-Trino-User": TRINO_USER, "Content-Type": "text/plain"}
|
headers = {"X-Trino-User": TRINO_USER, "Content-Type": "text/plain"}
|
||||||
@@ -104,19 +250,14 @@ def _run_trino(sql: str, limit: int = 200) -> dict[str, Any]:
|
|||||||
data = client.get(nxt, headers=headers).json()
|
data = client.get(nxt, headers=headers).json()
|
||||||
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||||||
stats = data.get("stats") or {}
|
stats = data.get("stats") or {}
|
||||||
return {
|
return _tabular(columns, rows, elapsed_ms, engine_stats_ms=stats.get("elapsedTimeMillis"), truncated=len(rows) >= limit)
|
||||||
"ok": True,
|
|
||||||
"columns": columns,
|
|
||||||
"rows": rows,
|
ENGINES = ("postgres", "mysql", "mongodb", "trino")
|
||||||
"row_count": len(rows),
|
|
||||||
"elapsed_ms": elapsed_ms,
|
|
||||||
"engine_stats_ms": stats.get("elapsedTimeMillis"),
|
|
||||||
"truncated": len(rows) >= limit,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class SqlRequest(BaseModel):
|
class SqlRequest(BaseModel):
|
||||||
engine: str = Field(..., pattern="^(postgres|trino)$")
|
engine: str = Field(..., pattern="^(postgres|mysql|mongodb|trino)$")
|
||||||
sql: str = Field(..., min_length=1, max_length=8000)
|
sql: str = Field(..., min_length=1, max_length=8000)
|
||||||
|
|
||||||
|
|
||||||
@@ -124,51 +265,58 @@ class SqlRequest(BaseModel):
|
|||||||
async def get_samples(engine: str):
|
async def get_samples(engine: str):
|
||||||
if engine not in SAMPLES:
|
if engine not in SAMPLES:
|
||||||
return JSONResponse({"error": "unknown engine"}, status_code=404)
|
return JSONResponse({"error": "unknown engine"}, status_code=404)
|
||||||
return {
|
return {"engine": engine, "samples": SAMPLES[engine], "connection": _connection_info(engine)}
|
||||||
"engine": engine,
|
|
||||||
"samples": SAMPLES[engine],
|
|
||||||
"connection": _connection_info(engine),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health")
|
@router.get("/health")
|
||||||
async def sql_health():
|
async def sql_health():
|
||||||
pg_ok = trino_ok = False
|
out: dict[str, Any] = {}
|
||||||
pg_err = trino_err = None
|
for eng in ENGINES:
|
||||||
try:
|
try:
|
||||||
_run_postgres("SELECT 1")
|
if eng == "postgres":
|
||||||
pg_ok = True
|
_run_postgres("SELECT 1")
|
||||||
except Exception as exc:
|
out[eng] = {"ok": True, "host": DB_HOST, "user": PG_USER, "error": None}
|
||||||
pg_err = str(exc)[:200]
|
elif eng == "mysql":
|
||||||
try:
|
_run_mysql("SELECT 1")
|
||||||
r = _run_trino("SELECT 1")
|
out[eng] = {"ok": True, "host": DB_HOST, "database": MYSQL_DB, "user": MYSQL_USER, "error": None}
|
||||||
trino_ok = bool(r.get("ok"))
|
elif eng == "mongodb":
|
||||||
if not trino_ok:
|
_run_mongo("SHOW DATABASES")
|
||||||
trino_err = r.get("error")
|
out[eng] = {"ok": True, "host": MONGO_HOST, "database": MONGO_DB, "error": None}
|
||||||
except Exception as exc:
|
else:
|
||||||
trino_err = str(exc)[:200]
|
r = _run_trino("SELECT 1")
|
||||||
return {
|
out[eng] = {"ok": bool(r.get("ok")), "url": TRINO_URL, "user": TRINO_USER, "error": r.get("error")}
|
||||||
"postgres": {"ok": pg_ok, "host": PG_HOST, "user": PG_USER, "error": pg_err},
|
except Exception as exc:
|
||||||
"trino": {"ok": trino_ok, "url": TRINO_URL, "user": TRINO_USER, "error": trino_err},
|
out[eng] = {"ok": False, "error": str(exc)[:200]}
|
||||||
}
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _connection_info(engine: str) -> dict[str, str]:
|
def _connection_info(engine: str) -> dict[str, str]:
|
||||||
if engine == "postgres":
|
if engine == "postgres":
|
||||||
return {"host": PG_HOST, "port": str(PG_PORT), "database": PG_DB, "user": PG_USER}
|
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}
|
return {"url": TRINO_URL, "user": TRINO_USER}
|
||||||
|
|
||||||
|
|
||||||
|
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.post("/execute")
|
@router.post("/execute")
|
||||||
async def execute_sql(body: SqlRequest):
|
async def execute_sql(body: SqlRequest):
|
||||||
sql = body.sql.strip().rstrip(";")
|
sql = body.sql.strip().rstrip(";")
|
||||||
if not sql:
|
if not sql:
|
||||||
return JSONResponse({"ok": False, "error": "Empty query"}, status_code=400)
|
return JSONResponse({"ok": False, "error": "Empty query"}, status_code=400)
|
||||||
try:
|
try:
|
||||||
if body.engine == "postgres":
|
result = _dispatch(body.engine, sql)
|
||||||
result = _run_postgres(sql)
|
|
||||||
else:
|
|
||||||
result = _run_trino(sql)
|
|
||||||
if not result.get("ok"):
|
if not result.get("ok"):
|
||||||
return JSONResponse(result, status_code=422)
|
return JSONResponse(result, status_code=422)
|
||||||
return {**result, "engine": body.engine, "sql": sql}
|
return {**result, "engine": body.engine, "sql": sql}
|
||||||
@@ -182,21 +330,17 @@ async def benchmark():
|
|||||||
results: dict[str, Any] = {}
|
results: dict[str, Any] = {}
|
||||||
for engine, sql in BENCHMARK_SQL.items():
|
for engine, sql in BENCHMARK_SQL.items():
|
||||||
try:
|
try:
|
||||||
if engine == "postgres":
|
results[engine] = _dispatch(engine, sql)
|
||||||
results[engine] = _run_postgres(sql)
|
|
||||||
else:
|
|
||||||
results[engine] = _run_trino(sql)
|
|
||||||
results[engine]["sql"] = sql
|
results[engine]["sql"] = sql
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
results[engine] = {"ok": False, "error": str(exc)[:300], "sql": sql}
|
results[engine] = {"ok": False, "error": str(exc)[:300], "sql": sql}
|
||||||
pg_ms = results.get("postgres", {}).get("elapsed_ms")
|
pg_ms = results.get("postgres", {}).get("elapsed_ms")
|
||||||
tr_ms = results.get("trino", {}).get("elapsed_ms")
|
tr_ms = results.get("trino", {}).get("elapsed_ms")
|
||||||
faster = None
|
faster = None
|
||||||
|
speedup = None
|
||||||
if pg_ms and tr_ms:
|
if pg_ms and tr_ms:
|
||||||
faster = "trino" if tr_ms < pg_ms else "postgres"
|
faster = "trino" if tr_ms < pg_ms else "postgres"
|
||||||
speedup = round(pg_ms / tr_ms, 2) if tr_ms and tr_ms < pg_ms else round(tr_ms / pg_ms, 2) if pg_ms else None
|
speedup = round(pg_ms / tr_ms, 2) if tr_ms < pg_ms else round(tr_ms / pg_ms, 2)
|
||||||
else:
|
|
||||||
speedup = None
|
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"postgres": results.get("postgres"),
|
"postgres": results.get("postgres"),
|
||||||
|
|||||||
+26
-14
@@ -1,4 +1,5 @@
|
|||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
|
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
import { useClock } from './hooks/useClock'
|
import { useClock } from './hooks/useClock'
|
||||||
import { useCommandCenter } from './hooks/useCommandCenter'
|
import { useCommandCenter } from './hooks/useCommandCenter'
|
||||||
import { useLiveMetrics } from './hooks/useLiveMetrics'
|
import { useLiveMetrics } from './hooks/useLiveMetrics'
|
||||||
@@ -25,6 +26,7 @@ export default function App() {
|
|||||||
const clock = useClock()
|
const clock = useClock()
|
||||||
const cc = useCommandCenter()
|
const cc = useCommandCenter()
|
||||||
const [gpuChatActive, setGpuChatActive] = useState(false)
|
const [gpuChatActive, setGpuChatActive] = useState(false)
|
||||||
|
const [infraOpen, setInfraOpen] = useState(false)
|
||||||
const gpuBoost = gpuChatActive || cc.mainView === 'knowledge'
|
const gpuBoost = gpuChatActive || cc.mainView === 'knowledge'
|
||||||
const { agentLoads, gpuLive } = useLiveMetrics(cc.agents, cc.gpu, cc.anims, gpuBoost)
|
const { agentLoads, gpuLive } = useLiveMetrics(cc.agents, cc.gpu, cc.anims, gpuBoost)
|
||||||
const mainScrollRef = useRef<HTMLDivElement>(null)
|
const mainScrollRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -74,9 +76,9 @@ export default function App() {
|
|||||||
onSelectZone={cc.selectNode}
|
onSelectZone={cc.selectNode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div ref={mainScrollRef} className="scrollbar-thin flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto bg-surface">
|
<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 className={cn('flex min-h-0 flex-1', isPlatform ? '' : 'flex-col')}>
|
<div className={cn('flex min-h-0 flex-1', isPlatform ? '' : 'flex-col')}>
|
||||||
<div className={cn('flex min-w-0 flex-1 flex-col gap-2', isPlatform ? 'p-2' : 'min-h-0 p-3')}>
|
<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')}>
|
||||||
{isPlatform && (
|
{isPlatform && (
|
||||||
<>
|
<>
|
||||||
<div className="grid shrink-0 grid-cols-1 gap-2 xl:grid-cols-[1fr_auto]">
|
<div className="grid shrink-0 grid-cols-1 gap-2 xl:grid-cols-[1fr_auto]">
|
||||||
@@ -91,20 +93,30 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
<GpuMonitor gpu={cc.gpu} live={gpuLive} />
|
<GpuMonitor gpu={cc.gpu} live={gpuLive} />
|
||||||
</div>
|
</div>
|
||||||
<InfraQuickAccess
|
<button
|
||||||
workload={cc.workload}
|
type="button"
|
||||||
agents={cc.agents}
|
onClick={() => setInfraOpen((v) => !v)}
|
||||||
selectedNodeId={cc.selectedNodeId}
|
className="flex shrink-0 items-center gap-1 self-start rounded border border-border/60 px-2 py-0.5 text-[9px] text-foreground-muted hover:border-docker/30 hover:text-docker"
|
||||||
busy={cc.nodeBusy}
|
>
|
||||||
onSelectNode={cc.selectNode}
|
{infraOpen ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||||
onSelectAgent={cc.selectAgent}
|
Infrastructure & Apps
|
||||||
onProbe={cc.probeNodeId}
|
</button>
|
||||||
onOpenTerminal={cc.openTerminal}
|
{infraOpen && (
|
||||||
/>
|
<InfraQuickAccess
|
||||||
|
workload={cc.workload}
|
||||||
|
agents={cc.agents}
|
||||||
|
selectedNodeId={cc.selectedNodeId}
|
||||||
|
busy={cc.nodeBusy}
|
||||||
|
onSelectNode={cc.selectNode}
|
||||||
|
onSelectAgent={cc.selectAgent}
|
||||||
|
onProbe={cc.probeNodeId}
|
||||||
|
onOpenTerminal={cc.openTerminal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={cn('flex min-h-0 flex-col', isPlatform ? 'min-h-[300px] flex-1' : 'min-h-0 flex-1')}>
|
<div className={cn('flex min-h-0 flex-col', isPlatform ? 'min-h-0 flex-1 overflow-hidden' : 'min-h-0 flex-1')}>
|
||||||
{cc.mainView === 'platform' ? (
|
{cc.mainView === 'platform' ? (
|
||||||
<PlatformTopology
|
<PlatformTopology
|
||||||
workload={cc.workload}
|
workload={cc.workload}
|
||||||
@@ -139,7 +151,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isPlatform && (
|
{isPlatform && (
|
||||||
<div className="flex w-[340px] shrink-0 flex-col border-l border-border">
|
<div className="flex w-[280px] shrink-0 flex-col border-l border-border">
|
||||||
<InspectorPanel
|
<InspectorPanel
|
||||||
node={cc.selectedNode}
|
node={cc.selectedNode}
|
||||||
nodeDetail={cc.nodeDetail}
|
nodeDetail={cc.nodeDetail}
|
||||||
|
|||||||
@@ -36,16 +36,16 @@ function AgentCard({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-[136px] w-[168px] shrink-0 flex-col gap-1 rounded-lg border bg-surface-raised p-1.5 text-left shadow-sm dark:bg-surface-overlay',
|
'flex h-[112px] w-[142px] shrink-0 flex-col gap-1 rounded-lg border bg-surface-raised p-1.5 text-left shadow-sm dark:bg-surface-overlay',
|
||||||
selected ? 'border-docker ring-2 ring-docker/30 shadow-[0_0_20px_rgba(13,183,237,0.15)]' : 'border-border hover:border-docker/30',
|
selected ? 'border-docker ring-2 ring-docker/30 shadow-[0_0_20px_rgba(13,183,237,0.15)]' : 'border-border hover:border-docker/30',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-surface" style={{ color: meta.accent }}>
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-surface" style={{ color: meta.accent }}>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-3.5 w-3.5" />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-[12px] font-semibold text-foreground">{agent.name.split(' ·')[0]}</p>
|
<p className="truncate text-[11px] font-semibold text-foreground">{agent.name.split(' ·')[0]}</p>
|
||||||
<p className="truncate text-[8px] text-foreground-muted">{meta.domain}</p>
|
<p className="truncate text-[8px] text-foreground-muted">{meta.domain}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', busy ? 'bg-success animate-pulse' : 'bg-foreground-faint/30')} />
|
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', busy ? 'bg-success animate-pulse' : 'bg-foreground-faint/30')} />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type Props = {
|
|||||||
lines: TerminalLine[]
|
lines: TerminalLine[]
|
||||||
busy: boolean
|
busy: boolean
|
||||||
onSendPrompt: (message: string, agentId?: string) => void
|
onSendPrompt: (message: string, agentId?: string) => void
|
||||||
|
compact?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEVEL: Record<string, string> = {
|
const LEVEL: Record<string, string> = {
|
||||||
@@ -23,7 +24,7 @@ const LEVEL: Record<string, string> = {
|
|||||||
probe: 'text-amber-300',
|
probe: 'text-amber-300',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentWorkbench({ agent, lines, busy, onSendPrompt }: Props) {
|
export function AgentWorkbench({ agent, lines, busy, onSendPrompt, compact }: Props) {
|
||||||
const meta = getAgentMeta(agent.id)
|
const meta = getAgentMeta(agent.id)
|
||||||
const Icon = meta.icon
|
const Icon = meta.icon
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -33,49 +34,44 @@ export function AgentWorkbench({ agent, lines, busy, onSendPrompt }: Props) {
|
|||||||
}, [lines, busy])
|
}, [lines, busy])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col bg-[#0a0e14]">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
<header className="flex shrink-0 items-center gap-3 border-b border-border/60 px-4 py-3">
|
<header className={cn('flex shrink-0 items-center gap-2 border-b border-border/60 px-2', compact ? 'py-1' : 'py-3')}>
|
||||||
<span
|
<span
|
||||||
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border border-border/80 bg-surface-overlay"
|
className={cn('flex shrink-0 items-center justify-center rounded-lg border border-border/80 bg-surface-overlay', compact ? 'h-7 w-7' : 'h-10 w-10')}
|
||||||
style={{ color: meta.accent, boxShadow: `0 0 24px ${meta.accent}33` }}
|
style={{ color: meta.accent, boxShadow: compact ? undefined : `0 0 24px ${meta.accent}33` }}
|
||||||
>
|
>
|
||||||
<Icon className="h-6 w-6" />
|
<Icon className={compact ? 'h-3.5 w-3.5' : 'h-5 w-5'} />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="truncate text-base font-bold text-foreground">{agent.name}</h3>
|
<h3 className={cn('truncate font-bold text-foreground', compact ? 'text-xs' : 'text-sm')}>{agent.name}</h3>
|
||||||
<p className="text-[11px] text-docker">{meta.domain} · {agent.zone}</p>
|
{!compact && <p className="text-[10px] text-docker">{meta.domain} · {agent.zone}</p>}
|
||||||
<p className="truncate text-[10px] text-foreground-muted">{agent.role}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
{busy && (
|
{busy && <Loader2 className="h-3 w-3 animate-spin text-success" />}
|
||||||
<span className="flex items-center gap-1 text-[10px] text-success">
|
<span className="flex items-center gap-1 rounded border border-emerald-500/40 bg-emerald-500/10 px-1.5 py-0.5 text-[9px] text-emerald-300">
|
||||||
<Loader2 className="h-3 w-3 animate-spin" /> Live
|
<Terminal className="h-3 w-3" /> Terminal
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="flex items-center gap-1 rounded border border-emerald-500/40 bg-emerald-500/10 px-2 py-1 text-[10px] text-emerald-300">
|
|
||||||
<Terminal className="h-3 w-3" /> Agent terminal
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex shrink-0 flex-wrap gap-1 border-b border-border/40 px-3 py-2">
|
{!compact && (
|
||||||
{(agent.suggested_prompts || []).slice(0, 6).map((prompt) => (
|
<div className="flex shrink-0 flex-wrap gap-1 border-b border-border/40 px-2 py-1">
|
||||||
<button
|
{(agent.suggested_prompts || []).slice(0, 4).map((prompt) => (
|
||||||
key={prompt}
|
<button
|
||||||
type="button"
|
key={prompt}
|
||||||
onClick={() => onSendPrompt(prompt, agent.id)}
|
type="button"
|
||||||
className={cn('rounded border border-border/60 px-2 py-1 text-[10px] text-foreground-muted hover:border-docker/40 hover:text-docker', subTabIdle)}
|
onClick={() => onSendPrompt(prompt, agent.id)}
|
||||||
>
|
className={cn('rounded border border-border/60 px-1.5 py-0.5 text-[9px] text-foreground-muted hover:border-docker/40 hover:text-docker', subTabIdle)}
|
||||||
{prompt}
|
>
|
||||||
</button>
|
{prompt}
|
||||||
))}
|
</button>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto px-4 py-2 font-mono text-[11px] leading-relaxed">
|
<div className={cn('scrollbar-thin min-h-0 flex-1 overflow-y-auto font-mono leading-relaxed', compact ? 'px-2 py-1 text-[10px]' : 'px-3 py-2 text-[11px]')}>
|
||||||
{lines.length === 0 && (
|
{lines.length === 0 && (
|
||||||
<p className="py-8 text-center text-foreground-faint">
|
<p className="py-2 text-center text-foreground-faint">Agent terminal ready</p>
|
||||||
Agent terminal ready — probe output and LLM responses appear here
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
{lines.map((line) => (
|
{lines.map((line) => (
|
||||||
<div key={line.id} className={LEVEL[line.level] || 'text-foreground-muted'}>
|
<div key={line.id} className={LEVEL[line.level] || 'text-foreground-muted'}>
|
||||||
|
|||||||
@@ -153,13 +153,17 @@ STAGES.forEach((stage, col) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function nodeCoords(col: number, row: number, rows: number) {
|
function nodeCoords(col: number, row: number, rows: number) {
|
||||||
const colW = 100 / 5
|
const colCount = 5
|
||||||
const yPad = 8
|
const gap = 3.2
|
||||||
const ySpan = 84
|
const colW = (100 - gap * (colCount - 1)) / colCount
|
||||||
|
const xCenter = col * (colW + gap) + colW / 2
|
||||||
|
const nodeHalf = colW * 0.34
|
||||||
|
const yPad = 5
|
||||||
|
const ySpan = 90
|
||||||
const y = yPad + ((row + 0.5) / rows) * ySpan
|
const y = yPad + ((row + 0.5) / rows) * ySpan
|
||||||
return {
|
return {
|
||||||
inX: col * colW + colW * 0.08,
|
inX: xCenter - nodeHalf,
|
||||||
outX: col * colW + colW * 0.92,
|
outX: xCenter + nodeHalf,
|
||||||
y,
|
y,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,7 +283,7 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC
|
|||||||
return (
|
return (
|
||||||
<section className="panel flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
<section className="panel flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<header
|
<header
|
||||||
className="flex shrink-0 flex-col gap-1 border-b border-border px-3 py-1.5"
|
className="flex shrink-0 flex-col gap-0.5 border-b border-border px-2 py-1"
|
||||||
style={{ background: 'var(--topo-header-bg)' }}
|
style={{ background: 'var(--topo-header-bg)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
@@ -290,7 +294,7 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="truncate text-xs font-semibold text-foreground">Data Platform Topology</h2>
|
<h2 className="truncate text-xs font-semibold text-foreground">Data Platform Topology</h2>
|
||||||
<p className="truncate text-[9px] text-foreground-muted">
|
<p className="truncate text-[9px] text-foreground-muted">
|
||||||
Click PostgreSQL or Trino → live SQL console · agents → terminal below
|
Click PostgreSQL / MySQL / MongoDB / Trino → live console · agents → terminal below
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -361,7 +365,7 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC
|
|||||||
})}
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<div className="relative z-10 flex h-full min-h-0 w-full overflow-x-auto">
|
<div className="relative z-10 flex h-full min-h-0 w-full">
|
||||||
{STAGES.map((stage) => (
|
{STAGES.map((stage) => (
|
||||||
<div key={stage.id} className={cn('topo-stage-col', stage.accent)}>
|
<div key={stage.id} className={cn('topo-stage-col', stage.accent)}>
|
||||||
<header className="mb-1 shrink-0 border-b border-white/10 pb-1">
|
<header className="mb-1 shrink-0 border-b border-white/10 pb-1">
|
||||||
|
|||||||
@@ -14,32 +14,47 @@ type SqlResult = {
|
|||||||
sql?: string
|
sql?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Engine = 'postgres' | 'mysql' | 'mongodb' | 'trino'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
engine: 'postgres' | 'trino'
|
engine: Engine
|
||||||
|
compact?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const ENGINE_META = {
|
const ENGINE_META: Record<Engine, { title: string; sub: string; accent: string; queryLabel: string }> = {
|
||||||
postgres: {
|
postgres: {
|
||||||
title: 'PostgreSQL SQL Console',
|
title: 'PostgreSQL SQL Console',
|
||||||
sub: 'DB Vault · 10.0.21.51:5432 · user mo',
|
sub: 'DB Vault · 10.0.21.51:5432 · user mo',
|
||||||
accent: 'text-blue-400',
|
accent: 'text-blue-400',
|
||||||
|
queryLabel: 'SQL',
|
||||||
|
},
|
||||||
|
mysql: {
|
||||||
|
title: 'MySQL SQL Console',
|
||||||
|
sub: 'DB Vault · 10.0.21.51:3306 · database hr · user mo',
|
||||||
|
accent: 'text-amber-400',
|
||||||
|
queryLabel: 'SQL',
|
||||||
|
},
|
||||||
|
mongodb: {
|
||||||
|
title: 'MongoDB Query Console',
|
||||||
|
sub: 'DB Vault · 10.0.21.51:27017 · supplychain',
|
||||||
|
accent: 'text-emerald-400',
|
||||||
|
queryLabel: 'Command',
|
||||||
},
|
},
|
||||||
trino: {
|
trino: {
|
||||||
title: 'Trino SQL Console',
|
title: 'Trino SQL Console',
|
||||||
sub: 'Lakehouse · 10.0.21.50:8089 · federated lakehouse queries',
|
sub: 'Lakehouse · 10.0.21.50:8089 · federated queries',
|
||||||
accent: 'text-violet-400',
|
accent: 'text-violet-400',
|
||||||
|
queryLabel: 'SQL',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SqlWorkbench({ engine }: Props) {
|
export function SqlWorkbench({ engine, compact }: Props) {
|
||||||
const meta = ENGINE_META[engine]
|
const meta = ENGINE_META[engine]
|
||||||
const [samples, setSamples] = useState<Sample[]>([])
|
const [samples, setSamples] = useState<Sample[]>([])
|
||||||
const [sql, setSql] = useState('SELECT version();')
|
const [sql, setSql] = useState('SELECT version();')
|
||||||
const [result, setResult] = useState<SqlResult | null>(null)
|
const [result, setResult] = useState<SqlResult | null>(null)
|
||||||
const [benchmark, setBenchmark] = useState<{
|
const [benchmark, setBenchmark] = useState<{
|
||||||
comparison?: { postgres_ms?: number; trino_ms?: number; faster?: string; speedup_factor?: number }
|
comparison?: { postgres_ms?: number; trino_ms?: number; faster?: string; speedup_factor?: number }
|
||||||
postgres?: SqlResult
|
|
||||||
trino?: SqlResult
|
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [benchLoading, setBenchLoading] = useState(false)
|
const [benchLoading, setBenchLoading] = useState(false)
|
||||||
@@ -106,40 +121,41 @@ export function SqlWorkbench({ engine }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col bg-[#0a0e14] text-foreground">
|
<div className="flex h-full min-h-0 flex-col text-foreground">
|
||||||
<header className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border/60 px-3 py-2">
|
<header className={cn('flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border/60', compact ? 'px-2 py-1' : 'px-3 py-2')}>
|
||||||
<div>
|
<div>
|
||||||
<h3 className={cn('flex items-center gap-2 text-sm font-semibold', meta.accent)}>
|
<h3 className={cn('flex items-center gap-1.5 font-semibold', meta.accent, compact ? 'text-xs' : 'text-sm')}>
|
||||||
<Database className="h-4 w-4" />
|
<Database className={compact ? 'h-3 w-3' : 'h-4 w-4'} />
|
||||||
{meta.title}
|
{meta.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[10px] text-foreground-muted">{meta.sub}</p>
|
<p className="text-[9px] text-foreground-muted">{meta.sub}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-1.5">
|
||||||
<button type="button" onClick={() => run()} disabled={loading} className={cn('inline-flex items-center gap-1 rounded px-3 py-1.5 text-[11px]', subTabActive)}>
|
<button type="button" onClick={() => run()} disabled={loading} className={cn('inline-flex items-center gap-1 rounded px-2 py-1 text-[10px]', subTabActive)}>
|
||||||
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <Play className="h-3 w-3" />}
|
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <Play className="h-3 w-3" />}
|
||||||
Run
|
Run
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={runBenchmark} disabled={benchLoading} className={cn('inline-flex items-center gap-1 rounded px-3 py-1.5 text-[11px]', subTabIdle)}>
|
{(engine === 'postgres' || engine === 'trino') && (
|
||||||
{benchLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <Zap className="h-3 w-3 text-amber-400" />}
|
<button type="button" onClick={runBenchmark} disabled={benchLoading} className={cn('inline-flex items-center gap-1 rounded px-2 py-1 text-[10px]', subTabIdle)}>
|
||||||
Postgres vs Trino
|
{benchLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <Zap className="h-3 w-3 text-amber-400" />}
|
||||||
</button>
|
PG vs Trino
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
|
<div className="flex min-h-0 flex-1">
|
||||||
<aside className="shrink-0 border-b border-border/60 p-2 lg:w-56 lg:border-b-0 lg:border-r">
|
<aside className={cn('shrink-0 border-r border-border/60 p-1.5', compact ? 'w-44' : 'w-52')}>
|
||||||
<p className="mb-1 text-[9px] 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">10 demo commands</p>
|
||||||
<div className="scrollbar-thin max-h-32 space-y-0.5 overflow-y-auto lg:max-h-none">
|
<div className="scrollbar-thin max-h-full space-y-0.5 overflow-y-auto">
|
||||||
{samples.map((s) => (
|
{samples.map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s.id}
|
key={s.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setSql(s.sql); run(s.sql) }}
|
onClick={() => { setSql(s.sql); run(s.sql) }}
|
||||||
className="block w-full rounded border border-transparent px-2 py-1 text-left text-[10px] hover:border-docker/30 hover:bg-docker/5"
|
className="block w-full rounded border border-transparent px-1.5 py-0.5 text-left text-[9px] hover:border-docker/30 hover:bg-docker/5"
|
||||||
>
|
>
|
||||||
<span className="font-medium text-foreground">{s.label}</span>
|
<span className="font-medium text-foreground">{s.label}</span>
|
||||||
<span className="mt-0.5 block truncate font-mono text-[8px] text-foreground-faint">{s.sql}</span>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -149,42 +165,45 @@ export function SqlWorkbench({ engine }: Props) {
|
|||||||
<textarea
|
<textarea
|
||||||
value={sql}
|
value={sql}
|
||||||
onChange={(e) => setSql(e.target.value)}
|
onChange={(e) => setSql(e.target.value)}
|
||||||
className="min-h-[72px] shrink-0 resize-none border-b border-border/60 bg-black/40 p-2 font-mono text-[11px] text-emerald-100 outline-none focus:ring-1 focus:ring-docker/40"
|
className={cn(
|
||||||
|
'shrink-0 resize-none border-b border-border/60 bg-black/40 p-1.5 font-mono text-[10px] text-emerald-100 outline-none focus:ring-1 focus:ring-docker/40',
|
||||||
|
compact ? 'min-h-[40px]' : 'min-h-[56px]',
|
||||||
|
)}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
|
placeholder={`${meta.queryLabel}…`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && <p className="shrink-0 px-3 py-1 text-[11px] text-danger">{error}</p>}
|
{error && <p className="shrink-0 px-2 py-0.5 text-[10px] text-danger">{error}</p>}
|
||||||
|
|
||||||
{benchmark?.comparison && (
|
{benchmark?.comparison && (
|
||||||
<div className="shrink-0 border-b border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px]">
|
<div className="shrink-0 border-b border-amber-500/30 bg-amber-500/10 px-2 py-1 text-[10px]">
|
||||||
<span className="font-semibold text-amber-300">Benchmark (5M parallel compute): </span>
|
<span className="font-semibold text-amber-300">Benchmark: </span>
|
||||||
PostgreSQL <span className="font-mono">{benchmark.comparison.postgres_ms}ms</span>
|
PG <span className="font-mono">{benchmark.comparison.postgres_ms}ms</span>
|
||||||
{' · '}
|
{' · '}Trino <span className="font-mono">{benchmark.comparison.trino_ms}ms</span>
|
||||||
Trino <span className="font-mono">{benchmark.comparison.trino_ms}ms</span>
|
|
||||||
{' — '}
|
{' — '}
|
||||||
<span className="font-semibold text-success">
|
<span className="font-semibold text-success">
|
||||||
{benchmark.comparison.faster} {benchmark.comparison.speedup_factor ? `${benchmark.comparison.speedup_factor}× faster` : ''}
|
{benchmark.comparison.faster} {benchmark.comparison.speedup_factor ? `${benchmark.comparison.speedup_factor}×` : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="scrollbar-thin min-h-0 flex-1 overflow-auto p-2">
|
<div className="scrollbar-thin min-h-0 flex-1 overflow-auto p-1.5">
|
||||||
{result?.ok && result.columns && (
|
{result?.ok && result.columns && (
|
||||||
<>
|
<>
|
||||||
<p className="mb-1 font-mono text-[9px] text-foreground-faint">
|
<p className="mb-0.5 font-mono text-[8px] text-foreground-faint">
|
||||||
{result.row_count} rows · {result.elapsed_ms}ms
|
{result.row_count} rows · {result.elapsed_ms}ms
|
||||||
</p>
|
</p>
|
||||||
<table className="w-full text-left font-mono text-[10px]">
|
<table className="w-full text-left font-mono text-[9px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border text-docker">
|
<tr className="border-b border-border text-docker">
|
||||||
{result.columns.map((c) => <th key={c} className="px-2 py-1">{c}</th>)}
|
{result.columns.map((c) => <th key={c} className="px-1 py-0.5">{c}</th>)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{result.rows?.map((row, i) => (
|
{result.rows?.map((row, i) => (
|
||||||
<tr key={i} className="border-b border-border/30 hover:bg-white/5">
|
<tr key={i} className="border-b border-border/30 hover:bg-white/5">
|
||||||
{row.map((cell, j) => (
|
{row.map((cell, j) => (
|
||||||
<td key={j} className="max-w-[200px] truncate px-2 py-1 text-foreground-muted">
|
<td key={j} className="max-w-[160px] truncate px-1 py-0.5 text-foreground-muted">
|
||||||
{cell === null ? 'NULL' : String(cell)}
|
{cell === null ? 'NULL' : String(cell)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
@@ -195,7 +214,7 @@ export function SqlWorkbench({ engine }: Props) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!result && !loading && (
|
{!result && !loading && (
|
||||||
<p className="py-6 text-center text-[11px] text-foreground-faint">Pick a command or write SQL · click Run</p>
|
<p className="py-3 text-center text-[10px] text-foreground-faint">Pick a command or write {meta.queryLabel}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { AgentWorkbench } from './AgentWorkbench'
|
|||||||
import { SqlWorkbench } from './SqlWorkbench'
|
import { SqlWorkbench } from './SqlWorkbench'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mode: 'agent' | 'sql-postgres' | 'sql-trino' | null
|
mode: 'agent' | 'sql-postgres' | 'sql-mysql' | 'sql-mongodb' | 'sql-trino' | null
|
||||||
agent: Agent | null
|
agent: Agent | null
|
||||||
lines: TerminalLine[]
|
lines: TerminalLine[]
|
||||||
busy: boolean
|
busy: boolean
|
||||||
@@ -14,12 +14,14 @@ export function WorkbenchPanel({ mode, agent, lines, busy, onSendPrompt }: Props
|
|||||||
if (!mode) return null
|
if (!mode) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex h-[min(42vh,380px)] min-h-[260px] shrink-0 flex-col border-t-2 border-docker/30 shadow-[0_-8px_32px_rgba(0,0,0,0.35)]">
|
<section className="flex h-[190px] max-h-[26vh] shrink-0 flex-col border-t border-docker/25 bg-[#0a0e14] shadow-[0_-4px_20px_rgba(0,0,0,0.25)]">
|
||||||
{mode === 'agent' && agent && (
|
{mode === 'agent' && agent && (
|
||||||
<AgentWorkbench agent={agent} lines={lines} busy={busy} onSendPrompt={onSendPrompt} />
|
<AgentWorkbench agent={agent} lines={lines} busy={busy} onSendPrompt={onSendPrompt} compact />
|
||||||
)}
|
)}
|
||||||
{mode === 'sql-postgres' && <SqlWorkbench engine="postgres" />}
|
{mode === 'sql-postgres' && <SqlWorkbench engine="postgres" compact />}
|
||||||
{mode === 'sql-trino' && <SqlWorkbench engine="trino" />}
|
{mode === 'sql-mysql' && <SqlWorkbench engine="mysql" compact />}
|
||||||
|
{mode === 'sql-mongodb' && <SqlWorkbench engine="mongodb" compact />}
|
||||||
|
{mode === 'sql-trino' && <SqlWorkbench engine="trino" compact />}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,14 +36,18 @@ function resolveProbeId(nodeId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const SQL_NODE_ENGINES: Record<string, 'postgres' | 'trino'> = {
|
const SQL_NODE_ENGINES: Record<string, 'postgres' | 'mysql' | 'mongodb' | 'trino'> = {
|
||||||
postgresql: 'postgres',
|
postgresql: 'postgres',
|
||||||
'src-postgres': 'postgres',
|
'src-postgres': 'postgres',
|
||||||
|
mysql: 'mysql',
|
||||||
|
'src-mysql': 'mysql',
|
||||||
|
mongodb: 'mongodb',
|
||||||
|
'src-mongo': 'mongodb',
|
||||||
trino: 'trino',
|
trino: 'trino',
|
||||||
'query-trino': 'trino',
|
'query-trino': 'trino',
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSqlEngine(nodeId: string): 'postgres' | 'trino' | null {
|
function resolveSqlEngine(nodeId: string): 'postgres' | 'mysql' | 'mongodb' | 'trino' | null {
|
||||||
return SQL_NODE_ENGINES[nodeId] || SQL_NODE_ENGINES[resolveProbeId(nodeId)] || null
|
return SQL_NODE_ENGINES[nodeId] || SQL_NODE_ENGINES[resolveProbeId(nodeId)] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +70,7 @@ export function useCommandCenter() {
|
|||||||
const [nodeBusy, setNodeBusy] = useState(false)
|
const [nodeBusy, setNodeBusy] = useState(false)
|
||||||
const [mainView, setMainView] = useState<'platform' | 'approvals' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'search'>('platform')
|
const [mainView, setMainView] = useState<'platform' | 'approvals' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'search'>('platform')
|
||||||
const [approvalHighlight, setApprovalHighlight] = useState(false)
|
const [approvalHighlight, setApprovalHighlight] = useState(false)
|
||||||
const [workbenchMode, setWorkbenchMode] = useState<'agent' | 'sql-postgres' | 'sql-trino' | null>(null)
|
const [workbenchMode, setWorkbenchMode] = useState<'agent' | 'sql-postgres' | 'sql-mysql' | 'sql-mongodb' | 'sql-trino' | null>(null)
|
||||||
const [chatExpanded, setChatExpanded] = useState(false)
|
const [chatExpanded, setChatExpanded] = useState(false)
|
||||||
const promptTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const promptTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const [terminalExpanded, setTerminalExpanded] = useState(true)
|
const [terminalExpanded, setTerminalExpanded] = useState(true)
|
||||||
@@ -225,7 +229,7 @@ export function useCommandCenter() {
|
|||||||
const probeId = resolveProbeId(nodeId)
|
const probeId = resolveProbeId(nodeId)
|
||||||
const sqlEng = resolveSqlEngine(nodeId)
|
const sqlEng = resolveSqlEngine(nodeId)
|
||||||
if (sqlEng) {
|
if (sqlEng) {
|
||||||
setWorkbenchMode(sqlEng === 'postgres' ? 'sql-postgres' : 'sql-trino')
|
setWorkbenchMode(`sql-${sqlEng}` as 'sql-postgres' | 'sql-mysql' | 'sql-mongodb' | 'sql-trino')
|
||||||
setSelectedAgentId(null)
|
setSelectedAgentId(null)
|
||||||
} else if (options?.keepWorkbench) {
|
} else if (options?.keepWorkbench) {
|
||||||
setWorkbenchMode(options.keepWorkbench)
|
setWorkbenchMode(options.keepWorkbench)
|
||||||
|
|||||||
@@ -87,14 +87,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.topo-edge-idle {
|
.topo-edge-idle {
|
||||||
stroke: rgba(100, 140, 180, 0.35);
|
stroke: rgba(100, 140, 180, 0.45);
|
||||||
stroke-width: 1.5;
|
stroke-width: 2;
|
||||||
stroke-dasharray: 4 8;
|
stroke-dasharray: 4 8;
|
||||||
fill: none;
|
fill: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-edge-live {
|
.topo-edge-live {
|
||||||
stroke-width: 2;
|
stroke-width: 2.5;
|
||||||
stroke-dasharray: 8 12;
|
stroke-dasharray: 8 12;
|
||||||
fill: none;
|
fill: none;
|
||||||
animation: flow-dash 1.2s linear infinite;
|
animation: flow-dash 1.2s linear infinite;
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.topo-node {
|
.topo-node {
|
||||||
@apply w-full rounded-md border px-2 py-1.5 text-left transition-all;
|
@apply mx-auto w-[84%] rounded-md border px-1.5 py-1 text-left transition-all;
|
||||||
background: var(--topo-node-bg);
|
background: var(--topo-node-bg);
|
||||||
border-color: var(--topo-node-border);
|
border-color: var(--topo-node-border);
|
||||||
box-shadow: var(--topo-node-shadow);
|
box-shadow: var(--topo-node-shadow);
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.topo-stage-col {
|
.topo-stage-col {
|
||||||
@apply flex min-w-[130px] flex-1 flex-col px-1.5 py-2 last:border-r-0;
|
@apply flex min-w-0 flex-1 flex-col px-0.5 py-1 last:border-r-0;
|
||||||
border-right: 1px dashed var(--topo-stage-border);
|
border-right: 1px dashed var(--topo-stage-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user