FleetHealth PR 2: human-oriented landing + View Advanced #101

Merged
osobh merged 1 commits from fleethealth-frontend into main 2026-07-15 00:23:31 +00:00
10 changed files with 479 additions and 157 deletions
Showing only changes of commit 22af481c3e - Show all commits
+39 -14
View File
@@ -1,13 +1,13 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import { Route, Switch, Link, Router, useLocation } from 'wouter'; import { Route, Switch, Link, Router, useLocation } from 'wouter';
import { CommandCenter } from './pages/CommandCenter'; import { CommandCenter } from './pages/CommandCenter';
import { NodeDetail } from './pages/NodeDetail'; import { NodeDetail } from './pages/NodeDetail';
import { StorageBrowser } from './pages/StorageBrowser'; import { StorageBrowser } from './pages/StorageBrowser';
import { RefTrackingPage } from './pages/RefTrackingPage'; import { RefTrackingPage } from './pages/RefTrackingPage';
// Base path — matches the deploy mount. When served locally at // Base path — matches the deploy mount. Detected from
// :7700/v2/ the base is /v2; via Tailscale it's /clawstor. // window.location so a single SPA build serves both local
// Detected from the browser's current pathname so a single SPA // (:7700/v2/) and Tailscale (/clawstor).
// build works both places without env-var wiring.
const BASE = (() => { const BASE = (() => {
if (typeof window === 'undefined') if (typeof window === 'undefined')
return ''; return '';
@@ -22,15 +22,40 @@ export default function App() {
return (_jsx(Router, { base: BASE, children: _jsx(Shell, {}) })); return (_jsx(Router, { base: BASE, children: _jsx(Shell, {}) }));
} }
function Shell() { function Shell() {
return (_jsxs("div", { className: "min-h-screen", children: [_jsx(NavBar, {}), _jsx("main", { className: "max-w-7xl mx-auto p-4", children: _jsxs(Switch, { children: [_jsx(Route, { path: "/", component: CommandCenter }), _jsx(Route, { path: "/nodes/:name", children: (params) => _jsx(NodeDetail, { name: params.name }) }), _jsx(Route, { path: "/advanced", children: _jsx(AdvancedRedirect, {}) }), _jsx(Route, { path: "/advanced/:tab", children: (params) => _jsx(StorageBrowser, { tab: params.tab }) }), _jsx(Route, { path: "/advanced/refs/tracking", component: RefTrackingPage }), _jsx(Route, { children: _jsx("div", { className: "text-slate-400 py-12 text-center", children: "404" }) })] }) })] }));
}
function NavBar() {
const [loc] = useLocation(); const [loc] = useLocation();
const tab = (path, label) => { const [advOpen, setAdvOpen] = useState(false);
const active = loc === path || (path !== '/' && loc.startsWith(path)); const advActive = loc.startsWith('/advanced');
return (_jsx(Link, { href: path, children: _jsx("a", { className: [ const primary = [{ path: '/', label: 'Fleet health' }];
'px-3 py-2 text-sm border-b-2 transition-colors', const advanced = [
active { path: '/advanced/blobs', label: 'Blobs' },
? 'border-emerald-400 text-emerald-300' { path: '/advanced/tags', label: 'Tags' },
: 'border-transparent text-slate-400 hover:text-slate-100', { path: '/advanced/refs', label: 'Refs' },
].join(' '), children: label }) })); { path: '/advanced/snapshots', label: 'Snapshots' },
}; { path: '/advanced/refs/tracking', label: 'Ref-tracking' },
return (_jsxs("div", { className: "min-h-screen", children: [_jsx("header", { className: "border-b border-slate-800 bg-slate-950/80 backdrop-blur sticky top-0 z-10", children: _jsxs("div", { className: "max-w-7xl mx-auto px-4 flex items-center gap-6", children: [_jsx(Link, { href: "/", children: _jsx("a", { className: "font-mono text-lg text-emerald-300 py-3", children: "clawstor \u00B7 command center" }) }), _jsxs("nav", { className: "flex", children: [tab('/', 'Overview'), tab('/storage/blobs', 'Blobs'), tab('/storage/tags', 'Tags'), tab('/storage/refs', 'Refs'), tab('/storage/snapshots', 'Snapshots'), tab('/refs/tracking', 'Ref-tracking')] })] }) }), _jsx("main", { className: "max-w-7xl mx-auto p-4", children: _jsxs(Switch, { children: [_jsx(Route, { path: "/", component: CommandCenter }), _jsx(Route, { path: "/nodes/:name", children: (params) => _jsx(NodeDetail, { name: params.name }) }), _jsx(Route, { path: "/storage/:tab", children: (params) => _jsx(StorageBrowser, { tab: params.tab }) }), _jsx(Route, { path: "/refs/tracking", component: RefTrackingPage }), _jsx(Route, { children: _jsx("div", { className: "text-slate-400 py-12 text-center", children: "404" }) })] }) })] })); ];
return (_jsx("header", { className: "border-b border-slate-800 bg-slate-950/80 backdrop-blur sticky top-0 z-10", children: _jsxs("div", { className: "max-w-7xl mx-auto px-4 flex items-center gap-6", children: [_jsx(Link, { href: "/", children: _jsx("a", { className: "font-mono text-lg text-emerald-300 py-3", children: "clawstor \u00B7 command center" }) }), _jsxs("nav", { className: "flex items-center", children: [primary.map((t) => (_jsx(Tab, { path: t.path, label: t.label, active: loc === t.path }, t.path))), _jsxs("div", { className: "relative", onMouseEnter: () => setAdvOpen(true), onMouseLeave: () => setAdvOpen(false), children: [_jsx("span", { className: [
'px-3 py-2 text-sm border-b-2 cursor-pointer transition-colors select-none',
advActive
? 'border-emerald-400 text-emerald-300'
: 'border-transparent text-slate-400 hover:text-slate-100',
].join(' '), children: "View Advanced \u25BE" }), advOpen && (_jsx("div", { className: "absolute left-0 top-full mt-0 bg-slate-900 border border-slate-800 rounded shadow-lg min-w-[12rem] z-20", children: advanced.map((t) => (_jsx(Link, { href: t.path, children: _jsx("a", { className: [
'block px-3 py-2 text-sm hover:bg-slate-800',
loc === t.path ? 'text-emerald-300' : 'text-slate-300',
].join(' '), children: t.label }) }, t.path))) }))] })] })] }) }));
}
function Tab({ path, label, active, }) {
return (_jsx(Link, { href: path, children: _jsx("a", { className: [
'px-3 py-2 text-sm border-b-2 transition-colors',
active
? 'border-emerald-400 text-emerald-300'
: 'border-transparent text-slate-400 hover:text-slate-100',
].join(' '), children: label }) }));
}
function AdvancedRedirect() {
const [, nav] = useLocation();
nav('/advanced/blobs', { replace: true });
return null;
} }
+103 -41
View File
@@ -1,13 +1,13 @@
import { useState } from 'react';
import { Route, Switch, Link, Router, useLocation } from 'wouter'; import { Route, Switch, Link, Router, useLocation } from 'wouter';
import { CommandCenter } from './pages/CommandCenter'; import { CommandCenter } from './pages/CommandCenter';
import { NodeDetail } from './pages/NodeDetail'; import { NodeDetail } from './pages/NodeDetail';
import { StorageBrowser } from './pages/StorageBrowser'; import { StorageBrowser } from './pages/StorageBrowser';
import { RefTrackingPage } from './pages/RefTrackingPage'; import { RefTrackingPage } from './pages/RefTrackingPage';
// Base path — matches the deploy mount. When served locally at // Base path — matches the deploy mount. Detected from
// :7700/v2/ the base is /v2; via Tailscale it's /clawstor. // window.location so a single SPA build serves both local
// Detected from the browser's current pathname so a single SPA // (:7700/v2/) and Tailscale (/clawstor).
// build works both places without env-var wiring.
const BASE = (() => { const BASE = (() => {
if (typeof window === 'undefined') return ''; if (typeof window === 'undefined') return '';
const p = window.location.pathname; const p = window.location.pathname;
@@ -25,53 +25,22 @@ export default function App() {
} }
function Shell() { function Shell() {
const [loc] = useLocation();
const tab = (path: string, label: string) => {
const active = loc === path || (path !== '/' && loc.startsWith(path));
return (
<Link href={path}>
<a
className={[
'px-3 py-2 text-sm border-b-2 transition-colors',
active
? 'border-emerald-400 text-emerald-300'
: 'border-transparent text-slate-400 hover:text-slate-100',
].join(' ')}
>
{label}
</a>
</Link>
);
};
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<header className="border-b border-slate-800 bg-slate-950/80 backdrop-blur sticky top-0 z-10"> <NavBar />
<div className="max-w-7xl mx-auto px-4 flex items-center gap-6">
<Link href="/">
<a className="font-mono text-lg text-emerald-300 py-3">
clawstor · command center
</a>
</Link>
<nav className="flex">
{tab('/', 'Overview')}
{tab('/storage/blobs', 'Blobs')}
{tab('/storage/tags', 'Tags')}
{tab('/storage/refs', 'Refs')}
{tab('/storage/snapshots', 'Snapshots')}
{tab('/refs/tracking', 'Ref-tracking')}
</nav>
</div>
</header>
<main className="max-w-7xl mx-auto p-4"> <main className="max-w-7xl mx-auto p-4">
<Switch> <Switch>
<Route path="/" component={CommandCenter} /> <Route path="/" component={CommandCenter} />
<Route path="/nodes/:name"> <Route path="/nodes/:name">
{(params) => <NodeDetail name={params.name} />} {(params) => <NodeDetail name={params.name} />}
</Route> </Route>
<Route path="/storage/:tab"> <Route path="/advanced">
<AdvancedRedirect />
</Route>
<Route path="/advanced/:tab">
{(params) => <StorageBrowser tab={params.tab as any} />} {(params) => <StorageBrowser tab={params.tab as any} />}
</Route> </Route>
<Route path="/refs/tracking" component={RefTrackingPage} /> <Route path="/advanced/refs/tracking" component={RefTrackingPage} />
<Route> <Route>
<div className="text-slate-400 py-12 text-center">404</div> <div className="text-slate-400 py-12 text-center">404</div>
</Route> </Route>
@@ -80,3 +49,96 @@ function Shell() {
</div> </div>
); );
} }
function NavBar() {
const [loc] = useLocation();
const [advOpen, setAdvOpen] = useState(false);
const advActive = loc.startsWith('/advanced');
const primary = [{ path: '/', label: 'Fleet health' }];
const advanced = [
{ path: '/advanced/blobs', label: 'Blobs' },
{ path: '/advanced/tags', label: 'Tags' },
{ path: '/advanced/refs', label: 'Refs' },
{ path: '/advanced/snapshots', label: 'Snapshots' },
{ path: '/advanced/refs/tracking', label: 'Ref-tracking' },
];
return (
<header className="border-b border-slate-800 bg-slate-950/80 backdrop-blur sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 flex items-center gap-6">
<Link href="/">
<a className="font-mono text-lg text-emerald-300 py-3">
clawstor · command center
</a>
</Link>
<nav className="flex items-center">
{primary.map((t) => (
<Tab key={t.path} path={t.path} label={t.label} active={loc === t.path} />
))}
<div
className="relative"
onMouseEnter={() => setAdvOpen(true)}
onMouseLeave={() => setAdvOpen(false)}
>
<span
className={[
'px-3 py-2 text-sm border-b-2 cursor-pointer transition-colors select-none',
advActive
? 'border-emerald-400 text-emerald-300'
: 'border-transparent text-slate-400 hover:text-slate-100',
].join(' ')}
>
View Advanced
</span>
{advOpen && (
<div className="absolute left-0 top-full mt-0 bg-slate-900 border border-slate-800 rounded shadow-lg min-w-[12rem] z-20">
{advanced.map((t) => (
<Link key={t.path} href={t.path}>
<a
className={[
'block px-3 py-2 text-sm hover:bg-slate-800',
loc === t.path ? 'text-emerald-300' : 'text-slate-300',
].join(' ')}
>
{t.label}
</a>
</Link>
))}
</div>
)}
</div>
</nav>
</div>
</header>
);
}
function Tab({
path,
label,
active,
}: {
path: string;
label: string;
active: boolean;
}) {
return (
<Link href={path}>
<a
className={[
'px-3 py-2 text-sm border-b-2 transition-colors',
active
? 'border-emerald-400 text-emerald-300'
: 'border-transparent text-slate-400 hover:text-slate-100',
].join(' ')}
>
{label}
</a>
</Link>
);
}
function AdvancedRedirect() {
const [, nav] = useLocation();
nav('/advanced/blobs', { replace: true });
return null;
}
+57 -14
View File
@@ -1,18 +1,61 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { Link } from 'wouter'; import { Link } from 'wouter';
import { fmtBytes } from '../lib/api'; import { StorageBar } from './StorageBar';
export function NodeCard({ node, loading, error }) { /// Human-oriented node card for the FleetHealth landing.
const health = error || !node.online ? 'err' : loading ? 'idle' : 'ok'; /// Shows: overall health traffic-light, storage bars, mount state,
const displayError = error ?? (!node.online ? node.error : null); /// cache hit rate, next scheduled job. No hex, no primitives.
const ring = { export function NodeCard({ node }) {
ok: 'ring-emerald-500/60', const health = healthOf(node);
warn: 'ring-amber-500/60', const border = {
err: 'ring-red-500/60', ok: 'border-emerald-700 hover:border-emerald-500',
idle: 'ring-slate-700', warn: 'border-amber-700 hover:border-amber-500',
err: 'border-red-700 hover:border-red-500',
idle: 'border-slate-700 hover:border-slate-500',
}[health];
const dot = {
ok: 'bg-emerald-400',
warn: 'bg-amber-400',
err: 'bg-red-400',
idle: 'bg-slate-500',
}[health]; }[health];
return (_jsx(Link, { href: `/nodes/${node.node_name}`, children: _jsxs("a", { className: [ return (_jsx(Link, { href: `/nodes/${node.node_name}`, children: _jsxs("a", { className: [
'block rounded-lg border border-slate-800 bg-slate-900 p-5', 'block rounded-lg bg-slate-900 border transition-colors',
'ring-1 hover:bg-slate-800/70 transition-colors', 'p-5 space-y-4',
ring, border,
].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) })] }))] }) })); ].join(' '), children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: `inline-block w-2.5 h-2.5 rounded-full ${dot}` }), _jsx("span", { className: "text-lg font-semibold text-slate-100", children: node.node_name })] }), _jsx("span", { className: "text-xs text-slate-500 font-mono", children: node.zone || '—' })] }), !node.online && (_jsx("div", { className: "text-sm text-red-400 break-words", children: node.error ?? 'offline' })), node.online && (_jsxs(_Fragment, { children: [node.filesystem && (_jsx(StorageBar, { label: "disk", used: node.filesystem.used_bytes, total: node.filesystem.total_bytes })), node.hot && node.hot.max_bytes > 0 && (_jsx(StorageBar, { label: "hot tier", used: node.hot.used_bytes, total: node.hot.max_bytes, pinned: node.hot.pinned_bytes ?? undefined })), _jsxs("div", { className: "grid grid-cols-2 gap-y-1 text-sm", children: [_jsx("span", { className: "text-slate-500", children: "mount" }), _jsx("span", { className: "text-right font-mono text-xs", children: node.mount?.active ? (_jsx("span", { className: "text-emerald-300", children: "\u2713 mounted" })) : (_jsx("span", { className: "text-slate-500", children: "not mounted" })) }), _jsx("span", { className: "text-slate-500", children: "cache hit rate" }), _jsx("span", { className: "text-right font-mono text-xs", children: node.cache && node.cache.hits + node.cache.misses > 0
? `${Math.round(node.cache.hit_rate * 100)}%`
: _jsx("span", { className: "text-slate-500", children: "idle" }) }), _jsx("span", { className: "text-slate-500", children: "next scheduled job" }), _jsx("span", { className: "text-right font-mono text-xs", children: nextTimer(node) })] })] }))] }) }));
}
function healthOf(n) {
if (!n.online)
return 'err';
const fsPct = n.filesystem
? n.filesystem.used_bytes / Math.max(n.filesystem.total_bytes, 1)
: 0;
const anyFailed = n.timers.some((t) => t.last_result && t.last_result !== 'success');
if (fsPct > 0.9 || anyFailed)
return 'err';
if (fsPct > 0.75)
return 'warn';
if (n.mount && !n.mount.active)
return 'warn';
return 'ok';
}
function nextTimer(n) {
const next = n.timers
.filter((t) => t.next_fire_unix)
.sort((a, b) => (a.next_fire_unix ?? 0) - (b.next_fire_unix ?? 0))[0];
if (!next?.next_fire_unix)
return _jsx("span", { className: "text-slate-500", children: "\u2014" });
const label = next.unit
.replace(/^clawstor-/, '')
.replace(/\.timer$/, '');
const now = Math.floor(Date.now() / 1000);
const diff = next.next_fire_unix - now;
const when = diff < 3600
? `in ${Math.max(0, Math.floor(diff / 60))}m`
: diff < 86400
? `in ${Math.floor(diff / 3600)}h`
: `in ${Math.floor(diff / 86400)}d`;
return (_jsxs("span", { children: [_jsx("span", { className: "text-slate-300", children: label }), ' ', _jsx("span", { className: "text-slate-500", children: when })] }));
} }
+118 -36
View File
@@ -1,57 +1,139 @@
import { Link } from 'wouter'; import { Link } from 'wouter';
import { NodeStatusV2, fmtBytes } from '../lib/api'; import { NodeStatusV2 } from '../lib/api';
import { StorageBar } from './StorageBar';
interface Props { interface Props {
node: NodeStatusV2; node: NodeStatusV2;
loading?: boolean;
error?: string | null;
} }
export function NodeCard({ node, loading, error }: Props) { /// Human-oriented node card for the FleetHealth landing.
const health = error || !node.online ? 'err' : loading ? 'idle' : 'ok'; /// Shows: overall health traffic-light, storage bars, mount state,
const displayError = error ?? (!node.online ? node.error : null); /// cache hit rate, next scheduled job. No hex, no primitives.
const ring = { export function NodeCard({ node }: Props) {
ok: 'ring-emerald-500/60', const health = healthOf(node);
warn: 'ring-amber-500/60', const border = {
err: 'ring-red-500/60', ok: 'border-emerald-700 hover:border-emerald-500',
idle: 'ring-slate-700', warn: 'border-amber-700 hover:border-amber-500',
err: 'border-red-700 hover:border-red-500',
idle: 'border-slate-700 hover:border-slate-500',
}[health]; }[health];
const dot = {
ok: 'bg-emerald-400',
warn: 'bg-amber-400',
err: 'bg-red-400',
idle: 'bg-slate-500',
}[health];
return ( return (
<Link href={`/nodes/${node.node_name}`}> <Link href={`/nodes/${node.node_name}`}>
<a <a
className={[ className={[
'block rounded-lg border border-slate-800 bg-slate-900 p-5', 'block rounded-lg bg-slate-900 border transition-colors',
'ring-1 hover:bg-slate-800/70 transition-colors', 'p-5 space-y-4',
ring, border,
].join(' ')} ].join(' ')}
> >
<div className="flex items-baseline justify-between"> {/* Header */}
<div className="text-lg font-semibold text-slate-100">{node.node_name}</div> <div className="flex items-center justify-between">
<div className="text-xs text-slate-500 font-mono"> <div className="flex items-center gap-2">
{node.zone || (loading ? 'loading' : '')} <span className={`inline-block w-2.5 h-2.5 rounded-full ${dot}`} />
<span className="text-lg font-semibold text-slate-100">
{node.node_name}
</span>
</div> </div>
<span className="text-xs text-slate-500 font-mono">
{node.zone || '—'}
</span>
</div> </div>
{displayError ? (
<div className="mt-3 text-sm text-red-400 break-words"> {/* Error banner */}
{displayError} {!node.online && (
</div> <div className="text-sm text-red-400 break-words">
) : ( {node.error ?? 'offline'}
<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="font-mono text-right">{node.blob_count.toLocaleString()}</div>
<div className="text-slate-500">tags</div>
<div className="font-mono text-right">{node.tag_count}</div>
<div className="text-slate-500">refs</div>
<div className="font-mono text-right">{node.ref_count}</div>
<div className="text-slate-500">snapshots</div>
<div className="font-mono text-right">{node.snapshot_count}</div>
<div className="text-slate-500">ref-tracking</div>
<div className="font-mono text-right">{node.ref_tracking_count}</div>
<div className="text-slate-500">store size</div>
<div className="font-mono text-right">{fmtBytes(node.blob_store_bytes)}</div>
</div> </div>
)} )}
{node.online && (
<>
{/* Storage bars */}
{node.filesystem && (
<StorageBar
label="disk"
used={node.filesystem.used_bytes}
total={node.filesystem.total_bytes}
/>
)}
{node.hot && node.hot.max_bytes > 0 && (
<StorageBar
label="hot tier"
used={node.hot.used_bytes}
total={node.hot.max_bytes}
pinned={node.hot.pinned_bytes ?? undefined}
/>
)}
{/* One-liner facts */}
<div className="grid grid-cols-2 gap-y-1 text-sm">
<span className="text-slate-500">mount</span>
<span className="text-right font-mono text-xs">
{node.mount?.active ? (
<span className="text-emerald-300"> mounted</span>
) : (
<span className="text-slate-500">not mounted</span>
)}
</span>
<span className="text-slate-500">cache hit rate</span>
<span className="text-right font-mono text-xs">
{node.cache && node.cache.hits + node.cache.misses > 0
? `${Math.round(node.cache.hit_rate * 100)}%`
: <span className="text-slate-500">idle</span>}
</span>
<span className="text-slate-500">next scheduled job</span>
<span className="text-right font-mono text-xs">
{nextTimer(node)}
</span>
</div>
</>
)}
</a> </a>
</Link> </Link>
); );
} }
function healthOf(n: NodeStatusV2): 'ok' | 'warn' | 'err' | 'idle' {
if (!n.online) return 'err';
const fsPct = n.filesystem
? n.filesystem.used_bytes / Math.max(n.filesystem.total_bytes, 1)
: 0;
const anyFailed = n.timers.some(
(t) => t.last_result && t.last_result !== 'success'
);
if (fsPct > 0.9 || anyFailed) return 'err';
if (fsPct > 0.75) return 'warn';
if (n.mount && !n.mount.active) return 'warn';
return 'ok';
}
function nextTimer(n: NodeStatusV2): React.ReactNode {
const next = n.timers
.filter((t) => t.next_fire_unix)
.sort((a, b) => (a.next_fire_unix ?? 0) - (b.next_fire_unix ?? 0))[0];
if (!next?.next_fire_unix) return <span className="text-slate-500"></span>;
const label = next.unit
.replace(/^clawstor-/, '')
.replace(/\.timer$/, '');
const now = Math.floor(Date.now() / 1000);
const diff = next.next_fire_unix - now;
const when =
diff < 3600
? `in ${Math.max(0, Math.floor(diff / 60))}m`
: diff < 86400
? `in ${Math.floor(diff / 3600)}h`
: `in ${Math.floor(diff / 86400)}d`;
return (
<span>
<span className="text-slate-300">{label}</span>{' '}
<span className="text-slate-500">{when}</span>
</span>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { fmtBytes } from '../lib/api';
/// Big horizontal storage bar. Read-only. Renders green (pinned,
/// safe) + amber (used, evictable) + slate (free). Health color
/// on the label based on fill %.
export function StorageBar({ label, used, total, pinned }) {
const safeTotal = Math.max(total, 1);
const pct = Math.min(100, Math.round((used / safeTotal) * 100));
const pinnedPct = pinned
? Math.min(100, Math.round((pinned / safeTotal) * 100))
: 0;
const evictablePct = Math.max(0, pct - pinnedPct);
const bar = pct < 60 ? 'ok' : pct < 85 ? 'warn' : 'err';
const barText = {
ok: 'text-emerald-300',
warn: 'text-amber-300',
err: 'text-red-300',
}[bar];
return (_jsxs("div", { children: [_jsxs("div", { className: "flex items-baseline justify-between text-xs mb-1", children: [_jsx("span", { className: "text-slate-500 uppercase tracking-wider", children: label }), _jsxs("span", { className: `font-mono ${barText}`, children: [fmtBytes(used), " / ", fmtBytes(total), " \u00B7 ", pct, "%"] })] }), _jsxs("div", { className: "h-2.5 w-full rounded-full bg-slate-800 overflow-hidden flex", children: [pinnedPct > 0 && (_jsx("div", { className: "bg-emerald-500 h-full", style: { width: `${pinnedPct}%` }, title: `Pinned: ${fmtBytes(pinned)}` })), evictablePct > 0 && (_jsx("div", { className: `h-full ${bar === 'err' ? 'bg-red-500' : bar === 'warn' ? 'bg-amber-500' : 'bg-emerald-600'}`, style: { width: `${evictablePct}%` }, title: `Used: ${fmtBytes(used - (pinned ?? 0))}` }))] })] }));
}
@@ -0,0 +1,57 @@
import { fmtBytes } from '../lib/api';
interface Props {
label: string;
used: number;
total: number;
// Optional split: a portion of `used` that's "pinned" (won't be
// evicted). Rendered green; the rest of used is amber.
pinned?: number | null;
}
/// Big horizontal storage bar. Read-only. Renders green (pinned,
/// safe) + amber (used, evictable) + slate (free). Health color
/// on the label based on fill %.
export function StorageBar({ label, used, total, pinned }: Props) {
const safeTotal = Math.max(total, 1);
const pct = Math.min(100, Math.round((used / safeTotal) * 100));
const pinnedPct = pinned
? Math.min(100, Math.round((pinned / safeTotal) * 100))
: 0;
const evictablePct = Math.max(0, pct - pinnedPct);
const bar = pct < 60 ? 'ok' : pct < 85 ? 'warn' : 'err';
const barText = {
ok: 'text-emerald-300',
warn: 'text-amber-300',
err: 'text-red-300',
}[bar];
return (
<div>
<div className="flex items-baseline justify-between text-xs mb-1">
<span className="text-slate-500 uppercase tracking-wider">{label}</span>
<span className={`font-mono ${barText}`}>
{fmtBytes(used)} / {fmtBytes(total)} · {pct}%
</span>
</div>
<div className="h-2.5 w-full rounded-full bg-slate-800 overflow-hidden flex">
{pinnedPct > 0 && (
<div
className="bg-emerald-500 h-full"
style={{ width: `${pinnedPct}%` }}
title={`Pinned: ${fmtBytes(pinned!)}`}
/>
)}
{evictablePct > 0 && (
<div
className={`h-full ${
bar === 'err' ? 'bg-red-500' : bar === 'warn' ? 'bg-amber-500' : 'bg-emerald-600'
}`}
style={{ width: `${evictablePct}%` }}
title={`Used: ${fmtBytes(used - (pinned ?? 0))}`}
/>
)}
</div>
</div>
);
}
+37
View File
@@ -1,6 +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.
export interface FilesystemUsage {
mount_point: string;
total_bytes: number;
available_bytes: number;
used_bytes: number;
}
export interface HotTierUsage {
used_bytes: number;
max_bytes: number;
pinned_bytes: number | null;
}
export interface MountStatus {
path: string;
active: boolean;
}
export interface CacheSummary {
hits: number;
misses: number;
bytes_served: number;
bytes_ingested: number;
hit_rate: number;
}
export interface TimerStatus {
unit: string;
next_fire_unix: number | null;
last_result: string | null;
}
export interface NodeStatusV2 { export interface NodeStatusV2 {
node_name: string; node_name: string;
zone: string; zone: string;
@@ -12,6 +44,11 @@ export interface NodeStatusV2 {
ref_tracking_count: number; ref_tracking_count: number;
blob_store_bytes: number; blob_store_bytes: number;
rustc_release: string | null; rustc_release: string | null;
filesystem: FilesystemUsage | null;
hot: HotTierUsage | null;
mount: MountStatus | null;
cache: CacheSummary | null;
timers: TimerStatus[];
online: boolean; online: boolean;
error: string | null; error: string | null;
} }
+11 -15
View File
@@ -1,13 +1,9 @@
import { jsx as _jsx, Fragment as _Fragment, 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, fmtAge, fmtBytes } from '../lib/api'; import { api, fmtBytes, fmtAge } from '../lib/api';
import { NodeCard } from '../components/NodeCard'; import { NodeCard } from '../components/NodeCard';
import { StatTile } from '../components/StatTile'; // FleetHealth landing — human-oriented single-pane-of-glass.
// Fleet command center — the landing pane. // Polls the aggregator's /api/v2/fleet every 10 s.
//
// 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 [fleet, setFleet] = useState(null); const [fleet, setFleet] = useState(null);
const [err, setErr] = useState(null); const [err, setErr] = useState(null);
@@ -27,14 +23,14 @@ export function CommandCenter() {
}, []); }, []);
const totals = fleet const totals = fleet
? fleet.nodes.reduce((a, n) => ({ ? fleet.nodes.reduce((a, n) => ({
blobs: a.blobs + n.blob_count, diskUsed: a.diskUsed + (n.filesystem?.used_bytes ?? 0),
tags: a.tags + n.tag_count, diskTotal: a.diskTotal + (n.filesystem?.total_bytes ?? 0),
refs: a.refs + n.ref_count, hotUsed: a.hotUsed + (n.hot?.used_bytes ?? 0),
snapshots: a.snapshots + n.snapshot_count, hotMax: a.hotMax + (n.hot?.max_bytes ?? 0),
bytes: a.bytes + n.blob_store_bytes,
online: a.online + (n.online ? 1 : 0), online: a.online + (n.online ? 1 : 0),
}), { blobs: 0, tags: 0, refs: 0, snapshots: 0, bytes: 0, online: 0 }) mounted: a.mounted + (n.mount?.active ? 1 : 0),
}), { diskUsed: 0, diskTotal: 0, hotUsed: 0, hotMax: 0, online: 0, mounted: 0 })
: null; : 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 && return (_jsxs("div", { className: "space-y-6", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-2xl font-semibold text-slate-100", children: "Fleet health" }), _jsx("div", { className: "text-sm text-slate-500 mt-1", children: fleet && (_jsxs(_Fragment, { children: [_jsx("span", { className: "text-emerald-300", children: totals?.online }), "/", fleet.nodes.length, " nodes online", ' · ', totals?.mounted, "/", fleet.nodes.length, " mounted", ' · ', "updated ", _jsx("span", { className: "font-mono", children: fmtAge(fleet.fetched_at_unix) }), ' · ', "hosted by ", _jsx("span", { className: "font-mono text-slate-300", children: fleet.aggregator_name })] })) })] }), err && (_jsx("div", { className: "rounded border border-red-800 bg-red-950/40 p-3 text-red-300 text-sm", children: err })), totals && totals.diskTotal > 0 && (_jsxs("div", { className: "rounded-lg border border-slate-800 bg-slate-900/60 p-4 flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("div", { className: "text-xs uppercase tracking-wider text-slate-500", children: "Fleet-wide storage" }), _jsxs("div", { className: "text-2xl font-semibold font-mono mt-1", children: [fmtBytes(totals.diskUsed), ' ', _jsxs("span", { className: "text-slate-500 text-lg", children: ["/ ", fmtBytes(totals.diskTotal)] })] })] }), _jsxs("div", { className: "text-right text-slate-400 text-sm", children: [_jsxs("div", { children: [Math.round((totals.diskUsed / totals.diskTotal) * 100), "% used"] }), _jsxs("div", { className: "text-xs text-slate-500 mt-1", children: ["hot tier: ", fmtBytes(totals.hotUsed), " / ", fmtBytes(totals.hotMax)] })] })] })), _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, 2, 3].map((i) => (_jsx("div", { className: "rounded-lg border border-slate-800 bg-slate-900/40 p-5 h-64 animate-pulse" }, i)))] })] })] }));
} }
+36 -36
View File
@@ -1,13 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { api, FleetSnapshot, fmtAge, fmtBytes } from '../lib/api'; import { api, FleetSnapshot, fmtBytes, fmtAge } from '../lib/api';
import { NodeCard } from '../components/NodeCard'; import { NodeCard } from '../components/NodeCard';
import { StatTile } from '../components/StatTile';
// Fleet command center — the landing pane. // FleetHealth landing — human-oriented single-pane-of-glass.
// // Polls the aggregator's /api/v2/fleet every 10 s.
// 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 [fleet, setFleet] = useState<FleetSnapshot | null>(null); const [fleet, setFleet] = useState<FleetSnapshot | null>(null);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
@@ -31,33 +27,32 @@ export function CommandCenter() {
const totals = fleet const totals = fleet
? fleet.nodes.reduce( ? fleet.nodes.reduce(
(a, n) => ({ (a, n) => ({
blobs: a.blobs + n.blob_count, diskUsed: a.diskUsed + (n.filesystem?.used_bytes ?? 0),
tags: a.tags + n.tag_count, diskTotal: a.diskTotal + (n.filesystem?.total_bytes ?? 0),
refs: a.refs + n.ref_count, hotUsed: a.hotUsed + (n.hot?.used_bytes ?? 0),
snapshots: a.snapshots + n.snapshot_count, hotMax: a.hotMax + (n.hot?.max_bytes ?? 0),
bytes: a.bytes + n.blob_store_bytes,
online: a.online + (n.online ? 1 : 0), online: a.online + (n.online ? 1 : 0),
mounted: a.mounted + (n.mount?.active ? 1 : 0),
}), }),
{ blobs: 0, tags: 0, refs: 0, snapshots: 0, bytes: 0, online: 0 } { diskUsed: 0, diskTotal: 0, hotUsed: 0, hotMax: 0, online: 0, mounted: 0 }
) )
: null; : 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 health</h1>
<div className="text-sm text-slate-500"> <div className="text-sm text-slate-500 mt-1">
aggregated by{' '}
<span className="font-mono text-slate-300">
{fleet?.aggregator_name ?? '…'}
</span>
{fleet && ( {fleet && (
<> <>
{' · updated '}
<span className="font-mono">{fmtAge(fleet.fetched_at_unix)}</span>
{' · '}
<span className="text-emerald-300">{totals?.online}</span> <span className="text-emerald-300">{totals?.online}</span>
/{fleet.nodes.length} online /{fleet.nodes.length} nodes online
{' · '}
{totals?.mounted}/{fleet.nodes.length} mounted
{' · '}
updated <span className="font-mono">{fmtAge(fleet.fetched_at_unix)}</span>
{' · '}
hosted by <span className="font-mono text-slate-300">{fleet.aggregator_name}</span>
</> </>
)} )}
</div> </div>
@@ -69,19 +64,24 @@ export function CommandCenter() {
</div> </div>
)} )}
{totals && fleet && ( {totals && totals.diskTotal > 0 && (
<section> <div className="rounded-lg border border-slate-800 bg-slate-900/60 p-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-100 mb-3"> <div>
Totals (fleet-wide) <div className="text-xs uppercase tracking-wider text-slate-500">
</h2> Fleet-wide storage
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3"> </div>
<StatTile label="online nodes" value={`${totals.online}/${fleet.nodes.length}`} color={totals.online === fleet.nodes.length ? 'ok' : 'warn'} /> <div className="text-2xl font-semibold font-mono mt-1">
<StatTile label="blobs" value={totals.blobs.toLocaleString()} color="ok" /> {fmtBytes(totals.diskUsed)}{' '}
<StatTile label="tags" value={totals.tags} color="ok" /> <span className="text-slate-500 text-lg">/ {fmtBytes(totals.diskTotal)}</span>
<StatTile label="refs" value={totals.refs} color="ok" /> </div>
<StatTile label="total bytes" value={fmtBytes(totals.bytes)} color="ok" />
</div> </div>
</section> <div className="text-right text-slate-400 text-sm">
<div>{Math.round((totals.diskUsed / totals.diskTotal) * 100)}% used</div>
<div className="text-xs text-slate-500 mt-1">
hot tier: {fmtBytes(totals.hotUsed)} / {fmtBytes(totals.hotMax)}
</div>
</div>
</div>
)} )}
<section> <section>
@@ -94,7 +94,7 @@ export function CommandCenter() {
[1, 2, 3].map((i) => ( [1, 2, 3].map((i) => (
<div <div
key={i} key={i}
className="rounded-lg border border-slate-800 bg-slate-900/40 p-5 h-40 animate-pulse" className="rounded-lg border border-slate-800 bg-slate-900/40 p-5 h-64 animate-pulse"
/> />
))} ))}
</div> </div>
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/components/nodecard.tsx","./src/components/stattile.tsx","./src/lib/api.ts","./src/pages/commandcenter.tsx","./src/pages/nodedetail.tsx","./src/pages/reftrackingpage.tsx","./src/pages/storagebrowser.tsx"],"version":"6.0.3"} {"root":["./src/app.tsx","./src/main.tsx","./src/components/nodecard.tsx","./src/components/stattile.tsx","./src/components/storagebar.tsx","./src/lib/api.ts","./src/pages/commandcenter.tsx","./src/pages/nodedetail.tsx","./src/pages/reftrackingpage.tsx","./src/pages/storagebrowser.tsx"],"version":"6.0.3"}