Author SHA1 Message Date
Mitchell-JohnsonandClaude Opus 5 1eb59fd08c One tab called Processes, showing what Top showed
The two read the same data and answered the same question. Procs.tsx is gone; a rail
order saved before this keeps the merged page in Top's slot. The mouse back-button item
is dropped from the plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 22:58:06 -07:00
Mitchell-JohnsonandClaude Opus 5 e184b4a149 PLAN: seven more to do — tab overlap, two renames, ZFS scheduling, virtualization, Files polish
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 21:38:19 -07:00
3 changed files with 66 additions and 174 deletions
+61
View File
@@ -252,6 +252,13 @@ something long in one, switch away and back, refresh — it's all still there.
use", and "Remove unused" takes every image no container uses. Nothing is ever forced: use", and "Remove unused" takes every image no container uses. Nothing is ever forced:
Docker refuses what is still in use, and that refusal is the answer. Still absent: Docker refuses what is still in use, and that refusal is the answer. Still absent:
removing networks. removing networks.
- **Top merged into Processes** — **2026-09-23.** The two tabs answered the same question
from the same data (Top already read `/api/procs`, the endpoint the Processes table was
built on), so at Mitchell's instruction there is now one tab, **Processes**, showing what
Top showed: the btop-style whole-machine screen. `Procs.tsx` is deleted; the `top` view
id and its route are gone. A rail order saved before this keeps its shape — `railorder.ts`
maps the retired `top` to `procs` and drops the duplicate, so the merged page sits where
Top sat rather than falling to the bottom.
- **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark, - **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark,
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light; type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
@@ -487,6 +494,60 @@ something long in one, switch away and back, refresh — it's all still there.
- *Not app work, but the trigger:* nothing here blocks starting the migration except the - *Not app work, but the trigger:* nothing here blocks starting the migration except the
inventory. Do that first. inventory. Do that first.
- **Rename the Space tab** — asked for 2026-09-23, not started. "Space" is vague; the tab
is WizTree for a disk or pool. Candidates: **Usage**, **Disk Usage**, **Storage**. Note
`Disks` already exists and `Storage` would muddle with it, so Usage is probably the one.
The rename touches the rail label, the `View` id, the hash route (`#/<machine>/space`,
which people may have bookmarked — decide whether to keep `space` as the id and change
only the label), the footer's page name and `Space.tsx`'s own strings.
- **ZFS gets its own tab, under Disks** — asked for 2026-09-23, not started. Today ZFS is a
section inside `Disks.tsx`: pools with health, capacity, fragmentation, scrub controls, a
dataset tree and per-dataset snapshot lists with "Snapshot now". Moving it out makes room
for what is actually wanted: **scheduled snapshots with a retention policy**, configured
from the console rather than by hand. What exists to build on — `grid` runs `zfSnap`
nightly at 02:30 from root cron with 6-week expiry (`zfsnap-cron-6w-daily.sh`, predates
this repo), and Ham Lab's Cron tab can already read, write and run root crontabs, so the
job can be managed rather than reinvented. Decide: does Ham Lab drive `zfSnap`/`zfs-auto-
snapshot` if present, or write and own its own timer? Per-dataset policies or one for the
pool? And it must show what a retention rule *will* delete before it runs.
- **Rename Cron to something generic** — asked for 2026-09-23, not started. Mitchell's
reasoning: the Services tab is systemd but "Services" is the right generic name for it,
so the scheduling tab should be named for the job it does, not the tool. **Scheduled** or
**Jobs**. The tab already covers more than cron — `/etc/crontab`, `/etc/cron.d`, user
crontabs, `run-parts` directories *and* systemd timers — so the generic name is the
honest one. Same rename surface as the Space tab: rail label, `View` id, hash route,
footer name.
- **A Virtualization tab** — asked for 2026-09-23, not started, and **large**. Run and
manage **LXC containers and virtual machines** from the console, the way Proxmox does —
which is the comparison Ham Lab has invited since its first line. Nothing exists yet.
What a future session has to settle before writing code: which stack (libvirt/KVM +
`virsh` is the obvious one for VMs; LXC or Incus for containers, and Incus covers both);
whether it manages what is already installed or installs it; where disk images live
(`tank` on `grid`, local NVMe elsewhere); console access (SPICE/VNC proxied through the
daemon, or a serial console over the existing terminal machinery, which would reuse
`internal/term`); snapshots and clones; and what it does on a host with no virtualisation
at all, which is every host here today. Worth scoping as its own plan before starting.
- **Deprecate the Dashboard tab** — asked for 2026-09-23, not started. Mitchell: it is nice
but the app repeats itself — the same reasoning that merged Top into Processes the same
day. Dashboard is the customisable module list (CPU, memory, load, uptime, temps, fans,
network, disks, filesystems, UPS) plus custom sensor widgets; the Processes tab shows
most of the same numbers on one screen already. **Widgets stays**, reframed: its page should say plainly that the
widgets there are for **external dashboards** (Glance, Home Assistant, a wall display)
via `/embed/widget` and read-only sensor tokens, not a second copy of this console's own
screens. Care needed on the way out: the dashboard layout is per-user in `state.json`
(`users.<name>.dashboard`) and carries `intervalMs`, which the server reads in
`applyPrefs` to set the sampler — that wiring has to move or the sampling rate loses its
source. `dashboard` is also the default view after login and after switching machines.
- **Files explorer: cleanup and ease of use** — asked for 2026-09-23, not started.
General polish and ease of use. The **Deprecate copyparty**
item names the heavier pieces (multi-select and bulk actions, folder download, resumable
uploads, a gallery, search) — worth doing the two together, since they are the same tab.
## Open questions ## Open questions
- Idle-session reaping policy (never / after N days) — leaning never; it's a - Idle-session reaping policy (never / after N days) — leaning never; it's a
-166
View File
@@ -1,166 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react'
import { api, Proc, ProcTable } from './api'
import { fmtBytes, fmtRate } from './Dashboard'
import { Btn, Loading, askYes, toast } from './ui'
import { useRefreshMs } from './prefs'
type Col = 'cpu' | 'rss' | 'pid' | 'name' | 'user' | 'started' | 'cpu_time'
const pct = (v: number) => `${Math.round(v)}%`
const fmtUptime = (s: number) => { 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` }
const fmtDur = (s: number) => (s >= 3600 ? `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}` : s >= 60 ? `${Math.floor(s / 60)}m${String(Math.floor(s % 60)).padStart(2, '0')}` : `${s.toFixed(1)}s`)
const fmtStart = (ms: number) => { const d = new Date(ms), now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString(undefined, { month: 'short', day: '2-digit' }) }
const STATE: Record<string, string> = { R: 'running', S: 'sleeping', D: 'disk wait', Z: 'zombie', T: 'stopped', t: 'traced', I: 'idle' }
const BLOCKS = '▁▂▃▄▅▆▇█'
// btop-style gradient bar: the track carries the green→yellow→red gradient and a cover
// from the right hides the unfilled part, so colour depends on position not on level.
function GBar({ frac }: { frac: number }) {
return <div className="gbar"><div className="gcover" style={{ width: `${100 - Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
}
function Row({ label, frac, value }: { label: string; frac: number; value: string }) {
return <div className="brow"><span className="bl">{label}</span><GBar frac={frac} /><span className="bv">{value}</span></div>
}
// Block-character graph, coloured per column by its value, like btop's cpu graph.
function Graph({ values, max = 100 }: { values: number[]; max?: number }) {
const cols = 60
const v = values.slice(-cols)
const pad = cols - v.length
return (
<div className="graph" aria-hidden="true">
{pad > 0 && <span className="gpad">{'\u2581'.repeat(pad)}</span>}
{v.map((x, i) => { const f = Math.min(1, Math.max(0, x / max)); return <span key={i} className={f >= 0.8 ? 'g3' : f >= 0.5 ? 'g2' : 'g1'}>{BLOCKS[Math.min(7, Math.round(f * 7))]}</span> })}
</div>
)
}
function Box({ title, right, children, className = '' }: { title: string; right?: string; children: React.ReactNode; className?: string }) {
return <section className={'box ' + className}><span className="box-title">{title}</span>{right && <span className="box-right">{right}</span>}{children}</section>
}
export default function Procs() {
const [t, setT] = useState<ProcTable | null>(null)
const [err, setErr] = useState('')
const [sort, setSort] = useState<Col>('cpu')
const [asc, setAsc] = useState(false)
const [q, setQ] = useState('')
const [mine, setMine] = useState(false)
const [sel, setSel] = useState<number | null>(null)
const [paused, setPaused] = useState(false)
const [showTop, setShowTop] = useState<boolean>(() => localStorage.getItem('hamlab.procs.top') !== '0')
const interval = useRefreshMs()
const load = useCallback(async () => {
try { setT(await api.procs()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}, [])
useEffect(() => {
load()
const id = window.setInterval(() => { if (!paused && document.visibilityState === 'visible') load() }, interval)
return () => window.clearInterval(id)
}, [load, paused, interval])
const head = (c: Col, label: string, cls = '') => (
<button className={'th ' + cls + (sort === c ? ' on' : '')} title={{ cpu: 'share of one core, right now', rss: 'resident memory (RSS)', cpu_time: 'total CPU time used since it started', started: 'when the process started', pid: 'process id', name: 'program name', user: 'owner' }[c]} onClick={() => { if (sort === c) setAsc(!asc); else { setSort(c); setAsc(c === 'name' || c === 'user' || c === 'pid') } }}>
{label}{sort === c ? (asc ? ' ▲' : ' ▼') : ''}
</button>
)
if (!t) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Reading processes…" />}</div>
const needle = q.trim().toLowerCase()
const rows = t.procs
.filter((p) => (!needle || p.name.toLowerCase().includes(needle) || (p.cmd ?? '').toLowerCase().includes(needle) || p.user.toLowerCase().includes(needle) || String(p.pid) === needle) && (!mine || p.user !== 'root'))
.sort((a, b) => {
const va = a[sort] as number | string, vb = b[sort] as number | string
const r = typeof va === 'string' ? (va as string).localeCompare(vb as string) : (va as number) - (vb as number)
return asc ? r : -r
})
const selected = sel !== null ? t.procs.find((p) => p.pid === sel) : undefined
const memFrac = t.mem.total ? t.mem.used / t.mem.total : 0
const swapFrac = t.swap.total ? t.swap.used / t.swap.total : 0
const signal = async (p: Proc, sig: 'TERM' | 'KILL') => {
if (!await askYes(`Send SIG${sig} to ${p.name} (pid ${p.pid})?`)) return
try { await api.procSignal(p.pid, sig); toast(`SIG${sig} sent to ${p.name}`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
return (
<div className="procs-page">
{showTop && <div className="procs-top">
<Box title="cpu" right={`${t.cpu_model ?? ''}${t.cpu_mhz ? ` · ${(t.cpu_mhz / 1000).toFixed(2)}${t.cpu_max_mhz ? ` / ${(t.cpu_max_mhz / 1000).toFixed(1)}` : ''} GHz` : ''}${t.cpu_temp ? ` · ${t.cpu_temp.toFixed(0)}°C` : ''}`}>
<div className="cpu-total">
<Graph values={t.cpu_hist} />
<div className="cpu-total-v"><span className="bl">CPU</span><span className="bv big">{pct(t.cpu)}</span><span className="muted small">total, all cores</span></div>
</div>
<div className="cores">
{t.cores.map((c, i) => <Row key={i} label={`C${i}`} frac={c / 100} value={pct(c)} />)}
</div>
<div className="box-foot muted">
<span>load avg <b>{t.load[0].toFixed(2)}</b> {t.load[1].toFixed(2)} {t.load[2].toFixed(2)} <small>(1 · 5 · 15 min, {t.cores.length} cores)</small></span>
<span>up <b>{fmtUptime(t.uptime)}</b></span>
<span><b>{t.total}</b> tasks, <b>{t.running}</b> running</span>
</div>
</Box>
<Box title="mem" right={`${fmtBytes(t.mem.total)} total`}>
<Row label="Used" frac={memFrac} value={`${fmtBytes(t.mem.used)} · ${pct(memFrac * 100)}`} />
<Row label="Available" frac={t.mem.total ? t.mem.avail / t.mem.total : 0} value={fmtBytes(t.mem.avail)} />
<Row label="Cached" frac={t.mem.total ? (t.mem.cached ?? 0) / t.mem.total : 0} value={fmtBytes(t.mem.cached ?? 0)} />
<Row label="Free" frac={t.mem.total ? (t.mem.free ?? 0) / t.mem.total : 0} value={fmtBytes(t.mem.free ?? 0)} />
{t.swap.total > 0 ? (
<><div className="box-sub">swap · {fmtBytes(t.swap.total)} total</div>
<Row label="Used" frac={swapFrac} value={`${fmtBytes(t.swap.used)} · ${pct(swapFrac * 100)}`} />
<Row label="Free" frac={t.swap.total ? t.swap.avail / t.swap.total : 0} value={fmtBytes(t.swap.avail)} /></>
) : <div className="box-sub muted">no swap</div>}
<div className="box-foot muted small">Used = total − available. Cached is file cache the kernel gives back under pressure, so "available" is the honest headroom.</div>
</Box>
<Box title="net" className="netbox">
{t.net.filter((n) => n.up).map((n) => (
<div className="netrow" key={n.iface}>
<span className="bl mono">{n.iface}</span>
<span className="bv"><span className="dn">▼ {fmtRate(n.rx_bps)}</span> <span className="upc">▲ {fmtRate(n.tx_bps)}</span></span>
<span className="muted small">{fmtBytes(n.rx_total)} in · {fmtBytes(n.tx_total)} out</span>
</div>
))}
{t.net.filter((n) => n.up).length === 0 && <div className="muted">no interfaces up</div>}
</Box>
</div>}
<div className="procs-sticky">
<div className="procs-tools">
<button className={'chip' + (showTop ? ' on' : '')} title="show or hide the cpu/mem/net boxes" onClick={() => { setShowTop(!showTop); localStorage.setItem('hamlab.procs.top', showTop ? '0' : '1') }}>{showTop ? 'metrics ▴' : 'metrics ▾'}</button>
<input placeholder="filter name, command, user, pid" value={q} onChange={(e) => setQ(e.target.value)} />
<button className={'chip' + (mine ? ' on' : '')} onClick={() => setMine(!mine)}>hide root</button>
<button className={'chip' + (paused ? ' on' : '')} onClick={() => setPaused(!paused)}>{paused ? 'paused' : 'live'}</button>
<span className="muted small">{rows.length} shown</span>
</div>
<div className="prow phead">
{head('pid', 'PID', 'num')}{head('user', 'User')}{head('name', 'Program')}{head('cpu', 'CPU %', 'num')}{head('rss', 'Memory', 'num')}{head('cpu_time', 'CPU time', 'num')}{head('started', 'Started', 'num')}
</div>
</div>
<div className="ptable">
{rows.length === 0 && <div className="muted" style={{ padding: 12 }}>Nothing matches.</div>}
{rows.map((p) => (
<div key={p.pid} className={'prow' + (p.pid === sel ? ' active' : '') + (p.state === 'Z' ? ' zombie' : p.state === 'R' ? ' running' : '')} onClick={() => setSel(p.pid === sel ? null : p.pid)} title={p.cmd || p.name}>
<span className="num muted">{p.pid}</span>
<span className="ell">{p.user}</span>
<span className="ell mono">{p.name}{p.threads > 1 && <small className="muted"> ×{p.threads}</small>}</span>
<span className={'num' + (p.cpu >= 50 ? ' hot' : '')}>{p.cpu.toFixed(1)}</span>
<span className="num">{fmtBytes(p.rss)}</span>
<span className="num muted">{fmtDur(p.cpu_time)}</span>
<span className="num muted">{fmtStart(p.started)}</span>
</div>
))}
</div>
{selected && (
<div className="pdetail">
<div className="sub-head"><b className="mono">{selected.name}</b><span className="muted">pid {selected.pid} · parent {selected.ppid} · {selected.user} · {STATE[selected.state] ?? selected.state} · nice {selected.nice} · {selected.threads} threads</span><span className="spacer" /><button className="icon-btn" onClick={() => setSel(null)}>✕</button></div>
<div className="mono cmdline">{selected.cmd || '(kernel thread)'}</div>
<div className="svc-actions">
<Btn className="btn" onClick={() => signal(selected, 'TERM')}>Terminate</Btn>
<Btn className="btn danger" onClick={() => signal(selected, 'KILL')}>Kill −9</Btn>
<span className="muted">cpu {selected.cpu.toFixed(1)}% · {fmtBytes(selected.rss)} · running {fmtDur(selected.cpu_time)} of cpu since {new Date(selected.started).toLocaleString()}</span>
</div>
</div>
)}
</div>
)
}
+5 -8
View File
@@ -13,7 +13,6 @@ import Cron from './Cron'
import Updates from './Updates' import Updates from './Updates'
import Disks from './Disks' import Disks from './Disks'
import Files from './Files' import Files from './Files'
import Procs from './Procs'
import Top from './Top' import Top from './Top'
import Machines from './Machines' import Machines from './Machines'
import Settings from './Settings' import Settings from './Settings'
@@ -53,8 +52,7 @@ const BellIcon = (
const GROUPS: Group[] = [ const GROUPS: Group[] = [
{ id: 'dashboard', label: 'Dashboard', icon: '◔' }, { id: 'dashboard', label: 'Dashboard', icon: '◔' },
{ id: 'top', label: 'Top', icon: '▚' }, { id: 'procs', label: 'Processes', icon: '▚' },
{ id: 'procs', label: 'Processes', icon: '≡' },
{ id: 'terminal', label: 'Terminal', icon: '>_' }, { id: 'terminal', label: 'Terminal', icon: '>_' },
{ id: 'services', label: 'Services', icon: '◈' }, { id: 'services', label: 'Services', icon: '◈' },
{ id: 'docker', label: 'Docker', icon: DockerIcon }, { id: 'docker', label: 'Docker', icon: DockerIcon },
@@ -70,8 +68,8 @@ const GROUPS: Group[] = [
{ id: 'settings', label: 'Settings', icon: '⚙' }, { id: 'settings', label: 'Settings', icon: '⚙' },
] ]
type View = 'terminal' | 'dashboard' | 'top' | 'widgets' | 'docker' | 'alerts' | 'services' | 'cron' | 'updates' | 'disks' | 'space' | 'files' | 'procs' | 'machines' | 'users' | 'settings' type View = 'terminal' | 'dashboard' | 'widgets' | 'docker' | 'alerts' | 'services' | 'cron' | 'updates' | 'disks' | 'space' | 'files' | 'procs' | 'machines' | 'users' | 'settings'
const VIEWS: View[] = ['terminal', 'dashboard', 'top', 'widgets', 'docker', 'alerts', 'services', 'cron', 'updates', 'disks', 'space', 'files', 'procs', 'machines', 'users', 'settings'] const VIEWS: View[] = ['terminal', 'dashboard', 'widgets', 'docker', 'alerts', 'services', 'cron', 'updates', 'disks', 'space', 'files', 'procs', 'machines', 'users', 'settings']
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts'] const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and // URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
@@ -272,8 +270,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
{view === 'widgets' && <Gallery />} {view === 'widgets' && <Gallery />}
{view === 'docker' && <Docker />} {view === 'docker' && <Docker />}
{view === 'alerts' && <Alerts machines={machines.map((m) => m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />} {view === 'alerts' && <Alerts machines={machines.map((m) => m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />}
{view === 'top' && <Top />} {view === 'procs' && <Top />}
{view === 'procs' && <Procs />}
{view === 'services' && <Services />} {view === 'services' && <Services />}
{view === 'cron' && <Cron />} {view === 'cron' && <Cron />}
{view === 'updates' && <Updates />} {view === 'updates' && <Updates />}
@@ -304,7 +301,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
<span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span> <span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
<span>up {fmtUptime(me.uptime)}</span> <span>up {fmtUptime(me.uptime)}</span>
<span className="spacer" /> <span className="spacer" />
<span className="muted">{view === 'top' ? 'Top' : view === 'dashboard' ? 'Dashboard' : view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'space' ? 'Space' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span> <span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'space' ? 'Space' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
</footer> </footer>
</div> </div>
) )