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