295 lines
13 KiB
TypeScript
295 lines
13 KiB
TypeScript
|
|
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>
|
||
|
|
)
|
||
|
|
}
|