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
+44
View File
@@ -0,0 +1,44 @@
// Thin fetch wrappers around /api/v2/*. Kept minimal — no state
// library; each page owns its own useEffect + useState.
// The backend runs on the same origin the SPA loads from, so
// absolute URLs are unnecessary. Dev proxy handles the :5173 →
// :7700 hop.
async function get(path) {
const resp = await fetch(path, { headers: { Accept: 'application/json' } });
if (!resp.ok) {
throw new Error(`${path}${resp.status} ${resp.statusText}`);
}
return resp.json();
}
export const api = {
nodeStatus: (name = 'local') => get(`/api/v2/node/${name}/status`),
blobs: (limit = 200, offset = 0) => get(`/api/v2/storage/blobs?limit=${limit}&offset=${offset}`),
tags: (prefix = '') => get(`/api/v2/storage/tags?prefix=${encodeURIComponent(prefix)}`),
refs: (limit = 200, offset = 0) => get(`/api/v2/storage/refs?limit=${limit}&offset=${offset}`),
snapshots: () => get(`/api/v2/storage/snapshots`),
refTracking: (repo = '') => get(`/api/v2/storage/ref-tracking?repo=${encodeURIComponent(repo)}`),
};
/** Format bytes as MB / GB / TB as needed. */
export function fmtBytes(n) {
if (n < 1024)
return `${n} B`;
if (n < 1024 * 1024)
return `${(n / 1024).toFixed(1)} KiB`;
if (n < 1024 * 1024 * 1024)
return `${(n / (1024 * 1024)).toFixed(1)} MiB`;
if (n < 1024 * 1024 * 1024 * 1024)
return `${(n / (1024 * 1024 * 1024)).toFixed(1)} GiB`;
return `${(n / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TiB`;
}
/** Human-friendly relative time. */
export function fmtAge(unix) {
const now = Math.floor(Date.now() / 1000);
const diff = now - unix;
if (diff < 60)
return `${diff}s ago`;
if (diff < 3600)
return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400)
return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
+87
View File
@@ -0,0 +1,87 @@
// Thin fetch wrappers around /api/v2/*. Kept minimal — no state
// library; each page owns its own useEffect + useState.
export interface NodeStatusV2 {
node_name: string;
blob_store_root: string | null;
blob_count: number;
tag_count: number;
ref_count: number;
snapshot_count: number;
ref_tracking_count: number;
blob_store_bytes: number;
}
export interface BlobSummary {
blob_id_hex: string;
size_bytes: number;
chunk_count: number;
}
export interface TagSummary {
key: string;
value_hex: string;
}
export interface RefSummary {
fingerprint_hex: string;
blob_id_hex: string;
}
export interface SnapshotSummary {
name: string;
created_at_unix: number;
blob_count: number;
file_bytes: number;
}
export interface RefTrackingItem {
fingerprint_hex: string;
repo: string;
refs: string[];
first_seen_unix: number;
last_seen_unix: number;
}
// The backend runs on the same origin the SPA loads from, so
// absolute URLs are unnecessary. Dev proxy handles the :5173 →
// :7700 hop.
async function get<T>(path: string): Promise<T> {
const resp = await fetch(path, { headers: { Accept: 'application/json' } });
if (!resp.ok) {
throw new Error(`${path}${resp.status} ${resp.statusText}`);
}
return resp.json();
}
export const api = {
nodeStatus: (name = 'local') => get<NodeStatusV2>(`/api/v2/node/${name}/status`),
blobs: (limit = 200, offset = 0) =>
get<BlobSummary[]>(`/api/v2/storage/blobs?limit=${limit}&offset=${offset}`),
tags: (prefix = '') =>
get<TagSummary[]>(`/api/v2/storage/tags?prefix=${encodeURIComponent(prefix)}`),
refs: (limit = 200, offset = 0) =>
get<RefSummary[]>(`/api/v2/storage/refs?limit=${limit}&offset=${offset}`),
snapshots: () => get<SnapshotSummary[]>(`/api/v2/storage/snapshots`),
refTracking: (repo = '') =>
get<RefTrackingItem[]>(`/api/v2/storage/ref-tracking?repo=${encodeURIComponent(repo)}`),
};
/** Format bytes as MB / GB / TB as needed. */
export function fmtBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`;
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MiB`;
if (n < 1024 * 1024 * 1024 * 1024) return `${(n / (1024 * 1024 * 1024)).toFixed(1)} GiB`;
return `${(n / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TiB`;
}
/** Human-friendly relative time. */
export function fmtAge(unix: number): string {
const now = Math.floor(Date.now() / 1000);
const diff = now - unix;
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}