FleetHealth PR 2: human-oriented landing + View Advanced #101
+33
-8
@@ -1,13 +1,13 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState } from 'react';
|
||||
import { Route, Switch, Link, Router, useLocation } from 'wouter';
|
||||
import { CommandCenter } from './pages/CommandCenter';
|
||||
import { NodeDetail } from './pages/NodeDetail';
|
||||
import { StorageBrowser } from './pages/StorageBrowser';
|
||||
import { RefTrackingPage } from './pages/RefTrackingPage';
|
||||
// Base path — matches the deploy mount. When served locally at
|
||||
// :7700/v2/ the base is /v2; via Tailscale it's /clawstor.
|
||||
// Detected from the browser's current pathname so a single SPA
|
||||
// build works both places without env-var wiring.
|
||||
// Base path — matches the deploy mount. Detected from
|
||||
// window.location so a single SPA build serves both local
|
||||
// (:7700/v2/) and Tailscale (/clawstor).
|
||||
const BASE = (() => {
|
||||
if (typeof window === 'undefined')
|
||||
return '';
|
||||
@@ -22,15 +22,40 @@ export default function App() {
|
||||
return (_jsx(Router, { base: BASE, children: _jsx(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 tab = (path, label) => {
|
||||
const active = loc === path || (path !== '/' && loc.startsWith(path));
|
||||
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 (_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 }) }));
|
||||
};
|
||||
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" }) })] }) })] }));
|
||||
}
|
||||
function AdvancedRedirect() {
|
||||
const [, nav] = useLocation();
|
||||
nav('/advanced/blobs', { replace: true });
|
||||
return null;
|
||||
}
|
||||
|
||||
+105
-43
@@ -1,13 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { Route, Switch, Link, Router, useLocation } from 'wouter';
|
||||
import { CommandCenter } from './pages/CommandCenter';
|
||||
import { NodeDetail } from './pages/NodeDetail';
|
||||
import { StorageBrowser } from './pages/StorageBrowser';
|
||||
import { RefTrackingPage } from './pages/RefTrackingPage';
|
||||
|
||||
// Base path — matches the deploy mount. When served locally at
|
||||
// :7700/v2/ the base is /v2; via Tailscale it's /clawstor.
|
||||
// Detected from the browser's current pathname so a single SPA
|
||||
// build works both places without env-var wiring.
|
||||
// Base path — matches the deploy mount. Detected from
|
||||
// window.location so a single SPA build serves both local
|
||||
// (:7700/v2/) and Tailscale (/clawstor).
|
||||
const BASE = (() => {
|
||||
if (typeof window === 'undefined') return '';
|
||||
const p = window.location.pathname;
|
||||
@@ -25,9 +25,102 @@ export default function App() {
|
||||
}
|
||||
|
||||
function Shell() {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<NavBar />
|
||||
<main className="max-w-7xl mx-auto p-4">
|
||||
<Switch>
|
||||
<Route path="/" component={CommandCenter} />
|
||||
<Route path="/nodes/:name">
|
||||
{(params) => <NodeDetail name={params.name} />}
|
||||
</Route>
|
||||
<Route path="/advanced">
|
||||
<AdvancedRedirect />
|
||||
</Route>
|
||||
<Route path="/advanced/:tab">
|
||||
{(params) => <StorageBrowser tab={params.tab as any} />}
|
||||
</Route>
|
||||
<Route path="/advanced/refs/tracking" component={RefTrackingPage} />
|
||||
<Route>
|
||||
<div className="text-slate-400 py-12 text-center">404</div>
|
||||
</Route>
|
||||
</Switch>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavBar() {
|
||||
const [loc] = useLocation();
|
||||
const tab = (path: string, label: string) => {
|
||||
const active = loc === path || (path !== '/' && loc.startsWith(path));
|
||||
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
|
||||
@@ -42,41 +135,10 @@ function Shell() {
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<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">
|
||||
{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">
|
||||
<Switch>
|
||||
<Route path="/" component={CommandCenter} />
|
||||
<Route path="/nodes/:name">
|
||||
{(params) => <NodeDetail name={params.name} />}
|
||||
</Route>
|
||||
<Route path="/storage/:tab">
|
||||
{(params) => <StorageBrowser tab={params.tab as any} />}
|
||||
</Route>
|
||||
<Route path="/refs/tracking" component={RefTrackingPage} />
|
||||
<Route>
|
||||
<div className="text-slate-400 py-12 text-center">404</div>
|
||||
</Route>
|
||||
</Switch>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdvancedRedirect() {
|
||||
const [, nav] = useLocation();
|
||||
nav('/advanced/blobs', { replace: true });
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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 { fmtBytes } from '../lib/api';
|
||||
export function NodeCard({ node, loading, error }) {
|
||||
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',
|
||||
err: 'ring-red-500/60',
|
||||
idle: 'ring-slate-700',
|
||||
import { StorageBar } from './StorageBar';
|
||||
/// Human-oriented node card for the FleetHealth landing.
|
||||
/// Shows: overall health traffic-light, storage bars, mount state,
|
||||
/// cache hit rate, next scheduled job. No hex, no primitives.
|
||||
export function NodeCard({ node }) {
|
||||
const health = healthOf(node);
|
||||
const border = {
|
||||
ok: 'border-emerald-700 hover:border-emerald-500',
|
||||
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];
|
||||
return (_jsx(Link, { href: `/nodes/${node.node_name}`, children: _jsxs("a", { className: [
|
||||
'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: 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) })] }))] }) }));
|
||||
'block rounded-lg bg-slate-900 border transition-colors',
|
||||
'p-5 space-y-4',
|
||||
border,
|
||||
].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 })] }));
|
||||
}
|
||||
|
||||
@@ -1,57 +1,139 @@
|
||||
import { Link } from 'wouter';
|
||||
import { NodeStatusV2, fmtBytes } from '../lib/api';
|
||||
import { NodeStatusV2 } from '../lib/api';
|
||||
import { StorageBar } from './StorageBar';
|
||||
|
||||
interface Props {
|
||||
node: NodeStatusV2;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export function NodeCard({ node, loading, error }: Props) {
|
||||
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',
|
||||
err: 'ring-red-500/60',
|
||||
idle: 'ring-slate-700',
|
||||
/// Human-oriented node card for the FleetHealth landing.
|
||||
/// Shows: overall health traffic-light, storage bars, mount state,
|
||||
/// cache hit rate, next scheduled job. No hex, no primitives.
|
||||
export function NodeCard({ node }: Props) {
|
||||
const health = healthOf(node);
|
||||
const border = {
|
||||
ok: 'border-emerald-700 hover:border-emerald-500',
|
||||
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];
|
||||
|
||||
return (
|
||||
<Link href={`/nodes/${node.node_name}`}>
|
||||
<a
|
||||
className={[
|
||||
'block rounded-lg border border-slate-800 bg-slate-900 p-5',
|
||||
'ring-1 hover:bg-slate-800/70 transition-colors',
|
||||
ring,
|
||||
'block rounded-lg bg-slate-900 border transition-colors',
|
||||
'p-5 space-y-4',
|
||||
border,
|
||||
].join(' ')}
|
||||
>
|
||||
<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">
|
||||
{node.zone || (loading ? 'loading' : '')}
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<span className="text-xs text-slate-500 font-mono">
|
||||
{node.zone || '—'}
|
||||
</span>
|
||||
</div>
|
||||
{displayError ? (
|
||||
<div className="mt-3 text-sm text-red-400 break-words">
|
||||
{displayError}
|
||||
|
||||
{/* Error banner */}
|
||||
{!node.online && (
|
||||
<div className="text-sm text-red-400 break-words">
|
||||
{node.error ?? 'offline'}
|
||||
</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>
|
||||
) : (
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,38 @@
|
||||
// Thin fetch wrappers around /api/v2/*. Kept minimal — no state
|
||||
// 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 {
|
||||
node_name: string;
|
||||
zone: string;
|
||||
@@ -12,6 +44,11 @@ export interface NodeStatusV2 {
|
||||
ref_tracking_count: number;
|
||||
blob_store_bytes: number;
|
||||
rustc_release: string | null;
|
||||
filesystem: FilesystemUsage | null;
|
||||
hot: HotTierUsage | null;
|
||||
mount: MountStatus | null;
|
||||
cache: CacheSummary | null;
|
||||
timers: TimerStatus[];
|
||||
online: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
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 { StatTile } from '../components/StatTile';
|
||||
// Fleet command center — the landing pane.
|
||||
//
|
||||
// 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.
|
||||
// FleetHealth landing — human-oriented single-pane-of-glass.
|
||||
// Polls the aggregator's /api/v2/fleet every 10 s.
|
||||
export function CommandCenter() {
|
||||
const [fleet, setFleet] = useState(null);
|
||||
const [err, setErr] = useState(null);
|
||||
@@ -27,14 +23,14 @@ export function CommandCenter() {
|
||||
}, []);
|
||||
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,
|
||||
diskUsed: a.diskUsed + (n.filesystem?.used_bytes ?? 0),
|
||||
diskTotal: a.diskTotal + (n.filesystem?.total_bytes ?? 0),
|
||||
hotUsed: a.hotUsed + (n.hot?.used_bytes ?? 0),
|
||||
hotMax: a.hotMax + (n.hot?.max_bytes ?? 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;
|
||||
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)))] })] })] }));
|
||||
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-64 animate-pulse" }, i)))] })] })] }));
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
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 { StatTile } from '../components/StatTile';
|
||||
|
||||
// Fleet command center — the landing pane.
|
||||
//
|
||||
// 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.
|
||||
// FleetHealth landing — human-oriented single-pane-of-glass.
|
||||
// Polls the aggregator's /api/v2/fleet every 10 s.
|
||||
export function CommandCenter() {
|
||||
const [fleet, setFleet] = useState<FleetSnapshot | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
@@ -31,33 +27,32 @@ export function CommandCenter() {
|
||||
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,
|
||||
diskUsed: a.diskUsed + (n.filesystem?.used_bytes ?? 0),
|
||||
diskTotal: a.diskTotal + (n.filesystem?.total_bytes ?? 0),
|
||||
hotUsed: a.hotUsed + (n.hot?.used_bytes ?? 0),
|
||||
hotMax: a.hotMax + (n.hot?.max_bytes ?? 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;
|
||||
|
||||
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">
|
||||
aggregated by{' '}
|
||||
<span className="font-mono text-slate-300">
|
||||
{fleet?.aggregator_name ?? '…'}
|
||||
</span>
|
||||
<h1 className="text-2xl font-semibold text-slate-100">Fleet health</h1>
|
||||
<div className="text-sm text-slate-500 mt-1">
|
||||
{fleet && (
|
||||
<>
|
||||
{' · updated '}
|
||||
<span className="font-mono">{fmtAge(fleet.fetched_at_unix)}</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>
|
||||
@@ -69,19 +64,24 @@ export function CommandCenter() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totals && fleet && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-slate-100 mb-3">
|
||||
Totals (fleet-wide)
|
||||
</h2>
|
||||
<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" />
|
||||
{totals && totals.diskTotal > 0 && (
|
||||
<div className="rounded-lg border border-slate-800 bg-slate-900/60 p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wider text-slate-500">
|
||||
Fleet-wide storage
|
||||
</div>
|
||||
<div className="text-2xl font-semibold font-mono mt-1">
|
||||
{fmtBytes(totals.diskUsed)}{' '}
|
||||
<span className="text-slate-500 text-lg">/ {fmtBytes(totals.diskTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<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) => (
|
||||
<div
|
||||
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>
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user