219 lines
10 KiB
TypeScript
219 lines
10 KiB
TypeScript
|
|
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>
|
||
|
|
)
|
||
|
|
}
|