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:
+4
-4
@@ -1,14 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="light">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ATC Command Center</title>
|
||||
<title>ATC Data & AI Command Center</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Space+Grotesk:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body class="text-ink">
|
||||
<body class="antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
@@ -11,6 +11,17 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
+7
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "atc-command-center",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -9,9 +9,13 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"framer-motion": "^11.15.0",
|
||||
"@tanstack/react-query": "^5.62.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwind-merge": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
|
||||
+159
-199
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 0–100', 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 ~30–90 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'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 (30–180 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>
|
||||
)
|
||||
}
|
||||
@@ -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: "What maturity gaps exist in the customer dataset?"</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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 }),
|
||||
})
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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>
|
||||
}
|
||||
|
||||
+37
-20
@@ -1,32 +1,49 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
void: '#f0f4ff',
|
||||
panel: '#ffffff',
|
||||
ink: {
|
||||
DEFAULT: '#1a2332',
|
||||
muted: '#5a6b82',
|
||||
faint: '#8b9cb3',
|
||||
},
|
||||
neon: {
|
||||
cyan: '#0099cc',
|
||||
magenta: '#cc0088',
|
||||
green: '#22aa44',
|
||||
amber: '#cc7700',
|
||||
purple: '#8844cc',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
display: ['"Space Grotesk"', 'system-ui', 'sans-serif'],
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
mono: ['"JetBrains Mono"', 'monospace'],
|
||||
},
|
||||
colors: {
|
||||
docker: {
|
||||
DEFAULT: '#2496ED',
|
||||
dark: '#1D7CC8',
|
||||
light: '#E8F4FD',
|
||||
},
|
||||
surface: {
|
||||
DEFAULT: 'rgb(var(--surface) / <alpha-value>)',
|
||||
raised: 'rgb(var(--surface-raised) / <alpha-value>)',
|
||||
overlay: 'rgb(var(--surface-overlay) / <alpha-value>)',
|
||||
muted: 'rgb(var(--surface-muted) / <alpha-value>)',
|
||||
},
|
||||
border: {
|
||||
DEFAULT: 'rgb(var(--border) / <alpha-value>)',
|
||||
strong: 'rgb(var(--border-strong) / <alpha-value>)',
|
||||
},
|
||||
foreground: {
|
||||
DEFAULT: 'rgb(var(--foreground) / <alpha-value>)',
|
||||
muted: 'rgb(var(--foreground-muted) / <alpha-value>)',
|
||||
faint: 'rgb(var(--foreground-faint) / <alpha-value>)',
|
||||
},
|
||||
success: '#22C55E',
|
||||
warning: '#F59E0B',
|
||||
danger: '#EF4444',
|
||||
},
|
||||
boxShadow: {
|
||||
'neon-cyan': '0 0 20px rgba(0, 153, 204, 0.25), 0 4px 16px rgba(0, 153, 204, 0.12)',
|
||||
'neon-magenta': '0 0 20px rgba(204, 0, 136, 0.2)',
|
||||
card: '0 4px 20px rgba(15, 40, 80, 0.07)',
|
||||
panel: 'var(--shadow-panel)',
|
||||
docker: 'var(--shadow-docker)',
|
||||
},
|
||||
animation: {
|
||||
'flow-dash': 'flow-dash 1.4s linear infinite',
|
||||
'flow-pulse': 'flow-pulse 2.2s linear infinite',
|
||||
},
|
||||
keyframes: {
|
||||
'flow-dash': { to: { strokeDashoffset: '-48' } },
|
||||
'flow-pulse': { to: { strokeDashoffset: '-248' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user