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
+114
View File
@@ -0,0 +1,114 @@
import { useEffect, useState } from 'react';
import { api, NodeStatusV2, fmtBytes } from '../lib/api';
import { NodeCard } from '../components/NodeCard';
import { StatTile } from '../components/StatTile';
// Fleet-wide command center. Reads `/api/v2/node/local/status` from
// this node. Cross-node fan-out is server-side once PR 3 lands;
// until then we assume the operator hits any node's dashboard and
// sees that node's storage state plus quick nav to the others.
//
// Poll cadence: 10 s. Cheap: 5 filesystem walks.
export function CommandCenter() {
const [me, setMe] = useState<NodeStatusV2 | null>(null);
const [err, setErr] = useState<string | null>(null);
const [tick, setTick] = useState(0);
useEffect(() => {
api
.nodeStatus('local')
.then((n) => {
setMe(n);
setErr(null);
})
.catch((e) => setErr(String(e)));
}, [tick]);
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 10_000);
return () => clearInterval(id);
}, []);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-slate-100">Fleet</h1>
<div className="text-sm text-slate-500">
this dashboard was served by{' '}
<span className="font-mono text-slate-300">
{me?.node_name ?? '…'}
</span>
{' · click any node card to drill in'}
</div>
</div>
{err && (
<div className="rounded border border-red-800 bg-red-950/40 p-3 text-red-300 text-sm">
{err}
</div>
)}
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{me && (
<NodeCard
node={me}
loading={false}
error={null}
/>
)}
{['tank', 'architect', 'morpheus']
.filter((n) => n !== me?.node_name)
.map((n) => (
<NodeCard
key={n}
node={{
node_name: n,
blob_store_root: null,
blob_count: 0,
tag_count: 0,
ref_count: 0,
snapshot_count: 0,
ref_tracking_count: 0,
blob_store_bytes: 0,
}}
loading
error={null}
/>
))}
</section>
{me && (
<section>
<h2 className="text-lg font-semibold text-slate-100 mb-3">
This node
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<StatTile label="blobs" value={me.blob_count.toLocaleString()} color="ok" />
<StatTile
label="tags"
value={me.tag_count}
color={me.tag_count > 0 ? 'ok' : 'idle'}
/>
<StatTile
label="refs"
value={me.ref_count}
color={me.ref_count > 0 ? 'ok' : 'idle'}
/>
<StatTile label="snapshots" value={me.snapshot_count} color="ok" />
<StatTile
label="ref-tracking"
value={me.ref_tracking_count}
color={me.ref_tracking_count > 0 ? 'ok' : 'idle'}
/>
<StatTile
label="store size"
value={fmtBytes(me.blob_store_bytes)}
hint={me.blob_store_root ?? undefined}
color="ok"
/>
</div>
</section>
)}
</div>
);
}