Compare commits
2
Commits
3a58e265c6
...
faaa6aaf9d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faaa6aaf9d | ||
|
|
e0e2562f0a |
@@ -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
|
||||
is right, and the terminal belongs to the machine asked; `sockets.cjs` checks that ownership
|
||||
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
@@ -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(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-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