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