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
+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>
)
}