Files
clawstor/dashboard-v2/src/pages/StorageBrowser.tsx
T
Omar Sobh b672037e42
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 3s
dashboard-v2 frontend: node column + clickable pills on storage tabs
All storage rows now carry a 'node' field from the aggregator.
Adds a small green-pill component that renders the source node
name; click drills into that node's detail page.

Also fixes the TS interfaces to match the new backend shape
(node field added to Blob/Tag/Ref/Snapshot/RefTracking types).
2026-07-14 16:44:06 -07:00

210 lines
5.5 KiB
TypeScript

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={['node', 'blob-id', 'size', 'chunks']}
rows={rows.map((r) => [
<NodePill key="n" node={r.node} />,
<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={['node', 'key', 'blob-id']}
rows={rows.map((r) => [
<NodePill key="n" node={r.node} />,
<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={['node', 'fingerprint', 'blob-id']}
rows={rows.map((r) => [
<NodePill key="n" node={r.node} />,
<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={['node', 'name', 'created', 'blobs', 'json size']}
rows={rows.map((r) => [
<NodePill key="node" node={r.node} />,
<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>
);
}
/// Small pill badge for the originating node — clickable to
/// drill into that node's detail page.
function NodePill({ node }: { node: string }) {
return (
<a
href={`#/nodes/${node}`}
className="inline-block rounded bg-slate-800 text-emerald-300 text-xs font-mono px-2 py-0.5 hover:bg-slate-700"
>
{node}
</a>
);
}