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>
This commit is contained in:
Mitchell-Johnson
2026-09-23 22:58:06 -07:00
co-authored by Claude Opus 5
parent e184b4a149
commit 1eb59fd08c
3 changed files with 17 additions and 195 deletions
+12 -21
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,16 +494,6 @@ 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.
- **Keep Processes or Top, not both** — asked for 2026-09-23, not started. They answer the
same question and read the same data: `Top.tsx` already fetches `/api/procs` for its
process panel, the same endpoint `Procs.tsx` is built on, and both draw the same CPU,
memory, load and network numbers. Top is the btop-style whole-machine screen; Processes
is the sortable table with filter, kill and signal. Decide which survives and fold the
other's worth into it — most likely Top stays and gains Processes' sorting, filtering and
signal actions, since one screen beats two. Whichever goes, its rail entry, its `View` id
and its hash route go with it, and any saved rail order naming it must degrade quietly
(`railorder.ts` already ignores ids it does not recognise).
- **Rename the Space tab** — asked for 2026-09-23, not started. "Space" is vague; the tab - **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 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. `Disks` already exists and `Storage` would muddle with it, so Usage is probably the one.
@@ -535,10 +532,10 @@ something long in one, switch away and back, refresh — it's all still there.
at all, which is every host here today. Worth scoping as its own plan before starting. 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 - **Deprecate the Dashboard tab** — asked for 2026-09-23, not started. Mitchell: it is nice
but the app repeats itself, and this goes together with choosing between Processes and but the app repeats itself — the same reasoning that merged Top into Processes the same
Top. Dashboard is the customisable module list (CPU, memory, load, uptime, temps, fans, day. Dashboard is the customisable module list (CPU, memory, load, uptime, temps, fans,
network, disks, filesystems, UPS) plus custom sensor widgets; Top shows most of the same network, disks, filesystems, UPS) plus custom sensor widgets; the Processes tab shows
numbers on one screen. **Widgets stays**, reframed: its page should say plainly that the 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) 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 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` screens. Care needed on the way out: the dashboard layout is per-user in `state.json`
@@ -547,13 +544,7 @@ something long in one, switch away and back, refresh — it's all still there.
source. `dashboard` is also the default view after login and after switching machines. 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. - **Files explorer: cleanup and ease of use** — asked for 2026-09-23, not started.
Specifically named: **the mouse's back button must go up a directory**. Why it does not General polish and ease of use. The **Deprecate copyparty**
today — `hashsel.ts` navigates with `history.replaceState`, which deliberately leaves no
history entry, so there is nothing for the browser (or the mouse's back button, which is
just `history.back()`) to return to. Fixing it properly means Files pushing a history
entry per folder and answering `popstate`, which `useHashSel` already listens for; doing
it in `hashsel.ts` would change every page that uses it, so decide whether history is
per-page opt-in. Beyond that the ask is general polish, and the **Deprecate copyparty**
item names the heavier pieces (multi-select and bulk actions, folder download, resumable 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. uploads, a gallery, search) — worth doing the two together, since they are the same tab.
-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>
) )