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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user