dashboard-v2 hotfix: relative asset base #95
@@ -2,7 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { Link } from 'wouter';
|
||||
import { fmtBytes } from '../lib/api';
|
||||
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 = {
|
||||
ok: 'ring-emerald-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',
|
||||
'ring-1 hover:bg-slate-800/70 transition-colors',
|
||||
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) })] }))] }) }));
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ interface 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 = {
|
||||
ok: 'ring-emerald-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="text-lg font-semibold text-slate-100">{node.node_name}</div>
|
||||
<div className="text-xs text-slate-500 font-mono">
|
||||
{error ? 'error' : loading ? 'loading' : 'online'}
|
||||
{node.zone || (loading ? 'loading' : '')}
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="mt-3 text-sm text-red-400">{error}</div>
|
||||
{displayError ? (
|
||||
<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="text-slate-500">blobs</div>
|
||||
|
||||
+27
-11
@@ -1,22 +1,38 @@
|
||||
// Thin fetch wrappers around /api/v2/*. Kept minimal — no state
|
||||
// library; each page owns its own useEffect + useState.
|
||||
// The backend runs on the same origin the SPA loads from, so
|
||||
// absolute URLs are unnecessary. Dev proxy handles the :5173 →
|
||||
// :7700 hop.
|
||||
// API base:
|
||||
// * dev (vite proxy) → '/api'
|
||||
// * 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) {
|
||||
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) {
|
||||
throw new Error(`${path} → ${resp.status} ${resp.statusText}`);
|
||||
throw new Error(`${full} → ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
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 = {
|
||||
nodeStatus: (name = 'local') => get(`/api/v2/node/${name}/status`),
|
||||
blobs: (limit = 200, offset = 0) => get(`/api/v2/storage/blobs?limit=${limit}&offset=${offset}`),
|
||||
tags: (prefix = '') => get(`/api/v2/storage/tags?prefix=${encodeURIComponent(prefix)}`),
|
||||
refs: (limit = 200, offset = 0) => get(`/api/v2/storage/refs?limit=${limit}&offset=${offset}`),
|
||||
snapshots: () => get(`/api/v2/storage/snapshots`),
|
||||
refTracking: (repo = '') => get(`/api/v2/storage/ref-tracking?repo=${encodeURIComponent(repo)}`),
|
||||
fleet: () => get('/v2/fleet'),
|
||||
nodeStatus: (name) => get(`/v2/node/${name}/status`),
|
||||
blobs: (limit = 200, offset = 0) => get(`/v2/storage/blobs?limit=${limit}&offset=${offset}`),
|
||||
tags: (prefix = '') => get(`/v2/storage/tags?prefix=${encodeURIComponent(prefix)}`),
|
||||
refs: (limit = 200, offset = 0) => get(`/v2/storage/refs?limit=${limit}&offset=${offset}`),
|
||||
snapshots: () => get('/v2/storage/snapshots'),
|
||||
refTracking: (repo = '') => get(`/v2/storage/ref-tracking?repo=${encodeURIComponent(repo)}`),
|
||||
};
|
||||
/** Format bytes as MB / GB / TB as needed. */
|
||||
export function fmtBytes(n) {
|
||||
|
||||
+36
-11
@@ -3,6 +3,7 @@
|
||||
|
||||
export interface NodeStatusV2 {
|
||||
node_name: string;
|
||||
zone: string;
|
||||
blob_store_root: string | null;
|
||||
blob_count: number;
|
||||
tag_count: number;
|
||||
@@ -10,6 +11,15 @@ export interface NodeStatusV2 {
|
||||
snapshot_count: number;
|
||||
ref_tracking_count: 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 {
|
||||
@@ -43,28 +53,43 @@ export interface RefTrackingItem {
|
||||
last_seen_unix: number;
|
||||
}
|
||||
|
||||
// The backend runs on the same origin the SPA loads from, so
|
||||
// absolute URLs are unnecessary. Dev proxy handles the :5173 →
|
||||
// :7700 hop.
|
||||
// API base:
|
||||
// * dev (vite proxy) → '/api'
|
||||
// * 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> {
|
||||
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) {
|
||||
throw new Error(`${path} → ${resp.status} ${resp.statusText}`);
|
||||
throw new Error(`${full} → ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
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 = {
|
||||
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) =>
|
||||
get<BlobSummary[]>(`/api/v2/storage/blobs?limit=${limit}&offset=${offset}`),
|
||||
get<BlobSummary[]>(`/v2/storage/blobs?limit=${limit}&offset=${offset}`),
|
||||
tags: (prefix = '') =>
|
||||
get<TagSummary[]>(`/api/v2/storage/tags?prefix=${encodeURIComponent(prefix)}`),
|
||||
get<TagSummary[]>(`/v2/storage/tags?prefix=${encodeURIComponent(prefix)}`),
|
||||
refs: (limit = 200, offset = 0) =>
|
||||
get<RefSummary[]>(`/api/v2/storage/refs?limit=${limit}&offset=${offset}`),
|
||||
snapshots: () => get<SnapshotSummary[]>(`/api/v2/storage/snapshots`),
|
||||
get<RefSummary[]>(`/v2/storage/refs?limit=${limit}&offset=${offset}`),
|
||||
snapshots: () => get<SnapshotSummary[]>('/v2/storage/snapshots'),
|
||||
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. */
|
||||
|
||||
@@ -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)))] })] })] }));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,12 @@ import react from '@vitejs/plugin-react';
|
||||
// During local dev the daemon proxies /api/v2/* on :7700 so
|
||||
// `vite dev` on :5173 can hit it via server.proxy.
|
||||
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()],
|
||||
server: {
|
||||
port: 5173,
|
||||
|
||||
Reference in New Issue
Block a user