Files
clawstor/dashboard-v2/src/components/NodeCard.tsx
T
Omar Sobh 22af481c3e
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 18s
FleetHealth PR 2 (frontend): human-oriented landing + advanced menu
Reworks the SPA around the new DashboardStatus fields. The
landing is now a fleet-health dashboard aimed at a layperson —
disk gauges, mount ✓/✗, cache hit rate, next scheduled job.
No hex, no primitives.

Nav restructure:
* Primary: 'Fleet health' (just the landing).
* 'View Advanced ▾' dropdown reveals: Blobs / Tags / Refs /
  Snapshots / Ref-tracking. Routes moved under /advanced/*.

New components:
* StorageBar — horizontal used/total bar with pinned/evictable
  split, health-color threshold at 60/85%.
* NodeCard — traffic-light dot + disk + hot tier bars + mount
  state + cache hit rate + next-timer countdown. Whole card
  is a link into node detail.

New CommandCenter:
* Fleet-wide storage roll-up card (sum of every node's disk).
* Grid of NodeCards.
* 10s poll cadence retained from previous version.

Existing StorageBrowser + RefTrackingPage moved behind
/advanced/* routes; internal component code untouched.
2026-07-14 17:23:25 -07:00

140 lines
4.5 KiB
TypeScript

import { Link } from 'wouter';
import { NodeStatusV2 } from '../lib/api';
import { StorageBar } from './StorageBar';
interface Props {
node: NodeStatusV2;
}
/// 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 bg-slate-900 border transition-colors',
'p-5 space-y-4',
border,
].join(' ')}
>
{/* 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>
{/* 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>
) : (
<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>
);
}