Files
atc-agents/ui/src/components/features/InspectorPanel.tsx
T

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">Quick start</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>Click <strong className="text-foreground">Shell</strong> for SSH + live terminal output</li>
<li>Click <strong className="text-foreground">UI</strong> to open Airflow, Trino, Kafka UI, etc.</li>
<li>Ask questions via <strong className="text-foreground">Chat</strong> below agents see the full cluster</li>
</ol>
</div>
<div>
<p className="mb-1.5 text-[9px] uppercase tracking-wider text-foreground-faint">Agents & domains</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 copied' : '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={`Ask about ${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>
)
}