Add Command Center v2: DQ/RAG integration, S3 browser, Jupyter, GPU matrix.

Mirror mo/atc-GPU layout with config/, docs/, scripts/ for Gitea deploy.
This commit is contained in:
mo
2026-06-25 00:28:23 +00:00
parent fb9cc21c9a
commit a11621b21f
110 changed files with 14622 additions and 529 deletions
+11
View File
@@ -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
+52 -2
View File
@@ -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) |
+71
View File
@@ -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
+1 -1
View File
@@ -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
+72
View File
@@ -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
+248
View File
@@ -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
+243
View File
@@ -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)
+164
View File
@@ -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)}
+20
View File
@@ -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
+745
View File
@@ -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)
+705 -69
View File
@@ -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("<h1>Deck not found</h1>", 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:
+186
View File
@@ -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}")
+268
View File
@@ -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
+292
View File
@@ -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"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{title} — Presentation</title>
<style>
*{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0c1929;color:#e8f1ff;height:100vh;overflow:hidden}}
.deck{{height:100vh;display:flex;flex-direction:column}}
header{{padding:1rem 2rem;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid rgba(96,165,250,.2);background:rgba(15,27,46,.9)}}
header h1{{font-size:1.1rem;font-weight:600}}
header .meta{{font-size:.75rem;opacity:.7}}
.slide{{flex:1;display:none;padding:3rem 4rem;overflow:auto}}
.slide.active{{display:flex;flex-direction:column;justify-content:center}}
.slide h2{{font-size:2.4rem;margin-bottom:.5rem;background:linear-gradient(90deg,#60a5fa,#a78bfa);-webkit-background-clip:text;-webkit-text-fill-color:transparent}}
.slide h3{{font-size:1rem;opacity:.75;margin-bottom:2rem;font-weight:400}}
.slide ul{{list-style:none;font-size:1.15rem;line-height:1.9}}
.slide li::before{{content:"";color:#60a5fa}}
.slide.hero h2{{font-size:3.2rem}}
nav{{display:flex;gap:.5rem;padding:1rem 2rem;border-top:1px solid rgba(96,165,250,.2);align-items:center}}
nav button{{background:#1e3a5f;border:1px solid rgba(96,165,250,.3);color:#e8f1ff;padding:.5rem 1rem;border-radius:6px;cursor:pointer}}
nav button:hover{{background:#234876}}
.dots{{display:flex;gap:6px;flex:1;justify-content:center;flex-wrap:wrap}}
.dot{{width:8px;height:8px;border-radius:50%;background:rgba(96,165,250,.3);cursor:pointer;border:none}}
.dot.active{{background:#60a5fa;transform:scale(1.3)}}
.counter{{font-size:.8rem;opacity:.6;min-width:4rem;text-align:right}}
</style>
</head>
<body>
<div class="deck">
<header><h1>{title}</h1><div class="meta">Dell ATC · Live snapshot {ts}</div></header>
<div id="slides"></div>
<nav>
<button id="prev">← Prev</button>
<div class="dots" id="dots"></div>
<button id="next">Next →</button>
<span class="counter" id="counter"></span>
</nav>
</div>
<script>
const slides={slides_json};
let i=0;
const container=document.getElementById("slides");
const dots=document.getElementById("dots");
const counter=document.getElementById("counter");
slides.forEach((s,idx)=>{{
const el=document.createElement("section");
el.className="slide"+(s.kind==="hero"?" hero":"")+(idx===0?" active":"");
const bullets=(s.bullets||[]).map(b=>"<li>"+b+"</li>").join("");
el.innerHTML="<h2>"+s.title+"</h2><h3>"+(s.subtitle||"")+"</h3><ul>"+bullets+"</ul>";
container.appendChild(el);
const d=document.createElement("button");
d.className="dot"+(idx===0?" active":"");
d.onclick=()=>go(idx);
dots.appendChild(d);
}});
function go(n){{i=Math.max(0,Math.min(slides.length-1,n));document.querySelectorAll(".slide").forEach((e,j)=>e.classList.toggle("active",j===i));document.querySelectorAll(".dot").forEach((e,j)=>e.classList.toggle("active",j===i));counter.textContent=(i+1)+"/"+slides.length;}}
document.getElementById("prev").onclick=()=>go(i-1);
document.getElementById("next").onclick=()=>go(i+1);
document.onkeydown=e=>{{if(e.key==="ArrowRight"||e.key===" ")go(i+1);if(e.key==="ArrowLeft")go(i-1);if(e.key==="f"||e.key==="F")document.documentElement.requestFullscreen?.();}};
go(0);
</script>
</body>
</html>"""
+203
View File
@@ -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 (0100) 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 0100 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,
}
+165
View File
@@ -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())
+7
View File
@@ -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
+138
View File
@@ -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)
+34
View File
@@ -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")
+606
View File
@@ -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 "",
}
+316
View File
@@ -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.6170",
},
]
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,
},
}
+15 -2
View File
@@ -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
}
}
+27
View File
@@ -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
+17
View File
@@ -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
}
}
+150
View File
@@ -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:
+70
View File
@@ -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:
+113 -2
View File
@@ -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:
+46
View File
@@ -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
```
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en" class="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ATC Command Center</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+541
View File
@@ -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)
+662
View File
@@ -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)
+17
View File
@@ -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;
}
}
+26
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+9
View File
@@ -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
+7
View File
@@ -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/"
+80
View File
@@ -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"
+296
View File
@@ -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 (
<span className="text-xs font-mono text-[var(--text-faint)] hidden lg:inline tabular-nums">
{now.toLocaleTimeString()}
</span>
)
}
export default function App() {
const [tab, setTab] = useState<Tab>('Overview')
const [agents, setAgents] = useState<Agent[]>([])
const [status, setStatus] = useState<StatusData | null>(null)
const [workload, setWorkload] = useState<WorkloadData | null>(null)
const [gpu, setGpu] = useState<GpuStatus | null>(null)
const [feed, setFeed] = useState<FeedEntry[]>([])
const [approvals, setApprovals] = useState<Approval[]>([])
const [chat, setChat] = useState<ChatMessage[]>([])
const [anims, setAnims] = useState<Record<string, AgentAnim>>({})
const [selectedId, setSelectedId] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [terminals, setTerminals] = useState<Record<string, TerminalLine[]>>({})
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 (
<div className="min-h-screen p-4 md:p-6 max-w-[96rem] mx-auto font-display flex flex-col gap-5 relative">
<AmbientBackground />
<header className="panel rounded-2xl px-5 py-4 flex flex-wrap justify-between items-center gap-4 relative overflow-hidden">
<div className="header-aurora" />
<div className="flex items-center gap-4 relative z-10">
<div className="logo-mark">ATC</div>
<div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight neon-text" style={{ color: 'var(--accent)' }}>
Command Center
</h1>
<p className="text-xs font-mono text-[var(--text-muted)]">
{agents.length} agents · {totalTasks} missions · autonomous ops
</p>
</div>
</div>
<div className="flex items-center gap-3 flex-wrap relative z-10">
<LiveClock />
{status && (
<span className={`status-pill ${allOk ? 'ok' : 'warn'}`}>
{allOk ? 'All systems operational' : 'Attention required'}
</span>
)}
{gpu?.ok && gpu.active_model && (
<span className="status-pill gpu hidden md:inline-flex">
GPU · {gpu.active_model}
</span>
)}
<ThemeToggle />
</div>
</header>
<AgentRoster
agents={agents}
animations={anims}
selectedId={selectedId}
onSelect={setSelectedId}
onDelegate={(a) => setSelectedId(a.id)}
/>
<section className="panel rounded-2xl p-5">
<div className="flex flex-wrap items-end justify-between gap-3 mb-4">
<div>
<p className="section-eyebrow">Live agent shells</p>
<h2 className="text-lg font-bold" style={{ color: 'var(--accent)' }}>Agent Terminals</h2>
<p className="text-xs text-[var(--text-muted)] mt-1">Volg live hoe elke agent data ophaalt HTTP probes, Dockhand, JMX, vLLM.</p>
</div>
<div className="flex gap-2">
<button type="button" className={`tab-btn text-xs ${terminalLayout === 'grid' ? 'active' : ''}`} onClick={() => setTerminalLayout('grid')}>Grid</button>
<button type="button" className={`tab-btn text-xs ${terminalLayout === 'focus' ? 'active' : ''}`} onClick={() => setTerminalLayout('focus')}>Focus</button>
</div>
</div>
<AgentTerminalGrid
agents={agents}
terminals={terminals}
animations={anims}
selectedId={selectedId}
onSelect={setSelectedId}
layout={terminalLayout}
/>
</section>
<div className="grid grid-cols-1 xl:grid-cols-12 gap-5">
<div className="xl:col-span-8 flex flex-col gap-5">
<LiveClusterMap agents={agents} workload={workload} animations={anims} selectedId={selectedId} />
<nav className="flex gap-2 flex-wrap">
{TABS.map((t) => (
<button key={t} type="button" onClick={() => setTab(t)} className={`tab-btn ${tab === t ? 'active' : ''}`}>
{t}
{t === 'Approvals' && approvals.length > 0 && (
<span className="tab-badge">{approvals.length}</span>
)}
</button>
))}
</nav>
<main className="panel rounded-2xl p-5 min-h-[280px]">
{tab === 'Overview' && (
<div>
<p className="section-eyebrow mb-1">Infrastructure</p>
<h3 className="text-lg font-bold mb-4" style={{ color: 'var(--text)' }}>Live Cluster Workload</h3>
<LiveDomainGrid workload={workload} />
</div>
)}
{tab === 'Terminals' && (
<div>
<p className="section-eyebrow mb-1">Mission trace</p>
<h3 className="text-lg font-bold mb-4" style={{ color: 'var(--text)' }}>
{selectedAgent ? `${selectedAgent.name} — full terminal` : 'Select an agent'}
</h3>
{selectedAgent ? (
<AgentTerminalGrid
agents={agents}
terminals={terminals}
animations={anims}
selectedId={selectedId}
onSelect={setSelectedId}
layout="focus"
/>
) : (
<p className="text-sm text-[var(--text-muted)]">Klik een agent in de roster of stuur een prompt.</p>
)}
</div>
)}
{tab === 'Activity' && (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<p className="section-eyebrow">Event stream</p>
<h3 className="text-lg font-bold" style={{ color: 'var(--text)' }}>Agent Activity</h3>
</div>
{selectedAgent && (
<button type="button" className="btn-secondary text-xs" onClick={() => setSelectedId(null)}>
Clear filter
</button>
)}
</div>
<ActivityFeed feed={feed} agents={agents} filterAgentId={selectedId} />
</div>
)}
{tab === 'Approvals' && (
<div className="space-y-3">
{approvals.length === 0 && <p className="text-[var(--text-muted)] text-sm">Geen pending approvals.</p>}
{approvals.map((a) => {
const agent = agents.find((ag) => ag.id === a.agent_id)
return (
<div key={a.id} className="status-card rounded-xl p-4 flex gap-4" style={{ borderColor: 'var(--accent-secondary)' }}>
{agent && <span className="text-2xl">{agent.icon}</span>}
<div className="flex-1">
<div className="text-sm font-semibold" style={{ color: 'var(--accent-secondary)' }}>{a.action}</div>
<div className="text-xs text-[var(--text-muted)] mt-1">{a.reason}</div>
<div className="text-[10px] font-mono text-[var(--text-faint)] mt-1">via {agent?.name || a.agent_id}</div>
<div className="flex gap-2 mt-3">
<button type="button" onClick={() => decide(a.id, true)} className="btn-secondary text-[var(--status-ok)]">Approve</button>
<button type="button" onClick={() => decide(a.id, false)} className="btn-secondary text-[var(--status-down)]">Deny</button>
</div>
</div>
</div>
)
})}
</div>
)}
{tab === 'GPU' && <GpuPanel gpu={gpu} />}
</main>
</div>
<div className="xl:col-span-4 flex flex-col gap-5">
<GpuPanel gpu={gpu} compact />
<ChatPanel messages={chat} agents={agents} selectedAgent={selectedAgent} busy={busy} />
</div>
</div>
<CommandDock onSubmit={sendPrompt} busy={busy} selectedAgent={selectedAgent} />
</div>
)
}
+55
View File
@@ -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 (
<div className="empty-state">
<span className="text-2xl mb-2">📡</span>
<p>Geen activiteit{filterAgentId ? ' voor deze agent' : ''}.</p>
</div>
)
}
return (
<div className="activity-feed max-h-[420px] overflow-y-auto pr-1">
{entries.map((e, i) => {
const agent = agents.find((a) => a.id === e.agent_id)
return (
<motion.div
key={e.id}
className="activity-item"
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: Math.min(i * 0.03, 0.3) }}
>
<div className="activity-rail">
<div className="activity-dot" style={{ background: agent?.color || 'var(--text-faint)', boxShadow: `0 0 8px ${agent?.color || 'transparent'}` }} />
{i < entries.length - 1 && <div className="activity-line" />}
</div>
<div className="flex-1 min-w-0 pb-4">
<div className="flex flex-wrap items-center gap-2 mb-1">
<span className="text-[10px] font-mono text-[var(--text-faint)]">
{e.ts ? new Date(e.ts).toLocaleString() : ''}
</span>
<span className="activity-agent-badge" style={{ color: agent?.color, borderColor: `${agent?.color}44` }}>
{agent?.icon} {agent?.name || e.agent_id}
</span>
{e.level === 'warn' && <span className="text-[10px] font-mono text-[var(--status-warn)]">WARN</span>}
</div>
<p className="text-sm text-[var(--text)] leading-relaxed">{e.message}</p>
</div>
</motion.div>
)
})}
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
import { motion } from 'framer-motion'
import type { CSSProperties } from 'react'
import type { Agent, AgentAnim } from '../types'
const STATE_LABEL: Record<AgentAnim['state'], string> = {
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 (
<motion.button
type="button"
onClick={onSelect}
className={`agent-card text-left w-full ${selected ? 'selected' : ''}`}
whileHover={{ y: -3 }}
whileTap={{ scale: 0.98 }}
style={{ '--agent-color': agent.color } as CSSProperties}
>
<div className="agent-card-inner rounded-2xl p-4 h-full flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="agent-avatar" style={{ background: `color-mix(in srgb, ${agent.color} 18%, transparent)`, borderColor: agent.color }}>
<span className="text-xl">{agent.icon || '🤖'}</span>
<span className={`agent-status-dot ${busy ? 'active' : ''}`} style={{ background: busy ? agent.color : 'var(--status-ok)' }} />
</div>
<div className="flex flex-col items-end gap-1">
<span className={`agent-state-pill ${busy ? 'busy' : ''}`} style={{ color: busy ? agent.color : 'var(--text-muted)' }}>
{STATE_LABEL[anim.state]}
</span>
{alerts > 0 && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded-full bg-[var(--status-warn-bg)] text-[var(--status-warn)]">
{alerts} alert{alerts > 1 ? 's' : ''}
</span>
)}
</div>
</div>
<div>
<h3 className="font-bold text-base leading-tight" style={{ color: agent.color }}>{agent.name}</h3>
<p className="text-[11px] font-mono text-[var(--text-faint)] mt-0.5 italic">"{agent.motto || agent.role}"</p>
</div>
<div className="flex flex-wrap gap-1">
{(agent.capabilities || []).slice(0, 4).map((cap) => (
<span key={cap} className="cap-chip">{cap}</span>
))}
</div>
<div className="flex items-center justify-between mt-auto pt-2 border-t border-[var(--border)]">
<span className="text-[10px] font-mono text-[var(--text-faint)]">{tasks} tasks logged</span>
<span
role="button"
tabIndex={0}
onClick={(e) => { 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
</span>
</div>
</div>
</motion.button>
)
}
+53
View File
@@ -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<string, AgentAnim>
selectedId: string | null
onSelect: (id: string) => void
onDelegate: (agent: Agent) => void
}
export function AgentRoster({ agents, animations, selectedId, onSelect, onDelegate }: Props) {
return (
<section className="panel rounded-2xl p-5 relative overflow-hidden">
<div className="absolute inset-0 agent-roster-glow pointer-events-none" />
<div className="relative flex flex-wrap items-end justify-between gap-3 mb-4">
<div>
<p className="section-eyebrow">Autonomous workforce</p>
<h2 className="text-xl font-bold tracking-tight" style={{ color: 'var(--accent)' }}>
Agent Roster
</h2>
<p className="text-xs text-[var(--text-muted)] mt-1 max-w-md">
Selecteer een agent om te delegeren. Elk teamlid bewaakt een zone op de ops floor.
</p>
</div>
<div className="flex items-center gap-2 text-xs font-mono text-[var(--text-faint)]">
<span className="w-2 h-2 rounded-full bg-[var(--status-ok)] animate-pulse" />
{agents.length} agents online
</div>
</div>
<div className="relative grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-3">
{agents.map((agent, i) => (
<motion.div
key={agent.id}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.06 }}
>
<AgentCard
agent={agent}
anim={animations[agent.id] || { agentId: agent.id, state: 'idle' }}
selected={selectedId === agent.id}
onSelect={() => onSelect(agent.id)}
onDelegate={() => onDelegate(agent)}
/>
</motion.div>
))}
</div>
</section>
)
}
+216
View File
@@ -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 (
<svg width="72" height="92" viewBox="0 0 72 92" fill="none" xmlns="http://www.w3.org/2000/svg" className="sprite-svg">
<defs>
<linearGradient id={g} x1="36" y1="8" x2="36" y2="88" gradientUnits="userSpaceOnUse">
<stop stopColor={color} stopOpacity="0.35" />
<stop offset="0.45" stopColor={color} stopOpacity="0.08" />
<stop offset="1" stopColor={color} stopOpacity="0.02" />
</linearGradient>
<linearGradient id={visor} x1="36" y1="14" x2="36" y2="28" gradientUnits="userSpaceOnUse">
<stop stopColor={color} stopOpacity="0.95" />
<stop offset="1" stopColor={color} stopOpacity="0.25" />
</linearGradient>
<filter id={glow} x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="2.5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Platform + aura */}
<ellipse cx="36" cy="86" rx="22" ry="5" fill={color} opacity="0.2" />
<ellipse cx="36" cy="86" rx="14" ry="2.5" fill={color} opacity="0.45" className="sprite-platform-pulse" />
{agentId === 'etl-guardian' && (
<g filter={`url(#${glow})`}>
<path d="M26 38 L36 32 L46 38 L44 58 L28 58 Z" fill={`url(#${g})`} stroke={color} strokeWidth="1.4" />
<rect x="30" y="42" width="12" height="8" rx="1" fill={color} opacity="0.15" stroke={color} strokeWidth="0.8" />
<path d="M32 46 H40 M34 48 H38" stroke={color} strokeWidth="0.8" opacity="0.7" />
<motion.path
d="M48 40 Q54 36 56 44"
stroke={color} strokeWidth="1.5" fill="none"
animate={fetching ? { pathLength: [0.2, 1, 0.2] } : { pathLength: 1 }}
transition={{ repeat: Infinity, duration: 0.8 }}
/>
<circle cx="56" cy="44" r="2.5" fill={color} opacity={fetching ? 1 : 0.5} />
<motion.g animate={walking ? { rotate: [0, 12, 0] } : {}} style={{ originX: '48px', originY: '42px' }}>
<path d="M46 40 L52 36 L52 48 L46 44 Z" fill={color} opacity="0.25" stroke={color} strokeWidth="1" />
</motion.g>
<rect x="22" y="40" width="7" height="16" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
<rect x="43" y="40" width="7" height="16" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
<rect x="28" y="58" width="8" height="14" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
<rect x="36" y="58" width="8" height="14" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
<circle cx="36" cy="22" r="11" className="sprite-head" stroke={color} strokeWidth="1.5" />
<path d="M24 18 Q36 6 48 18 L46 22 Q36 12 26 22 Z" fill={color} opacity="0.85" />
<rect x="26" y="18" width="20" height="5" rx="2" fill={`url(#${visor})`} opacity="0.9" />
<path d="M28 20 L32 20 M40 20 L44 20" stroke={color} strokeWidth="1.2" opacity="0.8" />
<text x="36" y="23" textAnchor="middle" fontSize="9" fill={color}></text>
</g>
)}
{agentId === 'lakehouse-ops' && (
<g filter={`url(#${glow})`}>
<path d="M25 37 L36 30 L47 37 L45 59 L27 59 Z" fill={`url(#${g})`} stroke={color} strokeWidth="1.4" />
<path d="M30 35 L36 28 L42 35" stroke={color} strokeWidth="1.2" fill="none" opacity="0.6" />
<rect x="31" y="43" width="10" height="7" rx="1" fill={color} opacity="0.12" stroke={color} strokeWidth="0.8" />
<path d="M32 48 L36 44 L40 48 L38 50 L34 50 Z" fill={color} opacity="0.5" />
<motion.g animate={fetching ? { y: [0, -2, 0] } : {}} transition={{ repeat: Infinity, duration: 1.2 }}>
<rect x="48" y="38" width="10" height="12" rx="2" fill={color} opacity="0.2" stroke={color} strokeWidth="1" />
<path d="M50 46 L54 42 L54 46" stroke={color} strokeWidth="0.8" fill="none" />
</motion.g>
<rect x="21" y="39" width="7" height="15" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
<rect x="44" y="39" width="7" height="15" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
<rect x="27" y="59" width="8" height="13" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
<rect x="37" y="59" width="8" height="13" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
<circle cx="36" cy="21" r="11.5" className="sprite-head" stroke={color} strokeWidth="1.5" />
<path d="M23 17 Q36 5 49 17 L47 21 Q36 11 25 21 Z" fill={color} opacity="0.85" />
<ellipse cx="36" cy="20" rx="9" ry="4" fill={`url(#${visor})`} opacity="0.85" />
<text x="36" y="22" textAnchor="middle" fontSize="8" fill={color}>🏔</text>
</g>
)}
{agentId === 'data-custodian' && (
<g filter={`url(#${glow})`}>
<rect x="26" y="36" width="20" height="24" rx="5" fill={`url(#${g})`} stroke={color} strokeWidth="1.5" />
<rect x="29" y="40" width="14" height="10" rx="2" fill={color} opacity="0.12" stroke={color} strokeWidth="0.8" />
<circle cx="36" cy="45" r="3" stroke={color} strokeWidth="1" fill="none" opacity="0.7" />
<path d="M36 45 L36 48 M34 47 L38 47" stroke={color} strokeWidth="0.8" opacity="0.7" />
<motion.g animate={walking ? { x: [0, 1, 0] } : {}} transition={{ repeat: Infinity, duration: 0.4 }}>
<path d="M18 38 L18 52 L24 52 L28 44 L24 38 Z" fill={color} opacity="0.3" stroke={color} strokeWidth="1.2" />
<path d="M20 42 L24 42 M20 46 L24 46" stroke={color} strokeWidth="0.7" opacity="0.6" />
</motion.g>
<rect x="44" y="39" width="7" height="16" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
<rect x="28" y="60" width="9" height="12" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
<rect x="35" y="60" width="9" height="12" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
<circle cx="36" cy="22" r="12" className="sprite-head" stroke={color} strokeWidth="1.5" />
<path d="M22 19 Q36 7 50 19 L48 23 Q36 13 24 23 Z" fill={color} opacity="0.9" />
<rect x="27" y="18" width="18" height="6" rx="3" fill={`url(#${visor})`} />
<text x="36" y="23" textAnchor="middle" fontSize="8" fill={color}>🛡</text>
</g>
)}
{agentId === 'hadoop-ranger' && (
<g filter={`url(#${glow})`}>
<path d="M27 38 L36 33 L45 38 L43 58 L29 58 Z" fill={`url(#${g})`} stroke={color} strokeWidth="1.4" />
<rect x="30" y="42" width="12" height="9" rx="1.5" fill={color} opacity="0.1" stroke={color} strokeWidth="0.8" />
<path d="M32 46 H40 M33 49 H39" stroke={color} strokeWidth="0.7" opacity="0.6" />
<path d="M14 42 L20 38 L20 46 L14 50 Z" fill={color} opacity="0.25" stroke={color} strokeWidth="1" />
<circle cx="17" cy="43" r="2" fill={color} opacity="0.8" />
<circle cx="17" cy="47" r="2" fill={color} opacity="0.8" />
<rect x="22" y="40" width="7" height="16" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
<rect x="43" y="40" width="7" height="16" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
<rect x="28" y="58" width="8" height="14" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
<rect x="36" y="58" width="8" height="14" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
<circle cx="36" cy="22" r="11" className="sprite-head" stroke={color} strokeWidth="1.5" />
<path d="M24 18 Q36 8 48 18 L46 22 Q36 14 26 22 Z" fill={color} opacity="0.85" />
<path d="M30 14 L36 10 L42 14 L40 17 L32 17 Z" fill={color} opacity="0.5" />
<rect x="28" y="19" width="16" height="5" rx="2" fill={`url(#${visor})`} opacity="0.85" />
<text x="36" y="23" textAnchor="middle" fontSize="8" fill={color}>🌲</text>
{fetching && (
<motion.circle cx="50" cy="30" r="3" fill={color} animate={{ opacity: [0.3, 1, 0.3] }} transition={{ repeat: Infinity, duration: 0.6 }} />
)}
</g>
)}
{agentId === 'infra-sentinel' && (
<g filter={`url(#${glow})`}>
<path d="M24 37 Q36 30 48 37 L46 59 L26 59 Z" fill={`url(#${g})`} stroke={color} strokeWidth="1.4" />
<circle cx="36" cy="46" r="5" stroke={color} strokeWidth="1" fill={color} opacity="0.15" />
<circle cx="36" cy="46" r="2" fill={color} opacity="0.8" className="sprite-core-pulse" />
<motion.g
animate={{ rotate: 360 }}
transition={{ repeat: Infinity, duration: 8, ease: 'linear' }}
style={{ originX: '36px', originY: '46px' }}
>
<ellipse cx="36" cy="46" rx="10" ry="4" stroke={color} strokeWidth="0.8" fill="none" opacity="0.35" />
</motion.g>
<rect x="20" y="39" width="7" height="15" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
<rect x="45" y="39" width="7" height="15" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
<rect x="27" y="59" width="8" height="13" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
<rect x="37" y="59" width="8" height="13" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
<circle cx="36" cy="21" r="11.5" className="sprite-head" stroke={color} strokeWidth="1.5" />
<path d="M23 17 Q36 5 49 17 L47 21 Q36 11 25 21 Z" fill={color} opacity="0.85" />
<rect x="26" y="17" width="20" height="6" rx="3" fill={`url(#${visor})`} />
<circle cx="36" cy="20" r="3" fill={color} opacity="0.9" />
<motion.circle
cx="52" cy="18" r="4"
fill={color} opacity="0.4" stroke={color} strokeWidth="1"
animate={{ y: [0, -3, 0], opacity: [0.3, 0.8, 0.3] }}
transition={{ repeat: Infinity, duration: 2 }}
/>
<text x="36" y="24" textAnchor="middle" fontSize="7" fill="#fff" opacity="0.9">👁</text>
</g>
)}
{/* Default fallback */}
{!['etl-guardian', 'lakehouse-ops', 'data-custodian', 'hadoop-ranger', 'infra-sentinel'].includes(agentId) && (
<g filter={`url(#${glow})`}>
<rect x="26" y="36" width="20" height="24" rx="4" fill={`url(#${g})`} stroke={color} strokeWidth="1.5" />
<circle cx="36" cy="22" r="11" className="sprite-head" stroke={color} strokeWidth="1.5" />
<text x="36" y="25" textAnchor="middle" fontSize="10" fill={color}>🤖</text>
</g>
)}
{fetching && (
<motion.g animate={{ opacity: [0.4, 1, 0.4] }} transition={{ repeat: Infinity, duration: 0.7 }}>
<circle cx="58" cy="26" r="4" fill={color} />
<circle cx="58" cy="26" r="7" stroke={color} strokeWidth="1" fill="none" opacity="0.4" />
</motion.g>
)}
</svg>
)
}
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 (
<motion.div
className="flex flex-col items-center agent-sprite"
animate={{ ...bob, scale }}
transition={{ repeat: Infinity, duration: state === 'walk' || state === 'return' ? 0.28 : 2.4, ease: 'easeInOut' }}
>
<div className={`sprite-figure ${busy ? 'sprite-figure-busy' : ''}`}>
{state !== 'idle' && (
<motion.div
className="sprite-ring-outer"
style={{ borderColor: color, boxShadow: `0 0 20px ${color}55, inset 0 0 12px ${color}22` }}
animate={{ scale: [1, 1.06, 1], opacity: [0.6, 1, 0.6] }}
transition={{ repeat: Infinity, duration: 1.5 }}
/>
)}
<div className="sprite-holo-shimmer" style={{ background: `linear-gradient(135deg, ${color}18, transparent 60%)` }} />
<CharacterBody agentId={agentId} color={color} state={state} />
</div>
<div className="sprite-nameplate" style={{ borderColor: `${color}55`, boxShadow: `0 0 12px ${color}33` }}>
<span className="sprite-nameplate-dot" style={{ background: color, boxShadow: `0 0 6px ${color}` }} />
<span className="sprite-nameplate-text" style={{ color }}>{label}</span>
</div>
</motion.div>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useRef } from 'react'
import type { CSSProperties } from 'react'
import type { Agent, TerminalLine } from '../types'
const LEVEL_CLASS: Record<string, string> = {
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<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (active || expanded) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}
}, [lines, active, expanded])
return (
<div
className={`agent-terminal ${active ? 'active' : ''} ${expanded ? 'expanded' : ''}`}
style={{ '--term-accent': agent.color } as CSSProperties}
onClick={onFocus}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && onFocus?.()}
>
<div className="agent-terminal-header">
<div className="flex items-center gap-2 min-w-0">
<span>{agent.icon}</span>
<span className="font-semibold text-xs truncate" style={{ color: agent.color }}>{agent.name}</span>
{active && <span className="term-live-badge">LIVE</span>}
</div>
<span className="text-[10px] font-mono text-[var(--text-faint)]">{lines.length} lines</span>
</div>
<div ref={containerRef} className="agent-terminal-body">
{lines.length === 0 && (
<div className="term-line term-info">
<span className="term-ts">--:--:--</span>
<span className="term-text">Waiting for missions</span>
</div>
)}
{lines.map((line) => (
<div key={line.id} className={`term-line ${LEVEL_CLASS[line.level] || 'term-info'}`}>
<span className="term-ts">{line.ts ? new Date(line.ts).toLocaleTimeString() : ''}</span>
<span className="term-phase">{line.phase}</span>
<span className="term-text">{line.text}</span>
</div>
))}
{active && (
<div className="term-line term-cmd">
<span className="term-ts" />
<span className="term-text term-cursor"></span>
</div>
)}
<div ref={bottomRef} />
</div>
</div>
)
}
+60
View File
@@ -0,0 +1,60 @@
import type { Agent, AgentAnim, TerminalLine } from '../types'
import { AgentTerminal } from './AgentTerminal'
type Props = {
agents: Agent[]
terminals: Record<string, TerminalLine[]>
animations: Record<string, AgentAnim>
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 (
<div className="agent-terminal-focus">
<AgentTerminal
agent={agent}
lines={terminals[focusId] || []}
active={anim.state !== 'idle'}
expanded
/>
<div className="agent-terminal-tabs">
{agents.map((a) => (
<button
key={a.id}
type="button"
onClick={() => 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]}
</button>
))}
</div>
</div>
)
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5 gap-3">
{agents.map((agent) => {
const anim = animations[agent.id] || { agentId: agent.id, state: 'idle' as const }
return (
<AgentTerminal
key={agent.id}
agent={agent}
lines={terminals[agent.id] || []}
active={anim.state !== 'idle'}
onFocus={() => onSelect(agent.id)}
/>
)
})}
</div>
)
}
+18
View File
@@ -0,0 +1,18 @@
export function AmbientBackground() {
return (
<div className="ambient-bg pointer-events-none fixed inset-0 z-0 overflow-hidden" aria-hidden>
{[...Array(12)].map((_, i) => (
<span
key={i}
className="ambient-orb"
style={{
left: `${(i * 17 + 5) % 95}%`,
top: `${(i * 23 + 8) % 90}%`,
animationDelay: `${i * 0.7}s`,
animationDuration: `${8 + (i % 4) * 2}s`,
}}
/>
))}
</div>
)
}
+89
View File
@@ -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<HTMLDivElement>(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, busy])
return (
<div className="panel rounded-2xl flex flex-col h-full min-h-[360px] overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--border)] flex items-center justify-between gap-3">
<div>
<p className="section-eyebrow">Mission control</p>
<h3 className="font-bold text-base" style={{ color: 'var(--accent)' }}>Agent Comms</h3>
</div>
{selectedAgent && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl border text-xs font-mono" style={{ borderColor: `${selectedAgent.color}44`, color: selectedAgent.color }}>
<span>{selectedAgent.icon}</span>
<span> {selectedAgent.name}</span>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.length === 0 && (
<div className="empty-state h-full flex flex-col items-center justify-center">
<span className="text-3xl mb-3">💬</span>
<p className="text-sm text-[var(--text-muted)] text-center max-w-xs">
{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.'}
</p>
</div>
)}
{messages.map((m, i) => {
const agent = m.role === 'agent' ? agentFor(agents, m.agent) : null
return (
<motion.div
key={i}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className={`chat-message ${m.role}`}
>
{m.role === 'agent' && agent && (
<div className="chat-avatar" style={{ background: `color-mix(in srgb, ${agent.color} 20%, transparent)`, borderColor: agent.color }}>
{agent.icon}
</div>
)}
<div className={`chat-bubble ${m.role === 'user' ? 'chat-bubble-user' : 'chat-bubble-agent'}`}>
<div className="chat-meta">
{m.role === 'user' ? 'You' : agent?.name || m.agent}
{m.ts && <span>{new Date(m.ts).toLocaleTimeString()}</span>}
</div>
<div className="chat-text whitespace-pre-wrap">{m.text}</div>
</div>
</motion.div>
)
})}
{busy && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="chat-message agent">
<div className="chat-avatar thinking-pulse" style={{ background: 'var(--surface-elevated)' }}></div>
<div className="chat-bubble chat-bubble-agent">
<div className="typing-indicator">
<span /><span /><span />
</div>
</div>
</motion.div>
)}
<div ref={bottomRef} />
</div>
</div>
)
}
+167
View File
@@ -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 (
<svg className="absolute inset-0 w-full h-full pointer-events-none cluster-flow-svg" preserveAspectRatio="none">
<defs>
{zones.map((z) => (
<linearGradient key={`grad-${z.id}`} id={`flow-grad-${z.id}`} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={z.color} stopOpacity="0.05" />
<stop offset="50%" stopColor={z.color} stopOpacity="0.6" />
<stop offset="100%" stopColor={z.color} stopOpacity="0.05" />
</linearGradient>
))}
</defs>
{zones.map((z, i) => (
<g key={z.id}>
<motion.line
x1={`${DESK_X}%`} y1="92%" x2={`${z.x}%`} y2="22%"
stroke={`url(#flow-grad-${z.id})`}
strokeWidth={activeZoneId === z.id ? 3 : 1.5}
strokeDasharray="6 5"
animate={{ strokeDashoffset: [0, -22] }}
transition={{ repeat: Infinity, duration: 1.8 + i * 0.15, ease: 'linear' }}
/>
{[0, 1, 2].map((p) => (
<motion.circle
key={p}
r={activeZoneId === z.id ? 4 : 3}
fill={z.color}
filter={`drop-shadow(0 0 4px ${z.color})`}
animate={{
cx: [`${DESK_X}%`, `${z.x}%`],
cy: ['92%', '22%'],
opacity: [0, 1, 1, 0],
}}
transition={{
repeat: Infinity,
duration: 2.2 + i * 0.2 + p * 0.4,
delay: p * 0.7 + i * 0.1,
ease: 'linear',
}}
/>
))}
</g>
))}
</svg>
)
}
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 (
<div className="absolute top-0 -translate-x-1/2 flex flex-col items-center gap-1" style={{ left: `${zone.x}%` }}>
<motion.div
className={`zone-tower ${pulse ? 'zone-tower-live' : ''} ${active ? 'zone-tower-active' : ''}`}
style={{
borderColor: zone.color,
boxShadow: `0 0 ${active || agentBusy ? 28 : 14}px ${zone.color}${active ? '66' : '33'}`,
color: zone.color,
}}
animate={agentBusy ? { scale: [1, 1.04, 1] } : { scale: 1 }}
transition={{ repeat: Infinity, duration: 0.8 }}
>
<div className="zone-tower-label">{zone.label}</div>
<div className="zone-tower-stats">
<span className={`zone-level-dot level-${zone.level}`} />
{zone.running}/{zone.total || zone.apps.length}
</div>
{zone.id === 'hadoop' && zone.hdfs_total_gb != null && (
<div className="zone-tower-extra">{zone.hdfs_used_gb ?? 0}/{zone.hdfs_total_gb} GB</div>
)}
{zone.id === 'lakehouse' && (
<div className="zone-tower-extra">{zone.trino_ok ? 'Trino ●' : 'Trino ○'}</div>
)}
</motion.div>
<div className="zone-app-orbit">
{apps.map((app, i) => (
<motion.div
key={app.name}
className={`zone-app-chip state-${app.state}`}
style={{ borderColor: `${zone.color}55` }}
animate={{ y: [0, -3, 0], opacity: app.state === 'running' ? [0.85, 1, 0.85] : 0.45 }}
transition={{ repeat: Infinity, duration: 2 + i * 0.3, delay: i * 0.15 }}
title={`${app.name} (${app.state})`}
>
<span>{appIcon(app.name, app.image)}</span>
<span>{shortName(app.name, 10)}</span>
</motion.div>
))}
</div>
</div>
)
}
export function GpuBeacon({ model, util, count, level, active }: {
model?: string | null
util?: number
count?: number
level: string
active?: boolean
}) {
return (
<motion.div
className={`gpu-beacon level-${level} ${active ? 'gpu-beacon-active' : ''}`}
animate={{ boxShadow: active ? ['0 0 20px #76b90044', '0 0 36px #76b90088', '0 0 20px #76b90044'] : undefined }}
transition={{ repeat: Infinity, duration: 2 }}
>
<div className="gpu-beacon-title"> GPU LAB</div>
<div className="gpu-beacon-model">{shortName(model || 'offline', 18)}</div>
<div className="gpu-beacon-meta">{count ?? 0}× V100 · {Math.round(util ?? 0)}%</div>
<div className="gpu-beacon-bar">
<motion.div
className="gpu-beacon-fill"
animate={{ width: `${Math.max(util ?? 0, 4)}%` }}
transition={{ duration: 0.8 }}
/>
</div>
</motion.div>
)
}
export function WorkloadTicker({ zones }: { zones: WorkloadZone[] }) {
const allApps = zones.flatMap((z) =>
z.apps.map((a) => ({ ...a, zoneColor: z.color, zoneId: z.id })),
)
return (
<div className="workload-ticker-wrap">
<div className="workload-ticker-label">LIVE WORKLOAD</div>
<div className="workload-ticker-track">
<motion.div
className="workload-ticker-inner"
animate={{ x: ['0%', '-50%'] }}
transition={{ repeat: Infinity, duration: 40, ease: 'linear' }}
>
{[...allApps, ...allApps].map((app, i) => (
<span
key={`${app.name}-${i}`}
className={`ticker-chip state-${app.state}`}
style={{ borderColor: `${app.zoneColor}44`, color: app.zoneColor }}
>
{appIcon(app.name, app.image)} {app.name}
</span>
))}
</motion.div>
</div>
</div>
)
}
+69
View File
@@ -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 (
<div className="command-dock panel rounded-2xl p-4 md:p-5">
<div className="flex flex-wrap gap-2 mb-3">
{prompts.map((p) => (
<motion.button
key={p}
type="button"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
disabled={busy}
onClick={() => sendQuick(p)}
className="quick-prompt-chip"
style={selectedAgent ? { borderColor: `${selectedAgent.color}55`, color: selectedAgent.color } : undefined}
>
{p}
</motion.button>
))}
</div>
<form onSubmit={handle} className="flex gap-3 items-center">
<div className="command-input-wrap flex-1 flex items-center gap-3 rounded-xl px-4 py-1">
{selectedAgent && (
<span className="text-lg shrink-0" title={selectedAgent.name}>{selectedAgent.icon}</span>
)}
<input
className="prompt-input border-0 bg-transparent shadow-none focus:shadow-none flex-1 py-2.5"
placeholder={selectedAgent ? `Opdracht voor ${selectedAgent.name}...` : 'Vraag je agents... routing kiest de specialist'}
value={text}
onChange={(e) => setText(e.target.value)}
disabled={busy}
/>
</div>
<button type="submit" disabled={busy || !text.trim()} className="btn-primary shrink-0 px-6">
{busy ? 'Dispatching...' : 'Dispatch'}
</button>
</form>
</div>
)
}
+137
View File
@@ -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 (
<div className="panel rounded-2xl p-5 animate-pulse">
<div className="h-4 w-32 rounded bg-[var(--surface-elevated)] mb-4" />
<div className="h-24 rounded-xl bg-[var(--surface-elevated)]" />
</div>
)
}
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 (
<div className="panel rounded-2xl p-4 relative overflow-hidden">
<div className="absolute -top-8 -right-8 w-24 h-24 rounded-full bg-[var(--accent-gpu)] opacity-[0.1] blur-2xl pointer-events-none" />
<div className="flex items-center justify-between gap-2 mb-3 relative">
<div className="flex items-center gap-2">
<span></span>
<h2 className="font-bold text-sm text-[var(--accent-gpu)]">GPU Lab</h2>
</div>
<a href={gpu.ui_url} target="_blank" rel="noopener noreferrer" className="text-[10px] font-mono text-[var(--accent-gpu)] hover:underline">
Open
</a>
</div>
{gpu.active_model && (
<div className="text-sm font-semibold text-[var(--text)] truncate">{gpu.active_model}</div>
)}
<div className="flex gap-3 mt-2 text-[10px] font-mono text-[var(--text-muted)]">
<span>{gpu.gpu_count ?? gpus.length}× V100</span>
<span>{Math.round(avgUtil)}% util</span>
<span>{Math.round(avgVram)}% VRAM</span>
<span className={online && gpu.inference_active ? 'text-[var(--status-ok)]' : 'text-[var(--status-warn)]'}>
{online ? (gpu.inference_active ? 'ON' : 'STBY') : 'OFF'}
</span>
</div>
<div className="h-1.5 rounded-full bg-[var(--surface-muted)] mt-3 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${Math.max(avgUtil, avgVram * 0.5)}%`,
background: 'linear-gradient(90deg, var(--accent-gpu), var(--accent))',
}}
/>
</div>
</div>
)
}
return (
<div className="panel rounded-2xl p-5 relative overflow-hidden">
<div className="absolute -top-12 -right-12 w-40 h-40 rounded-full bg-[var(--accent-gpu)] opacity-[0.08] blur-2xl pointer-events-none" />
<div className="flex flex-wrap items-start justify-between gap-3 mb-4 relative">
<div>
<p className="section-eyebrow">Inference cluster</p>
<div className="flex items-center gap-2">
<span className="text-lg" aria-hidden></span>
<h2 className="font-display font-bold text-[var(--accent-gpu)] tracking-tight">GPU Lab</h2>
<span
className={`text-[10px] font-mono px-2 py-0.5 rounded-full border ${
online && gpu.inference_active
? 'border-[var(--status-ok)] text-[var(--status-ok)] bg-[var(--status-ok-bg)]'
: 'border-[var(--status-warn)] text-[var(--status-warn)] bg-[var(--status-warn-bg)]'
}`}
>
{online ? (gpu.inference_active ? 'INFERENCE ON' : 'STANDBY') : 'OFFLINE'}
</span>
</div>
<p className="text-xs font-mono text-[var(--text-muted)] mt-1">
atc-gpu-dev · {gpu.host} · {gpu.gpu_count ?? gpus.length}× V100
</p>
</div>
<a href={gpu.ui_url} target="_blank" rel="noopener noreferrer" className="btn-secondary text-xs shrink-0">
Open GPU Manager
</a>
</div>
{gpu.active_model && (
<div className="rounded-xl border border-[var(--border)] bg-[var(--surface-elevated)] px-4 py-3 mb-4">
<div className="text-[10px] font-mono uppercase tracking-widest text-[var(--text-faint)]">Active model</div>
<div className="font-semibold text-[var(--text)] mt-0.5">{gpu.active_model}</div>
{gpu.vllm_url && (
<div className="text-[11px] font-mono text-[var(--text-muted)] mt-1 truncate">{gpu.vllm_url}</div>
)}
</div>
)}
{!online && (
<p className="text-sm text-[var(--status-down)]">{gpu.error || 'GPU manager unreachable'}</p>
)}
<div className="grid gap-2 sm:grid-cols-2">
{gpus.map((g) => {
const pct = memPct(g.memory_used_mib, g.memory_total_mib)
return (
<div key={g.index} className="gpu-card rounded-xl p-3 border border-[var(--border)]">
<div className="flex justify-between items-center mb-2">
<span className="text-xs font-mono font-semibold text-[var(--accent-gpu)]">GPU {g.index}</span>
<span className="text-[10px] font-mono text-[var(--text-faint)]">{g.temperature_c}°C · {g.power_w}W</span>
</div>
<div className="text-[11px] text-[var(--text-muted)] truncate mb-2">{g.name}</div>
<div className="flex gap-3 text-[10px] font-mono mb-1.5">
<span>Util {Math.round(g.util_gpu)}%</span>
<span>VRAM {pct}%</span>
</div>
<div className="h-1.5 rounded-full bg-[var(--surface-muted)] overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${Math.max(g.util_gpu, pct * 0.3)}%`,
background: 'linear-gradient(90deg, var(--accent-gpu), var(--accent))',
}}
/>
</div>
</div>
)
})}
</div>
</div>
)
}
+154
View File
@@ -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<string, number> = {
docker: 8,
db: 28,
lakehouse: 50,
hadoop: 72,
etl: 92,
}
const STATE_LABEL: Record<AgentAnim['state'], string> = {
idle: '',
walk: '→ zone',
fetch: '⟳ fetch',
return: '← desk',
}
const DESK_X = 50
type Props = {
agents: Agent[]
workload: WorkloadData | null
animations: Record<string, AgentAnim>
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 (
<div className="panel rounded-2xl p-5 relative overflow-hidden min-h-[480px] live-cluster-map">
<div className="cluster-ambient" />
<div className="ops-floor-scan" />
<div className="flex justify-between items-start mb-4 relative z-10 gap-3 flex-wrap">
<div>
<p className="section-eyebrow">Live simulation</p>
<h2 className="font-display text-lg font-bold tracking-wide neon-text" style={{ color: 'var(--accent)' }}>
Cluster Ops Floor
</h2>
{workload && (
<p className="text-[10px] font-mono text-[var(--text-muted)] mt-1">
{workload.totals.apps_running} workloads active · {workload.totals.connectors} connectors · GPU {workload.gpu.avg_util ?? 0}%
</p>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{activeCount > 0 && (
<span className="text-[10px] font-mono px-2 py-1 rounded-full border border-[var(--accent)] text-[var(--accent)] bg-[color-mix(in_srgb,var(--accent)_10%,transparent)] animate-pulse">
{activeCount} agent{activeCount > 1 ? 's' : ''} deployed
</span>
)}
<span className="live-badge"> LIVE</span>
</div>
</div>
{workload && (
<div className="absolute top-4 right-4 z-20 hidden lg:block">
<GpuBeacon
model={workload.gpu.model}
util={workload.gpu.avg_util}
count={workload.gpu.gpu_count}
level={workload.gpu.level}
active={workload.gpu.inference_active}
/>
</div>
)}
<div className="relative h-52 mb-2 z-10">
<DataFlowLayer zones={zones} activeZoneId={mappedBusyZone} />
{zones.map((z) => (
<ZoneTower
key={z.id}
zone={z}
active={mappedBusyZone === z.id}
agentBusy={mappedBusyZone === z.id}
/>
))}
<motion.div
className="absolute bottom-0 left-1/2 -translate-x-1/2 command-desk"
animate={{ boxShadow: ['0 0 24px var(--glow)', '0 0 40px var(--glow)', '0 0 24px var(--glow)'] }}
transition={{ repeat: Infinity, duration: 3 }}
>
<span className="command-desk-ring" />
COMMAND DESK
</motion.div>
</div>
{workload && zones.length > 0 && (
<div className="relative z-10 mb-3">
<WorkloadTicker zones={zones} />
</div>
)}
<div className="relative h-44 rounded-2xl border ops-floor-stage z-10 cluster-agent-stage">
<div className="cluster-stage-grid" />
{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 (
<motion.div
key={agent.id}
className="absolute bottom-3 -translate-x-1/2"
animate={{ left: `${targetX}%`, y, scale: selected ? 1.08 : 1 }}
transition={{ type: 'spring', stiffness: 90, damping: 15 }}
>
<AnimatePresence>
{busy && STATE_LABEL[anim.state] && (
<motion.div
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="absolute -top-6 left-1/2 -translate-x-1/2 text-[9px] font-mono font-bold whitespace-nowrap px-2 py-0.5 rounded-full"
style={{
color: agent.color,
background: `color-mix(in srgb, ${agent.color} 15%, var(--surface-strong))`,
border: `1px solid ${agent.color}44`,
}}
>
{STATE_LABEL[anim.state]}
</motion.div>
)}
</AnimatePresence>
<div
className={`sprite-wrap ${selected ? 'selected' : ''} ${busy ? 'busy' : ''}`}
style={{ '--sprite-color': agent.color } as CSSProperties}
>
<AgentSprite
agentId={agent.id}
color={agent.color}
state={anim.state}
label={agent.name.split(' ')[0]}
/>
</div>
</motion.div>
)
})}
</div>
</div>
)
}
+110
View File
@@ -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 <div className="live-domain-grid animate-pulse h-48 rounded-xl bg-[var(--surface-elevated)]" />
}
return (
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{workload.zones.map((zone, zi) => {
const pct = zone.total ? Math.round((zone.running / zone.total) * 100) : 100
return (
<motion.div
key={zone.id}
className={`live-domain-card level-${zone.level}`}
style={{ borderColor: LEVEL_COLOR[zone.level] || zone.color }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: zi * 0.05 }}
whileHover={{ y: -3, boxShadow: `0 8px 32px ${zone.color}22` }}
>
<div className="flex justify-between items-start gap-2 mb-2">
<div>
<div className="text-[10px] font-mono uppercase tracking-widest" style={{ color: zone.color }}>
{zone.label}
</div>
<div className="text-lg font-bold mt-0.5" style={{ color: LEVEL_COLOR[zone.level] }}>
{zone.running}/{zone.total || zone.apps.length}
</div>
</div>
<motion.span
className={`domain-pulse-dot level-${zone.level}`}
animate={{ scale: [1, 1.3, 1], opacity: [0.7, 1, 0.7] }}
transition={{ repeat: Infinity, duration: 2 }}
/>
</div>
<div className="domain-progress-bar">
<motion.div
className="domain-progress-fill"
style={{ background: `linear-gradient(90deg, ${zone.color}, ${zone.color}88)` }}
animate={{ width: `${pct}%` }}
transition={{ duration: 0.6 }}
/>
</div>
<div className="domain-app-grid mt-3">
{zone.apps.slice(0, 6).map((app, ai) => (
<motion.div
key={app.name}
className={`domain-app-tile state-${app.state}`}
title={app.name}
animate={app.state === 'running' ? { opacity: [0.7, 1, 0.7] } : { opacity: 0.4 }}
transition={{ repeat: Infinity, duration: 2.5, delay: ai * 0.1 }}
>
<span>{appIcon(app.name, app.image)}</span>
<span className="truncate">{app.name.split('_')[0]}</span>
</motion.div>
))}
</div>
</motion.div>
)
})}
<motion.div
className={`live-domain-card level-${workload.gpu.level}`}
style={{ borderColor: 'var(--accent-gpu)' }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<div className="text-[10px] font-mono uppercase tracking-widest text-[var(--accent-gpu)]">GPU LAB</div>
<div className="text-lg font-bold mt-0.5 text-[var(--accent-gpu)]">
{workload.gpu.gpu_count ?? 0}× V100
</div>
<div className="text-xs font-mono text-[var(--text-muted)] mt-1 truncate">
{workload.gpu.model || 'offline'}
</div>
<div className="domain-progress-bar mt-3">
<motion.div
className="domain-progress-fill"
style={{ background: 'linear-gradient(90deg, var(--accent-gpu), var(--accent))' }}
animate={{ width: `${Math.max(workload.gpu.avg_util ?? 0, 2)}%` }}
/>
</div>
<div className="flex gap-1 mt-3 flex-wrap">
{(workload.gpu.gpus || []).map((g) => (
<motion.div
key={g.index}
className="gpu-mini-tile"
animate={{ opacity: [0.6, 1, 0.6] }}
transition={{ repeat: Infinity, duration: 1.5 + g.index * 0.2 }}
title={`GPU${g.index} ${g.util_gpu}%`}
>
G{g.index}
</motion.div>
))}
</div>
</motion.div>
</div>
</div>
)
}
+134
View File
@@ -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<string, number> = {
docker: 8,
db: 28,
lakehouse: 50,
hadoop: 72,
etl: 92,
}
const STATE_LABEL: Record<AgentAnim['state'], string> = {
idle: '',
walk: '→ zone',
fetch: '⟳ fetch',
return: '← desk',
}
const DESK_X = 50
type Props = {
agents: Agent[]
zones: Zone[]
animations: Record<string, AgentAnim>
selectedId: string | null
}
export function OpsFloor({ agents, zones, animations, selectedId }: Props) {
const activeCount = agents.filter((a) => (animations[a.id]?.state || 'idle') !== 'idle').length
return (
<div className="panel rounded-2xl p-5 relative overflow-hidden min-h-[340px] ops-floor">
<div className="ops-floor-scan" />
<div className="flex justify-between items-center mb-5 relative z-10">
<div>
<p className="section-eyebrow">Live simulation</p>
<h2 className="font-display text-lg font-bold tracking-wide neon-text" style={{ color: 'var(--accent)' }}>
Ops Floor
</h2>
</div>
<div className="flex items-center gap-2">
{activeCount > 0 && (
<span className="text-[10px] font-mono px-2 py-1 rounded-full border border-[var(--accent)] text-[var(--accent)] bg-[color-mix(in_srgb,var(--accent)_10%,transparent)]">
{activeCount} agent{activeCount > 1 ? 's' : ''} deployed
</span>
)}
<span className="live-badge"> LIVE</span>
</div>
</div>
<div className="relative h-32 mb-4 z-10">
{zones.map((z) => (
<div key={z.id} className="absolute top-0 -translate-x-1/2 text-center" style={{ left: `${z.x}%` }}>
<motion.div
className="zone-node rounded-xl px-3 py-2.5 min-w-[88px] text-[9px] font-mono tracking-wider font-semibold"
style={{
border: `2px solid ${z.color}`,
boxShadow: `0 0 20px ${z.color}33`,
color: z.color,
}}
whileHover={{ scale: 1.05 }}
>
{z.label}
</motion.div>
</div>
))}
<div
className="absolute bottom-0 left-1/2 -translate-x-1/2 zone-node rounded-xl px-4 py-2 text-[9px] font-mono font-bold tracking-widest"
style={{ borderColor: 'var(--accent)', color: 'var(--accent)', boxShadow: '0 0 24px var(--glow)' }}
>
COMMAND DESK
</div>
<svg className="absolute inset-0 w-full h-full pointer-events-none" preserveAspectRatio="none">
{zones.map((z) => (
<motion.line
key={`path-${z.id}`}
x1={`${DESK_X}%`} y1="88%" x2={`${z.x}%`} y2="30%"
stroke={z.color} strokeWidth="1.5" strokeDasharray="5 4" opacity="0.35"
animate={{ strokeDashoffset: [0, -18] }}
transition={{ repeat: Infinity, duration: 2, ease: 'linear' }}
/>
))}
</svg>
</div>
<div className="relative h-40 rounded-2xl border ops-floor-stage z-10">
{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 (
<motion.div
key={agent.id}
className="absolute bottom-3 -translate-x-1/2"
animate={{ left: `${targetX}%`, y, scale: selected ? 1.08 : 1 }}
transition={{ type: 'spring', stiffness: 90, damping: 15 }}
>
<AnimatePresence>
{busy && STATE_LABEL[anim.state] && (
<motion.div
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="absolute -top-6 left-1/2 -translate-x-1/2 text-[9px] font-mono font-bold whitespace-nowrap px-2 py-0.5 rounded-full"
style={{ color: agent.color, background: `color-mix(in srgb, ${agent.color} 15%, var(--surface-strong))`, border: `1px solid ${agent.color}44` }}
>
{STATE_LABEL[anim.state]}
</motion.div>
)}
</AnimatePresence>
<div className={`sprite-wrap ${selected ? 'selected' : ''} ${busy ? 'busy' : ''}`} style={{ '--sprite-color': agent.color } as CSSProperties}>
<AgentSprite
agentId={agent.id}
color={agent.color}
icon={agent.icon}
state={anim.state}
label={agent.name.split(' ')[0]}
/>
</div>
</motion.div>
)
})}
</div>
</div>
)
}
+33
View File
@@ -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 (
<form onSubmit={handle} className="panel rounded-2xl p-4 flex gap-3 items-center">
<span className="text-2xl shrink-0" aria-hidden>💬</span>
<input
className="prompt-input"
placeholder="Vraag je agents... bijv. Hoe staat de GPU? Welk model draait er?"
value={text}
onChange={(e) => setText(e.target.value)}
disabled={busy}
/>
<button type="submit" disabled={busy || !text.trim()} className="btn-primary shrink-0">
{busy ? 'Bezig...' : 'Send'}
</button>
</form>
)
}
+23
View File
@@ -0,0 +1,23 @@
import { useTheme } from '../context/ThemeContext'
export function ThemeToggle() {
const { theme, toggle } = useTheme()
const isDark = theme === 'dark'
return (
<button
type="button"
onClick={toggle}
className="theme-toggle"
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
title={isDark ? 'Light mode' : 'Dark mode'}
>
<span className={`theme-toggle-track ${isDark ? 'is-dark' : ''}`}>
<span className="theme-toggle-thumb">{isDark ? '🌙' : '☀️'}</span>
</span>
<span className="text-xs font-mono hidden sm:inline text-[var(--text-muted)]">
{isDark ? 'Dark' : 'Light'}
</span>
</button>
)
}
+46
View File
@@ -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<ThemeContextValue | null>(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<Theme>(() => {
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 (
<ThemeContext.Provider value={{ theme, toggle, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme outside ThemeProvider')
return ctx
}
+629
View File
@@ -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%); }
}
+41
View File
@@ -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<string, string> = {
ok: 'var(--status-ok)',
warn: 'var(--status-warn)',
down: 'var(--status-down)',
unknown: 'var(--text-faint)',
}
+13
View File
@@ -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(
<React.StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</React.StrictMode>,
)
+134
View File
@@ -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<string, DomainStatus>
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
}
+14
View File
@@ -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: [],
}
+17
View File
@@ -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"]
}
+4 -4
View File
@@ -1,14 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" class="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ATC Command Center</title>
<title>ATC Data & AI Command Center</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Space+Grotesk:wght@400;600;700&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
</head>
<body class="text-ink">
<body class="antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
+11
View File
@@ -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;
}
+7 -3
View File
@@ -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",
+159 -199
View File
@@ -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<Tab>('Overview')
const [agents, setAgents] = useState<Agent[]>([])
const [zones, setZones] = useState<Zone[]>([])
const [status, setStatus] = useState<StatusData | null>(null)
const [feed, setFeed] = useState<FeedEntry[]>([])
const [approvals, setApprovals] = useState<Approval[]>([])
const [chat, setChat] = useState<{ role: 'user' | 'agent'; text: string; agent?: string }[]>([])
const [anims, setAnims] = useState<Record<string, AgentAnim>>({})
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<HTMLDivElement>(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 (
<div className="min-h-screen p-4 md:p-6 max-w-7xl mx-auto font-display flex flex-col gap-4">
<header className="glass-strong rounded-2xl px-5 py-4 flex flex-wrap justify-between items-center gap-3">
<div className="flex items-center gap-4">
<div
className="w-11 h-11 rounded-xl flex items-center justify-center text-xl font-bold text-white shadow-neon-cyan"
style={{ background: 'linear-gradient(135deg, #0099cc, #8844cc)' }}
>
ATC
</div>
<div>
<h1 className="text-2xl font-bold text-neon-cyan neon-text-cyan tracking-tight">Command Center</h1>
<p className="text-xs font-mono text-ink-muted">Agent ops floor · Dell ATC Lab</p>
</div>
</div>
<div className="flex items-center gap-3">
{status && (
<span className={`text-xs font-mono px-3 py-1.5 rounded-full border ${allOk ? 'bg-green-50 border-neon-green/30 text-neon-green' : 'bg-amber-50 border-neon-amber/30 text-neon-amber'}`}>
{allOk ? '● All systems operational' : '● Attention required'}
</span>
)}
{status && (
<span className="text-xs font-mono text-ink-faint">
Scan {new Date(status.ts).toLocaleTimeString()}
</span>
)}
</div>
</header>
<div className="flex h-full flex-col overflow-hidden bg-surface">
<TopBar
clock={clock}
status={cc.status}
workload={cc.workload}
agents={cc.agents}
approvals={cc.approvals}
onApprovalsClick={openApprovals}
/>
<OpsFloor agents={agents} zones={zones} animations={anims} />
<div className="flex min-h-0 flex-1 overflow-hidden">
<SideNav
agents={cc.agents}
animations={cc.anims}
gpu={cc.gpu}
gpuLive={gpuLive}
gpuBoost={gpuBoost}
selectedAgentId={cc.selectedAgentId}
selectedNodeId={cc.selectedNodeId}
mainView={cc.mainView}
approvalCount={cc.approvals.length}
agentsLoading={cc.agentsLoading}
onSetMainView={cc.setMainView}
onOpenApprovals={openApprovals}
onSelectAgent={cc.selectAgent}
onSelectZone={cc.selectNode}
/>
<nav className="flex gap-2 flex-wrap">
{TABS.map((t) => (
<button
key={t}
onClick={() => 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}
</button>
))}
</nav>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 flex-1">
<main className="lg:col-span-2 glass-strong rounded-2xl p-5 min-h-[260px]">
{tab === 'Overview' && status && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{Object.entries(status.domains).map(([key, d]) => (
<div
key={key}
className="status-card rounded-xl p-4 border"
style={{ borderColor: `${LEVEL_COLOR[d.level]}44`, background: LEVEL_BG[d.level] }}
>
<div className="text-xs font-mono uppercase tracking-wider text-ink-muted">{key}</div>
<div className="text-xl font-bold mt-1" style={{ color: LEVEL_COLOR[d.level] }}>{d.label}</div>
<div className="flex items-center gap-2 mt-3">
<div className="w-2.5 h-2.5 rounded-full animate-pulse" style={{ background: LEVEL_COLOR[d.level], boxShadow: `0 0 8px ${LEVEL_COLOR[d.level]}` }} />
<span className="text-xs font-mono uppercase" style={{ color: LEVEL_COLOR[d.level] }}>{d.level}</span>
<div ref={mainScrollRef} className="scrollbar-thin flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto bg-surface">
<div className={cn('flex min-h-0 flex-1', isPlatform ? '' : 'flex-col')}>
<div className={cn('flex min-w-0 flex-1 flex-col gap-2', isPlatform ? 'p-2' : 'min-h-0 p-3')}>
{isPlatform && (
<>
<div className="grid shrink-0 grid-cols-1 gap-2 xl:grid-cols-[1fr_auto]">
<AgentFleet
agents={cc.agents}
animations={cc.anims}
selectedId={cc.selectedAgentId}
loads={agentLoads}
approvalCount={cc.approvals.length}
onSelect={cc.selectAgent}
onOpenApprovals={openApprovals}
/>
<GpuMonitor gpu={cc.gpu} live={gpuLive} />
</div>
</div>
))}
</div>
)}
{tab === 'Agents' && (
<div className="grid gap-3 sm:grid-cols-2">
{agents.map((a) => (
<div key={a.id} className="status-card rounded-xl p-4 border border-slate-200/80">
<div className="font-semibold text-lg" style={{ color: a.color }}>{a.name}</div>
<div className="text-sm text-ink-muted mt-1">{a.role}</div>
<div className="text-xs font-mono text-ink-faint mt-2 px-2 py-1 rounded-md bg-slate-50 inline-block">zone: {a.zone}</div>
</div>
))}
</div>
)}
{tab === 'Feed' && (
<div className="font-mono text-xs space-y-2 max-h-80 overflow-y-auto">
{feed.map((e) => (
<div key={e.id} className="flex gap-2 py-1.5 border-b border-slate-100 last:border-0">
<span className="text-ink-faint shrink-0">{e.ts ? new Date(e.ts).toLocaleTimeString() : ''}</span>
<span className="font-semibold shrink-0" style={{ color: agents.find((a) => a.id === e.agent_id)?.color || '#888' }}>{e.agent_id}</span>
<span className={e.level === 'warn' ? 'text-neon-amber' : 'text-ink'}>{e.message}</span>
</div>
))}
</div>
)}
{tab === 'Approvals' && (
<div className="space-y-3">
{approvals.length === 0 && <p className="text-ink-muted text-sm">Geen pending approvals.</p>}
{approvals.map((a) => (
<div key={a.id} className="status-card border border-neon-magenta/25 rounded-xl p-4">
<div className="text-sm font-semibold text-neon-magenta">{a.action}</div>
<div className="text-xs text-ink-muted mt-1">{a.reason}</div>
<div className="flex gap-2 mt-3">
<button onClick={() => 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</button>
<button onClick={() => 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</button>
</div>
</div>
))}
</div>
)}
{tab === 'Audit' && (
<p className="text-ink-muted text-sm">Audit log approvals en agent acties (v1 via Feed tab).</p>
)}
</main>
<InfraQuickAccess
workload={cc.workload}
agents={cc.agents}
selectedNodeId={cc.selectedNodeId}
busy={cc.nodeBusy}
onSelectNode={cc.selectNode}
onSelectAgent={cc.selectAgent}
onProbe={cc.probeNodeId}
onOpenTerminal={cc.openTerminal}
/>
</>
)}
<aside className="glass-strong rounded-2xl p-5 flex flex-col max-h-80 lg:max-h-none">
<h3 className="text-sm font-mono font-semibold text-neon-cyan mb-3 tracking-wider">CHAT</h3>
<div className="flex-1 overflow-y-auto space-y-3 text-sm font-mono mb-2">
{chat.length === 0 && <p className="text-ink-faint text-xs">Stel een vraag je agent loopt data ophalen.</p>}
{chat.map((m, i) => (
<div key={i} className={`rounded-lg p-3 ${m.role === 'user' ? 'bg-cyan-50 border border-cyan-100' : 'bg-slate-50 border border-slate-100'}`}>
<span className="text-ink-faint text-xs">{m.role === 'user' ? '▶ jij' : `${m.agent}`}</span>
<div className={`mt-1 whitespace-pre-wrap ${m.role === 'user' ? 'text-neon-cyan' : 'text-ink'}`}>{m.text}</div>
<div className={isPlatform ? 'min-h-[420px]' : 'min-h-0 flex-1'}>
{cc.mainView === 'platform' ? (
<PlatformTopology
workload={cc.workload}
animations={cc.anims}
selectedNodeId={cc.selectedNodeId}
onNodeClick={cc.selectNode}
/>
) : cc.mainView === 'presentation' ? (
<PresentationView />
) : cc.mainView === 'dataquality' ? (
<DataQualityView />
) : cc.mainView === 'knowledge' ? (
<KnowledgeChatView onGpuActivity={setGpuChatActive} />
) : cc.mainView === 'storage' ? (
<StorageView />
) : (
<ApprovalInbox agents={cc.agents} livePending={cc.approvals} onDecide={cc.decide} />
)}
</div>
))}
</div>
{isPlatform && (
<div className="flex w-[340px] shrink-0 flex-col border-l border-border">
<InspectorPanel
node={cc.selectedNode}
nodeDetail={cc.nodeDetail}
agent={cc.selectedAgent}
agents={cc.agents}
workload={cc.workload}
gpu={cc.gpu}
feed={cc.feed}
lines={cc.inspectorLines}
busy={cc.nodeBusy}
onProbe={cc.probeNode}
onAsk={cc.askNode}
onSelectAgent={cc.selectAgent}
onSendPrompt={cc.sendPrompt}
onClear={cc.clearSelection}
onOpenTerminal={cc.openTerminal}
onProbeNodeId={cc.probeNodeId}
/>
<TerminalDock
subjectId={terminalSubject}
subjectLabel={terminalLabel}
lines={cc.inspectorLines}
busy={cc.nodeBusy || cc.promptBusy}
expanded={cc.terminalExpanded}
onToggle={() => cc.setTerminalExpanded(!cc.terminalExpanded)}
/>
</div>
)}
</div>
</aside>
</div>
</div>
<PromptBar onSubmit={sendPrompt} busy={busy} />
<ChatDrawer
expanded={cc.chatExpanded}
onToggle={() => 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)}
/>
</div>
)
}
-48
View File
@@ -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 (
<motion.div
className="flex flex-col items-center"
animate={{ ...bob, scale }}
transition={{ repeat: Infinity, duration: state === 'walk' || state === 'return' ? 0.35 : 2 }}
>
<svg width="56" height="72" viewBox="0 0 56 72" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="28" cy="68" rx="16" ry="4" fill={color} opacity="0.2" />
<rect x="18" y="36" width="20" height="26" rx="4" fill="#f8fafc" stroke={color} strokeWidth="1.5" />
<rect x="10" y="38" width="8" height="18" rx="3" fill="#f1f5f9" stroke={color} strokeWidth="1" />
<rect x="38" y="38" width="8" height="18" rx="3" fill="#f1f5f9" stroke={color} strokeWidth="1" />
<rect x="20" y="58" width="7" height="12" rx="2" fill="#e2e8f0" stroke={color} strokeWidth="1" />
<rect x="29" y="58" width="7" height="12" rx="2" fill="#e2e8f0" stroke={color} strokeWidth="1" />
<circle cx="28" cy="22" r="12" fill="#f8fafc" stroke={color} strokeWidth="1.5" />
<path d="M14 20 Q28 8 42 20 L40 24 Q28 14 16 24 Z" fill={color} opacity="0.9" />
<rect x="14" y="20" width="28" height="4" rx="1" fill={color} />
<path d="M16 24 Q16 34 20 36" stroke={color} strokeWidth="2" fill="none" />
<path d="M40 24 Q40 34 36 36" stroke={color} strokeWidth="2" fill="none" />
<rect x="12" y="22" width="6" height="10" rx="2" fill={color} opacity="0.7" />
<rect x="38" y="22" width="6" height="10" rx="2" fill={color} opacity="0.7" />
<path d="M36 36 L42 44" stroke={color} strokeWidth="1.5" />
<circle cx="43" cy="45" r="2" fill={color} />
<rect x="20" y="20" width="16" height="5" rx="2" fill={color} opacity="0.3" />
{state === 'fetch' && (
<motion.circle
cx="46" cy="30" r="4"
fill={color}
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{ repeat: Infinity, duration: 0.6 }}
/>
)}
</svg>
<span className="text-[10px] font-mono font-semibold mt-1 truncate max-w-[72px]" style={{ color }}>{label}</span>
</motion.div>
)
}
-79
View File
@@ -1,79 +0,0 @@
import { motion } from 'framer-motion'
import { AgentSprite } from './AgentSprite'
import type { Agent, AgentAnim, Zone } from '../types'
const ZONE_X: Record<string, number> = {
docker: 8,
db: 28,
lakehouse: 50,
hadoop: 72,
etl: 92,
}
const DESK_X = 50
type Props = {
agents: Agent[]
zones: Zone[]
animations: Record<string, AgentAnim>
}
export function OpsFloor({ agents, zones, animations }: Props) {
return (
<div className="glass-strong rounded-2xl p-5 relative overflow-hidden min-h-[300px]">
<div className="flex justify-between items-center mb-4">
<h2 className="font-display text-lg font-bold text-neon-cyan neon-text-cyan tracking-wide">OPS FLOOR</h2>
<span className="text-xs font-mono px-2.5 py-1 rounded-full bg-green-50 text-neon-green border border-neon-green/30 animate-pulse"> LIVE</span>
</div>
<div className="relative h-28 mb-4">
{zones.map((z) => (
<div
key={z.id}
className="absolute top-0 -translate-x-1/2 text-center"
style={{ left: `${z.x}%` }}
>
<div
className="rounded-xl px-3 py-3 min-w-[92px] text-[9px] font-mono tracking-wider font-semibold bg-white/90"
style={{ border: `2px solid ${z.color}`, boxShadow: `0 4px 16px ${z.color}22`, color: z.color }}
>
{z.label}
</div>
</div>
))}
<svg className="absolute inset-0 w-full h-full pointer-events-none" preserveAspectRatio="none">
{zones.map((z) => (
<line
key={`path-${z.id}`}
x1={`${DESK_X}%`} y1="85%" x2={`${z.x}%`} y2="35%"
stroke={z.color} strokeWidth="2" strokeDasharray="6 4" opacity="0.45"
/>
))}
</svg>
</div>
<div className="relative h-28 rounded-xl bg-gradient-to-b from-slate-50 to-white border border-slate-100">
{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 (
<motion.div
key={agent.id}
className="absolute bottom-2 -translate-x-1/2"
animate={{ left: `${targetX}%`, y }}
transition={{ type: 'spring', stiffness: 80, damping: 14 }}
>
<AgentSprite
color={agent.color}
state={anim.state}
label={agent.name.split(' ')[0]}
/>
</motion.div>
)
})}
</div>
</div>
)
}
-41
View File
@@ -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 (
<form onSubmit={handle} className="glass-strong rounded-2xl p-4 flex gap-3 items-center shadow-card">
<span className="text-2xl" aria-hidden>💬</span>
<input
className="flex-1 bg-white border border-slate-200 rounded-xl px-4 py-2.5 outline-none font-mono text-sm text-ink placeholder:text-ink-faint focus:border-neon-cyan focus:ring-2 focus:ring-neon-cyan/20 transition"
placeholder="Vraag je agents... bijv. Hoe staat Debezium er voor?"
value={text}
onChange={(e) => setText(e.target.value)}
disabled={busy}
/>
<button
type="submit"
disabled={busy || !text.trim()}
className="px-5 py-2.5 rounded-xl font-display text-sm font-semibold text-white disabled:opacity-40 transition-all hover:brightness-110"
style={{
background: busy ? '#94a3b8' : 'linear-gradient(135deg, #0099cc, #8844cc)',
boxShadow: busy ? 'none' : '0 4px 16px rgba(0, 153, 204, 0.35)',
}}
>
{busy ? 'Bezig...' : 'Send'}
</button>
</form>
)
}
@@ -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<string, string> = {
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 (
<div className="scrollbar-thin flex-1 overflow-y-auto px-2 pb-2">
{items.length === 0 && (
<p className="py-8 text-center text-[11px] text-foreground-faint">
{opsOnly ? 'Geen operationele events — antwords staan in Chat.' : 'No activity yet — agents are on standby.'}
</p>
)}
{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 (
<div key={e.id} className="flex gap-2 border-b border-border/50 py-1.5 last:border-0">
{Icon && (
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded bg-surface-overlay" style={{ color: meta?.accent }}>
<Icon className="h-3 w-3" />
</span>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[10px] font-medium text-foreground-muted">{ag?.name.split(' ·')[0] || e.agent_id}</span>
<span className="font-mono text-[9px] text-foreground-faint">{new Date(e.ts).toLocaleTimeString('en-US', { hour12: false })}</span>
</div>
<p className={cn('text-[10px] leading-relaxed', LEVEL[e.level] || 'text-foreground-muted')}>{e.message}</p>
</div>
</div>
)
})}
</div>
)
}
+121
View File
@@ -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<string, AgentAnim>
selectedId: string | null
loads: Record<string, AgentLoad>
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 (
<button
type="button"
onClick={onSelect}
className={cn(
'flex h-[118px] w-[140px] shrink-0 flex-col gap-1 rounded-lg border bg-surface-raised p-1.5 text-left shadow-sm dark:bg-surface-overlay',
selected ? 'border-docker/50 ring-1 ring-docker/20' : 'border-border hover:border-border-strong',
)}
>
<div className="flex items-center gap-1.5">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-surface" style={{ color: meta.accent }}>
<Icon className="h-3.5 w-3.5" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[11px] font-semibold text-foreground">{agent.name.split(' ·')[0]}</p>
<p className="truncate text-[8px] text-foreground-muted">{meta.domain}</p>
</div>
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', busy ? 'bg-success animate-pulse' : 'bg-foreground-faint/30')} />
</div>
<p className="line-clamp-2 text-[8px] leading-[10px] text-foreground-muted">{agent.role}</p>
<p className="h-[20px] line-clamp-2 text-[8px] leading-[10px] text-foreground-faint">{agentTaskLabel(agent.id, anim)}</p>
<div className="mt-auto space-y-0.5">
<div className="flex justify-between font-mono text-[7px] tabular-nums text-foreground-faint">
<span>CPU {load?.cpu ?? 0}%</span>
<span>MEM {load?.mem ?? 0}%</span>
</div>
<div className="h-1 overflow-hidden rounded-full bg-surface">
<div className="h-full rounded-full transition-[width] duration-700" style={{ width: `${load?.cpu ?? 0}%`, background: meta.accent }} />
</div>
</div>
</button>
)
}
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 (
<div className="panel flex shrink-0 flex-col p-2">
<div className="mb-1 flex shrink-0 items-center justify-between gap-2">
<div className="min-w-0">
<h3 className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">Agent Fleet</h3>
<p className="truncate text-[8px] text-foreground-faint">Klik agent stel vraag in chat · elk agent bewaakt één domein</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={onOpenApprovals}
className={cn(
'flex items-center gap-1 rounded-md border px-2 py-1 text-[9px] font-medium',
approvalCount > 0 ? 'border-warning/40 bg-warning/10 text-warning' : 'border-border text-foreground-muted hover:bg-surface-overlay',
)}
>
<ShieldCheck className="h-3 w-3" />
Approvals{approvalCount > 0 ? ` (${approvalCount})` : ''}
</button>
<span className="font-mono text-[8px] text-foreground-faint">{agents.length} agents</span>
</div>
</div>
<div className="scroll-x-stable flex min-h-0 gap-3 pb-1">
<div className="shrink-0">
<p className="mb-1 text-[8px] uppercase tracking-widest text-foreground-faint">Supervisors</p>
<div className="flex gap-1.5">
{supervisors.map((a) => (
<AgentCard key={a.id} agent={a} anim={animations[a.id]} load={loads[a.id]} selected={selectedId === a.id} onSelect={() => onSelect(a.id)} />
))}
</div>
</div>
{mcp && (
<div className="shrink-0">
<p className="mb-1 text-[8px] uppercase tracking-widest text-foreground-faint">MCP Hub</p>
<AgentCard agent={mcp} anim={animations[mcp.id]} load={loads[mcp.id]} selected={selectedId === mcp.id} onSelect={() => onSelect(mcp.id)} />
</div>
)}
<div className="shrink-0">
<p className="mb-1 text-[8px] uppercase tracking-widest text-foreground-faint">Field Operators</p>
<div className="flex gap-1.5">
{operators.map((a) => (
<AgentCard key={a.id} agent={a} anim={animations[a.id]} load={loads[a.id]} selected={selectedId === a.id} onSelect={() => onSelect(a.id)} />
))}
</div>
</div>
</div>
</div>
)
}
@@ -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<string, string> = {
'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<void>
onDismissHighlight?: () => void
}
export function ApprovalCards({ approvals, agents, highlighted, onDecide, onDismissHighlight }: Props) {
const [busyId, setBusyId] = useState<string | null>(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 (
<div className={cn('mx-2 mb-2 rounded-lg border bg-surface-overlay p-2.5', highlighted ? 'border-warning/50 ring-1 ring-warning/20' : 'border-border')}>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-warning">
<ShieldAlert className="h-3.5 w-3.5" />
Pending approvals ({approvals.length})
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-[9px] text-foreground-muted">
As
<select
value={decider}
onChange={(e) => setDecider(e.target.value as typeof decider)}
className="rounded border border-border bg-surface px-1 py-0.5 text-[10px] text-foreground-muted"
>
<option value="mo-commander">Mo</option>
<option value="bart-commander">Bart</option>
</select>
</label>
{highlighted && onDismissHighlight && (
<button type="button" onClick={onDismissHighlight} className="text-[9px] text-foreground-muted hover:text-foreground-muted">Dismiss</button>
)}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{approvals.map((a) => {
const ag = agentOf(a.agent_id)
const meta = ag ? getAgentMeta(ag.id) : null
const Icon = meta?.icon
return (
<div key={a.id} className="rounded-lg border border-border bg-surface p-2">
<div className="mb-1.5 flex items-start gap-1.5">
{Icon && (
<span className="flex h-6 w-6 items-center justify-center rounded bg-surface-overlay text-docker">
<Icon className="h-3.5 w-3.5" />
</span>
)}
<div className="min-w-0 flex-1">
<span className="text-[8px] uppercase tracking-wide text-warning">{ACTION_LABELS[a.action_type] || a.action_type}</span>
<p className="truncate text-[11px] font-medium text-foreground">{ag?.name || a.agent_id}</p>
</div>
<span className="font-mono text-[8px] text-foreground-faint">#{a.id.slice(0, 6)}</span>
</div>
<p className="mb-1 text-[10px] text-foreground-muted line-clamp-2">{a.action}</p>
{a.target && <p className="text-[9px] text-foreground-muted">Target: {a.target}</p>}
<div className="mt-2 flex gap-1">
<Button size="sm" variant="success" className="flex-1 text-[10px]" disabled={busyId === a.id} onClick={() => handle(a.id, true)}>
<Check className="h-3 w-3" /> Approve
</Button>
<Button size="sm" variant="danger" className="flex-1 text-[10px]" disabled={busyId === a.id} onClick={() => handle(a.id, false)}>
<X className="h-3 w-3" /> Deny
</Button>
</div>
</div>
)
})}
</div>
</div>
)
}
@@ -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<void>
}
export function ApprovalInbox({ agents, livePending, onDecide }: Props) {
const [filter, setFilter] = useState<Filter>('pending')
const [items, setItems] = useState<Approval[]>([])
const [stats, setStats] = useState({ pending: 0, approved: 0, denied: 0, total: 0 })
const [selectedId, setSelectedId] = useState<string | null>(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 (
<section className="panel flex min-h-0 flex-1 flex-col overflow-hidden">
<header className="flex shrink-0 items-start justify-between gap-3 border-b border-border px-3 py-2">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-docker" />
<div>
<h2 className="text-sm font-semibold text-foreground">Approval Inbox</h2>
<p className="text-[10px] text-foreground-muted">Mo & Bart review mutating agent actions</p>
</div>
</div>
<div className="flex gap-1">
<Badge variant="warning">{stats.pending} pending</Badge>
<Badge variant="success">{stats.approved} ok</Badge>
<Badge variant="danger">{stats.denied} denied</Badge>
</div>
</header>
<div className="flex shrink-0 gap-1 border-b border-border px-2 py-1">
{(['pending', 'approved', 'denied', 'all'] as Filter[]).map((f) => (
<button key={f} type="button" onClick={() => setFilter(f)} className={cn('rounded px-2 py-0.5 text-[10px] capitalize', filter === f ? 'bg-docker-light text-docker' : 'text-foreground-muted')}>
{f}
</button>
))}
</div>
<div className="grid min-h-0 flex-1 grid-cols-[minmax(200px,0.9fr)_1.1fr]">
<div className="scrollbar-thin overflow-y-auto border-r border-border p-1">
{!items.length && <p className="p-4 text-center text-[10px] text-foreground-faint">No {filter} requests.</p>}
{items.map((a) => {
const ag = agentOf(a.agent_id)
const meta = ag ? getAgentMeta(ag.id) : null
const Icon = meta?.icon
return (
<button key={a.id} type="button" onClick={() => 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 && <Icon className="h-4 w-4 shrink-0 text-docker" />}
<span className="min-w-0 flex-1">
<span className="block truncate text-[11px] font-medium text-foreground">{a.action.slice(0, 80)}</span>
<span className="block text-[9px] text-foreground-faint">{ag?.name || a.agent_id}</span>
</span>
<Badge variant={a.status === 'pending' ? 'warning' : a.status === 'approved' ? 'success' : 'danger'}>{a.status}</Badge>
</button>
)
})}
</div>
{selected && (
<div className="scrollbar-thin overflow-y-auto p-3">
<Badge variant={selected.status === 'pending' ? 'warning' : 'success'}>{selected.status}</Badge>
<dl className="mt-3 grid grid-cols-2 gap-2 text-[10px]">
<div><dt className="text-foreground-faint">Agent</dt><dd className="text-foreground">{agentOf(selected.agent_id)?.name}</dd></div>
<div><dt className="text-foreground-faint">Type</dt><dd className="text-foreground">{selected.action_type}</dd></div>
<div className="col-span-2"><dt className="text-foreground-faint">Action</dt><dd className="text-foreground-muted">{selected.action}</dd></div>
<div className="col-span-2"><dt className="text-foreground-faint">Reason</dt><dd className="text-foreground-muted">{selected.reason}</dd></div>
</dl>
{selected.status === 'pending' && (
<div className="mt-4 space-y-2">
<select value={decider} onChange={(e) => 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">
<option value="mo-commander">Decide as Mo</option>
<option value="bart-commander">Decide as Bart</option>
</select>
<input value={note} onChange={(e) => 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" />
<div className="flex gap-2">
<Button variant="success" className="flex-1" disabled={busy} onClick={() => handleDecide(true)}><Check className="h-3 w-3" /> Approve</Button>
<Button variant="danger" className="flex-1" disabled={busy} onClick={() => handleDecide(false)}><X className="h-3 w-3" /> Deny</Button>
</div>
</div>
)}
</div>
)}
</div>
</section>
)
}
@@ -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<string, { nodes: FlowNode[]; edges: FlowEdge[] }> = {
'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 0100', 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<string, Record<string, { x: number; y: number }>> = {
'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 (
<div className="relative mx-auto mb-6 h-[280px] w-full max-w-4xl rounded-xl border border-docker/30 bg-surface-overlay/60 p-2 md:h-[320px]">
<svg className="absolute inset-0 h-full w-full" viewBox="0 0 100 100" preserveAspectRatio="none">
{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 (
<g key={`${edge.from}-${edge.to}`}>
<line
x1={from.x}
y1={from.y}
x2={to.x}
y2={to.y}
stroke={active ? '#38bdf8' : 'rgba(56,189,248,0.25)'}
strokeWidth={active ? 0.6 : 0.35}
strokeDasharray={active ? '2 1' : '1 2'}
className={active ? 'animate-pulse' : undefined}
/>
{active && (
<circle r="1.2" fill="#38bdf8">
<animateMotion dur="1.8s" repeatCount="indefinite" path={`M${from.x},${from.y} L${to.x},${to.y}`} />
</circle>
)}
</g>
)
})}
</svg>
{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 (
<div
key={node.id}
className={cn(
'absolute -translate-x-1/2 -translate-y-1/2 rounded-lg border px-2 py-1 text-center transition-all duration-500',
lit ? 'scale-105 border-docker shadow-docker bg-docker/20' : 'border-border bg-surface-raised/90',
)}
style={{ left: `${pos.x}%`, top: `${pos.y}%`, minWidth: '72px' }}
>
<p className="text-[9px] font-semibold leading-tight text-foreground md:text-[10px]" style={{ color: lit ? node.color : undefined }}>
{node.label}
</p>
{node.sub && <p className="text-[7px] text-foreground-faint md:text-[8px]">{node.sub}</p>}
</div>
)
})}
</div>
)
}
+119
View File
@@ -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<void>
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 (
<button
type="button"
onClick={onToggle}
className="flex w-full shrink-0 items-center justify-between border-t border-border bg-surface-raised/95 px-4 py-2 backdrop-blur-sm hover:bg-surface-overlay"
>
<span className="flex items-center gap-2 text-xs font-medium text-foreground">
<MessageSquare className="h-4 w-4 text-docker" />
Chat & Activity
{unread > 0 && (
<span className="rounded-full bg-docker/20 px-2 py-0.5 font-mono text-[10px] text-docker">
{unread} bericht{unread !== 1 ? 'en' : ''}
</span>
)}
{approvals.length > 0 && (
<span className="rounded-full bg-warning/20 px-2 py-0.5 font-mono text-[10px] text-warning">
{approvals.length} approval{approvals.length !== 1 ? 's' : ''}
</span>
)}
</span>
<ChevronUp className="h-4 w-4 text-foreground-muted" />
</button>
)
}
return (
<div className="flex shrink-0 flex-col border-t border-border bg-surface-raised/95 backdrop-blur-sm" style={{ height: 'min(42vh, 380px)' }}>
<div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-1.5">
<div className="flex gap-1">
{(['chat', 'activity'] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => 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' ? <MessageSquare className="h-3 w-3" /> : <Radio className="h-3 w-3" />}
{t === 'chat' ? 'Chat' : 'Activity'}
{t === 'activity' && approvals.length > 0 && (
<span className="rounded-full bg-warning/20 px-1 font-mono text-[8px] text-warning">{approvals.length}</span>
)}
</button>
))}
</div>
<button type="button" onClick={onToggle} className="rounded p-1 text-foreground-muted hover:bg-surface-overlay hover:text-foreground" title="Inklappen">
<ChevronDown className="h-4 w-4" />
</button>
</div>
<div className="panel mx-2 mb-1 min-h-0 flex-1 overflow-hidden">
{tab === 'chat' ? (
<CommsPanel messages={chat} agents={agents} selectedAgent={selectedAgent} busy={promptBusy} />
) : (
<div className="flex h-full min-h-0 flex-col overflow-hidden">
<ApprovalCards
approvals={approvals}
agents={agents}
highlighted={approvalHighlight}
onDecide={onDecide}
onDismissHighlight={onDismissHighlight}
/>
<ActivityStream feed={feed} agents={agents} filterAgentId={filterAgentId} opsOnly />
</div>
)}
</div>
{tab === 'chat' && <CommandBar busy={promptBusy} selectedAgent={selectedAgent} onSubmit={onSendPrompt} />}
</div>
)
}
+58
View File
@@ -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 (
<footer className="shrink-0 border-t border-border bg-surface-raised/90 px-3 py-1.5 backdrop-blur-sm">
<form onSubmit={submit} className="flex gap-2">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={selectedAgent ? `Command ${selectedAgent.name.split(' ·')[0]}` : 'Enter command — auto-routed to specialist…'}
disabled={busy}
className="flex-1"
/>
<Button type="submit" disabled={busy || !input.trim()}>
<Send className="h-3.5 w-3.5" />
Send
</Button>
</form>
<div className="mt-1.5 flex flex-wrap gap-1">
{suggestions.map((s) => (
<button
key={s}
type="button"
onClick={() => setInput(s)}
className="rounded border border-border bg-surface px-2 py-0.5 text-[9px] text-foreground-muted hover:border-border-strong hover:text-foreground-muted"
>
{s}
</button>
))}
</div>
</footer>
)
}
+68
View File
@@ -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<HTMLDivElement>(null)
const meta = selectedAgent ? getAgentMeta(selectedAgent.id) : null
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, busy])
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2">
<h3 className="flex items-center gap-1.5 text-xs font-semibold text-foreground">
<MessageSquare className="h-3.5 w-3.5 text-docker" /> Comms
</h3>
{selectedAgent && meta && (
<span className="truncate font-mono text-[9px]" style={{ color: meta.accent }}>
{selectedAgent.name.split(' ·')[0]}
</span>
)}
</div>
<div className="scrollbar-thin flex-1 space-y-2 overflow-y-auto p-2">
{!messages.length && (
<div className="flex h-full flex-col items-center justify-center py-6 text-center">
<MessageSquare className="mb-2 h-6 w-6 text-foreground-faint" />
<p className="max-w-[200px] text-[10px] text-foreground-muted">
Send a command below routing selects the right specialist automatically.
</p>
</div>
)}
{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 (
<div key={i} className={cn('flex gap-2', m.role === 'user' && 'flex-row-reverse')}>
<div className={cn('max-w-[85%] rounded-lg border px-2 py-1.5', m.role === 'user' ? 'border-docker/25 bg-docker-light' : 'border-border bg-surface-overlay')}>
<p className="mb-0.5 text-[8px] text-foreground-muted">
{m.role === 'user' ? 'You' : ag?.name || m.agent}
{m.ts && ` · ${new Date(m.ts).toLocaleTimeString('en-US', { hour12: false })}`}
</p>
<p className="whitespace-pre-wrap text-[11px] text-foreground">{m.text}</p>
</div>
</div>
)
})}
{busy && (
<div className="flex items-center gap-2 px-2 py-2 text-[10px] text-foreground-muted">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-accent" />
<span>Agent verzamelt cluster-data en vraagt Llama 70B verwacht ~3090 sec</span>
</div>
)}
<div ref={bottomRef} />
</div>
</div>
)
}
@@ -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<string, number>
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<string, number>; 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<string, number>
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<string, { status: string; capabilities?: string[] }>
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<Tab>('assess')
const [caps, setCaps] = useState<Capabilities | null>(null)
const [loading, setLoading] = useState(false)
const [assess, setAssess] = useState<AssessResult | null>(null)
const [parse, setParse] = useState<ParseResult | null>(null)
const [parseFormat, setParseFormat] = useState<'markdown' | 'html'>('markdown')
const [reports, setReports] = useState<ReportSummary[]>([])
const [error, setError] = useState<string | null>(null)
const [expandedCol, setExpandedCol] = useState<string | null>(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 (
<div className="flex h-full min-h-[calc(100vh-140px)] flex-col rounded-lg border border-border bg-surface-raised">
<header className="shrink-0 border-b border-border bg-surface-overlay/30 px-4 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-foreground">Data Quality & Maturity Platform</h2>
<p className="text-[11px] text-foreground-muted">
Full data maturity assessment for customer data Docling, Great Expectations, Soda Core
</p>
</div>
<div className="flex items-center gap-2">
<span className={cn('rounded-full px-2.5 py-1 text-[10px] font-medium', caps?.docling_online ? 'bg-success/20 text-success' : 'bg-danger/20 text-danger')}>
Docling {caps?.docling_online ? '● online' : '○ offline'}
</span>
<button type="button" onClick={loadMeta} className="rounded border border-border p-1.5 hover:bg-surface-overlay">
<RefreshCw className="h-4 w-4" />
</button>
<a href={`http://${window.location.hostname}:5001/ui/`} target="_blank" rel="noreferrer" className="rounded border border-docker/40 bg-docker/15 px-2 py-1 text-[10px] text-docker">
Docling UI
</a>
</div>
</div>
{caps && (
<div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
<CapCard title="Maturity Engine" items={caps.maturity_dimensions.map((d) => d.label)} icon={Layers} />
<CapCard title="Data Quality Tools" items={['Great Expectations', 'Soda Core', 'Pandas Profiling']} icon={CheckCircle2} />
<CapCard title="Document Parsing" items={caps.tools.docling?.capabilities || ['PDF', 'PPTX', 'DOCX']} icon={FileText} />
<CapCard title="File formats" items={[...caps.supported_data_formats.slice(0, 4), ...caps.supported_document_formats.slice(0, 3)]} icon={Upload} />
</div>
)}
</header>
<div className="flex shrink-0 gap-1 border-b border-border bg-surface-overlay/20 px-3 py-2">
{tabs.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => 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)}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</div>
<div className="scrollbar-thin flex-1 overflow-y-auto p-4">
{error && (
<div className="mb-4 flex items-start gap-2 rounded-lg border border-danger/40 bg-danger/10 px-4 py-3 text-[11px] text-danger">
<XCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
{tab === 'assess' && (
<div className="space-y-5">
<UploadZone
loading={loading}
label="Upload customer data for full maturity assessment"
hint="CSV · Excel · JSON · Parquet · PDF · PPTX · DOCX"
accept=".csv,.tsv,.xlsx,.xls,.json,.parquet,.pdf,.pptx,.ppt,.docx"
onFile={onAssess}
/>
{loading && <LoadingMsg text="Analyzing: 6 maturity dimensions · GE checks · Soda checks · column profiles…" />}
{assess && (
<div className="space-y-5">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<StatCard label="Overall Score" value={`${assess.overall_score}`} sub="/100" accent />
<StatCard label="Maturity Level" value={assess.maturity_level} sub={assess.maturity_description} />
<StatCard label="Dataset" value={`${assess.rows.toLocaleString()}`} sub={`${assess.columns} columns`} />
<StatCard label="Great Expectations" value={`${assess.checks_summary.great_expectations.passed}/${assess.checks_summary.great_expectations.total}`} sub="checks passed" />
<StatCard label="Soda Core" value={String(assess.checks_summary.soda_core.warnings)} sub="warnings" warn={assess.checks_summary.soda_core.warnings > 0} />
</div>
{assess.docling?.used && assess.docling.document_structure && (
<>
<DocStructurePanel structure={assess.docling.document_structure} title="Document structure (via Docling)" />
{assess.docling.images && assess.docling.images.length > 0 && assess.docling.parse_id && (
<ImageGallery images={assess.docling.images} parseId={assess.docling.parse_id} />
)}
</>
)}
{assess.rag_ingest && (
<div className={cn(
'rounded-lg border px-3 py-2 text-[11px]',
assess.rag_ingest.ok ? 'border-success/30 bg-success/10 text-success' : 'border-warning/30 bg-warning/10 text-warning',
)}>
<p className="font-medium">Knowledge Chat sync</p>
<p className="text-foreground-muted">
{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')}
</p>
</div>
)}
<div className="flex flex-wrap gap-2">
<a href={assess.report_url} target="_blank" rel="noreferrer" className={cn('rounded-md px-3 py-1.5 text-[11px] font-medium', subTabActive)}>
Full HTML report
</a>
<button type="button" onClick={() => setShowGx(!showGx)} className={cn('rounded-md px-3 py-1.5 text-[11px]', showGx ? subTabActive : subTabIdle)}>
GE checks ({assess.checks?.great_expectations.length || 0})
</button>
<button type="button" onClick={() => setShowSoda(!showSoda)} className={cn('rounded-md px-3 py-1.5 text-[11px]', showSoda ? subTabActive : subTabIdle)}>
Soda checks ({assess.checks?.soda_core.length || 0})
</button>
</div>
{showGx && assess.checks?.great_expectations && (
<CheckTable title="Great Expectations" rows={assess.checks.great_expectations.map((c) => ({
name: c.column ? `${c.expectation} [${c.column}]` : c.expectation,
status: c.success ? 'pass' : 'fail',
detail: c.result,
}))} />
)}
{showSoda && assess.checks?.soda_core && (
<CheckTable title="Soda Core" rows={assess.checks.soda_core.map((c) => ({
name: c.name,
status: c.outcome,
detail: `${c.check}${c.detail}`,
}))} />
)}
<section>
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">6 Maturity Dimensions</h3>
<div className="grid gap-3 lg:grid-cols-2 xl:grid-cols-3">
{assess.dimensions.map((d) => (
<DimensionCard key={d.id} dimension={d} />
))}
</div>
</section>
{assess.action_items.length > 0 && (
<section>
<h3 className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">
<AlertTriangle className="h-3.5 w-3.5 text-warning" /> Remediation Roadmap
</h3>
<div className="space-y-1.5">
{assess.action_items.map((a, i) => (
<div key={i} className={cn('rounded-lg border px-3 py-2 text-[11px]', a.priority === 'high' ? 'border-danger/40 bg-danger/10' : a.priority === 'medium' ? 'border-warning/40 bg-warning/10' : 'border-border bg-surface-overlay/40')}>
<span className="font-bold uppercase text-foreground-faint">{a.priority}</span>
{' · '}<strong>{a.dimension}</strong> ({a.score}): {a.action}
</div>
))}
</div>
</section>
)}
<section>
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">
Column profiles ({assess.column_profiles.length})
</h3>
<ColumnTable profiles={assess.column_profiles} expandedCol={expandedCol} onToggle={setExpandedCol} />
</section>
</div>
)}
</div>
)}
{tab === 'docling' && (
<div className="space-y-5">
<p className="text-[12px] leading-relaxed text-foreground-muted">
Docling extracts text, tables, images and document structure from PDF, PowerPoint, Word, Excel and images.
Resultaat: Markdown, HTML, JSON met pagina&apos;s, plaatjes, tabellen en outline.
</p>
<UploadZone
loading={loading}
label="Upload document for Docling parsing"
hint="PDF · PPTX · DOCX · XLSX · PNG · JPG · TIFF · MD · HTML"
accept=".pdf,.pptx,.ppt,.docx,.doc,.xlsx,.png,.jpg,.jpeg,.tiff,.txt,.md,.html"
onFile={onParse}
/>
{loading && <LoadingMsg text="Docling processing document — OCR, table detection, images (30180 sec)…" />}
{parse && (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6">
<StatCard label="Bestand" value={parse.filename.length > 20 ? parse.filename.slice(0, 18) + '…' : parse.filename} sub={parse.status} />
<StatCard label="Verwerking" value={`${(parse.processing_time_sec || 0).toFixed(1)}s`} sub={`Formats: ${parse.formats_available.join(', ')}`} />
<StatCard label="Pages" value={String(parse.document_structure?.pages ?? parse.stats.pages ?? 0)} icon={Layers} />
<StatCard label="Images" value={String(parse.document_structure?.pictures ?? 0)} icon={Image} accent />
<StatCard label="Tables" value={String(parse.document_structure?.tables ?? 0)} icon={Table2} />
<StatCard label="Text blocks" value={String(parse.document_structure?.text_blocks ?? 0)} sub={`${parse.stats.words?.toLocaleString() ?? 0} words`} />
</div>
<DocStructurePanel structure={parse.document_structure} title="Document analysis" />
{parse.images && parse.images.filter((i) => i.available).length > 0 && (
<ImageGallery images={parse.images} parseId={parse.parse_id} />
)}
{parse.document_structure?.outline && parse.document_structure.outline.length > 0 && (
<section className="rounded-lg border border-border bg-surface-overlay/30 p-3">
<h3 className="mb-2 text-[11px] font-semibold uppercase text-foreground-faint">Document outline</h3>
<ul className="space-y-1 text-[11px]">
{parse.document_structure.outline.map((o, i) => (
<li key={i} className="flex gap-2" style={{ paddingLeft: (o.level || 0) * 12 }}>
<span className="shrink-0 rounded bg-docker/20 px-1 font-mono text-[9px] text-docker">{o.type}</span>
<span className="text-foreground-muted">{o.text}</span>
</li>
))}
</ul>
</section>
)}
<div className="flex gap-1">
{(['markdown', 'html'] as const).map((f) => (
<button key={f} type="button" onClick={() => setParseFormat(f)} className={cn('rounded-md px-3 py-1.5 text-[11px] font-medium', parseFormat === f ? subTabActive : subTabIdle)}>
{f.toUpperCase()}
</button>
))}
{parse.parse_json_url && (
<a href={parse.parse_json_url} target="_blank" rel="noreferrer" className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
Full JSON
</a>
)}
</div>
{parse.table_preview && parse.table_preview.length > 0 && (
<section>
<h3 className="mb-1 text-[11px] font-semibold uppercase text-foreground-faint">Tables (markdown preview)</h3>
<pre className="scrollbar-thin max-h-40 overflow-auto rounded-lg border border-border bg-surface-overlay p-3 font-mono text-[10px]">
{parse.table_preview.join('\n')}
</pre>
</section>
)}
<section>
<h3 className="mb-2 text-[11px] font-semibold uppercase text-foreground-faint">Extracted content</h3>
{parseFormat === 'html' && (parse.content.preview_html || parse.content.html) ? (
<div className="scrollbar-thin max-h-[500px] overflow-auto rounded-lg border border-border bg-surface-overlay p-2">
<div className="rounded bg-white p-4 text-black" dangerouslySetInnerHTML={{ __html: parse.content.preview_html || parse.content.html || '' }} />
</div>
) : (
<pre className="scrollbar-thin max-h-[500px] overflow-auto rounded-lg border border-border bg-surface-overlay p-4 font-mono text-[11px] leading-relaxed text-foreground">
{parse.content.preview_markdown || parse.content.markdown || '(no content)'}
</pre>
)}
</section>
</div>
)}
</div>
)}
{tab === 'reports' && (
<div className="space-y-2">
{reports.length === 0 ? (
<p className="py-12 text-center text-sm text-foreground-muted">No reports yet upload customer data in Maturity Assessment.</p>
) : (
reports.map((r) => (
<a key={r.id} href={`/dq/report/${r.id}`} target="_blank" rel="noreferrer"
className="flex items-center justify-between rounded-lg border border-border bg-surface-overlay/30 px-4 py-3 transition-all hover:border-docker/40 hover:bg-docker/10">
<div>
<p className="text-[12px] font-medium">{r.filename}</p>
<p className="text-[10px] text-foreground-faint">{r.ts} · {r.rows?.toLocaleString()} rows · {r.columns} cols</p>
</div>
<div className="text-right">
<p className={cn('text-xl font-bold', SCORE_COLOR(r.overall_score))}>{r.overall_score}</p>
<p className="text-[10px] text-foreground-muted">{r.maturity_level}</p>
</div>
</a>
))
)}
</div>
)}
</div>
</div>
)
}
function ImageGallery({ images, parseId }: { images: DocImage[]; parseId: string }) {
const available = images.filter((i) => i.available)
const [lightbox, setLightbox] = useState<number | null>(null)
if (!available.length) {
return (
<section className="rounded-lg border border-border bg-surface-overlay/30 p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">
Images gedetecteerd ({images.length}) no embedded export
</h3>
<p className="text-[11px] text-foreground-muted">Re-upload the document to extract images (embedded mode).</p>
</section>
)
}
return (
<section className="rounded-lg border border-border bg-surface-overlay/30 p-4">
<h3 className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">
<Image className="h-4 w-4 text-docker" />
Images die Docling ziet ({available.length})
</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{available.map((img) => (
<button
key={img.index}
type="button"
onClick={() => 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"
>
<div className="flex aspect-[4/3] items-center justify-center overflow-hidden bg-black/20">
<img
src={img.url || `/dq/parse/${parseId}/image/${img.index}`}
alt={img.label}
className="max-h-full max-w-full object-contain transition-transform group-hover:scale-105"
loading="lazy"
/>
</div>
<div className="p-2">
<p className="text-[10px] font-medium text-foreground">#{img.index + 1} {img.label}</p>
<p className="text-[9px] text-foreground-faint">
{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` : ''}
</p>
</div>
</button>
))}
</div>
{lightbox !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4" onClick={() => setLightbox(null)}>
<div className="relative max-h-[90vh] max-w-[90vw]" onClick={(e) => e.stopPropagation()}>
<img
src={`/dq/parse/${parseId}/image/${lightbox}`}
alt={`Image ${lightbox + 1}`}
className="max-h-[85vh] max-w-full rounded-lg object-contain"
/>
<button type="button" onClick={() => setLightbox(null)} className="absolute -top-3 -right-3 rounded-full bg-surface-raised px-2 py-1 text-xs text-foreground"></button>
</div>
</div>
)}
</section>
)
}
function DocStructurePanel({ structure, title }: { structure: DocStructure; title: string }) {
return (
<section className="rounded-lg border border-border bg-surface-overlay/30 p-4">
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">{title}</h3>
<div className="mb-3 grid grid-cols-3 gap-2 sm:grid-cols-6">
{[
{ 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 }) => (
<div key={label} className="rounded-md border border-border bg-surface-raised p-2 text-center">
{Icon && <Icon className="mx-auto mb-1 h-4 w-4 text-docker" />}
<p className="text-lg font-bold text-foreground">{value}</p>
<p className="text-[9px] text-foreground-faint">{label}</p>
</div>
))}
</div>
{structure.picture_details && structure.picture_details.length > 0 && (
<div className="mb-3">
<p className="mb-1 text-[10px] font-medium text-foreground-muted">Images ({structure.picture_details.length})</p>
<div className="flex flex-wrap gap-1">
{structure.picture_details.map((p) => (
<span key={p.index} className="rounded border border-border bg-surface-raised px-2 py-0.5 text-[9px]">
#{p.index + 1} {p.label} {p.has_image ? '🖼' : ''} {p.captions > 0 ? `(${p.captions} captions)` : ''}
</span>
))}
</div>
</div>
)}
{structure.table_details && structure.table_details.length > 0 && (
<div>
<p className="mb-1 text-[10px] font-medium text-foreground-muted">Tables ({structure.table_details.length})</p>
<div className="space-y-1">
{structure.table_details.map((t) => (
<div key={t.index} className="rounded border border-border bg-surface-raised px-2 py-1 text-[10px] text-foreground-muted">
Table {t.index + 1}: {t.rows}×{t.cols} ({t.cells} cells) {t.preview || '…'}
</div>
))}
</div>
</div>
)}
</section>
)
}
function DimensionCard({ dimension: d }: { dimension: Dimension }) {
return (
<div className="rounded-lg border border-border bg-surface-overlay/40 p-3">
<div className="mb-1 flex items-center justify-between">
<span className="text-[12px] font-semibold">{d.label}</span>
<span className={cn('text-base font-bold', SCORE_COLOR(d.score))}>{d.score}</span>
</div>
<div className="mb-2 h-2 overflow-hidden rounded-full bg-border">
<div className={cn('h-full rounded-full', BAR_COLOR(d.score))} style={{ width: `${d.score}%` }} />
</div>
<p className="mb-2 text-[10px] text-foreground-faint">{d.description}</p>
<ul className="space-y-0.5 text-[10px] text-foreground-muted">
{d.findings.map((f) => (
<li key={f} className="flex gap-1"><span className="text-docker"></span>{f}</li>
))}
</ul>
{d.recommended_actions && d.recommended_actions.length > 0 && (
<p className="mt-2 border-t border-border pt-2 text-[9px] text-warning"> {d.recommended_actions[0]}</p>
)}
</div>
)
}
function ColumnTable({ profiles, expandedCol, onToggle }: { profiles: ColumnProfile[]; expandedCol: string | null; onToggle: (n: string | null) => void }) {
return (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-left text-[11px]">
<thead className="bg-surface-overlay text-[10px] uppercase text-foreground-faint">
<tr>
<th className="px-3 py-2">Column</th><th className="px-3 py-2">Type</th><th className="px-3 py-2">Null%</th>
<th className="px-3 py-2">Unique</th><th className="px-3 py-2">Flags</th>
</tr>
</thead>
<tbody>
{profiles.map((c) => (
<Fragment key={c.name}>
<tr className="cursor-pointer border-t border-border hover:bg-surface-overlay/50" onClick={() => onToggle(expandedCol === c.name ? null : c.name)}>
<td className="px-3 py-2 font-mono text-docker">{c.name}</td>
<td className="px-3 py-2">{c.dtype}</td>
<td className={cn('px-3 py-2', c.null_pct > 10 && 'font-semibold text-warning')}>{c.null_pct}%</td>
<td className="px-3 py-2">{c.unique_count.toLocaleString()}</td>
<td className="px-3 py-2 text-foreground-muted">{c.quality_flags.join(', ') || '—'}</td>
</tr>
{expandedCol === c.name && (
<tr className="border-t border-border bg-surface-overlay/20">
<td colSpan={5} className="px-4 py-2 text-[10px] text-foreground-muted">
{c.sample_values?.length ? <p className="mb-1">Samples: {c.sample_values.join(' · ')}</p> : null}
{c.numeric && <p>Range {c.numeric.min} {c.numeric.max}, μ={c.numeric.mean}, {c.numeric.outliers} outliers</p>}
{c.text && <p>Avg len {c.text.avg_length}, {c.text.empty_strings} empty strings</p>}
{c.top_values?.map((tv) => <span key={tv.value} className="mr-3">{tv.value} ({tv.count})</span>)}
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
)
}
function CheckTable({ title, rows }: { title: string; rows: { name: string; status: string; detail: string }[] }) {
return (
<div className="overflow-x-auto rounded-lg border border-border">
<p className="border-b border-border bg-surface-overlay px-3 py-2 text-[11px] font-semibold">{title}</p>
<table className="w-full text-[10px]">
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-t border-border">
<td className="px-3 py-1.5">
<span className={cn('mr-2 rounded px-1.5 py-0.5 text-[9px] font-bold uppercase',
r.status === 'pass' ? 'bg-success/20 text-success' : r.status === 'warn' ? 'bg-warning/20 text-warning' : 'bg-danger/20 text-danger')}>
{r.status}
</span>
{r.name}
</td>
<td className="px-3 py-1.5 text-foreground-muted">{r.detail}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function CapCard({ title, items, icon: Icon }: { title: string; items: string[]; icon: typeof Layers }) {
return (
<div className="rounded-lg border border-border bg-surface-raised/80 p-2.5">
<div className="mb-1 flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5 text-docker" />
<span className="text-[10px] font-semibold uppercase tracking-wide text-foreground-faint">{title}</span>
</div>
<p className="text-[10px] leading-relaxed text-foreground-muted">{items.join(' · ')}</p>
</div>
)
}
function UploadZone({ label, hint, accept, loading, onFile }: { label: string; hint: string; accept: string; loading: boolean; onFile: (f: File) => void }) {
return (
<label className={cn('flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-border/80 bg-surface-overlay/30 px-8 py-10 transition-all hover:border-docker/50 hover:bg-docker/5', loading && 'pointer-events-none opacity-50')}>
<Upload className="mb-3 h-10 w-10 text-docker opacity-60" />
<p className="text-[13px] font-medium text-foreground">{label}</p>
<p className="mt-1 text-[10px] text-foreground-faint">{hint}</p>
<input type="file" accept={accept} className="hidden" disabled={loading} onChange={(e) => e.target.files?.[0] && onFile(e.target.files[0])} />
</label>
)
}
function LoadingMsg({ text }: { text: string }) {
return (
<div className="flex items-center justify-center gap-3 rounded-lg border border-docker/30 bg-docker/5 py-10 text-sm text-foreground-muted">
<Loader2 className="h-6 w-6 animate-spin text-docker" />
{text}
</div>
)
}
function StatCard({ label, value, sub, accent, warn, icon: Icon }: { label: string; value: string; sub?: string; accent?: boolean; warn?: boolean; icon?: typeof Image }) {
return (
<div className="rounded-lg border border-border bg-surface-overlay/40 p-3">
<div className="flex items-center gap-1">
{Icon && <Icon className="h-3.5 w-3.5 text-docker" />}
<p className="text-[9px] uppercase tracking-wider text-foreground-faint">{label}</p>
</div>
<p className={cn('text-xl font-bold', accent ? 'text-docker' : warn ? 'text-warning' : 'text-foreground')}>{value}</p>
{sub && <p className="text-[10px] text-foreground-muted">{sub}</p>}
</div>
)
}
@@ -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 (
<div
className={cn(
'rounded-md border border-border/80 bg-surface-overlay/60 px-2 py-1.5 transition-colors',
active && util > 5 && 'border-docker/30 bg-docker/5',
)}
>
<div className="mb-1 flex items-center justify-between gap-1">
<span className="font-mono text-[9px] font-semibold text-foreground">GPU {device.index}</span>
<span className="font-mono text-[8px] text-foreground-faint">{util.toFixed(0)}% · {vramPct}% VRAM</span>
</div>
<div className="space-y-1">
<MetricBar label="Util" value={util} colorClass={utilColor(util)} />
<MetricBar label="VRAM" value={vramPct} colorClass="bg-docker" />
</div>
<div className="mt-1 flex justify-between font-mono text-[7px] text-foreground-faint">
<span className="inline-flex items-center gap-0.5">
<Thermometer className="h-2.5 w-2.5" />
{device.temperature_c?.toFixed(0) ?? '—'}°C
</span>
<span>{device.power_w?.toFixed(0) ?? '—'} W</span>
</div>
</div>
)
}
function MetricBar({ label, value, colorClass }: { label: string; value: number; colorClass: string }) {
return (
<div className="flex items-center gap-1.5">
<span className="w-7 shrink-0 text-[7px] text-foreground-faint">{label}</span>
<div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-raised">
<div
className={cn('h-full rounded-full transition-all duration-700 ease-out', colorClass)}
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
/>
</div>
</div>
)
}
export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props) {
const [localGpu, setLocalGpu] = useState<GpuStatus | null>(gpu)
const [lastPoll, setLastPoll] = useState<Date | null>(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 (
<section className="border-b border-border p-3">
<h2 className="mb-2 flex items-center gap-1.5 text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">
<Cpu className="h-3 w-3" /> GPU Matrix
</h2>
<p className="text-[9px] text-foreground-faint">GPU Lab offline</p>
</section>
)
}
return (
<section className="border-b border-border p-3">
<button
type="button"
onClick={onSelectGpu}
className="mb-2 flex w-full items-start justify-between gap-1 text-left hover:opacity-90"
>
<div className="min-w-0">
<h2 className="flex items-center gap-1.5 text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">
<Cpu className="h-3 w-3 text-docker" /> GPU Matrix
{boost && (
<span className="inline-flex items-center gap-0.5 rounded border border-docker/40 bg-docker/10 px-1 py-px text-[7px] font-bold normal-case tracking-normal text-docker">
<Activity className="h-2.5 w-2.5 animate-pulse" /> Live
</span>
)}
</h2>
<p className="mt-0.5 truncate text-[10px] font-medium text-foreground">{modelLabel}</p>
<p className="font-mono text-[8px] text-foreground-faint">{g.gpu_count ?? devices.length}× V100 · {g.host}</p>
</div>
{g.ui_url && (
<a
href={g.ui_url}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="shrink-0 text-docker hover:underline"
>
<ExternalLink className="h-3 w-3" />
</a>
)}
</button>
<div className="mb-2 grid grid-cols-3 gap-1">
<StatChip
label="Status"
value={inferenceOn ? 'Active' : 'Idle'}
accent={inferenceOn ? 'text-success' : 'text-foreground-muted'}
/>
<StatChip label="Util" value={`${avgUtil.toFixed(0)}%`} accent={avgUtil > 20 ? 'text-warning' : 'text-foreground'} />
<StatChip
label="tok/s"
value={boost && inferenceOn ? String(live.tokenThroughput) : inferenceOn ? '—' : '0'}
icon={<Zap className="h-2.5 w-2.5 text-amber-400" />}
/>
</div>
<div className="scrollbar-thin max-h-[280px] space-y-1.5 overflow-y-auto">
{devices.map((d, i) => (
<GpuRow
key={d.index}
device={d}
liveUtil={live.deviceUtils[i] ?? d.util_gpu}
active={boost}
/>
))}
</div>
<p className="mt-1.5 font-mono text-[7px] text-foreground-faint">
VRAM avg {avgVram.toFixed(0)}% · poll {boost ? '1s' : '3s'}
{lastPoll && ` · ${lastPoll.toLocaleTimeString()}`}
</p>
</section>
)
}
function StatChip({
label,
value,
accent,
icon,
}: {
label: string
value: string
accent?: string
icon?: ReactNode
}) {
return (
<div className="rounded border border-border bg-surface-overlay/80 px-1.5 py-1 text-center">
<p className="flex items-center justify-center gap-0.5 text-[7px] text-foreground-faint">{icon}{label}</p>
<p className={cn('font-mono text-[9px] font-semibold', accent || 'text-foreground')}>{value}</p>
</div>
)
}
+56
View File
@@ -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 (
<div className="panel flex h-[148px] shrink-0 items-center px-2.5 py-1.5 text-[9px] text-foreground-faint">GPU offline</div>
)
}
const inferenceOn = gpu.inference_active && gpu.ok
const devices = gpu.gpus || []
return (
<div className="panel flex h-[148px] shrink-0 flex-col px-2.5 py-1.5">
<div className="flex min-h-0 flex-1 flex-nowrap items-center gap-x-3 gap-y-1 overflow-x-auto overflow-y-hidden">
<div className="flex items-center gap-1.5">
<Cpu className="h-3 w-3 text-docker" />
<span className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">GPU</span>
<Badge variant={inferenceOn ? 'success' : 'default'} className="!py-0">
{inferenceOn ? 'ON' : 'Standby'}
</Badge>
</div>
<Chip label="Model" value={gpu.active_model?.split('-')[0] || '—'} />
<Chip label="tok/s" value={inferenceOn ? String(live.tokenThroughput) : '—'} icon={<Zap className="h-2.5 w-2.5 text-amber-500" />} />
<Chip label="Util" value={`${Math.round(live.avgUtil)}%`} />
<Chip label="VRAM" value={`${Math.round(live.avgVram)}%`} />
{devices.slice(0, 4).map((d, i) => (
<Chip key={d.index} label={`G${d.index}`} value={`${live.deviceUtils[i] ?? d.util_gpu}%`} />
))}
{gpu.ui_url && (
<a href={gpu.ui_url} target="_blank" rel="noreferrer" className="ml-auto flex items-center gap-0.5 text-[9px] text-docker hover:underline">
{gpu.host} <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
</div>
</div>
)
}
function Chip({ label, value, icon }: { label: string; value: string; icon?: ReactNode }) {
return (
<div className="flex items-center gap-1 rounded border border-border bg-surface-overlay/80 px-1.5 py-0.5">
<span className="flex items-center gap-0.5 text-[8px] text-foreground-faint">{icon}{label}</span>
<span className="font-mono text-[9px] font-medium text-foreground">{value}</span>
</div>
)
}
@@ -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 (
<section className="panel shrink-0 p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div>
<h3 className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">Infrastructure & Apps</h3>
<p className="text-[8px] text-foreground-faint">Klik voor inspector · Shell kopieert SSH en opent live terminal · UI opent de applicatie</p>
</div>
{workload && (
<span className="shrink-0 font-mono text-[8px] text-foreground-faint">
{workload.totals.apps_running}/{workload.totals.apps_total} containers
</span>
)}
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
{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 (
<div
key={node.id}
className={cn(
'flex flex-col rounded-lg border bg-surface-overlay/60 p-2 transition-colors',
active ? 'border-docker/50 ring-1 ring-docker/20' : 'border-border hover:border-border-strong',
)}
>
<button type="button" onClick={() => onSelectNode(node.id)} className="mb-1.5 text-left">
<div className="flex items-start gap-2">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-surface" style={{ color: node.accent }}>
<Icon className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[11px] font-semibold text-foreground">{node.label}</p>
<p className="font-mono text-[8px] text-foreground-faint">{node.vm} · {node.ip}</p>
{stats && <p className="font-mono text-[8px] text-foreground-muted">{stats} running</p>}
</div>
</div>
<p className="mt-1 line-clamp-2 text-[9px] leading-snug text-foreground-muted">{node.description}</p>
</button>
{agent && meta && (
<button
type="button"
onClick={() => onSelectAgent(agent.id)}
className="mb-1.5 truncate text-left text-[8px] hover:text-docker"
style={{ color: meta.accent }}
>
Agent: {agent.name.split(' ·')[0]}
</button>
)}
<div className="mt-auto flex flex-wrap gap-1">
<button
type="button"
onClick={() => 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}
>
<Terminal className="h-3 w-3" /> Shell
</button>
<button
type="button"
onClick={() => { 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"
>
<RefreshCw className={cn('h-3 w-3', busy && active && 'animate-spin')} /> Probe
</button>
{node.apps.slice(0, 2).map((app) => (
<a
key={app.url}
href={app.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded border border-docker/25 bg-docker-light/40 px-1.5 py-0.5 text-[8px] text-docker hover:underline dark:bg-blue-500/10"
>
<ExternalLink className="h-3 w-3" /> {app.label}
</a>
))}
</div>
</div>
)
})}
</div>
</section>
)
}
@@ -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<Tab>('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 (
<aside className="flex min-h-0 flex-1 flex-col bg-surface-raised">
<header className="flex items-start justify-between gap-2 border-b border-border p-3">
<div className="min-w-0">
<p className="text-[9px] font-semibold uppercase tracking-widest text-docker">Inspector</p>
<h2 className="truncate text-sm font-semibold text-foreground">
{d ? d.label : agent ? agent.name.split(' ·')[0] : 'Lab overview'}
</h2>
</div>
{(d || agent) && (
<Button variant="ghost" size="icon" onClick={onClear} aria-label="Clear">
<X className="h-4 w-4" />
</Button>
)}
</header>
{!d && !agent && (
<div className="scrollbar-thin flex-1 space-y-3 overflow-y-auto p-3">
<div className="rounded-lg border border-docker/25 bg-docker-light/50 p-2.5 dark:bg-blue-500/10">
<p className="mb-1.5 text-[10px] font-semibold text-foreground">Snel starten</p>
<ol className="list-decimal space-y-1 pl-4 text-[10px] leading-relaxed text-foreground-muted">
<li>Select an <strong className="text-foreground">infrastructure card</strong> or topology node</li>
<li>Klik <strong className="text-foreground">Shell</strong> voor SSH + live terminal output</li>
<li>Klik <strong className="text-foreground">UI</strong> om Airflow, Trino, Kafka UI, etc. te openen</li>
<li>Stel vragen via <strong className="text-foreground">Chat</strong> onderaan agents zien de hele cluster</li>
</ol>
</div>
<div>
<p className="mb-1.5 text-[9px] uppercase tracking-wider text-foreground-faint">Agents & domeinen</p>
<div className="space-y-1">
{agents.filter((a) => !a.supervisor).map((a) => {
const meta = getAgentMeta(a.id)
return (
<button
key={a.id}
type="button"
onClick={() => 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"
>
<span className="mt-0.5 text-[9px] font-semibold" style={{ color: meta.accent }}>{a.name.split(' ·')[0]}</span>
<span className="min-w-0 flex-1 text-[9px] text-foreground-muted">{a.role}</span>
</button>
)
})}
</div>
</div>
{workload && (
<div className="grid grid-cols-2 gap-1.5">
<Stat label="VMs" value={String(workload.totals.vms ?? '—')} />
<Stat label="Containers" value={`${workload.totals.apps_running}/${workload.totals.apps_total}`} />
<Stat label="Connectors" value={String(workload.totals.connectors)} />
<Stat label="Pipeline" value={workload.totals.pipeline_active ? 'active' : 'degraded'} ok={workload.totals.pipeline_active} />
</div>
)}
{gpu?.ok && (
<Card padding className="!p-2">
<CardTitle>GPU · {gpu.host}</CardTitle>
<CardDescription>{gpu.active_model || 'No model'}</CardDescription>
</Card>
)}
</div>
)}
{agent && !d && (
<div className="scrollbar-thin flex-1 overflow-y-auto p-3">
{(() => {
const meta = getAgentMeta(agent.id)
const Icon = meta.icon
return (
<>
<div className="mb-3 flex items-center gap-2 rounded-lg border border-border bg-surface-overlay p-2">
<span className="flex h-10 w-10 items-center justify-center rounded-lg bg-surface" style={{ color: meta.accent }}>
<Icon className="h-5 w-5" />
</span>
<div>
<p className="text-sm font-semibold text-foreground">{agent.name}</p>
<p className="text-[10px] text-foreground-muted">{meta.domain} · {agent.zone}</p>
</div>
</div>
<p className="mb-2 text-[10px] italic text-foreground-muted">"{agent.motto || agent.role}"</p>
<div className="mb-2 flex flex-wrap gap-1">
{(agent.suggested_prompts || []).slice(0, 4).map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => 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}
</button>
))}
</div>
<button
type="button"
onClick={() => 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"
>
<Terminal className="h-3 w-3" /> Agent terminal
</button>
</>
)
})()}
</div>
)}
{d && (
<>
{linkedAgent && (
<div className="flex items-center gap-2 border-b border-border bg-surface-overlay/50 px-3 py-1.5">
<span className="text-[8px] uppercase tracking-wider text-foreground-faint">Agent</span>
<button type="button" onClick={() => onSelectAgent(linkedAgent.id)} className="truncate text-[10px] font-medium text-docker hover:underline">
{linkedAgent.name.split(' ·')[0]}
</button>
</div>
)}
<div className="flex flex-wrap items-center gap-1 border-b border-border px-3 py-1.5 font-mono text-[9px] text-foreground-muted">
<span className={cn('h-1.5 w-1.5 rounded-full', d.level === 'ok' ? 'bg-success' : 'bg-warning')} />
{d.vm} · {d.ip}
<span className="ml-auto">{d.running}/{d.total}</span>
</div>
<div className="flex flex-wrap gap-1 border-b border-border p-2">
{infra && (
<Button size="sm" variant="outline" onClick={runShell} disabled={busy}>
<Terminal className="h-3 w-3" />
{shellCopied ? 'SSH gekopieerd' : 'Shell'}
</Button>
)}
<Button size="sm" variant="outline" onClick={onProbe} disabled={busy}>
<RefreshCw className={cn('h-3 w-3', busy && 'animate-spin')} /> Probe
</Button>
{(infra?.apps || []).slice(0, 3).map((app) => (
<a
key={app.url}
href={app.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-md border border-docker/30 bg-docker-light/30 px-2 py-1 text-[9px] text-docker hover:underline dark:bg-blue-500/10"
>
<ExternalLink className="h-3 w-3" /> {app.label}
</a>
))}
{(d.links || []).map((l) => (
<a key={l.url} href={l.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-[9px] hover:bg-surface-overlay">
<ExternalLink className="h-3 w-3" /> {l.label}
</a>
))}
</div>
<nav className="flex gap-1 border-b border-border px-2 py-1">
{(['overview', 'apps', 'terminal'] as Tab[]).map((t) => (
<button key={t} type="button" onClick={() => setTab(t)} className={cn('rounded px-2 py-0.5 text-[10px] capitalize', tab === t ? 'bg-docker-light text-docker' : 'text-foreground-muted')}>
{t}
</button>
))}
</nav>
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto p-3 text-[10px]">
{tab === 'overview' && (
<div className="space-y-2">
{(d.description || infra?.description) && <p className="text-foreground-muted">{d.description || infra?.description}</p>}
{infra && <p className="font-mono text-[9px] text-foreground-faint">{infra.ssh}</p>}
{(d.endpoints || []).map((ep) => (
<p key={ep.name} className="font-mono text-foreground-muted">{ep.name}: {ep.host}:{ep.port}</p>
))}
{(d.commands || []).map((cmd) => (
<button
key={cmd}
type="button"
onClick={() => { 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}
</button>
))}
</div>
)}
{tab === 'apps' && (
<div className="space-y-1">
{(d.apps || []).map((app) => (
<div key={app.name} className="rounded border border-border bg-surface px-2 py-1">
<p className="font-medium text-foreground">{app.name}</p>
<p className="font-mono text-[9px] text-foreground-faint">{app.state} · {app.image}</p>
</div>
))}
</div>
)}
{tab === 'terminal' && (
<div className="rounded border border-border bg-black/40 p-2 font-mono text-[9px]">
{lines.map((line) => (
<div key={line.id} className="text-foreground-muted">
<span className="text-foreground-faint">{line.ts ? new Date(line.ts).toLocaleTimeString('en-US', { hour12: false }) : ''}</span>{' '}
<span className="text-docker">{line.phase}</span> {line.text}
</div>
))}
{busy && <span className="text-docker animate-pulse"></span>}
</div>
)}
</div>
<form onSubmit={submit} className="flex gap-1 border-t border-border p-2">
<Input value={input} onChange={(e) => setInput(e.target.value)} placeholder={`Vraag over ${d.label}`} disabled={busy} className="text-xs" />
<Button type="submit" size="sm" disabled={busy || !input.trim()}>Send</Button>
</form>
</>
)}
</aside>
)
}
function Stat({ label, value, ok }: { label: string; value: string; ok?: boolean }) {
return (
<div className="rounded border border-border bg-surface-overlay px-2 py-1.5">
<p className="text-[8px] uppercase text-foreground-faint">{label}</p>
<p className={cn('font-mono text-[11px] font-medium', ok === false ? 'text-warning' : ok ? 'text-success' : 'text-foreground')}>{value}</p>
</div>
)
}
@@ -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<Health | null>(null)
const [collections, setCollections] = useState<Collection[]>([])
const [collection, setCollection] = useState('default')
const [newCol, setNewCol] = useState('')
const [messages, setMessages] = useState<ChatMsg[]>([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [ingesting, setIngesting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [storedDocs, setStoredDocs] = useState<StoredDoc[]>([])
const [selectedDocId, setSelectedDocId] = useState<string | null>(null)
const [summarizing, setSummarizing] = useState(false)
const [reindexing, setReindexing] = useState<string | null>(null)
const bottomRef = useRef<HTMLDivElement>(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 (
<div className="flex h-full min-h-[calc(100vh-140px)] flex-col rounded-lg border border-border bg-surface-raised">
<header className="shrink-0 border-b border-border bg-surface-overlay/30 px-4 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-foreground">Knowledge Chat (RAG)</h2>
<p className="text-[11px] text-foreground-muted">
LangChain + ChromaDB chat with your ingested documents via Llama 70B
</p>
</div>
<div className="flex flex-wrap items-center gap-2 text-[10px]">
{health && (
<>
<StatusPill ok={health.chroma} label="ChromaDB" />
<StatusPill ok={health.docling} label="Docling" />
<StatusPill ok={health.llm} label="LLM" />
</>
)}
<button type="button" onClick={loadMeta} className="rounded border border-border p-1.5 hover:bg-surface-overlay">
<RefreshCw className="h-4 w-4" />
</button>
</div>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
<aside className="shrink-0 border-b border-border p-4 lg:w-72 lg:border-b-0 lg:border-r">
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Collection</h3>
<select
value={collection}
onChange={(e) => 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 && <option value="default">default (empty)</option>}
{collections.map((c) => (
<option key={c.name} value={c.name}>{c.name} ({c.documents} docs)</option>
))}
</select>
<div className="mb-4 flex gap-1">
<input
value={newCol}
onChange={(e) => 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]"
/>
<button type="button" onClick={createCollection} className={cn('shrink-0 rounded px-2 py-1 text-[10px]', subTabIdle)}>Add</button>
</div>
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Ingest documents</h3>
<label className={cn('flex cursor-pointer flex-col items-center rounded-lg border-2 border-dashed border-border px-3 py-4 text-center hover:border-docker/40', ingesting && 'opacity-50')}>
<Upload className="mb-1 h-6 w-6 text-docker opacity-60" />
<span className="text-[10px] font-medium">PDF, PPTX, DOCX, CSV, TXT, MD</span>
<span className="text-[9px] text-foreground-faint">Stored in ChromaDB + disk upload once</span>
<input type="file" className="hidden" disabled={ingesting} accept=".pdf,.pptx,.ppt,.docx,.csv,.txt,.md,.json" onChange={(e) => e.target.files?.[0] && onIngest(e.target.files[0])} />
</label>
{ingesting && (
<p className="mt-2 flex items-center gap-1 text-[10px] text-foreground-muted">
<Loader2 className="h-3 w-3 animate-spin" /> Ingesting & embedding
</p>
)}
<h3 className="mb-2 mt-4 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">
Document library ({storedDocs.length})
</h3>
<div className="scrollbar-thin max-h-40 space-y-1 overflow-y-auto">
{storedDocs.length === 0 ? (
<p className="text-[9px] text-foreground-faint">No documents yet upload above.</p>
) : (
storedDocs.map((doc) => (
<div
key={doc.id}
className={cn(
'rounded border px-2 py-1.5 text-[9px] transition-colors',
selectedDocId === doc.id ? 'border-docker/40 bg-docker/10' : 'border-border',
)}
>
<button type="button" onClick={() => { setSelectedDocId(doc.id); setCollection(doc.collection) }} className="w-full text-left">
<p className="truncate font-medium text-foreground">{doc.filename}</p>
<p className="text-foreground-faint">{doc.collection} · {doc.chunks} chunks · {new Date(doc.ingested_at).toLocaleDateString()}</p>
</button>
<div className="mt-1 flex gap-1">
<button
type="button"
disabled={summarizing}
onClick={() => 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 ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <FileText className="h-2.5 w-2.5" />}
Summarize
</button>
<button
type="button"
disabled={reindexing === doc.id}
onClick={() => 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 ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <RotateCcw className="h-2.5 w-2.5" />}
Re-index
</button>
</div>
</div>
))
)}
</div>
<div className="mt-4 space-y-1 text-[9px] text-foreground-faint">
<p><BookOpen className="mr-1 inline h-3 w-3" />Embed: {health?.embed_model || 'all-MiniLM-L6-v2'}</p>
<a href={doclingUiUrl} target="_blank" rel="noreferrer" className="text-docker hover:underline">Docling UI (port 5001) </a>
</div>
</aside>
<div className="flex min-h-0 flex-1 flex-col">
<div className="scrollbar-thin flex-1 overflow-y-auto p-4">
{messages.length === 0 && (
<div className="flex h-full flex-col items-center justify-center gap-2 text-center text-sm text-foreground-muted">
<MessageSquare className="h-10 w-10 opacity-30" />
<p>Upload once documents stay in ChromaDB. Ask anytime without re-uploading.</p>
<p className="text-[11px]">Example: &quot;What maturity gaps exist in the customer dataset?&quot;</p>
</div>
)}
{messages.map((m, i) => (
<div key={i} className={cn('mb-3 max-w-[90%] rounded-lg px-3 py-2 text-[12px]', m.role === 'user' ? 'ml-auto bg-docker/20 text-foreground' : 'bg-surface-overlay text-foreground-muted')}>
<p className="whitespace-pre-wrap leading-relaxed">{m.content}</p>
{m.sources && m.sources.length > 0 && (
<div className="mt-2 border-t border-border pt-2">
<p className="mb-1 text-[9px] font-semibold uppercase text-foreground-faint">Sources</p>
{m.sources.map((s, j) => (
<p key={j} className="text-[9px] text-foreground-faint">
{s.source} · chunk {s.chunk}: {s.preview?.slice(0, 120)}
</p>
))}
</div>
)}
</div>
))}
{loading && (
<div className="flex items-center gap-2 text-[11px] text-foreground-muted">
<Loader2 className="h-4 w-4 animate-spin text-docker" /> Retrieving context & generating answer
</div>
)}
{error && <p className="text-[11px] text-danger">{error}</p>}
<div ref={bottomRef} />
</div>
<div className="flex shrink-0 gap-2 border-t border-border p-3">
<input
value={input}
onChange={(e) => 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}
/>
<button type="button" onClick={onSend} disabled={loading || !input.trim()} className={cn('rounded-lg px-3 py-2', subTabActive, 'disabled:opacity-40')}>
<Send className="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
)
}
function StatusPill({ ok, label }: { ok: boolean; label: string }) {
return (
<span className={cn('rounded-full px-2 py-0.5 font-medium', ok ? 'bg-success/20 text-success' : 'bg-danger/20 text-danger')}>
{label} {ok ? '●' : '○'}
</span>
)
}
@@ -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<string, string> = {
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<FlowKind, string> = {
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<FlowKind, string> = {
orchestration: '#fbbf24',
cdc: '#22d3ee',
stream: '#38bdf8',
etl: '#c4b5fd',
query: '#818cf8',
serve: '#34d399',
}
const NODE_CLICK_MAP: Record<string, string> = {
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<string, { col: number; row: number; rows: number }> = {}
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<string, string>
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, () => 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<string, AgentAnim>
selectedNodeId: string | null
onNodeClick: (nodeId: string) => void
}
export function PlatformTopology({ workload, animations, selectedNodeId, onNodeClick }: Props) {
const [metrics, setMetrics] = useState<MetricState>(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 (
<section className="panel flex h-full min-h-0 flex-1 flex-col overflow-hidden">
<header
className="flex shrink-0 flex-col gap-1 border-b border-border px-3 py-1.5"
style={{ background: 'var(--topo-header-bg)' }}
>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-docker text-white shadow-docker">
<Box className="h-3 w-3" />
</span>
<div className="min-w-0">
<h2 className="truncate text-xs font-semibold text-foreground">Data Platform Topology</h2>
<p className="truncate text-[9px] text-foreground-muted">
Airflow daily Python CDC stream lakehouse consumers
</p>
</div>
</div>
<div className="flex shrink-0 flex-wrap justify-end gap-1">
<Badge variant={pipelineActive ? 'success' : 'warning'}>
{pipelineActive ? 'Pipeline active' : 'Degraded'}
</Badge>
<Badge>{workload?.totals?.connectors ?? 4} CDC</Badge>
<Badge variant="accent">{FLOW_EDGES.length} flows</Badge>
</div>
</div>
<div className="flex flex-wrap gap-x-2 gap-y-0.5">
{FLOW_LEGEND.map((item) => (
<span key={item.kind} className="inline-flex items-center gap-1 text-[8px] text-foreground-muted">
<span className="h-1.5 w-1.5 rounded-full" style={{ background: item.color }} />
{item.label}
</span>
))}
</div>
</header>
<div className="topo-canvas flex min-h-0 flex-1">
<svg
className="pointer-events-none absolute inset-0 z-0 h-full w-full"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden
>
<defs>
<linearGradient id="topo-flow-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#22d3ee" stopOpacity="0.7" />
<stop offset="50%" stopColor="#34d399" stopOpacity="1" />
<stop offset="100%" stopColor="#60a5fa" stopOpacity="0.7" />
</linearGradient>
</defs>
{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 (
<g key={`${edge.from}-${edge.to}-${edge.kind}`}>
<path d={d} className="topo-edge-glow" vectorEffect="non-scaling-stroke" />
<path
d={d}
className={cn(EDGE_CLASS[edge.kind], live ? 'topo-edge-live' : 'topo-edge-idle')}
vectorEffect="non-scaling-stroke"
/>
{live && (
<>
<circle r="0.55" fill={PARTICLE_FILL[edge.kind]} opacity="0.95">
<animateMotion dur={`${dur}s`} repeatCount="indefinite" path={d} />
</circle>
<circle r="0.35" fill="#ffffff" opacity="0.85">
<animateMotion dur={`${dur}s`} repeatCount="indefinite" path={d} begin={`${dur * 0.45}s`} />
</circle>
</>
)}
</g>
)
})}
</svg>
<div className="relative z-10 flex h-full min-h-0 w-full overflow-x-auto">
{STAGES.map((stage) => (
<div key={stage.id} className={cn('topo-stage-col', stage.accent)}>
<header className="mb-1 shrink-0 border-b border-white/10 pb-1">
<div className="flex items-start gap-1">
<span className={cn('rounded border px-1 py-px font-mono text-[8px] font-bold', STAGE_BADGE[stage.id])}>
0{stage.num}
</span>
<div className="min-w-0">
<h3 className="text-[8px] font-bold leading-tight tracking-wide text-white">{stage.title}</h3>
<p className="text-[7px] text-blue-200/70">{stage.subtitle}</p>
</div>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col justify-evenly gap-1">
{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 (
<button
key={node.id}
type="button"
onClick={() => 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',
)}
>
<span className="block truncate text-[10px] font-semibold leading-tight text-white">{label}</span>
<span className="block truncate text-[8px] text-blue-100/80">{sub}</span>
<span className="mt-0.5 inline-block max-w-full truncate rounded border border-emerald-400/35 bg-emerald-500/20 px-1 py-px font-mono text-[7px] font-medium text-emerald-300">
{metrics[node.metricKey]}
</span>
</button>
)
})}
</div>
</div>
))}
</div>
</div>
</section>
)
}
@@ -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<string, string> = {
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<PresentationData | null> {
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<DeckSource>('live')
const [data, setData] = useState<PresentationData | null>(null)
const [slideIdx, setSlideIdx] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [uploadMsg, setUploadMsg] = useState<string | null>(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 (
<div className="flex h-full min-h-[calc(100vh-140px)] flex-col overflow-hidden rounded-lg border border-border bg-surface-raised">
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border bg-surface-raised/90 px-3 py-2">
<div>
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-foreground">Presentation</h2>
<p className="text-[9px] text-foreground-muted">
Live cluster · HTML templates · PPT upload (converts via python-pptx + Docling)
</p>
</div>
<div className="flex flex-wrap gap-1">
<a href="/dq/" target="_blank" rel="noreferrer" className={cn('inline-flex items-center gap-1 rounded-md px-2 py-1 text-[9px]', subTabIdle)}>
<Monitor className="h-3 w-3" /> DQ Portal
</a>
<a href="/docling/ui/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">
<ExternalLink className="h-3 w-3" /> Docling
</a>
<button type="button" onClick={() => load(source)} className="rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">Refresh</button>
<button type="button" onClick={exportHtml} className={cn('rounded-md px-2 py-1 text-[9px]', subTabActive)}>Export HTML</button>
</div>
</div>
<div className="flex shrink-0 flex-wrap gap-1 border-b border-border bg-surface-overlay/40 px-2 py-1.5">
{tabs.map((t) => (
<button
key={t.id}
type="button"
onClick={() => 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}
</button>
))}
<label className={cn('ml-auto inline-flex cursor-pointer items-center gap-1 rounded-md border border-dashed border-border px-2 py-1 text-[10px]', uploading && 'opacity-50')}>
<Upload className="h-3 w-3" />
{uploading ? 'Uploading…' : 'PPT upload'}
<input type="file" accept=".ppt,.pptx,.pdf,.docx" className="hidden" disabled={uploading} onChange={(e) => e.target.files?.[0] && onUpload(e.target.files[0])} />
</label>
</div>
{uploadMsg && <p className="shrink-0 px-3 py-1 text-[10px] text-docker">{uploadMsg}</p>}
{error && <p className="shrink-0 px-3 py-1 text-[10px] text-warning">{error}</p>}
{loading ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-sm text-foreground-muted">
<FileUp className="h-8 w-8 animate-pulse opacity-40" />
<p>Loading presentation{source === 'live' ? ' (live cluster snapshot, ~15 sec)' : '…'}</p>
</div>
) : !slide ? (
<div className="flex flex-1 items-center justify-center text-sm text-foreground-muted">
<button type="button" onClick={() => load(source)} className="rounded border border-border px-3 py-1 text-xs">Retry</button>
</div>
) : (
<>
<div className={cn('relative flex min-h-0 flex-1 flex-col justify-center bg-gradient-to-br p-6 md:p-10', KIND_STYLES[slide.kind || 'narrative'] || KIND_STYLES.narrative)}>
<div className="max-w-4xl">
<p className="mb-1 text-[10px] font-medium uppercase tracking-widest text-docker/80">{slide.kind || 'slide'} · {slideIdx + 1}/{slides.length}</p>
<h1 className="mb-2 text-2xl font-bold tracking-tight text-foreground md:text-4xl">{slide.title}</h1>
{slide.subtitle && <p className="mb-4 text-sm text-foreground-muted md:text-base">{slide.subtitle}</p>}
{'animation' in slide && slide.animation && (
<ArchitectureDiagram animation={String(slide.animation)} />
)}
<ul className="space-y-2 text-sm leading-relaxed text-foreground md:text-base">
{(slide.bullets || []).map((b: string) => (
<li key={b} className="flex gap-2"><span className="shrink-0 text-docker"></span><span>{b}</span></li>
))}
</ul>
</div>
</div>
<div className="flex shrink-0 items-center gap-2 border-t border-border bg-surface-raised/90 px-3 py-2">
<button type="button" disabled={slideIdx === 0} onClick={() => setSlideIdx((i) => Math.max(0, i - 1))} className="rounded border border-border px-2 py-1 text-[10px] disabled:opacity-40"> Prev</button>
<div className="flex flex-1 flex-wrap justify-center gap-1">
{slides.map((_: PresentationSlide, i: number) => (
<button key={i} type="button" onClick={() => setSlideIdx(i)} className={cn('h-2 w-2 rounded-full', i === slideIdx ? 'scale-125 bg-docker' : 'bg-border')} />
))}
</div>
<button type="button" disabled={slideIdx >= 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 </button>
</div>
</>
)}
</div>
)
}
+197
View File
@@ -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<Bucket[]>([])
const [bucket, setBucket] = useState<string | null>(null)
const [prefix, setPrefix] = useState('')
const [folders, setFolders] = useState<S3Item[]>([])
const [objects, setObjects] = useState<S3Item[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(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 (
<div className="panel flex h-full min-h-0 flex-1 flex-col overflow-hidden">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3">
<div>
<h2 className="flex items-center gap-2 text-sm font-semibold text-foreground">
<HardDrive className="h-4 w-4 text-docker" />
ObjectScale S3 Storage
</h2>
<p className="text-[10px] text-foreground-muted">
Dell ECS · {health?.endpoint || '10.0.20.111:9020'} · live bucket browser
</p>
</div>
<div className="flex flex-wrap gap-2">
<a href="/jupyter/" target="_blank" rel="noreferrer" className={cn('inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-[11px] font-medium', subTabActive)}>
<ExternalLink className="h-3 w-3" /> Open Jupyter
</a>
<button type="button" onClick={() => { loadBuckets(); if (bucket) loadObjects(bucket, prefix) }} className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
<RefreshCw className={cn('inline h-3 w-3', loading && 'animate-spin')} /> Refresh
</button>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
<aside className="shrink-0 border-b border-border p-3 lg:w-52 lg:border-b-0 lg:border-r">
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Buckets</h3>
<div className="space-y-1">
{buckets.map((b) => (
<button
key={b.name}
type="button"
onClick={() => { 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',
)}
>
<Database className="h-3 w-3 shrink-0 text-docker" />
<span className="truncate font-medium">{b.name}</span>
</button>
))}
{buckets.length === 0 && !loading && (
<p className="text-[9px] text-foreground-faint">No buckets or access denied.</p>
)}
</div>
</aside>
<div className="flex min-h-0 flex-1 flex-col p-3">
{bucket && (
<nav className="mb-2 flex flex-wrap items-center gap-1 text-[10px] text-foreground-muted">
<button type="button" className="hover:text-docker" onClick={() => setPrefix('')}>{bucket}</button>
{crumbs.map((c, i) => (
<span key={i} className="inline-flex items-center gap-1">
<ChevronRight className="h-3 w-3" />
<button
type="button"
className="hover:text-docker"
onClick={() => setPrefix(crumbs.slice(0, i + 1).join('/') + '/')}
>
{c}
</button>
</span>
))}
</nav>
)}
{loading && (
<p className="flex items-center gap-2 text-[11px] text-foreground-muted">
<Loader2 className="h-4 w-4 animate-spin" /> Loading
</p>
)}
{error && <p className="mb-2 text-[11px] text-danger">{error}</p>}
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto">
<table className="w-full text-left text-[11px]">
<thead>
<tr className="border-b border-border text-[9px] uppercase text-foreground-faint">
<th className="py-1.5 pr-2">Name</th>
<th className="py-1.5 pr-2">Size</th>
<th className="py-1.5 pr-2">Modified</th>
<th className="py-1.5" />
</tr>
</thead>
<tbody>
{folders.map((f) => (
<tr key={f.prefix} className="border-b border-border/50 hover:bg-surface-overlay/50">
<td className="py-1.5 pr-2">
<button
type="button"
className="inline-flex items-center gap-1 font-medium text-docker hover:underline"
onClick={() => setPrefix(f.prefix || '')}
>
<Folder className="h-3.5 w-3.5" /> {f.name}/
</button>
</td>
<td className="py-1.5 pr-2 text-foreground-faint"></td>
<td className="py-1.5 pr-2 text-foreground-faint"></td>
<td />
</tr>
))}
{objects.map((o) => (
<tr key={o.key} className="border-b border-border/50 hover:bg-surface-overlay/50">
<td className="max-w-[240px] truncate py-1.5 pr-2 font-mono text-[10px]">{o.name || o.key}</td>
<td className="py-1.5 pr-2 text-foreground-muted">{o.size_human}</td>
<td className="py-1.5 pr-2 text-foreground-faint">{o.modified?.slice(0, 19) || '—'}</td>
<td className="py-1.5">
{o.key && bucket && (
<a
href={`/api/storage/s3/buckets/${encodeURIComponent(bucket)}/download?key=${encodeURIComponent(o.key)}`}
className="inline-flex items-center gap-0.5 text-docker hover:underline"
>
<Download className="h-3 w-3" />
</a>
)}
</td>
</tr>
))}
</tbody>
</table>
{!loading && folders.length === 0 && objects.length === 0 && bucket && (
<p className="py-8 text-center text-sm text-foreground-muted">This prefix is empty.</p>
)}
</div>
</div>
</div>
</div>
)
}
@@ -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<string, string> = {
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<HTMLDivElement>(null)
const infra = resolveInfraNode(subjectId)
useEffect(() => {
if (expanded) bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [lines, busy, expanded])
return (
<div className={cn('flex shrink-0 flex-col border-t border-border bg-black/80', expanded ? 'h-[200px]' : 'h-9')}>
<button
type="button"
onClick={onToggle}
className="flex shrink-0 items-center justify-between px-3 py-2 text-left hover:bg-white/5"
>
<span className="flex items-center gap-2 text-[10px] font-medium text-emerald-300">
<Terminal className="h-3.5 w-3.5" />
Terminal {subjectLabel}
{busy && <span className="animate-pulse text-docker"> live</span>}
</span>
<span className="font-mono text-[8px] text-foreground-faint">{lines.length} lines · {expanded ? '▼' : '▲'}</span>
</button>
{expanded && (
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto px-3 pb-2 font-mono text-[9px] leading-relaxed">
{infra && (
<p className="mb-1 text-foreground-faint">
<span className="text-docker">$</span> {infra.ssh} <span className="text-foreground-faint/70">(gekopieerd bij Shell-knop)</span>
</p>
)}
{lines.length === 0 && (
<p className="py-4 text-center text-foreground-faint">Selecteer een node of agent · klik Shell of Probe om output te zien</p>
)}
{lines.map((line) => (
<div key={line.id} className={LEVEL[line.level] || 'text-foreground-muted'}>
<span className="text-foreground-faint/60">
{line.ts ? new Date(line.ts).toLocaleTimeString('en-US', { hour12: false }) : ''}
</span>{' '}
<span className="text-docker/80">[{line.phase}]</span> {line.text}
</div>
))}
{busy && <span className="text-docker animate-pulse"></span>}
<div ref={bottomRef} />
</div>
)}
</div>
)
}
+162
View File
@@ -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<string, AgentAnim>
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 (
<nav className="flex w-[240px] shrink-0 flex-col border-r border-border bg-surface-raised">
<section className="border-b border-border p-3">
<h2 className="mb-2 text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">Views</h2>
<div className="space-y-1">
{VIEWS.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => 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,
)}
>
<Icon className={cn('h-4 w-4', mainView === id ? 'text-docker' : 'text-foreground-muted')} />
<span className={cn('text-[11px] font-medium', mainView === id ? 'text-docker' : 'text-foreground')}>{label}</span>
</button>
))}
</div>
</section>
<GpuMatrixPanel
gpu={gpu}
live={gpuLive}
boost={matrixBoost}
onSelectGpu={() => onSelectZone('gpu')}
/>
<section className="flex min-h-0 flex-1 flex-col p-3">
<div className="mb-2 flex items-center justify-between gap-1">
<h2 className="text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">Agents</h2>
<button
type="button"
onClick={onOpenApprovals}
className={cn(
'flex items-center gap-1 rounded-md border px-2 py-0.5 text-[9px] font-medium transition-colors',
approvalCount > 0
? 'border-warning/40 bg-warning/10 text-warning'
: 'border-border text-foreground-muted hover:border-border-strong',
)}
>
<ShieldCheck className="h-3 w-3" />
Approvals
{approvalCount > 0 && <span className="font-mono">{approvalCount}</span>}
</button>
</div>
<div className="scrollbar-thin flex-1 space-y-1 overflow-y-auto">
{agentsLoading && agents.length === 0 && (
<p className="text-[9px] text-foreground-faint">Loading agents</p>
)}
{supervisors.length > 0 && (
<p className="text-[8px] uppercase tracking-widest text-foreground-faint">Supervisors</p>
)}
{supervisors.map((a) => (
<AgentRow key={a.id} agent={a} animations={animations} selected={selectedAgentId === a.id} onSelect={onSelectAgent} />
))}
{operators.length > 0 && (
<p className="mt-1 text-[8px] uppercase tracking-widest text-foreground-faint">Field operators</p>
)}
{operators.map((a) => (
<AgentRow key={a.id} agent={a} animations={animations} selected={selectedAgentId === a.id} onSelect={onSelectAgent} />
))}
</div>
</section>
</nav>
)
}
function AgentRow({
agent,
animations,
selected,
onSelect,
}: {
agent: Agent
animations: Record<string, AgentAnim>
selected: boolean
onSelect: (id: string) => void
}) {
const meta = getAgentMeta(agent.id)
const Icon = meta.icon
const busy = (animations[agent.id]?.state || 'idle') !== 'idle'
return (
<button
type="button"
onClick={() => 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}
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-surface-overlay" style={{ color: meta.accent }}>
<Icon className="h-3.5 w-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[11px] font-medium text-foreground">{agent.name.split(' ·')[0]}</span>
<span className="block truncate font-mono text-[9px] text-foreground-faint">{meta.domain}</span>
<span className="block truncate text-[8px] text-foreground-faint">{agent.role}</span>
</span>
{(agent.stats?.tasks ?? 0) > 0 && (
<span className="rounded-full bg-docker px-1.5 font-mono text-[8px] text-foreground">{agent.stats?.tasks}</span>
)}
</button>
)
}
+25
View File
@@ -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 (
<button
type="button"
onClick={toggle}
className={cn(
'flex h-8 items-center gap-1.5 rounded-lg border px-2.5 text-[10px] font-medium transition-colors',
isDark
? 'border-blue-400/30 bg-blue-500/15 text-blue-200 hover:bg-blue-500/25'
: 'border-border bg-surface-overlay text-foreground-muted hover:bg-docker-light hover:text-docker',
)}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{isDark ? <Moon className="h-3.5 w-3.5" /> : <Sun className="h-3.5 w-3.5" />}
<span className="hidden sm:inline">{isDark ? 'Dark' : 'Light'}</span>
</button>
)
}
+65
View File
@@ -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 (
<header className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border bg-surface-raised/90 px-3 shadow-panel backdrop-blur-sm">
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-docker to-blue-600 shadow-docker">
<Box className="h-4 w-4 text-white" />
</div>
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold text-foreground">Data & AI Command Center</h1>
<p className="text-[9px] text-foreground-muted">ATC Lab · Enterprise Operations</p>
</div>
</div>
<div className="hidden items-center gap-1.5 md:flex">
<Badge variant={pipelineOk ? 'success' : 'warning'}>
<Activity className="h-3 w-3" />
Pipeline {pipelineOk ? 'active' : 'degraded'}
</Badge>
<Badge>{running}/{total} containers</Badge>
<Badge variant="accent">
<Bot className="h-3 w-3" />
{activeAgents} agents
</Badge>
<button type="button" onClick={onApprovalsClick} className="focus:outline-none">
<Badge
variant={approvals.length ? 'warning' : 'default'}
className={cn(approvals.length && 'cursor-pointer hover:opacity-90')}
>
<ShieldAlert className="h-3 w-3" />
{approvals.length}
</Badge>
</button>
</div>
<div className="flex items-center gap-2">
<ThemeToggle />
<div className="flex items-center gap-1.5 font-mono text-[10px] text-foreground-muted">
<Clock className="h-3 w-3" />
{clock}
<span className="hidden text-success sm:inline"></span>
</div>
</div>
</header>
)
}
+25
View File
@@ -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<HTMLSpanElement> & VariantProps<typeof badgeVariants>
export function Badge({ className, variant, ...props }: Props) {
return <span className={cn(badgeVariants({ variant }), className)} {...props} />
}
+30
View File
@@ -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<HTMLButtonElement> & VariantProps<typeof buttonVariants>
export function Button({ className, variant, size, ...props }: Props) {
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />
}
+26
View File
@@ -0,0 +1,26 @@
import type { HTMLAttributes } from 'react'
import { cn } from '../../lib/utils'
type Props = HTMLAttributes<HTMLDivElement> & {
padding?: boolean
}
export function Card({ className, padding = true, children, ...props }: Props) {
return (
<div className={cn('panel', padding && 'p-3', className)} {...props}>
{children}
</div>
)
}
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('mb-2 flex items-center justify-between gap-2', className)} {...props} />
}
export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn('text-xs font-semibold tracking-tight text-foreground', className)} {...props} />
}
export function CardDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn('text-[10px] text-foreground-muted', className)} {...props} />
}
+16
View File
@@ -0,0 +1,16 @@
import type { InputHTMLAttributes } from 'react'
import { cn } from '../../lib/utils'
type Props = InputHTMLAttributes<HTMLInputElement>
export function Input({ className, ...props }: Props) {
return (
<input
className={cn(
'h-8 w-full rounded-md border border-border bg-surface px-2.5 text-sm text-foreground placeholder:text-foreground-faint focus:border-docker/50 focus:outline-none focus:ring-1 focus:ring-docker/30',
className,
)}
{...props}
/>
)
}
+44
View File
@@ -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<ThemeContextValue | null>(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<Theme>(() => {
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 (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme outside ThemeProvider')
return ctx
}
+10
View File
@@ -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 })
}
+331
View File
@@ -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<Agent[]>([])
const [agentsLoading, setAgentsLoading] = useState(true)
const [status, setStatus] = useState<StatusData | null>(null)
const [workload, setWorkload] = useState<WorkloadData | null>(null)
const [gpu, setGpu] = useState<GpuStatus | null>(null)
const [feed, setFeed] = useState<FeedEntry[]>([])
const [approvals, setApprovals] = useState<Approval[]>([])
const [chat, setChat] = useState<ChatMessage[]>([])
const [anims, setAnims] = useState<Record<string, AgentAnim>>({})
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null)
const [promptBusy, setPromptBusy] = useState(false)
const [terminals, setTerminals] = useState<Record<string, TerminalLine[]>>({})
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const [selectedNode, setSelectedNode] = useState<TopologyNode | null>(null)
const [nodeDetail, setNodeDetail] = useState<NodeDetail | null>(null)
const [nodeBusy, setNodeBusy] = useState(false)
const [mainView, setMainView] = useState<'platform' | 'approvals' | 'presentation' | 'dataquality' | 'knowledge' | 'storage'>('platform')
const [approvalHighlight, setApprovalHighlight] = useState(false)
const [chatExpanded, setChatExpanded] = useState(false)
const promptTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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,
}
}
+120
View File
@@ -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<string, AgentAnim>,
boost = false,
) {
const [agentLoads, setAgentLoads] = useState<Record<string, AgentLoad>>({})
const [gpuLive, setGpuLive] = useState<GpuLiveMetrics>({
tokenThroughput: 0,
avgUtil: 0,
avgVram: 0,
deviceUtils: [],
})
const loadsRef = useRef(agentLoads)
loadsRef.current = agentLoads
useEffect(() => {
const seed: Record<string, AgentLoad> = {}
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<string, AgentLoad> = {}
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 }
}
-57
View File
@@ -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);
}
+126
View File
@@ -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<string, AgentMeta> = {
'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<string, string> = {
docker: 'Docker Platform',
databases: 'Database Vault',
lakehouse: 'Lakehouse',
etl: 'ETL / Streaming',
hadoop: 'Hadoop Cluster',
gpu: 'GPU / AI',
}
+101
View File
@@ -0,0 +1,101 @@
import type {
PresentationData,
Agent,
Approval,
FeedEntry,
GpuStatus,
StatusData,
TerminalLine,
WorkloadData,
} from '../types'
async function fetchJson<T>(url: string, timeoutMs = 10000): Promise<T | null> {
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<StatusData>('/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<GpuStatus | null> {
return fetchJson<GpuStatus>('/api/gpu', 8000)
}
export async function fetchTerminals(): Promise<Record<string, TerminalLine[]>> {
const j = await fetchJson<{ terminals?: Record<string, TerminalLine[]> }>('/api/terminals', 8000)
return j?.terminals || {}
}
export async function fetchWorkload(): Promise<WorkloadData | null> {
return fetchJson<WorkloadData>('/api/workload?fast=true', 25000)
}
export async function fetchNodeDetail(nodeId: string) {
const j = await fetchJson<Record<string, unknown>>(`/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<PresentationData | null> {
return fetchJson<PresentationData>('/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 }),
})
}

Some files were not shown because too many files have changed in this diff Show More