2 Commits
Author SHA1 Message Date
Mitchell-JohnsonandClaude Fable 5.1 faaa6aaf9d The phone's ZFS table headers no longer collide; the bash notes that deploy is still owed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 11:34:31 -07:00
Mitchell-JohnsonandClaude Fable 5.1 e0e2562f0a 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>
2026-10-03 11:28:44 -07:00
10 changed files with 165 additions and 14 deletions
+125
View File
@@ -608,3 +608,128 @@ swallows a failed WebSocket where Firefox writes it to the console. One thing it
hands logs "can't establish a connection". Sitting still on a page produces none, the address hands logs "can't establish a connection". Sitting still on a page produces none, the address
is right, and the terminal belongs to the machine asked; `sockets.cjs` checks that ownership is right, and the terminal belongs to the machine asked; `sockets.cjs` checks that ownership
rather than the noise. rather than the noise.
# Bug bash 6 — 2026-10-03: every page, every machine, desktop and phone
Headless Chromium over all 19 pages on the hub and on each of the four machines at 1440px
and at an iPhone 13's 390px (158 page loads, each measured for horizontal overflow, elements
poking past the viewport, console errors and failed requests), then every interaction on
the hub: login and a wrong password, every rail entry and the drawer, the machine switcher,
the power dialog, terminals (create, type, rename by dialog and double-click, reorder by
drag and by the arrows, close by × and middle-click, Alt+Shift+T/←/→, the phone's key bar),
Services (filter, chips, detail, logs pause/resume/clear, unit file, a cancelled restart),
Jobs (job and timer detail, the crontab editor), Disks (SMART, show-all), Files (navigate,
crumbs, path box, hidden, sort, filter, folder, file, edit, save, Ctrl+S, unsaved-changes
guards on back and on leaving the page, rename, chmod, copy path, move, delete with the name
typed wrong and right, upload by button and by drag-and-drop, overwrite prompt, download,
image preview, binary notice, open in terminal, root toggle), Network (interfaces, show
more, the VPN hand-off, a full speed test), Usage (picker, tree, treemap hover and click,
largest files), Docker (every tab, container detail with all five tabs, Limits, a cancelled
restart, a shell into gitea, the stack editor with Revert and the diff, the .env mask,
a wrong-name delete, the new-stack dialog in both modes with its clash warning, pulling and
removing an image, and a whole stack lifecycle — create and deploy whoami, see it answer,
Down, Delete), KVM (dialogs, the NanoKVM frame on grid), Hardware re-read, Updates (changelog,
selection, All/None, a real apt-get update as a task), Alerts (rule dialog with live hits
and a bad pattern, save, pause, edit, delete, channel validation), Machines (swatches,
add-form validation, an unreachable address, Open), Widgets, Users (sessions, change-password
with the wrong current password, add and remove an account with the right and wrong
password, the reset dialog, a guest login created, used in a second browser, and revoked
under it), Settings (every theme, refresh rate, history window, a read-only token used in
/embed and refused for /api/me, revoked), the rail's Arrange mode, the Processes page
(rate, sorts, filter, select, a cancelled signal, interface pick), logout and back in. Then
read-only probes of the API with traversal, injection and malformed input. Firefox could
not run here (the box lacks its GTK libraries).
## Bugs — all ✅ fixed and verified by the script that found them
Verified against the live API through the Vite dev server rather than a deployed build: the
permission classifier refused `make deploy` (as it did for the GPU work the day before), so
the fixes are committed and built but **not yet on any machine** until Mitchell runs it.
1. ✅ **The ZFS tab was wider than a phone.** The dataset table carried a 700px minimum and
the ARC's "since boot" breakdown four fixed columns; on grid the page scrolled 330px
sideways. Both drop to three columns and the ellipsis below 800px.
2. ✅ **The Usage tab ran under the footer on a phone**, its legend printed over the version
line: the tree and the treemap each insisted on a minimum height the screen didn't have.
Below 800px the page scrolls, the tree 40vh and the map 60vh.
3. ✅ **Journal lines on a phone squeezed the message into a third of the width**, three
columns deep. Below 560px the stamp and unit share a line and the message takes the next.
4. ✅ **Services on `gate` (no systemd) showed the server's error flush against the edge,
with "Nothing matches." under it** as if a filter had hidden them. A padded notice now, and
no filter message when there was nothing to filter.
5. ✅ **ZFS "Snapshot now" offered a name seven hours wrong.** The default came from
`toISOString()` — UTC — while zfSnap's names, and the list reading them back, are local
time: at 11:25 it offered `…_18.25.52`, and that snapshot would have shown as taken in the
future and expiring late. Built from local fields now.
6. ✅ **"Adopt as a schedule" always ticked Recursive** — `/\s-\w*r/.test(cmd) || true`.
7. ✅ **Files "+ file" opened the editor even when the create had failed**, on a path that
didn't exist. It opens only after the create succeeds.
8. ✅ **Updates: a dangling " · " after each version on `gate`**, whose `apk` reports no suite.
9. ✅ **Updates: apt's raw "E: Failed to fetch changelog: … Changelog unavailable" for a
third-party package** (docker-ce) was shown as the changelog. Said plainly now.
10. ✅ **Services "Since" fell back to systemd's raw timestamp in Firefox**, which can't parse
"Fri 2026-10-02 23:48:25 PDT". The fields are read directly now.
11. ✅ **Hardware on `gate` led with a separator** — "· 4 threads · up to 2.70 GHz" — when the
core count is unknown. The parts are joined, not concatenated.
12. ✅ **Settings → Updates said "This is the newest published build" of a build newer than
anything published** (169 installed, 137 published). It says so instead.
13. ✅ **Escape did not close the phone's drawer**, alone among the overlays.
14. ✅ **Machines "Update N outdated", when some were busy and the force was accepted,
concatenated both result lists** and toasted every machine twice.
## Found by the sweep, not a fault of the console
**`core`'s boot NVMe (WD Blue SN580 500GB) is failing.** The "Drive failing SMART" rule fired
during the sweep, as it had the evening before; `smartctl` confirms it: critical warning 4
(*NVM subsystem reliability has been degraded*), 18 media errors, every one an *Unrecovered
Read Error*, 64 unsafe shutdowns, 2.5 years powered on. The console did its job; the drive is
a `hamlet` matter (`docs/pending.md`).
## Checked and fine
Every page on every machine at both widths, with no console error that wasn't an expected
401/400/403 from a deliberately wrong input. Login lockout messages, the password eye. URL
selections surviving a reload and back/forward on every page that keeps one. The unsaved-work
guard on the file editor and the stack editor, from the Back button, the rail, and the
switcher. Terminal tabs, their order, their shortcuts; a shell inside a container landing on
the Terminal tab. The whole stack lifecycle, image pull and prune, container Limits. A guest
login kicked out at the moment of revocation. A revoked read-only token refused on
`/api/sensors`, the stream and `/metrics` (an earlier reading that it still worked was the
probe sending the session cookie alongside it). Traversal in every path-taking endpoint,
injection in unit, device, dataset, package and container names, malformed `since`, `q` and
`ids`, a nonexistent task, a wrong-method call — each answered with the right 4xx and the
right words. No session, no cookie: 401 on every API, locally and through NPM. The KVM proxy
hands NanoKVM's own `/api/ws` through to the device (the 401 in the console is the device's,
before its own login).
## Quality of life — to discuss, not built
1. **Treemap blank space.** Where a folder's children don't add up to its size — `tank`'s
root, where snapshots and compression hold the rest — the leftover is drawn as nothing.
An "unaccounted" tile would say what it is.
2. **Sessions pile up.** Every script, curl and headless browser that logs in leaves a row;
the hub has **fifty**, forty-six of them from this and earlier automated sweeps (the
classifier would not let the sweep revoke its own). A "revoke all but the devices I named"
action, idle expiry shown per row, or pruning sessions idle for a week.
3. **The Mac's GPU box takes half the Processes page** to say Asahi exposes no counters; a
GPU with nothing measurable could be one line.
4. **A host without systemd** still gets Services chips reading "All 0" and a "systemd timers
· 0" heading on Jobs. The server answers Services with a 500 there; a 200 with an
`available: false`, as Docker does, would let both pages say so up front.
5. **An alert whose sensor vanishes resolves silently.** If `smartctl` errors out, the
`smart.*.passed` sensor is omitted and a firing "Drive failing SMART" clears. A sensor
that disappears while a rule fires should hold the alert, or say the sensor is gone.
6. **Network "Past tests" rows are clipped** on desktop ("↓ 302 · ↑ 300 Mbps · 7.5 …") by the
four-column props grid.
7. **Logs "Clear" has no "Reload"**: once cleared, the backlog only comes back by leaving the
detail and returning.
8. **NanoKVM asks for its own login every time** inside the frame. Ham Lab could remember the
device's cookie per browser, or the hub could hold its credentials — a trade worth stating.
9. **Settings → Updates could offer to publish** when the installed build is newer than the
last release, since that is what `make gitea-release` is for and the page already knows.
10. **The compose editor is a plain textarea**: no line numbers, no YAML highlighting, and a
phone keyboard's autocorrect is off but its indentation is a fight.
11. **The hub's `/api/nodes/<m>/../me` answers a 307** from Go's path cleaning rather than a
404 — harmless, but a stray.
12. **Guest logins can open Users and Settings** — by design ("a guest can do everything you
can"), but worth a second look now that Users includes removing accounts.
+3 -3
View File
@@ -147,13 +147,13 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
}) })
// ---- actions ---- // ---- actions ----
const act = async (label: string, fn: () => Promise<unknown>) => { const act = async (label: string, fn: () => Promise<unknown>): Promise<boolean> => {
try { await fn(); toast(label); await load() } catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err') } 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.) // 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 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 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 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 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)) } 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="tile">
<div className="label">Processor</div> <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="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>
<div className="tile"> <div className="tile">
<div className="label">Memory</div> <div className="label">Memory</div>
+4 -1
View File
@@ -54,7 +54,10 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
try { try {
let res = await api.updateAllMachines() let res = await api.updateAllMachines()
const busy = res.filter((r) => !r.ok && (r.error ?? '').startsWith('busy:')) 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') for (const r of res) toast(r.ok ? `${r.name} → ${r.version}` : `${r.name}: ${r.error}`, r.ok ? 'ok' : 'err')
setTimeout(load, 4000) setTimeout(load, 4000)
} catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } 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 const s = ns / 1e9
return s >= 3600 ? `${(s / 3600).toFixed(1)} h` : s >= 60 ? `${(s / 60).toFixed(1)} min` : `${s.toFixed(1)} s` 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 { function since(ts?: string): string {
if (!ts) return '' if (!ts) return ''
const t = Date.parse(ts.replace(/^[A-Za-z]{3} /, '')) const t = parseSystemdTime(ts)
if (isNaN(t)) return ts if (isNaN(t)) return ts
const s = Math.max(0, (Date.now() - t) / 1000) 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) 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>
</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"> <div className="svc-rows">
{!loaded && <Loading label="Listing services…" />} {!loaded && <Loading label="Listing services…" />}
{shown.map((u) => ( {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> <span className="svc-state">{u.failed ? 'failed' : u.sub}{u.enabled === 'enabled' ? '' : u.enabled ? ` · ${u.enabled}` : ''}</span>
</div> </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>
</div> </div>
{sel ? <Detail name={sel} onChanged={load} onBack={() => setSel(null)} /> : <div className="svc-empty muted">Select a service.</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>} {st.notes && <div className="mono small" style={{ whiteSpace: 'pre-wrap', marginTop: 6, maxHeight: 140, overflow: 'auto' }}>{st.notes.trim()}</div>}
</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>} {(err || st?.error) && <div className="error" style={{ marginTop: 8 }}>{err || st?.error}</div>}
<div className="svc-actions" style={{ marginTop: 10 }}> <div className="svc-actions" style={{ marginTop: 10 }}>
<Btn className="btn" busy={busy === 'check'} disabled={!st?.enabled || !!busy} onClick={check}>Check for updates</Btn> <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 { LIST_FLOOR, useRefreshMs } from './prefs'
import Power from './Power' import Power from './Power'
import { applyAccent } from './accents' 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' import { fmtLeft, useTimeLeft } from './guest'
// Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour). // 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 [active, setActive] = useState<string | null>(null)
const [view, setView] = useState<View>(() => parseHash()?.view ?? 'procs') const [view, setView] = useState<View>(() => parseHash()?.view ?? 'procs')
const [drawer, setDrawer] = useState(false) const [drawer, setDrawer] = useState(false)
useEscape(() => setDrawer(false), drawer) // the phone's drawer closes like every other overlay
const [online, setOnline] = useState(true) const [online, setOnline] = useState(true)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const handles = useRef<Record<string, TermHandle>>({}) const handles = useRef<Record<string, TermHandle>>({})
+6 -2
View File
@@ -52,7 +52,11 @@ export default function Updates() {
setOpen(p.name) setOpen(p.name)
if (!changelog[p.name]) { if (!changelog[p.name]) {
try { const r = await api.changelog(p.name); setChangelog((c) => ({ ...c, [p.name]: r.text || '(empty)' })) } 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 }) 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="n">{p.name}{p.security && <span className="badge bad">security</span>}</span>
<span className="d">{p.description}</span> <span className="d">{p.description}</span>
</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> </div>
{open === p.name && <pre className="run-out changelog">{changelog[p.name] ?? <span className="muted"><Spinner /> fetching changelog…</span>}</pre>} {open === p.name && <pre className="run-out changelog">{changelog[p.name] ?? <span className="muted"><Spinner /> fetching changelog…</span>}</pre>}
</div> </div>
+5 -2
View File
@@ -97,7 +97,10 @@ function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChange
if (!open && !snaps) await reload() if (!open && !snaps) await reload()
} }
async function snapshot() { 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) const name = await askText(`Snapshot name for ${d.name}@`, def)
if (!name) return if (!name) return
try { await api.zfsSnapshot(d.name, name.trim()); toast(`${d.name}@${name.trim()} created`); await reload(); onChanged() } 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 const ds = /\b(tank[\w/-]*)\b/.exec(cmd)?.[1] ?? fallbackDataset
return { return {
...BLANK, name: `${x.tool} (adopted)`, dataset: ds, ...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', every: simple ? 'daily' : 'custom',
at: simple ? `${String(hour).padStart(2, '0')}:${String(min).padStart(2, '0')}` : '02:30', at: simple ? `${String(hour).padStart(2, '0')}:${String(min).padStart(2, '0')}` : '02:30',
cron: simple ? '' : x.schedule.trim(), 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:hover, .login button.pweye[aria-pressed="true"] { color: var(--fg); }
.login button.pweye:disabled { opacity: .4; } .login button.pweye:disabled { opacity: .4; }
.error { color: var(--bad); font-size: 13px; } .error { color: var(--bad); font-size: 13px; }
.svc-list > .error, .cron-list > .error { margin: 8px 12px; }
/* layout */ /* layout */
.app { height: 100%; display: flex; flex-direction: column; padding-top: env(safe-area-inset-top); } .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-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 { 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; } .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); } .log.err .lm { color: var(--bad); } .log.warn .lm { color: var(--s2); } .log.debug .lm { color: var(--muted); }
@media (max-width: 800px) { @media (max-width: 800px) {
.svc-list { width: 100%; border-right: 0; } .has-sel .svc-list { display: none; } .svc-empty { display: none; } .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 { 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.head { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
.ds-row .num { text-align: right; } .ds-row .num { text-align: right; }
@media (max-width: 800px) { .ds-row { grid-template-columns: minmax(90px, 1fr) 84px 100px; 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, .ds-row.head > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ds-row.head { letter-spacing: 0; } }
.ds-name { cursor: pointer; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .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-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; } .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; } } @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 { 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; } .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-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 { 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; } .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 .warn { color: var(--s2); }
.space-tabs { padding: 8px 12px 0; } .space-tabs { padding: 8px 12px 0; }
.space-split { flex: 1; display: flex; flex-direction: column; min-height: 0; padding: 8px 12px 12px; gap: 8px; } .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-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-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; } .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; }