Add HDFS browser, SSH terminal, presentation editor, lab health panel

- HDFS WebHDFS file browser (api/hdfs_api.py + HdfsView)
- In-browser SSH terminal via paramiko WebSocket bridge (api/ssh_terminal.py + SshTerminal, xterm.js)
- Presentation deck editor (text + image upload) and CRUD endpoints
- Collapsible GPU matrix + new LabHealthPanel in SideNav
- Topology fixes (edge alignment, Hadoop node, compact nodes)
- nginx ws timeout bump for long-lived SSH sessions
This commit is contained in:
mo
2026-06-26 00:47:49 +00:00
parent fefe3016ad
commit 46b51a891c
21 changed files with 1957 additions and 328 deletions
+3
View File
@@ -9,6 +9,9 @@ server {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_buffering off;
}
location /assets/ {
+2
View File
@@ -10,6 +10,8 @@
},
"dependencies": {
"@tanstack/react-query": "^5.62.8",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
+9 -5
View File
@@ -16,7 +16,9 @@ import { PresentationView } from './components/features/PresentationView'
import { DataQualityView } from './components/features/DataQualityView'
import { KnowledgeChatView } from './components/features/KnowledgeChatView'
import { StorageView } from './components/features/StorageView'
import { HdfsView } from './components/features/HdfsView'
import { SearchView } from './components/features/SearchView'
import { SshTerminal } from './components/features/SshTerminal'
import { TerminalDock } from './components/features/TerminalDock'
import { WorkbenchPanel } from './components/features/WorkbenchPanel'
import { resolveInfraNode } from './lib/infraCatalog'
@@ -27,6 +29,7 @@ export default function App() {
const cc = useCommandCenter()
const [gpuChatActive, setGpuChatActive] = useState(false)
const [infraOpen, setInfraOpen] = useState(false)
const [sshOpen, setSshOpen] = useState(false)
const gpuBoost = gpuChatActive || cc.mainView === 'knowledge'
const { agentLoads, gpuLive } = useLiveMetrics(cc.agents, cc.gpu, cc.anims, gpuBoost)
const mainScrollRef = useRef<HTMLDivElement>(null)
@@ -60,20 +63,17 @@ export default function App() {
<div className="flex min-h-0 flex-1 overflow-hidden">
<SideNav
agents={cc.agents}
animations={cc.anims}
workload={cc.workload}
gpu={cc.gpu}
gpuLive={gpuLive}
gpuBoost={gpuBoost}
selectedAgentId={cc.selectedAgentId}
selectedNodeId={cc.selectedNodeId}
mainView={cc.mainView}
approvalCount={cc.approvals.length}
agentsLoading={cc.agentsLoading}
onSetMainView={cc.setMainView}
onOpenApprovals={openApprovals}
onSelectAgent={cc.selectAgent}
onSelectZone={cc.selectNode}
onOpenSsh={() => setSshOpen(true)}
/>
<div ref={mainScrollRef} className={cn('flex min-h-0 min-w-0 flex-1 flex-col bg-surface', isPlatform ? 'overflow-hidden' : 'scrollbar-thin overflow-y-auto')}>
@@ -132,6 +132,8 @@ export default function App() {
<KnowledgeChatView onGpuActivity={setGpuChatActive} />
) : cc.mainView === 'storage' ? (
<StorageView />
) : cc.mainView === 'hdfs' ? (
<HdfsView />
) : cc.mainView === 'search' ? (
<SearchView />
) : (
@@ -201,6 +203,8 @@ export default function App() {
onDecide={cc.decide}
onDismissHighlight={() => cc.setApprovalHighlight(false)}
/>
<SshTerminal open={sshOpen} onClose={() => setSshOpen(false)} />
</div>
)
}
+118 -106
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { Activity, Cpu, ExternalLink, Thermometer, Zap } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { Activity, ChevronDown, ChevronUp, Cpu, ExternalLink, Zap } from 'lucide-react'
import { fetchGpu } from '../../lib/api'
import type { GpuDevice, GpuStatus } from '../../types'
import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
@@ -23,46 +23,28 @@ function utilColor(pct: number) {
return 'bg-success'
}
function GpuRow({ device, liveUtil, active }: { device: GpuDevice; liveUtil: number; active: boolean }) {
/** Compact card — fits 4 GPUs in a 2×2 grid without scrolling */
function GpuCardCompact({ device, liveUtil }: { device: GpuDevice; liveUtil: number }) {
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 className="rounded border border-border/70 bg-surface-overlay/50 px-1.5 py-1">
<div className="mb-0.5 flex items-center justify-between gap-1 font-mono text-[8px]">
<span className="font-semibold text-foreground">GPU {device.index}</span>
<span className="text-foreground-faint">{util.toFixed(0)}% · {vramPct}% VR</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 className="flex gap-1">
<div className="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-raised" title={`Util ${util.toFixed(0)}%`}>
<div className={cn('h-full rounded-full transition-all duration-700', utilColor(util))} style={{ width: `${util}%` }} />
</div>
<div className="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-raised" title={`VRAM ${vramPct}%`}>
<div className="h-full rounded-full bg-docker transition-all duration-700" style={{ width: `${vramPct}%` }} />
</div>
</div>
<p className="mt-0.5 font-mono text-[7px] text-foreground-faint">
{device.temperature_c?.toFixed(0) ?? '—'}°C · {device.power_w?.toFixed(0) ?? '—'} W
</p>
</div>
)
}
@@ -70,11 +52,16 @@ function MetricBar({ label, value, colorClass }: { label: string; value: number;
export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props) {
const [localGpu, setLocalGpu] = useState<GpuStatus | null>(gpu)
const [lastPoll, setLastPoll] = useState<Date | null>(null)
const [expanded, setExpanded] = useState(false)
useEffect(() => {
setLocalGpu(gpu)
}, [gpu])
useEffect(() => {
if (boost) setExpanded(true)
}, [boost])
useEffect(() => {
const poll = async () => {
const g = await fetchGpu()
@@ -111,23 +98,25 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
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">
<section className="shrink-0 border-b border-border px-3 py-2">
<h2 className="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>
<p className="mt-1 text-[9px] text-foreground-faint">GPU Lab offline</p>
</section>
)
}
const tokLabel = boost && inferenceOn ? String(live.tokenThroughput) : inferenceOn ? '—' : '0'
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">
<section className="shrink-0 border-b border-border px-3 py-2">
<div className="flex items-start gap-1">
<button
type="button"
onClick={onSelectGpu}
className="min-w-0 flex-1 text-left hover:opacity-90"
>
<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 && (
@@ -136,70 +125,93 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
</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"
{!expanded && (
<p className="mt-0.5 truncate text-[10px] font-medium text-foreground">{modelLabel}</p>
)}
</button>
<div className="flex shrink-0 items-center gap-0.5">
{g.ui_url && (
<a
href={g.ui_url}
target="_blank"
rel="noreferrer"
className="rounded p-1 text-docker hover:bg-surface-overlay"
title="Open GPU UI"
>
<ExternalLink className="h-3 w-3" />
</a>
)}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="rounded p-1 text-foreground-muted hover:bg-surface-overlay hover:text-foreground"
title={expanded ? 'Collapse GPU details' : 'Expand GPU details'}
aria-expanded={expanded}
>
<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" />}
/>
{expanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
</button>
</div>
</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>
{!expanded ? (
<button
type="button"
onClick={() => setExpanded(true)}
className="mt-1.5 w-full rounded-md border border-border/80 bg-surface-overlay/50 px-2 py-1.5 text-left transition-colors hover:border-border-strong hover:bg-surface-overlay"
>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 font-mono text-[8px] text-foreground-muted">
<span className={inferenceOn ? 'text-success' : 'text-foreground-faint'}>
{inferenceOn ? 'Active' : 'Idle'}
</span>
<span>{avgUtil.toFixed(0)}% util</span>
<span>{avgVram.toFixed(0)}% VRAM</span>
{inferenceOn && (
<span className="inline-flex items-center gap-0.5">
<Zap className="h-2.5 w-2.5 text-amber-400" />
{tokLabel} tok/s
</span>
)}
</div>
<div className="mt-1 flex items-center gap-1.5">
<div className="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-raised">
<div className="h-full rounded-full bg-docker transition-all duration-700" style={{ width: `${avgVram}%` }} />
</div>
<span className="shrink-0 font-mono text-[7px] text-foreground-faint">
{g.gpu_count ?? devices.length}× V100
</span>
</div>
</button>
) : (
<div className="mt-1.5 space-y-1.5">
<p className="truncate text-[10px] font-medium text-foreground">{modelLabel}</p>
<div className="flex flex-wrap gap-x-2 gap-y-0.5 font-mono text-[8px] text-foreground-muted">
<span className={inferenceOn ? 'text-success' : 'text-foreground-faint'}>
{inferenceOn ? 'Active' : 'Idle'}
</span>
<span>{avgUtil.toFixed(0)}% util avg</span>
<span>{avgVram.toFixed(0)}% VRAM avg</span>
<span className="inline-flex items-center gap-0.5">
<Zap className="h-2.5 w-2.5 text-amber-400" />
{tokLabel} tok/s
</span>
</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>
<div className="grid grid-cols-2 gap-1">
{devices.map((d, i) => (
<GpuCardCompact
key={d.index}
device={d}
liveUtil={live.deviceUtils[i] ?? d.util_gpu}
/>
))}
</div>
<p className="font-mono text-[7px] text-foreground-faint">
{g.gpu_count ?? devices.length}× V100 · {g.host} · poll {boost ? '1s' : '3s'}
{lastPoll && ` · ${lastPoll.toLocaleTimeString()}`}
</p>
</div>
)}
</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>
)
}
+230
View File
@@ -0,0 +1,230 @@
import { useCallback, useEffect, useState } from 'react'
import { ChevronRight, Download, Eye, FileText, Folder, Loader2, RefreshCw, Server, X } from 'lucide-react'
import { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive'
type HdfsEntry = {
name: string
path: string
type: 'directory' | 'file'
size?: number
size_human?: string
modified?: string
owner?: string
group?: string
perms?: string
replication?: number
}
type Health = {
ok: boolean
namenode?: string
live_datanodes?: number
dead_datanodes?: number
capacity_total_human?: string
capacity_used_human?: string
capacity_used_pct?: number
error?: string
}
export function HdfsView() {
const [health, setHealth] = useState<Health | null>(null)
const [path, setPath] = useState('/')
const [folders, setFolders] = useState<HdfsEntry[]>([])
const [files, setFiles] = useState<HdfsEntry[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [preview, setPreview] = useState<{ path: string; text: string; binary: boolean; truncated: boolean } | null>(null)
const [previewing, setPreviewing] = useState(false)
const loadHealth = useCallback(async () => {
try {
const r = await fetch('/api/storage/hdfs/health')
if (r.ok) setHealth(await r.json())
} catch {
/* ignore */
}
}, [])
const loadList = useCallback(async (p: string) => {
setLoading(true)
setError(null)
try {
const r = await fetch(`/api/storage/hdfs/list?path=${encodeURIComponent(p)}`)
const j = await r.json()
if (!j.ok) {
setError(j.error || 'List failed')
setFolders([])
setFiles([])
return
}
setFolders(j.folders || [])
setFiles(j.files || [])
} catch {
setError('HDFS API unavailable')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadHealth()
}, [loadHealth])
useEffect(() => {
loadList(path)
}, [path, loadList])
const openPreview = useCallback(async (p: string) => {
setPreviewing(true)
setPreview({ path: p, text: '', binary: false, truncated: false })
try {
const r = await fetch(`/api/storage/hdfs/preview?path=${encodeURIComponent(p)}`)
const j = await r.json()
if (j.ok) setPreview({ path: p, text: j.text, binary: j.binary, truncated: j.truncated })
else setPreview({ path: p, text: j.error || 'Preview failed', binary: false, truncated: false })
} catch {
setPreview({ path: p, text: 'Preview failed', binary: false, truncated: false })
} finally {
setPreviewing(false)
}
}, [])
const crumbs = path === '/' ? [] : path.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">
<Server className="h-4 w-4 text-emerald-400" />
Hadoop HDFS
</h2>
<p className="text-[10px] text-foreground-muted">
{health?.namenode || '10.0.21.61:9870'} · {health?.live_datanodes ?? '—'} datanodes ·{' '}
{health?.capacity_used_human || '—'} / {health?.capacity_total_human || '—'}
{typeof health?.capacity_used_pct === 'number' ? ` (${health.capacity_used_pct}%)` : ''}
</p>
</div>
<div className="flex flex-wrap gap-2">
<a href="http://10.0.21.61:9870" target="_blank" rel="noreferrer" className={cn('inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-[11px] font-medium', subTabActive)}>
NameNode UI
</a>
<button type="button" onClick={() => { loadHealth(); loadList(path) }} 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 p-3">
<nav className="mb-2 flex flex-wrap items-center gap-1 text-[10px] text-foreground-muted">
<button type="button" className="font-medium hover:text-emerald-400" onClick={() => setPath('/')}>
HDFS root
</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-emerald-400"
onClick={() => setPath('/' + 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">Owner</th>
<th className="py-1.5 pr-2">Perms</th>
<th className="py-1.5 pr-2">Modified</th>
<th className="py-1.5" />
</tr>
</thead>
<tbody>
{folders.map((f) => (
<tr key={f.path} 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-emerald-400 hover:underline"
onClick={() => setPath(f.path)}
>
<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-muted">{f.owner}</td>
<td className="py-1.5 pr-2 font-mono text-[9px] text-foreground-faint">{f.perms}</td>
<td className="py-1.5 pr-2 text-foreground-faint">{f.modified?.slice(0, 19).replace('T', ' ') || '—'}</td>
<td />
</tr>
))}
{files.map((o) => (
<tr key={o.path} className="border-b border-border/50 hover:bg-surface-overlay/50">
<td className="max-w-[280px] truncate py-1.5 pr-2">
<span className="inline-flex items-center gap-1 font-mono text-[10px]">
<FileText className="h-3.5 w-3.5 shrink-0 text-foreground-faint" /> {o.name}
</span>
</td>
<td className="py-1.5 pr-2 text-foreground-muted">{o.size_human}</td>
<td className="py-1.5 pr-2 text-foreground-muted">{o.owner}</td>
<td className="py-1.5 pr-2 font-mono text-[9px] text-foreground-faint">{o.perms}</td>
<td className="py-1.5 pr-2 text-foreground-faint">{o.modified?.slice(0, 19).replace('T', ' ') || '—'}</td>
<td className="py-1.5">
<div className="flex items-center gap-2">
<button type="button" title="Preview" className="text-foreground-muted hover:text-emerald-400" onClick={() => openPreview(o.path)}>
<Eye className="h-3.5 w-3.5" />
</button>
<a
title="Download"
href={`/api/storage/hdfs/download?path=${encodeURIComponent(o.path)}`}
className="text-foreground-muted hover:text-emerald-400"
>
<Download className="h-3.5 w-3.5" />
</a>
</div>
</td>
</tr>
))}
</tbody>
</table>
{!loading && folders.length === 0 && files.length === 0 && !error && (
<p className="py-8 text-center text-sm text-foreground-muted">This directory is empty.</p>
)}
</div>
</div>
{preview && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={() => setPreview(null)}>
<div className="flex max-h-[80vh] w-full max-w-3xl flex-col overflow-hidden rounded-lg border border-border bg-surface" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<span className="truncate font-mono text-[11px] text-foreground">{preview.path}</span>
<button type="button" onClick={() => setPreview(null)} className="text-foreground-muted hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
{preview.binary && <p className="px-4 pt-2 text-[10px] text-amber-400">Binary file showing decoded preview.</p>}
<pre className="scrollbar-thin min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-words p-4 font-mono text-[11px] leading-relaxed text-foreground-muted">
{previewing ? 'Loading…' : preview.text}
{preview.truncated && '\n\n… (truncated)'}
</pre>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,302 @@
import { useMemo, useState } from 'react'
import {
Box,
ChevronDown,
ChevronUp,
Cpu,
Database,
HardDrive,
Layers,
Server,
ShieldCheck,
Workflow,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { GpuStatus, WorkloadData, WorkloadZone } from '../../types'
import { NODE_ALIASES } from '../../lib/constants'
import { cn } from '../../lib/utils'
type Props = {
workload: WorkloadData | null
gpu: GpuStatus | null
selectedNodeId: string | null
approvalCount: number
onSelectZone: (id: string) => void
onOpenApprovals: () => void
}
const ZONE_ORDER = ['docker', 'db', 'etl', 'lakehouse', 's3', 'hadoop'] as const
const ZONE_META: Record<string, { icon: LucideIcon; shortLabel: string; abbrev: string }> = {
docker: { icon: Box, shortLabel: 'Docker Rack', abbrev: 'Docker' },
db: { icon: Database, shortLabel: 'DB Vault', abbrev: 'DB' },
etl: { icon: Workflow, shortLabel: 'ETL Pipe', abbrev: 'ETL' },
lakehouse: { icon: Layers, shortLabel: 'Lakehouse', abbrev: 'Lake' },
s3: { icon: HardDrive, shortLabel: 'ObjectScale S3', abbrev: 'S3' },
hadoop: { icon: Server, shortLabel: 'Hadoop HDFS', abbrev: 'HDFS' },
gpu: { icon: Cpu, shortLabel: 'GPU Lab', abbrev: 'GPU' },
}
const ETL_NODE_IDS = new Set([
'etl',
'kafka',
'airflow',
'debezium',
'stream-kafka',
'src-airflow',
'cdc-postgres',
'cdc-mysql',
'cdc-mongo',
'cdc-cassandra',
])
function levelDot(level: string) {
if (level === 'ok') return 'bg-success shadow-[0_0_6px_rgba(52,211,153,0.45)]'
if (level === 'warn') return 'bg-warning shadow-[0_0_6px_rgba(245,158,11,0.4)]'
if (level === 'down') return 'bg-danger shadow-[0_0_6px_rgba(239,68,68,0.4)]'
return 'bg-foreground-faint'
}
function resolveActiveZone(selectedNodeId: string | null): string | null {
if (!selectedNodeId) return null
if (selectedNodeId === 'gpu' || selectedNodeId === 'cons-ml') return 'gpu'
if ((ZONE_ORDER as readonly string[]).includes(selectedNodeId)) return selectedNodeId
if (ETL_NODE_IDS.has(selectedNodeId)) return 'etl'
const alias = NODE_ALIASES[selectedNodeId]
if (!alias) return null
if (alias === 'gpu') return 'gpu'
if (ETL_NODE_IDS.has(alias)) return 'etl'
if ((ZONE_ORDER as readonly string[]).includes(alias)) return alias
return null
}
function zoneMetric(zone: WorkloadZone): string {
if (zone.id === 'hadoop' && zone.hdfs_used_gb != null && zone.hdfs_total_gb != null && zone.hdfs_total_gb > 0) {
return `${zone.hdfs_used_gb.toFixed(1)} / ${zone.hdfs_total_gb.toFixed(1)} TB`
}
if (zone.id === 's3' && zone.bucket) return zone.bucket
return `${zone.running}/${zone.total}`
}
function gpuMetric(gpu: GpuStatus | null, wlGpu: WorkloadData['gpu'] | undefined): string {
if (!gpu?.ok) return 'Offline'
const model = gpu.active_model?.replace(/\s*GPTQ$/i, '').replace(/\s*AWQ$/i, '').trim()
if (gpu.inference_active && model) return model.split('-').slice(0, 2).join(' ')
if (model) return 'Loaded'
if (wlGpu?.gpu_count) return `${wlGpu.gpu_count}× GPU`
return 'Standby'
}
function gpuLevel(gpu: GpuStatus | null, wlGpu: WorkloadData['gpu'] | undefined): string {
if (!gpu?.ok) return 'down'
if (gpu.inference_active) return 'ok'
return wlGpu?.level || 'warn'
}
type ZoneEntry = { id: string; zone: WorkloadZone; metric?: string }
export function LabHealthPanel({
workload,
gpu,
selectedNodeId,
approvalCount,
onSelectZone,
onOpenApprovals,
}: Props) {
const [expanded, setExpanded] = useState(false)
const activeZone = resolveActiveZone(selectedNodeId)
const zones = workload?.zones ?? []
const ordered = ZONE_ORDER.map((id) => zones.find((z) => z.id === id)).filter(Boolean) as WorkloadZone[]
const totals = workload?.totals
const gpuZone: WorkloadZone = {
id: 'gpu',
label: 'GPU LAB',
x: 0,
color: '#4c9aed',
level: gpuLevel(gpu, workload?.gpu) as WorkloadZone['level'],
running: gpu?.gpu_count ?? workload?.gpu?.gpu_count ?? 0,
total: gpu?.gpu_count ?? workload?.gpu?.gpu_count ?? 0,
apps: [],
vm: gpu?.host,
ip: gpu?.host,
}
const allZones: ZoneEntry[] = [
...ordered.map((zone) => ({ id: zone.id, zone })),
{ id: 'gpu', zone: gpuZone, metric: gpuMetric(gpu, workload?.gpu) },
]
const healthSummary = useMemo(() => {
const ok = allZones.filter((z) => z.zone.level === 'ok').length
const warn = allZones.filter((z) => z.zone.level === 'warn').length
const down = allZones.filter((z) => z.zone.level === 'down').length
return { ok, warn, down, total: allZones.length }
}, [allZones])
return (
<section className={cn('flex flex-col p-3', expanded ? 'min-h-0 flex-1' : 'shrink-0')}>
<div className="mb-2 flex shrink-0 items-start justify-between gap-1">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="min-w-0 flex-1 text-left"
>
<h2 className="flex items-center gap-1.5 text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">
Lab Health
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</h2>
{!expanded && totals && (
<p className="mt-0.5 font-mono text-[8px] leading-snug text-foreground-muted">
<span className="text-success">{healthSummary.ok} ok</span>
{healthSummary.warn > 0 && (
<>
<span className="text-foreground-faint"> · </span>
<span className="text-warning">{healthSummary.warn} warn</span>
</>
)}
{healthSummary.down > 0 && (
<>
<span className="text-foreground-faint"> · </span>
<span className="text-danger">{healthSummary.down} down</span>
</>
)}
<span className="text-foreground-faint"> · </span>
<span className={totals.pipeline_active ? 'text-success' : 'text-warning'}>
{totals.pipeline_active ? 'pipeline ok' : 'degraded'}
</span>
</p>
)}
{expanded && totals && (
<p className="mt-0.5 font-mono text-[8px] leading-snug text-foreground-muted">
<span>{totals.apps_running}/{totals.apps_total} containers</span>
<span className="text-foreground-faint"> · </span>
<span>{totals.connectors} CDC</span>
<span className="text-foreground-faint"> · </span>
<span className={totals.pipeline_active ? 'text-success' : 'text-warning'}>
{totals.pipeline_active ? 'pipeline active' : 'degraded'}
</span>
</p>
)}
{!totals && (
<p className="mt-0.5 text-[8px] text-foreground-faint">Loading workload</p>
)}
</button>
<button
type="button"
onClick={onOpenApprovals}
className={cn(
'flex shrink-0 items-center gap-1 rounded-md border px-2 py-0.5 text-[9px] font-medium transition-colors',
approvalCount > 0
? 'border-warning/40 bg-warning/10 text-warning'
: 'border-border text-foreground-muted hover:border-border-strong',
)}
>
<ShieldCheck className="h-3 w-3" />
{approvalCount > 0 && <span className="font-mono">{approvalCount}</span>}
</button>
</div>
{!expanded ? (
<div className="flex flex-wrap gap-1">
{allZones.map(({ id, zone }) => {
const meta = ZONE_META[id] || { abbrev: id, shortLabel: zone.label, icon: Server }
const active = activeZone === id
return (
<button
key={id}
type="button"
onClick={() => onSelectZone(id)}
className={cn(
'inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[8px] font-medium transition-colors',
active
? 'border-docker/45 bg-docker-light/80 text-docker dark:bg-blue-500/15'
: 'border-border/70 bg-surface-overlay/40 text-foreground-muted hover:border-border-strong hover:text-foreground',
)}
>
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', levelDot(zone.level))} />
{meta.abbrev}
</button>
)
})}
</div>
) : (
<>
<div className="scrollbar-thin min-h-0 flex-1 space-y-1 overflow-y-auto">
{ordered.map((zone) => (
<ZoneRow
key={zone.id}
zone={zone}
active={activeZone === zone.id}
onSelect={() => onSelectZone(zone.id)}
/>
))}
<ZoneRow
zone={gpuZone}
metric={gpuMetric(gpu, workload?.gpu)}
active={activeZone === 'gpu'}
onSelect={() => onSelectZone('gpu')}
/>
{!workload && (
<p className="py-2 text-center text-[9px] text-foreground-faint">Waiting for lab snapshot</p>
)}
</div>
<p className="mt-2 shrink-0 text-[7px] leading-relaxed text-foreground-faint">
Click a zone inspector · agents via Agent Fleet
</p>
</>
)}
</section>
)
}
function ZoneRow({
zone,
metric,
active,
onSelect,
}: {
zone: WorkloadZone
metric?: string
active: boolean
onSelect: () => void
}) {
const meta = ZONE_META[zone.id] || { icon: Server, shortLabel: zone.label, abbrev: zone.id }
const Icon = meta.icon
const right = metric ?? zoneMetric(zone)
const subtitle = [zone.vm, zone.ip].filter(Boolean).join(' · ')
return (
<button
type="button"
onClick={onSelect}
className={cn(
'group flex w-full items-center gap-2 rounded-md border px-2 py-2 text-left transition-all',
active
? 'border-docker/45 bg-docker-light/80 shadow-sm dark:bg-blue-500/10'
: 'border-transparent bg-surface-overlay/30 hover:border-border hover:bg-surface-overlay',
)}
>
<span
className={cn('h-2 w-2 shrink-0 rounded-full transition-transform group-hover:scale-110', levelDot(zone.level))}
title={zone.level}
/>
<span
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border/60 bg-surface"
style={{ color: zone.color }}
>
<Icon className="h-3.5 w-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-1">
<span className="truncate text-[11px] font-medium text-foreground">{meta.shortLabel}</span>
<span className="shrink-0 font-mono text-[9px] tabular-nums text-foreground-muted">{right}</span>
</span>
{subtitle && (
<span className="block truncate font-mono text-[8px] text-foreground-faint">{subtitle}</span>
)}
</span>
</button>
)
}
+111 -63
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Box } from 'lucide-react'
import type { AgentAnim, WorkloadData } from '../../types'
import { Badge } from '../ui/Badge'
@@ -52,10 +52,11 @@ const STAGES: TopoStage[] = [
],
},
{
id: 'storage', num: 4, title: 'STORAGE', subtitle: 'Lakehouse layer', accent: 'topo-stage-col--storage',
id: 'storage', num: 4, title: 'STORAGE', subtitle: 'Lakehouse · object · historical archive', 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: 'hadoop', label: 'Hadoop HDFS', sub: 'Historical archive', metricKey: 'hadoop' },
],
},
{
@@ -90,8 +91,12 @@ const FLOW_EDGES: FlowEdge[] = [
{ 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' },
{ from: 'spark', to: 'hadoop', kind: 'etl', label: 'Historical archive' },
{ from: 'airflow', to: 'hadoop', kind: 'orchestration', label: 'Archive DAG' },
{ from: 'iceberg', to: 'hadoop', kind: 'etl', label: 'Cold archive' },
// Query & serve
{ from: 'iceberg', to: 'trino', kind: 'query', label: 'SQL' },
{ from: 'hadoop', to: 'trino', kind: 'query', label: 'Historical 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' },
@@ -141,54 +146,40 @@ const PARTICLE_FILL: Record<FlowKind, string> = {
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',
trino: 'query-trino', iceberg: 'lake-iceberg', s3: 'lake-s3', hadoop: 'hadoop', bi: 'cons-bi',
jupyter: 'cons-notebooks', elasticsearch: 'cons-elastic', kibana: 'cons-kibana', llm: 'cons-ml',
}
const NODE_POS: Record<string, { col: number; row: number; rows: number }> = {}
const NODE_COL: Record<string, number> = {}
STAGES.forEach((stage, col) => {
stage.nodes.forEach((node, row) => {
NODE_POS[node.id] = { col, row, rows: stage.nodes.length }
stage.nodes.forEach((node) => {
NODE_COL[node.id] = col
})
})
function nodeCoords(col: number, row: number, rows: number) {
const colCount = 5
const gap = 3.2
const colW = (100 - gap * (colCount - 1)) / colCount
const xCenter = col * (colW + gap) + colW / 2
const nodeHalf = colW * 0.34
const yPad = 5
const ySpan = 90
const y = yPad + ((row + 0.5) / rows) * ySpan
return {
inX: xCenter - nodeHalf,
outX: xCenter + nodeHalf,
y,
}
}
type NodeAnchor = { x: number; y: number; inX: number; outX: number; col: number }
/** Curved path — route edges to avoid dangling stubs over intermediate nodes */
/** SVG path in pixel coordinates between measured node anchors */
function flowPath(
x1: number, y1: number, x2: number, y2: number,
opts: { backward?: boolean; sameCol?: boolean } = {},
opts: { backward?: boolean; longArc?: boolean } = {},
) {
const { backward = false, sameCol = false } = opts
if (backward || x2 < x1 - 2) {
const arcY = Math.min(y1, y2) - 14
const { backward = false, longArc = false } = opts
if (backward) {
const arcY = Math.min(y1, y2) - 36
return `M ${x1} ${y1} C ${x1} ${arcY}, ${x2} ${arcY}, ${x2} ${y2}`
}
if (sameCol) {
const cx = (x1 + x2) / 2
return `M ${x1} ${y1} C ${cx} ${y1}, ${cx} ${y2}, ${x2} ${y2}`
const dx = x2 - x1
if (Math.abs(dx) < 6) {
const midY = (y1 + y2) / 2
return `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`
}
const span = x2 - x1
const dy = Math.abs(y2 - y1)
if (span > 16 && dy > 4) {
const dipY = Math.max(y1, y2) + Math.min(12, 6 + span * 0.08)
return `M ${x1} ${y1} C ${x1 + span * 0.2} ${dipY}, ${x2 - span * 0.2} ${dipY}, ${x2} ${y2}`
if (longArc) {
const sag = Math.max(32, Math.min(72, Math.abs(dx) * 0.14))
const midY = Math.max(y1, y2) + sag
return `M ${x1} ${y1} Q ${(x1 + x2) / 2} ${midY} ${x2} ${y2}`
}
const mx = (x1 + x2) / 2
const mx = x1 + dx * 0.5
return `M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`
}
@@ -199,10 +190,23 @@ function seedMetrics(): MetricState {
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',
hadoop: 'Historical archive',
bi: '26 dashboards', jupyter: '12 kernels active', elasticsearch: 'atc-lakehouse', kibana: 'available', llm: 'Checking…',
}
}
function formatHadoopMetric(workload: WorkloadData | null): string {
const hz = workload?.zones?.find((z) => z.id === 'hadoop')
if (!hz) return 'Historical archive'
const used = hz.hdfs_used_gb
const total = hz.hdfs_total_gb
if (used != null && total != null && total > 0) {
return `${used.toFixed(1)} / ${total.toFixed(1)} TB HDFS`
}
if (hz.running > 0) return `${hz.running} datanodes · archive tier`
return hz.level === 'ok' ? 'Historical archive · ready' : 'NameNode check'
}
function formatLlmLabel(model?: string | null): string {
if (!model) return 'GenAI LLM'
return model.replace(/\s*GPTQ$/i, '').replace(/\s*AWQ$/i, '').trim()
@@ -224,6 +228,7 @@ function formatLlmMetric(workload: WorkloadData | null): string {
function jitterMetric(key: string, current: string, workload: WorkloadData | null): string {
if (key === 'llm') return formatLlmMetric(workload)
if (key === 'hadoop') return formatHadoopMetric(workload)
const n = () => (Math.random() - 0.5) * 2
const fns: Record<string, () => string> = {
postgresql: () => `${(12.4 + n() * 0.8).toFixed(1)}k rows/s`,
@@ -252,6 +257,32 @@ type Props = {
export function PlatformTopology({ workload, animations, selectedNodeId, onNodeClick }: Props) {
const [metrics, setMetrics] = useState<MetricState>(seedMetrics)
const canvasRef = useRef<HTMLDivElement>(null)
const nodeRefs = useRef<Record<string, HTMLButtonElement | null>>({})
const [anchors, setAnchors] = useState<Record<string, NodeAnchor>>({})
const [canvasSize, setCanvasSize] = useState({ w: 800, h: 400 })
const setNodeRef = useCallback((id: string) => (el: HTMLButtonElement | null) => {
nodeRefs.current[id] = el
}, [])
const measureAnchors = useCallback(() => {
const canvas = canvasRef.current
if (!canvas) return
const rect = canvas.getBoundingClientRect()
if (rect.width < 10 || rect.height < 10) return
setCanvasSize({ w: rect.width, h: rect.height })
const next: Record<string, NodeAnchor> = {}
for (const [id, el] of Object.entries(nodeRefs.current)) {
if (!el) continue
const r = el.getBoundingClientRect()
const x = (r.left + r.right) / 2 - rect.left
const y = (r.top + r.bottom) / 2 - rect.top
const hw = r.width / 2
next[id] = { x, y, inX: x - hw, outX: x + hw, col: NODE_COL[id] ?? 0 }
}
setAnchors(next)
}, [])
const llmLabel = formatLlmLabel(workload?.gpu?.model)
@@ -276,8 +307,8 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC
}, [])
useEffect(() => {
setMetrics((prev) => ({ ...prev, llm: formatLlmMetric(workload) }))
}, [workload?.gpu?.model, workload?.gpu?.inference_active, workload?.gpu?.avg_util, workload?.gpu?.gpus])
setMetrics((prev) => ({ ...prev, llm: formatLlmMetric(workload), hadoop: formatHadoopMetric(workload) }))
}, [workload?.gpu?.model, workload?.gpu?.inference_active, workload?.gpu?.avg_util, workload?.gpu?.gpus, workload?.zones])
useEffect(() => {
const iv = setInterval(() => {
@@ -294,6 +325,23 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC
? Object.entries(NODE_CLICK_MAP).find(([, v]) => v === selectedNodeId)?.[0] ?? null
: null
useLayoutEffect(() => {
measureAnchors()
const canvas = canvasRef.current
if (!canvas) return
const ro = new ResizeObserver(() => measureAnchors())
ro.observe(canvas)
window.addEventListener('resize', measureAnchors)
return () => {
ro.disconnect()
window.removeEventListener('resize', measureAnchors)
}
}, [measureAnchors])
useLayoutEffect(() => {
measureAnchors()
}, [measureAnchors, metrics, llmLabel])
return (
<section className="panel flex h-full min-h-0 flex-1 flex-col overflow-hidden">
<header
@@ -308,7 +356,7 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC
<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">
Click PostgreSQL / MySQL / MongoDB / Trino live console · agents terminal below
Click PostgreSQL / MySQL / MongoDB / Trino live console · Hadoop historical archive · agents terminal below
</p>
</div>
</div>
@@ -330,11 +378,10 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC
</div>
</header>
<div className="topo-canvas flex min-h-0 flex-1">
<div ref={canvasRef} 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"
viewBox={`0 0 ${canvasSize.w} ${canvasSize.h}`}
aria-hidden
>
<defs>
@@ -345,31 +392,30 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC
</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, sameCol: pa.col === pb.col })
const a = anchors[edge.from]
const b = anchors[edge.to]
if (!a || !b) return null
const backward = b.col < a.col
const fromX = backward ? a.inX : a.outX
const toX = backward ? b.outX : b.inX
const colGap = Math.abs(b.col - a.col)
const longArc = colGap > 1 || (Math.abs(b.y - a.y) > 48 && Math.abs(toX - fromX) > 120)
const d = flowPath(fromX, a.y, toX, b.y, { backward, longArc })
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="topo-edge-glow" />
<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">
<circle r="2.5" 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">
<circle r="1.5" fill="#ffffff" opacity="0.85">
<animateMotion dur={`${dur}s`} repeatCount="indefinite" path={d} begin={`${dur * 0.45}s`} />
</circle>
</>
@@ -382,18 +428,18 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC
<div className="relative z-10 flex h-full min-h-0 w-full">
{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])}>
<header className="mb-0.5 shrink-0 border-b border-white/10 pb-0.5">
<div className="flex items-start gap-0.5">
<span className={cn('rounded border px-0.5 py-px font-mono text-[7px] 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>
<h3 className="text-[7px] font-bold leading-tight tracking-wide text-white">{stage.title}</h3>
<p className="text-[6px] leading-tight text-blue-200/70">{stage.subtitle}</p>
</div>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col justify-evenly gap-1">
<div className="flex min-h-0 flex-1 flex-col justify-evenly gap-0.5">
{stage.nodes.map((node) => {
const label = node.id === 'llm' ? llmLabel : node.label
const sub = node.id === 'llm'
@@ -402,18 +448,20 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC
return (
<button
key={node.id}
ref={setNodeRef(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 === 'hadoop' && 'topo-node-hadoop',
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">
<span className="block truncate text-[9px] font-semibold leading-tight text-white">{label}</span>
<span className="block truncate text-[7px] leading-tight text-blue-100/80">{sub}</span>
<span className="mt-px inline-block max-w-full truncate rounded border border-emerald-400/35 bg-emerald-500/20 px-0.5 py-px font-mono text-[6px] font-medium leading-tight text-emerald-300">
{metrics[node.metricKey]}
</span>
</button>
+370 -51
View File
@@ -1,5 +1,16 @@
import { useCallback, useEffect, useState } from 'react'
import { ExternalLink, FileUp, Monitor, Upload } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
ExternalLink,
FileUp,
ImagePlus,
Monitor,
Pencil,
Plus,
Save,
Trash2,
Upload,
X,
} from 'lucide-react'
import { ArchitectureDiagram } from './ArchitectureDiagram'
import type { PresentationData, PresentationSlide } from '../../types'
import { cn } from '../../lib/utils'
@@ -46,6 +57,23 @@ export function PresentationView() {
const [uploadMsg, setUploadMsg] = useState<string | null>(null)
const [customDecks, setCustomDecks] = useState<{ id: string; title: string }[]>([])
// ── edit state ──
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState<PresentationData | null>(null)
const [saving, setSaving] = useState(false)
const [imgBusy, setImgBusy] = useState(false)
const imgInputRef = useRef<HTMLInputElement>(null)
const isCustom = customDecks.some((d) => d.id === source)
const refreshDeckList = useCallback(async () => {
try {
const j = await (await fetch('/api/presentation/decks')).json()
const uploaded = (j.uploaded || []).map((d: { id: string; title: string }) => ({ id: d.id, title: d.title }))
setCustomDecks(uploaded)
} catch { /* ignore */ }
}, [])
const load = useCallback(async (deckId: DeckSource) => {
setLoading(true)
setError(null)
@@ -72,17 +100,13 @@ export function PresentationView() {
}, [])
useEffect(() => {
setEditing(false)
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])
refreshDeckList()
}, [source, load, refreshDeckList])
useEffect(() => {
if (editing) return
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)) }
@@ -91,11 +115,15 @@ export function PresentationView() {
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [data?.slides.length])
}, [data?.slides.length, editing])
const slides = data?.slides || []
const slide: PresentationSlide | undefined = slides[slideIdx]
const editIdx = editing ? slideIdx : null
const editSlides = draft?.slides || []
const editSlide = editIdx != null ? editSlides[editIdx] : undefined
const exportHtml = () => {
const id = source === 'live' ? 'live' : source
window.open(`/api/presentation/decks/${id}/html`, '_blank')
@@ -123,6 +151,125 @@ export function PresentationView() {
}
}
// ── editing actions ──
const startEdit = () => {
if (!data) return
setDraft(JSON.parse(JSON.stringify(data)))
setEditing(true)
}
const newDeck = async (fromCurrent = false) => {
setUploadMsg(null)
try {
const body: Record<string, unknown> = {}
if (fromCurrent) {
body.from = source
body.title = `${data?.title || 'Deck'} (copy)`
} else {
body.title = 'Untitled deck'
}
const r = await fetch('/api/presentation/decks/new', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const j = await r.json()
if (j.ok && j.deck) {
await refreshDeckList()
setCustomDecks((prev) => prev.some((d) => d.id === j.deck.id) ? prev : [{ id: j.deck.id, title: j.deck.title }, ...prev])
setSource(j.deck.id)
setData(j.deck)
setSlideIdx(0)
setDraft(JSON.parse(JSON.stringify(j.deck)))
setEditing(true)
} else {
setUploadMsg(j.error || 'Could not create deck')
}
} catch {
setUploadMsg('Could not create deck — check connection')
}
}
const saveDraft = async () => {
if (!draft) return
setSaving(true)
try {
const r = await fetch(`/api/presentation/decks/${source}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(draft),
})
const j = await r.json()
if (j.ok && j.deck) {
setData(j.deck)
setEditing(false)
setSlideIdx((i) => Math.min(i, (j.deck.slides?.length || 1) - 1))
await refreshDeckList()
setUploadMsg('✓ Saved')
} else {
setUploadMsg(j.error || 'Save failed')
}
} catch {
setUploadMsg('Save failed — check connection')
} finally {
setSaving(false)
}
}
const cancelEdit = () => {
setEditing(false)
setDraft(null)
}
const patchSlide = (idx: number, patch: Partial<PresentationSlide>) => {
setDraft((d) => {
if (!d) return d
const next = { ...d, slides: d.slides.map((s, i) => (i === idx ? { ...s, ...patch } : s)) }
return next
})
}
const addSlide = () => {
setDraft((d) => {
if (!d) return d
const n = d.slides.length + 1
const blank: PresentationSlide = { id: `slide-${Date.now()}`, title: `Slide ${n}`, subtitle: '', bullets: ['New point'], image: '', kind: 'narrative' }
const slidesNext = [...d.slides]
slidesNext.splice(slideIdx + 1, 0, blank)
return { ...d, slides: slidesNext, slide_count: slidesNext.length }
})
setSlideIdx((i) => i + 1)
}
const deleteSlide = () => {
setDraft((d) => {
if (!d || d.slides.length <= 1) return d
const slidesNext = d.slides.filter((_, i) => i !== slideIdx)
return { ...d, slides: slidesNext, slide_count: slidesNext.length }
})
setSlideIdx((i) => Math.max(0, i - 1))
}
const onPickImage = async (file: File) => {
if (!file || editIdx == null) return
setImgBusy(true)
const fd = new FormData()
fd.append('file', file)
try {
const r = await fetch(`/api/presentation/decks/${source}/image`, { method: 'POST', body: fd })
const j = await r.json()
if (j.ok && j.url) {
patchSlide(editIdx, { image: j.url })
} else {
setUploadMsg(j.error || 'Image upload failed')
}
} catch {
setUploadMsg('Image upload failed — check connection')
} finally {
setImgBusy(false)
}
}
const tabs: { id: DeckSource; label: string }[] = [
{ id: 'live', label: 'Live Cluster' },
{ id: 'stack-architecture', label: 'Stack Architecture' },
@@ -137,41 +284,70 @@ export function PresentationView() {
<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)
Live cluster · HTML templates · PPT upload · editable decks with text &amp; photos
</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>
{!editing && (
<>
<button type="button" onClick={() => newDeck(false)} className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">
<Plus className="h-3 w-3" /> New
</button>
{isCustom ? (
<button type="button" onClick={startEdit} className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[9px] text-docker hover:bg-surface-overlay">
<Pencil className="h-3 w-3" /> Edit
</button>
) : (
<button type="button" onClick={() => newDeck(true)} className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">
<Pencil className="h-3 w-3" /> Edit a copy
</button>
)}
<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>
</>
)}
{editing && (
<>
<span className="inline-flex items-center rounded bg-docker/15 px-2 py-0.5 text-[9px] font-medium text-docker">Editing</span>
<button type="button" onClick={cancelEdit} className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">
<X className="h-3 w-3" /> Cancel
</button>
<button type="button" onClick={saveDraft} disabled={saving} className={cn('inline-flex items-center gap-1 rounded-md px-2 py-1 text-[9px]', subTabActive, saving && 'opacity-50')}>
<Save className="h-3 w-3" /> {saving ? 'Saving…' : 'Save'}
</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>
{!editing && (
<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>}
@@ -181,26 +357,169 @@ export function PresentationView() {
<FileUp className="h-8 w-8 animate-pulse opacity-40" />
<p>Loading presentation{source === 'live' ? ' (live cluster snapshot, ~15 sec)' : '…'}</p>
</div>
) : editing && editSlide ? (
/* ─────────── EDIT MODE ─────────── */
<div className="flex min-h-0 flex-1 overflow-hidden">
<div className="scrollbar-thin flex-1 overflow-y-auto p-4 md:p-6">
<div className="mx-auto max-w-3xl space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-[10px] font-medium uppercase tracking-widest text-docker/80">Slide {editIdx! + 1} / {editSlides.length}</p>
<div className="flex gap-1">
<button type="button" onClick={addSlide} className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] hover:bg-surface-overlay">
<Plus className="h-3 w-3" /> Slide
</button>
<button type="button" onClick={deleteSlide} disabled={editSlides.length <= 1} className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] text-danger hover:bg-surface-overlay disabled:opacity-40">
<Trash2 className="h-3 w-3" /> Delete
</button>
</div>
</div>
<label className="block">
<span className="mb-1 block text-[9px] uppercase tracking-wider text-foreground-faint">Title</span>
<input
value={editSlide.title}
onChange={(e) => patchSlide(editIdx!, { title: e.target.value })}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-lg font-bold text-foreground outline-none focus:border-docker"
placeholder="Slide title"
/>
</label>
<label className="block">
<span className="mb-1 block text-[9px] uppercase tracking-wider text-foreground-faint">Subtitle</span>
<input
value={editSlide.subtitle || ''}
onChange={(e) => patchSlide(editIdx!, { subtitle: e.target.value })}
className="w-full rounded-md border border-border bg-surface px-3 py-1.5 text-sm text-foreground-muted outline-none focus:border-docker"
placeholder="Optional subtitle"
/>
</label>
<div>
<span className="mb-1 block text-[9px] uppercase tracking-wider text-foreground-faint">Bullet points</span>
<div className="space-y-1.5">
{(editSlide.bullets || []).map((b, bi) => (
<div key={bi} className="flex items-center gap-1.5">
<span className="shrink-0 text-docker"></span>
<input
value={b}
onChange={(e) => {
const next = [...(editSlide.bullets || [])]
next[bi] = e.target.value
patchSlide(editIdx!, { bullets: next })
}}
className="flex-1 rounded-md border border-border bg-surface px-2 py-1.5 text-sm text-foreground outline-none focus:border-docker"
placeholder="Bullet text"
/>
<button
type="button"
onClick={() => patchSlide(editIdx!, { bullets: (editSlide.bullets || []).filter((_, j) => j !== bi) })}
className="shrink-0 rounded border border-border p-1 text-foreground-muted hover:bg-surface-overlay hover:text-danger"
aria-label="Remove bullet"
>
<X className="h-3 w-3" />
</button>
</div>
))}
<button
type="button"
onClick={() => patchSlide(editIdx!, { bullets: [...(editSlide.bullets || []), ''] })}
className="inline-flex items-center gap-1 rounded border border-dashed border-border px-2 py-1 text-[10px] text-foreground-muted hover:bg-surface-overlay"
>
<Plus className="h-3 w-3" /> Add bullet
</button>
</div>
</div>
<div>
<span className="mb-1 block text-[9px] uppercase tracking-wider text-foreground-faint">Image</span>
{editSlide.image ? (
<div className="space-y-2">
<img src={editSlide.image} alt="" className="max-h-56 rounded-lg border border-border object-contain" />
<div className="flex gap-1">
<button type="button" onClick={() => imgInputRef.current?.click()} disabled={imgBusy} className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] hover:bg-surface-overlay disabled:opacity-50">
<ImagePlus className="h-3 w-3" /> Replace
</button>
<button type="button" onClick={() => patchSlide(editIdx!, { image: '' })} className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] text-danger hover:bg-surface-overlay">
<Trash2 className="h-3 w-3" /> Remove
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => imgInputRef.current?.click()}
disabled={imgBusy}
className="inline-flex items-center gap-1 rounded-md border border-dashed border-border px-3 py-2 text-[10px] text-foreground-muted hover:bg-surface-overlay disabled:opacity-50"
>
<ImagePlus className="h-3.5 w-3.5" /> {imgBusy ? 'Uploading…' : 'Add image'}
</button>
)}
<input
ref={imgInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => e.target.files?.[0] && onPickImage(e.target.files[0])}
/>
</div>
</div>
</div>
{/* slide rail */}
<div className="scrollbar-thin w-40 shrink-0 space-y-1 overflow-y-auto border-l border-border bg-surface-overlay/30 p-2">
{editSlides.map((s, i) => (
<button
key={s.id || i}
type="button"
onClick={() => setSlideIdx(i)}
className={cn(
'block w-full truncate rounded border px-2 py-1.5 text-left text-[10px] transition-colors',
i === slideIdx ? 'border-docker/50 bg-docker-light/60 text-docker dark:bg-blue-500/15' : 'border-border bg-surface hover:bg-surface-overlay',
)}
>
<span className="mr-1 font-mono text-foreground-faint">{i + 1}</span>
{s.title || 'Untitled'}
{s.image && <ImagePlus className="ml-1 inline h-2.5 w-2.5 text-foreground-faint" />}
</button>
))}
<button type="button" onClick={addSlide} className="flex w-full items-center justify-center gap-1 rounded border border-dashed border-border px-2 py-1.5 text-[10px] text-foreground-muted hover:bg-surface-overlay">
<Plus className="h-3 w-3" /> Add slide
</button>
</div>
</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>
) : (
/* ─────────── VIEW MODE ─────────── */
<>
<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)} />
<div className="flex max-w-5xl gap-6">
<div className="min-w-0 flex-1">
<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, bi: number) => (
<li key={bi} className="flex gap-2"><span className="shrink-0 text-docker"></span><span>{b}</span></li>
))}
</ul>
</div>
{slide.image && (
<div className="hidden shrink-0 items-center md:flex">
<img src={slide.image} alt="" className="max-h-[46vh] max-w-[40vw] rounded-lg border border-border object-contain shadow-lg" />
</div>
)}
<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>
{slide.image && (
<div className="mt-4 md:hidden">
<img src={slide.image} alt="" className="max-h-[30vh] rounded-lg border border-border object-contain" />
</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>
+240
View File
@@ -0,0 +1,240 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Loader2, Plug, RotateCcw, TerminalSquare, X } from 'lucide-react'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
import { INFRA_CATALOG } from '../../lib/infraCatalog'
type Props = {
open: boolean
onClose: () => void
}
type ConnState = 'form' | 'connecting' | 'connected' | 'closed'
const PRESETS = INFRA_CATALOG.map((n) => ({ label: `${n.label} · ${n.ip}`, ip: n.ip }))
export function SshTerminal({ open, onClose }: Props) {
const [host, setHost] = useState('10.0.21.33')
const [port, setPort] = useState('22')
const [username, setUsername] = useState('root')
const [password, setPassword] = useState('')
const [state, setState] = useState<ConnState>('form')
const [statusMsg, setStatusMsg] = useState<string | null>(null)
const [pos, setPos] = useState({ x: 0, y: 0 })
const termRef = useRef<HTMLDivElement | null>(null)
const term = useRef<Terminal | null>(null)
const fit = useRef<FitAddon | null>(null)
const ws = useRef<WebSocket | null>(null)
const dragging = useRef<{ dx: number; dy: number } | null>(null)
const teardown = useCallback(() => {
try { ws.current?.close() } catch { /* */ }
ws.current = null
try { term.current?.dispose() } catch { /* */ }
term.current = null
fit.current = null
}, [])
const connect = useCallback(() => {
setState('connecting')
setStatusMsg(`Connecting to ${username}@${host}:${port}`)
const t = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
theme: { background: '#0a0e14', foreground: '#d6deeb', cursor: '#7ee787' },
})
const fitAddon = new FitAddon()
t.loadAddon(fitAddon)
term.current = t
fit.current = fitAddon
// open after the DOM node is mounted
requestAnimationFrame(() => {
if (!termRef.current) return
t.open(termRef.current)
try { fitAddon.fit() } catch { /* */ }
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const socket = new WebSocket(`${proto}://${window.location.host}/api/ws/ssh`)
ws.current = socket
socket.onopen = () => {
socket.send(JSON.stringify({
type: 'connect',
host,
port: Number(port) || 22,
username,
password,
cols: t.cols,
rows: t.rows,
}))
}
socket.onmessage = (ev) => {
let msg: any
try { msg = JSON.parse(ev.data) } catch { return }
if (msg.type === 'data') {
t.write(msg.data)
} else if (msg.type === 'status') {
setStatusMsg(msg.message)
} else if (msg.type === 'connected') {
setState('connected')
setStatusMsg(null)
setPassword('')
t.focus()
} else if (msg.type === 'error') {
setState('closed')
setStatusMsg(msg.message)
t.writeln(`\r\n\x1b[31m${msg.message}\x1b[0m`)
} else if (msg.type === 'closed') {
setState('closed')
t.writeln('\r\n\x1b[33m*** Session closed ***\x1b[0m')
}
}
socket.onclose = () => {
setState((s) => (s === 'connected' ? 'closed' : s))
}
t.onData((d) => {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'data', data: d }))
})
})
}, [host, port, username, password])
const disconnect = useCallback(() => {
try { ws.current?.send(JSON.stringify({ type: 'disconnect' })) } catch { /* */ }
teardown()
setState('form')
setStatusMsg(null)
}, [teardown])
// resize handling
useEffect(() => {
if (state !== 'connected' && state !== 'closed') return
const onResize = () => {
try {
fit.current?.fit()
const t = term.current
if (t && ws.current?.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({ type: 'resize', cols: t.cols, rows: t.rows }))
}
} catch { /* */ }
}
const ro = new ResizeObserver(onResize)
if (termRef.current) ro.observe(termRef.current)
window.addEventListener('resize', onResize)
onResize()
return () => { ro.disconnect(); window.removeEventListener('resize', onResize) }
}, [state])
useEffect(() => {
if (!open) { teardown(); setState('form'); setStatusMsg(null); setPos({ x: 0, y: 0 }) }
}, [open, teardown])
useEffect(() => () => teardown(), [teardown])
const onDragMove = useCallback((e: PointerEvent) => {
if (!dragging.current) return
setPos({ x: e.clientX - dragging.current.dx, y: e.clientY - dragging.current.dy })
}, [])
const onDragUp = useCallback(() => {
dragging.current = null
window.removeEventListener('pointermove', onDragMove)
window.removeEventListener('pointerup', onDragUp)
}, [onDragMove])
const startDrag = useCallback((e: React.PointerEvent) => {
dragging.current = { dx: e.clientX - pos.x, dy: e.clientY - pos.y }
window.addEventListener('pointermove', onDragMove)
window.addEventListener('pointerup', onDragUp)
}, [pos, onDragMove, onDragUp])
if (!open) return null
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<div
className="flex h-[600px] max-h-[90vh] w-[900px] max-w-[95vw] flex-col overflow-hidden rounded-lg border border-border bg-surface shadow-2xl"
style={{ transform: `translate(${pos.x}px, ${pos.y}px)` }}
>
<header
className="flex shrink-0 cursor-move items-center justify-between border-b border-border bg-surface-overlay px-3 py-2"
onPointerDown={startDrag}
>
<span className="flex items-center gap-2 text-[12px] font-semibold text-foreground">
<TerminalSquare className="h-4 w-4 text-emerald-400" />
SSH Terminal
{(state === 'connected' || state === 'closed') && (
<span className="font-mono text-[10px] text-foreground-muted">{username}@{host}:{port}</span>
)}
{state === 'connected' && <span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] text-emerald-400">live</span>}
{state === 'closed' && <span className="rounded bg-amber-500/15 px-1.5 py-0.5 text-[9px] text-amber-400">closed</span>}
</span>
<div className="flex items-center gap-2">
{(state === 'connected' || state === 'closed') && (
<button type="button" onClick={disconnect} title="New connection" className="text-foreground-muted hover:text-foreground">
<RotateCcw className="h-3.5 w-3.5" />
</button>
)}
<button type="button" onClick={onClose} title="Close" className="text-foreground-muted hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
</header>
{state === 'form' ? (
<form
className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-5"
onSubmit={(e) => { e.preventDefault(); if (host && password) connect() }}
>
<p className="text-[11px] text-foreground-muted">Connect to any host reachable from the platform. Credentials are used for this session only and never stored.</p>
<div className="grid grid-cols-[1fr_110px] gap-2">
<label className="flex flex-col gap-1 text-[10px] uppercase tracking-wide text-foreground-faint">
Host
<input value={host} onChange={(e) => setHost(e.target.value)} className="rounded border border-border bg-background px-2 py-1.5 text-[12px] text-foreground" placeholder="10.0.21.33" />
</label>
<label className="flex flex-col gap-1 text-[10px] uppercase tracking-wide text-foreground-faint">
Port
<input value={port} onChange={(e) => setPort(e.target.value)} className="rounded border border-border bg-background px-2 py-1.5 text-[12px] text-foreground" />
</label>
</div>
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1 text-[10px] uppercase tracking-wide text-foreground-faint">
Username
<input value={username} onChange={(e) => setUsername(e.target.value)} className="rounded border border-border bg-background px-2 py-1.5 text-[12px] text-foreground" />
</label>
<label className="flex flex-col gap-1 text-[10px] uppercase tracking-wide text-foreground-faint">
Password
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoFocus className="rounded border border-border bg-background px-2 py-1.5 text-[12px] text-foreground" />
</label>
</div>
<div>
<p className="mb-1 text-[10px] uppercase tracking-wide text-foreground-faint">Lab presets</p>
<div className="flex flex-wrap gap-1.5">
{PRESETS.map((p) => (
<button key={p.ip} type="button" onClick={() => setHost(p.ip)} className="rounded border border-border px-2 py-1 text-[10px] text-foreground-muted hover:border-emerald-400/40 hover:text-emerald-400">
{p.label}
</button>
))}
</div>
</div>
<button type="submit" disabled={!host || !password} className="mt-1 inline-flex items-center justify-center gap-2 rounded-md bg-emerald-500/90 px-4 py-2 text-[12px] font-semibold text-black hover:bg-emerald-400 disabled:opacity-40">
<Plug className="h-4 w-4" /> Connect
</button>
</form>
) : (
<div className="relative flex min-h-0 flex-1 flex-col bg-[#0a0e14]">
{state === 'connecting' && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[#0a0e14]/80 text-[12px] text-foreground-muted">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {statusMsg || 'Connecting…'}
</div>
)}
<div ref={termRef} className="min-h-0 flex-1 p-2" />
</div>
)}
</div>
</div>
)
}
+26 -92
View File
@@ -1,28 +1,25 @@
import { DatabaseZap, HardDrive, Search, LayoutDashboard, MessageSquare, Presentation, ShieldCheck } from 'lucide-react'
import type { Agent, AgentAnim, GpuStatus } from '../../types'
import { DatabaseZap, HardDrive, Search, LayoutDashboard, MessageSquare, Presentation, Server, TerminalSquare } from 'lucide-react'
import type { GpuStatus, WorkloadData } from '../../types'
import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
import { getAgentMeta } from '../../lib/agentMeta'
import { cn } from '../../lib/utils'
import { viewTabActive, viewTabIdle } from '../../lib/tabActive'
import { GpuMatrixPanel } from '../features/GpuMatrixPanel'
import { LabHealthPanel } from '../features/LabHealthPanel'
type MainView = 'platform' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'search' | 'approvals'
type MainView = 'platform' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'hdfs' | 'search' | 'approvals'
type Props = {
agents: Agent[]
animations: Record<string, AgentAnim>
workload: WorkloadData | null
gpu: GpuStatus | null
gpuLive: GpuLiveMetrics
gpuBoost?: boolean
selectedAgentId: string | null
selectedNodeId: string | null
mainView: MainView
approvalCount: number
agentsLoading: boolean
onSetMainView: (view: MainView) => void
onOpenApprovals: () => void
onSelectAgent: (id: string) => void
onSelectZone: (id: string) => void
onOpenSsh: () => void
}
const VIEWS: { id: MainView; label: string; icon: typeof LayoutDashboard }[] = [
@@ -31,32 +28,28 @@ const VIEWS: { id: MainView; label: string; icon: typeof LayoutDashboard }[] = [
{ id: 'dataquality', label: 'Data Quality', icon: DatabaseZap },
{ id: 'knowledge', label: 'Knowledge Chat', icon: MessageSquare },
{ id: 'storage', label: 'Object Storage', icon: HardDrive },
{ id: 'hdfs', label: 'Hadoop HDFS', icon: Server },
{ id: 'search', label: 'Elasticsearch', icon: Search },
]
export function SideNav({
agents,
animations,
workload,
gpu,
gpuLive,
gpuBoost = false,
selectedAgentId,
selectedNodeId,
mainView,
approvalCount,
agentsLoading,
onSetMainView,
onOpenApprovals,
onSelectAgent,
onSelectZone,
onOpenSsh,
}: Props) {
const supervisors = agents.filter((a) => a.supervisor)
const operators = agents.filter((a) => !a.supervisor)
const matrixBoost = gpuBoost || mainView === 'knowledge'
return (
<nav className="flex w-[240px] shrink-0 flex-col border-r border-border bg-surface-raised">
<section className="border-b border-border p-3">
<section className="shrink-0 border-b border-border p-3">
<h2 className="mb-2 text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">Views</h2>
<div className="space-y-1">
{VIEWS.map(({ id, label, icon: Icon }) => (
@@ -73,6 +66,14 @@ export function SideNav({
<span className={cn('text-[11px] font-medium', mainView === id ? 'text-docker' : 'text-foreground')}>{label}</span>
</button>
))}
<button
type="button"
onClick={onOpenSsh}
className={cn('flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left transition-all', viewTabIdle)}
>
<TerminalSquare className="h-4 w-4 text-emerald-400" />
<span className="text-[11px] font-medium text-foreground">SSH Terminal</span>
</button>
</div>
</section>
@@ -83,81 +84,14 @@ export function SideNav({
onSelectGpu={() => onSelectZone('gpu')}
/>
<section className="flex min-h-0 flex-1 flex-col p-3">
<div className="mb-2 flex items-center justify-between gap-1">
<h2 className="text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">Agents</h2>
<button
type="button"
onClick={onOpenApprovals}
className={cn(
'flex items-center gap-1 rounded-md border px-2 py-0.5 text-[9px] font-medium transition-colors',
approvalCount > 0
? 'border-warning/40 bg-warning/10 text-warning'
: 'border-border text-foreground-muted hover:border-border-strong',
)}
>
<ShieldCheck className="h-3 w-3" />
Approvals
{approvalCount > 0 && <span className="font-mono">{approvalCount}</span>}
</button>
</div>
<div className="scrollbar-thin flex-1 space-y-1 overflow-y-auto">
{agentsLoading && agents.length === 0 && (
<p className="text-[9px] text-foreground-faint">Loading agents</p>
)}
{supervisors.length > 0 && (
<p className="text-[8px] uppercase tracking-widest text-foreground-faint">Supervisors</p>
)}
{supervisors.map((a) => (
<AgentRow key={a.id} agent={a} animations={animations} selected={selectedAgentId === a.id} onSelect={onSelectAgent} />
))}
{operators.length > 0 && (
<p className="mt-1 text-[8px] uppercase tracking-widest text-foreground-faint">Field operators</p>
)}
{operators.map((a) => (
<AgentRow key={a.id} agent={a} animations={animations} selected={selectedAgentId === a.id} onSelect={onSelectAgent} />
))}
</div>
</section>
<LabHealthPanel
workload={workload}
gpu={gpu}
selectedNodeId={selectedNodeId}
approvalCount={approvalCount}
onSelectZone={onSelectZone}
onOpenApprovals={onOpenApprovals}
/>
</nav>
)
}
function AgentRow({
agent,
animations,
selected,
onSelect,
}: {
agent: Agent
animations: Record<string, AgentAnim>
selected: boolean
onSelect: (id: string) => void
}) {
const meta = getAgentMeta(agent.id)
const Icon = meta.icon
const busy = (animations[agent.id]?.state || 'idle') !== 'idle'
return (
<button
type="button"
onClick={() => onSelect(agent.id)}
className={cn(
'flex w-full items-center gap-2 rounded-md border px-2 py-2 text-left transition-colors',
selected ? 'border-docker/40 bg-docker-light' : 'border-transparent hover:border-border hover:bg-surface-overlay',
)}
style={busy ? { boxShadow: `inset 3px 0 0 0 ${meta.accent}` } : undefined}
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-surface-overlay" style={{ color: meta.accent }}>
<Icon className="h-3.5 w-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[11px] font-medium text-foreground">{agent.name.split(' ·')[0]}</span>
<span className="block truncate font-mono text-[9px] text-foreground-faint">{meta.domain}</span>
<span className="block truncate text-[8px] text-foreground-faint">{agent.role}</span>
</span>
{(agent.stats?.tasks ?? 0) > 0 && (
<span className="rounded-full bg-docker px-1.5 font-mono text-[8px] text-foreground">{agent.stats?.tasks}</span>
)}
</button>
)
}
+1 -1
View File
@@ -68,7 +68,7 @@ export function useCommandCenter() {
const [selectedNode, setSelectedNode] = useState<TopologyNode | null>(null)
const [nodeDetail, setNodeDetail] = useState<NodeDetail | null>(null)
const [nodeBusy, setNodeBusy] = useState(false)
const [mainView, setMainView] = useState<'platform' | 'approvals' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'search'>('platform')
const [mainView, setMainView] = useState<'platform' | 'approvals' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'hdfs' | 'search'>('platform')
const [approvalHighlight, setApprovalHighlight] = useState(false)
const [workbenchMode, setWorkbenchMode] = useState<'agent' | 'sql-postgres' | 'sql-mysql' | 'sql-mongodb' | 'sql-trino' | null>(null)
const [chatExpanded, setChatExpanded] = useState(false)
+10 -5
View File
@@ -87,14 +87,14 @@
}
.topo-edge-idle {
stroke: rgba(100, 140, 180, 0.45);
stroke-width: 2;
stroke: rgba(100, 140, 180, 0.35);
stroke-width: 1.5;
stroke-dasharray: 4 8;
fill: none;
}
.topo-edge-live {
stroke-width: 2.5;
stroke-width: 2;
stroke-dasharray: 8 12;
fill: none;
animation: flow-dash 1.2s linear infinite;
@@ -129,8 +129,13 @@
box-shadow: 0 0 12px rgba(245, 158, 11, 0.25), var(--topo-node-shadow);
}
.topo-node-hadoop {
border-color: rgba(57, 255, 20, 0.45) !important;
box-shadow: 0 0 12px rgba(57, 255, 20, 0.2), var(--topo-node-shadow);
}
.topo-node {
@apply mx-auto w-[84%] rounded-md border px-1.5 py-1 text-left transition-all;
@apply mx-auto w-[72%] max-w-[104px] rounded border px-1.5 py-1 text-left transition-all;
background: var(--topo-node-bg);
border-color: var(--topo-node-border);
box-shadow: var(--topo-node-shadow);
@@ -146,7 +151,7 @@
}
.topo-stage-col {
@apply flex min-w-0 flex-1 flex-col px-0.5 py-1 last:border-r-0;
@apply flex min-w-[100px] flex-1 flex-col px-1 py-1.5 last:border-r-0;
border-right: 1px dashed var(--topo-stage-border);
}
+1
View File
@@ -223,6 +223,7 @@ export type PresentationSlide = {
subtitle?: string
bullets: string[]
kind?: string
image?: string
animation?: string
topology?: TopologyViewData
zone?: WorkloadZone