diff --git a/BUGS.md b/BUGS.md index 0c30e8e..7e775c6 100644 --- a/BUGS.md +++ b/BUGS.md @@ -608,3 +608,123 @@ 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 + +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 had thirteen. 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//../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. diff --git a/web/src/Files.tsx b/web/src/Files.tsx index b7bacf4..197cc52 100644 --- a/web/src/Files.tsx +++ b/web/src/Files.tsx @@ -147,13 +147,13 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s }) // ---- actions ---- - const act = async (label: string, fn: () => Promise) => { - 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): Promise => { + 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)) } diff --git a/web/src/Hardware.tsx b/web/src/Hardware.tsx index 8ca9b76..beb003f 100644 --- a/web/src/Hardware.tsx +++ b/web/src/Hardware.tsx @@ -172,7 +172,7 @@ export default function Hardware() {
Processor
20 ? { fontSize: 16 } : undefined}>{cpu.model}
-
{cores ? `${cores} core${cores === 1 ? '' : 's'}` : ''}{threads && threads !== cores ? ` · ${threads} threads` : ''}{cpu.max_mhz ? ` · up to ${ghz(cpu.max_mhz)}` : ''}
+
{[cores ? `${cores} core${cores === 1 ? '' : 's'}` : '', threads && threads !== cores ? `${threads} threads` : '', cpu.max_mhz ? `up to ${ghz(cpu.max_mhz)}` : ''].filter(Boolean).join(' · ')}
Memory
diff --git a/web/src/Machines.tsx b/web/src/Machines.tsx index 7083c94..c11dee6 100644 --- a/web/src/Machines.tsx +++ b/web/src/Machines.tsx @@ -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') } diff --git a/web/src/Services.tsx b/web/src/Services.tsx index 640c01a..d0e097b 100644 --- a/web/src/Services.tsx +++ b/web/src/Services.tsx @@ -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() { ))}
- {err &&
{err}
} + {err && units.length === 0 && loaded + ?
{err}
// a host with no systemd, or one that is unreachable: not a filter result + : err &&
{err}
}
{!loaded && } {shown.map((u) => ( @@ -230,7 +239,7 @@ export default function Services() { {u.failed ? 'failed' : u.sub}{u.enabled === 'enabled' ? '' : u.enabled ? ` · ${u.enabled}` : ''}
))} - {loaded && shown.length === 0 &&
Nothing matches.
} + {loaded && shown.length === 0 && !(err && units.length === 0) &&
Nothing matches.
} {sel ? setSel(null)} /> :
Select a service.
} diff --git a/web/src/Settings.tsx b/web/src/Settings.tsx index 7253442..688e9cb 100644 --- a/web/src/Settings.tsx +++ b/web/src/Settings.tsx @@ -198,7 +198,7 @@ function Updates({ me }: { me: Me }) { {st.notes &&
{st.notes.trim()}
} )} - {st && !st.behind && st.latest &&
This is the newest published build.
} + {st && !st.behind && st.latest &&
{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}).`}
} {(err || st?.error) &&
{err || st?.error}
}
Check for updates diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index 096aa16..82915d5 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -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(null) const [view, setView] = useState(() => 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>({}) diff --git a/web/src/Updates.tsx b/web/src/Updates.tsx index 7de6778..9a5cf99 100644 --- a/web/src/Updates.tsx +++ b/web/src/Updates.tsx @@ -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() { {p.name}{p.security && security} {p.description} - {p.current} → {p.candidate} · {p.suite} + {p.current} → {p.candidate}{p.suite && · {p.suite}}
{open === p.name &&
{changelog[p.name] ??  fetching changelog…}
} diff --git a/web/src/ZFS.tsx b/web/src/ZFS.tsx index 9578e24..b1efe1b 100644 --- a/web/src/ZFS.tsx +++ b/web/src/ZFS.tsx @@ -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 { 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(), diff --git a/web/src/styles.css b/web/src/styles.css index ba92d5d..0d78333 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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; }