commit fb9cc21c9a1cd135ebc3203371d51d911c6bf849 Author: mo Date: Tue Jun 23 15:07:51 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e8f3fee --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +__pycache__/ +*.pyc +node_modules/ +dist/ +*.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..f60c48c --- /dev/null +++ b/README.md @@ -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` diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..b4772c1 --- /dev/null +++ b/api/Dockerfile @@ -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"] diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..b59b4e5 --- /dev/null +++ b/api/main.py @@ -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) diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..c506982 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +redis==5.2.1 +httpx==0.28.1 +sqlalchemy==2.0.36 +aiosqlite==0.20.0 +pydantic==2.10.4 +python-multipart==0.0.20 +websockets==14.1 diff --git a/caddy/Caddyfile b/caddy/Caddyfile new file mode 100644 index 0000000..89a87c3 --- /dev/null +++ b/caddy/Caddyfile @@ -0,0 +1,4 @@ +:80 { + reverse_proxy /api/* api:3201 + reverse_proxy /* ui:80 +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1496864 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/ui/Dockerfile b/ui/Dockerfile new file mode 100644 index 0000000..0331674 --- /dev/null +++ b/ui/Dockerfile @@ -0,0 +1,11 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..7c68b66 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,15 @@ + + + + + + ATC Command Center + + + + + +
+ + + diff --git a/ui/nginx.conf b/ui/nginx.conf new file mode 100644 index 0000000..3d24bfe --- /dev/null +++ b/ui/nginx.conf @@ -0,0 +1,17 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://api:3201/api/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..0aa1dcf --- /dev/null +++ b/ui/package.json @@ -0,0 +1,26 @@ +{ + "name": "atc-command-center", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "framer-motion": "^11.15.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.16", + "typescript": "^5.7.2", + "vite": "^6.0.3" + } +} diff --git a/ui/postcss.config.js b/ui/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/ui/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx new file mode 100644 index 0000000..3c1347a --- /dev/null +++ b/ui/src/App.tsx @@ -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('Overview') + const [agents, setAgents] = useState([]) + const [zones, setZones] = useState([]) + const [status, setStatus] = useState(null) + const [feed, setFeed] = useState([]) + const [approvals, setApprovals] = useState([]) + const [chat, setChat] = useState<{ role: 'user' | 'agent'; text: string; agent?: string }[]>([]) + const [anims, setAnims] = useState>({}) + const [busy, setBusy] = useState(false) + + const 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 ( +
+
+
+
+ ATC +
+
+

Command Center

+

Agent ops floor · Dell ATC Lab

+
+
+
+ {status && ( + + {allOk ? '● All systems operational' : '● Attention required'} + + )} + {status && ( + + Scan {new Date(status.ts).toLocaleTimeString()} + + )} +
+
+ + + + + +
+
+ {tab === 'Overview' && status && ( +
+ {Object.entries(status.domains).map(([key, d]) => ( +
+
{key}
+
{d.label}
+
+
+ {d.level} +
+
+ ))} +
+ )} + {tab === 'Agents' && ( +
+ {agents.map((a) => ( +
+
{a.name}
+
{a.role}
+
zone: {a.zone}
+
+ ))} +
+ )} + {tab === 'Feed' && ( +
+ {feed.map((e) => ( +
+ {e.ts ? new Date(e.ts).toLocaleTimeString() : ''} + a.id === e.agent_id)?.color || '#888' }}>{e.agent_id} + {e.message} +
+ ))} +
+ )} + {tab === 'Approvals' && ( +
+ {approvals.length === 0 &&

Geen pending approvals.

} + {approvals.map((a) => ( +
+
{a.action}
+
{a.reason}
+
+ + +
+
+ ))} +
+ )} + {tab === 'Audit' && ( +

Audit log — approvals en agent acties (v1 via Feed tab).

+ )} +
+ + +
+ + +
+ ) +} diff --git a/ui/src/components/AgentSprite.tsx b/ui/src/components/AgentSprite.tsx new file mode 100644 index 0000000..eca0191 --- /dev/null +++ b/ui/src/components/AgentSprite.tsx @@ -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 ( + + + + + + + + + + + + + + + + + + + {state === 'fetch' && ( + + )} + + {label} + + ) +} diff --git a/ui/src/components/OpsFloor.tsx b/ui/src/components/OpsFloor.tsx new file mode 100644 index 0000000..8e16917 --- /dev/null +++ b/ui/src/components/OpsFloor.tsx @@ -0,0 +1,79 @@ +import { motion } from 'framer-motion' +import { AgentSprite } from './AgentSprite' +import type { Agent, AgentAnim, Zone } from '../types' + +const ZONE_X: Record = { + docker: 8, + db: 28, + lakehouse: 50, + hadoop: 72, + etl: 92, +} + +const DESK_X = 50 + +type Props = { + agents: Agent[] + zones: Zone[] + animations: Record +} + +export function OpsFloor({ agents, zones, animations }: Props) { + return ( +
+
+

OPS FLOOR

+ ● LIVE +
+ +
+ {zones.map((z) => ( +
+
+ {z.label} +
+
+ ))} + + {zones.map((z) => ( + + ))} + +
+ +
+ {agents.map((agent, i) => { + const anim = animations[agent.id] || { agentId: agent.id, state: 'idle' as const } + const targetX = anim.state === 'idle' ? 12 + i * 17 : ZONE_X[anim.zone || agent.zone] ?? DESK_X + const y = anim.state === 'fetch' ? 8 : anim.state === 'idle' ? 0 : 4 + + return ( + + + + ) + })} +
+
+ ) +} diff --git a/ui/src/components/PromptBar.tsx b/ui/src/components/PromptBar.tsx new file mode 100644 index 0000000..d9b6762 --- /dev/null +++ b/ui/src/components/PromptBar.tsx @@ -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 ( +
+ 💬 + setText(e.target.value)} + disabled={busy} + /> + +
+ ) +} diff --git a/ui/src/index.css b/ui/src/index.css new file mode 100644 index 0000000..f177940 --- /dev/null +++ b/ui/src/index.css @@ -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); +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx new file mode 100644 index 0000000..964aeb4 --- /dev/null +++ b/ui/src/main.tsx @@ -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( + + + , +) diff --git a/ui/src/types.ts b/ui/src/types.ts new file mode 100644 index 0000000..1f2ffa7 --- /dev/null +++ b/ui/src/types.ts @@ -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 +} + +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 +} diff --git a/ui/tailwind.config.js b/ui/tailwind.config.js new file mode 100644 index 0000000..f012dc6 --- /dev/null +++ b/ui/tailwind.config.js @@ -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: [], +} diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..42e0521 --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true + }, + "include": ["src"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..204e90a --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { host: true, port: 5173 }, + build: { outDir: 'dist' }, +})