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
+25
View File
@@ -0,0 +1,25 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useMemo, useState } from 'react';
import { api, fmtAge } from '../lib/api';
export function RefTrackingPage() {
const [rows, setRows] = useState([]);
const [repoFilter, setRepoFilter] = useState('');
const [err, setErr] = useState(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();
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 (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-2xl font-semibold text-slate-100", children: "Ref-tracking" }), _jsxs("p", { className: "text-sm text-slate-500 mt-1", children: ["Every cached fingerprint's producing ", _jsx("span", { className: "font-mono", children: "(repo, git-ref)" }), ". Feeds the nightly ", _jsx("span", { className: "font-mono", children: "cluster-ref-sweep" }), " that reaps fingerprints whose refs are gone from Gitea."] })] }), _jsx("input", { value: repoFilter, onChange: (e) => setRepoFilter(e.target.value), placeholder: "filter repo \u2014 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 && (_jsx("div", { className: "rounded border border-red-800 bg-red-950/40 p-3 text-red-300 text-sm", children: err })), grouped.length === 0 && (_jsx("div", { className: "text-slate-500 text-sm py-6", children: "no ref-tracking entries yet" })), grouped.map(([repo, items]) => (_jsxs("div", { className: "rounded border border-slate-800 overflow-hidden", children: [_jsxs("div", { className: "bg-slate-900/60 px-4 py-2 flex items-baseline justify-between", children: [_jsx("span", { className: "font-mono text-sm text-emerald-300", children: repo }), _jsxs("span", { className: "text-xs text-slate-500", children: [items.length, " fingerprint", items.length === 1 ? '' : 's'] })] }), _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { className: "text-slate-500 uppercase text-xs tracking-wider", children: _jsxs("tr", { children: [_jsx("th", { className: "text-left px-4 py-2 font-normal", children: "fingerprint" }), _jsx("th", { className: "text-left px-4 py-2 font-normal", children: "refs" }), _jsx("th", { className: "text-left px-4 py-2 font-normal", children: "first seen" }), _jsx("th", { className: "text-left px-4 py-2 font-normal", children: "last seen" })] }) }), _jsx("tbody", { children: items.map((it) => (_jsxs("tr", { className: "border-t border-slate-900", children: [_jsxs("td", { className: "px-4 py-2 font-mono text-xs", children: [it.fingerprint_hex.slice(0, 24), "\u2026"] }), _jsx("td", { className: "px-4 py-2 font-mono text-xs", children: it.refs.join(', ') }), _jsx("td", { className: "px-4 py-2 text-slate-400", children: fmtAge(it.first_seen_unix) }), _jsx("td", { className: "px-4 py-2 text-slate-400", children: fmtAge(it.last_seen_unix) })] }, it.fingerprint_hex))) })] })] }, repo)))] }));
}