The tab reports one of four states for the selected machine (ready, repair, installable, unavailable) from what it finds — /dev/kvm, the package manager's incus candidate, the pool drivers the kernel has — and acts on that machine only. Install is one followed task: packages, a pool whose backing file sits in Ham Lab's own virt/ folder (zfs pool on a file, btrfs on a loop mount, or a plain dir), a bridge on a subnet picked clear of the machine's routes and Docker's pools (never 10.x on a /8 LAN), the DOCKER-USER rules that keep Docker's FORWARD drop off the bridge, the incus-admin group for Ham Lab's account, and a restart. Instances list with live CPU and memory, create from the images: catalogue or a local image (container or VM, limits, cloud-init), start/stop/restart/force-stop/rename/ delete, console and shell as terminal tabs, snapshots with Incus's own schedule and expiry, limits editing. Images: local store, catalogue search and download (exported into the library too), import a tarball or a VM disk from a path, publish an instance. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
377 lines
21 KiB
TypeScript
377 lines
21 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { api, Me, Term, titleOf } from './api'
|
|
import Rail, { Group } from './Rail'
|
|
import TerminalPane, { TermHandle } from './TerminalPane'
|
|
import TermTabs from './TermTabs'
|
|
import KeyBar from './KeyBar'
|
|
import Gallery from './Gallery'
|
|
import Docker from './Docker'
|
|
import Alerts from './Alerts'
|
|
import Services from './Services'
|
|
import Jobs from './Jobs'
|
|
import Updates from './Updates'
|
|
import Disks from './Disks'
|
|
import Hardware from './Hardware'
|
|
import Network from './Network'
|
|
import VPN from './VPN'
|
|
import ZFS from './ZFS'
|
|
import Virtualization from './Virtualization'
|
|
import KVM from './KVM'
|
|
import Files from './Files'
|
|
import Top from './Top'
|
|
import Machines from './Machines'
|
|
import Settings from './Settings'
|
|
import Usage from './Usage'
|
|
import Users from './Users'
|
|
import MachineSwitcher from './MachineSwitcher'
|
|
import { MachineStatus, setMachine } from './api'
|
|
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
|
import Power from './Power'
|
|
import { applyAccent } from './accents'
|
|
import { Btn, PageBoundary, Toasts, askText, askYes, hasUnsaved, okToLeave, toast, useEscape } from './ui'
|
|
import { fmtLeft, useTimeLeft } from './guest'
|
|
|
|
// Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour).
|
|
const FolderIcon = (
|
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" aria-hidden="true">
|
|
<path d="M1.5 4a1 1 0 0 1 1-1h3.2l1.6 1.6h6.2a1 1 0 0 1 1 1V12a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1V4z" />
|
|
</svg>
|
|
)
|
|
|
|
// ⏱ is an emoji on phones (full colour, its own size); an outline clock matches the rest.
|
|
const ClockIcon = (
|
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<circle cx="8" cy="8" r="6.2" /><path d="M8 4.6V8l2.3 1.5" />
|
|
</svg>
|
|
)
|
|
|
|
const DockerIcon = (
|
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" aria-hidden="true">
|
|
<path d="M2 5.2 8 2l6 3.2v5.6L8 14l-6-3.2z" /><path d="M2 5.2 8 8.4l6-3.2M8 8.4V14" />
|
|
</svg>
|
|
)
|
|
const BellIcon = (
|
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<path d="M4 11V7.2a4 4 0 0 1 8 0V11l1.2 1.6H2.8z" /><path d="M6.6 14.2h2.8" />
|
|
</svg>
|
|
)
|
|
|
|
const GlobeIcon = (
|
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" aria-hidden="true">
|
|
<circle cx="8" cy="8" r="6.2" /><path d="M1.9 8h12.2M8 1.8c1.7 1.8 2.6 3.9 2.6 6.2S9.7 12.4 8 14.2C6.3 12.4 5.4 10.3 5.4 8S6.3 3.6 8 1.8z" />
|
|
</svg>
|
|
)
|
|
|
|
// A padlock-ish shield: the tab is about who may come in, and a keyhole reads at 14px where
|
|
// a padlock's shackle does not.
|
|
const ShieldIcon = (
|
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" aria-hidden="true">
|
|
<path d="M8 1.6 13.2 3.5v4.2c0 3-2.1 5.4-5.2 6.7-3.1-1.3-5.2-3.7-5.2-6.7V3.5z" /><circle cx="8" cy="7.2" r="1.3" /><path d="M8 8.5v2" />
|
|
</svg>
|
|
)
|
|
|
|
const GROUPS: Group[] = [
|
|
{ id: 'procs', label: 'Processes', icon: '▚' },
|
|
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
|
{ id: 'services', label: 'Services', icon: '◈' },
|
|
{ id: 'jobs', label: 'Jobs', icon: ClockIcon },
|
|
{ id: 'disks', label: 'Disks', icon: '▤' },
|
|
{ id: 'files', label: 'Files', icon: FolderIcon },
|
|
{ id: 'network', label: 'Network', icon: GlobeIcon },
|
|
{ id: 'vpn', label: 'VPN', icon: ShieldIcon },
|
|
{ id: 'usage', label: 'Usage', icon: '▧' },
|
|
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
|
|
{ id: 'docker', label: 'Docker', icon: DockerIcon },
|
|
{ id: 'virt', label: 'Virtualization', icon: '▣' },
|
|
{ id: 'kvm', label: 'KVM', icon: '⌨' },
|
|
{ id: 'hardware', label: 'Hardware', icon: '⬡' },
|
|
{ id: 'updates', label: 'Updates', icon: '⇪' },
|
|
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
|
{ id: 'machines', label: 'Machines', icon: '⌂' },
|
|
{ id: 'widgets', label: 'Widgets', icon: '▦' },
|
|
{ id: 'users', label: 'Users', icon: '⚇' },
|
|
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
|
]
|
|
|
|
type View = 'terminal' | 'widgets' | 'docker' | 'virt' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'vpn' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings'
|
|
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'virt', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'vpn', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
|
|
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
|
|
|
|
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
|
// forward work, reload restores the tab, and links can point at a page on a machine.
|
|
// Pages that were renamed, so a link or a bookmark made before it still lands where it meant.
|
|
const RENAMED_VIEWS: Record<string, View> = { space: 'usage', cron: 'jobs', top: 'procs', dashboard: 'procs' }
|
|
|
|
function parseHash(): { machine: string | null; view: View } | null {
|
|
const m = location.hash.match(/^#\/([a-z0-9-]+)\/([a-z]+)(?:\/.*)?$/) // anything after the page is that page's own selection
|
|
if (!m) return null
|
|
const view = VIEWS.includes(m[2] as View) ? (m[2] as View) : RENAMED_VIEWS[m[2]]
|
|
if (!view) return null
|
|
return { machine: m[1] === 'hub' ? null : m[1], view }
|
|
}
|
|
|
|
function fmtUptime(s: number): string {
|
|
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
|
return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
|
|
}
|
|
|
|
export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: () => void }) {
|
|
// Which machine we're looking at. null = the hub itself. Set before anything renders so
|
|
// every page's first request already goes to the right place.
|
|
// The URL wins when it has an opinion; the last machine is only for a bare address. "hub" is
|
|
// an opinion — it is null here — so ?? would have read straight past it to the remembered
|
|
// machine, and a link to a page of the hub reloaded onto whichever machine was last open.
|
|
const [machine, setMachineState] = useState<string | null>(() => { const h = parseHash(); const m = h ? h.machine : sessionStorage.getItem('hamlab.machine'); setMachine(m); return m })
|
|
const [machines, setMachines] = useState<MachineStatus[]>([])
|
|
const [remoteMe, setRemoteMe] = useState<Me | null>(null)
|
|
const [remoteErr, setRemoteErr] = useState('')
|
|
// A guest login is an account with a clock on it: nothing is hidden from it, but the header
|
|
// says how long it has, for whoever is holding the phone.
|
|
const leftMs = useTimeLeft(hubMe.guest_until)
|
|
// A machine is titled by its own hostname; the name it was added under stays its identity
|
|
// (the URL, its accent, what the hub's API calls it). See titleOf.
|
|
const title = titleOf(machine, machines, hubMe.host)
|
|
const me: Me = machine ? (remoteMe ?? { ...hubMe, host: title, load: '', version: '…', uptime: 0, reboot_needed: false }) : hubMe
|
|
const listRate = useRefreshMs(LIST_FLOOR)
|
|
const [terms, setTerms] = useState<Term[]>([])
|
|
const [active, setActive] = useState<string | null>(null)
|
|
const [view, setView] = useState<View>(() => parseHash()?.view ?? 'procs')
|
|
const [drawer, setDrawer] = useState(false)
|
|
useEscape(() => setDrawer(false), drawer) // the phone's drawer closes like every other overlay
|
|
const [online, setOnline] = useState(true)
|
|
const [creating, setCreating] = useState(false)
|
|
const handles = useRef<Record<string, TermHandle>>({})
|
|
|
|
const reload = useCallback(async () => {
|
|
try {
|
|
const list = await api.terminals()
|
|
setTerms(list)
|
|
setActive((a) => (a && list.some((t) => t.id === a) ? a : list[0]?.id ?? null))
|
|
} catch (e) { setTerms([]); if (machine) setRemoteErr(e instanceof Error ? e.message : 'unreachable') }
|
|
}, [machine])
|
|
|
|
useEffect(() => { reload() }, [reload])
|
|
|
|
// One page sending you to another — the Network tab handing a tunnel over to the VPN tab.
|
|
// A custom event rather than a hash assignment, because assigning location.hash does not
|
|
// reliably raise popstate, which is what this shell navigates by.
|
|
useEffect(() => {
|
|
const h = (e: Event) => {
|
|
const id = (e as CustomEvent<string>).detail
|
|
if (VIEWS.includes(id as View)) { setView(id as View); setDrawer(false) }
|
|
}
|
|
window.addEventListener('hamlab:view', h)
|
|
return () => window.removeEventListener('hamlab:view', h)
|
|
}, [])
|
|
|
|
// A shell opened inside a container from the Docker tab is a terminal like any other:
|
|
// pick it up, show the Terminal tab, and make it the active one.
|
|
useEffect(() => {
|
|
const h = async (e: Event) => {
|
|
const id = (e as CustomEvent<string>).detail
|
|
if (!id) return
|
|
await reload()
|
|
setActive(id)
|
|
setView('terminal')
|
|
}
|
|
window.addEventListener('hamlab:terminal', h)
|
|
return () => window.removeEventListener('hamlab:terminal', h)
|
|
}, [reload])
|
|
|
|
// Machine list for the switcher (hub-local), refreshed while the console is open.
|
|
const loadMachines = useCallback(async () => { try { setMachines((await api.machines()).nodes) } catch { /* not a hub or offline */ } }, [])
|
|
useEffect(() => { loadMachines(); const t = setInterval(loadMachines, listRate); return () => clearInterval(t) }, [loadMachines, listRate])
|
|
|
|
// Switching machines: reset everything that belongs to a host, then re-fetch its identity.
|
|
const switchMachine = async (name: string | null) => {
|
|
if (name !== machine && !(await okToLeave())) return
|
|
setMachine(name); sessionStorage.setItem('hamlab.machine', name ?? ''); if (!name) sessionStorage.removeItem('hamlab.machine')
|
|
handles.current = {}; setTerms([]); setActive(null); setRemoteMe(null); setRemoteErr(''); setDrawer(false); setOnline(true)
|
|
setMachineState(name)
|
|
}
|
|
|
|
// keep the hash in step, and follow it when the browser navigates
|
|
const [firing, setFiring] = useState(0)
|
|
useEffect(() => {
|
|
const tick = () => { if (document.visibilityState === 'visible') api.alerts().then((a) => setFiring(a.firing.length)).catch(() => {}) }
|
|
tick(); const t = setInterval(tick, 15000); window.addEventListener('hamlab:alerts', tick)
|
|
return () => { clearInterval(t); window.removeEventListener('hamlab:alerts', tick) }
|
|
}, [])
|
|
// The machine's colour: set on <html>, so the header stripe and the switcher pick it up.
|
|
// Hub-only pages make no sense on a remote machine. Judged once both have settled, not while
|
|
// the machine is changing: the URL sets the machine and the page in one go, and deciding from
|
|
// inside the machine change read the page we were leaving — so a link from Settings to a
|
|
// machine's Disks arrived at its Dashboard.
|
|
useEffect(() => { if (machine && HUB_ONLY.includes(view)) setView('procs') }, [machine, view])
|
|
|
|
useEffect(() => { const paint = () => applyAccent(machine ?? hubMe.host); paint(); window.addEventListener('hamlab:accents', paint); return () => window.removeEventListener('hamlab:accents', paint) }, [machine, hubMe.host])
|
|
// The tab's title says where you are, so history entries and open tabs can be told apart.
|
|
useEffect(() => { document.title = `${GROUPS.find((g) => g.id === view)?.label ?? view} · ${title} — Ham Lab` }, [view, title])
|
|
useEffect(() => {
|
|
const want = `#/${machine ?? 'hub'}/${view}`
|
|
if (location.hash !== want && !location.hash.startsWith(want + '/')) history.pushState(null, '', want)
|
|
}, [machine, view])
|
|
useEffect(() => {
|
|
const h = async () => {
|
|
const p = parseHash(); if (!p) return
|
|
if (hasUnsaved() && (p.machine !== machine || p.view !== view)) { // Back/Forward away from unsaved work: stay put until answered
|
|
const there = location.hash
|
|
history.pushState(null, '', `#/${machine ?? 'hub'}/${view}`)
|
|
if (!(await okToLeave())) return
|
|
history.replaceState(null, '', there)
|
|
}
|
|
if (p.machine !== machine) switchMachine(p.machine); if (p.view !== view) setView(p.view)
|
|
}
|
|
window.addEventListener('popstate', h)
|
|
return () => window.removeEventListener('popstate', h)
|
|
}) // no deps: needs the latest switchMachine/view each time
|
|
useEffect(() => {
|
|
if (!machine) { setRemoteMe(null); return }
|
|
let stop = false
|
|
const tick = async () => { try { const m = await api.me(); if (!stop) { setRemoteMe(m); setRemoteErr('') } } catch (e) { if (!stop) setRemoteErr(e instanceof Error ? e.message : 'unreachable') } }
|
|
tick()
|
|
const t = setInterval(tick, listRate)
|
|
return () => { stop = true; clearInterval(t) }
|
|
}, [machine, listRate])
|
|
|
|
async function create() {
|
|
setCreating(true)
|
|
try {
|
|
const t = await api.createTerminal()
|
|
await reload()
|
|
setActive(t.id)
|
|
setView('terminal')
|
|
setDrawer(false)
|
|
} catch (e) { toast(`new terminal — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
|
finally { setCreating(false) }
|
|
}
|
|
async function rename(t: Term) {
|
|
const name = await askText('Rename terminal', t.name)
|
|
if (name && name.trim() && name !== t.name) {
|
|
try { await api.renameTerminal(t.id, name.trim()); await reload() } catch (e) { toast(`rename — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
|
}
|
|
}
|
|
async function close(t: Term) {
|
|
if (t.running && !await askYes(`Close "${t.name}"? The shell will be hung up.`)) return
|
|
try { await api.deleteTerminal(t.id); delete handles.current[t.id]; await reload() }
|
|
catch (e) { toast(`close — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
|
}
|
|
async function logout() { await api.logout(); onLoggedOut() }
|
|
|
|
// Files → "open in terminal": type into the active shell (creating one if needed) and switch to it.
|
|
async function runInTerminal(cmd: string) {
|
|
let id = active
|
|
if (!id || !terms.some((t) => t.id === id && t.running)) {
|
|
try { const t = await api.createTerminal(); await reload(); id = t.id; setActive(t.id) } catch (e) { toast(`terminal — ${e instanceof Error ? e.message : 'failed'}`, 'err'); return }
|
|
}
|
|
setView('terminal'); setDrawer(false)
|
|
const send = (tries: number) => { const h = handles.current[id!]; if (h) h.input(cmd + '\n'); else if (tries > 0) window.setTimeout(() => send(tries - 1), 100) }
|
|
window.setTimeout(() => send(20), 50)
|
|
}
|
|
|
|
// Tab order is the user's, kept per machine in this browser; new terminals join at the end.
|
|
const orderKey = `hamlab.termorder.${machine ?? 'hub'}`
|
|
const [order, setOrder] = useState<string[]>([])
|
|
useEffect(() => { try { setOrder(JSON.parse(localStorage.getItem(orderKey) ?? '[]')) } catch { setOrder([]) } }, [orderKey])
|
|
const ordered = useMemo(() => [...terms].sort((a, b) => { const i = order.indexOf(a.id), j = order.indexOf(b.id); return (i < 0 ? 1e9 : i) - (j < 0 ? 1e9 : j) }), [terms, order])
|
|
const moveTab = (id: string, before: string | null) => {
|
|
const ids = ordered.map((t) => t.id).filter((x) => x !== id)
|
|
const at = before ? ids.indexOf(before) : -1
|
|
ids.splice(at < 0 ? ids.length : at, 0, id)
|
|
setOrder(ids); localStorage.setItem(orderKey, JSON.stringify(ids))
|
|
}
|
|
// Alt+Shift+←/→ switches tabs, Alt+Shift+T opens one. Caught before xterm sees the keys.
|
|
useEffect(() => {
|
|
if (view !== 'terminal') return
|
|
const h = (e: KeyboardEvent) => {
|
|
if (!e.altKey || !e.shiftKey || e.ctrlKey || e.metaKey) return
|
|
const dir = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0
|
|
if (dir && ordered.length > 1) { const i = ordered.findIndex((t) => t.id === active); setActive(ordered[(i + dir + ordered.length) % ordered.length].id) }
|
|
else if (e.key.toLowerCase() === 't') create()
|
|
else return
|
|
e.preventDefault(); e.stopPropagation()
|
|
}
|
|
window.addEventListener('keydown', h, true)
|
|
return () => window.removeEventListener('keydown', h, true)
|
|
}) // no deps: needs the latest tabs each time
|
|
|
|
const activeRef = useRef(active); activeRef.current = active
|
|
const activeTerm = terms.find((t) => t.id === active)
|
|
const termView = view === 'terminal'
|
|
|
|
return (
|
|
<div className={'app' + (drawer ? ' drawer-open' : '')}>
|
|
<Toasts />
|
|
<header className="header">
|
|
<button className="icon-btn menu" onClick={() => setDrawer(!drawer)} aria-label="menu">☰</button>
|
|
<span className="brand">Ham Lab</span>
|
|
<span className="host">{me.user}@{me.host}</span>
|
|
{machine && remoteErr && <span className="badge bad" title={remoteErr}>offline</span>}
|
|
<span className="spacer" />
|
|
<span className="stat" title="load average, 1 · 5 · 15 min">load {me.load}</span>
|
|
<span className={'dot ' + (online && !remoteErr ? 'ok' : 'bad')} title={remoteErr ? `${title} unreachable` : online ? 'connected' : 'disconnected'} />
|
|
<Power host={me.host} label={title} rebootNeeded={me.reboot_needed} />
|
|
{hubMe.guest && <span className="badge guest" title={`this guest login expires ${new Date(hubMe.guest_until ?? '').toLocaleTimeString()}`}>guest · {fmtLeft(leftMs)}</span>}
|
|
{hubMe.account && <span className="host account" title="signed in as">{hubMe.account}</span>}
|
|
<Btn className="link" onClick={logout}>logout</Btn>
|
|
</header>
|
|
|
|
<div className="body">
|
|
<Rail groups={machine ? GROUPS.filter((g) => !HUB_ONLY.includes(g.id as View)) : GROUPS} view={view} badges={{ alerts: firing, settings: hubMe.update_available ? 1 : 0 }}
|
|
header={<MachineSwitcher hub={hubMe.host} hubAddr={hubMe.addr} current={machine} machines={machines} onChange={switchMachine} onManage={() => { setView('machines'); setDrawer(false) }} />}
|
|
onView={async (id) => { if (id !== view && !(await okToLeave())) return; setView(id as View); setDrawer(false) }} />
|
|
<div className="backdrop" onClick={() => setDrawer(false)} />
|
|
|
|
<main className="main" key={machine ?? 'hub'}>
|
|
<PageBoundary page={GROUPS.find((g) => g.id === view)?.label ?? view}>
|
|
{view === 'settings' && <Settings me={hubMe} />}
|
|
{view === 'users' && <Users me={hubMe} />}
|
|
{view === 'kvm' && <KVM key={machine ?? 'hub'} machine={machine} title={title} />}
|
|
{view === 'usage' && <Usage key={machine ?? ''} />}
|
|
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />}
|
|
{view === 'widgets' && <Gallery />}
|
|
{view === 'docker' && <Docker />}
|
|
{view === 'virt' && <Virtualization />}
|
|
{view === 'alerts' && <Alerts machines={machines.map((m) => m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />}
|
|
{view === 'procs' && <Top />}
|
|
{view === 'services' && <Services />}
|
|
{view === 'jobs' && <Jobs />}
|
|
{view === 'updates' && <Updates />}
|
|
{view === 'disks' && <Disks />}
|
|
{view === 'hardware' && <Hardware />}
|
|
{view === 'network' && <Network />}
|
|
{view === 'vpn' && <VPN />}
|
|
{view === 'zfs' && <ZFS />}
|
|
{view === 'files' && <Files onOpenInTerminal={runInTerminal} />}
|
|
</PageBoundary>
|
|
{termView && <TermTabs terms={ordered} active={active} creating={creating}
|
|
onSelect={setActive} onNew={create} onRename={rename} onClose={close} onMove={moveTab}
|
|
onFocusTerm={() => window.setTimeout(() => { const id = activeRef.current; if (id) handles.current[id]?.armCtrl(false) }, 0)} />}
|
|
{termView && terms.length === 0 && (
|
|
<div className="center muted">
|
|
<div>No terminals.</div>
|
|
<Btn className="primary" busy={creating} onClick={create}>+ New Terminal</Btn>
|
|
</div>
|
|
)}
|
|
{terms.map((t) => (
|
|
<TerminalPane key={t.id} id={t.id} active={termView && t.id === active}
|
|
register={(h) => { if (h) handles.current[t.id] = h; else delete handles.current[t.id] }}
|
|
onStatus={(ok) => { if (t.id === active) setOnline(ok) }}
|
|
onExit={reload} />
|
|
))}
|
|
</main>
|
|
</div>
|
|
|
|
{termView && activeTerm && <KeyBar send={(s) => handles.current[activeTerm.id]?.input(s)}
|
|
setCtrl={(v) => handles.current[activeTerm.id]?.armCtrl(v)} />}
|
|
|
|
<footer className="footer">
|
|
<span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
|
<span>up {fmtUptime(me.uptime)}</span>
|
|
<span className="spacer" />
|
|
<span className="muted">{view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'virt' ? 'Virtualization' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'hardware' ? 'Hardware' : view === 'network' ? 'Network' : view === 'vpn' ? 'VPN' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
|
|
</footer>
|
|
</div>
|
|
)
|
|
}
|