React 19 + Vite + Tailwind + wouter (tiny router, no external
state library). Consumes the /api/v2/* endpoints shipped in PR 1.
Serves under /v2/* so the legacy dashboard at / stays live.
Pages:
* CommandCenter (/) — fleet strip + this-node stat tiles
* NodeDetail (/nodes/:name) — per-node deep dive
* StorageBrowser (/storage/{blobs,tags,refs,snapshots}) — tables
with prefix filter
* RefTrackingPage (/refs/tracking) — grouped by repo
Backend changes:
* claw-store serve grows --v2-static-dir <path>
* build_app split into build_app_with_v2 for the extra static
mount
* /v2/* falls through to index.html so wouter client routing works
New systemd unit: clawstor-dashboard.service. Points at both
static dirs; installs on any node.
dashboard/ (legacy) untouched. dashboard-v2/ built to
target/dashboard-v2/dist for deploy.
Deploy sequence per node:
1. cp target/release/claw-store ~/clawstor-deploy/
2. rsync dashboard-v2/dist/ ~/clawstor-deploy/dashboard-v2/
3. cp deploy/systemd/clawstor-dashboard.service ~/.config/systemd/user/
4. systemctl --user daemon-reload && enable --now clawstor-dashboard.service
Cross-node fan-out for /api/v2/node/:name/status is PR 3.
Action POSTs (scrub/gc/snapshot/pin) are PR 4.
deploy/scripts/fleet-status.sh iterates NODES (default 'tank
architect morpheus') and prints per-host: daemon state, FUSE
mount + layer listing, blob-store byte count, and each timer's
next-fire + last-result.
Fits in half a screen per node; useful as a smoke check before
+ after any fleet-wide change. Read-only over ssh — safe to
run from any workstation with keys.
Live smoke on morpheus with the previous unit failed:
Error: mounting FUSE at /home/osobh/clawstor-mount
Caused by: Operation not permitted (os error 1)
But 'systemd-run --user' with the exact same binary worked, and
the manual invocation worked. The delta was our ExecStartPre
chain (mkdir + fusermount3 -u -z) — on some Ubuntu 24.04 builds
that combination poisons the subsequent mount syscall even with
the -z lazy flag + ignored exit code.
Fix: drop the pre-mkdir + pre-unmount. Operator creates the
mount dir manually once (documented in README). Restart handling
falls to systemd's Restart=on-failure + ExecStop unmount.
Verified live on morpheus with the minimal unit — active +
mount visible in <3s.
Cargo.toml: fuser dep now target-gated to Linux + macOS. macOS
build requires macFUSE (brew install --cask macfuse) + pkg-config
before 'cargo build --features fuse' works.
deploy/macos/README.md — one-time prereqs, build steps, mount /
umount, known differences (no AllowOther, unmount is 'umount'
not fusermount3 -u).
deploy/macos/claw-fuse.plist — launchd agent template
(RunAtLoad + KeepAlive) so ghost / macbook / smith can run the
mount the same way tank/architect do under systemd.
Default 'cargo build' (no --features fuse) still works on macOS
with no macFUSE installed — feature gate keeps the dep opt-in.
Historically every bin (claw-store, claw-cargo, claw-fuse)
re-declared the same 13-line 'mod ...' block at its own root.
Working but fragile — a new bin (or a new module) needed edits
in N+1 places and stayed one edit-slip away from silently
dropping something.
Now: src/lib.rs owns the shared module tree. Cargo.toml gets a
[lib] entry so cargo picks it up. claw_fuse.rs migrated as the
proof-of-concept — 13 mod lines → 4 use lines.
claw-store + claw-cargo bins still use their internal 'mod ...'
blocks and 'crate::...' paths — migrating them is mechanical
but noisy; deferred to a follow-on so this PR stays reviewable.
Both bins + tests continue to build unchanged.
387 tests pass.
deploy/scripts/rotate-snapshots.sh creates daily-YYYY-MM-DD
snapshot + prunes daily-* older than RETAIN_DAYS. Only touches
its own daily-* namespace so hand-created snapshots (release
anchors etc) never get reaped.
02:00 timer runs ahead of the 03:15 ref-sweep + 03:30 gc so
tonight's fresh snapshot pins protect its blobs from eviction.
DRY_RUN=1 for preview.
Default remains dry-run. --apply iterates the stale set and calls
RefTracking::forget per fp. Blob eviction stays a separate step
(next cluster-gc). Errors are surfaced per-fp, batch continues.
03:15 local, Persistent=true. Runs ahead of the 03:30 GC so
operators see the stale-fingerprint report before eviction lands.
Environment= drives the Gitea URL; token expected in a drop-in
(clawstor-ref-sweep.service.d/token.conf) so it doesn't sit in
the unit file. README + install recipe updated.
03:30 local, Persistent=true. Ordered before the Sun 04:00
scrub so scrub reads a fresh post-GC layout.
Default ExecStart is orphan-chunk sweep only (safe on any
node). Fleets that want LRU size-cap eviction add a drop-in:
systemctl --user edit clawstor-gc.service
[Service]
ExecStart=
ExecStart=%h/clawstor-deploy/claw-store --config ... cluster-gc --evict-to-gb 200
README updated.
Deploy artifact. Sunday 04:00 local, Persistent=true so a
box that missed the wall-clock moment fires on next boot.
Timer + Service pair — both go under
~/.config/systemd/user/. The service is Type=oneshot; timer
drives it. Non-zero exit from cluster-scrub (integrity issue)
surfaces via systemd failed state; journalctl has the details.
README updated with install recipe.
<mount>/refs/<fp-hex> file, content = blob that fp points at
Adds RefStore::list() unioning legacy refs/ and stamped refs-v2/.
FUSE wires the new dir. On lookup, tries get_stamped first then
legacy — matches the resolution order used by claw-cargo build.
Ref files piggy-back the blob inode (same content), so a hex
readable via /blobs/<hex> and /refs/<fp> shares the inode. cheap.
status=203/EXEC means the exec path didn't resolve. systemd
expands %h (home) at parse time but does NOT expand ${VAR}
in the ExecStart executable position — only in ExecStart args.
Rewrote to use %h/clawstor-deploy/claw-fuse directly.
Deploy artifact — not built into the binary tree. `deploy/systemd/
clawstor-fuse.service` + README with the install recipe.
Unit shape:
* Type=simple, blocks on the FUSE binary (unmount = SIGTERM).
* ExecStartPre = mkdir -p mount, best-effort lazy unmount of any
stale prior mount (guards against a hard SIGKILL leaving the
kernel with a dangling mount).
* Restart=on-failure, RestartSec=5 — recover from transient IO
errors without operator involvement.
* Environment= for CLAWSTOR_FUSE_BIN / DATA / MOUNT so a
`systemctl edit` drop-in retargets without editing the unit file.
Dependency chain: clawstor-fuse.service After= + Wants=
clawstor-cluster.service. FUSE reads only the on-disk state so
this is technically not needed for correctness — but keeps the
mount from spinning up on a node where the daemon is broken.
Companion to the previous list/get fix. Same layer-split problem:
* delete() only unlinked tags/, leaving stamped tags-v2/ behind.
Result: `unpin` prints \"no such tag\" for pins created via
Phase 3c+ RPC even though the tag is right there on disk.
* contains() only checked tags/. Same false-negative.
Fix: both APIs now inspect BOTH layers. delete() unlinks
whichever files exist (either or both) AND removes the TTL
expiry sidecar if present. Returns true when anything was
actually removed.
Legacy behavior preserved: tests unchanged, 381 tests pass.
Live smoke exposed the gap: `claw-cargo pin` writes to the
stamped store (tags-v2/, Phase 3c+) but `TagStore::list()` +
`get()` only walked the legacy `tags/` layer. Modern pins were
invisible to everything using those APIs — including the new
FUSE tag layer, but also `claw-cargo list-tags`.
Fix:
* list() now unions legacy + stamped entries (deduped by key).
New private list_stamped_only() walks tags-v2/.
* get() falls through to get_stamped() when the legacy file is
absent — modern pins resolve without callers knowing which
layer stored them.
No API breakage: legacy tests still pass unchanged.
381 tests pass.
<mount>/tags/<sanitized-name> ← file, content = current tag's blob
Layers atop the Phase 6a/6b mount. Operator can now `cat` a named
build cache without translating a tag → blob-id first:
cat <mount>/tags/clawverse:main:latest-cache | tar -tvzf -
Slash → underscore for filenames (tag keys like `a/b:c` land as
`a_b:c`), tags with control chars or NUL are dropped from the
listing. Non-existent value blobs also drop.
Tag file inodes 1_000..9_999. Each getattr / read re-resolves
the tag key (they're mutable — a `pin --replace` under a tag
should show the new blob without unmount).
Reused alloc_blob_ino so tag reads share inodes with /blobs/<hex>
where possible. TagStore opened at `<data_dir>/tags-db` matching
the daemon's convention.
Extends the read-only FUSE mount with a `snapshots/` tree:
<mount>/snapshots/<name>/ ← dir per snapshot
<mount>/snapshots/<name>/<blob-id-hex> ← file, content = assembled blob
Lets an operator browse a point-in-time capture by name — `ls`,
`find`, `sha256sum` all just work.
Implementation:
* Inode partitioning: 1 = /, 2 = /blobs, 3 = /snapshots,
10_000..99_999 = snapshot dirs (lazy allocation), 100_000+
= blob files (shared with the /blobs tree — same blob has
the same inode whether reached via /blobs or /snapshots/<n>).
* lookup on /snapshots/<name> validates the snapshot exists via
SnapshotStore::get.
* lookup on /snapshots/<name>/<blob-hex> validates both that
the snapshot references that blob AND that the blob is on
disk — no stale symlinks.
* Reused the shared alloc_blob_ino helper so the /blobs and
/snapshots trees hand out identical inodes for the same blob.
No new tests: FUSE is integration-heavy and the underlying
snapshot + blob primitives are already covered.
First slice of Phase 6. Ships a minimal, feature-gated `claw-fuse`
binary that mounts the local blob store read-only as a POSIX
filesystem:
<mount>/blobs/<blob-id-hex> ← file, content = assembled blob
<mount>/blobs/ ← dir, ls shows all blob-ids
<mount>/ ← dir, contains `blobs`
Lets an operator `tar -tvzf`, `md5sum`, or grep at a cached
tarball without wiring a client. Debug + audit tool for now;
warm-tier git-worktrees + write path come in later slices.
Feature-gated so my macOS dev box doesn't need macFUSE headers
to build the rest of the tree:
* Cargo.toml declares `[[bin]] name = "claw-fuse"` with
`required-features = ["fuse"]`.
* Feature `fuse` pulls in `fuser = "0.15"`, target-restricted
to `cfg(target_os = "linux")` — dep resolution never
considers fuser on other platforms.
* `cargo build` (default) leaves claw-fuse out entirely.
`cargo build --features fuse --bin claw-fuse` on Linux builds it.
Design notes baked into the impl:
* Inode allocation is lazy — first `lookup` for a hex assigns an
inode. Avoids pre-indexing the full blob store at mount time
which would be O(blobs) fs walk before FUSE is even ready.
* getattr / read validate that the manifest exists on every
call — no stale-inode reads if a blob is GC'd out from under
us mid-mount. Extra read cost is negligible against the
per-request FUSE overhead.
* size = manifest.total_size (bytes reported without touching
chunk files) so `ls -l` is cheap.
* runtime = current-thread tokio, block_on per callback. fuser
is sync; a full tokio worker pool would just add scheduling
overhead when callbacks are already serialized by the kernel.
No new tests here — Filesystem impls are integration-heavy and
the underlying BlobStore methods are already covered. The
`fuse` feature build itself will be smoke-tested on tank.
381 tests pass unchanged (feature-gated bin doesn't affect the
existing test surface).