FleetHealth PR 3: 'Projects' panel — which repos live where
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.
This commit is contained in:
Omar Sobh
2026-07-14 19:06:40 -07:00
parent 22af481c3e
commit f31c94607a
9 changed files with 443 additions and 4 deletions
+164
View File
@@ -425,6 +425,10 @@ pub struct DashboardStorageReply {
/// Up to 200 (fingerprint, blob-id) pairs. Ordered by fp hex. /// Up to 200 (fingerprint, blob-id) pairs. Ordered by fp hex.
pub refs_sample: Vec<DashboardRef>, pub refs_sample: Vec<DashboardRef>,
pub refs_sample_capped_at: usize, pub refs_sample_capped_at: usize,
/// Per-repo rollup for the FleetHealth "Projects" widget.
/// Sorted by last_seen_unix descending — hottest first.
#[serde(default)]
pub projects: Vec<DashboardProject>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -463,6 +467,155 @@ pub struct DashboardRef {
pub blob_id_hex: String, pub blob_id_hex: String,
} }
/// Per-repo project rollup — the "which projects live here" view.
/// Derived from ref-tracking cross-referenced with ref-store +
/// blob-store: for each recorded (repo, git-ref, fp) triple we
/// know the blob it produced and thus its bytes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardProject {
pub repo: String,
/// Sum of blob sizes for every fingerprint this repo has
/// produced on this node. 0 when we can't resolve any
/// fp → blob mapping (e.g. eviction between record + view).
pub cache_bytes: u64,
pub fingerprint_count: usize,
/// Distinct git-refs observed across all fingerprints.
pub refs: Vec<String>,
pub first_seen_unix: u64,
pub last_seen_unix: u64,
/// "active" (< 24h), "recent" (< 7d), "idle" (older).
pub tier: String,
}
/// Group ref-tracking entries by repo. For each repo, sum the
/// bytes of every blob the recorded fingerprints resolved to
/// (via the stamped ref store). Tier is a wall-clock function
/// of `last_seen_unix` — "active" < 24h, "recent" < 7d, else
/// "idle".
async fn build_projects(
blob_store: Option<&crate::cluster::blob::BlobStore>,
ref_store: Option<&crate::cluster::refs::RefStore>,
_root: &Option<std::path::PathBuf>,
ref_tracking: &[DashboardRefTracking],
) -> Vec<DashboardProject> {
use std::collections::HashMap;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
// repo → aggregation state
struct Agg {
cache_bytes: u64,
fingerprint_count: usize,
refs: std::collections::HashSet<String>,
first_seen_unix: u64,
last_seen_unix: u64,
}
let mut by_repo: HashMap<String, Agg> = HashMap::new();
for entry in ref_tracking {
let fp_hex = &entry.fingerprint_hex;
// Resolve fp → blob-id via ref store (try stamped first,
// fall back to legacy).
let bytes = if let (Some(rs), Some(bs)) = (ref_store, blob_store) {
match decode_fp(fp_hex) {
Some(key) => {
let val = match rs.get_stamped(&key).await {
Ok(Some(s)) => Some(s.value),
_ => rs.get(&key).await.ok().flatten(),
};
match val {
Some(v) => {
let blob_id = crate::cluster::blob::BlobId::from_bytes(v);
bs.load_manifest(&blob_id)
.await
.ok()
.flatten()
.map(|m| m.total_size)
.unwrap_or(0)
}
None => 0,
}
}
None => 0,
}
} else {
0
};
let agg = by_repo.entry(entry.repo.clone()).or_insert(Agg {
cache_bytes: 0,
fingerprint_count: 0,
refs: std::collections::HashSet::new(),
first_seen_unix: u64::MAX,
last_seen_unix: 0,
});
agg.cache_bytes = agg.cache_bytes.saturating_add(bytes);
agg.fingerprint_count += 1;
for r in &entry.refs {
agg.refs.insert(r.clone());
}
agg.first_seen_unix = agg.first_seen_unix.min(entry.first_seen_unix);
agg.last_seen_unix = agg.last_seen_unix.max(entry.last_seen_unix);
}
let mut out: Vec<DashboardProject> = by_repo
.into_iter()
.map(|(repo, a)| {
let age = now.saturating_sub(a.last_seen_unix);
let tier = if age < 86_400 {
"active"
} else if age < 7 * 86_400 {
"recent"
} else {
"idle"
};
let mut refs: Vec<String> = a.refs.into_iter().collect();
refs.sort();
DashboardProject {
repo,
cache_bytes: a.cache_bytes,
fingerprint_count: a.fingerprint_count,
refs,
first_seen_unix: if a.first_seen_unix == u64::MAX {
0
} else {
a.first_seen_unix
},
last_seen_unix: a.last_seen_unix,
tier: tier.to_string(),
}
})
.collect();
// Hottest first.
out.sort_by(|a, b| b.last_seen_unix.cmp(&a.last_seen_unix));
out
}
fn decode_fp(hex: &str) -> Option<[u8; 32]> {
if hex.len() != 64 {
return None;
}
let bytes = hex.as_bytes();
let mut out = [0u8; 32];
for i in 0..32 {
let hi = decode_nibble(bytes[i * 2])?;
let lo = decode_nibble(bytes[i * 2 + 1])?;
out[i] = (hi << 4) | lo;
}
Some(out)
}
fn decode_nibble(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
/// statvfs on the given path. Uses libc directly — cheap enough /// statvfs on the given path. Uses libc directly — cheap enough
/// that we don't need to cache. Silent on error (returns None). /// that we don't need to cache. Silent on error (returns None).
fn filesystem_usage(path: &std::path::Path) -> Option<FilesystemUsage> { fn filesystem_usage(path: &std::path::Path) -> Option<FilesystemUsage> {
@@ -930,6 +1083,16 @@ impl RpcRouter {
} }
} }
} }
// Per-repo rollup: group ref-tracking by repo, sum
// blob sizes for each fp via ref-store lookup.
let projects = build_projects(
self.blob_store.as_deref(),
self.ref_store.as_deref(),
&blob_root,
&ref_tracking,
)
.await;
let reply = DashboardStorageReply { let reply = DashboardStorageReply {
node_name: self.local_name.clone(), node_name: self.local_name.clone(),
tags, tags,
@@ -939,6 +1102,7 @@ impl RpcRouter {
blobs_sample, blobs_sample,
refs_sample_capped_at: SAMPLE_CAP, refs_sample_capped_at: SAMPLE_CAP,
refs_sample, refs_sample,
projects,
}; };
let json = serde_json::to_vec(&reply) let json = serde_json::to_vec(&reply)
.context("encoding DashboardStorageReply as JSON")?; .context("encoding DashboardStorageReply as JSON")?;
+39
View File
@@ -251,6 +251,18 @@ pub struct RefRow {
pub blob_id_hex: String, pub blob_id_hex: String,
} }
#[derive(Serialize)]
pub struct ProjectRow {
pub node: String,
pub repo: String,
pub cache_bytes: u64,
pub fingerprint_count: usize,
pub refs: Vec<String>,
pub first_seen_unix: u64,
pub last_seen_unix: u64,
pub tier: String,
}
// ── handlers ───────────────────────────────────────────────────── // ── handlers ─────────────────────────────────────────────────────
async fn handle_fleet(State(s): State<Arc<V2State>>) -> Json<FleetSnapshot> { async fn handle_fleet(State(s): State<Arc<V2State>>) -> Json<FleetSnapshot> {
@@ -407,6 +419,32 @@ async fn handle_blobs(State(s): State<Arc<V2State>>) -> Json<Vec<BlobRow>> {
Json(rows) Json(rows)
} }
async fn handle_projects(State(s): State<Arc<V2State>>) -> Json<Vec<ProjectRow>> {
let mut rows = Vec::new();
for (node, r) in gather_storage(&s).await {
for p in r.projects {
rows.push(ProjectRow {
node: node.clone(),
repo: p.repo,
cache_bytes: p.cache_bytes,
fingerprint_count: p.fingerprint_count,
refs: p.refs,
first_seen_unix: p.first_seen_unix,
last_seen_unix: p.last_seen_unix,
tier: p.tier,
});
}
}
// Hottest first, then by node so ties from the same repo
// group visually.
rows.sort_by(|a, b| {
b.last_seen_unix
.cmp(&a.last_seen_unix)
.then_with(|| a.repo.cmp(&b.repo))
});
Json(rows)
}
async fn handle_refs(State(s): State<Arc<V2State>>) -> Json<Vec<RefRow>> { async fn handle_refs(State(s): State<Arc<V2State>>) -> Json<Vec<RefRow>> {
let mut rows = Vec::new(); let mut rows = Vec::new();
for (node, r) in gather_storage(&s).await { for (node, r) in gather_storage(&s).await {
@@ -446,4 +484,5 @@ pub fn routes() -> Router<Arc<V2State>> {
.route("/api/v2/storage/refs", get(handle_refs)) .route("/api/v2/storage/refs", get(handle_refs))
.route("/api/v2/storage/snapshots", get(handle_snapshots)) .route("/api/v2/storage/snapshots", get(handle_snapshots))
.route("/api/v2/storage/ref-tracking", get(handle_ref_tracking)) .route("/api/v2/storage/ref-tracking", get(handle_ref_tracking))
.route("/api/v2/projects", get(handle_projects))
} }
@@ -0,0 +1,66 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useMemo, useState } from 'react';
import { api, fmtAge, fmtBytes } from '../lib/api';
// "Which projects live where" — the human answer to what agents
// have cached across the fleet. Reads /api/v2/projects (aggregated
// from each daemon's ref-tracking → ref-store → blob-store chain).
export function ProjectsPanel() {
const [rows, setRows] = useState(null);
const [err, setErr] = useState(null);
const [tick, setTick] = useState(0);
useEffect(() => {
api
.projects()
.then((r) => {
setRows(r);
setErr(null);
})
.catch((e) => setErr(String(e)));
}, [tick]);
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 15_000);
return () => clearInterval(id);
}, []);
// Group per-repo so a single project appearing on multiple
// nodes surfaces as one card with a badge per node.
const grouped = useMemo(() => {
if (!rows)
return null;
const g = new Map();
for (const r of rows) {
const arr = g.get(r.repo) ?? [];
arr.push(r);
g.set(r.repo, arr);
}
return [...g.entries()]
.map(([repo, items]) => ({
repo,
items: items.sort((a, b) => b.last_seen_unix - a.last_seen_unix),
totalBytes: items.reduce((a, i) => a + i.cache_bytes, 0),
latest: Math.max(...items.map((i) => i.last_seen_unix)),
tier: hottestTier(items.map((i) => i.tier)),
}))
.sort((a, b) => b.latest - a.latest);
}, [rows]);
return (_jsxs("section", { children: [_jsxs("div", { className: "flex items-baseline justify-between mb-3", children: [_jsx("h2", { className: "text-lg font-semibold text-slate-100", children: "Projects" }), _jsx("span", { className: "text-xs text-slate-500", children: rows && `${rows.length} entries · ${grouped?.length ?? 0} repos` })] }), err && (_jsx("div", { className: "rounded border border-red-800 bg-red-950/40 p-3 text-red-300 text-sm", children: err })), rows && rows.length === 0 && (_jsxs("div", { className: "rounded border border-slate-800 bg-slate-900/40 p-4 text-sm text-slate-400", children: ["No project activity tracked yet. Once ", _jsx("code", { className: "font-mono text-slate-300", children: "claw-cargo build" }), ' ', "runs with ", _jsx("code", { className: "font-mono", children: "--repo" }), " +", ' ', _jsx("code", { className: "font-mono", children: "--git-ref" }), " (or the equivalent", _jsx("code", { className: "font-mono", children: " CLAWSTOR_REPO" }), "/", _jsx("code", { className: "font-mono", children: "CLAWSTOR_GIT_REF" }), " env vars in CI), each cache-put annotates the producing repo and this pane fills in."] })), grouped && grouped.length > 0 && (_jsx("div", { className: "rounded border border-slate-800 overflow-hidden", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { className: "bg-slate-900/60 text-slate-500 uppercase text-xs tracking-wider", children: _jsxs("tr", { children: [_jsx("th", { className: "text-left px-4 py-2 font-normal", children: "tier" }), _jsx("th", { className: "text-left px-4 py-2 font-normal", children: "repo" }), _jsx("th", { className: "text-left px-4 py-2 font-normal", children: "nodes" }), _jsx("th", { className: "text-left px-4 py-2 font-normal", children: "cache size" }), _jsx("th", { className: "text-left px-4 py-2 font-normal", children: "refs" }), _jsx("th", { className: "text-left px-4 py-2 font-normal", children: "last activity" })] }) }), _jsx("tbody", { children: grouped.map((g) => (_jsxs("tr", { className: "border-t border-slate-900", children: [_jsx("td", { className: "px-4 py-2", children: _jsx(TierBadge, { tier: g.tier }) }), _jsx("td", { className: "px-4 py-2 font-mono text-sm text-emerald-300", children: g.repo }), _jsx("td", { className: "px-4 py-2 space-x-1", children: g.items.map((it) => (_jsx(NodePill, { node: it.node, bytes: it.cache_bytes }, it.node))) }), _jsx("td", { className: "px-4 py-2 font-mono text-xs", children: fmtBytes(g.totalBytes) }), _jsx("td", { className: "px-4 py-2 font-mono text-xs text-slate-400", children: [...new Set(g.items.flatMap((i) => i.refs))]
.slice(0, 3)
.join(', ') || '—' }), _jsx("td", { className: "px-4 py-2 text-slate-400", children: fmtAge(g.latest) })] }, g.repo))) })] }) }))] }));
}
function hottestTier(tiers) {
if (tiers.includes('active'))
return 'active';
if (tiers.includes('recent'))
return 'recent';
return 'idle';
}
function TierBadge({ tier }) {
const cls = {
active: 'bg-emerald-900/60 text-emerald-300 border-emerald-700',
recent: 'bg-amber-900/60 text-amber-300 border-amber-700',
idle: 'bg-slate-800 text-slate-400 border-slate-700',
}[tier] ?? 'bg-slate-800 text-slate-400 border-slate-700';
return (_jsx("span", { className: `inline-block rounded border px-2 py-0.5 text-xs font-mono uppercase tracking-wider ${cls}`, children: tier }));
}
function NodePill({ node, bytes }) {
return (_jsx("a", { href: `#/nodes/${node}`, className: "inline-block rounded bg-slate-800 text-emerald-300 text-xs font-mono px-2 py-0.5 hover:bg-slate-700", title: `${fmtBytes(bytes)} on ${node}`, children: node }));
}
@@ -0,0 +1,155 @@
import { useEffect, useMemo, useState } from 'react';
import { api, ProjectRow, fmtAge, fmtBytes } from '../lib/api';
// "Which projects live where" — the human answer to what agents
// have cached across the fleet. Reads /api/v2/projects (aggregated
// from each daemon's ref-tracking → ref-store → blob-store chain).
export function ProjectsPanel() {
const [rows, setRows] = useState<ProjectRow[] | null>(null);
const [err, setErr] = useState<string | null>(null);
const [tick, setTick] = useState(0);
useEffect(() => {
api
.projects()
.then((r) => {
setRows(r);
setErr(null);
})
.catch((e) => setErr(String(e)));
}, [tick]);
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 15_000);
return () => clearInterval(id);
}, []);
// Group per-repo so a single project appearing on multiple
// nodes surfaces as one card with a badge per node.
const grouped = useMemo(() => {
if (!rows) return null;
const g = new Map<string, ProjectRow[]>();
for (const r of rows) {
const arr = g.get(r.repo) ?? [];
arr.push(r);
g.set(r.repo, arr);
}
return [...g.entries()]
.map(([repo, items]) => ({
repo,
items: items.sort((a, b) => b.last_seen_unix - a.last_seen_unix),
totalBytes: items.reduce((a, i) => a + i.cache_bytes, 0),
latest: Math.max(...items.map((i) => i.last_seen_unix)),
tier: hottestTier(items.map((i) => i.tier)),
}))
.sort((a, b) => b.latest - a.latest);
}, [rows]);
return (
<section>
<div className="flex items-baseline justify-between mb-3">
<h2 className="text-lg font-semibold text-slate-100">Projects</h2>
<span className="text-xs text-slate-500">
{rows && `${rows.length} entries · ${grouped?.length ?? 0} repos`}
</span>
</div>
{err && (
<div className="rounded border border-red-800 bg-red-950/40 p-3 text-red-300 text-sm">
{err}
</div>
)}
{rows && rows.length === 0 && (
<div className="rounded border border-slate-800 bg-slate-900/40 p-4 text-sm text-slate-400">
No project activity tracked yet. Once <code className="font-mono text-slate-300">claw-cargo build</code>{' '}
runs with <code className="font-mono">--repo</code> +{' '}
<code className="font-mono">--git-ref</code> (or the equivalent
<code className="font-mono"> CLAWSTOR_REPO</code>/
<code className="font-mono">CLAWSTOR_GIT_REF</code> env vars in CI),
each cache-put annotates the producing repo and this pane fills in.
</div>
)}
{grouped && grouped.length > 0 && (
<div className="rounded border border-slate-800 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-900/60 text-slate-500 uppercase text-xs tracking-wider">
<tr>
<th className="text-left px-4 py-2 font-normal">tier</th>
<th className="text-left px-4 py-2 font-normal">repo</th>
<th className="text-left px-4 py-2 font-normal">nodes</th>
<th className="text-left px-4 py-2 font-normal">cache size</th>
<th className="text-left px-4 py-2 font-normal">refs</th>
<th className="text-left px-4 py-2 font-normal">last activity</th>
</tr>
</thead>
<tbody>
{grouped.map((g) => (
<tr key={g.repo} className="border-t border-slate-900">
<td className="px-4 py-2">
<TierBadge tier={g.tier} />
</td>
<td className="px-4 py-2 font-mono text-sm text-emerald-300">
{g.repo}
</td>
<td className="px-4 py-2 space-x-1">
{g.items.map((it) => (
<NodePill
key={it.node}
node={it.node}
bytes={it.cache_bytes}
/>
))}
</td>
<td className="px-4 py-2 font-mono text-xs">
{fmtBytes(g.totalBytes)}
</td>
<td className="px-4 py-2 font-mono text-xs text-slate-400">
{[...new Set(g.items.flatMap((i) => i.refs))]
.slice(0, 3)
.join(', ') || '—'}
</td>
<td className="px-4 py-2 text-slate-400">
{fmtAge(g.latest)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
function hottestTier(tiers: string[]): string {
if (tiers.includes('active')) return 'active';
if (tiers.includes('recent')) return 'recent';
return 'idle';
}
function TierBadge({ tier }: { tier: string }) {
const cls = {
active: 'bg-emerald-900/60 text-emerald-300 border-emerald-700',
recent: 'bg-amber-900/60 text-amber-300 border-amber-700',
idle: 'bg-slate-800 text-slate-400 border-slate-700',
}[tier] ?? 'bg-slate-800 text-slate-400 border-slate-700';
return (
<span className={`inline-block rounded border px-2 py-0.5 text-xs font-mono uppercase tracking-wider ${cls}`}>
{tier}
</span>
);
}
function NodePill({ node, bytes }: { node: string; bytes: number }) {
return (
<a
href={`#/nodes/${node}`}
className="inline-block rounded bg-slate-800 text-emerald-300 text-xs font-mono px-2 py-0.5 hover:bg-slate-700"
title={`${fmtBytes(bytes)} on ${node}`}
>
{node}
</a>
);
}
+1 -2
View File
@@ -23,10 +23,9 @@ async function get(path) {
} }
return resp.json(); 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 = { export const api = {
fleet: () => get('/v2/fleet'), fleet: () => get('/v2/fleet'),
projects: () => get('/v2/projects'),
nodeStatus: (name) => get(`/v2/node/${name}/status`), nodeStatus: (name) => get(`/v2/node/${name}/status`),
blobs: (limit = 200, offset = 0) => get(`/v2/storage/blobs?limit=${limit}&offset=${offset}`), blobs: (limit = 200, offset = 0) => get(`/v2/storage/blobs?limit=${limit}&offset=${offset}`),
tags: (prefix = '') => get(`/v2/storage/tags?prefix=${encodeURIComponent(prefix)}`), tags: (prefix = '') => get(`/v2/storage/tags?prefix=${encodeURIComponent(prefix)}`),
+12
View File
@@ -121,8 +121,20 @@ async function get<T>(path: string): Promise<T> {
// All paths are relative to API_BASE. E.g. '/v2/fleet' becomes // All paths are relative to API_BASE. E.g. '/v2/fleet' becomes
// '/api/v2/fleet' locally or '/clawstor/api/v2/fleet' via Tailscale. // '/api/v2/fleet' locally or '/clawstor/api/v2/fleet' via Tailscale.
export interface ProjectRow {
node: string;
repo: string;
cache_bytes: number;
fingerprint_count: number;
refs: string[];
first_seen_unix: number;
last_seen_unix: number;
tier: 'active' | 'recent' | 'idle' | string;
}
export const api = { export const api = {
fleet: () => get<FleetSnapshot>('/v2/fleet'), fleet: () => get<FleetSnapshot>('/v2/fleet'),
projects: () => get<ProjectRow[]>('/v2/projects'),
nodeStatus: (name: string) => get<NodeStatusV2>(`/v2/node/${name}/status`), nodeStatus: (name: string) => get<NodeStatusV2>(`/v2/node/${name}/status`),
blobs: (limit = 200, offset = 0) => blobs: (limit = 200, offset = 0) =>
get<BlobSummary[]>(`/v2/storage/blobs?limit=${limit}&offset=${offset}`), get<BlobSummary[]>(`/v2/storage/blobs?limit=${limit}&offset=${offset}`),
+2 -1
View File
@@ -2,6 +2,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { api, fmtBytes, fmtAge } from '../lib/api'; import { api, fmtBytes, fmtAge } from '../lib/api';
import { NodeCard } from '../components/NodeCard'; import { NodeCard } from '../components/NodeCard';
import { ProjectsPanel } from '../components/ProjectsPanel';
// FleetHealth landing — human-oriented single-pane-of-glass. // FleetHealth landing — human-oriented single-pane-of-glass.
// Polls the aggregator's /api/v2/fleet every 10 s. // Polls the aggregator's /api/v2/fleet every 10 s.
export function CommandCenter() { export function CommandCenter() {
@@ -32,5 +33,5 @@ export function CommandCenter() {
}), { diskUsed: 0, diskTotal: 0, hotUsed: 0, hotMax: 0, online: 0, mounted: 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 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 && 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, 2, 3].map((i) => (_jsx("div", { className: "rounded-lg border border-slate-800 bg-slate-900/40 p-5 h-64 animate-pulse" }, i)))] })] }), _jsx(ProjectsPanel, {})] }));
} }
+3
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { api, FleetSnapshot, fmtBytes, fmtAge } from '../lib/api'; import { api, FleetSnapshot, fmtBytes, fmtAge } from '../lib/api';
import { NodeCard } from '../components/NodeCard'; import { NodeCard } from '../components/NodeCard';
import { ProjectsPanel } from '../components/ProjectsPanel';
// FleetHealth landing — human-oriented single-pane-of-glass. // FleetHealth landing — human-oriented single-pane-of-glass.
// Polls the aggregator's /api/v2/fleet every 10 s. // Polls the aggregator's /api/v2/fleet every 10 s.
@@ -99,6 +100,8 @@ export function CommandCenter() {
))} ))}
</div> </div>
</section> </section>
<ProjectsPanel />
</div> </div>
); );
} }
+1 -1
View File
@@ -1 +1 @@
{"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"} {"root":["./src/app.tsx","./src/main.tsx","./src/components/nodecard.tsx","./src/components/projectspanel.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"}