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:
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
node_modules/
|
||||
dist/
|
||||
*.db
|
||||
@@ -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`
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
:80 {
|
||||
reverse_proxy /api/* api:3201
|
||||
reverse_proxy /* ui:80
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
+218
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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' },
|
||||
})
|
||||
Reference in New Issue
Block a user