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:
mo
2026-06-25 00:28:23 +00:00
parent fb9cc21c9a
commit a11621b21f
110 changed files with 14622 additions and 529 deletions
+159 -199
View File
@@ -1,218 +1,178 @@
import { useCallback, useEffect, useState } from 'react'
import { OpsFloor } from './components/OpsFloor'
import { PromptBar } from './components/PromptBar'
import type { Agent, AgentAnim, Approval, FeedEntry, StatusData, Zone } from './types'
const TABS = ['Overview', 'Agents', 'Feed', 'Approvals', 'Audit'] as const
type Tab = (typeof TABS)[number]
const LEVEL_COLOR = { ok: '#22aa44', warn: '#cc7700', down: '#dd3355', unknown: '#8b9cb3' }
const LEVEL_BG = { ok: '#e8f8ec', warn: '#fff6e6', down: '#ffeef2', unknown: '#f0f3f8' }
function wsUrl() {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const host = window.location.host
return `${proto}://${host}/api/ws/ops`
}
import { useRef, useState } from 'react'
import { useClock } from './hooks/useClock'
import { useCommandCenter } from './hooks/useCommandCenter'
import { useLiveMetrics } from './hooks/useLiveMetrics'
import { SideNav } from './components/layout/SideNav'
import { TopBar } from './components/layout/TopBar'
import { AgentFleet } from './components/features/AgentFleet'
import { ApprovalInbox } from './components/features/ApprovalInbox'
import { ChatDrawer } from './components/features/ChatDrawer'
import { GpuMonitor } from './components/features/GpuMonitor'
import { InfraQuickAccess } from './components/features/InfraQuickAccess'
import { InspectorPanel } from './components/features/InspectorPanel'
import { PlatformTopology } from './components/features/PlatformTopology'
import { PresentationView } from './components/features/PresentationView'
import { DataQualityView } from './components/features/DataQualityView'
import { KnowledgeChatView } from './components/features/KnowledgeChatView'
import { StorageView } from './components/features/StorageView'
import { TerminalDock } from './components/features/TerminalDock'
import { resolveInfraNode } from './lib/infraCatalog'
import { cn } from './lib/utils'
export default function App() {
const [tab, setTab] = useState<Tab>('Overview')
const [agents, setAgents] = useState<Agent[]>([])
const [zones, setZones] = useState<Zone[]>([])
const [status, setStatus] = useState<StatusData | null>(null)
const [feed, setFeed] = useState<FeedEntry[]>([])
const [approvals, setApprovals] = useState<Approval[]>([])
const [chat, setChat] = useState<{ role: 'user' | 'agent'; text: string; agent?: string }[]>([])
const [anims, setAnims] = useState<Record<string, AgentAnim>>({})
const [busy, setBusy] = useState(false)
const clock = useClock()
const cc = useCommandCenter()
const [gpuChatActive, setGpuChatActive] = useState(false)
const gpuBoost = gpuChatActive || cc.mainView === 'knowledge'
const { agentLoads, gpuLive } = useLiveMetrics(cc.agents, cc.gpu, cc.anims, gpuBoost)
const mainScrollRef = useRef<HTMLDivElement>(null)
const load = useCallback(async () => {
const [a, s, f, ap] = await Promise.all([
fetch('/api/agents').then((r) => r.json()),
fetch('/api/status').then((r) => r.json()),
fetch('/api/feed').then((r) => r.json()),
fetch('/api/approvals').then((r) => r.json()),
])
setAgents(a.agents || [])
setZones(a.zones || [])
setStatus(s)
setFeed(f.entries || [])
setApprovals(ap.approvals || [])
}, [])
const isPlatform = cc.mainView === 'platform'
useEffect(() => {
load()
const ws = new WebSocket(wsUrl())
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data)
if (msg.type === 'status') setStatus(msg.data)
if (msg.type === 'feed') setFeed((prev) => [msg.entry, ...prev].slice(0, 100))
if (msg.type === 'agent_dispatch') {
setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'walk', zone: msg.zone } }))
}
if (msg.type === 'agent_fetch') {
setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'fetch', zone: msg.zone } }))
}
if (msg.type === 'agent_return') {
setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'return', zone: msg.zone } }))
setTimeout(() => {
setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'idle' } }))
}, 1200)
}
if (msg.type === 'prompt_result') {
setChat((c) => [...c, { role: 'agent', text: msg.answer, agent: msg.agent_id }])
setBusy(false)
}
}
const iv = setInterval(load, 30000)
return () => { ws.close(); clearInterval(iv) }
}, [load])
const sendPrompt = async (message: string) => {
setBusy(true)
setChat((c) => [...c, { role: 'user', text: message }])
await fetch('/api/prompt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
})
const openApprovals = () => {
cc.setMainView('approvals')
mainScrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
}
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 terminalSubject = cc.terminalSubjectId
const terminalLabel = (() => {
if (cc.selectedAgent) return cc.selectedAgent.name.split(' ·')[0]
const infra = resolveInfraNode(terminalSubject)
if (infra) return infra.label
if (cc.selectedNode) return cc.selectedNode.label
return 'Lab'
})()
return (
<div className="min-h-screen p-4 md:p-6 max-w-7xl mx-auto font-display flex flex-col gap-4">
<header className="glass-strong rounded-2xl px-5 py-4 flex flex-wrap justify-between items-center gap-3">
<div className="flex items-center gap-4">
<div
className="w-11 h-11 rounded-xl flex items-center justify-center text-xl font-bold text-white shadow-neon-cyan"
style={{ background: 'linear-gradient(135deg, #0099cc, #8844cc)' }}
>
ATC
</div>
<div>
<h1 className="text-2xl font-bold text-neon-cyan neon-text-cyan tracking-tight">Command Center</h1>
<p className="text-xs font-mono text-ink-muted">Agent ops floor · Dell ATC Lab</p>
</div>
</div>
<div className="flex items-center gap-3">
{status && (
<span className={`text-xs font-mono px-3 py-1.5 rounded-full border ${allOk ? 'bg-green-50 border-neon-green/30 text-neon-green' : 'bg-amber-50 border-neon-amber/30 text-neon-amber'}`}>
{allOk ? '● All systems operational' : '● Attention required'}
</span>
)}
{status && (
<span className="text-xs font-mono text-ink-faint">
Scan {new Date(status.ts).toLocaleTimeString()}
</span>
)}
</div>
</header>
<div className="flex h-full flex-col overflow-hidden bg-surface">
<TopBar
clock={clock}
status={cc.status}
workload={cc.workload}
agents={cc.agents}
approvals={cc.approvals}
onApprovalsClick={openApprovals}
/>
<OpsFloor agents={agents} zones={zones} animations={anims} />
<div className="flex min-h-0 flex-1 overflow-hidden">
<SideNav
agents={cc.agents}
animations={cc.anims}
gpu={cc.gpu}
gpuLive={gpuLive}
gpuBoost={gpuBoost}
selectedAgentId={cc.selectedAgentId}
selectedNodeId={cc.selectedNodeId}
mainView={cc.mainView}
approvalCount={cc.approvals.length}
agentsLoading={cc.agentsLoading}
onSetMainView={cc.setMainView}
onOpenApprovals={openApprovals}
onSelectAgent={cc.selectAgent}
onSelectZone={cc.selectNode}
/>
<nav className="flex gap-2 flex-wrap">
{TABS.map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2 rounded-xl text-sm font-mono border transition-all ${
tab === t
? 'bg-white border-neon-cyan text-neon-cyan shadow-neon-cyan font-semibold'
: 'bg-white/60 border-slate-200 text-ink-muted hover:bg-white hover:border-neon-cyan/40'
}`}
>
{t}
</button>
))}
</nav>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 flex-1">
<main className="lg:col-span-2 glass-strong rounded-2xl p-5 min-h-[260px]">
{tab === 'Overview' && status && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{Object.entries(status.domains).map(([key, d]) => (
<div
key={key}
className="status-card rounded-xl p-4 border"
style={{ borderColor: `${LEVEL_COLOR[d.level]}44`, background: LEVEL_BG[d.level] }}
>
<div className="text-xs font-mono uppercase tracking-wider text-ink-muted">{key}</div>
<div className="text-xl font-bold mt-1" style={{ color: LEVEL_COLOR[d.level] }}>{d.label}</div>
<div className="flex items-center gap-2 mt-3">
<div className="w-2.5 h-2.5 rounded-full animate-pulse" style={{ background: LEVEL_COLOR[d.level], boxShadow: `0 0 8px ${LEVEL_COLOR[d.level]}` }} />
<span className="text-xs font-mono uppercase" style={{ color: LEVEL_COLOR[d.level] }}>{d.level}</span>
<div ref={mainScrollRef} className="scrollbar-thin flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto bg-surface">
<div className={cn('flex min-h-0 flex-1', isPlatform ? '' : 'flex-col')}>
<div className={cn('flex min-w-0 flex-1 flex-col gap-2', isPlatform ? 'p-2' : 'min-h-0 p-3')}>
{isPlatform && (
<>
<div className="grid shrink-0 grid-cols-1 gap-2 xl:grid-cols-[1fr_auto]">
<AgentFleet
agents={cc.agents}
animations={cc.anims}
selectedId={cc.selectedAgentId}
loads={agentLoads}
approvalCount={cc.approvals.length}
onSelect={cc.selectAgent}
onOpenApprovals={openApprovals}
/>
<GpuMonitor gpu={cc.gpu} live={gpuLive} />
</div>
</div>
))}
</div>
)}
{tab === 'Agents' && (
<div className="grid gap-3 sm:grid-cols-2">
{agents.map((a) => (
<div key={a.id} className="status-card rounded-xl p-4 border border-slate-200/80">
<div className="font-semibold text-lg" style={{ color: a.color }}>{a.name}</div>
<div className="text-sm text-ink-muted mt-1">{a.role}</div>
<div className="text-xs font-mono text-ink-faint mt-2 px-2 py-1 rounded-md bg-slate-50 inline-block">zone: {a.zone}</div>
</div>
))}
</div>
)}
{tab === 'Feed' && (
<div className="font-mono text-xs space-y-2 max-h-80 overflow-y-auto">
{feed.map((e) => (
<div key={e.id} className="flex gap-2 py-1.5 border-b border-slate-100 last:border-0">
<span className="text-ink-faint shrink-0">{e.ts ? new Date(e.ts).toLocaleTimeString() : ''}</span>
<span className="font-semibold shrink-0" style={{ color: agents.find((a) => a.id === e.agent_id)?.color || '#888' }}>{e.agent_id}</span>
<span className={e.level === 'warn' ? 'text-neon-amber' : 'text-ink'}>{e.message}</span>
</div>
))}
</div>
)}
{tab === 'Approvals' && (
<div className="space-y-3">
{approvals.length === 0 && <p className="text-ink-muted text-sm">Geen pending approvals.</p>}
{approvals.map((a) => (
<div key={a.id} className="status-card border border-neon-magenta/25 rounded-xl p-4">
<div className="text-sm font-semibold text-neon-magenta">{a.action}</div>
<div className="text-xs text-ink-muted mt-1">{a.reason}</div>
<div className="flex gap-2 mt-3">
<button onClick={() => decide(a.id, true)} className="px-3 py-1.5 rounded-lg bg-green-50 border border-neon-green/40 text-neon-green text-xs font-semibold hover:bg-green-100">Approve</button>
<button onClick={() => decide(a.id, false)} className="px-3 py-1.5 rounded-lg bg-red-50 border border-red-300 text-red-600 text-xs font-semibold hover:bg-red-100">Deny</button>
</div>
</div>
))}
</div>
)}
{tab === 'Audit' && (
<p className="text-ink-muted text-sm">Audit log approvals en agent acties (v1 via Feed tab).</p>
)}
</main>
<InfraQuickAccess
workload={cc.workload}
agents={cc.agents}
selectedNodeId={cc.selectedNodeId}
busy={cc.nodeBusy}
onSelectNode={cc.selectNode}
onSelectAgent={cc.selectAgent}
onProbe={cc.probeNodeId}
onOpenTerminal={cc.openTerminal}
/>
</>
)}
<aside className="glass-strong rounded-2xl p-5 flex flex-col max-h-80 lg:max-h-none">
<h3 className="text-sm font-mono font-semibold text-neon-cyan mb-3 tracking-wider">CHAT</h3>
<div className="flex-1 overflow-y-auto space-y-3 text-sm font-mono mb-2">
{chat.length === 0 && <p className="text-ink-faint text-xs">Stel een vraag je agent loopt data ophalen.</p>}
{chat.map((m, i) => (
<div key={i} className={`rounded-lg p-3 ${m.role === 'user' ? 'bg-cyan-50 border border-cyan-100' : 'bg-slate-50 border border-slate-100'}`}>
<span className="text-ink-faint text-xs">{m.role === 'user' ? '▶ jij' : `${m.agent}`}</span>
<div className={`mt-1 whitespace-pre-wrap ${m.role === 'user' ? 'text-neon-cyan' : 'text-ink'}`}>{m.text}</div>
<div className={isPlatform ? 'min-h-[420px]' : 'min-h-0 flex-1'}>
{cc.mainView === 'platform' ? (
<PlatformTopology
workload={cc.workload}
animations={cc.anims}
selectedNodeId={cc.selectedNodeId}
onNodeClick={cc.selectNode}
/>
) : cc.mainView === 'presentation' ? (
<PresentationView />
) : cc.mainView === 'dataquality' ? (
<DataQualityView />
) : cc.mainView === 'knowledge' ? (
<KnowledgeChatView onGpuActivity={setGpuChatActive} />
) : cc.mainView === 'storage' ? (
<StorageView />
) : (
<ApprovalInbox agents={cc.agents} livePending={cc.approvals} onDecide={cc.decide} />
)}
</div>
))}
</div>
{isPlatform && (
<div className="flex w-[340px] shrink-0 flex-col border-l border-border">
<InspectorPanel
node={cc.selectedNode}
nodeDetail={cc.nodeDetail}
agent={cc.selectedAgent}
agents={cc.agents}
workload={cc.workload}
gpu={cc.gpu}
feed={cc.feed}
lines={cc.inspectorLines}
busy={cc.nodeBusy}
onProbe={cc.probeNode}
onAsk={cc.askNode}
onSelectAgent={cc.selectAgent}
onSendPrompt={cc.sendPrompt}
onClear={cc.clearSelection}
onOpenTerminal={cc.openTerminal}
onProbeNodeId={cc.probeNodeId}
/>
<TerminalDock
subjectId={terminalSubject}
subjectLabel={terminalLabel}
lines={cc.inspectorLines}
busy={cc.nodeBusy || cc.promptBusy}
expanded={cc.terminalExpanded}
onToggle={() => cc.setTerminalExpanded(!cc.terminalExpanded)}
/>
</div>
)}
</div>
</aside>
</div>
</div>
<PromptBar onSubmit={sendPrompt} busy={busy} />
<ChatDrawer
expanded={cc.chatExpanded}
onToggle={() => cc.setChatExpanded(!cc.chatExpanded)}
chat={cc.chat}
feed={cc.feed}
agents={cc.agents}
approvals={cc.approvals}
selectedAgent={cc.selectedAgent}
promptBusy={cc.promptBusy}
approvalHighlight={cc.approvalHighlight}
filterAgentId={cc.selectedAgentId}
onSendPrompt={cc.sendPrompt}
onDecide={cc.decide}
onDismissHighlight={() => cc.setApprovalHighlight(false)}
/>
</div>
)
}
-48
View File
@@ -1,48 +0,0 @@
import { motion } from 'framer-motion'
type Props = {
color: string
state: 'idle' | 'walk' | 'fetch' | 'return'
label: string
}
export function AgentSprite({ color, state, label }: Props) {
const bob = state === 'idle' ? { y: [0, -3, 0] } : state === 'walk' || state === 'return' ? { y: [0, -6, 0] } : { y: 0 }
const scale = state === 'fetch' ? 0.92 : 1
return (
<motion.div
className="flex flex-col items-center"
animate={{ ...bob, scale }}
transition={{ repeat: Infinity, duration: state === 'walk' || state === 'return' ? 0.35 : 2 }}
>
<svg width="56" height="72" viewBox="0 0 56 72" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="28" cy="68" rx="16" ry="4" fill={color} opacity="0.2" />
<rect x="18" y="36" width="20" height="26" rx="4" fill="#f8fafc" stroke={color} strokeWidth="1.5" />
<rect x="10" y="38" width="8" height="18" rx="3" fill="#f1f5f9" stroke={color} strokeWidth="1" />
<rect x="38" y="38" width="8" height="18" rx="3" fill="#f1f5f9" stroke={color} strokeWidth="1" />
<rect x="20" y="58" width="7" height="12" rx="2" fill="#e2e8f0" stroke={color} strokeWidth="1" />
<rect x="29" y="58" width="7" height="12" rx="2" fill="#e2e8f0" stroke={color} strokeWidth="1" />
<circle cx="28" cy="22" r="12" fill="#f8fafc" stroke={color} strokeWidth="1.5" />
<path d="M14 20 Q28 8 42 20 L40 24 Q28 14 16 24 Z" fill={color} opacity="0.9" />
<rect x="14" y="20" width="28" height="4" rx="1" fill={color} />
<path d="M16 24 Q16 34 20 36" stroke={color} strokeWidth="2" fill="none" />
<path d="M40 24 Q40 34 36 36" stroke={color} strokeWidth="2" fill="none" />
<rect x="12" y="22" width="6" height="10" rx="2" fill={color} opacity="0.7" />
<rect x="38" y="22" width="6" height="10" rx="2" fill={color} opacity="0.7" />
<path d="M36 36 L42 44" stroke={color} strokeWidth="1.5" />
<circle cx="43" cy="45" r="2" fill={color} />
<rect x="20" y="20" width="16" height="5" rx="2" fill={color} opacity="0.3" />
{state === 'fetch' && (
<motion.circle
cx="46" cy="30" r="4"
fill={color}
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{ repeat: Infinity, duration: 0.6 }}
/>
)}
</svg>
<span className="text-[10px] font-mono font-semibold mt-1 truncate max-w-[72px]" style={{ color }}>{label}</span>
</motion.div>
)
}
-79
View File
@@ -1,79 +0,0 @@
import { motion } from 'framer-motion'
import { AgentSprite } from './AgentSprite'
import type { Agent, AgentAnim, Zone } from '../types'
const ZONE_X: Record<string, number> = {
docker: 8,
db: 28,
lakehouse: 50,
hadoop: 72,
etl: 92,
}
const DESK_X = 50
type Props = {
agents: Agent[]
zones: Zone[]
animations: Record<string, AgentAnim>
}
export function OpsFloor({ agents, zones, animations }: Props) {
return (
<div className="glass-strong rounded-2xl p-5 relative overflow-hidden min-h-[300px]">
<div className="flex justify-between items-center mb-4">
<h2 className="font-display text-lg font-bold text-neon-cyan neon-text-cyan tracking-wide">OPS FLOOR</h2>
<span className="text-xs font-mono px-2.5 py-1 rounded-full bg-green-50 text-neon-green border border-neon-green/30 animate-pulse"> LIVE</span>
</div>
<div className="relative h-28 mb-4">
{zones.map((z) => (
<div
key={z.id}
className="absolute top-0 -translate-x-1/2 text-center"
style={{ left: `${z.x}%` }}
>
<div
className="rounded-xl px-3 py-3 min-w-[92px] text-[9px] font-mono tracking-wider font-semibold bg-white/90"
style={{ border: `2px solid ${z.color}`, boxShadow: `0 4px 16px ${z.color}22`, color: z.color }}
>
{z.label}
</div>
</div>
))}
<svg className="absolute inset-0 w-full h-full pointer-events-none" preserveAspectRatio="none">
{zones.map((z) => (
<line
key={`path-${z.id}`}
x1={`${DESK_X}%`} y1="85%" x2={`${z.x}%`} y2="35%"
stroke={z.color} strokeWidth="2" strokeDasharray="6 4" opacity="0.45"
/>
))}
</svg>
</div>
<div className="relative h-28 rounded-xl bg-gradient-to-b from-slate-50 to-white border border-slate-100">
{agents.map((agent, i) => {
const anim = animations[agent.id] || { agentId: agent.id, state: 'idle' as const }
const targetX = anim.state === 'idle' ? 12 + i * 17 : ZONE_X[anim.zone || agent.zone] ?? DESK_X
const y = anim.state === 'fetch' ? 8 : anim.state === 'idle' ? 0 : 4
return (
<motion.div
key={agent.id}
className="absolute bottom-2 -translate-x-1/2"
animate={{ left: `${targetX}%`, y }}
transition={{ type: 'spring', stiffness: 80, damping: 14 }}
>
<AgentSprite
color={agent.color}
state={anim.state}
label={agent.name.split(' ')[0]}
/>
</motion.div>
)
})}
</div>
</div>
)
}
-41
View File
@@ -1,41 +0,0 @@
import { FormEvent, useState } from 'react'
type Props = {
onSubmit: (message: string) => void
busy: boolean
}
export function PromptBar({ onSubmit, busy }: Props) {
const [text, setText] = useState('')
const handle = (e: FormEvent) => {
e.preventDefault()
if (!text.trim() || busy) return
onSubmit(text.trim())
setText('')
}
return (
<form onSubmit={handle} className="glass-strong rounded-2xl p-4 flex gap-3 items-center shadow-card">
<span className="text-2xl" aria-hidden>💬</span>
<input
className="flex-1 bg-white border border-slate-200 rounded-xl px-4 py-2.5 outline-none font-mono text-sm text-ink placeholder:text-ink-faint focus:border-neon-cyan focus:ring-2 focus:ring-neon-cyan/20 transition"
placeholder="Vraag je agents... bijv. Hoe staat Debezium er voor?"
value={text}
onChange={(e) => setText(e.target.value)}
disabled={busy}
/>
<button
type="submit"
disabled={busy || !text.trim()}
className="px-5 py-2.5 rounded-xl font-display text-sm font-semibold text-white disabled:opacity-40 transition-all hover:brightness-110"
style={{
background: busy ? '#94a3b8' : 'linear-gradient(135deg, #0099cc, #8844cc)',
boxShadow: busy ? 'none' : '0 4px 16px rgba(0, 153, 204, 0.35)',
}}
>
{busy ? 'Bezig...' : 'Send'}
</button>
</form>
)
}
@@ -0,0 +1,61 @@
import type { Agent, FeedEntry } from '../../types'
import { getAgentMeta } from '../../lib/agentMeta'
import { cn } from '../../lib/utils'
type Props = {
feed: FeedEntry[]
agents: Agent[]
filterAgentId?: string | null
opsOnly?: boolean
}
const LEVEL: Record<string, string> = {
info: 'text-foreground-muted',
ok: 'text-success',
warn: 'text-warning',
err: 'text-danger',
}
function isOpsEvent(message: string): boolean {
const lower = message.toLowerCase()
if (message.includes(' answered:')) return false
if (message.startsWith('Prompt received:')) return false
if (lower.includes('completed a response')) return false
return true
}
export function ActivityStream({ feed, agents, filterAgentId, opsOnly }: Props) {
let items = filterAgentId ? feed.filter((e) => e.agent_id === filterAgentId) : feed
if (opsOnly) items = items.filter((e) => isOpsEvent(e.message))
return (
<div className="scrollbar-thin flex-1 overflow-y-auto px-2 pb-2">
{items.length === 0 && (
<p className="py-8 text-center text-[11px] text-foreground-faint">
{opsOnly ? 'Geen operationele events — antwords staan in Chat.' : 'No activity yet — agents are on standby.'}
</p>
)}
{items.map((e) => {
const ag = agents.find((a) => a.id === e.agent_id)
const meta = ag ? getAgentMeta(ag.id) : null
const Icon = meta?.icon
return (
<div key={e.id} className="flex gap-2 border-b border-border/50 py-1.5 last:border-0">
{Icon && (
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded bg-surface-overlay" style={{ color: meta?.accent }}>
<Icon className="h-3 w-3" />
</span>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[10px] font-medium text-foreground-muted">{ag?.name.split(' ·')[0] || e.agent_id}</span>
<span className="font-mono text-[9px] text-foreground-faint">{new Date(e.ts).toLocaleTimeString('en-US', { hour12: false })}</span>
</div>
<p className={cn('text-[10px] leading-relaxed', LEVEL[e.level] || 'text-foreground-muted')}>{e.message}</p>
</div>
</div>
)
})}
</div>
)
}
+121
View File
@@ -0,0 +1,121 @@
import { ShieldCheck } from 'lucide-react'
import type { Agent, AgentAnim } from '../../types'
import type { AgentLoad } from '../../hooks/useLiveMetrics'
import { agentTaskLabel, getAgentMeta } from '../../lib/agentMeta'
import { cn } from '../../lib/utils'
type Props = {
agents: Agent[]
animations: Record<string, AgentAnim>
selectedId: string | null
loads: Record<string, AgentLoad>
approvalCount: number
onSelect: (id: string) => void
onOpenApprovals: () => void
}
function AgentCard({
agent,
anim,
load,
selected,
onSelect,
}: {
agent: Agent
anim?: AgentAnim
load?: AgentLoad
selected: boolean
onSelect: () => void
}) {
const meta = getAgentMeta(agent.id)
const Icon = meta.icon
const busy = anim && anim.state !== 'idle'
return (
<button
type="button"
onClick={onSelect}
className={cn(
'flex h-[118px] w-[140px] shrink-0 flex-col gap-1 rounded-lg border bg-surface-raised p-1.5 text-left shadow-sm dark:bg-surface-overlay',
selected ? 'border-docker/50 ring-1 ring-docker/20' : 'border-border hover:border-border-strong',
)}
>
<div className="flex items-center gap-1.5">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-surface" style={{ color: meta.accent }}>
<Icon className="h-3.5 w-3.5" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[11px] font-semibold text-foreground">{agent.name.split(' ·')[0]}</p>
<p className="truncate text-[8px] text-foreground-muted">{meta.domain}</p>
</div>
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', busy ? 'bg-success animate-pulse' : 'bg-foreground-faint/30')} />
</div>
<p className="line-clamp-2 text-[8px] leading-[10px] text-foreground-muted">{agent.role}</p>
<p className="h-[20px] line-clamp-2 text-[8px] leading-[10px] text-foreground-faint">{agentTaskLabel(agent.id, anim)}</p>
<div className="mt-auto space-y-0.5">
<div className="flex justify-between font-mono text-[7px] tabular-nums text-foreground-faint">
<span>CPU {load?.cpu ?? 0}%</span>
<span>MEM {load?.mem ?? 0}%</span>
</div>
<div className="h-1 overflow-hidden rounded-full bg-surface">
<div className="h-full rounded-full transition-[width] duration-700" style={{ width: `${load?.cpu ?? 0}%`, background: meta.accent }} />
</div>
</div>
</button>
)
}
export function AgentFleet({ agents, animations, selectedId, loads, approvalCount, onSelect, onOpenApprovals }: Props) {
const supervisors = agents.filter((a) => a.supervisor)
const operators = agents.filter((a) => !a.supervisor && a.id !== 'mcp-coordinator')
const mcp = agents.find((a) => a.id === 'mcp-coordinator')
return (
<div className="panel flex shrink-0 flex-col p-2">
<div className="mb-1 flex shrink-0 items-center justify-between gap-2">
<div className="min-w-0">
<h3 className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">Agent Fleet</h3>
<p className="truncate text-[8px] text-foreground-faint">Klik agent stel vraag in chat · elk agent bewaakt één domein</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={onOpenApprovals}
className={cn(
'flex items-center gap-1 rounded-md border px-2 py-1 text-[9px] font-medium',
approvalCount > 0 ? 'border-warning/40 bg-warning/10 text-warning' : 'border-border text-foreground-muted hover:bg-surface-overlay',
)}
>
<ShieldCheck className="h-3 w-3" />
Approvals{approvalCount > 0 ? ` (${approvalCount})` : ''}
</button>
<span className="font-mono text-[8px] text-foreground-faint">{agents.length} agents</span>
</div>
</div>
<div className="scroll-x-stable flex min-h-0 gap-3 pb-1">
<div className="shrink-0">
<p className="mb-1 text-[8px] uppercase tracking-widest text-foreground-faint">Supervisors</p>
<div className="flex gap-1.5">
{supervisors.map((a) => (
<AgentCard key={a.id} agent={a} anim={animations[a.id]} load={loads[a.id]} selected={selectedId === a.id} onSelect={() => onSelect(a.id)} />
))}
</div>
</div>
{mcp && (
<div className="shrink-0">
<p className="mb-1 text-[8px] uppercase tracking-widest text-foreground-faint">MCP Hub</p>
<AgentCard agent={mcp} anim={animations[mcp.id]} load={loads[mcp.id]} selected={selectedId === mcp.id} onSelect={() => onSelect(mcp.id)} />
</div>
)}
<div className="shrink-0">
<p className="mb-1 text-[8px] uppercase tracking-widest text-foreground-faint">Field Operators</p>
<div className="flex gap-1.5">
{operators.map((a) => (
<AgentCard key={a.id} agent={a} anim={animations[a.id]} load={loads[a.id]} selected={selectedId === a.id} onSelect={() => onSelect(a.id)} />
))}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,98 @@
import { useState } from 'react'
import { Check, ShieldAlert, X } from 'lucide-react'
import type { Agent, Approval } from '../../types'
import { getAgentMeta } from '../../lib/agentMeta'
import { Button } from '../ui/Button'
import { cn } from '../../lib/utils'
const ACTION_LABELS: Record<string, string> = {
'docker.restart': 'Container restart',
'docker.update': 'Image update',
'generic.mutate': 'Infrastructure change',
}
type Props = {
approvals: Approval[]
agents: Agent[]
highlighted: boolean
onDecide: (id: string, approved: boolean, decidedBy: string, note: string) => Promise<void>
onDismissHighlight?: () => void
}
export function ApprovalCards({ approvals, agents, highlighted, onDecide, onDismissHighlight }: Props) {
const [busyId, setBusyId] = useState<string | null>(null)
const [decider, setDecider] = useState<'mo-commander' | 'bart-commander'>('mo-commander')
if (!approvals.length) return null
const agentOf = (id: string) => agents.find((a) => a.id === id)
const handle = async (id: string, approved: boolean) => {
setBusyId(id)
try {
await onDecide(id, approved, decider, '')
} finally {
setBusyId(null)
}
}
return (
<div className={cn('mx-2 mb-2 rounded-lg border bg-surface-overlay p-2.5', highlighted ? 'border-warning/50 ring-1 ring-warning/20' : 'border-border')}>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-warning">
<ShieldAlert className="h-3.5 w-3.5" />
Pending approvals ({approvals.length})
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-[9px] text-foreground-muted">
As
<select
value={decider}
onChange={(e) => setDecider(e.target.value as typeof decider)}
className="rounded border border-border bg-surface px-1 py-0.5 text-[10px] text-foreground-muted"
>
<option value="mo-commander">Mo</option>
<option value="bart-commander">Bart</option>
</select>
</label>
{highlighted && onDismissHighlight && (
<button type="button" onClick={onDismissHighlight} className="text-[9px] text-foreground-muted hover:text-foreground-muted">Dismiss</button>
)}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{approvals.map((a) => {
const ag = agentOf(a.agent_id)
const meta = ag ? getAgentMeta(ag.id) : null
const Icon = meta?.icon
return (
<div key={a.id} className="rounded-lg border border-border bg-surface p-2">
<div className="mb-1.5 flex items-start gap-1.5">
{Icon && (
<span className="flex h-6 w-6 items-center justify-center rounded bg-surface-overlay text-docker">
<Icon className="h-3.5 w-3.5" />
</span>
)}
<div className="min-w-0 flex-1">
<span className="text-[8px] uppercase tracking-wide text-warning">{ACTION_LABELS[a.action_type] || a.action_type}</span>
<p className="truncate text-[11px] font-medium text-foreground">{ag?.name || a.agent_id}</p>
</div>
<span className="font-mono text-[8px] text-foreground-faint">#{a.id.slice(0, 6)}</span>
</div>
<p className="mb-1 text-[10px] text-foreground-muted line-clamp-2">{a.action}</p>
{a.target && <p className="text-[9px] text-foreground-muted">Target: {a.target}</p>}
<div className="mt-2 flex gap-1">
<Button size="sm" variant="success" className="flex-1 text-[10px]" disabled={busyId === a.id} onClick={() => handle(a.id, true)}>
<Check className="h-3 w-3" /> Approve
</Button>
<Button size="sm" variant="danger" className="flex-1 text-[10px]" disabled={busyId === a.id} onClick={() => handle(a.id, false)}>
<X className="h-3 w-3" /> Deny
</Button>
</div>
</div>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,122 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Check, ShieldCheck, X } from 'lucide-react'
import { fetchApprovalHistory } from '../../lib/api'
import type { Agent, Approval } from '../../types'
import { getAgentMeta } from '../../lib/agentMeta'
import { Badge } from '../ui/Badge'
import { Button } from '../ui/Button'
import { cn } from '../../lib/utils'
type Filter = 'pending' | 'approved' | 'denied' | 'all'
type Props = {
agents: Agent[]
livePending: Approval[]
onDecide: (id: string, approved: boolean, decidedBy: string, note: string) => Promise<void>
}
export function ApprovalInbox({ agents, livePending, onDecide }: Props) {
const [filter, setFilter] = useState<Filter>('pending')
const [items, setItems] = useState<Approval[]>([])
const [stats, setStats] = useState({ pending: 0, approved: 0, denied: 0, total: 0 })
const [selectedId, setSelectedId] = useState<string | null>(null)
const [decider, setDecider] = useState<'mo-commander' | 'bart-commander'>('mo-commander')
const [note, setNote] = useState('')
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
const res = await fetchApprovalHistory(filter === 'all' ? 'all' : filter)
setItems(res.approvals)
if (res.stats) setStats(res.stats)
}, [filter])
useEffect(() => { load() }, [load, livePending])
const selected = useMemo(() => items.find((a) => a.id === selectedId) || items[0] || null, [items, selectedId])
const agentOf = (id: string) => agents.find((a) => a.id === id)
const handleDecide = async (approved: boolean) => {
if (!selected || selected.status !== 'pending') return
setBusy(true)
try {
await onDecide(selected.id, approved, decider, note)
setNote('')
await load()
} finally {
setBusy(false)
}
}
return (
<section className="panel flex min-h-0 flex-1 flex-col overflow-hidden">
<header className="flex shrink-0 items-start justify-between gap-3 border-b border-border px-3 py-2">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-docker" />
<div>
<h2 className="text-sm font-semibold text-foreground">Approval Inbox</h2>
<p className="text-[10px] text-foreground-muted">Mo & Bart review mutating agent actions</p>
</div>
</div>
<div className="flex gap-1">
<Badge variant="warning">{stats.pending} pending</Badge>
<Badge variant="success">{stats.approved} ok</Badge>
<Badge variant="danger">{stats.denied} denied</Badge>
</div>
</header>
<div className="flex shrink-0 gap-1 border-b border-border px-2 py-1">
{(['pending', 'approved', 'denied', 'all'] as Filter[]).map((f) => (
<button key={f} type="button" onClick={() => setFilter(f)} className={cn('rounded px-2 py-0.5 text-[10px] capitalize', filter === f ? 'bg-docker-light text-docker' : 'text-foreground-muted')}>
{f}
</button>
))}
</div>
<div className="grid min-h-0 flex-1 grid-cols-[minmax(200px,0.9fr)_1.1fr]">
<div className="scrollbar-thin overflow-y-auto border-r border-border p-1">
{!items.length && <p className="p-4 text-center text-[10px] text-foreground-faint">No {filter} requests.</p>}
{items.map((a) => {
const ag = agentOf(a.agent_id)
const meta = ag ? getAgentMeta(ag.id) : null
const Icon = meta?.icon
return (
<button key={a.id} type="button" onClick={() => setSelectedId(a.id)} className={cn('mb-1 flex w-full gap-2 rounded-lg border p-2 text-left', selected?.id === a.id ? 'border-docker/40 bg-docker-light' : 'border-transparent hover:bg-surface-overlay')}>
{Icon && <Icon className="h-4 w-4 shrink-0 text-docker" />}
<span className="min-w-0 flex-1">
<span className="block truncate text-[11px] font-medium text-foreground">{a.action.slice(0, 80)}</span>
<span className="block text-[9px] text-foreground-faint">{ag?.name || a.agent_id}</span>
</span>
<Badge variant={a.status === 'pending' ? 'warning' : a.status === 'approved' ? 'success' : 'danger'}>{a.status}</Badge>
</button>
)
})}
</div>
{selected && (
<div className="scrollbar-thin overflow-y-auto p-3">
<Badge variant={selected.status === 'pending' ? 'warning' : 'success'}>{selected.status}</Badge>
<dl className="mt-3 grid grid-cols-2 gap-2 text-[10px]">
<div><dt className="text-foreground-faint">Agent</dt><dd className="text-foreground">{agentOf(selected.agent_id)?.name}</dd></div>
<div><dt className="text-foreground-faint">Type</dt><dd className="text-foreground">{selected.action_type}</dd></div>
<div className="col-span-2"><dt className="text-foreground-faint">Action</dt><dd className="text-foreground-muted">{selected.action}</dd></div>
<div className="col-span-2"><dt className="text-foreground-faint">Reason</dt><dd className="text-foreground-muted">{selected.reason}</dd></div>
</dl>
{selected.status === 'pending' && (
<div className="mt-4 space-y-2">
<select value={decider} onChange={(e) => setDecider(e.target.value as typeof decider)} className="w-full rounded border border-border bg-surface px-2 py-1 text-[10px] text-foreground-muted">
<option value="mo-commander">Decide as Mo</option>
<option value="bart-commander">Decide as Bart</option>
</select>
<input value={note} onChange={(e) => setNote(e.target.value)} placeholder="Note (optional)" className="w-full rounded border border-border bg-surface px-2 py-1 text-[10px] text-foreground-muted" />
<div className="flex gap-2">
<Button variant="success" className="flex-1" disabled={busy} onClick={() => handleDecide(true)}><Check className="h-3 w-3" /> Approve</Button>
<Button variant="danger" className="flex-1" disabled={busy} onClick={() => handleDecide(false)}><X className="h-3 w-3" /> Deny</Button>
</div>
</div>
)}
</div>
)}
</div>
</section>
)
}
@@ -0,0 +1,212 @@
import { useEffect, useState } from 'react'
import { cn } from '../../lib/utils'
type FlowNode = { id: string; label: string; sub?: string; color: string }
type FlowEdge = { from: string; to: string; label?: string }
const FLOWS: Record<string, { nodes: FlowNode[]; edges: FlowEdge[] }> = {
'full-stack': {
nodes: [
{ id: 'user', label: 'User / Customer', sub: 'Browser', color: '#60a5fa' },
{ id: 'caddy', label: 'Caddy :80', sub: 'Reverse proxy', color: '#38bdf8' },
{ id: 'ui', label: 'Command Center', sub: 'React UI', color: '#818cf8' },
{ id: 'api', label: 'Agents API', sub: 'FastAPI :3201', color: '#a78bfa' },
{ id: 'dq', label: 'DQ API', sub: 'Maturity + Docling', color: '#f59e0b' },
{ id: 'rag', label: 'RAG API', sub: 'LangChain', color: '#34d399' },
{ id: 'chroma', label: 'ChromaDB', sub: 'Vectors (persistent)', color: '#22d3ee' },
{ id: 'docling', label: 'Docling', sub: ':5001', color: '#fb923c' },
{ id: 'llm', label: 'vLLM Llama 70B', sub: 'GPU Lab', color: '#4ade80' },
{ id: 'lake', label: 'Lakehouse', sub: 'Kafka · Spark · Trino', color: '#6366f1' },
],
edges: [
{ from: 'user', to: 'caddy', label: 'HTTP' },
{ from: 'caddy', to: 'ui' },
{ from: 'ui', to: 'api' },
{ from: 'ui', to: 'dq' },
{ from: 'ui', to: 'rag' },
{ from: 'dq', to: 'docling' },
{ from: 'rag', to: 'docling' },
{ from: 'rag', to: 'chroma' },
{ from: 'rag', to: 'llm' },
{ from: 'api', to: 'llm' },
{ from: 'api', to: 'lake' },
],
},
'rag-flow': {
nodes: [
{ id: 'upload', label: 'Upload PDF/CSV', sub: 'Once', color: '#60a5fa' },
{ id: 'store', label: 'File Store', sub: '/data/uploads', color: '#64748b' },
{ id: 'docling', label: 'Docling', sub: 'Parse + OCR', color: '#fb923c' },
{ id: 'chunk', label: 'LangChain Splitter', sub: '800 char chunks', color: '#a78bfa' },
{ id: 'embed', label: 'MiniLM Embeddings', sub: '384-d vectors', color: '#818cf8' },
{ id: 'chroma', label: 'ChromaDB', sub: 'Persistent', color: '#22d3ee' },
{ id: 'query', label: 'Your Question', sub: 'Any time', color: '#60a5fa' },
{ id: 'retrieve', label: 'Similarity Search', sub: 'top-k chunks', color: '#34d399' },
{ id: 'llm', label: 'Llama 70B', sub: 'Answer + sources', color: '#4ade80' },
],
edges: [
{ from: 'upload', to: 'store', label: 'save' },
{ from: 'upload', to: 'docling' },
{ from: 'docling', to: 'chunk' },
{ from: 'chunk', to: 'embed' },
{ from: 'embed', to: 'chroma', label: 'index' },
{ from: 'query', to: 'retrieve' },
{ from: 'retrieve', to: 'chroma' },
{ from: 'retrieve', to: 'llm' },
],
},
'dq-flow': {
nodes: [
{ id: 'data', label: 'Customer Data', sub: 'CSV · Excel · PDF', color: '#60a5fa' },
{ id: 'docling', label: 'Docling', sub: 'Structure + images', color: '#fb923c' },
{ id: 'pandas', label: 'Pandas Profiling', sub: 'Column stats', color: '#a78bfa' },
{ id: 'ge', label: 'Great Expectations', sub: 'Expectation checks', color: '#34d399' },
{ id: 'soda', label: 'Soda Core', sub: 'YAML checks', color: '#22d3ee' },
{ id: 'maturity', label: '6 Dimensions', sub: 'Score 0100', color: '#f59e0b' },
{ id: 'report', label: 'HTML Report', sub: 'Roadmap + actions', color: '#818cf8' },
],
edges: [
{ from: 'data', to: 'docling' },
{ from: 'data', to: 'pandas' },
{ from: 'pandas', to: 'ge' },
{ from: 'pandas', to: 'soda' },
{ from: 'ge', to: 'maturity' },
{ from: 'soda', to: 'maturity' },
{ from: 'maturity', to: 'report' },
],
},
'lakehouse': {
nodes: [
{ id: 'pg', label: 'PostgreSQL', color: '#60a5fa' },
{ id: 'mysql', label: 'MySQL', color: '#60a5fa' },
{ id: 'mongo', label: 'MongoDB', color: '#60a5fa' },
{ id: 'debezium', label: 'Debezium CDC', color: '#f59e0b' },
{ id: 'kafka', label: 'Kafka', color: '#fb923c' },
{ id: 'spark', label: 'Spark', color: '#a78bfa' },
{ id: 'iceberg', label: 'Iceberg', color: '#22d3ee' },
{ id: 'trino', label: 'Trino', color: '#34d399' },
{ id: 'bi', label: 'Superset BI', color: '#818cf8' },
],
edges: [
{ from: 'pg', to: 'debezium' },
{ from: 'mysql', to: 'debezium' },
{ from: 'mongo', to: 'debezium' },
{ from: 'debezium', to: 'kafka' },
{ from: 'kafka', to: 'spark' },
{ from: 'spark', to: 'iceberg' },
{ from: 'iceberg', to: 'trino' },
{ from: 'trino', to: 'bi' },
],
},
}
const POSITIONS: Record<string, Record<string, { x: number; y: number }>> = {
'full-stack': {
user: { x: 50, y: 8 },
caddy: { x: 50, y: 22 },
ui: { x: 50, y: 38 },
api: { x: 18, y: 58 },
dq: { x: 50, y: 58 },
rag: { x: 82, y: 58 },
docling: { x: 50, y: 78 },
chroma: { x: 82, y: 78 },
llm: { x: 82, y: 92 },
lake: { x: 18, y: 92 },
},
'rag-flow': {
upload: { x: 12, y: 20 },
store: { x: 12, y: 45 },
docling: { x: 35, y: 20 },
chunk: { x: 58, y: 20 },
embed: { x: 58, y: 45 },
chroma: { x: 58, y: 70 },
query: { x: 82, y: 20 },
retrieve: { x: 82, y: 45 },
llm: { x: 82, y: 70 },
},
'dq-flow': {
data: { x: 10, y: 50 },
docling: { x: 28, y: 25 },
pandas: { x: 28, y: 75 },
ge: { x: 52, y: 35 },
soda: { x: 52, y: 65 },
maturity: { x: 72, y: 50 },
report: { x: 90, y: 50 },
},
'lakehouse': {
pg: { x: 8, y: 15 },
mysql: { x: 8, y: 35 },
mongo: { x: 8, y: 55 },
debezium: { x: 28, y: 35 },
kafka: { x: 45, y: 35 },
spark: { x: 58, y: 35 },
iceberg: { x: 72, y: 35 },
trino: { x: 85, y: 35 },
bi: { x: 92, y: 55 },
},
}
export function ArchitectureDiagram({ animation }: { animation: string }) {
const flow = FLOWS[animation] || FLOWS['full-stack']
const positions = POSITIONS[animation] || POSITIONS['full-stack']
const [tick, setTick] = useState(0)
useEffect(() => {
const t = setInterval(() => setTick((n) => n + 1), 2200)
return () => clearInterval(t)
}, [])
const activeEdge = tick % flow.edges.length
return (
<div className="relative mx-auto mb-6 h-[280px] w-full max-w-4xl rounded-xl border border-docker/30 bg-surface-overlay/60 p-2 md:h-[320px]">
<svg className="absolute inset-0 h-full w-full" viewBox="0 0 100 100" preserveAspectRatio="none">
{flow.edges.map((edge, i) => {
const from = positions[edge.from]
const to = positions[edge.to]
if (!from || !to) return null
const active = i === activeEdge
return (
<g key={`${edge.from}-${edge.to}`}>
<line
x1={from.x}
y1={from.y}
x2={to.x}
y2={to.y}
stroke={active ? '#38bdf8' : 'rgba(56,189,248,0.25)'}
strokeWidth={active ? 0.6 : 0.35}
strokeDasharray={active ? '2 1' : '1 2'}
className={active ? 'animate-pulse' : undefined}
/>
{active && (
<circle r="1.2" fill="#38bdf8">
<animateMotion dur="1.8s" repeatCount="indefinite" path={`M${from.x},${from.y} L${to.x},${to.y}`} />
</circle>
)}
</g>
)
})}
</svg>
{flow.nodes.map((node) => {
const pos = positions[node.id]
if (!pos) return null
const lit = flow.edges.some((e, i) => i === activeEdge && (e.from === node.id || e.to === node.id))
return (
<div
key={node.id}
className={cn(
'absolute -translate-x-1/2 -translate-y-1/2 rounded-lg border px-2 py-1 text-center transition-all duration-500',
lit ? 'scale-105 border-docker shadow-docker bg-docker/20' : 'border-border bg-surface-raised/90',
)}
style={{ left: `${pos.x}%`, top: `${pos.y}%`, minWidth: '72px' }}
>
<p className="text-[9px] font-semibold leading-tight text-foreground md:text-[10px]" style={{ color: lit ? node.color : undefined }}>
{node.label}
</p>
{node.sub && <p className="text-[7px] text-foreground-faint md:text-[8px]">{node.sub}</p>}
</div>
)
})}
</div>
)
}
+119
View File
@@ -0,0 +1,119 @@
import { useState } from 'react'
import { ChevronDown, ChevronUp, MessageSquare, Radio } from 'lucide-react'
import type { Agent, Approval, ChatMessage, FeedEntry } from '../../types'
import { ActivityStream } from './ActivityStream'
import { ApprovalCards } from './ApprovalCards'
import { CommandBar } from './CommandBar'
import { CommsPanel } from './CommsPanel'
import { cn } from '../../lib/utils'
type Props = {
expanded: boolean
onToggle: () => void
chat: ChatMessage[]
feed: FeedEntry[]
agents: Agent[]
approvals: Approval[]
selectedAgent: Agent | null
promptBusy: boolean
approvalHighlight: boolean
filterAgentId?: string | null
onSendPrompt: (message: string, agentId?: string) => void
onDecide: (id: string, approved: boolean, decidedBy: string, note: string) => Promise<void>
onDismissHighlight: () => void
}
export function ChatDrawer({
expanded,
onToggle,
chat,
feed,
agents,
approvals,
selectedAgent,
promptBusy,
approvalHighlight,
filterAgentId,
onSendPrompt,
onDecide,
onDismissHighlight,
}: Props) {
const [tab, setTab] = useState<'chat' | 'activity'>('chat')
const unread = chat.length
if (!expanded) {
return (
<button
type="button"
onClick={onToggle}
className="flex w-full shrink-0 items-center justify-between border-t border-border bg-surface-raised/95 px-4 py-2 backdrop-blur-sm hover:bg-surface-overlay"
>
<span className="flex items-center gap-2 text-xs font-medium text-foreground">
<MessageSquare className="h-4 w-4 text-docker" />
Chat & Activity
{unread > 0 && (
<span className="rounded-full bg-docker/20 px-2 py-0.5 font-mono text-[10px] text-docker">
{unread} bericht{unread !== 1 ? 'en' : ''}
</span>
)}
{approvals.length > 0 && (
<span className="rounded-full bg-warning/20 px-2 py-0.5 font-mono text-[10px] text-warning">
{approvals.length} approval{approvals.length !== 1 ? 's' : ''}
</span>
)}
</span>
<ChevronUp className="h-4 w-4 text-foreground-muted" />
</button>
)
}
return (
<div className="flex shrink-0 flex-col border-t border-border bg-surface-raised/95 backdrop-blur-sm" style={{ height: 'min(42vh, 380px)' }}>
<div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-1.5">
<div className="flex gap-1">
{(['chat', 'activity'] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={cn(
'flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[11px] font-medium capitalize transition-colors',
tab === t
? 'bg-docker-light text-docker dark:bg-blue-500/20 dark:text-blue-200'
: 'text-foreground-muted hover:text-foreground',
)}
>
{t === 'chat' ? <MessageSquare className="h-3 w-3" /> : <Radio className="h-3 w-3" />}
{t === 'chat' ? 'Chat' : 'Activity'}
{t === 'activity' && approvals.length > 0 && (
<span className="rounded-full bg-warning/20 px-1 font-mono text-[8px] text-warning">{approvals.length}</span>
)}
</button>
))}
</div>
<button type="button" onClick={onToggle} className="rounded p-1 text-foreground-muted hover:bg-surface-overlay hover:text-foreground" title="Inklappen">
<ChevronDown className="h-4 w-4" />
</button>
</div>
<div className="panel mx-2 mb-1 min-h-0 flex-1 overflow-hidden">
{tab === 'chat' ? (
<CommsPanel messages={chat} agents={agents} selectedAgent={selectedAgent} busy={promptBusy} />
) : (
<div className="flex h-full min-h-0 flex-col overflow-hidden">
<ApprovalCards
approvals={approvals}
agents={agents}
highlighted={approvalHighlight}
onDecide={onDecide}
onDismissHighlight={onDismissHighlight}
/>
<ActivityStream feed={feed} agents={agents} filterAgentId={filterAgentId} opsOnly />
</div>
)}
</div>
{tab === 'chat' && <CommandBar busy={promptBusy} selectedAgent={selectedAgent} onSubmit={onSendPrompt} />}
</div>
)
}
+58
View File
@@ -0,0 +1,58 @@
import { useState, type FormEvent } from 'react'
import { Send } from 'lucide-react'
import type { Agent } from '../../types'
import { Button } from '../ui/Button'
import { Input } from '../ui/Input'
type Props = {
busy: boolean
selectedAgent: Agent | null
onSubmit: (message: string, agentId?: string) => void
}
export function CommandBar({ busy, selectedAgent, onSubmit }: Props) {
const [input, setInput] = useState('')
const submit = (e: FormEvent) => {
e.preventDefault()
if (!input.trim() || busy) return
onSubmit(input.trim(), selectedAgent?.id)
setInput('')
}
const suggestions = selectedAgent?.suggested_prompts?.slice(0, 3) || [
'Hoeveel data zit er in de databases?',
'Wat staat er in PostgreSQL?',
'MongoDB supplychain overzicht',
]
return (
<footer className="shrink-0 border-t border-border bg-surface-raised/90 px-3 py-1.5 backdrop-blur-sm">
<form onSubmit={submit} className="flex gap-2">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={selectedAgent ? `Command ${selectedAgent.name.split(' ·')[0]}` : 'Enter command — auto-routed to specialist…'}
disabled={busy}
className="flex-1"
/>
<Button type="submit" disabled={busy || !input.trim()}>
<Send className="h-3.5 w-3.5" />
Send
</Button>
</form>
<div className="mt-1.5 flex flex-wrap gap-1">
{suggestions.map((s) => (
<button
key={s}
type="button"
onClick={() => setInput(s)}
className="rounded border border-border bg-surface px-2 py-0.5 text-[9px] text-foreground-muted hover:border-border-strong hover:text-foreground-muted"
>
{s}
</button>
))}
</div>
</footer>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useRef } from 'react'
import { MessageSquare } from 'lucide-react'
import type { Agent, ChatMessage } from '../../types'
import { getAgentMeta } from '../../lib/agentMeta'
import { cn } from '../../lib/utils'
type Props = {
messages: ChatMessage[]
agents: Agent[]
selectedAgent: Agent | null
busy: boolean
}
export function CommsPanel({ messages, agents, selectedAgent, busy }: Props) {
const bottomRef = useRef<HTMLDivElement>(null)
const meta = selectedAgent ? getAgentMeta(selectedAgent.id) : null
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, busy])
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2">
<h3 className="flex items-center gap-1.5 text-xs font-semibold text-foreground">
<MessageSquare className="h-3.5 w-3.5 text-docker" /> Comms
</h3>
{selectedAgent && meta && (
<span className="truncate font-mono text-[9px]" style={{ color: meta.accent }}>
{selectedAgent.name.split(' ·')[0]}
</span>
)}
</div>
<div className="scrollbar-thin flex-1 space-y-2 overflow-y-auto p-2">
{!messages.length && (
<div className="flex h-full flex-col items-center justify-center py-6 text-center">
<MessageSquare className="mb-2 h-6 w-6 text-foreground-faint" />
<p className="max-w-[200px] text-[10px] text-foreground-muted">
Send a command below routing selects the right specialist automatically.
</p>
</div>
)}
{messages.map((m, i) => {
const ag = m.role === 'agent' ? agents.find((a) => a.id === m.agent) : null
const agMeta = ag ? getAgentMeta(ag.id) : null
return (
<div key={i} className={cn('flex gap-2', m.role === 'user' && 'flex-row-reverse')}>
<div className={cn('max-w-[85%] rounded-lg border px-2 py-1.5', m.role === 'user' ? 'border-docker/25 bg-docker-light' : 'border-border bg-surface-overlay')}>
<p className="mb-0.5 text-[8px] text-foreground-muted">
{m.role === 'user' ? 'You' : ag?.name || m.agent}
{m.ts && ` · ${new Date(m.ts).toLocaleTimeString('en-US', { hour12: false })}`}
</p>
<p className="whitespace-pre-wrap text-[11px] text-foreground">{m.text}</p>
</div>
</div>
)
})}
{busy && (
<div className="flex items-center gap-2 px-2 py-2 text-[10px] text-foreground-muted">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-accent" />
<span>Agent verzamelt cluster-data en vraagt Llama 70B verwacht ~3090 sec</span>
</div>
)}
<div ref={bottomRef} />
</div>
</div>
)
}
@@ -0,0 +1,732 @@
import { useCallback, useEffect, useState, Fragment } from 'react'
import {
AlertTriangle,
CheckCircle2,
FileSearch,
FileText,
Image,
Layers,
Loader2,
RefreshCw,
Table2,
Upload,
XCircle,
} from 'lucide-react'
import { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive'
type Dimension = {
id: string
label: string
description: string
score: number
level: string
findings: string[]
recommended_actions?: string[]
}
type ColumnProfile = {
name: string
dtype: string
null_pct: number
unique_count: number
quality_flags: string[]
sample_values?: string[]
numeric?: { min: number; max: number; mean: number; outliers: number }
text?: { avg_length: number; empty_strings: number }
top_values?: { value: string; count: number }[]
}
type GxCheck = { suite: string; expectation: string; success: boolean; result: string; column?: string }
type SodaCheck = { suite: string; name: string; check: string; outcome: string; detail: string }
type DocStructure = {
pages: number
pictures: number
tables: number
text_blocks: number
headings: number
paragraphs: number
list_items?: number
form_items: number
key_value_pairs: number
label_counts?: Record<string, number>
table_details?: { index: number; rows: number; cols: number; cells: number; preview?: string }[]
picture_details?: { index: number; label: string; has_image: boolean; captions: number }[]
outline?: { type: string; text: string; level?: number }[]
}
type AssessResult = {
ok: boolean
report_id: string
overall_score: number
maturity_level: string
maturity_description?: string
rows: number
columns: number
dimensions: Dimension[]
column_profiles: ColumnProfile[]
action_items: { priority: string; dimension: string; score: number; action: string }[]
checks?: { great_expectations: GxCheck[]; soda_core: SodaCheck[] }
checks_summary: {
great_expectations: { total: number; passed: number }
soda_core: { total: number; warnings: number }
}
docling?: { used: boolean; parse_id?: string; document_structure?: DocStructure; stats?: Record<string, number>; images?: DocImage[] }
rag_ingest?: { ok: boolean; duplicate?: boolean; chunks?: number; message?: string; error?: string }
report_url: string
}
type DocImage = {
index: number
label: string
available: boolean
url?: string
width?: number
height?: number
mimetype?: string
dpi?: number
bytes?: number
captions?: string[]
}
type ParseResult = {
ok: boolean
parse_id: string
filename: string
status: string
processing_time_sec?: number
formats_available: string[]
document_structure: DocStructure
images?: DocImage[]
stats: Record<string, number>
content: { preview_markdown?: string; preview_html?: string; markdown?: string; html?: string }
table_preview?: string[]
errors?: string[]
parse_json_url?: string
}
type Capabilities = {
maturity_dimensions: { id: string; label: string; description: string }[]
maturity_levels: { min_score: number; label: string; description: string }[]
supported_data_formats: string[]
supported_document_formats: string[]
tools: Record<string, { status: string; capabilities?: string[] }>
docling_online: boolean
}
type ReportSummary = {
id: string
filename: string
ts: string
overall_score: number
maturity_level: string
rows: number
columns: number
}
type Tab = 'assess' | 'docling' | 'reports'
const SCORE_COLOR = (s: number) => (s >= 80 ? 'text-success' : s >= 60 ? 'text-warning' : 'text-danger')
const BAR_COLOR = (s: number) => (s >= 80 ? 'bg-success' : s >= 60 ? 'bg-warning' : 'bg-danger')
export function DataQualityView() {
const [tab, setTab] = useState<Tab>('assess')
const [caps, setCaps] = useState<Capabilities | null>(null)
const [loading, setLoading] = useState(false)
const [assess, setAssess] = useState<AssessResult | null>(null)
const [parse, setParse] = useState<ParseResult | null>(null)
const [parseFormat, setParseFormat] = useState<'markdown' | 'html'>('markdown')
const [reports, setReports] = useState<ReportSummary[]>([])
const [error, setError] = useState<string | null>(null)
const [expandedCol, setExpandedCol] = useState<string | null>(null)
const [showGx, setShowGx] = useState(false)
const [showSoda, setShowSoda] = useState(false)
const loadMeta = useCallback(async () => {
try {
const [c, r] = await Promise.all([fetch('/dq/capabilities'), fetch('/dq/reports')])
if (c.ok) setCaps(await c.json())
if (r.ok) {
const j = await r.json()
setReports(j.reports || [])
}
} catch {
/* ignore */
}
}, [])
useEffect(() => {
loadMeta()
}, [loadMeta])
const onAssess = async (file: File) => {
setLoading(true)
setError(null)
setAssess(null)
const fd = new FormData()
fd.append('file', file)
try {
const r = await fetch('/dq/assess', { method: 'POST', body: fd })
const j = await r.json()
if (!r.ok || !j.ok) {
setError(j.error || j.detail || 'Assessment failed')
return
}
setAssess(j as AssessResult)
loadMeta()
} catch {
setError('Connection failed — check DQ API')
} finally {
setLoading(false)
}
}
const onParse = async (file: File) => {
setLoading(true)
setError(null)
setParse(null)
const fd = new FormData()
fd.append('file', file)
fd.append('to_formats', 'md,html,json')
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 300000)
try {
const r = await fetch('/dq/parse', { method: 'POST', body: fd, signal: ctrl.signal })
const j = await r.json()
if (!r.ok || !j.ok) {
setError(typeof j.error === 'string' ? j.error : JSON.stringify(j.error || j).slice(0, 200) || 'Docling parse failed')
return
}
setParse(j as ParseResult)
loadMeta()
} catch (e) {
setError(e instanceof Error && e.name === 'AbortError' ? 'Timeout — document too large or Docling overloaded' : 'Docling unavailable')
} finally {
clearTimeout(timer)
setLoading(false)
}
}
const tabs: { id: Tab; label: string; icon: typeof FileSearch }[] = [
{ id: 'assess', label: 'Maturity Assessment', icon: FileSearch },
{ id: 'docling', label: 'Docling Parser', icon: FileText },
{ id: 'reports', label: 'Reports', icon: CheckCircle2 },
]
return (
<div className="flex h-full min-h-[calc(100vh-140px)] flex-col rounded-lg border border-border bg-surface-raised">
<header className="shrink-0 border-b border-border bg-surface-overlay/30 px-4 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-foreground">Data Quality & Maturity Platform</h2>
<p className="text-[11px] text-foreground-muted">
Full data maturity assessment for customer data Docling, Great Expectations, Soda Core
</p>
</div>
<div className="flex items-center gap-2">
<span className={cn('rounded-full px-2.5 py-1 text-[10px] font-medium', caps?.docling_online ? 'bg-success/20 text-success' : 'bg-danger/20 text-danger')}>
Docling {caps?.docling_online ? '● online' : '○ offline'}
</span>
<button type="button" onClick={loadMeta} className="rounded border border-border p-1.5 hover:bg-surface-overlay">
<RefreshCw className="h-4 w-4" />
</button>
<a href={`http://${window.location.hostname}:5001/ui/`} target="_blank" rel="noreferrer" className="rounded border border-docker/40 bg-docker/15 px-2 py-1 text-[10px] text-docker">
Docling UI
</a>
</div>
</div>
{caps && (
<div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
<CapCard title="Maturity Engine" items={caps.maturity_dimensions.map((d) => d.label)} icon={Layers} />
<CapCard title="Data Quality Tools" items={['Great Expectations', 'Soda Core', 'Pandas Profiling']} icon={CheckCircle2} />
<CapCard title="Document Parsing" items={caps.tools.docling?.capabilities || ['PDF', 'PPTX', 'DOCX']} icon={FileText} />
<CapCard title="File formats" items={[...caps.supported_data_formats.slice(0, 4), ...caps.supported_document_formats.slice(0, 3)]} icon={Upload} />
</div>
)}
</header>
<div className="flex shrink-0 gap-1 border-b border-border bg-surface-overlay/20 px-3 py-2">
{tabs.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => setTab(id)}
className={cn('flex items-center gap-1.5 rounded-md px-3 py-2 text-[11px] font-medium transition-all', tab === id ? subTabActive : subTabIdle)}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</div>
<div className="scrollbar-thin flex-1 overflow-y-auto p-4">
{error && (
<div className="mb-4 flex items-start gap-2 rounded-lg border border-danger/40 bg-danger/10 px-4 py-3 text-[11px] text-danger">
<XCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
{tab === 'assess' && (
<div className="space-y-5">
<UploadZone
loading={loading}
label="Upload customer data for full maturity assessment"
hint="CSV · Excel · JSON · Parquet · PDF · PPTX · DOCX"
accept=".csv,.tsv,.xlsx,.xls,.json,.parquet,.pdf,.pptx,.ppt,.docx"
onFile={onAssess}
/>
{loading && <LoadingMsg text="Analyzing: 6 maturity dimensions · GE checks · Soda checks · column profiles…" />}
{assess && (
<div className="space-y-5">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<StatCard label="Overall Score" value={`${assess.overall_score}`} sub="/100" accent />
<StatCard label="Maturity Level" value={assess.maturity_level} sub={assess.maturity_description} />
<StatCard label="Dataset" value={`${assess.rows.toLocaleString()}`} sub={`${assess.columns} columns`} />
<StatCard label="Great Expectations" value={`${assess.checks_summary.great_expectations.passed}/${assess.checks_summary.great_expectations.total}`} sub="checks passed" />
<StatCard label="Soda Core" value={String(assess.checks_summary.soda_core.warnings)} sub="warnings" warn={assess.checks_summary.soda_core.warnings > 0} />
</div>
{assess.docling?.used && assess.docling.document_structure && (
<>
<DocStructurePanel structure={assess.docling.document_structure} title="Document structure (via Docling)" />
{assess.docling.images && assess.docling.images.length > 0 && assess.docling.parse_id && (
<ImageGallery images={assess.docling.images} parseId={assess.docling.parse_id} />
)}
</>
)}
{assess.rag_ingest && (
<div className={cn(
'rounded-lg border px-3 py-2 text-[11px]',
assess.rag_ingest.ok ? 'border-success/30 bg-success/10 text-success' : 'border-warning/30 bg-warning/10 text-warning',
)}>
<p className="font-medium">Knowledge Chat sync</p>
<p className="text-foreground-muted">
{assess.rag_ingest.ok
? (assess.rag_ingest.duplicate
? `Already in Knowledge Chat — ${assess.rag_ingest.message || 'you can chat immediately.'}`
: `Indexed for chat: ${assess.rag_ingest.chunks ?? '?'} text chunks. Open Knowledge Chat to ask questions.`)
: (assess.rag_ingest.error || 'Could not sync to Knowledge Chat')}
</p>
</div>
)}
<div className="flex flex-wrap gap-2">
<a href={assess.report_url} target="_blank" rel="noreferrer" className={cn('rounded-md px-3 py-1.5 text-[11px] font-medium', subTabActive)}>
Full HTML report
</a>
<button type="button" onClick={() => setShowGx(!showGx)} className={cn('rounded-md px-3 py-1.5 text-[11px]', showGx ? subTabActive : subTabIdle)}>
GE checks ({assess.checks?.great_expectations.length || 0})
</button>
<button type="button" onClick={() => setShowSoda(!showSoda)} className={cn('rounded-md px-3 py-1.5 text-[11px]', showSoda ? subTabActive : subTabIdle)}>
Soda checks ({assess.checks?.soda_core.length || 0})
</button>
</div>
{showGx && assess.checks?.great_expectations && (
<CheckTable title="Great Expectations" rows={assess.checks.great_expectations.map((c) => ({
name: c.column ? `${c.expectation} [${c.column}]` : c.expectation,
status: c.success ? 'pass' : 'fail',
detail: c.result,
}))} />
)}
{showSoda && assess.checks?.soda_core && (
<CheckTable title="Soda Core" rows={assess.checks.soda_core.map((c) => ({
name: c.name,
status: c.outcome,
detail: `${c.check}${c.detail}`,
}))} />
)}
<section>
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">6 Maturity Dimensions</h3>
<div className="grid gap-3 lg:grid-cols-2 xl:grid-cols-3">
{assess.dimensions.map((d) => (
<DimensionCard key={d.id} dimension={d} />
))}
</div>
</section>
{assess.action_items.length > 0 && (
<section>
<h3 className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">
<AlertTriangle className="h-3.5 w-3.5 text-warning" /> Remediation Roadmap
</h3>
<div className="space-y-1.5">
{assess.action_items.map((a, i) => (
<div key={i} className={cn('rounded-lg border px-3 py-2 text-[11px]', a.priority === 'high' ? 'border-danger/40 bg-danger/10' : a.priority === 'medium' ? 'border-warning/40 bg-warning/10' : 'border-border bg-surface-overlay/40')}>
<span className="font-bold uppercase text-foreground-faint">{a.priority}</span>
{' · '}<strong>{a.dimension}</strong> ({a.score}): {a.action}
</div>
))}
</div>
</section>
)}
<section>
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">
Column profiles ({assess.column_profiles.length})
</h3>
<ColumnTable profiles={assess.column_profiles} expandedCol={expandedCol} onToggle={setExpandedCol} />
</section>
</div>
)}
</div>
)}
{tab === 'docling' && (
<div className="space-y-5">
<p className="text-[12px] leading-relaxed text-foreground-muted">
Docling extracts text, tables, images and document structure from PDF, PowerPoint, Word, Excel and images.
Resultaat: Markdown, HTML, JSON met pagina&apos;s, plaatjes, tabellen en outline.
</p>
<UploadZone
loading={loading}
label="Upload document for Docling parsing"
hint="PDF · PPTX · DOCX · XLSX · PNG · JPG · TIFF · MD · HTML"
accept=".pdf,.pptx,.ppt,.docx,.doc,.xlsx,.png,.jpg,.jpeg,.tiff,.txt,.md,.html"
onFile={onParse}
/>
{loading && <LoadingMsg text="Docling processing document — OCR, table detection, images (30180 sec)…" />}
{parse && (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6">
<StatCard label="Bestand" value={parse.filename.length > 20 ? parse.filename.slice(0, 18) + '…' : parse.filename} sub={parse.status} />
<StatCard label="Verwerking" value={`${(parse.processing_time_sec || 0).toFixed(1)}s`} sub={`Formats: ${parse.formats_available.join(', ')}`} />
<StatCard label="Pages" value={String(parse.document_structure?.pages ?? parse.stats.pages ?? 0)} icon={Layers} />
<StatCard label="Images" value={String(parse.document_structure?.pictures ?? 0)} icon={Image} accent />
<StatCard label="Tables" value={String(parse.document_structure?.tables ?? 0)} icon={Table2} />
<StatCard label="Text blocks" value={String(parse.document_structure?.text_blocks ?? 0)} sub={`${parse.stats.words?.toLocaleString() ?? 0} words`} />
</div>
<DocStructurePanel structure={parse.document_structure} title="Document analysis" />
{parse.images && parse.images.filter((i) => i.available).length > 0 && (
<ImageGallery images={parse.images} parseId={parse.parse_id} />
)}
{parse.document_structure?.outline && parse.document_structure.outline.length > 0 && (
<section className="rounded-lg border border-border bg-surface-overlay/30 p-3">
<h3 className="mb-2 text-[11px] font-semibold uppercase text-foreground-faint">Document outline</h3>
<ul className="space-y-1 text-[11px]">
{parse.document_structure.outline.map((o, i) => (
<li key={i} className="flex gap-2" style={{ paddingLeft: (o.level || 0) * 12 }}>
<span className="shrink-0 rounded bg-docker/20 px-1 font-mono text-[9px] text-docker">{o.type}</span>
<span className="text-foreground-muted">{o.text}</span>
</li>
))}
</ul>
</section>
)}
<div className="flex gap-1">
{(['markdown', 'html'] as const).map((f) => (
<button key={f} type="button" onClick={() => setParseFormat(f)} className={cn('rounded-md px-3 py-1.5 text-[11px] font-medium', parseFormat === f ? subTabActive : subTabIdle)}>
{f.toUpperCase()}
</button>
))}
{parse.parse_json_url && (
<a href={parse.parse_json_url} target="_blank" rel="noreferrer" className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
Full JSON
</a>
)}
</div>
{parse.table_preview && parse.table_preview.length > 0 && (
<section>
<h3 className="mb-1 text-[11px] font-semibold uppercase text-foreground-faint">Tables (markdown preview)</h3>
<pre className="scrollbar-thin max-h-40 overflow-auto rounded-lg border border-border bg-surface-overlay p-3 font-mono text-[10px]">
{parse.table_preview.join('\n')}
</pre>
</section>
)}
<section>
<h3 className="mb-2 text-[11px] font-semibold uppercase text-foreground-faint">Extracted content</h3>
{parseFormat === 'html' && (parse.content.preview_html || parse.content.html) ? (
<div className="scrollbar-thin max-h-[500px] overflow-auto rounded-lg border border-border bg-surface-overlay p-2">
<div className="rounded bg-white p-4 text-black" dangerouslySetInnerHTML={{ __html: parse.content.preview_html || parse.content.html || '' }} />
</div>
) : (
<pre className="scrollbar-thin max-h-[500px] overflow-auto rounded-lg border border-border bg-surface-overlay p-4 font-mono text-[11px] leading-relaxed text-foreground">
{parse.content.preview_markdown || parse.content.markdown || '(no content)'}
</pre>
)}
</section>
</div>
)}
</div>
)}
{tab === 'reports' && (
<div className="space-y-2">
{reports.length === 0 ? (
<p className="py-12 text-center text-sm text-foreground-muted">No reports yet upload customer data in Maturity Assessment.</p>
) : (
reports.map((r) => (
<a key={r.id} href={`/dq/report/${r.id}`} target="_blank" rel="noreferrer"
className="flex items-center justify-between rounded-lg border border-border bg-surface-overlay/30 px-4 py-3 transition-all hover:border-docker/40 hover:bg-docker/10">
<div>
<p className="text-[12px] font-medium">{r.filename}</p>
<p className="text-[10px] text-foreground-faint">{r.ts} · {r.rows?.toLocaleString()} rows · {r.columns} cols</p>
</div>
<div className="text-right">
<p className={cn('text-xl font-bold', SCORE_COLOR(r.overall_score))}>{r.overall_score}</p>
<p className="text-[10px] text-foreground-muted">{r.maturity_level}</p>
</div>
</a>
))
)}
</div>
)}
</div>
</div>
)
}
function ImageGallery({ images, parseId }: { images: DocImage[]; parseId: string }) {
const available = images.filter((i) => i.available)
const [lightbox, setLightbox] = useState<number | null>(null)
if (!available.length) {
return (
<section className="rounded-lg border border-border bg-surface-overlay/30 p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">
Images gedetecteerd ({images.length}) no embedded export
</h3>
<p className="text-[11px] text-foreground-muted">Re-upload the document to extract images (embedded mode).</p>
</section>
)
}
return (
<section className="rounded-lg border border-border bg-surface-overlay/30 p-4">
<h3 className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">
<Image className="h-4 w-4 text-docker" />
Images die Docling ziet ({available.length})
</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{available.map((img) => (
<button
key={img.index}
type="button"
onClick={() => setLightbox(img.index)}
className="group overflow-hidden rounded-lg border border-border bg-surface-raised text-left transition-all hover:border-docker/50 hover:shadow-docker"
>
<div className="flex aspect-[4/3] items-center justify-center overflow-hidden bg-black/20">
<img
src={img.url || `/dq/parse/${parseId}/image/${img.index}`}
alt={img.label}
className="max-h-full max-w-full object-contain transition-transform group-hover:scale-105"
loading="lazy"
/>
</div>
<div className="p-2">
<p className="text-[10px] font-medium text-foreground">#{img.index + 1} {img.label}</p>
<p className="text-[9px] text-foreground-faint">
{img.width && img.height ? `${Math.round(img.width)}×${Math.round(img.height)}` : ''}
{img.dpi ? ` · ${img.dpi}dpi` : ''}
{img.bytes ? ` · ${(img.bytes / 1024).toFixed(0)}KB` : ''}
</p>
</div>
</button>
))}
</div>
{lightbox !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4" onClick={() => setLightbox(null)}>
<div className="relative max-h-[90vh] max-w-[90vw]" onClick={(e) => e.stopPropagation()}>
<img
src={`/dq/parse/${parseId}/image/${lightbox}`}
alt={`Image ${lightbox + 1}`}
className="max-h-[85vh] max-w-full rounded-lg object-contain"
/>
<button type="button" onClick={() => setLightbox(null)} className="absolute -top-3 -right-3 rounded-full bg-surface-raised px-2 py-1 text-xs text-foreground"></button>
</div>
</div>
)}
</section>
)
}
function DocStructurePanel({ structure, title }: { structure: DocStructure; title: string }) {
return (
<section className="rounded-lg border border-border bg-surface-overlay/30 p-4">
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">{title}</h3>
<div className="mb-3 grid grid-cols-3 gap-2 sm:grid-cols-6">
{[
{ label: 'Pagina\'s', value: structure.pages, icon: Layers },
{ label: 'Images', value: structure.pictures, icon: Image },
{ label: 'Tables', value: structure.tables, icon: Table2 },
{ label: 'Headings', value: structure.headings },
{ label: 'Paragraphs', value: structure.paragraphs },
{ label: 'Text blocks', value: structure.text_blocks },
].map(({ label, value, icon: Icon }) => (
<div key={label} className="rounded-md border border-border bg-surface-raised p-2 text-center">
{Icon && <Icon className="mx-auto mb-1 h-4 w-4 text-docker" />}
<p className="text-lg font-bold text-foreground">{value}</p>
<p className="text-[9px] text-foreground-faint">{label}</p>
</div>
))}
</div>
{structure.picture_details && structure.picture_details.length > 0 && (
<div className="mb-3">
<p className="mb-1 text-[10px] font-medium text-foreground-muted">Images ({structure.picture_details.length})</p>
<div className="flex flex-wrap gap-1">
{structure.picture_details.map((p) => (
<span key={p.index} className="rounded border border-border bg-surface-raised px-2 py-0.5 text-[9px]">
#{p.index + 1} {p.label} {p.has_image ? '🖼' : ''} {p.captions > 0 ? `(${p.captions} captions)` : ''}
</span>
))}
</div>
</div>
)}
{structure.table_details && structure.table_details.length > 0 && (
<div>
<p className="mb-1 text-[10px] font-medium text-foreground-muted">Tables ({structure.table_details.length})</p>
<div className="space-y-1">
{structure.table_details.map((t) => (
<div key={t.index} className="rounded border border-border bg-surface-raised px-2 py-1 text-[10px] text-foreground-muted">
Table {t.index + 1}: {t.rows}×{t.cols} ({t.cells} cells) {t.preview || '…'}
</div>
))}
</div>
</div>
)}
</section>
)
}
function DimensionCard({ dimension: d }: { dimension: Dimension }) {
return (
<div className="rounded-lg border border-border bg-surface-overlay/40 p-3">
<div className="mb-1 flex items-center justify-between">
<span className="text-[12px] font-semibold">{d.label}</span>
<span className={cn('text-base font-bold', SCORE_COLOR(d.score))}>{d.score}</span>
</div>
<div className="mb-2 h-2 overflow-hidden rounded-full bg-border">
<div className={cn('h-full rounded-full', BAR_COLOR(d.score))} style={{ width: `${d.score}%` }} />
</div>
<p className="mb-2 text-[10px] text-foreground-faint">{d.description}</p>
<ul className="space-y-0.5 text-[10px] text-foreground-muted">
{d.findings.map((f) => (
<li key={f} className="flex gap-1"><span className="text-docker"></span>{f}</li>
))}
</ul>
{d.recommended_actions && d.recommended_actions.length > 0 && (
<p className="mt-2 border-t border-border pt-2 text-[9px] text-warning"> {d.recommended_actions[0]}</p>
)}
</div>
)
}
function ColumnTable({ profiles, expandedCol, onToggle }: { profiles: ColumnProfile[]; expandedCol: string | null; onToggle: (n: string | null) => void }) {
return (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-left text-[11px]">
<thead className="bg-surface-overlay text-[10px] uppercase text-foreground-faint">
<tr>
<th className="px-3 py-2">Column</th><th className="px-3 py-2">Type</th><th className="px-3 py-2">Null%</th>
<th className="px-3 py-2">Unique</th><th className="px-3 py-2">Flags</th>
</tr>
</thead>
<tbody>
{profiles.map((c) => (
<Fragment key={c.name}>
<tr className="cursor-pointer border-t border-border hover:bg-surface-overlay/50" onClick={() => onToggle(expandedCol === c.name ? null : c.name)}>
<td className="px-3 py-2 font-mono text-docker">{c.name}</td>
<td className="px-3 py-2">{c.dtype}</td>
<td className={cn('px-3 py-2', c.null_pct > 10 && 'font-semibold text-warning')}>{c.null_pct}%</td>
<td className="px-3 py-2">{c.unique_count.toLocaleString()}</td>
<td className="px-3 py-2 text-foreground-muted">{c.quality_flags.join(', ') || '—'}</td>
</tr>
{expandedCol === c.name && (
<tr className="border-t border-border bg-surface-overlay/20">
<td colSpan={5} className="px-4 py-2 text-[10px] text-foreground-muted">
{c.sample_values?.length ? <p className="mb-1">Samples: {c.sample_values.join(' · ')}</p> : null}
{c.numeric && <p>Range {c.numeric.min} {c.numeric.max}, μ={c.numeric.mean}, {c.numeric.outliers} outliers</p>}
{c.text && <p>Avg len {c.text.avg_length}, {c.text.empty_strings} empty strings</p>}
{c.top_values?.map((tv) => <span key={tv.value} className="mr-3">{tv.value} ({tv.count})</span>)}
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
)
}
function CheckTable({ title, rows }: { title: string; rows: { name: string; status: string; detail: string }[] }) {
return (
<div className="overflow-x-auto rounded-lg border border-border">
<p className="border-b border-border bg-surface-overlay px-3 py-2 text-[11px] font-semibold">{title}</p>
<table className="w-full text-[10px]">
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-t border-border">
<td className="px-3 py-1.5">
<span className={cn('mr-2 rounded px-1.5 py-0.5 text-[9px] font-bold uppercase',
r.status === 'pass' ? 'bg-success/20 text-success' : r.status === 'warn' ? 'bg-warning/20 text-warning' : 'bg-danger/20 text-danger')}>
{r.status}
</span>
{r.name}
</td>
<td className="px-3 py-1.5 text-foreground-muted">{r.detail}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function CapCard({ title, items, icon: Icon }: { title: string; items: string[]; icon: typeof Layers }) {
return (
<div className="rounded-lg border border-border bg-surface-raised/80 p-2.5">
<div className="mb-1 flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5 text-docker" />
<span className="text-[10px] font-semibold uppercase tracking-wide text-foreground-faint">{title}</span>
</div>
<p className="text-[10px] leading-relaxed text-foreground-muted">{items.join(' · ')}</p>
</div>
)
}
function UploadZone({ label, hint, accept, loading, onFile }: { label: string; hint: string; accept: string; loading: boolean; onFile: (f: File) => void }) {
return (
<label className={cn('flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-border/80 bg-surface-overlay/30 px-8 py-10 transition-all hover:border-docker/50 hover:bg-docker/5', loading && 'pointer-events-none opacity-50')}>
<Upload className="mb-3 h-10 w-10 text-docker opacity-60" />
<p className="text-[13px] font-medium text-foreground">{label}</p>
<p className="mt-1 text-[10px] text-foreground-faint">{hint}</p>
<input type="file" accept={accept} className="hidden" disabled={loading} onChange={(e) => e.target.files?.[0] && onFile(e.target.files[0])} />
</label>
)
}
function LoadingMsg({ text }: { text: string }) {
return (
<div className="flex items-center justify-center gap-3 rounded-lg border border-docker/30 bg-docker/5 py-10 text-sm text-foreground-muted">
<Loader2 className="h-6 w-6 animate-spin text-docker" />
{text}
</div>
)
}
function StatCard({ label, value, sub, accent, warn, icon: Icon }: { label: string; value: string; sub?: string; accent?: boolean; warn?: boolean; icon?: typeof Image }) {
return (
<div className="rounded-lg border border-border bg-surface-overlay/40 p-3">
<div className="flex items-center gap-1">
{Icon && <Icon className="h-3.5 w-3.5 text-docker" />}
<p className="text-[9px] uppercase tracking-wider text-foreground-faint">{label}</p>
</div>
<p className={cn('text-xl font-bold', accent ? 'text-docker' : warn ? 'text-warning' : 'text-foreground')}>{value}</p>
{sub && <p className="text-[10px] text-foreground-muted">{sub}</p>}
</div>
)
}
@@ -0,0 +1,205 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { Activity, Cpu, ExternalLink, Thermometer, Zap } from 'lucide-react'
import { fetchGpu } from '../../lib/api'
import type { GpuDevice, GpuStatus } from '../../types'
import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
import { cn } from '../../lib/utils'
type Props = {
gpu: GpuStatus | null
live: GpuLiveMetrics
boost?: boolean
onSelectGpu?: () => void
}
function memPct(used: number, total: number) {
if (!total) return 0
return Math.round((used / total) * 100)
}
function utilColor(pct: number) {
if (pct >= 75) return 'bg-danger'
if (pct >= 35) return 'bg-warning'
return 'bg-success'
}
function GpuRow({ device, liveUtil, active }: { device: GpuDevice; liveUtil: number; active: boolean }) {
const vramPct = memPct(device.memory_used_mib, device.memory_total_mib)
const util = liveUtil ?? device.util_gpu
return (
<div
className={cn(
'rounded-md border border-border/80 bg-surface-overlay/60 px-2 py-1.5 transition-colors',
active && util > 5 && 'border-docker/30 bg-docker/5',
)}
>
<div className="mb-1 flex items-center justify-between gap-1">
<span className="font-mono text-[9px] font-semibold text-foreground">GPU {device.index}</span>
<span className="font-mono text-[8px] text-foreground-faint">{util.toFixed(0)}% · {vramPct}% VRAM</span>
</div>
<div className="space-y-1">
<MetricBar label="Util" value={util} colorClass={utilColor(util)} />
<MetricBar label="VRAM" value={vramPct} colorClass="bg-docker" />
</div>
<div className="mt-1 flex justify-between font-mono text-[7px] text-foreground-faint">
<span className="inline-flex items-center gap-0.5">
<Thermometer className="h-2.5 w-2.5" />
{device.temperature_c?.toFixed(0) ?? '—'}°C
</span>
<span>{device.power_w?.toFixed(0) ?? '—'} W</span>
</div>
</div>
)
}
function MetricBar({ label, value, colorClass }: { label: string; value: number; colorClass: string }) {
return (
<div className="flex items-center gap-1.5">
<span className="w-7 shrink-0 text-[7px] text-foreground-faint">{label}</span>
<div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-raised">
<div
className={cn('h-full rounded-full transition-all duration-700 ease-out', colorClass)}
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
/>
</div>
</div>
)
}
export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props) {
const [localGpu, setLocalGpu] = useState<GpuStatus | null>(gpu)
const [lastPoll, setLastPoll] = useState<Date | null>(null)
useEffect(() => {
setLocalGpu(gpu)
}, [gpu])
useEffect(() => {
const poll = async () => {
const g = await fetchGpu()
if (g) {
setLocalGpu(g)
setLastPoll(new Date())
}
}
poll()
const ms = boost ? 1000 : 3000
const iv = setInterval(poll, ms)
return () => clearInterval(iv)
}, [boost])
const g = localGpu
const devices = g?.gpus || []
const inferenceOn = g?.ok && g.inference_active
const modelLabel = g?.active_model?.replace(/\s*GPTQ$/i, '').replace(/\s*AWQ$/i, '') || 'No model'
const avgUtil = useMemo(() => {
if (devices.length) {
const sum = devices.reduce((s, d, i) => s + (live.deviceUtils[i] ?? d.util_gpu), 0)
return sum / devices.length
}
return live.avgUtil
}, [devices, live.avgUtil, live.deviceUtils])
const avgVram = useMemo(() => {
if (devices.length) {
return devices.reduce((s, d) => s + memPct(d.memory_used_mib, d.memory_total_mib), 0) / devices.length
}
return live.avgVram
}, [devices, live.avgVram])
if (!g?.ok) {
return (
<section className="border-b border-border p-3">
<h2 className="mb-2 flex items-center gap-1.5 text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">
<Cpu className="h-3 w-3" /> GPU Matrix
</h2>
<p className="text-[9px] text-foreground-faint">GPU Lab offline</p>
</section>
)
}
return (
<section className="border-b border-border p-3">
<button
type="button"
onClick={onSelectGpu}
className="mb-2 flex w-full items-start justify-between gap-1 text-left hover:opacity-90"
>
<div className="min-w-0">
<h2 className="flex items-center gap-1.5 text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">
<Cpu className="h-3 w-3 text-docker" /> GPU Matrix
{boost && (
<span className="inline-flex items-center gap-0.5 rounded border border-docker/40 bg-docker/10 px-1 py-px text-[7px] font-bold normal-case tracking-normal text-docker">
<Activity className="h-2.5 w-2.5 animate-pulse" /> Live
</span>
)}
</h2>
<p className="mt-0.5 truncate text-[10px] font-medium text-foreground">{modelLabel}</p>
<p className="font-mono text-[8px] text-foreground-faint">{g.gpu_count ?? devices.length}× V100 · {g.host}</p>
</div>
{g.ui_url && (
<a
href={g.ui_url}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="shrink-0 text-docker hover:underline"
>
<ExternalLink className="h-3 w-3" />
</a>
)}
</button>
<div className="mb-2 grid grid-cols-3 gap-1">
<StatChip
label="Status"
value={inferenceOn ? 'Active' : 'Idle'}
accent={inferenceOn ? 'text-success' : 'text-foreground-muted'}
/>
<StatChip label="Util" value={`${avgUtil.toFixed(0)}%`} accent={avgUtil > 20 ? 'text-warning' : 'text-foreground'} />
<StatChip
label="tok/s"
value={boost && inferenceOn ? String(live.tokenThroughput) : inferenceOn ? '—' : '0'}
icon={<Zap className="h-2.5 w-2.5 text-amber-400" />}
/>
</div>
<div className="scrollbar-thin max-h-[280px] space-y-1.5 overflow-y-auto">
{devices.map((d, i) => (
<GpuRow
key={d.index}
device={d}
liveUtil={live.deviceUtils[i] ?? d.util_gpu}
active={boost}
/>
))}
</div>
<p className="mt-1.5 font-mono text-[7px] text-foreground-faint">
VRAM avg {avgVram.toFixed(0)}% · poll {boost ? '1s' : '3s'}
{lastPoll && ` · ${lastPoll.toLocaleTimeString()}`}
</p>
</section>
)
}
function StatChip({
label,
value,
accent,
icon,
}: {
label: string
value: string
accent?: string
icon?: ReactNode
}) {
return (
<div className="rounded border border-border bg-surface-overlay/80 px-1.5 py-1 text-center">
<p className="flex items-center justify-center gap-0.5 text-[7px] text-foreground-faint">{icon}{label}</p>
<p className={cn('font-mono text-[9px] font-semibold', accent || 'text-foreground')}>{value}</p>
</div>
)
}
+56
View File
@@ -0,0 +1,56 @@
import type { ReactNode } from 'react'
import { Cpu, ExternalLink, Zap } from 'lucide-react'
import type { GpuStatus } from '../../types'
import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
import { Badge } from '../ui/Badge'
type Props = {
gpu: GpuStatus | null
live: GpuLiveMetrics
}
export function GpuMonitor({ gpu, live }: Props) {
if (!gpu) {
return (
<div className="panel flex h-[148px] shrink-0 items-center px-2.5 py-1.5 text-[9px] text-foreground-faint">GPU offline</div>
)
}
const inferenceOn = gpu.inference_active && gpu.ok
const devices = gpu.gpus || []
return (
<div className="panel flex h-[148px] shrink-0 flex-col px-2.5 py-1.5">
<div className="flex min-h-0 flex-1 flex-nowrap items-center gap-x-3 gap-y-1 overflow-x-auto overflow-y-hidden">
<div className="flex items-center gap-1.5">
<Cpu className="h-3 w-3 text-docker" />
<span className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">GPU</span>
<Badge variant={inferenceOn ? 'success' : 'default'} className="!py-0">
{inferenceOn ? 'ON' : 'Standby'}
</Badge>
</div>
<Chip label="Model" value={gpu.active_model?.split('-')[0] || '—'} />
<Chip label="tok/s" value={inferenceOn ? String(live.tokenThroughput) : '—'} icon={<Zap className="h-2.5 w-2.5 text-amber-500" />} />
<Chip label="Util" value={`${Math.round(live.avgUtil)}%`} />
<Chip label="VRAM" value={`${Math.round(live.avgVram)}%`} />
{devices.slice(0, 4).map((d, i) => (
<Chip key={d.index} label={`G${d.index}`} value={`${live.deviceUtils[i] ?? d.util_gpu}%`} />
))}
{gpu.ui_url && (
<a href={gpu.ui_url} target="_blank" rel="noreferrer" className="ml-auto flex items-center gap-0.5 text-[9px] text-docker hover:underline">
{gpu.host} <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
</div>
</div>
)
}
function Chip({ label, value, icon }: { label: string; value: string; icon?: ReactNode }) {
return (
<div className="flex items-center gap-1 rounded border border-border bg-surface-overlay/80 px-1.5 py-0.5">
<span className="flex items-center gap-0.5 text-[8px] text-foreground-faint">{icon}{label}</span>
<span className="font-mono text-[9px] font-medium text-foreground">{value}</span>
</div>
)
}
@@ -0,0 +1,129 @@
import { ExternalLink, RefreshCw, Terminal } from 'lucide-react'
import type { Agent, WorkloadData } from '../../types'
import { getAgentMeta } from '../../lib/agentMeta'
import { copyShellCommand, INFRA_CATALOG, type InfraNode } from '../../lib/infraCatalog'
import { cn } from '../../lib/utils'
type Props = {
workload: WorkloadData | null
agents: Agent[]
selectedNodeId: string | null
busy: boolean
onSelectNode: (id: string) => void
onSelectAgent: (id: string) => void
onProbe: (nodeId: string) => void
onOpenTerminal: (nodeId: string) => void
}
function zoneStats(workload: WorkloadData | null, zoneId: string) {
const z = workload?.zones?.find((x) => x.id === zoneId)
if (!z) return null
return `${z.running}/${z.total}`
}
export function InfraQuickAccess({
workload,
agents,
selectedNodeId,
busy,
onSelectNode,
onSelectAgent,
onProbe,
onOpenTerminal,
}: Props) {
const handleShell = async (node: InfraNode) => {
await copyShellCommand(node.ssh)
onSelectNode(node.id)
onOpenTerminal(node.id)
onProbe(node.id)
}
return (
<section className="panel shrink-0 p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div>
<h3 className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">Infrastructure & Apps</h3>
<p className="text-[8px] text-foreground-faint">Klik voor inspector · Shell kopieert SSH en opent live terminal · UI opent de applicatie</p>
</div>
{workload && (
<span className="shrink-0 font-mono text-[8px] text-foreground-faint">
{workload.totals.apps_running}/{workload.totals.apps_total} containers
</span>
)}
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
{INFRA_CATALOG.map((node) => {
const Icon = node.icon
const agent = agents.find((a) => a.id === node.agentId)
const meta = agent ? getAgentMeta(agent.id) : null
const active = selectedNodeId === node.id || node.topoIds.includes(selectedNodeId || '')
const stats = zoneStats(workload, node.zone)
return (
<div
key={node.id}
className={cn(
'flex flex-col rounded-lg border bg-surface-overlay/60 p-2 transition-colors',
active ? 'border-docker/50 ring-1 ring-docker/20' : 'border-border hover:border-border-strong',
)}
>
<button type="button" onClick={() => onSelectNode(node.id)} className="mb-1.5 text-left">
<div className="flex items-start gap-2">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-surface" style={{ color: node.accent }}>
<Icon className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[11px] font-semibold text-foreground">{node.label}</p>
<p className="font-mono text-[8px] text-foreground-faint">{node.vm} · {node.ip}</p>
{stats && <p className="font-mono text-[8px] text-foreground-muted">{stats} running</p>}
</div>
</div>
<p className="mt-1 line-clamp-2 text-[9px] leading-snug text-foreground-muted">{node.description}</p>
</button>
{agent && meta && (
<button
type="button"
onClick={() => onSelectAgent(agent.id)}
className="mb-1.5 truncate text-left text-[8px] hover:text-docker"
style={{ color: meta.accent }}
>
Agent: {agent.name.split(' ·')[0]}
</button>
)}
<div className="mt-auto flex flex-wrap gap-1">
<button
type="button"
onClick={() => handleShell(node)}
className="inline-flex items-center gap-1 rounded border border-border bg-surface px-1.5 py-0.5 text-[8px] text-foreground-muted hover:border-docker/40 hover:text-docker"
title={node.ssh}
>
<Terminal className="h-3 w-3" /> Shell
</button>
<button
type="button"
onClick={() => { onSelectNode(node.id); onProbe(node.id) }}
disabled={busy}
className="inline-flex items-center gap-1 rounded border border-border bg-surface px-1.5 py-0.5 text-[8px] text-foreground-muted hover:border-border-strong disabled:opacity-50"
>
<RefreshCw className={cn('h-3 w-3', busy && active && 'animate-spin')} /> Probe
</button>
{node.apps.slice(0, 2).map((app) => (
<a
key={app.url}
href={app.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded border border-docker/25 bg-docker-light/40 px-1.5 py-0.5 text-[8px] text-docker hover:underline dark:bg-blue-500/10"
>
<ExternalLink className="h-3 w-3" /> {app.label}
</a>
))}
</div>
</div>
)
})}
</div>
</section>
)
}
@@ -0,0 +1,294 @@
import { useState, type FormEvent } from 'react'
import { ExternalLink, RefreshCw, Terminal, X } from 'lucide-react'
import type { Agent, FeedEntry, GpuStatus, NodeDetail, TerminalLine, TopologyNode, WorkloadData } from '../../types'
import { AGENT_NODE } from '../../lib/constants'
import { getAgentMeta } from '../../lib/agentMeta'
import { copyShellCommand, resolveInfraNode } from '../../lib/infraCatalog'
import { Button } from '../ui/Button'
import { Card, CardDescription, CardTitle } from '../ui/Card'
import { Input } from '../ui/Input'
import { cn } from '../../lib/utils'
type Tab = 'overview' | 'apps' | 'terminal'
type Props = {
node: TopologyNode | null
nodeDetail: NodeDetail | null
agent: Agent | null
agents: Agent[]
workload: WorkloadData | null
gpu: GpuStatus | null
feed: FeedEntry[]
lines: TerminalLine[]
busy: boolean
onProbe: () => void
onAsk: (message: string) => void
onSelectAgent: (id: string) => void
onSendPrompt: (message: string, agentId?: string) => void
onClear: () => void
onOpenTerminal: (nodeId: string) => void
onProbeNodeId: (nodeId: string) => void
}
export function InspectorPanel({
node,
nodeDetail,
agent,
agents,
workload,
gpu,
feed,
lines,
busy,
onProbe,
onAsk,
onSelectAgent,
onSendPrompt,
onClear,
onOpenTerminal,
onProbeNodeId,
}: Props) {
const [tab, setTab] = useState<Tab>('overview')
const [input, setInput] = useState('')
const [shellCopied, setShellCopied] = useState(false)
const d = nodeDetail || node
const infra = resolveInfraNode(d?.id || null)
const linkedAgent = d
? agents.find((a) => a.id === d.id || AGENT_NODE[a.id] === d.id || a.id === infra?.agentId || a.zone === d.id)
: agent
const submit = (e: FormEvent) => {
e.preventDefault()
if (!input.trim() || busy) return
onAsk(input.trim())
setInput('')
setTab('terminal')
if (d?.id) onOpenTerminal(d.id)
}
const runShell = async () => {
if (!infra) return
await copyShellCommand(infra.ssh)
setShellCopied(true)
setTimeout(() => setShellCopied(false), 2000)
onOpenTerminal(infra.id)
onProbeNodeId(infra.id)
setTab('terminal')
}
return (
<aside className="flex min-h-0 flex-1 flex-col bg-surface-raised">
<header className="flex items-start justify-between gap-2 border-b border-border p-3">
<div className="min-w-0">
<p className="text-[9px] font-semibold uppercase tracking-widest text-docker">Inspector</p>
<h2 className="truncate text-sm font-semibold text-foreground">
{d ? d.label : agent ? agent.name.split(' ·')[0] : 'Lab overview'}
</h2>
</div>
{(d || agent) && (
<Button variant="ghost" size="icon" onClick={onClear} aria-label="Clear">
<X className="h-4 w-4" />
</Button>
)}
</header>
{!d && !agent && (
<div className="scrollbar-thin flex-1 space-y-3 overflow-y-auto p-3">
<div className="rounded-lg border border-docker/25 bg-docker-light/50 p-2.5 dark:bg-blue-500/10">
<p className="mb-1.5 text-[10px] font-semibold text-foreground">Snel starten</p>
<ol className="list-decimal space-y-1 pl-4 text-[10px] leading-relaxed text-foreground-muted">
<li>Select an <strong className="text-foreground">infrastructure card</strong> or topology node</li>
<li>Klik <strong className="text-foreground">Shell</strong> voor SSH + live terminal output</li>
<li>Klik <strong className="text-foreground">UI</strong> om Airflow, Trino, Kafka UI, etc. te openen</li>
<li>Stel vragen via <strong className="text-foreground">Chat</strong> onderaan agents zien de hele cluster</li>
</ol>
</div>
<div>
<p className="mb-1.5 text-[9px] uppercase tracking-wider text-foreground-faint">Agents & domeinen</p>
<div className="space-y-1">
{agents.filter((a) => !a.supervisor).map((a) => {
const meta = getAgentMeta(a.id)
return (
<button
key={a.id}
type="button"
onClick={() => onSelectAgent(a.id)}
className="flex w-full items-start gap-2 rounded border border-border bg-surface-overlay/60 px-2 py-1.5 text-left hover:border-border-strong"
>
<span className="mt-0.5 text-[9px] font-semibold" style={{ color: meta.accent }}>{a.name.split(' ·')[0]}</span>
<span className="min-w-0 flex-1 text-[9px] text-foreground-muted">{a.role}</span>
</button>
)
})}
</div>
</div>
{workload && (
<div className="grid grid-cols-2 gap-1.5">
<Stat label="VMs" value={String(workload.totals.vms ?? '—')} />
<Stat label="Containers" value={`${workload.totals.apps_running}/${workload.totals.apps_total}`} />
<Stat label="Connectors" value={String(workload.totals.connectors)} />
<Stat label="Pipeline" value={workload.totals.pipeline_active ? 'active' : 'degraded'} ok={workload.totals.pipeline_active} />
</div>
)}
{gpu?.ok && (
<Card padding className="!p-2">
<CardTitle>GPU · {gpu.host}</CardTitle>
<CardDescription>{gpu.active_model || 'No model'}</CardDescription>
</Card>
)}
</div>
)}
{agent && !d && (
<div className="scrollbar-thin flex-1 overflow-y-auto p-3">
{(() => {
const meta = getAgentMeta(agent.id)
const Icon = meta.icon
return (
<>
<div className="mb-3 flex items-center gap-2 rounded-lg border border-border bg-surface-overlay p-2">
<span className="flex h-10 w-10 items-center justify-center rounded-lg bg-surface" style={{ color: meta.accent }}>
<Icon className="h-5 w-5" />
</span>
<div>
<p className="text-sm font-semibold text-foreground">{agent.name}</p>
<p className="text-[10px] text-foreground-muted">{meta.domain} · {agent.zone}</p>
</div>
</div>
<p className="mb-2 text-[10px] italic text-foreground-muted">"{agent.motto || agent.role}"</p>
<div className="mb-2 flex flex-wrap gap-1">
{(agent.suggested_prompts || []).slice(0, 4).map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => onSendPrompt(prompt, agent.id)}
className="rounded border border-border px-1.5 py-0.5 text-[9px] text-foreground-muted hover:border-docker/40 hover:text-docker"
>
{prompt}
</button>
))}
</div>
<button
type="button"
onClick={() => onOpenTerminal(agent.id)}
className="mb-2 inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[9px] hover:border-docker/40"
>
<Terminal className="h-3 w-3" /> Agent terminal
</button>
</>
)
})()}
</div>
)}
{d && (
<>
{linkedAgent && (
<div className="flex items-center gap-2 border-b border-border bg-surface-overlay/50 px-3 py-1.5">
<span className="text-[8px] uppercase tracking-wider text-foreground-faint">Agent</span>
<button type="button" onClick={() => onSelectAgent(linkedAgent.id)} className="truncate text-[10px] font-medium text-docker hover:underline">
{linkedAgent.name.split(' ·')[0]}
</button>
</div>
)}
<div className="flex flex-wrap items-center gap-1 border-b border-border px-3 py-1.5 font-mono text-[9px] text-foreground-muted">
<span className={cn('h-1.5 w-1.5 rounded-full', d.level === 'ok' ? 'bg-success' : 'bg-warning')} />
{d.vm} · {d.ip}
<span className="ml-auto">{d.running}/{d.total}</span>
</div>
<div className="flex flex-wrap gap-1 border-b border-border p-2">
{infra && (
<Button size="sm" variant="outline" onClick={runShell} disabled={busy}>
<Terminal className="h-3 w-3" />
{shellCopied ? 'SSH gekopieerd' : 'Shell'}
</Button>
)}
<Button size="sm" variant="outline" onClick={onProbe} disabled={busy}>
<RefreshCw className={cn('h-3 w-3', busy && 'animate-spin')} /> Probe
</Button>
{(infra?.apps || []).slice(0, 3).map((app) => (
<a
key={app.url}
href={app.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-md border border-docker/30 bg-docker-light/30 px-2 py-1 text-[9px] text-docker hover:underline dark:bg-blue-500/10"
>
<ExternalLink className="h-3 w-3" /> {app.label}
</a>
))}
{(d.links || []).map((l) => (
<a key={l.url} href={l.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-[9px] hover:bg-surface-overlay">
<ExternalLink className="h-3 w-3" /> {l.label}
</a>
))}
</div>
<nav className="flex gap-1 border-b border-border px-2 py-1">
{(['overview', 'apps', 'terminal'] as Tab[]).map((t) => (
<button key={t} type="button" onClick={() => setTab(t)} className={cn('rounded px-2 py-0.5 text-[10px] capitalize', tab === t ? 'bg-docker-light text-docker' : 'text-foreground-muted')}>
{t}
</button>
))}
</nav>
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto p-3 text-[10px]">
{tab === 'overview' && (
<div className="space-y-2">
{(d.description || infra?.description) && <p className="text-foreground-muted">{d.description || infra?.description}</p>}
{infra && <p className="font-mono text-[9px] text-foreground-faint">{infra.ssh}</p>}
{(d.endpoints || []).map((ep) => (
<p key={ep.name} className="font-mono text-foreground-muted">{ep.name}: {ep.host}:{ep.port}</p>
))}
{(d.commands || []).map((cmd) => (
<button
key={cmd}
type="button"
onClick={() => { setInput(cmd); setTab('terminal') }}
className="block w-full rounded border border-border px-2 py-1 text-left font-mono text-[9px] hover:bg-surface-overlay"
>
$ {cmd}
</button>
))}
</div>
)}
{tab === 'apps' && (
<div className="space-y-1">
{(d.apps || []).map((app) => (
<div key={app.name} className="rounded border border-border bg-surface px-2 py-1">
<p className="font-medium text-foreground">{app.name}</p>
<p className="font-mono text-[9px] text-foreground-faint">{app.state} · {app.image}</p>
</div>
))}
</div>
)}
{tab === 'terminal' && (
<div className="rounded border border-border bg-black/40 p-2 font-mono text-[9px]">
{lines.map((line) => (
<div key={line.id} className="text-foreground-muted">
<span className="text-foreground-faint">{line.ts ? new Date(line.ts).toLocaleTimeString('en-US', { hour12: false }) : ''}</span>{' '}
<span className="text-docker">{line.phase}</span> {line.text}
</div>
))}
{busy && <span className="text-docker animate-pulse"></span>}
</div>
)}
</div>
<form onSubmit={submit} className="flex gap-1 border-t border-border p-2">
<Input value={input} onChange={(e) => setInput(e.target.value)} placeholder={`Vraag over ${d.label}`} disabled={busy} className="text-xs" />
<Button type="submit" size="sm" disabled={busy || !input.trim()}>Send</Button>
</form>
</>
)}
</aside>
)
}
function Stat({ label, value, ok }: { label: string; value: string; ok?: boolean }) {
return (
<div className="rounded border border-border bg-surface-overlay px-2 py-1.5">
<p className="text-[8px] uppercase text-foreground-faint">{label}</p>
<p className={cn('font-mono text-[11px] font-medium', ok === false ? 'text-warning' : ok ? 'text-success' : 'text-foreground')}>{value}</p>
</div>
)
}
@@ -0,0 +1,358 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { BookOpen, FileText, Loader2, MessageSquare, RefreshCw, RotateCcw, Send, Upload } from 'lucide-react'
import { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive'
type Collection = { name: string; documents: number; files?: number; filenames?: string[] }
type StoredDoc = {
id: string
filename: string
collection: string
chunks: number
characters?: number
ingested_at: string
bytes?: number
}
type Source = { source?: string; chunk?: number; preview?: string }
type ChatMsg = { role: 'user' | 'assistant'; content: string; sources?: Source[] }
type Health = { ok: boolean; chroma: boolean; docling: boolean; llm: boolean; embed_model?: string }
type Props = { onGpuActivity?: (active: boolean) => void }
export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
const [health, setHealth] = useState<Health | null>(null)
const [collections, setCollections] = useState<Collection[]>([])
const [collection, setCollection] = useState('default')
const [newCol, setNewCol] = useState('')
const [messages, setMessages] = useState<ChatMsg[]>([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [ingesting, setIngesting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [storedDocs, setStoredDocs] = useState<StoredDoc[]>([])
const [selectedDocId, setSelectedDocId] = useState<string | null>(null)
const [summarizing, setSummarizing] = useState(false)
const [reindexing, setReindexing] = useState<string | null>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const loadMeta = useCallback(async () => {
try {
const [h, c, d] = await Promise.all([
fetch('/rag/health'),
fetch('/rag/collections'),
fetch('/rag/documents'),
])
if (h.ok) setHealth(await h.json())
if (c.ok) {
const j = await c.json()
setCollections(j.collections || [])
}
if (d.ok) {
const j = await d.json()
setStoredDocs(j.documents || [])
}
} catch {
setHealth(null)
}
}, [])
useEffect(() => {
loadMeta()
}, [loadMeta])
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, loading])
useEffect(() => {
onGpuActivity?.(loading || ingesting || summarizing || reindexing !== null)
}, [loading, ingesting, summarizing, reindexing, onGpuActivity])
const onIngest = async (file: File) => {
setIngesting(true)
setError(null)
const fd = new FormData()
fd.append('file', file)
fd.append('collection', collection)
try {
const r = await fetch('/rag/ingest', { method: 'POST', body: fd })
const j = await r.json()
if (!r.ok || !j.ok) {
setError(j.error || 'Ingest failed')
return
}
setMessages((m) => [...m, {
role: 'assistant',
content: j.duplicate
? `Already indexed: ${j.filename} (${j.chunks} chunks). You can chat immediately — no re-upload needed.`
: `Indexed ${j.filename} → collection "${j.collection}" — ${j.chunks} chunks (${j.characters?.toLocaleString()} chars). Stored permanently.`,
}])
loadMeta()
} catch {
setError('RAG API unavailable')
} finally {
setIngesting(false)
}
}
const onSummarize = async (doc: StoredDoc) => {
setSummarizing(true)
setError(null)
setSelectedDocId(doc.id)
setCollection(doc.collection)
setMessages((m) => [...m, { role: 'user', content: `Summarize: ${doc.filename}` }])
try {
const r = await fetch('/rag/summarize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ collection: doc.collection, doc_id: doc.id }),
})
const j = await r.json()
if (!r.ok || !j.ok) {
setError(j.error || 'Summarize failed')
return
}
setMessages((m) => [...m, {
role: 'assistant',
content: `Summary of ${j.filename} (${j.characters?.toLocaleString()} chars):\n\n${j.summary}`,
}])
} catch {
setError('Summarize request failed')
} finally {
setSummarizing(false)
}
}
const onReindex = async (doc: StoredDoc) => {
setReindexing(doc.id)
setError(null)
try {
const r = await fetch(`/rag/documents/${doc.id}/reindex`, { method: 'POST' })
const j = await r.json()
if (!r.ok || !j.ok) {
setError(j.error || 'Re-index failed')
return
}
setMessages((m) => [...m, {
role: 'assistant',
content: `Re-indexed ${j.filename}: ${j.chunks} clean text chunks (${j.characters?.toLocaleString()} chars). You can now chat and summarize.`,
}])
loadMeta()
} catch {
setError('Re-index request failed')
} finally {
setReindexing(null)
}
}
const onSend = async () => {
const msg = input.trim()
if (!msg || loading) return
setInput('')
setError(null)
setMessages((m) => [...m, { role: 'user', content: msg }])
setLoading(true)
try {
const r = await fetch('/rag/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: msg, collection, top_k: 5 }),
})
const j = await r.json()
if (!r.ok || !j.ok) {
setError(j.error || 'Chat failed')
setLoading(false)
return
}
setMessages((m) => [...m, { role: 'assistant', content: j.answer, sources: j.sources }])
} catch {
setError('Failed to reach RAG / LLM service')
} finally {
setLoading(false)
}
}
const createCollection = async () => {
if (!newCol.trim()) return
const fd = new FormData()
fd.append('name', newCol.trim())
await fetch('/rag/collections', { method: 'POST', body: fd })
setCollection(newCol.trim())
setNewCol('')
loadMeta()
}
const doclingUiUrl = `${window.location.protocol}//${window.location.hostname}:5001/ui/`
return (
<div className="flex h-full min-h-[calc(100vh-140px)] flex-col rounded-lg border border-border bg-surface-raised">
<header className="shrink-0 border-b border-border bg-surface-overlay/30 px-4 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-foreground">Knowledge Chat (RAG)</h2>
<p className="text-[11px] text-foreground-muted">
LangChain + ChromaDB chat with your ingested documents via Llama 70B
</p>
</div>
<div className="flex flex-wrap items-center gap-2 text-[10px]">
{health && (
<>
<StatusPill ok={health.chroma} label="ChromaDB" />
<StatusPill ok={health.docling} label="Docling" />
<StatusPill ok={health.llm} label="LLM" />
</>
)}
<button type="button" onClick={loadMeta} className="rounded border border-border p-1.5 hover:bg-surface-overlay">
<RefreshCw className="h-4 w-4" />
</button>
</div>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
<aside className="shrink-0 border-b border-border p-4 lg:w-72 lg:border-b-0 lg:border-r">
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Collection</h3>
<select
value={collection}
onChange={(e) => setCollection(e.target.value)}
className="mb-2 w-full rounded border border-border bg-surface-overlay px-2 py-1.5 text-[11px]"
>
{collections.length === 0 && <option value="default">default (empty)</option>}
{collections.map((c) => (
<option key={c.name} value={c.name}>{c.name} ({c.documents} docs)</option>
))}
</select>
<div className="mb-4 flex gap-1">
<input
value={newCol}
onChange={(e) => setNewCol(e.target.value)}
placeholder="New collection name"
className="min-w-0 flex-1 rounded border border-border bg-surface-overlay px-2 py-1 text-[10px]"
/>
<button type="button" onClick={createCollection} className={cn('shrink-0 rounded px-2 py-1 text-[10px]', subTabIdle)}>Add</button>
</div>
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Ingest documents</h3>
<label className={cn('flex cursor-pointer flex-col items-center rounded-lg border-2 border-dashed border-border px-3 py-4 text-center hover:border-docker/40', ingesting && 'opacity-50')}>
<Upload className="mb-1 h-6 w-6 text-docker opacity-60" />
<span className="text-[10px] font-medium">PDF, PPTX, DOCX, CSV, TXT, MD</span>
<span className="text-[9px] text-foreground-faint">Stored in ChromaDB + disk upload once</span>
<input type="file" className="hidden" disabled={ingesting} accept=".pdf,.pptx,.ppt,.docx,.csv,.txt,.md,.json" onChange={(e) => e.target.files?.[0] && onIngest(e.target.files[0])} />
</label>
{ingesting && (
<p className="mt-2 flex items-center gap-1 text-[10px] text-foreground-muted">
<Loader2 className="h-3 w-3 animate-spin" /> Ingesting & embedding
</p>
)}
<h3 className="mb-2 mt-4 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">
Document library ({storedDocs.length})
</h3>
<div className="scrollbar-thin max-h-40 space-y-1 overflow-y-auto">
{storedDocs.length === 0 ? (
<p className="text-[9px] text-foreground-faint">No documents yet upload above.</p>
) : (
storedDocs.map((doc) => (
<div
key={doc.id}
className={cn(
'rounded border px-2 py-1.5 text-[9px] transition-colors',
selectedDocId === doc.id ? 'border-docker/40 bg-docker/10' : 'border-border',
)}
>
<button type="button" onClick={() => { setSelectedDocId(doc.id); setCollection(doc.collection) }} className="w-full text-left">
<p className="truncate font-medium text-foreground">{doc.filename}</p>
<p className="text-foreground-faint">{doc.collection} · {doc.chunks} chunks · {new Date(doc.ingested_at).toLocaleDateString()}</p>
</button>
<div className="mt-1 flex gap-1">
<button
type="button"
disabled={summarizing}
onClick={() => onSummarize(doc)}
className={cn('flex flex-1 items-center justify-center gap-0.5 rounded px-1 py-0.5', subTabIdle)}
>
{summarizing && selectedDocId === doc.id ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <FileText className="h-2.5 w-2.5" />}
Summarize
</button>
<button
type="button"
disabled={reindexing === doc.id}
onClick={() => onReindex(doc)}
className={cn('flex flex-1 items-center justify-center gap-0.5 rounded px-1 py-0.5', subTabIdle)}
title="Re-parse with clean text (fixes corrupted PDF index)"
>
{reindexing === doc.id ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <RotateCcw className="h-2.5 w-2.5" />}
Re-index
</button>
</div>
</div>
))
)}
</div>
<div className="mt-4 space-y-1 text-[9px] text-foreground-faint">
<p><BookOpen className="mr-1 inline h-3 w-3" />Embed: {health?.embed_model || 'all-MiniLM-L6-v2'}</p>
<a href={doclingUiUrl} target="_blank" rel="noreferrer" className="text-docker hover:underline">Docling UI (port 5001) </a>
</div>
</aside>
<div className="flex min-h-0 flex-1 flex-col">
<div className="scrollbar-thin flex-1 overflow-y-auto p-4">
{messages.length === 0 && (
<div className="flex h-full flex-col items-center justify-center gap-2 text-center text-sm text-foreground-muted">
<MessageSquare className="h-10 w-10 opacity-30" />
<p>Upload once documents stay in ChromaDB. Ask anytime without re-uploading.</p>
<p className="text-[11px]">Example: &quot;What maturity gaps exist in the customer dataset?&quot;</p>
</div>
)}
{messages.map((m, i) => (
<div key={i} className={cn('mb-3 max-w-[90%] rounded-lg px-3 py-2 text-[12px]', m.role === 'user' ? 'ml-auto bg-docker/20 text-foreground' : 'bg-surface-overlay text-foreground-muted')}>
<p className="whitespace-pre-wrap leading-relaxed">{m.content}</p>
{m.sources && m.sources.length > 0 && (
<div className="mt-2 border-t border-border pt-2">
<p className="mb-1 text-[9px] font-semibold uppercase text-foreground-faint">Sources</p>
{m.sources.map((s, j) => (
<p key={j} className="text-[9px] text-foreground-faint">
{s.source} · chunk {s.chunk}: {s.preview?.slice(0, 120)}
</p>
))}
</div>
)}
</div>
))}
{loading && (
<div className="flex items-center gap-2 text-[11px] text-foreground-muted">
<Loader2 className="h-4 w-4 animate-spin text-docker" /> Retrieving context & generating answer
</div>
)}
{error && <p className="text-[11px] text-danger">{error}</p>}
<div ref={bottomRef} />
</div>
<div className="flex shrink-0 gap-2 border-t border-border p-3">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && (e.preventDefault(), onSend())}
placeholder="Ask a question about your ingested data…"
className="min-w-0 flex-1 rounded-lg border border-border bg-surface-overlay px-3 py-2 text-[12px]"
disabled={loading}
/>
<button type="button" onClick={onSend} disabled={loading || !input.trim()} className={cn('rounded-lg px-3 py-2', subTabActive, 'disabled:opacity-40')}>
<Send className="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
)
}
function StatusPill({ ok, label }: { ok: boolean; label: string }) {
return (
<span className={cn('rounded-full px-2 py-0.5 font-medium', ok ? 'bg-success/20 text-success' : 'bg-danger/20 text-danger')}>
{label} {ok ? '●' : '○'}
</span>
)
}
@@ -0,0 +1,393 @@
import { useEffect, useMemo, useState } from 'react'
import { Box } from 'lucide-react'
import type { AgentAnim, WorkloadData } from '../../types'
import { Badge } from '../ui/Badge'
import { cn } from '../../lib/utils'
/* ── Pipeline model ─────────────────────────────────────────────── */
type TopoNode = { id: string; label: string; sub: string; metricKey: string }
type TopoStage = {
id: string
num: number
title: string
subtitle: string
accent: string
nodes: TopoNode[]
}
type FlowKind = 'orchestration' | 'cdc' | 'stream' | 'etl' | 'query' | 'serve'
type FlowEdge = {
from: string
to: string
kind: FlowKind
label: string
}
const STAGES: TopoStage[] = [
{
id: 'sources', num: 1, title: 'SOURCES', subtitle: 'Operational databases', accent: 'topo-stage-col--sources',
nodes: [
{ id: 'postgresql', label: 'PostgreSQL', sub: 'OLTP · primary', metricKey: 'postgresql' },
{ id: 'mysql', label: 'MySQL', sub: 'Replica set', metricKey: 'mysql' },
{ id: 'mongodb', label: 'MongoDB', sub: 'Document store', metricKey: 'mongodb' },
{ id: 'cassandra', label: 'Cassandra', sub: 'Wide-column', metricKey: 'cassandra' },
],
},
{
id: 'ingestion', num: 2, title: 'INGESTION & STREAMING', subtitle: 'CDC · event bus · orchestration', accent: 'topo-stage-col--ingestion',
nodes: [
{ id: 'debezium', label: 'Debezium', sub: 'CDC connectors', metricKey: 'debezium' },
{ id: 'kafka', label: 'Apache Kafka', sub: 'Event bus', metricKey: 'kafka' },
{ id: 'airflow', label: 'Apache Airflow', sub: 'Daily Python DAGs · source sync', metricKey: 'airflow' },
],
},
{
id: 'compute', num: 3, title: 'COMPUTE', subtitle: 'Processing & query', accent: 'topo-stage-col--compute',
nodes: [
{ id: 'spark', label: 'Apache Spark', sub: 'Batch / micro-batch', metricKey: 'spark' },
{ id: 'trino', label: 'Trino', sub: 'Distributed SQL', metricKey: 'trino' },
],
},
{
id: 'storage', num: 4, title: 'STORAGE', subtitle: 'Lakehouse layer', accent: 'topo-stage-col--storage',
nodes: [
{ id: 'iceberg', label: 'Iceberg Tables', sub: 'Open table format', metricKey: 'iceberg' },
{ id: 's3', label: 'Dell ECS S3', sub: 'Object scale', metricKey: 's3' },
],
},
{
id: 'consumers', num: 5, title: 'CONSUMERS', subtitle: 'Analytics & AI', accent: 'topo-stage-col--consumers',
nodes: [
{ id: 'bi', label: 'BI / Reporting', sub: 'Dashboards', metricKey: 'bi' },
{ id: 'jupyter', label: 'Jupyter Notebooks', sub: 'Data science', metricKey: 'jupyter' },
{ id: 'llm', label: 'GenAI LLM', sub: 'vLLM inference', metricKey: 'llm' },
],
},
]
/** Full data-foundation flows — Airflow daily Python generation + CDC stream + lakehouse */
const FLOW_EDGES: FlowEdge[] = [
// Airflow orchestrates daily Python jobs on every source
{ from: 'airflow', to: 'postgresql', kind: 'orchestration', label: 'Daily Python gen' },
{ from: 'airflow', to: 'mysql', kind: 'orchestration', label: 'Daily Python gen' },
{ from: 'airflow', to: 'mongodb', kind: 'orchestration', label: 'Daily Python gen' },
{ from: 'airflow', to: 'cassandra', kind: 'orchestration', label: 'Daily Python gen' },
// CDC capture from sources
{ from: 'postgresql', to: 'debezium', kind: 'cdc', label: 'CDC' },
{ from: 'mysql', to: 'debezium', kind: 'cdc', label: 'CDC' },
{ from: 'mongodb', to: 'debezium', kind: 'cdc', label: 'CDC' },
{ from: 'cassandra', to: 'debezium', kind: 'cdc', label: 'CDC' },
// Streaming bus
{ from: 'debezium', to: 'kafka', kind: 'stream', label: 'Events' },
{ from: 'airflow', to: 'kafka', kind: 'orchestration', label: 'DAG trigger' },
// ETL compute
{ from: 'kafka', to: 'spark', kind: 'etl', label: 'Micro-batch' },
{ from: 'airflow', to: 'spark', kind: 'orchestration', label: 'Pipeline DAG' },
{ from: 'spark', to: 'iceberg', kind: 'etl', label: 'Lake write' },
{ from: 'spark', to: 's3', kind: 'etl', label: 'Object export' },
// Query & serve
{ from: 'iceberg', to: 'trino', kind: 'query', label: 'SQL' },
{ from: 'trino', to: 'bi', kind: 'serve', label: 'Reports' },
{ from: 'iceberg', to: 'jupyter', kind: 'serve', label: 'Notebooks' },
{ from: 's3', to: 'jupyter', kind: 'serve', label: 'Datasets' },
{ from: 'trino', to: 'llm', kind: 'serve', label: 'RAG context' },
{ from: 's3', to: 'llm', kind: 'serve', label: 'Model artifacts' },
]
const STAGE_BADGE: Record<string, string> = {
sources: 'border-emerald-400/50 bg-emerald-500/20 text-emerald-300',
ingestion: 'border-cyan-400/50 bg-cyan-500/20 text-cyan-300',
compute: 'border-violet-400/50 bg-violet-500/20 text-violet-300',
storage: 'border-blue-400/50 bg-blue-500/20 text-blue-300',
consumers: 'border-amber-400/50 bg-amber-500/20 text-amber-300',
}
const FLOW_LEGEND: { kind: FlowKind; label: string; color: string }[] = [
{ kind: 'orchestration', label: 'Airflow orchestration', color: '#f59e0b' },
{ kind: 'cdc', label: 'CDC capture', color: '#22d3ee' },
{ kind: 'stream', label: 'Event stream', color: '#38bdf8' },
{ kind: 'etl', label: 'ETL / compute', color: '#a78bfa' },
{ kind: 'query', label: 'SQL query', color: '#818cf8' },
{ kind: 'serve', label: 'Consumption', color: '#34d399' },
]
const EDGE_CLASS: Record<FlowKind, string> = {
orchestration: 'topo-edge-orchestration',
cdc: 'topo-edge-cdc',
stream: 'topo-edge-stream',
etl: 'topo-edge-etl',
query: 'topo-edge-query',
serve: 'topo-edge-serve',
}
const PARTICLE_FILL: Record<FlowKind, string> = {
orchestration: '#fbbf24',
cdc: '#22d3ee',
stream: '#38bdf8',
etl: '#c4b5fd',
query: '#818cf8',
serve: '#34d399',
}
const NODE_CLICK_MAP: Record<string, string> = {
postgresql: 'src-postgres', mysql: 'src-mysql', mongodb: 'src-mongo', cassandra: 'src-cassandra',
debezium: 'cdc-postgres', kafka: 'stream-kafka', airflow: 'src-airflow', spark: 'stream-spark',
trino: 'query-trino', iceberg: 'lake-iceberg', s3: 'lake-s3', bi: 'cons-bi',
jupyter: 'cons-notebooks', llm: 'cons-ml',
}
const NODE_POS: Record<string, { col: number; row: number; rows: number }> = {}
STAGES.forEach((stage, col) => {
stage.nodes.forEach((node, row) => {
NODE_POS[node.id] = { col, row, rows: stage.nodes.length }
})
})
function nodeCoords(col: number, row: number, rows: number) {
const colW = 100 / 5
const yPad = 8
const ySpan = 84
const y = yPad + ((row + 0.5) / rows) * ySpan
return {
inX: col * colW + colW * 0.08,
outX: col * colW + colW * 0.92,
y,
}
}
/** Curved path — arcs upward for backward (orchestration) flows */
function flowPath(x1: number, y1: number, x2: number, y2: number, backward = false) {
if (backward || x2 < x1 - 2) {
const arcY = Math.min(y1, y2) - 14
return `M ${x1} ${y1} C ${x1} ${arcY}, ${x2} ${arcY}, ${x2} ${y2}`
}
const mx = (x1 + x2) / 2
return `M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`
}
type MetricState = Record<string, string>
function seedMetrics(): MetricState {
return {
postgresql: '12.4k rows/s', mysql: '8.1k rows/s', mongodb: '2.3k docs/s', cassandra: '5.6k ops/s',
debezium: '4 connectors active', kafka: '142 MB/s', airflow: '18 DAGs · daily 02:00 UTC',
spark: '6 executors live', trino: '3 queries active', iceberg: '847 tables · 2.1 TB', s3: '14.2 TB stored',
bi: '26 dashboards', jupyter: '12 kernels active', llm: 'Checking…',
}
}
function formatLlmLabel(model?: string | null): string {
if (!model) return 'GenAI LLM'
return model.replace(/\s*GPTQ$/i, '').replace(/\s*AWQ$/i, '').trim()
}
function formatLlmMetric(workload: WorkloadData | null): string {
const gpu = workload?.gpu
if (!gpu?.model) return 'Connecting…'
if (!gpu.inference_active) return 'Offline'
const gpus = gpu.gpus || []
const util = gpu.avg_util ?? (gpus.length ? gpus.reduce((s, g) => s + g.util_gpu, 0) / gpus.length : 0)
const vram = gpus.length
? gpus.reduce((s, g) => s + (g.memory_used_mib / Math.max(g.memory_total_mib, 1)) * 100, 0) / gpus.length
: 0
if (util >= 1) return `${util.toFixed(0)}% GPU · live`
if (vram >= 50) return `Loaded · ${vram.toFixed(0)}% VRAM`
return 'Inference active'
}
function jitterMetric(key: string, current: string, workload: WorkloadData | null): string {
if (key === 'llm') return formatLlmMetric(workload)
const n = () => (Math.random() - 0.5) * 2
const fns: Record<string, () => string> = {
postgresql: () => `${(12.4 + n() * 0.8).toFixed(1)}k rows/s`,
mysql: () => `${(8.1 + n() * 0.6).toFixed(1)}k rows/s`,
mongodb: () => `${(2.3 + n() * 0.3).toFixed(1)}k docs/s`,
cassandra: () => `${(5.6 + n() * 0.5).toFixed(1)}k ops/s`,
debezium: () => `${Math.max(3, Math.round(4 + n()))} connectors active`,
kafka: () => `${Math.max(80, Math.round(142 + n() * 18))} MB/s`,
airflow: () => `${Math.max(12, Math.round(18 + n() * 2))} DAGs · daily 02:00 UTC`,
spark: () => `${Math.max(4, Math.round(6 + n()))} executors live`,
trino: () => `${Math.max(1, Math.round(3 + n()))} queries active`,
iceberg: () => `${Math.round(847 + n() * 5)} tables · ${(2.1 + n() * 0.05).toFixed(1)} TB`,
s3: () => `${(14.2 + n() * 0.08).toFixed(1)} TB stored`,
bi: () => `${Math.max(20, Math.round(26 + n() * 2))} dashboards`,
jupyter: () => `${Math.max(8, Math.round(12 + n() * 2))} kernels active`,
}
return fns[key]?.() ?? current
}
type Props = {
workload: WorkloadData | null
animations: Record<string, AgentAnim>
selectedNodeId: string | null
onNodeClick: (nodeId: string) => void
}
export function PlatformTopology({ workload, animations, selectedNodeId, onNodeClick }: Props) {
const [metrics, setMetrics] = useState<MetricState>(seedMetrics)
const llmLabel = formatLlmLabel(workload?.gpu?.model)
const pipelineActive = workload?.totals?.pipeline_active ?? true
const anyBusy = useMemo(
() => Object.values(animations).some((a) => a.state !== 'idle'),
[animations],
)
const edgesLive = pipelineActive || anyBusy
useEffect(() => {
setMetrics((prev) => ({ ...prev, llm: formatLlmMetric(workload) }))
}, [workload?.gpu?.model, workload?.gpu?.inference_active, workload?.gpu?.avg_util, workload?.gpu?.gpus])
useEffect(() => {
const iv = setInterval(() => {
setMetrics((prev) => {
const next = { ...prev }
for (const k of Object.keys(next)) next[k] = jitterMetric(k, prev[k], workload)
return next
})
}, 2200)
return () => clearInterval(iv)
}, [workload])
const resolvedSel = selectedNodeId
? Object.entries(NODE_CLICK_MAP).find(([, v]) => v === selectedNodeId)?.[0] ?? null
: null
return (
<section className="panel flex h-full min-h-0 flex-1 flex-col overflow-hidden">
<header
className="flex shrink-0 flex-col gap-1 border-b border-border px-3 py-1.5"
style={{ background: 'var(--topo-header-bg)' }}
>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-docker text-white shadow-docker">
<Box className="h-3 w-3" />
</span>
<div className="min-w-0">
<h2 className="truncate text-xs font-semibold text-foreground">Data Platform Topology</h2>
<p className="truncate text-[9px] text-foreground-muted">
Airflow daily Python CDC stream lakehouse consumers
</p>
</div>
</div>
<div className="flex shrink-0 flex-wrap justify-end gap-1">
<Badge variant={pipelineActive ? 'success' : 'warning'}>
{pipelineActive ? 'Pipeline active' : 'Degraded'}
</Badge>
<Badge>{workload?.totals?.connectors ?? 4} CDC</Badge>
<Badge variant="accent">{FLOW_EDGES.length} flows</Badge>
</div>
</div>
<div className="flex flex-wrap gap-x-2 gap-y-0.5">
{FLOW_LEGEND.map((item) => (
<span key={item.kind} className="inline-flex items-center gap-1 text-[8px] text-foreground-muted">
<span className="h-1.5 w-1.5 rounded-full" style={{ background: item.color }} />
{item.label}
</span>
))}
</div>
</header>
<div className="topo-canvas flex min-h-0 flex-1">
<svg
className="pointer-events-none absolute inset-0 z-0 h-full w-full"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden
>
<defs>
<linearGradient id="topo-flow-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#22d3ee" stopOpacity="0.7" />
<stop offset="50%" stopColor="#34d399" stopOpacity="1" />
<stop offset="100%" stopColor="#60a5fa" stopOpacity="0.7" />
</linearGradient>
</defs>
{FLOW_EDGES.map((edge, i) => {
const pa = NODE_POS[edge.from]
const pb = NODE_POS[edge.to]
if (!pa || !pb) return null
const a = nodeCoords(pa.col, pa.row, pa.rows)
const b = nodeCoords(pb.col, pb.row, pb.rows)
const backward = edge.kind === 'orchestration' && pb.col < pa.col
const fromX = backward ? a.inX + (a.outX - a.inX) * 0.15 : a.outX
const toX = backward ? b.outX - (b.outX - b.inX) * 0.15 : b.inX
const d = flowPath(fromX, a.y, toX, b.y, backward)
const live = edgesLive
const dur = 1.8 + (i % 5) * 0.35
return (
<g key={`${edge.from}-${edge.to}-${edge.kind}`}>
<path d={d} className="topo-edge-glow" vectorEffect="non-scaling-stroke" />
<path
d={d}
className={cn(EDGE_CLASS[edge.kind], live ? 'topo-edge-live' : 'topo-edge-idle')}
vectorEffect="non-scaling-stroke"
/>
{live && (
<>
<circle r="0.55" fill={PARTICLE_FILL[edge.kind]} opacity="0.95">
<animateMotion dur={`${dur}s`} repeatCount="indefinite" path={d} />
</circle>
<circle r="0.35" fill="#ffffff" opacity="0.85">
<animateMotion dur={`${dur}s`} repeatCount="indefinite" path={d} begin={`${dur * 0.45}s`} />
</circle>
</>
)}
</g>
)
})}
</svg>
<div className="relative z-10 flex h-full min-h-0 w-full overflow-x-auto">
{STAGES.map((stage) => (
<div key={stage.id} className={cn('topo-stage-col', stage.accent)}>
<header className="mb-1 shrink-0 border-b border-white/10 pb-1">
<div className="flex items-start gap-1">
<span className={cn('rounded border px-1 py-px font-mono text-[8px] font-bold', STAGE_BADGE[stage.id])}>
0{stage.num}
</span>
<div className="min-w-0">
<h3 className="text-[8px] font-bold leading-tight tracking-wide text-white">{stage.title}</h3>
<p className="text-[7px] text-blue-200/70">{stage.subtitle}</p>
</div>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col justify-evenly gap-1">
{stage.nodes.map((node) => {
const label = node.id === 'llm' ? llmLabel : node.label
const sub = node.id === 'llm'
? (workload?.gpu?.inference_active ? 'vLLM · live' : 'vLLM inference')
: node.sub
return (
<button
key={node.id}
type="button"
onClick={() => onNodeClick(NODE_CLICK_MAP[node.id] || node.id)}
className={cn(
'topo-node',
node.id === 'airflow' && 'topo-node-airflow',
node.id === 'llm' && workload?.gpu?.inference_active && 'topo-node-airflow',
resolvedSel === node.id && 'topo-node-selected',
)}
>
<span className="block truncate text-[10px] font-semibold leading-tight text-white">{label}</span>
<span className="block truncate text-[8px] text-blue-100/80">{sub}</span>
<span className="mt-0.5 inline-block max-w-full truncate rounded border border-emerald-400/35 bg-emerald-500/20 px-1 py-px font-mono text-[7px] font-medium text-emerald-300">
{metrics[node.metricKey]}
</span>
</button>
)
})}
</div>
</div>
))}
</div>
</div>
</section>
)
}
@@ -0,0 +1,218 @@
import { useCallback, useEffect, useState } from 'react'
import { ExternalLink, FileUp, Monitor, Upload } from 'lucide-react'
import { ArchitectureDiagram } from './ArchitectureDiagram'
import type { PresentationData, PresentationSlide } from '../../types'
import { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive'
type DeckSource = 'live' | 'data-maturity' | 'atc-platform' | string
const KIND_STYLES: Record<string, string> = {
hero: 'from-blue-600/25 via-violet-600/20 to-emerald-600/15',
narrative: 'from-slate-600/15 to-blue-600/15',
topology: 'from-cyan-600/20 to-blue-800/15',
zone: 'from-amber-600/15 to-orange-600/10',
gpu: 'from-emerald-600/20 to-green-800/15',
agents: 'from-fuchsia-600/15 to-pink-600/10',
cta: 'from-blue-600/15 to-violet-600/20',
upload: 'from-indigo-600/15 to-purple-600/10',
command: 'from-sky-600/15 to-blue-600/10',
architecture: 'from-teal-600/15 to-cyan-600/10',
}
async function fetchDeck(id: DeckSource): Promise<PresentationData | null> {
const ctrl = new AbortController()
const timeout = id === 'live' ? 45000 : 10000
const timer = setTimeout(() => ctrl.abort(), timeout)
try {
const url = id === 'live' ? '/api/presentation' : `/api/presentation/decks/${id}`
const r = await fetch(url, { signal: ctrl.signal })
if (!r.ok) return null
return (await r.json()) as PresentationData
} catch {
return null
} finally {
clearTimeout(timer)
}
}
export function PresentationView() {
const [source, setSource] = useState<DeckSource>('live')
const [data, setData] = useState<PresentationData | null>(null)
const [slideIdx, setSlideIdx] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [uploadMsg, setUploadMsg] = useState<string | null>(null)
const [customDecks, setCustomDecks] = useState<{ id: string; title: string }[]>([])
const load = useCallback(async (deckId: DeckSource) => {
setLoading(true)
setError(null)
const d = await fetchDeck(deckId)
if (d && d.slides?.length) {
setData(d)
setSlideIdx(0)
setLoading(false)
return
}
if (deckId === 'live') {
const fallback = await fetchDeck('data-maturity')
if (fallback?.slides?.length) {
setData(fallback)
setSlideIdx(0)
setError('Live deck timeout — showing Data Maturity template. Click Refresh for live cluster data.')
setLoading(false)
return
}
}
setData(null)
setError('Could not load presentation.')
setLoading(false)
}, [])
useEffect(() => {
load(source)
fetch('/api/presentation/decks')
.then((r) => r.json())
.then((j) => {
const uploaded = (j.uploaded || []).map((d: { id: string; title: string }) => ({ id: d.id, title: d.title }))
setCustomDecks(uploaded)
})
.catch(() => {})
}, [source, load])
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const n = data?.slides.length || 1
if (e.key === 'ArrowRight' || e.key === ' ') { e.preventDefault(); setSlideIdx((i) => Math.min(n - 1, i + 1)) }
if (e.key === 'ArrowLeft') setSlideIdx((i) => Math.max(0, i - 1))
if (e.key === 'f' || e.key === 'F') document.documentElement.requestFullscreen?.()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [data?.slides.length])
const slides = data?.slides || []
const slide: PresentationSlide | undefined = slides[slideIdx]
const exportHtml = () => {
const id = source === 'live' ? 'live' : source
window.open(`/api/presentation/decks/${id}/html`, '_blank')
}
const onUpload = async (file: File) => {
setUploading(true)
setUploadMsg(null)
const fd = new FormData()
fd.append('file', file)
try {
const r = await fetch('/api/presentation/upload', { method: 'POST', body: fd })
const j = await r.json()
if (j.ok && j.deck) {
setCustomDecks((prev) => [{ id: j.deck.id, title: j.deck.title }, ...prev])
setSource(j.deck.id)
setUploadMsg(`${j.deck.slide_count} slides loaded from ${file.name}`)
} else {
setUploadMsg(j.error || 'Upload failed')
}
} catch {
setUploadMsg('Upload failed — check connection')
} finally {
setUploading(false)
}
}
const tabs: { id: DeckSource; label: string }[] = [
{ id: 'live', label: 'Live Cluster' },
{ id: 'stack-architecture', label: 'Stack Architecture' },
{ id: 'data-maturity', label: 'Data Maturity' },
{ id: 'atc-platform', label: 'ATC Platform' },
...customDecks.map((d) => ({ id: d.id, label: d.title.slice(0, 18) })),
]
return (
<div className="flex h-full min-h-[calc(100vh-140px)] flex-col overflow-hidden rounded-lg border border-border bg-surface-raised">
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border bg-surface-raised/90 px-3 py-2">
<div>
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-foreground">Presentation</h2>
<p className="text-[9px] text-foreground-muted">
Live cluster · HTML templates · PPT upload (converts via python-pptx + Docling)
</p>
</div>
<div className="flex flex-wrap gap-1">
<a href="/dq/" target="_blank" rel="noreferrer" className={cn('inline-flex items-center gap-1 rounded-md px-2 py-1 text-[9px]', subTabIdle)}>
<Monitor className="h-3 w-3" /> DQ Portal
</a>
<a href="/docling/ui/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">
<ExternalLink className="h-3 w-3" /> Docling
</a>
<button type="button" onClick={() => load(source)} className="rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">Refresh</button>
<button type="button" onClick={exportHtml} className={cn('rounded-md px-2 py-1 text-[9px]', subTabActive)}>Export HTML</button>
</div>
</div>
<div className="flex shrink-0 flex-wrap gap-1 border-b border-border bg-surface-overlay/40 px-2 py-1.5">
{tabs.map((t) => (
<button
key={t.id}
type="button"
onClick={() => setSource(t.id)}
className={cn(
'rounded-md px-2.5 py-1.5 text-[10px] font-medium transition-all',
source === t.id ? subTabActive : subTabIdle,
)}
>
{t.label}
</button>
))}
<label className={cn('ml-auto inline-flex cursor-pointer items-center gap-1 rounded-md border border-dashed border-border px-2 py-1 text-[10px]', uploading && 'opacity-50')}>
<Upload className="h-3 w-3" />
{uploading ? 'Uploading…' : 'PPT upload'}
<input type="file" accept=".ppt,.pptx,.pdf,.docx" className="hidden" disabled={uploading} onChange={(e) => e.target.files?.[0] && onUpload(e.target.files[0])} />
</label>
</div>
{uploadMsg && <p className="shrink-0 px-3 py-1 text-[10px] text-docker">{uploadMsg}</p>}
{error && <p className="shrink-0 px-3 py-1 text-[10px] text-warning">{error}</p>}
{loading ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-sm text-foreground-muted">
<FileUp className="h-8 w-8 animate-pulse opacity-40" />
<p>Loading presentation{source === 'live' ? ' (live cluster snapshot, ~15 sec)' : '…'}</p>
</div>
) : !slide ? (
<div className="flex flex-1 items-center justify-center text-sm text-foreground-muted">
<button type="button" onClick={() => load(source)} className="rounded border border-border px-3 py-1 text-xs">Retry</button>
</div>
) : (
<>
<div className={cn('relative flex min-h-0 flex-1 flex-col justify-center bg-gradient-to-br p-6 md:p-10', KIND_STYLES[slide.kind || 'narrative'] || KIND_STYLES.narrative)}>
<div className="max-w-4xl">
<p className="mb-1 text-[10px] font-medium uppercase tracking-widest text-docker/80">{slide.kind || 'slide'} · {slideIdx + 1}/{slides.length}</p>
<h1 className="mb-2 text-2xl font-bold tracking-tight text-foreground md:text-4xl">{slide.title}</h1>
{slide.subtitle && <p className="mb-4 text-sm text-foreground-muted md:text-base">{slide.subtitle}</p>}
{'animation' in slide && slide.animation && (
<ArchitectureDiagram animation={String(slide.animation)} />
)}
<ul className="space-y-2 text-sm leading-relaxed text-foreground md:text-base">
{(slide.bullets || []).map((b: string) => (
<li key={b} className="flex gap-2"><span className="shrink-0 text-docker"></span><span>{b}</span></li>
))}
</ul>
</div>
</div>
<div className="flex shrink-0 items-center gap-2 border-t border-border bg-surface-raised/90 px-3 py-2">
<button type="button" disabled={slideIdx === 0} onClick={() => setSlideIdx((i) => Math.max(0, i - 1))} className="rounded border border-border px-2 py-1 text-[10px] disabled:opacity-40"> Prev</button>
<div className="flex flex-1 flex-wrap justify-center gap-1">
{slides.map((_: PresentationSlide, i: number) => (
<button key={i} type="button" onClick={() => setSlideIdx(i)} className={cn('h-2 w-2 rounded-full', i === slideIdx ? 'scale-125 bg-docker' : 'bg-border')} />
))}
</div>
<button type="button" disabled={slideIdx >= slides.length - 1} onClick={() => setSlideIdx((i) => Math.min(slides.length - 1, i + 1))} className="rounded border border-border px-2 py-1 text-[10px] disabled:opacity-40">Next </button>
</div>
</>
)}
</div>
)
}
+197
View File
@@ -0,0 +1,197 @@
import { useCallback, useEffect, useState } from 'react'
import { ChevronRight, Database, Download, ExternalLink, Folder, HardDrive, Loader2, RefreshCw } from 'lucide-react'
import { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive'
type Bucket = { name: string; created?: string; has_objects?: boolean }
type S3Item = { type: string; name?: string; prefix?: string; key?: string; size_human?: string; modified?: string }
export function StorageView() {
const [health, setHealth] = useState<{ ok: boolean; endpoint?: string; bucket_names?: string[]; error?: string } | null>(null)
const [buckets, setBuckets] = useState<Bucket[]>([])
const [bucket, setBucket] = useState<string | null>(null)
const [prefix, setPrefix] = useState('')
const [folders, setFolders] = useState<S3Item[]>([])
const [objects, setObjects] = useState<S3Item[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const loadBuckets = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [h, b] = await Promise.all([
fetch('/api/storage/s3/health'),
fetch('/api/storage/s3/buckets'),
])
if (h.ok) setHealth(await h.json())
if (b.ok) {
const j = await b.json()
setBuckets(j.buckets || [])
if (!bucket && j.buckets?.length) setBucket(j.buckets[0].name)
} else {
setError('Failed to load buckets')
}
} catch {
setError('S3 API unavailable')
} finally {
setLoading(false)
}
}, [bucket])
const loadObjects = useCallback(async (b: string, p: string) => {
setLoading(true)
setError(null)
try {
const r = await fetch(`/api/storage/s3/buckets/${encodeURIComponent(b)}/objects?prefix=${encodeURIComponent(p)}`)
const j = await r.json()
if (!r.ok || !j.ok) {
setError(j.error || 'List failed')
return
}
setFolders(j.folders || [])
setObjects(j.objects || [])
} catch {
setError('Failed to list objects')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadBuckets()
}, [loadBuckets])
useEffect(() => {
if (bucket) loadObjects(bucket, prefix)
}, [bucket, prefix, loadObjects])
const crumbs = prefix ? prefix.split('/').filter(Boolean) : []
return (
<div className="panel flex h-full min-h-0 flex-1 flex-col overflow-hidden">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3">
<div>
<h2 className="flex items-center gap-2 text-sm font-semibold text-foreground">
<HardDrive className="h-4 w-4 text-docker" />
ObjectScale S3 Storage
</h2>
<p className="text-[10px] text-foreground-muted">
Dell ECS · {health?.endpoint || '10.0.20.111:9020'} · live bucket browser
</p>
</div>
<div className="flex flex-wrap gap-2">
<a href="/jupyter/" target="_blank" rel="noreferrer" className={cn('inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-[11px] font-medium', subTabActive)}>
<ExternalLink className="h-3 w-3" /> Open Jupyter
</a>
<button type="button" onClick={() => { loadBuckets(); if (bucket) loadObjects(bucket, prefix) }} className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
<RefreshCw className={cn('inline h-3 w-3', loading && 'animate-spin')} /> Refresh
</button>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
<aside className="shrink-0 border-b border-border p-3 lg:w-52 lg:border-b-0 lg:border-r">
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Buckets</h3>
<div className="space-y-1">
{buckets.map((b) => (
<button
key={b.name}
type="button"
onClick={() => { setBucket(b.name); setPrefix('') }}
className={cn(
'flex w-full items-center gap-2 rounded border px-2 py-1.5 text-left text-[10px]',
bucket === b.name ? 'border-docker/40 bg-docker/10' : 'border-border hover:bg-surface-overlay',
)}
>
<Database className="h-3 w-3 shrink-0 text-docker" />
<span className="truncate font-medium">{b.name}</span>
</button>
))}
{buckets.length === 0 && !loading && (
<p className="text-[9px] text-foreground-faint">No buckets or access denied.</p>
)}
</div>
</aside>
<div className="flex min-h-0 flex-1 flex-col p-3">
{bucket && (
<nav className="mb-2 flex flex-wrap items-center gap-1 text-[10px] text-foreground-muted">
<button type="button" className="hover:text-docker" onClick={() => setPrefix('')}>{bucket}</button>
{crumbs.map((c, i) => (
<span key={i} className="inline-flex items-center gap-1">
<ChevronRight className="h-3 w-3" />
<button
type="button"
className="hover:text-docker"
onClick={() => setPrefix(crumbs.slice(0, i + 1).join('/') + '/')}
>
{c}
</button>
</span>
))}
</nav>
)}
{loading && (
<p className="flex items-center gap-2 text-[11px] text-foreground-muted">
<Loader2 className="h-4 w-4 animate-spin" /> Loading
</p>
)}
{error && <p className="mb-2 text-[11px] text-danger">{error}</p>}
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto">
<table className="w-full text-left text-[11px]">
<thead>
<tr className="border-b border-border text-[9px] uppercase text-foreground-faint">
<th className="py-1.5 pr-2">Name</th>
<th className="py-1.5 pr-2">Size</th>
<th className="py-1.5 pr-2">Modified</th>
<th className="py-1.5" />
</tr>
</thead>
<tbody>
{folders.map((f) => (
<tr key={f.prefix} className="border-b border-border/50 hover:bg-surface-overlay/50">
<td className="py-1.5 pr-2">
<button
type="button"
className="inline-flex items-center gap-1 font-medium text-docker hover:underline"
onClick={() => setPrefix(f.prefix || '')}
>
<Folder className="h-3.5 w-3.5" /> {f.name}/
</button>
</td>
<td className="py-1.5 pr-2 text-foreground-faint"></td>
<td className="py-1.5 pr-2 text-foreground-faint"></td>
<td />
</tr>
))}
{objects.map((o) => (
<tr key={o.key} className="border-b border-border/50 hover:bg-surface-overlay/50">
<td className="max-w-[240px] truncate py-1.5 pr-2 font-mono text-[10px]">{o.name || o.key}</td>
<td className="py-1.5 pr-2 text-foreground-muted">{o.size_human}</td>
<td className="py-1.5 pr-2 text-foreground-faint">{o.modified?.slice(0, 19) || '—'}</td>
<td className="py-1.5">
{o.key && bucket && (
<a
href={`/api/storage/s3/buckets/${encodeURIComponent(bucket)}/download?key=${encodeURIComponent(o.key)}`}
className="inline-flex items-center gap-0.5 text-docker hover:underline"
>
<Download className="h-3 w-3" />
</a>
)}
</td>
</tr>
))}
</tbody>
</table>
{!loading && folders.length === 0 && objects.length === 0 && bucket && (
<p className="py-8 text-center text-sm text-foreground-muted">This prefix is empty.</p>
)}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,72 @@
import { useEffect, useRef } from 'react'
import { Terminal } from 'lucide-react'
import type { TerminalLine } from '../../types'
import { resolveInfraNode } from '../../lib/infraCatalog'
import { cn } from '../../lib/utils'
type Props = {
subjectId: string | null
subjectLabel: string
lines: TerminalLine[]
busy: boolean
expanded: boolean
onToggle: () => void
}
const LEVEL: Record<string, string> = {
info: 'text-foreground-muted',
ok: 'text-success',
warn: 'text-warning',
err: 'text-danger',
cmd: 'text-docker',
llm: 'text-violet-400',
}
export function TerminalDock({ subjectId, subjectLabel, lines, busy, expanded, onToggle }: Props) {
const bottomRef = useRef<HTMLDivElement>(null)
const infra = resolveInfraNode(subjectId)
useEffect(() => {
if (expanded) bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [lines, busy, expanded])
return (
<div className={cn('flex shrink-0 flex-col border-t border-border bg-black/80', expanded ? 'h-[200px]' : 'h-9')}>
<button
type="button"
onClick={onToggle}
className="flex shrink-0 items-center justify-between px-3 py-2 text-left hover:bg-white/5"
>
<span className="flex items-center gap-2 text-[10px] font-medium text-emerald-300">
<Terminal className="h-3.5 w-3.5" />
Terminal {subjectLabel}
{busy && <span className="animate-pulse text-docker"> live</span>}
</span>
<span className="font-mono text-[8px] text-foreground-faint">{lines.length} lines · {expanded ? '▼' : '▲'}</span>
</button>
{expanded && (
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto px-3 pb-2 font-mono text-[9px] leading-relaxed">
{infra && (
<p className="mb-1 text-foreground-faint">
<span className="text-docker">$</span> {infra.ssh} <span className="text-foreground-faint/70">(gekopieerd bij Shell-knop)</span>
</p>
)}
{lines.length === 0 && (
<p className="py-4 text-center text-foreground-faint">Selecteer een node of agent · klik Shell of Probe om output te zien</p>
)}
{lines.map((line) => (
<div key={line.id} className={LEVEL[line.level] || 'text-foreground-muted'}>
<span className="text-foreground-faint/60">
{line.ts ? new Date(line.ts).toLocaleTimeString('en-US', { hour12: false }) : ''}
</span>{' '}
<span className="text-docker/80">[{line.phase}]</span> {line.text}
</div>
))}
{busy && <span className="text-docker animate-pulse"></span>}
<div ref={bottomRef} />
</div>
)}
</div>
)
}
+162
View File
@@ -0,0 +1,162 @@
import { DatabaseZap, HardDrive, LayoutDashboard, MessageSquare, Presentation, ShieldCheck } from 'lucide-react'
import type { Agent, AgentAnim, GpuStatus } from '../../types'
import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
import { getAgentMeta } from '../../lib/agentMeta'
import { cn } from '../../lib/utils'
import { viewTabActive, viewTabIdle } from '../../lib/tabActive'
import { GpuMatrixPanel } from '../features/GpuMatrixPanel'
type MainView = 'platform' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'approvals'
type Props = {
agents: Agent[]
animations: Record<string, AgentAnim>
gpu: GpuStatus | null
gpuLive: GpuLiveMetrics
gpuBoost?: boolean
selectedAgentId: string | null
selectedNodeId: string | null
mainView: MainView
approvalCount: number
agentsLoading: boolean
onSetMainView: (view: MainView) => void
onOpenApprovals: () => void
onSelectAgent: (id: string) => void
onSelectZone: (id: string) => void
}
const VIEWS: { id: MainView; label: string; icon: typeof LayoutDashboard }[] = [
{ id: 'platform', label: 'Data Platform', icon: LayoutDashboard },
{ id: 'presentation', label: 'Presentation', icon: Presentation },
{ id: 'dataquality', label: 'Data Quality', icon: DatabaseZap },
{ id: 'knowledge', label: 'Knowledge Chat', icon: MessageSquare },
{ id: 'storage', label: 'Object Storage', icon: HardDrive },
]
export function SideNav({
agents,
animations,
gpu,
gpuLive,
gpuBoost = false,
selectedAgentId,
selectedNodeId,
mainView,
approvalCount,
agentsLoading,
onSetMainView,
onOpenApprovals,
onSelectAgent,
onSelectZone,
}: Props) {
const supervisors = agents.filter((a) => a.supervisor)
const operators = agents.filter((a) => !a.supervisor)
const matrixBoost = gpuBoost || mainView === 'knowledge'
return (
<nav className="flex w-[240px] shrink-0 flex-col border-r border-border bg-surface-raised">
<section className="border-b border-border p-3">
<h2 className="mb-2 text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">Views</h2>
<div className="space-y-1">
{VIEWS.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => onSetMainView(id)}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left transition-all',
mainView === id ? viewTabActive : viewTabIdle,
)}
>
<Icon className={cn('h-4 w-4', mainView === id ? 'text-docker' : 'text-foreground-muted')} />
<span className={cn('text-[11px] font-medium', mainView === id ? 'text-docker' : 'text-foreground')}>{label}</span>
</button>
))}
</div>
</section>
<GpuMatrixPanel
gpu={gpu}
live={gpuLive}
boost={matrixBoost}
onSelectGpu={() => onSelectZone('gpu')}
/>
<section className="flex min-h-0 flex-1 flex-col p-3">
<div className="mb-2 flex items-center justify-between gap-1">
<h2 className="text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">Agents</h2>
<button
type="button"
onClick={onOpenApprovals}
className={cn(
'flex items-center gap-1 rounded-md border px-2 py-0.5 text-[9px] font-medium transition-colors',
approvalCount > 0
? 'border-warning/40 bg-warning/10 text-warning'
: 'border-border text-foreground-muted hover:border-border-strong',
)}
>
<ShieldCheck className="h-3 w-3" />
Approvals
{approvalCount > 0 && <span className="font-mono">{approvalCount}</span>}
</button>
</div>
<div className="scrollbar-thin flex-1 space-y-1 overflow-y-auto">
{agentsLoading && agents.length === 0 && (
<p className="text-[9px] text-foreground-faint">Loading agents</p>
)}
{supervisors.length > 0 && (
<p className="text-[8px] uppercase tracking-widest text-foreground-faint">Supervisors</p>
)}
{supervisors.map((a) => (
<AgentRow key={a.id} agent={a} animations={animations} selected={selectedAgentId === a.id} onSelect={onSelectAgent} />
))}
{operators.length > 0 && (
<p className="mt-1 text-[8px] uppercase tracking-widest text-foreground-faint">Field operators</p>
)}
{operators.map((a) => (
<AgentRow key={a.id} agent={a} animations={animations} selected={selectedAgentId === a.id} onSelect={onSelectAgent} />
))}
</div>
</section>
</nav>
)
}
function AgentRow({
agent,
animations,
selected,
onSelect,
}: {
agent: Agent
animations: Record<string, AgentAnim>
selected: boolean
onSelect: (id: string) => void
}) {
const meta = getAgentMeta(agent.id)
const Icon = meta.icon
const busy = (animations[agent.id]?.state || 'idle') !== 'idle'
return (
<button
type="button"
onClick={() => onSelect(agent.id)}
className={cn(
'flex w-full items-center gap-2 rounded-md border px-2 py-2 text-left transition-colors',
selected ? 'border-docker/40 bg-docker-light' : 'border-transparent hover:border-border hover:bg-surface-overlay',
)}
style={busy ? { boxShadow: `inset 3px 0 0 0 ${meta.accent}` } : undefined}
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-surface-overlay" style={{ color: meta.accent }}>
<Icon className="h-3.5 w-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[11px] font-medium text-foreground">{agent.name.split(' ·')[0]}</span>
<span className="block truncate font-mono text-[9px] text-foreground-faint">{meta.domain}</span>
<span className="block truncate text-[8px] text-foreground-faint">{agent.role}</span>
</span>
{(agent.stats?.tasks ?? 0) > 0 && (
<span className="rounded-full bg-docker px-1.5 font-mono text-[8px] text-foreground">{agent.stats?.tasks}</span>
)}
</button>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { Moon, Sun } from 'lucide-react'
import { useTheme } from '../../context/ThemeContext'
import { cn } from '../../lib/utils'
export function ThemeToggle() {
const { theme, toggle } = useTheme()
const isDark = theme === 'dark'
return (
<button
type="button"
onClick={toggle}
className={cn(
'flex h-8 items-center gap-1.5 rounded-lg border px-2.5 text-[10px] font-medium transition-colors',
isDark
? 'border-blue-400/30 bg-blue-500/15 text-blue-200 hover:bg-blue-500/25'
: 'border-border bg-surface-overlay text-foreground-muted hover:bg-docker-light hover:text-docker',
)}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{isDark ? <Moon className="h-3.5 w-3.5" /> : <Sun className="h-3.5 w-3.5" />}
<span className="hidden sm:inline">{isDark ? 'Dark' : 'Light'}</span>
</button>
)
}
+65
View File
@@ -0,0 +1,65 @@
import { Activity, Bot, Box, Clock, ShieldAlert } from 'lucide-react'
import type { Agent, Approval, StatusData, WorkloadData } from '../../types'
import { Badge } from '../ui/Badge'
import { ThemeToggle } from './ThemeToggle'
import { cn } from '../../lib/utils'
type Props = {
clock: string
status: StatusData | null
workload: WorkloadData | null
agents: Agent[]
approvals: Approval[]
onApprovalsClick: () => void
}
export function TopBar({ clock, workload, agents, approvals, onApprovalsClick }: Props) {
const pipelineOk = workload?.totals?.pipeline_active ?? false
const running = workload?.totals?.apps_running ?? 0
const total = workload?.totals?.apps_total ?? 0
const activeAgents = agents.filter((a) => (a.stats?.tasks ?? 0) > 0).length
return (
<header className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border bg-surface-raised/90 px-3 shadow-panel backdrop-blur-sm">
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-docker to-blue-600 shadow-docker">
<Box className="h-4 w-4 text-white" />
</div>
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold text-foreground">Data & AI Command Center</h1>
<p className="text-[9px] text-foreground-muted">ATC Lab · Enterprise Operations</p>
</div>
</div>
<div className="hidden items-center gap-1.5 md:flex">
<Badge variant={pipelineOk ? 'success' : 'warning'}>
<Activity className="h-3 w-3" />
Pipeline {pipelineOk ? 'active' : 'degraded'}
</Badge>
<Badge>{running}/{total} containers</Badge>
<Badge variant="accent">
<Bot className="h-3 w-3" />
{activeAgents} agents
</Badge>
<button type="button" onClick={onApprovalsClick} className="focus:outline-none">
<Badge
variant={approvals.length ? 'warning' : 'default'}
className={cn(approvals.length && 'cursor-pointer hover:opacity-90')}
>
<ShieldAlert className="h-3 w-3" />
{approvals.length}
</Badge>
</button>
</div>
<div className="flex items-center gap-2">
<ThemeToggle />
<div className="flex items-center gap-1.5 font-mono text-[10px] text-foreground-muted">
<Clock className="h-3 w-3" />
{clock}
<span className="hidden text-success sm:inline"></span>
</div>
</div>
</header>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { cva, type VariantProps } from 'class-variance-authority'
import type { HTMLAttributes } from 'react'
import { cn } from '../../lib/utils'
const badgeVariants = cva(
'inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[10px] font-medium',
{
variants: {
variant: {
default: 'border-border bg-surface-overlay text-foreground-muted dark:bg-surface-overlay dark:text-foreground-muted',
accent: 'border-docker/30 bg-docker-light text-docker dark:border-blue-400/30 dark:bg-blue-500/15 dark:text-blue-200',
success: 'border-success/30 bg-green-50 text-green-700 dark:border-green-500/30 dark:bg-green-500/15 dark:text-green-300',
warning: 'border-warning/30 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-300',
danger: 'border-danger/30 bg-red-50 text-red-700 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-300',
},
},
defaultVariants: { variant: 'default' },
},
)
type Props = HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>
export function Badge({ className, variant, ...props }: Props) {
return <span className={cn(badgeVariants({ variant }), className)} {...props} />
}
+30
View File
@@ -0,0 +1,30 @@
import { cva, type VariantProps } from 'class-variance-authority'
import type { ButtonHTMLAttributes } from 'react'
import { cn } from '../../lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-1.5 rounded-md border font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-docker/40 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'border-docker/30 bg-docker text-foreground hover:bg-docker-dark',
ghost: 'border-transparent text-foreground-muted hover:bg-surface-overlay hover:text-foreground',
outline: 'border-border bg-surface-raised text-foreground hover:bg-surface-overlay',
success: 'border-success/30 bg-green-600 text-foreground hover:bg-green-700',
danger: 'border-danger/30 bg-red-600 text-foreground hover:bg-red-700',
},
size: {
sm: 'h-7 px-2.5 text-xs',
md: 'h-8 px-3 text-sm',
icon: 'h-8 w-8',
},
},
defaultVariants: { variant: 'default', size: 'md' },
},
)
type Props = ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>
export function Button({ className, variant, size, ...props }: Props) {
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />
}
+26
View File
@@ -0,0 +1,26 @@
import type { HTMLAttributes } from 'react'
import { cn } from '../../lib/utils'
type Props = HTMLAttributes<HTMLDivElement> & {
padding?: boolean
}
export function Card({ className, padding = true, children, ...props }: Props) {
return (
<div className={cn('panel', padding && 'p-3', className)} {...props}>
{children}
</div>
)
}
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('mb-2 flex items-center justify-between gap-2', className)} {...props} />
}
export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn('text-xs font-semibold tracking-tight text-foreground', className)} {...props} />
}
export function CardDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn('text-[10px] text-foreground-muted', className)} {...props} />
}
+16
View File
@@ -0,0 +1,16 @@
import type { InputHTMLAttributes } from 'react'
import { cn } from '../../lib/utils'
type Props = InputHTMLAttributes<HTMLInputElement>
export function Input({ className, ...props }: Props) {
return (
<input
className={cn(
'h-8 w-full rounded-md border border-border bg-surface px-2.5 text-sm text-foreground placeholder:text-foreground-faint focus:border-docker/50 focus:outline-none focus:ring-1 focus:ring-docker/30',
className,
)}
{...props}
/>
)
}
+44
View File
@@ -0,0 +1,44 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
export type Theme = 'light' | 'dark'
type ThemeContextValue = {
theme: Theme
toggle: () => 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, setTheme] = 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 toggle = () => setTheme((t) => (t === 'light' ? 'dark' : 'light'))
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme outside ThemeProvider')
return ctx
}
+10
View File
@@ -0,0 +1,10 @@
import { useEffect, useState } from 'react'
export function useClock() {
const [now, setNow] = useState(new Date())
useEffect(() => {
const t = setInterval(() => setNow(new Date()), 1000)
return () => clearInterval(t)
}, [])
return now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
}
+331
View File
@@ -0,0 +1,331 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
askNode as apiAskNode,
decideApproval,
fetchAgents,
fetchApprovals,
fetchFeed,
fetchGpu,
fetchNodeDetail,
fetchStatus,
fetchTerminals,
fetchWorkload,
probeNode as apiProbeNode,
sendPrompt as apiSendPrompt,
} from '../lib/api'
import { AGENT_NODE, NODE_ALIASES, wsUrl } from '../lib/constants'
import { resolveInfraNode } from '../lib/infraCatalog'
import type {
Agent,
AgentAnim,
Approval,
ChatMessage,
FeedEntry,
GpuStatus,
NodeDetail,
StatusData,
TerminalLine,
TopologyNode,
WorkloadData,
} from '../types'
function resolveProbeId(nodeId: string) {
const aliased = NODE_ALIASES[nodeId] || nodeId
const infra = resolveInfraNode(nodeId) || resolveInfraNode(aliased)
return infra?.id || aliased
}
export function useCommandCenter() {
const [agents, setAgents] = useState<Agent[]>([])
const [agentsLoading, setAgentsLoading] = useState(true)
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 [selectedAgentId, setSelectedAgentId] = useState<string | null>(null)
const [promptBusy, setPromptBusy] = useState(false)
const [terminals, setTerminals] = useState<Record<string, TerminalLine[]>>({})
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const [selectedNode, setSelectedNode] = useState<TopologyNode | null>(null)
const [nodeDetail, setNodeDetail] = useState<NodeDetail | null>(null)
const [nodeBusy, setNodeBusy] = useState(false)
const [mainView, setMainView] = useState<'platform' | 'approvals' | 'presentation' | 'dataquality' | 'knowledge' | 'storage'>('platform')
const [approvalHighlight, setApprovalHighlight] = useState(false)
const [chatExpanded, setChatExpanded] = useState(false)
const promptTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [terminalExpanded, setTerminalExpanded] = useState(true)
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 === selectedAgentId) || null,
[agents, selectedAgentId],
)
const reloadFast = useCallback(async () => {
const [a, s, f, ap, g, t] = await Promise.all([
fetchAgents(),
fetchStatus(),
fetchFeed(),
fetchApprovals(),
fetchGpu(),
fetchTerminals(),
])
setAgents(a)
setAgentsLoading(false)
setStatus(s)
setGpu(g || s?.gpu || null)
setFeed(f)
setApprovals(ap)
setTerminals(t)
}, [])
const reloadWorkload = useCallback(async () => {
const w = await fetchWorkload()
if (w?.zones) setWorkload(w)
}, [])
const reload = useCallback(async () => {
await reloadFast()
reloadWorkload()
}, [reloadFast, reloadWorkload])
useEffect(() => {
reloadFast().then(() => reloadWorkload())
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') {
setSelectedAgentId(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() }])
setPromptBusy(false)
reloadFast()
}
if (msg.type === 'approval_new') {
setApprovals((prev) => {
if (prev.some((a) => a.id === msg.approval?.id)) return prev
return [msg.approval, ...prev]
})
if (msg.approval?.status === 'pending') setApprovalHighlight(true)
}
if (msg.type === 'approval_update' && msg.approval) {
setApprovals((prev) => prev.filter((a) => a.id !== msg.approval.id))
}
if (msg.type === 'node_ask_result') {
setNodeBusy(false)
if (msg.agent_id) setSelectedAgentId(msg.agent_id)
}
}
const iv = setInterval(reloadFast, 15000)
const wv = setInterval(reloadWorkload, 45000)
return () => { ws.close(); clearInterval(iv); clearInterval(wv) }
}, [reloadFast, reloadWorkload, appendTerminal])
const findNodeStub = useCallback((nodeId: string): TopologyNode | null => {
const resolved = NODE_ALIASES[nodeId] || nodeId
const allNodes = [
...(workload?.topology?.nodes || []),
...Object.values(workload?.topologies || {}).flatMap((v) => v.nodes),
]
const wn = allNodes.find((n) => n.id === nodeId) || allNodes.find((n) => n.id === resolved)
if (wn) return wn
const infra = resolveInfraNode(nodeId) || resolveInfraNode(resolved)
if (infra) {
return {
id: infra.id,
label: infra.label,
vm: infra.vm,
ip: infra.ip,
x: 50,
y: 50,
color: infra.accent,
level: 'ok',
role: infra.description,
apps: infra.apps.map((a) => ({ name: a.label, state: 'link', image: a.url, ports: a.port ? [a.port] : [] })),
running: 1,
total: 1,
}
}
const agent = agents.find((a) => a.id === nodeId)
if (agent) {
return {
id: agent.id, label: agent.name, vm: 'agent', ip: '10.0.21.33',
x: 50, y: 50, color: agent.color, level: 'ok', role: agent.role, apps: [], running: 1, total: 1,
agent_id: agent.id,
}
}
const zone = workload?.zones.find((z) => z.id === nodeId || z.id === resolved)
if (zone) {
return {
id: nodeId, label: zone.label, vm: zone.vm || zone.id, ip: zone.ip || '',
x: 50, y: 50, color: zone.color, level: zone.level, role: 'zone', apps: zone.apps,
running: zone.running, total: zone.total,
}
}
return null
}, [workload, agents])
const probeNodeId = useCallback((nodeId: string) => {
setNodeBusy(true)
setTerminalExpanded(true)
apiProbeNode(resolveProbeId(nodeId)).finally(() => setNodeBusy(false))
}, [])
const openTerminal = useCallback((nodeId: string) => {
setTerminalExpanded(true)
setSelectedNodeId(nodeId)
}, [])
const selectNode = useCallback(async (nodeId: string) => {
const stub = findNodeStub(nodeId)
if (!stub) return
const probeId = resolveProbeId(nodeId)
setSelectedNodeId(probeId)
setSelectedNode({ ...stub, id: probeId })
setNodeDetail(null)
setTerminalExpanded(true)
const infra = resolveInfraNode(nodeId)
const linked = agents.find(
(a) => a.id === nodeId || AGENT_NODE[a.id] === probeId || a.id === infra?.agentId,
)
if (linked) setSelectedAgentId(linked.id)
try {
const detail = await fetchNodeDetail(probeId)
if (!detail.error) setNodeDetail(detail as NodeDetail)
} catch { /* ok */ }
probeNodeId(nodeId)
}, [findNodeStub, agents, probeNodeId])
const selectAgent = useCallback((id: string) => {
setSelectedAgentId(id)
setTerminalExpanded(true)
const agent = agents.find((a) => a.id === id)
if (!agent) return
const nodeId = agent.supervisor ? id : (AGENT_NODE[id] || agent.zone)
if (nodeId) {
const stub = findNodeStub(nodeId)
if (stub) {
selectNode(nodeId)
return
}
}
setSelectedNodeId(null)
setSelectedNode(null)
setNodeDetail(null)
}, [agents, findNodeStub, selectNode])
const clearSelection = useCallback(() => {
setSelectedNodeId(null)
setSelectedNode(null)
setNodeDetail(null)
setSelectedAgentId(null)
}, [])
const probeNode = useCallback(() => {
if (selectedNodeId) probeNodeId(selectedNodeId)
}, [selectedNodeId, probeNodeId])
const askNode = useCallback(async (message: string) => {
if (!selectedNodeId) return
setNodeBusy(true)
setTerminalExpanded(true)
await apiAskNode(resolveProbeId(selectedNodeId), message)
}, [selectedNodeId])
const sendPrompt = useCallback(async (message: string, agentId?: string) => {
setPromptBusy(true)
setChatExpanded(true)
setChat((c) => [...c, { role: 'user', text: message, ts: new Date().toISOString() }])
if (agentId) setSelectedAgentId(agentId)
await apiSendPrompt(message, agentId)
}, [])
const decide = useCallback(async (id: string, approved: boolean, decidedBy = 'mo-commander', note = '') => {
setApprovals((prev) => prev.filter((a) => a.id !== id))
await decideApproval(id, approved, decidedBy, note)
reloadFast()
}, [reloadFast])
const terminalSubjectId = selectedNodeId || selectedAgentId
const inspectorLines = useMemo(() => {
if (!terminalSubjectId) return []
const probeId = resolveProbeId(terminalSubjectId)
return terminals[probeId] || terminals[terminalSubjectId] || terminals[selectedAgentId || ''] || []
}, [terminalSubjectId, terminals, selectedAgentId])
const focusApprovals = useCallback(() => {
setApprovalHighlight(true)
setMainView('approvals')
}, [])
return {
agents,
agentsLoading,
status,
workload,
gpu,
feed,
approvals,
chat,
anims,
selectedAgentId,
selectedAgent,
promptBusy,
selectedNodeId,
selectedNode,
nodeDetail,
nodeBusy,
mainView,
setMainView,
approvalHighlight,
setApprovalHighlight,
inspectorLines,
terminalSubjectId,
terminalExpanded,
setTerminalExpanded,
selectNode,
selectAgent,
clearSelection,
probeNode,
probeNodeId,
openTerminal,
askNode,
sendPrompt,
decide,
focusApprovals,
reload,
chatExpanded,
setChatExpanded,
}
}
+120
View File
@@ -0,0 +1,120 @@
import { useEffect, useRef, useState } from 'react'
import type { Agent, AgentAnim, GpuStatus } from '../types'
import { pseudoAgentLoad } from '../lib/agentMeta'
export type AgentLoad = { cpu: number; mem: number }
export type GpuLiveMetrics = {
tokenThroughput: number
avgUtil: number
avgVram: number
deviceUtils: number[]
}
function clamp(n: number, min: number, max: number) {
return Math.min(max, Math.max(min, n))
}
function memPct(used: number, total: number) {
if (!total) return 0
return Math.round((used / total) * 100)
}
export function useLiveMetrics(
agents: Agent[],
gpu: GpuStatus | null,
animations: Record<string, AgentAnim>,
boost = false,
) {
const [agentLoads, setAgentLoads] = useState<Record<string, AgentLoad>>({})
const [gpuLive, setGpuLive] = useState<GpuLiveMetrics>({
tokenThroughput: 0,
avgUtil: 0,
avgVram: 0,
deviceUtils: [],
})
const loadsRef = useRef(agentLoads)
loadsRef.current = agentLoads
useEffect(() => {
const seed: Record<string, AgentLoad> = {}
for (const a of agents) {
seed[a.id] = pseudoAgentLoad(a.stats)
}
setAgentLoads(seed)
const gpus = gpu?.gpus || []
const baseUtil = gpus.length ? gpus.reduce((s, g) => s + g.util_gpu, 0) / gpus.length : 0
const baseVram = gpus.length
? gpus.reduce((s, g) => s + memPct(g.memory_used_mib, g.memory_total_mib), 0) / gpus.length
: 0
const inferenceOn = gpu?.ok && gpu.inference_active
setGpuLive({
tokenThroughput: inferenceOn ? Math.round(baseUtil * 42 + 120) : 0,
avgUtil: baseUtil,
avgVram: baseVram,
deviceUtils: gpus.map((g) => g.util_gpu),
})
}, [agents, gpu])
useEffect(() => {
const tick = () => {
setAgentLoads((prev) => {
const next: Record<string, AgentLoad> = {}
for (const a of agents) {
const busy = (animations[a.id]?.state || 'idle') !== 'idle'
const base = pseudoAgentLoad(a.stats)
const cur = prev[a.id] || base
const drift = (Math.random() - 0.5) * (busy ? 7 : 2.5)
const driftMem = (Math.random() - 0.5) * (busy ? 5 : 2)
const targetCpu = busy ? Math.max(base.cpu, cur.cpu) : base.cpu
const targetMem = busy ? Math.max(base.mem, cur.mem) : base.mem
next[a.id] = {
cpu: clamp(Math.round(cur.cpu + drift + (busy ? 1.2 : -0.3)), 4, 96),
mem: clamp(Math.round(cur.mem + driftMem + (busy ? 0.8 : -0.2)), 6, 92),
}
if (!busy) {
next[a.id].cpu = clamp(Math.round(next[a.id].cpu * 0.85 + targetCpu * 0.15), 4, 96)
next[a.id].mem = clamp(Math.round(next[a.id].mem * 0.85 + targetMem * 0.15), 6, 92)
}
}
return next
})
if (gpu?.ok) {
const gpus = gpu.gpus || []
const inferenceOn = gpu.inference_active
setGpuLive((prev) => {
const baseUtil = gpus.length
? gpus.reduce((s, g) => s + g.util_gpu, 0) / gpus.length
: prev.avgUtil
const baseVram = gpus.length
? gpus.reduce((s, g) => s + memPct(g.memory_used_mib, g.memory_total_mib), 0) / gpus.length
: prev.avgVram
const jitterScale = boost ? 12 : 6
const utilJitter = (Math.random() - 0.5) * (inferenceOn ? jitterScale : 2)
const avgUtil = clamp(baseUtil + utilJitter, 0, 100)
const avgVram = clamp(baseVram + (Math.random() - 0.5) * 3, 0, 100)
const deviceUtils = gpus.map((g, i) => {
const real = g.util_gpu
if (boost && inferenceOn) {
return clamp(real + (Math.random() - 0.5) * 8, 0, 100)
}
return clamp((prev.deviceUtils[i] ?? real) + (Math.random() - 0.5) * 5, 0, 100)
})
const tokenBase = boost ? Math.max(180, baseUtil * 55 + 140) : baseUtil * 42 + 120
const tokenThroughput = inferenceOn
? clamp(Math.round(prev.tokenThroughput * 0.4 + tokenBase * 0.6 + (Math.random() - 0.5) * (boost ? 45 : 28)), boost ? 120 : 80, boost ? 520 : 420)
: 0
return { tokenThroughput, avgUtil, avgVram, deviceUtils }
})
}
}
tick()
const id = setInterval(tick, boost ? 1000 : 5000)
return () => clearInterval(id)
}, [agents, animations, gpu, boost])
return { agentLoads, gpuLive }
}
-57
View File
@@ -1,57 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
min-height: 100vh;
background: linear-gradient(165deg, #f0f4ff 0%, #e8eef9 35%, #f5f0ff 70%, #eef8ff 100%);
background-attachment: fixed;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(0, 140, 200, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 140, 200, 0.04) 1px, transparent 1px);
background-size: 48px 48px;
pointer-events: none;
z-index: 0;
}
#root {
position: relative;
z-index: 1;
}
.glass {
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(16px);
border: 1px solid rgba(0, 160, 220, 0.18);
box-shadow:
0 4px 24px rgba(15, 40, 80, 0.06),
0 1px 0 rgba(255, 255, 255, 0.9) inset;
}
.glass-strong {
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(20px);
border: 1px solid rgba(0, 160, 220, 0.22);
box-shadow: 0 8px 32px rgba(15, 40, 80, 0.08);
}
.neon-text-cyan {
text-shadow: 0 0 24px rgba(0, 180, 220, 0.35);
}
.status-card {
background: linear-gradient(145deg, #ffffff 0%, #f8fbff 100%);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.status-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(15, 40, 80, 0.1);
}
+126
View File
@@ -0,0 +1,126 @@
import type { LucideIcon } from 'lucide-react'
import {
Bot,
Cpu,
Database,
Layers,
Network,
Radio,
Server,
Shield,
TreePine,
Workflow,
} from 'lucide-react'
import type { AgentAnim } from '../types'
export type AgentMeta = {
icon: LucideIcon
accent: string
idleTask: string
activeTask: string
domain: string
}
export const AGENT_META: Record<string, AgentMeta> = {
'etl-guardian': {
icon: Workflow,
accent: '#38bdf8',
domain: 'ETL / CDC',
idleTask: 'Monitoring Airflow DAGs & Kafka connectors',
activeTask: 'Automating ETL layer — CDC sync validation',
},
'lakehouse-ops': {
icon: Layers,
accent: '#818cf8',
domain: 'Lakehouse',
idleTask: 'Watching Spark, Trino & Iceberg catalogs',
activeTask: 'Optimizing lakehouse queries & table health',
},
'data-custodian': {
icon: Database,
accent: '#34d399',
domain: 'Databases',
idleTask: 'Guarding PostgreSQL, MySQL & document stores',
activeTask: 'Running database health & replication checks',
},
'hadoop-ranger': {
icon: TreePine,
accent: '#4ade80',
domain: 'Hadoop',
idleTask: 'Patrolling HDFS capacity & YARN nodes',
activeTask: 'Analyzing HDFS blocks & cluster balance',
},
'infra-sentinel': {
icon: Server,
accent: '#94a3b8',
domain: 'Infrastructure',
idleTask: 'Observing Docker hosts & platform services',
activeTask: 'Correlating infra events across the lab',
},
'mo-commander': {
icon: Shield,
accent: '#60a5fa',
domain: 'Supervision',
idleTask: 'Ingress intel & approval oversight',
activeTask: 'Reviewing agent dispatch & approvals',
},
'bart-commander': {
icon: Radio,
accent: '#2dd4bf',
domain: 'Supervision',
idleTask: 'Egress monitoring & MCP comms relay',
activeTask: 'Tracking outbound agent communications',
},
'network-watcher': {
icon: Network,
accent: '#38bdf8',
domain: 'Network',
idleTask: 'VLAN 20/21 traffic path analysis',
activeTask: 'Mapping data ingress & egress flows',
},
'mcp-coordinator': {
icon: Cpu,
accent: '#c084fc',
domain: 'MCP Hub',
idleTask: 'Routing tool calls between agents',
activeTask: 'Orchestrating MCP tool execution',
},
}
const DEFAULT_META: AgentMeta = {
icon: Bot,
accent: '#94a3b8',
domain: 'Agent',
idleTask: 'Standing by',
activeTask: 'Executing mission',
}
export function getAgentMeta(agentId: string): AgentMeta {
return AGENT_META[agentId] || DEFAULT_META
}
export function agentTaskLabel(agentId: string, anim?: AgentAnim): string {
const meta = getAgentMeta(agentId)
if (!anim || anim.state === 'idle') return meta.idleTask
if (anim.state === 'walk') return `Routing to ${anim.zone || 'target zone'}`
if (anim.state === 'fetch') return meta.activeTask
if (anim.state === 'return') return 'Publishing mission results…'
return meta.activeTask
}
export function pseudoAgentLoad(stats?: { tasks: number; alerts: number }) {
const tasks = stats?.tasks ?? 0
const alerts = stats?.alerts ?? 0
const cpu = Math.min(94, 8 + tasks * 3 + alerts * 5)
const mem = Math.min(88, 12 + tasks * 2 + alerts * 4)
return { cpu, mem }
}
export const DOMAIN_LABELS: Record<string, string> = {
docker: 'Docker Platform',
databases: 'Database Vault',
lakehouse: 'Lakehouse',
etl: 'ETL / Streaming',
hadoop: 'Hadoop Cluster',
gpu: 'GPU / AI',
}
+101
View File
@@ -0,0 +1,101 @@
import type {
PresentationData,
Agent,
Approval,
FeedEntry,
GpuStatus,
StatusData,
TerminalLine,
WorkloadData,
} from '../types'
async function fetchJson<T>(url: string, timeoutMs = 10000): Promise<T | null> {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
try {
const r = await fetch(url, { signal: ctrl.signal })
if (!r.ok) return null
return (await r.json()) as T
} catch {
return null
} finally {
clearTimeout(timer)
}
}
export async function fetchAgents() {
const j = await fetchJson<{ agents?: Agent[] }>('/api/agents', 8000)
return j?.agents || []
}
export async function fetchStatus() {
return (await fetchJson<StatusData>('/api/status', 8000)) as StatusData
}
export async function fetchFeed() {
const j = await fetchJson<{ entries?: FeedEntry[] }>('/api/feed', 8000)
return j?.entries || []
}
export async function fetchApprovals() {
const j = await fetchJson<{ approvals?: Approval[] }>('/api/approvals', 8000)
return j?.approvals || []
}
export async function fetchApprovalHistory(status: string = 'pending') {
const j = await fetchJson<{
approvals?: Approval[]
stats?: { pending: number; approved: number; denied: number; total: number }
}>(`/api/approvals?status=${encodeURIComponent(status)}&limit=200`, 8000)
return { approvals: j?.approvals || [], stats: j?.stats }
}
export async function fetchGpu(): Promise<GpuStatus | null> {
return fetchJson<GpuStatus>('/api/gpu', 8000)
}
export async function fetchTerminals(): Promise<Record<string, TerminalLine[]>> {
const j = await fetchJson<{ terminals?: Record<string, TerminalLine[]> }>('/api/terminals', 8000)
return j?.terminals || {}
}
export async function fetchWorkload(): Promise<WorkloadData | null> {
return fetchJson<WorkloadData>('/api/workload?fast=true', 25000)
}
export async function fetchNodeDetail(nodeId: string) {
const j = await fetchJson<Record<string, unknown>>(`/api/nodes/${nodeId}`, 15000)
return j || { error: 'timeout' }
}
export function probeNode(nodeId: string) {
return fetch(`/api/nodes/${nodeId}/probe`, { method: 'POST' })
}
export function askNode(nodeId: string, message: string) {
return fetch(`/api/nodes/${nodeId}/ask`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
})
}
export function sendPrompt(message: string, agentId?: string) {
return fetch('/api/prompt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, agent_id: agentId || undefined }),
})
}
export async function fetchPresentation(): Promise<PresentationData | null> {
return fetchJson<PresentationData>('/api/presentation', 60000)
}
export async function decideApproval(id: string, approved: boolean, decidedBy: string, note: string) {
return fetch(`/api/approvals/${id}/decide`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approved, decided_by: decidedBy, note }),
})
}
+47
View File
@@ -0,0 +1,47 @@
export const NODE_ALIASES: Record<string, string> = {
'src-postgres': 'db',
'src-mysql': 'db',
'src-mongo': 'db',
'src-cassandra': 'db',
'src-airflow': 'airflow',
'cdc-postgres': 'debezium',
'cdc-mysql': 'debezium',
'cdc-mongo': 'debezium',
'cdc-cassandra': 'debezium',
'stream-kafka': 'kafka',
'stream-schema': 'kafka',
'stream-spark': 'lakehouse',
'lake-iceberg': 'lakehouse',
'lake-s3': 's3',
'query-trino': 'lakehouse',
'query-dbt': 'lakehouse',
'cons-bi': 'docker',
'cons-notebooks': 'lakehouse',
'cons-ml': 'gpu',
}
export const AGENT_NODE: Record<string, string> = {
'infra-sentinel': 'docker',
'data-custodian': 'db',
'lakehouse-ops': 'lakehouse',
'etl-guardian': 'kafka',
'hadoop-ranger': 'hadoop',
'network-watcher': 'network-watcher',
'mcp-coordinator': 'mcp-coordinator',
'mo-commander': 'mo-commander',
'bart-commander': 'bart-commander',
}
export const ZONE_NODE: Record<string, string> = {
docker: 'docker',
db: 'db',
etl: 'kafka',
lakehouse: 'lakehouse',
s3: 's3',
hadoop: 'hadoop',
}
export function wsUrl() {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
return `${proto}://${window.location.host}/api/ws/ops`
}
+210
View File
@@ -0,0 +1,210 @@
import type { LucideIcon } from 'lucide-react'
import {
Database,
HardDrive,
Layers,
MessageSquare,
Server,
Sparkles,
Workflow,
} from 'lucide-react'
export type InfraApp = {
label: string
url: string
port?: string
}
export type InfraNode = {
id: string
label: string
vm: string
ip: string
zone: string
agentId: string
icon: LucideIcon
accent: string
description: string
ssh: string
apps: InfraApp[]
topoIds: string[]
}
export const INFRA_CATALOG: InfraNode[] = [
{
id: 'db',
label: 'DB Vault',
vm: 'atc-db02',
ip: '10.0.21.51',
zone: 'db',
agentId: 'data-custodian',
icon: Database,
accent: '#34d399',
description: 'PostgreSQL, MySQL, MongoDB, Cassandra, Neo4j — CDC sources',
ssh: 'ssh root@10.0.21.51',
apps: [
{ label: 'Dockhand env 5', url: 'http://10.0.21.45:8082', port: '8082' },
{ label: 'PostgreSQL', url: 'postgresql://10.0.21.51:5432/postgres', port: '5432' },
{ label: 'MongoDB', url: 'mongodb://10.0.21.51:27017/', port: '27017' },
],
topoIds: ['postgresql', 'mysql', 'mongodb', 'cassandra', 'src-postgres', 'src-mysql', 'src-mongo', 'src-cassandra'],
},
{
id: 'airflow',
label: 'Airflow',
vm: 'atc-airflow01',
ip: '10.0.21.55',
zone: 'etl',
agentId: 'etl-guardian',
icon: Workflow,
accent: '#38bdf8',
description: 'DAG orchestration — daily data generation on all sources',
ssh: 'ssh root@10.0.21.55',
apps: [{ label: 'Airflow UI', url: 'http://10.0.21.55:8080', port: '8080' }],
topoIds: ['airflow', 'src-airflow'],
},
{
id: 'kafka',
label: 'Kafka Bus',
vm: 'atc-kafka01',
ip: '10.0.21.36',
zone: 'etl',
agentId: 'etl-guardian',
icon: MessageSquare,
accent: '#4c9aed',
description: 'Event bus — CDC topics & consumer streams',
ssh: 'ssh root@10.0.21.36',
apps: [{ label: 'Kafka UI', url: 'http://10.0.21.36:9000', port: '9000' }],
topoIds: ['kafka', 'stream-kafka'],
},
{
id: 'debezium',
label: 'Debezium CDC',
vm: 'atc-lake01',
ip: '10.0.21.50',
zone: 'lakehouse',
agentId: 'etl-guardian',
icon: Workflow,
accent: '#c77dff',
description: 'Kafka Connect — row-level CDC from source DBs',
ssh: 'ssh root@10.0.21.50',
apps: [{ label: 'Kafka Connect', url: 'http://10.0.21.50:8083', port: '8083' }],
topoIds: ['debezium', 'cdc-postgres', 'cdc-mysql', 'cdc-mongo', 'cdc-cassandra'],
},
{
id: 'lakehouse',
label: 'Lakehouse Hub',
vm: 'atc-lake01',
ip: '10.0.21.50',
zone: 'lakehouse',
agentId: 'lakehouse-ops',
icon: Layers,
accent: '#818cf8',
description: 'Spark, Trino, s3-kafka-consumer, Iceberg catalog',
ssh: 'ssh root@10.0.21.50',
apps: [
{ label: 'Trino', url: 'http://10.0.21.50:8089', port: '8089' },
{ label: 'Spark UI', url: 'http://10.0.21.50:8080', port: '8080' },
{ label: 'Kafka Connect', url: 'http://10.0.21.50:8083', port: '8083' },
],
topoIds: ['spark', 'trino', 'iceberg', 'stream-spark', 'lake-iceberg', 'query-trino', 'cons-notebooks'],
},
{
id: 's3',
label: 'ObjectScale S3',
vm: 'atc-objectscale',
ip: '10.0.20.111',
zone: 's3',
agentId: 'lakehouse-ops',
icon: HardDrive,
accent: '#d4a017',
description: 'Dell ECS S3 — Iceberg landing zone (bucket: data)',
ssh: 'ssh root@10.0.20.111',
apps: [{ label: 'S3 API', url: 'http://10.0.20.111:9020', port: '9020' }],
topoIds: ['s3', 'lake-s3'],
},
{
id: 'docker',
label: 'Docker Rack',
vm: 'atc-docker01',
ip: '10.0.21.45',
zone: 'docker',
agentId: 'infra-sentinel',
icon: Server,
accent: '#94a3b8',
description: 'Homepage, Dockhand, Superset, monitoring stack',
ssh: 'ssh root@10.0.21.45',
apps: [
{ label: 'Homepage', url: 'http://10.0.21.45', port: '80' },
{ label: 'Dockhand', url: 'http://10.0.21.45:8082', port: '8082' },
{ label: 'Superset', url: 'http://10.0.21.45:8088', port: '8088' },
],
topoIds: ['bi', 'cons-bi'],
},
{
id: 'hadoop',
label: 'Hadoop HDFS',
vm: 'atc-hadoop-m01',
ip: '10.0.21.61',
zone: 'hadoop',
agentId: 'hadoop-ranger',
icon: Server,
accent: '#4ade80',
description: '9-node HDFS cluster — parallel storage layer',
ssh: 'ssh root@10.0.21.61',
apps: [{ label: 'NameNode UI', url: 'http://10.0.21.61:9870', port: '9870' }],
topoIds: [],
},
{
id: 'gpu',
label: 'GPU Lab',
vm: 'atc-gpu-dev',
ip: '10.0.20.106',
zone: 'gpu',
agentId: 'infra-sentinel',
icon: Sparkles,
accent: '#3fb950',
description: 'vLLM inference — Llama 3 70B on 4× V100',
ssh: 'ssh root@10.0.20.106',
apps: [
{ label: 'GPU Lab UI', url: 'http://10.0.20.106:9000', port: '9000' },
{ label: 'vLLM API', url: 'http://10.0.20.106:8001/v1', port: '8001' },
],
topoIds: ['llm', 'cons-ml'],
},
{
id: 'command',
label: 'Command Center',
vm: 'MCP · VM304',
ip: '10.0.21.33',
zone: 'command',
agentId: 'mcp-coordinator',
icon: Server,
accent: '#60a5fa',
description: 'This dashboard — agents, approvals, LLM routing',
ssh: 'ssh root@10.0.21.33',
apps: [
{ label: 'Dashboard', url: 'http://10.0.21.33/', port: '80' },
{ label: 'API', url: 'http://10.0.21.33/api', port: '80' },
],
topoIds: [],
},
]
export function resolveInfraNode(nodeId: string | null): InfraNode | null {
if (!nodeId) return null
return (
INFRA_CATALOG.find((n) => n.id === nodeId) ||
INFRA_CATALOG.find((n) => n.topoIds.includes(nodeId)) ||
null
)
}
export async function copyShellCommand(cmd: string) {
try {
await navigator.clipboard.writeText(cmd)
return true
} catch {
return false
}
}
+12
View File
@@ -0,0 +1,12 @@
/** Shared active-tab styles — dark-mode safe (no white docker-light backgrounds). */
export const viewTabActive =
'border-l-[3px] border-l-docker bg-docker/20 text-docker border-docker/40 shadow-docker dark:bg-docker/25'
export const viewTabIdle =
'border border-transparent text-foreground-muted hover:border-border hover:bg-surface-overlay'
export const subTabActive =
'border border-docker/50 bg-docker/20 text-docker shadow-sm dark:bg-docker/25 dark:border-docker/40'
export const subTabIdle =
'border border-transparent text-foreground-muted hover:bg-surface-overlay hover:border-border'
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+12 -2
View File
@@ -1,10 +1,20 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './index.css'
import { ThemeProvider } from './context/ThemeContext'
import './styles/globals.css'
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, staleTime: 10_000 } },
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<App />
</ThemeProvider>
</QueryClientProvider>
</React.StrictMode>,
)
+171
View File
@@ -0,0 +1,171 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
color-scheme: light;
--surface: 244 245 247;
--surface-raised: 255 255 255;
--surface-overlay: 236 240 245;
--surface-muted: 221 225 230;
--border: 210 218 228;
--border-strong: 180 192 208;
--foreground: 26 31 38;
--foreground-muted: 95 107 122;
--foreground-faint: 139 149 165;
--shadow-panel: 0 1px 3px rgba(15, 40, 80, 0.06), 0 4px 12px rgba(36, 150, 237, 0.06);
--shadow-docker: 0 0 0 1px rgba(36, 150, 237, 0.12), 0 4px 14px rgba(36, 150, 237, 0.1);
--topo-canvas: linear-gradient(145deg, #dbeafe 0%, #e0f2fe 35%, #ede9fe 70%, #ecfdf5 100%);
--topo-grid: rgba(37, 99, 235, 0.06);
--topo-stage-border: rgba(37, 99, 235, 0.15);
--topo-node-bg: linear-gradient(145deg, #1e40af 0%, #2563eb 50%, #1d4ed8 100%);
--topo-node-border: rgba(147, 197, 253, 0.45);
--topo-node-shadow: 0 4px 14px rgba(30, 64, 175, 0.35);
--topo-header-bg: linear-gradient(90deg, rgba(36, 150, 237, 0.12), rgba(99, 102, 241, 0.08));
}
.dark {
color-scheme: dark;
--surface: 15 27 46;
--surface-raised: 22 38 62;
--surface-overlay: 28 48 78;
--surface-muted: 36 58 92;
--border: 48 74 112;
--border-strong: 64 96 140;
--foreground: 232 241 255;
--foreground-muted: 148 175 212;
--foreground-faint: 100 130 168;
--shadow-panel: 0 1px 0 rgba(147, 197, 253, 0.06) inset, 0 8px 24px rgba(0, 0, 0, 0.35);
--shadow-docker: 0 0 0 1px rgba(56, 189, 248, 0.2), 0 4px 16px rgba(14, 116, 214, 0.25);
--topo-canvas: linear-gradient(145deg, #0c1929 0%, #132f4c 40%, #1a365d 75%, #0f2847 100%);
--topo-grid: rgba(56, 189, 248, 0.07);
--topo-stage-border: rgba(56, 189, 248, 0.18);
--topo-node-bg: linear-gradient(145deg, #1e3a5f 0%, #234876 45%, #1a4470 100%);
--topo-node-border: rgba(96, 165, 250, 0.4);
--topo-node-shadow: 0 4px 16px rgba(0, 20, 60, 0.45);
--topo-header-bg: linear-gradient(90deg, rgba(36, 150, 237, 0.18), rgba(99, 102, 241, 0.12));
}
html, body, #root {
height: 100%;
scrollbar-gutter: stable;
}
body {
@apply bg-surface font-sans text-foreground antialiased;
background-image: var(--body-gradient, none);
}
.dark body {
--body-gradient: radial-gradient(ellipse 120% 80% at 50% -20%, rgba(37, 99, 235, 0.15), transparent);
}
:root body {
--body-gradient: radial-gradient(ellipse 100% 60% at 50% -10%, rgba(36, 150, 237, 0.08), transparent);
}
}
@layer components {
.panel {
@apply rounded-lg border border-border bg-surface-raised shadow-panel;
}
.topo-canvas {
@apply relative min-h-0 flex-1 overflow-hidden rounded-lg;
background:
linear-gradient(var(--topo-grid) 1px, transparent 1px),
linear-gradient(90deg, var(--topo-grid) 1px, transparent 1px),
var(--topo-canvas);
background-size: 20px 20px, 20px 20px, auto;
}
.topo-edge-glow {
stroke: rgba(56, 189, 248, 0.18);
stroke-width: 4;
fill: none;
}
.topo-edge-idle {
stroke: rgba(100, 140, 180, 0.35);
stroke-width: 1.5;
stroke-dasharray: 4 8;
fill: none;
}
.topo-edge-live {
stroke-width: 2;
stroke-dasharray: 8 12;
fill: none;
animation: flow-dash 1.2s linear infinite;
}
.topo-edge-orchestration.topo-edge-live { stroke: #f59e0b; filter: drop-shadow(0 0 3px rgba(245, 158, 11, 0.5)); }
.topo-edge-cdc.topo-edge-live { stroke: #22d3ee; filter: drop-shadow(0 0 3px rgba(34, 211, 238, 0.45)); }
.topo-edge-stream.topo-edge-live { stroke: #38bdf8; filter: drop-shadow(0 0 3px rgba(56, 189, 248, 0.45)); }
.topo-edge-etl.topo-edge-live { stroke: #a78bfa; filter: drop-shadow(0 0 3px rgba(167, 139, 250, 0.45)); }
.topo-edge-query.topo-edge-live { stroke: #818cf8; filter: drop-shadow(0 0 3px rgba(129, 140, 248, 0.45)); }
.topo-edge-serve.topo-edge-live { stroke: #34d399; filter: drop-shadow(0 0 3px rgba(52, 211, 153, 0.45)); }
.topo-edge-active {
stroke: url(#topo-flow-gradient);
stroke-width: 2.5;
stroke-dasharray: 10 14;
fill: none;
animation: flow-dash 1.4s linear infinite;
}
.topo-edge-pulse {
stroke: #34d399;
stroke-width: 2;
stroke-dasharray: 4 100;
fill: none;
opacity: 0.9;
animation: flow-pulse 2s linear infinite;
}
.topo-node-airflow {
border-color: rgba(245, 158, 11, 0.55) !important;
box-shadow: 0 0 12px rgba(245, 158, 11, 0.25), var(--topo-node-shadow);
}
.topo-node {
@apply w-full rounded-md border px-2 py-1.5 text-left transition-all;
background: var(--topo-node-bg);
border-color: var(--topo-node-border);
box-shadow: var(--topo-node-shadow);
}
.topo-node:hover {
filter: brightness(1.12);
transform: translateY(-1px);
}
.topo-node-selected {
@apply ring-2 ring-cyan-400/60 border-cyan-300;
}
.topo-stage-col {
@apply flex min-w-[130px] flex-1 flex-col px-1.5 py-2 last:border-r-0;
border-right: 1px dashed var(--topo-stage-border);
}
.topo-stage-col--sources { background: linear-gradient(180deg, rgba(16, 185, 129, 0.08), transparent 60%); }
.topo-stage-col--ingestion { background: linear-gradient(180deg, rgba(6, 182, 212, 0.1), transparent 60%); }
.topo-stage-col--compute { background: linear-gradient(180deg, rgba(139, 92, 246, 0.1), transparent 60%); }
.topo-stage-col--storage { background: linear-gradient(180deg, rgba(37, 99, 235, 0.1), transparent 60%); }
.topo-stage-col--consumers { background: linear-gradient(180deg, rgba(245, 158, 11, 0.08), transparent 60%); }
}
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: rgb(var(--border-strong)) transparent;
}
.scroll-x-stable {
overflow-x: auto;
overflow-y: hidden;
scrollbar-gutter: stable;
}
}
+197
View File
@@ -1,9 +1,39 @@
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
supervisor?: boolean
person?: string
}
export type TopologyLayer = {
id: string
label: string
y: number
color: string
x?: number
}
export type TopologyViewData = {
id: string
label: string
subtitle: string
layers?: TopologyLayer[]
nodes: TopologyNode[]
edges: TopologyEdge[]
}
export type Zone = { id: string; label: string; x: number; color: string }
@@ -24,6 +54,29 @@ export type DomainStatus = {
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'
@@ -41,4 +94,148 @@ export type Approval = {
action: string
reason: string
status: string
action_type: string
target: string
payload?: Record<string, unknown>
decided_by?: string | null
decide_note?: string | null
decided_at?: string | null
priority?: 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[]
host?: 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
vm?: string
ip?: string
bucket?: string
}
export type NodeLink = { label: string; url: string }
export type NodeEndpoint = { name: string; host: string; port: string; proto: string }
export type TopologyNode = {
id: string
label: string
vm: string
ip: string
x: number
y: number
color: string
level: string
role: string
apps: WorkloadApp[]
running: number
total: number
description?: string
agent_id?: string
links?: NodeLink[]
endpoints?: NodeEndpoint[]
commands?: string[]
vmid?: number
pve?: string
bucket?: string
port?: string
connectors?: string[]
trino_ok?: boolean
model?: string
util?: number
hdfs_used_gb?: number
hdfs_total_gb?: number
consumer_ok?: boolean
subtitle?: string
metrics?: string[]
icon?: string
layer?: string
}
export type NodeDetail = TopologyNode
export type TopologyEdge = {
id: string
from: string
to: string
label: string
kind: 'pipeline' | 'query' | 'parallel' | 'infra'
active: boolean
}
export type WorkloadData = {
ts: string
zones: WorkloadZone[]
topology?: TopologyViewData
topologies?: Record<string, TopologyViewData>
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
vms?: number
pipeline_active?: boolean
}
}
export type ChatMessage = {
role: 'user' | 'agent'
text: string
agent?: string
ts?: string
}
export type PresentationSlide = {
id: string
title: string
subtitle?: string
bullets: string[]
kind?: string
animation?: string
topology?: TopologyViewData
zone?: WorkloadZone
}
export type PresentationData = {
ts: string
title: string
subtitle: string
totals: WorkloadData['totals']
pipeline_active?: boolean
slides: PresentationSlide[]
slide_count: number
workload?: WorkloadData
topologies?: Record<string, TopologyViewData>
}