The Space tab is Usage; the Cron tab is Jobs

Both are named for what they do rather than the tool underneath, the way Services is.
Old links and saved rail orders still land in the right place. The Go packages and their
endpoints keep their names, with a pointer in each to what the UI calls them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-24 12:54:23 -07:00
co-authored by Claude Opus 5
parent dfa9e88f5b
commit a07a444170
6 changed files with 48 additions and 20 deletions
+3
View File
@@ -1,3 +1,6 @@
// Package cron reads and writes what the UI calls the **Jobs** tab (named "Cron" until
// 2026-09-24; the package and its /api/cron endpoints kept the old name).
//
// Package cron reads every scheduled job on the host into one list: /etc/crontab, // Package cron reads every scheduled job on the host into one list: /etc/crontab,
// /etc/cron.d/*, per-user crontabs, the run-parts directories they drive, and systemd // /etc/cron.d/*, per-user crontabs, the run-parts directories they drive, and systemd
// timers. It computes next runs from the cron expressions, pulls last runs from the // timers. It computes next runs from the cron expressions, pulls last runs from the
+3 -1
View File
@@ -1,4 +1,6 @@
// Package space measures what is using a disk: a parallel walk of the filesystems a disk // Package space measures what is using a disk — it is what the UI calls the **Usage** tab
// (renamed from "Space" on 2026-09-24; the package and its /api/space endpoints kept the
// old name rather than churn a working API): a parallel walk of the filesystems a disk
// backs, kept as a tree of directories and the files worth seeing, the way WizTree does it. // backs, kept as a tree of directories and the files worth seeing, the way WizTree does it.
// //
// The walk never crosses a filesystem boundary by itself — /proc, /sys, another disk's // The walk never crosses a filesystem boundary by itself — /proc, /sys, another disk's
+4 -4
View File
@@ -175,11 +175,11 @@ function TimerDetail({ t, onChanged, onBack }: { t: CronTimer; onChanged: () =>
} }
// ---- page ---- // ---- page ----
export default function Cron() { export default function Jobs() {
const [ov, setOv] = useState<CronOverview | null>(null) const [ov, setOv] = useState<CronOverview | null>(null)
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [hashSel, setHashSel] = useHashSel('cron') // "job:<id>" or "timer:<unit>", in the URL const [hashSel, setHashSel] = useHashSel('jobs') // "job:<id>" or "timer:<unit>", in the URL
const sel = /^(job|timer):./.test(hashSel) ? { kind: hashSel.split(':')[0] as 'job' | 'timer', id: hashSel.slice(hashSel.indexOf(':') + 1) } : null const sel = /^(job|timer):./.test(hashSel) ? { kind: hashSel.split(':')[0] as 'job' | 'timer', id: hashSel.slice(hashSel.indexOf(':') + 1) } : null
const setSel = (v: { kind: 'job' | 'timer'; id: string } | null) => setHashSel(v ? `${v.kind}:${v.id}` : '') const setSel = (v: { kind: 'job' | 'timer'; id: string } | null) => setHashSel(v ? `${v.kind}:${v.id}` : '')
const [editing, setEditing] = useState<string | null>(null) const [editing, setEditing] = useState<string | null>(null)
@@ -206,7 +206,7 @@ export default function Cron() {
else pane = <div className="svc-empty muted">Select a job or timer.</div> else pane = <div className="svc-empty muted">Select a job or timer.</div>
return ( return (
<div className={'cron' + (sel || editing ? ' has-sel' : '')}> <div className={'cron jobs' + (sel || editing ? ' has-sel' : '')}>
<div className="cron-list"> <div className="cron-list">
<div className="svc-tools"> <div className="svc-tools">
<div className="tools-row"><input placeholder="filter jobs and timers" value={q} onChange={(e) => setQ(e.target.value)} /><Btn className="btn" title="reload" onClick={load}>↻</Btn></div> <div className="tools-row"><input placeholder="filter jobs and timers" value={q} onChange={(e) => setQ(e.target.value)} /><Btn className="btn" title="reload" onClick={load}>↻</Btn></div>
@@ -218,7 +218,7 @@ export default function Cron() {
{ov && !ov.logging && <div className="notice" style={{ margin: 8 }}>cron isn’t logging to the journal on this host, so “last run” is unavailable for cron jobs.</div>} {ov && !ov.logging && <div className="notice" style={{ margin: 8 }}>cron isn’t logging to the journal on this host, so “last run” is unavailable for cron jobs.</div>}
<div className="cron-rows"> <div className="cron-rows">
{!ov && !err && <Loading label="Reading crontabs…" />} {!ov && !err && <Loading label="Reading crontabs…" />}
{ov && <h3>Cron jobs · {jobs.length}</h3>} {ov && <h3>Scheduled jobs · {jobs.length}</h3>}
{jobs.map((j) => ( {jobs.map((j) => (
<div key={j.id} className={'cron-row' + (sel?.kind === 'job' && sel.id === j.id ? ' active' : '') + (j.enabled ? '' : ' off')} onClick={() => { setEditing(null); setSel({ kind: 'job', id: j.id }) }}> <div key={j.id} className={'cron-row' + (sel?.kind === 'job' && sel.id === j.id ? ' active' : '') + (j.enabled ? '' : ' off')} onClick={() => { setEditing(null); setSel({ kind: 'job', id: j.id }) }}>
<span className={'sdot ' + (j.enabled ? 'ok' : '')} /> <span className={'sdot ' + (j.enabled ? 'ok' : '')} />
+16 -11
View File
@@ -8,14 +8,14 @@ import Gallery from './Gallery'
import Docker from './Docker' import Docker from './Docker'
import Alerts from './Alerts' import Alerts from './Alerts'
import Services from './Services' import Services from './Services'
import Cron from './Cron' import Jobs from './Jobs'
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 Top from './Top' import Top from './Top'
import Machines from './Machines' import Machines from './Machines'
import Settings from './Settings' import Settings from './Settings'
import Space from './Space' import Usage from './Usage'
import Users from './Users' import Users from './Users'
import MachineSwitcher from './MachineSwitcher' import MachineSwitcher from './MachineSwitcher'
import { MachineStatus, setMachine } from './api' import { MachineStatus, setMachine } from './api'
@@ -54,10 +54,10 @@ const GROUPS: Group[] = [
{ 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 },
{ id: 'cron', label: 'Cron', icon: ClockIcon }, { id: 'jobs', label: 'Jobs', icon: ClockIcon },
{ id: 'updates', label: 'Updates', icon: '⇪' }, { id: 'updates', label: 'Updates', icon: '⇪' },
{ id: 'disks', label: 'Disks', icon: '▤' }, { id: 'disks', label: 'Disks', icon: '▤' },
{ id: 'space', label: 'Space', icon: '▧' }, { id: 'usage', label: 'Usage', icon: '▧' },
{ id: 'files', label: 'Files', icon: FolderIcon }, { id: 'files', label: 'Files', icon: FolderIcon },
{ id: 'alerts', label: 'Alerts', icon: BellIcon }, { id: 'alerts', label: 'Alerts', icon: BellIcon },
{ id: 'machines', label: 'Machines', icon: '⌂' }, { id: 'machines', label: 'Machines', icon: '⌂' },
@@ -66,16 +66,21 @@ const GROUPS: Group[] = [
{ id: 'settings', label: 'Settings', icon: '⚙' }, { id: 'settings', label: 'Settings', icon: '⚙' },
] ]
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'cron' | 'updates' | 'disks' | 'space' | 'files' | 'procs' | 'machines' | 'users' | 'settings' type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'usage' | 'files' | 'procs' | 'machines' | 'users' | 'settings'
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'cron', 'updates', 'disks', 'space', 'files', 'procs', 'machines', 'users', 'settings'] const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'usage', '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
// forward work, reload restores the tab, and links can point at a page on a machine. // 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 { 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 const m = location.hash.match(/^#\/([a-z0-9-]+)\/([a-z]+)(?:\/.*)?$/) // anything after the page is that page's own selection
if (!m || !VIEWS.includes(m[2] as View)) return null if (!m) return null
return { machine: m[1] === 'hub' ? null : m[1], view: m[2] as View } 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 { function fmtUptime(s: number): string {
@@ -262,14 +267,14 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
<main className="main" key={machine ?? 'hub'}> <main className="main" key={machine ?? 'hub'}>
{view === 'settings' && <Settings me={hubMe} />} {view === 'settings' && <Settings me={hubMe} />}
{view === 'users' && <Users me={hubMe} />} {view === 'users' && <Users me={hubMe} />}
{view === 'space' && <Space key={machine ?? ''} />} {view === 'usage' && <Usage key={machine ?? ''} />}
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />} {view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />}
{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 === 'procs' && <Top />} {view === 'procs' && <Top />}
{view === 'services' && <Services />} {view === 'services' && <Services />}
{view === 'cron' && <Cron />} {view === 'jobs' && <Jobs />}
{view === 'updates' && <Updates />} {view === 'updates' && <Updates />}
{view === 'disks' && <Disks />} {view === 'disks' && <Disks />}
{view === 'files' && <Files onOpenInTerminal={runInTerminal} />} {view === 'files' && <Files onOpenInTerminal={runInTerminal} />}
@@ -298,7 +303,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 === '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 === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
</footer> </footer>
</div> </div>
) )
+4 -3
View File
@@ -1,4 +1,5 @@
// Space: what is using a disk, WizTree-style. Pick a disk, scan it (a parallel walk of every // Usage: what is using a disk, WizTree-style. (The tab was called Space until 2026-09-24;
// the Go package and the /api/space endpoints behind it still carry the old name.) Pick a disk, scan it (a parallel walk of every
// filesystem the disk backs, as root when the host allows), then read the result two ways: // filesystem the disk backs, as root when the host allows), then read the result two ways:
// a tree of folders largest-first, and a treemap of whatever folder is selected, where every // a tree of folders largest-first, and a treemap of whatever folder is selected, where every
// file is a tile sized by what it takes on disk and coloured by what kind of thing it is. // file is a tile sized by what it takes on disk and coloured by what kind of thing it is.
@@ -45,10 +46,10 @@ const ago = (iso?: string) => {
const fmtDur = (a: string, b?: string) => { const ms = (b ? new Date(b).getTime() : Date.now()) - new Date(a).getTime(); return ms < 1000 ? `${ms} ms` : ms < 60000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.floor(ms / 60000)} min ${Math.round((ms % 60000) / 1000)} s` } const fmtDur = (a: string, b?: string) => { const ms = (b ? new Date(b).getTime() : Date.now()) - new Date(a).getTime(); return ms < 1000 ? `${ms} ms` : ms < 60000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.floor(ms / 60000)} min ${Math.round((ms % 60000) / 1000)} s` }
const parentOf = (p: string) => (p === '/' ? '/' : p.slice(0, p.lastIndexOf('/')) || '/') const parentOf = (p: string) => (p === '/' ? '/' : p.slice(0, p.lastIndexOf('/')) || '/')
export default function Space() { export default function Usage() {
const [targets, setTargets] = useState<SpaceTarget[] | null>(null) const [targets, setTargets] = useState<SpaceTarget[] | null>(null)
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [sel, setSel] = useHashSel('space') const [sel, setSel] = useHashSel('usage')
const [tab, setTab] = useState<'tree' | 'files'>('tree') const [tab, setTab] = useState<'tree' | 'files'>('tree')
const rate = useRefreshMs() const rate = useRefreshMs()
+18 -1
View File
@@ -5,8 +5,25 @@ import { useEffect, useState } from 'react'
const KEY = 'hamlab.rail' const KEY = 'hamlab.rail'
// Pages that were renamed, so an order saved before it still puts the page where it was put:
// Top became Processes, Space became Usage, Cron became Jobs. A page that was removed
// outright (Dashboard) is not here — orderBy drops ids that match no page.
const RENAMED: Record<string, string> = { top: 'procs', space: 'usage', cron: 'jobs' }
export function getRailOrder(): string[] { export function getRailOrder(): string[] {
try { const v = JSON.parse(localStorage.getItem(KEY) ?? '[]'); return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : [] } catch { return [] } let raw: unknown
try { raw = JSON.parse(localStorage.getItem(KEY) ?? '[]') } catch { return [] }
if (!Array.isArray(raw)) return []
const seen = new Set<string>()
const out: string[] = []
for (const x of raw) {
if (typeof x !== 'string') continue
const id = RENAMED[x] ?? x
if (seen.has(id)) continue // a merged page keeps the earlier of the two slots
seen.add(id)
out.push(id)
}
return out
} }
export function setRailOrder(ids: string[], save = true) { export function setRailOrder(ids: string[], save = true) {