dashboard-v2 hotfix: relative asset base so /clawstor mount works
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 2s

Browser hit /clawstor/ then requested /v2/assets/index-XXX.css
(absolute path baked in by vite base '/v2/') which 404'd because
Tailscale Serve only mounted /clawstor and /clawstor/api. Switch
vite base to './' — assets resolve relative to whatever URL the
SPA loaded from. Works for local (:7700/v2/) + Tailscale
(/clawstor) with no config coupling.
This commit is contained in:
Omar Sobh
2026-07-14 16:28:21 -07:00
parent a2114b918d
commit 9f28ad5338
7 changed files with 159 additions and 120 deletions
+22 -23
View File
@@ -1,23 +1,22 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useState } from 'react';
import { api, fmtBytes } from '../lib/api';
import { api, fmtAge, 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.
// Fleet command center — the landing pane.
//
// Poll cadence: 10 s. Cheap: 5 filesystem walks.
// Aggregator polls each peer via cluster RPC every 10 s. Numbers
// are ground truth from the daemons themselves; no local
// blob-store reads on the aggregator.
export function CommandCenter() {
const [me, setMe] = useState(null);
const [fleet, setFleet] = useState(null);
const [err, setErr] = useState(null);
const [tick, setTick] = useState(0);
useEffect(() => {
api
.nodeStatus('local')
.then((n) => {
setMe(n);
.fleet()
.then((f) => {
setFleet(f);
setErr(null);
})
.catch((e) => setErr(String(e)));
@@ -26,16 +25,16 @@ export function CommandCenter() {
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" })] })] }))] }));
const totals = fleet
? fleet.nodes.reduce((a, n) => ({
blobs: a.blobs + n.blob_count,
tags: a.tags + n.tag_count,
refs: a.refs + n.ref_count,
snapshots: a.snapshots + n.snapshot_count,
bytes: a.bytes + n.blob_store_bytes,
online: a.online + (n.online ? 1 : 0),
}), { blobs: 0, tags: 0, refs: 0, snapshots: 0, bytes: 0, online: 0 })
: null;
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: ["aggregated by", ' ', _jsx("span", { className: "font-mono text-slate-300", children: fleet?.aggregator_name ?? '…' }), fleet && (_jsxs(_Fragment, { children: [' · updated ', _jsx("span", { className: "font-mono", children: fmtAge(fleet.fetched_at_unix) }), ' · ', _jsx("span", { className: "text-emerald-300", children: totals?.online }), "/", fleet.nodes.length, " online"] }))] })] }), err && (_jsx("div", { className: "rounded border border-red-800 bg-red-950/40 p-3 text-red-300 text-sm", children: err })), totals && fleet && (_jsxs("section", { children: [_jsx("h2", { className: "text-lg font-semibold text-slate-100 mb-3", children: "Totals (fleet-wide)" }), _jsxs("div", { className: "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3", children: [_jsx(StatTile, { label: "online nodes", value: `${totals.online}/${fleet.nodes.length}`, color: totals.online === fleet.nodes.length ? 'ok' : 'warn' }), _jsx(StatTile, { label: "blobs", value: totals.blobs.toLocaleString(), color: "ok" }), _jsx(StatTile, { label: "tags", value: totals.tags, color: "ok" }), _jsx(StatTile, { label: "refs", value: totals.refs, color: "ok" }), _jsx(StatTile, { label: "total bytes", value: fmtBytes(totals.bytes), color: "ok" })] })] })), _jsxs("section", { children: [_jsx("h2", { className: "text-lg font-semibold text-slate-100 mb-3", children: "Nodes" }), _jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4", children: [fleet?.nodes.map((n) => (_jsx(NodeCard, { node: n }, n.node_name))), !fleet &&
[1, 2, 3].map((i) => (_jsx("div", { className: "rounded-lg border border-slate-800 bg-slate-900/40 p-5 h-40 animate-pulse" }, i)))] })] })] }));
}
+58 -68
View File
@@ -1,24 +1,23 @@
import { useEffect, useState } from 'react';
import { api, NodeStatusV2, fmtBytes } from '../lib/api';
import { api, FleetSnapshot, fmtAge, 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.
// Fleet command center — the landing pane.
//
// Poll cadence: 10 s. Cheap: 5 filesystem walks.
// Aggregator polls each peer via cluster RPC every 10 s. Numbers
// are ground truth from the daemons themselves; no local
// blob-store reads on the aggregator.
export function CommandCenter() {
const [me, setMe] = useState<NodeStatusV2 | null>(null);
const [fleet, setFleet] = useState<FleetSnapshot | null>(null);
const [err, setErr] = useState<string | null>(null);
const [tick, setTick] = useState(0);
useEffect(() => {
api
.nodeStatus('local')
.then((n) => {
setMe(n);
.fleet()
.then((f) => {
setFleet(f);
setErr(null);
})
.catch((e) => setErr(String(e)));
@@ -29,16 +28,38 @@ export function CommandCenter() {
return () => clearInterval(id);
}, []);
const totals = fleet
? fleet.nodes.reduce(
(a, n) => ({
blobs: a.blobs + n.blob_count,
tags: a.tags + n.tag_count,
refs: a.refs + n.ref_count,
snapshots: a.snapshots + n.snapshot_count,
bytes: a.bytes + n.blob_store_bytes,
online: a.online + (n.online ? 1 : 0),
}),
{ blobs: 0, tags: 0, refs: 0, snapshots: 0, bytes: 0, online: 0 }
)
: null;
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{' '}
aggregated by{' '}
<span className="font-mono text-slate-300">
{me?.node_name ?? '…'}
{fleet?.aggregator_name ?? '…'}
</span>
{' · click any node card to drill in'}
{fleet && (
<>
{' · updated '}
<span className="font-mono">{fmtAge(fleet.fetched_at_unix)}</span>
{' · '}
<span className="text-emerald-300">{totals?.online}</span>
/{fleet.nodes.length} online
</>
)}
</div>
</div>
@@ -48,67 +69,36 @@ export function CommandCenter() {
</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 && (
{totals && fleet && (
<section>
<h2 className="text-lg font-semibold text-slate-100 mb-3">
This node
Totals (fleet-wide)
</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 className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<StatTile label="online nodes" value={`${totals.online}/${fleet.nodes.length}`} color={totals.online === fleet.nodes.length ? 'ok' : 'warn'} />
<StatTile label="blobs" value={totals.blobs.toLocaleString()} color="ok" />
<StatTile label="tags" value={totals.tags} color="ok" />
<StatTile label="refs" value={totals.refs} color="ok" />
<StatTile label="total bytes" value={fmtBytes(totals.bytes)} color="ok" />
</div>
</section>
)}
<section>
<h2 className="text-lg font-semibold text-slate-100 mb-3">Nodes</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{fleet?.nodes.map((n) => (
<NodeCard key={n.node_name} node={n} />
))}
{!fleet &&
[1, 2, 3].map((i) => (
<div
key={i}
className="rounded-lg border border-slate-800 bg-slate-900/40 p-5 h-40 animate-pulse"
/>
))}
</div>
</section>
</div>
);
}