Add Command Center v2: DQ/RAG integration, S3 browser, Jupyter, GPU matrix.

Mirror mo/atc-GPU layout with config/, docs/, scripts/ for Gitea deploy.
This commit is contained in:
mo
2026-06-25 00:28:23 +00:00
parent fb9cc21c9a
commit a11621b21f
110 changed files with 14622 additions and 529 deletions
-48
View File
@@ -1,48 +0,0 @@
import { motion } from 'framer-motion'
type Props = {
color: string
state: 'idle' | 'walk' | 'fetch' | 'return'
label: string
}
export function AgentSprite({ color, state, label }: Props) {
const bob = state === 'idle' ? { y: [0, -3, 0] } : state === 'walk' || state === 'return' ? { y: [0, -6, 0] } : { y: 0 }
const scale = state === 'fetch' ? 0.92 : 1
return (
<motion.div
className="flex flex-col items-center"
animate={{ ...bob, scale }}
transition={{ repeat: Infinity, duration: state === 'walk' || state === 'return' ? 0.35 : 2 }}
>
<svg width="56" height="72" viewBox="0 0 56 72" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="28" cy="68" rx="16" ry="4" fill={color} opacity="0.2" />
<rect x="18" y="36" width="20" height="26" rx="4" fill="#f8fafc" stroke={color} strokeWidth="1.5" />
<rect x="10" y="38" width="8" height="18" rx="3" fill="#f1f5f9" stroke={color} strokeWidth="1" />
<rect x="38" y="38" width="8" height="18" rx="3" fill="#f1f5f9" stroke={color} strokeWidth="1" />
<rect x="20" y="58" width="7" height="12" rx="2" fill="#e2e8f0" stroke={color} strokeWidth="1" />
<rect x="29" y="58" width="7" height="12" rx="2" fill="#e2e8f0" stroke={color} strokeWidth="1" />
<circle cx="28" cy="22" r="12" fill="#f8fafc" stroke={color} strokeWidth="1.5" />
<path d="M14 20 Q28 8 42 20 L40 24 Q28 14 16 24 Z" fill={color} opacity="0.9" />
<rect x="14" y="20" width="28" height="4" rx="1" fill={color} />
<path d="M16 24 Q16 34 20 36" stroke={color} strokeWidth="2" fill="none" />
<path d="M40 24 Q40 34 36 36" stroke={color} strokeWidth="2" fill="none" />
<rect x="12" y="22" width="6" height="10" rx="2" fill={color} opacity="0.7" />
<rect x="38" y="22" width="6" height="10" rx="2" fill={color} opacity="0.7" />
<path d="M36 36 L42 44" stroke={color} strokeWidth="1.5" />
<circle cx="43" cy="45" r="2" fill={color} />
<rect x="20" y="20" width="16" height="5" rx="2" fill={color} opacity="0.3" />
{state === 'fetch' && (
<motion.circle
cx="46" cy="30" r="4"
fill={color}
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{ repeat: Infinity, duration: 0.6 }}
/>
)}
</svg>
<span className="text-[10px] font-mono font-semibold mt-1 truncate max-w-[72px]" style={{ color }}>{label}</span>
</motion.div>
)
}
-79
View File
@@ -1,79 +0,0 @@
import { motion } from 'framer-motion'
import { AgentSprite } from './AgentSprite'
import type { Agent, AgentAnim, Zone } from '../types'
const ZONE_X: Record<string, number> = {
docker: 8,
db: 28,
lakehouse: 50,
hadoop: 72,
etl: 92,
}
const DESK_X = 50
type Props = {
agents: Agent[]
zones: Zone[]
animations: Record<string, AgentAnim>
}
export function OpsFloor({ agents, zones, animations }: Props) {
return (
<div className="glass-strong rounded-2xl p-5 relative overflow-hidden min-h-[300px]">
<div className="flex justify-between items-center mb-4">
<h2 className="font-display text-lg font-bold text-neon-cyan neon-text-cyan tracking-wide">OPS FLOOR</h2>
<span className="text-xs font-mono px-2.5 py-1 rounded-full bg-green-50 text-neon-green border border-neon-green/30 animate-pulse"> LIVE</span>
</div>
<div className="relative h-28 mb-4">
{zones.map((z) => (
<div
key={z.id}
className="absolute top-0 -translate-x-1/2 text-center"
style={{ left: `${z.x}%` }}
>
<div
className="rounded-xl px-3 py-3 min-w-[92px] text-[9px] font-mono tracking-wider font-semibold bg-white/90"
style={{ border: `2px solid ${z.color}`, boxShadow: `0 4px 16px ${z.color}22`, color: z.color }}
>
{z.label}
</div>
</div>
))}
<svg className="absolute inset-0 w-full h-full pointer-events-none" preserveAspectRatio="none">
{zones.map((z) => (
<line
key={`path-${z.id}`}
x1={`${DESK_X}%`} y1="85%" x2={`${z.x}%`} y2="35%"
stroke={z.color} strokeWidth="2" strokeDasharray="6 4" opacity="0.45"
/>
))}
</svg>
</div>
<div className="relative h-28 rounded-xl bg-gradient-to-b from-slate-50 to-white border border-slate-100">
{agents.map((agent, i) => {
const anim = animations[agent.id] || { agentId: agent.id, state: 'idle' as const }
const targetX = anim.state === 'idle' ? 12 + i * 17 : ZONE_X[anim.zone || agent.zone] ?? DESK_X
const y = anim.state === 'fetch' ? 8 : anim.state === 'idle' ? 0 : 4
return (
<motion.div
key={agent.id}
className="absolute bottom-2 -translate-x-1/2"
animate={{ left: `${targetX}%`, y }}
transition={{ type: 'spring', stiffness: 80, damping: 14 }}
>
<AgentSprite
color={agent.color}
state={anim.state}
label={agent.name.split(' ')[0]}
/>
</motion.div>
)
})}
</div>
</div>
)
}
-41
View File
@@ -1,41 +0,0 @@
import { FormEvent, useState } from 'react'
type Props = {
onSubmit: (message: string) => void
busy: boolean
}
export function PromptBar({ onSubmit, busy }: Props) {
const [text, setText] = useState('')
const handle = (e: FormEvent) => {
e.preventDefault()
if (!text.trim() || busy) return
onSubmit(text.trim())
setText('')
}
return (
<form onSubmit={handle} className="glass-strong rounded-2xl p-4 flex gap-3 items-center shadow-card">
<span className="text-2xl" aria-hidden>💬</span>
<input
className="flex-1 bg-white border border-slate-200 rounded-xl px-4 py-2.5 outline-none font-mono text-sm text-ink placeholder:text-ink-faint focus:border-neon-cyan focus:ring-2 focus:ring-neon-cyan/20 transition"
placeholder="Vraag je agents... bijv. Hoe staat Debezium er voor?"
value={text}
onChange={(e) => setText(e.target.value)}
disabled={busy}
/>
<button
type="submit"
disabled={busy || !text.trim()}
className="px-5 py-2.5 rounded-xl font-display text-sm font-semibold text-white disabled:opacity-40 transition-all hover:brightness-110"
style={{
background: busy ? '#94a3b8' : 'linear-gradient(135deg, #0099cc, #8844cc)',
boxShadow: busy ? 'none' : '0 4px 16px rgba(0, 153, 204, 0.35)',
}}
>
{busy ? 'Bezig...' : 'Send'}
</button>
</form>
)
}
@@ -0,0 +1,61 @@
import type { Agent, FeedEntry } from '../../types'
import { getAgentMeta } from '../../lib/agentMeta'
import { cn } from '../../lib/utils'
type Props = {
feed: FeedEntry[]
agents: Agent[]
filterAgentId?: string | null
opsOnly?: boolean
}
const LEVEL: Record<string, string> = {
info: 'text-foreground-muted',
ok: 'text-success',
warn: 'text-warning',
err: 'text-danger',
}
function isOpsEvent(message: string): boolean {
const lower = message.toLowerCase()
if (message.includes(' answered:')) return false
if (message.startsWith('Prompt received:')) return false
if (lower.includes('completed a response')) return false
return true
}
export function ActivityStream({ feed, agents, filterAgentId, opsOnly }: Props) {
let items = filterAgentId ? feed.filter((e) => e.agent_id === filterAgentId) : feed
if (opsOnly) items = items.filter((e) => isOpsEvent(e.message))
return (
<div className="scrollbar-thin flex-1 overflow-y-auto px-2 pb-2">
{items.length === 0 && (
<p className="py-8 text-center text-[11px] text-foreground-faint">
{opsOnly ? 'Geen operationele events — antwords staan in Chat.' : 'No activity yet — agents are on standby.'}
</p>
)}
{items.map((e) => {
const ag = agents.find((a) => a.id === e.agent_id)
const meta = ag ? getAgentMeta(ag.id) : null
const Icon = meta?.icon
return (
<div key={e.id} className="flex gap-2 border-b border-border/50 py-1.5 last:border-0">
{Icon && (
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded bg-surface-overlay" style={{ color: meta?.accent }}>
<Icon className="h-3 w-3" />
</span>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[10px] font-medium text-foreground-muted">{ag?.name.split(' ·')[0] || e.agent_id}</span>
<span className="font-mono text-[9px] text-foreground-faint">{new Date(e.ts).toLocaleTimeString('en-US', { hour12: false })}</span>
</div>
<p className={cn('text-[10px] leading-relaxed', LEVEL[e.level] || 'text-foreground-muted')}>{e.message}</p>
</div>
</div>
)
})}
</div>
)
}
+121
View File
@@ -0,0 +1,121 @@
import { ShieldCheck } from 'lucide-react'
import type { Agent, AgentAnim } from '../../types'
import type { AgentLoad } from '../../hooks/useLiveMetrics'
import { agentTaskLabel, getAgentMeta } from '../../lib/agentMeta'
import { cn } from '../../lib/utils'
type Props = {
agents: Agent[]
animations: Record<string, AgentAnim>
selectedId: string | null
loads: Record<string, AgentLoad>
approvalCount: number
onSelect: (id: string) => void
onOpenApprovals: () => void
}
function AgentCard({
agent,
anim,
load,
selected,
onSelect,
}: {
agent: Agent
anim?: AgentAnim
load?: AgentLoad
selected: boolean
onSelect: () => void
}) {
const meta = getAgentMeta(agent.id)
const Icon = meta.icon
const busy = anim && anim.state !== 'idle'
return (
<button
type="button"
onClick={onSelect}
className={cn(
'flex h-[118px] w-[140px] shrink-0 flex-col gap-1 rounded-lg border bg-surface-raised p-1.5 text-left shadow-sm dark:bg-surface-overlay',
selected ? 'border-docker/50 ring-1 ring-docker/20' : 'border-border hover:border-border-strong',
)}
>
<div className="flex items-center gap-1.5">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-surface" style={{ color: meta.accent }}>
<Icon className="h-3.5 w-3.5" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[11px] font-semibold text-foreground">{agent.name.split(' ·')[0]}</p>
<p className="truncate text-[8px] text-foreground-muted">{meta.domain}</p>
</div>
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', busy ? 'bg-success animate-pulse' : 'bg-foreground-faint/30')} />
</div>
<p className="line-clamp-2 text-[8px] leading-[10px] text-foreground-muted">{agent.role}</p>
<p className="h-[20px] line-clamp-2 text-[8px] leading-[10px] text-foreground-faint">{agentTaskLabel(agent.id, anim)}</p>
<div className="mt-auto space-y-0.5">
<div className="flex justify-between font-mono text-[7px] tabular-nums text-foreground-faint">
<span>CPU {load?.cpu ?? 0}%</span>
<span>MEM {load?.mem ?? 0}%</span>
</div>
<div className="h-1 overflow-hidden rounded-full bg-surface">
<div className="h-full rounded-full transition-[width] duration-700" style={{ width: `${load?.cpu ?? 0}%`, background: meta.accent }} />
</div>
</div>
</button>
)
}
export function AgentFleet({ agents, animations, selectedId, loads, approvalCount, onSelect, onOpenApprovals }: Props) {
const supervisors = agents.filter((a) => a.supervisor)
const operators = agents.filter((a) => !a.supervisor && a.id !== 'mcp-coordinator')
const mcp = agents.find((a) => a.id === 'mcp-coordinator')
return (
<div className="panel flex shrink-0 flex-col p-2">
<div className="mb-1 flex shrink-0 items-center justify-between gap-2">
<div className="min-w-0">
<h3 className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">Agent Fleet</h3>
<p className="truncate text-[8px] text-foreground-faint">Klik agent stel vraag in chat · elk agent bewaakt één domein</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={onOpenApprovals}
className={cn(
'flex items-center gap-1 rounded-md border px-2 py-1 text-[9px] font-medium',
approvalCount > 0 ? 'border-warning/40 bg-warning/10 text-warning' : 'border-border text-foreground-muted hover:bg-surface-overlay',
)}
>
<ShieldCheck className="h-3 w-3" />
Approvals{approvalCount > 0 ? ` (${approvalCount})` : ''}
</button>
<span className="font-mono text-[8px] text-foreground-faint">{agents.length} agents</span>
</div>
</div>
<div className="scroll-x-stable flex min-h-0 gap-3 pb-1">
<div className="shrink-0">
<p className="mb-1 text-[8px] uppercase tracking-widest text-foreground-faint">Supervisors</p>
<div className="flex gap-1.5">
{supervisors.map((a) => (
<AgentCard key={a.id} agent={a} anim={animations[a.id]} load={loads[a.id]} selected={selectedId === a.id} onSelect={() => onSelect(a.id)} />
))}
</div>
</div>
{mcp && (
<div className="shrink-0">
<p className="mb-1 text-[8px] uppercase tracking-widest text-foreground-faint">MCP Hub</p>
<AgentCard agent={mcp} anim={animations[mcp.id]} load={loads[mcp.id]} selected={selectedId === mcp.id} onSelect={() => onSelect(mcp.id)} />
</div>
)}
<div className="shrink-0">
<p className="mb-1 text-[8px] uppercase tracking-widest text-foreground-faint">Field Operators</p>
<div className="flex gap-1.5">
{operators.map((a) => (
<AgentCard key={a.id} agent={a} anim={animations[a.id]} load={loads[a.id]} selected={selectedId === a.id} onSelect={() => onSelect(a.id)} />
))}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,98 @@
import { useState } from 'react'
import { Check, ShieldAlert, X } from 'lucide-react'
import type { Agent, Approval } from '../../types'
import { getAgentMeta } from '../../lib/agentMeta'
import { Button } from '../ui/Button'
import { cn } from '../../lib/utils'
const ACTION_LABELS: Record<string, string> = {
'docker.restart': 'Container restart',
'docker.update': 'Image update',
'generic.mutate': 'Infrastructure change',
}
type Props = {
approvals: Approval[]
agents: Agent[]
highlighted: boolean
onDecide: (id: string, approved: boolean, decidedBy: string, note: string) => Promise<void>
onDismissHighlight?: () => void
}
export function ApprovalCards({ approvals, agents, highlighted, onDecide, onDismissHighlight }: Props) {
const [busyId, setBusyId] = useState<string | null>(null)
const [decider, setDecider] = useState<'mo-commander' | 'bart-commander'>('mo-commander')
if (!approvals.length) return null
const agentOf = (id: string) => agents.find((a) => a.id === id)
const handle = async (id: string, approved: boolean) => {
setBusyId(id)
try {
await onDecide(id, approved, decider, '')
} finally {
setBusyId(null)
}
}
return (
<div className={cn('mx-2 mb-2 rounded-lg border bg-surface-overlay p-2.5', highlighted ? 'border-warning/50 ring-1 ring-warning/20' : 'border-border')}>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-warning">
<ShieldAlert className="h-3.5 w-3.5" />
Pending approvals ({approvals.length})
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-[9px] text-foreground-muted">
As
<select
value={decider}
onChange={(e) => setDecider(e.target.value as typeof decider)}
className="rounded border border-border bg-surface px-1 py-0.5 text-[10px] text-foreground-muted"
>
<option value="mo-commander">Mo</option>
<option value="bart-commander">Bart</option>
</select>
</label>
{highlighted && onDismissHighlight && (
<button type="button" onClick={onDismissHighlight} className="text-[9px] text-foreground-muted hover:text-foreground-muted">Dismiss</button>
)}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{approvals.map((a) => {
const ag = agentOf(a.agent_id)
const meta = ag ? getAgentMeta(ag.id) : null
const Icon = meta?.icon
return (
<div key={a.id} className="rounded-lg border border-border bg-surface p-2">
<div className="mb-1.5 flex items-start gap-1.5">
{Icon && (
<span className="flex h-6 w-6 items-center justify-center rounded bg-surface-overlay text-docker">
<Icon className="h-3.5 w-3.5" />
</span>
)}
<div className="min-w-0 flex-1">
<span className="text-[8px] uppercase tracking-wide text-warning">{ACTION_LABELS[a.action_type] || a.action_type}</span>
<p className="truncate text-[11px] font-medium text-foreground">{ag?.name || a.agent_id}</p>
</div>
<span className="font-mono text-[8px] text-foreground-faint">#{a.id.slice(0, 6)}</span>
</div>
<p className="mb-1 text-[10px] text-foreground-muted line-clamp-2">{a.action}</p>
{a.target && <p className="text-[9px] text-foreground-muted">Target: {a.target}</p>}
<div className="mt-2 flex gap-1">
<Button size="sm" variant="success" className="flex-1 text-[10px]" disabled={busyId === a.id} onClick={() => handle(a.id, true)}>
<Check className="h-3 w-3" /> Approve
</Button>
<Button size="sm" variant="danger" className="flex-1 text-[10px]" disabled={busyId === a.id} onClick={() => handle(a.id, false)}>
<X className="h-3 w-3" /> Deny
</Button>
</div>
</div>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,122 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Check, ShieldCheck, X } from 'lucide-react'
import { fetchApprovalHistory } from '../../lib/api'
import type { Agent, Approval } from '../../types'
import { getAgentMeta } from '../../lib/agentMeta'
import { Badge } from '../ui/Badge'
import { Button } from '../ui/Button'
import { cn } from '../../lib/utils'
type Filter = 'pending' | 'approved' | 'denied' | 'all'
type Props = {
agents: Agent[]
livePending: Approval[]
onDecide: (id: string, approved: boolean, decidedBy: string, note: string) => Promise<void>
}
export function ApprovalInbox({ agents, livePending, onDecide }: Props) {
const [filter, setFilter] = useState<Filter>('pending')
const [items, setItems] = useState<Approval[]>([])
const [stats, setStats] = useState({ pending: 0, approved: 0, denied: 0, total: 0 })
const [selectedId, setSelectedId] = useState<string | null>(null)
const [decider, setDecider] = useState<'mo-commander' | 'bart-commander'>('mo-commander')
const [note, setNote] = useState('')
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
const res = await fetchApprovalHistory(filter === 'all' ? 'all' : filter)
setItems(res.approvals)
if (res.stats) setStats(res.stats)
}, [filter])
useEffect(() => { load() }, [load, livePending])
const selected = useMemo(() => items.find((a) => a.id === selectedId) || items[0] || null, [items, selectedId])
const agentOf = (id: string) => agents.find((a) => a.id === id)
const handleDecide = async (approved: boolean) => {
if (!selected || selected.status !== 'pending') return
setBusy(true)
try {
await onDecide(selected.id, approved, decider, note)
setNote('')
await load()
} finally {
setBusy(false)
}
}
return (
<section className="panel flex min-h-0 flex-1 flex-col overflow-hidden">
<header className="flex shrink-0 items-start justify-between gap-3 border-b border-border px-3 py-2">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-docker" />
<div>
<h2 className="text-sm font-semibold text-foreground">Approval Inbox</h2>
<p className="text-[10px] text-foreground-muted">Mo & Bart review mutating agent actions</p>
</div>
</div>
<div className="flex gap-1">
<Badge variant="warning">{stats.pending} pending</Badge>
<Badge variant="success">{stats.approved} ok</Badge>
<Badge variant="danger">{stats.denied} denied</Badge>
</div>
</header>
<div className="flex shrink-0 gap-1 border-b border-border px-2 py-1">
{(['pending', 'approved', 'denied', 'all'] as Filter[]).map((f) => (
<button key={f} type="button" onClick={() => setFilter(f)} className={cn('rounded px-2 py-0.5 text-[10px] capitalize', filter === f ? 'bg-docker-light text-docker' : 'text-foreground-muted')}>
{f}
</button>
))}
</div>
<div className="grid min-h-0 flex-1 grid-cols-[minmax(200px,0.9fr)_1.1fr]">
<div className="scrollbar-thin overflow-y-auto border-r border-border p-1">
{!items.length && <p className="p-4 text-center text-[10px] text-foreground-faint">No {filter} requests.</p>}
{items.map((a) => {
const ag = agentOf(a.agent_id)
const meta = ag ? getAgentMeta(ag.id) : null
const Icon = meta?.icon
return (
<button key={a.id} type="button" onClick={() => setSelectedId(a.id)} className={cn('mb-1 flex w-full gap-2 rounded-lg border p-2 text-left', selected?.id === a.id ? 'border-docker/40 bg-docker-light' : 'border-transparent hover:bg-surface-overlay')}>
{Icon && <Icon className="h-4 w-4 shrink-0 text-docker" />}
<span className="min-w-0 flex-1">
<span className="block truncate text-[11px] font-medium text-foreground">{a.action.slice(0, 80)}</span>
<span className="block text-[9px] text-foreground-faint">{ag?.name || a.agent_id}</span>
</span>
<Badge variant={a.status === 'pending' ? 'warning' : a.status === 'approved' ? 'success' : 'danger'}>{a.status}</Badge>
</button>
)
})}
</div>
{selected && (
<div className="scrollbar-thin overflow-y-auto p-3">
<Badge variant={selected.status === 'pending' ? 'warning' : 'success'}>{selected.status}</Badge>
<dl className="mt-3 grid grid-cols-2 gap-2 text-[10px]">
<div><dt className="text-foreground-faint">Agent</dt><dd className="text-foreground">{agentOf(selected.agent_id)?.name}</dd></div>
<div><dt className="text-foreground-faint">Type</dt><dd className="text-foreground">{selected.action_type}</dd></div>
<div className="col-span-2"><dt className="text-foreground-faint">Action</dt><dd className="text-foreground-muted">{selected.action}</dd></div>
<div className="col-span-2"><dt className="text-foreground-faint">Reason</dt><dd className="text-foreground-muted">{selected.reason}</dd></div>
</dl>
{selected.status === 'pending' && (
<div className="mt-4 space-y-2">
<select value={decider} onChange={(e) => setDecider(e.target.value as typeof decider)} className="w-full rounded border border-border bg-surface px-2 py-1 text-[10px] text-foreground-muted">
<option value="mo-commander">Decide as Mo</option>
<option value="bart-commander">Decide as Bart</option>
</select>
<input value={note} onChange={(e) => setNote(e.target.value)} placeholder="Note (optional)" className="w-full rounded border border-border bg-surface px-2 py-1 text-[10px] text-foreground-muted" />
<div className="flex gap-2">
<Button variant="success" className="flex-1" disabled={busy} onClick={() => handleDecide(true)}><Check className="h-3 w-3" /> Approve</Button>
<Button variant="danger" className="flex-1" disabled={busy} onClick={() => handleDecide(false)}><X className="h-3 w-3" /> Deny</Button>
</div>
</div>
)}
</div>
)}
</div>
</section>
)
}
@@ -0,0 +1,212 @@
import { useEffect, useState } from 'react'
import { cn } from '../../lib/utils'
type FlowNode = { id: string; label: string; sub?: string; color: string }
type FlowEdge = { from: string; to: string; label?: string }
const FLOWS: Record<string, { nodes: FlowNode[]; edges: FlowEdge[] }> = {
'full-stack': {
nodes: [
{ id: 'user', label: 'User / Customer', sub: 'Browser', color: '#60a5fa' },
{ id: 'caddy', label: 'Caddy :80', sub: 'Reverse proxy', color: '#38bdf8' },
{ id: 'ui', label: 'Command Center', sub: 'React UI', color: '#818cf8' },
{ id: 'api', label: 'Agents API', sub: 'FastAPI :3201', color: '#a78bfa' },
{ id: 'dq', label: 'DQ API', sub: 'Maturity + Docling', color: '#f59e0b' },
{ id: 'rag', label: 'RAG API', sub: 'LangChain', color: '#34d399' },
{ id: 'chroma', label: 'ChromaDB', sub: 'Vectors (persistent)', color: '#22d3ee' },
{ id: 'docling', label: 'Docling', sub: ':5001', color: '#fb923c' },
{ id: 'llm', label: 'vLLM Llama 70B', sub: 'GPU Lab', color: '#4ade80' },
{ id: 'lake', label: 'Lakehouse', sub: 'Kafka · Spark · Trino', color: '#6366f1' },
],
edges: [
{ from: 'user', to: 'caddy', label: 'HTTP' },
{ from: 'caddy', to: 'ui' },
{ from: 'ui', to: 'api' },
{ from: 'ui', to: 'dq' },
{ from: 'ui', to: 'rag' },
{ from: 'dq', to: 'docling' },
{ from: 'rag', to: 'docling' },
{ from: 'rag', to: 'chroma' },
{ from: 'rag', to: 'llm' },
{ from: 'api', to: 'llm' },
{ from: 'api', to: 'lake' },
],
},
'rag-flow': {
nodes: [
{ id: 'upload', label: 'Upload PDF/CSV', sub: 'Once', color: '#60a5fa' },
{ id: 'store', label: 'File Store', sub: '/data/uploads', color: '#64748b' },
{ id: 'docling', label: 'Docling', sub: 'Parse + OCR', color: '#fb923c' },
{ id: 'chunk', label: 'LangChain Splitter', sub: '800 char chunks', color: '#a78bfa' },
{ id: 'embed', label: 'MiniLM Embeddings', sub: '384-d vectors', color: '#818cf8' },
{ id: 'chroma', label: 'ChromaDB', sub: 'Persistent', color: '#22d3ee' },
{ id: 'query', label: 'Your Question', sub: 'Any time', color: '#60a5fa' },
{ id: 'retrieve', label: 'Similarity Search', sub: 'top-k chunks', color: '#34d399' },
{ id: 'llm', label: 'Llama 70B', sub: 'Answer + sources', color: '#4ade80' },
],
edges: [
{ from: 'upload', to: 'store', label: 'save' },
{ from: 'upload', to: 'docling' },
{ from: 'docling', to: 'chunk' },
{ from: 'chunk', to: 'embed' },
{ from: 'embed', to: 'chroma', label: 'index' },
{ from: 'query', to: 'retrieve' },
{ from: 'retrieve', to: 'chroma' },
{ from: 'retrieve', to: 'llm' },
],
},
'dq-flow': {
nodes: [
{ id: 'data', label: 'Customer Data', sub: 'CSV · Excel · PDF', color: '#60a5fa' },
{ id: 'docling', label: 'Docling', sub: 'Structure + images', color: '#fb923c' },
{ id: 'pandas', label: 'Pandas Profiling', sub: 'Column stats', color: '#a78bfa' },
{ id: 'ge', label: 'Great Expectations', sub: 'Expectation checks', color: '#34d399' },
{ id: 'soda', label: 'Soda Core', sub: 'YAML checks', color: '#22d3ee' },
{ id: 'maturity', label: '6 Dimensions', sub: 'Score 0100', color: '#f59e0b' },
{ id: 'report', label: 'HTML Report', sub: 'Roadmap + actions', color: '#818cf8' },
],
edges: [
{ from: 'data', to: 'docling' },
{ from: 'data', to: 'pandas' },
{ from: 'pandas', to: 'ge' },
{ from: 'pandas', to: 'soda' },
{ from: 'ge', to: 'maturity' },
{ from: 'soda', to: 'maturity' },
{ from: 'maturity', to: 'report' },
],
},
'lakehouse': {
nodes: [
{ id: 'pg', label: 'PostgreSQL', color: '#60a5fa' },
{ id: 'mysql', label: 'MySQL', color: '#60a5fa' },
{ id: 'mongo', label: 'MongoDB', color: '#60a5fa' },
{ id: 'debezium', label: 'Debezium CDC', color: '#f59e0b' },
{ id: 'kafka', label: 'Kafka', color: '#fb923c' },
{ id: 'spark', label: 'Spark', color: '#a78bfa' },
{ id: 'iceberg', label: 'Iceberg', color: '#22d3ee' },
{ id: 'trino', label: 'Trino', color: '#34d399' },
{ id: 'bi', label: 'Superset BI', color: '#818cf8' },
],
edges: [
{ from: 'pg', to: 'debezium' },
{ from: 'mysql', to: 'debezium' },
{ from: 'mongo', to: 'debezium' },
{ from: 'debezium', to: 'kafka' },
{ from: 'kafka', to: 'spark' },
{ from: 'spark', to: 'iceberg' },
{ from: 'iceberg', to: 'trino' },
{ from: 'trino', to: 'bi' },
],
},
}
const POSITIONS: Record<string, Record<string, { x: number; y: number }>> = {
'full-stack': {
user: { x: 50, y: 8 },
caddy: { x: 50, y: 22 },
ui: { x: 50, y: 38 },
api: { x: 18, y: 58 },
dq: { x: 50, y: 58 },
rag: { x: 82, y: 58 },
docling: { x: 50, y: 78 },
chroma: { x: 82, y: 78 },
llm: { x: 82, y: 92 },
lake: { x: 18, y: 92 },
},
'rag-flow': {
upload: { x: 12, y: 20 },
store: { x: 12, y: 45 },
docling: { x: 35, y: 20 },
chunk: { x: 58, y: 20 },
embed: { x: 58, y: 45 },
chroma: { x: 58, y: 70 },
query: { x: 82, y: 20 },
retrieve: { x: 82, y: 45 },
llm: { x: 82, y: 70 },
},
'dq-flow': {
data: { x: 10, y: 50 },
docling: { x: 28, y: 25 },
pandas: { x: 28, y: 75 },
ge: { x: 52, y: 35 },
soda: { x: 52, y: 65 },
maturity: { x: 72, y: 50 },
report: { x: 90, y: 50 },
},
'lakehouse': {
pg: { x: 8, y: 15 },
mysql: { x: 8, y: 35 },
mongo: { x: 8, y: 55 },
debezium: { x: 28, y: 35 },
kafka: { x: 45, y: 35 },
spark: { x: 58, y: 35 },
iceberg: { x: 72, y: 35 },
trino: { x: 85, y: 35 },
bi: { x: 92, y: 55 },
},
}
export function ArchitectureDiagram({ animation }: { animation: string }) {
const flow = FLOWS[animation] || FLOWS['full-stack']
const positions = POSITIONS[animation] || POSITIONS['full-stack']
const [tick, setTick] = useState(0)
useEffect(() => {
const t = setInterval(() => setTick((n) => n + 1), 2200)
return () => clearInterval(t)
}, [])
const activeEdge = tick % flow.edges.length
return (
<div className="relative mx-auto mb-6 h-[280px] w-full max-w-4xl rounded-xl border border-docker/30 bg-surface-overlay/60 p-2 md:h-[320px]">
<svg className="absolute inset-0 h-full w-full" viewBox="0 0 100 100" preserveAspectRatio="none">
{flow.edges.map((edge, i) => {
const from = positions[edge.from]
const to = positions[edge.to]
if (!from || !to) return null
const active = i === activeEdge
return (
<g key={`${edge.from}-${edge.to}`}>
<line
x1={from.x}
y1={from.y}
x2={to.x}
y2={to.y}
stroke={active ? '#38bdf8' : 'rgba(56,189,248,0.25)'}
strokeWidth={active ? 0.6 : 0.35}
strokeDasharray={active ? '2 1' : '1 2'}
className={active ? 'animate-pulse' : undefined}
/>
{active && (
<circle r="1.2" fill="#38bdf8">
<animateMotion dur="1.8s" repeatCount="indefinite" path={`M${from.x},${from.y} L${to.x},${to.y}`} />
</circle>
)}
</g>
)
})}
</svg>
{flow.nodes.map((node) => {
const pos = positions[node.id]
if (!pos) return null
const lit = flow.edges.some((e, i) => i === activeEdge && (e.from === node.id || e.to === node.id))
return (
<div
key={node.id}
className={cn(
'absolute -translate-x-1/2 -translate-y-1/2 rounded-lg border px-2 py-1 text-center transition-all duration-500',
lit ? 'scale-105 border-docker shadow-docker bg-docker/20' : 'border-border bg-surface-raised/90',
)}
style={{ left: `${pos.x}%`, top: `${pos.y}%`, minWidth: '72px' }}
>
<p className="text-[9px] font-semibold leading-tight text-foreground md:text-[10px]" style={{ color: lit ? node.color : undefined }}>
{node.label}
</p>
{node.sub && <p className="text-[7px] text-foreground-faint md:text-[8px]">{node.sub}</p>}
</div>
)
})}
</div>
)
}
+119
View File
@@ -0,0 +1,119 @@
import { useState } from 'react'
import { ChevronDown, ChevronUp, MessageSquare, Radio } from 'lucide-react'
import type { Agent, Approval, ChatMessage, FeedEntry } from '../../types'
import { ActivityStream } from './ActivityStream'
import { ApprovalCards } from './ApprovalCards'
import { CommandBar } from './CommandBar'
import { CommsPanel } from './CommsPanel'
import { cn } from '../../lib/utils'
type Props = {
expanded: boolean
onToggle: () => void
chat: ChatMessage[]
feed: FeedEntry[]
agents: Agent[]
approvals: Approval[]
selectedAgent: Agent | null
promptBusy: boolean
approvalHighlight: boolean
filterAgentId?: string | null
onSendPrompt: (message: string, agentId?: string) => void
onDecide: (id: string, approved: boolean, decidedBy: string, note: string) => Promise<void>
onDismissHighlight: () => void
}
export function ChatDrawer({
expanded,
onToggle,
chat,
feed,
agents,
approvals,
selectedAgent,
promptBusy,
approvalHighlight,
filterAgentId,
onSendPrompt,
onDecide,
onDismissHighlight,
}: Props) {
const [tab, setTab] = useState<'chat' | 'activity'>('chat')
const unread = chat.length
if (!expanded) {
return (
<button
type="button"
onClick={onToggle}
className="flex w-full shrink-0 items-center justify-between border-t border-border bg-surface-raised/95 px-4 py-2 backdrop-blur-sm hover:bg-surface-overlay"
>
<span className="flex items-center gap-2 text-xs font-medium text-foreground">
<MessageSquare className="h-4 w-4 text-docker" />
Chat & Activity
{unread > 0 && (
<span className="rounded-full bg-docker/20 px-2 py-0.5 font-mono text-[10px] text-docker">
{unread} bericht{unread !== 1 ? 'en' : ''}
</span>
)}
{approvals.length > 0 && (
<span className="rounded-full bg-warning/20 px-2 py-0.5 font-mono text-[10px] text-warning">
{approvals.length} approval{approvals.length !== 1 ? 's' : ''}
</span>
)}
</span>
<ChevronUp className="h-4 w-4 text-foreground-muted" />
</button>
)
}
return (
<div className="flex shrink-0 flex-col border-t border-border bg-surface-raised/95 backdrop-blur-sm" style={{ height: 'min(42vh, 380px)' }}>
<div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-1.5">
<div className="flex gap-1">
{(['chat', 'activity'] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={cn(
'flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[11px] font-medium capitalize transition-colors',
tab === t
? 'bg-docker-light text-docker dark:bg-blue-500/20 dark:text-blue-200'
: 'text-foreground-muted hover:text-foreground',
)}
>
{t === 'chat' ? <MessageSquare className="h-3 w-3" /> : <Radio className="h-3 w-3" />}
{t === 'chat' ? 'Chat' : 'Activity'}
{t === 'activity' && approvals.length > 0 && (
<span className="rounded-full bg-warning/20 px-1 font-mono text-[8px] text-warning">{approvals.length}</span>
)}
</button>
))}
</div>
<button type="button" onClick={onToggle} className="rounded p-1 text-foreground-muted hover:bg-surface-overlay hover:text-foreground" title="Inklappen">
<ChevronDown className="h-4 w-4" />
</button>
</div>
<div className="panel mx-2 mb-1 min-h-0 flex-1 overflow-hidden">
{tab === 'chat' ? (
<CommsPanel messages={chat} agents={agents} selectedAgent={selectedAgent} busy={promptBusy} />
) : (
<div className="flex h-full min-h-0 flex-col overflow-hidden">
<ApprovalCards
approvals={approvals}
agents={agents}
highlighted={approvalHighlight}
onDecide={onDecide}
onDismissHighlight={onDismissHighlight}
/>
<ActivityStream feed={feed} agents={agents} filterAgentId={filterAgentId} opsOnly />
</div>
)}
</div>
{tab === 'chat' && <CommandBar busy={promptBusy} selectedAgent={selectedAgent} onSubmit={onSendPrompt} />}
</div>
)
}
+58
View File
@@ -0,0 +1,58 @@
import { useState, type FormEvent } from 'react'
import { Send } from 'lucide-react'
import type { Agent } from '../../types'
import { Button } from '../ui/Button'
import { Input } from '../ui/Input'
type Props = {
busy: boolean
selectedAgent: Agent | null
onSubmit: (message: string, agentId?: string) => void
}
export function CommandBar({ busy, selectedAgent, onSubmit }: Props) {
const [input, setInput] = useState('')
const submit = (e: FormEvent) => {
e.preventDefault()
if (!input.trim() || busy) return
onSubmit(input.trim(), selectedAgent?.id)
setInput('')
}
const suggestions = selectedAgent?.suggested_prompts?.slice(0, 3) || [
'Hoeveel data zit er in de databases?',
'Wat staat er in PostgreSQL?',
'MongoDB supplychain overzicht',
]
return (
<footer className="shrink-0 border-t border-border bg-surface-raised/90 px-3 py-1.5 backdrop-blur-sm">
<form onSubmit={submit} className="flex gap-2">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={selectedAgent ? `Command ${selectedAgent.name.split(' ·')[0]}` : 'Enter command — auto-routed to specialist…'}
disabled={busy}
className="flex-1"
/>
<Button type="submit" disabled={busy || !input.trim()}>
<Send className="h-3.5 w-3.5" />
Send
</Button>
</form>
<div className="mt-1.5 flex flex-wrap gap-1">
{suggestions.map((s) => (
<button
key={s}
type="button"
onClick={() => setInput(s)}
className="rounded border border-border bg-surface px-2 py-0.5 text-[9px] text-foreground-muted hover:border-border-strong hover:text-foreground-muted"
>
{s}
</button>
))}
</div>
</footer>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useRef } from 'react'
import { MessageSquare } from 'lucide-react'
import type { Agent, ChatMessage } from '../../types'
import { getAgentMeta } from '../../lib/agentMeta'
import { cn } from '../../lib/utils'
type Props = {
messages: ChatMessage[]
agents: Agent[]
selectedAgent: Agent | null
busy: boolean
}
export function CommsPanel({ messages, agents, selectedAgent, busy }: Props) {
const bottomRef = useRef<HTMLDivElement>(null)
const meta = selectedAgent ? getAgentMeta(selectedAgent.id) : null
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, busy])
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2">
<h3 className="flex items-center gap-1.5 text-xs font-semibold text-foreground">
<MessageSquare className="h-3.5 w-3.5 text-docker" /> Comms
</h3>
{selectedAgent && meta && (
<span className="truncate font-mono text-[9px]" style={{ color: meta.accent }}>
{selectedAgent.name.split(' ·')[0]}
</span>
)}
</div>
<div className="scrollbar-thin flex-1 space-y-2 overflow-y-auto p-2">
{!messages.length && (
<div className="flex h-full flex-col items-center justify-center py-6 text-center">
<MessageSquare className="mb-2 h-6 w-6 text-foreground-faint" />
<p className="max-w-[200px] text-[10px] text-foreground-muted">
Send a command below routing selects the right specialist automatically.
</p>
</div>
)}
{messages.map((m, i) => {
const ag = m.role === 'agent' ? agents.find((a) => a.id === m.agent) : null
const agMeta = ag ? getAgentMeta(ag.id) : null
return (
<div key={i} className={cn('flex gap-2', m.role === 'user' && 'flex-row-reverse')}>
<div className={cn('max-w-[85%] rounded-lg border px-2 py-1.5', m.role === 'user' ? 'border-docker/25 bg-docker-light' : 'border-border bg-surface-overlay')}>
<p className="mb-0.5 text-[8px] text-foreground-muted">
{m.role === 'user' ? 'You' : ag?.name || m.agent}
{m.ts && ` · ${new Date(m.ts).toLocaleTimeString('en-US', { hour12: false })}`}
</p>
<p className="whitespace-pre-wrap text-[11px] text-foreground">{m.text}</p>
</div>
</div>
)
})}
{busy && (
<div className="flex items-center gap-2 px-2 py-2 text-[10px] text-foreground-muted">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-accent" />
<span>Agent verzamelt cluster-data en vraagt Llama 70B verwacht ~3090 sec</span>
</div>
)}
<div ref={bottomRef} />
</div>
</div>
)
}
@@ -0,0 +1,732 @@
import { useCallback, useEffect, useState, Fragment } from 'react'
import {
AlertTriangle,
CheckCircle2,
FileSearch,
FileText,
Image,
Layers,
Loader2,
RefreshCw,
Table2,
Upload,
XCircle,
} from 'lucide-react'
import { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive'
type Dimension = {
id: string
label: string
description: string
score: number
level: string
findings: string[]
recommended_actions?: string[]
}
type ColumnProfile = {
name: string
dtype: string
null_pct: number
unique_count: number
quality_flags: string[]
sample_values?: string[]
numeric?: { min: number; max: number; mean: number; outliers: number }
text?: { avg_length: number; empty_strings: number }
top_values?: { value: string; count: number }[]
}
type GxCheck = { suite: string; expectation: string; success: boolean; result: string; column?: string }
type SodaCheck = { suite: string; name: string; check: string; outcome: string; detail: string }
type DocStructure = {
pages: number
pictures: number
tables: number
text_blocks: number
headings: number
paragraphs: number
list_items?: number
form_items: number
key_value_pairs: number
label_counts?: Record<string, number>
table_details?: { index: number; rows: number; cols: number; cells: number; preview?: string }[]
picture_details?: { index: number; label: string; has_image: boolean; captions: number }[]
outline?: { type: string; text: string; level?: number }[]
}
type AssessResult = {
ok: boolean
report_id: string
overall_score: number
maturity_level: string
maturity_description?: string
rows: number
columns: number
dimensions: Dimension[]
column_profiles: ColumnProfile[]
action_items: { priority: string; dimension: string; score: number; action: string }[]
checks?: { great_expectations: GxCheck[]; soda_core: SodaCheck[] }
checks_summary: {
great_expectations: { total: number; passed: number }
soda_core: { total: number; warnings: number }
}
docling?: { used: boolean; parse_id?: string; document_structure?: DocStructure; stats?: Record<string, number>; images?: DocImage[] }
rag_ingest?: { ok: boolean; duplicate?: boolean; chunks?: number; message?: string; error?: string }
report_url: string
}
type DocImage = {
index: number
label: string
available: boolean
url?: string
width?: number
height?: number
mimetype?: string
dpi?: number
bytes?: number
captions?: string[]
}
type ParseResult = {
ok: boolean
parse_id: string
filename: string
status: string
processing_time_sec?: number
formats_available: string[]
document_structure: DocStructure
images?: DocImage[]
stats: Record<string, number>
content: { preview_markdown?: string; preview_html?: string; markdown?: string; html?: string }
table_preview?: string[]
errors?: string[]
parse_json_url?: string
}
type Capabilities = {
maturity_dimensions: { id: string; label: string; description: string }[]
maturity_levels: { min_score: number; label: string; description: string }[]
supported_data_formats: string[]
supported_document_formats: string[]
tools: Record<string, { status: string; capabilities?: string[] }>
docling_online: boolean
}
type ReportSummary = {
id: string
filename: string
ts: string
overall_score: number
maturity_level: string
rows: number
columns: number
}
type Tab = 'assess' | 'docling' | 'reports'
const SCORE_COLOR = (s: number) => (s >= 80 ? 'text-success' : s >= 60 ? 'text-warning' : 'text-danger')
const BAR_COLOR = (s: number) => (s >= 80 ? 'bg-success' : s >= 60 ? 'bg-warning' : 'bg-danger')
export function DataQualityView() {
const [tab, setTab] = useState<Tab>('assess')
const [caps, setCaps] = useState<Capabilities | null>(null)
const [loading, setLoading] = useState(false)
const [assess, setAssess] = useState<AssessResult | null>(null)
const [parse, setParse] = useState<ParseResult | null>(null)
const [parseFormat, setParseFormat] = useState<'markdown' | 'html'>('markdown')
const [reports, setReports] = useState<ReportSummary[]>([])
const [error, setError] = useState<string | null>(null)
const [expandedCol, setExpandedCol] = useState<string | null>(null)
const [showGx, setShowGx] = useState(false)
const [showSoda, setShowSoda] = useState(false)
const loadMeta = useCallback(async () => {
try {
const [c, r] = await Promise.all([fetch('/dq/capabilities'), fetch('/dq/reports')])
if (c.ok) setCaps(await c.json())
if (r.ok) {
const j = await r.json()
setReports(j.reports || [])
}
} catch {
/* ignore */
}
}, [])
useEffect(() => {
loadMeta()
}, [loadMeta])
const onAssess = async (file: File) => {
setLoading(true)
setError(null)
setAssess(null)
const fd = new FormData()
fd.append('file', file)
try {
const r = await fetch('/dq/assess', { method: 'POST', body: fd })
const j = await r.json()
if (!r.ok || !j.ok) {
setError(j.error || j.detail || 'Assessment failed')
return
}
setAssess(j as AssessResult)
loadMeta()
} catch {
setError('Connection failed — check DQ API')
} finally {
setLoading(false)
}
}
const onParse = async (file: File) => {
setLoading(true)
setError(null)
setParse(null)
const fd = new FormData()
fd.append('file', file)
fd.append('to_formats', 'md,html,json')
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 300000)
try {
const r = await fetch('/dq/parse', { method: 'POST', body: fd, signal: ctrl.signal })
const j = await r.json()
if (!r.ok || !j.ok) {
setError(typeof j.error === 'string' ? j.error : JSON.stringify(j.error || j).slice(0, 200) || 'Docling parse failed')
return
}
setParse(j as ParseResult)
loadMeta()
} catch (e) {
setError(e instanceof Error && e.name === 'AbortError' ? 'Timeout — document too large or Docling overloaded' : 'Docling unavailable')
} finally {
clearTimeout(timer)
setLoading(false)
}
}
const tabs: { id: Tab; label: string; icon: typeof FileSearch }[] = [
{ id: 'assess', label: 'Maturity Assessment', icon: FileSearch },
{ id: 'docling', label: 'Docling Parser', icon: FileText },
{ id: 'reports', label: 'Reports', icon: CheckCircle2 },
]
return (
<div className="flex h-full min-h-[calc(100vh-140px)] flex-col rounded-lg border border-border bg-surface-raised">
<header className="shrink-0 border-b border-border bg-surface-overlay/30 px-4 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-foreground">Data Quality & Maturity Platform</h2>
<p className="text-[11px] text-foreground-muted">
Full data maturity assessment for customer data Docling, Great Expectations, Soda Core
</p>
</div>
<div className="flex items-center gap-2">
<span className={cn('rounded-full px-2.5 py-1 text-[10px] font-medium', caps?.docling_online ? 'bg-success/20 text-success' : 'bg-danger/20 text-danger')}>
Docling {caps?.docling_online ? '● online' : '○ offline'}
</span>
<button type="button" onClick={loadMeta} className="rounded border border-border p-1.5 hover:bg-surface-overlay">
<RefreshCw className="h-4 w-4" />
</button>
<a href={`http://${window.location.hostname}:5001/ui/`} target="_blank" rel="noreferrer" className="rounded border border-docker/40 bg-docker/15 px-2 py-1 text-[10px] text-docker">
Docling UI
</a>
</div>
</div>
{caps && (
<div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
<CapCard title="Maturity Engine" items={caps.maturity_dimensions.map((d) => d.label)} icon={Layers} />
<CapCard title="Data Quality Tools" items={['Great Expectations', 'Soda Core', 'Pandas Profiling']} icon={CheckCircle2} />
<CapCard title="Document Parsing" items={caps.tools.docling?.capabilities || ['PDF', 'PPTX', 'DOCX']} icon={FileText} />
<CapCard title="File formats" items={[...caps.supported_data_formats.slice(0, 4), ...caps.supported_document_formats.slice(0, 3)]} icon={Upload} />
</div>
)}
</header>
<div className="flex shrink-0 gap-1 border-b border-border bg-surface-overlay/20 px-3 py-2">
{tabs.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => setTab(id)}
className={cn('flex items-center gap-1.5 rounded-md px-3 py-2 text-[11px] font-medium transition-all', tab === id ? subTabActive : subTabIdle)}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</div>
<div className="scrollbar-thin flex-1 overflow-y-auto p-4">
{error && (
<div className="mb-4 flex items-start gap-2 rounded-lg border border-danger/40 bg-danger/10 px-4 py-3 text-[11px] text-danger">
<XCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
{tab === 'assess' && (
<div className="space-y-5">
<UploadZone
loading={loading}
label="Upload customer data for full maturity assessment"
hint="CSV · Excel · JSON · Parquet · PDF · PPTX · DOCX"
accept=".csv,.tsv,.xlsx,.xls,.json,.parquet,.pdf,.pptx,.ppt,.docx"
onFile={onAssess}
/>
{loading && <LoadingMsg text="Analyzing: 6 maturity dimensions · GE checks · Soda checks · column profiles…" />}
{assess && (
<div className="space-y-5">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<StatCard label="Overall Score" value={`${assess.overall_score}`} sub="/100" accent />
<StatCard label="Maturity Level" value={assess.maturity_level} sub={assess.maturity_description} />
<StatCard label="Dataset" value={`${assess.rows.toLocaleString()}`} sub={`${assess.columns} columns`} />
<StatCard label="Great Expectations" value={`${assess.checks_summary.great_expectations.passed}/${assess.checks_summary.great_expectations.total}`} sub="checks passed" />
<StatCard label="Soda Core" value={String(assess.checks_summary.soda_core.warnings)} sub="warnings" warn={assess.checks_summary.soda_core.warnings > 0} />
</div>
{assess.docling?.used && assess.docling.document_structure && (
<>
<DocStructurePanel structure={assess.docling.document_structure} title="Document structure (via Docling)" />
{assess.docling.images && assess.docling.images.length > 0 && assess.docling.parse_id && (
<ImageGallery images={assess.docling.images} parseId={assess.docling.parse_id} />
)}
</>
)}
{assess.rag_ingest && (
<div className={cn(
'rounded-lg border px-3 py-2 text-[11px]',
assess.rag_ingest.ok ? 'border-success/30 bg-success/10 text-success' : 'border-warning/30 bg-warning/10 text-warning',
)}>
<p className="font-medium">Knowledge Chat sync</p>
<p className="text-foreground-muted">
{assess.rag_ingest.ok
? (assess.rag_ingest.duplicate
? `Already in Knowledge Chat — ${assess.rag_ingest.message || 'you can chat immediately.'}`
: `Indexed for chat: ${assess.rag_ingest.chunks ?? '?'} text chunks. Open Knowledge Chat to ask questions.`)
: (assess.rag_ingest.error || 'Could not sync to Knowledge Chat')}
</p>
</div>
)}
<div className="flex flex-wrap gap-2">
<a href={assess.report_url} target="_blank" rel="noreferrer" className={cn('rounded-md px-3 py-1.5 text-[11px] font-medium', subTabActive)}>
Full HTML report
</a>
<button type="button" onClick={() => setShowGx(!showGx)} className={cn('rounded-md px-3 py-1.5 text-[11px]', showGx ? subTabActive : subTabIdle)}>
GE checks ({assess.checks?.great_expectations.length || 0})
</button>
<button type="button" onClick={() => setShowSoda(!showSoda)} className={cn('rounded-md px-3 py-1.5 text-[11px]', showSoda ? subTabActive : subTabIdle)}>
Soda checks ({assess.checks?.soda_core.length || 0})
</button>
</div>
{showGx && assess.checks?.great_expectations && (
<CheckTable title="Great Expectations" rows={assess.checks.great_expectations.map((c) => ({
name: c.column ? `${c.expectation} [${c.column}]` : c.expectation,
status: c.success ? 'pass' : 'fail',
detail: c.result,
}))} />
)}
{showSoda && assess.checks?.soda_core && (
<CheckTable title="Soda Core" rows={assess.checks.soda_core.map((c) => ({
name: c.name,
status: c.outcome,
detail: `${c.check}${c.detail}`,
}))} />
)}
<section>
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">6 Maturity Dimensions</h3>
<div className="grid gap-3 lg:grid-cols-2 xl:grid-cols-3">
{assess.dimensions.map((d) => (
<DimensionCard key={d.id} dimension={d} />
))}
</div>
</section>
{assess.action_items.length > 0 && (
<section>
<h3 className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">
<AlertTriangle className="h-3.5 w-3.5 text-warning" /> Remediation Roadmap
</h3>
<div className="space-y-1.5">
{assess.action_items.map((a, i) => (
<div key={i} className={cn('rounded-lg border px-3 py-2 text-[11px]', a.priority === 'high' ? 'border-danger/40 bg-danger/10' : a.priority === 'medium' ? 'border-warning/40 bg-warning/10' : 'border-border bg-surface-overlay/40')}>
<span className="font-bold uppercase text-foreground-faint">{a.priority}</span>
{' · '}<strong>{a.dimension}</strong> ({a.score}): {a.action}
</div>
))}
</div>
</section>
)}
<section>
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">
Column profiles ({assess.column_profiles.length})
</h3>
<ColumnTable profiles={assess.column_profiles} expandedCol={expandedCol} onToggle={setExpandedCol} />
</section>
</div>
)}
</div>
)}
{tab === 'docling' && (
<div className="space-y-5">
<p className="text-[12px] leading-relaxed text-foreground-muted">
Docling extracts text, tables, images and document structure from PDF, PowerPoint, Word, Excel and images.
Resultaat: Markdown, HTML, JSON met pagina&apos;s, plaatjes, tabellen en outline.
</p>
<UploadZone
loading={loading}
label="Upload document for Docling parsing"
hint="PDF · PPTX · DOCX · XLSX · PNG · JPG · TIFF · MD · HTML"
accept=".pdf,.pptx,.ppt,.docx,.doc,.xlsx,.png,.jpg,.jpeg,.tiff,.txt,.md,.html"
onFile={onParse}
/>
{loading && <LoadingMsg text="Docling processing document — OCR, table detection, images (30180 sec)…" />}
{parse && (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6">
<StatCard label="Bestand" value={parse.filename.length > 20 ? parse.filename.slice(0, 18) + '…' : parse.filename} sub={parse.status} />
<StatCard label="Verwerking" value={`${(parse.processing_time_sec || 0).toFixed(1)}s`} sub={`Formats: ${parse.formats_available.join(', ')}`} />
<StatCard label="Pages" value={String(parse.document_structure?.pages ?? parse.stats.pages ?? 0)} icon={Layers} />
<StatCard label="Images" value={String(parse.document_structure?.pictures ?? 0)} icon={Image} accent />
<StatCard label="Tables" value={String(parse.document_structure?.tables ?? 0)} icon={Table2} />
<StatCard label="Text blocks" value={String(parse.document_structure?.text_blocks ?? 0)} sub={`${parse.stats.words?.toLocaleString() ?? 0} words`} />
</div>
<DocStructurePanel structure={parse.document_structure} title="Document analysis" />
{parse.images && parse.images.filter((i) => i.available).length > 0 && (
<ImageGallery images={parse.images} parseId={parse.parse_id} />
)}
{parse.document_structure?.outline && parse.document_structure.outline.length > 0 && (
<section className="rounded-lg border border-border bg-surface-overlay/30 p-3">
<h3 className="mb-2 text-[11px] font-semibold uppercase text-foreground-faint">Document outline</h3>
<ul className="space-y-1 text-[11px]">
{parse.document_structure.outline.map((o, i) => (
<li key={i} className="flex gap-2" style={{ paddingLeft: (o.level || 0) * 12 }}>
<span className="shrink-0 rounded bg-docker/20 px-1 font-mono text-[9px] text-docker">{o.type}</span>
<span className="text-foreground-muted">{o.text}</span>
</li>
))}
</ul>
</section>
)}
<div className="flex gap-1">
{(['markdown', 'html'] as const).map((f) => (
<button key={f} type="button" onClick={() => setParseFormat(f)} className={cn('rounded-md px-3 py-1.5 text-[11px] font-medium', parseFormat === f ? subTabActive : subTabIdle)}>
{f.toUpperCase()}
</button>
))}
{parse.parse_json_url && (
<a href={parse.parse_json_url} target="_blank" rel="noreferrer" className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
Full JSON
</a>
)}
</div>
{parse.table_preview && parse.table_preview.length > 0 && (
<section>
<h3 className="mb-1 text-[11px] font-semibold uppercase text-foreground-faint">Tables (markdown preview)</h3>
<pre className="scrollbar-thin max-h-40 overflow-auto rounded-lg border border-border bg-surface-overlay p-3 font-mono text-[10px]">
{parse.table_preview.join('\n')}
</pre>
</section>
)}
<section>
<h3 className="mb-2 text-[11px] font-semibold uppercase text-foreground-faint">Extracted content</h3>
{parseFormat === 'html' && (parse.content.preview_html || parse.content.html) ? (
<div className="scrollbar-thin max-h-[500px] overflow-auto rounded-lg border border-border bg-surface-overlay p-2">
<div className="rounded bg-white p-4 text-black" dangerouslySetInnerHTML={{ __html: parse.content.preview_html || parse.content.html || '' }} />
</div>
) : (
<pre className="scrollbar-thin max-h-[500px] overflow-auto rounded-lg border border-border bg-surface-overlay p-4 font-mono text-[11px] leading-relaxed text-foreground">
{parse.content.preview_markdown || parse.content.markdown || '(no content)'}
</pre>
)}
</section>
</div>
)}
</div>
)}
{tab === 'reports' && (
<div className="space-y-2">
{reports.length === 0 ? (
<p className="py-12 text-center text-sm text-foreground-muted">No reports yet upload customer data in Maturity Assessment.</p>
) : (
reports.map((r) => (
<a key={r.id} href={`/dq/report/${r.id}`} target="_blank" rel="noreferrer"
className="flex items-center justify-between rounded-lg border border-border bg-surface-overlay/30 px-4 py-3 transition-all hover:border-docker/40 hover:bg-docker/10">
<div>
<p className="text-[12px] font-medium">{r.filename}</p>
<p className="text-[10px] text-foreground-faint">{r.ts} · {r.rows?.toLocaleString()} rows · {r.columns} cols</p>
</div>
<div className="text-right">
<p className={cn('text-xl font-bold', SCORE_COLOR(r.overall_score))}>{r.overall_score}</p>
<p className="text-[10px] text-foreground-muted">{r.maturity_level}</p>
</div>
</a>
))
)}
</div>
)}
</div>
</div>
)
}
function ImageGallery({ images, parseId }: { images: DocImage[]; parseId: string }) {
const available = images.filter((i) => i.available)
const [lightbox, setLightbox] = useState<number | null>(null)
if (!available.length) {
return (
<section className="rounded-lg border border-border bg-surface-overlay/30 p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">
Images gedetecteerd ({images.length}) no embedded export
</h3>
<p className="text-[11px] text-foreground-muted">Re-upload the document to extract images (embedded mode).</p>
</section>
)
}
return (
<section className="rounded-lg border border-border bg-surface-overlay/30 p-4">
<h3 className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">
<Image className="h-4 w-4 text-docker" />
Images die Docling ziet ({available.length})
</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{available.map((img) => (
<button
key={img.index}
type="button"
onClick={() => setLightbox(img.index)}
className="group overflow-hidden rounded-lg border border-border bg-surface-raised text-left transition-all hover:border-docker/50 hover:shadow-docker"
>
<div className="flex aspect-[4/3] items-center justify-center overflow-hidden bg-black/20">
<img
src={img.url || `/dq/parse/${parseId}/image/${img.index}`}
alt={img.label}
className="max-h-full max-w-full object-contain transition-transform group-hover:scale-105"
loading="lazy"
/>
</div>
<div className="p-2">
<p className="text-[10px] font-medium text-foreground">#{img.index + 1} {img.label}</p>
<p className="text-[9px] text-foreground-faint">
{img.width && img.height ? `${Math.round(img.width)}×${Math.round(img.height)}` : ''}
{img.dpi ? ` · ${img.dpi}dpi` : ''}
{img.bytes ? ` · ${(img.bytes / 1024).toFixed(0)}KB` : ''}
</p>
</div>
</button>
))}
</div>
{lightbox !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4" onClick={() => setLightbox(null)}>
<div className="relative max-h-[90vh] max-w-[90vw]" onClick={(e) => e.stopPropagation()}>
<img
src={`/dq/parse/${parseId}/image/${lightbox}`}
alt={`Image ${lightbox + 1}`}
className="max-h-[85vh] max-w-full rounded-lg object-contain"
/>
<button type="button" onClick={() => setLightbox(null)} className="absolute -top-3 -right-3 rounded-full bg-surface-raised px-2 py-1 text-xs text-foreground"></button>
</div>
</div>
)}
</section>
)
}
function DocStructurePanel({ structure, title }: { structure: DocStructure; title: string }) {
return (
<section className="rounded-lg border border-border bg-surface-overlay/30 p-4">
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">{title}</h3>
<div className="mb-3 grid grid-cols-3 gap-2 sm:grid-cols-6">
{[
{ label: 'Pagina\'s', value: structure.pages, icon: Layers },
{ label: 'Images', value: structure.pictures, icon: Image },
{ label: 'Tables', value: structure.tables, icon: Table2 },
{ label: 'Headings', value: structure.headings },
{ label: 'Paragraphs', value: structure.paragraphs },
{ label: 'Text blocks', value: structure.text_blocks },
].map(({ label, value, icon: Icon }) => (
<div key={label} className="rounded-md border border-border bg-surface-raised p-2 text-center">
{Icon && <Icon className="mx-auto mb-1 h-4 w-4 text-docker" />}
<p className="text-lg font-bold text-foreground">{value}</p>
<p className="text-[9px] text-foreground-faint">{label}</p>
</div>
))}
</div>
{structure.picture_details && structure.picture_details.length > 0 && (
<div className="mb-3">
<p className="mb-1 text-[10px] font-medium text-foreground-muted">Images ({structure.picture_details.length})</p>
<div className="flex flex-wrap gap-1">
{structure.picture_details.map((p) => (
<span key={p.index} className="rounded border border-border bg-surface-raised px-2 py-0.5 text-[9px]">
#{p.index + 1} {p.label} {p.has_image ? '🖼' : ''} {p.captions > 0 ? `(${p.captions} captions)` : ''}
</span>
))}
</div>
</div>
)}
{structure.table_details && structure.table_details.length > 0 && (
<div>
<p className="mb-1 text-[10px] font-medium text-foreground-muted">Tables ({structure.table_details.length})</p>
<div className="space-y-1">
{structure.table_details.map((t) => (
<div key={t.index} className="rounded border border-border bg-surface-raised px-2 py-1 text-[10px] text-foreground-muted">
Table {t.index + 1}: {t.rows}×{t.cols} ({t.cells} cells) {t.preview || '…'}
</div>
))}
</div>
</div>
)}
</section>
)
}
function DimensionCard({ dimension: d }: { dimension: Dimension }) {
return (
<div className="rounded-lg border border-border bg-surface-overlay/40 p-3">
<div className="mb-1 flex items-center justify-between">
<span className="text-[12px] font-semibold">{d.label}</span>
<span className={cn('text-base font-bold', SCORE_COLOR(d.score))}>{d.score}</span>
</div>
<div className="mb-2 h-2 overflow-hidden rounded-full bg-border">
<div className={cn('h-full rounded-full', BAR_COLOR(d.score))} style={{ width: `${d.score}%` }} />
</div>
<p className="mb-2 text-[10px] text-foreground-faint">{d.description}</p>
<ul className="space-y-0.5 text-[10px] text-foreground-muted">
{d.findings.map((f) => (
<li key={f} className="flex gap-1"><span className="text-docker"></span>{f}</li>
))}
</ul>
{d.recommended_actions && d.recommended_actions.length > 0 && (
<p className="mt-2 border-t border-border pt-2 text-[9px] text-warning"> {d.recommended_actions[0]}</p>
)}
</div>
)
}
function ColumnTable({ profiles, expandedCol, onToggle }: { profiles: ColumnProfile[]; expandedCol: string | null; onToggle: (n: string | null) => void }) {
return (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-left text-[11px]">
<thead className="bg-surface-overlay text-[10px] uppercase text-foreground-faint">
<tr>
<th className="px-3 py-2">Column</th><th className="px-3 py-2">Type</th><th className="px-3 py-2">Null%</th>
<th className="px-3 py-2">Unique</th><th className="px-3 py-2">Flags</th>
</tr>
</thead>
<tbody>
{profiles.map((c) => (
<Fragment key={c.name}>
<tr className="cursor-pointer border-t border-border hover:bg-surface-overlay/50" onClick={() => onToggle(expandedCol === c.name ? null : c.name)}>
<td className="px-3 py-2 font-mono text-docker">{c.name}</td>
<td className="px-3 py-2">{c.dtype}</td>
<td className={cn('px-3 py-2', c.null_pct > 10 && 'font-semibold text-warning')}>{c.null_pct}%</td>
<td className="px-3 py-2">{c.unique_count.toLocaleString()}</td>
<td className="px-3 py-2 text-foreground-muted">{c.quality_flags.join(', ') || '—'}</td>
</tr>
{expandedCol === c.name && (
<tr className="border-t border-border bg-surface-overlay/20">
<td colSpan={5} className="px-4 py-2 text-[10px] text-foreground-muted">
{c.sample_values?.length ? <p className="mb-1">Samples: {c.sample_values.join(' · ')}</p> : null}
{c.numeric && <p>Range {c.numeric.min} {c.numeric.max}, μ={c.numeric.mean}, {c.numeric.outliers} outliers</p>}
{c.text && <p>Avg len {c.text.avg_length}, {c.text.empty_strings} empty strings</p>}
{c.top_values?.map((tv) => <span key={tv.value} className="mr-3">{tv.value} ({tv.count})</span>)}
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
)
}
function CheckTable({ title, rows }: { title: string; rows: { name: string; status: string; detail: string }[] }) {
return (
<div className="overflow-x-auto rounded-lg border border-border">
<p className="border-b border-border bg-surface-overlay px-3 py-2 text-[11px] font-semibold">{title}</p>
<table className="w-full text-[10px]">
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-t border-border">
<td className="px-3 py-1.5">
<span className={cn('mr-2 rounded px-1.5 py-0.5 text-[9px] font-bold uppercase',
r.status === 'pass' ? 'bg-success/20 text-success' : r.status === 'warn' ? 'bg-warning/20 text-warning' : 'bg-danger/20 text-danger')}>
{r.status}
</span>
{r.name}
</td>
<td className="px-3 py-1.5 text-foreground-muted">{r.detail}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function CapCard({ title, items, icon: Icon }: { title: string; items: string[]; icon: typeof Layers }) {
return (
<div className="rounded-lg border border-border bg-surface-raised/80 p-2.5">
<div className="mb-1 flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5 text-docker" />
<span className="text-[10px] font-semibold uppercase tracking-wide text-foreground-faint">{title}</span>
</div>
<p className="text-[10px] leading-relaxed text-foreground-muted">{items.join(' · ')}</p>
</div>
)
}
function UploadZone({ label, hint, accept, loading, onFile }: { label: string; hint: string; accept: string; loading: boolean; onFile: (f: File) => void }) {
return (
<label className={cn('flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-border/80 bg-surface-overlay/30 px-8 py-10 transition-all hover:border-docker/50 hover:bg-docker/5', loading && 'pointer-events-none opacity-50')}>
<Upload className="mb-3 h-10 w-10 text-docker opacity-60" />
<p className="text-[13px] font-medium text-foreground">{label}</p>
<p className="mt-1 text-[10px] text-foreground-faint">{hint}</p>
<input type="file" accept={accept} className="hidden" disabled={loading} onChange={(e) => e.target.files?.[0] && onFile(e.target.files[0])} />
</label>
)
}
function LoadingMsg({ text }: { text: string }) {
return (
<div className="flex items-center justify-center gap-3 rounded-lg border border-docker/30 bg-docker/5 py-10 text-sm text-foreground-muted">
<Loader2 className="h-6 w-6 animate-spin text-docker" />
{text}
</div>
)
}
function StatCard({ label, value, sub, accent, warn, icon: Icon }: { label: string; value: string; sub?: string; accent?: boolean; warn?: boolean; icon?: typeof Image }) {
return (
<div className="rounded-lg border border-border bg-surface-overlay/40 p-3">
<div className="flex items-center gap-1">
{Icon && <Icon className="h-3.5 w-3.5 text-docker" />}
<p className="text-[9px] uppercase tracking-wider text-foreground-faint">{label}</p>
</div>
<p className={cn('text-xl font-bold', accent ? 'text-docker' : warn ? 'text-warning' : 'text-foreground')}>{value}</p>
{sub && <p className="text-[10px] text-foreground-muted">{sub}</p>}
</div>
)
}
@@ -0,0 +1,205 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { Activity, Cpu, ExternalLink, Thermometer, Zap } from 'lucide-react'
import { fetchGpu } from '../../lib/api'
import type { GpuDevice, GpuStatus } from '../../types'
import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
import { cn } from '../../lib/utils'
type Props = {
gpu: GpuStatus | null
live: GpuLiveMetrics
boost?: boolean
onSelectGpu?: () => void
}
function memPct(used: number, total: number) {
if (!total) return 0
return Math.round((used / total) * 100)
}
function utilColor(pct: number) {
if (pct >= 75) return 'bg-danger'
if (pct >= 35) return 'bg-warning'
return 'bg-success'
}
function GpuRow({ device, liveUtil, active }: { device: GpuDevice; liveUtil: number; active: boolean }) {
const vramPct = memPct(device.memory_used_mib, device.memory_total_mib)
const util = liveUtil ?? device.util_gpu
return (
<div
className={cn(
'rounded-md border border-border/80 bg-surface-overlay/60 px-2 py-1.5 transition-colors',
active && util > 5 && 'border-docker/30 bg-docker/5',
)}
>
<div className="mb-1 flex items-center justify-between gap-1">
<span className="font-mono text-[9px] font-semibold text-foreground">GPU {device.index}</span>
<span className="font-mono text-[8px] text-foreground-faint">{util.toFixed(0)}% · {vramPct}% VRAM</span>
</div>
<div className="space-y-1">
<MetricBar label="Util" value={util} colorClass={utilColor(util)} />
<MetricBar label="VRAM" value={vramPct} colorClass="bg-docker" />
</div>
<div className="mt-1 flex justify-between font-mono text-[7px] text-foreground-faint">
<span className="inline-flex items-center gap-0.5">
<Thermometer className="h-2.5 w-2.5" />
{device.temperature_c?.toFixed(0) ?? '—'}°C
</span>
<span>{device.power_w?.toFixed(0) ?? '—'} W</span>
</div>
</div>
)
}
function MetricBar({ label, value, colorClass }: { label: string; value: number; colorClass: string }) {
return (
<div className="flex items-center gap-1.5">
<span className="w-7 shrink-0 text-[7px] text-foreground-faint">{label}</span>
<div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-raised">
<div
className={cn('h-full rounded-full transition-all duration-700 ease-out', colorClass)}
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
/>
</div>
</div>
)
}
export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props) {
const [localGpu, setLocalGpu] = useState<GpuStatus | null>(gpu)
const [lastPoll, setLastPoll] = useState<Date | null>(null)
useEffect(() => {
setLocalGpu(gpu)
}, [gpu])
useEffect(() => {
const poll = async () => {
const g = await fetchGpu()
if (g) {
setLocalGpu(g)
setLastPoll(new Date())
}
}
poll()
const ms = boost ? 1000 : 3000
const iv = setInterval(poll, ms)
return () => clearInterval(iv)
}, [boost])
const g = localGpu
const devices = g?.gpus || []
const inferenceOn = g?.ok && g.inference_active
const modelLabel = g?.active_model?.replace(/\s*GPTQ$/i, '').replace(/\s*AWQ$/i, '') || 'No model'
const avgUtil = useMemo(() => {
if (devices.length) {
const sum = devices.reduce((s, d, i) => s + (live.deviceUtils[i] ?? d.util_gpu), 0)
return sum / devices.length
}
return live.avgUtil
}, [devices, live.avgUtil, live.deviceUtils])
const avgVram = useMemo(() => {
if (devices.length) {
return devices.reduce((s, d) => s + memPct(d.memory_used_mib, d.memory_total_mib), 0) / devices.length
}
return live.avgVram
}, [devices, live.avgVram])
if (!g?.ok) {
return (
<section className="border-b border-border p-3">
<h2 className="mb-2 flex items-center gap-1.5 text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">
<Cpu className="h-3 w-3" /> GPU Matrix
</h2>
<p className="text-[9px] text-foreground-faint">GPU Lab offline</p>
</section>
)
}
return (
<section className="border-b border-border p-3">
<button
type="button"
onClick={onSelectGpu}
className="mb-2 flex w-full items-start justify-between gap-1 text-left hover:opacity-90"
>
<div className="min-w-0">
<h2 className="flex items-center gap-1.5 text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">
<Cpu className="h-3 w-3 text-docker" /> GPU Matrix
{boost && (
<span className="inline-flex items-center gap-0.5 rounded border border-docker/40 bg-docker/10 px-1 py-px text-[7px] font-bold normal-case tracking-normal text-docker">
<Activity className="h-2.5 w-2.5 animate-pulse" /> Live
</span>
)}
</h2>
<p className="mt-0.5 truncate text-[10px] font-medium text-foreground">{modelLabel}</p>
<p className="font-mono text-[8px] text-foreground-faint">{g.gpu_count ?? devices.length}× V100 · {g.host}</p>
</div>
{g.ui_url && (
<a
href={g.ui_url}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="shrink-0 text-docker hover:underline"
>
<ExternalLink className="h-3 w-3" />
</a>
)}
</button>
<div className="mb-2 grid grid-cols-3 gap-1">
<StatChip
label="Status"
value={inferenceOn ? 'Active' : 'Idle'}
accent={inferenceOn ? 'text-success' : 'text-foreground-muted'}
/>
<StatChip label="Util" value={`${avgUtil.toFixed(0)}%`} accent={avgUtil > 20 ? 'text-warning' : 'text-foreground'} />
<StatChip
label="tok/s"
value={boost && inferenceOn ? String(live.tokenThroughput) : inferenceOn ? '—' : '0'}
icon={<Zap className="h-2.5 w-2.5 text-amber-400" />}
/>
</div>
<div className="scrollbar-thin max-h-[280px] space-y-1.5 overflow-y-auto">
{devices.map((d, i) => (
<GpuRow
key={d.index}
device={d}
liveUtil={live.deviceUtils[i] ?? d.util_gpu}
active={boost}
/>
))}
</div>
<p className="mt-1.5 font-mono text-[7px] text-foreground-faint">
VRAM avg {avgVram.toFixed(0)}% · poll {boost ? '1s' : '3s'}
{lastPoll && ` · ${lastPoll.toLocaleTimeString()}`}
</p>
</section>
)
}
function StatChip({
label,
value,
accent,
icon,
}: {
label: string
value: string
accent?: string
icon?: ReactNode
}) {
return (
<div className="rounded border border-border bg-surface-overlay/80 px-1.5 py-1 text-center">
<p className="flex items-center justify-center gap-0.5 text-[7px] text-foreground-faint">{icon}{label}</p>
<p className={cn('font-mono text-[9px] font-semibold', accent || 'text-foreground')}>{value}</p>
</div>
)
}
+56
View File
@@ -0,0 +1,56 @@
import type { ReactNode } from 'react'
import { Cpu, ExternalLink, Zap } from 'lucide-react'
import type { GpuStatus } from '../../types'
import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
import { Badge } from '../ui/Badge'
type Props = {
gpu: GpuStatus | null
live: GpuLiveMetrics
}
export function GpuMonitor({ gpu, live }: Props) {
if (!gpu) {
return (
<div className="panel flex h-[148px] shrink-0 items-center px-2.5 py-1.5 text-[9px] text-foreground-faint">GPU offline</div>
)
}
const inferenceOn = gpu.inference_active && gpu.ok
const devices = gpu.gpus || []
return (
<div className="panel flex h-[148px] shrink-0 flex-col px-2.5 py-1.5">
<div className="flex min-h-0 flex-1 flex-nowrap items-center gap-x-3 gap-y-1 overflow-x-auto overflow-y-hidden">
<div className="flex items-center gap-1.5">
<Cpu className="h-3 w-3 text-docker" />
<span className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">GPU</span>
<Badge variant={inferenceOn ? 'success' : 'default'} className="!py-0">
{inferenceOn ? 'ON' : 'Standby'}
</Badge>
</div>
<Chip label="Model" value={gpu.active_model?.split('-')[0] || '—'} />
<Chip label="tok/s" value={inferenceOn ? String(live.tokenThroughput) : '—'} icon={<Zap className="h-2.5 w-2.5 text-amber-500" />} />
<Chip label="Util" value={`${Math.round(live.avgUtil)}%`} />
<Chip label="VRAM" value={`${Math.round(live.avgVram)}%`} />
{devices.slice(0, 4).map((d, i) => (
<Chip key={d.index} label={`G${d.index}`} value={`${live.deviceUtils[i] ?? d.util_gpu}%`} />
))}
{gpu.ui_url && (
<a href={gpu.ui_url} target="_blank" rel="noreferrer" className="ml-auto flex items-center gap-0.5 text-[9px] text-docker hover:underline">
{gpu.host} <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
</div>
</div>
)
}
function Chip({ label, value, icon }: { label: string; value: string; icon?: ReactNode }) {
return (
<div className="flex items-center gap-1 rounded border border-border bg-surface-overlay/80 px-1.5 py-0.5">
<span className="flex items-center gap-0.5 text-[8px] text-foreground-faint">{icon}{label}</span>
<span className="font-mono text-[9px] font-medium text-foreground">{value}</span>
</div>
)
}
@@ -0,0 +1,129 @@
import { ExternalLink, RefreshCw, Terminal } from 'lucide-react'
import type { Agent, WorkloadData } from '../../types'
import { getAgentMeta } from '../../lib/agentMeta'
import { copyShellCommand, INFRA_CATALOG, type InfraNode } from '../../lib/infraCatalog'
import { cn } from '../../lib/utils'
type Props = {
workload: WorkloadData | null
agents: Agent[]
selectedNodeId: string | null
busy: boolean
onSelectNode: (id: string) => void
onSelectAgent: (id: string) => void
onProbe: (nodeId: string) => void
onOpenTerminal: (nodeId: string) => void
}
function zoneStats(workload: WorkloadData | null, zoneId: string) {
const z = workload?.zones?.find((x) => x.id === zoneId)
if (!z) return null
return `${z.running}/${z.total}`
}
export function InfraQuickAccess({
workload,
agents,
selectedNodeId,
busy,
onSelectNode,
onSelectAgent,
onProbe,
onOpenTerminal,
}: Props) {
const handleShell = async (node: InfraNode) => {
await copyShellCommand(node.ssh)
onSelectNode(node.id)
onOpenTerminal(node.id)
onProbe(node.id)
}
return (
<section className="panel shrink-0 p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div>
<h3 className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">Infrastructure & Apps</h3>
<p className="text-[8px] text-foreground-faint">Klik voor inspector · Shell kopieert SSH en opent live terminal · UI opent de applicatie</p>
</div>
{workload && (
<span className="shrink-0 font-mono text-[8px] text-foreground-faint">
{workload.totals.apps_running}/{workload.totals.apps_total} containers
</span>
)}
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
{INFRA_CATALOG.map((node) => {
const Icon = node.icon
const agent = agents.find((a) => a.id === node.agentId)
const meta = agent ? getAgentMeta(agent.id) : null
const active = selectedNodeId === node.id || node.topoIds.includes(selectedNodeId || '')
const stats = zoneStats(workload, node.zone)
return (
<div
key={node.id}
className={cn(
'flex flex-col rounded-lg border bg-surface-overlay/60 p-2 transition-colors',
active ? 'border-docker/50 ring-1 ring-docker/20' : 'border-border hover:border-border-strong',
)}
>
<button type="button" onClick={() => onSelectNode(node.id)} className="mb-1.5 text-left">
<div className="flex items-start gap-2">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-surface" style={{ color: node.accent }}>
<Icon className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[11px] font-semibold text-foreground">{node.label}</p>
<p className="font-mono text-[8px] text-foreground-faint">{node.vm} · {node.ip}</p>
{stats && <p className="font-mono text-[8px] text-foreground-muted">{stats} running</p>}
</div>
</div>
<p className="mt-1 line-clamp-2 text-[9px] leading-snug text-foreground-muted">{node.description}</p>
</button>
{agent && meta && (
<button
type="button"
onClick={() => onSelectAgent(agent.id)}
className="mb-1.5 truncate text-left text-[8px] hover:text-docker"
style={{ color: meta.accent }}
>
Agent: {agent.name.split(' ·')[0]}
</button>
)}
<div className="mt-auto flex flex-wrap gap-1">
<button
type="button"
onClick={() => handleShell(node)}
className="inline-flex items-center gap-1 rounded border border-border bg-surface px-1.5 py-0.5 text-[8px] text-foreground-muted hover:border-docker/40 hover:text-docker"
title={node.ssh}
>
<Terminal className="h-3 w-3" /> Shell
</button>
<button
type="button"
onClick={() => { onSelectNode(node.id); onProbe(node.id) }}
disabled={busy}
className="inline-flex items-center gap-1 rounded border border-border bg-surface px-1.5 py-0.5 text-[8px] text-foreground-muted hover:border-border-strong disabled:opacity-50"
>
<RefreshCw className={cn('h-3 w-3', busy && active && 'animate-spin')} /> Probe
</button>
{node.apps.slice(0, 2).map((app) => (
<a
key={app.url}
href={app.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded border border-docker/25 bg-docker-light/40 px-1.5 py-0.5 text-[8px] text-docker hover:underline dark:bg-blue-500/10"
>
<ExternalLink className="h-3 w-3" /> {app.label}
</a>
))}
</div>
</div>
)
})}
</div>
</section>
)
}
@@ -0,0 +1,294 @@
import { useState, type FormEvent } from 'react'
import { ExternalLink, RefreshCw, Terminal, X } from 'lucide-react'
import type { Agent, FeedEntry, GpuStatus, NodeDetail, TerminalLine, TopologyNode, WorkloadData } from '../../types'
import { AGENT_NODE } from '../../lib/constants'
import { getAgentMeta } from '../../lib/agentMeta'
import { copyShellCommand, resolveInfraNode } from '../../lib/infraCatalog'
import { Button } from '../ui/Button'
import { Card, CardDescription, CardTitle } from '../ui/Card'
import { Input } from '../ui/Input'
import { cn } from '../../lib/utils'
type Tab = 'overview' | 'apps' | 'terminal'
type Props = {
node: TopologyNode | null
nodeDetail: NodeDetail | null
agent: Agent | null
agents: Agent[]
workload: WorkloadData | null
gpu: GpuStatus | null
feed: FeedEntry[]
lines: TerminalLine[]
busy: boolean
onProbe: () => void
onAsk: (message: string) => void
onSelectAgent: (id: string) => void
onSendPrompt: (message: string, agentId?: string) => void
onClear: () => void
onOpenTerminal: (nodeId: string) => void
onProbeNodeId: (nodeId: string) => void
}
export function InspectorPanel({
node,
nodeDetail,
agent,
agents,
workload,
gpu,
feed,
lines,
busy,
onProbe,
onAsk,
onSelectAgent,
onSendPrompt,
onClear,
onOpenTerminal,
onProbeNodeId,
}: Props) {
const [tab, setTab] = useState<Tab>('overview')
const [input, setInput] = useState('')
const [shellCopied, setShellCopied] = useState(false)
const d = nodeDetail || node
const infra = resolveInfraNode(d?.id || null)
const linkedAgent = d
? agents.find((a) => a.id === d.id || AGENT_NODE[a.id] === d.id || a.id === infra?.agentId || a.zone === d.id)
: agent
const submit = (e: FormEvent) => {
e.preventDefault()
if (!input.trim() || busy) return
onAsk(input.trim())
setInput('')
setTab('terminal')
if (d?.id) onOpenTerminal(d.id)
}
const runShell = async () => {
if (!infra) return
await copyShellCommand(infra.ssh)
setShellCopied(true)
setTimeout(() => setShellCopied(false), 2000)
onOpenTerminal(infra.id)
onProbeNodeId(infra.id)
setTab('terminal')
}
return (
<aside className="flex min-h-0 flex-1 flex-col bg-surface-raised">
<header className="flex items-start justify-between gap-2 border-b border-border p-3">
<div className="min-w-0">
<p className="text-[9px] font-semibold uppercase tracking-widest text-docker">Inspector</p>
<h2 className="truncate text-sm font-semibold text-foreground">
{d ? d.label : agent ? agent.name.split(' ·')[0] : 'Lab overview'}
</h2>
</div>
{(d || agent) && (
<Button variant="ghost" size="icon" onClick={onClear} aria-label="Clear">
<X className="h-4 w-4" />
</Button>
)}
</header>
{!d && !agent && (
<div className="scrollbar-thin flex-1 space-y-3 overflow-y-auto p-3">
<div className="rounded-lg border border-docker/25 bg-docker-light/50 p-2.5 dark:bg-blue-500/10">
<p className="mb-1.5 text-[10px] font-semibold text-foreground">Snel starten</p>
<ol className="list-decimal space-y-1 pl-4 text-[10px] leading-relaxed text-foreground-muted">
<li>Select an <strong className="text-foreground">infrastructure card</strong> or topology node</li>
<li>Klik <strong className="text-foreground">Shell</strong> voor SSH + live terminal output</li>
<li>Klik <strong className="text-foreground">UI</strong> om Airflow, Trino, Kafka UI, etc. te openen</li>
<li>Stel vragen via <strong className="text-foreground">Chat</strong> onderaan agents zien de hele cluster</li>
</ol>
</div>
<div>
<p className="mb-1.5 text-[9px] uppercase tracking-wider text-foreground-faint">Agents & domeinen</p>
<div className="space-y-1">
{agents.filter((a) => !a.supervisor).map((a) => {
const meta = getAgentMeta(a.id)
return (
<button
key={a.id}
type="button"
onClick={() => onSelectAgent(a.id)}
className="flex w-full items-start gap-2 rounded border border-border bg-surface-overlay/60 px-2 py-1.5 text-left hover:border-border-strong"
>
<span className="mt-0.5 text-[9px] font-semibold" style={{ color: meta.accent }}>{a.name.split(' ·')[0]}</span>
<span className="min-w-0 flex-1 text-[9px] text-foreground-muted">{a.role}</span>
</button>
)
})}
</div>
</div>
{workload && (
<div className="grid grid-cols-2 gap-1.5">
<Stat label="VMs" value={String(workload.totals.vms ?? '—')} />
<Stat label="Containers" value={`${workload.totals.apps_running}/${workload.totals.apps_total}`} />
<Stat label="Connectors" value={String(workload.totals.connectors)} />
<Stat label="Pipeline" value={workload.totals.pipeline_active ? 'active' : 'degraded'} ok={workload.totals.pipeline_active} />
</div>
)}
{gpu?.ok && (
<Card padding className="!p-2">
<CardTitle>GPU · {gpu.host}</CardTitle>
<CardDescription>{gpu.active_model || 'No model'}</CardDescription>
</Card>
)}
</div>
)}
{agent && !d && (
<div className="scrollbar-thin flex-1 overflow-y-auto p-3">
{(() => {
const meta = getAgentMeta(agent.id)
const Icon = meta.icon
return (
<>
<div className="mb-3 flex items-center gap-2 rounded-lg border border-border bg-surface-overlay p-2">
<span className="flex h-10 w-10 items-center justify-center rounded-lg bg-surface" style={{ color: meta.accent }}>
<Icon className="h-5 w-5" />
</span>
<div>
<p className="text-sm font-semibold text-foreground">{agent.name}</p>
<p className="text-[10px] text-foreground-muted">{meta.domain} · {agent.zone}</p>
</div>
</div>
<p className="mb-2 text-[10px] italic text-foreground-muted">"{agent.motto || agent.role}"</p>
<div className="mb-2 flex flex-wrap gap-1">
{(agent.suggested_prompts || []).slice(0, 4).map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => onSendPrompt(prompt, agent.id)}
className="rounded border border-border px-1.5 py-0.5 text-[9px] text-foreground-muted hover:border-docker/40 hover:text-docker"
>
{prompt}
</button>
))}
</div>
<button
type="button"
onClick={() => onOpenTerminal(agent.id)}
className="mb-2 inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[9px] hover:border-docker/40"
>
<Terminal className="h-3 w-3" /> Agent terminal
</button>
</>
)
})()}
</div>
)}
{d && (
<>
{linkedAgent && (
<div className="flex items-center gap-2 border-b border-border bg-surface-overlay/50 px-3 py-1.5">
<span className="text-[8px] uppercase tracking-wider text-foreground-faint">Agent</span>
<button type="button" onClick={() => onSelectAgent(linkedAgent.id)} className="truncate text-[10px] font-medium text-docker hover:underline">
{linkedAgent.name.split(' ·')[0]}
</button>
</div>
)}
<div className="flex flex-wrap items-center gap-1 border-b border-border px-3 py-1.5 font-mono text-[9px] text-foreground-muted">
<span className={cn('h-1.5 w-1.5 rounded-full', d.level === 'ok' ? 'bg-success' : 'bg-warning')} />
{d.vm} · {d.ip}
<span className="ml-auto">{d.running}/{d.total}</span>
</div>
<div className="flex flex-wrap gap-1 border-b border-border p-2">
{infra && (
<Button size="sm" variant="outline" onClick={runShell} disabled={busy}>
<Terminal className="h-3 w-3" />
{shellCopied ? 'SSH gekopieerd' : 'Shell'}
</Button>
)}
<Button size="sm" variant="outline" onClick={onProbe} disabled={busy}>
<RefreshCw className={cn('h-3 w-3', busy && 'animate-spin')} /> Probe
</Button>
{(infra?.apps || []).slice(0, 3).map((app) => (
<a
key={app.url}
href={app.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-md border border-docker/30 bg-docker-light/30 px-2 py-1 text-[9px] text-docker hover:underline dark:bg-blue-500/10"
>
<ExternalLink className="h-3 w-3" /> {app.label}
</a>
))}
{(d.links || []).map((l) => (
<a key={l.url} href={l.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-[9px] hover:bg-surface-overlay">
<ExternalLink className="h-3 w-3" /> {l.label}
</a>
))}
</div>
<nav className="flex gap-1 border-b border-border px-2 py-1">
{(['overview', 'apps', 'terminal'] as Tab[]).map((t) => (
<button key={t} type="button" onClick={() => setTab(t)} className={cn('rounded px-2 py-0.5 text-[10px] capitalize', tab === t ? 'bg-docker-light text-docker' : 'text-foreground-muted')}>
{t}
</button>
))}
</nav>
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto p-3 text-[10px]">
{tab === 'overview' && (
<div className="space-y-2">
{(d.description || infra?.description) && <p className="text-foreground-muted">{d.description || infra?.description}</p>}
{infra && <p className="font-mono text-[9px] text-foreground-faint">{infra.ssh}</p>}
{(d.endpoints || []).map((ep) => (
<p key={ep.name} className="font-mono text-foreground-muted">{ep.name}: {ep.host}:{ep.port}</p>
))}
{(d.commands || []).map((cmd) => (
<button
key={cmd}
type="button"
onClick={() => { setInput(cmd); setTab('terminal') }}
className="block w-full rounded border border-border px-2 py-1 text-left font-mono text-[9px] hover:bg-surface-overlay"
>
$ {cmd}
</button>
))}
</div>
)}
{tab === 'apps' && (
<div className="space-y-1">
{(d.apps || []).map((app) => (
<div key={app.name} className="rounded border border-border bg-surface px-2 py-1">
<p className="font-medium text-foreground">{app.name}</p>
<p className="font-mono text-[9px] text-foreground-faint">{app.state} · {app.image}</p>
</div>
))}
</div>
)}
{tab === 'terminal' && (
<div className="rounded border border-border bg-black/40 p-2 font-mono text-[9px]">
{lines.map((line) => (
<div key={line.id} className="text-foreground-muted">
<span className="text-foreground-faint">{line.ts ? new Date(line.ts).toLocaleTimeString('en-US', { hour12: false }) : ''}</span>{' '}
<span className="text-docker">{line.phase}</span> {line.text}
</div>
))}
{busy && <span className="text-docker animate-pulse"></span>}
</div>
)}
</div>
<form onSubmit={submit} className="flex gap-1 border-t border-border p-2">
<Input value={input} onChange={(e) => setInput(e.target.value)} placeholder={`Vraag over ${d.label}`} disabled={busy} className="text-xs" />
<Button type="submit" size="sm" disabled={busy || !input.trim()}>Send</Button>
</form>
</>
)}
</aside>
)
}
function Stat({ label, value, ok }: { label: string; value: string; ok?: boolean }) {
return (
<div className="rounded border border-border bg-surface-overlay px-2 py-1.5">
<p className="text-[8px] uppercase text-foreground-faint">{label}</p>
<p className={cn('font-mono text-[11px] font-medium', ok === false ? 'text-warning' : ok ? 'text-success' : 'text-foreground')}>{value}</p>
</div>
)
}
@@ -0,0 +1,358 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { BookOpen, FileText, Loader2, MessageSquare, RefreshCw, RotateCcw, Send, Upload } from 'lucide-react'
import { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive'
type Collection = { name: string; documents: number; files?: number; filenames?: string[] }
type StoredDoc = {
id: string
filename: string
collection: string
chunks: number
characters?: number
ingested_at: string
bytes?: number
}
type Source = { source?: string; chunk?: number; preview?: string }
type ChatMsg = { role: 'user' | 'assistant'; content: string; sources?: Source[] }
type Health = { ok: boolean; chroma: boolean; docling: boolean; llm: boolean; embed_model?: string }
type Props = { onGpuActivity?: (active: boolean) => void }
export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
const [health, setHealth] = useState<Health | null>(null)
const [collections, setCollections] = useState<Collection[]>([])
const [collection, setCollection] = useState('default')
const [newCol, setNewCol] = useState('')
const [messages, setMessages] = useState<ChatMsg[]>([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [ingesting, setIngesting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [storedDocs, setStoredDocs] = useState<StoredDoc[]>([])
const [selectedDocId, setSelectedDocId] = useState<string | null>(null)
const [summarizing, setSummarizing] = useState(false)
const [reindexing, setReindexing] = useState<string | null>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const loadMeta = useCallback(async () => {
try {
const [h, c, d] = await Promise.all([
fetch('/rag/health'),
fetch('/rag/collections'),
fetch('/rag/documents'),
])
if (h.ok) setHealth(await h.json())
if (c.ok) {
const j = await c.json()
setCollections(j.collections || [])
}
if (d.ok) {
const j = await d.json()
setStoredDocs(j.documents || [])
}
} catch {
setHealth(null)
}
}, [])
useEffect(() => {
loadMeta()
}, [loadMeta])
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, loading])
useEffect(() => {
onGpuActivity?.(loading || ingesting || summarizing || reindexing !== null)
}, [loading, ingesting, summarizing, reindexing, onGpuActivity])
const onIngest = async (file: File) => {
setIngesting(true)
setError(null)
const fd = new FormData()
fd.append('file', file)
fd.append('collection', collection)
try {
const r = await fetch('/rag/ingest', { method: 'POST', body: fd })
const j = await r.json()
if (!r.ok || !j.ok) {
setError(j.error || 'Ingest failed')
return
}
setMessages((m) => [...m, {
role: 'assistant',
content: j.duplicate
? `Already indexed: ${j.filename} (${j.chunks} chunks). You can chat immediately — no re-upload needed.`
: `Indexed ${j.filename} → collection "${j.collection}" — ${j.chunks} chunks (${j.characters?.toLocaleString()} chars). Stored permanently.`,
}])
loadMeta()
} catch {
setError('RAG API unavailable')
} finally {
setIngesting(false)
}
}
const onSummarize = async (doc: StoredDoc) => {
setSummarizing(true)
setError(null)
setSelectedDocId(doc.id)
setCollection(doc.collection)
setMessages((m) => [...m, { role: 'user', content: `Summarize: ${doc.filename}` }])
try {
const r = await fetch('/rag/summarize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ collection: doc.collection, doc_id: doc.id }),
})
const j = await r.json()
if (!r.ok || !j.ok) {
setError(j.error || 'Summarize failed')
return
}
setMessages((m) => [...m, {
role: 'assistant',
content: `Summary of ${j.filename} (${j.characters?.toLocaleString()} chars):\n\n${j.summary}`,
}])
} catch {
setError('Summarize request failed')
} finally {
setSummarizing(false)
}
}
const onReindex = async (doc: StoredDoc) => {
setReindexing(doc.id)
setError(null)
try {
const r = await fetch(`/rag/documents/${doc.id}/reindex`, { method: 'POST' })
const j = await r.json()
if (!r.ok || !j.ok) {
setError(j.error || 'Re-index failed')
return
}
setMessages((m) => [...m, {
role: 'assistant',
content: `Re-indexed ${j.filename}: ${j.chunks} clean text chunks (${j.characters?.toLocaleString()} chars). You can now chat and summarize.`,
}])
loadMeta()
} catch {
setError('Re-index request failed')
} finally {
setReindexing(null)
}
}
const onSend = async () => {
const msg = input.trim()
if (!msg || loading) return
setInput('')
setError(null)
setMessages((m) => [...m, { role: 'user', content: msg }])
setLoading(true)
try {
const r = await fetch('/rag/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: msg, collection, top_k: 5 }),
})
const j = await r.json()
if (!r.ok || !j.ok) {
setError(j.error || 'Chat failed')
setLoading(false)
return
}
setMessages((m) => [...m, { role: 'assistant', content: j.answer, sources: j.sources }])
} catch {
setError('Failed to reach RAG / LLM service')
} finally {
setLoading(false)
}
}
const createCollection = async () => {
if (!newCol.trim()) return
const fd = new FormData()
fd.append('name', newCol.trim())
await fetch('/rag/collections', { method: 'POST', body: fd })
setCollection(newCol.trim())
setNewCol('')
loadMeta()
}
const doclingUiUrl = `${window.location.protocol}//${window.location.hostname}:5001/ui/`
return (
<div className="flex h-full min-h-[calc(100vh-140px)] flex-col rounded-lg border border-border bg-surface-raised">
<header className="shrink-0 border-b border-border bg-surface-overlay/30 px-4 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-foreground">Knowledge Chat (RAG)</h2>
<p className="text-[11px] text-foreground-muted">
LangChain + ChromaDB chat with your ingested documents via Llama 70B
</p>
</div>
<div className="flex flex-wrap items-center gap-2 text-[10px]">
{health && (
<>
<StatusPill ok={health.chroma} label="ChromaDB" />
<StatusPill ok={health.docling} label="Docling" />
<StatusPill ok={health.llm} label="LLM" />
</>
)}
<button type="button" onClick={loadMeta} className="rounded border border-border p-1.5 hover:bg-surface-overlay">
<RefreshCw className="h-4 w-4" />
</button>
</div>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
<aside className="shrink-0 border-b border-border p-4 lg:w-72 lg:border-b-0 lg:border-r">
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Collection</h3>
<select
value={collection}
onChange={(e) => setCollection(e.target.value)}
className="mb-2 w-full rounded border border-border bg-surface-overlay px-2 py-1.5 text-[11px]"
>
{collections.length === 0 && <option value="default">default (empty)</option>}
{collections.map((c) => (
<option key={c.name} value={c.name}>{c.name} ({c.documents} docs)</option>
))}
</select>
<div className="mb-4 flex gap-1">
<input
value={newCol}
onChange={(e) => setNewCol(e.target.value)}
placeholder="New collection name"
className="min-w-0 flex-1 rounded border border-border bg-surface-overlay px-2 py-1 text-[10px]"
/>
<button type="button" onClick={createCollection} className={cn('shrink-0 rounded px-2 py-1 text-[10px]', subTabIdle)}>Add</button>
</div>
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Ingest documents</h3>
<label className={cn('flex cursor-pointer flex-col items-center rounded-lg border-2 border-dashed border-border px-3 py-4 text-center hover:border-docker/40', ingesting && 'opacity-50')}>
<Upload className="mb-1 h-6 w-6 text-docker opacity-60" />
<span className="text-[10px] font-medium">PDF, PPTX, DOCX, CSV, TXT, MD</span>
<span className="text-[9px] text-foreground-faint">Stored in ChromaDB + disk upload once</span>
<input type="file" className="hidden" disabled={ingesting} accept=".pdf,.pptx,.ppt,.docx,.csv,.txt,.md,.json" onChange={(e) => e.target.files?.[0] && onIngest(e.target.files[0])} />
</label>
{ingesting && (
<p className="mt-2 flex items-center gap-1 text-[10px] text-foreground-muted">
<Loader2 className="h-3 w-3 animate-spin" /> Ingesting & embedding
</p>
)}
<h3 className="mb-2 mt-4 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">
Document library ({storedDocs.length})
</h3>
<div className="scrollbar-thin max-h-40 space-y-1 overflow-y-auto">
{storedDocs.length === 0 ? (
<p className="text-[9px] text-foreground-faint">No documents yet upload above.</p>
) : (
storedDocs.map((doc) => (
<div
key={doc.id}
className={cn(
'rounded border px-2 py-1.5 text-[9px] transition-colors',
selectedDocId === doc.id ? 'border-docker/40 bg-docker/10' : 'border-border',
)}
>
<button type="button" onClick={() => { setSelectedDocId(doc.id); setCollection(doc.collection) }} className="w-full text-left">
<p className="truncate font-medium text-foreground">{doc.filename}</p>
<p className="text-foreground-faint">{doc.collection} · {doc.chunks} chunks · {new Date(doc.ingested_at).toLocaleDateString()}</p>
</button>
<div className="mt-1 flex gap-1">
<button
type="button"
disabled={summarizing}
onClick={() => onSummarize(doc)}
className={cn('flex flex-1 items-center justify-center gap-0.5 rounded px-1 py-0.5', subTabIdle)}
>
{summarizing && selectedDocId === doc.id ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <FileText className="h-2.5 w-2.5" />}
Summarize
</button>
<button
type="button"
disabled={reindexing === doc.id}
onClick={() => onReindex(doc)}
className={cn('flex flex-1 items-center justify-center gap-0.5 rounded px-1 py-0.5', subTabIdle)}
title="Re-parse with clean text (fixes corrupted PDF index)"
>
{reindexing === doc.id ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <RotateCcw className="h-2.5 w-2.5" />}
Re-index
</button>
</div>
</div>
))
)}
</div>
<div className="mt-4 space-y-1 text-[9px] text-foreground-faint">
<p><BookOpen className="mr-1 inline h-3 w-3" />Embed: {health?.embed_model || 'all-MiniLM-L6-v2'}</p>
<a href={doclingUiUrl} target="_blank" rel="noreferrer" className="text-docker hover:underline">Docling UI (port 5001) </a>
</div>
</aside>
<div className="flex min-h-0 flex-1 flex-col">
<div className="scrollbar-thin flex-1 overflow-y-auto p-4">
{messages.length === 0 && (
<div className="flex h-full flex-col items-center justify-center gap-2 text-center text-sm text-foreground-muted">
<MessageSquare className="h-10 w-10 opacity-30" />
<p>Upload once documents stay in ChromaDB. Ask anytime without re-uploading.</p>
<p className="text-[11px]">Example: &quot;What maturity gaps exist in the customer dataset?&quot;</p>
</div>
)}
{messages.map((m, i) => (
<div key={i} className={cn('mb-3 max-w-[90%] rounded-lg px-3 py-2 text-[12px]', m.role === 'user' ? 'ml-auto bg-docker/20 text-foreground' : 'bg-surface-overlay text-foreground-muted')}>
<p className="whitespace-pre-wrap leading-relaxed">{m.content}</p>
{m.sources && m.sources.length > 0 && (
<div className="mt-2 border-t border-border pt-2">
<p className="mb-1 text-[9px] font-semibold uppercase text-foreground-faint">Sources</p>
{m.sources.map((s, j) => (
<p key={j} className="text-[9px] text-foreground-faint">
{s.source} · chunk {s.chunk}: {s.preview?.slice(0, 120)}
</p>
))}
</div>
)}
</div>
))}
{loading && (
<div className="flex items-center gap-2 text-[11px] text-foreground-muted">
<Loader2 className="h-4 w-4 animate-spin text-docker" /> Retrieving context & generating answer
</div>
)}
{error && <p className="text-[11px] text-danger">{error}</p>}
<div ref={bottomRef} />
</div>
<div className="flex shrink-0 gap-2 border-t border-border p-3">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && (e.preventDefault(), onSend())}
placeholder="Ask a question about your ingested data…"
className="min-w-0 flex-1 rounded-lg border border-border bg-surface-overlay px-3 py-2 text-[12px]"
disabled={loading}
/>
<button type="button" onClick={onSend} disabled={loading || !input.trim()} className={cn('rounded-lg px-3 py-2', subTabActive, 'disabled:opacity-40')}>
<Send className="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
)
}
function StatusPill({ ok, label }: { ok: boolean; label: string }) {
return (
<span className={cn('rounded-full px-2 py-0.5 font-medium', ok ? 'bg-success/20 text-success' : 'bg-danger/20 text-danger')}>
{label} {ok ? '●' : '○'}
</span>
)
}
@@ -0,0 +1,393 @@
import { useEffect, useMemo, useState } from 'react'
import { Box } from 'lucide-react'
import type { AgentAnim, WorkloadData } from '../../types'
import { Badge } from '../ui/Badge'
import { cn } from '../../lib/utils'
/* ── Pipeline model ─────────────────────────────────────────────── */
type TopoNode = { id: string; label: string; sub: string; metricKey: string }
type TopoStage = {
id: string
num: number
title: string
subtitle: string
accent: string
nodes: TopoNode[]
}
type FlowKind = 'orchestration' | 'cdc' | 'stream' | 'etl' | 'query' | 'serve'
type FlowEdge = {
from: string
to: string
kind: FlowKind
label: string
}
const STAGES: TopoStage[] = [
{
id: 'sources', num: 1, title: 'SOURCES', subtitle: 'Operational databases', accent: 'topo-stage-col--sources',
nodes: [
{ id: 'postgresql', label: 'PostgreSQL', sub: 'OLTP · primary', metricKey: 'postgresql' },
{ id: 'mysql', label: 'MySQL', sub: 'Replica set', metricKey: 'mysql' },
{ id: 'mongodb', label: 'MongoDB', sub: 'Document store', metricKey: 'mongodb' },
{ id: 'cassandra', label: 'Cassandra', sub: 'Wide-column', metricKey: 'cassandra' },
],
},
{
id: 'ingestion', num: 2, title: 'INGESTION & STREAMING', subtitle: 'CDC · event bus · orchestration', accent: 'topo-stage-col--ingestion',
nodes: [
{ id: 'debezium', label: 'Debezium', sub: 'CDC connectors', metricKey: 'debezium' },
{ id: 'kafka', label: 'Apache Kafka', sub: 'Event bus', metricKey: 'kafka' },
{ id: 'airflow', label: 'Apache Airflow', sub: 'Daily Python DAGs · source sync', metricKey: 'airflow' },
],
},
{
id: 'compute', num: 3, title: 'COMPUTE', subtitle: 'Processing & query', accent: 'topo-stage-col--compute',
nodes: [
{ id: 'spark', label: 'Apache Spark', sub: 'Batch / micro-batch', metricKey: 'spark' },
{ id: 'trino', label: 'Trino', sub: 'Distributed SQL', metricKey: 'trino' },
],
},
{
id: 'storage', num: 4, title: 'STORAGE', subtitle: 'Lakehouse layer', accent: 'topo-stage-col--storage',
nodes: [
{ id: 'iceberg', label: 'Iceberg Tables', sub: 'Open table format', metricKey: 'iceberg' },
{ id: 's3', label: 'Dell ECS S3', sub: 'Object scale', metricKey: 's3' },
],
},
{
id: 'consumers', num: 5, title: 'CONSUMERS', subtitle: 'Analytics & AI', accent: 'topo-stage-col--consumers',
nodes: [
{ id: 'bi', label: 'BI / Reporting', sub: 'Dashboards', metricKey: 'bi' },
{ id: 'jupyter', label: 'Jupyter Notebooks', sub: 'Data science', metricKey: 'jupyter' },
{ id: 'llm', label: 'GenAI LLM', sub: 'vLLM inference', metricKey: 'llm' },
],
},
]
/** Full data-foundation flows — Airflow daily Python generation + CDC stream + lakehouse */
const FLOW_EDGES: FlowEdge[] = [
// Airflow orchestrates daily Python jobs on every source
{ from: 'airflow', to: 'postgresql', kind: 'orchestration', label: 'Daily Python gen' },
{ from: 'airflow', to: 'mysql', kind: 'orchestration', label: 'Daily Python gen' },
{ from: 'airflow', to: 'mongodb', kind: 'orchestration', label: 'Daily Python gen' },
{ from: 'airflow', to: 'cassandra', kind: 'orchestration', label: 'Daily Python gen' },
// CDC capture from sources
{ from: 'postgresql', to: 'debezium', kind: 'cdc', label: 'CDC' },
{ from: 'mysql', to: 'debezium', kind: 'cdc', label: 'CDC' },
{ from: 'mongodb', to: 'debezium', kind: 'cdc', label: 'CDC' },
{ from: 'cassandra', to: 'debezium', kind: 'cdc', label: 'CDC' },
// Streaming bus
{ from: 'debezium', to: 'kafka', kind: 'stream', label: 'Events' },
{ from: 'airflow', to: 'kafka', kind: 'orchestration', label: 'DAG trigger' },
// ETL compute
{ from: 'kafka', to: 'spark', kind: 'etl', label: 'Micro-batch' },
{ from: 'airflow', to: 'spark', kind: 'orchestration', label: 'Pipeline DAG' },
{ from: 'spark', to: 'iceberg', kind: 'etl', label: 'Lake write' },
{ from: 'spark', to: 's3', kind: 'etl', label: 'Object export' },
// Query & serve
{ from: 'iceberg', to: 'trino', kind: 'query', label: 'SQL' },
{ from: 'trino', to: 'bi', kind: 'serve', label: 'Reports' },
{ from: 'iceberg', to: 'jupyter', kind: 'serve', label: 'Notebooks' },
{ from: 's3', to: 'jupyter', kind: 'serve', label: 'Datasets' },
{ from: 'trino', to: 'llm', kind: 'serve', label: 'RAG context' },
{ from: 's3', to: 'llm', kind: 'serve', label: 'Model artifacts' },
]
const STAGE_BADGE: Record<string, string> = {
sources: 'border-emerald-400/50 bg-emerald-500/20 text-emerald-300',
ingestion: 'border-cyan-400/50 bg-cyan-500/20 text-cyan-300',
compute: 'border-violet-400/50 bg-violet-500/20 text-violet-300',
storage: 'border-blue-400/50 bg-blue-500/20 text-blue-300',
consumers: 'border-amber-400/50 bg-amber-500/20 text-amber-300',
}
const FLOW_LEGEND: { kind: FlowKind; label: string; color: string }[] = [
{ kind: 'orchestration', label: 'Airflow orchestration', color: '#f59e0b' },
{ kind: 'cdc', label: 'CDC capture', color: '#22d3ee' },
{ kind: 'stream', label: 'Event stream', color: '#38bdf8' },
{ kind: 'etl', label: 'ETL / compute', color: '#a78bfa' },
{ kind: 'query', label: 'SQL query', color: '#818cf8' },
{ kind: 'serve', label: 'Consumption', color: '#34d399' },
]
const EDGE_CLASS: Record<FlowKind, string> = {
orchestration: 'topo-edge-orchestration',
cdc: 'topo-edge-cdc',
stream: 'topo-edge-stream',
etl: 'topo-edge-etl',
query: 'topo-edge-query',
serve: 'topo-edge-serve',
}
const PARTICLE_FILL: Record<FlowKind, string> = {
orchestration: '#fbbf24',
cdc: '#22d3ee',
stream: '#38bdf8',
etl: '#c4b5fd',
query: '#818cf8',
serve: '#34d399',
}
const NODE_CLICK_MAP: Record<string, string> = {
postgresql: 'src-postgres', mysql: 'src-mysql', mongodb: 'src-mongo', cassandra: 'src-cassandra',
debezium: 'cdc-postgres', kafka: 'stream-kafka', airflow: 'src-airflow', spark: 'stream-spark',
trino: 'query-trino', iceberg: 'lake-iceberg', s3: 'lake-s3', bi: 'cons-bi',
jupyter: 'cons-notebooks', llm: 'cons-ml',
}
const NODE_POS: Record<string, { col: number; row: number; rows: number }> = {}
STAGES.forEach((stage, col) => {
stage.nodes.forEach((node, row) => {
NODE_POS[node.id] = { col, row, rows: stage.nodes.length }
})
})
function nodeCoords(col: number, row: number, rows: number) {
const colW = 100 / 5
const yPad = 8
const ySpan = 84
const y = yPad + ((row + 0.5) / rows) * ySpan
return {
inX: col * colW + colW * 0.08,
outX: col * colW + colW * 0.92,
y,
}
}
/** Curved path — arcs upward for backward (orchestration) flows */
function flowPath(x1: number, y1: number, x2: number, y2: number, backward = false) {
if (backward || x2 < x1 - 2) {
const arcY = Math.min(y1, y2) - 14
return `M ${x1} ${y1} C ${x1} ${arcY}, ${x2} ${arcY}, ${x2} ${y2}`
}
const mx = (x1 + x2) / 2
return `M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`
}
type MetricState = Record<string, string>
function seedMetrics(): MetricState {
return {
postgresql: '12.4k rows/s', mysql: '8.1k rows/s', mongodb: '2.3k docs/s', cassandra: '5.6k ops/s',
debezium: '4 connectors active', kafka: '142 MB/s', airflow: '18 DAGs · daily 02:00 UTC',
spark: '6 executors live', trino: '3 queries active', iceberg: '847 tables · 2.1 TB', s3: '14.2 TB stored',
bi: '26 dashboards', jupyter: '12 kernels active', llm: 'Checking…',
}
}
function formatLlmLabel(model?: string | null): string {
if (!model) return 'GenAI LLM'
return model.replace(/\s*GPTQ$/i, '').replace(/\s*AWQ$/i, '').trim()
}
function formatLlmMetric(workload: WorkloadData | null): string {
const gpu = workload?.gpu
if (!gpu?.model) return 'Connecting…'
if (!gpu.inference_active) return 'Offline'
const gpus = gpu.gpus || []
const util = gpu.avg_util ?? (gpus.length ? gpus.reduce((s, g) => s + g.util_gpu, 0) / gpus.length : 0)
const vram = gpus.length
? gpus.reduce((s, g) => s + (g.memory_used_mib / Math.max(g.memory_total_mib, 1)) * 100, 0) / gpus.length
: 0
if (util >= 1) return `${util.toFixed(0)}% GPU · live`
if (vram >= 50) return `Loaded · ${vram.toFixed(0)}% VRAM`
return 'Inference active'
}
function jitterMetric(key: string, current: string, workload: WorkloadData | null): string {
if (key === 'llm') return formatLlmMetric(workload)
const n = () => (Math.random() - 0.5) * 2
const fns: Record<string, () => string> = {
postgresql: () => `${(12.4 + n() * 0.8).toFixed(1)}k rows/s`,
mysql: () => `${(8.1 + n() * 0.6).toFixed(1)}k rows/s`,
mongodb: () => `${(2.3 + n() * 0.3).toFixed(1)}k docs/s`,
cassandra: () => `${(5.6 + n() * 0.5).toFixed(1)}k ops/s`,
debezium: () => `${Math.max(3, Math.round(4 + n()))} connectors active`,
kafka: () => `${Math.max(80, Math.round(142 + n() * 18))} MB/s`,
airflow: () => `${Math.max(12, Math.round(18 + n() * 2))} DAGs · daily 02:00 UTC`,
spark: () => `${Math.max(4, Math.round(6 + n()))} executors live`,
trino: () => `${Math.max(1, Math.round(3 + n()))} queries active`,
iceberg: () => `${Math.round(847 + n() * 5)} tables · ${(2.1 + n() * 0.05).toFixed(1)} TB`,
s3: () => `${(14.2 + n() * 0.08).toFixed(1)} TB stored`,
bi: () => `${Math.max(20, Math.round(26 + n() * 2))} dashboards`,
jupyter: () => `${Math.max(8, Math.round(12 + n() * 2))} kernels active`,
}
return fns[key]?.() ?? current
}
type Props = {
workload: WorkloadData | null
animations: Record<string, AgentAnim>
selectedNodeId: string | null
onNodeClick: (nodeId: string) => void
}
export function PlatformTopology({ workload, animations, selectedNodeId, onNodeClick }: Props) {
const [metrics, setMetrics] = useState<MetricState>(seedMetrics)
const llmLabel = formatLlmLabel(workload?.gpu?.model)
const pipelineActive = workload?.totals?.pipeline_active ?? true
const anyBusy = useMemo(
() => Object.values(animations).some((a) => a.state !== 'idle'),
[animations],
)
const edgesLive = pipelineActive || anyBusy
useEffect(() => {
setMetrics((prev) => ({ ...prev, llm: formatLlmMetric(workload) }))
}, [workload?.gpu?.model, workload?.gpu?.inference_active, workload?.gpu?.avg_util, workload?.gpu?.gpus])
useEffect(() => {
const iv = setInterval(() => {
setMetrics((prev) => {
const next = { ...prev }
for (const k of Object.keys(next)) next[k] = jitterMetric(k, prev[k], workload)
return next
})
}, 2200)
return () => clearInterval(iv)
}, [workload])
const resolvedSel = selectedNodeId
? Object.entries(NODE_CLICK_MAP).find(([, v]) => v === selectedNodeId)?.[0] ?? null
: null
return (
<section className="panel flex h-full min-h-0 flex-1 flex-col overflow-hidden">
<header
className="flex shrink-0 flex-col gap-1 border-b border-border px-3 py-1.5"
style={{ background: 'var(--topo-header-bg)' }}
>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-docker text-white shadow-docker">
<Box className="h-3 w-3" />
</span>
<div className="min-w-0">
<h2 className="truncate text-xs font-semibold text-foreground">Data Platform Topology</h2>
<p className="truncate text-[9px] text-foreground-muted">
Airflow daily Python CDC stream lakehouse consumers
</p>
</div>
</div>
<div className="flex shrink-0 flex-wrap justify-end gap-1">
<Badge variant={pipelineActive ? 'success' : 'warning'}>
{pipelineActive ? 'Pipeline active' : 'Degraded'}
</Badge>
<Badge>{workload?.totals?.connectors ?? 4} CDC</Badge>
<Badge variant="accent">{FLOW_EDGES.length} flows</Badge>
</div>
</div>
<div className="flex flex-wrap gap-x-2 gap-y-0.5">
{FLOW_LEGEND.map((item) => (
<span key={item.kind} className="inline-flex items-center gap-1 text-[8px] text-foreground-muted">
<span className="h-1.5 w-1.5 rounded-full" style={{ background: item.color }} />
{item.label}
</span>
))}
</div>
</header>
<div className="topo-canvas flex min-h-0 flex-1">
<svg
className="pointer-events-none absolute inset-0 z-0 h-full w-full"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden
>
<defs>
<linearGradient id="topo-flow-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#22d3ee" stopOpacity="0.7" />
<stop offset="50%" stopColor="#34d399" stopOpacity="1" />
<stop offset="100%" stopColor="#60a5fa" stopOpacity="0.7" />
</linearGradient>
</defs>
{FLOW_EDGES.map((edge, i) => {
const pa = NODE_POS[edge.from]
const pb = NODE_POS[edge.to]
if (!pa || !pb) return null
const a = nodeCoords(pa.col, pa.row, pa.rows)
const b = nodeCoords(pb.col, pb.row, pb.rows)
const backward = edge.kind === 'orchestration' && pb.col < pa.col
const fromX = backward ? a.inX + (a.outX - a.inX) * 0.15 : a.outX
const toX = backward ? b.outX - (b.outX - b.inX) * 0.15 : b.inX
const d = flowPath(fromX, a.y, toX, b.y, backward)
const live = edgesLive
const dur = 1.8 + (i % 5) * 0.35
return (
<g key={`${edge.from}-${edge.to}-${edge.kind}`}>
<path d={d} className="topo-edge-glow" vectorEffect="non-scaling-stroke" />
<path
d={d}
className={cn(EDGE_CLASS[edge.kind], live ? 'topo-edge-live' : 'topo-edge-idle')}
vectorEffect="non-scaling-stroke"
/>
{live && (
<>
<circle r="0.55" fill={PARTICLE_FILL[edge.kind]} opacity="0.95">
<animateMotion dur={`${dur}s`} repeatCount="indefinite" path={d} />
</circle>
<circle r="0.35" fill="#ffffff" opacity="0.85">
<animateMotion dur={`${dur}s`} repeatCount="indefinite" path={d} begin={`${dur * 0.45}s`} />
</circle>
</>
)}
</g>
)
})}
</svg>
<div className="relative z-10 flex h-full min-h-0 w-full overflow-x-auto">
{STAGES.map((stage) => (
<div key={stage.id} className={cn('topo-stage-col', stage.accent)}>
<header className="mb-1 shrink-0 border-b border-white/10 pb-1">
<div className="flex items-start gap-1">
<span className={cn('rounded border px-1 py-px font-mono text-[8px] font-bold', STAGE_BADGE[stage.id])}>
0{stage.num}
</span>
<div className="min-w-0">
<h3 className="text-[8px] font-bold leading-tight tracking-wide text-white">{stage.title}</h3>
<p className="text-[7px] text-blue-200/70">{stage.subtitle}</p>
</div>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col justify-evenly gap-1">
{stage.nodes.map((node) => {
const label = node.id === 'llm' ? llmLabel : node.label
const sub = node.id === 'llm'
? (workload?.gpu?.inference_active ? 'vLLM · live' : 'vLLM inference')
: node.sub
return (
<button
key={node.id}
type="button"
onClick={() => onNodeClick(NODE_CLICK_MAP[node.id] || node.id)}
className={cn(
'topo-node',
node.id === 'airflow' && 'topo-node-airflow',
node.id === 'llm' && workload?.gpu?.inference_active && 'topo-node-airflow',
resolvedSel === node.id && 'topo-node-selected',
)}
>
<span className="block truncate text-[10px] font-semibold leading-tight text-white">{label}</span>
<span className="block truncate text-[8px] text-blue-100/80">{sub}</span>
<span className="mt-0.5 inline-block max-w-full truncate rounded border border-emerald-400/35 bg-emerald-500/20 px-1 py-px font-mono text-[7px] font-medium text-emerald-300">
{metrics[node.metricKey]}
</span>
</button>
)
})}
</div>
</div>
))}
</div>
</div>
</section>
)
}
@@ -0,0 +1,218 @@
import { useCallback, useEffect, useState } from 'react'
import { ExternalLink, FileUp, Monitor, Upload } from 'lucide-react'
import { ArchitectureDiagram } from './ArchitectureDiagram'
import type { PresentationData, PresentationSlide } from '../../types'
import { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive'
type DeckSource = 'live' | 'data-maturity' | 'atc-platform' | string
const KIND_STYLES: Record<string, string> = {
hero: 'from-blue-600/25 via-violet-600/20 to-emerald-600/15',
narrative: 'from-slate-600/15 to-blue-600/15',
topology: 'from-cyan-600/20 to-blue-800/15',
zone: 'from-amber-600/15 to-orange-600/10',
gpu: 'from-emerald-600/20 to-green-800/15',
agents: 'from-fuchsia-600/15 to-pink-600/10',
cta: 'from-blue-600/15 to-violet-600/20',
upload: 'from-indigo-600/15 to-purple-600/10',
command: 'from-sky-600/15 to-blue-600/10',
architecture: 'from-teal-600/15 to-cyan-600/10',
}
async function fetchDeck(id: DeckSource): Promise<PresentationData | null> {
const ctrl = new AbortController()
const timeout = id === 'live' ? 45000 : 10000
const timer = setTimeout(() => ctrl.abort(), timeout)
try {
const url = id === 'live' ? '/api/presentation' : `/api/presentation/decks/${id}`
const r = await fetch(url, { signal: ctrl.signal })
if (!r.ok) return null
return (await r.json()) as PresentationData
} catch {
return null
} finally {
clearTimeout(timer)
}
}
export function PresentationView() {
const [source, setSource] = useState<DeckSource>('live')
const [data, setData] = useState<PresentationData | null>(null)
const [slideIdx, setSlideIdx] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [uploadMsg, setUploadMsg] = useState<string | null>(null)
const [customDecks, setCustomDecks] = useState<{ id: string; title: string }[]>([])
const load = useCallback(async (deckId: DeckSource) => {
setLoading(true)
setError(null)
const d = await fetchDeck(deckId)
if (d && d.slides?.length) {
setData(d)
setSlideIdx(0)
setLoading(false)
return
}
if (deckId === 'live') {
const fallback = await fetchDeck('data-maturity')
if (fallback?.slides?.length) {
setData(fallback)
setSlideIdx(0)
setError('Live deck timeout — showing Data Maturity template. Click Refresh for live cluster data.')
setLoading(false)
return
}
}
setData(null)
setError('Could not load presentation.')
setLoading(false)
}, [])
useEffect(() => {
load(source)
fetch('/api/presentation/decks')
.then((r) => r.json())
.then((j) => {
const uploaded = (j.uploaded || []).map((d: { id: string; title: string }) => ({ id: d.id, title: d.title }))
setCustomDecks(uploaded)
})
.catch(() => {})
}, [source, load])
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const n = data?.slides.length || 1
if (e.key === 'ArrowRight' || e.key === ' ') { e.preventDefault(); setSlideIdx((i) => Math.min(n - 1, i + 1)) }
if (e.key === 'ArrowLeft') setSlideIdx((i) => Math.max(0, i - 1))
if (e.key === 'f' || e.key === 'F') document.documentElement.requestFullscreen?.()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [data?.slides.length])
const slides = data?.slides || []
const slide: PresentationSlide | undefined = slides[slideIdx]
const exportHtml = () => {
const id = source === 'live' ? 'live' : source
window.open(`/api/presentation/decks/${id}/html`, '_blank')
}
const onUpload = async (file: File) => {
setUploading(true)
setUploadMsg(null)
const fd = new FormData()
fd.append('file', file)
try {
const r = await fetch('/api/presentation/upload', { method: 'POST', body: fd })
const j = await r.json()
if (j.ok && j.deck) {
setCustomDecks((prev) => [{ id: j.deck.id, title: j.deck.title }, ...prev])
setSource(j.deck.id)
setUploadMsg(`${j.deck.slide_count} slides loaded from ${file.name}`)
} else {
setUploadMsg(j.error || 'Upload failed')
}
} catch {
setUploadMsg('Upload failed — check connection')
} finally {
setUploading(false)
}
}
const tabs: { id: DeckSource; label: string }[] = [
{ id: 'live', label: 'Live Cluster' },
{ id: 'stack-architecture', label: 'Stack Architecture' },
{ id: 'data-maturity', label: 'Data Maturity' },
{ id: 'atc-platform', label: 'ATC Platform' },
...customDecks.map((d) => ({ id: d.id, label: d.title.slice(0, 18) })),
]
return (
<div className="flex h-full min-h-[calc(100vh-140px)] flex-col overflow-hidden rounded-lg border border-border bg-surface-raised">
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border bg-surface-raised/90 px-3 py-2">
<div>
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-foreground">Presentation</h2>
<p className="text-[9px] text-foreground-muted">
Live cluster · HTML templates · PPT upload (converts via python-pptx + Docling)
</p>
</div>
<div className="flex flex-wrap gap-1">
<a href="/dq/" target="_blank" rel="noreferrer" className={cn('inline-flex items-center gap-1 rounded-md px-2 py-1 text-[9px]', subTabIdle)}>
<Monitor className="h-3 w-3" /> DQ Portal
</a>
<a href="/docling/ui/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">
<ExternalLink className="h-3 w-3" /> Docling
</a>
<button type="button" onClick={() => load(source)} className="rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">Refresh</button>
<button type="button" onClick={exportHtml} className={cn('rounded-md px-2 py-1 text-[9px]', subTabActive)}>Export HTML</button>
</div>
</div>
<div className="flex shrink-0 flex-wrap gap-1 border-b border-border bg-surface-overlay/40 px-2 py-1.5">
{tabs.map((t) => (
<button
key={t.id}
type="button"
onClick={() => setSource(t.id)}
className={cn(
'rounded-md px-2.5 py-1.5 text-[10px] font-medium transition-all',
source === t.id ? subTabActive : subTabIdle,
)}
>
{t.label}
</button>
))}
<label className={cn('ml-auto inline-flex cursor-pointer items-center gap-1 rounded-md border border-dashed border-border px-2 py-1 text-[10px]', uploading && 'opacity-50')}>
<Upload className="h-3 w-3" />
{uploading ? 'Uploading…' : 'PPT upload'}
<input type="file" accept=".ppt,.pptx,.pdf,.docx" className="hidden" disabled={uploading} onChange={(e) => e.target.files?.[0] && onUpload(e.target.files[0])} />
</label>
</div>
{uploadMsg && <p className="shrink-0 px-3 py-1 text-[10px] text-docker">{uploadMsg}</p>}
{error && <p className="shrink-0 px-3 py-1 text-[10px] text-warning">{error}</p>}
{loading ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-sm text-foreground-muted">
<FileUp className="h-8 w-8 animate-pulse opacity-40" />
<p>Loading presentation{source === 'live' ? ' (live cluster snapshot, ~15 sec)' : '…'}</p>
</div>
) : !slide ? (
<div className="flex flex-1 items-center justify-center text-sm text-foreground-muted">
<button type="button" onClick={() => load(source)} className="rounded border border-border px-3 py-1 text-xs">Retry</button>
</div>
) : (
<>
<div className={cn('relative flex min-h-0 flex-1 flex-col justify-center bg-gradient-to-br p-6 md:p-10', KIND_STYLES[slide.kind || 'narrative'] || KIND_STYLES.narrative)}>
<div className="max-w-4xl">
<p className="mb-1 text-[10px] font-medium uppercase tracking-widest text-docker/80">{slide.kind || 'slide'} · {slideIdx + 1}/{slides.length}</p>
<h1 className="mb-2 text-2xl font-bold tracking-tight text-foreground md:text-4xl">{slide.title}</h1>
{slide.subtitle && <p className="mb-4 text-sm text-foreground-muted md:text-base">{slide.subtitle}</p>}
{'animation' in slide && slide.animation && (
<ArchitectureDiagram animation={String(slide.animation)} />
)}
<ul className="space-y-2 text-sm leading-relaxed text-foreground md:text-base">
{(slide.bullets || []).map((b: string) => (
<li key={b} className="flex gap-2"><span className="shrink-0 text-docker"></span><span>{b}</span></li>
))}
</ul>
</div>
</div>
<div className="flex shrink-0 items-center gap-2 border-t border-border bg-surface-raised/90 px-3 py-2">
<button type="button" disabled={slideIdx === 0} onClick={() => setSlideIdx((i) => Math.max(0, i - 1))} className="rounded border border-border px-2 py-1 text-[10px] disabled:opacity-40"> Prev</button>
<div className="flex flex-1 flex-wrap justify-center gap-1">
{slides.map((_: PresentationSlide, i: number) => (
<button key={i} type="button" onClick={() => setSlideIdx(i)} className={cn('h-2 w-2 rounded-full', i === slideIdx ? 'scale-125 bg-docker' : 'bg-border')} />
))}
</div>
<button type="button" disabled={slideIdx >= slides.length - 1} onClick={() => setSlideIdx((i) => Math.min(slides.length - 1, i + 1))} className="rounded border border-border px-2 py-1 text-[10px] disabled:opacity-40">Next </button>
</div>
</>
)}
</div>
)
}
+197
View File
@@ -0,0 +1,197 @@
import { useCallback, useEffect, useState } from 'react'
import { ChevronRight, Database, Download, ExternalLink, Folder, HardDrive, Loader2, RefreshCw } from 'lucide-react'
import { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive'
type Bucket = { name: string; created?: string; has_objects?: boolean }
type S3Item = { type: string; name?: string; prefix?: string; key?: string; size_human?: string; modified?: string }
export function StorageView() {
const [health, setHealth] = useState<{ ok: boolean; endpoint?: string; bucket_names?: string[]; error?: string } | null>(null)
const [buckets, setBuckets] = useState<Bucket[]>([])
const [bucket, setBucket] = useState<string | null>(null)
const [prefix, setPrefix] = useState('')
const [folders, setFolders] = useState<S3Item[]>([])
const [objects, setObjects] = useState<S3Item[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const loadBuckets = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [h, b] = await Promise.all([
fetch('/api/storage/s3/health'),
fetch('/api/storage/s3/buckets'),
])
if (h.ok) setHealth(await h.json())
if (b.ok) {
const j = await b.json()
setBuckets(j.buckets || [])
if (!bucket && j.buckets?.length) setBucket(j.buckets[0].name)
} else {
setError('Failed to load buckets')
}
} catch {
setError('S3 API unavailable')
} finally {
setLoading(false)
}
}, [bucket])
const loadObjects = useCallback(async (b: string, p: string) => {
setLoading(true)
setError(null)
try {
const r = await fetch(`/api/storage/s3/buckets/${encodeURIComponent(b)}/objects?prefix=${encodeURIComponent(p)}`)
const j = await r.json()
if (!r.ok || !j.ok) {
setError(j.error || 'List failed')
return
}
setFolders(j.folders || [])
setObjects(j.objects || [])
} catch {
setError('Failed to list objects')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadBuckets()
}, [loadBuckets])
useEffect(() => {
if (bucket) loadObjects(bucket, prefix)
}, [bucket, prefix, loadObjects])
const crumbs = prefix ? prefix.split('/').filter(Boolean) : []
return (
<div className="panel flex h-full min-h-0 flex-1 flex-col overflow-hidden">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3">
<div>
<h2 className="flex items-center gap-2 text-sm font-semibold text-foreground">
<HardDrive className="h-4 w-4 text-docker" />
ObjectScale S3 Storage
</h2>
<p className="text-[10px] text-foreground-muted">
Dell ECS · {health?.endpoint || '10.0.20.111:9020'} · live bucket browser
</p>
</div>
<div className="flex flex-wrap gap-2">
<a href="/jupyter/" target="_blank" rel="noreferrer" className={cn('inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-[11px] font-medium', subTabActive)}>
<ExternalLink className="h-3 w-3" /> Open Jupyter
</a>
<button type="button" onClick={() => { loadBuckets(); if (bucket) loadObjects(bucket, prefix) }} className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
<RefreshCw className={cn('inline h-3 w-3', loading && 'animate-spin')} /> Refresh
</button>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
<aside className="shrink-0 border-b border-border p-3 lg:w-52 lg:border-b-0 lg:border-r">
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Buckets</h3>
<div className="space-y-1">
{buckets.map((b) => (
<button
key={b.name}
type="button"
onClick={() => { setBucket(b.name); setPrefix('') }}
className={cn(
'flex w-full items-center gap-2 rounded border px-2 py-1.5 text-left text-[10px]',
bucket === b.name ? 'border-docker/40 bg-docker/10' : 'border-border hover:bg-surface-overlay',
)}
>
<Database className="h-3 w-3 shrink-0 text-docker" />
<span className="truncate font-medium">{b.name}</span>
</button>
))}
{buckets.length === 0 && !loading && (
<p className="text-[9px] text-foreground-faint">No buckets or access denied.</p>
)}
</div>
</aside>
<div className="flex min-h-0 flex-1 flex-col p-3">
{bucket && (
<nav className="mb-2 flex flex-wrap items-center gap-1 text-[10px] text-foreground-muted">
<button type="button" className="hover:text-docker" onClick={() => setPrefix('')}>{bucket}</button>
{crumbs.map((c, i) => (
<span key={i} className="inline-flex items-center gap-1">
<ChevronRight className="h-3 w-3" />
<button
type="button"
className="hover:text-docker"
onClick={() => setPrefix(crumbs.slice(0, i + 1).join('/') + '/')}
>
{c}
</button>
</span>
))}
</nav>
)}
{loading && (
<p className="flex items-center gap-2 text-[11px] text-foreground-muted">
<Loader2 className="h-4 w-4 animate-spin" /> Loading
</p>
)}
{error && <p className="mb-2 text-[11px] text-danger">{error}</p>}
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto">
<table className="w-full text-left text-[11px]">
<thead>
<tr className="border-b border-border text-[9px] uppercase text-foreground-faint">
<th className="py-1.5 pr-2">Name</th>
<th className="py-1.5 pr-2">Size</th>
<th className="py-1.5 pr-2">Modified</th>
<th className="py-1.5" />
</tr>
</thead>
<tbody>
{folders.map((f) => (
<tr key={f.prefix} className="border-b border-border/50 hover:bg-surface-overlay/50">
<td className="py-1.5 pr-2">
<button
type="button"
className="inline-flex items-center gap-1 font-medium text-docker hover:underline"
onClick={() => setPrefix(f.prefix || '')}
>
<Folder className="h-3.5 w-3.5" /> {f.name}/
</button>
</td>
<td className="py-1.5 pr-2 text-foreground-faint"></td>
<td className="py-1.5 pr-2 text-foreground-faint"></td>
<td />
</tr>
))}
{objects.map((o) => (
<tr key={o.key} className="border-b border-border/50 hover:bg-surface-overlay/50">
<td className="max-w-[240px] truncate py-1.5 pr-2 font-mono text-[10px]">{o.name || o.key}</td>
<td className="py-1.5 pr-2 text-foreground-muted">{o.size_human}</td>
<td className="py-1.5 pr-2 text-foreground-faint">{o.modified?.slice(0, 19) || '—'}</td>
<td className="py-1.5">
{o.key && bucket && (
<a
href={`/api/storage/s3/buckets/${encodeURIComponent(bucket)}/download?key=${encodeURIComponent(o.key)}`}
className="inline-flex items-center gap-0.5 text-docker hover:underline"
>
<Download className="h-3 w-3" />
</a>
)}
</td>
</tr>
))}
</tbody>
</table>
{!loading && folders.length === 0 && objects.length === 0 && bucket && (
<p className="py-8 text-center text-sm text-foreground-muted">This prefix is empty.</p>
)}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,72 @@
import { useEffect, useRef } from 'react'
import { Terminal } from 'lucide-react'
import type { TerminalLine } from '../../types'
import { resolveInfraNode } from '../../lib/infraCatalog'
import { cn } from '../../lib/utils'
type Props = {
subjectId: string | null
subjectLabel: string
lines: TerminalLine[]
busy: boolean
expanded: boolean
onToggle: () => void
}
const LEVEL: Record<string, string> = {
info: 'text-foreground-muted',
ok: 'text-success',
warn: 'text-warning',
err: 'text-danger',
cmd: 'text-docker',
llm: 'text-violet-400',
}
export function TerminalDock({ subjectId, subjectLabel, lines, busy, expanded, onToggle }: Props) {
const bottomRef = useRef<HTMLDivElement>(null)
const infra = resolveInfraNode(subjectId)
useEffect(() => {
if (expanded) bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [lines, busy, expanded])
return (
<div className={cn('flex shrink-0 flex-col border-t border-border bg-black/80', expanded ? 'h-[200px]' : 'h-9')}>
<button
type="button"
onClick={onToggle}
className="flex shrink-0 items-center justify-between px-3 py-2 text-left hover:bg-white/5"
>
<span className="flex items-center gap-2 text-[10px] font-medium text-emerald-300">
<Terminal className="h-3.5 w-3.5" />
Terminal {subjectLabel}
{busy && <span className="animate-pulse text-docker"> live</span>}
</span>
<span className="font-mono text-[8px] text-foreground-faint">{lines.length} lines · {expanded ? '▼' : '▲'}</span>
</button>
{expanded && (
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto px-3 pb-2 font-mono text-[9px] leading-relaxed">
{infra && (
<p className="mb-1 text-foreground-faint">
<span className="text-docker">$</span> {infra.ssh} <span className="text-foreground-faint/70">(gekopieerd bij Shell-knop)</span>
</p>
)}
{lines.length === 0 && (
<p className="py-4 text-center text-foreground-faint">Selecteer een node of agent · klik Shell of Probe om output te zien</p>
)}
{lines.map((line) => (
<div key={line.id} className={LEVEL[line.level] || 'text-foreground-muted'}>
<span className="text-foreground-faint/60">
{line.ts ? new Date(line.ts).toLocaleTimeString('en-US', { hour12: false }) : ''}
</span>{' '}
<span className="text-docker/80">[{line.phase}]</span> {line.text}
</div>
))}
{busy && <span className="text-docker animate-pulse"></span>}
<div ref={bottomRef} />
</div>
)}
</div>
)
}
+162
View File
@@ -0,0 +1,162 @@
import { DatabaseZap, HardDrive, LayoutDashboard, MessageSquare, Presentation, ShieldCheck } from 'lucide-react'
import type { Agent, AgentAnim, GpuStatus } from '../../types'
import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
import { getAgentMeta } from '../../lib/agentMeta'
import { cn } from '../../lib/utils'
import { viewTabActive, viewTabIdle } from '../../lib/tabActive'
import { GpuMatrixPanel } from '../features/GpuMatrixPanel'
type MainView = 'platform' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'approvals'
type Props = {
agents: Agent[]
animations: Record<string, AgentAnim>
gpu: GpuStatus | null
gpuLive: GpuLiveMetrics
gpuBoost?: boolean
selectedAgentId: string | null
selectedNodeId: string | null
mainView: MainView
approvalCount: number
agentsLoading: boolean
onSetMainView: (view: MainView) => void
onOpenApprovals: () => void
onSelectAgent: (id: string) => void
onSelectZone: (id: string) => void
}
const VIEWS: { id: MainView; label: string; icon: typeof LayoutDashboard }[] = [
{ id: 'platform', label: 'Data Platform', icon: LayoutDashboard },
{ id: 'presentation', label: 'Presentation', icon: Presentation },
{ id: 'dataquality', label: 'Data Quality', icon: DatabaseZap },
{ id: 'knowledge', label: 'Knowledge Chat', icon: MessageSquare },
{ id: 'storage', label: 'Object Storage', icon: HardDrive },
]
export function SideNav({
agents,
animations,
gpu,
gpuLive,
gpuBoost = false,
selectedAgentId,
selectedNodeId,
mainView,
approvalCount,
agentsLoading,
onSetMainView,
onOpenApprovals,
onSelectAgent,
onSelectZone,
}: Props) {
const supervisors = agents.filter((a) => a.supervisor)
const operators = agents.filter((a) => !a.supervisor)
const matrixBoost = gpuBoost || mainView === 'knowledge'
return (
<nav className="flex w-[240px] shrink-0 flex-col border-r border-border bg-surface-raised">
<section className="border-b border-border p-3">
<h2 className="mb-2 text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">Views</h2>
<div className="space-y-1">
{VIEWS.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => onSetMainView(id)}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left transition-all',
mainView === id ? viewTabActive : viewTabIdle,
)}
>
<Icon className={cn('h-4 w-4', mainView === id ? 'text-docker' : 'text-foreground-muted')} />
<span className={cn('text-[11px] font-medium', mainView === id ? 'text-docker' : 'text-foreground')}>{label}</span>
</button>
))}
</div>
</section>
<GpuMatrixPanel
gpu={gpu}
live={gpuLive}
boost={matrixBoost}
onSelectGpu={() => onSelectZone('gpu')}
/>
<section className="flex min-h-0 flex-1 flex-col p-3">
<div className="mb-2 flex items-center justify-between gap-1">
<h2 className="text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">Agents</h2>
<button
type="button"
onClick={onOpenApprovals}
className={cn(
'flex items-center gap-1 rounded-md border px-2 py-0.5 text-[9px] font-medium transition-colors',
approvalCount > 0
? 'border-warning/40 bg-warning/10 text-warning'
: 'border-border text-foreground-muted hover:border-border-strong',
)}
>
<ShieldCheck className="h-3 w-3" />
Approvals
{approvalCount > 0 && <span className="font-mono">{approvalCount}</span>}
</button>
</div>
<div className="scrollbar-thin flex-1 space-y-1 overflow-y-auto">
{agentsLoading && agents.length === 0 && (
<p className="text-[9px] text-foreground-faint">Loading agents</p>
)}
{supervisors.length > 0 && (
<p className="text-[8px] uppercase tracking-widest text-foreground-faint">Supervisors</p>
)}
{supervisors.map((a) => (
<AgentRow key={a.id} agent={a} animations={animations} selected={selectedAgentId === a.id} onSelect={onSelectAgent} />
))}
{operators.length > 0 && (
<p className="mt-1 text-[8px] uppercase tracking-widest text-foreground-faint">Field operators</p>
)}
{operators.map((a) => (
<AgentRow key={a.id} agent={a} animations={animations} selected={selectedAgentId === a.id} onSelect={onSelectAgent} />
))}
</div>
</section>
</nav>
)
}
function AgentRow({
agent,
animations,
selected,
onSelect,
}: {
agent: Agent
animations: Record<string, AgentAnim>
selected: boolean
onSelect: (id: string) => void
}) {
const meta = getAgentMeta(agent.id)
const Icon = meta.icon
const busy = (animations[agent.id]?.state || 'idle') !== 'idle'
return (
<button
type="button"
onClick={() => onSelect(agent.id)}
className={cn(
'flex w-full items-center gap-2 rounded-md border px-2 py-2 text-left transition-colors',
selected ? 'border-docker/40 bg-docker-light' : 'border-transparent hover:border-border hover:bg-surface-overlay',
)}
style={busy ? { boxShadow: `inset 3px 0 0 0 ${meta.accent}` } : undefined}
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-surface-overlay" style={{ color: meta.accent }}>
<Icon className="h-3.5 w-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[11px] font-medium text-foreground">{agent.name.split(' ·')[0]}</span>
<span className="block truncate font-mono text-[9px] text-foreground-faint">{meta.domain}</span>
<span className="block truncate text-[8px] text-foreground-faint">{agent.role}</span>
</span>
{(agent.stats?.tasks ?? 0) > 0 && (
<span className="rounded-full bg-docker px-1.5 font-mono text-[8px] text-foreground">{agent.stats?.tasks}</span>
)}
</button>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { Moon, Sun } from 'lucide-react'
import { useTheme } from '../../context/ThemeContext'
import { cn } from '../../lib/utils'
export function ThemeToggle() {
const { theme, toggle } = useTheme()
const isDark = theme === 'dark'
return (
<button
type="button"
onClick={toggle}
className={cn(
'flex h-8 items-center gap-1.5 rounded-lg border px-2.5 text-[10px] font-medium transition-colors',
isDark
? 'border-blue-400/30 bg-blue-500/15 text-blue-200 hover:bg-blue-500/25'
: 'border-border bg-surface-overlay text-foreground-muted hover:bg-docker-light hover:text-docker',
)}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{isDark ? <Moon className="h-3.5 w-3.5" /> : <Sun className="h-3.5 w-3.5" />}
<span className="hidden sm:inline">{isDark ? 'Dark' : 'Light'}</span>
</button>
)
}
+65
View File
@@ -0,0 +1,65 @@
import { Activity, Bot, Box, Clock, ShieldAlert } from 'lucide-react'
import type { Agent, Approval, StatusData, WorkloadData } from '../../types'
import { Badge } from '../ui/Badge'
import { ThemeToggle } from './ThemeToggle'
import { cn } from '../../lib/utils'
type Props = {
clock: string
status: StatusData | null
workload: WorkloadData | null
agents: Agent[]
approvals: Approval[]
onApprovalsClick: () => void
}
export function TopBar({ clock, workload, agents, approvals, onApprovalsClick }: Props) {
const pipelineOk = workload?.totals?.pipeline_active ?? false
const running = workload?.totals?.apps_running ?? 0
const total = workload?.totals?.apps_total ?? 0
const activeAgents = agents.filter((a) => (a.stats?.tasks ?? 0) > 0).length
return (
<header className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border bg-surface-raised/90 px-3 shadow-panel backdrop-blur-sm">
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-docker to-blue-600 shadow-docker">
<Box className="h-4 w-4 text-white" />
</div>
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold text-foreground">Data & AI Command Center</h1>
<p className="text-[9px] text-foreground-muted">ATC Lab · Enterprise Operations</p>
</div>
</div>
<div className="hidden items-center gap-1.5 md:flex">
<Badge variant={pipelineOk ? 'success' : 'warning'}>
<Activity className="h-3 w-3" />
Pipeline {pipelineOk ? 'active' : 'degraded'}
</Badge>
<Badge>{running}/{total} containers</Badge>
<Badge variant="accent">
<Bot className="h-3 w-3" />
{activeAgents} agents
</Badge>
<button type="button" onClick={onApprovalsClick} className="focus:outline-none">
<Badge
variant={approvals.length ? 'warning' : 'default'}
className={cn(approvals.length && 'cursor-pointer hover:opacity-90')}
>
<ShieldAlert className="h-3 w-3" />
{approvals.length}
</Badge>
</button>
</div>
<div className="flex items-center gap-2">
<ThemeToggle />
<div className="flex items-center gap-1.5 font-mono text-[10px] text-foreground-muted">
<Clock className="h-3 w-3" />
{clock}
<span className="hidden text-success sm:inline"></span>
</div>
</div>
</header>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { cva, type VariantProps } from 'class-variance-authority'
import type { HTMLAttributes } from 'react'
import { cn } from '../../lib/utils'
const badgeVariants = cva(
'inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[10px] font-medium',
{
variants: {
variant: {
default: 'border-border bg-surface-overlay text-foreground-muted dark:bg-surface-overlay dark:text-foreground-muted',
accent: 'border-docker/30 bg-docker-light text-docker dark:border-blue-400/30 dark:bg-blue-500/15 dark:text-blue-200',
success: 'border-success/30 bg-green-50 text-green-700 dark:border-green-500/30 dark:bg-green-500/15 dark:text-green-300',
warning: 'border-warning/30 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-300',
danger: 'border-danger/30 bg-red-50 text-red-700 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-300',
},
},
defaultVariants: { variant: 'default' },
},
)
type Props = HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>
export function Badge({ className, variant, ...props }: Props) {
return <span className={cn(badgeVariants({ variant }), className)} {...props} />
}
+30
View File
@@ -0,0 +1,30 @@
import { cva, type VariantProps } from 'class-variance-authority'
import type { ButtonHTMLAttributes } from 'react'
import { cn } from '../../lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-1.5 rounded-md border font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-docker/40 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'border-docker/30 bg-docker text-foreground hover:bg-docker-dark',
ghost: 'border-transparent text-foreground-muted hover:bg-surface-overlay hover:text-foreground',
outline: 'border-border bg-surface-raised text-foreground hover:bg-surface-overlay',
success: 'border-success/30 bg-green-600 text-foreground hover:bg-green-700',
danger: 'border-danger/30 bg-red-600 text-foreground hover:bg-red-700',
},
size: {
sm: 'h-7 px-2.5 text-xs',
md: 'h-8 px-3 text-sm',
icon: 'h-8 w-8',
},
},
defaultVariants: { variant: 'default', size: 'md' },
},
)
type Props = ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>
export function Button({ className, variant, size, ...props }: Props) {
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />
}
+26
View File
@@ -0,0 +1,26 @@
import type { HTMLAttributes } from 'react'
import { cn } from '../../lib/utils'
type Props = HTMLAttributes<HTMLDivElement> & {
padding?: boolean
}
export function Card({ className, padding = true, children, ...props }: Props) {
return (
<div className={cn('panel', padding && 'p-3', className)} {...props}>
{children}
</div>
)
}
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('mb-2 flex items-center justify-between gap-2', className)} {...props} />
}
export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn('text-xs font-semibold tracking-tight text-foreground', className)} {...props} />
}
export function CardDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn('text-[10px] text-foreground-muted', className)} {...props} />
}
+16
View File
@@ -0,0 +1,16 @@
import type { InputHTMLAttributes } from 'react'
import { cn } from '../../lib/utils'
type Props = InputHTMLAttributes<HTMLInputElement>
export function Input({ className, ...props }: Props) {
return (
<input
className={cn(
'h-8 w-full rounded-md border border-border bg-surface px-2.5 text-sm text-foreground placeholder:text-foreground-faint focus:border-docker/50 focus:outline-none focus:ring-1 focus:ring-docker/30',
className,
)}
{...props}
/>
)
}