Add Command Center v2: DQ/RAG integration, S3 browser, Jupyter, GPU matrix.
Mirror mo/atc-GPU layout with config/, docs/, scripts/ for Gitea deploy.
This commit is contained in:
+296
@@ -0,0 +1,296 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { ActivityFeed } from './components/ActivityFeed'
|
||||
import { AgentRoster } from './components/AgentRoster'
|
||||
import { AgentTerminalGrid } from './components/AgentTerminalGrid'
|
||||
import { ChatPanel } from './components/ChatPanel'
|
||||
import { CommandDock } from './components/CommandDock'
|
||||
import { GpuPanel } from './components/GpuPanel'
|
||||
import { AmbientBackground } from './components/AmbientBackground'
|
||||
import { LiveClusterMap } from './components/LiveClusterMap'
|
||||
import { LiveDomainGrid } from './components/LiveDomainGrid'
|
||||
import { ThemeToggle } from './components/ThemeToggle'
|
||||
import type { Agent, AgentAnim, Approval, ChatMessage, FeedEntry, GpuStatus, StatusData, TerminalLine, WorkloadData } from './types'
|
||||
|
||||
const TABS = ['Overview', 'Terminals', 'Activity', 'Approvals', 'GPU'] as const
|
||||
type Tab = (typeof TABS)[number]
|
||||
|
||||
function wsUrl() {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
return `${proto}://${window.location.host}/api/ws/ops`
|
||||
}
|
||||
|
||||
function LiveClock() {
|
||||
const [now, setNow] = useState(new Date())
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setNow(new Date()), 1000)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
return (
|
||||
<span className="text-xs font-mono text-[var(--text-faint)] hidden lg:inline tabular-nums">
|
||||
{now.toLocaleTimeString()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [tab, setTab] = useState<Tab>('Overview')
|
||||
const [agents, setAgents] = useState<Agent[]>([])
|
||||
const [status, setStatus] = useState<StatusData | null>(null)
|
||||
const [workload, setWorkload] = useState<WorkloadData | null>(null)
|
||||
const [gpu, setGpu] = useState<GpuStatus | null>(null)
|
||||
const [feed, setFeed] = useState<FeedEntry[]>([])
|
||||
const [approvals, setApprovals] = useState<Approval[]>([])
|
||||
const [chat, setChat] = useState<ChatMessage[]>([])
|
||||
const [anims, setAnims] = useState<Record<string, AgentAnim>>({})
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [terminals, setTerminals] = useState<Record<string, TerminalLine[]>>({})
|
||||
const [terminalLayout, setTerminalLayout] = useState<'grid' | 'focus'>('grid')
|
||||
|
||||
const appendTerminal = useCallback((line: TerminalLine) => {
|
||||
setTerminals((prev) => {
|
||||
const cur = prev[line.agent_id] || []
|
||||
return { ...prev, [line.agent_id]: [...cur, line].slice(-300) }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const selectedAgent = useMemo(
|
||||
() => agents.find((a) => a.id === selectedId) || null,
|
||||
[agents, selectedId],
|
||||
)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [a, s, f, ap, g, t, w] = 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()),
|
||||
fetch('/api/gpu').then((r) => r.json()).catch(() => null),
|
||||
fetch('/api/terminals').then((r) => r.json()).catch(() => ({ terminals: {} })),
|
||||
fetch('/api/workload').then((r) => r.json()).catch(() => null),
|
||||
])
|
||||
setAgents(a.agents || [])
|
||||
setStatus(s)
|
||||
setGpu(g || s.gpu || null)
|
||||
setFeed(f.entries || [])
|
||||
setApprovals(ap.approvals || [])
|
||||
if (t.terminals) setTerminals(t.terminals)
|
||||
if (w?.zones) setWorkload(w)
|
||||
}, [])
|
||||
|
||||
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.data.gpu) setGpu(msg.data.gpu)
|
||||
}
|
||||
if (msg.type === 'workload') setWorkload(msg.data)
|
||||
if (msg.type === 'terminal') appendTerminal(msg.line)
|
||||
if (msg.type === 'terminal_history' && msg.terminals) setTerminals(msg.terminals)
|
||||
if (msg.type === 'feed') setFeed((prev) => [msg.entry, ...prev].slice(0, 100))
|
||||
if (msg.type === 'agent_dispatch') {
|
||||
setSelectedId(msg.agent_id)
|
||||
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, ts: new Date().toISOString() }])
|
||||
setBusy(false)
|
||||
load()
|
||||
}
|
||||
}
|
||||
const iv = setInterval(load, 15000)
|
||||
return () => { ws.close(); clearInterval(iv) }
|
||||
}, [load, appendTerminal])
|
||||
|
||||
const sendPrompt = async (message: string, agentId?: string) => {
|
||||
setBusy(true)
|
||||
setChat((c) => [...c, { role: 'user', text: message, ts: new Date().toISOString() }])
|
||||
if (agentId) setSelectedId(agentId)
|
||||
await fetch('/api/prompt', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, agent_id: agentId || undefined }),
|
||||
})
|
||||
}
|
||||
|
||||
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')
|
||||
const totalTasks = agents.reduce((n, a) => n + (a.stats?.tasks || 0), 0)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-4 md:p-6 max-w-[96rem] mx-auto font-display flex flex-col gap-5 relative">
|
||||
<AmbientBackground />
|
||||
<header className="panel rounded-2xl px-5 py-4 flex flex-wrap justify-between items-center gap-4 relative overflow-hidden">
|
||||
<div className="header-aurora" />
|
||||
<div className="flex items-center gap-4 relative z-10">
|
||||
<div className="logo-mark">ATC</div>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight neon-text" style={{ color: 'var(--accent)' }}>
|
||||
Command Center
|
||||
</h1>
|
||||
<p className="text-xs font-mono text-[var(--text-muted)]">
|
||||
{agents.length} agents · {totalTasks} missions · autonomous ops
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap relative z-10">
|
||||
<LiveClock />
|
||||
{status && (
|
||||
<span className={`status-pill ${allOk ? 'ok' : 'warn'}`}>
|
||||
{allOk ? 'All systems operational' : 'Attention required'}
|
||||
</span>
|
||||
)}
|
||||
{gpu?.ok && gpu.active_model && (
|
||||
<span className="status-pill gpu hidden md:inline-flex">
|
||||
GPU · {gpu.active_model}
|
||||
</span>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<AgentRoster
|
||||
agents={agents}
|
||||
animations={anims}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
onDelegate={(a) => setSelectedId(a.id)}
|
||||
/>
|
||||
|
||||
<section className="panel rounded-2xl p-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<p className="section-eyebrow">Live agent shells</p>
|
||||
<h2 className="text-lg font-bold" style={{ color: 'var(--accent)' }}>Agent Terminals</h2>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">Volg live hoe elke agent data ophaalt — HTTP probes, Dockhand, JMX, vLLM.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" className={`tab-btn text-xs ${terminalLayout === 'grid' ? 'active' : ''}`} onClick={() => setTerminalLayout('grid')}>Grid</button>
|
||||
<button type="button" className={`tab-btn text-xs ${terminalLayout === 'focus' ? 'active' : ''}`} onClick={() => setTerminalLayout('focus')}>Focus</button>
|
||||
</div>
|
||||
</div>
|
||||
<AgentTerminalGrid
|
||||
agents={agents}
|
||||
terminals={terminals}
|
||||
animations={anims}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
layout={terminalLayout}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-12 gap-5">
|
||||
<div className="xl:col-span-8 flex flex-col gap-5">
|
||||
<LiveClusterMap agents={agents} workload={workload} animations={anims} selectedId={selectedId} />
|
||||
|
||||
<nav className="flex gap-2 flex-wrap">
|
||||
{TABS.map((t) => (
|
||||
<button key={t} type="button" onClick={() => setTab(t)} className={`tab-btn ${tab === t ? 'active' : ''}`}>
|
||||
{t}
|
||||
{t === 'Approvals' && approvals.length > 0 && (
|
||||
<span className="tab-badge">{approvals.length}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<main className="panel rounded-2xl p-5 min-h-[280px]">
|
||||
{tab === 'Overview' && (
|
||||
<div>
|
||||
<p className="section-eyebrow mb-1">Infrastructure</p>
|
||||
<h3 className="text-lg font-bold mb-4" style={{ color: 'var(--text)' }}>Live Cluster Workload</h3>
|
||||
<LiveDomainGrid workload={workload} />
|
||||
</div>
|
||||
)}
|
||||
{tab === 'Terminals' && (
|
||||
<div>
|
||||
<p className="section-eyebrow mb-1">Mission trace</p>
|
||||
<h3 className="text-lg font-bold mb-4" style={{ color: 'var(--text)' }}>
|
||||
{selectedAgent ? `${selectedAgent.name} — full terminal` : 'Select an agent'}
|
||||
</h3>
|
||||
{selectedAgent ? (
|
||||
<AgentTerminalGrid
|
||||
agents={agents}
|
||||
terminals={terminals}
|
||||
animations={anims}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
layout="focus"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--text-muted)]">Klik een agent in de roster of stuur een prompt.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tab === 'Activity' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<p className="section-eyebrow">Event stream</p>
|
||||
<h3 className="text-lg font-bold" style={{ color: 'var(--text)' }}>Agent Activity</h3>
|
||||
</div>
|
||||
{selectedAgent && (
|
||||
<button type="button" className="btn-secondary text-xs" onClick={() => setSelectedId(null)}>
|
||||
Clear filter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ActivityFeed feed={feed} agents={agents} filterAgentId={selectedId} />
|
||||
</div>
|
||||
)}
|
||||
{tab === 'Approvals' && (
|
||||
<div className="space-y-3">
|
||||
{approvals.length === 0 && <p className="text-[var(--text-muted)] text-sm">Geen pending approvals.</p>}
|
||||
{approvals.map((a) => {
|
||||
const agent = agents.find((ag) => ag.id === a.agent_id)
|
||||
return (
|
||||
<div key={a.id} className="status-card rounded-xl p-4 flex gap-4" style={{ borderColor: 'var(--accent-secondary)' }}>
|
||||
{agent && <span className="text-2xl">{agent.icon}</span>}
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--accent-secondary)' }}>{a.action}</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-1">{a.reason}</div>
|
||||
<div className="text-[10px] font-mono text-[var(--text-faint)] mt-1">via {agent?.name || a.agent_id}</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button type="button" onClick={() => decide(a.id, true)} className="btn-secondary text-[var(--status-ok)]">Approve</button>
|
||||
<button type="button" onClick={() => decide(a.id, false)} className="btn-secondary text-[var(--status-down)]">Deny</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{tab === 'GPU' && <GpuPanel gpu={gpu} />}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div className="xl:col-span-4 flex flex-col gap-5">
|
||||
<GpuPanel gpu={gpu} compact />
|
||||
<ChatPanel messages={chat} agents={agents} selectedAgent={selectedAgent} busy={busy} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CommandDock onSubmit={sendPrompt} busy={busy} selectedAgent={selectedAgent} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import type { Agent, FeedEntry } from '../types'
|
||||
|
||||
type Props = {
|
||||
feed: FeedEntry[]
|
||||
agents: Agent[]
|
||||
filterAgentId?: string | null
|
||||
}
|
||||
|
||||
export function ActivityFeed({ feed, agents, filterAgentId }: Props) {
|
||||
const entries = filterAgentId ? feed.filter((e) => e.agent_id === filterAgentId) : feed
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<span className="text-2xl mb-2">📡</span>
|
||||
<p>Geen activiteit{filterAgentId ? ' voor deze agent' : ''}.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="activity-feed max-h-[420px] overflow-y-auto pr-1">
|
||||
{entries.map((e, i) => {
|
||||
const agent = agents.find((a) => a.id === e.agent_id)
|
||||
return (
|
||||
<motion.div
|
||||
key={e.id}
|
||||
className="activity-item"
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: Math.min(i * 0.03, 0.3) }}
|
||||
>
|
||||
<div className="activity-rail">
|
||||
<div className="activity-dot" style={{ background: agent?.color || 'var(--text-faint)', boxShadow: `0 0 8px ${agent?.color || 'transparent'}` }} />
|
||||
{i < entries.length - 1 && <div className="activity-line" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pb-4">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-mono text-[var(--text-faint)]">
|
||||
{e.ts ? new Date(e.ts).toLocaleString() : ''}
|
||||
</span>
|
||||
<span className="activity-agent-badge" style={{ color: agent?.color, borderColor: `${agent?.color}44` }}>
|
||||
{agent?.icon} {agent?.name || e.agent_id}
|
||||
</span>
|
||||
{e.level === 'warn' && <span className="text-[10px] font-mono text-[var(--status-warn)]">WARN</span>}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text)] leading-relaxed">{e.message}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { Agent, AgentAnim } from '../types'
|
||||
|
||||
const STATE_LABEL: Record<AgentAnim['state'], string> = {
|
||||
idle: 'Standby',
|
||||
walk: 'En route',
|
||||
fetch: 'Fetching data',
|
||||
return: 'Returning',
|
||||
}
|
||||
|
||||
type Props = {
|
||||
agent: Agent
|
||||
anim: AgentAnim
|
||||
selected: boolean
|
||||
onSelect: () => void
|
||||
onDelegate: () => void
|
||||
}
|
||||
|
||||
export function AgentCard({ agent, anim, selected, onSelect, onDelegate }: Props) {
|
||||
const busy = anim.state !== 'idle'
|
||||
const tasks = agent.stats?.tasks ?? 0
|
||||
const alerts = agent.stats?.alerts ?? 0
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={`agent-card text-left w-full ${selected ? 'selected' : ''}`}
|
||||
whileHover={{ y: -3 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
style={{ '--agent-color': agent.color } as CSSProperties}
|
||||
>
|
||||
<div className="agent-card-inner rounded-2xl p-4 h-full flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="agent-avatar" style={{ background: `color-mix(in srgb, ${agent.color} 18%, transparent)`, borderColor: agent.color }}>
|
||||
<span className="text-xl">{agent.icon || '🤖'}</span>
|
||||
<span className={`agent-status-dot ${busy ? 'active' : ''}`} style={{ background: busy ? agent.color : 'var(--status-ok)' }} />
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className={`agent-state-pill ${busy ? 'busy' : ''}`} style={{ color: busy ? agent.color : 'var(--text-muted)' }}>
|
||||
{STATE_LABEL[anim.state]}
|
||||
</span>
|
||||
{alerts > 0 && (
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded-full bg-[var(--status-warn-bg)] text-[var(--status-warn)]">
|
||||
{alerts} alert{alerts > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-bold text-base leading-tight" style={{ color: agent.color }}>{agent.name}</h3>
|
||||
<p className="text-[11px] font-mono text-[var(--text-faint)] mt-0.5 italic">"{agent.motto || agent.role}"</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(agent.capabilities || []).slice(0, 4).map((cap) => (
|
||||
<span key={cap} className="cap-chip">{cap}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-auto pt-2 border-t border-[var(--border)]">
|
||||
<span className="text-[10px] font-mono text-[var(--text-faint)]">{tasks} tasks logged</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => { e.stopPropagation(); onDelegate() }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); onDelegate() } }}
|
||||
className="delegate-btn text-[10px] font-mono font-semibold px-2 py-1 rounded-lg"
|
||||
style={{ color: agent.color }}
|
||||
>
|
||||
Delegate →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import type { Agent, AgentAnim } from '../types'
|
||||
import { AgentCard } from './AgentCard'
|
||||
|
||||
type Props = {
|
||||
agents: Agent[]
|
||||
animations: Record<string, AgentAnim>
|
||||
selectedId: string | null
|
||||
onSelect: (id: string) => void
|
||||
onDelegate: (agent: Agent) => void
|
||||
}
|
||||
|
||||
export function AgentRoster({ agents, animations, selectedId, onSelect, onDelegate }: Props) {
|
||||
return (
|
||||
<section className="panel rounded-2xl p-5 relative overflow-hidden">
|
||||
<div className="absolute inset-0 agent-roster-glow pointer-events-none" />
|
||||
<div className="relative flex flex-wrap items-end justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<p className="section-eyebrow">Autonomous workforce</p>
|
||||
<h2 className="text-xl font-bold tracking-tight" style={{ color: 'var(--accent)' }}>
|
||||
Agent Roster
|
||||
</h2>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1 max-w-md">
|
||||
Selecteer een agent om te delegeren. Elk teamlid bewaakt een zone op de ops floor.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs font-mono text-[var(--text-faint)]">
|
||||
<span className="w-2 h-2 rounded-full bg-[var(--status-ok)] animate-pulse" />
|
||||
{agents.length} agents online
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-3">
|
||||
{agents.map((agent, i) => (
|
||||
<motion.div
|
||||
key={agent.id}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.06 }}
|
||||
>
|
||||
<AgentCard
|
||||
agent={agent}
|
||||
anim={animations[agent.id] || { agentId: agent.id, state: 'idle' }}
|
||||
selected={selectedId === agent.id}
|
||||
onSelect={() => onSelect(agent.id)}
|
||||
onDelegate={() => onDelegate(agent)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
type Props = {
|
||||
agentId: string
|
||||
color: string
|
||||
icon?: string
|
||||
state: 'idle' | 'walk' | 'fetch' | 'return'
|
||||
label: string
|
||||
}
|
||||
|
||||
function uid(agentId: string, name: string) {
|
||||
return `${agentId}-${name}`
|
||||
}
|
||||
|
||||
function CharacterBody({ agentId, color, state }: { agentId: string; color: string; state: Props['state'] }) {
|
||||
const g = uid(agentId, 'bodyGrad')
|
||||
const glow = uid(agentId, 'glow')
|
||||
const visor = uid(agentId, 'visor')
|
||||
const walking = state === 'walk' || state === 'return'
|
||||
const fetching = state === 'fetch'
|
||||
|
||||
return (
|
||||
<svg width="72" height="92" viewBox="0 0 72 92" fill="none" xmlns="http://www.w3.org/2000/svg" className="sprite-svg">
|
||||
<defs>
|
||||
<linearGradient id={g} x1="36" y1="8" x2="36" y2="88" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor={color} stopOpacity="0.35" />
|
||||
<stop offset="0.45" stopColor={color} stopOpacity="0.08" />
|
||||
<stop offset="1" stopColor={color} stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
<linearGradient id={visor} x1="36" y1="14" x2="36" y2="28" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor={color} stopOpacity="0.95" />
|
||||
<stop offset="1" stopColor={color} stopOpacity="0.25" />
|
||||
</linearGradient>
|
||||
<filter id={glow} x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feGaussianBlur stdDeviation="2.5" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Platform + aura */}
|
||||
<ellipse cx="36" cy="86" rx="22" ry="5" fill={color} opacity="0.2" />
|
||||
<ellipse cx="36" cy="86" rx="14" ry="2.5" fill={color} opacity="0.45" className="sprite-platform-pulse" />
|
||||
|
||||
{agentId === 'etl-guardian' && (
|
||||
<g filter={`url(#${glow})`}>
|
||||
<path d="M26 38 L36 32 L46 38 L44 58 L28 58 Z" fill={`url(#${g})`} stroke={color} strokeWidth="1.4" />
|
||||
<rect x="30" y="42" width="12" height="8" rx="1" fill={color} opacity="0.15" stroke={color} strokeWidth="0.8" />
|
||||
<path d="M32 46 H40 M34 48 H38" stroke={color} strokeWidth="0.8" opacity="0.7" />
|
||||
<motion.path
|
||||
d="M48 40 Q54 36 56 44"
|
||||
stroke={color} strokeWidth="1.5" fill="none"
|
||||
animate={fetching ? { pathLength: [0.2, 1, 0.2] } : { pathLength: 1 }}
|
||||
transition={{ repeat: Infinity, duration: 0.8 }}
|
||||
/>
|
||||
<circle cx="56" cy="44" r="2.5" fill={color} opacity={fetching ? 1 : 0.5} />
|
||||
<motion.g animate={walking ? { rotate: [0, 12, 0] } : {}} style={{ originX: '48px', originY: '42px' }}>
|
||||
<path d="M46 40 L52 36 L52 48 L46 44 Z" fill={color} opacity="0.25" stroke={color} strokeWidth="1" />
|
||||
</motion.g>
|
||||
<rect x="22" y="40" width="7" height="16" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||
<rect x="43" y="40" width="7" height="16" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||
<rect x="28" y="58" width="8" height="14" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||
<rect x="36" y="58" width="8" height="14" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||
<circle cx="36" cy="22" r="11" className="sprite-head" stroke={color} strokeWidth="1.5" />
|
||||
<path d="M24 18 Q36 6 48 18 L46 22 Q36 12 26 22 Z" fill={color} opacity="0.85" />
|
||||
<rect x="26" y="18" width="20" height="5" rx="2" fill={`url(#${visor})`} opacity="0.9" />
|
||||
<path d="M28 20 L32 20 M40 20 L44 20" stroke={color} strokeWidth="1.2" opacity="0.8" />
|
||||
<text x="36" y="23" textAnchor="middle" fontSize="9" fill={color}>⚡</text>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{agentId === 'lakehouse-ops' && (
|
||||
<g filter={`url(#${glow})`}>
|
||||
<path d="M25 37 L36 30 L47 37 L45 59 L27 59 Z" fill={`url(#${g})`} stroke={color} strokeWidth="1.4" />
|
||||
<path d="M30 35 L36 28 L42 35" stroke={color} strokeWidth="1.2" fill="none" opacity="0.6" />
|
||||
<rect x="31" y="43" width="10" height="7" rx="1" fill={color} opacity="0.12" stroke={color} strokeWidth="0.8" />
|
||||
<path d="M32 48 L36 44 L40 48 L38 50 L34 50 Z" fill={color} opacity="0.5" />
|
||||
<motion.g animate={fetching ? { y: [0, -2, 0] } : {}} transition={{ repeat: Infinity, duration: 1.2 }}>
|
||||
<rect x="48" y="38" width="10" height="12" rx="2" fill={color} opacity="0.2" stroke={color} strokeWidth="1" />
|
||||
<path d="M50 46 L54 42 L54 46" stroke={color} strokeWidth="0.8" fill="none" />
|
||||
</motion.g>
|
||||
<rect x="21" y="39" width="7" height="15" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||
<rect x="44" y="39" width="7" height="15" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||
<rect x="27" y="59" width="8" height="13" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||
<rect x="37" y="59" width="8" height="13" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||
<circle cx="36" cy="21" r="11.5" className="sprite-head" stroke={color} strokeWidth="1.5" />
|
||||
<path d="M23 17 Q36 5 49 17 L47 21 Q36 11 25 21 Z" fill={color} opacity="0.85" />
|
||||
<ellipse cx="36" cy="20" rx="9" ry="4" fill={`url(#${visor})`} opacity="0.85" />
|
||||
<text x="36" y="22" textAnchor="middle" fontSize="8" fill={color}>🏔</text>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{agentId === 'data-custodian' && (
|
||||
<g filter={`url(#${glow})`}>
|
||||
<rect x="26" y="36" width="20" height="24" rx="5" fill={`url(#${g})`} stroke={color} strokeWidth="1.5" />
|
||||
<rect x="29" y="40" width="14" height="10" rx="2" fill={color} opacity="0.12" stroke={color} strokeWidth="0.8" />
|
||||
<circle cx="36" cy="45" r="3" stroke={color} strokeWidth="1" fill="none" opacity="0.7" />
|
||||
<path d="M36 45 L36 48 M34 47 L38 47" stroke={color} strokeWidth="0.8" opacity="0.7" />
|
||||
<motion.g animate={walking ? { x: [0, 1, 0] } : {}} transition={{ repeat: Infinity, duration: 0.4 }}>
|
||||
<path d="M18 38 L18 52 L24 52 L28 44 L24 38 Z" fill={color} opacity="0.3" stroke={color} strokeWidth="1.2" />
|
||||
<path d="M20 42 L24 42 M20 46 L24 46" stroke={color} strokeWidth="0.7" opacity="0.6" />
|
||||
</motion.g>
|
||||
<rect x="44" y="39" width="7" height="16" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||
<rect x="28" y="60" width="9" height="12" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||
<rect x="35" y="60" width="9" height="12" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||
<circle cx="36" cy="22" r="12" className="sprite-head" stroke={color} strokeWidth="1.5" />
|
||||
<path d="M22 19 Q36 7 50 19 L48 23 Q36 13 24 23 Z" fill={color} opacity="0.9" />
|
||||
<rect x="27" y="18" width="18" height="6" rx="3" fill={`url(#${visor})`} />
|
||||
<text x="36" y="23" textAnchor="middle" fontSize="8" fill={color}>🛡</text>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{agentId === 'hadoop-ranger' && (
|
||||
<g filter={`url(#${glow})`}>
|
||||
<path d="M27 38 L36 33 L45 38 L43 58 L29 58 Z" fill={`url(#${g})`} stroke={color} strokeWidth="1.4" />
|
||||
<rect x="30" y="42" width="12" height="9" rx="1.5" fill={color} opacity="0.1" stroke={color} strokeWidth="0.8" />
|
||||
<path d="M32 46 H40 M33 49 H39" stroke={color} strokeWidth="0.7" opacity="0.6" />
|
||||
<path d="M14 42 L20 38 L20 46 L14 50 Z" fill={color} opacity="0.25" stroke={color} strokeWidth="1" />
|
||||
<circle cx="17" cy="43" r="2" fill={color} opacity="0.8" />
|
||||
<circle cx="17" cy="47" r="2" fill={color} opacity="0.8" />
|
||||
<rect x="22" y="40" width="7" height="16" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||
<rect x="43" y="40" width="7" height="16" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||
<rect x="28" y="58" width="8" height="14" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||
<rect x="36" y="58" width="8" height="14" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||
<circle cx="36" cy="22" r="11" className="sprite-head" stroke={color} strokeWidth="1.5" />
|
||||
<path d="M24 18 Q36 8 48 18 L46 22 Q36 14 26 22 Z" fill={color} opacity="0.85" />
|
||||
<path d="M30 14 L36 10 L42 14 L40 17 L32 17 Z" fill={color} opacity="0.5" />
|
||||
<rect x="28" y="19" width="16" height="5" rx="2" fill={`url(#${visor})`} opacity="0.85" />
|
||||
<text x="36" y="23" textAnchor="middle" fontSize="8" fill={color}>🌲</text>
|
||||
{fetching && (
|
||||
<motion.circle cx="50" cy="30" r="3" fill={color} animate={{ opacity: [0.3, 1, 0.3] }} transition={{ repeat: Infinity, duration: 0.6 }} />
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{agentId === 'infra-sentinel' && (
|
||||
<g filter={`url(#${glow})`}>
|
||||
<path d="M24 37 Q36 30 48 37 L46 59 L26 59 Z" fill={`url(#${g})`} stroke={color} strokeWidth="1.4" />
|
||||
<circle cx="36" cy="46" r="5" stroke={color} strokeWidth="1" fill={color} opacity="0.15" />
|
||||
<circle cx="36" cy="46" r="2" fill={color} opacity="0.8" className="sprite-core-pulse" />
|
||||
<motion.g
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ repeat: Infinity, duration: 8, ease: 'linear' }}
|
||||
style={{ originX: '36px', originY: '46px' }}
|
||||
>
|
||||
<ellipse cx="36" cy="46" rx="10" ry="4" stroke={color} strokeWidth="0.8" fill="none" opacity="0.35" />
|
||||
</motion.g>
|
||||
<rect x="20" y="39" width="7" height="15" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||
<rect x="45" y="39" width="7" height="15" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||
<rect x="27" y="59" width="8" height="13" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||
<rect x="37" y="59" width="8" height="13" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||
<circle cx="36" cy="21" r="11.5" className="sprite-head" stroke={color} strokeWidth="1.5" />
|
||||
<path d="M23 17 Q36 5 49 17 L47 21 Q36 11 25 21 Z" fill={color} opacity="0.85" />
|
||||
<rect x="26" y="17" width="20" height="6" rx="3" fill={`url(#${visor})`} />
|
||||
<circle cx="36" cy="20" r="3" fill={color} opacity="0.9" />
|
||||
<motion.circle
|
||||
cx="52" cy="18" r="4"
|
||||
fill={color} opacity="0.4" stroke={color} strokeWidth="1"
|
||||
animate={{ y: [0, -3, 0], opacity: [0.3, 0.8, 0.3] }}
|
||||
transition={{ repeat: Infinity, duration: 2 }}
|
||||
/>
|
||||
<text x="36" y="24" textAnchor="middle" fontSize="7" fill="#fff" opacity="0.9">👁</text>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Default fallback */}
|
||||
{!['etl-guardian', 'lakehouse-ops', 'data-custodian', 'hadoop-ranger', 'infra-sentinel'].includes(agentId) && (
|
||||
<g filter={`url(#${glow})`}>
|
||||
<rect x="26" y="36" width="20" height="24" rx="4" fill={`url(#${g})`} stroke={color} strokeWidth="1.5" />
|
||||
<circle cx="36" cy="22" r="11" className="sprite-head" stroke={color} strokeWidth="1.5" />
|
||||
<text x="36" y="25" textAnchor="middle" fontSize="10" fill={color}>🤖</text>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{fetching && (
|
||||
<motion.g animate={{ opacity: [0.4, 1, 0.4] }} transition={{ repeat: Infinity, duration: 0.7 }}>
|
||||
<circle cx="58" cy="26" r="4" fill={color} />
|
||||
<circle cx="58" cy="26" r="7" stroke={color} strokeWidth="1" fill="none" opacity="0.4" />
|
||||
</motion.g>
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentSprite({ agentId, color, state, label }: Props) {
|
||||
const bob = state === 'idle' ? { y: [0, -5, 0] } : state === 'walk' || state === 'return' ? { y: [0, -9, 0] } : { y: [0, -2, 0] }
|
||||
const scale = state === 'fetch' ? 0.94 : 1
|
||||
const busy = state !== 'idle'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex flex-col items-center agent-sprite"
|
||||
animate={{ ...bob, scale }}
|
||||
transition={{ repeat: Infinity, duration: state === 'walk' || state === 'return' ? 0.28 : 2.4, ease: 'easeInOut' }}
|
||||
>
|
||||
<div className={`sprite-figure ${busy ? 'sprite-figure-busy' : ''}`}>
|
||||
{state !== 'idle' && (
|
||||
<motion.div
|
||||
className="sprite-ring-outer"
|
||||
style={{ borderColor: color, boxShadow: `0 0 20px ${color}55, inset 0 0 12px ${color}22` }}
|
||||
animate={{ scale: [1, 1.06, 1], opacity: [0.6, 1, 0.6] }}
|
||||
transition={{ repeat: Infinity, duration: 1.5 }}
|
||||
/>
|
||||
)}
|
||||
<div className="sprite-holo-shimmer" style={{ background: `linear-gradient(135deg, ${color}18, transparent 60%)` }} />
|
||||
<CharacterBody agentId={agentId} color={color} state={state} />
|
||||
</div>
|
||||
<div className="sprite-nameplate" style={{ borderColor: `${color}55`, boxShadow: `0 0 12px ${color}33` }}>
|
||||
<span className="sprite-nameplate-dot" style={{ background: color, boxShadow: `0 0 6px ${color}` }} />
|
||||
<span className="sprite-nameplate-text" style={{ color }}>{label}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { Agent, TerminalLine } from '../types'
|
||||
|
||||
const LEVEL_CLASS: Record<string, string> = {
|
||||
info: 'term-info',
|
||||
ok: 'term-ok',
|
||||
warn: 'term-warn',
|
||||
err: 'term-err',
|
||||
cmd: 'term-cmd',
|
||||
llm: 'term-llm',
|
||||
}
|
||||
|
||||
type Props = {
|
||||
agent: Agent
|
||||
lines: TerminalLine[]
|
||||
active: boolean
|
||||
expanded?: boolean
|
||||
onFocus?: () => void
|
||||
}
|
||||
|
||||
export function AgentTerminal({ agent, lines, active, expanded, onFocus }: Props) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (active || expanded) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}, [lines, active, expanded])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`agent-terminal ${active ? 'active' : ''} ${expanded ? 'expanded' : ''}`}
|
||||
style={{ '--term-accent': agent.color } as CSSProperties}
|
||||
onClick={onFocus}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onFocus?.()}
|
||||
>
|
||||
<div className="agent-terminal-header">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span>{agent.icon}</span>
|
||||
<span className="font-semibold text-xs truncate" style={{ color: agent.color }}>{agent.name}</span>
|
||||
{active && <span className="term-live-badge">LIVE</span>}
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-[var(--text-faint)]">{lines.length} lines</span>
|
||||
</div>
|
||||
|
||||
<div ref={containerRef} className="agent-terminal-body">
|
||||
{lines.length === 0 && (
|
||||
<div className="term-line term-info">
|
||||
<span className="term-ts">--:--:--</span>
|
||||
<span className="term-text">Waiting for missions…</span>
|
||||
</div>
|
||||
)}
|
||||
{lines.map((line) => (
|
||||
<div key={line.id} className={`term-line ${LEVEL_CLASS[line.level] || 'term-info'}`}>
|
||||
<span className="term-ts">{line.ts ? new Date(line.ts).toLocaleTimeString() : ''}</span>
|
||||
<span className="term-phase">{line.phase}</span>
|
||||
<span className="term-text">{line.text}</span>
|
||||
</div>
|
||||
))}
|
||||
{active && (
|
||||
<div className="term-line term-cmd">
|
||||
<span className="term-ts" />
|
||||
<span className="term-text term-cursor">█</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Agent, AgentAnim, TerminalLine } from '../types'
|
||||
import { AgentTerminal } from './AgentTerminal'
|
||||
|
||||
type Props = {
|
||||
agents: Agent[]
|
||||
terminals: Record<string, TerminalLine[]>
|
||||
animations: Record<string, AgentAnim>
|
||||
selectedId: string | null
|
||||
onSelect: (id: string) => void
|
||||
layout?: 'grid' | 'focus'
|
||||
}
|
||||
|
||||
export function AgentTerminalGrid({ agents, terminals, animations, selectedId, onSelect, layout = 'grid' }: Props) {
|
||||
const focusId = selectedId || agents[0]?.id
|
||||
|
||||
if (layout === 'focus' && focusId) {
|
||||
const agent = agents.find((a) => a.id === focusId)!
|
||||
const anim = animations[focusId] || { agentId: focusId, state: 'idle' as const }
|
||||
return (
|
||||
<div className="agent-terminal-focus">
|
||||
<AgentTerminal
|
||||
agent={agent}
|
||||
lines={terminals[focusId] || []}
|
||||
active={anim.state !== 'idle'}
|
||||
expanded
|
||||
/>
|
||||
<div className="agent-terminal-tabs">
|
||||
{agents.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(a.id)}
|
||||
className={`agent-terminal-tab ${focusId === a.id ? 'active' : ''}`}
|
||||
style={focusId === a.id ? { borderColor: a.color, color: a.color } : undefined}
|
||||
>
|
||||
{a.icon} {a.name.split(' ')[0]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5 gap-3">
|
||||
{agents.map((agent) => {
|
||||
const anim = animations[agent.id] || { agentId: agent.id, state: 'idle' as const }
|
||||
return (
|
||||
<AgentTerminal
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
lines={terminals[agent.id] || []}
|
||||
active={anim.state !== 'idle'}
|
||||
onFocus={() => onSelect(agent.id)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export function AmbientBackground() {
|
||||
return (
|
||||
<div className="ambient-bg pointer-events-none fixed inset-0 z-0 overflow-hidden" aria-hidden>
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="ambient-orb"
|
||||
style={{
|
||||
left: `${(i * 17 + 5) % 95}%`,
|
||||
top: `${(i * 23 + 8) % 90}%`,
|
||||
animationDelay: `${i * 0.7}s`,
|
||||
animationDuration: `${8 + (i % 4) * 2}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import type { Agent, ChatMessage } from '../types'
|
||||
|
||||
type Props = {
|
||||
messages: ChatMessage[]
|
||||
agents: Agent[]
|
||||
selectedAgent: Agent | null
|
||||
busy: boolean
|
||||
}
|
||||
|
||||
function agentFor(agents: Agent[], id?: string) {
|
||||
return agents.find((a) => a.id === id)
|
||||
}
|
||||
|
||||
export function ChatPanel({ messages, agents, selectedAgent, busy }: Props) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages, busy])
|
||||
|
||||
return (
|
||||
<div className="panel rounded-2xl flex flex-col h-full min-h-[360px] overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-[var(--border)] flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="section-eyebrow">Mission control</p>
|
||||
<h3 className="font-bold text-base" style={{ color: 'var(--accent)' }}>Agent Comms</h3>
|
||||
</div>
|
||||
{selectedAgent && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl border text-xs font-mono" style={{ borderColor: `${selectedAgent.color}44`, color: selectedAgent.color }}>
|
||||
<span>{selectedAgent.icon}</span>
|
||||
<span>→ {selectedAgent.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{messages.length === 0 && (
|
||||
<div className="empty-state h-full flex flex-col items-center justify-center">
|
||||
<span className="text-3xl mb-3">💬</span>
|
||||
<p className="text-sm text-[var(--text-muted)] text-center max-w-xs">
|
||||
{selectedAgent
|
||||
? `Stuur een opdracht naar ${selectedAgent.name}. Kies een suggestie hieronder of typ je eigen vraag.`
|
||||
: 'Selecteer een agent of stel een vraag — routing kiest automatisch de specialist.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((m, i) => {
|
||||
const agent = m.role === 'agent' ? agentFor(agents, m.agent) : null
|
||||
return (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`chat-message ${m.role}`}
|
||||
>
|
||||
{m.role === 'agent' && agent && (
|
||||
<div className="chat-avatar" style={{ background: `color-mix(in srgb, ${agent.color} 20%, transparent)`, borderColor: agent.color }}>
|
||||
{agent.icon}
|
||||
</div>
|
||||
)}
|
||||
<div className={`chat-bubble ${m.role === 'user' ? 'chat-bubble-user' : 'chat-bubble-agent'}`}>
|
||||
<div className="chat-meta">
|
||||
{m.role === 'user' ? 'You' : agent?.name || m.agent}
|
||||
{m.ts && <span>{new Date(m.ts).toLocaleTimeString()}</span>}
|
||||
</div>
|
||||
<div className="chat-text whitespace-pre-wrap">{m.text}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
|
||||
{busy && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="chat-message agent">
|
||||
<div className="chat-avatar thinking-pulse" style={{ background: 'var(--surface-elevated)' }}>⋯</div>
|
||||
<div className="chat-bubble chat-bubble-agent">
|
||||
<div className="typing-indicator">
|
||||
<span /><span /><span />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { appIcon, shortName } from '../lib/appIcons'
|
||||
import type { WorkloadZone } from '../types'
|
||||
|
||||
const DESK_X = 50
|
||||
|
||||
type Props = {
|
||||
zones: WorkloadZone[]
|
||||
activeZoneId?: string | null
|
||||
}
|
||||
|
||||
export function DataFlowLayer({ zones, activeZoneId }: Props) {
|
||||
return (
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none cluster-flow-svg" preserveAspectRatio="none">
|
||||
<defs>
|
||||
{zones.map((z) => (
|
||||
<linearGradient key={`grad-${z.id}`} id={`flow-grad-${z.id}`} x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor={z.color} stopOpacity="0.05" />
|
||||
<stop offset="50%" stopColor={z.color} stopOpacity="0.6" />
|
||||
<stop offset="100%" stopColor={z.color} stopOpacity="0.05" />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
{zones.map((z, i) => (
|
||||
<g key={z.id}>
|
||||
<motion.line
|
||||
x1={`${DESK_X}%`} y1="92%" x2={`${z.x}%`} y2="22%"
|
||||
stroke={`url(#flow-grad-${z.id})`}
|
||||
strokeWidth={activeZoneId === z.id ? 3 : 1.5}
|
||||
strokeDasharray="6 5"
|
||||
animate={{ strokeDashoffset: [0, -22] }}
|
||||
transition={{ repeat: Infinity, duration: 1.8 + i * 0.15, ease: 'linear' }}
|
||||
/>
|
||||
{[0, 1, 2].map((p) => (
|
||||
<motion.circle
|
||||
key={p}
|
||||
r={activeZoneId === z.id ? 4 : 3}
|
||||
fill={z.color}
|
||||
filter={`drop-shadow(0 0 4px ${z.color})`}
|
||||
animate={{
|
||||
cx: [`${DESK_X}%`, `${z.x}%`],
|
||||
cy: ['92%', '22%'],
|
||||
opacity: [0, 1, 1, 0],
|
||||
}}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
duration: 2.2 + i * 0.2 + p * 0.4,
|
||||
delay: p * 0.7 + i * 0.1,
|
||||
ease: 'linear',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
type ZoneProps = {
|
||||
zone: WorkloadZone
|
||||
active: boolean
|
||||
agentBusy: boolean
|
||||
}
|
||||
|
||||
export function ZoneTower({ zone, active, agentBusy }: ZoneProps) {
|
||||
const pulse = zone.level === 'ok'
|
||||
const apps = zone.apps.slice(0, 5)
|
||||
|
||||
return (
|
||||
<div className="absolute top-0 -translate-x-1/2 flex flex-col items-center gap-1" style={{ left: `${zone.x}%` }}>
|
||||
<motion.div
|
||||
className={`zone-tower ${pulse ? 'zone-tower-live' : ''} ${active ? 'zone-tower-active' : ''}`}
|
||||
style={{
|
||||
borderColor: zone.color,
|
||||
boxShadow: `0 0 ${active || agentBusy ? 28 : 14}px ${zone.color}${active ? '66' : '33'}`,
|
||||
color: zone.color,
|
||||
}}
|
||||
animate={agentBusy ? { scale: [1, 1.04, 1] } : { scale: 1 }}
|
||||
transition={{ repeat: Infinity, duration: 0.8 }}
|
||||
>
|
||||
<div className="zone-tower-label">{zone.label}</div>
|
||||
<div className="zone-tower-stats">
|
||||
<span className={`zone-level-dot level-${zone.level}`} />
|
||||
{zone.running}/{zone.total || zone.apps.length}
|
||||
</div>
|
||||
{zone.id === 'hadoop' && zone.hdfs_total_gb != null && (
|
||||
<div className="zone-tower-extra">{zone.hdfs_used_gb ?? 0}/{zone.hdfs_total_gb} GB</div>
|
||||
)}
|
||||
{zone.id === 'lakehouse' && (
|
||||
<div className="zone-tower-extra">{zone.trino_ok ? 'Trino ●' : 'Trino ○'}</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<div className="zone-app-orbit">
|
||||
{apps.map((app, i) => (
|
||||
<motion.div
|
||||
key={app.name}
|
||||
className={`zone-app-chip state-${app.state}`}
|
||||
style={{ borderColor: `${zone.color}55` }}
|
||||
animate={{ y: [0, -3, 0], opacity: app.state === 'running' ? [0.85, 1, 0.85] : 0.45 }}
|
||||
transition={{ repeat: Infinity, duration: 2 + i * 0.3, delay: i * 0.15 }}
|
||||
title={`${app.name} (${app.state})`}
|
||||
>
|
||||
<span>{appIcon(app.name, app.image)}</span>
|
||||
<span>{shortName(app.name, 10)}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function GpuBeacon({ model, util, count, level, active }: {
|
||||
model?: string | null
|
||||
util?: number
|
||||
count?: number
|
||||
level: string
|
||||
active?: boolean
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
className={`gpu-beacon level-${level} ${active ? 'gpu-beacon-active' : ''}`}
|
||||
animate={{ boxShadow: active ? ['0 0 20px #76b90044', '0 0 36px #76b90088', '0 0 20px #76b90044'] : undefined }}
|
||||
transition={{ repeat: Infinity, duration: 2 }}
|
||||
>
|
||||
<div className="gpu-beacon-title">⚡ GPU LAB</div>
|
||||
<div className="gpu-beacon-model">{shortName(model || 'offline', 18)}</div>
|
||||
<div className="gpu-beacon-meta">{count ?? 0}× V100 · {Math.round(util ?? 0)}%</div>
|
||||
<div className="gpu-beacon-bar">
|
||||
<motion.div
|
||||
className="gpu-beacon-fill"
|
||||
animate={{ width: `${Math.max(util ?? 0, 4)}%` }}
|
||||
transition={{ duration: 0.8 }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkloadTicker({ zones }: { zones: WorkloadZone[] }) {
|
||||
const allApps = zones.flatMap((z) =>
|
||||
z.apps.map((a) => ({ ...a, zoneColor: z.color, zoneId: z.id })),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="workload-ticker-wrap">
|
||||
<div className="workload-ticker-label">LIVE WORKLOAD</div>
|
||||
<div className="workload-ticker-track">
|
||||
<motion.div
|
||||
className="workload-ticker-inner"
|
||||
animate={{ x: ['0%', '-50%'] }}
|
||||
transition={{ repeat: Infinity, duration: 40, ease: 'linear' }}
|
||||
>
|
||||
{[...allApps, ...allApps].map((app, i) => (
|
||||
<span
|
||||
key={`${app.name}-${i}`}
|
||||
className={`ticker-chip state-${app.state}`}
|
||||
style={{ borderColor: `${app.zoneColor}44`, color: app.zoneColor }}
|
||||
>
|
||||
{appIcon(app.name, app.image)} {app.name}
|
||||
</span>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import type { Agent } from '../types'
|
||||
|
||||
type Props = {
|
||||
onSubmit: (message: string, agentId?: string) => void
|
||||
busy: boolean
|
||||
selectedAgent: Agent | null
|
||||
}
|
||||
|
||||
export function CommandDock({ onSubmit, busy, selectedAgent }: Props) {
|
||||
const [text, setText] = useState('')
|
||||
const prompts = selectedAgent?.suggested_prompts || [
|
||||
'Lab health overview?',
|
||||
'Hoe staat de GPU?',
|
||||
'Database status?',
|
||||
]
|
||||
|
||||
const handle = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!text.trim() || busy) return
|
||||
onSubmit(text.trim(), selectedAgent?.id)
|
||||
setText('')
|
||||
}
|
||||
|
||||
const sendQuick = (prompt: string) => {
|
||||
if (busy) return
|
||||
onSubmit(prompt, selectedAgent?.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="command-dock panel rounded-2xl p-4 md:p-5">
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{prompts.map((p) => (
|
||||
<motion.button
|
||||
key={p}
|
||||
type="button"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
disabled={busy}
|
||||
onClick={() => sendQuick(p)}
|
||||
className="quick-prompt-chip"
|
||||
style={selectedAgent ? { borderColor: `${selectedAgent.color}55`, color: selectedAgent.color } : undefined}
|
||||
>
|
||||
{p}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handle} className="flex gap-3 items-center">
|
||||
<div className="command-input-wrap flex-1 flex items-center gap-3 rounded-xl px-4 py-1">
|
||||
{selectedAgent && (
|
||||
<span className="text-lg shrink-0" title={selectedAgent.name}>{selectedAgent.icon}</span>
|
||||
)}
|
||||
<input
|
||||
className="prompt-input border-0 bg-transparent shadow-none focus:shadow-none flex-1 py-2.5"
|
||||
placeholder={selectedAgent ? `Opdracht voor ${selectedAgent.name}...` : 'Vraag je agents... routing kiest de specialist'}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={busy || !text.trim()} className="btn-primary shrink-0 px-6">
|
||||
{busy ? 'Dispatching...' : 'Dispatch'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { GpuStatus } from '../types'
|
||||
|
||||
type Props = {
|
||||
gpu: GpuStatus | null
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
function memPct(used: number, total: number) {
|
||||
if (!total) return 0
|
||||
return Math.round((used / total) * 100)
|
||||
}
|
||||
|
||||
export function GpuPanel({ gpu, compact }: Props) {
|
||||
if (!gpu) {
|
||||
return (
|
||||
<div className="panel rounded-2xl p-5 animate-pulse">
|
||||
<div className="h-4 w-32 rounded bg-[var(--surface-elevated)] mb-4" />
|
||||
<div className="h-24 rounded-xl bg-[var(--surface-elevated)]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const online = gpu.ok
|
||||
const gpus = gpu.gpus || []
|
||||
const avgUtil = gpus.length ? gpus.reduce((s, g) => s + g.util_gpu, 0) / gpus.length : 0
|
||||
const avgVram = gpus.length ? gpus.reduce((s, g) => s + memPct(g.memory_used_mib, g.memory_total_mib), 0) / gpus.length : 0
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="panel rounded-2xl p-4 relative overflow-hidden">
|
||||
<div className="absolute -top-8 -right-8 w-24 h-24 rounded-full bg-[var(--accent-gpu)] opacity-[0.1] blur-2xl pointer-events-none" />
|
||||
<div className="flex items-center justify-between gap-2 mb-3 relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>⚡</span>
|
||||
<h2 className="font-bold text-sm text-[var(--accent-gpu)]">GPU Lab</h2>
|
||||
</div>
|
||||
<a href={gpu.ui_url} target="_blank" rel="noopener noreferrer" className="text-[10px] font-mono text-[var(--accent-gpu)] hover:underline">
|
||||
Open ↗
|
||||
</a>
|
||||
</div>
|
||||
{gpu.active_model && (
|
||||
<div className="text-sm font-semibold text-[var(--text)] truncate">{gpu.active_model}</div>
|
||||
)}
|
||||
<div className="flex gap-3 mt-2 text-[10px] font-mono text-[var(--text-muted)]">
|
||||
<span>{gpu.gpu_count ?? gpus.length}× V100</span>
|
||||
<span>{Math.round(avgUtil)}% util</span>
|
||||
<span>{Math.round(avgVram)}% VRAM</span>
|
||||
<span className={online && gpu.inference_active ? 'text-[var(--status-ok)]' : 'text-[var(--status-warn)]'}>
|
||||
{online ? (gpu.inference_active ? 'ON' : 'STBY') : 'OFF'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-[var(--surface-muted)] mt-3 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.max(avgUtil, avgVram * 0.5)}%`,
|
||||
background: 'linear-gradient(90deg, var(--accent-gpu), var(--accent))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel rounded-2xl p-5 relative overflow-hidden">
|
||||
<div className="absolute -top-12 -right-12 w-40 h-40 rounded-full bg-[var(--accent-gpu)] opacity-[0.08] blur-2xl pointer-events-none" />
|
||||
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 mb-4 relative">
|
||||
<div>
|
||||
<p className="section-eyebrow">Inference cluster</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg" aria-hidden>⚡</span>
|
||||
<h2 className="font-display font-bold text-[var(--accent-gpu)] tracking-tight">GPU Lab</h2>
|
||||
<span
|
||||
className={`text-[10px] font-mono px-2 py-0.5 rounded-full border ${
|
||||
online && gpu.inference_active
|
||||
? 'border-[var(--status-ok)] text-[var(--status-ok)] bg-[var(--status-ok-bg)]'
|
||||
: 'border-[var(--status-warn)] text-[var(--status-warn)] bg-[var(--status-warn-bg)]'
|
||||
}`}
|
||||
>
|
||||
{online ? (gpu.inference_active ? 'INFERENCE ON' : 'STANDBY') : 'OFFLINE'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs font-mono text-[var(--text-muted)] mt-1">
|
||||
atc-gpu-dev · {gpu.host} · {gpu.gpu_count ?? gpus.length}× V100
|
||||
</p>
|
||||
</div>
|
||||
<a href={gpu.ui_url} target="_blank" rel="noopener noreferrer" className="btn-secondary text-xs shrink-0">
|
||||
Open GPU Manager ↗
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{gpu.active_model && (
|
||||
<div className="rounded-xl border border-[var(--border)] bg-[var(--surface-elevated)] px-4 py-3 mb-4">
|
||||
<div className="text-[10px] font-mono uppercase tracking-widest text-[var(--text-faint)]">Active model</div>
|
||||
<div className="font-semibold text-[var(--text)] mt-0.5">{gpu.active_model}</div>
|
||||
{gpu.vllm_url && (
|
||||
<div className="text-[11px] font-mono text-[var(--text-muted)] mt-1 truncate">{gpu.vllm_url}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!online && (
|
||||
<p className="text-sm text-[var(--status-down)]">{gpu.error || 'GPU manager unreachable'}</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{gpus.map((g) => {
|
||||
const pct = memPct(g.memory_used_mib, g.memory_total_mib)
|
||||
return (
|
||||
<div key={g.index} className="gpu-card rounded-xl p-3 border border-[var(--border)]">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-xs font-mono font-semibold text-[var(--accent-gpu)]">GPU {g.index}</span>
|
||||
<span className="text-[10px] font-mono text-[var(--text-faint)]">{g.temperature_c}°C · {g.power_w}W</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)] truncate mb-2">{g.name}</div>
|
||||
<div className="flex gap-3 text-[10px] font-mono mb-1.5">
|
||||
<span>Util {Math.round(g.util_gpu)}%</span>
|
||||
<span>VRAM {pct}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-[var(--surface-muted)] overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.max(g.util_gpu, pct * 0.3)}%`,
|
||||
background: 'linear-gradient(90deg, var(--accent-gpu), var(--accent))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { AgentSprite } from './AgentSprite'
|
||||
import { DataFlowLayer, GpuBeacon, WorkloadTicker, ZoneTower } from './ClusterViz'
|
||||
import type { Agent, AgentAnim, WorkloadData } from '../types'
|
||||
|
||||
const ZONE_X: Record<string, number> = {
|
||||
docker: 8,
|
||||
db: 28,
|
||||
lakehouse: 50,
|
||||
hadoop: 72,
|
||||
etl: 92,
|
||||
}
|
||||
|
||||
const STATE_LABEL: Record<AgentAnim['state'], string> = {
|
||||
idle: '',
|
||||
walk: '→ zone',
|
||||
fetch: '⟳ fetch',
|
||||
return: '← desk',
|
||||
}
|
||||
|
||||
const DESK_X = 50
|
||||
|
||||
type Props = {
|
||||
agents: Agent[]
|
||||
workload: WorkloadData | null
|
||||
animations: Record<string, AgentAnim>
|
||||
selectedId: string | null
|
||||
}
|
||||
|
||||
export function LiveClusterMap({ agents, workload, animations, selectedId }: Props) {
|
||||
const zones = workload?.zones || []
|
||||
const activeCount = agents.filter((a) => (animations[a.id]?.state || 'idle') !== 'idle').length
|
||||
const busyAgent = agents.find((a) => (animations[a.id]?.state || 'idle') !== 'idle')
|
||||
const mappedBusyZone = busyAgent?.zone ?? null
|
||||
|
||||
return (
|
||||
<div className="panel rounded-2xl p-5 relative overflow-hidden min-h-[480px] live-cluster-map">
|
||||
<div className="cluster-ambient" />
|
||||
<div className="ops-floor-scan" />
|
||||
|
||||
<div className="flex justify-between items-start mb-4 relative z-10 gap-3 flex-wrap">
|
||||
<div>
|
||||
<p className="section-eyebrow">Live simulation</p>
|
||||
<h2 className="font-display text-lg font-bold tracking-wide neon-text" style={{ color: 'var(--accent)' }}>
|
||||
Cluster Ops Floor
|
||||
</h2>
|
||||
{workload && (
|
||||
<p className="text-[10px] font-mono text-[var(--text-muted)] mt-1">
|
||||
{workload.totals.apps_running} workloads active · {workload.totals.connectors} connectors · GPU {workload.gpu.avg_util ?? 0}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{activeCount > 0 && (
|
||||
<span className="text-[10px] font-mono px-2 py-1 rounded-full border border-[var(--accent)] text-[var(--accent)] bg-[color-mix(in_srgb,var(--accent)_10%,transparent)] animate-pulse">
|
||||
{activeCount} agent{activeCount > 1 ? 's' : ''} deployed
|
||||
</span>
|
||||
)}
|
||||
<span className="live-badge">● LIVE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{workload && (
|
||||
<div className="absolute top-4 right-4 z-20 hidden lg:block">
|
||||
<GpuBeacon
|
||||
model={workload.gpu.model}
|
||||
util={workload.gpu.avg_util}
|
||||
count={workload.gpu.gpu_count}
|
||||
level={workload.gpu.level}
|
||||
active={workload.gpu.inference_active}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative h-52 mb-2 z-10">
|
||||
<DataFlowLayer zones={zones} activeZoneId={mappedBusyZone} />
|
||||
{zones.map((z) => (
|
||||
<ZoneTower
|
||||
key={z.id}
|
||||
zone={z}
|
||||
active={mappedBusyZone === z.id}
|
||||
agentBusy={mappedBusyZone === z.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
<motion.div
|
||||
className="absolute bottom-0 left-1/2 -translate-x-1/2 command-desk"
|
||||
animate={{ boxShadow: ['0 0 24px var(--glow)', '0 0 40px var(--glow)', '0 0 24px var(--glow)'] }}
|
||||
transition={{ repeat: Infinity, duration: 3 }}
|
||||
>
|
||||
<span className="command-desk-ring" />
|
||||
COMMAND DESK
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{workload && zones.length > 0 && (
|
||||
<div className="relative z-10 mb-3">
|
||||
<WorkloadTicker zones={zones} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative h-44 rounded-2xl border ops-floor-stage z-10 cluster-agent-stage">
|
||||
<div className="cluster-stage-grid" />
|
||||
{agents.map((agent, i) => {
|
||||
const anim = animations[agent.id] || { agentId: agent.id, state: 'idle' as const }
|
||||
const targetX = anim.state === 'idle' ? 10 + i * 18 : ZONE_X[anim.zone || agent.zone] ?? DESK_X
|
||||
const y = anim.state === 'fetch' ? 10 : anim.state === 'idle' ? 0 : 6
|
||||
const selected = selectedId === agent.id
|
||||
const busy = anim.state !== 'idle'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={agent.id}
|
||||
className="absolute bottom-3 -translate-x-1/2"
|
||||
animate={{ left: `${targetX}%`, y, scale: selected ? 1.08 : 1 }}
|
||||
transition={{ type: 'spring', stiffness: 90, damping: 15 }}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{busy && STATE_LABEL[anim.state] && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute -top-6 left-1/2 -translate-x-1/2 text-[9px] font-mono font-bold whitespace-nowrap px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
color: agent.color,
|
||||
background: `color-mix(in srgb, ${agent.color} 15%, var(--surface-strong))`,
|
||||
border: `1px solid ${agent.color}44`,
|
||||
}}
|
||||
>
|
||||
{STATE_LABEL[anim.state]}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div
|
||||
className={`sprite-wrap ${selected ? 'selected' : ''} ${busy ? 'busy' : ''}`}
|
||||
style={{ '--sprite-color': agent.color } as CSSProperties}
|
||||
>
|
||||
<AgentSprite
|
||||
agentId={agent.id}
|
||||
color={agent.color}
|
||||
state={anim.state}
|
||||
label={agent.name.split(' ')[0]}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { appIcon, LEVEL_COLOR } from '../lib/appIcons'
|
||||
import type { WorkloadData } from '../types'
|
||||
|
||||
type Props = {
|
||||
workload: WorkloadData | null
|
||||
}
|
||||
|
||||
export function LiveDomainGrid({ workload }: Props) {
|
||||
if (!workload) {
|
||||
return <div className="live-domain-grid animate-pulse h-48 rounded-xl bg-[var(--surface-elevated)]" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{workload.zones.map((zone, zi) => {
|
||||
const pct = zone.total ? Math.round((zone.running / zone.total) * 100) : 100
|
||||
return (
|
||||
<motion.div
|
||||
key={zone.id}
|
||||
className={`live-domain-card level-${zone.level}`}
|
||||
style={{ borderColor: LEVEL_COLOR[zone.level] || zone.color }}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: zi * 0.05 }}
|
||||
whileHover={{ y: -3, boxShadow: `0 8px 32px ${zone.color}22` }}
|
||||
>
|
||||
<div className="flex justify-between items-start gap-2 mb-2">
|
||||
<div>
|
||||
<div className="text-[10px] font-mono uppercase tracking-widest" style={{ color: zone.color }}>
|
||||
{zone.label}
|
||||
</div>
|
||||
<div className="text-lg font-bold mt-0.5" style={{ color: LEVEL_COLOR[zone.level] }}>
|
||||
{zone.running}/{zone.total || zone.apps.length}
|
||||
</div>
|
||||
</div>
|
||||
<motion.span
|
||||
className={`domain-pulse-dot level-${zone.level}`}
|
||||
animate={{ scale: [1, 1.3, 1], opacity: [0.7, 1, 0.7] }}
|
||||
transition={{ repeat: Infinity, duration: 2 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="domain-progress-bar">
|
||||
<motion.div
|
||||
className="domain-progress-fill"
|
||||
style={{ background: `linear-gradient(90deg, ${zone.color}, ${zone.color}88)` }}
|
||||
animate={{ width: `${pct}%` }}
|
||||
transition={{ duration: 0.6 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="domain-app-grid mt-3">
|
||||
{zone.apps.slice(0, 6).map((app, ai) => (
|
||||
<motion.div
|
||||
key={app.name}
|
||||
className={`domain-app-tile state-${app.state}`}
|
||||
title={app.name}
|
||||
animate={app.state === 'running' ? { opacity: [0.7, 1, 0.7] } : { opacity: 0.4 }}
|
||||
transition={{ repeat: Infinity, duration: 2.5, delay: ai * 0.1 }}
|
||||
>
|
||||
<span>{appIcon(app.name, app.image)}</span>
|
||||
<span className="truncate">{app.name.split('_')[0]}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
|
||||
<motion.div
|
||||
className={`live-domain-card level-${workload.gpu.level}`}
|
||||
style={{ borderColor: 'var(--accent-gpu)' }}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<div className="text-[10px] font-mono uppercase tracking-widest text-[var(--accent-gpu)]">GPU LAB</div>
|
||||
<div className="text-lg font-bold mt-0.5 text-[var(--accent-gpu)]">
|
||||
{workload.gpu.gpu_count ?? 0}× V100
|
||||
</div>
|
||||
<div className="text-xs font-mono text-[var(--text-muted)] mt-1 truncate">
|
||||
{workload.gpu.model || 'offline'}
|
||||
</div>
|
||||
<div className="domain-progress-bar mt-3">
|
||||
<motion.div
|
||||
className="domain-progress-fill"
|
||||
style={{ background: 'linear-gradient(90deg, var(--accent-gpu), var(--accent))' }}
|
||||
animate={{ width: `${Math.max(workload.gpu.avg_util ?? 0, 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 mt-3 flex-wrap">
|
||||
{(workload.gpu.gpus || []).map((g) => (
|
||||
<motion.div
|
||||
key={g.index}
|
||||
className="gpu-mini-tile"
|
||||
animate={{ opacity: [0.6, 1, 0.6] }}
|
||||
transition={{ repeat: Infinity, duration: 1.5 + g.index * 0.2 }}
|
||||
title={`GPU${g.index} ${g.util_gpu}%`}
|
||||
>
|
||||
G{g.index}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import type { CSSProperties } from 'react'
|
||||
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 STATE_LABEL: Record<AgentAnim['state'], string> = {
|
||||
idle: '',
|
||||
walk: '→ zone',
|
||||
fetch: '⟳ fetch',
|
||||
return: '← desk',
|
||||
}
|
||||
|
||||
const DESK_X = 50
|
||||
|
||||
type Props = {
|
||||
agents: Agent[]
|
||||
zones: Zone[]
|
||||
animations: Record<string, AgentAnim>
|
||||
selectedId: string | null
|
||||
}
|
||||
|
||||
export function OpsFloor({ agents, zones, animations, selectedId }: Props) {
|
||||
const activeCount = agents.filter((a) => (animations[a.id]?.state || 'idle') !== 'idle').length
|
||||
|
||||
return (
|
||||
<div className="panel rounded-2xl p-5 relative overflow-hidden min-h-[340px] ops-floor">
|
||||
<div className="ops-floor-scan" />
|
||||
<div className="flex justify-between items-center mb-5 relative z-10">
|
||||
<div>
|
||||
<p className="section-eyebrow">Live simulation</p>
|
||||
<h2 className="font-display text-lg font-bold tracking-wide neon-text" style={{ color: 'var(--accent)' }}>
|
||||
Ops Floor
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{activeCount > 0 && (
|
||||
<span className="text-[10px] font-mono px-2 py-1 rounded-full border border-[var(--accent)] text-[var(--accent)] bg-[color-mix(in_srgb,var(--accent)_10%,transparent)]">
|
||||
{activeCount} agent{activeCount > 1 ? 's' : ''} deployed
|
||||
</span>
|
||||
)}
|
||||
<span className="live-badge">● LIVE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-32 mb-4 z-10">
|
||||
{zones.map((z) => (
|
||||
<div key={z.id} className="absolute top-0 -translate-x-1/2 text-center" style={{ left: `${z.x}%` }}>
|
||||
<motion.div
|
||||
className="zone-node rounded-xl px-3 py-2.5 min-w-[88px] text-[9px] font-mono tracking-wider font-semibold"
|
||||
style={{
|
||||
border: `2px solid ${z.color}`,
|
||||
boxShadow: `0 0 20px ${z.color}33`,
|
||||
color: z.color,
|
||||
}}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
{z.label}
|
||||
</motion.div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
className="absolute bottom-0 left-1/2 -translate-x-1/2 zone-node rounded-xl px-4 py-2 text-[9px] font-mono font-bold tracking-widest"
|
||||
style={{ borderColor: 'var(--accent)', color: 'var(--accent)', boxShadow: '0 0 24px var(--glow)' }}
|
||||
>
|
||||
COMMAND DESK
|
||||
</div>
|
||||
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none" preserveAspectRatio="none">
|
||||
{zones.map((z) => (
|
||||
<motion.line
|
||||
key={`path-${z.id}`}
|
||||
x1={`${DESK_X}%`} y1="88%" x2={`${z.x}%`} y2="30%"
|
||||
stroke={z.color} strokeWidth="1.5" strokeDasharray="5 4" opacity="0.35"
|
||||
animate={{ strokeDashoffset: [0, -18] }}
|
||||
transition={{ repeat: Infinity, duration: 2, ease: 'linear' }}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="relative h-40 rounded-2xl border ops-floor-stage z-10">
|
||||
{agents.map((agent, i) => {
|
||||
const anim = animations[agent.id] || { agentId: agent.id, state: 'idle' as const }
|
||||
const targetX = anim.state === 'idle' ? 10 + i * 18 : ZONE_X[anim.zone || agent.zone] ?? DESK_X
|
||||
const y = anim.state === 'fetch' ? 10 : anim.state === 'idle' ? 0 : 6
|
||||
const selected = selectedId === agent.id
|
||||
const busy = anim.state !== 'idle'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={agent.id}
|
||||
className="absolute bottom-3 -translate-x-1/2"
|
||||
animate={{ left: `${targetX}%`, y, scale: selected ? 1.08 : 1 }}
|
||||
transition={{ type: 'spring', stiffness: 90, damping: 15 }}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{busy && STATE_LABEL[anim.state] && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute -top-6 left-1/2 -translate-x-1/2 text-[9px] font-mono font-bold whitespace-nowrap px-2 py-0.5 rounded-full"
|
||||
style={{ color: agent.color, background: `color-mix(in srgb, ${agent.color} 15%, var(--surface-strong))`, border: `1px solid ${agent.color}44` }}
|
||||
>
|
||||
{STATE_LABEL[anim.state]}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className={`sprite-wrap ${selected ? 'selected' : ''} ${busy ? 'busy' : ''}`} style={{ '--sprite-color': agent.color } as CSSProperties}>
|
||||
<AgentSprite
|
||||
agentId={agent.id}
|
||||
color={agent.color}
|
||||
icon={agent.icon}
|
||||
state={anim.state}
|
||||
label={agent.name.split(' ')[0]}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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="panel rounded-2xl p-4 flex gap-3 items-center">
|
||||
<span className="text-2xl shrink-0" aria-hidden>💬</span>
|
||||
<input
|
||||
className="prompt-input"
|
||||
placeholder="Vraag je agents... bijv. Hoe staat de GPU? Welk model draait er?"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<button type="submit" disabled={busy || !text.trim()} className="btn-primary shrink-0">
|
||||
{busy ? 'Bezig...' : 'Send'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useTheme } from '../context/ThemeContext'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, toggle } = useTheme()
|
||||
const isDark = theme === 'dark'
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="theme-toggle"
|
||||
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
title={isDark ? 'Light mode' : 'Dark mode'}
|
||||
>
|
||||
<span className={`theme-toggle-track ${isDark ? 'is-dark' : ''}`}>
|
||||
<span className="theme-toggle-thumb">{isDark ? '🌙' : '☀️'}</span>
|
||||
</span>
|
||||
<span className="text-xs font-mono hidden sm:inline text-[var(--text-muted)]">
|
||||
{isDark ? 'Dark' : 'Light'}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
|
||||
export type Theme = 'light' | 'dark'
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: Theme
|
||||
toggle: () => void
|
||||
setTheme: (t: Theme) => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
const STORAGE_KEY = 'atc-command-center-theme'
|
||||
|
||||
function readStored(): Theme {
|
||||
const v = localStorage.getItem(STORAGE_KEY)
|
||||
return v === 'dark' || v === 'light' ? v : 'light'
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
if (typeof window === 'undefined') return 'light'
|
||||
return readStored()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
root.classList.remove('light', 'dark')
|
||||
root.classList.add(theme)
|
||||
localStorage.setItem(STORAGE_KEY, theme)
|
||||
}, [theme])
|
||||
|
||||
const setTheme = (t: Theme) => setThemeState(t)
|
||||
const toggle = () => setThemeState((t) => (t === 'light' ? 'dark' : 'light'))
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggle, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext)
|
||||
if (!ctx) throw new Error('useTheme outside ThemeProvider')
|
||||
return ctx
|
||||
}
|
||||
+629
@@ -0,0 +1,629 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root,
|
||||
.light {
|
||||
--bg-1: #e8f4fc;
|
||||
--bg-2: #f4f7fb;
|
||||
--bg-3: #f5f0ff;
|
||||
--grid-color: rgba(8, 145, 178, 0.06);
|
||||
--surface: rgba(255, 255, 255, 0.72);
|
||||
--surface-strong: rgba(255, 255, 255, 0.94);
|
||||
--surface-elevated: #f1f5f9;
|
||||
--surface-muted: #e2e8f0;
|
||||
--text: #0f172a;
|
||||
--text-muted: #64748b;
|
||||
--text-faint: #94a3b8;
|
||||
--border: rgba(15, 23, 42, 0.09);
|
||||
--accent: #0891b2;
|
||||
--accent-gpu: #65a30d;
|
||||
--accent-secondary: #7c3aed;
|
||||
--glow: rgba(8, 145, 178, 0.22);
|
||||
--status-ok: #16a34a;
|
||||
--status-ok-bg: rgba(22, 163, 74, 0.1);
|
||||
--status-warn: #d97706;
|
||||
--status-warn-bg: rgba(217, 119, 6, 0.1);
|
||||
--status-down: #dc2626;
|
||||
--status-down-bg: rgba(220, 38, 38, 0.08);
|
||||
--shadow: 0 12px 40px rgba(15, 40, 80, 0.09);
|
||||
--floor-bg: linear-gradient(180deg, #f1f5f9 0%, #ffffff 100%);
|
||||
--aurora-1: rgba(8, 145, 178, 0.12);
|
||||
--aurora-2: rgba(124, 58, 237, 0.08);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--bg-1: #050810;
|
||||
--bg-2: #0a0f1a;
|
||||
--bg-3: #100818;
|
||||
--grid-color: rgba(34, 211, 238, 0.05);
|
||||
--surface: rgba(12, 18, 32, 0.78);
|
||||
--surface-strong: rgba(16, 22, 38, 0.94);
|
||||
--surface-elevated: #1a2236;
|
||||
--surface-muted: #243049;
|
||||
--text: #eef2f9;
|
||||
--text-muted: #94a3b8;
|
||||
--text-faint: #64748b;
|
||||
--border: rgba(34, 211, 238, 0.14);
|
||||
--accent: #22d3ee;
|
||||
--accent-gpu: #a3e635;
|
||||
--accent-secondary: #c084fc;
|
||||
--glow: rgba(34, 211, 238, 0.28);
|
||||
--status-ok: #4ade80;
|
||||
--status-ok-bg: rgba(74, 222, 128, 0.12);
|
||||
--status-warn: #fbbf24;
|
||||
--status-warn-bg: rgba(251, 191, 36, 0.12);
|
||||
--status-down: #f87171;
|
||||
--status-down-bg: rgba(248, 113, 113, 0.12);
|
||||
--shadow: 0 12px 48px rgba(0, 0, 0, 0.5);
|
||||
--floor-bg: linear-gradient(180deg, #121a2e 0%, #0a0e18 100%);
|
||||
--aurora-1: rgba(34, 211, 238, 0.15);
|
||||
--aurora-2: rgba(192, 132, 252, 0.1);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--text);
|
||||
background: linear-gradient(145deg, var(--bg-1) 0%, var(--bg-2) 45%, var(--bg-3) 100%);
|
||||
background-attachment: fixed;
|
||||
transition: background 0.4s ease, color 0.4s ease;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
radial-gradient(ellipse 80% 50% at 20% 0%, var(--aurora-1), transparent 50%),
|
||||
radial-gradient(ellipse 60% 40% at 80% 10%, var(--aurora-2), transparent 45%),
|
||||
linear-gradient(var(--grid-color) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-color) 1px, transparent 1px);
|
||||
background-size: 100% 100%, 100% 100%, 40px 40px, 40px 40px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
#root { position: relative; z-index: 1; }
|
||||
|
||||
.section-eyebrow {
|
||||
@apply text-[10px] font-mono uppercase tracking-[0.2em] text-[var(--text-faint)] mb-0.5;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--surface-strong);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
transition: background 0.35s ease, border-color 0.35s ease, box-shadow 0.35s ease;
|
||||
}
|
||||
|
||||
.neon-text { text-shadow: 0 0 32px var(--glow); }
|
||||
|
||||
.logo-mark {
|
||||
@apply w-12 h-12 rounded-2xl flex items-center justify-center text-sm font-bold text-white shrink-0;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
|
||||
box-shadow: 0 4px 28px var(--glow);
|
||||
}
|
||||
|
||||
.header-aurora {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent, var(--aurora-1), transparent);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
@apply text-xs font-mono px-3 py-1.5 rounded-full border inline-flex items-center gap-1.5;
|
||||
}
|
||||
.status-pill::before { content: '●'; font-size: 8px; }
|
||||
.status-pill.ok { color: var(--status-ok); border-color: var(--status-ok); background: var(--status-ok-bg); }
|
||||
.status-pill.warn { color: var(--status-warn); border-color: var(--status-warn); background: var(--status-warn-bg); }
|
||||
.status-pill.gpu { color: var(--accent-gpu); border-color: var(--accent-gpu); background: color-mix(in srgb, var(--accent-gpu) 10%, transparent); }
|
||||
|
||||
.live-badge {
|
||||
@apply text-xs font-mono px-2.5 py-1 rounded-full border animate-pulse;
|
||||
color: var(--status-ok);
|
||||
border-color: var(--status-ok);
|
||||
background: var(--status-ok-bg);
|
||||
}
|
||||
|
||||
/* Agent cards */
|
||||
.agent-roster-glow {
|
||||
background: radial-gradient(ellipse at 50% 0%, var(--aurora-1), transparent 70%);
|
||||
}
|
||||
|
||||
.agent-card {
|
||||
@apply rounded-2xl p-[1px] transition-all duration-300 cursor-pointer;
|
||||
background: var(--border);
|
||||
}
|
||||
.agent-card:hover,
|
||||
.agent-card.selected {
|
||||
background: linear-gradient(135deg, var(--agent-color, var(--accent)), var(--accent-secondary));
|
||||
box-shadow: 0 8px 32px color-mix(in srgb, var(--agent-color, var(--accent)) 25%, transparent);
|
||||
}
|
||||
.agent-card-inner {
|
||||
background: var(--surface-strong);
|
||||
min-height: 200px;
|
||||
}
|
||||
.agent-card.selected .agent-card-inner {
|
||||
background: color-mix(in srgb, var(--agent-color, var(--accent)) 4%, var(--surface-strong));
|
||||
}
|
||||
|
||||
.agent-avatar {
|
||||
@apply relative w-11 h-11 rounded-xl flex items-center justify-center border-2;
|
||||
}
|
||||
.agent-status-dot {
|
||||
@apply absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2;
|
||||
border-color: var(--surface-strong);
|
||||
}
|
||||
.agent-status-dot.active { animation: pulse-dot 1.2s ease infinite; }
|
||||
|
||||
.agent-state-pill {
|
||||
@apply text-[10px] font-mono px-2 py-0.5 rounded-full bg-[var(--surface-elevated)];
|
||||
}
|
||||
.agent-state-pill.busy { background: color-mix(in srgb, currentColor 12%, transparent); }
|
||||
|
||||
.cap-chip {
|
||||
@apply text-[9px] font-mono px-1.5 py-0.5 rounded-md;
|
||||
background: var(--surface-elevated);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.delegate-btn {
|
||||
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.delegate-btn:hover {
|
||||
background: color-mix(in srgb, currentColor 18%, transparent);
|
||||
}
|
||||
|
||||
/* Ops floor */
|
||||
.ops-floor-stage {
|
||||
background: var(--floor-bg);
|
||||
border-color: var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ops-floor-scan {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, transparent 0%, color-mix(in srgb, var(--accent) 4%, transparent) 50%, transparent 100%);
|
||||
animation: scan 4s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
.zone-node {
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
|
||||
.sprite-wrap.selected::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -10px -6px;
|
||||
border-radius: 50%;
|
||||
border: 2px dashed var(--sprite-color, var(--accent));
|
||||
opacity: 0.65;
|
||||
animation: spin-slow 10s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
.sprite-wrap { position: relative; }
|
||||
.sprite-wrap.busy {
|
||||
filter: drop-shadow(0 0 16px var(--sprite-color)) drop-shadow(0 4px 8px rgba(0,0,0,0.3));
|
||||
}
|
||||
|
||||
.sprite-figure {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.sprite-figure-busy .sprite-svg {
|
||||
filter: drop-shadow(0 0 8px var(--sprite-color, var(--accent)));
|
||||
}
|
||||
.sprite-ring-outer {
|
||||
position: absolute;
|
||||
inset: -4px 2px 16px 2px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid;
|
||||
pointer-events: none;
|
||||
}
|
||||
.sprite-holo-shimmer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 40%;
|
||||
pointer-events: none;
|
||||
animation: holo-shimmer 3s ease-in-out infinite;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.sprite-nameplate {
|
||||
@apply flex items-center gap-1.5 mt-1.5 px-2.5 py-0.5 rounded-full border;
|
||||
background: color-mix(in srgb, var(--surface-strong) 85%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.sprite-nameplate-dot {
|
||||
@apply w-1.5 h-1.5 rounded-full shrink-0;
|
||||
}
|
||||
.sprite-nameplate-text {
|
||||
@apply text-[10px] font-mono font-bold tracking-wide;
|
||||
}
|
||||
.sprite-platform-pulse {
|
||||
animation: platform-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
.sprite-core-pulse {
|
||||
animation: pulse-dot 1.5s ease infinite;
|
||||
}
|
||||
.sprite-svg { overflow: visible; }
|
||||
|
||||
.sprite-ring {
|
||||
position: absolute;
|
||||
inset: -6px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid;
|
||||
animation: pulse-ring 1.5s ease infinite;
|
||||
}
|
||||
|
||||
/* Activity feed */
|
||||
.activity-feed { scrollbar-width: thin; }
|
||||
.activity-item { @apply flex gap-3; }
|
||||
.activity-rail { @apply flex flex-col items-center w-4 shrink-0; }
|
||||
.activity-dot { @apply w-2.5 h-2.5 rounded-full shrink-0 mt-1.5; }
|
||||
.activity-line { @apply w-px flex-1 bg-[var(--border)] min-h-[1rem]; }
|
||||
.activity-agent-badge {
|
||||
@apply text-[10px] font-mono px-2 py-0.5 rounded-full border;
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@apply text-sm text-[var(--text-muted)] text-center py-8;
|
||||
}
|
||||
|
||||
/* Chat */
|
||||
.chat-message { @apply flex gap-2 items-end; }
|
||||
.chat-message.user { @apply flex-row-reverse; }
|
||||
.chat-avatar {
|
||||
@apply w-8 h-8 rounded-xl flex items-center justify-center text-sm shrink-0 border;
|
||||
}
|
||||
.chat-bubble { @apply rounded-2xl px-3 py-2 max-w-[90%]; }
|
||||
.chat-meta {
|
||||
@apply text-[10px] font-mono text-[var(--text-faint)] flex gap-2 mb-1;
|
||||
}
|
||||
.chat-text { @apply text-sm leading-relaxed; }
|
||||
.chat-bubble-user {
|
||||
background: color-mix(in srgb, var(--accent) 14%, var(--surface-elevated));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
}
|
||||
.chat-bubble-agent {
|
||||
background: var(--surface-elevated);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.thinking-pulse { animation: pulse-dot 1s ease infinite; }
|
||||
.typing-indicator { @apply flex gap-1 py-1; }
|
||||
.typing-indicator span {
|
||||
@apply w-1.5 h-1.5 rounded-full bg-[var(--text-faint)];
|
||||
animation: typing 1.2s ease infinite;
|
||||
}
|
||||
.typing-indicator span:nth-child(2) { animation-delay: 0.15s; }
|
||||
.typing-indicator span:nth-child(3) { animation-delay: 0.3s; }
|
||||
|
||||
/* Command dock */
|
||||
.command-dock { border-color: color-mix(in srgb, var(--accent) 20%, var(--border)); }
|
||||
.command-input-wrap {
|
||||
background: var(--surface-elevated);
|
||||
border: 1px solid var(--border);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.command-input-wrap:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--glow);
|
||||
}
|
||||
|
||||
.quick-prompt-chip {
|
||||
@apply text-xs font-mono px-3 py-1.5 rounded-full border transition-all disabled:opacity-40;
|
||||
background: var(--surface-elevated);
|
||||
border-color: var(--border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.quick-prompt-chip:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 8%, var(--surface-elevated));
|
||||
}
|
||||
|
||||
/* Shared */
|
||||
.status-card {
|
||||
background: var(--surface-strong);
|
||||
border: 1px solid var(--border);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.status-card:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
|
||||
|
||||
.gpu-card { background: var(--surface-elevated); transition: border-color 0.2s ease; }
|
||||
.gpu-card:hover { border-color: var(--accent-gpu); }
|
||||
|
||||
.btn-primary {
|
||||
@apply px-5 py-2.5 rounded-xl font-display text-sm font-semibold text-white transition-all hover:brightness-110 disabled:opacity-40;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
|
||||
box-shadow: 0 4px 24px var(--glow);
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply px-3 py-1.5 rounded-lg font-mono border transition-all;
|
||||
background: var(--surface-elevated);
|
||||
border-color: var(--border);
|
||||
color: var(--accent);
|
||||
}
|
||||
.btn-secondary:hover { border-color: var(--accent); box-shadow: 0 0 16px var(--glow); }
|
||||
|
||||
.tab-btn {
|
||||
@apply px-4 py-2 rounded-xl text-sm font-mono border transition-all inline-flex items-center gap-2;
|
||||
border-color: var(--border);
|
||||
color: var(--text-muted);
|
||||
background: var(--surface);
|
||||
}
|
||||
.tab-btn.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--surface-strong);
|
||||
box-shadow: 0 0 24px var(--glow);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tab-badge {
|
||||
@apply text-[10px] px-1.5 py-0.5 rounded-full font-bold;
|
||||
background: var(--accent-secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
@apply flex items-center gap-2 px-2 py-1 rounded-xl border transition-all;
|
||||
border-color: var(--border);
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
.theme-toggle:hover { border-color: var(--accent); }
|
||||
.theme-toggle-track { @apply relative w-11 h-6 rounded-full transition-colors; background: var(--surface-muted); }
|
||||
.theme-toggle-track.is-dark { background: linear-gradient(90deg, #1e293b, #312e81); }
|
||||
.theme-toggle-thumb {
|
||||
@apply absolute top-0.5 left-0.5 w-5 h-5 rounded-full flex items-center justify-center text-xs transition-transform;
|
||||
background: var(--surface-strong);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.theme-toggle-track.is-dark .theme-toggle-thumb { transform: translateX(1.25rem); }
|
||||
|
||||
.prompt-input {
|
||||
@apply flex-1 rounded-xl px-4 py-2.5 outline-none font-mono text-sm transition;
|
||||
background: var(--surface-elevated);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.prompt-input::placeholder { color: var(--text-faint); }
|
||||
.prompt-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--glow); }
|
||||
|
||||
.agent-sprite .sprite-body { fill: var(--surface-strong); }
|
||||
.agent-sprite .sprite-limb { fill: var(--surface-elevated); }
|
||||
.agent-sprite .sprite-head { fill: var(--surface-strong); }
|
||||
|
||||
/* Agent terminals */
|
||||
.agent-terminal {
|
||||
@apply rounded-xl border overflow-hidden flex flex-col cursor-pointer transition-all;
|
||||
border-color: var(--border);
|
||||
background: #0a0e14;
|
||||
min-height: 200px;
|
||||
max-height: 240px;
|
||||
}
|
||||
.dark .agent-terminal { background: #060a10; }
|
||||
.light .agent-terminal { background: #0f172a; }
|
||||
|
||||
.agent-terminal.active {
|
||||
border-color: color-mix(in srgb, var(--term-accent, var(--accent)) 55%, transparent);
|
||||
box-shadow: 0 0 24px color-mix(in srgb, var(--term-accent, var(--accent)) 15%, transparent);
|
||||
}
|
||||
.agent-terminal.expanded {
|
||||
max-height: 480px;
|
||||
min-height: 400px;
|
||||
}
|
||||
.agent-terminal-header {
|
||||
@apply flex items-center justify-between gap-2 px-3 py-2 border-b;
|
||||
border-color: rgba(255,255,255,0.06);
|
||||
background: rgba(0,0,0,0.25);
|
||||
}
|
||||
.agent-terminal-body {
|
||||
@apply flex-1 overflow-y-auto p-2 font-mono text-[11px] leading-relaxed;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.term-line {
|
||||
@apply flex gap-2 py-0.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
.term-ts {
|
||||
@apply shrink-0 text-[10px] opacity-50 w-[4.5rem];
|
||||
color: #64748b;
|
||||
}
|
||||
.term-phase {
|
||||
@apply shrink-0 text-[9px] uppercase w-10 opacity-40 hidden sm:inline;
|
||||
}
|
||||
.term-text { flex: 1; }
|
||||
.term-info .term-text { color: #94a3b8; }
|
||||
.term-ok .term-text { color: #4ade80; }
|
||||
.term-warn .term-text { color: #fbbf24; }
|
||||
.term-err .term-text { color: #f87171; }
|
||||
.term-cmd .term-text { color: #67e8f9; }
|
||||
.term-llm .term-text { color: #c084fc; }
|
||||
.term-live-badge {
|
||||
@apply text-[9px] font-mono px-1.5 py-0.5 rounded-full animate-pulse;
|
||||
color: var(--term-accent, var(--accent));
|
||||
border: 1px solid color-mix(in srgb, var(--term-accent, var(--accent)) 40%, transparent);
|
||||
}
|
||||
.term-cursor { animation: blink 1s step-end infinite; color: var(--term-accent, var(--accent)); }
|
||||
.agent-terminal-focus { @apply flex flex-col gap-3; }
|
||||
.agent-terminal-tabs { @apply flex flex-wrap gap-2; }
|
||||
.agent-terminal-tab {
|
||||
@apply text-xs font-mono px-3 py-1.5 rounded-lg border transition-all;
|
||||
border-color: var(--border);
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
.agent-terminal-tab.active { font-weight: 600; background: var(--surface-strong); }
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.6; transform: scale(1.15); }
|
||||
}
|
||||
@keyframes pulse-ring {
|
||||
0%, 100% { opacity: 0.8; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(1.08); }
|
||||
}
|
||||
@keyframes scan {
|
||||
0%, 100% { transform: translateY(-100%); opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
100% { transform: translateY(100%); }
|
||||
}
|
||||
@keyframes spin-slow { to { transform: rotate(360deg); } }
|
||||
@keyframes holo-shimmer {
|
||||
0%, 100% { opacity: 0.4; transform: translateX(-2px); }
|
||||
50% { opacity: 0.85; transform: translateX(2px); }
|
||||
}
|
||||
@keyframes platform-pulse {
|
||||
0%, 100% { opacity: 0.35; transform: scaleX(1); }
|
||||
50% { opacity: 0.7; transform: scaleX(1.08); }
|
||||
}
|
||||
@keyframes typing {
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||
30% { transform: translateY(-4px); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Live cluster map */
|
||||
.live-cluster-map { isolation: isolate; }
|
||||
.cluster-ambient {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse 70% 50% at 50% 30%, var(--aurora-1), transparent 60%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.cluster-stage-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: linear-gradient(var(--grid-color) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-color) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
opacity: 0.5;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.cluster-agent-stage { overflow: hidden; }
|
||||
|
||||
.zone-tower {
|
||||
@apply rounded-xl px-2.5 py-2 text-center min-w-[84px] border-2;
|
||||
background: color-mix(in srgb, var(--surface-strong) 90%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.zone-tower-live { animation: tower-breathe 3s ease-in-out infinite; }
|
||||
.zone-tower-active { transform: scale(1.05); }
|
||||
.zone-tower-label { @apply text-[8px] font-mono font-bold tracking-wider; }
|
||||
.zone-tower-stats { @apply text-[10px] font-mono font-semibold mt-0.5 flex items-center justify-center gap-1; }
|
||||
.zone-tower-extra { @apply text-[8px] font-mono opacity-70 mt-0.5; }
|
||||
.zone-level-dot { @apply w-1.5 h-1.5 rounded-full; }
|
||||
.zone-level-dot.level-ok { background: var(--status-ok); box-shadow: 0 0 6px var(--status-ok); }
|
||||
.zone-level-dot.level-warn { background: var(--status-warn); }
|
||||
.zone-level-dot.level-down { background: var(--status-down); }
|
||||
|
||||
.zone-app-orbit {
|
||||
@apply flex flex-col gap-0.5 mt-1 max-w-[90px];
|
||||
}
|
||||
.zone-app-chip {
|
||||
@apply flex items-center gap-1 text-[8px] font-mono px-1.5 py-0.5 rounded-md border;
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
.zone-app-chip.state-running { opacity: 1; }
|
||||
.zone-app-chip.state-down, .zone-app-chip.state-created { opacity: 0.45; filter: grayscale(0.5); }
|
||||
|
||||
.command-desk {
|
||||
@apply relative px-4 py-2 rounded-xl text-[9px] font-mono font-bold tracking-widest border-2;
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
.command-desk-ring {
|
||||
@apply absolute inset-0 rounded-xl border border-[var(--accent)] opacity-30 animate-ping;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gpu-beacon {
|
||||
@apply rounded-xl border px-3 py-2 text-left min-w-[140px];
|
||||
border-color: var(--accent-gpu);
|
||||
background: color-mix(in srgb, var(--accent-gpu) 8%, var(--surface-strong));
|
||||
}
|
||||
.gpu-beacon-title { @apply text-[9px] font-mono font-bold text-[var(--accent-gpu)]; }
|
||||
.gpu-beacon-model { @apply text-xs font-semibold text-[var(--text)] mt-0.5; }
|
||||
.gpu-beacon-meta { @apply text-[9px] font-mono text-[var(--text-muted)]; }
|
||||
.gpu-beacon-bar { @apply h-1 rounded-full bg-[var(--surface-muted)] mt-2 overflow-hidden; }
|
||||
.gpu-beacon-fill { @apply h-full rounded-full bg-[var(--accent-gpu)]; }
|
||||
|
||||
.workload-ticker-wrap {
|
||||
@apply flex items-center gap-3 overflow-hidden rounded-xl border px-3 py-2;
|
||||
border-color: var(--border);
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
.workload-ticker-label {
|
||||
@apply text-[9px] font-mono font-bold tracking-widest shrink-0 text-[var(--accent)];
|
||||
}
|
||||
.workload-ticker-track { @apply flex-1 overflow-hidden; }
|
||||
.workload-ticker-inner { @apply flex gap-2 whitespace-nowrap; }
|
||||
.ticker-chip {
|
||||
@apply inline-flex items-center gap-1 text-[10px] font-mono px-2 py-0.5 rounded-full border;
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
.ticker-chip.state-running { opacity: 1; }
|
||||
.ticker-chip.state-down, .ticker-chip.state-created { opacity: 0.4; }
|
||||
|
||||
.live-domain-card {
|
||||
@apply rounded-xl p-4 border-2 transition-all;
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
.domain-pulse-dot { @apply w-2.5 h-2.5 rounded-full shrink-0; }
|
||||
.domain-pulse-dot.level-ok { background: var(--status-ok); }
|
||||
.domain-pulse-dot.level-warn { background: var(--status-warn); }
|
||||
.domain-pulse-dot.level-down { background: var(--status-down); }
|
||||
.domain-progress-bar { @apply h-1.5 rounded-full bg-[var(--surface-muted)] overflow-hidden; }
|
||||
.domain-progress-fill { @apply h-full rounded-full; }
|
||||
.domain-app-grid {
|
||||
@apply grid grid-cols-3 gap-1;
|
||||
}
|
||||
.domain-app-tile {
|
||||
@apply flex flex-col items-center text-[8px] font-mono p-1 rounded-md border border-[var(--border)];
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
.domain-app-tile.state-down { opacity: 0.35; }
|
||||
.gpu-mini-tile {
|
||||
@apply text-[8px] font-mono px-1.5 py-0.5 rounded border border-[var(--accent-gpu)] text-[var(--accent-gpu)];
|
||||
background: color-mix(in srgb, var(--accent-gpu) 10%, transparent);
|
||||
}
|
||||
|
||||
.ambient-bg { pointer-events: none; }
|
||||
.ambient-orb {
|
||||
position: absolute;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, var(--aurora-1), transparent 70%);
|
||||
animation: orb-float linear infinite;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
@keyframes tower-breathe {
|
||||
0%, 100% { filter: brightness(1); }
|
||||
50% { filter: brightness(1.15); }
|
||||
}
|
||||
@keyframes orb-float {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.2; }
|
||||
33% { transform: translate(20px, -30px) scale(1.1); opacity: 0.4; }
|
||||
66% { transform: translate(-15px, 20px) scale(0.9); opacity: 0.25; }
|
||||
}
|
||||
@keyframes ticker-scroll {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
const ICON_MAP: [RegExp, string][] = [
|
||||
[/trino/i, '🔷'],
|
||||
[/spark/i, '⚡'],
|
||||
[/kafka/i, '📨'],
|
||||
[/connect/i, '🔗'],
|
||||
[/postgres/i, '🐘'],
|
||||
[/mysql/i, '🐬'],
|
||||
[/mongo/i, '🍃'],
|
||||
[/cassandra/i, '💿'],
|
||||
[/neo4j/i, '🔴'],
|
||||
[/airflow/i, '🌀'],
|
||||
[/superset/i, '📊'],
|
||||
[/forgejo|gitea/i, '🦊'],
|
||||
[/homepage/i, '🏠'],
|
||||
[/dockhand/i, '🐳'],
|
||||
[/redis/i, '⚙️'],
|
||||
[/nginx|caddy/i, '🌐'],
|
||||
[/hdfs|namenode|datanode/i, '🌲'],
|
||||
[/gpu|vllm|nvidia/i, '🎮'],
|
||||
[/lam-|ldap/i, '👤'],
|
||||
[/cadvisor/i, '📈'],
|
||||
]
|
||||
|
||||
export function appIcon(name: string, image?: string): string {
|
||||
const hay = `${name} ${image || ''}`
|
||||
for (const [re, icon] of ICON_MAP) {
|
||||
if (re.test(hay)) return icon
|
||||
}
|
||||
return '📦'
|
||||
}
|
||||
|
||||
export function shortName(name: string, max = 14): string {
|
||||
return name.length > max ? `${name.slice(0, max - 1)}…` : name
|
||||
}
|
||||
|
||||
export const LEVEL_COLOR: Record<string, string> = {
|
||||
ok: 'var(--status-ok)',
|
||||
warn: 'var(--status-warn)',
|
||||
down: 'var(--status-down)',
|
||||
unknown: 'var(--text-faint)',
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import { ThemeProvider } from './context/ThemeContext'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
export type AgentStats = {
|
||||
tasks: number
|
||||
last_active: string | null
|
||||
alerts: number
|
||||
}
|
||||
|
||||
export type Agent = {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
zone: string
|
||||
role: string
|
||||
icon?: string
|
||||
motto?: string
|
||||
capabilities?: string[]
|
||||
suggested_prompts?: string[]
|
||||
stats?: AgentStats
|
||||
}
|
||||
|
||||
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>
|
||||
gpu?: GpuStatus
|
||||
}
|
||||
|
||||
export type GpuDevice = {
|
||||
index: number
|
||||
name: string
|
||||
util_gpu: number
|
||||
memory_used_mib: number
|
||||
memory_total_mib: number
|
||||
temperature_c: number
|
||||
power_w: number
|
||||
}
|
||||
|
||||
export type GpuStatus = {
|
||||
ok: boolean
|
||||
host: string
|
||||
ui_url: string
|
||||
inference_active?: boolean
|
||||
active_model?: string | null
|
||||
vllm_url?: string | null
|
||||
gpu_count?: number
|
||||
gpus?: GpuDevice[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export type TerminalLine = {
|
||||
id: string
|
||||
ts: string
|
||||
agent_id: string
|
||||
level: 'info' | 'ok' | 'warn' | 'err' | 'cmd' | 'llm'
|
||||
phase: string
|
||||
text: string
|
||||
prompt_id?: string
|
||||
}
|
||||
|
||||
export type WorkloadApp = {
|
||||
name: string
|
||||
state: string
|
||||
image: string
|
||||
ports: string[]
|
||||
}
|
||||
|
||||
export type WorkloadZone = {
|
||||
id: string
|
||||
label: string
|
||||
x: number
|
||||
color: string
|
||||
level: 'ok' | 'warn' | 'down' | 'unknown'
|
||||
running: number
|
||||
total: number
|
||||
apps: WorkloadApp[]
|
||||
trino_ok?: boolean
|
||||
hdfs_used_gb?: number
|
||||
hdfs_total_gb?: number
|
||||
}
|
||||
|
||||
export type WorkloadData = {
|
||||
ts: string
|
||||
zones: WorkloadZone[]
|
||||
gpu: {
|
||||
level: string
|
||||
model?: string | null
|
||||
inference_active?: boolean
|
||||
gpu_count?: number
|
||||
avg_util?: number
|
||||
gpus?: GpuDevice[]
|
||||
}
|
||||
totals: {
|
||||
apps_running: number
|
||||
apps_total: number
|
||||
connectors: number
|
||||
}
|
||||
}
|
||||
|
||||
export type ChatMessage = {
|
||||
role: 'user' | 'agent'
|
||||
text: string
|
||||
agent?: string
|
||||
ts?: string
|
||||
}
|
||||
Reference in New Issue
Block a user