Add agent workbench terminal and live PostgreSQL/Trino SQL consoles.
Clicking agents opens a dedicated terminal panel; topology PostgreSQL/Trino nodes open SQL workbench with ten demo queries and Postgres vs Trino benchmark.
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@ WORKDIR /app
|
|||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY main.py lab_context.py agent_terminal.py workload.py node_registry.py node_ops.py topology_views.py supervisor.py approval_service.py db.py dockhand_envs.py presentation.py database_inventory.py presentation_upload.py presentation_static.py storage_s3.py elasticsearch_api.py .
|
COPY main.py lab_context.py agent_terminal.py workload.py node_registry.py node_ops.py topology_views.py supervisor.py approval_service.py db.py dockhand_envs.py presentation.py database_inventory.py presentation_upload.py presentation_static.py storage_s3.py elasticsearch_api.py sql_console.py .
|
||||||
RUN mkdir -p /data
|
RUN mkdir -p /data
|
||||||
ENV DATABASE_URL=sqlite:////data/atc-agents.db
|
ENV DATABASE_URL=sqlite:////data/atc-agents.db
|
||||||
EXPOSE 3201
|
EXPOSE 3201
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from presentation_upload import get_deck, list_decks, save_upload
|
|||||||
from presentation_static import get_static_deck, list_static_decks
|
from presentation_static import get_static_deck, list_static_decks
|
||||||
from storage_s3 import router as storage_s3_router
|
from storage_s3 import router as storage_s3_router
|
||||||
from elasticsearch_api import router as elasticsearch_router
|
from elasticsearch_api import router as elasticsearch_router
|
||||||
|
from sql_console import router as sql_router
|
||||||
from node_registry import NODE_IDS, NODE_AGENT, NODE_REGISTRY, is_node_id
|
from node_registry import NODE_IDS, NODE_AGENT, NODE_REGISTRY, is_node_id
|
||||||
from node_ops import build_node_detail, probe_node, run_node_probe_task
|
from node_ops import build_node_detail, probe_node, run_node_probe_task
|
||||||
from approval_service import (
|
from approval_service import (
|
||||||
@@ -707,6 +708,7 @@ async def lifespan(app: FastAPI):
|
|||||||
app = FastAPI(title="ATC Command Center API", lifespan=lifespan)
|
app = FastAPI(title="ATC Command Center API", lifespan=lifespan)
|
||||||
app.include_router(storage_s3_router)
|
app.include_router(storage_s3_router)
|
||||||
app.include_router(elasticsearch_router)
|
app.include_router(elasticsearch_router)
|
||||||
|
app.include_router(sql_router)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Live SQL console — PostgreSQL + Trino with benchmark."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import psycopg2
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
PG_HOST = os.getenv("DB_VAULT_HOST", "10.0.21.51")
|
||||||
|
PG_PORT = int(os.getenv("PG_PORT", "5432"))
|
||||||
|
PG_USER = os.getenv("PG_USER", "mo")
|
||||||
|
PG_PASS = os.getenv("PG_PASSWORD", "Dell2026!")
|
||||||
|
PG_DB = os.getenv("PG_DATABASE", "postgres")
|
||||||
|
|
||||||
|
TRINO_URL = os.getenv("TRINO_URL", "http://10.0.21.50:8089").rstrip("/")
|
||||||
|
TRINO_USER = os.getenv("TRINO_USER", "atc")
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/sql", tags=["sql"])
|
||||||
|
|
||||||
|
SAMPLES: dict[str, list[dict[str, str]]] = {
|
||||||
|
"postgres": [
|
||||||
|
{"id": "pg1", "label": "Server version", "sql": "SELECT version();"},
|
||||||
|
{"id": "pg2", "label": "Current session", "sql": "SELECT current_database(), current_user, now();"},
|
||||||
|
{"id": "pg3", "label": "Public tables", "sql": "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY 1 LIMIT 20;"},
|
||||||
|
{"id": "pg4", "label": "Table count", "sql": "SELECT count(*) AS public_tables FROM information_schema.tables WHERE table_schema='public';"},
|
||||||
|
{"id": "pg5", "label": "Active queries", "sql": "SELECT pid, usename, state, left(query, 100) AS query FROM pg_stat_activity WHERE state='active' AND pid <> pg_backend_pid() LIMIT 10;"},
|
||||||
|
{"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": "pg10", "label": "Memory settings", "sql": "SELECT name, setting, unit FROM pg_settings WHERE name IN ('max_connections','shared_buffers','work_mem','effective_cache_size');"},
|
||||||
|
],
|
||||||
|
"trino": [
|
||||||
|
{"id": "tq1", "label": "Trino version", "sql": "SELECT version()"},
|
||||||
|
{"id": "tq2", "label": "Catalogs", "sql": "SHOW CATALOGS"},
|
||||||
|
{"id": "tq3", "label": "Iceberg schemas", "sql": "SHOW SCHEMAS FROM iceberg"},
|
||||||
|
{"id": "tq4", "label": "Iceberg tables", "sql": "SHOW TABLES FROM iceberg.default"},
|
||||||
|
{"id": "tq5", "label": "Federated PG catalog", "sql": "SHOW SCHEMAS FROM postgres_sales"},
|
||||||
|
{"id": "tq6", "label": "Cluster nodes", "sql": "SELECT node_id, http_uri, state FROM system.runtime.nodes"},
|
||||||
|
{"id": "tq7", "label": "Running queries", "sql": "SELECT query_id, state, user FROM system.runtime.queries WHERE state != 'FINISHED' LIMIT 10"},
|
||||||
|
{"id": "tq8", "label": "Count 1M rows (fast)", "sql": "SELECT count(*) AS rows FROM range(1, 1000000)"},
|
||||||
|
{"id": "tq9", "label": "Explain 5M scan", "sql": "EXPLAIN SELECT count(*) FROM range(1, 5000000)"},
|
||||||
|
{"id": "tq10", "label": "Sum benchmark prep", "sql": "SELECT sum(x) AS total FROM (SELECT x FROM unnest(sequence(1, 500000)) t(x))"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
BENCHMARK_SQL = {
|
||||||
|
"postgres": "SELECT count(*) AS rows FROM generate_series(1, 2000000)",
|
||||||
|
"trino": "SELECT count(*) AS rows FROM range(1, 2000000)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_postgres(sql: str, limit: int = 200) -> dict[str, Any]:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host=PG_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(sql)
|
||||||
|
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||||||
|
if cur.description:
|
||||||
|
columns = [d[0] for d in cur.description]
|
||||||
|
rows = cur.fetchmany(limit)
|
||||||
|
data = [list(r) for r in rows]
|
||||||
|
return {"ok": True, "columns": columns, "rows": data, "row_count": len(data), "elapsed_ms": elapsed_ms, "truncated": len(data) >= limit}
|
||||||
|
return {"ok": True, "columns": [], "rows": [], "row_count": 0, "elapsed_ms": elapsed_ms, "message": "OK"}
|
||||||
|
finally:
|
||||||
|
conn.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"}
|
||||||
|
with httpx.Client(timeout=120.0) as client:
|
||||||
|
resp = client.post(f"{TRINO_URL}/v1/statement", content=sql, headers=headers)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
return {"ok": False, "error": resp.text[:500]}
|
||||||
|
data = resp.json()
|
||||||
|
if data.get("error"):
|
||||||
|
return {"ok": False, "error": str(data["error"])[:500]}
|
||||||
|
columns: list[str] = []
|
||||||
|
rows: list[list[Any]] = []
|
||||||
|
while True:
|
||||||
|
if data.get("columns") and not columns:
|
||||||
|
columns = [c["name"] for c in data["columns"]]
|
||||||
|
if data.get("data"):
|
||||||
|
rows.extend(data["data"])
|
||||||
|
if len(rows) >= limit:
|
||||||
|
rows = rows[:limit]
|
||||||
|
break
|
||||||
|
if data.get("error"):
|
||||||
|
return {"ok": False, "error": str(data["error"])[:500]}
|
||||||
|
nxt = data.get("nextUri")
|
||||||
|
if not nxt:
|
||||||
|
break
|
||||||
|
data = client.get(nxt, headers=headers).json()
|
||||||
|
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||||||
|
stats = data.get("stats") or {}
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"columns": columns,
|
||||||
|
"rows": rows,
|
||||||
|
"row_count": len(rows),
|
||||||
|
"elapsed_ms": elapsed_ms,
|
||||||
|
"engine_stats_ms": stats.get("elapsedTimeMillis"),
|
||||||
|
"truncated": len(rows) >= limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SqlRequest(BaseModel):
|
||||||
|
engine: str = Field(..., pattern="^(postgres|trino)$")
|
||||||
|
sql: str = Field(..., min_length=1, max_length=8000)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/samples/{engine}")
|
||||||
|
async def get_samples(engine: str):
|
||||||
|
if engine not in SAMPLES:
|
||||||
|
return JSONResponse({"error": "unknown engine"}, status_code=404)
|
||||||
|
return {
|
||||||
|
"engine": engine,
|
||||||
|
"samples": SAMPLES[engine],
|
||||||
|
"connection": _connection_info(engine),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def sql_health():
|
||||||
|
pg_ok = trino_ok = False
|
||||||
|
pg_err = trino_err = None
|
||||||
|
try:
|
||||||
|
_run_postgres("SELECT 1")
|
||||||
|
pg_ok = True
|
||||||
|
except Exception as exc:
|
||||||
|
pg_err = str(exc)[:200]
|
||||||
|
try:
|
||||||
|
r = _run_trino("SELECT 1")
|
||||||
|
trino_ok = bool(r.get("ok"))
|
||||||
|
if not trino_ok:
|
||||||
|
trino_err = r.get("error")
|
||||||
|
except Exception as exc:
|
||||||
|
trino_err = str(exc)[:200]
|
||||||
|
return {
|
||||||
|
"postgres": {"ok": pg_ok, "host": PG_HOST, "user": PG_USER, "error": pg_err},
|
||||||
|
"trino": {"ok": trino_ok, "url": TRINO_URL, "user": TRINO_USER, "error": trino_err},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _connection_info(engine: str) -> dict[str, str]:
|
||||||
|
if engine == "postgres":
|
||||||
|
return {"host": PG_HOST, "port": str(PG_PORT), "database": PG_DB, "user": PG_USER}
|
||||||
|
return {"url": TRINO_URL, "user": TRINO_USER}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/execute")
|
||||||
|
async def execute_sql(body: SqlRequest):
|
||||||
|
sql = body.sql.strip().rstrip(";")
|
||||||
|
if not sql:
|
||||||
|
return JSONResponse({"ok": False, "error": "Empty query"}, status_code=400)
|
||||||
|
try:
|
||||||
|
if body.engine == "postgres":
|
||||||
|
result = _run_postgres(sql)
|
||||||
|
else:
|
||||||
|
result = _run_trino(sql)
|
||||||
|
if not result.get("ok"):
|
||||||
|
return JSONResponse(result, status_code=422)
|
||||||
|
return {**result, "engine": body.engine, "sql": sql}
|
||||||
|
except Exception as exc:
|
||||||
|
return JSONResponse({"ok": False, "error": str(exc)[:500]}, status_code=502)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/benchmark")
|
||||||
|
async def benchmark():
|
||||||
|
"""Run equivalent count(*) on Postgres vs Trino to demo lakehouse speed."""
|
||||||
|
results: dict[str, Any] = {}
|
||||||
|
for engine, sql in BENCHMARK_SQL.items():
|
||||||
|
try:
|
||||||
|
if engine == "postgres":
|
||||||
|
results[engine] = _run_postgres(sql)
|
||||||
|
else:
|
||||||
|
results[engine] = _run_trino(sql)
|
||||||
|
results[engine]["sql"] = sql
|
||||||
|
except Exception as exc:
|
||||||
|
results[engine] = {"ok": False, "error": str(exc)[:300], "sql": sql}
|
||||||
|
pg_ms = results.get("postgres", {}).get("elapsed_ms")
|
||||||
|
tr_ms = results.get("trino", {}).get("elapsed_ms")
|
||||||
|
faster = None
|
||||||
|
if pg_ms and tr_ms:
|
||||||
|
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
|
||||||
|
else:
|
||||||
|
speedup = None
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"postgres": results.get("postgres"),
|
||||||
|
"trino": results.get("trino"),
|
||||||
|
"comparison": {
|
||||||
|
"postgres_ms": pg_ms,
|
||||||
|
"trino_ms": tr_ms,
|
||||||
|
"faster": faster,
|
||||||
|
"speedup_factor": speedup,
|
||||||
|
"note": "Same logical workload: count 2M rows — Trino distributed engine vs PostgreSQL OLTP",
|
||||||
|
},
|
||||||
|
}
|
||||||
+14
-1
@@ -17,6 +17,7 @@ import { KnowledgeChatView } from './components/features/KnowledgeChatView'
|
|||||||
import { StorageView } from './components/features/StorageView'
|
import { StorageView } from './components/features/StorageView'
|
||||||
import { SearchView } from './components/features/SearchView'
|
import { SearchView } from './components/features/SearchView'
|
||||||
import { TerminalDock } from './components/features/TerminalDock'
|
import { TerminalDock } from './components/features/TerminalDock'
|
||||||
|
import { WorkbenchPanel } from './components/features/WorkbenchPanel'
|
||||||
import { resolveInfraNode } from './lib/infraCatalog'
|
import { resolveInfraNode } from './lib/infraCatalog'
|
||||||
import { cn } from './lib/utils'
|
import { cn } from './lib/utils'
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ export default function App() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={isPlatform ? 'min-h-[420px]' : 'min-h-0 flex-1'}>
|
<div className={cn('flex min-h-0 flex-col', isPlatform ? 'min-h-[300px] flex-1' : 'min-h-0 flex-1')}>
|
||||||
{cc.mainView === 'platform' ? (
|
{cc.mainView === 'platform' ? (
|
||||||
<PlatformTopology
|
<PlatformTopology
|
||||||
workload={cc.workload}
|
workload={cc.workload}
|
||||||
@@ -125,6 +126,16 @@ export default function App() {
|
|||||||
<ApprovalInbox agents={cc.agents} livePending={cc.approvals} onDecide={cc.decide} />
|
<ApprovalInbox agents={cc.agents} livePending={cc.approvals} onDecide={cc.decide} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isPlatform && cc.workbenchMode && (
|
||||||
|
<WorkbenchPanel
|
||||||
|
mode={cc.workbenchMode}
|
||||||
|
agent={cc.selectedAgent}
|
||||||
|
lines={cc.inspectorLines}
|
||||||
|
busy={cc.nodeBusy || cc.promptBusy}
|
||||||
|
onSendPrompt={cc.sendPrompt}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isPlatform && (
|
{isPlatform && (
|
||||||
@@ -147,6 +158,7 @@ export default function App() {
|
|||||||
onOpenTerminal={cc.openTerminal}
|
onOpenTerminal={cc.openTerminal}
|
||||||
onProbeNodeId={cc.probeNodeId}
|
onProbeNodeId={cc.probeNodeId}
|
||||||
/>
|
/>
|
||||||
|
{!cc.workbenchMode && (
|
||||||
<TerminalDock
|
<TerminalDock
|
||||||
subjectId={terminalSubject}
|
subjectId={terminalSubject}
|
||||||
subjectLabel={terminalLabel}
|
subjectLabel={terminalLabel}
|
||||||
@@ -155,6 +167,7 @@ export default function App() {
|
|||||||
expanded={cc.terminalExpanded}
|
expanded={cc.terminalExpanded}
|
||||||
onToggle={() => cc.setTerminalExpanded(!cc.terminalExpanded)}
|
onToggle={() => cc.setTerminalExpanded(!cc.terminalExpanded)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,20 +36,25 @@ function AgentCard({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-[118px] w-[140px] 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-[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',
|
||||||
selected ? 'border-docker/50 ring-1 ring-docker/20' : 'border-border hover:border-border-strong',
|
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-6 w-6 shrink-0 items-center justify-center rounded-md bg-surface" style={{ color: meta.accent }}>
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-surface" style={{ color: meta.accent }}>
|
||||||
<Icon className="h-3.5 w-3.5" />
|
<Icon className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-[11px] font-semibold text-foreground">{agent.name.split(' ·')[0]}</p>
|
<p className="truncate text-[12px] 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')} />
|
||||||
</div>
|
</div>
|
||||||
|
{selected && (
|
||||||
|
<span className="rounded border border-docker/40 bg-docker/10 px-1.5 py-0.5 text-[7px] font-semibold uppercase tracking-wide text-docker">
|
||||||
|
Terminal open
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<p className="line-clamp-2 text-[8px] leading-[10px] text-foreground-muted">{agent.role}</p>
|
<p className="line-clamp-2 text-[8px] leading-[10px] text-foreground-muted">{agent.role}</p>
|
||||||
<p className="h-[20px] line-clamp-2 text-[8px] leading-[10px] text-foreground-faint">{agentTaskLabel(agent.id, anim)}</p>
|
<p className="h-[20px] line-clamp-2 text-[8px] leading-[10px] text-foreground-faint">{agentTaskLabel(agent.id, anim)}</p>
|
||||||
<div className="mt-auto space-y-0.5">
|
<div className="mt-auto space-y-0.5">
|
||||||
@@ -75,7 +80,7 @@ export function AgentFleet({ agents, animations, selectedId, loads, approvalCoun
|
|||||||
<div className="mb-1 flex shrink-0 items-center justify-between gap-2">
|
<div className="mb-1 flex shrink-0 items-center justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">Agent Fleet</h3>
|
<h3 className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">Agent Fleet</h3>
|
||||||
<p className="truncate text-[8px] text-foreground-faint">Klik agent → stel vraag in chat · elk agent bewaakt één domein</p>
|
<p className="truncate text-[8px] text-foreground-faint">Click agent → dedicated terminal opens below</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { Loader2, Terminal } from 'lucide-react'
|
||||||
|
import type { Agent, TerminalLine } from '../../types'
|
||||||
|
import { getAgentMeta } from '../../lib/agentMeta'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import { subTabIdle } from '../../lib/tabActive'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
agent: Agent
|
||||||
|
lines: TerminalLine[]
|
||||||
|
busy: boolean
|
||||||
|
onSendPrompt: (message: string, agentId?: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL: Record<string, string> = {
|
||||||
|
info: 'text-foreground-muted',
|
||||||
|
ok: 'text-success',
|
||||||
|
warn: 'text-warning',
|
||||||
|
err: 'text-danger',
|
||||||
|
cmd: 'text-docker',
|
||||||
|
llm: 'text-violet-400',
|
||||||
|
fetch: 'text-cyan-400',
|
||||||
|
probe: 'text-amber-300',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentWorkbench({ agent, lines, busy, onSendPrompt }: Props) {
|
||||||
|
const meta = getAgentMeta(agent.id)
|
||||||
|
const Icon = meta.icon
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [lines, busy])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col bg-[#0a0e14]">
|
||||||
|
<header className="flex shrink-0 items-center gap-3 border-b border-border/60 px-4 py-3">
|
||||||
|
<span
|
||||||
|
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border border-border/80 bg-surface-overlay"
|
||||||
|
style={{ color: meta.accent, boxShadow: `0 0 24px ${meta.accent}33` }}
|
||||||
|
>
|
||||||
|
<Icon className="h-6 w-6" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="truncate text-base font-bold text-foreground">{agent.name}</h3>
|
||||||
|
<p className="text-[11px] text-docker">{meta.domain} · {agent.zone}</p>
|
||||||
|
<p className="truncate text-[10px] text-foreground-muted">{agent.role}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{busy && (
|
||||||
|
<span className="flex items-center gap-1 text-[10px] text-success">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" /> Live
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 flex-wrap gap-1 border-b border-border/40 px-3 py-2">
|
||||||
|
{(agent.suggested_prompts || []).slice(0, 6).map((prompt) => (
|
||||||
|
<button
|
||||||
|
key={prompt}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSendPrompt(prompt, agent.id)}
|
||||||
|
className={cn('rounded border border-border/60 px-2 py-1 text-[10px] text-foreground-muted hover:border-docker/40 hover:text-docker', subTabIdle)}
|
||||||
|
>
|
||||||
|
{prompt}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto px-4 py-2 font-mono text-[11px] leading-relaxed">
|
||||||
|
{lines.length === 0 && (
|
||||||
|
<p className="py-8 text-center text-foreground-faint">
|
||||||
|
Agent terminal ready — probe output and LLM responses appear here
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{lines.map((line) => (
|
||||||
|
<div key={line.id} className={LEVEL[line.level] || 'text-foreground-muted'}>
|
||||||
|
<span className="text-foreground-faint/50">
|
||||||
|
{line.ts ? new Date(line.ts).toLocaleTimeString('en-US', { hour12: false }) : ''}
|
||||||
|
</span>{' '}
|
||||||
|
<span className="text-docker/70">[{line.phase}]</span> {line.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{busy && <span className="text-docker animate-pulse">█</span>}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -290,7 +290,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">
|
||||||
Airflow daily Python → CDC → stream → lakehouse → consumers
|
Click PostgreSQL or Trino → live SQL console · agents → terminal below
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Database, Loader2, Play, Zap } from 'lucide-react'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import { subTabActive, subTabIdle } from '../../lib/tabActive'
|
||||||
|
|
||||||
|
type Sample = { id: string; label: string; sql: string }
|
||||||
|
type SqlResult = {
|
||||||
|
ok: boolean
|
||||||
|
columns?: string[]
|
||||||
|
rows?: unknown[][]
|
||||||
|
row_count?: number
|
||||||
|
elapsed_ms?: number
|
||||||
|
error?: string
|
||||||
|
sql?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
engine: 'postgres' | 'trino'
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENGINE_META = {
|
||||||
|
postgres: {
|
||||||
|
title: 'PostgreSQL SQL Console',
|
||||||
|
sub: 'DB Vault · 10.0.21.51:5432 · user mo',
|
||||||
|
accent: 'text-blue-400',
|
||||||
|
},
|
||||||
|
trino: {
|
||||||
|
title: 'Trino SQL Console',
|
||||||
|
sub: 'Lakehouse · 10.0.21.50:8089 · federated lakehouse queries',
|
||||||
|
accent: 'text-violet-400',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SqlWorkbench({ engine }: Props) {
|
||||||
|
const meta = ENGINE_META[engine]
|
||||||
|
const [samples, setSamples] = useState<Sample[]>([])
|
||||||
|
const [sql, setSql] = useState('SELECT version();')
|
||||||
|
const [result, setResult] = useState<SqlResult | null>(null)
|
||||||
|
const [benchmark, setBenchmark] = useState<{
|
||||||
|
comparison?: { postgres_ms?: number; trino_ms?: number; faster?: string; speedup_factor?: number }
|
||||||
|
postgres?: SqlResult
|
||||||
|
trino?: SqlResult
|
||||||
|
} | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [benchLoading, setBenchLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const loadSamples = useCallback(async () => {
|
||||||
|
const r = await fetch(`/api/sql/samples/${engine}`)
|
||||||
|
if (r.ok) {
|
||||||
|
const j = await r.json()
|
||||||
|
setSamples(j.samples || [])
|
||||||
|
if (j.samples?.[0]) setSql(j.samples[0].sql)
|
||||||
|
}
|
||||||
|
}, [engine])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSamples()
|
||||||
|
setResult(null)
|
||||||
|
setBenchmark(null)
|
||||||
|
setError(null)
|
||||||
|
}, [engine, loadSamples])
|
||||||
|
|
||||||
|
const run = async (query?: string) => {
|
||||||
|
const q = (query ?? sql).trim()
|
||||||
|
if (!q) return
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/sql/execute', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ engine, sql: q }),
|
||||||
|
})
|
||||||
|
const j = await r.json()
|
||||||
|
if (!r.ok || !j.ok) {
|
||||||
|
setError(j.error || 'Query failed')
|
||||||
|
setResult(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSql(q)
|
||||||
|
setResult(j)
|
||||||
|
} catch {
|
||||||
|
setError('SQL API unavailable')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runBenchmark = async () => {
|
||||||
|
setBenchLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/sql/benchmark', { method: 'POST' })
|
||||||
|
const j = await r.json()
|
||||||
|
if (!r.ok) {
|
||||||
|
setError(j.error || 'Benchmark failed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBenchmark(j)
|
||||||
|
} catch {
|
||||||
|
setError('Benchmark failed')
|
||||||
|
} finally {
|
||||||
|
setBenchLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col bg-[#0a0e14] text-foreground">
|
||||||
|
<header className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border/60 px-3 py-2">
|
||||||
|
<div>
|
||||||
|
<h3 className={cn('flex items-center gap-2 text-sm font-semibold', meta.accent)}>
|
||||||
|
<Database className="h-4 w-4" />
|
||||||
|
{meta.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[10px] text-foreground-muted">{meta.sub}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => run()} disabled={loading} className={cn('inline-flex items-center gap-1 rounded px-3 py-1.5 text-[11px]', subTabActive)}>
|
||||||
|
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <Play className="h-3 w-3" />}
|
||||||
|
Run
|
||||||
|
</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)}>
|
||||||
|
{benchLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <Zap className="h-3 w-3 text-amber-400" />}
|
||||||
|
Postgres vs Trino
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
|
||||||
|
<aside className="shrink-0 border-b border-border/60 p-2 lg:w-56 lg:border-b-0 lg:border-r">
|
||||||
|
<p className="mb-1 text-[9px] 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">
|
||||||
|
{samples.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||||
|
<textarea
|
||||||
|
value={sql}
|
||||||
|
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"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <p className="shrink-0 px-3 py-1 text-[11px] text-danger">{error}</p>}
|
||||||
|
|
||||||
|
{benchmark?.comparison && (
|
||||||
|
<div className="shrink-0 border-b border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px]">
|
||||||
|
<span className="font-semibold text-amber-300">Benchmark (2M rows): </span>
|
||||||
|
PostgreSQL <span className="font-mono">{benchmark.comparison.postgres_ms}ms</span>
|
||||||
|
{' · '}
|
||||||
|
Trino <span className="font-mono">{benchmark.comparison.trino_ms}ms</span>
|
||||||
|
{' — '}
|
||||||
|
<span className="font-semibold text-success">
|
||||||
|
{benchmark.comparison.faster} {benchmark.comparison.speedup_factor ? `${benchmark.comparison.speedup_factor}× faster` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="scrollbar-thin min-h-0 flex-1 overflow-auto p-2">
|
||||||
|
{result?.ok && result.columns && (
|
||||||
|
<>
|
||||||
|
<p className="mb-1 font-mono text-[9px] text-foreground-faint">
|
||||||
|
{result.row_count} rows · {result.elapsed_ms}ms
|
||||||
|
</p>
|
||||||
|
<table className="w-full text-left font-mono text-[10px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-docker">
|
||||||
|
{result.columns.map((c) => <th key={c} className="px-2 py-1">{c}</th>)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{result.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 ? 'NULL' : String(cell)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!result && !loading && (
|
||||||
|
<p className="py-6 text-center text-[11px] text-foreground-faint">Pick a command or write SQL · click Run</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Agent, TerminalLine } from '../../types'
|
||||||
|
import { AgentWorkbench } from './AgentWorkbench'
|
||||||
|
import { SqlWorkbench } from './SqlWorkbench'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mode: 'agent' | 'sql-postgres' | 'sql-trino' | null
|
||||||
|
agent: Agent | null
|
||||||
|
lines: TerminalLine[]
|
||||||
|
busy: boolean
|
||||||
|
onSendPrompt: (message: string, agentId?: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkbenchPanel({ mode, agent, lines, busy, onSendPrompt }: Props) {
|
||||||
|
if (!mode) return null
|
||||||
|
|
||||||
|
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)]">
|
||||||
|
{mode === 'agent' && agent && (
|
||||||
|
<AgentWorkbench agent={agent} lines={lines} busy={busy} onSendPrompt={onSendPrompt} />
|
||||||
|
)}
|
||||||
|
{mode === 'sql-postgres' && <SqlWorkbench engine="postgres" />}
|
||||||
|
{mode === 'sql-trino' && <SqlWorkbench engine="trino" />}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -35,6 +35,18 @@ function resolveProbeId(nodeId: string) {
|
|||||||
return infra?.id || aliased
|
return infra?.id || aliased
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const SQL_NODE_ENGINES: Record<string, 'postgres' | 'trino'> = {
|
||||||
|
postgresql: 'postgres',
|
||||||
|
'src-postgres': 'postgres',
|
||||||
|
trino: 'trino',
|
||||||
|
'query-trino': 'trino',
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSqlEngine(nodeId: string): 'postgres' | 'trino' | null {
|
||||||
|
return SQL_NODE_ENGINES[nodeId] || SQL_NODE_ENGINES[resolveProbeId(nodeId)] || null
|
||||||
|
}
|
||||||
|
|
||||||
export function useCommandCenter() {
|
export function useCommandCenter() {
|
||||||
const [agents, setAgents] = useState<Agent[]>([])
|
const [agents, setAgents] = useState<Agent[]>([])
|
||||||
const [agentsLoading, setAgentsLoading] = useState(true)
|
const [agentsLoading, setAgentsLoading] = useState(true)
|
||||||
@@ -54,6 +66,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 [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)
|
||||||
@@ -206,10 +219,19 @@ export function useCommandCenter() {
|
|||||||
setSelectedNodeId(nodeId)
|
setSelectedNodeId(nodeId)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const selectNode = useCallback(async (nodeId: string) => {
|
const selectNode = useCallback(async (nodeId: string, options?: { keepWorkbench?: 'agent' }) => {
|
||||||
const stub = findNodeStub(nodeId)
|
const stub = findNodeStub(nodeId)
|
||||||
if (!stub) return
|
if (!stub) return
|
||||||
const probeId = resolveProbeId(nodeId)
|
const probeId = resolveProbeId(nodeId)
|
||||||
|
const sqlEng = resolveSqlEngine(nodeId)
|
||||||
|
if (sqlEng) {
|
||||||
|
setWorkbenchMode(sqlEng === 'postgres' ? 'sql-postgres' : 'sql-trino')
|
||||||
|
setSelectedAgentId(null)
|
||||||
|
} else if (options?.keepWorkbench) {
|
||||||
|
setWorkbenchMode(options.keepWorkbench)
|
||||||
|
} else {
|
||||||
|
setWorkbenchMode(null)
|
||||||
|
}
|
||||||
setSelectedNodeId(probeId)
|
setSelectedNodeId(probeId)
|
||||||
setSelectedNode({ ...stub, id: probeId })
|
setSelectedNode({ ...stub, id: probeId })
|
||||||
setNodeDetail(null)
|
setNodeDetail(null)
|
||||||
@@ -228,6 +250,7 @@ export function useCommandCenter() {
|
|||||||
|
|
||||||
const selectAgent = useCallback((id: string) => {
|
const selectAgent = useCallback((id: string) => {
|
||||||
setSelectedAgentId(id)
|
setSelectedAgentId(id)
|
||||||
|
setWorkbenchMode('agent')
|
||||||
setTerminalExpanded(true)
|
setTerminalExpanded(true)
|
||||||
const agent = agents.find((a) => a.id === id)
|
const agent = agents.find((a) => a.id === id)
|
||||||
if (!agent) return
|
if (!agent) return
|
||||||
@@ -235,7 +258,7 @@ export function useCommandCenter() {
|
|||||||
if (nodeId) {
|
if (nodeId) {
|
||||||
const stub = findNodeStub(nodeId)
|
const stub = findNodeStub(nodeId)
|
||||||
if (stub) {
|
if (stub) {
|
||||||
selectNode(nodeId)
|
void selectNode(nodeId, { keepWorkbench: 'agent' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,6 +272,7 @@ export function useCommandCenter() {
|
|||||||
setSelectedNode(null)
|
setSelectedNode(null)
|
||||||
setNodeDetail(null)
|
setNodeDetail(null)
|
||||||
setSelectedAgentId(null)
|
setSelectedAgentId(null)
|
||||||
|
setWorkbenchMode(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const probeNode = useCallback(() => {
|
const probeNode = useCallback(() => {
|
||||||
@@ -310,6 +334,7 @@ export function useCommandCenter() {
|
|||||||
setMainView,
|
setMainView,
|
||||||
approvalHighlight,
|
approvalHighlight,
|
||||||
setApprovalHighlight,
|
setApprovalHighlight,
|
||||||
|
workbenchMode,
|
||||||
inspectorLines,
|
inspectorLines,
|
||||||
terminalSubjectId,
|
terminalSubjectId,
|
||||||
terminalExpanded,
|
terminalExpanded,
|
||||||
|
|||||||
Reference in New Issue
Block a user