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
+41
View File
@@ -0,0 +1,41 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useState } from 'react';
import { api, 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(null);
const [err, setErr] = useState(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 (_jsxs("div", { className: "space-y-6", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-2xl font-semibold text-slate-100", children: "Fleet" }), _jsxs("div", { className: "text-sm text-slate-500", children: ["this dashboard was served by", ' ', _jsx("span", { className: "font-mono text-slate-300", children: me?.node_name ?? '…' }), ' · click any node card to drill in'] })] }), err && (_jsx("div", { className: "rounded border border-red-800 bg-red-950/40 p-3 text-red-300 text-sm", children: err })), _jsxs("section", { className: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4", children: [me && (_jsx(NodeCard, { node: me, loading: false, error: null })), ['tank', 'architect', 'morpheus']
.filter((n) => n !== me?.node_name)
.map((n) => (_jsx(NodeCard, { 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: true, error: null }, n)))] }), me && (_jsxs("section", { children: [_jsx("h2", { className: "text-lg font-semibold text-slate-100 mb-3", children: "This node" }), _jsxs("div", { className: "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3", children: [_jsx(StatTile, { label: "blobs", value: me.blob_count.toLocaleString(), color: "ok" }), _jsx(StatTile, { label: "tags", value: me.tag_count, color: me.tag_count > 0 ? 'ok' : 'idle' }), _jsx(StatTile, { label: "refs", value: me.ref_count, color: me.ref_count > 0 ? 'ok' : 'idle' }), _jsx(StatTile, { label: "snapshots", value: me.snapshot_count, color: "ok" }), _jsx(StatTile, { label: "ref-tracking", value: me.ref_tracking_count, color: me.ref_tracking_count > 0 ? 'ok' : 'idle' }), _jsx(StatTile, { label: "store size", value: fmtBytes(me.blob_store_bytes), hint: me.blob_store_root ?? undefined, color: "ok" })] })] }))] }));
}
+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>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useEffect, useState } from 'react';
import { Link } from 'wouter';
import { api, fmtBytes } from '../lib/api';
import { StatTile } from '../components/StatTile';
export function NodeDetail({ name }) {
const [status, setStatus] = useState(null);
const [err, setErr] = useState(null);
useEffect(() => {
api
.nodeStatus(name)
.then((n) => {
setStatus(n);
setErr(null);
})
.catch((e) => setErr(String(e)));
}, [name]);
return (_jsxs("div", { className: "space-y-6", children: [_jsxs("div", { children: [_jsx(Link, { href: "/", children: _jsx("a", { className: "text-sm text-slate-500 hover:text-slate-300", children: "\u2190 fleet" }) }), _jsx("h1", { className: "text-2xl font-semibold text-slate-100 mt-2", children: name })] }), err && (_jsxs("div", { className: "rounded border border-amber-800 bg-amber-950/40 p-3 text-amber-300 text-sm", children: [err, _jsxs("div", { className: "mt-2 text-xs text-slate-400", children: ["Cross-node lookup lands in a follow-on PR. Until then this page shows detail only when you're already viewing the dashboard hosted by ", name, ". Try opening", ' ', _jsxs("span", { className: "font-mono", children: ["http://", name, ":7700/v2/#/nodes/", name] }), ' ', "directly."] })] })), status && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3", children: [_jsx(StatTile, { label: "blobs", value: status.blob_count.toLocaleString(), color: "ok" }), _jsx(StatTile, { label: "tags", value: status.tag_count, color: "ok" }), _jsx(StatTile, { label: "refs", value: status.ref_count, color: "ok" }), _jsx(StatTile, { label: "snapshots", value: status.snapshot_count, color: "ok" }), _jsx(StatTile, { label: "ref-tracking", value: status.ref_tracking_count, color: "ok" }), _jsx(StatTile, { label: "store size", value: fmtBytes(status.blob_store_bytes), color: "ok" })] }), _jsxs("div", { className: "rounded border border-slate-800 bg-slate-900/40 p-4 text-sm", children: [_jsx("div", { className: "text-slate-500 uppercase text-xs tracking-wider", children: "blob store root" }), _jsx("div", { className: "font-mono mt-1", children: status.blob_store_root ?? '—' })] })] }))] }));
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useState } from 'react';
import { Link } from 'wouter';
import { api, NodeStatusV2, fmtBytes } from '../lib/api';
import { StatTile } from '../components/StatTile';
interface Props {
name: string;
}
export function NodeDetail({ name }: Props) {
const [status, setStatus] = useState<NodeStatusV2 | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
api
.nodeStatus(name)
.then((n) => {
setStatus(n);
setErr(null);
})
.catch((e) => setErr(String(e)));
}, [name]);
return (
<div className="space-y-6">
<div>
<Link href="/">
<a className="text-sm text-slate-500 hover:text-slate-300">
fleet
</a>
</Link>
<h1 className="text-2xl font-semibold text-slate-100 mt-2">
{name}
</h1>
</div>
{err && (
<div className="rounded border border-amber-800 bg-amber-950/40 p-3 text-amber-300 text-sm">
{err}
<div className="mt-2 text-xs text-slate-400">
Cross-node lookup lands in a follow-on PR. Until then this
page shows detail only when you're already viewing the
dashboard hosted by {name}. Try opening{' '}
<span className="font-mono">http://{name}:7700/v2/#/nodes/{name}</span>{' '}
directly.
</div>
</div>
)}
{status && (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<StatTile label="blobs" value={status.blob_count.toLocaleString()} color="ok" />
<StatTile label="tags" value={status.tag_count} color="ok" />
<StatTile label="refs" value={status.ref_count} color="ok" />
<StatTile label="snapshots" value={status.snapshot_count} color="ok" />
<StatTile label="ref-tracking" value={status.ref_tracking_count} color="ok" />
<StatTile
label="store size"
value={fmtBytes(status.blob_store_bytes)}
color="ok"
/>
</div>
<div className="rounded border border-slate-800 bg-slate-900/40 p-4 text-sm">
<div className="text-slate-500 uppercase text-xs tracking-wider">
blob store root
</div>
<div className="font-mono mt-1">{status.blob_store_root ?? ''}</div>
</div>
</>
)}
</div>
);
}
+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)))] }));
}
@@ -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>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
import { useEffect, useState } from 'react';
import { api, fmtBytes, fmtAge, } from '../lib/api';
export function StorageBrowser({ tab }) {
return (_jsxs("div", { className: "space-y-4", children: [_jsxs("h1", { className: "text-2xl font-semibold text-slate-100", children: ["Storage \u00B7 ", tab] }), tab === 'blobs' && _jsx(BlobsList, {}), tab === 'tags' && _jsx(TagsList, {}), tab === 'refs' && _jsx(RefsList, {}), tab === 'snapshots' && _jsx(SnapshotsList, {})] }));
}
function BlobsList() {
const [rows, setRows] = useState([]);
const [err, setErr] = useState(null);
useEffect(() => {
api.blobs().then(setRows).catch((e) => setErr(String(e)));
}, []);
return (_jsxs(_Fragment, { children: [err && _jsx(ErrorBox, { msg: err }), _jsx(Table, { headers: ['blob-id', 'size', 'chunks'], rows: rows.map((r) => [
_jsxs("span", { className: "font-mono text-xs", children: [r.blob_id_hex.slice(0, 24), "\u2026"] }, "hex"),
fmtBytes(r.size_bytes),
r.chunk_count.toString(),
]) }), _jsx(Footer, { count: rows.length })] }));
}
function TagsList() {
const [rows, setRows] = useState([]);
const [prefix, setPrefix] = useState('');
const [err, setErr] = useState(null);
useEffect(() => {
api.tags(prefix).then(setRows).catch((e) => setErr(String(e)));
}, [prefix]);
return (_jsxs(_Fragment, { children: [_jsx("input", { value: prefix, onChange: (e) => setPrefix(e.target.value), placeholder: "filter prefix \u2014 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 && _jsx(ErrorBox, { msg: err }), _jsx(Table, { headers: ['key', 'blob-id'], rows: rows.map((r) => [
_jsx("span", { className: "font-mono text-sm", children: r.key }, "k"),
_jsxs("span", { className: "font-mono text-xs text-slate-400", children: [r.value_hex.slice(0, 24), "\u2026"] }, "v"),
]) }), _jsx(Footer, { count: rows.length })] }));
}
function RefsList() {
const [rows, setRows] = useState([]);
const [err, setErr] = useState(null);
useEffect(() => {
api.refs().then(setRows).catch((e) => setErr(String(e)));
}, []);
return (_jsxs(_Fragment, { children: [err && _jsx(ErrorBox, { msg: err }), _jsx(Table, { headers: ['fingerprint', 'blob-id'], rows: rows.map((r) => [
_jsxs("span", { className: "font-mono text-xs", children: [r.fingerprint_hex.slice(0, 24), "\u2026"] }, "fp"),
_jsxs("span", { className: "font-mono text-xs text-slate-400", children: [r.blob_id_hex.slice(0, 24), "\u2026"] }, "b"),
]) }), _jsx(Footer, { count: rows.length })] }));
}
function SnapshotsList() {
const [rows, setRows] = useState([]);
const [err, setErr] = useState(null);
useEffect(() => {
api.snapshots().then(setRows).catch((e) => setErr(String(e)));
}, []);
return (_jsxs(_Fragment, { children: [err && _jsx(ErrorBox, { msg: err }), _jsx(Table, { headers: ['name', 'created', 'blobs', 'json size'], rows: rows.map((r) => [
_jsx("span", { className: "font-mono text-sm", children: r.name }, "n"),
fmtAge(r.created_at_unix),
r.blob_count.toString(),
fmtBytes(r.file_bytes),
]) }), _jsx(Footer, { count: rows.length })] }));
}
function Table({ headers, rows, }) {
return (_jsx("div", { className: "rounded border border-slate-800 overflow-hidden", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { className: "bg-slate-900/60 text-slate-500 uppercase text-xs tracking-wider", children: _jsx("tr", { children: headers.map((h) => (_jsx("th", { className: "text-left px-4 py-2 font-normal", children: h }, h))) }) }), _jsxs("tbody", { children: [rows.length === 0 && (_jsx("tr", { children: _jsx("td", { className: "px-4 py-6 text-center text-slate-500", colSpan: headers.length, children: "nothing here yet" }) })), rows.map((row, i) => (_jsx("tr", { className: "border-t border-slate-900 hover:bg-slate-900/40", children: row.map((cell, j) => (_jsx("td", { className: "px-4 py-2", children: cell }, j))) }, i)))] })] }) }));
}
function Footer({ count }) {
return (_jsxs("div", { className: "text-xs text-slate-500 mt-2 font-mono", children: [count, " row", count === 1 ? '' : 's'] }));
}
function ErrorBox({ msg }) {
return (_jsx("div", { className: "rounded border border-red-800 bg-red-950/40 p-3 text-red-300 text-sm mb-2", children: msg }));
}
+192
View File
@@ -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>
);
}