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