From 8f77cc23e1ca4b64deb3d2bf925a9c214f2785a8 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Tue, 22 Sep 2026 20:27:20 -0700 Subject: [PATCH] =?UTF-8?q?Space:=20the=20picker=20itself=20=E2=80=94=20po?= =?UTF-8?q?ols=20with=20layout=20and=20members,=20then=20disks=20on=20thei?= =?UTF-8?q?r=20own?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- web/src/Space.tsx | 141 ++++++++++++++++++++++++++++++--------------- web/src/api.ts | 4 +- web/src/styles.css | 17 +++++- 3 files changed, 110 insertions(+), 52 deletions(-) diff --git a/web/src/Space.tsx b/web/src/Space.tsx index 3968495..3b6163c 100644 --- a/web/src/Space.tsx +++ b/web/src/Space.tsx @@ -3,11 +3,11 @@ // a tree of folders largest-first, and a treemap of whatever folder is selected, where every // file is a tile sized by what it takes on disk and coloured by what kind of thing it is. import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { api, SpaceDisk, SpaceFile, SpaceScan, SpaceTree } from './api' +import { api, SpaceFile, SpaceTarget, SpaceTree } from './api' import { fmtBytes } from './Dashboard' import { useHashSel } from './hashsel' import { useRefreshMs } from './prefs' -import { Btn, Loading, Spinner, toast } from './ui' +import { Btn, Loading, Spinner, toast, useEscape } from './ui' // ---- what kind of file a tile is: the colour it wears (the validated categorical set, dark and light) ---- @@ -46,80 +46,127 @@ const fmtDur = (a: string, b?: string) => { const ms = (b ? new Date(b).getTime( const parentOf = (p: string) => (p === '/' ? '/' : p.slice(0, p.lastIndexOf('/')) || '/') export default function Space() { - const [disks, setDisks] = useState(null) + const [targets, setTargets] = useState(null) const [err, setErr] = useState('') const [sel, setSel] = useHashSel('space') const [tab, setTab] = useState<'tree' | 'files'>('tree') const rate = useRefreshMs() const load = useCallback(async () => { - try { const r = await api.space(); setDisks(r.disks); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } + try { const r = await api.space(); setTargets(r.targets); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, []) useEffect(() => { load() }, [load]) - const disk = disks?.find((d) => d.name === sel) ?? disks?.find((d) => d.roots.length > 0) ?? disks?.[0] - useEffect(() => { if (disk && disk.name !== sel) setSel(disk.name) }, [disk, sel, setSel]) - const scan = disk?.scan - // while a scan runs its status alone is polled at the refresh rate (the disk list means - // lsblk and zfs list, which need not run twice a second); the list is read again when it ends + const target = targets?.find((t) => t.id === sel) ?? targets?.find((t) => t.roots.length > 0) ?? targets?.[0] + useEffect(() => { if (target && target.id !== sel) setSel(target.id) }, [target, sel, setSel]) + const scan = target?.scan + // while a scan runs its status alone is polled at the refresh rate (the target list means + // lsblk and zpool, which need not run twice a second); the list is read again when it ends useEffect(() => { - if (!scan?.running || !disk) return - const name = disk.name + if (!scan?.running || !target) return + const id = target.id const t = setInterval(async () => { try { - const st = await api.spaceStatus(name) - setDisks((ds) => ds?.map((d) => (d.name === name ? { ...d, scan: st } : d)) ?? ds) + const st = await api.spaceStatus(id) + setTargets((ts) => ts?.map((x) => (x.id === id ? { ...x, scan: st } : x)) ?? ts) } catch { /* the next tick */ } }, Math.max(250, rate)) return () => clearInterval(t) - }, [scan?.running, disk, rate]) + }, [scan?.running, target, rate]) - const start = async () => { if (!disk) return; try { await api.spaceScan(disk.name); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } - const cancel = async () => { if (!disk) return; try { await api.spaceCancel(disk.name); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } + const start = async () => { if (!target) return; try { await api.spaceScan(target.id); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } + const cancel = async () => { if (!target) return; try { await api.spaceCancel(target.id); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } - if (err && !disks) return
{err}
- if (!disks) return
+ if (err && !targets) return
{err}
+ if (!targets) return
const done = !!scan && !scan.running && !scan.error && !!scan.finished return (
-
- {disks.map((d) => ( - - ))} - {disks.length === 0 && No disks found.} +
+ setSel(t.id)} /> + {target && ( +
+ {scan?.running ? ( + <> + {fmtN(scan.progress.files)} files · {fmtBytes(scan.progress.bytes)} · {fmtDur(scan.started)} + Cancel + + ) : ( + <> + {done && {fmtBytes(scan.size)} in {fmtN(scan.files)} files, {fmtN(scan.dirs)} folders · scanned {ago(scan.finished)} in {fmtDur(scan.started, scan.finished)}{scan.as_root ? '' : ' · not as root, so some folders were skipped'}{scan.denied ? ` · ${fmtN(scan.denied)} unreadable` : ''}} + {scan?.error && {scan.error === 'cancelled' ? 'the last scan was cancelled' : scan.error}} + {done ? 'Rescan' : 'Scan'} + + )} +
+ )}
- {disk && ( -
- {scan?.running ? ( - <> - {fmtN(scan.progress.files)} files · {fmtBytes(scan.progress.bytes)} · {fmtDur(scan.started)} - {scan.progress.current} - Cancel - - ) : ( - <> - {done && {fmtBytes(scan.size)} in {fmtN(scan.files)} files, {fmtN(scan.dirs)} folders · scanned {ago(scan.finished)} in {fmtDur(scan.started, scan.finished)}{scan.as_root ? '' : ' · not as root, so some folders were skipped'}{scan.denied ? ` · ${fmtN(scan.denied)} unreadable` : ''}} - {scan?.error && {scan.error}} - {!scan && {disk.roots.length ? `Scans ${disk.roots.join(', ')}` : 'Nothing is mounted from this disk.'}} - {done ? 'Rescan' : 'Scan'} - - )} -
- )} + {target &&
{describe(target)}{scan?.running && scan.progress.current && · {scan.progress.current}}
}
- {disk && done && ( + {target && done && ( <>
- {tab === 'tree' ? : } + {tab === 'tree' ? : } )} - {disk && !done && !scan?.running &&
{scan?.error ? 'The last scan failed. Scan again.' : 'Scan the disk to see what is on it.'}
} - {disk && scan?.running &&
Scanning…
} + {target && !done && !scan?.running &&
{target.roots.length === 0 ? 'Nothing is mounted from it, so there is nothing to scan.' : scan?.error && scan.error !== 'cancelled' ? 'The last scan failed. Scan again.' : `Scan the ${target.kind} to see what is on it.`}
} + {target && scan?.running &&
Scanning…
} +
+ ) +} + +// What a target is, in a line: "ZFS pool · raidz2 · 8 disks: sda sdb … · 29 TiB, 13 TiB used · scans /tank and 9 more" +function describe(t: SpaceTarget): string { + const parts: string[] = [] + if (t.kind === 'pool') { + parts.push(`ZFS pool${t.layout ? ' · ' + t.layout : ''}${t.health && t.health !== 'ONLINE' ? ' · ' + t.health : ''}`) + if (t.disks?.length) parts.push(`${t.disks.length} disk${t.disks.length === 1 ? '' : 's'}: ${t.disks.join(' ')}`) + parts.push(`${fmtBytes(t.size)}${t.used ? `, ${fmtBytes(t.used)} used` : ''}`) + } else { + parts.push(`disk${t.model ? ' · ' + t.model : ''} · ${fmtBytes(t.size)}`) + } + if (t.roots.length === 0) parts.push('nothing mounted') + else if (t.roots.length <= 3) parts.push('scans ' + t.roots.join(', ')) + else parts.push(`scans ${t.roots.slice(0, 2).join(', ')} and ${t.roots.length - 2} more`) + return parts.join(' · ') +} + +// The dropdown of what can be scanned: pools first, each with its layout and members, then +// disks on their own. A disk with nothing mounted is listed but can't be picked. +function TargetPicker({ targets, current, onPick }: { targets: SpaceTarget[]; current: SpaceTarget | null; onPick: (t: SpaceTarget) => void }) { + const [open, setOpen] = useState(false) + const box = useRef(null) + useEffect(() => { + const h = (e: MouseEvent) => { if (box.current && !box.current.contains(e.target as Node)) setOpen(false) } + window.addEventListener('click', h) + return () => window.removeEventListener('click', h) + }, []) + useEscape(() => setOpen(false), open) + const state = (t: SpaceTarget) => t.scan?.running ? 'scanning' : t.scan?.finished && !t.scan.error ? `scanned ${ago(t.scan.finished)}` : t.roots.length === 0 ? 'nothing mounted' : 'not scanned' + const icon = (t: SpaceTarget) => (t.kind === 'pool' ? '⛁' : '▤') + return ( +
+ + {open && ( +
+ {targets.map((t) => ( + + ))} +
+ )}
) } diff --git a/web/src/api.ts b/web/src/api.ts index b3565a1..53a6129 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -128,7 +128,7 @@ export interface TokenInfo { name: string; created: string; scope?: string; sens export interface SpaceProgress { files: number; dirs: number; bytes: number; denied: number; current: string } export interface SpaceScan { disk: string; roots: string[]; started: string; finished?: string; running: boolean; error?: string; progress: SpaceProgress; as_root: boolean; files: number; dirs: number; size: number; denied: number } -export interface SpaceDisk { name: string; kind: string; model?: string; size: number; pool?: string; roots: string[]; scan?: SpaceScan } +export interface SpaceTarget { id: string; kind: 'disk' | 'pool'; name: string; model?: string; layout?: string; health?: string; size: number; used?: number; disks?: string[]; roots: string[]; scan?: SpaceScan } export interface SpaceNode { name: string; dir: boolean; size: number; apparent: number; mtime: number; files: number; dirs: number; small_n?: number; small_size?: number; mount?: boolean; denied?: boolean } export interface SpaceTree extends SpaceNode { path: string; children?: SpaceTree[]; more_n?: number; more_size?: number; total: number } export interface SpaceFile { path: string; size: number; apparent: number; mtime: number } @@ -140,7 +140,7 @@ const q = (path: string, sudo: boolean, extra = '') => `path=${encodeURIComponen export const api = { procs: () => req('GET', '/api/procs'), - space: () => req<{ disks: SpaceDisk[] }>('GET', '/api/space'), + space: () => req<{ targets: SpaceTarget[] }>('GET', '/api/space'), spaceStatus: (disk: string) => req('GET', `/api/space/${encodeURIComponent(disk)}`), spaceScan: (disk: string) => req('POST', `/api/space/${encodeURIComponent(disk)}/scan`), spaceCancel: (disk: string) => req('POST', `/api/space/${encodeURIComponent(disk)}/cancel`), diff --git a/web/src/styles.css b/web/src/styles.css index a6b79ac..7bc24d7 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -672,10 +672,21 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%, /* ---- Space: what is using a disk ---- */ .space { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); min-height: 0; } .space-bar { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); background: var(--bg2); } -.space-bar .chip.disk b { color: var(--fg); font-weight: 600; } .space-bar .chip.disk:disabled { opacity: .45; cursor: default; } -.space-bar .chip.disk .spin { margin-left: 6px; margin-right: 0; } +.space-pick-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } .space-status { display: flex; align-items: center; gap: 10px; min-height: 28px; flex-wrap: wrap; } -.space-status .cur { flex: 1 1 200px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; } +.space-about { display: flex; gap: 0; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.space-about .cur { min-width: 0; overflow: hidden; text-overflow: ellipsis; } +/* the disk-or-pool picker */ +.tpick { position: relative; min-width: 260px; max-width: 100%; } +.tpick-btn { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; text-align: left; max-width: 100%; } +.tpick-btn b { color: var(--accent); } .tpick-btn .chev { margin-left: auto; color: var(--muted); padding-left: 8px; } .tpick-btn .spin { margin: 0 0 0 4px; } +.ticon { color: var(--accent2); font-family: ui-monospace, monospace; width: 16px; text-align: center; flex-shrink: 0; } +.tmenu { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; min-width: min(520px, 90vw); max-height: 60vh; overflow-y: auto; background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,.4); padding: 4px; } +.titem { width: 100%; display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 6px; text-align: left; } +.titem:hover { background: var(--bg3); } .titem.on { box-shadow: inset 3px 0 0 var(--accent); background: var(--bg3); } .titem.off { opacity: .5; cursor: default; } +.titem .tmain { display: flex; flex-direction: column; min-width: 0; flex: 1; } .titem .tname b { color: var(--fg); } +.titem .tsub { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; } +.titem .tstate { flex-shrink: 0; white-space: nowrap; } .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-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; }