Space: the picker itself — pools with layout and members, then disks on their own

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-22 20:27:20 -07:00
co-authored by Claude Fable 5.1
parent e9fa21768e
commit 8f77cc23e1
3 changed files with 110 additions and 52 deletions
+82 -35
View File
@@ -3,11 +3,11 @@
// a tree of folders largest-first, and a treemap of whatever folder is selected, where every // 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. // 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 { 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 { fmtBytes } from './Dashboard'
import { useHashSel } from './hashsel' import { useHashSel } from './hashsel'
import { useRefreshMs } from './prefs' 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) ---- // ---- 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('/')) || '/') const parentOf = (p: string) => (p === '/' ? '/' : p.slice(0, p.lastIndexOf('/')) || '/')
export default function Space() { export default function Space() {
const [disks, setDisks] = useState<SpaceDisk[] | null>(null) const [targets, setTargets] = useState<SpaceTarget[] | null>(null)
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [sel, setSel] = useHashSel('space') const [sel, setSel] = useHashSel('space')
const [tab, setTab] = useState<'tree' | 'files'>('tree') const [tab, setTab] = useState<'tree' | 'files'>('tree')
const rate = useRefreshMs() const rate = useRefreshMs()
const load = useCallback(async () => { 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]) useEffect(() => { load() }, [load])
const disk = disks?.find((d) => d.name === sel) ?? disks?.find((d) => d.roots.length > 0) ?? disks?.[0] const target = targets?.find((t) => t.id === sel) ?? targets?.find((t) => t.roots.length > 0) ?? targets?.[0]
useEffect(() => { if (disk && disk.name !== sel) setSel(disk.name) }, [disk, sel, setSel]) useEffect(() => { if (target && target.id !== sel) setSel(target.id) }, [target, sel, setSel])
const scan = disk?.scan const scan = target?.scan
// while a scan runs its status alone is polled at the refresh rate (the disk list means // while a scan runs its status alone is polled at the refresh rate (the target list means
// lsblk and zfs list, which need not run twice a second); the list is read again when it ends // lsblk and zpool, which need not run twice a second); the list is read again when it ends
useEffect(() => { useEffect(() => {
if (!scan?.running || !disk) return if (!scan?.running || !target) return
const name = disk.name const id = target.id
const t = setInterval(async () => { const t = setInterval(async () => {
try { try {
const st = await api.spaceStatus(name) const st = await api.spaceStatus(id)
setDisks((ds) => ds?.map((d) => (d.name === name ? { ...d, scan: st } : d)) ?? ds) setTargets((ts) => ts?.map((x) => (x.id === id ? { ...x, scan: st } : x)) ?? ts)
} catch { /* the next tick */ } } catch { /* the next tick */ }
}, Math.max(250, rate)) }, Math.max(250, rate))
return () => clearInterval(t) 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 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 (!disk) return; try { await api.spaceCancel(disk.name); 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 <div className="dash"><div className="error">{err}</div></div> if (err && !targets) return <div className="dash"><div className="error">{err}</div></div>
if (!disks) return <div className="dash"><Loading label="Looking at the disks…" /></div> if (!targets) return <div className="dash"><Loading label="Looking at the disks…" /></div>
const done = !!scan && !scan.running && !scan.error && !!scan.finished const done = !!scan && !scan.running && !scan.error && !!scan.finished
return ( return (
<div className="space"> <div className="space">
<div className="space-bar"> <div className="space-bar">
<div className="chips"> <div className="space-pick-row">
{disks.map((d) => ( <TargetPicker targets={targets} current={target ?? null} onPick={(t) => setSel(t.id)} />
<button key={d.name} className={'chip disk' + (disk?.name === d.name ? ' on' : '')} disabled={d.roots.length === 0} title={d.roots.length ? d.roots.join(' ') : 'nothing is mounted from this disk'} onClick={() => setSel(d.name)}> {target && (
<b>{d.name}</b>{d.model && <span className="muted"> · {d.model}</span>} · {fmtBytes(d.size)}{d.pool && <span className="muted"> · pool {d.pool}</span>}{d.scan?.running && <Spinner size={10} />}
</button>
))}
{disks.length === 0 && <span className="muted small">No disks found.</span>}
</div>
{disk && (
<div className="space-status"> <div className="space-status">
{scan?.running ? ( {scan?.running ? (
<> <>
<Spinner /><span className="mono small">{fmtN(scan.progress.files)} files · {fmtBytes(scan.progress.bytes)} · {fmtDur(scan.started)}</span> <Spinner /><span className="mono small">{fmtN(scan.progress.files)} files · {fmtBytes(scan.progress.bytes)} · {fmtDur(scan.started)}</span>
<span className="muted small mono cur" title={scan.progress.current}>{scan.progress.current}</span>
<Btn className="btn danger" onClick={cancel}>Cancel</Btn> <Btn className="btn danger" onClick={cancel}>Cancel</Btn>
</> </>
) : ( ) : (
<> <>
{done && <span className="small"><b>{fmtBytes(scan.size)}</b> 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` : ''}</span>} {done && <span className="small"><b>{fmtBytes(scan.size)}</b> 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` : ''}</span>}
{scan?.error && <span className="error small">{scan.error}</span>} {scan?.error && <span className={(scan.error === 'cancelled' ? 'muted' : 'error') + ' small'}>{scan.error === 'cancelled' ? 'the last scan was cancelled' : scan.error}</span>}
{!scan && <span className="muted small">{disk.roots.length ? `Scans ${disk.roots.join(', ')}` : 'Nothing is mounted from this disk.'}</span>} <Btn className="btn on" disabled={target.roots.length === 0} onClick={start}>{done ? 'Rescan' : 'Scan'}</Btn>
<Btn className="btn on" disabled={disk.roots.length === 0} onClick={start}>{done ? 'Rescan' : 'Scan'}</Btn>
</> </>
)} )}
</div> </div>
)} )}
</div> </div>
{disk && done && ( {target && <div className="muted small space-about">{describe(target)}{scan?.running && scan.progress.current && <span className="mono cur" title={scan.progress.current}> · {scan.progress.current}</span>}</div>}
</div>
{target && done && (
<> <>
<div className="chips space-tabs"> <div className="chips space-tabs">
<button className={'chip' + (tab === 'tree' ? ' on' : '')} onClick={() => setTab('tree')}>Folders</button> <button className={'chip' + (tab === 'tree' ? ' on' : '')} onClick={() => setTab('tree')}>Folders</button>
<button className={'chip' + (tab === 'files' ? ' on' : '')} onClick={() => setTab('files')}>Largest files</button> <button className={'chip' + (tab === 'files' ? ' on' : '')} onClick={() => setTab('files')}>Largest files</button>
</div> </div>
{tab === 'tree' ? <Explorer key={disk.name + (scan?.finished ?? '')} disk={disk.name} /> : <LargestFiles key={disk.name + (scan?.finished ?? '')} disk={disk.name} />} {tab === 'tree' ? <Explorer key={target.id + (scan?.finished ?? '')} disk={target.id} /> : <LargestFiles key={target.id + (scan?.finished ?? '')} disk={target.id} />}
</> </>
)} )}
{disk && !done && !scan?.running && <div className="svc-empty muted">{scan?.error ? 'The last scan failed. Scan again.' : 'Scan the disk to see what is on it.'}</div>} {target && !done && !scan?.running && <div className="svc-empty muted">{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.`}</div>}
{disk && scan?.running && <div className="svc-empty muted">Scanning…</div>} {target && scan?.running && <div className="svc-empty muted">Scanning…</div>}
</div>
)
}
// 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<HTMLDivElement>(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 (
<div className="tpick" ref={box}>
<button className="tpick-btn" onClick={() => setOpen(!open)} title="pick a disk or pool">
{current ? <><span className="ticon">{icon(current)}</span><b>{current.name}</b><span className="muted small">{current.kind === 'pool' ? `ZFS pool${current.layout ? ' · ' + current.layout : ''}` : current.model || 'disk'} · {fmtBytes(current.size)}</span>{current.scan?.running && <Spinner size={10} />}</> : <span className="muted">no disks</span>}
<span className="chev">▾</span>
</button>
{open && (
<div className="tmenu">
{targets.map((t) => (
<button key={t.id} className={'titem' + (current?.id === t.id ? ' on' : '') + (t.roots.length === 0 ? ' off' : '')} disabled={t.roots.length === 0} onClick={() => { setOpen(false); onPick(t) }}>
<span className="ticon">{icon(t)}</span>
<span className="tmain">
<span className="tname"><b>{t.name}</b><span className="muted small"> · {t.kind === 'pool' ? `ZFS pool${t.layout ? ' · ' + t.layout : ''}${t.health && t.health !== 'ONLINE' ? ' · ' + t.health : ''}` : t.model || 'disk'} · {fmtBytes(t.size)}</span></span>
<span className="muted small tsub">{t.kind === 'pool' && t.disks?.length ? `${t.disks.length} disks: ${t.disks.join(' ')}` : t.roots.length ? t.roots.slice(0, 3).join(' ') + (t.roots.length > 3 ? ` +${t.roots.length - 3}` : '') : 'nothing mounted'}</span>
</span>
<span className="muted small tstate">{state(t)}</span>
</button>
))}
</div>
)}
</div> </div>
) )
} }
+2 -2
View File
@@ -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 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 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 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 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 } 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 = { export const api = {
procs: () => req<ProcTable>('GET', '/api/procs'), procs: () => req<ProcTable>('GET', '/api/procs'),
space: () => req<{ disks: SpaceDisk[] }>('GET', '/api/space'), space: () => req<{ targets: SpaceTarget[] }>('GET', '/api/space'),
spaceStatus: (disk: string) => req<SpaceScan>('GET', `/api/space/${encodeURIComponent(disk)}`), spaceStatus: (disk: string) => req<SpaceScan>('GET', `/api/space/${encodeURIComponent(disk)}`),
spaceScan: (disk: string) => req<SpaceScan>('POST', `/api/space/${encodeURIComponent(disk)}/scan`), spaceScan: (disk: string) => req<SpaceScan>('POST', `/api/space/${encodeURIComponent(disk)}/scan`),
spaceCancel: (disk: string) => req<void>('POST', `/api/space/${encodeURIComponent(disk)}/cancel`), spaceCancel: (disk: string) => req<void>('POST', `/api/space/${encodeURIComponent(disk)}/cancel`),
+14 -3
View File
@@ -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: what is using a disk ---- */
.space { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); min-height: 0; } .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 { 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-pick-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.space-bar .chip.disk .spin { margin-left: 6px; margin-right: 0; }
.space-status { display: flex; align-items: center; gap: 10px; min-height: 28px; 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-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; }
.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; }