Add ATC Command Center v1 with light UI theme.

Agent hub dashboard, FastAPI backend, and Docker stack for VM 304 MCP.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mo
2026-06-23 15:07:51 +02:00
commit fb9cc21c9a
22 changed files with 1113 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.env
__pycache__/
*.pyc
node_modules/
dist/
*.db
+28
View File
@@ -0,0 +1,28 @@
# ATC Command Center
Autonomous agent hub for the Dell ATC lab — Ops Floor UI, FastAPI backend, 5 operator agents.
## Quick start
```bash
docker compose up -d --build
```
Open: **http://atc-mcp.dell-atc.lan/** (or `http://10.0.21.33/`)
## Stack
- **ui** — React dashboard + operator sprites (cap + headset)
- **api** — FastAPI (status, feed, prompts, WebSocket)
- **redis** — live event bus
- **caddy** — reverse proxy (:80)
## VM 304 (MCP)
- Host: `atc-mcp.dell-atc.lan` → 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`
+11
View File
@@ -0,0 +1,11 @@
FROM python:3.12-slim
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 .
RUN mkdir -p /data
ENV DATABASE_URL=sqlite:////data/atc-agents.db
EXPOSE 3201
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3201"]
+385
View File
@@ -0,0 +1,385 @@
"""ATC Command Center API — FastAPI backend."""
from __future__ import annotations
import asyncio
import json
import os
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 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")
AGENTS = [
{
"id": "etl-guardian",
"name": "ETL Guardian",
"color": "#00f0ff",
"zone": "etl",
"role": "Airflow, Kafka, Debezium, S3 pipeline",
},
{
"id": "lakehouse-ops",
"name": "Lakehouse Ops",
"color": "#ff00aa",
"zone": "lakehouse",
"role": "Spark, Trino, Iceberg",
},
{
"id": "data-custodian",
"name": "Data Custodian",
"color": "#ffaa00",
"zone": "db",
"role": "PostgreSQL, MySQL, Mongo, Cassandra, Neo4j",
},
{
"id": "hadoop-ranger",
"name": "Hadoop Ranger",
"color": "#39ff14",
"zone": "hadoop",
"role": "HDFS, YARN cluster",
},
{
"id": "infra-sentinel",
"name": "Infra Sentinel",
"color": "#b366ff",
"zone": "docker",
"role": "Docker, Proxmox, monitoring",
},
]
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", "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"],
"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)
class ApprovalDecision(BaseModel):
approved: bool
def route_agent(message: str) -> str:
lower = message.lower()
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 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_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"
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"},
},
"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)
await publish_event({"type": "agent_dispatch", "agent_id": agent_id, "zone": zone, "prompt_id": prompt_id})
await asyncio.sleep(0.8)
await publish_event({"type": "agent_fetch", "agent_id": agent_id, "zone": zone, "prompt_id": prompt_id})
status = await collect_status()
answer_parts = [f"**{next(a['name'] for a in AGENTS if a['id'] == agent_id)}** reporting:"]
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 = " ".join(answer_parts)
await asyncio.sleep(0.6)
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")
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()
await publish_event({"type": "status", "data": status})
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)
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():
return {"ok": True, "ts": datetime.now(timezone.utc).isoformat()}
@app.get("/api/status")
async def get_status():
return await collect_status()
@app.get("/api/agents")
async def get_agents():
return {"agents": AGENTS, "zones": ZONES}
@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]
agent_id = 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()
await websocket.send_text(json.dumps({"type": "status", "data": status}, default=str))
while True:
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
ws_clients.discard(websocket)
+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
+4
View File
@@ -0,0 +1,4 @@
:80 {
reverse_proxy /api/* api:3201
reverse_proxy /* ui:80
}
+39
View File
@@ -0,0 +1,39 @@
services:
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_data:/data
api:
build: ./api
restart: unless-stopped
environment:
REDIS_URL: redis://redis:6379/0
DOCKHAND_URL: http://10.0.21.45:8082
DATABASE_URL: sqlite:////data/atc-agents.db
volumes:
- api_data:/data
depends_on:
- redis
ui:
build: ./ui
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:
api_data:
+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
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<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;600;700&display=swap" rel="stylesheet" />
</head>
<body class="text-ink">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+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: {},
},
}
+218
View File
@@ -0,0 +1,218 @@
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`
}
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 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 || [])
}, [])
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 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')
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>
<OpsFloor agents={agents} zones={zones} animations={anims} />
<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>
</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>
<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>
))}
</div>
</aside>
</div>
<PromptBar onSubmit={sendPrompt} busy={busy} />
</div>
)
}
+48
View File
@@ -0,0 +1,48 @@
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
@@ -0,0 +1,79 @@
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
@@ -0,0 +1,41 @@
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>
)
}
+57
View File
@@ -0,0 +1,57 @@
@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);
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+44
View File
@@ -0,0 +1,44 @@
export type Agent = {
id: string
name: string
color: string
zone: string
role: string
}
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>
}
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
}
+34
View File
@@ -0,0 +1,34 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
void: '#f0f4ff',
panel: '#ffffff',
ink: {
DEFAULT: '#1a2332',
muted: '#5a6b82',
faint: '#8b9cb3',
},
neon: {
cyan: '#0099cc',
magenta: '#cc0088',
green: '#22aa44',
amber: '#cc7700',
purple: '#8844cc',
},
},
fontFamily: {
display: ['"Space Grotesk"', 'system-ui', 'sans-serif'],
mono: ['"JetBrains Mono"', 'monospace'],
},
boxShadow: {
'neon-cyan': '0 0 20px rgba(0, 153, 204, 0.25), 0 4px 16px rgba(0, 153, 204, 0.12)',
'neon-magenta': '0 0 20px rgba(204, 0, 136, 0.2)',
card: '0 4px 20px rgba(15, 40, 80, 0.07)',
},
},
},
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"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: { host: true, port: 5173 },
build: { outDir: 'dist' },
})