Space: what is using a disk, WizTree-style

Pick a disk, not a partition; the scan walks every filesystem it backs, as root when
the host allows, in a helper process that streams the tree back. A tree of folders
largest-first over a treemap of the selected folder, nested to the file and coloured by
kind, plus the thousand largest files. The rail no longer offers the hub-only pages
while a remote machine is being looked at.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-22 20:18:36 -07:00
co-authored by Claude Fable 5.1
parent 7e3e916f25
commit fc5236471e
11 changed files with 1476 additions and 4 deletions
+7 -4
View File
@@ -17,6 +17,7 @@ import Procs from './Procs'
import Top from './Top'
import Machines from './Machines'
import Settings from './Settings'
import Space from './Space'
import Users from './Users'
import MachineSwitcher from './MachineSwitcher'
import { MachineStatus, setMachine } from './api'
@@ -60,6 +61,7 @@ const GROUPS: Group[] = [
{ id: 'cron', label: 'Cron', icon: ClockIcon },
{ id: 'updates', label: 'Updates', icon: '⇪' },
{ id: 'disks', label: 'Disks', icon: '▤' },
{ id: 'space', label: 'Space', icon: '▧' },
{ id: 'files', label: 'Files', icon: FolderIcon },
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
{ id: 'machines', label: 'Machines', icon: '⌂' },
@@ -68,8 +70,8 @@ const GROUPS: Group[] = [
{ id: 'settings', label: 'Settings', icon: '⚙' },
]
type View = 'terminal' | 'dashboard' | 'top' | 'widgets' | 'docker' | 'alerts' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' | 'users' | 'settings'
const VIEWS: View[] = ['terminal', 'dashboard', 'top', 'widgets', 'docker', 'alerts', 'services', 'cron', 'updates', 'disks', 'files', 'procs', 'machines', 'users', 'settings']
type View = 'terminal' | 'dashboard' | 'top' | 'widgets' | 'docker' | 'alerts' | 'services' | 'cron' | 'updates' | 'disks' | 'space' | 'files' | 'procs' | 'machines' | 'users' | 'settings'
const VIEWS: View[] = ['terminal', 'dashboard', 'top', 'widgets', 'docker', 'alerts', 'services', 'cron', 'updates', 'disks', 'space', 'files', 'procs', 'machines', 'users', 'settings']
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
@@ -256,7 +258,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
</header>
<div className="body">
<Rail groups={GROUPS} view={view} badges={{ alerts: firing }}
<Rail groups={machine ? GROUPS.filter((g) => !HUB_ONLY.includes(g.id as View)) : GROUPS} view={view} badges={{ alerts: firing }}
header={<MachineSwitcher hub={hubMe.host} hubAddr={hubMe.addr} current={machine} machines={machines} onChange={switchMachine} onManage={() => { setView('machines'); setDrawer(false) }} />}
onView={async (id) => { if (id !== view && !(await okToLeave())) return; setView(id as View); setDrawer(false) }} />
<div className="backdrop" onClick={() => setDrawer(false)} />
@@ -264,6 +266,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
<main className="main" key={machine ?? 'hub'}>
{view === 'settings' && <Settings me={hubMe} />}
{view === 'users' && <Users me={hubMe} />}
{view === 'space' && <Space key={machine ?? ''} />}
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('dashboard') }} />}
{view === 'dashboard' && <Dashboard />}
{view === 'widgets' && <Gallery demoUrl={hubMe.demo_url} />}
@@ -301,7 +304,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
<span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
<span>up {fmtUptime(me.uptime)}</span>
<span className="spacer" />
<span className="muted">{view === 'top' ? 'Top' : view === 'dashboard' ? 'Dashboard' : view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
<span className="muted">{view === 'top' ? 'Top' : view === 'dashboard' ? 'Dashboard' : view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'space' ? 'Space' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
</footer>
</div>
)
+414
View File
@@ -0,0 +1,414 @@
// Space: what is using a disk, WizTree-style. Pick a disk, scan it (a parallel walk of every
// filesystem the disk backs, as root when the host allows), then read the result two ways:
// 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 { fmtBytes } from './Dashboard'
import { useHashSel } from './hashsel'
import { useRefreshMs } from './prefs'
import { Btn, Loading, Spinner, toast } from './ui'
// ---- what kind of file a tile is: the colour it wears (the validated categorical set, dark and light) ----
interface Kind { id: string; label: string; dark: string; light: string; ext: string[] }
const KINDS: Kind[] = [
{ id: 'video', label: 'Video', dark: '#9085e9', light: '#4a3aa7', ext: ['mp4', 'mkv', 'avi', 'mov', 'webm', 'm4v', 'ts', 'wmv', 'flv', 'mpg', 'mpeg', 'vob', 'm2ts'] },
{ id: 'image', label: 'Images', dark: '#d55181', light: '#e87ba4', ext: ['jpg', 'jpeg', 'png', 'gif', 'heic', 'heif', 'webp', 'raw', 'dng', 'cr2', 'nef', 'arw', 'tif', 'tiff', 'bmp', 'svg', 'psd'] },
{ id: 'audio', label: 'Audio', dark: '#199e70', light: '#1baf7a', ext: ['mp3', 'flac', 'm4a', 'aac', 'ogg', 'opus', 'wav', 'wma', 'aiff'] },
{ id: 'archive', label: 'Archives & images', dark: '#d95926', light: '#eb6834', ext: ['zip', 'tar', 'gz', 'tgz', 'xz', 'zst', 'bz2', '7z', 'rar', 'iso', 'img', 'qcow2', 'vmdk', 'vdi', 'vhd', 'vhdx', 'deb', 'rpm', 'apk', 'jar', 'whl'] },
{ id: 'doc', label: 'Documents', dark: '#c98500', light: '#eda100', ext: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'txt', 'md', 'epub', 'mobi', 'rtf', 'csv'] },
{ id: 'code', label: 'Code & config', dark: '#3987e5', light: '#2a78d6', ext: ['js', 'mjs', 'ts', 'tsx', 'jsx', 'go', 'py', 'rs', 'c', 'h', 'cpp', 'java', 'sh', 'css', 'html', 'json', 'yaml', 'yml', 'toml', 'xml', 'sql', 'wasm', 'so', 'a', 'o', 'node'] },
{ id: 'data', label: 'Databases & logs', dark: '#e66767', light: '#e34948', ext: ['db', 'sqlite', 'sqlite3', 'log', 'ldb', 'sst', 'wal', 'journal', 'bin', 'dat', 'idx', 'pack', 'ibd', 'frm', 'mdb', 'rdb', 'aof'] },
{ id: 'other', label: 'Other', dark: '#008300', light: '#008300', ext: [] },
]
const BY_EXT = new Map<string, Kind>()
for (const k of KINDS) for (const e of k.ext) BY_EXT.set(e, k)
function kindOf(name: string): Kind {
const dot = name.lastIndexOf('.')
const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : ''
return (ext && BY_EXT.get(ext)) || KINDS[KINDS.length - 1]
}
function lightTheme(): boolean {
const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim()
return /^#[0-9a-f]{6}$/i.test(bg) && parseInt(bg.slice(1, 3), 16) > 128
}
function cssVar(name: string): string { return getComputedStyle(document.documentElement).getPropertyValue(name).trim() }
const fmtN = (n: number) => n.toLocaleString()
const fmtWhen = (t: number) => (t ? new Date(t * 1000).toLocaleDateString() : '')
const ago = (iso?: string) => {
if (!iso) return ''
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000)
return s < 60 ? 'just now' : s < 3600 ? `${Math.floor(s / 60)} min ago` : s < 86400 ? `${Math.floor(s / 3600)} h ago` : `${Math.floor(s / 86400)} d ago`
}
const fmtDur = (a: string, b?: string) => { const ms = (b ? new Date(b).getTime() : Date.now()) - new Date(a).getTime(); return ms < 1000 ? `${ms} ms` : ms < 60000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.floor(ms / 60000)} min ${Math.round((ms % 60000) / 1000)} s` }
const parentOf = (p: string) => (p === '/' ? '/' : p.slice(0, p.lastIndexOf('/')) || '/')
export default function Space() {
const [disks, setDisks] = useState<SpaceDisk[] | null>(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') }
}, [])
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
useEffect(() => {
if (!scan?.running || !disk) return
const name = disk.name
const t = setInterval(async () => {
try {
const st = await api.spaceStatus(name)
setDisks((ds) => ds?.map((d) => (d.name === name ? { ...d, scan: st } : d)) ?? ds)
} catch { /* the next tick */ }
}, Math.max(250, rate))
return () => clearInterval(t)
}, [scan?.running, disk, 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') } }
if (err && !disks) return <div className="dash"><div className="error">{err}</div></div>
if (!disks) return <div className="dash"><Loading label="Looking at the disks…" /></div>
const done = !!scan && !scan.running && !scan.error && !!scan.finished
return (
<div className="space">
<div className="space-bar">
<div className="chips">
{disks.map((d) => (
<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)}>
<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">
{scan?.running ? (
<>
<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>
</>
) : (
<>
{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 && <span className="muted small">{disk.roots.length ? `Scans ${disk.roots.join(', ')}` : 'Nothing is mounted from this disk.'}</span>}
<Btn className="btn on" disabled={disk.roots.length === 0} onClick={start}>{done ? 'Rescan' : 'Scan'}</Btn>
</>
)}
</div>
)}
</div>
{disk && done && (
<>
<div className="chips space-tabs">
<button className={'chip' + (tab === 'tree' ? ' on' : '')} onClick={() => setTab('tree')}>Folders</button>
<button className={'chip' + (tab === 'files' ? ' on' : '')} onClick={() => setTab('files')}>Largest files</button>
</div>
{tab === 'tree' ? <Explorer key={disk.name + (scan?.finished ?? '')} disk={disk.name} /> : <LargestFiles key={disk.name + (scan?.finished ?? '')} disk={disk.name} />}
</>
)}
{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>}
{disk && scan?.running && <div className="svc-empty muted">Scanning…</div>}
</div>
)
}
// ---- the folder tree and the treemap of the selected folder ----
interface Row { t: SpaceTree; depth: number; kind: 'node' | 'small' | 'more'; parent: number }
function Explorer({ disk }: { disk: string }) {
const [cache, setCache] = useState<Record<string, SpaceTree>>({}) // one level per folder, for the rows
const [maps, setMaps] = useState<Record<string, SpaceTree>>({}) // deep, pruned to what a tile could show, for the map
const [open, setOpen] = useState<Set<string>>(() => new Set(['/']))
const [focus, setFocus] = useState('/')
const pending = useRef(new Set<string>())
const fetch = useCallback(async (path: string) => {
if (pending.current.has(path)) return
pending.current.add(path)
try { const t = await api.spaceTree(disk, path, 1, 200); setCache((c) => ({ ...c, [path]: t })) }
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
finally { pending.current.delete(path) }
}, [disk])
// The map of a folder reaches as deep as there is room to draw: everything under a
// four-thousandth of the folder would be a tile smaller than a few pixels, so it is left out.
const fetchMap = useCallback(async (path: string, size: number) => {
const key = 'map:' + path
if (pending.current.has(key)) return
pending.current.add(key)
try { const t = await api.spaceTree(disk, path, 12, 200, Math.max(1, Math.floor(size / 4000))); setMaps((m) => ({ ...m, [path]: t })) }
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
finally { pending.current.delete(key) }
}, [disk])
useEffect(() => { if (!cache['/']) fetch('/') }, [cache, fetch])
useEffect(() => { if (!cache[focus]) fetch(focus) }, [focus, cache, fetch])
useEffect(() => { const c = cache[focus]; if (c && !maps[focus]) fetchMap(focus, c.size) }, [focus, cache, maps, fetchMap])
const toggle = (path: string) => {
setOpen((o) => { const n = new Set(o); if (n.has(path)) n.delete(path); else { n.add(path); if (!cache[path]) fetch(path) } return n })
}
const select = (path: string) => {
setFocus(path)
// open every folder down to it so the row is on screen
setOpen((o) => { const n = new Set(o); let p = path; while (p !== '/') { p = parentOf(p); n.add(p) } return n })
if (!cache[path]) fetch(path)
}
const rows = useMemo(() => {
const out: Row[] = []
const walk = (t: SpaceTree, depth: number) => {
out.push({ t, depth, kind: 'node', parent: t.size })
if (!t.dir || !open.has(t.path)) return
const full = cache[t.path] ?? t // the row's own fetch has the full list; the parent's cut copy does until then
for (const c of full.children ?? []) walk(c, depth + 1)
if (full.small_n) out.push({ t: { ...full, name: `${fmtN(full.small_n)} smaller files`, dir: false, size: full.small_size ?? 0, files: full.small_n, path: full.path + '/*' }, depth: depth + 1, kind: 'small', parent: full.size })
if (full.more_n) out.push({ t: { ...full, name: `${fmtN(full.more_n)} more items`, dir: false, size: full.more_size ?? 0, path: full.path + '/…' }, depth: depth + 1, kind: 'more', parent: full.size })
}
if (cache['/']) walk(cache['/'], 0)
// parent sizes for the bars: each row's share of the folder it sits in
const stack: number[] = []
for (const r of out) { stack.length = r.depth; r.parent = stack[r.depth - 1] ?? r.t.size; stack[r.depth] = r.t.size }
return out
}, [cache, open])
const root = cache['/']
if (!root) return <Loading label="Reading the scan…" />
const focused = cache[focus]
const map = maps[focus]
return (
<div className="space-split">
<div className="space-tree">
<div className="space-row head">
<span className="n">Name</span><span className="r">Size</span><span className="pct">of parent</span><span className="r c">Files</span><span className="r c">Folders</span><span className="r d">Modified</span>
</div>
<div className="space-rows">
{rows.map((r) => {
const t = r.t
const pct = r.parent > 0 ? (t.size / r.parent) * 100 : 0
const isOpen = open.has(t.path)
return (
<div key={t.path} className={'space-row' + (t.path === focus ? ' active' : '') + (r.kind !== 'node' ? ' agg' : '')} style={{ paddingLeft: 8 + r.depth * 16 }}
onClick={() => { if (r.kind === 'node') { if (t.dir) { select(t.path); if (!isOpen) toggle(t.path) } else select(parentOf(t.path)) } }}>
<span className="n">
{t.dir && r.kind === 'node' ? <button className="tw" onClick={(e) => { e.stopPropagation(); toggle(t.path) }}>{isOpen ? '▾' : '▸'}</button> : <span className="tw" />}
<span className="ico" style={r.kind === 'node' && !t.dir ? { background: lightTheme() ? kindOf(t.name).light : kindOf(t.name).dark } : undefined}>{t.dir && r.kind === 'node' ? '▰' : ''}</span>
<span className="nm" title={t.path}>{t.name}{t.mount && t.dir && r.kind === 'node' ? <span className="muted small"> mount</span> : ''}{t.denied ? <span className="muted small"> unreadable</span> : ''}</span>
</span>
<span className="r mono">{fmtBytes(t.size)}</span>
<span className="pct"><span className="bar"><span style={{ width: `${Math.min(100, pct)}%` }} /></span><span className="muted small">{pct >= 10 ? pct.toFixed(0) : pct.toFixed(1)}%</span></span>
<span className="r c muted">{t.dir && r.kind === 'node' ? fmtN(t.files) : r.kind === 'small' ? fmtN(t.files) : ''}</span>
<span className="r c muted">{t.dir && r.kind === 'node' ? fmtN(t.dirs) : ''}</span>
<span className="r d muted small">{r.kind === 'node' ? fmtWhen(t.mtime) : ''}</span>
</div>
)
})}
</div>
</div>
<div className="space-map">
<div className="space-crumbs">
<button className="btn" disabled={focus === '/'} title="up one folder" onClick={() => select(parentOf(focus))}>↑</button>
{crumbs(focus).map((c) => <button key={c.path} className={'crumb' + (c.path === focus ? ' on' : '')} onClick={() => select(c.path)}>{c.name}</button>)}
<span className="spacer" />
{focused && <span className="muted small">{fmtBytes(focused.size)} · {root.size > 0 ? ((focused.size / root.size) * 100).toFixed(1) : 0}% of the disk's {fmtBytes(root.size)}</span>}
</div>
{map ? <Treemap tree={map} onOpen={select} /> : <Loading label="Laying it out…" />}
<div className="space-legend">
{KINDS.map((k) => <span key={k.id}><i style={{ background: lightTheme() ? k.light : k.dark }} />{k.label}</span>)}
<span><i className="dir" />Folder</span><span><i className="small" />Smaller files</span>
</div>
</div>
</div>
)
}
function crumbs(path: string): { name: string; path: string }[] {
const out = [{ name: '/', path: '/' }]
let p = ''
for (const part of path.split('/').filter(Boolean)) { p += '/' + part; out.push({ name: part, path: p }) }
return out
}
// ---- the treemap: squarified, nested three deep, drawn on a canvas ----
interface Tile { x: number; y: number; w: number; h: number; t: SpaceTree; agg?: 'small' | 'more'; depth: number; head?: number; inner?: boolean }
// squarify lays items (largest first) into rect, keeping tiles as square as it can.
function squarify(items: { size: number; t: SpaceTree; agg?: 'small' | 'more' }[], x: number, y: number, w: number, h: number, depth: number, out: Tile[]) {
const total = items.reduce((a, b) => a + b.size, 0)
if (total <= 0 || w <= 1 || h <= 1) return
let i = 0
let cx = x, cy = y, cw = w, ch = h
while (i < items.length) {
const vertical = cw < ch // lay the next row along the shorter side
const side = vertical ? cw : ch
const scale = (cw * ch) / items.slice(i).reduce((a, b) => a + b.size, 0)
let row: typeof items = []
let rowSum = 0
let worst = Infinity
for (let j = i; j < items.length; j++) {
const s = items[j].size * scale
if (s <= 0) { i = j + 1; continue }
const sum = rowSum + s
const thick = sum / side
let wst = 0
for (const r of [...row, items[j]]) { const len = (r.size * scale) / thick; wst = Math.max(wst, len / thick, thick / len) }
if (row.length && wst > worst) break
row.push(items[j]); rowSum = sum; worst = wst
}
if (row.length === 0) break
const thick = rowSum / side
let off = 0
for (const r of row) {
const len = (r.size * scale) / thick
const tile: Tile = vertical ? { x: cx, y: cy + off, w: thick, h: len, t: r.t, agg: r.agg, depth } : { x: cx + off, y: cy, w: len, h: thick, t: r.t, agg: r.agg, depth }
out.push(tile)
off += len
}
if (vertical) { cy += thick; ch -= thick } else { cx += thick; cw -= thick }
i += row.length
}
}
function layout(tree: SpaceTree, x: number, y: number, w: number, h: number, depth: number, out: Tile[]) {
const items: { size: number; t: SpaceTree; agg?: 'small' | 'more' }[] = (tree.children ?? []).filter((c) => c.size > 0).map((c) => ({ size: c.size, t: c }))
if (tree.small_n && tree.small_size) items.push({ size: tree.small_size, t: { ...tree, name: `${fmtN(tree.small_n)} smaller files`, dir: false, path: tree.path + '/*', children: undefined }, agg: 'small' })
if (tree.more_n && tree.more_size) items.push({ size: tree.more_size, t: { ...tree, name: `${fmtN(tree.more_n)} more items`, dir: false, path: tree.path + '/…', children: undefined }, agg: 'more' })
items.sort((a, b) => b.size - a.size)
const tiles: Tile[] = []
squarify(items, x, y, w, h, depth, tiles)
for (const t of tiles) {
out.push(t)
const kids = (t.t.children?.length ?? 0) + (t.t.small_size ? 1 : 0) + (t.t.more_size ? 1 : 0)
if (t.t.dir && kids > 0 && depth < 12 && t.w > 20 && t.h > 20) {
// a title strip when there is room for one; a tiny folder is just a box of its children
t.head = t.h > 40 && t.w > 40 ? 15 : 0
t.inner = true
layout(t.t, t.x + 2, t.y + 2 + t.head, t.w - 4, t.h - 4 - t.head, depth + 1, out)
}
}
}
function Treemap({ tree, onOpen }: { tree: SpaceTree; onOpen: (path: string) => void }) {
const box = useRef<HTMLDivElement>(null)
const canvas = useRef<HTMLCanvasElement>(null)
const tiles = useRef<Tile[]>([])
const [size, setSize] = useState({ w: 0, h: 0 })
const [hover, setHover] = useState<{ x: number; y: number; tile: Tile } | null>(null)
useEffect(() => {
const el = box.current
if (!el) return
const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight }))
ro.observe(el)
setSize({ w: el.clientWidth, h: el.clientHeight })
return () => ro.disconnect()
}, [])
useEffect(() => {
const c = canvas.current
if (!c || size.w === 0 || size.h === 0) return
const dpr = window.devicePixelRatio || 1
c.width = Math.round(size.w * dpr); c.height = Math.round(size.h * dpr)
const g = c.getContext('2d')
if (!g) return
g.scale(dpr, dpr)
const light = lightTheme()
const bg = cssVar('--bg2') || '#171b21', border = cssVar('--border') || '#2a313b', fg = cssVar('--fg') || '#d0d7de', muted = cssVar('--muted') || '#7d8590'
g.fillStyle = bg
g.fillRect(0, 0, size.w, size.h)
const out: Tile[] = []
layout(tree, 0, 0, size.w, size.h, 0, out)
tiles.current = out
const font = cssVar('--font') || 'system-ui, sans-serif'
for (const t of out) {
if (t.w < 1 || t.h < 1) continue
const isDir = t.t.dir && !t.agg
let fill: string
if (t.agg) fill = light ? '#c9ced4' : '#3a414b'
else if (isDir) fill = light ? (t.depth % 2 ? '#eef0f3' : '#e3e6ea') : (t.depth % 2 ? '#262d36' : '#1e242c')
else { const k = kindOf(t.t.name); fill = light ? k.light : k.dark }
g.fillStyle = fill
g.fillRect(t.x, t.y, t.w, t.h)
g.strokeStyle = isDir ? border : bg
g.lineWidth = 1
g.strokeRect(t.x + 0.5, t.y + 0.5, t.w - 1, t.h - 1)
// a label when there is room and it would not sit on the folder's own children: the
// name, and the size on a second line
const labelled = !isDir || !t.inner || (t.head ?? 0) > 0
if (labelled && t.w > 36 && t.h > 14) {
g.save()
g.beginPath(); g.rect(t.x + 1, t.y + 1, t.w - 2, t.h - 2); g.clip()
g.font = `${isDir ? '600 ' : ''}11px ${font}`
g.fillStyle = isDir || t.agg ? (isDir ? fg : muted) : (light ? '#111' : '#fff')
g.textBaseline = 'top'
g.fillText(t.t.name, t.x + 4, t.y + 2, t.w - 8)
if (t.h > 30 && !t.inner) { g.font = `10px ${font}`; g.fillStyle = isDir || t.agg ? muted : (light ? 'rgba(0,0,0,.7)' : 'rgba(255,255,255,.8)'); g.fillText(fmtBytes(t.t.size), t.x + 4, t.y + 16, t.w - 8) }
g.restore()
}
}
}, [tree, size])
const at = (e: React.MouseEvent) => {
const r = canvas.current!.getBoundingClientRect()
const x = e.clientX - r.left, y = e.clientY - r.top
// the deepest tile under the pointer
let best: Tile | null = null
for (const t of tiles.current) if (x >= t.x && x < t.x + t.w && y >= t.y && y < t.y + t.h && (!best || t.depth > best.depth)) best = t
return { x, y, tile: best }
}
return (
<div className="space-canvas" ref={box}>
<canvas ref={canvas} style={{ width: size.w, height: size.h }}
onMouseMove={(e) => { const h = at(e); setHover(h.tile ? { x: h.x, y: h.y, tile: h.tile } : null) }}
onMouseLeave={() => setHover(null)}
onClick={(e) => { const h = at(e); if (!h.tile) return; const t = h.tile.t; onOpen(t.dir && !h.tile.agg ? t.path : parentOf(t.path)) }} />
{hover && (
<div className="space-tip" style={{ left: Math.min(hover.x + 12, size.w - 260), top: hover.y + 12 }}>
<div className="mono small" style={{ wordBreak: 'break-all' }}>{hover.tile.agg ? hover.tile.t.name + ' in ' + parentOf(hover.tile.t.path) : hover.tile.t.path}</div>
<div><b>{fmtBytes(hover.tile.t.size)}</b> <span className="muted">· {tree.size > 0 ? ((hover.tile.t.size / tree.size) * 100).toFixed(1) : 0}% of {tree.name === '/' ? 'the disk' : tree.name}</span></div>
{hover.tile.t.dir && !hover.tile.agg && <div className="muted small">{fmtN(hover.tile.t.files)} files · {fmtN(hover.tile.t.dirs)} folders</div>}
{!hover.tile.t.dir && !hover.tile.agg && <div className="muted small">{kindOf(hover.tile.t.name).label} · {fmtWhen(hover.tile.t.mtime)}{hover.tile.t.apparent !== hover.tile.t.size ? ` · ${fmtBytes(hover.tile.t.apparent)} apparent` : ''}</div>}
</div>
)}
</div>
)
}
// ---- the largest files on the disk ----
function LargestFiles({ disk }: { disk: string }) {
const [files, setFiles] = useState<SpaceFile[] | null>(null)
const [filter, setFilter] = useState('')
useEffect(() => { api.spaceLargest(disk, 1000).then(setFiles).catch((e) => toast(e instanceof Error ? e.message : 'failed', 'err')) }, [disk])
if (!files) return <Loading label="Finding the largest files…" />
const q = filter.toLowerCase()
const shown = q ? files.filter((f) => f.path.toLowerCase().includes(q)) : files
const top = shown[0]?.size ?? 1
return (
<div className="space-files">
<div className="dash-bar"><input className="filter" placeholder="filter by path" value={filter} onChange={(e) => setFilter(e.target.value)} spellCheck={false} /><span className="muted small">the {fmtN(files.length)} largest files{q ? ` · ${fmtN(shown.length)} match` : ''}</span></div>
<div className="space-rows">
{shown.map((f) => (
<div key={f.path} className="space-row file">
<span className="n"><span className="ico" style={{ background: lightTheme() ? kindOf(f.path).light : kindOf(f.path).dark }} /><span className="nm mono" title={f.path}>{f.path}</span></span>
<span className="r mono">{fmtBytes(f.size)}</span>
<span className="pct"><span className="bar"><span style={{ width: `${(f.size / top) * 100}%` }} /></span></span>
<span className="r d muted small">{fmtWhen(f.mtime)}</span>
</div>
))}
</div>
</div>
)
}
+13
View File
@@ -126,6 +126,13 @@ export interface User { name: string; created: string; last_seen?: string; sessi
export interface Session { id: string; created: string; last_seen: string; expires: string; ip?: string; agent?: string; current: boolean }
export interface TokenInfo { name: string; created: string; scope?: string; sensors?: string[]; issued_by?: 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 SpaceDisk { name: string; kind: string; model?: string; size: number; pool?: 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 }
export interface FileEntry { name: string; type: 'dir' | 'file' | 'link' | 'other'; size: number; mode: string; perm: string; mtime: number; user: string; group: string; target?: string; broken?: boolean }
export interface FileInfo extends FileEntry { path: string; text: boolean }
@@ -133,6 +140,12 @@ const q = (path: string, sudo: boolean, extra = '') => `path=${encodeURIComponen
export const api = {
procs: () => req<ProcTable>('GET', '/api/procs'),
space: () => req<{ disks: SpaceDisk[] }>('GET', '/api/space'),
spaceStatus: (disk: string) => req<SpaceScan>('GET', `/api/space/${encodeURIComponent(disk)}`),
spaceScan: (disk: string) => req<SpaceScan>('POST', `/api/space/${encodeURIComponent(disk)}/scan`),
spaceCancel: (disk: string) => req<void>('POST', `/api/space/${encodeURIComponent(disk)}/cancel`),
spaceTree: (disk: string, path: string, depth = 1, limit = 200, min = 0) => req<SpaceTree>('GET', `/api/space/${encodeURIComponent(disk)}/tree?path=${encodeURIComponent(path)}&depth=${depth}&limit=${limit}&min=${min}`),
spaceLargest: (disk: string, n = 500) => req<SpaceFile[]>('GET', `/api/space/${encodeURIComponent(disk)}/largest?n=${n}`),
procSignal: (pid: number, signal: 'TERM' | 'KILL') => req<void>('POST', `/api/procs/${pid}/signal`, { signal }),
files: (path: string, sudo: boolean) => req<{ path: string; parent?: string; entries: FileEntry[] }>('GET', `/api/files?${q(path, sudo)}`),
fileStat: (path: string, sudo: boolean) => req<FileInfo>('GET', `/api/files/stat?${q(path, sudo)}`),
+40
View File
@@ -668,3 +668,43 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
.t-net { flex-direction: column; } .t-net-nums { width: auto; } .t-net-graphs { flex: 0 0 130px; }
.top-proc { height: 70vh; min-height: 420px; }
}
/* ---- 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-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-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; }
.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.head { position: sticky; top: 0; z-index: 1; background: var(--bg3); color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; cursor: default; }
.space-row:hover:not(.head) { background: var(--bg3); } .space-row.active { background: var(--bg3); box-shadow: inset 3px 0 0 var(--accent); }
.space-row.agg { color: var(--muted); font-style: italic; }
.space-row .n { display: flex; align-items: center; gap: 4px; min-width: 0; } .space-row .nm { overflow: hidden; text-overflow: ellipsis; }
.space-row .tw { width: 16px; flex-shrink: 0; color: var(--muted); padding: 0; font-size: 11px; } .space-row .tw:hover { color: var(--fg); }
.space-row .ico { width: 10px; height: 10px; flex-shrink: 0; border-radius: 2px; font-size: 9px; line-height: 10px; color: var(--accent2); text-align: center; }
.space-row .r { text-align: right; } .space-row .pct { display: flex; align-items: center; gap: 6px; }
.space-row .bar { flex: 1; height: 8px; background: var(--track); border-radius: 4px; overflow: hidden; } .space-row .bar > span { display: block; height: 100%; background: var(--s1); border-radius: 4px; }
.space-row.file { grid-template-columns: minmax(160px, 1fr) 84px 120px 88px; }
.space-map { flex: 1 1 45%; min-height: 200px; display: flex; flex-direction: column; gap: 6px; }
.space-crumbs { display: flex; align-items: center; gap: 2px; flex-wrap: wrap; font-size: 13px; }
.space-crumbs .crumb { padding: 2px 6px; border-radius: 4px; color: var(--muted); } .space-crumbs .crumb:hover { color: var(--fg); background: var(--bg3); } .space-crumbs .crumb.on { color: var(--fg); font-weight: 600; }
.space-crumbs .crumb + .crumb::before { content: '›'; color: var(--muted); margin-right: 4px; }
.space-canvas { flex: 1; position: relative; min-height: 160px; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; background: var(--bg2); }
.space-canvas canvas { display: block; cursor: pointer; }
.space-tip { position: absolute; z-index: 2; max-width: 260px; padding: 6px 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; font-size: 12px; pointer-events: none; box-shadow: 0 4px 16px rgba(0,0,0,.35); }
.space-legend { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 11px; color: var(--muted); }
.space-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 4px; vertical-align: -1px; }
.space-legend i.dir { background: var(--bg3); border: 1px solid var(--border); } .space-legend i.small { background: #3a414b; }
.space-files { flex: 1; display: flex; flex-direction: column; min-height: 0; padding: 8px 12px 12px; gap: 8px; }
.space-files .space-rows { border: 1px solid var(--border); border-radius: 6px; background: var(--bg2); }
.space-files input.filter { padding: 6px 10px; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 13px; width: min(320px, 100%); }
@media (max-width: 800px) {
.space-row { grid-template-columns: minmax(120px, 1fr) 72px 80px; } .space-row .c, .space-row .d { display: none; }
.space-row.file { grid-template-columns: minmax(120px, 1fr) 72px 80px; }
.space-split { padding: 8px; } .space-tree { flex: 1 1 45%; } .space-map { flex: 1 1 55%; }
}