Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 34s
Backend:
* New DashboardProject { repo, cache_bytes, fingerprint_count,
refs, first_seen_unix, last_seen_unix, tier }.
* Handler build_projects() joins ref-tracking with the ref-store
and blob-store: for each recorded fp, resolve fp → blob-id via
RefStore::{get_stamped, get} then sum blob sizes per repo.
* Tier is a wall-clock function of last_seen_unix:
active < 24h, recent < 7d, else idle.
* DashboardStorageReply gains `projects: Vec<DashboardProject>`
(serde-default so older clients still parse).
Aggregator:
* New /api/v2/projects endpoint. Fans out DashboardStorage to
every peer, tags each project row with its originating node,
returns hottest-first.
Frontend:
* New ProjectsPanel component appended to the FleetHealth
landing. Groups by repo, one row per project with tier badge
(active/recent/idle), per-node pill badges, cache-size sum,
ref list, last-activity age.
* Empty state explains how to populate: claw-cargo build with
--repo + --git-ref (or CLAWSTOR_REPO/CLAWSTOR_GIT_REF env
vars in CI).
Data populates automatically as each cache-put runs. Existing
demo entry on tank (clawverse/clawstor · main · c384a4...) will
surface after redeploy.
108 lines
3.6 KiB
TypeScript
108 lines
3.6 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { api, FleetSnapshot, fmtBytes, fmtAge } from '../lib/api';
|
|
import { NodeCard } from '../components/NodeCard';
|
|
import { ProjectsPanel } from '../components/ProjectsPanel';
|
|
|
|
// 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);
|
|
const [tick, setTick] = useState(0);
|
|
|
|
useEffect(() => {
|
|
api
|
|
.fleet()
|
|
.then((f) => {
|
|
setFleet(f);
|
|
setErr(null);
|
|
})
|
|
.catch((e) => setErr(String(e)));
|
|
}, [tick]);
|
|
|
|
useEffect(() => {
|
|
const id = setInterval(() => setTick((t) => t + 1), 10_000);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
|
|
const totals = fleet
|
|
? fleet.nodes.reduce(
|
|
(a, n) => ({
|
|
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),
|
|
}),
|
|
{ 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 health</h1>
|
|
<div className="text-sm text-slate-500 mt-1">
|
|
{fleet && (
|
|
<>
|
|
<span className="text-emerald-300">{totals?.online}</span>
|
|
/{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>
|
|
|
|
{err && (
|
|
<div className="rounded border border-red-800 bg-red-950/40 p-3 text-red-300 text-sm">
|
|
{err}
|
|
</div>
|
|
)}
|
|
|
|
{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>
|
|
<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-64 animate-pulse"
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<ProjectsPanel />
|
|
</div>
|
|
);
|
|
}
|