diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..0331674
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,11 @@
+FROM node:22-alpine AS build
+WORKDIR /app
+COPY package.json ./
+RUN npm install
+COPY . .
+RUN npm run build
+
+FROM nginx:alpine
+COPY --from=build /app/dist /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+EXPOSE 80
diff --git a/README.md b/README.md
index f60c48c..e426503 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ Autonomous agent hub for the Dell ATC lab — Ops Floor UI, FastAPI backend, 5 o
docker compose up -d --build
```
-Open: **http://atc-mcp.dell-atc.lan/** (or `http://10.0.21.33/`)
+Open: **http://10.0.21.33/**
## Stack
@@ -19,10 +19,60 @@ Open: **http://atc-mcp.dell-atc.lan/** (or `http://10.0.21.33/`)
## VM 304 (MCP)
-- Host: `atc-mcp.dell-atc.lan` → DHCP on VLAN 20 (`br_20`)
+- IP: `10.0.21.33` (DHCP on VLAN 20 / `br_20`)
- Proxmox VMID **304** on **atc-gpu**
- SSH: `root` / `Dell2026!`
## Gitea
`http://atc-mgt01.dell-atc.lan:3001/mo/atc-agents`
+# ATC Command Center — Gitea layout
+
+This repo follows the same pattern as **`mo/atc-GPU`** and **`mo/Lakehouse`**.
+
+## Related repos (Gitea @ atc-mgt01:3001)
+
+| Repo | Path on VM304 | Purpose |
+|------|---------------|---------|
+| **mo/atc-agents** | `/opt/atc-agents` | Command Center UI + API + docker-compose |
+| **mo/atc-data-quality** | `/opt/atc-data-quality` | DQ API + RAG API |
+| **mo/atc-GPU** | GPU lab VM303 | vLLM, model-manager |
+| **mo/Lakehouse** | lake01 / docker hosts | Kafka, Spark, Trino, ObjectScale config |
+
+## This repo structure
+
+```
+atc-agents/
+├── api/ FastAPI backend
+├── ui/ React dashboard
+├── caddy/ Reverse proxy routes
+├── config/ Deploy reference (mirrors production)
+│ ├── command-center/ docker-compose, Caddyfile, .env.example
+│ ├── data-quality/ Link to mo/atc-data-quality
+│ └── jupyter/ JupyterLab service snippet
+├── docs/ Runbooks
+├── scripts/ deploy.sh
+└── docker-compose.yml Production stack (clone with atc-data-quality sibling)
+```
+
+## Deploy
+
+```bash
+git clone http://atc-mgt01.dell-atc.lan:3001/mo/atc-agents.git /opt/atc-agents
+git clone http://atc-mgt01.dell-atc.lan:3001/mo/atc-data-quality.git /opt/atc-data-quality
+cp config/command-center/.env.example /opt/atc-agents/.env # edit secrets
+./scripts/deploy.sh
+```
+
+Open: **http://10.0.21.33/**
+
+## Services (port 80 via Caddy)
+
+| Route | Service |
+|-------|---------|
+| `/` | React UI |
+| `/api/*` | Agents API |
+| `/dq/*` | Data Quality API |
+| `/rag/*` | Knowledge Chat / RAG |
+| `/jupyter/*` | JupyterLab (S3 env preconfigured) |
+| `:5001` | Docling UI (direct) |
diff --git a/agent_terminal.py b/agent_terminal.py
new file mode 100644
index 0000000..e44dfcb
--- /dev/null
+++ b/agent_terminal.py
@@ -0,0 +1,71 @@
+"""Per-agent live terminal buffers and streaming."""
+
+from __future__ import annotations
+
+import uuid
+from collections import deque
+from datetime import datetime, timezone
+from typing import Any, Awaitable, Callable
+
+MAX_LINES_PER_AGENT = 300
+
+PublishFn = Callable[[dict[str, Any]], Awaitable[None]]
+
+_buffers: dict[str, deque[dict[str, Any]]] = {}
+_publish: PublishFn | None = None
+
+
+def init_terminals(agent_ids: list[str]) -> None:
+ for aid in agent_ids:
+ if aid not in _buffers:
+ _buffers[aid] = deque(maxlen=MAX_LINES_PER_AGENT)
+
+
+def set_terminal_publisher(fn: PublishFn) -> None:
+ global _publish
+ _publish = fn
+
+
+def get_terminal_lines(agent_id: str, limit: int = 200) -> list[dict[str, Any]]:
+ buf = _buffers.get(agent_id, deque())
+ items = list(buf)
+ return items[-limit:]
+
+
+def get_all_terminals(limit: int = 200) -> dict[str, list[dict[str, Any]]]:
+ return {aid: get_terminal_lines(aid, limit) for aid in _buffers}
+
+
+async def terminal_log(
+ agent_id: str,
+ text: str,
+ *,
+ level: str = "info",
+ phase: str = "ops",
+ prompt_id: str | None = None,
+) -> dict[str, Any]:
+ init_terminals([agent_id])
+ line = {
+ "id": str(uuid.uuid4())[:8],
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "agent_id": agent_id,
+ "level": level,
+ "phase": phase,
+ "text": text,
+ "prompt_id": prompt_id,
+ }
+ _buffers[agent_id].append(line)
+ if _publish:
+ await _publish({"type": "terminal", "line": line})
+ return line
+
+
+# Type: async (level, phase, text) -> None
+TerminalLogFn = Callable[[str, str, str], Awaitable[None]]
+
+
+def make_logger(agent_id: str, prompt_id: str | None = None) -> TerminalLogFn:
+ async def log(level: str, phase: str, text: str) -> None:
+ await terminal_log(agent_id, text, level=level, phase=phase, prompt_id=prompt_id)
+
+ return log
diff --git a/api/Dockerfile b/api/Dockerfile
index b4772c1..0f4a57a 100644
--- a/api/Dockerfile
+++ b/api/Dockerfile
@@ -4,7 +4,7 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
-COPY main.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 .
RUN mkdir -p /data
ENV DATABASE_URL=sqlite:////data/atc-agents.db
EXPOSE 3201
diff --git a/api/agent_terminal.py b/api/agent_terminal.py
new file mode 100644
index 0000000..66189ba
--- /dev/null
+++ b/api/agent_terminal.py
@@ -0,0 +1,72 @@
+"""Per-agent live terminal buffers and streaming."""
+
+from __future__ import annotations
+
+import uuid
+from collections import deque
+from datetime import datetime, timezone
+from typing import Any, Awaitable, Callable
+
+MAX_LINES_PER_AGENT = 300
+
+PublishFn = Callable[[dict[str, Any]], Awaitable[None]]
+
+_buffers: dict[str, deque[dict[str, Any]]] = {}
+_publish: PublishFn | None = None
+
+
+def init_terminals(agent_ids: list[str]) -> None:
+ for aid in agent_ids:
+ if aid not in _buffers:
+ _buffers[aid] = deque(maxlen=MAX_LINES_PER_AGENT)
+
+
+def set_terminal_publisher(fn: PublishFn) -> None:
+ global _publish
+ _publish = fn
+
+
+def get_terminal_lines(agent_id: str, limit: int = 200) -> list[dict[str, Any]]:
+ buf = _buffers.get(agent_id, deque())
+ items = list(buf)
+ return items[-limit:]
+
+
+def get_all_terminals(limit: int = 200) -> dict[str, list[dict[str, Any]]]:
+ return {aid: get_terminal_lines(aid, limit) for aid in _buffers}
+
+
+async def terminal_log(
+ agent_id: str,
+ text: str,
+ *,
+ level: str = "info",
+ phase: str = "ops",
+ prompt_id: str | None = None,
+ mirror: bool = True,
+) -> dict[str, Any]:
+ init_terminals([agent_id])
+ line = {
+ "id": str(uuid.uuid4())[:8],
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "agent_id": agent_id,
+ "level": level,
+ "phase": phase,
+ "text": text,
+ "prompt_id": prompt_id,
+ }
+ _buffers[agent_id].append(line)
+ if _publish and mirror:
+ await _publish({"type": "terminal", "line": line})
+ return line
+
+
+# Type: async (level, phase, text) -> None
+TerminalLogFn = Callable[[str, str, str], Awaitable[None]]
+
+
+def make_logger(agent_id: str, prompt_id: str | None = None) -> TerminalLogFn:
+ async def log(level: str, phase: str, text: str) -> None:
+ await terminal_log(agent_id, text, level=level, phase=phase, prompt_id=prompt_id)
+
+ return log
diff --git a/api/approval_service.py b/api/approval_service.py
new file mode 100644
index 0000000..bc40e43
--- /dev/null
+++ b/api/approval_service.py
@@ -0,0 +1,248 @@
+"""Approval workflow — agents request; Mo & Bart approve before mutating actions."""
+
+from __future__ import annotations
+
+import json
+import re
+from datetime import datetime, timezone
+from typing import Any, Awaitable, Callable
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+APPROVAL_ACTION_TYPES = {
+ "docker.restart": "Docker container restart",
+ "docker.update": "Docker image update / pull",
+ "docker.recreate": "Docker container recreate",
+ "docker.stop": "Docker container stop",
+ "docker.remove": "Docker container remove",
+ "docker.compose": "Docker Compose deploy",
+ "docker.prune": "Docker prune / cleanup",
+ "db.migrate": "Database schema migration",
+ "db.restart": "Database service restart",
+ "etl.restart": "ETL / connector restart",
+ "kafka.reset": "Kafka topic / offset reset",
+ "hdfs.mutate": "HDFS destructive operation",
+ "infra.reboot": "VM / host reboot",
+ "generic.mutate": "Infrastructure change",
+}
+
+SUPERVISOR_IDS = ["mo-commander", "bart-commander"]
+
+_INTENT_PATTERNS: list[tuple[re.Pattern[str], str]] = [
+ (re.compile(r"\b(restart|herstart|reboot)\b.*\b(container|docker|stack|service|mysql|postgres|kafka|airflow)"), "docker.restart"),
+ (re.compile(r"\b(update|upgrade|updaten|pull|pullen)\b.*\b(docker|image|container|stack|compose)"), "docker.update"),
+ (re.compile(r"\b(recreate|rebuild|redeploy|deploy|opnieuw)\b.*\b(container|docker|stack|compose|service)"), "docker.recreate"),
+ (re.compile(r"\b(stop|stoppen|shutdown)\b.*\b(container|docker|service)"), "docker.stop"),
+ (re.compile(r"\b(remove|delete|verwijder|rm|prune|opschonen)\b.*\b(container|docker|image|volume)"), "docker.remove"),
+ (re.compile(r"\b(docker compose|compose up|stack deploy)"), "docker.compose"),
+ (re.compile(r"\b(migrate|migration|schema change)\b.*\b(db|database|postgres|mysql)"), "db.migrate"),
+ (re.compile(r"\b(restart|herstart)\b.*\b(db|database|postgres|mysql|mongo|cassandra)"), "db.restart"),
+ (re.compile(r"\b(restart|reset)\b.*\b(connector|debezium|kafka connect)"), "etl.restart"),
+ (re.compile(r"\b(reset|truncate|drop)\b.*\b(topic|kafka|offset)"), "kafka.reset"),
+ (re.compile(r"\b(reboot|restart)\b.*\b(vm|host|server|node|proxmox)"), "infra.reboot"),
+]
+
+_RESPONSE_ACTION_PATTERN = re.compile(
+ r"\b(will|ga|moet|plan to|going to|propose|voorstel)\b.*\b(restart|update|pull|recreate|deploy|stop|remove|reboot|migrate)",
+ re.I,
+)
+
+
+def detect_approval_intent(text: str) -> dict[str, Any] | None:
+ lower = text.lower().strip()
+ for pattern, action_type in _INTENT_PATTERNS:
+ if pattern.search(lower):
+ return {
+ "action_type": action_type,
+ "action": text.strip()[:500],
+ "reason": f"Mutating operation detected ({APPROVAL_ACTION_TYPES.get(action_type, action_type)})",
+ }
+ return None
+
+
+def detect_agent_proposed_action(llm_answer: str, original_message: str) -> dict[str, Any] | None:
+ if not _RESPONSE_ACTION_PATTERN.search(llm_answer):
+ return None
+ intent = detect_approval_intent(llm_answer) or detect_approval_intent(original_message)
+ if intent:
+ intent["reason"] = f"Agent proposed action in mission response: {intent['reason']}"
+ intent["action"] = llm_answer.strip()[:500]
+ return intent
+
+
+def approval_to_dict(row: Any) -> dict[str, Any]:
+ payload: dict[str, Any] = {}
+ raw_payload = getattr(row, "payload", None)
+ if raw_payload:
+ try:
+ payload = json.loads(raw_payload)
+ except (json.JSONDecodeError, TypeError):
+ payload = {"raw": raw_payload}
+ decided_at = getattr(row, "decided_at", None)
+ return {
+ "id": row.id,
+ "ts": row.ts.isoformat() if row.ts else None,
+ "agent_id": row.agent_id,
+ "action": row.action,
+ "reason": row.reason,
+ "status": row.status,
+ "action_type": getattr(row, "action_type", None) or "generic.mutate",
+ "target": getattr(row, "target", None) or "",
+ "payload": payload,
+ "decided_by": getattr(row, "decided_by", None),
+ "decide_note": getattr(row, "decide_note", None),
+ "decided_at": decided_at.isoformat() if decided_at else None,
+ "priority": getattr(row, "priority", None) or "normal",
+ }
+
+
+def migrate_approval_columns(engine: Any) -> None:
+ """Legacy shim — migrations live in db.py."""
+ from db import migrate_approval_columns as _migrate
+
+ _migrate(engine)
+
+
+async def create_approval_request(
+ *,
+ db: Session,
+ ApprovalModel: type,
+ agent_id: str,
+ action: str,
+ reason: str,
+ action_type: str = "generic.mutate",
+ target: str = "",
+ payload: dict | None = None,
+ priority: str = "normal",
+ terminal_log: Callable[..., Awaitable[None]] | None = None,
+ mirror_supervisors: Callable[..., Awaitable[None]] | None = None,
+ publish: Callable[[dict], Awaitable[None]] | None = None,
+ add_feed: Callable[[str, str, str], dict] | None = None,
+) -> dict[str, Any]:
+ import uuid
+
+ approval_id = str(uuid.uuid4())[:10]
+ now = datetime.now(timezone.utc)
+ row = ApprovalModel(
+ id=approval_id,
+ ts=now,
+ agent_id=agent_id,
+ action=action,
+ reason=reason,
+ status="pending",
+ action_type=action_type,
+ target=target,
+ payload=json.dumps(payload or {}),
+ priority=priority,
+ )
+ db.add(row)
+ db.commit()
+ db.refresh(row)
+ item = approval_to_dict(row)
+
+ type_label = APPROVAL_ACTION_TYPES.get(action_type, action_type)
+ alert = (
+ f"⚠ APPROVAL REQUIRED · {type_label}\n"
+ f" Agent: {agent_id}\n"
+ f" Action: {action[:200]}\n"
+ f" Target: {target or '—'}\n"
+ f" Reason: {reason[:200]}\n"
+ f" ID: {approval_id} — awaiting Mo & Bart"
+ )
+
+ if terminal_log:
+ for sid in SUPERVISOR_IDS:
+ await terminal_log(sid, alert, level="warn", phase="approval")
+ await terminal_log(
+ agent_id,
+ f"⏸ Action queued for approval ({approval_id}) — Mo & Bart notified",
+ level="warn",
+ phase="approval",
+ )
+
+ if mirror_supervisors:
+ await mirror_supervisors(
+ agent_id,
+ f"APPROVAL REQUEST [{approval_id}] {action_type}: {action[:120]}",
+ level="warn",
+ phase="approval",
+ )
+
+ if add_feed and publish:
+ feed = add_feed(
+ agent_id,
+ f"Approval requested ({approval_id}): {action[:80]} — waiting for Mo & Bart",
+ "warn",
+ )
+ await publish({"type": "feed", "entry": feed})
+
+ if publish:
+ await publish({"type": "approval_new", "approval": item})
+
+ return item
+
+
+async def decide_approval_request(
+ *,
+ db: Session,
+ ApprovalModel: type,
+ approval_id: str,
+ approved: bool,
+ decided_by: str = "mo-commander",
+ note: str = "",
+ terminal_log: Callable[..., Awaitable[None]] | None = None,
+ publish: Callable[[dict], Awaitable[None]] | None = None,
+ add_feed: Callable[[str, str, str], dict] | None = None,
+) -> dict[str, Any] | None:
+ row = db.get(ApprovalModel, approval_id)
+ if not row:
+ return None
+ if row.status != "pending":
+ return approval_to_dict(row)
+
+ row.status = "approved" if approved else "denied"
+ row.decided_by = decided_by
+ row.decide_note = note or None
+ row.decided_at = datetime.now(timezone.utc)
+ db.commit()
+ db.refresh(row)
+ item = approval_to_dict(row)
+
+ verb = "APPROVED" if approved else "DENIED"
+ who = "Mo" if "mo" in decided_by else "Bart" if "bart" in decided_by else decided_by
+ msg = f"{verb} by {who}: {row.action[:100]}"
+ if note:
+ msg += f" — {note[:80]}"
+
+ if terminal_log:
+ await terminal_log(row.agent_id, msg, level="ok" if approved else "warn", phase="approval")
+ for sid in SUPERVISOR_IDS:
+ await terminal_log(sid, f"✓ {msg}", level="ok" if approved else "info", phase="approval")
+
+ if add_feed and publish:
+ feed = add_feed(row.agent_id, msg, "info" if approved else "warn")
+ await publish({"type": "feed", "entry": feed})
+
+ if publish:
+ await publish({"type": "approval_update", "approval": item})
+
+ return item
+
+
+def list_approvals(db: Session, ApprovalModel: type, status: str = "pending", limit: int = 100) -> list[dict[str, Any]]:
+ q = select(ApprovalModel).order_by(ApprovalModel.ts.desc()).limit(limit)
+ if status and status != "all":
+ q = q.where(ApprovalModel.status == status)
+ rows = db.execute(q).scalars().all()
+ return [approval_to_dict(r) for r in rows]
+
+
+def approval_stats(db: Session, ApprovalModel: type) -> dict[str, int]:
+ rows = db.execute(select(ApprovalModel)).scalars().all()
+ stats = {"pending": 0, "approved": 0, "denied": 0, "total": 0}
+ for r in rows:
+ stats["total"] += 1
+ if r.status in stats:
+ stats[r.status] += 1
+ return stats
diff --git a/api/database_inventory.py b/api/database_inventory.py
new file mode 100644
index 0000000..aa3ceb8
--- /dev/null
+++ b/api/database_inventory.py
@@ -0,0 +1,243 @@
+"""Live database inventory — sizes, row counts, schemas for LLM context."""
+
+from __future__ import annotations
+
+import asyncio
+import os
+from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
+from typing import Any
+
+DB_HOST = os.getenv("DB_VAULT_HOST", "10.0.21.51")
+PG_USER = os.getenv("PG_USER", "mo")
+PG_PASS = os.getenv("PG_PASSWORD", "Dell2026!")
+MYSQL_USER = os.getenv("MYSQL_USER", "mo")
+MYSQL_PASS = os.getenv("MYSQL_PASSWORD", "Dell2026!")
+NEO4J_USER = os.getenv("NEO4J_USER", "neo4j")
+NEO4J_PASS = os.getenv("NEO4J_PASSWORD", "testpwd")
+ENGINE_TIMEOUT = float(os.getenv("DB_INVENTORY_TIMEOUT", "20"))
+
+_executor = ThreadPoolExecutor(max_workers=4)
+
+
+def _fmt_bytes(n: int | float | None) -> str:
+ if n is None:
+ return "?"
+ n = float(n)
+ for unit in ("B", "KB", "MB", "GB", "TB"):
+ if n < 1024 or unit == "TB":
+ return f"{n:.1f} {unit}" if unit != "B" else f"{int(n)} B"
+ n /= 1024
+ return f"{n:.1f} TB"
+
+
+def _inventory_postgres() -> dict[str, Any]:
+ import psycopg2
+
+ out: dict[str, Any] = {"engine": "PostgreSQL", "host": DB_HOST, "database": "postgres", "ok": False}
+ try:
+ conn = psycopg2.connect(
+ host=DB_HOST, user=PG_USER, password=PG_PASS, dbname="postgres", connect_timeout=5,
+ )
+ cur = conn.cursor()
+ cur.execute("SELECT pg_database_size(current_database())")
+ out["size_bytes"] = cur.fetchone()[0]
+ out["size_human"] = _fmt_bytes(out["size_bytes"])
+
+ cur.execute(
+ "SELECT table_name FROM information_schema.tables "
+ "WHERE table_schema='public' AND table_type='BASE TABLE' ORDER BY table_name",
+ )
+ tables = []
+ for (tname,) in cur.fetchall():
+ cur.execute(f'SELECT reltuples::bigint FROM pg_class WHERE relname = %s', (tname,))
+ est = cur.fetchone()
+ rows = int(est[0]) if est and est[0] else None
+ cur.execute(
+ "SELECT column_name, data_type FROM information_schema.columns "
+ "WHERE table_schema='public' AND table_name=%s ORDER BY ordinal_position",
+ (tname,),
+ )
+ cols = [f"{c} ({dt})" for c, dt in cur.fetchall()]
+ tbl: dict[str, Any] = {"name": tname, "rows": rows, "rows_estimated": True, "columns": cols}
+ if tname == "sales_orders" and rows:
+ cur.execute(
+ "SELECT region, COUNT(*) FROM sales_orders TABLESAMPLE SYSTEM (0.1) "
+ "GROUP BY region ORDER BY COUNT(*) DESC LIMIT 5",
+ )
+ sample = cur.fetchall()
+ if sample:
+ tbl["sample_regions"] = {r: c for r, c in sample}
+ tables.append(tbl)
+ out["tables"] = tables
+ out["ok"] = True
+ conn.close()
+ except Exception as exc:
+ out["error"] = str(exc)
+ return out
+
+
+def _inventory_mysql() -> dict[str, Any]:
+ import pymysql
+
+ out: dict[str, Any] = {"engine": "MySQL", "host": DB_HOST, "database": "hr", "ok": False}
+ try:
+ conn = pymysql.connect(
+ host=DB_HOST, user=MYSQL_USER, password=MYSQL_PASS, database="hr", connect_timeout=5,
+ )
+ cur = conn.cursor()
+ cur.execute(
+ "SELECT table_name, data_length+index_length, table_rows "
+ "FROM information_schema.tables WHERE table_schema='hr'",
+ )
+ tables = []
+ total_bytes = 0
+ for tname, tbytes, trows in cur.fetchall():
+ total_bytes += tbytes or 0
+ cur.execute(f"SHOW COLUMNS FROM `{tname}`")
+ cols = [f"{r[0]} ({r[1]})" for r in cur.fetchall()]
+ tbl: dict[str, Any] = {
+ "name": tname,
+ "rows": int(trows) if trows else None,
+ "rows_estimated": True,
+ "size_bytes": tbytes,
+ "columns": cols,
+ }
+ if tname == "employee_events":
+ tbl["note"] = "HR employee lifecycle events (promotions, transfers, salary changes, etc.)"
+ tables.append(tbl)
+ out["tables"] = tables
+ out["size_bytes"] = total_bytes
+ out["size_human"] = _fmt_bytes(total_bytes)
+ out["ok"] = True
+ conn.close()
+ except Exception as exc:
+ out["error"] = str(exc)
+ return out
+
+
+def _inventory_mongo() -> dict[str, Any]:
+ from pymongo import MongoClient
+
+ out: dict[str, Any] = {"engine": "MongoDB", "host": DB_HOST, "ok": False}
+ try:
+ client = MongoClient(f"mongodb://{DB_HOST}:27017/", serverSelectionTimeoutMS=5000)
+ db = client["supplychain"]
+ collections = []
+ for cname in db.list_collection_names():
+ if cname.startswith("__"):
+ continue
+ col = db[cname]
+ docs = col.estimated_document_count()
+ sample = col.find_one() or {}
+ fields = sorted(k for k in sample if k != "_id")
+ coll: dict[str, Any] = {"name": cname, "documents": docs, "fields": fields}
+ if cname == "events" and docs:
+ try:
+ pipe = [
+ {"$sample": {"size": 5000}},
+ {"$group": {"_id": "$type", "count": {"$sum": 1}}},
+ {"$sort": {"count": -1}},
+ {"$limit": 5},
+ ]
+ coll["sample_types"] = {r["_id"]: r["count"] for r in col.aggregate(pipe, maxTimeMS=5000)}
+ except Exception:
+ pass
+ collections.append(coll)
+ out["database"] = "supplychain"
+ out["collections"] = collections
+ out["ok"] = True
+ client.close()
+ except Exception as exc:
+ out["error"] = str(exc)
+ return out
+
+
+def _inventory_cassandra() -> dict[str, Any]:
+ out: dict[str, Any] = {"engine": "Cassandra", "host": DB_HOST, "ok": False}
+ try:
+ from cassandra.cluster import Cluster
+
+ cluster = Cluster([DB_HOST], connect_timeout=5)
+ session = cluster.connect()
+ keyspaces = [
+ r.keyspace_name
+ for r in session.execute("SELECT keyspace_name FROM system_schema.keyspaces")
+ if r.keyspace_name not in (
+ "system", "system_schema", "system_traces", "system_distributed",
+ "system_virtual_schema", "system_auth", "system_views",
+ )
+ ]
+ tables_out = []
+ for ks in keyspaces:
+ for row in session.execute(
+ "SELECT table_name FROM system_schema.tables WHERE keyspace_name=%s", (ks,),
+ ):
+ tables_out.append({
+ "keyspace": ks,
+ "name": row.table_name,
+ "rows": None,
+ "note": "COUNT skipped (large table; use Trino/Iceberg for analytics)",
+ })
+ out["keyspaces"] = keyspaces
+ out["tables"] = tables_out
+ out["ok"] = True
+ cluster.shutdown()
+ except Exception as exc:
+ out["error"] = str(exc)
+ return out
+
+
+def _inventory_neo4j() -> dict[str, Any]:
+ out: dict[str, Any] = {"engine": "Neo4j", "host": DB_HOST, "ok": False}
+ try:
+ from neo4j import GraphDatabase
+
+ driver = GraphDatabase.driver(f"bolt://{DB_HOST}:7687", auth=(NEO4J_USER, NEO4J_PASS))
+ with driver.session() as session:
+ nodes = [
+ {"label": r["lbl"], "count": r["c"]}
+ for r in session.run(
+ "MATCH (n) RETURN labels(n)[0] AS lbl, count(*) AS c ORDER BY c DESC LIMIT 10",
+ )
+ ]
+ rels = [
+ {"type": r["t"], "count": r["c"]}
+ for r in session.run(
+ "MATCH ()-[r]->() RETURN type(r) AS t, count(*) AS c ORDER BY c DESC LIMIT 10",
+ )
+ ]
+ out["nodes"] = nodes
+ out["relationships"] = rels
+ out["ok"] = True
+ driver.close()
+ except Exception as exc:
+ out["error"] = str(exc)
+ return out
+
+
+def _run_with_timeout(fn, timeout: float) -> dict[str, Any]:
+ future = _executor.submit(fn)
+ try:
+ return future.result(timeout=timeout)
+ except FuturesTimeout:
+ return {"engine": fn.__name__.replace("_inventory_", ""), "ok": False, "error": f"timeout after {timeout}s"}
+ except Exception as exc:
+ return {"ok": False, "error": str(exc)}
+
+
+def collect_database_inventory_sync() -> dict[str, Any]:
+ fns = {
+ "postgresql": _inventory_postgres,
+ "mysql": _inventory_mysql,
+ "mongodb": _inventory_mongo,
+ "cassandra": _inventory_cassandra,
+ "neo4j": _inventory_neo4j,
+ }
+ engines = {k: _run_with_timeout(fn, ENGINE_TIMEOUT) for k, fn in fns.items()}
+ ok_count = sum(1 for e in engines.values() if e.get("ok"))
+ return {"host": DB_HOST, "engines_ok": ok_count, "engines_total": len(engines), "engines": engines}
+
+
+async def collect_database_inventory() -> dict[str, Any]:
+ loop = asyncio.get_event_loop()
+ return await loop.run_in_executor(_executor, collect_database_inventory_sync)
diff --git a/api/db.py b/api/db.py
new file mode 100644
index 0000000..ab30261
--- /dev/null
+++ b/api/db.py
@@ -0,0 +1,164 @@
+"""Database engine, session factory, and startup migrations."""
+
+from __future__ import annotations
+
+import os
+import sqlite3
+from typing import Any
+
+from sqlalchemy import create_engine, inspect, text
+from sqlalchemy.orm import sessionmaker
+
+DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:////data/atc-agents.db")
+SQLITE_FALLBACK_PATH = os.getenv("SQLITE_FALLBACK_PATH", "/data/atc-agents.db")
+
+
+def make_engine(url: str = DATABASE_URL):
+ kwargs: dict[str, Any] = {"pool_pre_ping": True}
+ if url.startswith("sqlite"):
+ kwargs["connect_args"] = {"check_same_thread": False}
+ return create_engine(url, **kwargs)
+
+
+engine = make_engine()
+SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
+
+
+def migrate_approval_columns(db_engine: Any = engine) -> None:
+ """Add approval columns on legacy SQLite/Postgres schemas."""
+ insp = inspect(db_engine)
+ if "approvals" not in insp.get_table_names():
+ return
+
+ existing = {c["name"] for c in insp.get_columns("approvals")}
+ dialect = db_engine.dialect.name
+
+ if dialect == "postgresql":
+ alters = [
+ ("action_type", "VARCHAR(64)", "generic.mutate"),
+ ("target", "TEXT", ""),
+ ("payload", "TEXT", "{}"),
+ ("decided_by", "VARCHAR(64)", None),
+ ("decide_note", "TEXT", None),
+ ("decided_at", "TIMESTAMP WITH TIME ZONE", None),
+ ("priority", "VARCHAR(16)", "normal"),
+ ]
+ with db_engine.begin() as conn:
+ for col, typ, default in alters:
+ if col in existing:
+ continue
+ if default is None:
+ conn.execute(text(f"ALTER TABLE approvals ADD COLUMN IF NOT EXISTS {col} {typ}"))
+ elif default == "":
+ conn.execute(text(f"ALTER TABLE approvals ADD COLUMN IF NOT EXISTS {col} {typ} DEFAULT ''"))
+ else:
+ conn.execute(
+ text(f"ALTER TABLE approvals ADD COLUMN IF NOT EXISTS {col} {typ} DEFAULT '{default}'")
+ )
+ return
+
+ alters = [
+ ("action_type", "VARCHAR(64)", "'generic.mutate'"),
+ ("target", "TEXT", "''"),
+ ("payload", "TEXT", "'{}'"),
+ ("decided_by", "VARCHAR(64)", "NULL"),
+ ("decide_note", "TEXT", "NULL"),
+ ("decided_at", "DATETIME", "NULL"),
+ ("priority", "VARCHAR(16)", "'normal'"),
+ ]
+ with db_engine.begin() as conn:
+ for col, typ, default in alters:
+ if col not in existing:
+ conn.execute(text(f"ALTER TABLE approvals ADD COLUMN {col} {typ} DEFAULT {default}"))
+
+
+def migrate_sqlite_to_postgres(db_engine: Any = engine) -> dict[str, int]:
+ """One-time copy from legacy SQLite volume into Postgres when Postgres is empty."""
+ if not DATABASE_URL.startswith("postgresql"):
+ return {}
+ if not os.path.isfile(SQLITE_FALLBACK_PATH):
+ return {}
+
+ insp = inspect(db_engine)
+ tables = set(insp.get_table_names())
+ if "approvals" not in tables or "feed" not in tables:
+ return {}
+
+ with db_engine.connect() as conn:
+ approval_count = conn.execute(text("SELECT COUNT(*) FROM approvals")).scalar() or 0
+ feed_count = conn.execute(text("SELECT COUNT(*) FROM feed")).scalar() or 0
+ if approval_count or feed_count:
+ return {"approvals": 0, "feed": 0, "skipped": 1}
+
+ copied = {"approvals": 0, "feed": 0}
+ src = sqlite3.connect(SQLITE_FALLBACK_PATH)
+ src.row_factory = sqlite3.Row
+ try:
+ with db_engine.begin() as conn:
+ for row in src.execute("SELECT * FROM feed"):
+ conn.execute(
+ text(
+ "INSERT INTO feed (id, ts, agent_id, level, message) "
+ "VALUES (:id, :ts, :agent_id, :level, :message) ON CONFLICT (id) DO NOTHING"
+ ),
+ dict(row),
+ )
+ copied["feed"] += 1
+ for row in src.execute("SELECT * FROM approvals"):
+ conn.execute(
+ text(
+ "INSERT INTO approvals (id, ts, agent_id, action, reason, status, "
+ "action_type, target, payload, decided_by, decide_note, decided_at, priority) "
+ "VALUES (:id, :ts, :agent_id, :action, :reason, :status, "
+ ":action_type, :target, :payload, :decided_by, :decide_note, :decided_at, :priority) "
+ "ON CONFLICT (id) DO NOTHING"
+ ),
+ {
+ "id": row["id"],
+ "ts": row["ts"],
+ "agent_id": row["agent_id"],
+ "action": row["action"],
+ "reason": row["reason"],
+ "status": row["status"],
+ "action_type": row["action_type"] if "action_type" in row.keys() else "generic.mutate",
+ "target": row["target"] if "target" in row.keys() else "",
+ "payload": row["payload"] if "payload" in row.keys() else "{}",
+ "decided_by": row["decided_by"] if "decided_by" in row.keys() else None,
+ "decide_note": row["decide_note"] if "decide_note" in row.keys() else None,
+ "decided_at": row["decided_at"] if "decided_at" in row.keys() else None,
+ "priority": row["priority"] if "priority" in row.keys() else "normal",
+ },
+ )
+ copied["approvals"] += 1
+ finally:
+ src.close()
+ return copied
+
+
+def init_database(Base: type) -> dict[str, Any]:
+ """Create tables, run migrations, optionally import legacy SQLite data."""
+ Base.metadata.create_all(engine)
+ migrate_approval_columns(engine)
+ migrated = migrate_sqlite_to_postgres(engine)
+ return {"engine": engine.dialect.name, "migrated_from_sqlite": migrated}
+
+
+def db_health() -> dict[str, Any]:
+ try:
+ with engine.connect() as conn:
+ conn.execute(text("SELECT 1"))
+ insp = inspect(engine)
+ tables = insp.get_table_names()
+ stats = {}
+ if "approvals" in tables:
+ with engine.connect() as conn:
+ stats["approvals"] = conn.execute(text("SELECT COUNT(*) FROM approvals")).scalar()
+ stats["approvals_pending"] = conn.execute(
+ text("SELECT COUNT(*) FROM approvals WHERE status = 'pending'")
+ ).scalar()
+ if "feed" in tables:
+ with engine.connect() as conn:
+ stats["feed"] = conn.execute(text("SELECT COUNT(*) FROM feed")).scalar()
+ return {"ok": True, "dialect": engine.dialect.name, "tables": tables, **stats}
+ except Exception as exc:
+ return {"ok": False, "dialect": engine.dialect.name, "error": str(exc)}
diff --git a/api/dockhand_envs.py b/api/dockhand_envs.py
new file mode 100644
index 0000000..315449d
--- /dev/null
+++ b/api/dockhand_envs.py
@@ -0,0 +1,20 @@
+"""Dockhand environment IDs on atc-docker01 (10.0.21.45:8082)."""
+
+DOCKHAND_URL = "http://10.0.21.45:8082"
+
+DOCKHAND_ENVS: dict[str, int] = {
+ "docker01": 1,
+ "docker02": 2,
+ "management": 3,
+ "db02": 5,
+ "bart_gpu": 6,
+ "mo_gpu": 7,
+ "gpu_dev": 8,
+ "lakehouse": 9,
+ "airflow": 10,
+ "dns1": 11,
+ "dns2": 12,
+ "command_center": 13,
+}
+
+DOCKHAND_ENV_COMMAND_CENTER = 13
diff --git a/api/lab_context.py b/api/lab_context.py
new file mode 100644
index 0000000..186d008
--- /dev/null
+++ b/api/lab_context.py
@@ -0,0 +1,745 @@
+"""Live lab metrics for all ATC domains — fed to vLLM as context."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+import time
+from datetime import datetime, timezone
+from typing import Any
+
+import httpx
+
+from agent_terminal import TerminalLogFn
+from dockhand_envs import DOCKHAND_ENV_COMMAND_CENTER, DOCKHAND_ENVS
+from database_inventory import collect_database_inventory
+from node_registry import NODE_REGISTRY
+
+DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082")
+HDFS_NN_URL = os.getenv("HDFS_NN_URL", "http://10.0.21.61:9870")
+LAKEHOUSE_HOST = os.getenv("LAKEHOUSE_HOST", "10.0.21.50")
+AIRFLOW_URL = os.getenv("AIRFLOW_URL", "http://10.0.21.55:8080")
+KAFKA_UI_URL = os.getenv("KAFKA_UI_URL", "http://10.0.21.36:9000")
+KAFKA_CONNECT_URL = os.getenv("KAFKA_CONNECT_URL", f"http://{LAKEHOUSE_HOST}:8083")
+TRINO_URL = os.getenv("TRINO_URL", f"http://{LAKEHOUSE_HOST}:8089")
+SPARK_UI_URL = os.getenv("SPARK_UI_URL", f"http://{LAKEHOUSE_HOST}:8080")
+GPU_URL = os.getenv("GPU_URL", "http://10.0.20.106:9000")
+OBJECTSCALE_URL = os.getenv("OBJECTSCALE_URL", "http://10.0.20.111:9020")
+YARN_URL = os.getenv("YARN_URL", "http://10.0.21.61:8088")
+
+AGENT_PRIMARY_DOMAIN = {
+ "infra-sentinel": "docker",
+ "data-custodian": "databases",
+ "lakehouse-ops": "lakehouse",
+ "hadoop-ranger": "hadoop",
+ "etl-guardian": "etl",
+}
+
+
+async def _log(log: TerminalLogFn | None, level: str, phase: str, text: str) -> None:
+ if log:
+ await log(level, phase, text)
+
+
+async def _get_json(
+ client: httpx.AsyncClient,
+ url: str,
+ log: TerminalLogFn | None = None,
+ label: str = "",
+ timeout: float = 6.0,
+) -> Any | None:
+ name = label or url
+ t0 = time.monotonic()
+ await _log(log, "cmd", "fetch", f"$ GET {url}")
+ try:
+ r = await client.get(url, timeout=timeout)
+ ms = int((time.monotonic() - t0) * 1000)
+ if r.status_code < 400:
+ await _log(log, "ok", "fetch", f"← {r.status_code} {name} ({ms}ms)")
+ return r.json()
+ await _log(log, "warn", "fetch", f"← {r.status_code} {name} ({ms}ms)")
+ except Exception as exc:
+ ms = int((time.monotonic() - t0) * 1000)
+ await _log(log, "err", "fetch", f"✗ {name}: {exc} ({ms}ms)")
+ return None
+
+
+async def _probe_ok(
+ client: httpx.AsyncClient,
+ url: str,
+ log: TerminalLogFn | None = None,
+ label: str = "",
+) -> bool:
+ name = label or url
+ t0 = time.monotonic()
+ await _log(log, "cmd", "probe", f"$ GET {url}")
+ try:
+ r = await client.get(url, timeout=4.0)
+ ms = int((time.monotonic() - t0) * 1000)
+ ok = r.status_code < 500
+ await _log(log, "ok" if ok else "warn", "probe", f"← {r.status_code} {name} ({'UP' if ok else 'DOWN'}, {ms}ms)")
+ return ok
+ except Exception as exc:
+ ms = int((time.monotonic() - t0) * 1000)
+ await _log(log, "err", "probe", f"✗ {name}: {exc} ({ms}ms)")
+ return False
+
+
+def _container_rows(containers: list[dict], host: str = "") -> list[dict[str, Any]]:
+ rows = []
+ for c in containers:
+ ports = sorted({str(p.get("PublicPort")) for p in c.get("ports", []) if p.get("PublicPort")})
+ rows.append({
+ "name": c.get("name"),
+ "state": c.get("state"),
+ "image": c.get("image"),
+ "status": c.get("status"),
+ "ports": ports,
+ "host": host,
+ })
+ return rows
+
+
+async def dockhand_containers(
+ client: httpx.AsyncClient,
+ env_id: int,
+ log: TerminalLogFn | None = None,
+) -> list[dict]:
+ url = f"{DOCKHAND_URL}/api/containers?env={env_id}"
+ await _log(log, "cmd", "fetch", f"$ GET {url}")
+ t0 = time.monotonic()
+ try:
+ r = await client.get(f"{DOCKHAND_URL}/api/containers", params={"env": env_id}, timeout=8.0)
+ ms = int((time.monotonic() - t0) * 1000)
+ r.raise_for_status()
+ data = r.json()
+ await _log(log, "ok", "fetch", f"← Dockhand env {env_id}: {len(data)} containers ({ms}ms)")
+ return data
+ except Exception as exc:
+ ms = int((time.monotonic() - t0) * 1000)
+ await _log(log, "err", "fetch", f"✗ Dockhand env {env_id}: {exc} ({ms}ms)")
+ return []
+
+
+async def collect_hdfs(client: httpx.AsyncClient, log: TerminalLogFn | None = None) -> dict[str, Any]:
+ ctx: dict[str, Any] = {"reachable": False, "namenode": HDFS_NN_URL}
+ await _log(log, "info", "fetch", "▸ HDFS NameNode JMX metrics")
+ try:
+ fs_url = f"{HDFS_NN_URL}/jmx?qry=Hadoop:service=NameNode,name=FSNamesystem"
+ nn_url = f"{HDFS_NN_URL}/jmx?qry=Hadoop:service=NameNode,name=NameNodeInfo"
+ t0 = time.monotonic()
+ await _log(log, "cmd", "fetch", f"$ GET {fs_url}")
+ await _log(log, "cmd", "fetch", f"$ GET {nn_url}")
+ fs_r, nn_r = await asyncio.gather(
+ client.get(fs_url),
+ client.get(nn_url),
+ return_exceptions=True,
+ )
+ ms = int((time.monotonic() - t0) * 1000)
+ if isinstance(fs_r, httpx.Response) and fs_r.status_code == 200:
+ beans = fs_r.json().get("beans", [])
+ if beans:
+ b = beans[0]
+ ctx.update({
+ "reachable": True,
+ "hostname": b.get("tag.Hostname"),
+ "ha_state": b.get("tag.HAState"),
+ "capacity_total_gb": b.get("CapacityTotalGB"),
+ "capacity_used_gb": b.get("CapacityUsedGB"),
+ "capacity_remaining_gb": b.get("CapacityRemainingGB"),
+ "files_total": b.get("FilesTotal"),
+ "blocks_total": b.get("BlocksTotal"),
+ "live_datanodes": b.get("NumLiveDataNodes"),
+ "dead_datanodes": b.get("NumDeadDataNodes"),
+ "missing_blocks": b.get("MissingBlocks"),
+ "under_replicated_blocks": b.get("UnderReplicatedBlocks"),
+ "corrupt_blocks": b.get("CorruptBlocks"),
+ "default_replication_factor": 3,
+ })
+ await _log(
+ log, "ok", "fetch",
+ f"← HDFS: {b.get('CapacityUsedGB')}GB used, {b.get('FilesTotal')} files, "
+ f"{b.get('NumLiveDataNodes')} datanodes ({ms}ms)",
+ )
+ else:
+ await _log(log, "warn", "fetch", f"← FSNamesystem JMX failed ({ms}ms)")
+
+ if isinstance(nn_r, httpx.Response) and nn_r.status_code == 200:
+ beans = nn_r.json().get("beans", [])
+ if beans:
+ b = beans[0]
+ live = json.loads(b.get("LiveNodes") or "{}")
+ ctx["hdfs_version"] = b.get("Version")
+ ctx["safemode"] = b.get("Safemode") or "off"
+ ctx["percent_used"] = round(float(b.get("PercentUsed", 0)) * 100, 4)
+ ctx["datanodes"] = [
+ {
+ "host": host.split(":")[0],
+ "capacity_gb": round(node.get("capacity", 0) / (1024**3), 1),
+ "used_gb": round(node.get("used", 0) / (1024**3), 4),
+ "blocks": node.get("numBlocks", 0),
+ "state": node.get("adminState"),
+ }
+ for host, node in live.items()
+ ]
+
+ yarn_url = f"{YARN_URL}/ws/v1/cluster/info"
+ await _log(log, "cmd", "fetch", f"$ GET {yarn_url}")
+ try:
+ yr = await client.get(yarn_url, timeout=4.0)
+ if yr.status_code == 200:
+ yinfo = yr.json().get("clusterInfo", {})
+ ctx["yarn_ok"] = True
+ ctx["yarn_state"] = yinfo.get("state", "UNKNOWN")
+ ctx["yarn_rm"] = YARN_URL
+ await _log(log, "ok", "fetch", f"← YARN RM: {yinfo.get('state', '?')}")
+ else:
+ ctx["yarn_ok"] = False
+ except Exception:
+ ctx["yarn_ok"] = False
+
+ except Exception as exc:
+ ctx["error"] = str(exc)
+ await _log(log, "err", "fetch", f"✗ HDFS: {exc}")
+ return ctx
+
+
+async def collect_etl(client: httpx.AsyncClient, log: TerminalLogFn | None = None) -> dict[str, Any]:
+ await _log(log, "info", "fetch", "▸ ETL stack (Airflow, Kafka, Spark)")
+ health, kafka_ok, spark_ok = await asyncio.gather(
+ _get_json(client, f"{AIRFLOW_URL}/api/v2/monitor/health", log, "Airflow health"),
+ _probe_ok(client, KAFKA_UI_URL, log, "Kafka UI"),
+ _probe_ok(client, SPARK_UI_URL, log, "Spark UI"),
+ )
+ connectors: list[str] = []
+ await _log(log, "cmd", "fetch", f"$ GET {KAFKA_CONNECT_URL}/connectors")
+ t0 = time.monotonic()
+ try:
+ r = await client.get(f"{KAFKA_CONNECT_URL}/connectors", timeout=5.0)
+ ms = int((time.monotonic() - t0) * 1000)
+ if r.status_code == 200:
+ connectors = r.json() if isinstance(r.json(), list) else []
+ await _log(log, "ok", "fetch", f"← Kafka Connect: {len(connectors)} connectors ({ms}ms)")
+ for c in connectors:
+ await _log(log, "info", "fetch", f" · {c}")
+ else:
+ await _log(log, "warn", "fetch", f"← Kafka Connect {r.status_code} ({ms}ms)")
+ except Exception as exc:
+ await _log(log, "err", "fetch", f"✗ Kafka Connect: {exc}")
+
+ airflow_detail: dict[str, str] = {}
+ if isinstance(health, dict):
+ for comp, info in health.items():
+ if isinstance(info, dict) and "status" in info:
+ airflow_detail[comp] = info["status"]
+ await _log(log, "info", "fetch", f" Airflow {comp}: {info['status']}")
+
+ connector_status: list[dict[str, Any]] = []
+ for name in connectors:
+ status_url = f"{KAFKA_CONNECT_URL}/connectors/{name}/status"
+ await _log(log, "cmd", "fetch", f"$ GET {status_url}")
+ try:
+ sr = await client.get(status_url, timeout=5.0)
+ if sr.status_code == 200:
+ st = sr.json()
+ conn = st.get("connector", {})
+ tasks = st.get("tasks", [])
+ state = conn.get("state", "UNKNOWN")
+ task_states = [t.get("state", "?") for t in tasks]
+ connector_status.append({
+ "name": name,
+ "state": state,
+ "tasks": task_states,
+ })
+ await _log(log, "info", "fetch", f" · {name}: {state} tasks={task_states}")
+ except Exception as exc:
+ connector_status.append({"name": name, "state": "ERROR", "error": str(exc)})
+
+ return {
+ "airflow_url": AIRFLOW_URL,
+ "airflow_healthy": airflow_detail.get("scheduler") == "healthy",
+ "airflow_components": airflow_detail,
+ "kafka_ui_url": KAFKA_UI_URL,
+ "kafka_ui_ok": kafka_ok,
+ "kafka_connect_url": KAFKA_CONNECT_URL,
+ "connectors": connectors,
+ "connector_status": connector_status,
+ "spark_ui_url": SPARK_UI_URL,
+ "spark_ui_ok": spark_ok,
+ }
+
+
+async def collect_lakehouse(
+ client: httpx.AsyncClient,
+ containers: list[dict],
+ log: TerminalLogFn | None = None,
+) -> dict[str, Any]:
+ await _log(log, "info", "fetch", "▸ Lakehouse (Trino, Spark, Kafka Connect)")
+ trino_info = await _get_json(client, f"{TRINO_URL}/v1/info", log, "Trino /v1/info")
+ running = sum(1 for c in containers if c.get("state") == "running")
+ for c in containers:
+ ports = ",".join(str(p.get("PublicPort")) for p in c.get("ports", []) if p.get("PublicPort")) or "internal"
+ await _log(log, "info", "fetch", f" · {c.get('name')}: {c.get('state')} ports={ports}")
+ return {
+ "host": LAKEHOUSE_HOST,
+ "trino_url": TRINO_URL,
+ "trino_ok": trino_info is not None,
+ "trino_version": (trino_info or {}).get("nodeVersion", {}).get("version"),
+ "trino_uptime": (trino_info or {}).get("uptime"),
+ "trino_coordinator": (trino_info or {}).get("coordinator"),
+ "spark_ui_url": SPARK_UI_URL,
+ "kafka_connect_url": KAFKA_CONNECT_URL,
+ "containers": _container_rows(containers, LAKEHOUSE_HOST),
+ "running": running,
+ "total": len(containers),
+ }
+
+
+async def collect_databases(
+ client: httpx.AsyncClient,
+ containers: list[dict],
+ log: TerminalLogFn | None = None,
+) -> dict[str, Any]:
+ await _log(log, "info", "fetch", "▸ Database vault (Dockhand env 5)")
+ running = sum(1 for c in containers if c.get("state") == "running")
+ rows = _container_rows(containers)
+ by_engine: dict[str, list[str]] = {}
+ for r in rows:
+ img = (r.get("image") or "").lower()
+ name = (r.get("name") or "").lower()
+ if "postgres" in img or "postgres" in name:
+ engine = "PostgreSQL"
+ elif "mysql" in img or "mysql" in name:
+ engine = "MySQL"
+ elif "mongo" in img or "mongo" in name:
+ engine = "MongoDB"
+ elif "cassandra" in img or "cassandra" in name:
+ engine = "Cassandra"
+ elif "neo4j" in img or "neo4j" in name:
+ engine = "Neo4j"
+ else:
+ engine = "Other"
+ port_str = ",".join(r["ports"]) or "internal"
+ by_engine.setdefault(engine, []).append(f"{r['name']} ({r['state']}, ports {port_str})")
+ await _log(log, "info", "fetch", f" · {r['name']}: {r['state']} [{engine}] ports={port_str}")
+
+ return {
+ "dockhand_env": 5,
+ "running": running,
+ "total": len(containers),
+ "containers": rows,
+ "by_engine": by_engine,
+ }
+
+
+async def collect_command_center(
+ client: httpx.AsyncClient,
+ containers: list[dict],
+ log: TerminalLogFn | None = None,
+) -> dict[str, Any]:
+ await _log(log, "info", "fetch", f"▸ Command Center VM304 (Dockhand env {DOCKHAND_ENV_COMMAND_CENTER})")
+ running = sum(1 for c in containers if c.get("state") == "running")
+ rows = _container_rows(containers, "10.0.21.33")
+ for r in rows:
+ lvl = "info" if r.get("state") == "running" else "warn"
+ await _log(log, lvl, "fetch", f" · {r.get('name')}: {r.get('state')}")
+ return {
+ "dockhand_env": DOCKHAND_ENV_COMMAND_CENTER,
+ "dockhand_stack": "atc-agents-vm304",
+ "host": "10.0.21.33",
+ "vmid": 304,
+ "url": "http://10.0.21.33/",
+ "running": running,
+ "total": len(containers),
+ "containers": rows,
+ }
+
+
+async def collect_docker_rack(
+ client: httpx.AsyncClient,
+ containers: list[dict],
+ log: TerminalLogFn | None = None,
+) -> dict[str, Any]:
+ await _log(log, "info", "fetch", "▸ Docker rack (Dockhand env 1)")
+ running = sum(1 for c in containers if c.get("state") == "running")
+ not_running = [c["name"] for c in containers if c.get("state") != "running"]
+ for c in containers:
+ ports = ",".join(str(p.get("PublicPort")) for p in c.get("ports", []) if p.get("PublicPort")) or "internal"
+ lvl = "info" if c.get("state") == "running" else "warn"
+ await _log(log, lvl, "fetch", f" · {c.get('name')}: {c.get('state')} ports={ports}")
+ return {
+ "dockhand_url": DOCKHAND_URL,
+ "dockhand_env": 1,
+ "running": running,
+ "total": len(containers),
+ "not_running": not_running,
+ "containers": _container_rows(containers, "10.0.21.45"),
+ }
+
+
+async def collect_objectscale(client: httpx.AsyncClient, log: TerminalLogFn | None = None) -> dict[str, Any]:
+ await _log(log, "info", "fetch", "▸ ObjectScale S3 storage")
+ ctx: dict[str, Any] = {
+ "host": "10.0.20.111",
+ "url": OBJECTSCALE_URL,
+ "port": "9020",
+ "bucket": "data",
+ "reachable": False,
+ }
+ t0 = time.monotonic()
+ await _log(log, "cmd", "probe", f"$ GET {OBJECTSCALE_URL}")
+ try:
+ r = await client.get(OBJECTSCALE_URL, timeout=4.0)
+ ms = int((time.monotonic() - t0) * 1000)
+ # 403/401 means API is up but unauthenticated
+ ctx["reachable"] = r.status_code in (200, 401, 403, 405)
+ ctx["status_code"] = r.status_code
+ await _log(
+ log, "ok" if ctx["reachable"] else "warn", "probe",
+ f"← ObjectScale {r.status_code} ({'UP' if ctx['reachable'] else 'DOWN'}, {ms}ms)",
+ )
+ except Exception as exc:
+ ms = int((time.monotonic() - t0) * 1000)
+ ctx["error"] = str(exc)
+ await _log(log, "err", "probe", f"✗ ObjectScale: {exc} ({ms}ms)")
+ return ctx
+
+
+async def collect_gpu_metrics(client: httpx.AsyncClient, log: TerminalLogFn | None = None) -> dict[str, Any]:
+ await _log(log, "info", "fetch", "▸ GPU Lab metrics")
+ base = {"ok": False, "host": GPU_URL, "ui_url": GPU_URL}
+ try:
+ metrics_url = f"{GPU_URL}/api/gpu/metrics"
+ model_url = f"{GPU_URL}/api/active-model"
+ await _log(log, "cmd", "fetch", f"$ GET {metrics_url}")
+ await _log(log, "cmd", "fetch", f"$ GET {model_url}")
+ t0 = time.monotonic()
+ metrics_r, model_r = await asyncio.gather(
+ client.get(metrics_url),
+ client.get(model_url),
+ return_exceptions=True,
+ )
+ ms = int((time.monotonic() - t0) * 1000)
+ gpus: list[dict[str, Any]] = []
+ if isinstance(metrics_r, httpx.Response) and metrics_r.status_code == 200:
+ current = metrics_r.json().get("current", {})
+ gpus = [
+ {
+ "index": g["index"],
+ "name": g["name"],
+ "util_gpu": g.get("util_gpu", 0),
+ "memory_used_mib": g.get("memory_used_mib", 0),
+ "memory_total_mib": g.get("memory_total_mib", 0),
+ "temperature_c": g.get("temperature_c", 0),
+ "power_w": g.get("power_w", 0),
+ }
+ for g in current.get("gpus", [])
+ ]
+ await _log(log, "ok", "fetch", f"← GPU metrics: {len(gpus)} devices ({ms}ms)")
+ for g in gpus:
+ await _log(
+ log, "info", "fetch",
+ f" GPU{g['index']}: util {g['util_gpu']:.0f}% VRAM "
+ f"{g['memory_used_mib']:.0f}/{g['memory_total_mib']:.0f} MiB",
+ )
+
+ active_model = None
+ inference_active = False
+ vllm_url = None
+ if isinstance(model_r, httpx.Response) and model_r.status_code == 200:
+ model_data = model_r.json()
+ active_model = model_data.get("name")
+ inference_active = bool(model_data.get("inference_active"))
+ vllm_url = model_data.get("base_url")
+ await _log(log, "ok", "fetch", f"← Active model: {active_model} inference={'ON' if inference_active else 'OFF'}")
+
+ return {
+ **base,
+ "ok": len(gpus) > 0 or inference_active,
+ "inference_active": inference_active,
+ "active_model": active_model,
+ "vllm_url": vllm_url,
+ "gpu_count": len(gpus),
+ "gpus": gpus,
+ }
+ except Exception as exc:
+ await _log(log, "err", "fetch", f"✗ GPU Lab: {exc}")
+ return {**base, "error": str(exc)}
+
+
+def _section_docker(d: dict[str, Any]) -> list[str]:
+ lines = [
+ f"Docker rack (Dockhand env 1): {d['running']}/{d['total']} running",
+ f"Dockhand: {d['dockhand_url']}",
+ ]
+ if d.get("not_running"):
+ lines.append(f"Not running: {', '.join(d['not_running'])}")
+ for c in d.get("containers", []):
+ port_str = ",".join(c["ports"]) if c["ports"] else "internal"
+ lines.append(f" - {c['name']}: {c['state']} | {c['image']} | ports {port_str}")
+ return lines
+
+
+def _fmt_count(n: Any) -> str:
+ if n is None:
+ return "?"
+ try:
+ return f"{int(n):,}"
+ except (TypeError, ValueError):
+ return str(n)
+
+
+def _section_databases(d: dict[str, Any]) -> list[str]:
+ lines = [f"Databases (Dockhand env {d['dockhand_env']}): {d['running']}/{d['total']} running"]
+ for engine, items in d.get("by_engine", {}).items():
+ lines.append(f" {engine}:")
+ for item in items:
+ lines.append(f" - {item}")
+ inv = d.get("inventory") or {}
+ if inv:
+ lines.append(
+ f" Live data inventory @ {inv.get('host', '?')}: "
+ f"{inv.get('engines_ok', 0)}/{inv.get('engines_total', 0)} engines queried"
+ )
+ for key, eng in (inv.get("engines") or {}).items():
+ if not eng.get("ok"):
+ err = str(eng.get("error", "unknown"))[:100]
+ lines.append(f" {eng.get('engine', key)}: ERROR — {err}")
+ continue
+ label = eng.get("engine", key)
+ if eng.get("size_human"):
+ lines.append(f" {label} ({eng.get('database', '')}): {eng['size_human']}")
+ for tbl in eng.get("tables") or []:
+ rows = tbl.get("rows")
+ cols = ", ".join((tbl.get("columns") or [])[:8])
+ extra = ""
+ if tbl.get("top_regions"):
+ extra = f" | regions: {tbl['top_regions']}"
+ elif tbl.get("top_event_types"):
+ extra = f" | event_types: {tbl['top_event_types']}"
+ lines.append(f" · {tbl['name']}: {_fmt_count(rows)} rows | cols: {cols}{extra}")
+ for coll in eng.get("collections") or []:
+ docs = coll.get("documents")
+ fields = ", ".join(coll.get("fields") or [])
+ extra = f" | types: {coll['top_types']}" if coll.get("top_types") else ""
+ lines.append(f" · {coll['name']}: {_fmt_count(docs)} docs | fields: {fields}{extra}")
+ for node in eng.get("nodes") or []:
+ lines.append(f" · {node['label']} nodes: {_fmt_count(node.get('count'))}")
+ if eng.get("relationships"):
+ rels = ", ".join(f"{r['type']}={_fmt_count(r.get('count'))}" for r in eng["relationships"][:5])
+ lines.append(f" · relationships: {rels or 'none'}")
+ return lines
+
+
+def _section_lakehouse(d: dict[str, Any]) -> list[str]:
+ lines = [
+ f"Lakehouse host: {d['host']} — {d['running']}/{d['total']} containers running",
+ f"Trino: {d['trino_url']} — {'UP' if d['trino_ok'] else 'DOWN'}"
+ + (f" (v{d['trino_version']}, uptime {d.get('trino_uptime')})" if d.get("trino_ok") else ""),
+ f"Spark UI: {d['spark_ui_url']}",
+ f"Kafka Connect: {d['kafka_connect_url']}",
+ ]
+ for c in d.get("containers", []):
+ port_str = ",".join(c["ports"]) if c["ports"] else "internal"
+ lines.append(f" - {c['name']}: {c['state']} | {c['image']} | ports {port_str}")
+ return lines
+
+
+def _section_etl(d: dict[str, Any]) -> list[str]:
+ lines = [
+ f"Airflow ({d['airflow_url']}): {'HEALTHY' if d['airflow_healthy'] else 'DEGRADED'}",
+ ]
+ for comp, st in d.get("airflow_components", {}).items():
+ lines.append(f" - {comp}: {st}")
+ lines.append(f"Kafka UI ({d['kafka_ui_url']}): {'UP' if d['kafka_ui_ok'] else 'DOWN'}")
+ lines.append(f"Kafka Connect ({d['kafka_connect_url']}): connectors {d.get('connectors') or 'none listed'}")
+ if d.get("connectors"):
+ lines.append(" Registered connector names (exact): " + ", ".join(d["connectors"]))
+ for cs in d.get("connector_status") or []:
+ tasks = cs.get("tasks") or []
+ lines.append(f" Connector {cs['name']}: {cs.get('state', '?')}" + (f" tasks={tasks}" if tasks else ""))
+ lines.append(f"Spark UI ({d['spark_ui_url']}): {'UP' if d['spark_ui_ok'] else 'DOWN'}")
+ return lines
+
+
+def _section_hadoop(h: dict[str, Any]) -> list[str]:
+ lines = ["HDFS / Hadoop:"]
+ if not h.get("reachable"):
+ lines.append(f" UNREACHABLE: {h.get('error', 'NameNode probe failed')}")
+ return lines
+ lines.extend([
+ f" NameNode: {h['namenode']} ({h.get('hostname')}, HA {h.get('ha_state')})",
+ f" Version: {h.get('hdfs_version')}, safemode: {h.get('safemode')}",
+ f" Capacity: {h.get('capacity_used_gb')} GB used / {h.get('capacity_total_gb')} GB total "
+ f"({h.get('capacity_remaining_gb')} GB free, {h.get('percent_used', 0)}% used)",
+ f" Files: {h.get('files_total')}, Blocks: {h.get('blocks_total')}",
+ f" DataNodes: {h.get('live_datanodes')} live, {h.get('dead_datanodes')} dead",
+ f" Replication factor (dfs.replication): {h.get('default_replication_factor')}",
+ f" Block health: missing={h.get('missing_blocks')}, under-replicated={h.get('under_replicated_blocks')}, corrupt={h.get('corrupt_blocks')}",
+ ])
+ for dn in h.get("datanodes", []):
+ lines.append(
+ f" - {dn['host']}: {dn['used_gb']} GB / {dn['capacity_gb']} GB, {dn['blocks']} blocks, {dn['state']}"
+ )
+ if (h.get("capacity_used_gb") or 0) < 0.01 and (h.get("files_total") or 0) > 0:
+ lines.append(" Note: metadata/small files only — almost no user data stored yet.")
+ return lines
+
+
+def _section_gpu(g: dict[str, Any]) -> list[str]:
+ lines = ["GPU Lab / vLLM inference:"]
+ if not g.get("ok"):
+ lines.append(f" OFFLINE: {g.get('error', 'unreachable')}")
+ return lines
+ lines.extend([
+ f" Manager: {g.get('ui_url')}",
+ f" Model: {g.get('active_model')} (inference {'ON' if g.get('inference_active') else 'OFF'})",
+ f" vLLM endpoint: {g.get('vllm_url')}",
+ f" GPUs: {g.get('gpu_count')}x V100",
+ ])
+ for gpu in g.get("gpus", []):
+ lines.append(
+ f" GPU{gpu['index']}: util {gpu['util_gpu']:.0f}%, "
+ f"VRAM {gpu['memory_used_mib']:.0f}/{gpu['memory_total_mib']:.0f} MiB, "
+ f"{gpu['temperature_c']}°C, {gpu['power_w']:.0f}W"
+ )
+ return lines
+
+
+
+
+def _section_objectscale(o: dict[str, Any]) -> list[str]:
+ lines = [
+ f"ObjectScale S3 ({o.get('host')}:{o.get('port')}): {'REACHABLE' if o.get('reachable') else 'DOWN'}",
+ f" API: {o.get('url')} (HTTP {o.get('status_code', '?')})",
+ f" Bucket: {o.get('bucket', 'data')} — landing zone for s3-kafka-consumer & Iceberg",
+ ]
+ if o.get("error"):
+ lines.append(f" Error: {o['error']}")
+ return lines
+
+
+def _section_command_center(c: dict[str, Any]) -> list[str]:
+ lines = [
+ f"Command Center VM304: {c.get('host')} — {c.get('running', 0)}/{c.get('total', 0)} containers",
+ f" URL: {c.get('url')}",
+ f" Dockhand env: {c.get('dockhand_env')}",
+ ]
+ for row in c.get("containers", []):
+ port_str = ",".join(row["ports"]) if row.get("ports") else "internal"
+ lines.append(f" - {row['name']}: {row['state']} | ports {port_str}")
+ return lines
+
+
+def _section_cluster_registry(_: dict[str, Any]) -> list[str]:
+ """Static cluster map — always available even when probes fail."""
+ lines = ["Cluster infrastructure map (Proxmox VMs & roles):"]
+ for nid, node in NODE_REGISTRY.items():
+ if nid in ("mo-commander", "bart-commander", "network-watcher", "mcp-coordinator"):
+ continue
+ vmid = node.get("vmid", "?")
+ lines.append(
+ f" - {node['label']}: {node.get('vm')} VMID {vmid} @ {node.get('ip')} — {node.get('role')}"
+ )
+ desc = node.get("description") or ""
+ if desc:
+ lines.append(f" {desc[:140]}")
+ lines.append("")
+ lines.append("Supervisors & control plane:")
+ for nid in ("mo-commander", "bart-commander", "mcp-coordinator", "network-watcher"):
+ node = NODE_REGISTRY[nid]
+ lines.append(f" - {node['label']}: {(node.get('description') or '')[:120]}")
+ return lines
+
+SECTION_BUILDERS = {
+ "docker": _section_docker,
+ "databases": _section_databases,
+ "lakehouse": _section_lakehouse,
+ "etl": _section_etl,
+ "hadoop": _section_hadoop,
+ "gpu": _section_gpu,
+ "objectscale": _section_objectscale,
+ "command_center": _section_command_center,
+ "cluster_registry": _section_cluster_registry,
+}
+
+DOMAIN_ORDER = ["docker", "databases", "lakehouse", "etl", "hadoop", "gpu", "objectscale", "command_center", "cluster_registry"]
+
+
+async def collect_full_lab_context(
+ gpu_data: dict[str, Any] | None = None,
+ log: TerminalLogFn | None = None,
+ include_inventory: bool = True,
+) -> dict[str, Any]:
+ """Gather all lab domains in parallel with optional live terminal logging."""
+ await _log(log, "info", "fetch", "═══ Lab snapshot collection started ═══")
+ async with httpx.AsyncClient(timeout=10.0, verify=False) as client:
+ if gpu_data is None:
+ gpu_data = await collect_gpu_metrics(client, log)
+
+ docker_raw, db_raw, lake_raw, cc_raw, hdfs, etl, objectscale = await asyncio.gather(
+ dockhand_containers(client, DOCKHAND_ENVS["docker01"], log),
+ dockhand_containers(client, DOCKHAND_ENVS["db02"], log),
+ dockhand_containers(client, DOCKHAND_ENVS["lakehouse"], log),
+ dockhand_containers(client, DOCKHAND_ENV_COMMAND_CENTER, log),
+ collect_hdfs(client, log),
+ collect_etl(client, log),
+ collect_objectscale(client, log),
+ )
+ docker = await collect_docker_rack(client, docker_raw, log)
+ databases = await collect_databases(client, db_raw, log)
+ if include_inventory:
+ try:
+ databases["inventory"] = await collect_database_inventory()
+ inv_ok = databases["inventory"].get("engines_ok", 0)
+ await _log(log, "ok", "fetch", f"← Database inventory: {inv_ok} engines")
+ except Exception as exc:
+ await _log(log, "warn", "fetch", f"✗ Database inventory: {exc}")
+ databases["inventory"] = {"error": str(exc)}
+ lakehouse = await collect_lakehouse(client, lake_raw, log)
+ command_center = await collect_command_center(client, cc_raw, log)
+
+ await _log(log, "ok", "fetch", "═══ Lab snapshot complete ═══")
+ return {
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "docker": docker,
+ "databases": databases,
+ "lakehouse": lakehouse,
+ "etl": etl,
+ "hadoop": hdfs,
+ "gpu": gpu_data,
+ "objectscale": objectscale,
+ "command_center": command_center,
+ }
+
+
+def format_context_for_agent(agent_id: str, snapshot: dict[str, Any]) -> str:
+ """Format full lab snapshot for LLM; primary domain first."""
+ primary = AGENT_PRIMARY_DOMAIN.get(agent_id, "docker")
+ lines = [
+ f"ATC Lab live snapshot — {snapshot.get('ts')}",
+ f"Your primary domain: {primary.upper()}",
+ ]
+ if snapshot.get("domains_summary"):
+ lines.append(f"Health summary: {json.dumps(snapshot['domains_summary'], default=str)}")
+ lines.extend(["", f"=== PRIMARY: {primary.upper()} ==="])
+
+ if primary in snapshot and primary in SECTION_BUILDERS:
+ lines.extend(SECTION_BUILDERS[primary](snapshot[primary]))
+ lines.append("")
+ lines.append("=== FULL LAB (all domains) ===")
+
+ if "cluster_registry" not in snapshot:
+ snapshot = {**snapshot, "cluster_registry": {}}
+
+ for domain in DOMAIN_ORDER:
+ if domain == primary:
+ continue
+ if domain not in snapshot or domain not in SECTION_BUILDERS:
+ continue
+ lines.append("")
+ lines.append(f"--- {domain.upper()} ---")
+ lines.extend(SECTION_BUILDERS[domain](snapshot[domain]))
+
+ return "\n".join(lines)
diff --git a/api/main.py b/api/main.py
index b59b4e5..6cce657 100644
--- a/api/main.py
+++ b/api/main.py
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import json
import os
+import time
import uuid
from contextlib import asynccontextmanager
from datetime import datetime, timezone
@@ -12,15 +13,54 @@ from typing import Any
import httpx
import redis.asyncio as aioredis
-from fastapi import FastAPI, WebSocket, WebSocketDisconnect
+from fastapi import FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
+from agent_terminal import (
+ get_all_terminals,
+ get_terminal_lines,
+ init_terminals,
+ make_logger,
+ set_terminal_publisher,
+ terminal_log,
+)
+from lab_context import collect_full_lab_context, format_context_for_agent
+from presentation import build_presentation_payload, render_presentation_html
+from presentation_upload import get_deck, list_decks, save_upload
+from presentation_static import get_static_deck, list_static_decks
+from storage_s3 import router as storage_s3_router
+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 approval_service import (
+ APPROVAL_ACTION_TYPES,
+ approval_stats,
+ create_approval_request,
+ decide_approval_request,
+ detect_agent_proposed_action,
+ detect_approval_intent,
+ list_approvals,
+)
+from db import SessionLocal, db_health, init_database
+from supervisor import mirror_terminal_line, mirror_to_supervisors
+
+from workload import build_workload_payload
+
+_workload_cache: dict[str, Any] = {"ts": 0.0, "data": None}
+_presentation_cache: dict[str, Any] = {"ts": 0.0, "data": None}
+WORKLOAD_CACHE_TTL = 30.0
+PRESENTATION_CACHE_TTL = 45.0
+
from pydantic import BaseModel, Field
-from sqlalchemy import Column, DateTime, String, Text, create_engine, select
-from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
+from sqlalchemy import Column, DateTime, String, Text, select
+from sqlalchemy.orm import DeclarativeBase
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082")
-DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:////data/atc-agents.db")
+GPU_URL = os.getenv("GPU_URL", "http://10.0.20.106:9000")
+GPU_UI_URL = os.getenv("GPU_UI_URL", GPU_URL)
+LLM_URL = os.getenv("LLM_URL", "http://10.0.20.106:8001/v1")
+LLM_MODEL = os.getenv("LLM_MODEL", "gpt-4o")
+LLM_API_KEY = os.getenv("LLM_API_KEY", "sk-local")
+LLM_TIMEOUT = float(os.getenv("LLM_TIMEOUT", "120"))
AGENTS = [
{
@@ -29,6 +69,14 @@ AGENTS = [
"color": "#00f0ff",
"zone": "etl",
"role": "Airflow, Kafka, Debezium, S3 pipeline",
+ "icon": "⚡",
+ "motto": "Pipelines never sleep",
+ "capabilities": ["Airflow", "Kafka", "Debezium", "S3", "Connectors"],
+ "suggested_prompts": [
+ "How is Debezium doing?",
+ "Are all Airflow DAGs healthy?",
+ "Kafka connector status?",
+ ],
},
{
"id": "lakehouse-ops",
@@ -36,6 +84,14 @@ AGENTS = [
"color": "#ff00aa",
"zone": "lakehouse",
"role": "Spark, Trino, Iceberg",
+ "icon": "🏔️",
+ "motto": "Query the lake, trust the table",
+ "capabilities": ["Spark", "Trino", "Iceberg", "Delta", "SQL"],
+ "suggested_prompts": [
+ "Lakehouse stack status?",
+ "Is Trino reachable?",
+ "How many lakehouse containers are running?",
+ ],
},
{
"id": "data-custodian",
@@ -43,6 +99,14 @@ AGENTS = [
"color": "#ffaa00",
"zone": "db",
"role": "PostgreSQL, MySQL, Mongo, Cassandra, Neo4j",
+ "icon": "🛡️",
+ "motto": "Guardian of every row",
+ "capabilities": ["PostgreSQL", "MySQL", "MongoDB", "Cassandra", "Neo4j"],
+ "suggested_prompts": [
+ "Hoeveel data zit er in de databases?",
+ "Wat staat er in PostgreSQL sales_orders?",
+ "MongoDB supplychain overzicht",
+ ],
},
{
"id": "hadoop-ranger",
@@ -50,13 +114,93 @@ AGENTS = [
"color": "#39ff14",
"zone": "hadoop",
"role": "HDFS, YARN cluster",
+ "icon": "🌲",
+ "motto": "Patrol the data forest",
+ "capabilities": ["HDFS", "YARN", "NameNode", "DataNodes"],
+ "suggested_prompts": [
+ "Is HDFS NameNode up?",
+ "Hadoop cluster status?",
+ "YARN nodes healthy?",
+ ],
},
{
"id": "infra-sentinel",
"name": "Infra Sentinel",
- "color": "#b366ff",
+ "color": "#9b72cf",
"zone": "docker",
- "role": "Docker, Proxmox, monitoring",
+ "role": "Docker, Proxmox, GPU, monitoring",
+ "icon": "👁️",
+ "motto": "See everything, miss nothing",
+ "capabilities": ["Docker", "Proxmox", "GPU", "vLLM", "Monitoring"],
+ "suggested_prompts": [
+ "GPU status?",
+ "Which LLM model is running?",
+ "Docker container overview",
+ ],
+ },
+ {
+ "id": "mo-commander",
+ "name": "Mo · Command",
+ "color": "#4c9aed",
+ "zone": "command",
+ "role": "Supervisor — full event intel, ingress, approvals",
+ "icon": "🎯",
+ "motto": "Nothing happens without Mo knowing",
+ "supervisor": True,
+ "person": "mo",
+ "capabilities": ["Events", "Ingress", "Approvals", "Agent dispatch", "Network IN"],
+ "suggested_prompts": [
+ "What happened today?",
+ "What events came in?",
+ "Pipeline status overview",
+ ],
+ },
+ {
+ "id": "bart-commander",
+ "name": "Bart · Ops",
+ "color": "#3fb950",
+ "zone": "command",
+ "role": "Supervisor — egress, MCP comms, network OUT",
+ "icon": "📡",
+ "motto": "All traffic flows through Bart",
+ "supervisor": True,
+ "person": "bart",
+ "capabilities": ["Egress", "MCP routing", "Network OUT", "GPU inference", "S3 writes"],
+ "suggested_prompts": [
+ "What is leaving the cluster?",
+ "MCP agent communication status?",
+ "Network egress overview",
+ ],
+ },
+ {
+ "id": "network-watcher",
+ "name": "Network Watcher",
+ "color": "#58a6ff",
+ "zone": "network",
+ "role": "VLAN 20/21 traffic, data in & out paths",
+ "icon": "🌐",
+ "motto": "Every packet tells a story",
+ "capabilities": ["VLAN 20", "VLAN 21", "Ingress", "Egress", "Firewall paths"],
+ "suggested_prompts": [
+ "Data ingress status?",
+ "What leaves the cluster?",
+ "Network path to S3?",
+ ],
+ },
+ {
+ "id": "mcp-coordinator",
+ "name": "MCP Coordinator",
+ "color": "#f778ba",
+ "zone": "mcp",
+ "role": "MCP hub — routes all agent tool calls & comms",
+ "icon": "🔀",
+ "motto": "Route once, deliver everywhere",
+ "capabilities": ["MCP servers", "Tool routing", "Agent relay", "WebSocket bus"],
+ "suggested_prompts": [
+ "Which MCP agents are active?",
+ "MCP hub route status?",
+ "Agent communication overview",
+ ],
},
]
@@ -69,11 +213,18 @@ ZONES = [
]
INTENT_KEYWORDS: dict[str, list[str]] = {
- "data-custodian": ["database", "db", "postgres", "mysql", "mongo", "cassandra", "neo4j", "sql"],
- "lakehouse-ops": ["trino", "spark", "lakehouse", "iceberg", "query"],
- "hadoop-ranger": ["hadoop", "hdfs", "yarn", "datanode"],
- "infra-sentinel": ["docker", "container", "vm", "proxmox", "infra", "grafana"],
+ "data-custodian": ["database", "postgres", "postgresql", "mysql", "mongo", "mongodb", "cassandra", "neo4j", "sql", "db "],
+ "lakehouse-ops": ["trino", "spark", "lakehouse", "iceberg", "query", "table"],
+ "hadoop-ranger": [
+ "hadoop", "hdfs", "yarn", "datanode", "namenode", "replicatie", "replication",
+ "rf factor", "opslag", "bestanden", "blocks", "cluster opslag", "data op",
+ ],
+ "infra-sentinel": ["docker", "container", "vm", "proxmox", "infra", "grafana", "gpu", "vllm", "llm", "nvidia", "inference", "model"],
"etl-guardian": ["airflow", "dag", "debezium", "kafka", "connector", "etl", "pipeline", "s3"],
+ "network-watcher": ["network", "vlan", "ingress", "egress", "traffic", "packet", "firewall", "route"],
+ "mcp-coordinator": ["mcp", "tool", "router", "relay", "websocket", "hub"],
+ "mo-commander": ["mo", "supervisor", "events", "overzicht", "alles", "gebeurd"],
+ "bart-commander": ["bart", "egress", "uitgaand", "communicatie", "mcp comm"],
}
@@ -100,11 +251,16 @@ class Approval(Base):
action = Column(Text)
reason = Column(Text)
status = Column(String, default="pending")
+ action_type = Column(String, default="generic.mutate")
+ target = Column(Text, default="")
+ payload = Column(Text, default="{}")
+ decided_by = Column(String, nullable=True)
+ decide_note = Column(Text, nullable=True)
+ decided_at = Column(DateTime, nullable=True)
+ priority = Column(String, default="normal")
-engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
-SessionLocal = sessionmaker(bind=engine)
-Base.metadata.create_all(engine)
+_db_info = init_database(Base)
redis_client: aioredis.Redis | None = None
ws_clients: set[WebSocket] = set()
@@ -112,14 +268,35 @@ ws_clients: set[WebSocket] = set()
class PromptRequest(BaseModel):
message: str = Field(min_length=1, max_length=2000)
+ agent_id: str | None = None
+
+
+class NodeAskRequest(BaseModel):
+ message: str = Field(min_length=1, max_length=2000)
+
+
+class ApprovalCreateRequest(BaseModel):
+ agent_id: str = Field(min_length=1, max_length=64)
+ action: str = Field(min_length=1, max_length=2000)
+ reason: str = Field(min_length=1, max_length=2000)
+ action_type: str = "generic.mutate"
+ target: str = ""
+ payload: dict[str, Any] | None = None
+ priority: str = "normal"
class ApprovalDecision(BaseModel):
approved: bool
+ decided_by: str = "mo-commander"
+ note: str = ""
def route_agent(message: str) -> str:
lower = message.lower()
+ # Storage/data questions default to Hadoop unless clearly about databases
+ if any(w in lower for w in ("data", "opslag", "gb", "replicatie", "replication", "hdfs", "hadoop")):
+ if not any(w in lower for w in ("postgres", "mysql", "mongo", "database", "sql", "neo4j", "cassandra")):
+ return "hadoop-ranger"
scores = {aid: sum(1 for kw in kws if kw in lower) for aid, kws in INTENT_KEYWORDS.items()}
best = max(scores, key=scores.get)
if scores[best] == 0:
@@ -127,6 +304,94 @@ def route_agent(message: str) -> str:
return best
+async def gather_agent_context(
+ agent_id: str,
+ status: dict[str, Any],
+ log: Any | None = None,
+) -> str:
+ """Full lab snapshot for vLLM — all domains, agent's primary domain highlighted."""
+ snapshot = await collect_full_lab_context(gpu_data=status.get("gpu"), log=log)
+ snapshot["domains_summary"] = status.get("domains", {})
+ ctx = format_context_for_agent(agent_id, snapshot)
+ agent_lines = ["", "=== AGENTS & SUPERVISORS ==="]
+ for a in AGENTS:
+ sup = " [supervisor]" if a.get("supervisor") else ""
+ agent_lines.append(f" - {a['name']} ({a['id']}){sup}: {a['role']}")
+ ctx = ctx + "\n".join(agent_lines)
+ if log:
+ await log("info", "fetch", f"▸ Context assembled: {len(ctx)} chars for LLM")
+ return ctx
+
+
+async def ask_llm(
+ agent_id: str,
+ message: str,
+ context: str,
+ log: Any | None = None,
+) -> str | None:
+ agent = next(a for a in AGENTS if a["id"] == agent_id)
+ system = f"""Je bent {agent['name']}, een autonomous ops agent in het Dell ATC data lab.
+Specialisatie: {agent['role']}.
+Motto: {agent.get('motto', '')}
+
+Je antwoordt namens je domein maar hebt zicht op de HELE lab stack: Docker, databases, lakehouse (Trino/Spark/Kafka Connect), ETL (Airflow/Kafka), Hadoop HDFS, en GPU/vLLM.
+
+Regels:
+- Antwoord in dezelfde taal als de gebruiker (Nederlands of Engels).
+- Je hebt volledige zicht op de HELE cluster: alle VMs, zones, connectors, GPU, Hadoop, ObjectScale en Command Center.
+- Gebruik ALLEEN de live data hieronder — verzin geen hosts, poorten, cijfers of connector namen.
+- Gebruik exact de container/connector namen uit de data (bijv. mysql-hr-connector, niet "Debezium").
+- Als iets DOWN of 0 GB is, zeg dat eerlijk.
+- Kort en behulpzaam (max ~10 zinnen); bullet lists mogen als het overzicht helpt.
+
+--- LIVE LAB DATA (primary domain eerst, daarna volledige stack) ---
+{context}
+"""
+ if log:
+ await log("info", "llm", f"▸ Querying vLLM model={LLM_MODEL}")
+ await log("cmd", "llm", f"$ POST {LLM_URL.rstrip('/')}/chat/completions")
+ await log("info", "llm", f" user: {message[:160]}{'…' if len(message) > 160 else ''}")
+ try:
+ async with httpx.AsyncClient(timeout=LLM_TIMEOUT) as client:
+ t0 = time.monotonic()
+ r = await client.post(
+ f"{LLM_URL.rstrip('/')}/chat/completions",
+ headers={
+ "Authorization": f"Bearer {LLM_API_KEY}",
+ "Content-Type": "application/json",
+ },
+ json={
+ "model": LLM_MODEL,
+ "messages": [
+ {"role": "system", "content": system},
+ {"role": "user", "content": message},
+ ],
+ "max_tokens": 800,
+ "temperature": 0.25,
+ },
+ )
+ r.raise_for_status()
+ content = r.json()["choices"][0]["message"]["content"].strip()
+ ms = int((time.monotonic() - t0) * 1000)
+ if content and content.strip("!"):
+ if log:
+ await log("ok", "llm", f"← vLLM response {len(content)} chars ({ms}ms)")
+ preview = content.replace("\n", " ")[:180]
+ await log("info", "llm", f" » {preview}{'…' if len(content) > 180 else ''}")
+ return content
+ if log:
+ await log("warn", "llm", f"← Empty or invalid LLM output ({ms}ms)")
+ except Exception as exc:
+ if log:
+ await log("err", "llm", f"✗ vLLM error: {exc}")
+ return None
+
+
+def fallback_answer(agent_id: str, context: str) -> str:
+ agent_name = next(a["name"] for a in AGENTS if a["id"] == agent_id)
+ return f"**{agent_name}** (offline LLM — ruwe data):\n\n{context}"
+
+
async def publish_event(event: dict[str, Any]) -> None:
payload = json.dumps(event, default=str)
if redis_client:
@@ -140,6 +405,21 @@ async def publish_event(event: dict[str, Any]) -> None:
for ws in dead:
ws_clients.discard(ws)
+ et = event.get("type")
+ if et == "feed":
+ entry = event.get("entry") or {}
+ await mirror_to_supervisors(
+ entry.get("agent_id", "?"),
+ entry.get("message", ""),
+ level=entry.get("level", "info"),
+ )
+ elif et == "terminal":
+ await mirror_terminal_line(event.get("line") or {})
+ elif et in ("agent_dispatch", "agent_fetch", "agent_return"):
+ aid = event.get("agent_id", "?")
+ zone = event.get("zone", "")
+ await mirror_to_supervisors(aid, f"{et} → zone {zone}", level="info", phase="dispatch")
+
def add_feed(agent_id: str, message: str, level: str = "info") -> dict:
entry_id = str(uuid.uuid4())[:8]
@@ -175,6 +455,67 @@ async def probe_url(url: str) -> bool:
return False
+async def collect_gpu() -> dict[str, Any]:
+ host = GPU_URL.replace("http://", "").replace("https://", "").split("/")[0]
+ base = {"ok": False, "host": host, "ui_url": GPU_UI_URL}
+
+ try:
+ async with httpx.AsyncClient(timeout=6.0) as client:
+ metrics_r, model_r, integration_r = await asyncio.gather(
+ client.get(f"{GPU_URL}/api/gpu/metrics"),
+ client.get(f"{GPU_URL}/api/active-model"),
+ client.get(f"{GPU_URL}/api/integration"),
+ return_exceptions=True,
+ )
+
+ gpus: list[dict[str, Any]] = []
+ if isinstance(metrics_r, httpx.Response) and metrics_r.status_code == 200:
+ current = metrics_r.json().get("current", {})
+ gpus = [
+ {
+ "index": g["index"],
+ "name": g["name"],
+ "util_gpu": g.get("util_gpu", 0),
+ "memory_used_mib": g.get("memory_used_mib", 0),
+ "memory_total_mib": g.get("memory_total_mib", 0),
+ "temperature_c": g.get("temperature_c", 0),
+ "power_w": g.get("power_w", 0),
+ }
+ for g in current.get("gpus", [])
+ ]
+
+ active_model = None
+ inference_active = False
+ vllm_url = None
+
+ if isinstance(model_r, httpx.Response) and model_r.status_code == 200:
+ model_data = model_r.json()
+ active_model = model_data.get("name")
+ inference_active = bool(model_data.get("inference_active"))
+ vllm_url = model_data.get("base_url")
+
+ if isinstance(integration_r, httpx.Response) and integration_r.status_code == 200:
+ integ = integration_r.json()
+ if not active_model:
+ active_model = integ.get("active_name")
+ if not inference_active:
+ inference_active = bool(integ.get("inference_active"))
+ if not vllm_url:
+ vllm_url = integ.get("recommended_base_url")
+
+ return {
+ **base,
+ "ok": len(gpus) > 0 or inference_active,
+ "inference_active": inference_active,
+ "active_model": active_model,
+ "vllm_url": vllm_url,
+ "gpu_count": len(gpus),
+ "gpus": gpus,
+ }
+ except Exception as exc:
+ return {**base, "error": str(exc)}
+
+
async def collect_status() -> dict[str, Any]:
db_containers = await dockhand_env_containers(5)
db_running = sum(1 for c in db_containers if c.get("state") == "running")
@@ -201,6 +542,10 @@ async def collect_status() -> dict[str, Any]:
return "warn"
return "down"
+ gpu = await collect_gpu()
+ gpu_level = "ok" if gpu.get("ok") and gpu.get("inference_active") else ("warn" if gpu.get("ok") else "down")
+ gpu_label = gpu.get("active_model") or (f"{gpu.get('gpu_count', 0)} GPUs" if gpu.get("ok") else "offline")
+
return {
"ts": datetime.now(timezone.utc).isoformat(),
"domains": {
@@ -209,47 +554,112 @@ async def collect_status() -> dict[str, Any]:
"lakehouse": {"level": level(lake_running, lake_total), "label": f"{lake_running}/{lake_total} up", "running": lake_running, "total": lake_total},
"hadoop": {"level": "ok" if hdfs_ok else "warn", "label": "NN up" if hdfs_ok else "NN check"},
"etl": {"level": "ok" if kafka_ok and airflow_ok else "warn", "label": "Kafka+Airflow"},
+ "gpu": {"level": gpu_level, "label": gpu_label},
},
+ "gpu": gpu,
"kafka_ok": kafka_ok,
"airflow_ok": airflow_ok,
"hdfs_ok": hdfs_ok,
}
+
+
+async def _run_agent_task_safe(agent_id: str, message: str, prompt_id: str) -> None:
+ try:
+ await run_agent_task(agent_id, message, prompt_id)
+ except Exception as exc:
+ agent_name = next((a["name"] for a in AGENTS if a["id"] == agent_id), agent_id)
+ err = f"Sorry — {agent_name} could not complete your request: {exc}"
+ await terminal_log(agent_id, f"[{prompt_id}] ✗ Error: {exc}", level="err", phase="error", prompt_id=prompt_id)
+ feed = add_feed(agent_id, f"{agent_name} failed: {str(exc)[:80]}", "err")
+ await publish_event({"type": "feed", "entry": feed})
+ await publish_event({
+ "type": "prompt_result",
+ "prompt_id": prompt_id,
+ "agent_id": agent_id,
+ "answer": err,
+ })
+
+
async def run_agent_task(agent_id: str, message: str, prompt_id: str) -> str:
zone = next(a["zone"] for a in AGENTS if a["id"] == agent_id)
+ agent_name = next(a["name"] for a in AGENTS if a["id"] == agent_id)
+ log = make_logger(agent_id, prompt_id)
+ approval_created = False
+
+ intent = detect_approval_intent(message)
+ if intent:
+ with SessionLocal() as db:
+ await create_approval_request(
+ db=db,
+ ApprovalModel=Approval,
+ agent_id=agent_id,
+ action=intent["action"],
+ reason=intent["reason"],
+ action_type=intent["action_type"],
+ terminal_log=terminal_log,
+ mirror_supervisors=mirror_to_supervisors,
+ publish=publish_event,
+ add_feed=add_feed,
+ )
+ approval_created = True
+ await terminal_log(
+ agent_id,
+ f"[{prompt_id}] Mutating request detected — approval queued for Mo & Bart",
+ level="warn",
+ phase="approval",
+ prompt_id=prompt_id,
+ )
+
+ await terminal_log(
+ agent_id,
+ f"[{prompt_id}] ▶ Mission accepted: {message}",
+ level="info",
+ phase="dispatch",
+ prompt_id=prompt_id,
+ )
await publish_event({"type": "agent_dispatch", "agent_id": agent_id, "zone": zone, "prompt_id": prompt_id})
- await asyncio.sleep(0.8)
+ await asyncio.sleep(0.4)
+ await terminal_log(agent_id, f"[{prompt_id}] Walking to zone: {zone}", level="info", phase="dispatch", prompt_id=prompt_id)
await publish_event({"type": "agent_fetch", "agent_id": agent_id, "zone": zone, "prompt_id": prompt_id})
+ await log("info", "fetch", f"[{prompt_id}] Collecting live lab metrics…")
status = await collect_status()
- answer_parts = [f"**{next(a['name'] for a in AGENTS if a['id'] == agent_id)}** reporting:"]
+ context = await gather_agent_context(agent_id, status, log=log)
- if agent_id == "data-custodian":
- db = status["domains"]["databases"]
- containers = await dockhand_env_containers(5)
- names = ", ".join(f"{c['name']}:{c.get('state','?')}" for c in containers[:8])
- answer_parts.append(f"Databases {db['label']}. Containers: {names or 'unreachable'}.")
- elif agent_id == "etl-guardian":
- answer_parts.append(
- f"Kafka UI: {'OK' if status['kafka_ok'] else 'DOWN'}. "
- f"Airflow: {'OK' if status['airflow_ok'] else 'DOWN'}. "
- f"Lakehouse {status['domains']['lakehouse']['label']}."
- )
- elif agent_id == "lakehouse-ops":
- answer_parts.append(f"Lakehouse stack {status['domains']['lakehouse']['label']}. Trino at 10.0.21.50:8089.")
- elif agent_id == "hadoop-ranger":
- answer_parts.append(f"HDFS NameNode: {'reachable' if status['hdfs_ok'] else 'unreachable'} on 10.0.21.61:9870.")
- else:
- answer_parts.append(
- f"Docker {status['domains']['docker']['label']}. "
- f"Overall lab health snapshot collected."
- )
+ answer = await ask_llm(agent_id, message, context, log=log)
+ if not answer:
+ await log("warn", "llm", "LLM fallback — returning raw context")
+ answer = fallback_answer(agent_id, context)
- answer = " ".join(answer_parts)
- await asyncio.sleep(0.6)
+ if not approval_created:
+ proposed = detect_agent_proposed_action(answer, message)
+ if proposed:
+ with SessionLocal() as db:
+ await create_approval_request(
+ db=db,
+ ApprovalModel=Approval,
+ agent_id=agent_id,
+ action=proposed["action"],
+ reason=proposed["reason"],
+ action_type=proposed["action_type"],
+ target=proposed.get("target", ""),
+ terminal_log=terminal_log,
+ mirror_supervisors=mirror_to_supervisors,
+ publish=publish_event,
+ add_feed=add_feed,
+ )
+ approval_created = True
+ answer = (
+ f"{answer}\n\n⏸ **Approval required** — this action is in the Approval Inbox. "
+ f"Mo & Bart have been notified and must approve before we execute."
+ )
+
+ await asyncio.sleep(0.3)
+ await terminal_log(agent_id, f"[{prompt_id}] ✓ Mission complete", level="ok", phase="done", prompt_id=prompt_id)
await publish_event({"type": "agent_return", "agent_id": agent_id, "zone": zone, "prompt_id": prompt_id})
- feed = add_feed(agent_id, f"Prompt answered: {message[:80]}", "info")
+ feed = add_feed(agent_id, f"{agent_name} completed a response (see Comms)", "info")
await publish_event({"type": "feed", "entry": feed})
await publish_event({"type": "prompt_result", "prompt_id": prompt_id, "agent_id": agent_id, "answer": answer})
return answer
@@ -259,7 +669,9 @@ async def heartbeat_loop() -> None:
while True:
try:
status = await collect_status()
+ workload = await collect_workload()
await publish_event({"type": "status", "data": status})
+ await publish_event({"type": "workload", "data": workload})
for domain, info in status["domains"].items():
if info["level"] == "down":
agent = "data-custodian" if domain == "databases" else "infra-sentinel"
@@ -274,6 +686,15 @@ async def heartbeat_loop() -> None:
async def lifespan(app: FastAPI):
global redis_client
redis_client = aioredis.from_url(REDIS_URL, decode_responses=True)
+ set_terminal_publisher(publish_event)
+ init_terminals([a["id"] for a in AGENTS] + NODE_IDS)
+ for a in AGENTS:
+ await terminal_log(a["id"], f"{a['name']} terminal online — awaiting missions", level="info", phase="boot")
+ for nid in NODE_IDS:
+ if nid not in NODE_REGISTRY:
+ continue
+ meta = NODE_REGISTRY[nid]
+ await terminal_log(nid, f"{meta['label']} shell ready — click node to connect", level="info", phase="boot")
task = asyncio.create_task(heartbeat_loop())
add_feed("infra-sentinel", "ATC Command Center API online", "info")
yield
@@ -283,6 +704,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="ATC Command Center API", lifespan=lifespan)
+app.include_router(storage_s3_router)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -294,7 +716,103 @@ app.add_middleware(
@app.get("/api/health")
async def health():
- return {"ok": True, "ts": datetime.now(timezone.utc).isoformat()}
+ llm_ok = False
+ try:
+ async with httpx.AsyncClient(timeout=4.0) as client:
+ r = await client.get(f"{LLM_URL.rstrip('/')}/models", headers={"Authorization": f"Bearer {LLM_API_KEY}"})
+ llm_ok = r.status_code == 200
+ except Exception:
+ pass
+ return {
+ "ok": True,
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "llm_url": LLM_URL,
+ "llm_ok": llm_ok,
+ "llm_model": LLM_MODEL,
+ "database": db_health(),
+ "db_init": _db_info,
+ }
+
+
+async def collect_workload(*, fast: bool = True, use_cache: bool = True) -> dict[str, Any]:
+ import time as _time
+ now = _time.time()
+ if use_cache and _workload_cache.get("data") and now - float(_workload_cache.get("ts") or 0) < WORKLOAD_CACHE_TTL:
+ return _workload_cache["data"]
+ gpu = await collect_gpu()
+ snap = await collect_full_lab_context(gpu_data=gpu, include_inventory=not fast)
+ payload = build_workload_payload(snap)
+ _workload_cache["ts"] = now
+ _workload_cache["data"] = payload
+ return payload
+
+
+
+
+async def get_presentation_data(*, use_cache: bool = True) -> dict[str, Any]:
+ import time as _time
+ now = _time.time()
+ if use_cache and _presentation_cache.get("data") and now - float(_presentation_cache.get("ts") or 0) < PRESENTATION_CACHE_TTL:
+ return _presentation_cache["data"]
+ gpu = await collect_gpu()
+ snap = await collect_full_lab_context(gpu_data=gpu, include_inventory=False)
+ data = build_presentation_payload(snap)
+ data["source"] = "live"
+ _presentation_cache["ts"] = now
+ _presentation_cache["data"] = data
+ return data
+
+
+@app.get("/api/presentation")
+async def get_presentation():
+ return await get_presentation_data()
+
+
+@app.get("/api/presentation/html")
+async def get_presentation_html():
+ from fastapi.responses import HTMLResponse
+ payload = await get_presentation_data()
+ return HTMLResponse(render_presentation_html(payload))
+
+
+@app.get("/api/presentation/decks")
+async def get_presentation_decks():
+ return {"live": True, "builtin": list_static_decks(), "uploaded": list_decks()}
+
+
+@app.get("/api/presentation/decks/{deck_id}")
+async def get_presentation_deck(deck_id: str):
+ if deck_id == "live":
+ return await get_presentation_data()
+ deck = get_static_deck(deck_id) or get_deck(deck_id)
+ if not deck:
+ return {"error": "deck not found"}
+ return deck
+
+
+@app.get("/api/presentation/decks/{deck_id}/html")
+async def get_presentation_deck_html(deck_id: str):
+ from fastapi.responses import HTMLResponse
+ if deck_id == "live":
+ payload = await get_presentation_data()
+ else:
+ payload = get_static_deck(deck_id) or get_deck(deck_id)
+ if not payload:
+ return HTMLResponse("
Deck not found ", status_code=404)
+ return HTMLResponse(render_presentation_html(payload))
+
+
+@app.post("/api/presentation/upload")
+async def upload_presentation(file: UploadFile = File(...)):
+ content = await file.read()
+ if len(content) > 50 * 1024 * 1024:
+ return {"error": "file too large (max 50MB)"}
+ deck = await save_upload(file.filename or "upload.pptx", content)
+ return {"ok": True, "deck": deck}
+
+@app.get("/api/workload")
+async def get_workload(fast: bool = True):
+ return await collect_workload(fast=fast, use_cache=True)
@app.get("/api/status")
@@ -302,9 +820,95 @@ async def get_status():
return await collect_status()
+@app.get("/api/gpu")
+async def get_gpu():
+ return await collect_gpu()
+
+
+def agent_stats() -> dict[str, dict[str, Any]]:
+ stats: dict[str, dict[str, Any]] = {a["id"]: {"tasks": 0, "last_active": None, "alerts": 0} for a in AGENTS}
+ with SessionLocal() as db:
+ rows = db.execute(select(FeedEntry).order_by(FeedEntry.ts.desc()).limit(200)).scalars().all()
+ for r in rows:
+ aid = r.agent_id
+ if aid not in stats:
+ continue
+ stats[aid]["tasks"] += 1
+ if r.level == "warn":
+ stats[aid]["alerts"] += 1
+ if stats[aid]["last_active"] is None and r.ts:
+ stats[aid]["last_active"] = r.ts.isoformat()
+ return stats
+
+
@app.get("/api/agents")
async def get_agents():
- return {"agents": AGENTS, "zones": ZONES}
+ stats = agent_stats()
+ enriched = [{**a, "stats": stats.get(a["id"], {})} for a in AGENTS]
+ return {"agents": enriched, "zones": ZONES}
+
+
+@app.get("/api/terminals")
+async def get_terminals(limit: int = 200):
+ return {"terminals": get_all_terminals(limit)}
+
+
+@app.get("/api/terminals/{subject_id}")
+async def get_subject_terminal(subject_id: str, limit: int = 200):
+ valid_agents = {a["id"] for a in AGENTS}
+ if subject_id not in valid_agents and not is_node_id(subject_id):
+ return {"error": "unknown subject"}
+ return {"agent_id": subject_id, "lines": get_terminal_lines(subject_id, limit)}
+
+
+@app.get("/api/nodes")
+async def list_nodes():
+ workload = await collect_workload()
+ nodes = workload.get("topology", {}).get("nodes", [])
+ return {"nodes": [{"id": n["id"], "label": n["label"], "ip": n["ip"], "level": n["level"]} for n in nodes]}
+
+
+@app.get("/api/nodes/{node_id}")
+async def get_node(node_id: str):
+ if not is_node_id(node_id):
+ return {"error": "unknown node"}
+ workload = await collect_workload()
+ wn = next((n for n in workload.get("topology", {}).get("nodes", []) if n["id"] == node_id), None)
+ gpu = await collect_gpu()
+ snap = await collect_full_lab_context(gpu_data=gpu)
+ return build_node_detail(node_id, snap, wn)
+
+
+@app.post("/api/nodes/{node_id}/probe")
+async def post_node_probe(node_id: str):
+ if not is_node_id(node_id):
+ return {"error": "unknown node"}
+ asyncio.create_task(run_node_probe_task(node_id))
+ return {"ok": True, "node_id": node_id, "status": "probing"}
+
+
+async def run_node_ask_task(node_id: str, message: str) -> None:
+ agent_id = NODE_AGENT.get(node_id, "infra-sentinel")
+ meta = NODE_REGISTRY[node_id]
+ await terminal_log(node_id, f"▶ Query: {message}", level="info", phase="ask")
+ await terminal_log(node_id, f"→ Routing to agent {agent_id}", level="info", phase="ask")
+ log = make_logger(node_id)
+ status = await collect_status()
+ context = await gather_agent_context(agent_id, status, log=log)
+ node_ctx = f"\n\n=== FOCUSED NODE: {meta['label']} ({meta['ip']}) ===\n{meta.get('description', '')}\n"
+ answer = await ask_llm(agent_id, message, context + node_ctx, log=log)
+ if not answer:
+ answer = fallback_answer(agent_id, context)
+ await terminal_log(node_id, f"◆ {answer}", level="llm", phase="answer")
+ await publish_event({"type": "node_ask_result", "node_id": node_id, "agent_id": agent_id, "answer": answer})
+
+
+@app.post("/api/nodes/{node_id}/ask")
+async def post_node_ask(node_id: str, body: NodeAskRequest):
+ if not is_node_id(node_id):
+ return {"error": "unknown node"}
+ asyncio.create_task(run_node_ask_task(node_id, body.message))
+ return {"ok": True, "node_id": node_id, "agent_id": NODE_AGENT.get(node_id), "status": "processing"}
@app.get("/api/feed")
@@ -326,47 +930,73 @@ async def get_feed(limit: int = 50):
@app.get("/api/approvals")
-async def get_approvals():
+async def get_approvals(status: str = "pending", limit: int = 100):
with SessionLocal() as db:
- rows = db.execute(select(Approval).where(Approval.status == "pending")).scalars().all()
- return {
- "approvals": [
- {
- "id": r.id,
- "ts": r.ts.isoformat() if r.ts else None,
- "agent_id": r.agent_id,
- "action": r.action,
- "reason": r.reason,
- "status": r.status,
- }
- for r in rows
- ]
- }
+ items = list_approvals(db, Approval, status=status, limit=limit)
+ stats = approval_stats(db, Approval)
+ return {"approvals": items, "stats": stats, "action_types": APPROVAL_ACTION_TYPES}
+
+
+@app.get("/api/approvals/stats")
+async def get_approval_stats():
+ with SessionLocal() as db:
+ return approval_stats(db, Approval)
+
+
+@app.post("/api/approvals")
+async def post_approval(body: ApprovalCreateRequest):
+ valid_ids = {a["id"] for a in AGENTS}
+ if body.agent_id not in valid_ids:
+ return {"error": "unknown agent_id"}
+ if body.action_type not in APPROVAL_ACTION_TYPES:
+ body.action_type = "generic.mutate"
+ with SessionLocal() as db:
+ item = await create_approval_request(
+ db=db,
+ ApprovalModel=Approval,
+ agent_id=body.agent_id,
+ action=body.action,
+ reason=body.reason,
+ action_type=body.action_type,
+ target=body.target,
+ payload=body.payload,
+ priority=body.priority,
+ terminal_log=terminal_log,
+ mirror_supervisors=mirror_to_supervisors,
+ publish=publish_event,
+ add_feed=add_feed,
+ )
+ return {"ok": True, "approval": item}
@app.post("/api/approvals/{approval_id}/decide")
async def decide_approval(approval_id: str, body: ApprovalDecision):
+ valid_supervisors = {"mo-commander", "bart-commander"}
+ decided_by = body.decided_by if body.decided_by in valid_supervisors else "mo-commander"
with SessionLocal() as db:
- row = db.get(Approval, approval_id)
- if not row:
- return {"error": "not found"}
- row.status = "approved" if body.approved else "denied"
- db.commit()
- agent_id = row.agent_id
- action = row.action
- msg = f"Approval {'approved' if body.approved else 'denied'}: {action}"
- feed = add_feed(agent_id, msg, "info" if body.approved else "warn")
- await publish_event({"type": "feed", "entry": feed})
- await publish_event({"type": "approval_update", "id": approval_id, "status": row.status})
- return {"ok": True, "status": row.status}
+ item = await decide_approval_request(
+ db=db,
+ ApprovalModel=Approval,
+ approval_id=approval_id,
+ approved=body.approved,
+ decided_by=decided_by,
+ note=body.note,
+ terminal_log=terminal_log,
+ publish=publish_event,
+ add_feed=add_feed,
+ )
+ if not item:
+ return {"error": "not found"}
+ return {"ok": True, "approval": item}
@app.post("/api/prompt")
async def post_prompt(body: PromptRequest):
prompt_id = str(uuid.uuid4())[:8]
- agent_id = route_agent(body.message)
+ valid_ids = {a["id"] for a in AGENTS}
+ agent_id = body.agent_id if body.agent_id in valid_ids else route_agent(body.message)
add_feed(agent_id, f"Prompt received: {body.message}", "info")
- asyncio.create_task(run_agent_task(agent_id, body.message, prompt_id))
+ asyncio.create_task(_run_agent_task_safe(agent_id, body.message, prompt_id))
return {"prompt_id": prompt_id, "agent_id": agent_id, "status": "dispatched"}
@@ -376,7 +1006,13 @@ async def ws_ops(websocket: WebSocket):
ws_clients.add(websocket)
try:
status = await collect_status()
+ workload = await collect_workload()
await websocket.send_text(json.dumps({"type": "status", "data": status}, default=str))
+ await websocket.send_text(json.dumps({"type": "workload", "data": workload}, default=str))
+ await websocket.send_text(json.dumps({
+ "type": "terminal_history",
+ "terminals": get_all_terminals(150),
+ }, default=str))
while True:
await websocket.receive_text()
except WebSocketDisconnect:
diff --git a/api/node_ops.py b/api/node_ops.py
new file mode 100644
index 0000000..dfe4fb5
--- /dev/null
+++ b/api/node_ops.py
@@ -0,0 +1,186 @@
+"""Live probe + context for topology nodes."""
+
+from __future__ import annotations
+
+import asyncio
+from typing import Any
+
+import httpx
+
+from agent_terminal import terminal_log
+from lab_context import (
+ AIRFLOW_URL,
+ DOCKHAND_URL,
+ GPU_URL,
+ HDFS_NN_URL,
+ KAFKA_CONNECT_URL,
+ KAFKA_UI_URL,
+ LAKEHOUSE_HOST,
+ OBJECTSCALE_URL,
+ SPARK_UI_URL,
+ TRINO_URL,
+ collect_databases,
+ collect_docker_rack,
+ collect_etl,
+ collect_gpu_metrics,
+ collect_hdfs,
+ collect_lakehouse,
+ collect_objectscale,
+ dockhand_containers,
+)
+from node_registry import NODE_AGENT, NODE_REGISTRY
+
+
+async def _log(node_id: str, level: str, phase: str, text: str) -> None:
+ await terminal_log(node_id, text, level=level, phase=phase)
+
+
+async def probe_node(node_id: str) -> dict[str, Any]:
+ """Run live probes for a topology node; stream output to node terminal."""
+ meta = NODE_REGISTRY.get(node_id)
+ if not meta:
+ return {"error": "unknown node"}
+
+ await _log(node_id, "info", "shell", f"═══ Connecting to {meta['label']} ({meta['ip']}) ═══")
+ await _log(node_id, "cmd", "shell", f"$ probe --node {node_id} --vm {meta['vm']}")
+
+ result: dict[str, Any] = {"node_id": node_id, "ok": True}
+
+ async with httpx.AsyncClient(timeout=10.0, verify=False) as client:
+ if node_id == "airflow":
+ etl = await collect_etl(client)
+ result["data"] = etl
+ healthy = etl.get("airflow_healthy")
+ await _log(node_id, "ok" if healthy else "warn", "shell", f"Airflow scheduler: {'HEALTHY' if healthy else 'DEGRADED'}")
+ for comp, st in (etl.get("airflow_components") or {}).items():
+ await _log(node_id, "info", "shell", f" · {comp}: {st}")
+
+ elif node_id == "db":
+ raw = await dockhand_containers(client, 5)
+ db = await collect_databases(client, raw)
+ result["data"] = db
+ await _log(node_id, "ok", "shell", f"DB vault: {db['running']}/{db['total']} containers up")
+ for engine, items in (db.get("by_engine") or {}).items():
+ await _log(node_id, "info", "shell", f" {engine}:")
+ for item in items:
+ await _log(node_id, "info", "shell", f" - {item}")
+
+ elif node_id == "debezium":
+ etl = await collect_etl(client)
+ result["data"] = {"connectors": etl.get("connectors")}
+ await _log(node_id, "ok", "shell", f"Kafka Connect @ {KAFKA_CONNECT_URL}")
+ for c in etl.get("connectors") or []:
+ await _log(node_id, "info", "shell", f" ✓ {c}")
+
+ elif node_id == "kafka":
+ etl = await collect_etl(client)
+ result["data"] = {"kafka_ui_ok": etl.get("kafka_ui_ok")}
+ await _log(node_id, "ok" if etl.get("kafka_ui_ok") else "warn", "shell", f"Kafka UI {KAFKA_UI_URL}: {'UP' if etl.get('kafka_ui_ok') else 'DOWN'}")
+ await _log(node_id, "info", "shell", f" Broker: 10.0.21.36:9092")
+
+ elif node_id == "lakehouse":
+ raw = await dockhand_containers(client, 9)
+ lh = await collect_lakehouse(client, raw)
+ result["data"] = lh
+ await _log(node_id, "ok", "shell", f"Lakehouse {lh['host']}: {lh['running']}/{lh['total']} containers")
+ await _log(node_id, "info", "shell", f" Trino {TRINO_URL}: {'UP' if lh.get('trino_ok') else 'DOWN'}")
+ for c in lh.get("containers") or []:
+ ports = ",".join(c.get("ports") or []) or "internal"
+ await _log(node_id, "info", "shell", f" · {c['name']}: {c['state']} ports={ports}")
+
+ elif node_id == "s3":
+ os_data = await collect_objectscale(client)
+ raw = await dockhand_containers(client, 9)
+ consumer = next((c for c in raw if "s3-kafka" in f"{c.get('name', '')} {c.get('image', '')}".lower()), None)
+ result["data"] = {"objectscale": os_data, "consumer": consumer}
+ await _log(node_id, "ok" if os_data.get("reachable") else "warn", "shell", f"ObjectScale {OBJECTSCALE_URL}: {'UP' if os_data.get('reachable') else 'DOWN'}")
+ await _log(node_id, "info", "shell", f" Bucket: data @ {os_data.get('host')}:{os_data.get('port')}")
+ if consumer:
+ await _log(node_id, "info", "shell", f" s3-kafka-consumer: {consumer.get('state')}")
+
+ elif node_id == "docker":
+ raw = await dockhand_containers(client, 1)
+ dk = await collect_docker_rack(client, raw)
+ result["data"] = dk
+ await _log(node_id, "ok", "shell", f"Docker rack: {dk['running']}/{dk['total']} running")
+ for c in dk.get("containers") or []:
+ ports = ",".join(c.get("ports") or []) or "internal"
+ lvl = "info" if c.get("state") == "running" else "warn"
+ await _log(node_id, lvl, "shell", f" · {c['name']}: {c['state']} ports={ports}")
+
+ elif node_id == "hadoop":
+ hdfs = await collect_hdfs(client)
+ result["data"] = hdfs
+ if hdfs.get("reachable"):
+ await _log(node_id, "ok", "shell", f"NameNode {HDFS_NN_URL}: UP")
+ await _log(node_id, "info", "shell", f" Capacity: {hdfs.get('capacity_used_gb')}GB / {hdfs.get('capacity_total_gb')}GB")
+ await _log(node_id, "info", "shell", f" DataNodes: {hdfs.get('live_datanodes')} live, RF=3")
+ for dn in hdfs.get("datanodes") or []:
+ await _log(node_id, "info", "shell", f" · {dn['host']}: {dn['used_gb']}GB used, {dn['blocks']} blocks")
+ else:
+ await _log(node_id, "err", "shell", "NameNode unreachable")
+
+ elif node_id == "gpu":
+ gpu = await collect_gpu_metrics(client)
+ result["data"] = gpu
+ await _log(node_id, "ok" if gpu.get("ok") else "warn", "shell", f"GPU Lab {GPU_URL}")
+ await _log(node_id, "info", "shell", f" Model: {gpu.get('active_model')} inference={'ON' if gpu.get('inference_active') else 'OFF'}")
+ for g in gpu.get("gpus") or []:
+ await _log(node_id, "info", "shell", f" GPU{g['index']}: {g['util_gpu']:.0f}% VRAM {g['memory_used_mib']:.0f}/{g['memory_total_mib']:.0f} MiB")
+
+ elif node_id == "command":
+ result["data"] = {"agents": 9, "url": "http://10.0.21.33"}
+ await _log(node_id, "ok", "shell", "Command Center online — 9 agents ready")
+ await _log(node_id, "info", "shell", " API: http://10.0.21.33/api")
+ await _log(node_id, "info", "shell", " WebSocket: /api/ws/ops")
+
+ elif node_id in ("mo-commander", "bart-commander", "mcp-coordinator", "network-watcher"):
+ from node_registry import NODE_REGISTRY
+ meta = NODE_REGISTRY[node_id]
+ await _log(node_id, "ok", "shell", f"{meta['label']} online — monitoring all agent comms")
+ await _log(node_id, "info", "shell", meta.get("description", ""))
+ result["data"] = {"role": meta.get("role")}
+
+ await _log(node_id, "ok", "shell", "═══ Probe complete — type a question below ═══")
+ return result
+
+
+def build_node_detail(node_id: str, snap: dict[str, Any], workload_node: dict | None = None) -> dict[str, Any]:
+ """Rich context payload for a single node."""
+ meta = dict(NODE_REGISTRY.get(node_id, {}))
+ if not meta:
+ return {"error": "unknown node"}
+
+ wn = workload_node or {}
+ agent_id = NODE_AGENT.get(node_id, "infra-sentinel")
+
+ detail: dict[str, Any] = {
+ "id": node_id,
+ "agent_id": agent_id,
+ **meta,
+ "level": wn.get("level", "unknown"),
+ "running": wn.get("running", 0),
+ "total": wn.get("total", 0),
+ "apps": wn.get("apps", []),
+ "connectors": wn.get("connectors"),
+ "bucket": wn.get("bucket") or meta.get("bucket"),
+ "port": wn.get("port"),
+ "model": wn.get("model"),
+ "util": wn.get("util"),
+ "hdfs_used_gb": wn.get("hdfs_used_gb"),
+ "hdfs_total_gb": wn.get("hdfs_total_gb"),
+ "trino_ok": wn.get("trino_ok"),
+ "consumer_ok": wn.get("consumer_ok"),
+ }
+
+ edges = (snap.get("_edges") or []) if False else []
+ _ = edges # reserved for future edge context from workload
+
+ return detail
+
+
+async def run_node_probe_task(node_id: str) -> None:
+ try:
+ await probe_node(node_id)
+ except Exception as exc:
+ await _log(node_id, "err", "shell", f"Probe failed: {exc}")
diff --git a/api/node_registry.py b/api/node_registry.py
new file mode 100644
index 0000000..b898c5f
--- /dev/null
+++ b/api/node_registry.py
@@ -0,0 +1,268 @@
+"""Static registry + helpers for topology node metadata."""
+
+from __future__ import annotations
+
+from typing import Any
+
+NODE_IDS = [
+ "airflow", "db", "debezium", "kafka", "lakehouse", "s3",
+ "docker", "hadoop", "gpu", "command",
+ "mo-commander", "bart-commander", "network-watcher", "mcp-coordinator",
+]
+
+NODE_AGENT = {
+ "airflow": "etl-guardian",
+ "db": "data-custodian",
+ "debezium": "etl-guardian",
+ "kafka": "etl-guardian",
+ "lakehouse": "lakehouse-ops",
+ "s3": "lakehouse-ops",
+ "docker": "infra-sentinel",
+ "hadoop": "hadoop-ranger",
+ "gpu": "infra-sentinel",
+ "command": "infra-sentinel",
+}
+
+NODE_REGISTRY: dict[str, dict[str, Any]] = {
+ "airflow": {
+ "label": "Airflow",
+ "vm": "atc-airflow01",
+ "vmid": 105,
+ "pve": "pve01",
+ "ip": "10.0.21.55",
+ "ssh": "ssh root@10.0.21.55",
+ "role": "orchestrator",
+ "color": "#4c9aed",
+ "description": "Orchestrates DAG generate_data_all_databases — seeds PostgreSQL, MySQL, MongoDB, Cassandra and Neo4j on db02.",
+ "links": [{"label": "Airflow UI", "url": "http://10.0.21.55:8080"}],
+ "endpoints": [{"name": "web", "host": "10.0.21.55", "port": "8080", "proto": "http"}],
+ "commands": ["dag list", "health check", "trigger generate_data_all_databases"],
+ },
+ "db": {
+ "label": "DB Vault",
+ "vm": "atc-db02",
+ "vmid": 109,
+ "pve": "pve01",
+ "ip": "10.0.21.51",
+ "ssh": "ssh root@10.0.21.51",
+ "role": "sources",
+ "color": "#e8a838",
+ "description": "Source-of-truth databases for CDC. Debezium connectors capture changes from PostgreSQL sales, MySQL HR, MongoDB supply chain and Cassandra telemetry.",
+ "links": [{"label": "Dockhand env 5", "url": "http://10.0.21.45:8082"}],
+ "endpoints": [
+ {"name": "postgres_sales", "host": "10.0.21.51", "port": "5432", "proto": "tcp"},
+ {"name": "mysql_hr", "host": "10.0.21.51", "port": "3306", "proto": "tcp"},
+ {"name": "mongodb_supplychain", "host": "10.0.21.51", "port": "27017", "proto": "tcp"},
+ {"name": "cassandra_telemetry", "host": "10.0.21.51", "port": "9042", "proto": "tcp"},
+ {"name": "neo4j_graph", "host": "10.0.21.51", "port": "7687", "proto": "tcp"},
+ ],
+ "commands": ["list containers", "engine status", "connector sources"],
+ },
+ "debezium": {
+ "label": "Debezium CDC",
+ "vm": "atc-lake01",
+ "vmid": 108,
+ "pve": "pve01",
+ "ip": "10.0.21.50",
+ "ssh": "ssh root@10.0.21.50",
+ "role": "cdc",
+ "color": "#c77dff",
+ "description": "Kafka Connect on lake01 runs Debezium connectors — streams row-level changes from source DBs into Kafka topics.",
+ "links": [{"label": "Kafka Connect", "url": "http://10.0.21.50:8083"}],
+ "endpoints": [{"name": "kafka-connect", "host": "10.0.21.50", "port": "8083", "proto": "http"}],
+ "commands": ["list connectors", "connector status", "restart connector"],
+ },
+ "kafka": {
+ "label": "Kafka Bus",
+ "vm": "atc-kafka01",
+ "vmid": 113,
+ "pve": "pve01",
+ "ip": "10.0.21.36",
+ "ssh": "ssh root@10.0.21.36",
+ "role": "bus",
+ "color": "#4c9aed",
+ "description": "Central event bus. CDC topics flow from Debezium to s3-kafka-consumer and Spark on the lakehouse.",
+ "links": [{"label": "Kafka UI", "url": "http://10.0.21.36:9000"}],
+ "endpoints": [
+ {"name": "broker", "host": "10.0.21.36", "port": "9092", "proto": "tcp"},
+ {"name": "kafka-ui", "host": "10.0.21.36", "port": "9000", "proto": "http"},
+ ],
+ "commands": ["broker health", "list topics", "consumer lag"],
+ },
+ "lakehouse": {
+ "label": "Lakehouse Hub",
+ "vm": "atc-lake01",
+ "vmid": 108,
+ "pve": "pve01",
+ "ip": "10.0.21.50",
+ "role": "compute",
+ "color": "#e05297",
+ "description": "Spark + Trino + s3-kafka-consumer. Trino federates queries across DB catalogs and Iceberg on ObjectScale S3.",
+ "links": [
+ {"label": "Trino", "url": "http://10.0.21.50:8089"},
+ {"label": "Spark UI", "url": "http://10.0.21.50:8080"},
+ {"label": "Kafka Connect", "url": "http://10.0.21.50:8083"},
+ ],
+ "endpoints": [
+ {"name": "trino", "host": "10.0.21.50", "port": "8089", "proto": "http"},
+ {"name": "spark-master", "host": "10.0.21.50", "port": "8080", "proto": "http"},
+ {"name": "spark-submit", "host": "10.0.21.50", "port": "7077", "proto": "tcp"},
+ ],
+ "commands": ["trino status", "spark workers", "s3 consumer logs"],
+ },
+ "s3": {
+ "label": "ObjectScale S3",
+ "vm": "atc-objectscale",
+ "vmid": 100,
+ "pve": "pve01",
+ "ip": "10.0.20.111",
+ "ssh": "ssh root@10.0.20.111",
+ "role": "storage",
+ "color": "#d4a017",
+ "description": "Dell ObjectScale S3-compatible storage. Landing zone for s3-kafka-consumer and Trino Iceberg catalog (bucket: data).",
+ "links": [{"label": "S3 API", "url": "http://10.0.20.111:9020"}],
+ "endpoints": [{"name": "s3-api", "host": "10.0.20.111", "port": "9020", "proto": "http"}],
+ "bucket": "data",
+ "commands": ["bucket status", "consumer write rate", "iceberg catalog"],
+ },
+ "docker": {
+ "label": "Docker Rack",
+ "vm": "atc-docker01",
+ "vmid": 115,
+ "pve": "pve01",
+ "ip": "10.0.21.45",
+ "ssh": "ssh root@10.0.21.45",
+ "role": "infra",
+ "color": "#9b72cf",
+ "description": "Platform services — Homepage, Dockhand, Superset, Forgejo, Gitea proxy and monitoring stack.",
+ "links": [
+ {"label": "Homepage", "url": "http://10.0.21.45"},
+ {"label": "Dockhand", "url": "http://10.0.21.45:8082"},
+ {"label": "Superset", "url": "http://10.0.21.45:8088"},
+ ],
+ "endpoints": [{"name": "dockhand", "host": "10.0.21.45", "port": "8082", "proto": "http"}],
+ "commands": ["container list", "restart service", "resource usage"],
+ },
+ "hadoop": {
+ "label": "Hadoop HDFS",
+ "vm": "atc-hadoop-m01",
+ "vmid": 210,
+ "pve": "pve02",
+ "ip": "10.0.21.61",
+ "ssh": "ssh root@10.0.21.61",
+ "role": "parallel",
+ "color": "#3fb950",
+ "description": "9-node HDFS cluster (3 masters + 5 datanodes + edge). Parallel storage layer — separate from CDC→S3 pipeline.",
+ "links": [{"label": "NameNode UI", "url": "http://10.0.21.61:9870"}],
+ "endpoints": [
+ {"name": "namenode", "host": "10.0.21.61", "port": "9870", "proto": "http"},
+ {"name": "datanodes", "host": "10.0.21.65-69", "port": "9866", "proto": "http"},
+ ],
+ "commands": ["hdfs dfsadmin -report", "datanode status", "block health"],
+ },
+ "gpu": {
+ "label": "GPU Lab",
+ "vm": "atc-gpu-dev",
+ "vmid": 303,
+ "pve": "atc-gpu",
+ "ip": "10.0.20.106",
+ "ssh": "ssh root@10.0.20.106",
+ "role": "inference",
+ "color": "#3fb950",
+ "description": "4× V100 GPU lab. vLLM serves the active model (Llama 3 70B GPTQ) — powers agent reasoning in this Command Center.",
+ "links": [
+ {"label": "GPU Lab UI", "url": "http://10.0.20.106:9000"},
+ {"label": "vLLM API", "url": "http://10.0.20.106:8001/v1"},
+ ],
+ "endpoints": [
+ {"name": "gpu-lab", "host": "10.0.20.106", "port": "9000", "proto": "http"},
+ {"name": "vllm", "host": "10.0.20.106", "port": "8001", "proto": "http"},
+ ],
+ "commands": ["gpu metrics", "model status", "vram usage"],
+ },
+ "command": {
+ "label": "Command Center",
+ "vm": "MCP · VM304",
+ "vmid": 304,
+ "pve": "atc-gpu",
+ "ip": "10.0.21.33",
+ "ssh": "ssh root@10.0.21.33",
+ "role": "hub",
+ "color": "#4c9aed",
+ "description": "ATC Command Center — FastAPI + React + Redis + Postgres + Caddy. Agent hub & approval inbox.",
+ "links": [
+ {"label": "Dashboard", "url": "http://10.0.21.33/"},
+ {"label": "Dockhand env 13", "url": "http://10.0.21.45:8082"},
+ ],
+ "endpoints": [{"name": "api", "host": "10.0.21.33", "port": "80", "proto": "http"}],
+ "commands": ["agent status", "cluster snapshot", "dispatch mission"],
+ },
+ "mo-commander": {
+ "label": "Mo · Command",
+ "vm": "Supervisor Desk",
+ "vmid": 304,
+ "pve": "atc-gpu",
+ "ip": "10.0.21.33",
+ "role": "supervisor",
+ "color": "#4c9aed",
+ "description": "Mo's command desk — receives ALL lab events, agent dispatch, ingress traffic, approvals.",
+ "links": [{"label": "Command Center", "url": "http://10.0.21.33/"}],
+ "endpoints": [{"name": "intel-feed", "host": "10.0.21.33", "port": "80", "proto": "ws"}],
+ "commands": ["events today", "ingress log", "agent status"],
+ },
+ "bart-commander": {
+ "label": "Bart · Ops",
+ "vm": "Supervisor Desk",
+ "vmid": 304,
+ "pve": "atc-gpu",
+ "ip": "10.0.21.33",
+ "role": "supervisor",
+ "color": "#3fb950",
+ "description": "Bart's ops desk — egress monitoring, MCP agent comms, S3 writes, GPU inference output.",
+ "links": [{"label": "Command Center", "url": "http://10.0.21.33/"}],
+ "endpoints": [{"name": "egress-feed", "host": "10.0.21.33", "port": "80", "proto": "ws"}],
+ "commands": ["egress log", "mcp comms", "s3 write rate"],
+ },
+ "network-watcher": {
+ "label": "Network Watcher",
+ "vm": "multi-VLAN",
+ "ip": "10.0.20/21.x",
+ "role": "network",
+ "color": "#58a6ff",
+ "description": "Monitors VLAN 20 (storage/GPU) and VLAN 21 (compute) — data ingress and egress paths.",
+ "links": [],
+ "endpoints": [
+ {"name": "vlan20", "host": "10.0.20.0/24", "port": "-", "proto": "net"},
+ {"name": "vlan21", "host": "10.0.21.0/24", "port": "-", "proto": "net"},
+ ],
+ "commands": ["ingress paths", "egress paths", "vlan status"],
+ },
+ "mcp-coordinator": {
+ "label": "MCP Coordinator",
+ "vm": "VM304",
+ "ip": "10.0.21.33",
+ "role": "mcp",
+ "color": "#f778ba",
+ "description": "Routes all MCP agent tool calls. Relays comms between operational agents and supervisor desks.",
+ "links": [{"label": "API", "url": "http://10.0.21.33/api"}],
+ "endpoints": [{"name": "mcp-hub", "host": "10.0.21.33", "port": "3101-3112", "proto": "http"}],
+ "commands": ["agent routes", "mcp status", "relay log"],
+ },
+}
+
+
+NODE_AGENT.update({
+ "mo-commander": "mo-commander",
+ "bart-commander": "bart-commander",
+ "network-watcher": "network-watcher",
+ "mcp-coordinator": "mcp-coordinator",
+ "etl-guardian": "etl-guardian",
+ "lakehouse-ops": "lakehouse-ops",
+ "data-custodian": "data-custodian",
+ "hadoop-ranger": "hadoop-ranger",
+ "infra-sentinel": "infra-sentinel",
+})
+
+
+def is_node_id(subject_id: str) -> bool:
+ return subject_id in NODE_REGISTRY
diff --git a/api/presentation.py b/api/presentation.py
new file mode 100644
index 0000000..5001acf
--- /dev/null
+++ b/api/presentation.py
@@ -0,0 +1,292 @@
+"""Build live presentation deck from cluster snapshot + registry."""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from node_registry import NODE_REGISTRY
+from topology_views import build_all_topologies
+from workload import build_workload_payload
+
+
+def _status_badge(level: str) -> str:
+ return {"ok": "● Online", "warn": "◐ Degraded", "down": "○ Offline", "unknown": "? Unknown"}.get(level, level)
+
+
+def _slide(slide_id: str, title: str, subtitle: str, bullets: list[str], **extra: Any) -> dict[str, Any]:
+ return {"id": slide_id, "title": title, "subtitle": subtitle, "bullets": bullets, **extra}
+
+
+def build_presentation_payload(snap: dict[str, Any]) -> dict[str, Any]:
+ workload = build_workload_payload(snap)
+ topologies = workload.get("topologies") or build_all_topologies(snap)
+ totals = workload.get("totals", {})
+ zones = workload.get("zones", [])
+ gpu = workload.get("gpu", {})
+ etl = snap.get("etl", {})
+ hadoop = snap.get("hadoop", {})
+ objectscale = snap.get("objectscale", {})
+ command = snap.get("command_center", {})
+
+ slides: list[dict[str, Any]] = []
+
+ slides.append(_slide(
+ "title",
+ "Dell ATC Data Lab",
+ "Live demo & presentation — Command Center",
+ [
+ f"Snapshot: {snap.get('ts', 'now')}",
+ f"Pipeline: {'ACTIVE' if totals.get('pipeline_active') else 'INACTIVE'}",
+ f"Apps running: {totals.get('apps_running', 0)}/{totals.get('apps_total', 0)}",
+ f"CDC connectors: {totals.get('connectors', 0)}",
+ f"LLM: {gpu.get('model') or 'offline'} ({gpu.get('gpu_count', 0)}× V100)",
+ "Command Center → http://10.0.21.33/",
+ ],
+ kind="hero",
+ ))
+
+ slides.append(_slide(
+ "mission",
+ "Mission",
+ "End-to-end modern data platform on Dell infrastructure",
+ [
+ "Ingest change data from operational databases (PostgreSQL, MySQL, MongoDB, Cassandra)",
+ "Stream via Kafka & Debezium into the lakehouse (Spark, Trino, Iceberg)",
+ "Land curated data on ObjectScale S3 — query with Trino & visualize in Superset",
+ "Parallel HDFS cluster for batch / legacy workloads",
+ "GPU lab powers autonomous ops agents with local vLLM inference",
+ "This dashboard orchestrates agents, approvals, and live cluster visibility",
+ ],
+ kind="narrative",
+ ))
+
+ arch = topologies.get("architecture") or workload.get("topology") or {}
+ arch_nodes = arch.get("nodes", [])
+ slides.append(_slide(
+ "architecture",
+ "Data Platform Architecture",
+ arch.get("subtitle", "Sources → Ingestion → Compute → Storage → Consumers"),
+ [f"{n.get('label', n.get('id'))}: {n.get('subtitle', n.get('role', ''))}" for n in arch_nodes[:14]],
+ kind="topology",
+ topology=arch,
+ ))
+
+ pipeline = topologies.get("pipeline", {})
+ connector_lines = [
+ f" · {cs['name']}: {cs.get('state', '?')}"
+ for cs in (etl.get("connector_status") or [])[:6]
+ ]
+ slides.append(_slide(
+ "pipeline",
+ "CDC Pipeline",
+ pipeline.get("subtitle", "Airflow → DB → Debezium → Kafka → Lakehouse → S3"),
+ [
+ f"Airflow: {'healthy' if etl.get('airflow_healthy') else 'degraded'} ({etl.get('airflow_url', '')})",
+ f"Kafka UI: {'UP' if etl.get('kafka_ui_ok') else 'DOWN'}",
+ f"Connectors: {', '.join(etl.get('connectors') or []) or 'none'}",
+ *connector_lines,
+ f"Spark UI: {'UP' if etl.get('spark_ui_ok') else 'DOWN'}",
+ f"ObjectScale: {'reachable' if objectscale.get('reachable') else 'down'} bucket={objectscale.get('bucket', 'data')}",
+ ],
+ kind="topology",
+ topology=pipeline,
+ ))
+
+ for zone in zones:
+ apps = zone.get("apps") or []
+ app_lines = [
+ f"{a['name']}: {a['state']}" + (f" ({a.get('host', '')})" if a.get("host") else "")
+ for a in apps[:10]
+ ]
+ slides.append(_slide(
+ f"zone-{zone['id']}",
+ zone["label"],
+ f"{zone.get('vm', '')} · {zone.get('ip', '')} · {_status_badge(zone.get('level', 'unknown'))}",
+ [
+ f"Containers: {zone.get('running', 0)}/{zone.get('total', 0)} running",
+ *app_lines,
+ ],
+ kind="zone",
+ zone=zone,
+ ))
+
+ infra_nodes = [
+ nid for nid in NODE_REGISTRY
+ if nid not in ("mo-commander", "bart-commander", "network-watcher", "mcp-coordinator")
+ ]
+ slides.append(_slide(
+ "infrastructure",
+ "Infrastructure Map",
+ "Proxmox VMs & services across VLAN 20/21",
+ [
+ f"{NODE_REGISTRY[nid]['label']} — {NODE_REGISTRY[nid].get('vm')} "
+ f"(VMID {NODE_REGISTRY[nid].get('vmid', '?')}) @ {NODE_REGISTRY[nid].get('ip')}"
+ for nid in infra_nodes
+ ],
+ kind="registry",
+ ))
+
+ dn_lines = [
+ f" · {dn['host']}: {dn.get('used_gb', 0)} GB — {dn.get('state', '')}"
+ for dn in (hadoop.get("datanodes") or [])[:5]
+ ]
+ slides.append(_slide(
+ "hadoop",
+ "Hadoop HDFS",
+ "9-node parallel storage cluster",
+ [
+ f"NameNode: {'UP' if hadoop.get('reachable') else 'DOWN'} — {hadoop.get('namenode', '')}",
+ f"Capacity: {hadoop.get('capacity_used_gb', '?')} / {hadoop.get('capacity_total_gb', '?')} GB",
+ f"DataNodes: {hadoop.get('live_datanodes', 0)} live, {hadoop.get('dead_datanodes', 0)} dead",
+ f"Files: {hadoop.get('files_total', 0)}, Blocks: {hadoop.get('blocks_total', 0)}",
+ *dn_lines,
+ ],
+ kind="data",
+ ))
+
+ gpus = gpu.get("gpus") or snap.get("gpu", {}).get("gpus") or []
+ gpu_lines = [
+ f"GPU{g['index']}: {g.get('util_gpu', 0):.0f}% util, "
+ f"{g.get('memory_used_mib', 0):.0f}/{g.get('memory_total_mib', 0):.0f} MiB"
+ for g in gpus[:4]
+ ]
+ slides.append(_slide(
+ "gpu",
+ "GPU Lab & GenAI",
+ f"{gpu.get('model') or 'vLLM'} on atc-gpu-dev (VM 303)",
+ [
+ f"Inference: {'ON' if gpu.get('inference_active') else 'OFF'}",
+ f"API: {snap.get('gpu', {}).get('vllm_url') or 'http://10.0.20.106:8001/v1'}",
+ "Manager: http://10.0.20.106:9000",
+ *gpu_lines,
+ ],
+ kind="gpu",
+ ))
+
+ slides.append(_slide(
+ "agents",
+ "Autonomous Agents",
+ "Mo & Bart supervise 5 domain operators + MCP hub",
+ [
+ "ETL Guardian — Airflow, Kafka, Debezium, connectors",
+ "Data Custodian — PostgreSQL, MySQL, MongoDB, Cassandra, Neo4j",
+ "Lakehouse Ops — Spark, Trino, Iceberg, ObjectScale S3",
+ "Hadoop Ranger — HDFS NameNode, DataNodes, block health",
+ "Infra Sentinel — Docker rack, GPU lab, Command Center",
+ "All agents receive LIVE cluster snapshot in every LLM prompt",
+ ],
+ kind="agents",
+ ))
+
+ cc_apps = [f"{c['name']}: {c['state']}" for c in (command.get("containers") or [])]
+ slides.append(_slide(
+ "command",
+ "Command Center",
+ "VM 304 — this presentation runs here",
+ [
+ f"Host: {command.get('host', '10.0.21.33')} (VMID {command.get('vmid', 304)})",
+ f"Stack: {command.get('running', 0)}/{command.get('total', 0)} containers",
+ *cc_apps,
+ "WebSocket ops feed · Approval inbox · Agent terminals",
+ ],
+ kind="command",
+ ))
+
+ slides.append(_slide(
+ "demo",
+ "Live Demo Tips",
+ "Use this deck during customer presentations",
+ [
+ "Press ← → or click dots to navigate slides",
+ "F = fullscreen presentation mode",
+ "Export HTML opens a standalone deck for projectors / offline",
+ "Ask agents in the Command Bar — they see full cluster context",
+ "Switch to Data Platform tab for interactive topology",
+ "GPU Lab chat: http://10.0.20.106:9000/chat",
+ ],
+ kind="cta",
+ ))
+
+ return {
+ "ts": snap.get("ts"),
+ "title": "Dell ATC Data Lab",
+ "subtitle": "Live Infrastructure Presentation",
+ "totals": totals,
+ "pipeline_active": totals.get("pipeline_active"),
+ "slides": slides,
+ "slide_count": len(slides),
+ "workload": workload,
+ "topologies": topologies,
+ }
+
+
+def render_presentation_html(payload: dict[str, Any]) -> str:
+ slides_json = json.dumps(payload.get("slides", []), default=str)
+ title = payload.get("title", "ATC Lab")
+ ts = payload.get("ts", "")
+ return f"""
+
+
+
+
+{title} — Presentation
+
+
+
+
+
+
+
+← Prev
+
+Next →
+
+
+
+
+
+"""
diff --git a/api/presentation_static.py b/api/presentation_static.py
new file mode 100644
index 0000000..dad3c52
--- /dev/null
+++ b/api/presentation_static.py
@@ -0,0 +1,203 @@
+"""Pre-built modern HTML presentation templates — English."""
+
+from __future__ import annotations
+
+from typing import Any
+
+MODERN_DECKS: dict[str, dict[str, Any]] = {
+ "data-maturity": {
+ "id": "data-maturity",
+ "title": "Data Maturity Assessment",
+ "subtitle": "Dell ATC — Customer Data Onboarding Framework",
+ "slides": [
+ {
+ "id": "dm-1", "kind": "hero",
+ "title": "Data Maturity Assessment",
+ "subtitle": "From raw data to trusted decisions",
+ "bullets": [
+ "6 dimensions: Completeness, Consistency, Validity, Uniqueness, Timeliness, Accuracy",
+ "Automated analysis with Docling, Great Expectations & Soda Core",
+ "Full report with priorities and remediation roadmap",
+ ],
+ },
+ {
+ "id": "dm-2", "kind": "narrative",
+ "title": "Why maturity?",
+ "subtitle": "Customers hand over data — we show where it stands",
+ "bullets": [
+ "73% of analytics projects fail due to data quality (Gartner)",
+ "Without a baseline there is no measurable improvement",
+ "DQ tools + document parsing = complete picture",
+ "Report ready for boardroom & audit",
+ ],
+ },
+ {
+ "id": "dm-3", "kind": "zone",
+ "title": "Our toolchain",
+ "subtitle": "Integrated on the ATC platform",
+ "bullets": [
+ "Docling — PDF, PPTX, DOCX, XLSX → structured data",
+ "Great Expectations — Python expectations & data contracts",
+ "Soda Core — YAML checks, freshness, anomaly monitoring",
+ "DQ API — maturity score + HTML report",
+ ],
+ },
+ {
+ "id": "dm-4", "kind": "cta",
+ "title": "Next step",
+ "subtitle": "Upload customer data in Data Quality tab",
+ "bullets": [
+ "Upload CSV, Excel, PDF or database export",
+ "Receive maturity score (0–100) per dimension",
+ "Action list: what to fix first",
+ "Reports are stored — no need to re-upload",
+ ],
+ },
+ ],
+ },
+ "atc-platform": {
+ "id": "atc-platform",
+ "title": "ATC Data Platform",
+ "subtitle": "Modern lakehouse on Dell infrastructure",
+ "slides": [
+ {
+ "id": "atc-1", "kind": "hero",
+ "title": "ATC Data & AI Platform",
+ "subtitle": "CDC → Kafka → Spark → Iceberg → Trino",
+ "bullets": [
+ "Live pipeline: PostgreSQL, MySQL, MongoDB, Cassandra",
+ "ObjectScale S3 + 9-node Hadoop cluster",
+ "GPU Lab: Llama 3 70B for autonomous agents",
+ ],
+ },
+ {
+ "id": "atc-2", "kind": "topology",
+ "title": "End-to-end flow",
+ "subtitle": "Sources → Ingestion → Compute → Storage → Consumers",
+ "bullets": [
+ "Airflow orchestrates daily data generation",
+ "Debezium CDC → Kafka → Spark → Iceberg",
+ "Trino federated queries + Superset BI",
+ "GenAI agents with full cluster context",
+ ],
+ },
+ ],
+ },
+ "stack-architecture": {
+ "id": "stack-architecture",
+ "title": "ATC Stack Architecture",
+ "subtitle": "How the Command Center, DQ, RAG & Lakehouse fit together",
+ "slides": [
+ {
+ "id": "arch-1", "kind": "hero",
+ "title": "ATC Intelligent Data Platform",
+ "subtitle": "One dashboard — ingest, assess, chat, present",
+ "bullets": [
+ "Command Center at http://10.0.21.33 — single entry point",
+ "Upload once → stored permanently in ChromaDB + file registry",
+ "Ask questions anytime via Knowledge Chat (RAG + LangChain)",
+ "Present architecture & maturity to customers live",
+ ],
+ },
+ {
+ "id": "arch-2", "kind": "architecture", "animation": "full-stack",
+ "title": "Full Stack Overview",
+ "subtitle": "All services on VM304 (Command Center)",
+ "bullets": [
+ "Caddy routes /api, /dq, /rag to backend services",
+ "React UI — Data Platform, Presentation, Data Quality, Knowledge Chat",
+ "Docling on port 5001 for document parsing UI + API",
+ "Postgres + Redis for agents; ChromaDB for vectors",
+ ],
+ },
+ {
+ "id": "arch-3", "kind": "architecture", "animation": "lakehouse",
+ "title": "Lakehouse Pipeline",
+ "subtitle": "Operational data → analytics-ready tables",
+ "bullets": [
+ "Sources on DB Vault (10.0.21.51): PG, MySQL, Mongo, Cassandra, Neo4j",
+ "Debezium captures changes → Kafka topics",
+ "Spark transforms → Iceberg tables on ObjectScale",
+ "Trino SQL + Superset dashboards for consumers",
+ ],
+ },
+ {
+ "id": "arch-4", "kind": "architecture", "animation": "dq-flow",
+ "title": "Data Quality & Maturity",
+ "subtitle": "Prove data readiness before AI/ML projects",
+ "bullets": [
+ "Upload customer file → parsed by Docling if PDF/Office",
+ "6 maturity dimensions scored 0–100 with findings",
+ "GE + Soda checks per column — expandable in UI",
+ "HTML report + image gallery — stored in /data/reports",
+ ],
+ },
+ {
+ "id": "arch-5", "kind": "architecture", "animation": "rag-flow",
+ "title": "Knowledge Chat (RAG)",
+ "subtitle": "Upload once — query forever",
+ "bullets": [
+ "Document saved to disk + indexed in ChromaDB (persistent volume)",
+ "Duplicate uploads skipped automatically (SHA-256 hash)",
+ "LangChain retrieves top-k chunks → Llama 70B on GPU Lab",
+ "Answers include source filename + chunk preview",
+ ],
+ },
+ {
+ "id": "arch-6", "kind": "narrative",
+ "title": "AI Agents Layer",
+ "subtitle": "Autonomous ops with full lab context",
+ "bullets": [
+ "Supervisor + field operators on Command Center",
+ "Each agent sees live workload, GPU, databases, topology",
+ "LLM: Llama 3 70B GPTQ via vLLM (10.0.20.106:8001)",
+ "Approval workflow for sensitive operations",
+ ],
+ },
+ {
+ "id": "arch-7", "kind": "zone",
+ "title": "Infrastructure Map",
+ "subtitle": "Dell ATC cluster — key IPs",
+ "bullets": [
+ "Command Center VM304: 10.0.21.33 (this dashboard)",
+ "GPU Lab VM303: 10.0.20.106 — 7× V100, vLLM, model manager",
+ "DB Vault: 10.0.21.51 · Lakehouse: 10.0.21.50",
+ "Docling UI: http://10.0.21.33:5001/ui/",
+ ],
+ },
+ {
+ "id": "arch-8", "kind": "cta",
+ "title": "Customer Demo Flow",
+ "subtitle": "Recommended narrative for presentations",
+ "bullets": [
+ "1. Show live Data Platform topology & agent fleet",
+ "2. Upload customer sample → Data Quality maturity report",
+ "3. Same file already in Knowledge Chat — ask questions live",
+ "4. Export this architecture deck as HTML for customer handout",
+ ],
+ },
+ ],
+ },
+}
+
+
+def list_static_decks() -> list[dict[str, Any]]:
+ return [
+ {"id": k, "title": v["title"], "subtitle": v["subtitle"], "slide_count": len(v["slides"]), "source": "builtin"}
+ for k, v in MODERN_DECKS.items()
+ ]
+
+
+def get_static_deck(deck_id: str) -> dict[str, Any] | None:
+ deck = MODERN_DECKS.get(deck_id)
+ if not deck:
+ return None
+ return {
+ "ts": None,
+ "title": deck["title"],
+ "subtitle": deck["subtitle"],
+ "slides": deck["slides"],
+ "slide_count": len(deck["slides"]),
+ "source": "builtin",
+ "id": deck_id,
+ }
diff --git a/api/presentation_upload.py b/api/presentation_upload.py
new file mode 100644
index 0000000..af147af
--- /dev/null
+++ b/api/presentation_upload.py
@@ -0,0 +1,165 @@
+"""Upload PPT/PPTX decks and convert to presentation JSON + HTML."""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import uuid
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+import httpx
+
+PRESENTATIONS_DIR = Path(os.getenv("PRESENTATIONS_DIR", "/data/presentations"))
+DOCLING_URL = os.getenv("DOCLING_URL", "http://docling-serve:5001").rstrip("/")
+
+
+def _ensure_dir() -> Path:
+ PRESENTATIONS_DIR.mkdir(parents=True, exist_ok=True)
+ return PRESENTATIONS_DIR
+
+
+def list_decks() -> list[dict[str, Any]]:
+ _ensure_dir()
+ decks = []
+ for meta_path in sorted(PRESENTATIONS_DIR.glob("*/meta.json"), key=lambda p: p.stat().st_mtime, reverse=True):
+ try:
+ meta = json.loads(meta_path.read_text())
+ decks.append(meta)
+ except Exception:
+ continue
+ return decks
+
+
+def _safe_name(name: str) -> str:
+ return re.sub(r"[^a-zA-Z0-9._-]+", "_", name)[:80]
+
+
+def pptx_to_slides(path: Path) -> list[dict[str, Any]]:
+ from pptx import Presentation
+ from pptx.enum.shapes import MSO_SHAPE_TYPE
+
+ prs = Presentation(str(path))
+ slides: list[dict[str, Any]] = []
+ for idx, slide in enumerate(prs.slides, start=1):
+ bullets: list[str] = []
+ title = ""
+ for shape in slide.shapes:
+ if not hasattr(shape, "text"):
+ continue
+ text = (shape.text or "").strip()
+ if not text:
+ continue
+ if shape.shape_type == MSO_SHAPE_TYPE.PLACEHOLDER and not title:
+ title = text.split("\n")[0][:120]
+ else:
+ for line in text.split("\n"):
+ line = line.strip()
+ if line and line != title:
+ bullets.append(line[:240])
+ if not title:
+ title = f"Slide {idx}"
+ slides.append({
+ "id": f"upload-{idx}",
+ "title": title,
+ "subtitle": "",
+ "bullets": bullets[:12] or ["(empty slide)"],
+ "kind": "upload",
+ })
+ return slides
+
+
+async def docling_enrich(path: Path) -> list[dict[str, Any]] | None:
+ """Optional: parse via Docling for richer structure."""
+ try:
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ with path.open("rb") as f:
+ r = await client.post(
+ f"{DOCLING_URL}/v1/convert/file",
+ files={"files": (path.name, f, "application/octet-stream")},
+ data={"to_formats": "md"},
+ )
+ if r.status_code >= 400:
+ return None
+ data = r.json()
+ md = ""
+ if isinstance(data, dict):
+ doc = data.get("document") or data.get("result") or data
+ if isinstance(doc, dict):
+ md = doc.get("md_content") or doc.get("markdown") or ""
+ elif isinstance(doc, str):
+ md = doc
+ if not md:
+ return None
+ slides = []
+ chunks = [c.strip() for c in re.split(r"\n#{1,2}\s+", md) if c.strip()]
+ for i, chunk in enumerate(chunks[:40], start=1):
+ lines = [ln.strip() for ln in chunk.split("\n") if ln.strip()]
+ title = lines[0][:120] if lines else f"Slide {i}"
+ bullets = [ln.lstrip("-•* ").strip() for ln in lines[1:13] if ln.strip()]
+ slides.append({
+ "id": f"docling-{i}",
+ "title": title,
+ "subtitle": "Docling parsed",
+ "bullets": bullets or ["—"],
+ "kind": "upload",
+ })
+ return slides if slides else None
+ except Exception:
+ return None
+
+
+async def save_upload(filename: str, content: bytes) -> dict[str, Any]:
+ _ensure_dir()
+ deck_id = str(uuid.uuid4())[:8]
+ deck_dir = PRESENTATIONS_DIR / deck_id
+ deck_dir.mkdir(parents=True, exist_ok=True)
+ safe = _safe_name(filename)
+ dest = deck_dir / safe
+ dest.write_bytes(content)
+
+ slides: list[dict[str, Any]] = []
+ source = "pptx"
+ if safe.lower().endswith((".pptx", ".ppt")):
+ slides = pptx_to_slides(dest)
+ docling_slides = await docling_enrich(dest)
+ if docling_slides and len(docling_slides) >= len(slides):
+ slides = docling_slides
+ source = "docling+pptx"
+ else:
+ docling_slides = await docling_enrich(dest)
+ if docling_slides:
+ slides = docling_slides
+ source = "docling"
+
+ if not slides:
+ slides = [{
+ "id": "upload-1",
+ "title": safe,
+ "subtitle": "Uploaded file",
+ "bullets": [f"File stored at {dest.name}", "Could not auto-parse slides — open in editor or re-upload PPTX"],
+ "kind": "upload",
+ }]
+
+ payload = {
+ "id": deck_id,
+ "filename": safe,
+ "source": source,
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "title": safe.rsplit(".", 1)[0],
+ "subtitle": "Uploaded presentation",
+ "slide_count": len(slides),
+ "slides": slides,
+ }
+ (deck_dir / "meta.json").write_text(json.dumps(payload, indent=2, default=str))
+ (deck_dir / "deck.json").write_text(json.dumps(payload, default=str))
+ return payload
+
+
+def get_deck(deck_id: str) -> dict[str, Any] | None:
+ path = PRESENTATIONS_DIR / deck_id / "meta.json"
+ if not path.exists():
+ return None
+ return json.loads(path.read_text())
diff --git a/api/requirements.txt b/api/requirements.txt
index c506982..4d1d03d 100644
--- a/api/requirements.txt
+++ b/api/requirements.txt
@@ -4,6 +4,13 @@ redis==5.2.1
httpx==0.28.1
sqlalchemy==2.0.36
aiosqlite==0.20.0
+psycopg2-binary==2.9.10
pydantic==2.10.4
python-multipart==0.0.20
websockets==14.1
+pymysql==1.1.1
+pymongo==4.10.1
+cassandra-driver==3.29.2
+neo4j==5.26.0
+python-pptx==1.0.2
+boto3==1.35.99
diff --git a/api/storage_s3.py b/api/storage_s3.py
new file mode 100644
index 0000000..4916161
--- /dev/null
+++ b/api/storage_s3.py
@@ -0,0 +1,138 @@
+"""ObjectScale / S3 storage API for Command Center."""
+
+from __future__ import annotations
+
+import os
+from typing import Any
+
+import boto3
+from botocore.client import Config
+from botocore.exceptions import ClientError
+from fastapi import APIRouter, Query
+from fastapi.responses import JSONResponse, StreamingResponse
+
+S3_ENDPOINT = os.getenv("S3_ENDPOINT", "http://10.0.20.111:9020")
+S3_ACCESS_KEY = os.getenv("S3_ACCESS_KEY", "object_admin1")
+S3_SECRET_KEY = os.getenv("S3_SECRET_KEY", "ChangeMeChangeMeChangeMeChangeMeChangeMe")
+S3_REGION = os.getenv("S3_REGION", "us-east-1")
+
+router = APIRouter(prefix="/api/storage/s3", tags=["storage"])
+
+
+def _client():
+ return boto3.client(
+ "s3",
+ endpoint_url=S3_ENDPOINT,
+ aws_access_key_id=S3_ACCESS_KEY,
+ aws_secret_access_key=S3_SECRET_KEY,
+ region_name=S3_REGION,
+ config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
+ )
+
+
+def _human_size(n: int) -> str:
+ for unit in ("B", "KB", "MB", "GB", "TB"):
+ if n < 1024:
+ return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
+ n /= 1024
+ return f"{n:.1f} PB"
+
+
+@router.get("/health")
+async def s3_health():
+ try:
+ s3 = _client()
+ buckets = s3.list_buckets()
+ names = [b["Name"] for b in buckets.get("Buckets", [])]
+ return {
+ "ok": True,
+ "endpoint": S3_ENDPOINT,
+ "buckets": len(names),
+ "bucket_names": names,
+ }
+ except Exception as exc:
+ return JSONResponse({"ok": False, "endpoint": S3_ENDPOINT, "error": str(exc)}, status_code=502)
+
+
+@router.get("/buckets")
+async def list_buckets():
+ try:
+ s3 = _client()
+ resp = s3.list_buckets()
+ items = []
+ for b in resp.get("Buckets", []):
+ name = b["Name"]
+ try:
+ loc = s3.list_objects_v2(Bucket=name, MaxKeys=1)
+ count_hint = loc.get("KeyCount", 0)
+ except ClientError:
+ count_hint = None
+ items.append({
+ "name": name,
+ "created": b.get("CreationDate", "").isoformat() if b.get("CreationDate") else None,
+ "has_objects": bool(count_hint),
+ })
+ return {"ok": True, "buckets": items, "endpoint": S3_ENDPOINT}
+ except Exception as exc:
+ return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
+
+
+@router.get("/buckets/{bucket}/objects")
+async def list_objects(
+ bucket: str,
+ prefix: str = Query("", alias="prefix"),
+ max_keys: int = Query(200, le=500),
+):
+ try:
+ s3 = _client()
+ resp = s3.list_objects_v2(Bucket=bucket, Prefix=prefix, Delimiter="/", MaxKeys=max_keys)
+ folders = [
+ {"type": "prefix", "name": p["Prefix"][len(prefix):].rstrip("/"), "prefix": p["Prefix"]}
+ for p in resp.get("CommonPrefixes", [])
+ ]
+ objects = [
+ {
+ "type": "object",
+ "key": o["Key"],
+ "name": o["Key"][len(prefix):] if o["Key"].startswith(prefix) else o["Key"],
+ "size": o.get("Size", 0),
+ "size_human": _human_size(o.get("Size", 0)),
+ "modified": o.get("LastModified", "").isoformat() if o.get("LastModified") else None,
+ }
+ for o in resp.get("Contents", [])
+ if o["Key"] != prefix
+ ]
+ return {
+ "ok": True,
+ "bucket": bucket,
+ "prefix": prefix,
+ "folders": folders,
+ "objects": objects,
+ "truncated": resp.get("IsTruncated", False),
+ }
+ except ClientError as exc:
+ return JSONResponse({"ok": False, "error": str(exc)}, status_code=403)
+ except Exception as exc:
+ return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
+
+
+@router.get("/buckets/{bucket}/download")
+async def download_object(bucket: str, key: str = Query(...)):
+ try:
+ s3 = _client()
+ obj = s3.get_object(Bucket=bucket, Key=key)
+ body = obj["Body"]
+ filename = key.split("/")[-1] or "download"
+ media = obj.get("ContentType") or "application/octet-stream"
+
+ def stream():
+ while chunk := body.read(1024 * 256):
+ yield chunk
+
+ return StreamingResponse(
+ stream(),
+ media_type=media,
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
+ except ClientError as exc:
+ return JSONResponse({"ok": False, "error": str(exc)}, status_code=404)
diff --git a/api/supervisor.py b/api/supervisor.py
new file mode 100644
index 0000000..ce49dc3
--- /dev/null
+++ b/api/supervisor.py
@@ -0,0 +1,34 @@
+"""Fan-out lab events to supervisor agents Mo & Bart."""
+
+from __future__ import annotations
+
+from agent_terminal import terminal_log
+from node_registry import NODE_IDS
+
+SUPERVISOR_IDS = ["mo-commander", "bart-commander"]
+OPERATOR_IDS = {
+ "etl-guardian", "lakehouse-ops", "data-custodian", "hadoop-ranger", "infra-sentinel",
+ "network-watcher", "mcp-coordinator",
+}
+
+
+async def mirror_to_supervisors(
+ source: str,
+ message: str,
+ *,
+ level: str = "info",
+ phase: str = "intel",
+) -> None:
+ icon = {"warn": "⚠", "err": "✗", "ok": "✓"}.get(level, "→")
+ text = f"{icon} [{source}] {message}"
+ for sid in SUPERVISOR_IDS:
+ await terminal_log(sid, text, level=level, phase=phase, mirror=False)
+
+
+async def mirror_terminal_line(line: dict) -> None:
+ aid = line.get("agent_id", "")
+ if aid in SUPERVISOR_IDS or aid in NODE_IDS:
+ return
+ if aid in OPERATOR_IDS:
+ lvl = line.get("level", "info")
+ await mirror_to_supervisors(aid, line.get("text", "")[:240], level=lvl, phase="trace")
diff --git a/api/topology_views.py b/api/topology_views.py
new file mode 100644
index 0000000..7c015ba
--- /dev/null
+++ b/api/topology_views.py
@@ -0,0 +1,606 @@
+"""Five animated topology views from data architecture perspectives."""
+
+from __future__ import annotations
+
+from typing import Any
+
+
+def _edge(eid: str, src: str, dst: str, label: str, kind: str, active: bool = True) -> dict[str, Any]:
+ return {"id": eid, "from": src, "to": dst, "label": label, "kind": kind, "active": active}
+
+
+def _clone_node(n: dict[str, Any], x: float, y: float, layer: str | None = None) -> dict[str, Any]:
+ out = {**n, "x": x, "y": y}
+ if layer:
+ out["layer"] = layer
+ return out
+
+
+def build_all_topologies(
+ base_nodes: list[dict[str, Any]],
+ base_edges: list[dict[str, Any]],
+ snap: dict[str, Any],
+ *,
+ pipeline_active: bool,
+ connectors: list[str],
+) -> dict[str, dict[str, Any]]:
+ by_id = {n["id"]: n for n in base_nodes}
+ etl = snap.get("etl", {})
+ lake = snap.get("lakehouse", {})
+ docker = snap.get("docker", {})
+ gpu = snap.get("gpu", {})
+
+ # ── 1. PIPELINE (CDC end-to-end) ──
+ pipeline = {
+ "id": "pipeline",
+ "label": "CDC Pipeline",
+ "subtitle": "Ingest → Stream → Process → Object Storage",
+ "layers": [
+ {"id": "ingest", "label": "INGEST", "y": 12, "color": "#e8a838"},
+ {"id": "stream", "label": "STREAM", "y": 12, "color": "#4c9aed"},
+ {"id": "process", "label": "PROCESS", "y": 12, "color": "#e05297"},
+ {"id": "store", "label": "STORE", "y": 12, "color": "#d4a017"},
+ ],
+ "nodes": [
+ _clone_node(by_id["airflow"], 8, 22, "ingest"),
+ _clone_node(by_id["db"], 24, 22, "ingest"),
+ _clone_node(by_id["debezium"], 40, 22, "stream"),
+ _clone_node(by_id["kafka"], 56, 22, "stream"),
+ _clone_node(by_id["lakehouse"], 72, 22, "process"),
+ _clone_node(by_id["s3"], 88, 22, "store"),
+ _clone_node(by_id["docker"], 12, 58, "infra"),
+ _clone_node(by_id["hadoop"], 50, 58, "parallel"),
+ _clone_node(by_id["gpu"], 88, 58, "compute"),
+ _clone_node(by_id["command"], 50, 82, "hub"),
+ ],
+ "edges": base_edges,
+ }
+
+ # ── 2. MEDALLION (Bronze → Silver → Gold) ──
+ medallion_nodes = [
+ _clone_node(by_id["airflow"], 12, 18, "bronze"),
+ _clone_node(by_id["db"], 30, 18, "bronze"),
+ _clone_node(by_id["debezium"], 48, 18, "bronze"),
+ _clone_node(by_id["kafka"], 20, 42, "silver"),
+ {
+ **by_id["lakehouse"],
+ "id": "spark",
+ "label": "Spark ETL",
+ "x": 42,
+ "y": 42,
+ "layer": "silver",
+ "apps": [a for a in by_id["lakehouse"].get("apps", []) if "spark" in a.get("name", "").lower()],
+ },
+ _clone_node(by_id["lakehouse"], 64, 42, "silver"),
+ _clone_node(by_id["s3"], 24, 68, "gold"),
+ {
+ **by_id.get("docker", {}),
+ "id": "superset",
+ "label": "Superset BI",
+ "x": 48,
+ "y": 68,
+ "layer": "gold",
+ "apps": [a for a in docker.get("containers", []) if "superset" in f"{a.get('name','')} {a.get('image','')}".lower()][:4]
+ or [{"name": "superset", "state": "running", "image": "superset", "ports": ["8088"]}],
+ },
+ _clone_node(by_id["hadoop"], 72, 68, "gold"),
+ _clone_node(by_id["gpu"], 88, 68, "gold"),
+ ]
+ medallion = {
+ "id": "medallion",
+ "label": "Medallion Architecture",
+ "subtitle": "Bronze (raw) → Silver (staging) → Gold (serving)",
+ "layers": [
+ {"id": "bronze", "label": "🥉 BRONZE · Raw Ingest", "y": 18, "color": "#cd7f32"},
+ {"id": "silver", "label": "🥈 SILVER · Staging & Transform", "y": 42, "color": "#c0c0c0"},
+ {"id": "gold", "label": "🥇 GOLD · Analytics & Serve", "y": 68, "color": "#d4a017"},
+ ],
+ "nodes": medallion_nodes,
+ "edges": [
+ _edge("m1", "airflow", "db", "seed", "pipeline", bool(etl.get("airflow_healthy"))),
+ _edge("m2", "db", "debezium", "CDC raw", "pipeline", bool(connectors)),
+ _edge("m3", "debezium", "kafka", "bronze topics", "pipeline", bool(connectors)),
+ _edge("m4", "kafka", "spark", "stream", "pipeline", bool(etl.get("kafka_ui_ok"))),
+ _edge("m5", "spark", "lakehouse", "transform", "pipeline", lake.get("running", 0) > 0),
+ _edge("m6", "lakehouse", "s3", "curated", "pipeline", pipeline_active),
+ _edge("m7", "s3", "superset", "BI queries", "query", True),
+ _edge("m8", "lakehouse", "hadoop", "archive", "parallel", True),
+ ],
+ }
+
+ # ── 3. NETWORK (VLAN zones, data in/out) ──
+ network = {
+ "id": "network",
+ "label": "Network Topology",
+ "subtitle": "VLAN 20 storage · VLAN 21 compute · ingress/egress",
+ "layers": [
+ {"id": "ingress", "label": "⬇ DATA IN", "y": 15, "color": "#3fb950"},
+ {"id": "compute", "label": "COMPUTE 10.0.21.x", "y": 42, "color": "#4c9aed"},
+ {"id": "storage", "label": "STORAGE 10.0.20.x", "y": 42, "color": "#d4a017"},
+ {"id": "egress", "label": "⬆ DATA OUT", "y": 70, "color": "#f778ba"},
+ ],
+ "nodes": [
+ _clone_node(by_id["airflow"], 12, 16, "ingress"),
+ _clone_node(by_id["db"], 32, 16, "ingress"),
+ _clone_node(by_id["kafka"], 18, 44, "compute"),
+ _clone_node(by_id["debezium"], 36, 44, "compute"),
+ _clone_node(by_id["lakehouse"], 54, 44, "compute"),
+ _clone_node(by_id["hadoop"], 72, 44, "compute"),
+ _clone_node(by_id["docker"], 54, 58, "compute"),
+ _clone_node(by_id["s3"], 18, 44, "storage"),
+ _clone_node(by_id["gpu"], 36, 44, "storage"),
+ {
+ **by_id["command"],
+ "id": "grafana",
+ "label": "Grafana Mon",
+ "vm": "atc-grafana",
+ "ip": "10.0.20.103",
+ "x": 72,
+ "y": 44,
+ "layer": "storage",
+ "color": "#f778ba",
+ },
+ _clone_node(by_id["s3"], 22, 72, "egress"),
+ _clone_node(by_id["gpu"], 48, 72, "egress"),
+ _clone_node(by_id["docker"], 74, 72, "egress"),
+ _clone_node(by_id["command"], 50, 88, "hub"),
+ ],
+ "edges": [
+ _edge("n-in1", "airflow", "db", "VLAN21 ingest", "pipeline", True),
+ _edge("n-in2", "db", "debezium", "CDC in", "pipeline", True),
+ _edge("n-x1", "debezium", "kafka", ":9092", "pipeline", True),
+ _edge("n-x2", "lakehouse", "s3", "→ VLAN20", "pipeline", pipeline_active),
+ _edge("n-out1", "s3", "docker", "S3 API out", "query", True),
+ _edge("n-out2", "gpu", "docker", "inference out", "query", bool(gpu.get("ok"))),
+ _edge("n-out3", "lakehouse", "grafana", "metrics", "infra", True),
+ ],
+ }
+ for n in network["nodes"]:
+ if n["id"] == "s3" and n["y"] == 44:
+ n.update({"x": 18, "y": 44})
+ if n["id"] == "gpu" and n.get("layer") == "storage":
+ n.update({"x": 36, "y": 44})
+
+ # ── 4. APPLICATIONS (all workloads by function) ──
+ all_apps: list[dict[str, Any]] = []
+ for a in by_id.get("docker", {}).get("apps", []):
+ all_apps.append(a)
+ for a in by_id.get("db", {}).get("apps", []):
+ all_apps.append(a)
+ for a in by_id.get("lakehouse", {}).get("apps", []):
+ all_apps.append(a)
+ all_apps.append({"name": "Airflow", "state": "running" if etl.get("airflow_healthy") else "down", "image": "airflow", "ports": ["8080"]})
+ all_apps.append({"name": "Kafka", "state": "running" if etl.get("kafka_ui_ok") else "down", "image": "kafka", "ports": ["9092"]})
+ for c in connectors:
+ all_apps.append({"name": c, "state": "running", "image": "connect", "ports": ["8083"]})
+
+ def _app_group(gid: str, label: str, x: float, y: float, color: str, filter_fn) -> dict:
+ apps = [a for a in all_apps if filter_fn(a)]
+ running = sum(1 for a in apps if a.get("state") == "running")
+ return {
+ "id": gid,
+ "label": label,
+ "vm": f"{len(apps)} apps",
+ "ip": "multi-host",
+ "x": x,
+ "y": y,
+ "color": color,
+ "level": "ok" if running == len(apps) and apps else "warn",
+ "role": "apps",
+ "apps": apps[:10],
+ "running": running,
+ "total": len(apps) or 1,
+ "layer": "apps",
+ }
+
+ applications = {
+ "id": "applications",
+ "label": "Application Map",
+ "subtitle": "Every container & service in the lab",
+ "layers": [
+ {"id": "ingest", "label": "INGEST", "y": 18, "color": "#e8a838"},
+ {"id": "stream", "label": "STREAM", "y": 18, "color": "#4c9aed"},
+ {"id": "process", "label": "PROCESS", "y": 42, "color": "#e05297"},
+ {"id": "store", "label": "STORE & SERVE", "y": 66, "color": "#d4a017"},
+ ],
+ "nodes": [
+ _app_group("apps-ingest", "Ingest", 12, 20, "#e8a838", lambda a: "airflow" in a.get("name", "").lower() or "airflow" in a.get("image", "").lower()),
+ _app_group("apps-sources", "Source DBs", 30, 20, "#ffaa00", lambda a: any(k in f"{a.get('name','')} {a.get('image','')}".lower() for k in ("postgres", "mysql", "mongo", "cassandra", "neo4j"))),
+ _app_group("apps-cdc", "CDC Connect", 48, 20, "#c77dff", lambda a: "connect" in a.get("image", "").lower() or "connector" in a.get("name", "").lower()),
+ _app_group("apps-stream", "Streaming", 66, 20, "#4c9aed", lambda a: "kafka" in f"{a.get('name','')} {a.get('image','')}".lower()),
+ _app_group("apps-process", "Processing", 24, 44, "#e05297", lambda a: any(k in f"{a.get('name','')} {a.get('image','')}".lower() for k in ("spark", "trino", "s3-kafka"))),
+ _app_group("apps-storage", "Storage", 48, 44, "#d4a017", lambda a: any(k in f"{a.get('name','')} {a.get('image','')}".lower() for k in ("s3", "object", "hdfs", "namenode"))),
+ _app_group("apps-platform", "Platform", 72, 44, "#9b72cf", lambda a: any(k in f"{a.get('name','')} {a.get('image','')}".lower() for k in ("dockhand", "homepage", "forgejo", "superset", "nginx", "redis", "lam"))),
+ _app_group("apps-serve", "Analytics", 36, 68, "#3fb950", lambda a: any(k in f"{a.get('name','')} {a.get('image','')}".lower() for k in ("superset", "grafana", "trino"))),
+ _app_group("apps-gpu", "AI / GPU", 60, 68, "#76b900", lambda a: any(k in f"{a.get('name','')} {a.get('image','')}".lower() for k in ("vllm", "gpu", "ollama", "sglang")) or "gpu" in a.get("name", "").lower()),
+ ],
+ "edges": [
+ _edge("a1", "apps-ingest", "apps-sources", "seed", "pipeline", True),
+ _edge("a2", "apps-sources", "apps-cdc", "CDC", "pipeline", bool(connectors)),
+ _edge("a3", "apps-cdc", "apps-stream", "topics", "pipeline", True),
+ _edge("a4", "apps-stream", "apps-process", "consume", "pipeline", True),
+ _edge("a5", "apps-process", "apps-storage", "persist", "pipeline", pipeline_active),
+ _edge("a6", "apps-storage", "apps-serve", "query", "query", True),
+ _edge("a7", "apps-platform", "apps-serve", "dashboards", "infra", True),
+ ],
+ }
+
+ # ── 5. COMMAND (Mo & Bart + all agents + MCP) ──
+ command_nodes = [
+ {
+ "id": "mo-commander",
+ "label": "Mo · Command",
+ "vm": "Supervisor",
+ "ip": "10.0.21.33",
+ "x": 28,
+ "y": 14,
+ "color": "#4c9aed",
+ "level": "ok",
+ "role": "supervisor",
+ "apps": [{"name": "event-intel", "state": "running", "image": "command", "ports": []}],
+ "running": 1,
+ "total": 1,
+ "layer": "command",
+ "description": "Full visibility — all events, network ingress, agent dispatch",
+ },
+ {
+ "id": "bart-commander",
+ "label": "Bart · Ops",
+ "vm": "Supervisor",
+ "ip": "10.0.21.33",
+ "x": 72,
+ "y": 14,
+ "color": "#3fb950",
+ "level": "ok",
+ "role": "supervisor",
+ "apps": [{"name": "network-intel", "state": "running", "image": "command", "ports": []}],
+ "running": 1,
+ "total": 1,
+ "layer": "command",
+ "description": "Full visibility — egress, MCP comms, approvals",
+ },
+ {
+ "id": "mcp-coordinator",
+ "label": "MCP Hub",
+ "vm": "VM304",
+ "ip": "10.0.21.33",
+ "x": 50,
+ "y": 32,
+ "color": "#f778ba",
+ "level": "ok",
+ "role": "mcp",
+ "apps": [{"name": "mcp-router", "state": "running", "image": "mcp", "ports": ["3101-3112"]}],
+ "running": 1,
+ "total": 1,
+ "layer": "mcp",
+ },
+ {
+ "id": "network-watcher",
+ "label": "Network Watcher",
+ "vm": "multi-VLAN",
+ "ip": "10.0.20/21.x",
+ "x": 50,
+ "y": 48,
+ "color": "#58a6ff",
+ "level": "ok",
+ "role": "network",
+ "apps": [
+ {"name": "ingress", "state": "running", "image": "net", "ports": []},
+ {"name": "egress", "state": "running", "image": "net", "ports": []},
+ ],
+ "running": 2,
+ "total": 2,
+ "layer": "network",
+ },
+ ]
+ agent_ops = [
+ ("etl-guardian", "ETL Guardian", 8, 68, "#4c9aed"),
+ ("data-custodian", "Data Custodian", 24, 68, "#e8a838"),
+ ("lakehouse-ops", "Lakehouse Ops", 40, 68, "#e05297"),
+ ("hadoop-ranger", "Hadoop Ranger", 56, 68, "#3fb950"),
+ ("infra-sentinel", "Infra Sentinel", 72, 68, "#9b72cf"),
+ ]
+ command_agent_nodes = []
+ for aid, label, x, y, color in agent_ops:
+ zone_map = {"etl-guardian": "kafka", "data-custodian": "db", "lakehouse-ops": "lakehouse", "hadoop-ranger": "hadoop", "infra-sentinel": "docker"}
+ src = by_id.get(zone_map[aid], by_id["command"])
+ command_agent_nodes.append({
+ **src,
+ "id": aid,
+ "label": label,
+ "x": x,
+ "y": y,
+ "color": color,
+ "layer": "agents",
+ "role": "mcp-agent",
+ })
+
+ command_nodes = command_nodes[:4] + command_agent_nodes + [_clone_node(by_id["gpu"], 88, 68, "agents")]
+
+ command_edges = []
+ for aid, _, _, _, _ in agent_ops:
+ command_edges.append(_edge(f"c-mo-{aid}", aid, "mo-commander", "report", "infra", True))
+ command_edges.append(_edge(f"c-bart-{aid}", aid, "bart-commander", "report", "infra", True))
+ command_edges.append(_edge(f"c-mcp-{aid}", aid, "mcp-coordinator", "MCP", "query", True))
+ command_edges += [
+ _edge("c-net-mo", "network-watcher", "mo-commander", "ingress", "pipeline", True),
+ _edge("c-net-bart", "network-watcher", "bart-commander", "egress", "pipeline", True),
+ _edge("c-mcp-mo", "mcp-coordinator", "mo-commander", "intel", "infra", True),
+ _edge("c-mcp-bart", "mcp-coordinator", "bart-commander", "intel", "infra", True),
+ _edge("c-gpu-mcp", "gpu", "mcp-coordinator", "LLM", "query", bool(gpu.get("ok"))),
+ ]
+
+ command = {
+ "id": "command",
+ "label": "Command & Control",
+ "subtitle": "Mo & Bart · MCP agents · all comms converge here",
+ "layers": [
+ {"id": "command", "label": "👤 SUPERVISORS", "y": 14, "color": "#4c9aed"},
+ {"id": "mcp", "label": "MCP HUB", "y": 32, "color": "#f778ba"},
+ {"id": "network", "label": "NETWORK", "y": 48, "color": "#58a6ff"},
+ {"id": "agents", "label": "MCP AGENTS", "y": 68, "color": "#8b949e"},
+ ],
+ "nodes": command_nodes,
+ "edges": command_edges,
+ }
+
+ return {
+ "pipeline": pipeline,
+ "medallion": medallion,
+ "network": network,
+ "applications": applications,
+ "command": command,
+ "architecture": _build_architecture(snap, by_id, connectors, pipeline_active, etl, lake, gpu, docker),
+ }
+
+
+def _arch_node(
+ nid: str,
+ label: str,
+ subtitle: str,
+ x: float,
+ y: float,
+ color: str,
+ layer: str,
+ level: str,
+ vm: str,
+ ip: str,
+ metrics: list[str],
+ apps: list[dict] | None = None,
+ running: int = 1,
+ total: int = 1,
+ icon: str = "◆",
+ extra: dict | None = None,
+) -> dict[str, Any]:
+ row: dict[str, Any] = {
+ "id": nid,
+ "label": label,
+ "subtitle": subtitle,
+ "vm": vm,
+ "ip": ip,
+ "x": x,
+ "y": y,
+ "color": color,
+ "level": level,
+ "role": layer,
+ "layer": layer,
+ "apps": apps or [],
+ "running": running,
+ "total": total,
+ "metrics": metrics,
+ "icon": icon,
+ }
+ if extra:
+ row.update(extra)
+ return row
+
+
+def _build_architecture(
+ snap: dict[str, Any],
+ by_id: dict[str, dict[str, Any]],
+ connectors: list[str],
+ pipeline_active: bool,
+ etl: dict[str, Any],
+ lake: dict[str, Any],
+ gpu: dict[str, Any],
+ docker: dict[str, Any],
+) -> dict[str, Any]:
+ """Palantir-style layered data platform (sources → consumers)."""
+ databases = snap.get("databases", {})
+ db_apps = by_id.get("db", {}).get("apps", [])
+ db_running = databases.get("running", 0)
+ db_total = max(databases.get("total", 1), 1)
+
+ def _db_node(db_id: str, label: str, subtitle: str, x: float, y: float, patterns: tuple[str, ...], icon: str) -> dict[str, Any]:
+ matched = [a for a in db_apps if any(p in f"{a.get('name','')} {a.get('image','')}".lower() for p in patterns)]
+ up = sum(1 for a in matched if a.get("state") == "running")
+ total = len(matched) or 1
+ return _arch_node(
+ db_id, label, subtitle, x, y, "#4c9aed", "sources",
+ "ok" if up == total and up else ("warn" if up else "down"),
+ "atc-db02", "10.0.21.51",
+ [f"{up}/{total} up"],
+ matched or [{"name": label, "state": "running", "image": label.lower(), "ports": []}],
+ up, total, icon,
+ )
+
+ # Horizontal columns — nodes stacked vertically per stage (no overlap)
+ C_SRC, C_CDC, C_STR, C_LAKE, C_QRY, C_CON = 11, 27, 43, 59, 75, 91
+
+ pg = _db_node("src-postgres", "PostgreSQL", "customers + orders", C_SRC, 12, ("postgres",), "🐘")
+ mysql = _db_node("src-mysql", "MySQL", "inventory + payments", C_SRC, 28, ("mysql",), "🐬")
+ mongo = _db_node("src-mongo", "MongoDB", "profiles + events", C_SRC, 44, ("mongo",), "🍃")
+ cass = _db_node("src-cassandra", "Cassandra", "time-series IoT", C_SRC, 60, ("cassandra",), "💍")
+
+ airflow_ok = bool(etl.get("airflow_healthy"))
+ airflow = _arch_node(
+ "src-airflow", "Apache Airflow", "Orchestrator", C_SRC, 76, "#e8a838", "sources",
+ "ok" if airflow_ok else "warn", "atc-airflow01", "10.0.21.55",
+ ["SLA green" if airflow_ok else "degraded"],
+ [{"name": "scheduler", "state": "running" if airflow_ok else "down", "image": "airflow", "ports": ["8080"]}],
+ int(airflow_ok), 1, "🌀",
+ )
+
+ def _cdc_node(cid: str, label: str, src: str, y: float) -> dict[str, Any]:
+ has = any(src.replace("src-", "") in c.lower() or label.split()[-1].lower() in c.lower() for c in connectors)
+ lag = "420 ms" if has else "—"
+ return _arch_node(
+ cid, f"Debezium {label}", f"CDC · {label}", C_CDC, y, "#e8a838", "cdc",
+ "ok" if has else "warn", "atc-lake01", "10.0.21.50",
+ [f"lag {lag}"],
+ [{"name": c, "state": "running", "image": "connect", "ports": ["8083"]} for c in connectors if label.lower() in c.lower()][:2]
+ or [{"name": f"debezium-{label.lower()}", "state": "running" if has else "down", "image": "connect", "ports": ["8083"]}],
+ len(connectors) if has else 0, 1, "⟿",
+ )
+
+ cdc_pg = _cdc_node("cdc-postgres", "PG", "postgres", 16)
+ cdc_mysql = _cdc_node("cdc-mysql", "MySQL", "mysql", 32)
+ cdc_mongo = _cdc_node("cdc-mongo", "Mongo", "mongo", 48)
+ cdc_cass = _cdc_node("cdc-cassandra", "Cassandra", "cassandra", 64)
+
+ kafka_ok = bool(etl.get("kafka_ui_ok"))
+ kafka = _arch_node(
+ "stream-kafka", "Apache Kafka", "KRaft · 3 brokers", C_STR, 20, "#e8a838", "streaming",
+ "ok" if kafka_ok else "warn", "atc-kafka01", "10.0.21.36",
+ [f"{len(connectors)} topics"],
+ [{"name": "broker", "state": "running" if kafka_ok else "down", "image": "kafka", "ports": ["9092"]}],
+ int(kafka_ok), 1, "📨",
+ {"connectors": connectors[:4]},
+ )
+ schema = _arch_node(
+ "stream-schema", "Schema Registry", "Avro schemas", C_STR, 44, "#e8a838", "streaming",
+ "ok" if kafka_ok else "warn", "atc-kafka01", "10.0.21.36",
+ ["compat BACKWARD"],
+ [{"name": "schema-registry", "state": "running" if kafka_ok else "down", "image": "confluent", "ports": ["8081"]}],
+ int(kafka_ok), 1, "📋",
+ )
+ spark_ok = lake.get("running", 0) > 0
+ spark = _arch_node(
+ "stream-spark", "Spark Streaming", "Dynamic executors", C_STR, 68, "#e8a838", "streaming",
+ "ok" if spark_ok else "warn", "atc-lake01", "10.0.21.50",
+ ["micro-batch 2.4s"],
+ [a for a in by_id.get("lakehouse", {}).get("apps", []) if "spark" in f"{a.get('name','')} {a.get('image','')}".lower()][:3]
+ or [{"name": "spark-worker", "state": "running" if spark_ok else "down", "image": "spark", "ports": ["8080"]}],
+ lake.get("running", 0), max(lake.get("total", 1), 1), "⚡",
+ )
+
+ iceberg = _arch_node(
+ "lake-iceberg", "Iceberg Tables", "bronze → silver → gold", C_LAKE, 28, "#4c9aed", "lakehouse",
+ "ok" if lake.get("trino_ok") else "warn", "atc-lake01", "10.0.21.50",
+ ["Parquet lake"],
+ by_id.get("lakehouse", {}).get("apps", [])[:4],
+ lake.get("running", 0), max(lake.get("total", 1), 1), "🧊",
+ {"trino_ok": lake.get("trino_ok")},
+ )
+ s3_node = by_id.get("s3", {})
+ s3_ok = s3_node.get("level") == "ok"
+ ecs = _arch_node(
+ "lake-s3", "Dell ECS S3", "ObjectScale bucket", C_LAKE, 58, "#4c9aed", "lakehouse",
+ s3_node.get("level", "warn"), "atc-objectscale", "10.0.20.111",
+ ["bucket: data"],
+ s3_node.get("apps", []),
+ s3_node.get("running", 0), max(s3_node.get("total", 1), 1), "🪣",
+ {"bucket": "data", "port": "9020", "consumer_ok": pipeline_active},
+ )
+
+ trino_ok = bool(lake.get("trino_ok"))
+ trino = _arch_node(
+ "query-trino", "Trino", "Federated SQL", C_QRY, 30, "#bc8cff", "query",
+ "ok" if trino_ok else "warn", "atc-lake01", "10.0.21.50",
+ ["5 catalogs"],
+ [a for a in by_id.get("lakehouse", {}).get("apps", []) if "trino" in f"{a.get('name','')} {a.get('image','')}".lower()][:2]
+ or [{"name": "trino", "state": "running" if trino_ok else "down", "image": "trino", "ports": ["8080"]}],
+ int(trino_ok), 1, "🔍",
+ )
+ dbt = _arch_node(
+ "query-dbt", "dbt on Trino", "Transformations", C_QRY, 58, "#e8a838", "query",
+ "ok" if trino_ok else "warn", "atc-lake01", "10.0.21.50",
+ ["84 models"],
+ [{"name": "dbt-core", "state": "running" if trino_ok else "down", "image": "dbt", "ports": []}],
+ int(trino_ok), 1, "🔧",
+ )
+
+ superset_apps = [a for a in docker.get("containers", []) if "superset" in f"{a.get('name','')} {a.get('image','')}".lower()]
+ superset_up = any(a.get("state") == "running" for a in superset_apps)
+ bi = _arch_node(
+ "cons-bi", "BI / Reporting", "Superset", C_CON, 18, "#bc8cff", "consumers",
+ "ok" if superset_up else "warn", "multi-host", "10.0.21.x",
+ ["dashboards"],
+ [_app_row(a) for a in superset_apps[:2]] if superset_apps else [{"name": "superset", "state": "running", "image": "superset", "ports": ["8088"]}],
+ int(superset_up), 1, "📊",
+ )
+ notebooks = _arch_node(
+ "cons-notebooks", "Notebooks", "Jupyter · DBeaver", C_CON, 44, "#bc8cff", "consumers",
+ "ok", "atc-lake01", "10.0.21.50",
+ ["Trino SQL"],
+ [{"name": "jupyter", "state": "running", "image": "jupyter", "ports": ["8888"]}],
+ 1, 1, "📓",
+ )
+ gpu_ok = bool(gpu.get("ok"))
+ ml = _arch_node(
+ "cons-ml", "ML / GenAI", "vLLM cluster", C_CON, 70, "#bc8cff", "consumers",
+ "ok" if gpu_ok else "warn", "atc-gpu-dev", "10.0.20.106",
+ [gpu.get("active_model") or "offline"],
+ [{"name": gpu.get("active_model") or "vllm", "state": "running" if gpu.get("inference_active") else "down", "image": "vllm", "ports": ["8001"]}],
+ gpu.get("gpu_count", 0) or 0, max(gpu.get("gpu_count", 4) or 4, 1), "🤖",
+ {"model": gpu.get("active_model"), "util": round(sum(g.get("util_gpu", 0) for g in gpu.get("gpus", [])) / max(len(gpu.get("gpus", [])), 1), 1)},
+ )
+
+ nodes = [
+ pg, mysql, mongo, cass, airflow,
+ cdc_pg, cdc_mysql, cdc_mongo, cdc_cass,
+ kafka, schema, spark,
+ iceberg, ecs,
+ trino, dbt,
+ bi, notebooks, ml,
+ ]
+
+ edges = [
+ _edge("ar1", "src-postgres", "cdc-postgres", "WAL", "pipeline", True),
+ _edge("ar2", "src-mysql", "cdc-mysql", "binlog", "pipeline", True),
+ _edge("ar3", "src-mongo", "cdc-mongo", "oplog", "pipeline", True),
+ _edge("ar4", "src-cassandra", "cdc-cassandra", "CDC", "pipeline", True),
+ _edge("ar5", "src-airflow", "src-postgres", "seed", "pipeline", airflow_ok),
+ _edge("ar6", "cdc-postgres", "stream-kafka", "topics", "pipeline", bool(connectors)),
+ _edge("ar7", "cdc-mysql", "stream-kafka", "topics", "pipeline", bool(connectors)),
+ _edge("ar8", "cdc-mongo", "stream-kafka", "topics", "pipeline", bool(connectors)),
+ _edge("ar9", "cdc-cassandra", "stream-kafka", "topics", "pipeline", bool(connectors)),
+ _edge("ar10", "stream-kafka", "stream-spark", "consume", "pipeline", kafka_ok),
+ _edge("ar11", "stream-kafka", "stream-schema", "schemas", "infra", kafka_ok),
+ _edge("ar12", "stream-spark", "lake-iceberg", "write", "pipeline", spark_ok),
+ _edge("ar13", "stream-spark", "lake-s3", "persist", "pipeline", pipeline_active),
+ _edge("ar14", "lake-iceberg", "query-trino", "catalog", "query", trino_ok),
+ _edge("ar15", "lake-s3", "query-trino", "S3 tables", "query", trino_ok),
+ _edge("ar16", "query-trino", "query-dbt", "models", "query", trino_ok),
+ _edge("ar17", "query-trino", "cons-bi", "SQL", "query", trino_ok),
+ _edge("ar18", "query-trino", "cons-notebooks", "ad-hoc", "query", trino_ok),
+ _edge("ar19", "lake-iceberg", "cons-ml", "features", "query", gpu_ok),
+ _edge("ar20", "cons-ml", "lake-s3", "training data", "parallel", gpu_ok),
+ ]
+
+ return {
+ "id": "architecture",
+ "label": "Data Platform Architecture",
+ "subtitle": "Sources → CDC → Streaming → Lakehouse → Query → Consumers",
+ "layers": [
+ {"id": "sources", "label": "SOURCES", "y": 8, "color": "#4c9aed", "x": 11},
+ {"id": "cdc", "label": "CDC", "y": 8, "color": "#e8a838", "x": 27},
+ {"id": "streaming", "label": "STREAMING", "y": 8, "color": "#e8a838", "x": 43},
+ {"id": "lakehouse", "label": "LAKEHOUSE", "y": 8, "color": "#4c9aed", "x": 59},
+ {"id": "query", "label": "QUERY", "y": 8, "color": "#bc8cff", "x": 75},
+ {"id": "consumers", "label": "CONSUMERS", "y": 8, "color": "#bc8cff", "x": 91},
+ ],
+ "nodes": nodes,
+ "edges": edges,
+ }
+
+
+def _app_row(c: dict[str, Any]) -> dict[str, Any]:
+ img = c.get("image") or ""
+ return {
+ "name": c.get("name", "?"),
+ "state": c.get("state", "unknown"),
+ "image": img.split("/")[-1].split(":")[0][:20],
+ "ports": c.get("ports") or [],
+ "host": c.get("host") or "",
+ }
diff --git a/api/workload.py b/api/workload.py
new file mode 100644
index 0000000..6ae2329
--- /dev/null
+++ b/api/workload.py
@@ -0,0 +1,316 @@
+"""Build UI workload + topology payload from lab snapshot."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from node_registry import NODE_AGENT, NODE_REGISTRY
+from topology_views import build_all_topologies
+
+OBJECTSCALE_HOST = "10.0.20.111"
+OBJECTSCALE_PORT = "9020"
+OBJECTSCALE_BUCKET = "data"
+
+
+def _level(running: int, total: int) -> str:
+ if total == 0:
+ return "unknown"
+ ratio = running / total
+ if ratio >= 0.9:
+ return "ok"
+ if ratio >= 0.5:
+ return "warn"
+ return "down"
+
+
+def _app_row(c: dict[str, Any]) -> dict[str, Any]:
+ img = c.get("image") or ""
+ short_img = img.split("/")[-1].split(":")[0][:20]
+ return {
+ "name": c.get("name", "?"),
+ "state": c.get("state", "unknown"),
+ "image": short_img,
+ "ports": c.get("ports") or [],
+ "host": c.get("host") or "",
+ }
+
+
+def _find_container(containers: list[dict], *patterns: str) -> dict | None:
+ for c in containers:
+ hay = f"{c.get('name', '')} {c.get('image', '')}".lower()
+ if any(p.lower() in hay for p in patterns):
+ return c
+ return None
+
+
+def _s3_level(lakehouse: dict[str, Any], objectscale_ok: bool) -> str:
+ containers = lakehouse.get("containers") or []
+ consumer = _find_container(containers, "s3-kafka", "s3_kafka")
+ consumer_up = consumer and consumer.get("state") == "running"
+ if objectscale_ok and consumer_up:
+ return "ok"
+ if objectscale_ok or consumer_up:
+ return "warn"
+ return "down"
+
+
+def build_workload_payload(snap: dict[str, Any]) -> dict[str, Any]:
+ docker = snap.get("docker", {})
+ databases = snap.get("databases", {})
+ lakehouse = snap.get("lakehouse", {})
+ etl = snap.get("etl", {})
+ hadoop = snap.get("hadoop", {})
+ gpu = snap.get("gpu", {})
+ objectscale = snap.get("objectscale", {})
+ command = snap.get("command_center", {})
+
+ docker_apps = [_app_row(c) for c in docker.get("containers", [])]
+ db_apps = [_app_row(c) for c in databases.get("containers", [])]
+ lake_apps = [_app_row(c) for c in lakehouse.get("containers", [])]
+
+ lake_containers = lakehouse.get("containers") or []
+ connect_app = _find_container(lake_containers, "kafka-connect", "connect")
+ s3_consumer = _find_container(lake_containers, "s3-kafka", "s3_kafka")
+ trino_app = _find_container(lake_containers, "trino")
+ spark_apps = [c for c in lake_containers if "spark" in f"{c.get('name', '')} {c.get('image', '')}".lower()]
+
+ hdfs_ok = hadoop.get("reachable", False)
+ etl_ok = etl.get("airflow_healthy") and etl.get("kafka_ui_ok")
+ objectscale_ok = objectscale.get("reachable", False)
+ s3_level = _s3_level(lakehouse, objectscale_ok)
+ connectors = etl.get("connectors") or []
+
+ etl_apps = [
+ {"name": "Airflow", "state": "running" if etl.get("airflow_healthy") else "down", "image": "airflow", "ports": ["8080"], "host": "10.0.21.55"},
+ {"name": "Kafka", "state": "running" if etl.get("kafka_ui_ok") else "down", "image": "kafka", "ports": ["9092"], "host": "10.0.21.36"},
+ {"name": "Kafka UI", "state": "running" if etl.get("kafka_ui_ok") else "down", "image": "kafka-ui", "ports": ["9000"], "host": "10.0.21.36"},
+ *[
+ {"name": c, "state": "running", "image": "connect", "ports": ["8083"], "host": lakehouse.get("host", "10.0.21.50")}
+ for c in connectors
+ ],
+ ]
+
+ s3_apps = [
+ {"name": "ObjectScale", "state": "running" if objectscale_ok else "down", "image": "objectscale", "ports": [OBJECTSCALE_PORT], "host": OBJECTSCALE_HOST},
+ {"name": f"bucket/{OBJECTSCALE_BUCKET}", "state": "running" if objectscale_ok else "down", "image": "s3", "ports": [], "host": OBJECTSCALE_HOST},
+ ]
+ if s3_consumer:
+ s3_apps.insert(0, _app_row(s3_consumer))
+
+ zones = [
+ {
+ "id": "docker",
+ "label": "DOCKER RACK",
+ "x": 8,
+ "color": "#b366ff",
+ "level": _level(docker.get("running", 0), docker.get("total", 1) or 1),
+ "running": docker.get("running", 0),
+ "total": docker.get("total", 0),
+ "apps": docker_apps,
+ "vm": "atc-docker01",
+ "ip": "10.0.21.45",
+ },
+ {
+ "id": "db",
+ "label": "DB VAULT",
+ "x": 22,
+ "color": "#ffaa00",
+ "level": _level(databases.get("running", 0), databases.get("total", 1) or 1),
+ "running": databases.get("running", 0),
+ "total": databases.get("total", 0),
+ "apps": db_apps,
+ "vm": "atc-db02",
+ "ip": "10.0.21.51",
+ },
+ {
+ "id": "etl",
+ "label": "ETL PIPE",
+ "x": 38,
+ "color": "#00f0ff",
+ "level": "ok" if etl_ok else "warn",
+ "running": sum(1 for s in [etl.get("airflow_healthy"), etl.get("kafka_ui_ok"), etl.get("spark_ui_ok")] if s),
+ "total": 3,
+ "apps": etl_apps,
+ "vm": "airflow + kafka",
+ "ip": "10.0.21.55 / .36",
+ },
+ {
+ "id": "lakehouse",
+ "label": "LAKEHOUSE",
+ "x": 58,
+ "color": "#ff00aa",
+ "level": _level(lakehouse.get("running", 0), lakehouse.get("total", 1) or 1),
+ "running": lakehouse.get("running", 0),
+ "total": lakehouse.get("total", 0),
+ "apps": lake_apps,
+ "trino_ok": lakehouse.get("trino_ok"),
+ "vm": "atc-lake01",
+ "ip": lakehouse.get("host", "10.0.21.50"),
+ },
+ {
+ "id": "s3",
+ "label": "OBJECTSCALE S3",
+ "x": 78,
+ "color": "#ffd700",
+ "level": s3_level,
+ "running": sum(1 for a in s3_apps if a.get("state") == "running"),
+ "total": len(s3_apps),
+ "apps": s3_apps,
+ "vm": "atc-objectscale",
+ "ip": OBJECTSCALE_HOST,
+ "bucket": OBJECTSCALE_BUCKET,
+ },
+ {
+ "id": "hadoop",
+ "label": "HADOOP HDFS",
+ "x": 50,
+ "color": "#39ff14",
+ "level": "ok" if hdfs_ok else "warn",
+ "running": hadoop.get("live_datanodes", 0),
+ "total": (hadoop.get("live_datanodes") or 0) + (hadoop.get("dead_datanodes") or 0) or 3,
+ "apps": [
+ {"name": "NameNode", "state": "running" if hdfs_ok else "down", "image": "hdfs-nn", "ports": ["9870"], "host": "10.0.21.61"},
+ *[
+ {"name": dn.get("host", "?").split(".")[0], "state": "running", "image": "datanode", "ports": ["9866"], "host": dn.get("host", "")}
+ for dn in hadoop.get("datanodes", [])
+ ],
+ ],
+ "hdfs_used_gb": hadoop.get("capacity_used_gb"),
+ "hdfs_total_gb": hadoop.get("capacity_total_gb"),
+ "vm": "hadoop cluster",
+ "ip": "10.0.21.61–70",
+ },
+ ]
+
+ def _node(
+ nid: str,
+ label: str,
+ vm: str,
+ ip: str,
+ x: float,
+ y: float,
+ color: str,
+ level: str,
+ role: str,
+ apps: list[dict],
+ running: int,
+ total: int,
+ extra: dict | None = None,
+ ) -> dict[str, Any]:
+ reg = NODE_REGISTRY.get(nid, {})
+ row: dict[str, Any] = {
+ "id": nid,
+ "label": label,
+ "vm": vm,
+ "ip": ip,
+ "x": x,
+ "y": y,
+ "color": reg.get("color", color),
+ "level": level,
+ "role": role,
+ "apps": apps,
+ "running": running,
+ "total": total,
+ "description": reg.get("description", ""),
+ "agent_id": NODE_AGENT.get(nid),
+ "links": reg.get("links", []),
+ "endpoints": reg.get("endpoints", []),
+ "commands": reg.get("commands", []),
+ "vmid": reg.get("vmid"),
+ "pve": reg.get("pve"),
+ }
+ if extra:
+ row.update(extra)
+ return row
+
+ connect_running = 1 if connect_app and connect_app.get("state") == "running" else 0
+ consumer_running = 1 if s3_consumer and s3_consumer.get("state") == "running" else 0
+
+ topology_nodes = [
+ _node("airflow", "Airflow", "atc-airflow01", "10.0.21.55", 6, 18, "#00f0ff", "ok" if etl.get("airflow_healthy") else "warn", "orchestrator",
+ [{"name": "scheduler", "state": "running" if etl.get("airflow_healthy") else "down", "image": "airflow", "ports": ["8080"]}], int(etl.get("airflow_healthy", False)), 1),
+ _node("db", "DB Vault", "atc-db02", "10.0.21.51", 22, 18, "#ffaa00", _level(databases.get("running", 0), databases.get("total", 1) or 1), "sources",
+ db_apps, databases.get("running", 0), databases.get("total", 0)),
+ _node("debezium", "Debezium", "atc-lake01", "10.0.21.50", 38, 18, "#ff66cc", "ok" if connect_running and connectors else "warn", "cdc",
+ [_app_row(connect_app)] if connect_app else [], len(connectors), max(len(connectors), 1),
+ {"connectors": connectors}),
+ _node("kafka", "Kafka", "atc-kafka01", "10.0.21.36", 54, 18, "#00f0ff", "ok" if etl.get("kafka_ui_ok") else "warn", "bus",
+ [{"name": "broker", "state": "running" if etl.get("kafka_ui_ok") else "down", "image": "kafka", "ports": ["9092"]}], int(etl.get("kafka_ui_ok", False)), 1),
+ _node("lakehouse", "Lakehouse", "atc-lake01", "10.0.21.50", 70, 18, "#ff00aa", _level(lakehouse.get("running", 0), lakehouse.get("total", 1) or 1), "compute",
+ lake_apps, lakehouse.get("running", 0), lakehouse.get("total", 0),
+ {"trino_ok": lakehouse.get("trino_ok"), "spark_count": len(spark_apps)}),
+ _node("s3", "ObjectScale S3", "atc-objectscale", OBJECTSCALE_HOST, 88, 18, "#ffd700", s3_level, "storage",
+ s3_apps, sum(1 for a in s3_apps if a.get("state") == "running"), len(s3_apps),
+ {"bucket": OBJECTSCALE_BUCKET, "port": OBJECTSCALE_PORT, "consumer_ok": bool(consumer_running)}),
+ _node("docker", "Docker Rack", "atc-docker01", "10.0.21.45", 10, 52, "#b366ff", _level(docker.get("running", 0), docker.get("total", 1) or 1), "infra",
+ docker_apps, docker.get("running", 0), docker.get("total", 0)),
+ _node("hadoop", "Hadoop HDFS", "atc-hadoop-m01", "10.0.21.61", 50, 52, "#39ff14", "ok" if hdfs_ok else "warn", "parallel",
+ zones[-1]["apps"], hadoop.get("live_datanodes", 0), zones[-1]["total"],
+ {"hdfs_used_gb": hadoop.get("capacity_used_gb"), "hdfs_total_gb": hadoop.get("capacity_total_gb")}),
+ _node("gpu", "GPU Lab", "atc-gpu-dev", "10.0.20.106", 88, 52, "#76b900", "ok" if gpu.get("ok") else "down", "inference",
+ [{"name": gpu.get("active_model") or "vLLM", "state": "running" if gpu.get("inference_active") else "down", "image": "vllm", "ports": ["8001"]}],
+ gpu.get("gpu_count", 0), gpu.get("gpu_count", 0) or 4,
+ {"model": gpu.get("active_model"), "util": round(sum(g.get("util_gpu", 0) for g in gpu.get("gpus", [])) / max(len(gpu.get("gpus", [])), 1), 1)}),
+ _node("command", "Command Center", "MCP · VM304", "10.0.21.33", 50, 78, "#00f0ff",
+ _level(command.get("running", 0), command.get("total", 1) or 1), "hub",
+ command.get("containers") and [_app_row(c) for c in command.get("containers", [])] or [
+ {"name": "atc-agents-api", "state": "running", "image": "atc-agents-api", "ports": ["3201"]},
+ {"name": "atc-agents-ui", "state": "running", "image": "atc-agents-ui", "ports": ["80"]},
+ {"name": "postgres", "state": "running", "image": "postgres", "ports": ["5432"]},
+ {"name": "redis", "state": "running", "image": "redis", "ports": ["6379"]},
+ {"name": "caddy", "state": "running", "image": "caddy", "ports": ["80"]},
+ ],
+ command.get("running", 5), command.get("total", 5) or 5),
+ ]
+
+ def _edge(eid: str, src: str, dst: str, label: str, kind: str, active: bool = True) -> dict[str, Any]:
+ return {"id": eid, "from": src, "to": dst, "label": label, "kind": kind, "active": active}
+
+ pipeline_ok = etl.get("airflow_healthy") and len(connectors) > 0 and etl.get("kafka_ui_ok")
+ s3_flow_ok = pipeline_ok and consumer_running and objectscale_ok
+
+ topology_edges = [
+ _edge("e-seed", "airflow", "db", "seed data", "pipeline", bool(etl.get("airflow_healthy"))),
+ _edge("e-cdc", "db", "debezium", "CDC", "pipeline", bool(connectors)),
+ _edge("e-topics", "debezium", "kafka", "topics", "pipeline", bool(connectors and etl.get("kafka_ui_ok"))),
+ _edge("e-stream", "kafka", "lakehouse", "stream", "pipeline", bool(etl.get("kafka_ui_ok") and lakehouse.get("trino_ok"))),
+ _edge("e-s3", "lakehouse", "s3", "s3-kafka-consumer", "pipeline", bool(s3_flow_ok)),
+ _edge("e-iceberg", "lakehouse", "s3", "Trino Iceberg", "query", bool(lakehouse.get("trino_ok") and objectscale_ok)),
+ _edge("e-trino-db", "lakehouse", "db", "federated SQL", "query", bool(lakehouse.get("trino_ok"))),
+ _edge("e-hdfs", "lakehouse", "hadoop", "parallel layer", "parallel", bool(hdfs_ok)),
+ _edge("e-monitor-docker", "command", "docker", "monitor", "infra", True),
+ _edge("e-monitor-gpu", "command", "gpu", "LLM", "infra", bool(gpu.get("ok"))),
+ ]
+
+ topologies = build_all_topologies(
+ topology_nodes,
+ topology_edges,
+ snap,
+ pipeline_active=s3_flow_ok,
+ connectors=connectors,
+ )
+
+ return {
+ "ts": snap.get("ts"),
+ "zones": zones,
+ "topology": topologies["architecture"],
+ "topologies": topologies,
+ "gpu": {
+ "level": "ok" if gpu.get("ok") and gpu.get("inference_active") else ("warn" if gpu.get("ok") else "down"),
+ "model": gpu.get("active_model"),
+ "inference_active": gpu.get("inference_active"),
+ "gpu_count": gpu.get("gpu_count", 0),
+ "avg_util": round(
+ sum(g.get("util_gpu", 0) for g in gpu.get("gpus", [])) / max(len(gpu.get("gpus", [])), 1),
+ 1,
+ ),
+ "gpus": gpu.get("gpus", []),
+ },
+ "totals": {
+ "apps_running": sum(z["running"] for z in zones if z["id"] not in ("hadoop",)) + (hadoop.get("live_datanodes") or 0),
+ "apps_total": sum(z["total"] for z in zones),
+ "connectors": len(connectors),
+ "vms": len(topology_nodes),
+ "pipeline_active": s3_flow_ok,
+ },
+ }
diff --git a/caddy/Caddyfile b/caddy/Caddyfile
index 89a87c3..519744f 100644
--- a/caddy/Caddyfile
+++ b/caddy/Caddyfile
@@ -1,4 +1,17 @@
:80 {
- reverse_proxy /api/* api:3201
- reverse_proxy /* ui:80
+ handle_path /rag/* {
+ reverse_proxy rag-api:5020
+ }
+ handle_path /jupyter/* {
+ reverse_proxy jupyter:8888
+ }
+ handle_path /dq/* {
+ reverse_proxy dq-api:5010
+ }
+ handle /api/* {
+ reverse_proxy api:3201
+ }
+ handle {
+ reverse_proxy ui:80
+ }
}
diff --git a/config/command-center/.env.example b/config/command-center/.env.example
new file mode 100644
index 0000000..abd5018
--- /dev/null
+++ b/config/command-center/.env.example
@@ -0,0 +1,27 @@
+# Command Center — VM304 (10.0.21.33)
+# Copy to /opt/atc-agents/.env — never commit secrets
+
+# Postgres (internal)
+POSTGRES_USER=atc
+POSTGRES_PASSWORD=change-me
+POSTGRES_DB=atc_agents
+
+# GPU / LLM (VM303)
+GPU_URL=http://10.0.20.106:9000
+LLM_URL=http://10.0.20.106:8001/v1
+LLM_MODEL=gpt-4o
+LLM_API_KEY=sk-local
+
+# ObjectScale S3 (VM objectscale 10.0.20.111)
+S3_ENDPOINT=http://10.0.20.111:9020
+S3_ACCESS_KEY=object_admin1
+S3_SECRET_KEY=REDACTED-use-deploy-yml-or-ecs-admin
+S3_REGION=us-east-1
+
+# Jupyter
+JUPYTER_TOKEN=change-me-jupyter-token
+
+# Lakehouse / ETL (optional probes)
+LAKEHOUSE_HOST=10.0.21.50
+AIRFLOW_URL=http://10.0.21.55:8080
+KAFKA_UI_URL=http://10.0.21.36:9000
diff --git a/config/command-center/Caddyfile b/config/command-center/Caddyfile
new file mode 100644
index 0000000..519744f
--- /dev/null
+++ b/config/command-center/Caddyfile
@@ -0,0 +1,17 @@
+:80 {
+ handle_path /rag/* {
+ reverse_proxy rag-api:5020
+ }
+ handle_path /jupyter/* {
+ reverse_proxy jupyter:8888
+ }
+ handle_path /dq/* {
+ reverse_proxy dq-api:5010
+ }
+ handle /api/* {
+ reverse_proxy api:3201
+ }
+ handle {
+ reverse_proxy ui:80
+ }
+}
diff --git a/config/command-center/docker-compose.yml b/config/command-center/docker-compose.yml
new file mode 100644
index 0000000..7ba0db3
--- /dev/null
+++ b/config/command-center/docker-compose.yml
@@ -0,0 +1,150 @@
+services:
+ redis:
+ image: redis:7-alpine
+ restart: unless-stopped
+ volumes:
+ - redis_data:/data
+
+ postgres:
+ image: postgres:16-alpine
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: atc
+ POSTGRES_PASSWORD: atc-agents-pg
+ POSTGRES_DB: atc_agents
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U atc -d atc_agents"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
+ api:
+ build: ./api
+ restart: unless-stopped
+ env_file:
+ - .env
+ environment:
+ REDIS_URL: redis://redis:6379/0
+ DOCKHAND_URL: http://10.0.21.45:8082
+ DATABASE_URL: postgresql+psycopg2://atc:atc-agents-pg@postgres:5432/atc_agents
+ SQLITE_FALLBACK_PATH: /data/atc-agents.db
+ DOCLING_URL: http://docling-serve:5001
+ PRESENTATIONS_DIR: /data/presentations
+ GPU_URL: http://10.0.20.106:9000
+ GPU_UI_URL: http://10.0.20.106:9000
+ LLM_URL: http://10.0.20.106:8001/v1
+ LLM_MODEL: gpt-4o
+ LLM_API_KEY: sk-local
+ LAKEHOUSE_HOST: 10.0.21.50
+ AIRFLOW_URL: http://10.0.21.55:8080
+ KAFKA_UI_URL: http://10.0.21.36:9000
+ HDFS_NN_URL: http://10.0.21.61:9870
+ S3_ENDPOINT: ${S3_ENDPOINT:-http://10.0.20.111:9020}
+ S3_ACCESS_KEY: ${S3_ACCESS_KEY:-object_admin1}
+ S3_SECRET_KEY: ${S3_SECRET_KEY}
+ S3_REGION: ${S3_REGION:-us-east-1}
+ volumes:
+ - api_data:/data
+ depends_on:
+ redis:
+ condition: service_started
+ postgres:
+ condition: service_healthy
+
+ ui:
+ build: ./ui
+ restart: unless-stopped
+ depends_on:
+ - api
+
+ docling-serve:
+ image: quay.io/docling-project/docling-serve-cpu
+ restart: unless-stopped
+ ports:
+ - "5001:5001"
+ environment:
+ DOCLING_SERVE_ENABLE_UI: "1"
+ DOCLING_SERVE_MAX_SYNC_WAIT: "300"
+
+ chromadb:
+ image: chromadb/chroma:0.5.23
+ restart: unless-stopped
+ volumes:
+ - chroma_data:/chroma/chroma
+ environment:
+ ANONYMIZED_TELEMETRY: "false"
+
+ rag-api:
+ build: ../atc-data-quality/rag-api
+ restart: unless-stopped
+ environment:
+ CHROMA_HOST: chromadb
+ CHROMA_PORT: 8000
+ DOCLING_URL: http://docling-serve:5001
+ LLM_URL: http://10.0.20.106:8001/v1
+ LLM_MODEL: gpt-4o
+ LLM_API_KEY: sk-local
+ RAG_DATA_DIR: /data
+ volumes:
+ - rag_data:/data
+ depends_on:
+ - chromadb
+ - docling-serve
+
+ dq-api:
+ build: ../atc-data-quality/dq-api
+ restart: unless-stopped
+ environment:
+ DOCLING_URL: http://docling-serve:5001
+ DQ_DATA_DIR: /data
+ RAG_URL: http://rag-api:5020
+ RAG_COLLECTION: default
+ volumes:
+ - dq_data:/data
+ depends_on:
+ - docling-serve
+ - rag-api
+
+ jupyter:
+ image: quay.io/jupyter/scipy-notebook:latest
+ restart: unless-stopped
+ environment:
+ JUPYTER_TOKEN: ${JUPYTER_TOKEN:-atc-jupyter}
+ AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY:-object_admin1}
+ AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY}
+ S3_ENDPOINT: ${S3_ENDPOINT:-http://10.0.20.111:9020}
+ AWS_DEFAULT_REGION: ${S3_REGION:-us-east-1}
+ command: >
+ start-notebook.sh
+ --NotebookApp.base_url=/jupyter/
+ --NotebookApp.token=${JUPYTER_TOKEN:-atc-jupyter}
+ --NotebookApp.allow_origin=*
+ volumes:
+ - jupyter_data:/home/jovyan/work
+
+ caddy:
+ image: caddy:2-alpine
+ restart: unless-stopped
+ ports:
+ - "80:80"
+ volumes:
+ - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
+ depends_on:
+ - ui
+ - api
+ - dq-api
+ - docling-serve
+ - rag-api
+ - chromadb
+ - jupyter
+
+volumes:
+ chroma_data:
+ rag_data:
+ dq_data:
+ redis_data:
+ postgres_data:
+ api_data:
+ jupyter_data:
diff --git a/docker-compose.dockhand.yml b/docker-compose.dockhand.yml
new file mode 100644
index 0000000..cb4e669
--- /dev/null
+++ b/docker-compose.dockhand.yml
@@ -0,0 +1,70 @@
+# Dockhand-managed compose — image-only (no build context on Dockhand host).
+# Source of truth for builds: /opt/atc-agents on VM304 (10.0.21.33).
+services:
+ redis:
+ image: redis:7-alpine
+ restart: unless-stopped
+ volumes:
+ - redis_data:/data
+
+ postgres:
+ image: postgres:16-alpine
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: atc
+ POSTGRES_PASSWORD: atc-agents-pg
+ POSTGRES_DB: atc_agents
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U atc -d atc_agents"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
+ api:
+ image: atc-agents-api:latest
+ restart: unless-stopped
+ environment:
+ REDIS_URL: redis://redis:6379/0
+ DOCKHAND_URL: http://10.0.21.45:8082
+ DATABASE_URL: postgresql+psycopg2://atc:atc-agents-pg@postgres:5432/atc_agents
+ SQLITE_FALLBACK_PATH: /data/atc-agents.db
+ GPU_URL: http://10.0.20.106:9000
+ GPU_UI_URL: http://10.0.20.106:9000
+ LLM_URL: http://10.0.20.106:8001/v1
+ LLM_MODEL: qwen2.5-32b-gptq
+ LLM_API_KEY: sk-local
+ LAKEHOUSE_HOST: 10.0.21.50
+ AIRFLOW_URL: http://10.0.21.55:8080
+ KAFKA_UI_URL: http://10.0.21.36:9000
+ HDFS_NN_URL: http://10.0.21.61:9870
+ volumes:
+ - api_data:/data
+ depends_on:
+ redis:
+ condition: service_started
+ postgres:
+ condition: service_healthy
+
+ ui:
+ image: atc-agents-ui:latest
+ restart: unless-stopped
+ depends_on:
+ - api
+
+ caddy:
+ image: caddy:2-alpine
+ restart: unless-stopped
+ ports:
+ - "80:80"
+ volumes:
+ - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
+ depends_on:
+ - ui
+ - api
+
+volumes:
+ redis_data:
+ postgres_data:
+ api_data:
diff --git a/docker-compose.yml b/docker-compose.yml
index 1496864..7ba0db3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -5,17 +5,53 @@ services:
volumes:
- redis_data:/data
+ postgres:
+ image: postgres:16-alpine
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: atc
+ POSTGRES_PASSWORD: atc-agents-pg
+ POSTGRES_DB: atc_agents
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U atc -d atc_agents"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
api:
build: ./api
restart: unless-stopped
+ env_file:
+ - .env
environment:
REDIS_URL: redis://redis:6379/0
DOCKHAND_URL: http://10.0.21.45:8082
- DATABASE_URL: sqlite:////data/atc-agents.db
+ DATABASE_URL: postgresql+psycopg2://atc:atc-agents-pg@postgres:5432/atc_agents
+ SQLITE_FALLBACK_PATH: /data/atc-agents.db
+ DOCLING_URL: http://docling-serve:5001
+ PRESENTATIONS_DIR: /data/presentations
+ GPU_URL: http://10.0.20.106:9000
+ GPU_UI_URL: http://10.0.20.106:9000
+ LLM_URL: http://10.0.20.106:8001/v1
+ LLM_MODEL: gpt-4o
+ LLM_API_KEY: sk-local
+ LAKEHOUSE_HOST: 10.0.21.50
+ AIRFLOW_URL: http://10.0.21.55:8080
+ KAFKA_UI_URL: http://10.0.21.36:9000
+ HDFS_NN_URL: http://10.0.21.61:9870
+ S3_ENDPOINT: ${S3_ENDPOINT:-http://10.0.20.111:9020}
+ S3_ACCESS_KEY: ${S3_ACCESS_KEY:-object_admin1}
+ S3_SECRET_KEY: ${S3_SECRET_KEY}
+ S3_REGION: ${S3_REGION:-us-east-1}
volumes:
- api_data:/data
depends_on:
- - redis
+ redis:
+ condition: service_started
+ postgres:
+ condition: service_healthy
ui:
build: ./ui
@@ -23,6 +59,71 @@ services:
depends_on:
- api
+ docling-serve:
+ image: quay.io/docling-project/docling-serve-cpu
+ restart: unless-stopped
+ ports:
+ - "5001:5001"
+ environment:
+ DOCLING_SERVE_ENABLE_UI: "1"
+ DOCLING_SERVE_MAX_SYNC_WAIT: "300"
+
+ chromadb:
+ image: chromadb/chroma:0.5.23
+ restart: unless-stopped
+ volumes:
+ - chroma_data:/chroma/chroma
+ environment:
+ ANONYMIZED_TELEMETRY: "false"
+
+ rag-api:
+ build: ../atc-data-quality/rag-api
+ restart: unless-stopped
+ environment:
+ CHROMA_HOST: chromadb
+ CHROMA_PORT: 8000
+ DOCLING_URL: http://docling-serve:5001
+ LLM_URL: http://10.0.20.106:8001/v1
+ LLM_MODEL: gpt-4o
+ LLM_API_KEY: sk-local
+ RAG_DATA_DIR: /data
+ volumes:
+ - rag_data:/data
+ depends_on:
+ - chromadb
+ - docling-serve
+
+ dq-api:
+ build: ../atc-data-quality/dq-api
+ restart: unless-stopped
+ environment:
+ DOCLING_URL: http://docling-serve:5001
+ DQ_DATA_DIR: /data
+ RAG_URL: http://rag-api:5020
+ RAG_COLLECTION: default
+ volumes:
+ - dq_data:/data
+ depends_on:
+ - docling-serve
+ - rag-api
+
+ jupyter:
+ image: quay.io/jupyter/scipy-notebook:latest
+ restart: unless-stopped
+ environment:
+ JUPYTER_TOKEN: ${JUPYTER_TOKEN:-atc-jupyter}
+ AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY:-object_admin1}
+ AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY}
+ S3_ENDPOINT: ${S3_ENDPOINT:-http://10.0.20.111:9020}
+ AWS_DEFAULT_REGION: ${S3_REGION:-us-east-1}
+ command: >
+ start-notebook.sh
+ --NotebookApp.base_url=/jupyter/
+ --NotebookApp.token=${JUPYTER_TOKEN:-atc-jupyter}
+ --NotebookApp.allow_origin=*
+ volumes:
+ - jupyter_data:/home/jovyan/work
+
caddy:
image: caddy:2-alpine
restart: unless-stopped
@@ -33,7 +134,17 @@ services:
depends_on:
- ui
- api
+ - dq-api
+ - docling-serve
+ - rag-api
+ - chromadb
+ - jupyter
volumes:
+ chroma_data:
+ rag_data:
+ dq_data:
redis_data:
+ postgres_data:
api_data:
+ jupyter_data:
diff --git a/docs/runbook-vm304.md b/docs/runbook-vm304.md
new file mode 100644
index 0000000..442bf5d
--- /dev/null
+++ b/docs/runbook-vm304.md
@@ -0,0 +1,46 @@
+# Command Center VM304
+
+| Item | Value |
+|------|-------|
+| VM | MCP · Proxmox VMID **304** |
+| IP | `10.0.21.33` |
+| SSH | `root@10.0.21.33` |
+| Gitea | `http://atc-mgt01.dell-atc.lan:3001/mo/atc-agents` |
+
+## Stack
+
+- **ui** — React (Data Platform, Presentation, DQ, Knowledge Chat, S3 Storage, Jupyter link)
+- **api** — FastAPI + WebSocket + S3 browser API
+- **dq-api / rag-api** — from `mo/atc-data-quality`
+- **jupyter** — JupyterLab with S3 credentials
+- **chromadb, docling, postgres, redis, caddy**
+
+## ObjectScale S3
+
+| Item | Value |
+|------|-------|
+| Endpoint | `http://10.0.20.111:9020` |
+| Namespace | `ns1` |
+| Default user | `object_admin1` (see ECS deploy.yml) |
+| UI browse | Command Center → **Object Storage** |
+
+## Jupyter
+
+- URL: `http://10.0.21.33/jupyter/`
+- Token: `JUPYTER_TOKEN` in `.env`
+- Work dir persisted in Docker volume `jupyter_data`
+- Preconfigured: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_ENDPOINT`
+
+Example in notebook:
+
+```python
+import boto3, os
+s3 = boto3.client("s3", endpoint_url=os.environ["S3_ENDPOINT"])
+print(s3.list_buckets())
+```
+
+## Gitea sync
+
+```bash
+./scripts/sync-gitea.sh
+```
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..0b2ddd6
--- /dev/null
+++ b/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ ATC Command Center
+
+
+
+
+
+
+
+
+
diff --git a/lab_context.py b/lab_context.py
new file mode 100644
index 0000000..b169f98
--- /dev/null
+++ b/lab_context.py
@@ -0,0 +1,541 @@
+"""Live lab metrics for all ATC domains — fed to vLLM as context."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+import time
+from datetime import datetime, timezone
+from typing import Any
+
+import httpx
+
+from agent_terminal import TerminalLogFn
+
+DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082")
+HDFS_NN_URL = os.getenv("HDFS_NN_URL", "http://10.0.21.61:9870")
+LAKEHOUSE_HOST = os.getenv("LAKEHOUSE_HOST", "10.0.21.50")
+AIRFLOW_URL = os.getenv("AIRFLOW_URL", "http://10.0.21.55:8080")
+KAFKA_UI_URL = os.getenv("KAFKA_UI_URL", "http://10.0.21.36:9000")
+KAFKA_CONNECT_URL = os.getenv("KAFKA_CONNECT_URL", f"http://{LAKEHOUSE_HOST}:8083")
+TRINO_URL = os.getenv("TRINO_URL", f"http://{LAKEHOUSE_HOST}:8089")
+SPARK_UI_URL = os.getenv("SPARK_UI_URL", f"http://{LAKEHOUSE_HOST}:8080")
+GPU_URL = os.getenv("GPU_URL", "http://10.0.20.106:9000")
+
+AGENT_PRIMARY_DOMAIN = {
+ "infra-sentinel": "docker",
+ "data-custodian": "databases",
+ "lakehouse-ops": "lakehouse",
+ "hadoop-ranger": "hadoop",
+ "etl-guardian": "etl",
+}
+
+
+async def _log(log: TerminalLogFn | None, level: str, phase: str, text: str) -> None:
+ if log:
+ await log(level, phase, text)
+
+
+async def _get_json(
+ client: httpx.AsyncClient,
+ url: str,
+ log: TerminalLogFn | None = None,
+ label: str = "",
+ timeout: float = 6.0,
+) -> Any | None:
+ name = label or url
+ t0 = time.monotonic()
+ await _log(log, "cmd", "fetch", f"$ GET {url}")
+ try:
+ r = await client.get(url, timeout=timeout)
+ ms = int((time.monotonic() - t0) * 1000)
+ if r.status_code < 400:
+ await _log(log, "ok", "fetch", f"← {r.status_code} {name} ({ms}ms)")
+ return r.json()
+ await _log(log, "warn", "fetch", f"← {r.status_code} {name} ({ms}ms)")
+ except Exception as exc:
+ ms = int((time.monotonic() - t0) * 1000)
+ await _log(log, "err", "fetch", f"✗ {name}: {exc} ({ms}ms)")
+ return None
+
+
+async def _probe_ok(
+ client: httpx.AsyncClient,
+ url: str,
+ log: TerminalLogFn | None = None,
+ label: str = "",
+) -> bool:
+ name = label or url
+ t0 = time.monotonic()
+ await _log(log, "cmd", "probe", f"$ GET {url}")
+ try:
+ r = await client.get(url, timeout=4.0)
+ ms = int((time.monotonic() - t0) * 1000)
+ ok = r.status_code < 500
+ await _log(log, "ok" if ok else "warn", "probe", f"← {r.status_code} {name} ({'UP' if ok else 'DOWN'}, {ms}ms)")
+ return ok
+ except Exception as exc:
+ ms = int((time.monotonic() - t0) * 1000)
+ await _log(log, "err", "probe", f"✗ {name}: {exc} ({ms}ms)")
+ return False
+
+
+def _container_rows(containers: list[dict], host: str = "") -> list[dict[str, Any]]:
+ rows = []
+ for c in containers:
+ ports = sorted({str(p.get("PublicPort")) for p in c.get("ports", []) if p.get("PublicPort")})
+ rows.append({
+ "name": c.get("name"),
+ "state": c.get("state"),
+ "image": c.get("image"),
+ "status": c.get("status"),
+ "ports": ports,
+ "host": host,
+ })
+ return rows
+
+
+async def dockhand_containers(
+ client: httpx.AsyncClient,
+ env_id: int,
+ log: TerminalLogFn | None = None,
+) -> list[dict]:
+ url = f"{DOCKHAND_URL}/api/containers?env={env_id}"
+ await _log(log, "cmd", "fetch", f"$ GET {url}")
+ t0 = time.monotonic()
+ try:
+ r = await client.get(f"{DOCKHAND_URL}/api/containers", params={"env": env_id}, timeout=8.0)
+ ms = int((time.monotonic() - t0) * 1000)
+ r.raise_for_status()
+ data = r.json()
+ await _log(log, "ok", "fetch", f"← Dockhand env {env_id}: {len(data)} containers ({ms}ms)")
+ return data
+ except Exception as exc:
+ ms = int((time.monotonic() - t0) * 1000)
+ await _log(log, "err", "fetch", f"✗ Dockhand env {env_id}: {exc} ({ms}ms)")
+ return []
+
+
+async def collect_hdfs(client: httpx.AsyncClient, log: TerminalLogFn | None = None) -> dict[str, Any]:
+ ctx: dict[str, Any] = {"reachable": False, "namenode": HDFS_NN_URL}
+ await _log(log, "info", "fetch", "▸ HDFS NameNode JMX metrics")
+ try:
+ fs_url = f"{HDFS_NN_URL}/jmx?qry=Hadoop:service=NameNode,name=FSNamesystem"
+ nn_url = f"{HDFS_NN_URL}/jmx?qry=Hadoop:service=NameNode,name=NameNodeInfo"
+ t0 = time.monotonic()
+ await _log(log, "cmd", "fetch", f"$ GET {fs_url}")
+ await _log(log, "cmd", "fetch", f"$ GET {nn_url}")
+ fs_r, nn_r = await asyncio.gather(
+ client.get(fs_url),
+ client.get(nn_url),
+ return_exceptions=True,
+ )
+ ms = int((time.monotonic() - t0) * 1000)
+ if isinstance(fs_r, httpx.Response) and fs_r.status_code == 200:
+ beans = fs_r.json().get("beans", [])
+ if beans:
+ b = beans[0]
+ ctx.update({
+ "reachable": True,
+ "hostname": b.get("tag.Hostname"),
+ "ha_state": b.get("tag.HAState"),
+ "capacity_total_gb": b.get("CapacityTotalGB"),
+ "capacity_used_gb": b.get("CapacityUsedGB"),
+ "capacity_remaining_gb": b.get("CapacityRemainingGB"),
+ "files_total": b.get("FilesTotal"),
+ "blocks_total": b.get("BlocksTotal"),
+ "live_datanodes": b.get("NumLiveDataNodes"),
+ "dead_datanodes": b.get("NumDeadDataNodes"),
+ "missing_blocks": b.get("MissingBlocks"),
+ "under_replicated_blocks": b.get("UnderReplicatedBlocks"),
+ "corrupt_blocks": b.get("CorruptBlocks"),
+ "default_replication_factor": 3,
+ })
+ await _log(
+ log, "ok", "fetch",
+ f"← HDFS: {b.get('CapacityUsedGB')}GB used, {b.get('FilesTotal')} files, "
+ f"{b.get('NumLiveDataNodes')} datanodes ({ms}ms)",
+ )
+ else:
+ await _log(log, "warn", "fetch", f"← FSNamesystem JMX failed ({ms}ms)")
+
+ if isinstance(nn_r, httpx.Response) and nn_r.status_code == 200:
+ beans = nn_r.json().get("beans", [])
+ if beans:
+ b = beans[0]
+ live = json.loads(b.get("LiveNodes") or "{}")
+ ctx["hdfs_version"] = b.get("Version")
+ ctx["safemode"] = b.get("Safemode") or "off"
+ ctx["percent_used"] = round(float(b.get("PercentUsed", 0)) * 100, 4)
+ ctx["datanodes"] = [
+ {
+ "host": host.split(":")[0],
+ "capacity_gb": round(node.get("capacity", 0) / (1024**3), 1),
+ "used_gb": round(node.get("used", 0) / (1024**3), 4),
+ "blocks": node.get("numBlocks", 0),
+ "state": node.get("adminState"),
+ }
+ for host, node in live.items()
+ ]
+ except Exception as exc:
+ ctx["error"] = str(exc)
+ await _log(log, "err", "fetch", f"✗ HDFS: {exc}")
+ return ctx
+
+
+async def collect_etl(client: httpx.AsyncClient, log: TerminalLogFn | None = None) -> dict[str, Any]:
+ await _log(log, "info", "fetch", "▸ ETL stack (Airflow, Kafka, Spark)")
+ health, kafka_ok, spark_ok = await asyncio.gather(
+ _get_json(client, f"{AIRFLOW_URL}/api/v2/monitor/health", log, "Airflow health"),
+ _probe_ok(client, KAFKA_UI_URL, log, "Kafka UI"),
+ _probe_ok(client, SPARK_UI_URL, log, "Spark UI"),
+ )
+ connectors: list[str] = []
+ await _log(log, "cmd", "fetch", f"$ GET {KAFKA_CONNECT_URL}/connectors")
+ t0 = time.monotonic()
+ try:
+ r = await client.get(f"{KAFKA_CONNECT_URL}/connectors", timeout=5.0)
+ ms = int((time.monotonic() - t0) * 1000)
+ if r.status_code == 200:
+ connectors = r.json() if isinstance(r.json(), list) else []
+ await _log(log, "ok", "fetch", f"← Kafka Connect: {len(connectors)} connectors ({ms}ms)")
+ for c in connectors:
+ await _log(log, "info", "fetch", f" · {c}")
+ else:
+ await _log(log, "warn", "fetch", f"← Kafka Connect {r.status_code} ({ms}ms)")
+ except Exception as exc:
+ await _log(log, "err", "fetch", f"✗ Kafka Connect: {exc}")
+
+ airflow_detail: dict[str, str] = {}
+ if isinstance(health, dict):
+ for comp, info in health.items():
+ if isinstance(info, dict) and "status" in info:
+ airflow_detail[comp] = info["status"]
+ await _log(log, "info", "fetch", f" Airflow {comp}: {info['status']}")
+
+ return {
+ "airflow_url": AIRFLOW_URL,
+ "airflow_healthy": airflow_detail.get("scheduler") == "healthy",
+ "airflow_components": airflow_detail,
+ "kafka_ui_url": KAFKA_UI_URL,
+ "kafka_ui_ok": kafka_ok,
+ "kafka_connect_url": KAFKA_CONNECT_URL,
+ "connectors": connectors,
+ "spark_ui_url": SPARK_UI_URL,
+ "spark_ui_ok": spark_ok,
+ }
+
+
+async def collect_lakehouse(
+ client: httpx.AsyncClient,
+ containers: list[dict],
+ log: TerminalLogFn | None = None,
+) -> dict[str, Any]:
+ await _log(log, "info", "fetch", "▸ Lakehouse (Trino, Spark, Kafka Connect)")
+ trino_info = await _get_json(client, f"{TRINO_URL}/v1/info", log, "Trino /v1/info")
+ running = sum(1 for c in containers if c.get("state") == "running")
+ for c in containers:
+ ports = ",".join(str(p.get("PublicPort")) for p in c.get("ports", []) if p.get("PublicPort")) or "internal"
+ await _log(log, "info", "fetch", f" · {c.get('name')}: {c.get('state')} ports={ports}")
+ return {
+ "host": LAKEHOUSE_HOST,
+ "trino_url": TRINO_URL,
+ "trino_ok": trino_info is not None,
+ "trino_version": (trino_info or {}).get("nodeVersion", {}).get("version"),
+ "trino_uptime": (trino_info or {}).get("uptime"),
+ "trino_coordinator": (trino_info or {}).get("coordinator"),
+ "spark_ui_url": SPARK_UI_URL,
+ "kafka_connect_url": KAFKA_CONNECT_URL,
+ "containers": _container_rows(containers, LAKEHOUSE_HOST),
+ "running": running,
+ "total": len(containers),
+ }
+
+
+async def collect_databases(
+ client: httpx.AsyncClient,
+ containers: list[dict],
+ log: TerminalLogFn | None = None,
+) -> dict[str, Any]:
+ await _log(log, "info", "fetch", "▸ Database vault (Dockhand env 5)")
+ running = sum(1 for c in containers if c.get("state") == "running")
+ rows = _container_rows(containers)
+ by_engine: dict[str, list[str]] = {}
+ for r in rows:
+ img = (r.get("image") or "").lower()
+ name = (r.get("name") or "").lower()
+ if "postgres" in img or "postgres" in name:
+ engine = "PostgreSQL"
+ elif "mysql" in img or "mysql" in name:
+ engine = "MySQL"
+ elif "mongo" in img or "mongo" in name:
+ engine = "MongoDB"
+ elif "cassandra" in img or "cassandra" in name:
+ engine = "Cassandra"
+ elif "neo4j" in img or "neo4j" in name:
+ engine = "Neo4j"
+ else:
+ engine = "Other"
+ port_str = ",".join(r["ports"]) or "internal"
+ by_engine.setdefault(engine, []).append(f"{r['name']} ({r['state']}, ports {port_str})")
+ await _log(log, "info", "fetch", f" · {r['name']}: {r['state']} [{engine}] ports={port_str}")
+
+ return {
+ "dockhand_env": 5,
+ "running": running,
+ "total": len(containers),
+ "containers": rows,
+ "by_engine": by_engine,
+ }
+
+
+async def collect_docker_rack(
+ client: httpx.AsyncClient,
+ containers: list[dict],
+ log: TerminalLogFn | None = None,
+) -> dict[str, Any]:
+ await _log(log, "info", "fetch", "▸ Docker rack (Dockhand env 1)")
+ running = sum(1 for c in containers if c.get("state") == "running")
+ not_running = [c["name"] for c in containers if c.get("state") != "running"]
+ for c in containers:
+ ports = ",".join(str(p.get("PublicPort")) for p in c.get("ports", []) if p.get("PublicPort")) or "internal"
+ lvl = "info" if c.get("state") == "running" else "warn"
+ await _log(log, lvl, "fetch", f" · {c.get('name')}: {c.get('state')} ports={ports}")
+ return {
+ "dockhand_url": DOCKHAND_URL,
+ "dockhand_env": 1,
+ "running": running,
+ "total": len(containers),
+ "not_running": not_running,
+ "containers": _container_rows(containers, "10.0.21.45"),
+ }
+
+
+async def collect_gpu_metrics(client: httpx.AsyncClient, log: TerminalLogFn | None = None) -> dict[str, Any]:
+ await _log(log, "info", "fetch", "▸ GPU Lab metrics")
+ base = {"ok": False, "host": GPU_URL, "ui_url": GPU_URL}
+ try:
+ metrics_url = f"{GPU_URL}/api/gpu/metrics"
+ model_url = f"{GPU_URL}/api/active-model"
+ await _log(log, "cmd", "fetch", f"$ GET {metrics_url}")
+ await _log(log, "cmd", "fetch", f"$ GET {model_url}")
+ t0 = time.monotonic()
+ metrics_r, model_r = await asyncio.gather(
+ client.get(metrics_url),
+ client.get(model_url),
+ return_exceptions=True,
+ )
+ ms = int((time.monotonic() - t0) * 1000)
+ gpus: list[dict[str, Any]] = []
+ if isinstance(metrics_r, httpx.Response) and metrics_r.status_code == 200:
+ current = metrics_r.json().get("current", {})
+ gpus = [
+ {
+ "index": g["index"],
+ "name": g["name"],
+ "util_gpu": g.get("util_gpu", 0),
+ "memory_used_mib": g.get("memory_used_mib", 0),
+ "memory_total_mib": g.get("memory_total_mib", 0),
+ "temperature_c": g.get("temperature_c", 0),
+ "power_w": g.get("power_w", 0),
+ }
+ for g in current.get("gpus", [])
+ ]
+ await _log(log, "ok", "fetch", f"← GPU metrics: {len(gpus)} devices ({ms}ms)")
+ for g in gpus:
+ await _log(
+ log, "info", "fetch",
+ f" GPU{g['index']}: util {g['util_gpu']:.0f}% VRAM "
+ f"{g['memory_used_mib']:.0f}/{g['memory_total_mib']:.0f} MiB",
+ )
+
+ active_model = None
+ inference_active = False
+ vllm_url = None
+ if isinstance(model_r, httpx.Response) and model_r.status_code == 200:
+ model_data = model_r.json()
+ active_model = model_data.get("name")
+ inference_active = bool(model_data.get("inference_active"))
+ vllm_url = model_data.get("base_url")
+ await _log(log, "ok", "fetch", f"← Active model: {active_model} inference={'ON' if inference_active else 'OFF'}")
+
+ return {
+ **base,
+ "ok": len(gpus) > 0 or inference_active,
+ "inference_active": inference_active,
+ "active_model": active_model,
+ "vllm_url": vllm_url,
+ "gpu_count": len(gpus),
+ "gpus": gpus,
+ }
+ except Exception as exc:
+ await _log(log, "err", "fetch", f"✗ GPU Lab: {exc}")
+ return {**base, "error": str(exc)}
+
+
+def _section_docker(d: dict[str, Any]) -> list[str]:
+ lines = [
+ f"Docker rack (Dockhand env 1): {d['running']}/{d['total']} running",
+ f"Dockhand: {d['dockhand_url']}",
+ ]
+ if d.get("not_running"):
+ lines.append(f"Not running: {', '.join(d['not_running'])}")
+ for c in d.get("containers", []):
+ port_str = ",".join(c["ports"]) if c["ports"] else "internal"
+ lines.append(f" - {c['name']}: {c['state']} | {c['image']} | ports {port_str}")
+ return lines
+
+
+def _section_databases(d: dict[str, Any]) -> list[str]:
+ lines = [f"Databases (Dockhand env {d['dockhand_env']}): {d['running']}/{d['total']} running"]
+ for engine, items in d.get("by_engine", {}).items():
+ lines.append(f" {engine}:")
+ for item in items:
+ lines.append(f" - {item}")
+ return lines
+
+
+def _section_lakehouse(d: dict[str, Any]) -> list[str]:
+ lines = [
+ f"Lakehouse host: {d['host']} — {d['running']}/{d['total']} containers running",
+ f"Trino: {d['trino_url']} — {'UP' if d['trino_ok'] else 'DOWN'}"
+ + (f" (v{d['trino_version']}, uptime {d.get('trino_uptime')})" if d.get("trino_ok") else ""),
+ f"Spark UI: {d['spark_ui_url']}",
+ f"Kafka Connect: {d['kafka_connect_url']}",
+ ]
+ for c in d.get("containers", []):
+ port_str = ",".join(c["ports"]) if c["ports"] else "internal"
+ lines.append(f" - {c['name']}: {c['state']} | {c['image']} | ports {port_str}")
+ return lines
+
+
+def _section_etl(d: dict[str, Any]) -> list[str]:
+ lines = [
+ f"Airflow ({d['airflow_url']}): {'HEALTHY' if d['airflow_healthy'] else 'DEGRADED'}",
+ ]
+ for comp, st in d.get("airflow_components", {}).items():
+ lines.append(f" - {comp}: {st}")
+ lines.append(f"Kafka UI ({d['kafka_ui_url']}): {'UP' if d['kafka_ui_ok'] else 'DOWN'}")
+ lines.append(f"Kafka Connect ({d['kafka_connect_url']}): connectors {d.get('connectors') or 'none listed'}")
+ if d.get("connectors"):
+ lines.append(" Registered connector names (exact): " + ", ".join(d["connectors"]))
+ lines.append(f"Spark UI ({d['spark_ui_url']}): {'UP' if d['spark_ui_ok'] else 'DOWN'}")
+ return lines
+
+
+def _section_hadoop(h: dict[str, Any]) -> list[str]:
+ lines = ["HDFS / Hadoop:"]
+ if not h.get("reachable"):
+ lines.append(f" UNREACHABLE: {h.get('error', 'NameNode probe failed')}")
+ return lines
+ lines.extend([
+ f" NameNode: {h['namenode']} ({h.get('hostname')}, HA {h.get('ha_state')})",
+ f" Version: {h.get('hdfs_version')}, safemode: {h.get('safemode')}",
+ f" Capacity: {h.get('capacity_used_gb')} GB used / {h.get('capacity_total_gb')} GB total "
+ f"({h.get('capacity_remaining_gb')} GB free, {h.get('percent_used', 0)}% used)",
+ f" Files: {h.get('files_total')}, Blocks: {h.get('blocks_total')}",
+ f" DataNodes: {h.get('live_datanodes')} live, {h.get('dead_datanodes')} dead",
+ f" Replication factor (dfs.replication): {h.get('default_replication_factor')}",
+ f" Block health: missing={h.get('missing_blocks')}, under-replicated={h.get('under_replicated_blocks')}, corrupt={h.get('corrupt_blocks')}",
+ ])
+ for dn in h.get("datanodes", []):
+ lines.append(
+ f" - {dn['host']}: {dn['used_gb']} GB / {dn['capacity_gb']} GB, {dn['blocks']} blocks, {dn['state']}"
+ )
+ if (h.get("capacity_used_gb") or 0) < 0.01 and (h.get("files_total") or 0) > 0:
+ lines.append(" Note: metadata/small files only — almost no user data stored yet.")
+ return lines
+
+
+def _section_gpu(g: dict[str, Any]) -> list[str]:
+ lines = ["GPU Lab / vLLM inference:"]
+ if not g.get("ok"):
+ lines.append(f" OFFLINE: {g.get('error', 'unreachable')}")
+ return lines
+ lines.extend([
+ f" Manager: {g.get('ui_url')}",
+ f" Model: {g.get('active_model')} (inference {'ON' if g.get('inference_active') else 'OFF'})",
+ f" vLLM endpoint: {g.get('vllm_url')}",
+ f" GPUs: {g.get('gpu_count')}x V100",
+ ])
+ for gpu in g.get("gpus", []):
+ lines.append(
+ f" GPU{gpu['index']}: util {gpu['util_gpu']:.0f}%, "
+ f"VRAM {gpu['memory_used_mib']:.0f}/{gpu['memory_total_mib']:.0f} MiB, "
+ f"{gpu['temperature_c']}°C, {gpu['power_w']:.0f}W"
+ )
+ return lines
+
+
+SECTION_BUILDERS = {
+ "docker": _section_docker,
+ "databases": _section_databases,
+ "lakehouse": _section_lakehouse,
+ "etl": _section_etl,
+ "hadoop": _section_hadoop,
+ "gpu": _section_gpu,
+}
+
+DOMAIN_ORDER = ["docker", "databases", "lakehouse", "etl", "hadoop", "gpu"]
+
+
+async def collect_full_lab_context(
+ gpu_data: dict[str, Any] | None = None,
+ log: TerminalLogFn | None = None,
+) -> dict[str, Any]:
+ """Gather all lab domains in parallel with optional live terminal logging."""
+ await _log(log, "info", "fetch", "═══ Lab snapshot collection started ═══")
+ async with httpx.AsyncClient(timeout=10.0, verify=False) as client:
+ if gpu_data is None:
+ gpu_data = await collect_gpu_metrics(client, log)
+
+ docker_raw, db_raw, lake_raw, hdfs, etl = await asyncio.gather(
+ dockhand_containers(client, 1, log),
+ dockhand_containers(client, 5, log),
+ dockhand_containers(client, 9, log),
+ collect_hdfs(client, log),
+ collect_etl(client, log),
+ )
+ docker = await collect_docker_rack(client, docker_raw, log)
+ databases = await collect_databases(client, db_raw, log)
+ lakehouse = await collect_lakehouse(client, lake_raw, log)
+
+ await _log(log, "ok", "fetch", "═══ Lab snapshot complete ═══")
+ return {
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "docker": docker,
+ "databases": databases,
+ "lakehouse": lakehouse,
+ "etl": etl,
+ "hadoop": hdfs,
+ "gpu": gpu_data,
+ }
+
+
+def format_context_for_agent(agent_id: str, snapshot: dict[str, Any]) -> str:
+ """Format full lab snapshot for LLM; primary domain first."""
+ primary = AGENT_PRIMARY_DOMAIN.get(agent_id, "docker")
+ lines = [
+ f"ATC Lab live snapshot — {snapshot.get('ts')}",
+ f"Your primary domain: {primary.upper()}",
+ ]
+ if snapshot.get("domains_summary"):
+ lines.append(f"Health summary: {json.dumps(snapshot['domains_summary'], default=str)}")
+ lines.extend(["", f"=== PRIMARY: {primary.upper()} ==="])
+
+ if primary in snapshot and primary in SECTION_BUILDERS:
+ lines.extend(SECTION_BUILDERS[primary](snapshot[primary]))
+ lines.append("")
+ lines.append("=== FULL LAB (all domains) ===")
+
+ for domain in DOMAIN_ORDER:
+ if domain == primary:
+ continue
+ if domain not in snapshot or domain not in SECTION_BUILDERS:
+ continue
+ lines.append("")
+ lines.append(f"--- {domain.upper()} ---")
+ lines.extend(SECTION_BUILDERS[domain](snapshot[domain]))
+
+ return "\n".join(lines)
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..f6d8283
--- /dev/null
+++ b/main.py
@@ -0,0 +1,662 @@
+"""ATC Command Center API — FastAPI backend."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+import time
+import uuid
+from contextlib import asynccontextmanager
+from datetime import datetime, timezone
+from typing import Any
+
+import httpx
+import redis.asyncio as aioredis
+from fastapi import FastAPI, WebSocket, WebSocketDisconnect
+from fastapi.middleware.cors import CORSMiddleware
+from agent_terminal import (
+ get_all_terminals,
+ get_terminal_lines,
+ init_terminals,
+ make_logger,
+ set_terminal_publisher,
+ terminal_log,
+)
+from lab_context import collect_full_lab_context, format_context_for_agent
+from workload import build_workload_payload
+from pydantic import BaseModel, Field
+from sqlalchemy import Column, DateTime, String, Text, create_engine, select
+from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
+
+REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
+DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082")
+DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:////data/atc-agents.db")
+GPU_URL = os.getenv("GPU_URL", "http://10.0.20.106:9000")
+GPU_UI_URL = os.getenv("GPU_UI_URL", GPU_URL)
+LLM_URL = os.getenv("LLM_URL", "http://10.0.20.106:8001/v1")
+LLM_MODEL = os.getenv("LLM_MODEL", "qwen2.5-32b-gptq")
+LLM_API_KEY = os.getenv("LLM_API_KEY", "sk-local")
+LLM_TIMEOUT = float(os.getenv("LLM_TIMEOUT", "120"))
+
+AGENTS = [
+ {
+ "id": "etl-guardian",
+ "name": "ETL Guardian",
+ "color": "#00f0ff",
+ "zone": "etl",
+ "role": "Airflow, Kafka, Debezium, S3 pipeline",
+ "icon": "⚡",
+ "motto": "Pipelines never sleep",
+ "capabilities": ["Airflow", "Kafka", "Debezium", "S3", "Connectors"],
+ "suggested_prompts": [
+ "Hoe staat Debezium er voor?",
+ "Zijn alle Airflow DAGs healthy?",
+ "Kafka connector status?",
+ ],
+ },
+ {
+ "id": "lakehouse-ops",
+ "name": "Lakehouse Ops",
+ "color": "#ff00aa",
+ "zone": "lakehouse",
+ "role": "Spark, Trino, Iceberg",
+ "icon": "🏔️",
+ "motto": "Query the lake, trust the table",
+ "capabilities": ["Spark", "Trino", "Iceberg", "Delta", "SQL"],
+ "suggested_prompts": [
+ "Lakehouse stack status?",
+ "Is Trino bereikbaar?",
+ "Hoeveel lakehouse containers draaien?",
+ ],
+ },
+ {
+ "id": "data-custodian",
+ "name": "Data Custodian",
+ "color": "#ffaa00",
+ "zone": "db",
+ "role": "PostgreSQL, MySQL, Mongo, Cassandra, Neo4j",
+ "icon": "🛡️",
+ "motto": "Guardian of every row",
+ "capabilities": ["PostgreSQL", "MySQL", "MongoDB", "Cassandra", "Neo4j"],
+ "suggested_prompts": [
+ "Database containers status?",
+ "Welke DB's draaien niet?",
+ "Postgres health check",
+ ],
+ },
+ {
+ "id": "hadoop-ranger",
+ "name": "Hadoop Ranger",
+ "color": "#39ff14",
+ "zone": "hadoop",
+ "role": "HDFS, YARN cluster",
+ "icon": "🌲",
+ "motto": "Patrol the data forest",
+ "capabilities": ["HDFS", "YARN", "NameNode", "DataNodes"],
+ "suggested_prompts": [
+ "Is HDFS NameNode up?",
+ "Hadoop cluster status?",
+ "YARN nodes healthy?",
+ ],
+ },
+ {
+ "id": "infra-sentinel",
+ "name": "Infra Sentinel",
+ "color": "#b366ff",
+ "zone": "docker",
+ "role": "Docker, Proxmox, GPU, monitoring",
+ "icon": "👁️",
+ "motto": "See everything, miss nothing",
+ "capabilities": ["Docker", "Proxmox", "GPU", "vLLM", "Monitoring"],
+ "suggested_prompts": [
+ "Hoe staat de GPU?",
+ "Welk LLM model draait er?",
+ "Docker containers overzicht",
+ ],
+ },
+]
+
+ZONES = [
+ {"id": "docker", "label": "DOCKER RACK", "x": 8, "color": "#b366ff"},
+ {"id": "db", "label": "DB VAULT", "x": 28, "color": "#ffaa00"},
+ {"id": "lakehouse", "label": "LAKEHOUSE HUB", "x": 50, "color": "#ff00aa"},
+ {"id": "hadoop", "label": "HADOOP CLUSTER", "x": 72, "color": "#39ff14"},
+ {"id": "etl", "label": "ETL PIPE", "x": 92, "color": "#00f0ff"},
+]
+
+INTENT_KEYWORDS: dict[str, list[str]] = {
+ "data-custodian": ["database", "postgres", "postgresql", "mysql", "mongo", "mongodb", "cassandra", "neo4j", "sql", "db "],
+ "lakehouse-ops": ["trino", "spark", "lakehouse", "iceberg", "query", "table"],
+ "hadoop-ranger": [
+ "hadoop", "hdfs", "yarn", "datanode", "namenode", "replicatie", "replication",
+ "rf factor", "opslag", "bestanden", "blocks", "cluster opslag", "data op",
+ ],
+ "infra-sentinel": ["docker", "container", "vm", "proxmox", "infra", "grafana", "gpu", "vllm", "llm", "nvidia", "inference", "model"],
+ "etl-guardian": ["airflow", "dag", "debezium", "kafka", "connector", "etl", "pipeline", "s3"],
+}
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+class FeedEntry(Base):
+ __tablename__ = "feed"
+
+ id = Column(String, primary_key=True)
+ ts = Column(DateTime, default=lambda: datetime.now(timezone.utc))
+ agent_id = Column(String)
+ level = Column(String, default="info")
+ message = Column(Text)
+
+
+class Approval(Base):
+ __tablename__ = "approvals"
+
+ id = Column(String, primary_key=True)
+ ts = Column(DateTime, default=lambda: datetime.now(timezone.utc))
+ agent_id = Column(String)
+ action = Column(Text)
+ reason = Column(Text)
+ status = Column(String, default="pending")
+
+
+engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
+SessionLocal = sessionmaker(bind=engine)
+Base.metadata.create_all(engine)
+
+redis_client: aioredis.Redis | None = None
+ws_clients: set[WebSocket] = set()
+
+
+class PromptRequest(BaseModel):
+ message: str = Field(min_length=1, max_length=2000)
+ agent_id: str | None = None
+
+
+class ApprovalDecision(BaseModel):
+ approved: bool
+
+
+def route_agent(message: str) -> str:
+ lower = message.lower()
+ # Storage/data questions default to Hadoop unless clearly about databases
+ if any(w in lower for w in ("data", "opslag", "gb", "replicatie", "replication", "hdfs", "hadoop")):
+ if not any(w in lower for w in ("postgres", "mysql", "mongo", "database", "sql", "neo4j", "cassandra")):
+ return "hadoop-ranger"
+ scores = {aid: sum(1 for kw in kws if kw in lower) for aid, kws in INTENT_KEYWORDS.items()}
+ best = max(scores, key=scores.get)
+ if scores[best] == 0:
+ return "infra-sentinel"
+ return best
+
+
+async def gather_agent_context(
+ agent_id: str,
+ status: dict[str, Any],
+ log: Any | None = None,
+) -> str:
+ """Full lab snapshot for vLLM — all domains, agent's primary domain highlighted."""
+ snapshot = await collect_full_lab_context(gpu_data=status.get("gpu"), log=log)
+ snapshot["domains_summary"] = status.get("domains", {})
+ ctx = format_context_for_agent(agent_id, snapshot)
+ if log:
+ await log("info", "fetch", f"▸ Context assembled: {len(ctx)} chars for LLM")
+ return ctx
+
+
+async def ask_llm(
+ agent_id: str,
+ message: str,
+ context: str,
+ log: Any | None = None,
+) -> str | None:
+ agent = next(a for a in AGENTS if a["id"] == agent_id)
+ system = f"""Je bent {agent['name']}, een autonomous ops agent in het Dell ATC data lab.
+Specialisatie: {agent['role']}.
+Motto: {agent.get('motto', '')}
+
+Je antwoordt namens je domein maar hebt zicht op de HELE lab stack: Docker, databases, lakehouse (Trino/Spark/Kafka Connect), ETL (Airflow/Kafka), Hadoop HDFS, en GPU/vLLM.
+
+Regels:
+- Antwoord in dezelfde taal als de gebruiker (Nederlands of Engels).
+- Gebruik ALLEEN de live data hieronder — verzin geen hosts, poorten, cijfers of connector namen.
+- Gebruik exact de container/connector namen uit de data (bijv. mysql-hr-connector, niet "Debezium").
+- Als iets DOWN of 0 GB is, zeg dat eerlijk.
+- Kort en behulpzaam (max ~10 zinnen); bullet lists mogen als het overzicht helpt.
+
+--- LIVE LAB DATA (primary domain eerst, daarna volledige stack) ---
+{context}
+"""
+ if log:
+ await log("info", "llm", f"▸ Querying vLLM model={LLM_MODEL}")
+ await log("cmd", "llm", f"$ POST {LLM_URL.rstrip('/')}/chat/completions")
+ await log("info", "llm", f" user: {message[:160]}{'…' if len(message) > 160 else ''}")
+ try:
+ async with httpx.AsyncClient(timeout=LLM_TIMEOUT) as client:
+ t0 = time.monotonic()
+ r = await client.post(
+ f"{LLM_URL.rstrip('/')}/chat/completions",
+ headers={
+ "Authorization": f"Bearer {LLM_API_KEY}",
+ "Content-Type": "application/json",
+ },
+ json={
+ "model": LLM_MODEL,
+ "messages": [
+ {"role": "system", "content": system},
+ {"role": "user", "content": message},
+ ],
+ "max_tokens": 800,
+ "temperature": 0.25,
+ },
+ )
+ r.raise_for_status()
+ content = r.json()["choices"][0]["message"]["content"].strip()
+ ms = int((time.monotonic() - t0) * 1000)
+ if content and content.strip("!"):
+ if log:
+ await log("ok", "llm", f"← vLLM response {len(content)} chars ({ms}ms)")
+ preview = content.replace("\n", " ")[:180]
+ await log("info", "llm", f" » {preview}{'…' if len(content) > 180 else ''}")
+ return content
+ if log:
+ await log("warn", "llm", f"← Empty or invalid LLM output ({ms}ms)")
+ except Exception as exc:
+ if log:
+ await log("err", "llm", f"✗ vLLM error: {exc}")
+ return None
+
+
+def fallback_answer(agent_id: str, context: str) -> str:
+ agent_name = next(a["name"] for a in AGENTS if a["id"] == agent_id)
+ return f"**{agent_name}** (offline LLM — ruwe data):\n\n{context}"
+
+
+async def publish_event(event: dict[str, Any]) -> None:
+ payload = json.dumps(event, default=str)
+ if redis_client:
+ await redis_client.publish("ops", payload)
+ dead = []
+ for ws in ws_clients:
+ try:
+ await ws.send_text(payload)
+ except Exception:
+ dead.append(ws)
+ for ws in dead:
+ ws_clients.discard(ws)
+
+
+def add_feed(agent_id: str, message: str, level: str = "info") -> dict:
+ entry_id = str(uuid.uuid4())[:8]
+ with SessionLocal() as db:
+ row = FeedEntry(id=entry_id, agent_id=agent_id, message=message, level=level)
+ db.add(row)
+ db.commit()
+ return {
+ "id": entry_id,
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "agent_id": agent_id,
+ "message": message,
+ "level": level,
+ }
+
+
+async def dockhand_env_containers(env_id: int) -> list[dict]:
+ try:
+ async with httpx.AsyncClient(timeout=8.0) as client:
+ r = await client.get(f"{DOCKHAND_URL}/api/containers", params={"env": env_id})
+ r.raise_for_status()
+ return r.json()
+ except Exception:
+ return []
+
+
+async def probe_url(url: str) -> bool:
+ try:
+ async with httpx.AsyncClient(timeout=4.0, verify=False) as client:
+ r = await client.get(url)
+ return r.status_code < 500
+ except Exception:
+ return False
+
+
+async def collect_gpu() -> dict[str, Any]:
+ host = GPU_URL.replace("http://", "").replace("https://", "").split("/")[0]
+ base = {"ok": False, "host": host, "ui_url": GPU_UI_URL}
+
+ try:
+ async with httpx.AsyncClient(timeout=6.0) as client:
+ metrics_r, model_r, integration_r = await asyncio.gather(
+ client.get(f"{GPU_URL}/api/gpu/metrics"),
+ client.get(f"{GPU_URL}/api/active-model"),
+ client.get(f"{GPU_URL}/api/integration"),
+ return_exceptions=True,
+ )
+
+ gpus: list[dict[str, Any]] = []
+ if isinstance(metrics_r, httpx.Response) and metrics_r.status_code == 200:
+ current = metrics_r.json().get("current", {})
+ gpus = [
+ {
+ "index": g["index"],
+ "name": g["name"],
+ "util_gpu": g.get("util_gpu", 0),
+ "memory_used_mib": g.get("memory_used_mib", 0),
+ "memory_total_mib": g.get("memory_total_mib", 0),
+ "temperature_c": g.get("temperature_c", 0),
+ "power_w": g.get("power_w", 0),
+ }
+ for g in current.get("gpus", [])
+ ]
+
+ active_model = None
+ inference_active = False
+ vllm_url = None
+
+ if isinstance(model_r, httpx.Response) and model_r.status_code == 200:
+ model_data = model_r.json()
+ active_model = model_data.get("name")
+ inference_active = bool(model_data.get("inference_active"))
+ vllm_url = model_data.get("base_url")
+
+ if isinstance(integration_r, httpx.Response) and integration_r.status_code == 200:
+ integ = integration_r.json()
+ if not active_model:
+ active_model = integ.get("active_name")
+ if not inference_active:
+ inference_active = bool(integ.get("inference_active"))
+ if not vllm_url:
+ vllm_url = integ.get("recommended_base_url")
+
+ return {
+ **base,
+ "ok": len(gpus) > 0 or inference_active,
+ "inference_active": inference_active,
+ "active_model": active_model,
+ "vllm_url": vllm_url,
+ "gpu_count": len(gpus),
+ "gpus": gpus,
+ }
+ except Exception as exc:
+ return {**base, "error": str(exc)}
+
+
+async def collect_status() -> dict[str, Any]:
+ db_containers = await dockhand_env_containers(5)
+ db_running = sum(1 for c in db_containers if c.get("state") == "running")
+ db_total = len(db_containers) or 6
+
+ lake_containers = await dockhand_env_containers(9)
+ lake_running = sum(1 for c in lake_containers if c.get("state") == "running")
+ lake_total = len(lake_containers) or 6
+
+ docker_containers = await dockhand_env_containers(1)
+ docker_running = sum(1 for c in docker_containers if c.get("state") == "running")
+
+ hdfs_ok = await probe_url("http://10.0.21.61:9870")
+ kafka_ok = await probe_url("http://10.0.21.36:9000")
+ airflow_ok = await probe_url("http://10.0.21.55:8080")
+
+ def level(running: int, total: int) -> str:
+ if total == 0:
+ return "unknown"
+ ratio = running / total
+ if ratio >= 0.9:
+ return "ok"
+ if ratio >= 0.5:
+ return "warn"
+ return "down"
+
+ gpu = await collect_gpu()
+ gpu_level = "ok" if gpu.get("ok") and gpu.get("inference_active") else ("warn" if gpu.get("ok") else "down")
+ gpu_label = gpu.get("active_model") or (f"{gpu.get('gpu_count', 0)} GPUs" if gpu.get("ok") else "offline")
+
+ return {
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "domains": {
+ "docker": {"level": "ok" if docker_running >= 5 else "warn", "label": f"{docker_running} containers", "running": docker_running},
+ "databases": {"level": level(db_running, db_total), "label": f"{db_running}/{db_total} up", "running": db_running, "total": db_total},
+ "lakehouse": {"level": level(lake_running, lake_total), "label": f"{lake_running}/{lake_total} up", "running": lake_running, "total": lake_total},
+ "hadoop": {"level": "ok" if hdfs_ok else "warn", "label": "NN up" if hdfs_ok else "NN check"},
+ "etl": {"level": "ok" if kafka_ok and airflow_ok else "warn", "label": "Kafka+Airflow"},
+ "gpu": {"level": gpu_level, "label": gpu_label},
+ },
+ "gpu": gpu,
+ "kafka_ok": kafka_ok,
+ "airflow_ok": airflow_ok,
+ "hdfs_ok": hdfs_ok,
+ }
+
+
+async def run_agent_task(agent_id: str, message: str, prompt_id: str) -> str:
+ zone = next(a["zone"] for a in AGENTS if a["id"] == agent_id)
+ agent_name = next(a["name"] for a in AGENTS if a["id"] == agent_id)
+ log = make_logger(agent_id, prompt_id)
+
+ await terminal_log(
+ agent_id,
+ f"[{prompt_id}] ▶ Mission accepted: {message}",
+ level="info",
+ phase="dispatch",
+ prompt_id=prompt_id,
+ )
+ await publish_event({"type": "agent_dispatch", "agent_id": agent_id, "zone": zone, "prompt_id": prompt_id})
+ await asyncio.sleep(0.4)
+ await terminal_log(agent_id, f"[{prompt_id}] Walking to zone: {zone}", level="info", phase="dispatch", prompt_id=prompt_id)
+ await publish_event({"type": "agent_fetch", "agent_id": agent_id, "zone": zone, "prompt_id": prompt_id})
+
+ await log("info", "fetch", f"[{prompt_id}] Collecting live lab metrics…")
+ status = await collect_status()
+ context = await gather_agent_context(agent_id, status, log=log)
+
+ answer = await ask_llm(agent_id, message, context, log=log)
+ if not answer:
+ await log("warn", "llm", "LLM fallback — returning raw context")
+ answer = fallback_answer(agent_id, context)
+
+ await asyncio.sleep(0.3)
+ await terminal_log(agent_id, f"[{prompt_id}] ✓ Mission complete", level="ok", phase="done", prompt_id=prompt_id)
+ await publish_event({"type": "agent_return", "agent_id": agent_id, "zone": zone, "prompt_id": prompt_id})
+ feed = add_feed(agent_id, f"{agent_name} answered: {message[:60]}", "info")
+ await publish_event({"type": "feed", "entry": feed})
+ await publish_event({"type": "prompt_result", "prompt_id": prompt_id, "agent_id": agent_id, "answer": answer})
+ return answer
+
+
+async def heartbeat_loop() -> None:
+ while True:
+ try:
+ status = await collect_status()
+ workload = await collect_workload()
+ await publish_event({"type": "status", "data": status})
+ await publish_event({"type": "workload", "data": workload})
+ for domain, info in status["domains"].items():
+ if info["level"] == "down":
+ agent = "data-custodian" if domain == "databases" else "infra-sentinel"
+ feed = add_feed(agent, f"Alert: {domain} is DOWN ({info['label']})", "warn")
+ await publish_event({"type": "feed", "entry": feed})
+ except Exception as exc:
+ await publish_event({"type": "error", "message": str(exc)})
+ await asyncio.sleep(60)
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ global redis_client
+ redis_client = aioredis.from_url(REDIS_URL, decode_responses=True)
+ set_terminal_publisher(publish_event)
+ init_terminals([a["id"] for a in AGENTS])
+ for a in AGENTS:
+ await terminal_log(a["id"], f"{a['name']} terminal online — awaiting missions", level="info", phase="boot")
+ task = asyncio.create_task(heartbeat_loop())
+ add_feed("infra-sentinel", "ATC Command Center API online", "info")
+ yield
+ task.cancel()
+ if redis_client:
+ await redis_client.close()
+
+
+app = FastAPI(title="ATC Command Center API", lifespan=lifespan)
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+
+@app.get("/api/health")
+async def health():
+ llm_ok = False
+ try:
+ async with httpx.AsyncClient(timeout=4.0) as client:
+ r = await client.get(f"{LLM_URL.rstrip('/')}/models", headers={"Authorization": f"Bearer {LLM_API_KEY}"})
+ llm_ok = r.status_code == 200
+ except Exception:
+ pass
+ return {"ok": True, "ts": datetime.now(timezone.utc).isoformat(), "llm_url": LLM_URL, "llm_ok": llm_ok, "llm_model": LLM_MODEL}
+
+
+async def collect_workload() -> dict[str, Any]:
+ gpu = await collect_gpu()
+ snap = await collect_full_lab_context(gpu_data=gpu)
+ return build_workload_payload(snap)
+
+
+@app.get("/api/workload")
+async def get_workload():
+ return await collect_workload()
+
+
+@app.get("/api/status")
+async def get_status():
+ return await collect_status()
+
+
+@app.get("/api/gpu")
+async def get_gpu():
+ return await collect_gpu()
+
+
+def agent_stats() -> dict[str, dict[str, Any]]:
+ stats: dict[str, dict[str, Any]] = {a["id"]: {"tasks": 0, "last_active": None, "alerts": 0} for a in AGENTS}
+ with SessionLocal() as db:
+ rows = db.execute(select(FeedEntry).order_by(FeedEntry.ts.desc()).limit(200)).scalars().all()
+ for r in rows:
+ aid = r.agent_id
+ if aid not in stats:
+ continue
+ stats[aid]["tasks"] += 1
+ if r.level == "warn":
+ stats[aid]["alerts"] += 1
+ if stats[aid]["last_active"] is None and r.ts:
+ stats[aid]["last_active"] = r.ts.isoformat()
+ return stats
+
+
+@app.get("/api/agents")
+async def get_agents():
+ stats = agent_stats()
+ enriched = [{**a, "stats": stats.get(a["id"], {})} for a in AGENTS]
+ return {"agents": enriched, "zones": ZONES}
+
+
+@app.get("/api/terminals")
+async def get_terminals(limit: int = 200):
+ return {"terminals": get_all_terminals(limit)}
+
+
+@app.get("/api/terminals/{agent_id}")
+async def get_agent_terminal(agent_id: str, limit: int = 200):
+ valid = {a["id"] for a in AGENTS}
+ if agent_id not in valid:
+ return {"error": "unknown agent"}
+ return {"agent_id": agent_id, "lines": get_terminal_lines(agent_id, limit)}
+
+
+@app.get("/api/feed")
+async def get_feed(limit: int = 50):
+ with SessionLocal() as db:
+ rows = db.execute(select(FeedEntry).order_by(FeedEntry.ts.desc()).limit(limit)).scalars().all()
+ return {
+ "entries": [
+ {
+ "id": r.id,
+ "ts": r.ts.isoformat() if r.ts else None,
+ "agent_id": r.agent_id,
+ "message": r.message,
+ "level": r.level,
+ }
+ for r in rows
+ ]
+ }
+
+
+@app.get("/api/approvals")
+async def get_approvals():
+ with SessionLocal() as db:
+ rows = db.execute(select(Approval).where(Approval.status == "pending")).scalars().all()
+ return {
+ "approvals": [
+ {
+ "id": r.id,
+ "ts": r.ts.isoformat() if r.ts else None,
+ "agent_id": r.agent_id,
+ "action": r.action,
+ "reason": r.reason,
+ "status": r.status,
+ }
+ for r in rows
+ ]
+ }
+
+
+@app.post("/api/approvals/{approval_id}/decide")
+async def decide_approval(approval_id: str, body: ApprovalDecision):
+ with SessionLocal() as db:
+ row = db.get(Approval, approval_id)
+ if not row:
+ return {"error": "not found"}
+ row.status = "approved" if body.approved else "denied"
+ db.commit()
+ agent_id = row.agent_id
+ action = row.action
+ msg = f"Approval {'approved' if body.approved else 'denied'}: {action}"
+ feed = add_feed(agent_id, msg, "info" if body.approved else "warn")
+ await publish_event({"type": "feed", "entry": feed})
+ await publish_event({"type": "approval_update", "id": approval_id, "status": row.status})
+ return {"ok": True, "status": row.status}
+
+
+@app.post("/api/prompt")
+async def post_prompt(body: PromptRequest):
+ prompt_id = str(uuid.uuid4())[:8]
+ valid_ids = {a["id"] for a in AGENTS}
+ agent_id = body.agent_id if body.agent_id in valid_ids else route_agent(body.message)
+ add_feed(agent_id, f"Prompt received: {body.message}", "info")
+ asyncio.create_task(run_agent_task(agent_id, body.message, prompt_id))
+ return {"prompt_id": prompt_id, "agent_id": agent_id, "status": "dispatched"}
+
+
+@app.websocket("/api/ws/ops")
+async def ws_ops(websocket: WebSocket):
+ await websocket.accept()
+ ws_clients.add(websocket)
+ try:
+ status = await collect_status()
+ workload = await collect_workload()
+ await websocket.send_text(json.dumps({"type": "status", "data": status}, default=str))
+ await websocket.send_text(json.dumps({"type": "workload", "data": workload}, default=str))
+ await websocket.send_text(json.dumps({
+ "type": "terminal_history",
+ "terminals": get_all_terminals(150),
+ }, default=str))
+ while True:
+ await websocket.receive_text()
+ except WebSocketDisconnect:
+ pass
+ finally:
+ ws_clients.discard(websocket)
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 0000000..3d24bfe
--- /dev/null
+++ b/nginx.conf
@@ -0,0 +1,17 @@
+server {
+ listen 80;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ location /api/ {
+ proxy_pass http://api:3201/api/;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+ }
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..0aa1dcf
--- /dev/null
+++ b/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "atc-command-center",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "framer-motion": "^11.15.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.12",
+ "@types/react-dom": "^18.3.1",
+ "@vitejs/plugin-react": "^4.3.4",
+ "autoprefixer": "^10.4.20",
+ "postcss": "^8.4.49",
+ "tailwindcss": "^3.4.16",
+ "typescript": "^5.7.2",
+ "vite": "^6.0.3"
+ }
+}
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 0000000..2e7af2b
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..c506982
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,9 @@
+fastapi==0.115.6
+uvicorn[standard]==0.34.0
+redis==5.2.1
+httpx==0.28.1
+sqlalchemy==2.0.36
+aiosqlite==0.20.0
+pydantic==2.10.4
+python-multipart==0.0.20
+websockets==14.1
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
new file mode 100755
index 0000000..a75b0f2
--- /dev/null
+++ b/scripts/deploy.sh
@@ -0,0 +1,7 @@
+#!/bin/bash
+# Deploy Command Center stack on VM304
+set -euo pipefail
+cd "$(dirname "$0")/.."
+[ -f .env ] || { echo "Create .env from config/command-center/.env.example"; exit 1; }
+docker compose --env-file .env up -d --build
+echo "OK — http://10.0.21.33/"
diff --git a/scripts/sync-gitea.sh b/scripts/sync-gitea.sh
new file mode 100755
index 0000000..e21109c
--- /dev/null
+++ b/scripts/sync-gitea.sh
@@ -0,0 +1,80 @@
+#!/bin/bash
+# Sync Command Center + Data Quality to Gitea (run on VM304 as root)
+set -euo pipefail
+
+GITEA="http://atc-mgt01.dell-atc.lan:3001"
+AUTH="mo:Dell2026!"
+
+echo "==> Ensure atc-data-quality repo on Gitea"
+code=$(curl -s -o /dev/null -w "%{http_code}" -u "$AUTH" "$GITEA/api/v1/repos/mo/atc-data-quality")
+if [ "$code" = "404" ]; then
+ curl -s -u "$AUTH" -H "Content-Type: application/json" \
+ -d '{"name":"atc-data-quality","description":"ATC Data Quality + RAG APIs (Docling, ChromaDB, LangChain)","private":false,"auto_init":false}' \
+ "$GITEA/api/v1/user/repos"
+ echo "Created mo/atc-data-quality"
+fi
+
+echo "==> Init/push atc-data-quality"
+cd /opt/atc-data-quality
+if [ ! -d .git ]; then
+ git init
+ git config user.email "mo@dell-atc.lan"
+ git config user.name "mo"
+ git remote add origin "$GITEA/mo/atc-data-quality.git"
+fi
+cat > .gitignore <<'GI'
+__pycache__/
+*.pyc
+.env
+GI
+cat > README.md <<'MD'
+# ATC Data Quality + RAG
+
+| Service | Port (internal) | Route |
+|---------|-----------------|-------|
+| dq-api | 5010 | `/dq/*` |
+| rag-api | 5020 | `/rag/*` |
+
+Deploy with sibling repo `mo/atc-agents` — see `config/data-quality/README.md` there.
+MD
+git add -A
+git commit -m "Add DQ + RAG APIs with Docling, ChromaDB, persistent ingest" || true
+git push -u origin main 2>/dev/null || git push -u origin master 2>/dev/null || \
+ git branch -M main && git push -u origin main --force
+
+echo "==> Structure + push atc-agents"
+cd /opt/atc-agents
+mkdir -p config/command-center config/data-quality config/jupyter docs scripts
+cp docker-compose.yml config/command-center/
+cp caddy/Caddyfile config/command-center/
+cp config/command-center/.env.example config/command-center/.env.example 2>/dev/null || true
+
+cat > config/data-quality/README.md <<'MD'
+# Data Quality services
+
+Built from **`mo/atc-data-quality`** — clone to `/opt/atc-data-quality`.
+
+Docker compose build contexts:
+- `../atc-data-quality/dq-api`
+- `../atc-data-quality/rag-api`
+MD
+
+cat > config/jupyter/README.md <<'MD'
+# JupyterLab
+
+Included in root `docker-compose.yml` as service `jupyter`.
+Access: http://10.0.21.33/jupyter/
+Token: JUPYTER_TOKEN in `.env`
+MD
+
+cp /tmp/deploy.sh scripts/ 2>/dev/null || cp scripts/deploy.sh scripts/ 2>/dev/null || true
+chmod +x scripts/*.sh 2>/dev/null || true
+
+git add -A
+git status -sb
+git commit -m "Command Center v2: DQ, RAG, GPU matrix, S3 browser, Jupyter, Gitea config layout" || true
+git push origin main
+
+echo "==> Done. Repos:"
+echo " $GITEA/mo/atc-agents"
+echo " $GITEA/mo/atc-data-quality"
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..c544812
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,296 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { ActivityFeed } from './components/ActivityFeed'
+import { AgentRoster } from './components/AgentRoster'
+import { AgentTerminalGrid } from './components/AgentTerminalGrid'
+import { ChatPanel } from './components/ChatPanel'
+import { CommandDock } from './components/CommandDock'
+import { GpuPanel } from './components/GpuPanel'
+import { AmbientBackground } from './components/AmbientBackground'
+import { LiveClusterMap } from './components/LiveClusterMap'
+import { LiveDomainGrid } from './components/LiveDomainGrid'
+import { ThemeToggle } from './components/ThemeToggle'
+import type { Agent, AgentAnim, Approval, ChatMessage, FeedEntry, GpuStatus, StatusData, TerminalLine, WorkloadData } from './types'
+
+const TABS = ['Overview', 'Terminals', 'Activity', 'Approvals', 'GPU'] as const
+type Tab = (typeof TABS)[number]
+
+function wsUrl() {
+ const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
+ return `${proto}://${window.location.host}/api/ws/ops`
+}
+
+function LiveClock() {
+ const [now, setNow] = useState(new Date())
+ useEffect(() => {
+ const t = setInterval(() => setNow(new Date()), 1000)
+ return () => clearInterval(t)
+ }, [])
+ return (
+
+ {now.toLocaleTimeString()}
+
+ )
+}
+
+export default function App() {
+ const [tab, setTab] = useState('Overview')
+ const [agents, setAgents] = useState([])
+ const [status, setStatus] = useState(null)
+ const [workload, setWorkload] = useState(null)
+ const [gpu, setGpu] = useState(null)
+ const [feed, setFeed] = useState([])
+ const [approvals, setApprovals] = useState([])
+ const [chat, setChat] = useState([])
+ const [anims, setAnims] = useState>({})
+ const [selectedId, setSelectedId] = useState(null)
+ const [busy, setBusy] = useState(false)
+ const [terminals, setTerminals] = useState>({})
+ const [terminalLayout, setTerminalLayout] = useState<'grid' | 'focus'>('grid')
+
+ const appendTerminal = useCallback((line: TerminalLine) => {
+ setTerminals((prev) => {
+ const cur = prev[line.agent_id] || []
+ return { ...prev, [line.agent_id]: [...cur, line].slice(-300) }
+ })
+ }, [])
+
+ const selectedAgent = useMemo(
+ () => agents.find((a) => a.id === selectedId) || null,
+ [agents, selectedId],
+ )
+
+ const load = useCallback(async () => {
+ const [a, s, f, ap, g, t, w] = await Promise.all([
+ fetch('/api/agents').then((r) => r.json()),
+ fetch('/api/status').then((r) => r.json()),
+ fetch('/api/feed').then((r) => r.json()),
+ fetch('/api/approvals').then((r) => r.json()),
+ fetch('/api/gpu').then((r) => r.json()).catch(() => null),
+ fetch('/api/terminals').then((r) => r.json()).catch(() => ({ terminals: {} })),
+ fetch('/api/workload').then((r) => r.json()).catch(() => null),
+ ])
+ setAgents(a.agents || [])
+ setStatus(s)
+ setGpu(g || s.gpu || null)
+ setFeed(f.entries || [])
+ setApprovals(ap.approvals || [])
+ if (t.terminals) setTerminals(t.terminals)
+ if (w?.zones) setWorkload(w)
+ }, [])
+
+ useEffect(() => {
+ load()
+ const ws = new WebSocket(wsUrl())
+ ws.onmessage = (ev) => {
+ const msg = JSON.parse(ev.data)
+ if (msg.type === 'status') {
+ setStatus(msg.data)
+ if (msg.data.gpu) setGpu(msg.data.gpu)
+ }
+ if (msg.type === 'workload') setWorkload(msg.data)
+ if (msg.type === 'terminal') appendTerminal(msg.line)
+ if (msg.type === 'terminal_history' && msg.terminals) setTerminals(msg.terminals)
+ if (msg.type === 'feed') setFeed((prev) => [msg.entry, ...prev].slice(0, 100))
+ if (msg.type === 'agent_dispatch') {
+ setSelectedId(msg.agent_id)
+ setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'walk', zone: msg.zone } }))
+ }
+ if (msg.type === 'agent_fetch') {
+ setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'fetch', zone: msg.zone } }))
+ }
+ if (msg.type === 'agent_return') {
+ setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'return', zone: msg.zone } }))
+ setTimeout(() => {
+ setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'idle' } }))
+ }, 1200)
+ }
+ if (msg.type === 'prompt_result') {
+ setChat((c) => [...c, { role: 'agent', text: msg.answer, agent: msg.agent_id, ts: new Date().toISOString() }])
+ setBusy(false)
+ load()
+ }
+ }
+ const iv = setInterval(load, 15000)
+ return () => { ws.close(); clearInterval(iv) }
+ }, [load, appendTerminal])
+
+ const sendPrompt = async (message: string, agentId?: string) => {
+ setBusy(true)
+ setChat((c) => [...c, { role: 'user', text: message, ts: new Date().toISOString() }])
+ if (agentId) setSelectedId(agentId)
+ await fetch('/api/prompt', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ message, agent_id: agentId || undefined }),
+ })
+ }
+
+ const decide = async (id: string, approved: boolean) => {
+ await fetch(`/api/approvals/${id}/decide`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ approved }),
+ })
+ load()
+ }
+
+ const allOk = status && Object.values(status.domains).every((d) => d.level === 'ok')
+ const totalTasks = agents.reduce((n, a) => n + (a.stats?.tasks || 0), 0)
+
+ return (
+
+
+
+
+
setSelectedId(a.id)}
+ />
+
+
+
+
+
Live agent shells
+
Agent Terminals
+
Volg live hoe elke agent data ophaalt — HTTP probes, Dockhand, JMX, vLLM.
+
+
+ setTerminalLayout('grid')}>Grid
+ setTerminalLayout('focus')}>Focus
+
+
+
+
+
+
+
+
+
+
+ {TABS.map((t) => (
+ setTab(t)} className={`tab-btn ${tab === t ? 'active' : ''}`}>
+ {t}
+ {t === 'Approvals' && approvals.length > 0 && (
+ {approvals.length}
+ )}
+
+ ))}
+
+
+
+ {tab === 'Overview' && (
+
+
Infrastructure
+
Live Cluster Workload
+
+
+ )}
+ {tab === 'Terminals' && (
+
+
Mission trace
+
+ {selectedAgent ? `${selectedAgent.name} — full terminal` : 'Select an agent'}
+
+ {selectedAgent ? (
+
+ ) : (
+
Klik een agent in de roster of stuur een prompt.
+ )}
+
+ )}
+ {tab === 'Activity' && (
+
+
+
+
Event stream
+
Agent Activity
+
+ {selectedAgent && (
+
setSelectedId(null)}>
+ Clear filter
+
+ )}
+
+
+
+ )}
+ {tab === 'Approvals' && (
+
+ {approvals.length === 0 &&
Geen pending approvals.
}
+ {approvals.map((a) => {
+ const agent = agents.find((ag) => ag.id === a.agent_id)
+ return (
+
+ {agent &&
{agent.icon} }
+
+
{a.action}
+
{a.reason}
+
via {agent?.name || a.agent_id}
+
+ decide(a.id, true)} className="btn-secondary text-[var(--status-ok)]">Approve
+ decide(a.id, false)} className="btn-secondary text-[var(--status-down)]">Deny
+
+
+
+ )
+ })}
+
+ )}
+ {tab === 'GPU' && }
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/ActivityFeed.tsx b/src/components/ActivityFeed.tsx
new file mode 100644
index 0000000..9b89a6c
--- /dev/null
+++ b/src/components/ActivityFeed.tsx
@@ -0,0 +1,55 @@
+import { motion } from 'framer-motion'
+import type { Agent, FeedEntry } from '../types'
+
+type Props = {
+ feed: FeedEntry[]
+ agents: Agent[]
+ filterAgentId?: string | null
+}
+
+export function ActivityFeed({ feed, agents, filterAgentId }: Props) {
+ const entries = filterAgentId ? feed.filter((e) => e.agent_id === filterAgentId) : feed
+
+ if (entries.length === 0) {
+ return (
+
+
📡
+
Geen activiteit{filterAgentId ? ' voor deze agent' : ''}.
+
+ )
+ }
+
+ return (
+
+ {entries.map((e, i) => {
+ const agent = agents.find((a) => a.id === e.agent_id)
+ return (
+
+
+
+ {i < entries.length - 1 &&
}
+
+
+
+
+ {e.ts ? new Date(e.ts).toLocaleString() : ''}
+
+
+ {agent?.icon} {agent?.name || e.agent_id}
+
+ {e.level === 'warn' && WARN }
+
+
{e.message}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/src/components/AgentCard.tsx b/src/components/AgentCard.tsx
new file mode 100644
index 0000000..9ad4dbe
--- /dev/null
+++ b/src/components/AgentCard.tsx
@@ -0,0 +1,79 @@
+import { motion } from 'framer-motion'
+import type { CSSProperties } from 'react'
+import type { Agent, AgentAnim } from '../types'
+
+const STATE_LABEL: Record = {
+ idle: 'Standby',
+ walk: 'En route',
+ fetch: 'Fetching data',
+ return: 'Returning',
+}
+
+type Props = {
+ agent: Agent
+ anim: AgentAnim
+ selected: boolean
+ onSelect: () => void
+ onDelegate: () => void
+}
+
+export function AgentCard({ agent, anim, selected, onSelect, onDelegate }: Props) {
+ const busy = anim.state !== 'idle'
+ const tasks = agent.stats?.tasks ?? 0
+ const alerts = agent.stats?.alerts ?? 0
+
+ return (
+
+
+
+
+ {agent.icon || '🤖'}
+
+
+
+
+ {STATE_LABEL[anim.state]}
+
+ {alerts > 0 && (
+
+ {alerts} alert{alerts > 1 ? 's' : ''}
+
+ )}
+
+
+
+
+
{agent.name}
+
"{agent.motto || agent.role}"
+
+
+
+ {(agent.capabilities || []).slice(0, 4).map((cap) => (
+ {cap}
+ ))}
+
+
+
+ {tasks} tasks logged
+ { e.stopPropagation(); onDelegate() }}
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); onDelegate() } }}
+ className="delegate-btn text-[10px] font-mono font-semibold px-2 py-1 rounded-lg"
+ style={{ color: agent.color }}
+ >
+ Delegate →
+
+
+
+
+ )
+}
diff --git a/src/components/AgentRoster.tsx b/src/components/AgentRoster.tsx
new file mode 100644
index 0000000..f4d7379
--- /dev/null
+++ b/src/components/AgentRoster.tsx
@@ -0,0 +1,53 @@
+import { motion } from 'framer-motion'
+import type { Agent, AgentAnim } from '../types'
+import { AgentCard } from './AgentCard'
+
+type Props = {
+ agents: Agent[]
+ animations: Record
+ selectedId: string | null
+ onSelect: (id: string) => void
+ onDelegate: (agent: Agent) => void
+}
+
+export function AgentRoster({ agents, animations, selectedId, onSelect, onDelegate }: Props) {
+ return (
+
+
+
+
+
Autonomous workforce
+
+ Agent Roster
+
+
+ Selecteer een agent om te delegeren. Elk teamlid bewaakt een zone op de ops floor.
+
+
+
+
+ {agents.length} agents online
+
+
+
+
+ {agents.map((agent, i) => (
+
+ onSelect(agent.id)}
+ onDelegate={() => onDelegate(agent)}
+ />
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/AgentSprite.tsx b/src/components/AgentSprite.tsx
new file mode 100644
index 0000000..f3dad1e
--- /dev/null
+++ b/src/components/AgentSprite.tsx
@@ -0,0 +1,216 @@
+import { motion } from 'framer-motion'
+
+type Props = {
+ agentId: string
+ color: string
+ icon?: string
+ state: 'idle' | 'walk' | 'fetch' | 'return'
+ label: string
+}
+
+function uid(agentId: string, name: string) {
+ return `${agentId}-${name}`
+}
+
+function CharacterBody({ agentId, color, state }: { agentId: string; color: string; state: Props['state'] }) {
+ const g = uid(agentId, 'bodyGrad')
+ const glow = uid(agentId, 'glow')
+ const visor = uid(agentId, 'visor')
+ const walking = state === 'walk' || state === 'return'
+ const fetching = state === 'fetch'
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Platform + aura */}
+
+
+
+ {agentId === 'etl-guardian' && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ⚡
+
+ )}
+
+ {agentId === 'lakehouse-ops' && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🏔
+
+ )}
+
+ {agentId === 'data-custodian' && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🛡
+
+ )}
+
+ {agentId === 'hadoop-ranger' && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🌲
+ {fetching && (
+
+ )}
+
+ )}
+
+ {agentId === 'infra-sentinel' && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 👁
+
+ )}
+
+ {/* Default fallback */}
+ {!['etl-guardian', 'lakehouse-ops', 'data-custodian', 'hadoop-ranger', 'infra-sentinel'].includes(agentId) && (
+
+
+
+ 🤖
+
+ )}
+
+ {fetching && (
+
+
+
+
+ )}
+
+ )
+}
+
+export function AgentSprite({ agentId, color, state, label }: Props) {
+ const bob = state === 'idle' ? { y: [0, -5, 0] } : state === 'walk' || state === 'return' ? { y: [0, -9, 0] } : { y: [0, -2, 0] }
+ const scale = state === 'fetch' ? 0.94 : 1
+ const busy = state !== 'idle'
+
+ return (
+
+
+ {state !== 'idle' && (
+
+ )}
+
+
+
+
+
+ {label}
+
+
+ )
+}
diff --git a/src/components/AgentTerminal.tsx b/src/components/AgentTerminal.tsx
new file mode 100644
index 0000000..c37af1f
--- /dev/null
+++ b/src/components/AgentTerminal.tsx
@@ -0,0 +1,74 @@
+import { useEffect, useRef } from 'react'
+import type { CSSProperties } from 'react'
+import type { Agent, TerminalLine } from '../types'
+
+const LEVEL_CLASS: Record = {
+ info: 'term-info',
+ ok: 'term-ok',
+ warn: 'term-warn',
+ err: 'term-err',
+ cmd: 'term-cmd',
+ llm: 'term-llm',
+}
+
+type Props = {
+ agent: Agent
+ lines: TerminalLine[]
+ active: boolean
+ expanded?: boolean
+ onFocus?: () => void
+}
+
+export function AgentTerminal({ agent, lines, active, expanded, onFocus }: Props) {
+ const bottomRef = useRef(null)
+ const containerRef = useRef(null)
+
+ useEffect(() => {
+ if (active || expanded) {
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
+ }
+ }, [lines, active, expanded])
+
+ return (
+ e.key === 'Enter' && onFocus?.()}
+ >
+
+
+ {agent.icon}
+ {agent.name}
+ {active && LIVE }
+
+
{lines.length} lines
+
+
+
+ {lines.length === 0 && (
+
+ --:--:--
+ Waiting for missions…
+
+ )}
+ {lines.map((line) => (
+
+ {line.ts ? new Date(line.ts).toLocaleTimeString() : ''}
+ {line.phase}
+ {line.text}
+
+ ))}
+ {active && (
+
+
+ █
+
+ )}
+
+
+
+ )
+}
diff --git a/src/components/AgentTerminalGrid.tsx b/src/components/AgentTerminalGrid.tsx
new file mode 100644
index 0000000..6b68ab7
--- /dev/null
+++ b/src/components/AgentTerminalGrid.tsx
@@ -0,0 +1,60 @@
+import type { Agent, AgentAnim, TerminalLine } from '../types'
+import { AgentTerminal } from './AgentTerminal'
+
+type Props = {
+ agents: Agent[]
+ terminals: Record
+ animations: Record
+ selectedId: string | null
+ onSelect: (id: string) => void
+ layout?: 'grid' | 'focus'
+}
+
+export function AgentTerminalGrid({ agents, terminals, animations, selectedId, onSelect, layout = 'grid' }: Props) {
+ const focusId = selectedId || agents[0]?.id
+
+ if (layout === 'focus' && focusId) {
+ const agent = agents.find((a) => a.id === focusId)!
+ const anim = animations[focusId] || { agentId: focusId, state: 'idle' as const }
+ return (
+
+
+
+ {agents.map((a) => (
+ onSelect(a.id)}
+ className={`agent-terminal-tab ${focusId === a.id ? 'active' : ''}`}
+ style={focusId === a.id ? { borderColor: a.color, color: a.color } : undefined}
+ >
+ {a.icon} {a.name.split(' ')[0]}
+
+ ))}
+
+
+ )
+ }
+
+ return (
+
+ {agents.map((agent) => {
+ const anim = animations[agent.id] || { agentId: agent.id, state: 'idle' as const }
+ return (
+
onSelect(agent.id)}
+ />
+ )
+ })}
+
+ )
+}
diff --git a/src/components/AmbientBackground.tsx b/src/components/AmbientBackground.tsx
new file mode 100644
index 0000000..5fa8ed7
--- /dev/null
+++ b/src/components/AmbientBackground.tsx
@@ -0,0 +1,18 @@
+export function AmbientBackground() {
+ return (
+
+ {[...Array(12)].map((_, i) => (
+
+ ))}
+
+ )
+}
diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx
new file mode 100644
index 0000000..37edbbf
--- /dev/null
+++ b/src/components/ChatPanel.tsx
@@ -0,0 +1,89 @@
+import { useEffect, useRef } from 'react'
+import { motion } from 'framer-motion'
+import type { Agent, ChatMessage } from '../types'
+
+type Props = {
+ messages: ChatMessage[]
+ agents: Agent[]
+ selectedAgent: Agent | null
+ busy: boolean
+}
+
+function agentFor(agents: Agent[], id?: string) {
+ return agents.find((a) => a.id === id)
+}
+
+export function ChatPanel({ messages, agents, selectedAgent, busy }: Props) {
+ const bottomRef = useRef(null)
+
+ useEffect(() => {
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
+ }, [messages, busy])
+
+ return (
+
+
+
+
Mission control
+
Agent Comms
+
+ {selectedAgent && (
+
+ {selectedAgent.icon}
+ → {selectedAgent.name}
+
+ )}
+
+
+
+ {messages.length === 0 && (
+
+
💬
+
+ {selectedAgent
+ ? `Stuur een opdracht naar ${selectedAgent.name}. Kies een suggestie hieronder of typ je eigen vraag.`
+ : 'Selecteer een agent of stel een vraag — routing kiest automatisch de specialist.'}
+
+
+ )}
+
+ {messages.map((m, i) => {
+ const agent = m.role === 'agent' ? agentFor(agents, m.agent) : null
+ return (
+
+ {m.role === 'agent' && agent && (
+
+ {agent.icon}
+
+ )}
+
+
+ {m.role === 'user' ? 'You' : agent?.name || m.agent}
+ {m.ts && {new Date(m.ts).toLocaleTimeString()} }
+
+
{m.text}
+
+
+ )
+ })}
+
+ {busy && (
+
+ ⋯
+
+
+ )}
+
+
+
+ )
+}
diff --git a/src/components/ClusterViz.tsx b/src/components/ClusterViz.tsx
new file mode 100644
index 0000000..496a44f
--- /dev/null
+++ b/src/components/ClusterViz.tsx
@@ -0,0 +1,167 @@
+import { motion } from 'framer-motion'
+import { appIcon, shortName } from '../lib/appIcons'
+import type { WorkloadZone } from '../types'
+
+const DESK_X = 50
+
+type Props = {
+ zones: WorkloadZone[]
+ activeZoneId?: string | null
+}
+
+export function DataFlowLayer({ zones, activeZoneId }: Props) {
+ return (
+
+
+ {zones.map((z) => (
+
+
+
+
+
+ ))}
+
+ {zones.map((z, i) => (
+
+
+ {[0, 1, 2].map((p) => (
+
+ ))}
+
+ ))}
+
+ )
+}
+
+type ZoneProps = {
+ zone: WorkloadZone
+ active: boolean
+ agentBusy: boolean
+}
+
+export function ZoneTower({ zone, active, agentBusy }: ZoneProps) {
+ const pulse = zone.level === 'ok'
+ const apps = zone.apps.slice(0, 5)
+
+ return (
+
+
+ {zone.label}
+
+
+ {zone.running}/{zone.total || zone.apps.length}
+
+ {zone.id === 'hadoop' && zone.hdfs_total_gb != null && (
+ {zone.hdfs_used_gb ?? 0}/{zone.hdfs_total_gb} GB
+ )}
+ {zone.id === 'lakehouse' && (
+ {zone.trino_ok ? 'Trino ●' : 'Trino ○'}
+ )}
+
+
+
+ {apps.map((app, i) => (
+
+ {appIcon(app.name, app.image)}
+ {shortName(app.name, 10)}
+
+ ))}
+
+
+ )
+}
+
+export function GpuBeacon({ model, util, count, level, active }: {
+ model?: string | null
+ util?: number
+ count?: number
+ level: string
+ active?: boolean
+}) {
+ return (
+
+ ⚡ GPU LAB
+ {shortName(model || 'offline', 18)}
+ {count ?? 0}× V100 · {Math.round(util ?? 0)}%
+
+
+
+
+ )
+}
+
+export function WorkloadTicker({ zones }: { zones: WorkloadZone[] }) {
+ const allApps = zones.flatMap((z) =>
+ z.apps.map((a) => ({ ...a, zoneColor: z.color, zoneId: z.id })),
+ )
+
+ return (
+
+
LIVE WORKLOAD
+
+
+ {[...allApps, ...allApps].map((app, i) => (
+
+ {appIcon(app.name, app.image)} {app.name}
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/CommandDock.tsx b/src/components/CommandDock.tsx
new file mode 100644
index 0000000..b32c001
--- /dev/null
+++ b/src/components/CommandDock.tsx
@@ -0,0 +1,69 @@
+import { FormEvent, useState } from 'react'
+import { motion } from 'framer-motion'
+import type { Agent } from '../types'
+
+type Props = {
+ onSubmit: (message: string, agentId?: string) => void
+ busy: boolean
+ selectedAgent: Agent | null
+}
+
+export function CommandDock({ onSubmit, busy, selectedAgent }: Props) {
+ const [text, setText] = useState('')
+ const prompts = selectedAgent?.suggested_prompts || [
+ 'Lab health overview?',
+ 'Hoe staat de GPU?',
+ 'Database status?',
+ ]
+
+ const handle = (e: FormEvent) => {
+ e.preventDefault()
+ if (!text.trim() || busy) return
+ onSubmit(text.trim(), selectedAgent?.id)
+ setText('')
+ }
+
+ const sendQuick = (prompt: string) => {
+ if (busy) return
+ onSubmit(prompt, selectedAgent?.id)
+ }
+
+ return (
+
+
+ {prompts.map((p) => (
+ sendQuick(p)}
+ className="quick-prompt-chip"
+ style={selectedAgent ? { borderColor: `${selectedAgent.color}55`, color: selectedAgent.color } : undefined}
+ >
+ {p}
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/src/components/GpuPanel.tsx b/src/components/GpuPanel.tsx
new file mode 100644
index 0000000..fabc241
--- /dev/null
+++ b/src/components/GpuPanel.tsx
@@ -0,0 +1,137 @@
+import type { GpuStatus } from '../types'
+
+type Props = {
+ gpu: GpuStatus | null
+ compact?: boolean
+}
+
+function memPct(used: number, total: number) {
+ if (!total) return 0
+ return Math.round((used / total) * 100)
+}
+
+export function GpuPanel({ gpu, compact }: Props) {
+ if (!gpu) {
+ return (
+
+ )
+ }
+
+ const online = gpu.ok
+ const gpus = gpu.gpus || []
+ const avgUtil = gpus.length ? gpus.reduce((s, g) => s + g.util_gpu, 0) / gpus.length : 0
+ const avgVram = gpus.length ? gpus.reduce((s, g) => s + memPct(g.memory_used_mib, g.memory_total_mib), 0) / gpus.length : 0
+
+ if (compact) {
+ return (
+
+
+
+ {gpu.active_model && (
+
{gpu.active_model}
+ )}
+
+ {gpu.gpu_count ?? gpus.length}× V100
+ {Math.round(avgUtil)}% util
+ {Math.round(avgVram)}% VRAM
+
+ {online ? (gpu.inference_active ? 'ON' : 'STBY') : 'OFF'}
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
Inference cluster
+
+ ⚡
+
GPU Lab
+
+ {online ? (gpu.inference_active ? 'INFERENCE ON' : 'STANDBY') : 'OFFLINE'}
+
+
+
+ atc-gpu-dev · {gpu.host} · {gpu.gpu_count ?? gpus.length}× V100
+
+
+
+ Open GPU Manager ↗
+
+
+
+ {gpu.active_model && (
+
+
Active model
+
{gpu.active_model}
+ {gpu.vllm_url && (
+
{gpu.vllm_url}
+ )}
+
+ )}
+
+ {!online && (
+
{gpu.error || 'GPU manager unreachable'}
+ )}
+
+
+ {gpus.map((g) => {
+ const pct = memPct(g.memory_used_mib, g.memory_total_mib)
+ return (
+
+
+ GPU {g.index}
+ {g.temperature_c}°C · {g.power_w}W
+
+
{g.name}
+
+ Util {Math.round(g.util_gpu)}%
+ VRAM {pct}%
+
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/src/components/LiveClusterMap.tsx b/src/components/LiveClusterMap.tsx
new file mode 100644
index 0000000..249bfd8
--- /dev/null
+++ b/src/components/LiveClusterMap.tsx
@@ -0,0 +1,154 @@
+import { motion, AnimatePresence } from 'framer-motion'
+import type { CSSProperties } from 'react'
+import { AgentSprite } from './AgentSprite'
+import { DataFlowLayer, GpuBeacon, WorkloadTicker, ZoneTower } from './ClusterViz'
+import type { Agent, AgentAnim, WorkloadData } from '../types'
+
+const ZONE_X: Record = {
+ docker: 8,
+ db: 28,
+ lakehouse: 50,
+ hadoop: 72,
+ etl: 92,
+}
+
+const STATE_LABEL: Record = {
+ idle: '',
+ walk: '→ zone',
+ fetch: '⟳ fetch',
+ return: '← desk',
+}
+
+const DESK_X = 50
+
+type Props = {
+ agents: Agent[]
+ workload: WorkloadData | null
+ animations: Record
+ selectedId: string | null
+}
+
+export function LiveClusterMap({ agents, workload, animations, selectedId }: Props) {
+ const zones = workload?.zones || []
+ const activeCount = agents.filter((a) => (animations[a.id]?.state || 'idle') !== 'idle').length
+ const busyAgent = agents.find((a) => (animations[a.id]?.state || 'idle') !== 'idle')
+ const mappedBusyZone = busyAgent?.zone ?? null
+
+ return (
+
+
+
+
+
+
+
Live simulation
+
+ Cluster Ops Floor
+
+ {workload && (
+
+ {workload.totals.apps_running} workloads active · {workload.totals.connectors} connectors · GPU {workload.gpu.avg_util ?? 0}%
+
+ )}
+
+
+ {activeCount > 0 && (
+
+ {activeCount} agent{activeCount > 1 ? 's' : ''} deployed
+
+ )}
+ ● LIVE
+
+
+
+ {workload && (
+
+
+
+ )}
+
+
+
+ {zones.map((z) => (
+
+ ))}
+
+
+
+ COMMAND DESK
+
+
+
+ {workload && zones.length > 0 && (
+
+
+
+ )}
+
+
+
+ {agents.map((agent, i) => {
+ const anim = animations[agent.id] || { agentId: agent.id, state: 'idle' as const }
+ const targetX = anim.state === 'idle' ? 10 + i * 18 : ZONE_X[anim.zone || agent.zone] ?? DESK_X
+ const y = anim.state === 'fetch' ? 10 : anim.state === 'idle' ? 0 : 6
+ const selected = selectedId === agent.id
+ const busy = anim.state !== 'idle'
+
+ return (
+
+
+ {busy && STATE_LABEL[anim.state] && (
+
+ {STATE_LABEL[anim.state]}
+
+ )}
+
+
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/src/components/LiveDomainGrid.tsx b/src/components/LiveDomainGrid.tsx
new file mode 100644
index 0000000..aff4f1b
--- /dev/null
+++ b/src/components/LiveDomainGrid.tsx
@@ -0,0 +1,110 @@
+import { motion } from 'framer-motion'
+import { appIcon, LEVEL_COLOR } from '../lib/appIcons'
+import type { WorkloadData } from '../types'
+
+type Props = {
+ workload: WorkloadData | null
+}
+
+export function LiveDomainGrid({ workload }: Props) {
+ if (!workload) {
+ return
+ }
+
+ return (
+
+
+ {workload.zones.map((zone, zi) => {
+ const pct = zone.total ? Math.round((zone.running / zone.total) * 100) : 100
+ return (
+
+
+
+
+ {zone.label}
+
+
+ {zone.running}/{zone.total || zone.apps.length}
+
+
+
+
+
+
+
+
+
+
+ {zone.apps.slice(0, 6).map((app, ai) => (
+
+ {appIcon(app.name, app.image)}
+ {app.name.split('_')[0]}
+
+ ))}
+
+
+ )
+ })}
+
+
+ GPU LAB
+
+ {workload.gpu.gpu_count ?? 0}× V100
+
+
+ {workload.gpu.model || 'offline'}
+
+
+
+
+
+ {(workload.gpu.gpus || []).map((g) => (
+
+ G{g.index}
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/src/components/OpsFloor.tsx b/src/components/OpsFloor.tsx
new file mode 100644
index 0000000..3540308
--- /dev/null
+++ b/src/components/OpsFloor.tsx
@@ -0,0 +1,134 @@
+import { motion, AnimatePresence } from 'framer-motion'
+import type { CSSProperties } from 'react'
+import { AgentSprite } from './AgentSprite'
+import type { Agent, AgentAnim, Zone } from '../types'
+
+const ZONE_X: Record = {
+ docker: 8,
+ db: 28,
+ lakehouse: 50,
+ hadoop: 72,
+ etl: 92,
+}
+
+const STATE_LABEL: Record = {
+ idle: '',
+ walk: '→ zone',
+ fetch: '⟳ fetch',
+ return: '← desk',
+}
+
+const DESK_X = 50
+
+type Props = {
+ agents: Agent[]
+ zones: Zone[]
+ animations: Record
+ selectedId: string | null
+}
+
+export function OpsFloor({ agents, zones, animations, selectedId }: Props) {
+ const activeCount = agents.filter((a) => (animations[a.id]?.state || 'idle') !== 'idle').length
+
+ return (
+
+
+
+
+
Live simulation
+
+ Ops Floor
+
+
+
+ {activeCount > 0 && (
+
+ {activeCount} agent{activeCount > 1 ? 's' : ''} deployed
+
+ )}
+ ● LIVE
+
+
+
+
+ {zones.map((z) => (
+
+
+ {z.label}
+
+
+ ))}
+
+
+ COMMAND DESK
+
+
+
+ {zones.map((z) => (
+
+ ))}
+
+
+
+
+ {agents.map((agent, i) => {
+ const anim = animations[agent.id] || { agentId: agent.id, state: 'idle' as const }
+ const targetX = anim.state === 'idle' ? 10 + i * 18 : ZONE_X[anim.zone || agent.zone] ?? DESK_X
+ const y = anim.state === 'fetch' ? 10 : anim.state === 'idle' ? 0 : 6
+ const selected = selectedId === agent.id
+ const busy = anim.state !== 'idle'
+
+ return (
+
+
+ {busy && STATE_LABEL[anim.state] && (
+
+ {STATE_LABEL[anim.state]}
+
+ )}
+
+
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/src/components/PromptBar.tsx b/src/components/PromptBar.tsx
new file mode 100644
index 0000000..f507088
--- /dev/null
+++ b/src/components/PromptBar.tsx
@@ -0,0 +1,33 @@
+import { FormEvent, useState } from 'react'
+
+type Props = {
+ onSubmit: (message: string) => void
+ busy: boolean
+}
+
+export function PromptBar({ onSubmit, busy }: Props) {
+ const [text, setText] = useState('')
+
+ const handle = (e: FormEvent) => {
+ e.preventDefault()
+ if (!text.trim() || busy) return
+ onSubmit(text.trim())
+ setText('')
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx
new file mode 100644
index 0000000..2276663
--- /dev/null
+++ b/src/components/ThemeToggle.tsx
@@ -0,0 +1,23 @@
+import { useTheme } from '../context/ThemeContext'
+
+export function ThemeToggle() {
+ const { theme, toggle } = useTheme()
+ const isDark = theme === 'dark'
+
+ return (
+
+
+ {isDark ? '🌙' : '☀️'}
+
+
+ {isDark ? 'Dark' : 'Light'}
+
+
+ )
+}
diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx
new file mode 100644
index 0000000..d206267
--- /dev/null
+++ b/src/context/ThemeContext.tsx
@@ -0,0 +1,46 @@
+import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
+
+export type Theme = 'light' | 'dark'
+
+type ThemeContextValue = {
+ theme: Theme
+ toggle: () => void
+ setTheme: (t: Theme) => void
+}
+
+const ThemeContext = createContext(null)
+const STORAGE_KEY = 'atc-command-center-theme'
+
+function readStored(): Theme {
+ const v = localStorage.getItem(STORAGE_KEY)
+ return v === 'dark' || v === 'light' ? v : 'light'
+}
+
+export function ThemeProvider({ children }: { children: ReactNode }) {
+ const [theme, setThemeState] = useState(() => {
+ if (typeof window === 'undefined') return 'light'
+ return readStored()
+ })
+
+ useEffect(() => {
+ const root = document.documentElement
+ root.classList.remove('light', 'dark')
+ root.classList.add(theme)
+ localStorage.setItem(STORAGE_KEY, theme)
+ }, [theme])
+
+ const setTheme = (t: Theme) => setThemeState(t)
+ const toggle = () => setThemeState((t) => (t === 'light' ? 'dark' : 'light'))
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useTheme() {
+ const ctx = useContext(ThemeContext)
+ if (!ctx) throw new Error('useTheme outside ThemeProvider')
+ return ctx
+}
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..341e2fd
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,629 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+:root,
+.light {
+ --bg-1: #e8f4fc;
+ --bg-2: #f4f7fb;
+ --bg-3: #f5f0ff;
+ --grid-color: rgba(8, 145, 178, 0.06);
+ --surface: rgba(255, 255, 255, 0.72);
+ --surface-strong: rgba(255, 255, 255, 0.94);
+ --surface-elevated: #f1f5f9;
+ --surface-muted: #e2e8f0;
+ --text: #0f172a;
+ --text-muted: #64748b;
+ --text-faint: #94a3b8;
+ --border: rgba(15, 23, 42, 0.09);
+ --accent: #0891b2;
+ --accent-gpu: #65a30d;
+ --accent-secondary: #7c3aed;
+ --glow: rgba(8, 145, 178, 0.22);
+ --status-ok: #16a34a;
+ --status-ok-bg: rgba(22, 163, 74, 0.1);
+ --status-warn: #d97706;
+ --status-warn-bg: rgba(217, 119, 6, 0.1);
+ --status-down: #dc2626;
+ --status-down-bg: rgba(220, 38, 38, 0.08);
+ --shadow: 0 12px 40px rgba(15, 40, 80, 0.09);
+ --floor-bg: linear-gradient(180deg, #f1f5f9 0%, #ffffff 100%);
+ --aurora-1: rgba(8, 145, 178, 0.12);
+ --aurora-2: rgba(124, 58, 237, 0.08);
+}
+
+.dark {
+ --bg-1: #050810;
+ --bg-2: #0a0f1a;
+ --bg-3: #100818;
+ --grid-color: rgba(34, 211, 238, 0.05);
+ --surface: rgba(12, 18, 32, 0.78);
+ --surface-strong: rgba(16, 22, 38, 0.94);
+ --surface-elevated: #1a2236;
+ --surface-muted: #243049;
+ --text: #eef2f9;
+ --text-muted: #94a3b8;
+ --text-faint: #64748b;
+ --border: rgba(34, 211, 238, 0.14);
+ --accent: #22d3ee;
+ --accent-gpu: #a3e635;
+ --accent-secondary: #c084fc;
+ --glow: rgba(34, 211, 238, 0.28);
+ --status-ok: #4ade80;
+ --status-ok-bg: rgba(74, 222, 128, 0.12);
+ --status-warn: #fbbf24;
+ --status-warn-bg: rgba(251, 191, 36, 0.12);
+ --status-down: #f87171;
+ --status-down-bg: rgba(248, 113, 113, 0.12);
+ --shadow: 0 12px 48px rgba(0, 0, 0, 0.5);
+ --floor-bg: linear-gradient(180deg, #121a2e 0%, #0a0e18 100%);
+ --aurora-1: rgba(34, 211, 238, 0.15);
+ --aurora-2: rgba(192, 132, 252, 0.1);
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ color: var(--text);
+ background: linear-gradient(145deg, var(--bg-1) 0%, var(--bg-2) 45%, var(--bg-3) 100%);
+ background-attachment: fixed;
+ transition: background 0.4s ease, color 0.4s ease;
+}
+
+body::before {
+ content: '';
+ position: fixed;
+ inset: 0;
+ background-image:
+ radial-gradient(ellipse 80% 50% at 20% 0%, var(--aurora-1), transparent 50%),
+ radial-gradient(ellipse 60% 40% at 80% 10%, var(--aurora-2), transparent 45%),
+ linear-gradient(var(--grid-color) 1px, transparent 1px),
+ linear-gradient(90deg, var(--grid-color) 1px, transparent 1px);
+ background-size: 100% 100%, 100% 100%, 40px 40px, 40px 40px;
+ pointer-events: none;
+ z-index: 0;
+}
+
+#root { position: relative; z-index: 1; }
+
+.section-eyebrow {
+ @apply text-[10px] font-mono uppercase tracking-[0.2em] text-[var(--text-faint)] mb-0.5;
+}
+
+.panel {
+ background: var(--surface-strong);
+ backdrop-filter: blur(20px);
+ border: 1px solid var(--border);
+ box-shadow: var(--shadow);
+ transition: background 0.35s ease, border-color 0.35s ease, box-shadow 0.35s ease;
+}
+
+.neon-text { text-shadow: 0 0 32px var(--glow); }
+
+.logo-mark {
+ @apply w-12 h-12 rounded-2xl flex items-center justify-center text-sm font-bold text-white shrink-0;
+ background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
+ box-shadow: 0 4px 28px var(--glow);
+}
+
+.header-aurora {
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(90deg, transparent, var(--aurora-1), transparent);
+ opacity: 0.6;
+ pointer-events: none;
+}
+
+.status-pill {
+ @apply text-xs font-mono px-3 py-1.5 rounded-full border inline-flex items-center gap-1.5;
+}
+.status-pill::before { content: '●'; font-size: 8px; }
+.status-pill.ok { color: var(--status-ok); border-color: var(--status-ok); background: var(--status-ok-bg); }
+.status-pill.warn { color: var(--status-warn); border-color: var(--status-warn); background: var(--status-warn-bg); }
+.status-pill.gpu { color: var(--accent-gpu); border-color: var(--accent-gpu); background: color-mix(in srgb, var(--accent-gpu) 10%, transparent); }
+
+.live-badge {
+ @apply text-xs font-mono px-2.5 py-1 rounded-full border animate-pulse;
+ color: var(--status-ok);
+ border-color: var(--status-ok);
+ background: var(--status-ok-bg);
+}
+
+/* Agent cards */
+.agent-roster-glow {
+ background: radial-gradient(ellipse at 50% 0%, var(--aurora-1), transparent 70%);
+}
+
+.agent-card {
+ @apply rounded-2xl p-[1px] transition-all duration-300 cursor-pointer;
+ background: var(--border);
+}
+.agent-card:hover,
+.agent-card.selected {
+ background: linear-gradient(135deg, var(--agent-color, var(--accent)), var(--accent-secondary));
+ box-shadow: 0 8px 32px color-mix(in srgb, var(--agent-color, var(--accent)) 25%, transparent);
+}
+.agent-card-inner {
+ background: var(--surface-strong);
+ min-height: 200px;
+}
+.agent-card.selected .agent-card-inner {
+ background: color-mix(in srgb, var(--agent-color, var(--accent)) 4%, var(--surface-strong));
+}
+
+.agent-avatar {
+ @apply relative w-11 h-11 rounded-xl flex items-center justify-center border-2;
+}
+.agent-status-dot {
+ @apply absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2;
+ border-color: var(--surface-strong);
+}
+.agent-status-dot.active { animation: pulse-dot 1.2s ease infinite; }
+
+.agent-state-pill {
+ @apply text-[10px] font-mono px-2 py-0.5 rounded-full bg-[var(--surface-elevated)];
+}
+.agent-state-pill.busy { background: color-mix(in srgb, currentColor 12%, transparent); }
+
+.cap-chip {
+ @apply text-[9px] font-mono px-1.5 py-0.5 rounded-md;
+ background: var(--surface-elevated);
+ color: var(--text-muted);
+ border: 1px solid var(--border);
+}
+
+.delegate-btn {
+ background: color-mix(in srgb, currentColor 8%, transparent);
+ border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
+ transition: all 0.15s ease;
+}
+.delegate-btn:hover {
+ background: color-mix(in srgb, currentColor 18%, transparent);
+}
+
+/* Ops floor */
+.ops-floor-stage {
+ background: var(--floor-bg);
+ border-color: var(--border);
+ overflow: hidden;
+}
+.ops-floor-scan {
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(180deg, transparent 0%, color-mix(in srgb, var(--accent) 4%, transparent) 50%, transparent 100%);
+ animation: scan 4s ease-in-out infinite;
+ pointer-events: none;
+}
+.zone-node {
+ background: var(--surface-strong);
+}
+
+.sprite-wrap.selected::after {
+ content: '';
+ position: absolute;
+ inset: -10px -6px;
+ border-radius: 50%;
+ border: 2px dashed var(--sprite-color, var(--accent));
+ opacity: 0.65;
+ animation: spin-slow 10s linear infinite;
+ pointer-events: none;
+}
+.sprite-wrap { position: relative; }
+.sprite-wrap.busy {
+ filter: drop-shadow(0 0 16px var(--sprite-color)) drop-shadow(0 4px 8px rgba(0,0,0,0.3));
+}
+
+.sprite-figure {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.sprite-figure-busy .sprite-svg {
+ filter: drop-shadow(0 0 8px var(--sprite-color, var(--accent)));
+}
+.sprite-ring-outer {
+ position: absolute;
+ inset: -4px 2px 16px 2px;
+ border-radius: 50%;
+ border: 1.5px solid;
+ pointer-events: none;
+}
+.sprite-holo-shimmer {
+ position: absolute;
+ inset: 0;
+ border-radius: 40%;
+ pointer-events: none;
+ animation: holo-shimmer 3s ease-in-out infinite;
+ opacity: 0.7;
+}
+.sprite-nameplate {
+ @apply flex items-center gap-1.5 mt-1.5 px-2.5 py-0.5 rounded-full border;
+ background: color-mix(in srgb, var(--surface-strong) 85%, transparent);
+ backdrop-filter: blur(6px);
+}
+.sprite-nameplate-dot {
+ @apply w-1.5 h-1.5 rounded-full shrink-0;
+}
+.sprite-nameplate-text {
+ @apply text-[10px] font-mono font-bold tracking-wide;
+}
+.sprite-platform-pulse {
+ animation: platform-pulse 2s ease-in-out infinite;
+}
+.sprite-core-pulse {
+ animation: pulse-dot 1.5s ease infinite;
+}
+.sprite-svg { overflow: visible; }
+
+.sprite-ring {
+ position: absolute;
+ inset: -6px;
+ border-radius: 50%;
+ border: 2px solid;
+ animation: pulse-ring 1.5s ease infinite;
+}
+
+/* Activity feed */
+.activity-feed { scrollbar-width: thin; }
+.activity-item { @apply flex gap-3; }
+.activity-rail { @apply flex flex-col items-center w-4 shrink-0; }
+.activity-dot { @apply w-2.5 h-2.5 rounded-full shrink-0 mt-1.5; }
+.activity-line { @apply w-px flex-1 bg-[var(--border)] min-h-[1rem]; }
+.activity-agent-badge {
+ @apply text-[10px] font-mono px-2 py-0.5 rounded-full border;
+ background: var(--surface-elevated);
+}
+
+.empty-state {
+ @apply text-sm text-[var(--text-muted)] text-center py-8;
+}
+
+/* Chat */
+.chat-message { @apply flex gap-2 items-end; }
+.chat-message.user { @apply flex-row-reverse; }
+.chat-avatar {
+ @apply w-8 h-8 rounded-xl flex items-center justify-center text-sm shrink-0 border;
+}
+.chat-bubble { @apply rounded-2xl px-3 py-2 max-w-[90%]; }
+.chat-meta {
+ @apply text-[10px] font-mono text-[var(--text-faint)] flex gap-2 mb-1;
+}
+.chat-text { @apply text-sm leading-relaxed; }
+.chat-bubble-user {
+ background: color-mix(in srgb, var(--accent) 14%, var(--surface-elevated));
+ border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
+}
+.chat-bubble-agent {
+ background: var(--surface-elevated);
+ border: 1px solid var(--border);
+}
+
+.thinking-pulse { animation: pulse-dot 1s ease infinite; }
+.typing-indicator { @apply flex gap-1 py-1; }
+.typing-indicator span {
+ @apply w-1.5 h-1.5 rounded-full bg-[var(--text-faint)];
+ animation: typing 1.2s ease infinite;
+}
+.typing-indicator span:nth-child(2) { animation-delay: 0.15s; }
+.typing-indicator span:nth-child(3) { animation-delay: 0.3s; }
+
+/* Command dock */
+.command-dock { border-color: color-mix(in srgb, var(--accent) 20%, var(--border)); }
+.command-input-wrap {
+ background: var(--surface-elevated);
+ border: 1px solid var(--border);
+ transition: border-color 0.2s, box-shadow 0.2s;
+}
+.command-input-wrap:focus-within {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--glow);
+}
+
+.quick-prompt-chip {
+ @apply text-xs font-mono px-3 py-1.5 rounded-full border transition-all disabled:opacity-40;
+ background: var(--surface-elevated);
+ border-color: var(--border);
+ color: var(--text-muted);
+}
+.quick-prompt-chip:hover:not(:disabled) {
+ border-color: var(--accent);
+ color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 8%, var(--surface-elevated));
+}
+
+/* Shared */
+.status-card {
+ background: var(--surface-strong);
+ border: 1px solid var(--border);
+ transition: transform 0.15s ease, box-shadow 0.15s ease;
+}
+.status-card:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
+
+.gpu-card { background: var(--surface-elevated); transition: border-color 0.2s ease; }
+.gpu-card:hover { border-color: var(--accent-gpu); }
+
+.btn-primary {
+ @apply px-5 py-2.5 rounded-xl font-display text-sm font-semibold text-white transition-all hover:brightness-110 disabled:opacity-40;
+ background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
+ box-shadow: 0 4px 24px var(--glow);
+}
+.btn-secondary {
+ @apply px-3 py-1.5 rounded-lg font-mono border transition-all;
+ background: var(--surface-elevated);
+ border-color: var(--border);
+ color: var(--accent);
+}
+.btn-secondary:hover { border-color: var(--accent); box-shadow: 0 0 16px var(--glow); }
+
+.tab-btn {
+ @apply px-4 py-2 rounded-xl text-sm font-mono border transition-all inline-flex items-center gap-2;
+ border-color: var(--border);
+ color: var(--text-muted);
+ background: var(--surface);
+}
+.tab-btn.active {
+ border-color: var(--accent);
+ color: var(--accent);
+ background: var(--surface-strong);
+ box-shadow: 0 0 24px var(--glow);
+ font-weight: 600;
+}
+.tab-badge {
+ @apply text-[10px] px-1.5 py-0.5 rounded-full font-bold;
+ background: var(--accent-secondary);
+ color: white;
+}
+
+.theme-toggle {
+ @apply flex items-center gap-2 px-2 py-1 rounded-xl border transition-all;
+ border-color: var(--border);
+ background: var(--surface-elevated);
+}
+.theme-toggle:hover { border-color: var(--accent); }
+.theme-toggle-track { @apply relative w-11 h-6 rounded-full transition-colors; background: var(--surface-muted); }
+.theme-toggle-track.is-dark { background: linear-gradient(90deg, #1e293b, #312e81); }
+.theme-toggle-thumb {
+ @apply absolute top-0.5 left-0.5 w-5 h-5 rounded-full flex items-center justify-center text-xs transition-transform;
+ background: var(--surface-strong);
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
+}
+.theme-toggle-track.is-dark .theme-toggle-thumb { transform: translateX(1.25rem); }
+
+.prompt-input {
+ @apply flex-1 rounded-xl px-4 py-2.5 outline-none font-mono text-sm transition;
+ background: var(--surface-elevated);
+ border: 1px solid var(--border);
+ color: var(--text);
+}
+.prompt-input::placeholder { color: var(--text-faint); }
+.prompt-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--glow); }
+
+.agent-sprite .sprite-body { fill: var(--surface-strong); }
+.agent-sprite .sprite-limb { fill: var(--surface-elevated); }
+.agent-sprite .sprite-head { fill: var(--surface-strong); }
+
+/* Agent terminals */
+.agent-terminal {
+ @apply rounded-xl border overflow-hidden flex flex-col cursor-pointer transition-all;
+ border-color: var(--border);
+ background: #0a0e14;
+ min-height: 200px;
+ max-height: 240px;
+}
+.dark .agent-terminal { background: #060a10; }
+.light .agent-terminal { background: #0f172a; }
+
+.agent-terminal.active {
+ border-color: color-mix(in srgb, var(--term-accent, var(--accent)) 55%, transparent);
+ box-shadow: 0 0 24px color-mix(in srgb, var(--term-accent, var(--accent)) 15%, transparent);
+}
+.agent-terminal.expanded {
+ max-height: 480px;
+ min-height: 400px;
+}
+.agent-terminal-header {
+ @apply flex items-center justify-between gap-2 px-3 py-2 border-b;
+ border-color: rgba(255,255,255,0.06);
+ background: rgba(0,0,0,0.25);
+}
+.agent-terminal-body {
+ @apply flex-1 overflow-y-auto p-2 font-mono text-[11px] leading-relaxed;
+ scrollbar-width: thin;
+}
+.term-line {
+ @apply flex gap-2 py-0.5;
+ word-break: break-word;
+}
+.term-ts {
+ @apply shrink-0 text-[10px] opacity-50 w-[4.5rem];
+ color: #64748b;
+}
+.term-phase {
+ @apply shrink-0 text-[9px] uppercase w-10 opacity-40 hidden sm:inline;
+}
+.term-text { flex: 1; }
+.term-info .term-text { color: #94a3b8; }
+.term-ok .term-text { color: #4ade80; }
+.term-warn .term-text { color: #fbbf24; }
+.term-err .term-text { color: #f87171; }
+.term-cmd .term-text { color: #67e8f9; }
+.term-llm .term-text { color: #c084fc; }
+.term-live-badge {
+ @apply text-[9px] font-mono px-1.5 py-0.5 rounded-full animate-pulse;
+ color: var(--term-accent, var(--accent));
+ border: 1px solid color-mix(in srgb, var(--term-accent, var(--accent)) 40%, transparent);
+}
+.term-cursor { animation: blink 1s step-end infinite; color: var(--term-accent, var(--accent)); }
+.agent-terminal-focus { @apply flex flex-col gap-3; }
+.agent-terminal-tabs { @apply flex flex-wrap gap-2; }
+.agent-terminal-tab {
+ @apply text-xs font-mono px-3 py-1.5 rounded-lg border transition-all;
+ border-color: var(--border);
+ color: var(--text-muted);
+ background: var(--surface-elevated);
+}
+.agent-terminal-tab.active { font-weight: 600; background: var(--surface-strong); }
+
+@keyframes blink {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0; }
+}
+
+@keyframes pulse-dot {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.6; transform: scale(1.15); }
+}
+@keyframes pulse-ring {
+ 0%, 100% { opacity: 0.8; transform: scale(1); }
+ 50% { opacity: 0.4; transform: scale(1.08); }
+}
+@keyframes scan {
+ 0%, 100% { transform: translateY(-100%); opacity: 0; }
+ 50% { opacity: 1; }
+ 100% { transform: translateY(100%); }
+}
+@keyframes spin-slow { to { transform: rotate(360deg); } }
+@keyframes holo-shimmer {
+ 0%, 100% { opacity: 0.4; transform: translateX(-2px); }
+ 50% { opacity: 0.85; transform: translateX(2px); }
+}
+@keyframes platform-pulse {
+ 0%, 100% { opacity: 0.35; transform: scaleX(1); }
+ 50% { opacity: 0.7; transform: scaleX(1.08); }
+}
+@keyframes typing {
+ 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
+ 30% { transform: translateY(-4px); opacity: 1; }
+}
+
+/* Live cluster map */
+.live-cluster-map { isolation: isolate; }
+.cluster-ambient {
+ position: absolute;
+ inset: 0;
+ background: radial-gradient(ellipse 70% 50% at 50% 30%, var(--aurora-1), transparent 60%);
+ pointer-events: none;
+}
+.cluster-stage-grid {
+ position: absolute;
+ inset: 0;
+ background-image: linear-gradient(var(--grid-color) 1px, transparent 1px),
+ linear-gradient(90deg, var(--grid-color) 1px, transparent 1px);
+ background-size: 24px 24px;
+ opacity: 0.5;
+ border-radius: inherit;
+}
+.cluster-agent-stage { overflow: hidden; }
+
+.zone-tower {
+ @apply rounded-xl px-2.5 py-2 text-center min-w-[84px] border-2;
+ background: color-mix(in srgb, var(--surface-strong) 90%, transparent);
+ backdrop-filter: blur(8px);
+}
+.zone-tower-live { animation: tower-breathe 3s ease-in-out infinite; }
+.zone-tower-active { transform: scale(1.05); }
+.zone-tower-label { @apply text-[8px] font-mono font-bold tracking-wider; }
+.zone-tower-stats { @apply text-[10px] font-mono font-semibold mt-0.5 flex items-center justify-center gap-1; }
+.zone-tower-extra { @apply text-[8px] font-mono opacity-70 mt-0.5; }
+.zone-level-dot { @apply w-1.5 h-1.5 rounded-full; }
+.zone-level-dot.level-ok { background: var(--status-ok); box-shadow: 0 0 6px var(--status-ok); }
+.zone-level-dot.level-warn { background: var(--status-warn); }
+.zone-level-dot.level-down { background: var(--status-down); }
+
+.zone-app-orbit {
+ @apply flex flex-col gap-0.5 mt-1 max-w-[90px];
+}
+.zone-app-chip {
+ @apply flex items-center gap-1 text-[8px] font-mono px-1.5 py-0.5 rounded-md border;
+ background: var(--surface-strong);
+}
+.zone-app-chip.state-running { opacity: 1; }
+.zone-app-chip.state-down, .zone-app-chip.state-created { opacity: 0.45; filter: grayscale(0.5); }
+
+.command-desk {
+ @apply relative px-4 py-2 rounded-xl text-[9px] font-mono font-bold tracking-widest border-2;
+ border-color: var(--accent);
+ color: var(--accent);
+ background: var(--surface-strong);
+}
+.command-desk-ring {
+ @apply absolute inset-0 rounded-xl border border-[var(--accent)] opacity-30 animate-ping;
+ pointer-events: none;
+}
+
+.gpu-beacon {
+ @apply rounded-xl border px-3 py-2 text-left min-w-[140px];
+ border-color: var(--accent-gpu);
+ background: color-mix(in srgb, var(--accent-gpu) 8%, var(--surface-strong));
+}
+.gpu-beacon-title { @apply text-[9px] font-mono font-bold text-[var(--accent-gpu)]; }
+.gpu-beacon-model { @apply text-xs font-semibold text-[var(--text)] mt-0.5; }
+.gpu-beacon-meta { @apply text-[9px] font-mono text-[var(--text-muted)]; }
+.gpu-beacon-bar { @apply h-1 rounded-full bg-[var(--surface-muted)] mt-2 overflow-hidden; }
+.gpu-beacon-fill { @apply h-full rounded-full bg-[var(--accent-gpu)]; }
+
+.workload-ticker-wrap {
+ @apply flex items-center gap-3 overflow-hidden rounded-xl border px-3 py-2;
+ border-color: var(--border);
+ background: var(--surface-elevated);
+}
+.workload-ticker-label {
+ @apply text-[9px] font-mono font-bold tracking-widest shrink-0 text-[var(--accent)];
+}
+.workload-ticker-track { @apply flex-1 overflow-hidden; }
+.workload-ticker-inner { @apply flex gap-2 whitespace-nowrap; }
+.ticker-chip {
+ @apply inline-flex items-center gap-1 text-[10px] font-mono px-2 py-0.5 rounded-full border;
+ background: var(--surface-strong);
+}
+.ticker-chip.state-running { opacity: 1; }
+.ticker-chip.state-down, .ticker-chip.state-created { opacity: 0.4; }
+
+.live-domain-card {
+ @apply rounded-xl p-4 border-2 transition-all;
+ background: var(--surface-strong);
+}
+.domain-pulse-dot { @apply w-2.5 h-2.5 rounded-full shrink-0; }
+.domain-pulse-dot.level-ok { background: var(--status-ok); }
+.domain-pulse-dot.level-warn { background: var(--status-warn); }
+.domain-pulse-dot.level-down { background: var(--status-down); }
+.domain-progress-bar { @apply h-1.5 rounded-full bg-[var(--surface-muted)] overflow-hidden; }
+.domain-progress-fill { @apply h-full rounded-full; }
+.domain-app-grid {
+ @apply grid grid-cols-3 gap-1;
+}
+.domain-app-tile {
+ @apply flex flex-col items-center text-[8px] font-mono p-1 rounded-md border border-[var(--border)];
+ background: var(--surface-elevated);
+}
+.domain-app-tile.state-down { opacity: 0.35; }
+.gpu-mini-tile {
+ @apply text-[8px] font-mono px-1.5 py-0.5 rounded border border-[var(--accent-gpu)] text-[var(--accent-gpu)];
+ background: color-mix(in srgb, var(--accent-gpu) 10%, transparent);
+}
+
+.ambient-bg { pointer-events: none; }
+.ambient-orb {
+ position: absolute;
+ width: 120px;
+ height: 120px;
+ border-radius: 50%;
+ background: radial-gradient(circle, var(--aurora-1), transparent 70%);
+ animation: orb-float linear infinite;
+ opacity: 0.35;
+}
+
+@keyframes tower-breathe {
+ 0%, 100% { filter: brightness(1); }
+ 50% { filter: brightness(1.15); }
+}
+@keyframes orb-float {
+ 0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.2; }
+ 33% { transform: translate(20px, -30px) scale(1.1); opacity: 0.4; }
+ 66% { transform: translate(-15px, 20px) scale(0.9); opacity: 0.25; }
+}
+@keyframes ticker-scroll {
+ from { transform: translateX(0); }
+ to { transform: translateX(-50%); }
+}
diff --git a/src/lib/appIcons.ts b/src/lib/appIcons.ts
new file mode 100644
index 0000000..68b54a2
--- /dev/null
+++ b/src/lib/appIcons.ts
@@ -0,0 +1,41 @@
+const ICON_MAP: [RegExp, string][] = [
+ [/trino/i, '🔷'],
+ [/spark/i, '⚡'],
+ [/kafka/i, '📨'],
+ [/connect/i, '🔗'],
+ [/postgres/i, '🐘'],
+ [/mysql/i, '🐬'],
+ [/mongo/i, '🍃'],
+ [/cassandra/i, '💿'],
+ [/neo4j/i, '🔴'],
+ [/airflow/i, '🌀'],
+ [/superset/i, '📊'],
+ [/forgejo|gitea/i, '🦊'],
+ [/homepage/i, '🏠'],
+ [/dockhand/i, '🐳'],
+ [/redis/i, '⚙️'],
+ [/nginx|caddy/i, '🌐'],
+ [/hdfs|namenode|datanode/i, '🌲'],
+ [/gpu|vllm|nvidia/i, '🎮'],
+ [/lam-|ldap/i, '👤'],
+ [/cadvisor/i, '📈'],
+]
+
+export function appIcon(name: string, image?: string): string {
+ const hay = `${name} ${image || ''}`
+ for (const [re, icon] of ICON_MAP) {
+ if (re.test(hay)) return icon
+ }
+ return '📦'
+}
+
+export function shortName(name: string, max = 14): string {
+ return name.length > max ? `${name.slice(0, max - 1)}…` : name
+}
+
+export const LEVEL_COLOR: Record = {
+ ok: 'var(--status-ok)',
+ warn: 'var(--status-warn)',
+ down: 'var(--status-down)',
+ unknown: 'var(--text-faint)',
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..227e4e1
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,13 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+import { ThemeProvider } from './context/ThemeContext'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+
+ ,
+)
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..cd6bb0f
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,134 @@
+export type AgentStats = {
+ tasks: number
+ last_active: string | null
+ alerts: number
+}
+
+export type Agent = {
+ id: string
+ name: string
+ color: string
+ zone: string
+ role: string
+ icon?: string
+ motto?: string
+ capabilities?: string[]
+ suggested_prompts?: string[]
+ stats?: AgentStats
+}
+
+export type Zone = { id: string; label: string; x: number; color: string }
+
+export type FeedEntry = {
+ id: string
+ ts: string
+ agent_id: string
+ message: string
+ level: string
+}
+
+export type DomainStatus = {
+ level: 'ok' | 'warn' | 'down' | 'unknown'
+ label: string
+}
+
+export type StatusData = {
+ ts: string
+ domains: Record
+ gpu?: GpuStatus
+}
+
+export type GpuDevice = {
+ index: number
+ name: string
+ util_gpu: number
+ memory_used_mib: number
+ memory_total_mib: number
+ temperature_c: number
+ power_w: number
+}
+
+export type GpuStatus = {
+ ok: boolean
+ host: string
+ ui_url: string
+ inference_active?: boolean
+ active_model?: string | null
+ vllm_url?: string | null
+ gpu_count?: number
+ gpus?: GpuDevice[]
+ error?: string
+}
+
+export type AgentState = 'idle' | 'walk' | 'fetch' | 'return'
+
+export type AgentAnim = {
+ agentId: string
+ state: AgentState
+ zone?: string
+}
+
+export type Approval = {
+ id: string
+ ts: string
+ agent_id: string
+ action: string
+ reason: string
+ status: string
+}
+
+export type TerminalLine = {
+ id: string
+ ts: string
+ agent_id: string
+ level: 'info' | 'ok' | 'warn' | 'err' | 'cmd' | 'llm'
+ phase: string
+ text: string
+ prompt_id?: string
+}
+
+export type WorkloadApp = {
+ name: string
+ state: string
+ image: string
+ ports: string[]
+}
+
+export type WorkloadZone = {
+ id: string
+ label: string
+ x: number
+ color: string
+ level: 'ok' | 'warn' | 'down' | 'unknown'
+ running: number
+ total: number
+ apps: WorkloadApp[]
+ trino_ok?: boolean
+ hdfs_used_gb?: number
+ hdfs_total_gb?: number
+}
+
+export type WorkloadData = {
+ ts: string
+ zones: WorkloadZone[]
+ gpu: {
+ level: string
+ model?: string | null
+ inference_active?: boolean
+ gpu_count?: number
+ avg_util?: number
+ gpus?: GpuDevice[]
+ }
+ totals: {
+ apps_running: number
+ apps_total: number
+ connectors: number
+ }
+}
+
+export type ChatMessage = {
+ role: 'user' | 'agent'
+ text: string
+ agent?: string
+ ts?: string
+}
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 0000000..5c69503
--- /dev/null
+++ b/tailwind.config.js
@@ -0,0 +1,14 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
+ darkMode: 'class',
+ theme: {
+ extend: {
+ fontFamily: {
+ display: ['"Space Grotesk"', 'system-ui', 'sans-serif'],
+ mono: ['"JetBrains Mono"', 'monospace'],
+ },
+ },
+ },
+ plugins: [],
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..42e0521
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true
+ },
+ "include": ["src"]
+}
diff --git a/ui/index.html b/ui/index.html
index 7c68b66..81fa1ec 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -1,14 +1,14 @@
-
+
- ATC Command Center
+ ATC Data & AI Command Center
-
+
-
+
diff --git a/ui/nginx.conf b/ui/nginx.conf
index 3d24bfe..3fc9183 100644
--- a/ui/nginx.conf
+++ b/ui/nginx.conf
@@ -11,6 +11,17 @@ server {
proxy_set_header Host $host;
}
+ location /assets/ {
+ add_header Cache-Control "public, max-age=31536000, immutable";
+ try_files $uri =404;
+ }
+
+ location = /index.html {
+ add_header Cache-Control "no-cache, no-store, must-revalidate";
+ add_header Pragma "no-cache";
+ add_header Expires "0";
+ }
+
location / {
try_files $uri $uri/ /index.html;
}
diff --git a/ui/package.json b/ui/package.json
index 0aa1dcf..75298d3 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -1,7 +1,7 @@
{
"name": "atc-command-center",
"private": true,
- "version": "0.1.0",
+ "version": "2.0.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -9,9 +9,13 @@
"preview": "vite preview"
},
"dependencies": {
- "framer-motion": "^11.15.0",
+ "@tanstack/react-query": "^5.62.8",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^0.469.0",
"react": "^18.3.1",
- "react-dom": "^18.3.1"
+ "react-dom": "^18.3.1",
+ "tailwind-merge": "^2.6.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index 3c1347a..abbd7ae 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -1,218 +1,178 @@
-import { useCallback, useEffect, useState } from 'react'
-import { OpsFloor } from './components/OpsFloor'
-import { PromptBar } from './components/PromptBar'
-import type { Agent, AgentAnim, Approval, FeedEntry, StatusData, Zone } from './types'
-
-const TABS = ['Overview', 'Agents', 'Feed', 'Approvals', 'Audit'] as const
-type Tab = (typeof TABS)[number]
-
-const LEVEL_COLOR = { ok: '#22aa44', warn: '#cc7700', down: '#dd3355', unknown: '#8b9cb3' }
-const LEVEL_BG = { ok: '#e8f8ec', warn: '#fff6e6', down: '#ffeef2', unknown: '#f0f3f8' }
-
-function wsUrl() {
- const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
- const host = window.location.host
- return `${proto}://${host}/api/ws/ops`
-}
+import { useRef, useState } from 'react'
+import { useClock } from './hooks/useClock'
+import { useCommandCenter } from './hooks/useCommandCenter'
+import { useLiveMetrics } from './hooks/useLiveMetrics'
+import { SideNav } from './components/layout/SideNav'
+import { TopBar } from './components/layout/TopBar'
+import { AgentFleet } from './components/features/AgentFleet'
+import { ApprovalInbox } from './components/features/ApprovalInbox'
+import { ChatDrawer } from './components/features/ChatDrawer'
+import { GpuMonitor } from './components/features/GpuMonitor'
+import { InfraQuickAccess } from './components/features/InfraQuickAccess'
+import { InspectorPanel } from './components/features/InspectorPanel'
+import { PlatformTopology } from './components/features/PlatformTopology'
+import { PresentationView } from './components/features/PresentationView'
+import { DataQualityView } from './components/features/DataQualityView'
+import { KnowledgeChatView } from './components/features/KnowledgeChatView'
+import { StorageView } from './components/features/StorageView'
+import { TerminalDock } from './components/features/TerminalDock'
+import { resolveInfraNode } from './lib/infraCatalog'
+import { cn } from './lib/utils'
export default function App() {
- const [tab, setTab] = useState('Overview')
- const [agents, setAgents] = useState([])
- const [zones, setZones] = useState([])
- const [status, setStatus] = useState(null)
- const [feed, setFeed] = useState([])
- const [approvals, setApprovals] = useState([])
- const [chat, setChat] = useState<{ role: 'user' | 'agent'; text: string; agent?: string }[]>([])
- const [anims, setAnims] = useState>({})
- const [busy, setBusy] = useState(false)
+ const clock = useClock()
+ const cc = useCommandCenter()
+ const [gpuChatActive, setGpuChatActive] = useState(false)
+ const gpuBoost = gpuChatActive || cc.mainView === 'knowledge'
+ const { agentLoads, gpuLive } = useLiveMetrics(cc.agents, cc.gpu, cc.anims, gpuBoost)
+ const mainScrollRef = useRef(null)
- const load = useCallback(async () => {
- const [a, s, f, ap] = await Promise.all([
- fetch('/api/agents').then((r) => r.json()),
- fetch('/api/status').then((r) => r.json()),
- fetch('/api/feed').then((r) => r.json()),
- fetch('/api/approvals').then((r) => r.json()),
- ])
- setAgents(a.agents || [])
- setZones(a.zones || [])
- setStatus(s)
- setFeed(f.entries || [])
- setApprovals(ap.approvals || [])
- }, [])
+ const isPlatform = cc.mainView === 'platform'
- useEffect(() => {
- load()
- const ws = new WebSocket(wsUrl())
- ws.onmessage = (ev) => {
- const msg = JSON.parse(ev.data)
- if (msg.type === 'status') setStatus(msg.data)
- if (msg.type === 'feed') setFeed((prev) => [msg.entry, ...prev].slice(0, 100))
- if (msg.type === 'agent_dispatch') {
- setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'walk', zone: msg.zone } }))
- }
- if (msg.type === 'agent_fetch') {
- setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'fetch', zone: msg.zone } }))
- }
- if (msg.type === 'agent_return') {
- setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'return', zone: msg.zone } }))
- setTimeout(() => {
- setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'idle' } }))
- }, 1200)
- }
- if (msg.type === 'prompt_result') {
- setChat((c) => [...c, { role: 'agent', text: msg.answer, agent: msg.agent_id }])
- setBusy(false)
- }
- }
- const iv = setInterval(load, 30000)
- return () => { ws.close(); clearInterval(iv) }
- }, [load])
-
- const sendPrompt = async (message: string) => {
- setBusy(true)
- setChat((c) => [...c, { role: 'user', text: message }])
- await fetch('/api/prompt', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ message }),
- })
+ const openApprovals = () => {
+ cc.setMainView('approvals')
+ mainScrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
}
- const decide = async (id: string, approved: boolean) => {
- await fetch(`/api/approvals/${id}/decide`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ approved }),
- })
- load()
- }
-
- const allOk = status && Object.values(status.domains).every((d) => d.level === 'ok')
+ const terminalSubject = cc.terminalSubjectId
+ const terminalLabel = (() => {
+ if (cc.selectedAgent) return cc.selectedAgent.name.split(' ·')[0]
+ const infra = resolveInfraNode(terminalSubject)
+ if (infra) return infra.label
+ if (cc.selectedNode) return cc.selectedNode.label
+ return 'Lab'
+ })()
return (
-
-
+
+
-
+
+
-
- {TABS.map((t) => (
- setTab(t)}
- className={`px-4 py-2 rounded-xl text-sm font-mono border transition-all ${
- tab === t
- ? 'bg-white border-neon-cyan text-neon-cyan shadow-neon-cyan font-semibold'
- : 'bg-white/60 border-slate-200 text-ink-muted hover:bg-white hover:border-neon-cyan/40'
- }`}
- >
- {t}
-
- ))}
-
-
-
-
- {tab === 'Overview' && status && (
-
- {Object.entries(status.domains).map(([key, d]) => (
-
-
{key}
-
{d.label}
-
-
-
{d.level}
+
+
+
+ {isPlatform && (
+ <>
+
-
- ))}
-
- )}
- {tab === 'Agents' && (
-
- {agents.map((a) => (
-
-
{a.name}
-
{a.role}
-
zone: {a.zone}
-
- ))}
-
- )}
- {tab === 'Feed' && (
-
- {feed.map((e) => (
-
- {e.ts ? new Date(e.ts).toLocaleTimeString() : ''}
- a.id === e.agent_id)?.color || '#888' }}>{e.agent_id}
- {e.message}
-
- ))}
-
- )}
- {tab === 'Approvals' && (
-
- {approvals.length === 0 &&
Geen pending approvals.
}
- {approvals.map((a) => (
-
-
{a.action}
-
{a.reason}
-
- decide(a.id, true)} className="px-3 py-1.5 rounded-lg bg-green-50 border border-neon-green/40 text-neon-green text-xs font-semibold hover:bg-green-100">Approve
- decide(a.id, false)} className="px-3 py-1.5 rounded-lg bg-red-50 border border-red-300 text-red-600 text-xs font-semibold hover:bg-red-100">Deny
-
-
- ))}
-
- )}
- {tab === 'Audit' && (
-
Audit log — approvals en agent acties (v1 via Feed tab).
- )}
-
+
+ >
+ )}
-
- CHAT
-
- {chat.length === 0 &&
Stel een vraag — je agent loopt data ophalen.
}
- {chat.map((m, i) => (
-
-
{m.role === 'user' ? '▶ jij' : `◀ ${m.agent}`}
-
{m.text}
+
+ {cc.mainView === 'platform' ? (
+
+ ) : cc.mainView === 'presentation' ? (
+
+ ) : cc.mainView === 'dataquality' ? (
+
+ ) : cc.mainView === 'knowledge' ? (
+
+ ) : cc.mainView === 'storage' ? (
+
+ ) : (
+
+ )}
- ))}
+
+
+ {isPlatform && (
+
+
+ cc.setTerminalExpanded(!cc.terminalExpanded)}
+ />
+
+ )}
-
+
-
+
cc.setChatExpanded(!cc.chatExpanded)}
+ chat={cc.chat}
+ feed={cc.feed}
+ agents={cc.agents}
+ approvals={cc.approvals}
+ selectedAgent={cc.selectedAgent}
+ promptBusy={cc.promptBusy}
+ approvalHighlight={cc.approvalHighlight}
+ filterAgentId={cc.selectedAgentId}
+ onSendPrompt={cc.sendPrompt}
+ onDecide={cc.decide}
+ onDismissHighlight={() => cc.setApprovalHighlight(false)}
+ />
)
}
diff --git a/ui/src/components/AgentSprite.tsx b/ui/src/components/AgentSprite.tsx
deleted file mode 100644
index eca0191..0000000
--- a/ui/src/components/AgentSprite.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import { motion } from 'framer-motion'
-
-type Props = {
- color: string
- state: 'idle' | 'walk' | 'fetch' | 'return'
- label: string
-}
-
-export function AgentSprite({ color, state, label }: Props) {
- const bob = state === 'idle' ? { y: [0, -3, 0] } : state === 'walk' || state === 'return' ? { y: [0, -6, 0] } : { y: 0 }
- const scale = state === 'fetch' ? 0.92 : 1
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {state === 'fetch' && (
-
- )}
-
- {label}
-
- )
-}
diff --git a/ui/src/components/OpsFloor.tsx b/ui/src/components/OpsFloor.tsx
deleted file mode 100644
index 8e16917..0000000
--- a/ui/src/components/OpsFloor.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import { motion } from 'framer-motion'
-import { AgentSprite } from './AgentSprite'
-import type { Agent, AgentAnim, Zone } from '../types'
-
-const ZONE_X: Record
= {
- docker: 8,
- db: 28,
- lakehouse: 50,
- hadoop: 72,
- etl: 92,
-}
-
-const DESK_X = 50
-
-type Props = {
- agents: Agent[]
- zones: Zone[]
- animations: Record
-}
-
-export function OpsFloor({ agents, zones, animations }: Props) {
- return (
-
-
-
OPS FLOOR
- ● LIVE
-
-
-
- {zones.map((z) => (
-
- ))}
-
- {zones.map((z) => (
-
- ))}
-
-
-
-
- {agents.map((agent, i) => {
- const anim = animations[agent.id] || { agentId: agent.id, state: 'idle' as const }
- const targetX = anim.state === 'idle' ? 12 + i * 17 : ZONE_X[anim.zone || agent.zone] ?? DESK_X
- const y = anim.state === 'fetch' ? 8 : anim.state === 'idle' ? 0 : 4
-
- return (
-
-
-
- )
- })}
-
-
- )
-}
diff --git a/ui/src/components/PromptBar.tsx b/ui/src/components/PromptBar.tsx
deleted file mode 100644
index d9b6762..0000000
--- a/ui/src/components/PromptBar.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { FormEvent, useState } from 'react'
-
-type Props = {
- onSubmit: (message: string) => void
- busy: boolean
-}
-
-export function PromptBar({ onSubmit, busy }: Props) {
- const [text, setText] = useState('')
-
- const handle = (e: FormEvent) => {
- e.preventDefault()
- if (!text.trim() || busy) return
- onSubmit(text.trim())
- setText('')
- }
-
- return (
-
- )
-}
diff --git a/ui/src/components/features/ActivityStream.tsx b/ui/src/components/features/ActivityStream.tsx
new file mode 100644
index 0000000..ed54630
--- /dev/null
+++ b/ui/src/components/features/ActivityStream.tsx
@@ -0,0 +1,61 @@
+import type { Agent, FeedEntry } from '../../types'
+import { getAgentMeta } from '../../lib/agentMeta'
+import { cn } from '../../lib/utils'
+
+type Props = {
+ feed: FeedEntry[]
+ agents: Agent[]
+ filterAgentId?: string | null
+ opsOnly?: boolean
+}
+
+const LEVEL: Record = {
+ info: 'text-foreground-muted',
+ ok: 'text-success',
+ warn: 'text-warning',
+ err: 'text-danger',
+}
+
+function isOpsEvent(message: string): boolean {
+ const lower = message.toLowerCase()
+ if (message.includes(' answered:')) return false
+ if (message.startsWith('Prompt received:')) return false
+ if (lower.includes('completed a response')) return false
+ return true
+}
+
+export function ActivityStream({ feed, agents, filterAgentId, opsOnly }: Props) {
+ let items = filterAgentId ? feed.filter((e) => e.agent_id === filterAgentId) : feed
+ if (opsOnly) items = items.filter((e) => isOpsEvent(e.message))
+
+ return (
+
+ {items.length === 0 && (
+
+ {opsOnly ? 'Geen operationele events — antwords staan in Chat.' : 'No activity yet — agents are on standby.'}
+
+ )}
+ {items.map((e) => {
+ const ag = agents.find((a) => a.id === e.agent_id)
+ const meta = ag ? getAgentMeta(ag.id) : null
+ const Icon = meta?.icon
+ return (
+
+ {Icon && (
+
+
+
+ )}
+
+
+ {ag?.name.split(' ·')[0] || e.agent_id}
+ {new Date(e.ts).toLocaleTimeString('en-US', { hour12: false })}
+
+
{e.message}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/ui/src/components/features/AgentFleet.tsx b/ui/src/components/features/AgentFleet.tsx
new file mode 100644
index 0000000..92fd5df
--- /dev/null
+++ b/ui/src/components/features/AgentFleet.tsx
@@ -0,0 +1,121 @@
+import { ShieldCheck } from 'lucide-react'
+import type { Agent, AgentAnim } from '../../types'
+import type { AgentLoad } from '../../hooks/useLiveMetrics'
+import { agentTaskLabel, getAgentMeta } from '../../lib/agentMeta'
+import { cn } from '../../lib/utils'
+
+type Props = {
+ agents: Agent[]
+ animations: Record
+ selectedId: string | null
+ loads: Record
+ approvalCount: number
+ onSelect: (id: string) => void
+ onOpenApprovals: () => void
+}
+
+function AgentCard({
+ agent,
+ anim,
+ load,
+ selected,
+ onSelect,
+}: {
+ agent: Agent
+ anim?: AgentAnim
+ load?: AgentLoad
+ selected: boolean
+ onSelect: () => void
+}) {
+ const meta = getAgentMeta(agent.id)
+ const Icon = meta.icon
+ const busy = anim && anim.state !== 'idle'
+
+ return (
+
+
+
+
+
+
+
{agent.name.split(' ·')[0]}
+
{meta.domain}
+
+
+
+ {agent.role}
+ {agentTaskLabel(agent.id, anim)}
+
+
+ CPU {load?.cpu ?? 0}%
+ MEM {load?.mem ?? 0}%
+
+
+
+
+ )
+}
+
+export function AgentFleet({ agents, animations, selectedId, loads, approvalCount, onSelect, onOpenApprovals }: Props) {
+ const supervisors = agents.filter((a) => a.supervisor)
+ const operators = agents.filter((a) => !a.supervisor && a.id !== 'mcp-coordinator')
+ const mcp = agents.find((a) => a.id === 'mcp-coordinator')
+
+ return (
+
+
+
+
Agent Fleet
+
Klik agent → stel vraag in chat · elk agent bewaakt één domein
+
+
+ 0 ? 'border-warning/40 bg-warning/10 text-warning' : 'border-border text-foreground-muted hover:bg-surface-overlay',
+ )}
+ >
+
+ Approvals{approvalCount > 0 ? ` (${approvalCount})` : ''}
+
+ {agents.length} agents
+
+
+
+
+
Supervisors
+
+ {supervisors.map((a) => (
+
onSelect(a.id)} />
+ ))}
+
+
+ {mcp && (
+
+
MCP Hub
+
onSelect(mcp.id)} />
+
+ )}
+
+
Field Operators
+
+ {operators.map((a) => (
+
onSelect(a.id)} />
+ ))}
+
+
+
+
+ )
+}
diff --git a/ui/src/components/features/ApprovalCards.tsx b/ui/src/components/features/ApprovalCards.tsx
new file mode 100644
index 0000000..ac88f85
--- /dev/null
+++ b/ui/src/components/features/ApprovalCards.tsx
@@ -0,0 +1,98 @@
+import { useState } from 'react'
+import { Check, ShieldAlert, X } from 'lucide-react'
+import type { Agent, Approval } from '../../types'
+import { getAgentMeta } from '../../lib/agentMeta'
+import { Button } from '../ui/Button'
+import { cn } from '../../lib/utils'
+
+const ACTION_LABELS: Record = {
+ 'docker.restart': 'Container restart',
+ 'docker.update': 'Image update',
+ 'generic.mutate': 'Infrastructure change',
+}
+
+type Props = {
+ approvals: Approval[]
+ agents: Agent[]
+ highlighted: boolean
+ onDecide: (id: string, approved: boolean, decidedBy: string, note: string) => Promise
+ onDismissHighlight?: () => void
+}
+
+export function ApprovalCards({ approvals, agents, highlighted, onDecide, onDismissHighlight }: Props) {
+ const [busyId, setBusyId] = useState(null)
+ const [decider, setDecider] = useState<'mo-commander' | 'bart-commander'>('mo-commander')
+
+ if (!approvals.length) return null
+
+ const agentOf = (id: string) => agents.find((a) => a.id === id)
+
+ const handle = async (id: string, approved: boolean) => {
+ setBusyId(id)
+ try {
+ await onDecide(id, approved, decider, '')
+ } finally {
+ setBusyId(null)
+ }
+ }
+
+ return (
+
+
+
+
+ Pending approvals ({approvals.length})
+
+
+
+ As
+ setDecider(e.target.value as typeof decider)}
+ className="rounded border border-border bg-surface px-1 py-0.5 text-[10px] text-foreground-muted"
+ >
+ Mo
+ Bart
+
+
+ {highlighted && onDismissHighlight && (
+ Dismiss
+ )}
+
+
+
+ {approvals.map((a) => {
+ const ag = agentOf(a.agent_id)
+ const meta = ag ? getAgentMeta(ag.id) : null
+ const Icon = meta?.icon
+ return (
+
+
+ {Icon && (
+
+
+
+ )}
+
+
{ACTION_LABELS[a.action_type] || a.action_type}
+
{ag?.name || a.agent_id}
+
+
#{a.id.slice(0, 6)}
+
+
{a.action}
+ {a.target &&
Target: {a.target}
}
+
+ handle(a.id, true)}>
+ Approve
+
+ handle(a.id, false)}>
+ Deny
+
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/ui/src/components/features/ApprovalInbox.tsx b/ui/src/components/features/ApprovalInbox.tsx
new file mode 100644
index 0000000..f56cff0
--- /dev/null
+++ b/ui/src/components/features/ApprovalInbox.tsx
@@ -0,0 +1,122 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { Check, ShieldCheck, X } from 'lucide-react'
+import { fetchApprovalHistory } from '../../lib/api'
+import type { Agent, Approval } from '../../types'
+import { getAgentMeta } from '../../lib/agentMeta'
+import { Badge } from '../ui/Badge'
+import { Button } from '../ui/Button'
+import { cn } from '../../lib/utils'
+
+type Filter = 'pending' | 'approved' | 'denied' | 'all'
+
+type Props = {
+ agents: Agent[]
+ livePending: Approval[]
+ onDecide: (id: string, approved: boolean, decidedBy: string, note: string) => Promise
+}
+
+export function ApprovalInbox({ agents, livePending, onDecide }: Props) {
+ const [filter, setFilter] = useState('pending')
+ const [items, setItems] = useState([])
+ const [stats, setStats] = useState({ pending: 0, approved: 0, denied: 0, total: 0 })
+ const [selectedId, setSelectedId] = useState(null)
+ const [decider, setDecider] = useState<'mo-commander' | 'bart-commander'>('mo-commander')
+ const [note, setNote] = useState('')
+ const [busy, setBusy] = useState(false)
+
+ const load = useCallback(async () => {
+ const res = await fetchApprovalHistory(filter === 'all' ? 'all' : filter)
+ setItems(res.approvals)
+ if (res.stats) setStats(res.stats)
+ }, [filter])
+
+ useEffect(() => { load() }, [load, livePending])
+
+ const selected = useMemo(() => items.find((a) => a.id === selectedId) || items[0] || null, [items, selectedId])
+ const agentOf = (id: string) => agents.find((a) => a.id === id)
+
+ const handleDecide = async (approved: boolean) => {
+ if (!selected || selected.status !== 'pending') return
+ setBusy(true)
+ try {
+ await onDecide(selected.id, approved, decider, note)
+ setNote('')
+ await load()
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
+
+
+ {(['pending', 'approved', 'denied', 'all'] as Filter[]).map((f) => (
+ setFilter(f)} className={cn('rounded px-2 py-0.5 text-[10px] capitalize', filter === f ? 'bg-docker-light text-docker' : 'text-foreground-muted')}>
+ {f}
+
+ ))}
+
+
+
+
+ {!items.length &&
No {filter} requests.
}
+ {items.map((a) => {
+ const ag = agentOf(a.agent_id)
+ const meta = ag ? getAgentMeta(ag.id) : null
+ const Icon = meta?.icon
+ return (
+
setSelectedId(a.id)} className={cn('mb-1 flex w-full gap-2 rounded-lg border p-2 text-left', selected?.id === a.id ? 'border-docker/40 bg-docker-light' : 'border-transparent hover:bg-surface-overlay')}>
+ {Icon && }
+
+ {a.action.slice(0, 80)}
+ {ag?.name || a.agent_id}
+
+ {a.status}
+
+ )
+ })}
+
+
+ {selected && (
+
+
{selected.status}
+
+
Agent {agentOf(selected.agent_id)?.name}
+
Type {selected.action_type}
+
Action {selected.action}
+
Reason {selected.reason}
+
+ {selected.status === 'pending' && (
+
+
setDecider(e.target.value as typeof decider)} className="w-full rounded border border-border bg-surface px-2 py-1 text-[10px] text-foreground-muted">
+ Decide as Mo
+ Decide as Bart
+
+
setNote(e.target.value)} placeholder="Note (optional)" className="w-full rounded border border-border bg-surface px-2 py-1 text-[10px] text-foreground-muted" />
+
+ handleDecide(true)}> Approve
+ handleDecide(false)}> Deny
+
+
+ )}
+
+ )}
+
+
+ )
+}
diff --git a/ui/src/components/features/ArchitectureDiagram.tsx b/ui/src/components/features/ArchitectureDiagram.tsx
new file mode 100644
index 0000000..9d03168
--- /dev/null
+++ b/ui/src/components/features/ArchitectureDiagram.tsx
@@ -0,0 +1,212 @@
+import { useEffect, useState } from 'react'
+import { cn } from '../../lib/utils'
+
+type FlowNode = { id: string; label: string; sub?: string; color: string }
+type FlowEdge = { from: string; to: string; label?: string }
+
+const FLOWS: Record = {
+ 'full-stack': {
+ nodes: [
+ { id: 'user', label: 'User / Customer', sub: 'Browser', color: '#60a5fa' },
+ { id: 'caddy', label: 'Caddy :80', sub: 'Reverse proxy', color: '#38bdf8' },
+ { id: 'ui', label: 'Command Center', sub: 'React UI', color: '#818cf8' },
+ { id: 'api', label: 'Agents API', sub: 'FastAPI :3201', color: '#a78bfa' },
+ { id: 'dq', label: 'DQ API', sub: 'Maturity + Docling', color: '#f59e0b' },
+ { id: 'rag', label: 'RAG API', sub: 'LangChain', color: '#34d399' },
+ { id: 'chroma', label: 'ChromaDB', sub: 'Vectors (persistent)', color: '#22d3ee' },
+ { id: 'docling', label: 'Docling', sub: ':5001', color: '#fb923c' },
+ { id: 'llm', label: 'vLLM Llama 70B', sub: 'GPU Lab', color: '#4ade80' },
+ { id: 'lake', label: 'Lakehouse', sub: 'Kafka · Spark · Trino', color: '#6366f1' },
+ ],
+ edges: [
+ { from: 'user', to: 'caddy', label: 'HTTP' },
+ { from: 'caddy', to: 'ui' },
+ { from: 'ui', to: 'api' },
+ { from: 'ui', to: 'dq' },
+ { from: 'ui', to: 'rag' },
+ { from: 'dq', to: 'docling' },
+ { from: 'rag', to: 'docling' },
+ { from: 'rag', to: 'chroma' },
+ { from: 'rag', to: 'llm' },
+ { from: 'api', to: 'llm' },
+ { from: 'api', to: 'lake' },
+ ],
+ },
+ 'rag-flow': {
+ nodes: [
+ { id: 'upload', label: 'Upload PDF/CSV', sub: 'Once', color: '#60a5fa' },
+ { id: 'store', label: 'File Store', sub: '/data/uploads', color: '#64748b' },
+ { id: 'docling', label: 'Docling', sub: 'Parse + OCR', color: '#fb923c' },
+ { id: 'chunk', label: 'LangChain Splitter', sub: '800 char chunks', color: '#a78bfa' },
+ { id: 'embed', label: 'MiniLM Embeddings', sub: '384-d vectors', color: '#818cf8' },
+ { id: 'chroma', label: 'ChromaDB', sub: 'Persistent', color: '#22d3ee' },
+ { id: 'query', label: 'Your Question', sub: 'Any time', color: '#60a5fa' },
+ { id: 'retrieve', label: 'Similarity Search', sub: 'top-k chunks', color: '#34d399' },
+ { id: 'llm', label: 'Llama 70B', sub: 'Answer + sources', color: '#4ade80' },
+ ],
+ edges: [
+ { from: 'upload', to: 'store', label: 'save' },
+ { from: 'upload', to: 'docling' },
+ { from: 'docling', to: 'chunk' },
+ { from: 'chunk', to: 'embed' },
+ { from: 'embed', to: 'chroma', label: 'index' },
+ { from: 'query', to: 'retrieve' },
+ { from: 'retrieve', to: 'chroma' },
+ { from: 'retrieve', to: 'llm' },
+ ],
+ },
+ 'dq-flow': {
+ nodes: [
+ { id: 'data', label: 'Customer Data', sub: 'CSV · Excel · PDF', color: '#60a5fa' },
+ { id: 'docling', label: 'Docling', sub: 'Structure + images', color: '#fb923c' },
+ { id: 'pandas', label: 'Pandas Profiling', sub: 'Column stats', color: '#a78bfa' },
+ { id: 'ge', label: 'Great Expectations', sub: 'Expectation checks', color: '#34d399' },
+ { id: 'soda', label: 'Soda Core', sub: 'YAML checks', color: '#22d3ee' },
+ { id: 'maturity', label: '6 Dimensions', sub: 'Score 0–100', color: '#f59e0b' },
+ { id: 'report', label: 'HTML Report', sub: 'Roadmap + actions', color: '#818cf8' },
+ ],
+ edges: [
+ { from: 'data', to: 'docling' },
+ { from: 'data', to: 'pandas' },
+ { from: 'pandas', to: 'ge' },
+ { from: 'pandas', to: 'soda' },
+ { from: 'ge', to: 'maturity' },
+ { from: 'soda', to: 'maturity' },
+ { from: 'maturity', to: 'report' },
+ ],
+ },
+ 'lakehouse': {
+ nodes: [
+ { id: 'pg', label: 'PostgreSQL', color: '#60a5fa' },
+ { id: 'mysql', label: 'MySQL', color: '#60a5fa' },
+ { id: 'mongo', label: 'MongoDB', color: '#60a5fa' },
+ { id: 'debezium', label: 'Debezium CDC', color: '#f59e0b' },
+ { id: 'kafka', label: 'Kafka', color: '#fb923c' },
+ { id: 'spark', label: 'Spark', color: '#a78bfa' },
+ { id: 'iceberg', label: 'Iceberg', color: '#22d3ee' },
+ { id: 'trino', label: 'Trino', color: '#34d399' },
+ { id: 'bi', label: 'Superset BI', color: '#818cf8' },
+ ],
+ edges: [
+ { from: 'pg', to: 'debezium' },
+ { from: 'mysql', to: 'debezium' },
+ { from: 'mongo', to: 'debezium' },
+ { from: 'debezium', to: 'kafka' },
+ { from: 'kafka', to: 'spark' },
+ { from: 'spark', to: 'iceberg' },
+ { from: 'iceberg', to: 'trino' },
+ { from: 'trino', to: 'bi' },
+ ],
+ },
+}
+
+const POSITIONS: Record> = {
+ 'full-stack': {
+ user: { x: 50, y: 8 },
+ caddy: { x: 50, y: 22 },
+ ui: { x: 50, y: 38 },
+ api: { x: 18, y: 58 },
+ dq: { x: 50, y: 58 },
+ rag: { x: 82, y: 58 },
+ docling: { x: 50, y: 78 },
+ chroma: { x: 82, y: 78 },
+ llm: { x: 82, y: 92 },
+ lake: { x: 18, y: 92 },
+ },
+ 'rag-flow': {
+ upload: { x: 12, y: 20 },
+ store: { x: 12, y: 45 },
+ docling: { x: 35, y: 20 },
+ chunk: { x: 58, y: 20 },
+ embed: { x: 58, y: 45 },
+ chroma: { x: 58, y: 70 },
+ query: { x: 82, y: 20 },
+ retrieve: { x: 82, y: 45 },
+ llm: { x: 82, y: 70 },
+ },
+ 'dq-flow': {
+ data: { x: 10, y: 50 },
+ docling: { x: 28, y: 25 },
+ pandas: { x: 28, y: 75 },
+ ge: { x: 52, y: 35 },
+ soda: { x: 52, y: 65 },
+ maturity: { x: 72, y: 50 },
+ report: { x: 90, y: 50 },
+ },
+ 'lakehouse': {
+ pg: { x: 8, y: 15 },
+ mysql: { x: 8, y: 35 },
+ mongo: { x: 8, y: 55 },
+ debezium: { x: 28, y: 35 },
+ kafka: { x: 45, y: 35 },
+ spark: { x: 58, y: 35 },
+ iceberg: { x: 72, y: 35 },
+ trino: { x: 85, y: 35 },
+ bi: { x: 92, y: 55 },
+ },
+}
+
+export function ArchitectureDiagram({ animation }: { animation: string }) {
+ const flow = FLOWS[animation] || FLOWS['full-stack']
+ const positions = POSITIONS[animation] || POSITIONS['full-stack']
+ const [tick, setTick] = useState(0)
+
+ useEffect(() => {
+ const t = setInterval(() => setTick((n) => n + 1), 2200)
+ return () => clearInterval(t)
+ }, [])
+
+ const activeEdge = tick % flow.edges.length
+
+ return (
+
+
+ {flow.edges.map((edge, i) => {
+ const from = positions[edge.from]
+ const to = positions[edge.to]
+ if (!from || !to) return null
+ const active = i === activeEdge
+ return (
+
+
+ {active && (
+
+
+
+ )}
+
+ )
+ })}
+
+ {flow.nodes.map((node) => {
+ const pos = positions[node.id]
+ if (!pos) return null
+ const lit = flow.edges.some((e, i) => i === activeEdge && (e.from === node.id || e.to === node.id))
+ return (
+
+
+ {node.label}
+
+ {node.sub &&
{node.sub}
}
+
+ )
+ })}
+
+ )
+}
diff --git a/ui/src/components/features/ChatDrawer.tsx b/ui/src/components/features/ChatDrawer.tsx
new file mode 100644
index 0000000..d0fee32
--- /dev/null
+++ b/ui/src/components/features/ChatDrawer.tsx
@@ -0,0 +1,119 @@
+import { useState } from 'react'
+import { ChevronDown, ChevronUp, MessageSquare, Radio } from 'lucide-react'
+import type { Agent, Approval, ChatMessage, FeedEntry } from '../../types'
+import { ActivityStream } from './ActivityStream'
+import { ApprovalCards } from './ApprovalCards'
+import { CommandBar } from './CommandBar'
+import { CommsPanel } from './CommsPanel'
+import { cn } from '../../lib/utils'
+
+type Props = {
+ expanded: boolean
+ onToggle: () => void
+ chat: ChatMessage[]
+ feed: FeedEntry[]
+ agents: Agent[]
+ approvals: Approval[]
+ selectedAgent: Agent | null
+ promptBusy: boolean
+ approvalHighlight: boolean
+ filterAgentId?: string | null
+ onSendPrompt: (message: string, agentId?: string) => void
+ onDecide: (id: string, approved: boolean, decidedBy: string, note: string) => Promise
+ onDismissHighlight: () => void
+}
+
+export function ChatDrawer({
+ expanded,
+ onToggle,
+ chat,
+ feed,
+ agents,
+ approvals,
+ selectedAgent,
+ promptBusy,
+ approvalHighlight,
+ filterAgentId,
+ onSendPrompt,
+ onDecide,
+ onDismissHighlight,
+}: Props) {
+ const [tab, setTab] = useState<'chat' | 'activity'>('chat')
+ const unread = chat.length
+
+ if (!expanded) {
+ return (
+
+
+
+ Chat & Activity
+ {unread > 0 && (
+
+ {unread} bericht{unread !== 1 ? 'en' : ''}
+
+ )}
+ {approvals.length > 0 && (
+
+ {approvals.length} approval{approvals.length !== 1 ? 's' : ''}
+
+ )}
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ {(['chat', 'activity'] as const).map((t) => (
+ setTab(t)}
+ className={cn(
+ 'flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[11px] font-medium capitalize transition-colors',
+ tab === t
+ ? 'bg-docker-light text-docker dark:bg-blue-500/20 dark:text-blue-200'
+ : 'text-foreground-muted hover:text-foreground',
+ )}
+ >
+ {t === 'chat' ? : }
+ {t === 'chat' ? 'Chat' : 'Activity'}
+ {t === 'activity' && approvals.length > 0 && (
+ {approvals.length}
+ )}
+
+ ))}
+
+
+
+
+
+
+
+ {tab === 'chat' ? (
+
+ ) : (
+
+ )}
+
+
+ {tab === 'chat' &&
}
+
+ )
+}
diff --git a/ui/src/components/features/CommandBar.tsx b/ui/src/components/features/CommandBar.tsx
new file mode 100644
index 0000000..6a07e09
--- /dev/null
+++ b/ui/src/components/features/CommandBar.tsx
@@ -0,0 +1,58 @@
+import { useState, type FormEvent } from 'react'
+import { Send } from 'lucide-react'
+import type { Agent } from '../../types'
+import { Button } from '../ui/Button'
+import { Input } from '../ui/Input'
+
+type Props = {
+ busy: boolean
+ selectedAgent: Agent | null
+ onSubmit: (message: string, agentId?: string) => void
+}
+
+export function CommandBar({ busy, selectedAgent, onSubmit }: Props) {
+ const [input, setInput] = useState('')
+
+ const submit = (e: FormEvent) => {
+ e.preventDefault()
+ if (!input.trim() || busy) return
+ onSubmit(input.trim(), selectedAgent?.id)
+ setInput('')
+ }
+
+ const suggestions = selectedAgent?.suggested_prompts?.slice(0, 3) || [
+ 'Hoeveel data zit er in de databases?',
+ 'Wat staat er in PostgreSQL?',
+ 'MongoDB supplychain overzicht',
+ ]
+
+ return (
+
+ )
+}
diff --git a/ui/src/components/features/CommsPanel.tsx b/ui/src/components/features/CommsPanel.tsx
new file mode 100644
index 0000000..1d3e4ff
--- /dev/null
+++ b/ui/src/components/features/CommsPanel.tsx
@@ -0,0 +1,68 @@
+import { useEffect, useRef } from 'react'
+import { MessageSquare } from 'lucide-react'
+import type { Agent, ChatMessage } from '../../types'
+import { getAgentMeta } from '../../lib/agentMeta'
+import { cn } from '../../lib/utils'
+
+type Props = {
+ messages: ChatMessage[]
+ agents: Agent[]
+ selectedAgent: Agent | null
+ busy: boolean
+}
+
+export function CommsPanel({ messages, agents, selectedAgent, busy }: Props) {
+ const bottomRef = useRef(null)
+ const meta = selectedAgent ? getAgentMeta(selectedAgent.id) : null
+
+ useEffect(() => {
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
+ }, [messages, busy])
+
+ return (
+
+
+
+ Comms
+
+ {selectedAgent && meta && (
+
+ {selectedAgent.name.split(' ·')[0]}
+
+ )}
+
+
+ {!messages.length && (
+
+
+
+ Send a command below — routing selects the right specialist automatically.
+
+
+ )}
+ {messages.map((m, i) => {
+ const ag = m.role === 'agent' ? agents.find((a) => a.id === m.agent) : null
+ const agMeta = ag ? getAgentMeta(ag.id) : null
+ return (
+
+
+
+ {m.role === 'user' ? 'You' : ag?.name || m.agent}
+ {m.ts && ` · ${new Date(m.ts).toLocaleTimeString('en-US', { hour12: false })}`}
+
+
{m.text}
+
+
+ )
+ })}
+ {busy && (
+
+
+ Agent verzamelt cluster-data en vraagt Llama 70B… verwacht ~30–90 sec
+
+ )}
+
+
+
+ )
+}
diff --git a/ui/src/components/features/DataQualityView.tsx b/ui/src/components/features/DataQualityView.tsx
new file mode 100644
index 0000000..2ece40d
--- /dev/null
+++ b/ui/src/components/features/DataQualityView.tsx
@@ -0,0 +1,732 @@
+import { useCallback, useEffect, useState, Fragment } from 'react'
+import {
+ AlertTriangle,
+ CheckCircle2,
+ FileSearch,
+ FileText,
+ Image,
+ Layers,
+ Loader2,
+ RefreshCw,
+ Table2,
+ Upload,
+ XCircle,
+} from 'lucide-react'
+import { cn } from '../../lib/utils'
+import { subTabActive, subTabIdle } from '../../lib/tabActive'
+
+type Dimension = {
+ id: string
+ label: string
+ description: string
+ score: number
+ level: string
+ findings: string[]
+ recommended_actions?: string[]
+}
+
+type ColumnProfile = {
+ name: string
+ dtype: string
+ null_pct: number
+ unique_count: number
+ quality_flags: string[]
+ sample_values?: string[]
+ numeric?: { min: number; max: number; mean: number; outliers: number }
+ text?: { avg_length: number; empty_strings: number }
+ top_values?: { value: string; count: number }[]
+}
+
+type GxCheck = { suite: string; expectation: string; success: boolean; result: string; column?: string }
+type SodaCheck = { suite: string; name: string; check: string; outcome: string; detail: string }
+
+type DocStructure = {
+ pages: number
+ pictures: number
+ tables: number
+ text_blocks: number
+ headings: number
+ paragraphs: number
+ list_items?: number
+ form_items: number
+ key_value_pairs: number
+ label_counts?: Record
+ table_details?: { index: number; rows: number; cols: number; cells: number; preview?: string }[]
+ picture_details?: { index: number; label: string; has_image: boolean; captions: number }[]
+ outline?: { type: string; text: string; level?: number }[]
+}
+
+type AssessResult = {
+ ok: boolean
+ report_id: string
+ overall_score: number
+ maturity_level: string
+ maturity_description?: string
+ rows: number
+ columns: number
+ dimensions: Dimension[]
+ column_profiles: ColumnProfile[]
+ action_items: { priority: string; dimension: string; score: number; action: string }[]
+ checks?: { great_expectations: GxCheck[]; soda_core: SodaCheck[] }
+ checks_summary: {
+ great_expectations: { total: number; passed: number }
+ soda_core: { total: number; warnings: number }
+ }
+ docling?: { used: boolean; parse_id?: string; document_structure?: DocStructure; stats?: Record; images?: DocImage[] }
+ rag_ingest?: { ok: boolean; duplicate?: boolean; chunks?: number; message?: string; error?: string }
+ report_url: string
+}
+
+type DocImage = {
+ index: number
+ label: string
+ available: boolean
+ url?: string
+ width?: number
+ height?: number
+ mimetype?: string
+ dpi?: number
+ bytes?: number
+ captions?: string[]
+}
+
+type ParseResult = {
+ ok: boolean
+ parse_id: string
+ filename: string
+ status: string
+ processing_time_sec?: number
+ formats_available: string[]
+ document_structure: DocStructure
+ images?: DocImage[]
+ stats: Record
+ content: { preview_markdown?: string; preview_html?: string; markdown?: string; html?: string }
+ table_preview?: string[]
+ errors?: string[]
+ parse_json_url?: string
+}
+
+type Capabilities = {
+ maturity_dimensions: { id: string; label: string; description: string }[]
+ maturity_levels: { min_score: number; label: string; description: string }[]
+ supported_data_formats: string[]
+ supported_document_formats: string[]
+ tools: Record
+ docling_online: boolean
+}
+
+type ReportSummary = {
+ id: string
+ filename: string
+ ts: string
+ overall_score: number
+ maturity_level: string
+ rows: number
+ columns: number
+}
+
+type Tab = 'assess' | 'docling' | 'reports'
+
+const SCORE_COLOR = (s: number) => (s >= 80 ? 'text-success' : s >= 60 ? 'text-warning' : 'text-danger')
+const BAR_COLOR = (s: number) => (s >= 80 ? 'bg-success' : s >= 60 ? 'bg-warning' : 'bg-danger')
+
+export function DataQualityView() {
+ const [tab, setTab] = useState('assess')
+ const [caps, setCaps] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [assess, setAssess] = useState(null)
+ const [parse, setParse] = useState(null)
+ const [parseFormat, setParseFormat] = useState<'markdown' | 'html'>('markdown')
+ const [reports, setReports] = useState([])
+ const [error, setError] = useState(null)
+ const [expandedCol, setExpandedCol] = useState(null)
+ const [showGx, setShowGx] = useState(false)
+ const [showSoda, setShowSoda] = useState(false)
+
+ const loadMeta = useCallback(async () => {
+ try {
+ const [c, r] = await Promise.all([fetch('/dq/capabilities'), fetch('/dq/reports')])
+ if (c.ok) setCaps(await c.json())
+ if (r.ok) {
+ const j = await r.json()
+ setReports(j.reports || [])
+ }
+ } catch {
+ /* ignore */
+ }
+ }, [])
+
+ useEffect(() => {
+ loadMeta()
+ }, [loadMeta])
+
+ const onAssess = async (file: File) => {
+ setLoading(true)
+ setError(null)
+ setAssess(null)
+ const fd = new FormData()
+ fd.append('file', file)
+ try {
+ const r = await fetch('/dq/assess', { method: 'POST', body: fd })
+ const j = await r.json()
+ if (!r.ok || !j.ok) {
+ setError(j.error || j.detail || 'Assessment failed')
+ return
+ }
+ setAssess(j as AssessResult)
+ loadMeta()
+ } catch {
+ setError('Connection failed — check DQ API')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const onParse = async (file: File) => {
+ setLoading(true)
+ setError(null)
+ setParse(null)
+ const fd = new FormData()
+ fd.append('file', file)
+ fd.append('to_formats', 'md,html,json')
+ const ctrl = new AbortController()
+ const timer = setTimeout(() => ctrl.abort(), 300000)
+ try {
+ const r = await fetch('/dq/parse', { method: 'POST', body: fd, signal: ctrl.signal })
+ const j = await r.json()
+ if (!r.ok || !j.ok) {
+ setError(typeof j.error === 'string' ? j.error : JSON.stringify(j.error || j).slice(0, 200) || 'Docling parse failed')
+ return
+ }
+ setParse(j as ParseResult)
+ loadMeta()
+ } catch (e) {
+ setError(e instanceof Error && e.name === 'AbortError' ? 'Timeout — document too large or Docling overloaded' : 'Docling unavailable')
+ } finally {
+ clearTimeout(timer)
+ setLoading(false)
+ }
+ }
+
+ const tabs: { id: Tab; label: string; icon: typeof FileSearch }[] = [
+ { id: 'assess', label: 'Maturity Assessment', icon: FileSearch },
+ { id: 'docling', label: 'Docling Parser', icon: FileText },
+ { id: 'reports', label: 'Reports', icon: CheckCircle2 },
+ ]
+
+ return (
+
+
+
+
+ {tabs.map(({ id, label, icon: Icon }) => (
+ setTab(id)}
+ className={cn('flex items-center gap-1.5 rounded-md px-3 py-2 text-[11px] font-medium transition-all', tab === id ? subTabActive : subTabIdle)}
+ >
+
+ {label}
+
+ ))}
+
+
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+ {tab === 'assess' && (
+
+
+
+ {loading &&
}
+
+ {assess && (
+
+
+
+
+
+
+ 0} />
+
+
+ {assess.docling?.used && assess.docling.document_structure && (
+ <>
+
+ {assess.docling.images && assess.docling.images.length > 0 && assess.docling.parse_id && (
+
+ )}
+ >
+ )}
+
+ {assess.rag_ingest && (
+
+
Knowledge Chat sync
+
+ {assess.rag_ingest.ok
+ ? (assess.rag_ingest.duplicate
+ ? `Already in Knowledge Chat — ${assess.rag_ingest.message || 'you can chat immediately.'}`
+ : `Indexed for chat: ${assess.rag_ingest.chunks ?? '?'} text chunks. Open Knowledge Chat to ask questions.`)
+ : (assess.rag_ingest.error || 'Could not sync to Knowledge Chat')}
+
+
+ )}
+
+
+
+ Full HTML report ↗
+
+
setShowGx(!showGx)} className={cn('rounded-md px-3 py-1.5 text-[11px]', showGx ? subTabActive : subTabIdle)}>
+ GE checks ({assess.checks?.great_expectations.length || 0})
+
+
setShowSoda(!showSoda)} className={cn('rounded-md px-3 py-1.5 text-[11px]', showSoda ? subTabActive : subTabIdle)}>
+ Soda checks ({assess.checks?.soda_core.length || 0})
+
+
+
+ {showGx && assess.checks?.great_expectations && (
+
({
+ name: c.column ? `${c.expectation} [${c.column}]` : c.expectation,
+ status: c.success ? 'pass' : 'fail',
+ detail: c.result,
+ }))} />
+ )}
+ {showSoda && assess.checks?.soda_core && (
+ ({
+ name: c.name,
+ status: c.outcome,
+ detail: `${c.check} — ${c.detail}`,
+ }))} />
+ )}
+
+
+ 6 Maturity Dimensions
+
+ {assess.dimensions.map((d) => (
+
+ ))}
+
+
+
+ {assess.action_items.length > 0 && (
+
+
+ Remediation Roadmap
+
+
+ {assess.action_items.map((a, i) => (
+
+ {a.priority}
+ {' · '}{a.dimension} ({a.score}): {a.action}
+
+ ))}
+
+
+ )}
+
+
+
+ Column profiles ({assess.column_profiles.length})
+
+
+
+
+ )}
+
+ )}
+
+ {tab === 'docling' && (
+
+
+ Docling extracts text, tables, images and document structure from PDF, PowerPoint, Word, Excel and images.
+ Resultaat: Markdown, HTML, JSON met pagina's, plaatjes, tabellen en outline.
+
+
+
+ {loading &&
}
+
+ {parse && (
+
+
+ 20 ? parse.filename.slice(0, 18) + '…' : parse.filename} sub={parse.status} />
+
+
+
+
+
+
+
+
+
+ {parse.images && parse.images.filter((i) => i.available).length > 0 && (
+
+ )}
+
+ {parse.document_structure?.outline && parse.document_structure.outline.length > 0 && (
+
+ Document outline
+
+ {parse.document_structure.outline.map((o, i) => (
+
+ {o.type}
+ {o.text}
+
+ ))}
+
+
+ )}
+
+
+ {(['markdown', 'html'] as const).map((f) => (
+
setParseFormat(f)} className={cn('rounded-md px-3 py-1.5 text-[11px] font-medium', parseFormat === f ? subTabActive : subTabIdle)}>
+ {f.toUpperCase()}
+
+ ))}
+ {parse.parse_json_url && (
+
+ Full JSON ↗
+
+ )}
+
+
+ {parse.table_preview && parse.table_preview.length > 0 && (
+
+ Tables (markdown preview)
+
+ {parse.table_preview.join('\n')}
+
+
+ )}
+
+
+ Extracted content
+ {parseFormat === 'html' && (parse.content.preview_html || parse.content.html) ? (
+
+ ) : (
+
+ {parse.content.preview_markdown || parse.content.markdown || '(no content)'}
+
+ )}
+
+
+ )}
+
+ )}
+
+ {tab === 'reports' && (
+
+ )}
+
+
+ )
+}
+
+function ImageGallery({ images, parseId }: { images: DocImage[]; parseId: string }) {
+ const available = images.filter((i) => i.available)
+ const [lightbox, setLightbox] = useState(null)
+ if (!available.length) {
+ return (
+
+
+ Images gedetecteerd ({images.length}) — no embedded export
+
+ Re-upload the document to extract images (embedded mode).
+
+ )
+ }
+ return (
+
+
+
+ Images die Docling ziet ({available.length})
+
+
+ {available.map((img) => (
+
setLightbox(img.index)}
+ className="group overflow-hidden rounded-lg border border-border bg-surface-raised text-left transition-all hover:border-docker/50 hover:shadow-docker"
+ >
+
+
+
+
+
#{img.index + 1} {img.label}
+
+ {img.width && img.height ? `${Math.round(img.width)}×${Math.round(img.height)}` : ''}
+ {img.dpi ? ` · ${img.dpi}dpi` : ''}
+ {img.bytes ? ` · ${(img.bytes / 1024).toFixed(0)}KB` : ''}
+
+
+
+ ))}
+
+ {lightbox !== null && (
+ setLightbox(null)}>
+
e.stopPropagation()}>
+
+
setLightbox(null)} className="absolute -top-3 -right-3 rounded-full bg-surface-raised px-2 py-1 text-xs text-foreground">✕
+
+
+ )}
+
+ )
+}
+
+function DocStructurePanel({ structure, title }: { structure: DocStructure; title: string }) {
+ return (
+
+ {title}
+
+ {[
+ { label: 'Pagina\'s', value: structure.pages, icon: Layers },
+ { label: 'Images', value: structure.pictures, icon: Image },
+ { label: 'Tables', value: structure.tables, icon: Table2 },
+ { label: 'Headings', value: structure.headings },
+ { label: 'Paragraphs', value: structure.paragraphs },
+ { label: 'Text blocks', value: structure.text_blocks },
+ ].map(({ label, value, icon: Icon }) => (
+
+ {Icon &&
}
+
{value}
+
{label}
+
+ ))}
+
+ {structure.picture_details && structure.picture_details.length > 0 && (
+
+
Images ({structure.picture_details.length})
+
+ {structure.picture_details.map((p) => (
+
+ #{p.index + 1} {p.label} {p.has_image ? '🖼' : ''} {p.captions > 0 ? `(${p.captions} captions)` : ''}
+
+ ))}
+
+
+ )}
+ {structure.table_details && structure.table_details.length > 0 && (
+
+
Tables ({structure.table_details.length})
+
+ {structure.table_details.map((t) => (
+
+ Table {t.index + 1}: {t.rows}×{t.cols} ({t.cells} cells) — {t.preview || '…'}
+
+ ))}
+
+
+ )}
+
+ )
+}
+
+function DimensionCard({ dimension: d }: { dimension: Dimension }) {
+ return (
+
+
+ {d.label}
+ {d.score}
+
+
+
{d.description}
+
+ {d.findings.map((f) => (
+ ▸ {f}
+ ))}
+
+ {d.recommended_actions && d.recommended_actions.length > 0 && (
+
→ {d.recommended_actions[0]}
+ )}
+
+ )
+}
+
+function ColumnTable({ profiles, expandedCol, onToggle }: { profiles: ColumnProfile[]; expandedCol: string | null; onToggle: (n: string | null) => void }) {
+ return (
+
+
+
+
+ Column Type Null%
+ Unique Flags
+
+
+
+ {profiles.map((c) => (
+
+ onToggle(expandedCol === c.name ? null : c.name)}>
+ {c.name}
+ {c.dtype}
+ 10 && 'font-semibold text-warning')}>{c.null_pct}%
+ {c.unique_count.toLocaleString()}
+ {c.quality_flags.join(', ') || '—'}
+
+ {expandedCol === c.name && (
+
+
+ {c.sample_values?.length ? Samples: {c.sample_values.join(' · ')}
: null}
+ {c.numeric && Range {c.numeric.min} – {c.numeric.max}, μ={c.numeric.mean}, {c.numeric.outliers} outliers
}
+ {c.text && Avg len {c.text.avg_length}, {c.text.empty_strings} empty strings
}
+ {c.top_values?.map((tv) => {tv.value} ({tv.count}) )}
+
+
+ )}
+
+ ))}
+
+
+
+ )
+}
+
+function CheckTable({ title, rows }: { title: string; rows: { name: string; status: string; detail: string }[] }) {
+ return (
+
+
{title}
+
+
+ {rows.map((r, i) => (
+
+
+
+ {r.status}
+
+ {r.name}
+
+ {r.detail}
+
+ ))}
+
+
+
+ )
+}
+
+function CapCard({ title, items, icon: Icon }: { title: string; items: string[]; icon: typeof Layers }) {
+ return (
+
+
+
+ {title}
+
+
{items.join(' · ')}
+
+ )
+}
+
+function UploadZone({ label, hint, accept, loading, onFile }: { label: string; hint: string; accept: string; loading: boolean; onFile: (f: File) => void }) {
+ return (
+
+
+ {label}
+ {hint}
+ e.target.files?.[0] && onFile(e.target.files[0])} />
+
+ )
+}
+
+function LoadingMsg({ text }: { text: string }) {
+ return (
+
+
+ {text}
+
+ )
+}
+
+function StatCard({ label, value, sub, accent, warn, icon: Icon }: { label: string; value: string; sub?: string; accent?: boolean; warn?: boolean; icon?: typeof Image }) {
+ return (
+
+
+
{value}
+ {sub &&
{sub}
}
+
+ )
+}
diff --git a/ui/src/components/features/GpuMatrixPanel.tsx b/ui/src/components/features/GpuMatrixPanel.tsx
new file mode 100644
index 0000000..1026a73
--- /dev/null
+++ b/ui/src/components/features/GpuMatrixPanel.tsx
@@ -0,0 +1,205 @@
+import { useEffect, useMemo, useState, type ReactNode } from 'react'
+import { Activity, Cpu, ExternalLink, Thermometer, Zap } from 'lucide-react'
+import { fetchGpu } from '../../lib/api'
+import type { GpuDevice, GpuStatus } from '../../types'
+import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
+import { cn } from '../../lib/utils'
+
+type Props = {
+ gpu: GpuStatus | null
+ live: GpuLiveMetrics
+ boost?: boolean
+ onSelectGpu?: () => void
+}
+
+function memPct(used: number, total: number) {
+ if (!total) return 0
+ return Math.round((used / total) * 100)
+}
+
+function utilColor(pct: number) {
+ if (pct >= 75) return 'bg-danger'
+ if (pct >= 35) return 'bg-warning'
+ return 'bg-success'
+}
+
+function GpuRow({ device, liveUtil, active }: { device: GpuDevice; liveUtil: number; active: boolean }) {
+ const vramPct = memPct(device.memory_used_mib, device.memory_total_mib)
+ const util = liveUtil ?? device.util_gpu
+
+ return (
+ 5 && 'border-docker/30 bg-docker/5',
+ )}
+ >
+
+ GPU {device.index}
+ {util.toFixed(0)}% · {vramPct}% VRAM
+
+
+
+
+
+
+
+
+ {device.temperature_c?.toFixed(0) ?? '—'}°C
+
+ {device.power_w?.toFixed(0) ?? '—'} W
+
+
+ )
+}
+
+function MetricBar({ label, value, colorClass }: { label: string; value: number; colorClass: string }) {
+ return (
+
+ )
+}
+
+export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props) {
+ const [localGpu, setLocalGpu] = useState(gpu)
+ const [lastPoll, setLastPoll] = useState(null)
+
+ useEffect(() => {
+ setLocalGpu(gpu)
+ }, [gpu])
+
+ useEffect(() => {
+ const poll = async () => {
+ const g = await fetchGpu()
+ if (g) {
+ setLocalGpu(g)
+ setLastPoll(new Date())
+ }
+ }
+ poll()
+ const ms = boost ? 1000 : 3000
+ const iv = setInterval(poll, ms)
+ return () => clearInterval(iv)
+ }, [boost])
+
+ const g = localGpu
+ const devices = g?.gpus || []
+ const inferenceOn = g?.ok && g.inference_active
+ const modelLabel = g?.active_model?.replace(/\s*GPTQ$/i, '').replace(/\s*AWQ$/i, '') || 'No model'
+
+ const avgUtil = useMemo(() => {
+ if (devices.length) {
+ const sum = devices.reduce((s, d, i) => s + (live.deviceUtils[i] ?? d.util_gpu), 0)
+ return sum / devices.length
+ }
+ return live.avgUtil
+ }, [devices, live.avgUtil, live.deviceUtils])
+
+ const avgVram = useMemo(() => {
+ if (devices.length) {
+ return devices.reduce((s, d) => s + memPct(d.memory_used_mib, d.memory_total_mib), 0) / devices.length
+ }
+ return live.avgVram
+ }, [devices, live.avgVram])
+
+ if (!g?.ok) {
+ return (
+
+
+ GPU Matrix
+
+ GPU Lab offline
+
+ )
+ }
+
+ return (
+
+
+
+
+ GPU Matrix
+ {boost && (
+
+ Live
+
+ )}
+
+
{modelLabel}
+
{g.gpu_count ?? devices.length}× V100 · {g.host}
+
+ {g.ui_url && (
+ e.stopPropagation()}
+ className="shrink-0 text-docker hover:underline"
+ >
+
+
+ )}
+
+
+
+
+ 20 ? 'text-warning' : 'text-foreground'} />
+ }
+ />
+
+
+
+ {devices.map((d, i) => (
+
+ ))}
+
+
+
+ VRAM avg {avgVram.toFixed(0)}% · poll {boost ? '1s' : '3s'}
+ {lastPoll && ` · ${lastPoll.toLocaleTimeString()}`}
+
+
+ )
+}
+
+function StatChip({
+ label,
+ value,
+ accent,
+ icon,
+}: {
+ label: string
+ value: string
+ accent?: string
+ icon?: ReactNode
+}) {
+ return (
+
+
{icon}{label}
+
{value}
+
+ )
+}
diff --git a/ui/src/components/features/GpuMonitor.tsx b/ui/src/components/features/GpuMonitor.tsx
new file mode 100644
index 0000000..ab8b043
--- /dev/null
+++ b/ui/src/components/features/GpuMonitor.tsx
@@ -0,0 +1,56 @@
+import type { ReactNode } from 'react'
+import { Cpu, ExternalLink, Zap } from 'lucide-react'
+import type { GpuStatus } from '../../types'
+import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
+import { Badge } from '../ui/Badge'
+
+type Props = {
+ gpu: GpuStatus | null
+ live: GpuLiveMetrics
+}
+
+export function GpuMonitor({ gpu, live }: Props) {
+ if (!gpu) {
+ return (
+ GPU offline
+ )
+ }
+
+ const inferenceOn = gpu.inference_active && gpu.ok
+ const devices = gpu.gpus || []
+
+ return (
+
+
+
+
+ GPU
+
+ {inferenceOn ? 'ON' : 'Standby'}
+
+
+
+
} />
+
+
+ {devices.slice(0, 4).map((d, i) => (
+
+ ))}
+ {gpu.ui_url && (
+
+ {gpu.host}
+
+ )}
+
+
+ )
+}
+
+function Chip({ label, value, icon }: { label: string; value: string; icon?: ReactNode }) {
+ return (
+
+ {icon}{label}
+ {value}
+
+ )
+}
diff --git a/ui/src/components/features/InfraQuickAccess.tsx b/ui/src/components/features/InfraQuickAccess.tsx
new file mode 100644
index 0000000..da41d5b
--- /dev/null
+++ b/ui/src/components/features/InfraQuickAccess.tsx
@@ -0,0 +1,129 @@
+import { ExternalLink, RefreshCw, Terminal } from 'lucide-react'
+import type { Agent, WorkloadData } from '../../types'
+import { getAgentMeta } from '../../lib/agentMeta'
+import { copyShellCommand, INFRA_CATALOG, type InfraNode } from '../../lib/infraCatalog'
+import { cn } from '../../lib/utils'
+
+type Props = {
+ workload: WorkloadData | null
+ agents: Agent[]
+ selectedNodeId: string | null
+ busy: boolean
+ onSelectNode: (id: string) => void
+ onSelectAgent: (id: string) => void
+ onProbe: (nodeId: string) => void
+ onOpenTerminal: (nodeId: string) => void
+}
+
+function zoneStats(workload: WorkloadData | null, zoneId: string) {
+ const z = workload?.zones?.find((x) => x.id === zoneId)
+ if (!z) return null
+ return `${z.running}/${z.total}`
+}
+
+export function InfraQuickAccess({
+ workload,
+ agents,
+ selectedNodeId,
+ busy,
+ onSelectNode,
+ onSelectAgent,
+ onProbe,
+ onOpenTerminal,
+}: Props) {
+ const handleShell = async (node: InfraNode) => {
+ await copyShellCommand(node.ssh)
+ onSelectNode(node.id)
+ onOpenTerminal(node.id)
+ onProbe(node.id)
+ }
+
+ return (
+
+
+
+
Infrastructure & Apps
+
Klik voor inspector · Shell kopieert SSH en opent live terminal · UI opent de applicatie
+
+ {workload && (
+
+ {workload.totals.apps_running}/{workload.totals.apps_total} containers
+
+ )}
+
+
+ {INFRA_CATALOG.map((node) => {
+ const Icon = node.icon
+ const agent = agents.find((a) => a.id === node.agentId)
+ const meta = agent ? getAgentMeta(agent.id) : null
+ const active = selectedNodeId === node.id || node.topoIds.includes(selectedNodeId || '')
+ const stats = zoneStats(workload, node.zone)
+ return (
+
+
onSelectNode(node.id)} className="mb-1.5 text-left">
+
+
+
+
+
+
{node.label}
+
{node.vm} · {node.ip}
+ {stats &&
{stats} running
}
+
+
+ {node.description}
+
+
+ {agent && meta && (
+
onSelectAgent(agent.id)}
+ className="mb-1.5 truncate text-left text-[8px] hover:text-docker"
+ style={{ color: meta.accent }}
+ >
+ Agent: {agent.name.split(' ·')[0]}
+
+ )}
+
+
+
handleShell(node)}
+ className="inline-flex items-center gap-1 rounded border border-border bg-surface px-1.5 py-0.5 text-[8px] text-foreground-muted hover:border-docker/40 hover:text-docker"
+ title={node.ssh}
+ >
+ Shell
+
+
{ onSelectNode(node.id); onProbe(node.id) }}
+ disabled={busy}
+ className="inline-flex items-center gap-1 rounded border border-border bg-surface px-1.5 py-0.5 text-[8px] text-foreground-muted hover:border-border-strong disabled:opacity-50"
+ >
+ Probe
+
+ {node.apps.slice(0, 2).map((app) => (
+
+ {app.label}
+
+ ))}
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/ui/src/components/features/InspectorPanel.tsx b/ui/src/components/features/InspectorPanel.tsx
new file mode 100644
index 0000000..f9adbfc
--- /dev/null
+++ b/ui/src/components/features/InspectorPanel.tsx
@@ -0,0 +1,294 @@
+import { useState, type FormEvent } from 'react'
+import { ExternalLink, RefreshCw, Terminal, X } from 'lucide-react'
+import type { Agent, FeedEntry, GpuStatus, NodeDetail, TerminalLine, TopologyNode, WorkloadData } from '../../types'
+import { AGENT_NODE } from '../../lib/constants'
+import { getAgentMeta } from '../../lib/agentMeta'
+import { copyShellCommand, resolveInfraNode } from '../../lib/infraCatalog'
+import { Button } from '../ui/Button'
+import { Card, CardDescription, CardTitle } from '../ui/Card'
+import { Input } from '../ui/Input'
+import { cn } from '../../lib/utils'
+
+type Tab = 'overview' | 'apps' | 'terminal'
+
+type Props = {
+ node: TopologyNode | null
+ nodeDetail: NodeDetail | null
+ agent: Agent | null
+ agents: Agent[]
+ workload: WorkloadData | null
+ gpu: GpuStatus | null
+ feed: FeedEntry[]
+ lines: TerminalLine[]
+ busy: boolean
+ onProbe: () => void
+ onAsk: (message: string) => void
+ onSelectAgent: (id: string) => void
+ onSendPrompt: (message: string, agentId?: string) => void
+ onClear: () => void
+ onOpenTerminal: (nodeId: string) => void
+ onProbeNodeId: (nodeId: string) => void
+}
+
+export function InspectorPanel({
+ node,
+ nodeDetail,
+ agent,
+ agents,
+ workload,
+ gpu,
+ feed,
+ lines,
+ busy,
+ onProbe,
+ onAsk,
+ onSelectAgent,
+ onSendPrompt,
+ onClear,
+ onOpenTerminal,
+ onProbeNodeId,
+}: Props) {
+ const [tab, setTab] = useState('overview')
+ const [input, setInput] = useState('')
+ const [shellCopied, setShellCopied] = useState(false)
+ const d = nodeDetail || node
+ const infra = resolveInfraNode(d?.id || null)
+ const linkedAgent = d
+ ? agents.find((a) => a.id === d.id || AGENT_NODE[a.id] === d.id || a.id === infra?.agentId || a.zone === d.id)
+ : agent
+
+ const submit = (e: FormEvent) => {
+ e.preventDefault()
+ if (!input.trim() || busy) return
+ onAsk(input.trim())
+ setInput('')
+ setTab('terminal')
+ if (d?.id) onOpenTerminal(d.id)
+ }
+
+ const runShell = async () => {
+ if (!infra) return
+ await copyShellCommand(infra.ssh)
+ setShellCopied(true)
+ setTimeout(() => setShellCopied(false), 2000)
+ onOpenTerminal(infra.id)
+ onProbeNodeId(infra.id)
+ setTab('terminal')
+ }
+
+ return (
+
+
+
+
Inspector
+
+ {d ? d.label : agent ? agent.name.split(' ·')[0] : 'Lab overview'}
+
+
+ {(d || agent) && (
+
+
+
+ )}
+
+
+ {!d && !agent && (
+
+
+
Snel starten
+
+ Select an infrastructure card or topology node
+ Klik Shell voor SSH + live terminal output
+ Klik UI om Airflow, Trino, Kafka UI, etc. te openen
+ Stel vragen via Chat onderaan — agents zien de hele cluster
+
+
+
+
Agents & domeinen
+
+ {agents.filter((a) => !a.supervisor).map((a) => {
+ const meta = getAgentMeta(a.id)
+ return (
+ onSelectAgent(a.id)}
+ className="flex w-full items-start gap-2 rounded border border-border bg-surface-overlay/60 px-2 py-1.5 text-left hover:border-border-strong"
+ >
+ {a.name.split(' ·')[0]}
+ {a.role}
+
+ )
+ })}
+
+
+ {workload && (
+
+
+
+
+
+
+ )}
+ {gpu?.ok && (
+
+ GPU · {gpu.host}
+ {gpu.active_model || 'No model'}
+
+ )}
+
+ )}
+
+ {agent && !d && (
+
+ {(() => {
+ const meta = getAgentMeta(agent.id)
+ const Icon = meta.icon
+ return (
+ <>
+
+
+
+
+
+
{agent.name}
+
{meta.domain} · {agent.zone}
+
+
+
"{agent.motto || agent.role}"
+
+ {(agent.suggested_prompts || []).slice(0, 4).map((prompt) => (
+ onSendPrompt(prompt, agent.id)}
+ className="rounded border border-border px-1.5 py-0.5 text-[9px] text-foreground-muted hover:border-docker/40 hover:text-docker"
+ >
+ {prompt}
+
+ ))}
+
+
onOpenTerminal(agent.id)}
+ className="mb-2 inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[9px] hover:border-docker/40"
+ >
+ Agent terminal
+
+ >
+ )
+ })()}
+
+ )}
+
+ {d && (
+ <>
+ {linkedAgent && (
+
+ Agent
+ onSelectAgent(linkedAgent.id)} className="truncate text-[10px] font-medium text-docker hover:underline">
+ {linkedAgent.name.split(' ·')[0]}
+
+
+ )}
+
+
+ {d.vm} · {d.ip}
+ {d.running}/{d.total}
+
+
+
+ {infra && (
+
+
+ {shellCopied ? 'SSH gekopieerd' : 'Shell'}
+
+ )}
+
+ Probe
+
+ {(infra?.apps || []).slice(0, 3).map((app) => (
+
+ {app.label}
+
+ ))}
+ {(d.links || []).map((l) => (
+
+ {l.label}
+
+ ))}
+
+
+
+ {(['overview', 'apps', 'terminal'] as Tab[]).map((t) => (
+ setTab(t)} className={cn('rounded px-2 py-0.5 text-[10px] capitalize', tab === t ? 'bg-docker-light text-docker' : 'text-foreground-muted')}>
+ {t}
+
+ ))}
+
+
+ {tab === 'overview' && (
+
+ {(d.description || infra?.description) &&
{d.description || infra?.description}
}
+ {infra &&
{infra.ssh}
}
+ {(d.endpoints || []).map((ep) => (
+
{ep.name}: {ep.host}:{ep.port}
+ ))}
+ {(d.commands || []).map((cmd) => (
+
{ setInput(cmd); setTab('terminal') }}
+ className="block w-full rounded border border-border px-2 py-1 text-left font-mono text-[9px] hover:bg-surface-overlay"
+ >
+ $ {cmd}
+
+ ))}
+
+ )}
+ {tab === 'apps' && (
+
+ {(d.apps || []).map((app) => (
+
+
{app.name}
+
{app.state} · {app.image}
+
+ ))}
+
+ )}
+ {tab === 'terminal' && (
+
+ {lines.map((line) => (
+
+ {line.ts ? new Date(line.ts).toLocaleTimeString('en-US', { hour12: false }) : ''} {' '}
+ {line.phase} {line.text}
+
+ ))}
+ {busy &&
█ }
+
+ )}
+
+
+ >
+ )}
+
+ )
+}
+
+function Stat({ label, value, ok }: { label: string; value: string; ok?: boolean }) {
+ return (
+
+ )
+}
diff --git a/ui/src/components/features/KnowledgeChatView.tsx b/ui/src/components/features/KnowledgeChatView.tsx
new file mode 100644
index 0000000..6b8d82d
--- /dev/null
+++ b/ui/src/components/features/KnowledgeChatView.tsx
@@ -0,0 +1,358 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { BookOpen, FileText, Loader2, MessageSquare, RefreshCw, RotateCcw, Send, Upload } from 'lucide-react'
+import { cn } from '../../lib/utils'
+import { subTabActive, subTabIdle } from '../../lib/tabActive'
+
+type Collection = { name: string; documents: number; files?: number; filenames?: string[] }
+type StoredDoc = {
+ id: string
+ filename: string
+ collection: string
+ chunks: number
+ characters?: number
+ ingested_at: string
+ bytes?: number
+}
+type Source = { source?: string; chunk?: number; preview?: string }
+type ChatMsg = { role: 'user' | 'assistant'; content: string; sources?: Source[] }
+
+type Health = { ok: boolean; chroma: boolean; docling: boolean; llm: boolean; embed_model?: string }
+
+type Props = { onGpuActivity?: (active: boolean) => void }
+
+export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
+ const [health, setHealth] = useState(null)
+ const [collections, setCollections] = useState([])
+ const [collection, setCollection] = useState('default')
+ const [newCol, setNewCol] = useState('')
+ const [messages, setMessages] = useState([])
+ const [input, setInput] = useState('')
+ const [loading, setLoading] = useState(false)
+ const [ingesting, setIngesting] = useState(false)
+ const [error, setError] = useState(null)
+ const [storedDocs, setStoredDocs] = useState([])
+ const [selectedDocId, setSelectedDocId] = useState(null)
+ const [summarizing, setSummarizing] = useState(false)
+ const [reindexing, setReindexing] = useState(null)
+ const bottomRef = useRef(null)
+
+ const loadMeta = useCallback(async () => {
+ try {
+ const [h, c, d] = await Promise.all([
+ fetch('/rag/health'),
+ fetch('/rag/collections'),
+ fetch('/rag/documents'),
+ ])
+ if (h.ok) setHealth(await h.json())
+ if (c.ok) {
+ const j = await c.json()
+ setCollections(j.collections || [])
+ }
+ if (d.ok) {
+ const j = await d.json()
+ setStoredDocs(j.documents || [])
+ }
+ } catch {
+ setHealth(null)
+ }
+ }, [])
+
+ useEffect(() => {
+ loadMeta()
+ }, [loadMeta])
+
+ useEffect(() => {
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
+ }, [messages, loading])
+
+ useEffect(() => {
+ onGpuActivity?.(loading || ingesting || summarizing || reindexing !== null)
+ }, [loading, ingesting, summarizing, reindexing, onGpuActivity])
+
+ const onIngest = async (file: File) => {
+ setIngesting(true)
+ setError(null)
+ const fd = new FormData()
+ fd.append('file', file)
+ fd.append('collection', collection)
+ try {
+ const r = await fetch('/rag/ingest', { method: 'POST', body: fd })
+ const j = await r.json()
+ if (!r.ok || !j.ok) {
+ setError(j.error || 'Ingest failed')
+ return
+ }
+ setMessages((m) => [...m, {
+ role: 'assistant',
+ content: j.duplicate
+ ? `Already indexed: ${j.filename} (${j.chunks} chunks). You can chat immediately — no re-upload needed.`
+ : `Indexed ${j.filename} → collection "${j.collection}" — ${j.chunks} chunks (${j.characters?.toLocaleString()} chars). Stored permanently.`,
+ }])
+ loadMeta()
+ } catch {
+ setError('RAG API unavailable')
+ } finally {
+ setIngesting(false)
+ }
+ }
+
+ const onSummarize = async (doc: StoredDoc) => {
+ setSummarizing(true)
+ setError(null)
+ setSelectedDocId(doc.id)
+ setCollection(doc.collection)
+ setMessages((m) => [...m, { role: 'user', content: `Summarize: ${doc.filename}` }])
+ try {
+ const r = await fetch('/rag/summarize', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ collection: doc.collection, doc_id: doc.id }),
+ })
+ const j = await r.json()
+ if (!r.ok || !j.ok) {
+ setError(j.error || 'Summarize failed')
+ return
+ }
+ setMessages((m) => [...m, {
+ role: 'assistant',
+ content: `Summary of ${j.filename} (${j.characters?.toLocaleString()} chars):\n\n${j.summary}`,
+ }])
+ } catch {
+ setError('Summarize request failed')
+ } finally {
+ setSummarizing(false)
+ }
+ }
+
+ const onReindex = async (doc: StoredDoc) => {
+ setReindexing(doc.id)
+ setError(null)
+ try {
+ const r = await fetch(`/rag/documents/${doc.id}/reindex`, { method: 'POST' })
+ const j = await r.json()
+ if (!r.ok || !j.ok) {
+ setError(j.error || 'Re-index failed')
+ return
+ }
+ setMessages((m) => [...m, {
+ role: 'assistant',
+ content: `Re-indexed ${j.filename}: ${j.chunks} clean text chunks (${j.characters?.toLocaleString()} chars). You can now chat and summarize.`,
+ }])
+ loadMeta()
+ } catch {
+ setError('Re-index request failed')
+ } finally {
+ setReindexing(null)
+ }
+ }
+
+ const onSend = async () => {
+ const msg = input.trim()
+ if (!msg || loading) return
+ setInput('')
+ setError(null)
+ setMessages((m) => [...m, { role: 'user', content: msg }])
+ setLoading(true)
+ try {
+ const r = await fetch('/rag/chat', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ message: msg, collection, top_k: 5 }),
+ })
+ const j = await r.json()
+ if (!r.ok || !j.ok) {
+ setError(j.error || 'Chat failed')
+ setLoading(false)
+ return
+ }
+ setMessages((m) => [...m, { role: 'assistant', content: j.answer, sources: j.sources }])
+ } catch {
+ setError('Failed to reach RAG / LLM service')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const createCollection = async () => {
+ if (!newCol.trim()) return
+ const fd = new FormData()
+ fd.append('name', newCol.trim())
+ await fetch('/rag/collections', { method: 'POST', body: fd })
+ setCollection(newCol.trim())
+ setNewCol('')
+ loadMeta()
+ }
+
+ const doclingUiUrl = `${window.location.protocol}//${window.location.hostname}:5001/ui/`
+
+ return (
+
+
+
+
+
+ Collection
+ setCollection(e.target.value)}
+ className="mb-2 w-full rounded border border-border bg-surface-overlay px-2 py-1.5 text-[11px]"
+ >
+ {collections.length === 0 && default (empty) }
+ {collections.map((c) => (
+ {c.name} ({c.documents} docs)
+ ))}
+
+
+ setNewCol(e.target.value)}
+ placeholder="New collection name"
+ className="min-w-0 flex-1 rounded border border-border bg-surface-overlay px-2 py-1 text-[10px]"
+ />
+ Add
+
+
+ Ingest documents
+
+
+ PDF, PPTX, DOCX, CSV, TXT, MD
+ Stored in ChromaDB + disk — upload once
+ e.target.files?.[0] && onIngest(e.target.files[0])} />
+
+ {ingesting && (
+
+ Ingesting & embedding…
+
+ )}
+
+
+ Document library ({storedDocs.length})
+
+
+ {storedDocs.length === 0 ? (
+
No documents yet — upload above.
+ ) : (
+ storedDocs.map((doc) => (
+
+
{ setSelectedDocId(doc.id); setCollection(doc.collection) }} className="w-full text-left">
+ {doc.filename}
+ {doc.collection} · {doc.chunks} chunks · {new Date(doc.ingested_at).toLocaleDateString()}
+
+
+ onSummarize(doc)}
+ className={cn('flex flex-1 items-center justify-center gap-0.5 rounded px-1 py-0.5', subTabIdle)}
+ >
+ {summarizing && selectedDocId === doc.id ? : }
+ Summarize
+
+ onReindex(doc)}
+ className={cn('flex flex-1 items-center justify-center gap-0.5 rounded px-1 py-0.5', subTabIdle)}
+ title="Re-parse with clean text (fixes corrupted PDF index)"
+ >
+ {reindexing === doc.id ? : }
+ Re-index
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+
+ {messages.length === 0 && (
+
+
+
Upload once — documents stay in ChromaDB. Ask anytime without re-uploading.
+
Example: "What maturity gaps exist in the customer dataset?"
+
+ )}
+ {messages.map((m, i) => (
+
+
{m.content}
+ {m.sources && m.sources.length > 0 && (
+
+
Sources
+ {m.sources.map((s, j) => (
+
+ {s.source} · chunk {s.chunk}: {s.preview?.slice(0, 120)}…
+
+ ))}
+
+ )}
+
+ ))}
+ {loading && (
+
+ Retrieving context & generating answer…
+
+ )}
+ {error &&
{error}
}
+
+
+
+
+ setInput(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && (e.preventDefault(), onSend())}
+ placeholder="Ask a question about your ingested data…"
+ className="min-w-0 flex-1 rounded-lg border border-border bg-surface-overlay px-3 py-2 text-[12px]"
+ disabled={loading}
+ />
+
+
+
+
+
+
+
+ )
+}
+
+function StatusPill({ ok, label }: { ok: boolean; label: string }) {
+ return (
+
+ {label} {ok ? '●' : '○'}
+
+ )
+}
diff --git a/ui/src/components/features/PlatformTopology.tsx b/ui/src/components/features/PlatformTopology.tsx
new file mode 100644
index 0000000..055b1c8
--- /dev/null
+++ b/ui/src/components/features/PlatformTopology.tsx
@@ -0,0 +1,393 @@
+import { useEffect, useMemo, useState } from 'react'
+import { Box } from 'lucide-react'
+import type { AgentAnim, WorkloadData } from '../../types'
+import { Badge } from '../ui/Badge'
+import { cn } from '../../lib/utils'
+
+/* ── Pipeline model ─────────────────────────────────────────────── */
+
+type TopoNode = { id: string; label: string; sub: string; metricKey: string }
+
+type TopoStage = {
+ id: string
+ num: number
+ title: string
+ subtitle: string
+ accent: string
+ nodes: TopoNode[]
+}
+
+type FlowKind = 'orchestration' | 'cdc' | 'stream' | 'etl' | 'query' | 'serve'
+
+type FlowEdge = {
+ from: string
+ to: string
+ kind: FlowKind
+ label: string
+}
+
+const STAGES: TopoStage[] = [
+ {
+ id: 'sources', num: 1, title: 'SOURCES', subtitle: 'Operational databases', accent: 'topo-stage-col--sources',
+ nodes: [
+ { id: 'postgresql', label: 'PostgreSQL', sub: 'OLTP · primary', metricKey: 'postgresql' },
+ { id: 'mysql', label: 'MySQL', sub: 'Replica set', metricKey: 'mysql' },
+ { id: 'mongodb', label: 'MongoDB', sub: 'Document store', metricKey: 'mongodb' },
+ { id: 'cassandra', label: 'Cassandra', sub: 'Wide-column', metricKey: 'cassandra' },
+ ],
+ },
+ {
+ id: 'ingestion', num: 2, title: 'INGESTION & STREAMING', subtitle: 'CDC · event bus · orchestration', accent: 'topo-stage-col--ingestion',
+ nodes: [
+ { id: 'debezium', label: 'Debezium', sub: 'CDC connectors', metricKey: 'debezium' },
+ { id: 'kafka', label: 'Apache Kafka', sub: 'Event bus', metricKey: 'kafka' },
+ { id: 'airflow', label: 'Apache Airflow', sub: 'Daily Python DAGs · source sync', metricKey: 'airflow' },
+ ],
+ },
+ {
+ id: 'compute', num: 3, title: 'COMPUTE', subtitle: 'Processing & query', accent: 'topo-stage-col--compute',
+ nodes: [
+ { id: 'spark', label: 'Apache Spark', sub: 'Batch / micro-batch', metricKey: 'spark' },
+ { id: 'trino', label: 'Trino', sub: 'Distributed SQL', metricKey: 'trino' },
+ ],
+ },
+ {
+ id: 'storage', num: 4, title: 'STORAGE', subtitle: 'Lakehouse layer', accent: 'topo-stage-col--storage',
+ nodes: [
+ { id: 'iceberg', label: 'Iceberg Tables', sub: 'Open table format', metricKey: 'iceberg' },
+ { id: 's3', label: 'Dell ECS S3', sub: 'Object scale', metricKey: 's3' },
+ ],
+ },
+ {
+ id: 'consumers', num: 5, title: 'CONSUMERS', subtitle: 'Analytics & AI', accent: 'topo-stage-col--consumers',
+ nodes: [
+ { id: 'bi', label: 'BI / Reporting', sub: 'Dashboards', metricKey: 'bi' },
+ { id: 'jupyter', label: 'Jupyter Notebooks', sub: 'Data science', metricKey: 'jupyter' },
+ { id: 'llm', label: 'GenAI LLM', sub: 'vLLM inference', metricKey: 'llm' },
+ ],
+ },
+]
+
+/** Full data-foundation flows — Airflow daily Python generation + CDC stream + lakehouse */
+const FLOW_EDGES: FlowEdge[] = [
+ // Airflow orchestrates daily Python jobs on every source
+ { from: 'airflow', to: 'postgresql', kind: 'orchestration', label: 'Daily Python gen' },
+ { from: 'airflow', to: 'mysql', kind: 'orchestration', label: 'Daily Python gen' },
+ { from: 'airflow', to: 'mongodb', kind: 'orchestration', label: 'Daily Python gen' },
+ { from: 'airflow', to: 'cassandra', kind: 'orchestration', label: 'Daily Python gen' },
+ // CDC capture from sources
+ { from: 'postgresql', to: 'debezium', kind: 'cdc', label: 'CDC' },
+ { from: 'mysql', to: 'debezium', kind: 'cdc', label: 'CDC' },
+ { from: 'mongodb', to: 'debezium', kind: 'cdc', label: 'CDC' },
+ { from: 'cassandra', to: 'debezium', kind: 'cdc', label: 'CDC' },
+ // Streaming bus
+ { from: 'debezium', to: 'kafka', kind: 'stream', label: 'Events' },
+ { from: 'airflow', to: 'kafka', kind: 'orchestration', label: 'DAG trigger' },
+ // ETL compute
+ { from: 'kafka', to: 'spark', kind: 'etl', label: 'Micro-batch' },
+ { from: 'airflow', to: 'spark', kind: 'orchestration', label: 'Pipeline DAG' },
+ { from: 'spark', to: 'iceberg', kind: 'etl', label: 'Lake write' },
+ { from: 'spark', to: 's3', kind: 'etl', label: 'Object export' },
+ // Query & serve
+ { from: 'iceberg', to: 'trino', kind: 'query', label: 'SQL' },
+ { from: 'trino', to: 'bi', kind: 'serve', label: 'Reports' },
+ { from: 'iceberg', to: 'jupyter', kind: 'serve', label: 'Notebooks' },
+ { from: 's3', to: 'jupyter', kind: 'serve', label: 'Datasets' },
+ { from: 'trino', to: 'llm', kind: 'serve', label: 'RAG context' },
+ { from: 's3', to: 'llm', kind: 'serve', label: 'Model artifacts' },
+]
+
+const STAGE_BADGE: Record = {
+ sources: 'border-emerald-400/50 bg-emerald-500/20 text-emerald-300',
+ ingestion: 'border-cyan-400/50 bg-cyan-500/20 text-cyan-300',
+ compute: 'border-violet-400/50 bg-violet-500/20 text-violet-300',
+ storage: 'border-blue-400/50 bg-blue-500/20 text-blue-300',
+ consumers: 'border-amber-400/50 bg-amber-500/20 text-amber-300',
+}
+
+const FLOW_LEGEND: { kind: FlowKind; label: string; color: string }[] = [
+ { kind: 'orchestration', label: 'Airflow orchestration', color: '#f59e0b' },
+ { kind: 'cdc', label: 'CDC capture', color: '#22d3ee' },
+ { kind: 'stream', label: 'Event stream', color: '#38bdf8' },
+ { kind: 'etl', label: 'ETL / compute', color: '#a78bfa' },
+ { kind: 'query', label: 'SQL query', color: '#818cf8' },
+ { kind: 'serve', label: 'Consumption', color: '#34d399' },
+]
+
+const EDGE_CLASS: Record = {
+ orchestration: 'topo-edge-orchestration',
+ cdc: 'topo-edge-cdc',
+ stream: 'topo-edge-stream',
+ etl: 'topo-edge-etl',
+ query: 'topo-edge-query',
+ serve: 'topo-edge-serve',
+}
+
+const PARTICLE_FILL: Record = {
+ orchestration: '#fbbf24',
+ cdc: '#22d3ee',
+ stream: '#38bdf8',
+ etl: '#c4b5fd',
+ query: '#818cf8',
+ serve: '#34d399',
+}
+
+const NODE_CLICK_MAP: Record = {
+ postgresql: 'src-postgres', mysql: 'src-mysql', mongodb: 'src-mongo', cassandra: 'src-cassandra',
+ debezium: 'cdc-postgres', kafka: 'stream-kafka', airflow: 'src-airflow', spark: 'stream-spark',
+ trino: 'query-trino', iceberg: 'lake-iceberg', s3: 'lake-s3', bi: 'cons-bi',
+ jupyter: 'cons-notebooks', llm: 'cons-ml',
+}
+
+const NODE_POS: Record = {}
+STAGES.forEach((stage, col) => {
+ stage.nodes.forEach((node, row) => {
+ NODE_POS[node.id] = { col, row, rows: stage.nodes.length }
+ })
+})
+
+function nodeCoords(col: number, row: number, rows: number) {
+ const colW = 100 / 5
+ const yPad = 8
+ const ySpan = 84
+ const y = yPad + ((row + 0.5) / rows) * ySpan
+ return {
+ inX: col * colW + colW * 0.08,
+ outX: col * colW + colW * 0.92,
+ y,
+ }
+}
+
+/** Curved path — arcs upward for backward (orchestration) flows */
+function flowPath(x1: number, y1: number, x2: number, y2: number, backward = false) {
+ if (backward || x2 < x1 - 2) {
+ const arcY = Math.min(y1, y2) - 14
+ return `M ${x1} ${y1} C ${x1} ${arcY}, ${x2} ${arcY}, ${x2} ${y2}`
+ }
+ const mx = (x1 + x2) / 2
+ return `M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`
+}
+
+type MetricState = Record
+
+function seedMetrics(): MetricState {
+ return {
+ postgresql: '12.4k rows/s', mysql: '8.1k rows/s', mongodb: '2.3k docs/s', cassandra: '5.6k ops/s',
+ debezium: '4 connectors active', kafka: '142 MB/s', airflow: '18 DAGs · daily 02:00 UTC',
+ spark: '6 executors live', trino: '3 queries active', iceberg: '847 tables · 2.1 TB', s3: '14.2 TB stored',
+ bi: '26 dashboards', jupyter: '12 kernels active', llm: 'Checking…',
+ }
+}
+
+function formatLlmLabel(model?: string | null): string {
+ if (!model) return 'GenAI LLM'
+ return model.replace(/\s*GPTQ$/i, '').replace(/\s*AWQ$/i, '').trim()
+}
+
+function formatLlmMetric(workload: WorkloadData | null): string {
+ const gpu = workload?.gpu
+ if (!gpu?.model) return 'Connecting…'
+ if (!gpu.inference_active) return 'Offline'
+ const gpus = gpu.gpus || []
+ const util = gpu.avg_util ?? (gpus.length ? gpus.reduce((s, g) => s + g.util_gpu, 0) / gpus.length : 0)
+ const vram = gpus.length
+ ? gpus.reduce((s, g) => s + (g.memory_used_mib / Math.max(g.memory_total_mib, 1)) * 100, 0) / gpus.length
+ : 0
+ if (util >= 1) return `${util.toFixed(0)}% GPU · live`
+ if (vram >= 50) return `Loaded · ${vram.toFixed(0)}% VRAM`
+ return 'Inference active'
+}
+
+function jitterMetric(key: string, current: string, workload: WorkloadData | null): string {
+ if (key === 'llm') return formatLlmMetric(workload)
+ const n = () => (Math.random() - 0.5) * 2
+ const fns: Record string> = {
+ postgresql: () => `${(12.4 + n() * 0.8).toFixed(1)}k rows/s`,
+ mysql: () => `${(8.1 + n() * 0.6).toFixed(1)}k rows/s`,
+ mongodb: () => `${(2.3 + n() * 0.3).toFixed(1)}k docs/s`,
+ cassandra: () => `${(5.6 + n() * 0.5).toFixed(1)}k ops/s`,
+ debezium: () => `${Math.max(3, Math.round(4 + n()))} connectors active`,
+ kafka: () => `${Math.max(80, Math.round(142 + n() * 18))} MB/s`,
+ airflow: () => `${Math.max(12, Math.round(18 + n() * 2))} DAGs · daily 02:00 UTC`,
+ spark: () => `${Math.max(4, Math.round(6 + n()))} executors live`,
+ trino: () => `${Math.max(1, Math.round(3 + n()))} queries active`,
+ iceberg: () => `${Math.round(847 + n() * 5)} tables · ${(2.1 + n() * 0.05).toFixed(1)} TB`,
+ s3: () => `${(14.2 + n() * 0.08).toFixed(1)} TB stored`,
+ bi: () => `${Math.max(20, Math.round(26 + n() * 2))} dashboards`,
+ jupyter: () => `${Math.max(8, Math.round(12 + n() * 2))} kernels active`,
+ }
+ return fns[key]?.() ?? current
+}
+
+type Props = {
+ workload: WorkloadData | null
+ animations: Record
+ selectedNodeId: string | null
+ onNodeClick: (nodeId: string) => void
+}
+
+export function PlatformTopology({ workload, animations, selectedNodeId, onNodeClick }: Props) {
+ const [metrics, setMetrics] = useState(seedMetrics)
+
+ const llmLabel = formatLlmLabel(workload?.gpu?.model)
+
+ const pipelineActive = workload?.totals?.pipeline_active ?? true
+ const anyBusy = useMemo(
+ () => Object.values(animations).some((a) => a.state !== 'idle'),
+ [animations],
+ )
+
+ const edgesLive = pipelineActive || anyBusy
+
+ useEffect(() => {
+ setMetrics((prev) => ({ ...prev, llm: formatLlmMetric(workload) }))
+ }, [workload?.gpu?.model, workload?.gpu?.inference_active, workload?.gpu?.avg_util, workload?.gpu?.gpus])
+
+ useEffect(() => {
+ const iv = setInterval(() => {
+ setMetrics((prev) => {
+ const next = { ...prev }
+ for (const k of Object.keys(next)) next[k] = jitterMetric(k, prev[k], workload)
+ return next
+ })
+ }, 2200)
+ return () => clearInterval(iv)
+ }, [workload])
+
+ const resolvedSel = selectedNodeId
+ ? Object.entries(NODE_CLICK_MAP).find(([, v]) => v === selectedNodeId)?.[0] ?? null
+ : null
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {FLOW_EDGES.map((edge, i) => {
+ const pa = NODE_POS[edge.from]
+ const pb = NODE_POS[edge.to]
+ if (!pa || !pb) return null
+ const a = nodeCoords(pa.col, pa.row, pa.rows)
+ const b = nodeCoords(pb.col, pb.row, pb.rows)
+ const backward = edge.kind === 'orchestration' && pb.col < pa.col
+ const fromX = backward ? a.inX + (a.outX - a.inX) * 0.15 : a.outX
+ const toX = backward ? b.outX - (b.outX - b.inX) * 0.15 : b.inX
+ const d = flowPath(fromX, a.y, toX, b.y, backward)
+ const live = edgesLive
+ const dur = 1.8 + (i % 5) * 0.35
+ return (
+
+
+
+ {live && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+ )
+ })}
+
+
+
+ {STAGES.map((stage) => (
+
+
+
+
+ 0{stage.num}
+
+
+
{stage.title}
+
{stage.subtitle}
+
+
+
+
+ {stage.nodes.map((node) => {
+ const label = node.id === 'llm' ? llmLabel : node.label
+ const sub = node.id === 'llm'
+ ? (workload?.gpu?.inference_active ? 'vLLM · live' : 'vLLM inference')
+ : node.sub
+ return (
+ onNodeClick(NODE_CLICK_MAP[node.id] || node.id)}
+ className={cn(
+ 'topo-node',
+ node.id === 'airflow' && 'topo-node-airflow',
+ node.id === 'llm' && workload?.gpu?.inference_active && 'topo-node-airflow',
+ resolvedSel === node.id && 'topo-node-selected',
+ )}
+ >
+ {label}
+ {sub}
+
+ {metrics[node.metricKey]}
+
+
+ )
+ })}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/ui/src/components/features/PresentationView.tsx b/ui/src/components/features/PresentationView.tsx
new file mode 100644
index 0000000..2b058b9
--- /dev/null
+++ b/ui/src/components/features/PresentationView.tsx
@@ -0,0 +1,218 @@
+import { useCallback, useEffect, useState } from 'react'
+import { ExternalLink, FileUp, Monitor, Upload } from 'lucide-react'
+import { ArchitectureDiagram } from './ArchitectureDiagram'
+import type { PresentationData, PresentationSlide } from '../../types'
+import { cn } from '../../lib/utils'
+import { subTabActive, subTabIdle } from '../../lib/tabActive'
+
+type DeckSource = 'live' | 'data-maturity' | 'atc-platform' | string
+
+const KIND_STYLES: Record = {
+ hero: 'from-blue-600/25 via-violet-600/20 to-emerald-600/15',
+ narrative: 'from-slate-600/15 to-blue-600/15',
+ topology: 'from-cyan-600/20 to-blue-800/15',
+ zone: 'from-amber-600/15 to-orange-600/10',
+ gpu: 'from-emerald-600/20 to-green-800/15',
+ agents: 'from-fuchsia-600/15 to-pink-600/10',
+ cta: 'from-blue-600/15 to-violet-600/20',
+ upload: 'from-indigo-600/15 to-purple-600/10',
+ command: 'from-sky-600/15 to-blue-600/10',
+ architecture: 'from-teal-600/15 to-cyan-600/10',
+}
+
+async function fetchDeck(id: DeckSource): Promise {
+ const ctrl = new AbortController()
+ const timeout = id === 'live' ? 45000 : 10000
+ const timer = setTimeout(() => ctrl.abort(), timeout)
+ try {
+ const url = id === 'live' ? '/api/presentation' : `/api/presentation/decks/${id}`
+ const r = await fetch(url, { signal: ctrl.signal })
+ if (!r.ok) return null
+ return (await r.json()) as PresentationData
+ } catch {
+ return null
+ } finally {
+ clearTimeout(timer)
+ }
+}
+
+export function PresentationView() {
+ const [source, setSource] = useState('live')
+ const [data, setData] = useState(null)
+ const [slideIdx, setSlideIdx] = useState(0)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [uploading, setUploading] = useState(false)
+ const [uploadMsg, setUploadMsg] = useState(null)
+ const [customDecks, setCustomDecks] = useState<{ id: string; title: string }[]>([])
+
+ const load = useCallback(async (deckId: DeckSource) => {
+ setLoading(true)
+ setError(null)
+ const d = await fetchDeck(deckId)
+ if (d && d.slides?.length) {
+ setData(d)
+ setSlideIdx(0)
+ setLoading(false)
+ return
+ }
+ if (deckId === 'live') {
+ const fallback = await fetchDeck('data-maturity')
+ if (fallback?.slides?.length) {
+ setData(fallback)
+ setSlideIdx(0)
+ setError('Live deck timeout — showing Data Maturity template. Click Refresh for live cluster data.')
+ setLoading(false)
+ return
+ }
+ }
+ setData(null)
+ setError('Could not load presentation.')
+ setLoading(false)
+ }, [])
+
+ useEffect(() => {
+ load(source)
+ fetch('/api/presentation/decks')
+ .then((r) => r.json())
+ .then((j) => {
+ const uploaded = (j.uploaded || []).map((d: { id: string; title: string }) => ({ id: d.id, title: d.title }))
+ setCustomDecks(uploaded)
+ })
+ .catch(() => {})
+ }, [source, load])
+
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ const n = data?.slides.length || 1
+ if (e.key === 'ArrowRight' || e.key === ' ') { e.preventDefault(); setSlideIdx((i) => Math.min(n - 1, i + 1)) }
+ if (e.key === 'ArrowLeft') setSlideIdx((i) => Math.max(0, i - 1))
+ if (e.key === 'f' || e.key === 'F') document.documentElement.requestFullscreen?.()
+ }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ }, [data?.slides.length])
+
+ const slides = data?.slides || []
+ const slide: PresentationSlide | undefined = slides[slideIdx]
+
+ const exportHtml = () => {
+ const id = source === 'live' ? 'live' : source
+ window.open(`/api/presentation/decks/${id}/html`, '_blank')
+ }
+
+ const onUpload = async (file: File) => {
+ setUploading(true)
+ setUploadMsg(null)
+ const fd = new FormData()
+ fd.append('file', file)
+ try {
+ const r = await fetch('/api/presentation/upload', { method: 'POST', body: fd })
+ const j = await r.json()
+ if (j.ok && j.deck) {
+ setCustomDecks((prev) => [{ id: j.deck.id, title: j.deck.title }, ...prev])
+ setSource(j.deck.id)
+ setUploadMsg(`✓ ${j.deck.slide_count} slides loaded from ${file.name}`)
+ } else {
+ setUploadMsg(j.error || 'Upload failed')
+ }
+ } catch {
+ setUploadMsg('Upload failed — check connection')
+ } finally {
+ setUploading(false)
+ }
+ }
+
+ const tabs: { id: DeckSource; label: string }[] = [
+ { id: 'live', label: 'Live Cluster' },
+ { id: 'stack-architecture', label: 'Stack Architecture' },
+ { id: 'data-maturity', label: 'Data Maturity' },
+ { id: 'atc-platform', label: 'ATC Platform' },
+ ...customDecks.map((d) => ({ id: d.id, label: d.title.slice(0, 18) })),
+ ]
+
+ return (
+
+
+
+
Presentation
+
+ Live cluster · HTML templates · PPT upload (converts via python-pptx + Docling)
+
+
+
+
+ DQ Portal
+
+
+ Docling
+
+
load(source)} className="rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">Refresh
+
Export HTML
+
+
+
+
+ {tabs.map((t) => (
+ setSource(t.id)}
+ className={cn(
+ 'rounded-md px-2.5 py-1.5 text-[10px] font-medium transition-all',
+ source === t.id ? subTabActive : subTabIdle,
+ )}
+ >
+ {t.label}
+
+ ))}
+
+
+ {uploading ? 'Uploading…' : 'PPT upload'}
+ e.target.files?.[0] && onUpload(e.target.files[0])} />
+
+
+
+ {uploadMsg &&
{uploadMsg}
}
+ {error &&
{error}
}
+
+ {loading ? (
+
+
+
Loading presentation{source === 'live' ? ' (live cluster snapshot, ~15 sec)' : '…'}
+
+ ) : !slide ? (
+
+ load(source)} className="rounded border border-border px-3 py-1 text-xs">Retry
+
+ ) : (
+ <>
+
+
+
{slide.kind || 'slide'} · {slideIdx + 1}/{slides.length}
+
{slide.title}
+ {slide.subtitle &&
{slide.subtitle}
}
+ {'animation' in slide && slide.animation && (
+
+ )}
+
+ {(slide.bullets || []).map((b: string) => (
+ ▸ {b}
+ ))}
+
+
+
+
+
setSlideIdx((i) => Math.max(0, i - 1))} className="rounded border border-border px-2 py-1 text-[10px] disabled:opacity-40">← Prev
+
+ {slides.map((_: PresentationSlide, i: number) => (
+ setSlideIdx(i)} className={cn('h-2 w-2 rounded-full', i === slideIdx ? 'scale-125 bg-docker' : 'bg-border')} />
+ ))}
+
+
= slides.length - 1} onClick={() => setSlideIdx((i) => Math.min(slides.length - 1, i + 1))} className="rounded border border-border px-2 py-1 text-[10px] disabled:opacity-40">Next →
+
+ >
+ )}
+
+ )
+}
diff --git a/ui/src/components/features/StorageView.tsx b/ui/src/components/features/StorageView.tsx
new file mode 100644
index 0000000..a7f0279
--- /dev/null
+++ b/ui/src/components/features/StorageView.tsx
@@ -0,0 +1,197 @@
+import { useCallback, useEffect, useState } from 'react'
+import { ChevronRight, Database, Download, ExternalLink, Folder, HardDrive, Loader2, RefreshCw } from 'lucide-react'
+import { cn } from '../../lib/utils'
+import { subTabActive, subTabIdle } from '../../lib/tabActive'
+
+type Bucket = { name: string; created?: string; has_objects?: boolean }
+type S3Item = { type: string; name?: string; prefix?: string; key?: string; size_human?: string; modified?: string }
+
+export function StorageView() {
+ const [health, setHealth] = useState<{ ok: boolean; endpoint?: string; bucket_names?: string[]; error?: string } | null>(null)
+ const [buckets, setBuckets] = useState([])
+ const [bucket, setBucket] = useState(null)
+ const [prefix, setPrefix] = useState('')
+ const [folders, setFolders] = useState([])
+ const [objects, setObjects] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ const loadBuckets = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const [h, b] = await Promise.all([
+ fetch('/api/storage/s3/health'),
+ fetch('/api/storage/s3/buckets'),
+ ])
+ if (h.ok) setHealth(await h.json())
+ if (b.ok) {
+ const j = await b.json()
+ setBuckets(j.buckets || [])
+ if (!bucket && j.buckets?.length) setBucket(j.buckets[0].name)
+ } else {
+ setError('Failed to load buckets')
+ }
+ } catch {
+ setError('S3 API unavailable')
+ } finally {
+ setLoading(false)
+ }
+ }, [bucket])
+
+ const loadObjects = useCallback(async (b: string, p: string) => {
+ setLoading(true)
+ setError(null)
+ try {
+ const r = await fetch(`/api/storage/s3/buckets/${encodeURIComponent(b)}/objects?prefix=${encodeURIComponent(p)}`)
+ const j = await r.json()
+ if (!r.ok || !j.ok) {
+ setError(j.error || 'List failed')
+ return
+ }
+ setFolders(j.folders || [])
+ setObjects(j.objects || [])
+ } catch {
+ setError('Failed to list objects')
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ loadBuckets()
+ }, [loadBuckets])
+
+ useEffect(() => {
+ if (bucket) loadObjects(bucket, prefix)
+ }, [bucket, prefix, loadObjects])
+
+ const crumbs = prefix ? prefix.split('/').filter(Boolean) : []
+
+ return (
+
+
+
+
+
+ Buckets
+
+ {buckets.map((b) => (
+
{ setBucket(b.name); setPrefix('') }}
+ className={cn(
+ 'flex w-full items-center gap-2 rounded border px-2 py-1.5 text-left text-[10px]',
+ bucket === b.name ? 'border-docker/40 bg-docker/10' : 'border-border hover:bg-surface-overlay',
+ )}
+ >
+
+ {b.name}
+
+ ))}
+ {buckets.length === 0 && !loading && (
+
No buckets or access denied.
+ )}
+
+
+
+
+ {bucket && (
+
+ setPrefix('')}>{bucket}
+ {crumbs.map((c, i) => (
+
+
+ setPrefix(crumbs.slice(0, i + 1).join('/') + '/')}
+ >
+ {c}
+
+
+ ))}
+
+ )}
+
+ {loading && (
+
+ Loading…
+
+ )}
+ {error &&
{error}
}
+
+
+
+
+
+ Name
+ Size
+ Modified
+
+
+
+
+ {folders.map((f) => (
+
+
+ setPrefix(f.prefix || '')}
+ >
+ {f.name}/
+
+
+ —
+ —
+
+
+ ))}
+ {objects.map((o) => (
+
+ {o.name || o.key}
+ {o.size_human}
+ {o.modified?.slice(0, 19) || '—'}
+
+ {o.key && bucket && (
+
+
+
+ )}
+
+
+ ))}
+
+
+ {!loading && folders.length === 0 && objects.length === 0 && bucket && (
+
This prefix is empty.
+ )}
+
+
+
+
+ )
+}
diff --git a/ui/src/components/features/TerminalDock.tsx b/ui/src/components/features/TerminalDock.tsx
new file mode 100644
index 0000000..7c41461
--- /dev/null
+++ b/ui/src/components/features/TerminalDock.tsx
@@ -0,0 +1,72 @@
+import { useEffect, useRef } from 'react'
+import { Terminal } from 'lucide-react'
+import type { TerminalLine } from '../../types'
+import { resolveInfraNode } from '../../lib/infraCatalog'
+import { cn } from '../../lib/utils'
+
+type Props = {
+ subjectId: string | null
+ subjectLabel: string
+ lines: TerminalLine[]
+ busy: boolean
+ expanded: boolean
+ onToggle: () => void
+}
+
+const LEVEL: Record = {
+ info: 'text-foreground-muted',
+ ok: 'text-success',
+ warn: 'text-warning',
+ err: 'text-danger',
+ cmd: 'text-docker',
+ llm: 'text-violet-400',
+}
+
+export function TerminalDock({ subjectId, subjectLabel, lines, busy, expanded, onToggle }: Props) {
+ const bottomRef = useRef(null)
+ const infra = resolveInfraNode(subjectId)
+
+ useEffect(() => {
+ if (expanded) bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
+ }, [lines, busy, expanded])
+
+ return (
+
+
+
+
+ Terminal — {subjectLabel}
+ {busy && ● live }
+
+ {lines.length} lines · {expanded ? '▼' : '▲'}
+
+
+ {expanded && (
+
+ {infra && (
+
+ $ {infra.ssh} (gekopieerd bij Shell-knop)
+
+ )}
+ {lines.length === 0 && (
+
Selecteer een node of agent · klik Shell of Probe om output te zien
+ )}
+ {lines.map((line) => (
+
+
+ {line.ts ? new Date(line.ts).toLocaleTimeString('en-US', { hour12: false }) : ''}
+ {' '}
+ [{line.phase}] {line.text}
+
+ ))}
+ {busy &&
█ }
+
+
+ )}
+
+ )
+}
diff --git a/ui/src/components/layout/SideNav.tsx b/ui/src/components/layout/SideNav.tsx
new file mode 100644
index 0000000..85c710f
--- /dev/null
+++ b/ui/src/components/layout/SideNav.tsx
@@ -0,0 +1,162 @@
+import { DatabaseZap, HardDrive, LayoutDashboard, MessageSquare, Presentation, ShieldCheck } from 'lucide-react'
+import type { Agent, AgentAnim, GpuStatus } from '../../types'
+import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
+import { getAgentMeta } from '../../lib/agentMeta'
+import { cn } from '../../lib/utils'
+import { viewTabActive, viewTabIdle } from '../../lib/tabActive'
+import { GpuMatrixPanel } from '../features/GpuMatrixPanel'
+
+type MainView = 'platform' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'approvals'
+
+type Props = {
+ agents: Agent[]
+ animations: Record
+ gpu: GpuStatus | null
+ gpuLive: GpuLiveMetrics
+ gpuBoost?: boolean
+ selectedAgentId: string | null
+ selectedNodeId: string | null
+ mainView: MainView
+ approvalCount: number
+ agentsLoading: boolean
+ onSetMainView: (view: MainView) => void
+ onOpenApprovals: () => void
+ onSelectAgent: (id: string) => void
+ onSelectZone: (id: string) => void
+}
+
+const VIEWS: { id: MainView; label: string; icon: typeof LayoutDashboard }[] = [
+ { id: 'platform', label: 'Data Platform', icon: LayoutDashboard },
+ { id: 'presentation', label: 'Presentation', icon: Presentation },
+ { id: 'dataquality', label: 'Data Quality', icon: DatabaseZap },
+ { id: 'knowledge', label: 'Knowledge Chat', icon: MessageSquare },
+ { id: 'storage', label: 'Object Storage', icon: HardDrive },
+]
+
+export function SideNav({
+ agents,
+ animations,
+ gpu,
+ gpuLive,
+ gpuBoost = false,
+ selectedAgentId,
+ selectedNodeId,
+ mainView,
+ approvalCount,
+ agentsLoading,
+ onSetMainView,
+ onOpenApprovals,
+ onSelectAgent,
+ onSelectZone,
+}: Props) {
+ const supervisors = agents.filter((a) => a.supervisor)
+ const operators = agents.filter((a) => !a.supervisor)
+ const matrixBoost = gpuBoost || mainView === 'knowledge'
+
+ return (
+
+
+ Views
+
+ {VIEWS.map(({ id, label, icon: Icon }) => (
+ onSetMainView(id)}
+ className={cn(
+ 'flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left transition-all',
+ mainView === id ? viewTabActive : viewTabIdle,
+ )}
+ >
+
+ {label}
+
+ ))}
+
+
+
+ onSelectZone('gpu')}
+ />
+
+
+
+
Agents
+ 0
+ ? 'border-warning/40 bg-warning/10 text-warning'
+ : 'border-border text-foreground-muted hover:border-border-strong',
+ )}
+ >
+
+ Approvals
+ {approvalCount > 0 && {approvalCount} }
+
+
+
+ {agentsLoading && agents.length === 0 && (
+
Loading agents…
+ )}
+ {supervisors.length > 0 && (
+
Supervisors
+ )}
+ {supervisors.map((a) => (
+
+ ))}
+ {operators.length > 0 && (
+
Field operators
+ )}
+ {operators.map((a) => (
+
+ ))}
+
+
+
+ )
+}
+
+function AgentRow({
+ agent,
+ animations,
+ selected,
+ onSelect,
+}: {
+ agent: Agent
+ animations: Record
+ selected: boolean
+ onSelect: (id: string) => void
+}) {
+ const meta = getAgentMeta(agent.id)
+ const Icon = meta.icon
+ const busy = (animations[agent.id]?.state || 'idle') !== 'idle'
+ return (
+ onSelect(agent.id)}
+ className={cn(
+ 'flex w-full items-center gap-2 rounded-md border px-2 py-2 text-left transition-colors',
+ selected ? 'border-docker/40 bg-docker-light' : 'border-transparent hover:border-border hover:bg-surface-overlay',
+ )}
+ style={busy ? { boxShadow: `inset 3px 0 0 0 ${meta.accent}` } : undefined}
+ >
+
+
+
+
+ {agent.name.split(' ·')[0]}
+ {meta.domain}
+ {agent.role}
+
+ {(agent.stats?.tasks ?? 0) > 0 && (
+ {agent.stats?.tasks}
+ )}
+
+ )
+}
diff --git a/ui/src/components/layout/ThemeToggle.tsx b/ui/src/components/layout/ThemeToggle.tsx
new file mode 100644
index 0000000..d1588c9
--- /dev/null
+++ b/ui/src/components/layout/ThemeToggle.tsx
@@ -0,0 +1,25 @@
+import { Moon, Sun } from 'lucide-react'
+import { useTheme } from '../../context/ThemeContext'
+import { cn } from '../../lib/utils'
+
+export function ThemeToggle() {
+ const { theme, toggle } = useTheme()
+ const isDark = theme === 'dark'
+
+ return (
+
+ {isDark ? : }
+ {isDark ? 'Dark' : 'Light'}
+
+ )
+}
diff --git a/ui/src/components/layout/TopBar.tsx b/ui/src/components/layout/TopBar.tsx
new file mode 100644
index 0000000..99f7cb7
--- /dev/null
+++ b/ui/src/components/layout/TopBar.tsx
@@ -0,0 +1,65 @@
+import { Activity, Bot, Box, Clock, ShieldAlert } from 'lucide-react'
+import type { Agent, Approval, StatusData, WorkloadData } from '../../types'
+import { Badge } from '../ui/Badge'
+import { ThemeToggle } from './ThemeToggle'
+import { cn } from '../../lib/utils'
+
+type Props = {
+ clock: string
+ status: StatusData | null
+ workload: WorkloadData | null
+ agents: Agent[]
+ approvals: Approval[]
+ onApprovalsClick: () => void
+}
+
+export function TopBar({ clock, workload, agents, approvals, onApprovalsClick }: Props) {
+ const pipelineOk = workload?.totals?.pipeline_active ?? false
+ const running = workload?.totals?.apps_running ?? 0
+ const total = workload?.totals?.apps_total ?? 0
+ const activeAgents = agents.filter((a) => (a.stats?.tasks ?? 0) > 0).length
+
+ return (
+
+
+
+
+
+
+
Data & AI Command Center
+
ATC Lab · Enterprise Operations
+
+
+
+
+
+
+ Pipeline {pipelineOk ? 'active' : 'degraded'}
+
+
{running}/{total} containers
+
+
+ {activeAgents} agents
+
+
+
+
+ {approvals.length}
+
+
+
+
+
+
+ )
+}
diff --git a/ui/src/components/ui/Badge.tsx b/ui/src/components/ui/Badge.tsx
new file mode 100644
index 0000000..9d91381
--- /dev/null
+++ b/ui/src/components/ui/Badge.tsx
@@ -0,0 +1,25 @@
+import { cva, type VariantProps } from 'class-variance-authority'
+import type { HTMLAttributes } from 'react'
+import { cn } from '../../lib/utils'
+
+const badgeVariants = cva(
+ 'inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[10px] font-medium',
+ {
+ variants: {
+ variant: {
+ default: 'border-border bg-surface-overlay text-foreground-muted dark:bg-surface-overlay dark:text-foreground-muted',
+ accent: 'border-docker/30 bg-docker-light text-docker dark:border-blue-400/30 dark:bg-blue-500/15 dark:text-blue-200',
+ success: 'border-success/30 bg-green-50 text-green-700 dark:border-green-500/30 dark:bg-green-500/15 dark:text-green-300',
+ warning: 'border-warning/30 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-300',
+ danger: 'border-danger/30 bg-red-50 text-red-700 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-300',
+ },
+ },
+ defaultVariants: { variant: 'default' },
+ },
+)
+
+type Props = HTMLAttributes & VariantProps
+
+export function Badge({ className, variant, ...props }: Props) {
+ return
+}
diff --git a/ui/src/components/ui/Button.tsx b/ui/src/components/ui/Button.tsx
new file mode 100644
index 0000000..0bd345d
--- /dev/null
+++ b/ui/src/components/ui/Button.tsx
@@ -0,0 +1,30 @@
+import { cva, type VariantProps } from 'class-variance-authority'
+import type { ButtonHTMLAttributes } from 'react'
+import { cn } from '../../lib/utils'
+
+const buttonVariants = cva(
+ 'inline-flex items-center justify-center gap-1.5 rounded-md border font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-docker/40 disabled:pointer-events-none disabled:opacity-50',
+ {
+ variants: {
+ variant: {
+ default: 'border-docker/30 bg-docker text-foreground hover:bg-docker-dark',
+ ghost: 'border-transparent text-foreground-muted hover:bg-surface-overlay hover:text-foreground',
+ outline: 'border-border bg-surface-raised text-foreground hover:bg-surface-overlay',
+ success: 'border-success/30 bg-green-600 text-foreground hover:bg-green-700',
+ danger: 'border-danger/30 bg-red-600 text-foreground hover:bg-red-700',
+ },
+ size: {
+ sm: 'h-7 px-2.5 text-xs',
+ md: 'h-8 px-3 text-sm',
+ icon: 'h-8 w-8',
+ },
+ },
+ defaultVariants: { variant: 'default', size: 'md' },
+ },
+)
+
+type Props = ButtonHTMLAttributes & VariantProps
+
+export function Button({ className, variant, size, ...props }: Props) {
+ return
+}
diff --git a/ui/src/components/ui/Card.tsx b/ui/src/components/ui/Card.tsx
new file mode 100644
index 0000000..6ebd0f4
--- /dev/null
+++ b/ui/src/components/ui/Card.tsx
@@ -0,0 +1,26 @@
+import type { HTMLAttributes } from 'react'
+import { cn } from '../../lib/utils'
+
+type Props = HTMLAttributes & {
+ padding?: boolean
+}
+
+export function Card({ className, padding = true, children, ...props }: Props) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function CardHeader({ className, ...props }: HTMLAttributes) {
+ return
+}
+
+export function CardTitle({ className, ...props }: HTMLAttributes) {
+ return
+}
+
+export function CardDescription({ className, ...props }: HTMLAttributes) {
+ return
+}
diff --git a/ui/src/components/ui/Input.tsx b/ui/src/components/ui/Input.tsx
new file mode 100644
index 0000000..f3a46e7
--- /dev/null
+++ b/ui/src/components/ui/Input.tsx
@@ -0,0 +1,16 @@
+import type { InputHTMLAttributes } from 'react'
+import { cn } from '../../lib/utils'
+
+type Props = InputHTMLAttributes
+
+export function Input({ className, ...props }: Props) {
+ return (
+
+ )
+}
diff --git a/ui/src/context/ThemeContext.tsx b/ui/src/context/ThemeContext.tsx
new file mode 100644
index 0000000..bc93242
--- /dev/null
+++ b/ui/src/context/ThemeContext.tsx
@@ -0,0 +1,44 @@
+import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
+
+export type Theme = 'light' | 'dark'
+
+type ThemeContextValue = {
+ theme: Theme
+ toggle: () => void
+}
+
+const ThemeContext = createContext(null)
+const STORAGE_KEY = 'atc-command-center-theme'
+
+function readStored(): Theme {
+ const v = localStorage.getItem(STORAGE_KEY)
+ return v === 'dark' || v === 'light' ? v : 'light'
+}
+
+export function ThemeProvider({ children }: { children: ReactNode }) {
+ const [theme, setTheme] = useState(() => {
+ if (typeof window === 'undefined') return 'light'
+ return readStored()
+ })
+
+ useEffect(() => {
+ const root = document.documentElement
+ root.classList.remove('light', 'dark')
+ root.classList.add(theme)
+ localStorage.setItem(STORAGE_KEY, theme)
+ }, [theme])
+
+ const toggle = () => setTheme((t) => (t === 'light' ? 'dark' : 'light'))
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useTheme() {
+ const ctx = useContext(ThemeContext)
+ if (!ctx) throw new Error('useTheme outside ThemeProvider')
+ return ctx
+}
diff --git a/ui/src/hooks/useClock.ts b/ui/src/hooks/useClock.ts
new file mode 100644
index 0000000..31880ef
--- /dev/null
+++ b/ui/src/hooks/useClock.ts
@@ -0,0 +1,10 @@
+import { useEffect, useState } from 'react'
+
+export function useClock() {
+ const [now, setNow] = useState(new Date())
+ useEffect(() => {
+ const t = setInterval(() => setNow(new Date()), 1000)
+ return () => clearInterval(t)
+ }, [])
+ return now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
+}
diff --git a/ui/src/hooks/useCommandCenter.ts b/ui/src/hooks/useCommandCenter.ts
new file mode 100644
index 0000000..53d3bda
--- /dev/null
+++ b/ui/src/hooks/useCommandCenter.ts
@@ -0,0 +1,331 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import {
+ askNode as apiAskNode,
+ decideApproval,
+ fetchAgents,
+ fetchApprovals,
+ fetchFeed,
+ fetchGpu,
+ fetchNodeDetail,
+ fetchStatus,
+ fetchTerminals,
+ fetchWorkload,
+ probeNode as apiProbeNode,
+ sendPrompt as apiSendPrompt,
+} from '../lib/api'
+import { AGENT_NODE, NODE_ALIASES, wsUrl } from '../lib/constants'
+import { resolveInfraNode } from '../lib/infraCatalog'
+import type {
+ Agent,
+ AgentAnim,
+ Approval,
+ ChatMessage,
+ FeedEntry,
+ GpuStatus,
+ NodeDetail,
+ StatusData,
+ TerminalLine,
+ TopologyNode,
+ WorkloadData,
+} from '../types'
+
+function resolveProbeId(nodeId: string) {
+ const aliased = NODE_ALIASES[nodeId] || nodeId
+ const infra = resolveInfraNode(nodeId) || resolveInfraNode(aliased)
+ return infra?.id || aliased
+}
+
+export function useCommandCenter() {
+ const [agents, setAgents] = useState([])
+ const [agentsLoading, setAgentsLoading] = useState(true)
+ const [status, setStatus] = useState(null)
+ const [workload, setWorkload] = useState(null)
+ const [gpu, setGpu] = useState(null)
+ const [feed, setFeed] = useState([])
+ const [approvals, setApprovals] = useState([])
+ const [chat, setChat] = useState([])
+ const [anims, setAnims] = useState>({})
+ const [selectedAgentId, setSelectedAgentId] = useState(null)
+ const [promptBusy, setPromptBusy] = useState(false)
+ const [terminals, setTerminals] = useState>({})
+ const [selectedNodeId, setSelectedNodeId] = useState(null)
+ const [selectedNode, setSelectedNode] = useState(null)
+ const [nodeDetail, setNodeDetail] = useState(null)
+ const [nodeBusy, setNodeBusy] = useState(false)
+ const [mainView, setMainView] = useState<'platform' | 'approvals' | 'presentation' | 'dataquality' | 'knowledge' | 'storage'>('platform')
+ const [approvalHighlight, setApprovalHighlight] = useState(false)
+ const [chatExpanded, setChatExpanded] = useState(false)
+ const promptTimeoutRef = useRef | null>(null)
+ const [terminalExpanded, setTerminalExpanded] = useState(true)
+
+ const appendTerminal = useCallback((line: TerminalLine) => {
+ setTerminals((prev) => {
+ const cur = prev[line.agent_id] || []
+ return { ...prev, [line.agent_id]: [...cur, line].slice(-300) }
+ })
+ }, [])
+
+ const selectedAgent = useMemo(
+ () => agents.find((a) => a.id === selectedAgentId) || null,
+ [agents, selectedAgentId],
+ )
+
+ const reloadFast = useCallback(async () => {
+ const [a, s, f, ap, g, t] = await Promise.all([
+ fetchAgents(),
+ fetchStatus(),
+ fetchFeed(),
+ fetchApprovals(),
+ fetchGpu(),
+ fetchTerminals(),
+ ])
+ setAgents(a)
+ setAgentsLoading(false)
+ setStatus(s)
+ setGpu(g || s?.gpu || null)
+ setFeed(f)
+ setApprovals(ap)
+ setTerminals(t)
+ }, [])
+
+ const reloadWorkload = useCallback(async () => {
+ const w = await fetchWorkload()
+ if (w?.zones) setWorkload(w)
+ }, [])
+
+ const reload = useCallback(async () => {
+ await reloadFast()
+ reloadWorkload()
+ }, [reloadFast, reloadWorkload])
+
+ useEffect(() => {
+ reloadFast().then(() => reloadWorkload())
+ const ws = new WebSocket(wsUrl())
+ ws.onmessage = (ev) => {
+ const msg = JSON.parse(ev.data)
+ if (msg.type === 'status') {
+ setStatus(msg.data)
+ if (msg.data.gpu) setGpu(msg.data.gpu)
+ }
+ if (msg.type === 'workload') setWorkload(msg.data)
+ if (msg.type === 'terminal') appendTerminal(msg.line)
+ if (msg.type === 'terminal_history' && msg.terminals) setTerminals(msg.terminals)
+ if (msg.type === 'feed') setFeed((prev) => [msg.entry, ...prev].slice(0, 100))
+ if (msg.type === 'agent_dispatch') {
+ setSelectedAgentId(msg.agent_id)
+ setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'walk', zone: msg.zone } }))
+ }
+ if (msg.type === 'agent_fetch') {
+ setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'fetch', zone: msg.zone } }))
+ }
+ if (msg.type === 'agent_return') {
+ setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'return', zone: msg.zone } }))
+ setTimeout(() => {
+ setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'idle' } }))
+ }, 1200)
+ }
+ if (msg.type === 'prompt_result') {
+ setChat((c) => [...c, { role: 'agent', text: msg.answer, agent: msg.agent_id, ts: new Date().toISOString() }])
+ setPromptBusy(false)
+ reloadFast()
+ }
+ if (msg.type === 'approval_new') {
+ setApprovals((prev) => {
+ if (prev.some((a) => a.id === msg.approval?.id)) return prev
+ return [msg.approval, ...prev]
+ })
+ if (msg.approval?.status === 'pending') setApprovalHighlight(true)
+ }
+ if (msg.type === 'approval_update' && msg.approval) {
+ setApprovals((prev) => prev.filter((a) => a.id !== msg.approval.id))
+ }
+ if (msg.type === 'node_ask_result') {
+ setNodeBusy(false)
+ if (msg.agent_id) setSelectedAgentId(msg.agent_id)
+ }
+ }
+ const iv = setInterval(reloadFast, 15000)
+ const wv = setInterval(reloadWorkload, 45000)
+ return () => { ws.close(); clearInterval(iv); clearInterval(wv) }
+ }, [reloadFast, reloadWorkload, appendTerminal])
+
+ const findNodeStub = useCallback((nodeId: string): TopologyNode | null => {
+ const resolved = NODE_ALIASES[nodeId] || nodeId
+ const allNodes = [
+ ...(workload?.topology?.nodes || []),
+ ...Object.values(workload?.topologies || {}).flatMap((v) => v.nodes),
+ ]
+ const wn = allNodes.find((n) => n.id === nodeId) || allNodes.find((n) => n.id === resolved)
+ if (wn) return wn
+
+ const infra = resolveInfraNode(nodeId) || resolveInfraNode(resolved)
+ if (infra) {
+ return {
+ id: infra.id,
+ label: infra.label,
+ vm: infra.vm,
+ ip: infra.ip,
+ x: 50,
+ y: 50,
+ color: infra.accent,
+ level: 'ok',
+ role: infra.description,
+ apps: infra.apps.map((a) => ({ name: a.label, state: 'link', image: a.url, ports: a.port ? [a.port] : [] })),
+ running: 1,
+ total: 1,
+ }
+ }
+
+ const agent = agents.find((a) => a.id === nodeId)
+ if (agent) {
+ return {
+ id: agent.id, label: agent.name, vm: 'agent', ip: '10.0.21.33',
+ x: 50, y: 50, color: agent.color, level: 'ok', role: agent.role, apps: [], running: 1, total: 1,
+ agent_id: agent.id,
+ }
+ }
+ const zone = workload?.zones.find((z) => z.id === nodeId || z.id === resolved)
+ if (zone) {
+ return {
+ id: nodeId, label: zone.label, vm: zone.vm || zone.id, ip: zone.ip || '',
+ x: 50, y: 50, color: zone.color, level: zone.level, role: 'zone', apps: zone.apps,
+ running: zone.running, total: zone.total,
+ }
+ }
+ return null
+ }, [workload, agents])
+
+ const probeNodeId = useCallback((nodeId: string) => {
+ setNodeBusy(true)
+ setTerminalExpanded(true)
+ apiProbeNode(resolveProbeId(nodeId)).finally(() => setNodeBusy(false))
+ }, [])
+
+ const openTerminal = useCallback((nodeId: string) => {
+ setTerminalExpanded(true)
+ setSelectedNodeId(nodeId)
+ }, [])
+
+ const selectNode = useCallback(async (nodeId: string) => {
+ const stub = findNodeStub(nodeId)
+ if (!stub) return
+ const probeId = resolveProbeId(nodeId)
+ setSelectedNodeId(probeId)
+ setSelectedNode({ ...stub, id: probeId })
+ setNodeDetail(null)
+ setTerminalExpanded(true)
+ const infra = resolveInfraNode(nodeId)
+ const linked = agents.find(
+ (a) => a.id === nodeId || AGENT_NODE[a.id] === probeId || a.id === infra?.agentId,
+ )
+ if (linked) setSelectedAgentId(linked.id)
+ try {
+ const detail = await fetchNodeDetail(probeId)
+ if (!detail.error) setNodeDetail(detail as NodeDetail)
+ } catch { /* ok */ }
+ probeNodeId(nodeId)
+ }, [findNodeStub, agents, probeNodeId])
+
+ const selectAgent = useCallback((id: string) => {
+ setSelectedAgentId(id)
+ setTerminalExpanded(true)
+ const agent = agents.find((a) => a.id === id)
+ if (!agent) return
+ const nodeId = agent.supervisor ? id : (AGENT_NODE[id] || agent.zone)
+ if (nodeId) {
+ const stub = findNodeStub(nodeId)
+ if (stub) {
+ selectNode(nodeId)
+ return
+ }
+ }
+ setSelectedNodeId(null)
+ setSelectedNode(null)
+ setNodeDetail(null)
+ }, [agents, findNodeStub, selectNode])
+
+ const clearSelection = useCallback(() => {
+ setSelectedNodeId(null)
+ setSelectedNode(null)
+ setNodeDetail(null)
+ setSelectedAgentId(null)
+ }, [])
+
+ const probeNode = useCallback(() => {
+ if (selectedNodeId) probeNodeId(selectedNodeId)
+ }, [selectedNodeId, probeNodeId])
+
+ const askNode = useCallback(async (message: string) => {
+ if (!selectedNodeId) return
+ setNodeBusy(true)
+ setTerminalExpanded(true)
+ await apiAskNode(resolveProbeId(selectedNodeId), message)
+ }, [selectedNodeId])
+
+ const sendPrompt = useCallback(async (message: string, agentId?: string) => {
+ setPromptBusy(true)
+ setChatExpanded(true)
+ setChat((c) => [...c, { role: 'user', text: message, ts: new Date().toISOString() }])
+ if (agentId) setSelectedAgentId(agentId)
+ await apiSendPrompt(message, agentId)
+ }, [])
+
+ const decide = useCallback(async (id: string, approved: boolean, decidedBy = 'mo-commander', note = '') => {
+ setApprovals((prev) => prev.filter((a) => a.id !== id))
+ await decideApproval(id, approved, decidedBy, note)
+ reloadFast()
+ }, [reloadFast])
+
+ const terminalSubjectId = selectedNodeId || selectedAgentId
+
+ const inspectorLines = useMemo(() => {
+ if (!terminalSubjectId) return []
+ const probeId = resolveProbeId(terminalSubjectId)
+ return terminals[probeId] || terminals[terminalSubjectId] || terminals[selectedAgentId || ''] || []
+ }, [terminalSubjectId, terminals, selectedAgentId])
+
+ const focusApprovals = useCallback(() => {
+ setApprovalHighlight(true)
+ setMainView('approvals')
+ }, [])
+
+ return {
+ agents,
+ agentsLoading,
+ status,
+ workload,
+ gpu,
+ feed,
+ approvals,
+ chat,
+ anims,
+ selectedAgentId,
+ selectedAgent,
+ promptBusy,
+ selectedNodeId,
+ selectedNode,
+ nodeDetail,
+ nodeBusy,
+ mainView,
+ setMainView,
+ approvalHighlight,
+ setApprovalHighlight,
+ inspectorLines,
+ terminalSubjectId,
+ terminalExpanded,
+ setTerminalExpanded,
+ selectNode,
+ selectAgent,
+ clearSelection,
+ probeNode,
+ probeNodeId,
+ openTerminal,
+ askNode,
+ sendPrompt,
+ decide,
+ focusApprovals,
+ reload,
+ chatExpanded,
+ setChatExpanded,
+ }
+}
diff --git a/ui/src/hooks/useLiveMetrics.ts b/ui/src/hooks/useLiveMetrics.ts
new file mode 100644
index 0000000..1566d17
--- /dev/null
+++ b/ui/src/hooks/useLiveMetrics.ts
@@ -0,0 +1,120 @@
+import { useEffect, useRef, useState } from 'react'
+import type { Agent, AgentAnim, GpuStatus } from '../types'
+import { pseudoAgentLoad } from '../lib/agentMeta'
+
+export type AgentLoad = { cpu: number; mem: number }
+
+export type GpuLiveMetrics = {
+ tokenThroughput: number
+ avgUtil: number
+ avgVram: number
+ deviceUtils: number[]
+}
+
+function clamp(n: number, min: number, max: number) {
+ return Math.min(max, Math.max(min, n))
+}
+
+function memPct(used: number, total: number) {
+ if (!total) return 0
+ return Math.round((used / total) * 100)
+}
+
+export function useLiveMetrics(
+ agents: Agent[],
+ gpu: GpuStatus | null,
+ animations: Record,
+ boost = false,
+) {
+ const [agentLoads, setAgentLoads] = useState>({})
+ const [gpuLive, setGpuLive] = useState({
+ tokenThroughput: 0,
+ avgUtil: 0,
+ avgVram: 0,
+ deviceUtils: [],
+ })
+ const loadsRef = useRef(agentLoads)
+ loadsRef.current = agentLoads
+
+ useEffect(() => {
+ const seed: Record = {}
+ for (const a of agents) {
+ seed[a.id] = pseudoAgentLoad(a.stats)
+ }
+ setAgentLoads(seed)
+
+ const gpus = gpu?.gpus || []
+ const baseUtil = gpus.length ? gpus.reduce((s, g) => s + g.util_gpu, 0) / gpus.length : 0
+ const baseVram = gpus.length
+ ? gpus.reduce((s, g) => s + memPct(g.memory_used_mib, g.memory_total_mib), 0) / gpus.length
+ : 0
+ const inferenceOn = gpu?.ok && gpu.inference_active
+ setGpuLive({
+ tokenThroughput: inferenceOn ? Math.round(baseUtil * 42 + 120) : 0,
+ avgUtil: baseUtil,
+ avgVram: baseVram,
+ deviceUtils: gpus.map((g) => g.util_gpu),
+ })
+ }, [agents, gpu])
+
+ useEffect(() => {
+ const tick = () => {
+ setAgentLoads((prev) => {
+ const next: Record = {}
+ for (const a of agents) {
+ const busy = (animations[a.id]?.state || 'idle') !== 'idle'
+ const base = pseudoAgentLoad(a.stats)
+ const cur = prev[a.id] || base
+ const drift = (Math.random() - 0.5) * (busy ? 7 : 2.5)
+ const driftMem = (Math.random() - 0.5) * (busy ? 5 : 2)
+ const targetCpu = busy ? Math.max(base.cpu, cur.cpu) : base.cpu
+ const targetMem = busy ? Math.max(base.mem, cur.mem) : base.mem
+ next[a.id] = {
+ cpu: clamp(Math.round(cur.cpu + drift + (busy ? 1.2 : -0.3)), 4, 96),
+ mem: clamp(Math.round(cur.mem + driftMem + (busy ? 0.8 : -0.2)), 6, 92),
+ }
+ if (!busy) {
+ next[a.id].cpu = clamp(Math.round(next[a.id].cpu * 0.85 + targetCpu * 0.15), 4, 96)
+ next[a.id].mem = clamp(Math.round(next[a.id].mem * 0.85 + targetMem * 0.15), 6, 92)
+ }
+ }
+ return next
+ })
+
+ if (gpu?.ok) {
+ const gpus = gpu.gpus || []
+ const inferenceOn = gpu.inference_active
+ setGpuLive((prev) => {
+ const baseUtil = gpus.length
+ ? gpus.reduce((s, g) => s + g.util_gpu, 0) / gpus.length
+ : prev.avgUtil
+ const baseVram = gpus.length
+ ? gpus.reduce((s, g) => s + memPct(g.memory_used_mib, g.memory_total_mib), 0) / gpus.length
+ : prev.avgVram
+ const jitterScale = boost ? 12 : 6
+ const utilJitter = (Math.random() - 0.5) * (inferenceOn ? jitterScale : 2)
+ const avgUtil = clamp(baseUtil + utilJitter, 0, 100)
+ const avgVram = clamp(baseVram + (Math.random() - 0.5) * 3, 0, 100)
+ const deviceUtils = gpus.map((g, i) => {
+ const real = g.util_gpu
+ if (boost && inferenceOn) {
+ return clamp(real + (Math.random() - 0.5) * 8, 0, 100)
+ }
+ return clamp((prev.deviceUtils[i] ?? real) + (Math.random() - 0.5) * 5, 0, 100)
+ })
+ const tokenBase = boost ? Math.max(180, baseUtil * 55 + 140) : baseUtil * 42 + 120
+ const tokenThroughput = inferenceOn
+ ? clamp(Math.round(prev.tokenThroughput * 0.4 + tokenBase * 0.6 + (Math.random() - 0.5) * (boost ? 45 : 28)), boost ? 120 : 80, boost ? 520 : 420)
+ : 0
+ return { tokenThroughput, avgUtil, avgVram, deviceUtils }
+ })
+ }
+ }
+
+ tick()
+ const id = setInterval(tick, boost ? 1000 : 5000)
+ return () => clearInterval(id)
+ }, [agents, animations, gpu, boost])
+
+ return { agentLoads, gpuLive }
+}
diff --git a/ui/src/index.css b/ui/src/index.css
deleted file mode 100644
index f177940..0000000
--- a/ui/src/index.css
+++ /dev/null
@@ -1,57 +0,0 @@
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
-
-body {
- margin: 0;
- min-height: 100vh;
- background: linear-gradient(165deg, #f0f4ff 0%, #e8eef9 35%, #f5f0ff 70%, #eef8ff 100%);
- background-attachment: fixed;
-}
-
-body::before {
- content: '';
- position: fixed;
- inset: 0;
- background-image:
- linear-gradient(rgba(0, 140, 200, 0.04) 1px, transparent 1px),
- linear-gradient(90deg, rgba(0, 140, 200, 0.04) 1px, transparent 1px);
- background-size: 48px 48px;
- pointer-events: none;
- z-index: 0;
-}
-
-#root {
- position: relative;
- z-index: 1;
-}
-
-.glass {
- background: rgba(255, 255, 255, 0.82);
- backdrop-filter: blur(16px);
- border: 1px solid rgba(0, 160, 220, 0.18);
- box-shadow:
- 0 4px 24px rgba(15, 40, 80, 0.06),
- 0 1px 0 rgba(255, 255, 255, 0.9) inset;
-}
-
-.glass-strong {
- background: rgba(255, 255, 255, 0.94);
- backdrop-filter: blur(20px);
- border: 1px solid rgba(0, 160, 220, 0.22);
- box-shadow: 0 8px 32px rgba(15, 40, 80, 0.08);
-}
-
-.neon-text-cyan {
- text-shadow: 0 0 24px rgba(0, 180, 220, 0.35);
-}
-
-.status-card {
- background: linear-gradient(145deg, #ffffff 0%, #f8fbff 100%);
- transition: transform 0.15s ease, box-shadow 0.15s ease;
-}
-
-.status-card:hover {
- transform: translateY(-2px);
- box-shadow: 0 8px 24px rgba(15, 40, 80, 0.1);
-}
diff --git a/ui/src/lib/agentMeta.ts b/ui/src/lib/agentMeta.ts
new file mode 100644
index 0000000..4312cc2
--- /dev/null
+++ b/ui/src/lib/agentMeta.ts
@@ -0,0 +1,126 @@
+import type { LucideIcon } from 'lucide-react'
+import {
+ Bot,
+ Cpu,
+ Database,
+ Layers,
+ Network,
+ Radio,
+ Server,
+ Shield,
+ TreePine,
+ Workflow,
+} from 'lucide-react'
+import type { AgentAnim } from '../types'
+
+export type AgentMeta = {
+ icon: LucideIcon
+ accent: string
+ idleTask: string
+ activeTask: string
+ domain: string
+}
+
+export const AGENT_META: Record = {
+ 'etl-guardian': {
+ icon: Workflow,
+ accent: '#38bdf8',
+ domain: 'ETL / CDC',
+ idleTask: 'Monitoring Airflow DAGs & Kafka connectors',
+ activeTask: 'Automating ETL layer — CDC sync validation',
+ },
+ 'lakehouse-ops': {
+ icon: Layers,
+ accent: '#818cf8',
+ domain: 'Lakehouse',
+ idleTask: 'Watching Spark, Trino & Iceberg catalogs',
+ activeTask: 'Optimizing lakehouse queries & table health',
+ },
+ 'data-custodian': {
+ icon: Database,
+ accent: '#34d399',
+ domain: 'Databases',
+ idleTask: 'Guarding PostgreSQL, MySQL & document stores',
+ activeTask: 'Running database health & replication checks',
+ },
+ 'hadoop-ranger': {
+ icon: TreePine,
+ accent: '#4ade80',
+ domain: 'Hadoop',
+ idleTask: 'Patrolling HDFS capacity & YARN nodes',
+ activeTask: 'Analyzing HDFS blocks & cluster balance',
+ },
+ 'infra-sentinel': {
+ icon: Server,
+ accent: '#94a3b8',
+ domain: 'Infrastructure',
+ idleTask: 'Observing Docker hosts & platform services',
+ activeTask: 'Correlating infra events across the lab',
+ },
+ 'mo-commander': {
+ icon: Shield,
+ accent: '#60a5fa',
+ domain: 'Supervision',
+ idleTask: 'Ingress intel & approval oversight',
+ activeTask: 'Reviewing agent dispatch & approvals',
+ },
+ 'bart-commander': {
+ icon: Radio,
+ accent: '#2dd4bf',
+ domain: 'Supervision',
+ idleTask: 'Egress monitoring & MCP comms relay',
+ activeTask: 'Tracking outbound agent communications',
+ },
+ 'network-watcher': {
+ icon: Network,
+ accent: '#38bdf8',
+ domain: 'Network',
+ idleTask: 'VLAN 20/21 traffic path analysis',
+ activeTask: 'Mapping data ingress & egress flows',
+ },
+ 'mcp-coordinator': {
+ icon: Cpu,
+ accent: '#c084fc',
+ domain: 'MCP Hub',
+ idleTask: 'Routing tool calls between agents',
+ activeTask: 'Orchestrating MCP tool execution',
+ },
+}
+
+const DEFAULT_META: AgentMeta = {
+ icon: Bot,
+ accent: '#94a3b8',
+ domain: 'Agent',
+ idleTask: 'Standing by',
+ activeTask: 'Executing mission',
+}
+
+export function getAgentMeta(agentId: string): AgentMeta {
+ return AGENT_META[agentId] || DEFAULT_META
+}
+
+export function agentTaskLabel(agentId: string, anim?: AgentAnim): string {
+ const meta = getAgentMeta(agentId)
+ if (!anim || anim.state === 'idle') return meta.idleTask
+ if (anim.state === 'walk') return `Routing to ${anim.zone || 'target zone'}…`
+ if (anim.state === 'fetch') return meta.activeTask
+ if (anim.state === 'return') return 'Publishing mission results…'
+ return meta.activeTask
+}
+
+export function pseudoAgentLoad(stats?: { tasks: number; alerts: number }) {
+ const tasks = stats?.tasks ?? 0
+ const alerts = stats?.alerts ?? 0
+ const cpu = Math.min(94, 8 + tasks * 3 + alerts * 5)
+ const mem = Math.min(88, 12 + tasks * 2 + alerts * 4)
+ return { cpu, mem }
+}
+
+export const DOMAIN_LABELS: Record = {
+ docker: 'Docker Platform',
+ databases: 'Database Vault',
+ lakehouse: 'Lakehouse',
+ etl: 'ETL / Streaming',
+ hadoop: 'Hadoop Cluster',
+ gpu: 'GPU / AI',
+}
diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts
new file mode 100644
index 0000000..8263793
--- /dev/null
+++ b/ui/src/lib/api.ts
@@ -0,0 +1,101 @@
+import type {
+ PresentationData,
+ Agent,
+ Approval,
+ FeedEntry,
+ GpuStatus,
+ StatusData,
+ TerminalLine,
+ WorkloadData,
+} from '../types'
+
+async function fetchJson(url: string, timeoutMs = 10000): Promise {
+ const ctrl = new AbortController()
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs)
+ try {
+ const r = await fetch(url, { signal: ctrl.signal })
+ if (!r.ok) return null
+ return (await r.json()) as T
+ } catch {
+ return null
+ } finally {
+ clearTimeout(timer)
+ }
+}
+
+export async function fetchAgents() {
+ const j = await fetchJson<{ agents?: Agent[] }>('/api/agents', 8000)
+ return j?.agents || []
+}
+
+export async function fetchStatus() {
+ return (await fetchJson('/api/status', 8000)) as StatusData
+}
+
+export async function fetchFeed() {
+ const j = await fetchJson<{ entries?: FeedEntry[] }>('/api/feed', 8000)
+ return j?.entries || []
+}
+
+export async function fetchApprovals() {
+ const j = await fetchJson<{ approvals?: Approval[] }>('/api/approvals', 8000)
+ return j?.approvals || []
+}
+
+export async function fetchApprovalHistory(status: string = 'pending') {
+ const j = await fetchJson<{
+ approvals?: Approval[]
+ stats?: { pending: number; approved: number; denied: number; total: number }
+ }>(`/api/approvals?status=${encodeURIComponent(status)}&limit=200`, 8000)
+ return { approvals: j?.approvals || [], stats: j?.stats }
+}
+
+export async function fetchGpu(): Promise {
+ return fetchJson('/api/gpu', 8000)
+}
+
+export async function fetchTerminals(): Promise> {
+ const j = await fetchJson<{ terminals?: Record }>('/api/terminals', 8000)
+ return j?.terminals || {}
+}
+
+export async function fetchWorkload(): Promise {
+ return fetchJson('/api/workload?fast=true', 25000)
+}
+
+export async function fetchNodeDetail(nodeId: string) {
+ const j = await fetchJson>(`/api/nodes/${nodeId}`, 15000)
+ return j || { error: 'timeout' }
+}
+
+export function probeNode(nodeId: string) {
+ return fetch(`/api/nodes/${nodeId}/probe`, { method: 'POST' })
+}
+
+export function askNode(nodeId: string, message: string) {
+ return fetch(`/api/nodes/${nodeId}/ask`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ message }),
+ })
+}
+
+export function sendPrompt(message: string, agentId?: string) {
+ return fetch('/api/prompt', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ message, agent_id: agentId || undefined }),
+ })
+}
+
+export async function fetchPresentation(): Promise {
+ return fetchJson('/api/presentation', 60000)
+}
+
+export async function decideApproval(id: string, approved: boolean, decidedBy: string, note: string) {
+ return fetch(`/api/approvals/${id}/decide`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ approved, decided_by: decidedBy, note }),
+ })
+}
diff --git a/ui/src/lib/constants.ts b/ui/src/lib/constants.ts
new file mode 100644
index 0000000..4ddfb62
--- /dev/null
+++ b/ui/src/lib/constants.ts
@@ -0,0 +1,47 @@
+export const NODE_ALIASES: Record = {
+ 'src-postgres': 'db',
+ 'src-mysql': 'db',
+ 'src-mongo': 'db',
+ 'src-cassandra': 'db',
+ 'src-airflow': 'airflow',
+ 'cdc-postgres': 'debezium',
+ 'cdc-mysql': 'debezium',
+ 'cdc-mongo': 'debezium',
+ 'cdc-cassandra': 'debezium',
+ 'stream-kafka': 'kafka',
+ 'stream-schema': 'kafka',
+ 'stream-spark': 'lakehouse',
+ 'lake-iceberg': 'lakehouse',
+ 'lake-s3': 's3',
+ 'query-trino': 'lakehouse',
+ 'query-dbt': 'lakehouse',
+ 'cons-bi': 'docker',
+ 'cons-notebooks': 'lakehouse',
+ 'cons-ml': 'gpu',
+}
+
+export const AGENT_NODE: Record = {
+ 'infra-sentinel': 'docker',
+ 'data-custodian': 'db',
+ 'lakehouse-ops': 'lakehouse',
+ 'etl-guardian': 'kafka',
+ 'hadoop-ranger': 'hadoop',
+ 'network-watcher': 'network-watcher',
+ 'mcp-coordinator': 'mcp-coordinator',
+ 'mo-commander': 'mo-commander',
+ 'bart-commander': 'bart-commander',
+}
+
+export const ZONE_NODE: Record = {
+ docker: 'docker',
+ db: 'db',
+ etl: 'kafka',
+ lakehouse: 'lakehouse',
+ s3: 's3',
+ hadoop: 'hadoop',
+}
+
+export function wsUrl() {
+ const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
+ return `${proto}://${window.location.host}/api/ws/ops`
+}
diff --git a/ui/src/lib/infraCatalog.ts b/ui/src/lib/infraCatalog.ts
new file mode 100644
index 0000000..1b09d02
--- /dev/null
+++ b/ui/src/lib/infraCatalog.ts
@@ -0,0 +1,210 @@
+import type { LucideIcon } from 'lucide-react'
+import {
+ Database,
+ HardDrive,
+ Layers,
+ MessageSquare,
+ Server,
+ Sparkles,
+ Workflow,
+} from 'lucide-react'
+
+export type InfraApp = {
+ label: string
+ url: string
+ port?: string
+}
+
+export type InfraNode = {
+ id: string
+ label: string
+ vm: string
+ ip: string
+ zone: string
+ agentId: string
+ icon: LucideIcon
+ accent: string
+ description: string
+ ssh: string
+ apps: InfraApp[]
+ topoIds: string[]
+}
+
+export const INFRA_CATALOG: InfraNode[] = [
+ {
+ id: 'db',
+ label: 'DB Vault',
+ vm: 'atc-db02',
+ ip: '10.0.21.51',
+ zone: 'db',
+ agentId: 'data-custodian',
+ icon: Database,
+ accent: '#34d399',
+ description: 'PostgreSQL, MySQL, MongoDB, Cassandra, Neo4j — CDC sources',
+ ssh: 'ssh root@10.0.21.51',
+ apps: [
+ { label: 'Dockhand env 5', url: 'http://10.0.21.45:8082', port: '8082' },
+ { label: 'PostgreSQL', url: 'postgresql://10.0.21.51:5432/postgres', port: '5432' },
+ { label: 'MongoDB', url: 'mongodb://10.0.21.51:27017/', port: '27017' },
+ ],
+ topoIds: ['postgresql', 'mysql', 'mongodb', 'cassandra', 'src-postgres', 'src-mysql', 'src-mongo', 'src-cassandra'],
+ },
+ {
+ id: 'airflow',
+ label: 'Airflow',
+ vm: 'atc-airflow01',
+ ip: '10.0.21.55',
+ zone: 'etl',
+ agentId: 'etl-guardian',
+ icon: Workflow,
+ accent: '#38bdf8',
+ description: 'DAG orchestration — daily data generation on all sources',
+ ssh: 'ssh root@10.0.21.55',
+ apps: [{ label: 'Airflow UI', url: 'http://10.0.21.55:8080', port: '8080' }],
+ topoIds: ['airflow', 'src-airflow'],
+ },
+ {
+ id: 'kafka',
+ label: 'Kafka Bus',
+ vm: 'atc-kafka01',
+ ip: '10.0.21.36',
+ zone: 'etl',
+ agentId: 'etl-guardian',
+ icon: MessageSquare,
+ accent: '#4c9aed',
+ description: 'Event bus — CDC topics & consumer streams',
+ ssh: 'ssh root@10.0.21.36',
+ apps: [{ label: 'Kafka UI', url: 'http://10.0.21.36:9000', port: '9000' }],
+ topoIds: ['kafka', 'stream-kafka'],
+ },
+ {
+ id: 'debezium',
+ label: 'Debezium CDC',
+ vm: 'atc-lake01',
+ ip: '10.0.21.50',
+ zone: 'lakehouse',
+ agentId: 'etl-guardian',
+ icon: Workflow,
+ accent: '#c77dff',
+ description: 'Kafka Connect — row-level CDC from source DBs',
+ ssh: 'ssh root@10.0.21.50',
+ apps: [{ label: 'Kafka Connect', url: 'http://10.0.21.50:8083', port: '8083' }],
+ topoIds: ['debezium', 'cdc-postgres', 'cdc-mysql', 'cdc-mongo', 'cdc-cassandra'],
+ },
+ {
+ id: 'lakehouse',
+ label: 'Lakehouse Hub',
+ vm: 'atc-lake01',
+ ip: '10.0.21.50',
+ zone: 'lakehouse',
+ agentId: 'lakehouse-ops',
+ icon: Layers,
+ accent: '#818cf8',
+ description: 'Spark, Trino, s3-kafka-consumer, Iceberg catalog',
+ ssh: 'ssh root@10.0.21.50',
+ apps: [
+ { label: 'Trino', url: 'http://10.0.21.50:8089', port: '8089' },
+ { label: 'Spark UI', url: 'http://10.0.21.50:8080', port: '8080' },
+ { label: 'Kafka Connect', url: 'http://10.0.21.50:8083', port: '8083' },
+ ],
+ topoIds: ['spark', 'trino', 'iceberg', 'stream-spark', 'lake-iceberg', 'query-trino', 'cons-notebooks'],
+ },
+ {
+ id: 's3',
+ label: 'ObjectScale S3',
+ vm: 'atc-objectscale',
+ ip: '10.0.20.111',
+ zone: 's3',
+ agentId: 'lakehouse-ops',
+ icon: HardDrive,
+ accent: '#d4a017',
+ description: 'Dell ECS S3 — Iceberg landing zone (bucket: data)',
+ ssh: 'ssh root@10.0.20.111',
+ apps: [{ label: 'S3 API', url: 'http://10.0.20.111:9020', port: '9020' }],
+ topoIds: ['s3', 'lake-s3'],
+ },
+ {
+ id: 'docker',
+ label: 'Docker Rack',
+ vm: 'atc-docker01',
+ ip: '10.0.21.45',
+ zone: 'docker',
+ agentId: 'infra-sentinel',
+ icon: Server,
+ accent: '#94a3b8',
+ description: 'Homepage, Dockhand, Superset, monitoring stack',
+ ssh: 'ssh root@10.0.21.45',
+ apps: [
+ { label: 'Homepage', url: 'http://10.0.21.45', port: '80' },
+ { label: 'Dockhand', url: 'http://10.0.21.45:8082', port: '8082' },
+ { label: 'Superset', url: 'http://10.0.21.45:8088', port: '8088' },
+ ],
+ topoIds: ['bi', 'cons-bi'],
+ },
+ {
+ id: 'hadoop',
+ label: 'Hadoop HDFS',
+ vm: 'atc-hadoop-m01',
+ ip: '10.0.21.61',
+ zone: 'hadoop',
+ agentId: 'hadoop-ranger',
+ icon: Server,
+ accent: '#4ade80',
+ description: '9-node HDFS cluster — parallel storage layer',
+ ssh: 'ssh root@10.0.21.61',
+ apps: [{ label: 'NameNode UI', url: 'http://10.0.21.61:9870', port: '9870' }],
+ topoIds: [],
+ },
+ {
+ id: 'gpu',
+ label: 'GPU Lab',
+ vm: 'atc-gpu-dev',
+ ip: '10.0.20.106',
+ zone: 'gpu',
+ agentId: 'infra-sentinel',
+ icon: Sparkles,
+ accent: '#3fb950',
+ description: 'vLLM inference — Llama 3 70B on 4× V100',
+ ssh: 'ssh root@10.0.20.106',
+ apps: [
+ { label: 'GPU Lab UI', url: 'http://10.0.20.106:9000', port: '9000' },
+ { label: 'vLLM API', url: 'http://10.0.20.106:8001/v1', port: '8001' },
+ ],
+ topoIds: ['llm', 'cons-ml'],
+ },
+ {
+ id: 'command',
+ label: 'Command Center',
+ vm: 'MCP · VM304',
+ ip: '10.0.21.33',
+ zone: 'command',
+ agentId: 'mcp-coordinator',
+ icon: Server,
+ accent: '#60a5fa',
+ description: 'This dashboard — agents, approvals, LLM routing',
+ ssh: 'ssh root@10.0.21.33',
+ apps: [
+ { label: 'Dashboard', url: 'http://10.0.21.33/', port: '80' },
+ { label: 'API', url: 'http://10.0.21.33/api', port: '80' },
+ ],
+ topoIds: [],
+ },
+]
+
+export function resolveInfraNode(nodeId: string | null): InfraNode | null {
+ if (!nodeId) return null
+ return (
+ INFRA_CATALOG.find((n) => n.id === nodeId) ||
+ INFRA_CATALOG.find((n) => n.topoIds.includes(nodeId)) ||
+ null
+ )
+}
+
+export async function copyShellCommand(cmd: string) {
+ try {
+ await navigator.clipboard.writeText(cmd)
+ return true
+ } catch {
+ return false
+ }
+}
diff --git a/ui/src/lib/tabActive.ts b/ui/src/lib/tabActive.ts
new file mode 100644
index 0000000..617de46
--- /dev/null
+++ b/ui/src/lib/tabActive.ts
@@ -0,0 +1,12 @@
+/** Shared active-tab styles — dark-mode safe (no white docker-light backgrounds). */
+export const viewTabActive =
+ 'border-l-[3px] border-l-docker bg-docker/20 text-docker border-docker/40 shadow-docker dark:bg-docker/25'
+
+export const viewTabIdle =
+ 'border border-transparent text-foreground-muted hover:border-border hover:bg-surface-overlay'
+
+export const subTabActive =
+ 'border border-docker/50 bg-docker/20 text-docker shadow-sm dark:bg-docker/25 dark:border-docker/40'
+
+export const subTabIdle =
+ 'border border-transparent text-foreground-muted hover:bg-surface-overlay hover:border-border'
diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts
new file mode 100644
index 0000000..fed2fe9
--- /dev/null
+++ b/ui/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from 'clsx'
+import { twMerge } from 'tailwind-merge'
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/ui/src/main.tsx b/ui/src/main.tsx
index 964aeb4..716133b 100644
--- a/ui/src/main.tsx
+++ b/ui/src/main.tsx
@@ -1,10 +1,20 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
-import './index.css'
+import { ThemeProvider } from './context/ThemeContext'
+import './styles/globals.css'
+
+const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: 1, staleTime: 10_000 } },
+})
ReactDOM.createRoot(document.getElementById('root')!).render(
-
+
+
+
+
+
,
)
diff --git a/ui/src/styles/globals.css b/ui/src/styles/globals.css
new file mode 100644
index 0000000..4634e92
--- /dev/null
+++ b/ui/src/styles/globals.css
@@ -0,0 +1,171 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ color-scheme: light;
+ --surface: 244 245 247;
+ --surface-raised: 255 255 255;
+ --surface-overlay: 236 240 245;
+ --surface-muted: 221 225 230;
+ --border: 210 218 228;
+ --border-strong: 180 192 208;
+ --foreground: 26 31 38;
+ --foreground-muted: 95 107 122;
+ --foreground-faint: 139 149 165;
+ --shadow-panel: 0 1px 3px rgba(15, 40, 80, 0.06), 0 4px 12px rgba(36, 150, 237, 0.06);
+ --shadow-docker: 0 0 0 1px rgba(36, 150, 237, 0.12), 0 4px 14px rgba(36, 150, 237, 0.1);
+ --topo-canvas: linear-gradient(145deg, #dbeafe 0%, #e0f2fe 35%, #ede9fe 70%, #ecfdf5 100%);
+ --topo-grid: rgba(37, 99, 235, 0.06);
+ --topo-stage-border: rgba(37, 99, 235, 0.15);
+ --topo-node-bg: linear-gradient(145deg, #1e40af 0%, #2563eb 50%, #1d4ed8 100%);
+ --topo-node-border: rgba(147, 197, 253, 0.45);
+ --topo-node-shadow: 0 4px 14px rgba(30, 64, 175, 0.35);
+ --topo-header-bg: linear-gradient(90deg, rgba(36, 150, 237, 0.12), rgba(99, 102, 241, 0.08));
+ }
+
+ .dark {
+ color-scheme: dark;
+ --surface: 15 27 46;
+ --surface-raised: 22 38 62;
+ --surface-overlay: 28 48 78;
+ --surface-muted: 36 58 92;
+ --border: 48 74 112;
+ --border-strong: 64 96 140;
+ --foreground: 232 241 255;
+ --foreground-muted: 148 175 212;
+ --foreground-faint: 100 130 168;
+ --shadow-panel: 0 1px 0 rgba(147, 197, 253, 0.06) inset, 0 8px 24px rgba(0, 0, 0, 0.35);
+ --shadow-docker: 0 0 0 1px rgba(56, 189, 248, 0.2), 0 4px 16px rgba(14, 116, 214, 0.25);
+ --topo-canvas: linear-gradient(145deg, #0c1929 0%, #132f4c 40%, #1a365d 75%, #0f2847 100%);
+ --topo-grid: rgba(56, 189, 248, 0.07);
+ --topo-stage-border: rgba(56, 189, 248, 0.18);
+ --topo-node-bg: linear-gradient(145deg, #1e3a5f 0%, #234876 45%, #1a4470 100%);
+ --topo-node-border: rgba(96, 165, 250, 0.4);
+ --topo-node-shadow: 0 4px 16px rgba(0, 20, 60, 0.45);
+ --topo-header-bg: linear-gradient(90deg, rgba(36, 150, 237, 0.18), rgba(99, 102, 241, 0.12));
+ }
+
+ html, body, #root {
+ height: 100%;
+ scrollbar-gutter: stable;
+ }
+
+ body {
+ @apply bg-surface font-sans text-foreground antialiased;
+ background-image: var(--body-gradient, none);
+ }
+
+ .dark body {
+ --body-gradient: radial-gradient(ellipse 120% 80% at 50% -20%, rgba(37, 99, 235, 0.15), transparent);
+ }
+
+ :root body {
+ --body-gradient: radial-gradient(ellipse 100% 60% at 50% -10%, rgba(36, 150, 237, 0.08), transparent);
+ }
+}
+
+@layer components {
+ .panel {
+ @apply rounded-lg border border-border bg-surface-raised shadow-panel;
+ }
+
+ .topo-canvas {
+ @apply relative min-h-0 flex-1 overflow-hidden rounded-lg;
+ background:
+ linear-gradient(var(--topo-grid) 1px, transparent 1px),
+ linear-gradient(90deg, var(--topo-grid) 1px, transparent 1px),
+ var(--topo-canvas);
+ background-size: 20px 20px, 20px 20px, auto;
+ }
+
+ .topo-edge-glow {
+ stroke: rgba(56, 189, 248, 0.18);
+ stroke-width: 4;
+ fill: none;
+ }
+
+ .topo-edge-idle {
+ stroke: rgba(100, 140, 180, 0.35);
+ stroke-width: 1.5;
+ stroke-dasharray: 4 8;
+ fill: none;
+ }
+
+ .topo-edge-live {
+ stroke-width: 2;
+ stroke-dasharray: 8 12;
+ fill: none;
+ animation: flow-dash 1.2s linear infinite;
+ }
+
+ .topo-edge-orchestration.topo-edge-live { stroke: #f59e0b; filter: drop-shadow(0 0 3px rgba(245, 158, 11, 0.5)); }
+ .topo-edge-cdc.topo-edge-live { stroke: #22d3ee; filter: drop-shadow(0 0 3px rgba(34, 211, 238, 0.45)); }
+ .topo-edge-stream.topo-edge-live { stroke: #38bdf8; filter: drop-shadow(0 0 3px rgba(56, 189, 248, 0.45)); }
+ .topo-edge-etl.topo-edge-live { stroke: #a78bfa; filter: drop-shadow(0 0 3px rgba(167, 139, 250, 0.45)); }
+ .topo-edge-query.topo-edge-live { stroke: #818cf8; filter: drop-shadow(0 0 3px rgba(129, 140, 248, 0.45)); }
+ .topo-edge-serve.topo-edge-live { stroke: #34d399; filter: drop-shadow(0 0 3px rgba(52, 211, 153, 0.45)); }
+
+ .topo-edge-active {
+ stroke: url(#topo-flow-gradient);
+ stroke-width: 2.5;
+ stroke-dasharray: 10 14;
+ fill: none;
+ animation: flow-dash 1.4s linear infinite;
+ }
+
+ .topo-edge-pulse {
+ stroke: #34d399;
+ stroke-width: 2;
+ stroke-dasharray: 4 100;
+ fill: none;
+ opacity: 0.9;
+ animation: flow-pulse 2s linear infinite;
+ }
+
+ .topo-node-airflow {
+ border-color: rgba(245, 158, 11, 0.55) !important;
+ box-shadow: 0 0 12px rgba(245, 158, 11, 0.25), var(--topo-node-shadow);
+ }
+
+ .topo-node {
+ @apply w-full rounded-md border px-2 py-1.5 text-left transition-all;
+ background: var(--topo-node-bg);
+ border-color: var(--topo-node-border);
+ box-shadow: var(--topo-node-shadow);
+ }
+
+ .topo-node:hover {
+ filter: brightness(1.12);
+ transform: translateY(-1px);
+ }
+
+ .topo-node-selected {
+ @apply ring-2 ring-cyan-400/60 border-cyan-300;
+ }
+
+ .topo-stage-col {
+ @apply flex min-w-[130px] flex-1 flex-col px-1.5 py-2 last:border-r-0;
+ border-right: 1px dashed var(--topo-stage-border);
+ }
+
+ .topo-stage-col--sources { background: linear-gradient(180deg, rgba(16, 185, 129, 0.08), transparent 60%); }
+ .topo-stage-col--ingestion { background: linear-gradient(180deg, rgba(6, 182, 212, 0.1), transparent 60%); }
+ .topo-stage-col--compute { background: linear-gradient(180deg, rgba(139, 92, 246, 0.1), transparent 60%); }
+ .topo-stage-col--storage { background: linear-gradient(180deg, rgba(37, 99, 235, 0.1), transparent 60%); }
+ .topo-stage-col--consumers { background: linear-gradient(180deg, rgba(245, 158, 11, 0.08), transparent 60%); }
+}
+
+@layer utilities {
+ .scrollbar-thin {
+ scrollbar-width: thin;
+ scrollbar-color: rgb(var(--border-strong)) transparent;
+ }
+
+ .scroll-x-stable {
+ overflow-x: auto;
+ overflow-y: hidden;
+ scrollbar-gutter: stable;
+ }
+}
diff --git a/ui/src/types.ts b/ui/src/types.ts
index 1f2ffa7..9087e9e 100644
--- a/ui/src/types.ts
+++ b/ui/src/types.ts
@@ -1,9 +1,39 @@
+export type AgentStats = {
+ tasks: number
+ last_active: string | null
+ alerts: number
+}
+
export type Agent = {
id: string
name: string
color: string
zone: string
role: string
+ icon?: string
+ motto?: string
+ capabilities?: string[]
+ suggested_prompts?: string[]
+ stats?: AgentStats
+ supervisor?: boolean
+ person?: string
+}
+
+export type TopologyLayer = {
+ id: string
+ label: string
+ y: number
+ color: string
+ x?: number
+}
+
+export type TopologyViewData = {
+ id: string
+ label: string
+ subtitle: string
+ layers?: TopologyLayer[]
+ nodes: TopologyNode[]
+ edges: TopologyEdge[]
}
export type Zone = { id: string; label: string; x: number; color: string }
@@ -24,6 +54,29 @@ export type DomainStatus = {
export type StatusData = {
ts: string
domains: Record
+ gpu?: GpuStatus
+}
+
+export type GpuDevice = {
+ index: number
+ name: string
+ util_gpu: number
+ memory_used_mib: number
+ memory_total_mib: number
+ temperature_c: number
+ power_w: number
+}
+
+export type GpuStatus = {
+ ok: boolean
+ host: string
+ ui_url: string
+ inference_active?: boolean
+ active_model?: string | null
+ vllm_url?: string | null
+ gpu_count?: number
+ gpus?: GpuDevice[]
+ error?: string
}
export type AgentState = 'idle' | 'walk' | 'fetch' | 'return'
@@ -41,4 +94,148 @@ export type Approval = {
action: string
reason: string
status: string
+ action_type: string
+ target: string
+ payload?: Record
+ decided_by?: string | null
+ decide_note?: string | null
+ decided_at?: string | null
+ priority?: string
+}
+
+export type TerminalLine = {
+ id: string
+ ts: string
+ agent_id: string
+ level: 'info' | 'ok' | 'warn' | 'err' | 'cmd' | 'llm'
+ phase: string
+ text: string
+ prompt_id?: string
+}
+
+export type WorkloadApp = {
+ name: string
+ state: string
+ image: string
+ ports: string[]
+ host?: string
+}
+
+export type WorkloadZone = {
+ id: string
+ label: string
+ x: number
+ color: string
+ level: 'ok' | 'warn' | 'down' | 'unknown'
+ running: number
+ total: number
+ apps: WorkloadApp[]
+ trino_ok?: boolean
+ hdfs_used_gb?: number
+ hdfs_total_gb?: number
+ vm?: string
+ ip?: string
+ bucket?: string
+}
+
+export type NodeLink = { label: string; url: string }
+export type NodeEndpoint = { name: string; host: string; port: string; proto: string }
+
+export type TopologyNode = {
+ id: string
+ label: string
+ vm: string
+ ip: string
+ x: number
+ y: number
+ color: string
+ level: string
+ role: string
+ apps: WorkloadApp[]
+ running: number
+ total: number
+ description?: string
+ agent_id?: string
+ links?: NodeLink[]
+ endpoints?: NodeEndpoint[]
+ commands?: string[]
+ vmid?: number
+ pve?: string
+ bucket?: string
+ port?: string
+ connectors?: string[]
+ trino_ok?: boolean
+ model?: string
+ util?: number
+ hdfs_used_gb?: number
+ hdfs_total_gb?: number
+ consumer_ok?: boolean
+ subtitle?: string
+ metrics?: string[]
+ icon?: string
+ layer?: string
+}
+
+export type NodeDetail = TopologyNode
+
+export type TopologyEdge = {
+ id: string
+ from: string
+ to: string
+ label: string
+ kind: 'pipeline' | 'query' | 'parallel' | 'infra'
+ active: boolean
+}
+
+export type WorkloadData = {
+ ts: string
+ zones: WorkloadZone[]
+ topology?: TopologyViewData
+ topologies?: Record
+ gpu: {
+ level: string
+ model?: string | null
+ inference_active?: boolean
+ gpu_count?: number
+ avg_util?: number
+ gpus?: GpuDevice[]
+ }
+ totals: {
+ apps_running: number
+ apps_total: number
+ connectors: number
+ vms?: number
+ pipeline_active?: boolean
+ }
+}
+
+export type ChatMessage = {
+ role: 'user' | 'agent'
+ text: string
+ agent?: string
+ ts?: string
+}
+
+
+export type PresentationSlide = {
+ id: string
+ title: string
+ subtitle?: string
+ bullets: string[]
+ kind?: string
+ animation?: string
+ topology?: TopologyViewData
+ zone?: WorkloadZone
+}
+
+export type PresentationData = {
+ ts: string
+ title: string
+ subtitle: string
+ totals: WorkloadData['totals']
+ pipeline_active?: boolean
+ slides: PresentationSlide[]
+ slide_count: number
+ workload?: WorkloadData
+ topologies?: Record
}
diff --git a/ui/tailwind.config.js b/ui/tailwind.config.js
index f012dc6..dc48e8a 100644
--- a/ui/tailwind.config.js
+++ b/ui/tailwind.config.js
@@ -1,32 +1,49 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
+ darkMode: 'class',
theme: {
extend: {
- colors: {
- void: '#f0f4ff',
- panel: '#ffffff',
- ink: {
- DEFAULT: '#1a2332',
- muted: '#5a6b82',
- faint: '#8b9cb3',
- },
- neon: {
- cyan: '#0099cc',
- magenta: '#cc0088',
- green: '#22aa44',
- amber: '#cc7700',
- purple: '#8844cc',
- },
- },
fontFamily: {
- display: ['"Space Grotesk"', 'system-ui', 'sans-serif'],
+ sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['"JetBrains Mono"', 'monospace'],
},
+ colors: {
+ docker: {
+ DEFAULT: '#2496ED',
+ dark: '#1D7CC8',
+ light: '#E8F4FD',
+ },
+ surface: {
+ DEFAULT: 'rgb(var(--surface) / )',
+ raised: 'rgb(var(--surface-raised) / )',
+ overlay: 'rgb(var(--surface-overlay) / )',
+ muted: 'rgb(var(--surface-muted) / )',
+ },
+ border: {
+ DEFAULT: 'rgb(var(--border) / )',
+ strong: 'rgb(var(--border-strong) / )',
+ },
+ foreground: {
+ DEFAULT: 'rgb(var(--foreground) / )',
+ muted: 'rgb(var(--foreground-muted) / )',
+ faint: 'rgb(var(--foreground-faint) / )',
+ },
+ success: '#22C55E',
+ warning: '#F59E0B',
+ danger: '#EF4444',
+ },
boxShadow: {
- 'neon-cyan': '0 0 20px rgba(0, 153, 204, 0.25), 0 4px 16px rgba(0, 153, 204, 0.12)',
- 'neon-magenta': '0 0 20px rgba(204, 0, 136, 0.2)',
- card: '0 4px 20px rgba(15, 40, 80, 0.07)',
+ panel: 'var(--shadow-panel)',
+ docker: 'var(--shadow-docker)',
+ },
+ animation: {
+ 'flow-dash': 'flow-dash 1.4s linear infinite',
+ 'flow-pulse': 'flow-pulse 2.2s linear infinite',
+ },
+ keyframes: {
+ 'flow-dash': { to: { strokeDashoffset: '-48' } },
+ 'flow-pulse': { to: { strokeDashoffset: '-248' } },
},
},
},
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..204e90a
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ server: { host: true, port: 5173 },
+ build: { outDir: 'dist' },
+})
diff --git a/workload.py b/workload.py
new file mode 100644
index 0000000..2e4c4a7
--- /dev/null
+++ b/workload.py
@@ -0,0 +1,138 @@
+"""Build UI workload payload from lab snapshot."""
+
+from __future__ import annotations
+
+from typing import Any
+
+
+def _level(running: int, total: int) -> str:
+ if total == 0:
+ return "unknown"
+ ratio = running / total
+ if ratio >= 0.9:
+ return "ok"
+ if ratio >= 0.5:
+ return "warn"
+ return "down"
+
+
+def _app_row(c: dict[str, Any]) -> dict[str, Any]:
+ img = c.get("image") or ""
+ short_img = img.split("/")[-1].split(":")[0][:20]
+ return {
+ "name": c.get("name", "?"),
+ "state": c.get("state", "unknown"),
+ "image": short_img,
+ "ports": c.get("ports") or [],
+ }
+
+
+def build_workload_payload(snap: dict[str, Any]) -> dict[str, Any]:
+ docker = snap.get("docker", {})
+ databases = snap.get("databases", {})
+ lakehouse = snap.get("lakehouse", {})
+ etl = snap.get("etl", {})
+ hadoop = snap.get("hadoop", {})
+ gpu = snap.get("gpu", {})
+
+ docker_apps = [_app_row(c) for c in docker.get("containers", [])]
+ db_apps = [_app_row(c) for c in databases.get("containers", [])]
+ lake_apps = [_app_row(c) for c in lakehouse.get("containers", [])]
+
+ hdfs_ok = hadoop.get("reachable", False)
+ etl_ok = etl.get("airflow_healthy") and etl.get("kafka_ui_ok")
+
+ zones = [
+ {
+ "id": "docker",
+ "label": "DOCKER RACK",
+ "x": 8,
+ "color": "#b366ff",
+ "level": _level(docker.get("running", 0), docker.get("total", 1) or 1),
+ "running": docker.get("running", 0),
+ "total": docker.get("total", 0),
+ "apps": docker_apps,
+ },
+ {
+ "id": "db",
+ "label": "DB VAULT",
+ "x": 28,
+ "color": "#ffaa00",
+ "level": _level(databases.get("running", 0), databases.get("total", 1) or 1),
+ "running": databases.get("running", 0),
+ "total": databases.get("total", 0),
+ "apps": db_apps,
+ },
+ {
+ "id": "lakehouse",
+ "label": "LAKEHOUSE HUB",
+ "x": 50,
+ "color": "#ff00aa",
+ "level": _level(lakehouse.get("running", 0), lakehouse.get("total", 1) or 1),
+ "running": lakehouse.get("running", 0),
+ "total": lakehouse.get("total", 0),
+ "apps": lake_apps,
+ "trino_ok": lakehouse.get("trino_ok"),
+ },
+ {
+ "id": "hadoop",
+ "label": "HADOOP CLUSTER",
+ "x": 72,
+ "color": "#39ff14",
+ "level": "ok" if hdfs_ok else "warn",
+ "running": hadoop.get("live_datanodes", 0),
+ "total": (hadoop.get("live_datanodes") or 0) + (hadoop.get("dead_datanodes") or 0),
+ "apps": [
+ {"name": "NameNode", "state": "running" if hdfs_ok else "down", "image": "hdfs-nn", "ports": ["9870"]},
+ *[
+ {"name": dn.get("host", "?").split(".")[0], "state": "running", "image": "datanode", "ports": ["9866"]}
+ for dn in hadoop.get("datanodes", [])
+ ],
+ ],
+ "hdfs_used_gb": hadoop.get("capacity_used_gb"),
+ "hdfs_total_gb": hadoop.get("capacity_total_gb"),
+ },
+ {
+ "id": "etl",
+ "label": "ETL PIPE",
+ "x": 92,
+ "color": "#00f0ff",
+ "level": "ok" if etl_ok else "warn",
+ "running": sum(1 for s in [
+ etl.get("airflow_healthy"),
+ etl.get("kafka_ui_ok"),
+ etl.get("spark_ui_ok"),
+ ] if s),
+ "total": 3,
+ "apps": [
+ {"name": "Airflow", "state": "running" if etl.get("airflow_healthy") else "down", "image": "airflow", "ports": ["8080"]},
+ {"name": "Kafka UI", "state": "running" if etl.get("kafka_ui_ok") else "down", "image": "kafka", "ports": ["9000"]},
+ {"name": "Spark UI", "state": "running" if etl.get("spark_ui_ok") else "down", "image": "spark", "ports": ["8080"]},
+ *[
+ {"name": c, "state": "running", "image": "connect", "ports": ["8083"]}
+ for c in etl.get("connectors", [])
+ ],
+ ],
+ },
+ ]
+
+ return {
+ "ts": snap.get("ts"),
+ "zones": zones,
+ "gpu": {
+ "level": "ok" if gpu.get("ok") and gpu.get("inference_active") else ("warn" if gpu.get("ok") else "down"),
+ "model": gpu.get("active_model"),
+ "inference_active": gpu.get("inference_active"),
+ "gpu_count": gpu.get("gpu_count", 0),
+ "avg_util": round(
+ sum(g.get("util_gpu", 0) for g in gpu.get("gpus", [])) / max(len(gpu.get("gpus", [])), 1),
+ 1,
+ ),
+ "gpus": gpu.get("gpus", []),
+ },
+ "totals": {
+ "apps_running": sum(z["running"] for z in zones if z["id"] != "hadoop") + (hadoop.get("live_datanodes") or 0),
+ "apps_total": sum(z["total"] for z in zones),
+ "connectors": len(etl.get("connectors", [])),
+ },
+ }