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