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:
+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
|
||||
}
|
||||
Reference in New Issue
Block a user