FleetHealth PR 3: 'Projects' panel — which repos live where
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 34s
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:
@@ -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")?;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)}`),
|
||||||
|
|||||||
@@ -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,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, {})] }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
{"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"}
|
||||||
Reference in New Issue
Block a user