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,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" })] })] }))] }));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ?? '—' })] })] }))] }));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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