dashboard-v2 PR 2: frontend SPA + serve integration
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 16s
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:
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import {
|
||||
api,
|
||||
BlobSummary,
|
||||
TagSummary,
|
||||
RefSummary,
|
||||
SnapshotSummary,
|
||||
fmtBytes,
|
||||
fmtAge,
|
||||
} from '../lib/api';
|
||||
|
||||
type Tab = 'blobs' | 'tags' | 'refs' | 'snapshots';
|
||||
|
||||
interface Props {
|
||||
tab: Tab;
|
||||
}
|
||||
|
||||
export function StorageBrowser({ tab }: Props) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-semibold text-slate-100">Storage · {tab}</h1>
|
||||
{tab === 'blobs' && <BlobsList />}
|
||||
{tab === 'tags' && <TagsList />}
|
||||
{tab === 'refs' && <RefsList />}
|
||||
{tab === 'snapshots' && <SnapshotsList />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlobsList() {
|
||||
const [rows, setRows] = useState<BlobSummary[]>([]);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
api.blobs().then(setRows).catch((e) => setErr(String(e)));
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
{err && <ErrorBox msg={err} />}
|
||||
<Table
|
||||
headers={['blob-id', 'size', 'chunks']}
|
||||
rows={rows.map((r) => [
|
||||
<span key="hex" className="font-mono text-xs">
|
||||
{r.blob_id_hex.slice(0, 24)}…
|
||||
</span>,
|
||||
fmtBytes(r.size_bytes),
|
||||
r.chunk_count.toString(),
|
||||
])}
|
||||
/>
|
||||
<Footer count={rows.length} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TagsList() {
|
||||
const [rows, setRows] = useState<TagSummary[]>([]);
|
||||
const [prefix, setPrefix] = useState('');
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
api.tags(prefix).then(setRows).catch((e) => setErr(String(e)));
|
||||
}, [prefix]);
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
value={prefix}
|
||||
onChange={(e) => setPrefix(e.target.value)}
|
||||
placeholder="filter prefix — e.g. clawverse:"
|
||||
className="w-full sm:w-96 rounded bg-slate-900 border border-slate-800 px-3 py-2 text-sm font-mono"
|
||||
/>
|
||||
{err && <ErrorBox msg={err} />}
|
||||
<Table
|
||||
headers={['key', 'blob-id']}
|
||||
rows={rows.map((r) => [
|
||||
<span key="k" className="font-mono text-sm">
|
||||
{r.key}
|
||||
</span>,
|
||||
<span key="v" className="font-mono text-xs text-slate-400">
|
||||
{r.value_hex.slice(0, 24)}…
|
||||
</span>,
|
||||
])}
|
||||
/>
|
||||
<Footer count={rows.length} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RefsList() {
|
||||
const [rows, setRows] = useState<RefSummary[]>([]);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
api.refs().then(setRows).catch((e) => setErr(String(e)));
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
{err && <ErrorBox msg={err} />}
|
||||
<Table
|
||||
headers={['fingerprint', 'blob-id']}
|
||||
rows={rows.map((r) => [
|
||||
<span key="fp" className="font-mono text-xs">
|
||||
{r.fingerprint_hex.slice(0, 24)}…
|
||||
</span>,
|
||||
<span key="b" className="font-mono text-xs text-slate-400">
|
||||
{r.blob_id_hex.slice(0, 24)}…
|
||||
</span>,
|
||||
])}
|
||||
/>
|
||||
<Footer count={rows.length} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SnapshotsList() {
|
||||
const [rows, setRows] = useState<SnapshotSummary[]>([]);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
api.snapshots().then(setRows).catch((e) => setErr(String(e)));
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
{err && <ErrorBox msg={err} />}
|
||||
<Table
|
||||
headers={['name', 'created', 'blobs', 'json size']}
|
||||
rows={rows.map((r) => [
|
||||
<span key="n" className="font-mono text-sm">
|
||||
{r.name}
|
||||
</span>,
|
||||
fmtAge(r.created_at_unix),
|
||||
r.blob_count.toString(),
|
||||
fmtBytes(r.file_bytes),
|
||||
])}
|
||||
/>
|
||||
<Footer count={rows.length} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Table({
|
||||
headers,
|
||||
rows,
|
||||
}: {
|
||||
headers: string[];
|
||||
rows: ReactNode[][];
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded border border-slate-800 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-900/60 text-slate-500 uppercase text-xs tracking-wider">
|
||||
<tr>
|
||||
{headers.map((h) => (
|
||||
<th key={h} className="text-left px-4 py-2 font-normal">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td className="px-4 py-6 text-center text-slate-500" colSpan={headers.length}>
|
||||
nothing here yet
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{rows.map((row, i) => (
|
||||
<tr key={i} className="border-t border-slate-900 hover:bg-slate-900/40">
|
||||
{row.map((cell, j) => (
|
||||
<td key={j} className="px-4 py-2">
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Footer({ count }: { count: number }) {
|
||||
return (
|
||||
<div className="text-xs text-slate-500 mt-2 font-mono">
|
||||
{count} row{count === 1 ? '' : 's'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorBox({ msg }: { msg: string }) {
|
||||
return (
|
||||
<div className="rounded border border-red-800 bg-red-950/40 p-3 text-red-300 text-sm mb-2">
|
||||
{msg}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user