dashboard-v2 PR 2: frontend SPA + serve integration
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 16s

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.
This commit is contained in:
Omar Sobh
2026-07-14 15:59:47 -07:00
parent b431475af7
commit f33468b7c2
32 changed files with 3264 additions and 5 deletions
@@ -0,0 +1,95 @@
import { useEffect, useMemo, useState } from 'react';
import { api, RefTrackingItem, fmtAge } from '../lib/api';
export function RefTrackingPage() {
const [rows, setRows] = useState<RefTrackingItem[]>([]);
const [repoFilter, setRepoFilter] = useState('');
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
api
.refTracking(repoFilter)
.then(setRows)
.catch((e) => setErr(String(e)));
}, [repoFilter]);
// Group by repo for a cleaner display.
const grouped = useMemo(() => {
const g = new Map<string, RefTrackingItem[]>();
for (const r of rows) {
const arr = g.get(r.repo) ?? [];
arr.push(r);
g.set(r.repo, arr);
}
return [...g.entries()].sort((a, b) => a[0].localeCompare(b[0]));
}, [rows]);
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-semibold text-slate-100">Ref-tracking</h1>
<p className="text-sm text-slate-500 mt-1">
Every cached fingerprint's producing <span className="font-mono">(repo, git-ref)</span>.
Feeds the nightly <span className="font-mono">cluster-ref-sweep</span> that reaps
fingerprints whose refs are gone from Gitea.
</p>
</div>
<input
value={repoFilter}
onChange={(e) => setRepoFilter(e.target.value)}
placeholder="filter repo — e.g. clawverse/clawstor"
className="w-full sm:w-96 rounded bg-slate-900 border border-slate-800 px-3 py-2 text-sm font-mono"
/>
{err && (
<div className="rounded border border-red-800 bg-red-950/40 p-3 text-red-300 text-sm">
{err}
</div>
)}
{grouped.length === 0 && (
<div className="text-slate-500 text-sm py-6">no ref-tracking entries yet</div>
)}
{grouped.map(([repo, items]) => (
<div key={repo} className="rounded border border-slate-800 overflow-hidden">
<div className="bg-slate-900/60 px-4 py-2 flex items-baseline justify-between">
<span className="font-mono text-sm text-emerald-300">{repo}</span>
<span className="text-xs text-slate-500">
{items.length} fingerprint{items.length === 1 ? '' : 's'}
</span>
</div>
<table className="w-full text-sm">
<thead className="text-slate-500 uppercase text-xs tracking-wider">
<tr>
<th className="text-left px-4 py-2 font-normal">fingerprint</th>
<th className="text-left px-4 py-2 font-normal">refs</th>
<th className="text-left px-4 py-2 font-normal">first seen</th>
<th className="text-left px-4 py-2 font-normal">last seen</th>
</tr>
</thead>
<tbody>
{items.map((it) => (
<tr key={it.fingerprint_hex} className="border-t border-slate-900">
<td className="px-4 py-2 font-mono text-xs">
{it.fingerprint_hex.slice(0, 24)}…
</td>
<td className="px-4 py-2 font-mono text-xs">
{it.refs.join(', ')}
</td>
<td className="px-4 py-2 text-slate-400">
{fmtAge(it.first_seen_unix)}
</td>
<td className="px-4 py-2 text-slate-400">
{fmtAge(it.last_seen_unix)}
</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</div>
);
}