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