Bug bash 6: the phone fits ZFS, Usage and journal lines; a snapshot's default name is local time; twelve smaller faults

Found by a headless pass over every page on every machine at desktop and phone widths, then
every interaction on the hub. BUGS.md has the list, what was checked and found fine, and the
quality-of-life notes to discuss.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-10-03 11:28:44 -07:00
co-authored by Claude Fable 5.1
parent 3a58e265c6
commit e0e2562f0a
10 changed files with 160 additions and 14 deletions
+3 -3
View File
@@ -147,13 +147,13 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
})
// ---- actions ----
const act = async (label: string, fn: () => Promise<unknown>) => {
try { await fn(); toast(label); await load() } catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
const act = async (label: string, fn: () => Promise<unknown>): Promise<boolean> => {
try { await fn(); toast(label); await load(); return true } catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err'); return false }
}
// A name is one path segment. ("../x" and "a/b" would quietly act somewhere else.)
const okName = (n?: string | null): n is string => { const t = n?.trim(); if (!t) return false; if (t.includes('/') || t === '.' || t === '..') { toast('a name can\'t contain / or be . or .. — use Move… to put it elsewhere', 'err'); return false } return true }
const mkdir = async () => { const n = await askText('New folder name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileMkdir(join(path, n.trim()), sudo)) }
const touch = async () => { const n = await askText('New file name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileTouch(join(path, n.trim()), sudo)).then(() => setOpen(join(path, n.trim()))) }
const touch = async () => { const n = await askText('New file name'); if (okName(n) && await act(`created ${n.trim()}`, () => api.fileTouch(join(path, n.trim()), sudo))) setOpen(join(path, n.trim())) } // opened only once it exists
const rename = async (e: FileEntry) => { const n = await askText('Rename to', e.name); if (okName(n) && n.trim() !== e.name) act(`renamed to ${n.trim()}`, () => api.fileRename(join(path, e.name), join(path, n.trim()), sudo)) }
const move = async (e: FileEntry) => { const n = await askText('Move to (absolute path)', join(path, e.name)); if (n?.trim() && n.trim() !== join(path, e.name)) act(`moved to ${n.trim()}`, () => api.fileRename(join(path, e.name), n.trim(), sudo)) }
const chmod = async (e: FileEntry) => { const n = await askText(`Permissions for ${e.name} (octal)`, e.perm); if (n?.trim() && n.trim() !== e.perm) act(`chmod ${n.trim()} ${e.name}`, () => api.fileChmod(join(path, e.name), n.trim(), sudo)) }
+1 -1
View File
@@ -172,7 +172,7 @@ export default function Hardware() {
<div className="tile">
<div className="label">Processor</div>
<div className="value" title={cpu.model} style={(cpu.model?.length ?? 0) > 20 ? { fontSize: 16 } : undefined}>{cpu.model}</div>
<div className="sub">{cores ? `${cores} core${cores === 1 ? '' : 's'}` : ''}{threads && threads !== cores ? ` · ${threads} threads` : ''}{cpu.max_mhz ? ` · up to ${ghz(cpu.max_mhz)}` : ''}</div>
<div className="sub">{[cores ? `${cores} core${cores === 1 ? '' : 's'}` : '', threads && threads !== cores ? `${threads} threads` : '', cpu.max_mhz ? `up to ${ghz(cpu.max_mhz)}` : ''].filter(Boolean).join(' · ')}</div>
</div>
<div className="tile">
<div className="label">Memory</div>
+4 -1
View File
@@ -54,7 +54,10 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
try {
let res = await api.updateAllMachines()
const busy = res.filter((r) => !r.ok && (r.error ?? '').startsWith('busy:'))
if (busy.length && await askYes(`${busy.map((r) => r.name).join(', ')}: ${busy[0].error?.slice(6)}\n\nForce those too?`)) res = res.map((r) => r).concat(await api.updateAllMachines(true))
if (busy.length && await askYes(`${busy.map((r) => r.name).join(', ')}: ${busy[0].error?.slice(6)}\n\nForce those too?`)) {
const forced = await api.updateAllMachines(true)
res = res.filter((r) => !forced.some((f) => f.name === r.name)).concat(forced) // one line per machine, the forced answer winning
}
for (const r of res) toast(r.ok ? `${r.name} → ${r.version}` : `${r.name}: ${r.error}`, r.ok ? 'ok' : 'err')
setTimeout(load, 4000)
} catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
+12 -3
View File
@@ -16,9 +16,16 @@ function fmtCPU(ns: number): string {
const s = ns / 1e9
return s >= 3600 ? `${(s / 3600).toFixed(1)} h` : s >= 60 ? `${(s / 60).toFixed(1)} min` : `${s.toFixed(1)} s`
}
// systemd writes "Fri 2026-10-02 23:48:25 PDT", which Chromium parses and Firefox does not; read
// the fields directly, in the browser's own zone (the machine's, on a LAN), and fall back to Date.parse.
function parseSystemdTime(ts: string): number {
const m = /(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/.exec(ts)
if (m) return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]).getTime()
return Date.parse(ts.replace(/^[A-Za-z]{3} /, ''))
}
function since(ts?: string): string {
if (!ts) return ''
const t = Date.parse(ts.replace(/^[A-Za-z]{3} /, ''))
const t = parseSystemdTime(ts)
if (isNaN(t)) return ts
const s = Math.max(0, (Date.now() - t) / 1000)
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
@@ -217,7 +224,9 @@ export default function Services() {
))}
</div>
</div>
{err && <div className="error">{err}</div>}
{err && units.length === 0 && loaded
? <div className="notice" style={{ margin: 8 }}>{err}</div> // a host with no systemd, or one that is unreachable: not a filter result
: err && <div className="error">{err}</div>}
<div className="svc-rows">
{!loaded && <Loading label="Listing services…" />}
{shown.map((u) => (
@@ -230,7 +239,7 @@ export default function Services() {
<span className="svc-state">{u.failed ? 'failed' : u.sub}{u.enabled === 'enabled' ? '' : u.enabled ? ` · ${u.enabled}` : ''}</span>
</div>
))}
{loaded && shown.length === 0 && <div className="muted" style={{ padding: 12 }}>Nothing matches.</div>}
{loaded && shown.length === 0 && !(err && units.length === 0) && <div className="muted" style={{ padding: 12 }}>Nothing matches.</div>}
</div>
</div>
{sel ? <Detail name={sel} onChanged={load} onBack={() => setSel(null)} /> : <div className="svc-empty muted">Select a service.</div>}
+1 -1
View File
@@ -198,7 +198,7 @@ function Updates({ me }: { me: Me }) {
{st.notes && <div className="mono small" style={{ whiteSpace: 'pre-wrap', marginTop: 6, maxHeight: 140, overflow: 'auto' }}>{st.notes.trim()}</div>}
</div>
)}
{st && !st.behind && st.latest && <div className="muted small" style={{ marginTop: 8 }}>This is the newest published build.</div>}
{st && !st.behind && st.latest && <div className="muted small" style={{ marginTop: 8 }}>{st.current.replace(/^v/, '') === st.latest.replace(/^v/, '') ? 'This is the newest published build.' : `This build is newer than the last published release (${st.latest}).`}</div>}
{(err || st?.error) && <div className="error" style={{ marginTop: 8 }}>{err || st?.error}</div>}
<div className="svc-actions" style={{ marginTop: 10 }}>
<Btn className="btn" busy={busy === 'check'} disabled={!st?.enabled || !!busy} onClick={check}>Check for updates</Btn>
+2 -1
View File
@@ -27,7 +27,7 @@ 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 } from './ui'
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).
@@ -135,6 +135,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
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>>({})
+6 -2
View File
@@ -52,7 +52,11 @@ export default function Updates() {
setOpen(p.name)
if (!changelog[p.name]) {
try { const r = await api.changelog(p.name); setChangelog((c) => ({ ...c, [p.name]: r.text || '(empty)' })) }
catch (e) { setChangelog((c) => ({ ...c, [p.name]: e instanceof Error ? e.message : 'unavailable' })) }
catch (e) {
const m = e instanceof Error ? e.message : 'unavailable'
// apt's own words for a third-party repository that publishes none are a wall of "E: Failed to fetch"
setChangelog((c) => ({ ...c, [p.name]: /changelog unavailable|failed to fetch/i.test(m) ? 'No changelog is published for this package — its repository does not provide one.' : m }))
}
}
}
const toggle = (n: string) => setSel((s) => { const x = new Set(s); if (x.has(n)) x.delete(n); else x.add(n); return x })
@@ -99,7 +103,7 @@ export default function Updates() {
<span className="n">{p.name}{p.security && <span className="badge bad">security</span>}</span>
<span className="d">{p.description}</span>
</span>
<span className="pkg-ver"><span className="muted">{p.current}</span> → {p.candidate}<span className="muted"> · {p.suite}</span></span>
<span className="pkg-ver"><span className="muted">{p.current}</span> → {p.candidate}{p.suite && <span className="muted"> · {p.suite}</span>}</span>
</div>
{open === p.name && <pre className="run-out changelog">{changelog[p.name] ?? <span className="muted"><Spinner /> fetching changelog…</span>}</pre>}
</div>
+5 -2
View File
@@ -97,7 +97,10 @@ function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChange
if (!open && !snaps) await reload()
}
async function snapshot() {
const def = new Date().toISOString().slice(0, 19).replace('T', '_').replace(/:/g, '.')
// zfSnap's name is the machine's local time, and snapMeta reads it back as local time — so the
// default is built from local fields, not toISOString(), which is UTC and read seven hours wrong.
const n = new Date(), two = (x: number) => String(x).padStart(2, '0')
const def = `${n.getFullYear()}-${two(n.getMonth() + 1)}-${two(n.getDate())}_${two(n.getHours())}.${two(n.getMinutes())}.${two(n.getSeconds())}`
const name = await askText(`Snapshot name for ${d.name}@`, def)
if (!name) return
try { await api.zfsSnapshot(d.name, name.trim()); toast(`${d.name}@${name.trim()} created`); await reload(); onChanged() }
@@ -299,7 +302,7 @@ function adopt(x: ExternalJob, fallbackDataset: string): Partial<ZFSPolicy> {
const ds = /\b(tank[\w/-]*)\b/.exec(cmd)?.[1] ?? fallbackDataset
return {
...BLANK, name: `${x.tool} (adopted)`, dataset: ds,
recursive: /\s-\w*r/.test(cmd) || true,
recursive: /\s-[a-zA-Z]*r\b/.test(cmd), // -r, or -r folded into another flag group
every: simple ? 'daily' : 'custom',
at: simple ? `${String(hour).padStart(2, '0')}:${String(min).padStart(2, '0')}` : '02:30',
cron: simple ? '' : x.schedule.trim(),
+6
View File
@@ -27,6 +27,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
.login button.pweye:hover, .login button.pweye[aria-pressed="true"] { color: var(--fg); }
.login button.pweye:disabled { opacity: .4; }
.error { color: var(--bad); font-size: 13px; }
.svc-list > .error, .cron-list > .error { margin: 8px 12px; }
/* layout */
.app { height: 100%; display: flex; flex-direction: column; padding-top: env(safe-area-inset-top); }
@@ -204,6 +205,8 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
.log-lines { flex: 1; overflow: auto; padding: 6px 8px; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
.log { display: flex; gap: 8px; white-space: pre-wrap; word-break: break-word; }
.log .lt { color: var(--muted); flex-shrink: 0; } .log .li { color: var(--accent2); flex-shrink: 0; }
/* a phone has no room for three columns: the stamp and the unit go on one line, the message underneath */
@media (max-width: 560px) { .log { flex-wrap: wrap; gap: 0 8px; padding: 2px 0; border-bottom: 1px solid var(--border); } .log .lm { flex-basis: 100%; } .log .lt { font-size: 11px; } }
.log.err .lm { color: var(--bad); } .log.warn .lm { color: var(--s2); } .log.debug .lm { color: var(--muted); }
@media (max-width: 800px) {
.svc-list { width: 100%; border-right: 0; } .has-sel .svc-list { display: none; } .svc-empty { display: none; }
@@ -315,6 +318,7 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
.ds-row { display: grid; grid-template-columns: minmax(160px, 1fr) 80px 90px 90px 90px minmax(120px, 1fr); gap: 8px; align-items: center; padding: 6px 12px; border-bottom: 1px solid var(--border); font-size: 13px; min-width: 700px; }
.ds-row.head { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
.ds-row .num { text-align: right; }
@media (max-width: 800px) { .ds-row { grid-template-columns: minmax(110px, 1fr) 58px 80px; min-width: 0; gap: 6px; padding: 6px 8px; } .ds-row > :nth-child(3), .ds-row > :nth-child(5), .ds-row > :nth-child(6) { display: none; } .ds-row .ds-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } }
.ds-name { cursor: pointer; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ds-mount { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; font-size: 12px; }
.ds-snaps { padding: 8px 12px 10px 40px; border-bottom: 1px solid var(--border); background: var(--bg); display: flex; flex-direction: column; gap: 6px; }
@@ -769,6 +773,7 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
@media (max-width: 640px) { .arc-sparks { grid-template-columns: 1fr; } }
.arc-break { display: grid; grid-template-columns: minmax(140px, 1fr) 80px 110px 110px; gap: 2px 10px; font-size: 12px; margin-top: 10px; }
.arc-break .head { color: var(--muted); } .arc-break .num { text-align: right; }
@media (max-width: 640px) { .arc-break { grid-template-columns: minmax(0, 1fr) auto auto auto; gap: 2px 8px; font-size: 11px; } .arc-break > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } }
.arc-tiers { display: flex; flex-direction: column; gap: 5px; font-size: 13px; margin-top: 4px; }
.arc-tier { display: flex; gap: 8px; min-width: 0; } .arc-tier .k { color: var(--muted); width: 80px; flex-shrink: 0; cursor: help; }
.arc-tier-v { display: flex; flex-direction: column; gap: 1px; min-width: 0; } .arc-tier-v > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -795,6 +800,7 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
.space .warn { color: var(--s2); }
.space-tabs { padding: 8px 12px 0; }
.space-split { flex: 1; display: flex; flex-direction: column; min-height: 0; padding: 8px 12px 12px; gap: 8px; }
@media (max-width: 800px) { .space-split { overflow-y: auto; padding: 8px; } .space-tree { flex: 0 0 auto; height: 40vh; } .space-map { flex: 0 0 auto; height: 60vh; } }
.space-tree { flex: 1 1 55%; min-height: 120px; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 6px; background: var(--bg2); overflow: hidden; }
.space-rows { flex: 1; overflow: auto; min-height: 0; }
.space-row { display: grid; grid-template-columns: minmax(160px, 1fr) 84px 120px 70px 64px 88px; gap: 8px; align-items: center; padding: 3px 8px; font-size: 13px; border-bottom: 1px solid var(--border); cursor: pointer; white-space: nowrap; }