A WinDirStat-style disk usage visualizer for a NAS, running as a single Docker container.
It scans configured roots in the background into a persistent SQLite store and serves the tree to the browser in slices — the client fetches only the directories currently on screen and navigates them like a map (continuous pan/zoom), so a multi-million-file share never has to be held in memory on either side.
- server — h3 v2 API. A background scanner (
worker_threads) walks each root, streaming into an embeddednode:sqlitestore; a scheduler refreshes on a staleness interval within operator-configured wall-clock windows. The API serves one directory level at a time (GET /api/tree,POST /api/tree/batch) with generation-pinned reads. Built with tsdown. - client — Vue 3 + Vite SPA. Renders a
d3-zoompan/zoom treemap to a canvas, fetching directory tiles lazily as zoom reveals them (level-of-detail by on-screen size); breadcrumbs and the file-list pane are derived from the camera. - shared — TypeScript types shared between server and client (no runtime code).
See docs/ for the design notes behind this architecture — most recently issue 0002 (the background service + slice store) and feature 0002 (the pan/zoom map).
pnpm install
pnpm dev # server on :3000, client (Vite) on :5173, proxying /apiThe dev server writes its store to ./data/webdirstat.db by default (override with DB_PATH).
pnpm build
ROOTS="Data=/some/path" DB_PATH=./data/wds.db CLIENT_DIST=./client/dist node server/dist/index.jsPublished on Docker Hub as treewyrm/webdirstat
(multi-arch linux/amd64 + linux/arm64; tags latest, X.Y.Z, X.Y):
docker run -p 8080:8080 \
-e PUID=1000 -e PGID=1000 \
-v /volume1/media:/data:ro \
-v webdirstat-db:/db \
treewyrm/webdirstat:latestThen open http://SERVER-IP:8080/ and press Start to run the first scan.
Mount the scanned shares read-only — the app only ever reads them — but give the store a
writable volume (DB_PATH defaults to /db/webdirstat.db in the image; never point it at
the read-only share). Set PUID/PGID (default 1000) to the owner of the /db volume: the
container remaps its runtime user to those ids and chowns the store on start, so it's writable
without a manual chown. See docker-compose.yml for a multi-share example.
To build the image yourself instead of pulling it:
docker build -t webdirstat .| Var | Default | Purpose |
|---|---|---|
ROOTS |
Data=/data |
Comma-separated roots. Each is either Label=/host/path or a bare /host/path (label derived from the basename). Since = and , are legal in paths, a path with , — or an unlabeled path with = — must use the Label=/path form. |
DB_PATH |
./data/webdirstat.db (/db/webdirstat.db in Docker) |
SQLite store file. Must be on writable storage. |
PUID / PGID |
1000 / 1000 |
Docker only: uid/gid the app runs as. Set to the owner of the /db volume; the entrypoint remaps the runtime user and chowns /db on start. |
PORT / HOST |
3000 / 0.0.0.0 (8080 in Docker) |
Listen address. |
CLIENT_DIST |
unset in dev | Directory of the built SPA; when set the server also serves the client. |
SCAN_INTERVAL |
unset | Max-staleness target (e.g. 6h); a rescan is wanted once data is older. |
SCAN_WINDOWS |
unset | Wall-clock windows a rescan is allowed in, e.g. "Mon-Fri 01:00-05:00; Sat,Sun 00:00-08:00". |
SCAN_CONCURRENCY |
4 |
In-flight syscalls per walk (disk pressure). |
SCAN_MIN_INTERVAL |
1h |
Hard floor between scans. |
SCAN_ON_WINDOW_END |
finish |
finish lets a running scan complete past its window; abort cancels it. |
HISTORY_GENERATIONS |
0 |
Full past generations to retain (0 = keep none). |
SCAN_ENABLED |
inferred | Master switch for automatic scans; defaults on if SCAN_INTERVAL or SCAN_WINDOWS is set. Manual scans always work. |
PASSWORD |
unset (open) | When set, enables a shared-password gate: every /api/** call needs a session cookie obtained from a login form. Unset = no gate. |
SESSION_SECRET |
ephemeral | Key (≥32 chars) that seals the session cookie. Unset → a random key is generated at startup (logins reset on restart); set it to keep sessions across restarts. |
These seed the per-root defaults; the schedule is editable per root in the UI and persisted in the store. With no schedule configured the service is manual-only (Start/Stop) over the same slice store.
- Symlinks are never followed (avoids cycles and escaping mounted volumes); they show up as their own zero-size tile.
- Scan data is always present with a "last scanned N ago" freshness stamp — the UI never blocks on a scan to browse.
- Very large trees are handled by the slice/tile model and level-of-detail rendering: the browser only ever holds what's on screen, so a multi-million-file NAS stays navigable.
- The app is unauthenticated by default. Set
PASSWORDto enable the shared-password gate; the cookie works over plain-HTTP LAN, so put TLS in front for anything wider. See feature 0001.
The HTTP API is documented in docs/api.md.