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:
co-authored by
Claude Fable 5.1
parent
3a58e265c6
commit
e0e2562f0a
+3
-3
@@ -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)) }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user