dashboard-v2 hotfix: relative asset base #95

Merged
osobh merged 1 commits from dashboard-v2-relative-base into main 2026-07-14 23:28:27 +00:00
7 changed files with 159 additions and 120 deletions
+3 -2
View File
@@ -2,7 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { Link } from 'wouter'; import { Link } from 'wouter';
import { fmtBytes } from '../lib/api'; import { fmtBytes } from '../lib/api';
export function NodeCard({ node, loading, error }) { export function NodeCard({ node, loading, error }) {
const health = error ? 'err' : loading ? 'idle' : 'ok'; const health = error || !node.online ? 'err' : loading ? 'idle' : 'ok';
const displayError = error ?? (!node.online ? node.error : null);
const ring = { const ring = {
ok: 'ring-emerald-500/60', ok: 'ring-emerald-500/60',
warn: 'ring-amber-500/60', warn: 'ring-amber-500/60',
@@ -13,5 +14,5 @@ export function NodeCard({ node, loading, error }) {
'block rounded-lg border border-slate-800 bg-slate-900 p-5', 'block rounded-lg border border-slate-800 bg-slate-900 p-5',
'ring-1 hover:bg-slate-800/70 transition-colors', 'ring-1 hover:bg-slate-800/70 transition-colors',
ring, ring,
].join(' '), children: [_jsxs("div", { className: "flex items-baseline justify-between", children: [_jsx("div", { className: "text-lg font-semibold text-slate-100", children: node.node_name }), _jsx("div", { className: "text-xs text-slate-500 font-mono", children: error ? 'error' : loading ? 'loading' : 'online' })] }), error ? (_jsx("div", { className: "mt-3 text-sm text-red-400", children: error })) : (_jsxs("div", { className: "mt-3 grid grid-cols-2 gap-x-4 gap-y-1 text-sm", children: [_jsx("div", { className: "text-slate-500", children: "blobs" }), _jsx("div", { className: "font-mono text-right", children: node.blob_count.toLocaleString() }), _jsx("div", { className: "text-slate-500", children: "tags" }), _jsx("div", { className: "font-mono text-right", children: node.tag_count }), _jsx("div", { className: "text-slate-500", children: "refs" }), _jsx("div", { className: "font-mono text-right", children: node.ref_count }), _jsx("div", { className: "text-slate-500", children: "snapshots" }), _jsx("div", { className: "font-mono text-right", children: node.snapshot_count }), _jsx("div", { className: "text-slate-500", children: "ref-tracking" }), _jsx("div", { className: "font-mono text-right", children: node.ref_tracking_count }), _jsx("div", { className: "text-slate-500", children: "store size" }), _jsx("div", { className: "font-mono text-right", children: fmtBytes(node.blob_store_bytes) })] }))] }) })); ].join(' '), children: [_jsxs("div", { className: "flex items-baseline justify-between", children: [_jsx("div", { className: "text-lg font-semibold text-slate-100", children: node.node_name }), _jsx("div", { className: "text-xs text-slate-500 font-mono", children: node.zone || (loading ? 'loading' : '') })] }), displayError ? (_jsx("div", { className: "mt-3 text-sm text-red-400 break-words", children: displayError })) : (_jsxs("div", { className: "mt-3 grid grid-cols-2 gap-x-4 gap-y-1 text-sm", children: [_jsx("div", { className: "text-slate-500", children: "blobs" }), _jsx("div", { className: "font-mono text-right", children: node.blob_count.toLocaleString() }), _jsx("div", { className: "text-slate-500", children: "tags" }), _jsx("div", { className: "font-mono text-right", children: node.tag_count }), _jsx("div", { className: "text-slate-500", children: "refs" }), _jsx("div", { className: "font-mono text-right", children: node.ref_count }), _jsx("div", { className: "text-slate-500", children: "snapshots" }), _jsx("div", { className: "font-mono text-right", children: node.snapshot_count }), _jsx("div", { className: "text-slate-500", children: "ref-tracking" }), _jsx("div", { className: "font-mono text-right", children: node.ref_tracking_count }), _jsx("div", { className: "text-slate-500", children: "store size" }), _jsx("div", { className: "font-mono text-right", children: fmtBytes(node.blob_store_bytes) })] }))] }) }));
} }
+7 -4
View File
@@ -8,7 +8,8 @@ interface Props {
} }
export function NodeCard({ node, loading, error }: Props) { export function NodeCard({ node, loading, error }: Props) {
const health = error ? 'err' : loading ? 'idle' : 'ok'; const health = error || !node.online ? 'err' : loading ? 'idle' : 'ok';
const displayError = error ?? (!node.online ? node.error : null);
const ring = { const ring = {
ok: 'ring-emerald-500/60', ok: 'ring-emerald-500/60',
warn: 'ring-amber-500/60', warn: 'ring-amber-500/60',
@@ -27,11 +28,13 @@ export function NodeCard({ node, loading, error }: Props) {
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<div className="text-lg font-semibold text-slate-100">{node.node_name}</div> <div className="text-lg font-semibold text-slate-100">{node.node_name}</div>
<div className="text-xs text-slate-500 font-mono"> <div className="text-xs text-slate-500 font-mono">
{error ? 'error' : loading ? 'loading' : 'online'} {node.zone || (loading ? 'loading' : '')}
</div> </div>
</div> </div>
{error ? ( {displayError ? (
<div className="mt-3 text-sm text-red-400">{error}</div> <div className="mt-3 text-sm text-red-400 break-words">
{displayError}
</div>
) : ( ) : (
<div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1 text-sm"> <div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<div className="text-slate-500">blobs</div> <div className="text-slate-500">blobs</div>
+27 -11
View File
@@ -1,22 +1,38 @@
// Thin fetch wrappers around /api/v2/*. Kept minimal — no state // Thin fetch wrappers around /api/v2/*. Kept minimal — no state
// library; each page owns its own useEffect + useState. // library; each page owns its own useEffect + useState.
// The backend runs on the same origin the SPA loads from, so // API base:
// absolute URLs are unnecessary. Dev proxy handles the :5173 → // * dev (vite proxy) → '/api'
// :7700 hop. // * prod local → '/api' (SPA at :7700/v2/, API at :7700/api/)
// * prod via Tailscale → '/clawstor/api' (SPA at /clawstor, API at /clawstor/api)
//
// Detect at load time by checking the current URL's pathname
// prefix. Cheap + no build-time coupling.
const API_BASE = (() => {
if (typeof window === 'undefined')
return '/api';
const p = window.location.pathname;
if (p.startsWith('/clawstor/') || p === '/clawstor')
return '/clawstor/api';
return '/api';
})();
async function get(path) { async function get(path) {
const resp = await fetch(path, { headers: { Accept: 'application/json' } }); const full = `${API_BASE}${path}`;
const resp = await fetch(full, { headers: { Accept: 'application/json' } });
if (!resp.ok) { if (!resp.ok) {
throw new Error(`${path}${resp.status} ${resp.statusText}`); throw new Error(`${full}${resp.status} ${resp.statusText}`);
} }
return resp.json(); return resp.json();
} }
// All paths are relative to API_BASE. E.g. '/v2/fleet' becomes
// '/api/v2/fleet' locally or '/clawstor/api/v2/fleet' via Tailscale.
export const api = { export const api = {
nodeStatus: (name = 'local') => get(`/api/v2/node/${name}/status`), fleet: () => get('/v2/fleet'),
blobs: (limit = 200, offset = 0) => get(`/api/v2/storage/blobs?limit=${limit}&offset=${offset}`), nodeStatus: (name) => get(`/v2/node/${name}/status`),
tags: (prefix = '') => get(`/api/v2/storage/tags?prefix=${encodeURIComponent(prefix)}`), blobs: (limit = 200, offset = 0) => get(`/v2/storage/blobs?limit=${limit}&offset=${offset}`),
refs: (limit = 200, offset = 0) => get(`/api/v2/storage/refs?limit=${limit}&offset=${offset}`), tags: (prefix = '') => get(`/v2/storage/tags?prefix=${encodeURIComponent(prefix)}`),
snapshots: () => get(`/api/v2/storage/snapshots`), refs: (limit = 200, offset = 0) => get(`/v2/storage/refs?limit=${limit}&offset=${offset}`),
refTracking: (repo = '') => get(`/api/v2/storage/ref-tracking?repo=${encodeURIComponent(repo)}`), snapshots: () => get('/v2/storage/snapshots'),
refTracking: (repo = '') => get(`/v2/storage/ref-tracking?repo=${encodeURIComponent(repo)}`),
}; };
/** Format bytes as MB / GB / TB as needed. */ /** Format bytes as MB / GB / TB as needed. */
export function fmtBytes(n) { export function fmtBytes(n) {
+36 -11
View File
@@ -3,6 +3,7 @@
export interface NodeStatusV2 { export interface NodeStatusV2 {
node_name: string; node_name: string;
zone: string;
blob_store_root: string | null; blob_store_root: string | null;
blob_count: number; blob_count: number;
tag_count: number; tag_count: number;
@@ -10,6 +11,15 @@ export interface NodeStatusV2 {
snapshot_count: number; snapshot_count: number;
ref_tracking_count: number; ref_tracking_count: number;
blob_store_bytes: number; blob_store_bytes: number;
rustc_release: string | null;
online: boolean;
error: string | null;
}
export interface FleetSnapshot {
aggregator_name: string;
fetched_at_unix: number;
nodes: NodeStatusV2[];
} }
export interface BlobSummary { export interface BlobSummary {
@@ -43,28 +53,43 @@ export interface RefTrackingItem {
last_seen_unix: number; last_seen_unix: number;
} }
// The backend runs on the same origin the SPA loads from, so // API base:
// absolute URLs are unnecessary. Dev proxy handles the :5173 → // * dev (vite proxy) → '/api'
// :7700 hop. // * prod local → '/api' (SPA at :7700/v2/, API at :7700/api/)
// * prod via Tailscale → '/clawstor/api' (SPA at /clawstor, API at /clawstor/api)
//
// Detect at load time by checking the current URL's pathname
// prefix. Cheap + no build-time coupling.
const API_BASE = (() => {
if (typeof window === 'undefined') return '/api';
const p = window.location.pathname;
if (p.startsWith('/clawstor/') || p === '/clawstor') return '/clawstor/api';
return '/api';
})();
async function get<T>(path: string): Promise<T> { async function get<T>(path: string): Promise<T> {
const resp = await fetch(path, { headers: { Accept: 'application/json' } }); const full = `${API_BASE}${path}`;
const resp = await fetch(full, { headers: { Accept: 'application/json' } });
if (!resp.ok) { if (!resp.ok) {
throw new Error(`${path}${resp.status} ${resp.statusText}`); throw new Error(`${full}${resp.status} ${resp.statusText}`);
} }
return resp.json(); return resp.json();
} }
// All paths are relative to API_BASE. E.g. '/v2/fleet' becomes
// '/api/v2/fleet' locally or '/clawstor/api/v2/fleet' via Tailscale.
export const api = { export const api = {
nodeStatus: (name = 'local') => get<NodeStatusV2>(`/api/v2/node/${name}/status`), fleet: () => get<FleetSnapshot>('/v2/fleet'),
nodeStatus: (name: string) => get<NodeStatusV2>(`/v2/node/${name}/status`),
blobs: (limit = 200, offset = 0) => blobs: (limit = 200, offset = 0) =>
get<BlobSummary[]>(`/api/v2/storage/blobs?limit=${limit}&offset=${offset}`), get<BlobSummary[]>(`/v2/storage/blobs?limit=${limit}&offset=${offset}`),
tags: (prefix = '') => tags: (prefix = '') =>
get<TagSummary[]>(`/api/v2/storage/tags?prefix=${encodeURIComponent(prefix)}`), get<TagSummary[]>(`/v2/storage/tags?prefix=${encodeURIComponent(prefix)}`),
refs: (limit = 200, offset = 0) => refs: (limit = 200, offset = 0) =>
get<RefSummary[]>(`/api/v2/storage/refs?limit=${limit}&offset=${offset}`), get<RefSummary[]>(`/v2/storage/refs?limit=${limit}&offset=${offset}`),
snapshots: () => get<SnapshotSummary[]>(`/api/v2/storage/snapshots`), snapshots: () => get<SnapshotSummary[]>('/v2/storage/snapshots'),
refTracking: (repo = '') => refTracking: (repo = '') =>
get<RefTrackingItem[]>(`/api/v2/storage/ref-tracking?repo=${encodeURIComponent(repo)}`), get<RefTrackingItem[]>(`/v2/storage/ref-tracking?repo=${encodeURIComponent(repo)}`),
}; };
/** Format bytes as MB / GB / TB as needed. */ /** Format bytes as MB / GB / TB as needed. */
+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 { useEffect, useState } from 'react';
import { api, fmtBytes } from '../lib/api'; import { api, fmtAge, fmtBytes } from '../lib/api';
import { NodeCard } from '../components/NodeCard'; import { NodeCard } from '../components/NodeCard';
import { StatTile } from '../components/StatTile'; import { StatTile } from '../components/StatTile';
// Fleet-wide command center. Reads `/api/v2/node/local/status` from // Fleet command center — the landing pane.
// 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. // 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() { export function CommandCenter() {
const [me, setMe] = useState(null); const [fleet, setFleet] = useState(null);
const [err, setErr] = useState(null); const [err, setErr] = useState(null);
const [tick, setTick] = useState(0); const [tick, setTick] = useState(0);
useEffect(() => { useEffect(() => {
api api
.nodeStatus('local') .fleet()
.then((n) => { .then((f) => {
setMe(n); setFleet(f);
setErr(null); setErr(null);
}) })
.catch((e) => setErr(String(e))); .catch((e) => setErr(String(e)));
@@ -26,16 +25,16 @@ export function CommandCenter() {
const id = setInterval(() => setTick((t) => t + 1), 10_000); const id = setInterval(() => setTick((t) => t + 1), 10_000);
return () => clearInterval(id); 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'] const totals = fleet
.filter((n) => n !== me?.node_name) ? fleet.nodes.reduce((a, n) => ({
.map((n) => (_jsx(NodeCard, { node: { blobs: a.blobs + n.blob_count,
node_name: n, tags: a.tags + n.tag_count,
blob_store_root: null, refs: a.refs + n.ref_count,
blob_count: 0, snapshots: a.snapshots + n.snapshot_count,
tag_count: 0, bytes: a.bytes + n.blob_store_bytes,
ref_count: 0, online: a.online + (n.online ? 1 : 0),
snapshot_count: 0, }), { blobs: 0, tags: 0, refs: 0, snapshots: 0, bytes: 0, online: 0 })
ref_tracking_count: 0, : null;
blob_store_bytes: 0, 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 &&
}, 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" })] })] }))] })); [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 { 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 { NodeCard } from '../components/NodeCard';
import { StatTile } from '../components/StatTile'; import { StatTile } from '../components/StatTile';
// Fleet-wide command center. Reads `/api/v2/node/local/status` from // Fleet command center — the landing pane.
// 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. // 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() { 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 [err, setErr] = useState<string | null>(null);
const [tick, setTick] = useState(0); const [tick, setTick] = useState(0);
useEffect(() => { useEffect(() => {
api api
.nodeStatus('local') .fleet()
.then((n) => { .then((f) => {
setMe(n); setFleet(f);
setErr(null); setErr(null);
}) })
.catch((e) => setErr(String(e))); .catch((e) => setErr(String(e)));
@@ -29,16 +28,38 @@ export function CommandCenter() {
return () => clearInterval(id); 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-semibold text-slate-100">Fleet</h1> <h1 className="text-2xl font-semibold text-slate-100">Fleet</h1>
<div className="text-sm text-slate-500"> <div className="text-sm text-slate-500">
this dashboard was served by{' '} aggregated by{' '}
<span className="font-mono text-slate-300"> <span className="font-mono text-slate-300">
{me?.node_name ?? '…'} {fleet?.aggregator_name ?? '…'}
</span> </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>
</div> </div>
@@ -48,67 +69,36 @@ export function CommandCenter() {
</div> </div>
)} )}
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> {totals && fleet && (
{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> <section>
<h2 className="text-lg font-semibold text-slate-100 mb-3"> <h2 className="text-lg font-semibold text-slate-100 mb-3">
This node Totals (fleet-wide)
</h2> </h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<StatTile label="blobs" value={me.blob_count.toLocaleString()} color="ok" /> <StatTile label="online nodes" value={`${totals.online}/${fleet.nodes.length}`} color={totals.online === fleet.nodes.length ? 'ok' : 'warn'} />
<StatTile <StatTile label="blobs" value={totals.blobs.toLocaleString()} color="ok" />
label="tags" <StatTile label="tags" value={totals.tags} color="ok" />
value={me.tag_count} <StatTile label="refs" value={totals.refs} color="ok" />
color={me.tag_count > 0 ? 'ok' : 'idle'} <StatTile label="total bytes" value={fmtBytes(totals.bytes)} color="ok" />
/>
<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> </div>
</section> </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> </div>
); );
} }
+6 -1
View File
@@ -6,7 +6,12 @@ import react from '@vitejs/plugin-react';
// During local dev the daemon proxies /api/v2/* on :7700 so // During local dev the daemon proxies /api/v2/* on :7700 so
// `vite dev` on :5173 can hit it via server.proxy. // `vite dev` on :5173 can hit it via server.proxy.
export default defineConfig({ export default defineConfig({
base: '/v2/', // Relative asset URLs so the SPA works under any mount path.
// Local: served at :7700/v2/ → assets requested as ./assets/...
// Tailscale: /clawstor → assets requested as ./assets/...
// Vite emits `./assets/index-XXX.js` in index.html which the
// browser resolves relative to the current URL — works for both.
base: './',
plugins: [react()], plugins: [react()],
server: { server: {
port: 5173, port: 5173,