wip: rescue uncommitted dashboard-v2 panels and cluster changes before archiving

Snapshot of work in progress found in ~/claw-store on 2026-09-12: maintenance,
pollution and warming-candidate panels in dashboard-v2, plus cluster blob/rpc/
services and daemon edits. Committed as-is for safekeeping.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DjN5qhFxHFuXjQ8krZoG81
This commit is contained in:
Omar Sobh
2026-09-12 04:40:42 -07:00
co-authored by Claude Fable 5.1
parent 5e7f7dfc83
commit 425ce58127
18 changed files with 1143 additions and 42 deletions
+84
View File
@@ -703,6 +703,90 @@ impl BlobStore {
}) })
} }
/// T3.4: pollution-score eviction.
///
/// Evicts blobs using `score = ln(chunk_count+1) * ln(age_secs+1) / (hit_count+1)`.
/// Highest-scoring (large, old, cold) blobs are evicted first. Falls back to
/// LRU ordering for blobs not in `hit_map` (treated as hit_count=0).
pub async fn evict_to_size_cap_with_scores(
&self,
max_bytes: u64,
pinned_blobs: &std::collections::HashSet<BlobId>,
hit_map: &std::collections::HashMap<BlobId, u64>,
) -> Result<GcReport> {
let manifest_summaries = self.collect_manifest_summaries().await?;
let mut referenced: std::collections::HashMap<ChunkHash, u32> =
std::collections::HashMap::new();
for summary in &manifest_summaries {
for hash in &summary.chunks {
*referenced.entry(*hash).or_insert(0) += 1;
}
}
let mut current_size: u64 = 0;
for hash in referenced.keys() {
if let Ok(meta) = tokio::fs::metadata(&self.chunk_path(hash)).await {
current_size = current_size.saturating_add(meta.len());
}
}
let now_unix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let mut summaries = manifest_summaries;
// Sort by pollution eviction score descending (evict highest first).
summaries.sort_by(|a, b| {
let score_of = |s: &ManifestSummary| {
let hit_count = hit_map.get(&s.blob_id).copied().unwrap_or(0);
let age = now_unix.saturating_sub(s.manifest_mtime);
let size_f = (s.chunks.len() as f64 + 1.0).ln();
let age_f = (age as f64 + 1.0).ln();
size_f * age_f / (hit_count as f64 + 1.0)
};
score_of(b)
.partial_cmp(&score_of(a))
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut chunks_removed = 0usize;
let mut bytes_reclaimed = 0u64;
for summary in summaries {
if current_size <= max_bytes {
break;
}
if pinned_blobs.contains(&summary.blob_id) {
continue;
}
self.delete_manifest(&summary.blob_id).await?;
for hash in &summary.chunks {
let entry = referenced.entry(*hash).or_insert(0);
if *entry > 0 {
*entry -= 1;
}
if *entry == 0 {
let path = self.chunk_path(hash);
if let Ok(meta) = tokio::fs::metadata(&path).await {
let sz = meta.len();
if tokio::fs::remove_file(&path).await.is_ok() {
chunks_removed += 1;
bytes_reclaimed = bytes_reclaimed.saturating_add(sz);
current_size = current_size.saturating_sub(sz);
}
}
referenced.remove(hash);
}
}
}
Ok(GcReport {
chunks_scanned: 0,
chunks_removed,
bytes_reclaimed,
})
}
/// Enumerate every on-disk manifest with the info eviction needs: /// Enumerate every on-disk manifest with the info eviction needs:
/// blob_id, chunk set, and mtime for LRU ordering. Bounded by the /// blob_id, chunk set, and mtime for LRU ordering. Bounded by the
/// number of manifests (small — one per cached target dir). /// number of manifests (small — one per cached target dir).
+207
View File
@@ -476,6 +476,27 @@ pub struct DashboardStorageReply {
/// Sorted by last_seen_unix descending — hottest first. /// Sorted by last_seen_unix descending — hottest first.
#[serde(default)] #[serde(default)]
pub projects: Vec<DashboardProject>, pub projects: Vec<DashboardProject>,
/// T2.7/T2.8: top-20 fingerprints by GetRef hit count since
/// daemon start. Used for cache-warming recommendations and
/// pollution detection. Empty until the first GetRef hit.
#[serde(default)]
pub hot_refs: Vec<HotRef>,
/// T1.2: recent GC/scrub/repair events on this node, newest first.
/// Capped at 100 entries; resets on daemon restart.
#[serde(default)]
pub maintenance_events: Vec<MaintenanceEvent>,
}
/// One entry from the per-fingerprint access log.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HotRef {
pub fingerprint_hex: String,
/// Total successful GetRef lookups since daemon start.
pub hit_count: u64,
/// Unix timestamp of the most recent hit.
pub last_hit_unix: u64,
/// Unix timestamp of the first hit.
pub first_hit_unix: u64,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -797,12 +818,131 @@ fn dir_size_bytes(root: &std::path::Path) -> u64 {
total total
} }
/// Maximum number of fingerprints tracked in the access log before the
/// lowest-count entry is evicted. 2000 covers ~3 months of daily unique
/// fingerprints at typical CI cadences.
const ACCESS_LOG_MAX: usize = 2000;
/// Per-fingerprint access record. Counts how many times a fingerprint
/// has been successfully served via `GetRef` or `GetRefVersioned`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessRecord {
/// Total successful lookups since daemon start.
pub count: u64,
/// Unix timestamp of the very first hit.
pub first_unix: u64,
/// Unix timestamp of the most recent hit.
pub last_unix: u64,
}
/// Bounded in-process log of per-fingerprint hit counts. Enables T2.7
/// (cache warming) and T2.8 (pollution detection) by recording which
/// fingerprints are hot, warm, or cold since the daemon started.
pub struct AccessLog {
entries: std::collections::HashMap<[u8; 32], AccessRecord>,
}
impl Default for AccessLog {
fn default() -> Self {
Self { entries: std::collections::HashMap::new() }
}
}
impl AccessLog {
/// Record one successful lookup for `key` at wall-clock `now_unix`.
/// On overflow (> ACCESS_LOG_MAX), the lowest-count entry is evicted.
pub fn record(&mut self, key: &[u8; 32], now_unix: u64) {
if let Some(rec) = self.entries.get_mut(key) {
rec.count += 1;
rec.last_unix = now_unix;
return;
}
if self.entries.len() >= ACCESS_LOG_MAX {
// Evict the least-accessed entry so hot fingerprints survive.
if let Some(&evict_key) = self
.entries
.iter()
.min_by_key(|(_, v)| v.count)
.map(|(k, _)| k)
{
self.entries.remove(&evict_key);
}
}
self.entries.insert(*key, AccessRecord { count: 1, first_unix: now_unix, last_unix: now_unix });
}
/// Return the top `n` entries sorted by count descending.
pub fn top(&self, n: usize) -> Vec<([u8; 32], AccessRecord)> {
let mut v: Vec<_> = self
.entries
.iter()
.map(|(k, v)| (*k, v.clone()))
.collect();
v.sort_by(|a, b| b.1.count.cmp(&a.1.count).then(b.1.last_unix.cmp(&a.1.last_unix)));
v.truncate(n);
v
}
/// Total number of fingerprints currently tracked.
pub fn len(&self) -> usize {
self.entries.len()
}
}
/// T1.2: one entry in the per-node maintenance event log.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaintenanceEvent {
/// Logical kind: "gc_orphan", "gc_eviction_scored", "scrub", "repair".
pub kind: String,
/// Wall-clock unix timestamp when the event completed.
pub unix_ts: u64,
pub chunks_scanned: usize,
pub chunks_removed: usize,
pub bytes_reclaimed: u64,
/// Non-empty when the operation encountered an error.
#[serde(default)]
pub error: Option<String>,
}
const MAINTENANCE_LOG_MAX: usize = 100;
/// Bounded ring of recent maintenance events. Oldest entry is dropped
/// when the cap is reached.
pub struct MaintenanceLog {
entries: std::collections::VecDeque<MaintenanceEvent>,
}
impl Default for MaintenanceLog {
fn default() -> Self {
Self { entries: std::collections::VecDeque::new() }
}
}
impl MaintenanceLog {
pub fn push(&mut self, ev: MaintenanceEvent) {
if self.entries.len() >= MAINTENANCE_LOG_MAX {
self.entries.pop_front();
}
self.entries.push_back(ev);
}
/// Return all events, most-recent first.
pub fn recent(&self) -> Vec<MaintenanceEvent> {
self.entries.iter().cloned().rev().collect()
}
}
pub struct RpcRouter { pub struct RpcRouter {
gossip: Arc<ClusterGossip>, gossip: Arc<ClusterGossip>,
blob_store: Option<Arc<BlobStore>>, blob_store: Option<Arc<BlobStore>>,
ref_store: Option<Arc<RefStore>>, ref_store: Option<Arc<RefStore>>,
tag_store: Option<Arc<TagStore>>, tag_store: Option<Arc<TagStore>>,
metrics: Arc<CacheMetrics>, metrics: Arc<CacheMetrics>,
/// T2.7/T2.8: per-fingerprint hit counter. Mutex because it's
/// written on every GetRef hit and read by the dashboard poller.
access_log: std::sync::Arc<std::sync::Mutex<AccessLog>>,
/// T1.2: recent GC/scrub/repair events for the maintenance panel.
maintenance_log: std::sync::Arc<std::sync::Mutex<MaintenanceLog>>,
local_name: String, local_name: String,
local_zone: String, local_zone: String,
/// Ref-forwarding (2026-07-13): when set, `GetRef` misses fan out /// Ref-forwarding (2026-07-13): when set, `GetRef` misses fan out
@@ -837,6 +977,8 @@ impl RpcRouter {
ref_store: None, ref_store: None,
tag_store: None, tag_store: None,
metrics: Arc::new(CacheMetrics::new()), metrics: Arc::new(CacheMetrics::new()),
access_log: std::sync::Arc::new(std::sync::Mutex::new(AccessLog::default())),
maintenance_log: std::sync::Arc::new(std::sync::Mutex::new(MaintenanceLog::default())),
local_name, local_name,
local_zone, local_zone,
outbound_client: None, outbound_client: None,
@@ -877,6 +1019,36 @@ impl RpcRouter {
self.outbound_client.clone() self.outbound_client.clone()
} }
/// Shared handle to the per-fingerprint access log (T2.7/T2.8).
/// The dashboard poller reads this to identify hot/cold refs.
pub fn access_log(&self) -> std::sync::Arc<std::sync::Mutex<AccessLog>> {
self.access_log.clone()
}
/// T1.2: shared handle to the maintenance event log.
pub fn maintenance_log(&self) -> std::sync::Arc<std::sync::Mutex<MaintenanceLog>> {
self.maintenance_log.clone()
}
/// Append a maintenance event. Called from services.rs gc_task after
/// each GC/scrub run so the dashboard can show recent activity.
pub fn push_maintenance_event(&self, ev: MaintenanceEvent) {
if let Ok(mut log) = self.maintenance_log.lock() {
log.push(ev);
}
}
/// Record one successful GetRef hit in the access log.
fn record_access(&self, key: &[u8; 32]) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if let Ok(mut log) = self.access_log.lock() {
log.record(key, now);
}
}
/// Attach a local blob store. Enables the `Blob*` methods; nodes /// Attach a local blob store. Enables the `Blob*` methods; nodes
/// without a store return [`ErrorCode::NotConfigured`] for those. /// without a store return [`ErrorCode::NotConfigured`] for those.
pub fn with_blob_store(mut self, store: Arc<BlobStore>) -> Self { pub fn with_blob_store(mut self, store: Arc<BlobStore>) -> Self {
@@ -1224,6 +1396,35 @@ impl RpcRouter {
) )
.await; .await;
// T2.7/T2.8: embed top-20 hot fingerprints from the
// access log so peers can recommend cross-node warming.
let hot_refs = self
.access_log
.lock()
.map(|log| {
log.top(20)
.into_iter()
.map(|(k, r)| {
let mut hex = String::with_capacity(64);
for b in &k { hex.push_str(&format!("{b:02x}")); }
HotRef {
fingerprint_hex: hex,
hit_count: r.count,
last_hit_unix: r.last_unix,
first_hit_unix: r.first_unix,
}
})
.collect()
})
.unwrap_or_default();
// T1.2: recent GC/scrub/repair events for the maintenance panel.
let maintenance_events = self
.maintenance_log
.lock()
.map(|log| log.recent())
.unwrap_or_default();
let reply = DashboardStorageReply { let reply = DashboardStorageReply {
node_name: self.local_name.clone(), node_name: self.local_name.clone(),
tags, tags,
@@ -1234,6 +1435,8 @@ impl RpcRouter {
refs_sample_capped_at: SAMPLE_CAP, refs_sample_capped_at: SAMPLE_CAP,
refs_sample, refs_sample,
projects, projects,
hot_refs,
maintenance_events,
}; };
let json = serde_json::to_vec(&reply) let json = serde_json::to_vec(&reply)
.context("encoding DashboardStorageReply as JSON")?; .context("encoding DashboardStorageReply as JSON")?;
@@ -1453,6 +1656,7 @@ impl RpcRouter {
// Local first. // Local first.
if let Some(value) = store.get(&key).await? { if let Some(value) = store.get(&key).await? {
self.metrics.record_get_ref_hit(); self.metrics.record_get_ref_hit();
self.record_access(&key);
return Ok(HandlerOutcome::Reply(value.to_vec())); return Ok(HandlerOutcome::Reply(value.to_vec()));
} }
// Ref-forwarding: try peers via gossip. First hit wins // Ref-forwarding: try peers via gossip. First hit wins
@@ -1460,6 +1664,7 @@ impl RpcRouter {
// (and reads) are all local. // (and reads) are all local.
if let Some(value) = self.forward_get_ref(&key).await { if let Some(value) = self.forward_get_ref(&key).await {
self.metrics.record_get_ref_hit(); self.metrics.record_get_ref_hit();
self.record_access(&key);
return Ok(HandlerOutcome::Reply(value.to_vec())); return Ok(HandlerOutcome::Reply(value.to_vec()));
} }
self.metrics.record_get_ref_miss(); self.metrics.record_get_ref_miss();
@@ -1530,6 +1735,7 @@ impl RpcRouter {
// Local first. // Local first.
if let Some(s) = store.get_stamped(&key).await? { if let Some(s) = store.get_stamped(&key).await? {
self.metrics.record_get_ref_hit(); self.metrics.record_get_ref_hit();
self.record_access(&key);
return Ok(HandlerOutcome::Reply(s.to_bytes().to_vec())); return Ok(HandlerOutcome::Reply(s.to_bytes().to_vec()));
} }
// Phase 3b: cross-runner sharing for stamped refs. // Phase 3b: cross-runner sharing for stamped refs.
@@ -1538,6 +1744,7 @@ impl RpcRouter {
// pure local hits (same semantics as GetRef path). // pure local hits (same semantics as GetRef path).
if let Some(s) = self.forward_get_ref_versioned(&key).await { if let Some(s) = self.forward_get_ref_versioned(&key).await {
self.metrics.record_get_ref_hit(); self.metrics.record_get_ref_hit();
self.record_access(&key);
return Ok(HandlerOutcome::Reply(s.to_bytes().to_vec())); return Ok(HandlerOutcome::Reply(s.to_bytes().to_vec()));
} }
self.metrics.record_get_ref_miss(); self.metrics.record_get_ref_miss();
+126 -20
View File
@@ -389,6 +389,8 @@ impl ClusterServices {
(Some(store), Some(hours)) if hours > 0 => { (Some(store), Some(hours)) if hours > 0 => {
let store = store.clone(); let store = store.clone();
let tag_store_for_gc = tag_store.clone(); let tag_store_for_gc = tag_store.clone();
let ref_store_for_gc = ref_store.clone();
let router_for_gc = router.clone();
// Phase 7d follow-on: snapshot store is under the // Phase 7d follow-on: snapshot store is under the
// same root as the blob store. Open once here so the // same root as the blob store. Open once here so the
// ticker doesn't pay the fs setup cost every tick. // ticker doesn't pay the fs setup cost every tick.
@@ -406,15 +408,45 @@ impl ClusterServices {
ticker.tick().await; ticker.tick().await;
loop { loop {
ticker.tick().await; ticker.tick().await;
let now_unix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
match store.gc_orphan_chunks().await { match store.gc_orphan_chunks().await {
Ok(r) => tracing::info!( Ok(r) => {
chunks_scanned = r.chunks_scanned, tracing::info!(
chunks_removed = r.chunks_removed, chunks_scanned = r.chunks_scanned,
bytes_reclaimed = r.bytes_reclaimed, chunks_removed = r.chunks_removed,
"auto-GC swept orphan chunks" bytes_reclaimed = r.bytes_reclaimed,
), "auto-GC swept orphan chunks"
);
if let Some(router) = &router_for_gc {
router.push_maintenance_event(
crate::cluster::rpc::MaintenanceEvent {
kind: "gc_orphan".into(),
unix_ts: now_unix,
chunks_scanned: r.chunks_scanned,
chunks_removed: r.chunks_removed,
bytes_reclaimed: r.bytes_reclaimed,
error: None,
},
);
}
}
Err(e) => { Err(e) => {
tracing::warn!(error = %e, "auto-GC failed; will retry next tick") tracing::warn!(error = %e, "auto-GC failed; will retry next tick");
if let Some(router) = &router_for_gc {
router.push_maintenance_event(
crate::cluster::rpc::MaintenanceEvent {
kind: "gc_orphan".into(),
unix_ts: now_unix,
chunks_scanned: 0,
chunks_removed: 0,
bytes_reclaimed: 0,
error: Some(e.to_string()),
},
);
}
} }
} }
// Field finding 2026-07-12: if configured with a // Field finding 2026-07-12: if configured with a
@@ -463,23 +495,60 @@ impl ClusterServices {
pinned.extend(snaps); pinned.extend(snaps);
} }
} }
// T3.4: build blob→hit_count map for
// pollution-score eviction (replaces pure LRU).
let hit_map = build_blob_hit_map(
&router_for_gc,
&ref_store_for_gc,
).await;
let hit_count = hit_map.values().sum::<u64>();
let eviction_now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
match store match store
.evict_to_size_cap_with_pins(cap, &pinned) .evict_to_size_cap_with_scores(cap, &pinned, &hit_map)
.await .await
{ {
Ok(r) if r.chunks_removed > 0 => tracing::info!( Ok(r) if r.chunks_removed > 0 => {
chunks_removed = r.chunks_removed, tracing::info!(
bytes_reclaimed = r.bytes_reclaimed, chunks_removed = r.chunks_removed,
max_gb = gb, bytes_reclaimed = r.bytes_reclaimed,
pinned_blobs = pinned.len(), max_gb = gb,
snapshot_pins = snapshot_pin_count, pinned_blobs = pinned.len(),
"auto-GC evicted LRU blobs to hit size cap" snapshot_pins = snapshot_pin_count,
), scored_blobs = hit_count,
"auto-GC evicted pollution-scored blobs to hit size cap"
);
if let Some(router) = &router_for_gc {
router.push_maintenance_event(
crate::cluster::rpc::MaintenanceEvent {
kind: "gc_eviction_scored".into(),
unix_ts: eviction_now,
chunks_scanned: 0,
chunks_removed: r.chunks_removed,
bytes_reclaimed: r.bytes_reclaimed,
error: None,
},
);
}
}
Ok(_) => {} // under cap already; keep quiet Ok(_) => {} // under cap already; keep quiet
Err(e) => tracing::warn!( Err(e) => {
error = %e, tracing::warn!(error = %e, "auto-GC eviction failed; will retry next tick");
"auto-GC eviction failed; will retry next tick" if let Some(router) = &router_for_gc {
), router.push_maintenance_event(
crate::cluster::rpc::MaintenanceEvent {
kind: "gc_eviction_scored".into(),
unix_ts: eviction_now,
chunks_scanned: 0,
chunks_removed: 0,
bytes_reclaimed: 0,
error: Some(e.to_string()),
},
);
}
}
} }
} }
} }
@@ -674,6 +743,43 @@ impl ClusterServices {
} }
} }
/// T3.4: build a blob_id → total_hit_count map from the access log + ref store.
///
/// The AccessLog tracks fingerprint→hits; the ref store maps fingerprint→blob_id.
/// Joining them gives blob_id→hits which the pollution-score eviction needs.
/// Returns an empty map when either input is unavailable (GC falls back to mtime ordering).
async fn build_blob_hit_map(
router: &Option<Arc<crate::cluster::rpc::RpcRouter>>,
ref_store: &Option<Arc<crate::cluster::refs::RefStore>>,
) -> std::collections::HashMap<crate::cluster::blob::BlobId, u64> {
use crate::cluster::blob::BlobId;
let mut map = std::collections::HashMap::new();
let (Some(router), Some(rs)) = (router, ref_store) else {
return map;
};
// fingerprint_bytes → hit_count from the in-memory access log
let fp_hits: std::collections::HashMap<[u8; 32], u64> =
match router.access_log().lock() {
Ok(log) => log.top(usize::MAX).into_iter().map(|(fp, rec)| (fp, rec.count)).collect(),
Err(_) => return map,
};
if fp_hits.is_empty() {
return map;
}
// fingerprint → blob_id from the ref store
let pairs = match rs.list().await {
Ok(p) => p,
Err(_) => return map,
};
for (fp_key, blob_id_bytes) in pairs {
if let Some(&hits) = fp_hits.get(&fp_key) {
let bid = BlobId::from_bytes(blob_id_bytes);
*map.entry(bid).or_insert(0u64) += hits;
}
}
map
}
/// Loop accepting incoming QUIC connections and dispatching each to a /// Loop accepting incoming QUIC connections and dispatching each to a
/// per-connection task running the RPC router. Runs until the endpoint /// per-connection task running the RPC router. Runs until the endpoint
/// is closed (which happens when the parent task is aborted). /// is closed (which happens when the parent task is aborted).
+25 -17
View File
@@ -188,27 +188,35 @@ pub async fn run(cfg: Config, mut manifest: Manifest) -> Result<()> {
} }
} }
_ = snap_tick.tick() => { _ = snap_tick.tick() => {
let ts = chrono::Utc::now().format("%Y-%m-%d-%H%M").to_string(); let dataset = &cfg.warm.zfs_dataset;
tracing::info!("taking snapshot {}", ts); if dataset.is_empty() || dataset == "none" {
if let Err(e) = snapshot::run_snapshot_cycle( tracing::debug!("zfs_dataset=none — skipping snapshot");
&zfs, &cfg.warm.zfs_dataset, &ts, } else {
cfg.warm.snapshot_retain_hours as usize, let ts = chrono::Utc::now().format("%Y-%m-%d-%H%M").to_string();
cfg.warm.snapshot_retain_days as usize, tracing::info!("taking snapshot {}", ts);
cfg.warm.snapshot_retain_weeks as usize, if let Err(e) = snapshot::run_snapshot_cycle(
) { &zfs, dataset, &ts,
tracing::error!("snapshot failed: {:#}", e); cfg.warm.snapshot_retain_hours as usize,
cfg.warm.snapshot_retain_days as usize,
cfg.warm.snapshot_retain_weeks as usize,
) {
tracing::error!("snapshot failed: {:#}", e);
}
} }
} }
_ = repl_tick.tick() => { _ = repl_tick.tick() => {
if let Some(rep) = &cfg.replication { let dataset = &cfg.warm.zfs_dataset;
if let (Some(host), Some(user), Some(dest)) = ( if !dataset.is_empty() && dataset != "none" {
&rep.send_to_host, &rep.send_to_user, &rep.cold_dataset_on_peer if let Some(rep) = &cfg.replication {
) { if let (Some(host), Some(user), Some(dest)) = (
tracing::info!("replicating warm → cold on {}", host); &rep.send_to_host, &rep.send_to_user, &rep.cold_dataset_on_peer
if let Err(e) = snapshot::replicate_to_cold(
&zfs, &cfg.warm.zfs_dataset, user, host, dest
) { ) {
tracing::error!("replication failed: {:#}", e); tracing::info!("replicating warm → cold on {}", host);
if let Err(e) = snapshot::replicate_to_cold(
&zfs, dataset, user, host, dest
) {
tracing::error!("replication failed: {:#}", e);
}
} }
} }
} }
+193 -2
View File
@@ -286,9 +286,26 @@ impl V2State {
.join("aggregator-sessions.json"); .join("aggregator-sessions.json");
let sessions = SessionStore::load(sessions_path) let sessions = SessionStore::load(sessions_path)
.map_err(|e| anyhow::anyhow!("loading session store: {e}"))?; .map_err(|e| anyhow::anyhow!("loading session store: {e}"))?;
// Include the serving node itself as a peer so it appears in
// fleet/storage/aggregated views. Skip if already listed.
let self_name = cfg.node.name.clone();
let mut peers = cluster.peers.clone();
if !peers.iter().any(|p| p.name == self_name) {
if cluster.bind_lan.is_some() || cluster.bind_tailscale.is_some() {
peers.insert(
0,
crate::config::PeerEntry {
name: self_name.clone(),
zone: cluster.zone.clone(),
lan_addr: cluster.bind_lan,
tailscale_addr: cluster.bind_tailscale,
},
);
}
}
Ok(Self { Ok(Self {
aggregator_name: cfg.node.name.clone(), aggregator_name: self_name,
peers: cluster.peers.clone(), peers,
client: std::sync::Arc::new(client), client: std::sync::Arc::new(client),
default_rpc_port_offset: 1, default_rpc_port_offset: 1,
api_token: cfg.api_token.clone(), api_token: cfg.api_token.clone(),
@@ -1559,6 +1576,177 @@ async fn handle_anomalies(State(s): State<Arc<V2State>>) -> Json<Vec<AnomalyStat
Json(hist.anomaly_statuses()) Json(hist.anomaly_statuses())
} }
/// T2.7: cross-node cache warming candidates.
///
/// A warming candidate is a fingerprint that is frequently accessed on
/// at least one node but absent (never hit) on at least one other node.
/// Pushing the blob to the missing node turns the next GetRef there
/// into a local hit instead of a cross-node forwarded fetch.
#[derive(serde::Serialize)]
struct WarmingCandidate {
fingerprint_hex: String,
/// Nodes where this fingerprint has been accessed. Sorted.
hot_on: Vec<String>,
/// Nodes where the fingerprint has zero recorded hits. Sorted.
missing_on: Vec<String>,
/// Highest hit count seen across all hot nodes.
max_hit_count: u64,
/// Most recent access timestamp across all hot nodes.
last_hit_unix: u64,
}
async fn handle_hot_refs(State(s): State<Arc<V2State>>) -> Json<Vec<WarmingCandidate>> {
// Collect each node's hot refs via the existing gather_storage fan-out.
let storage = gather_storage(&s).await;
let node_names: Vec<String> = storage.iter().map(|(n, _)| n.clone()).collect();
// Build a map: fingerprint_hex → { node → (hit_count, last_hit_unix) }
let mut fp_map: std::collections::HashMap<
String,
std::collections::HashMap<String, (u64, u64)>,
> = std::collections::HashMap::new();
for (node, reply) in &storage {
for hr in &reply.hot_refs {
fp_map
.entry(hr.fingerprint_hex.clone())
.or_default()
.insert(node.clone(), (hr.hit_count, hr.last_hit_unix));
}
}
// A candidate is hot on ≥1 node and missing on ≥1 other node.
let mut candidates: Vec<WarmingCandidate> = fp_map
.into_iter()
.filter_map(|(fp, node_hits)| {
let missing_on: Vec<String> = node_names
.iter()
.filter(|n| !node_hits.contains_key(*n))
.cloned()
.collect();
if missing_on.is_empty() {
return None; // present (or hit-tracked) on all nodes
}
let mut hot_on: Vec<String> = node_hits.keys().cloned().collect();
hot_on.sort();
let max_hit_count = node_hits.values().map(|&(c, _)| c).max().unwrap_or(0);
let last_hit_unix = node_hits.values().map(|&(_, t)| t).max().unwrap_or(0);
Some(WarmingCandidate {
fingerprint_hex: fp,
hot_on,
missing_on: { let mut v = missing_on; v.sort(); v },
max_hit_count,
last_hit_unix,
})
})
.collect();
// Hottest candidates first.
candidates.sort_by(|a, b| b.max_hit_count.cmp(&a.max_hit_count));
candidates.truncate(50);
Json(candidates)
}
/// T1.2: fleet-wide maintenance event log.
///
/// Aggregates GC/scrub/repair events from every peer and returns them
/// sorted newest-first, so the operator sees a single unified timeline
/// rather than having to check each node individually.
#[derive(serde::Serialize)]
struct MaintenanceRow {
node: String,
kind: String,
unix_ts: u64,
chunks_scanned: usize,
chunks_removed: usize,
bytes_reclaimed: u64,
error: Option<String>,
}
async fn handle_maintenance(State(s): State<Arc<V2State>>) -> Json<Vec<MaintenanceRow>> {
let storage = gather_storage(&s).await;
let mut rows: Vec<MaintenanceRow> = storage
.iter()
.flat_map(|(node, reply)| {
reply.maintenance_events.iter().map(move |ev| MaintenanceRow {
node: node.clone(),
kind: ev.kind.clone(),
unix_ts: ev.unix_ts,
chunks_scanned: ev.chunks_scanned,
chunks_removed: ev.chunks_removed,
bytes_reclaimed: ev.bytes_reclaimed,
error: ev.error.clone(),
})
})
.collect();
rows.sort_by(|a, b| b.unix_ts.cmp(&a.unix_ts));
Json(rows)
}
/// T2.8: cache pollution candidates (ACPC-inspired).
///
/// A blob is "polluting" the hot tier when it is large relative to how often
/// it is actually accessed. Pollution score = size_bytes / (hit_count + 1).
/// Zero-hit blobs score highest; large blobs that are hit frequently score low.
#[derive(serde::Serialize)]
struct PollutionCandidate {
node: String,
fingerprint_hex: String,
blob_id_hex: String,
size_bytes: u64,
/// GetRef hits since last daemon restart.
hit_count: u64,
/// size_bytes / (hit_count + 1) — higher = more polluting.
pollution_score: f64,
}
async fn handle_pollution(State(s): State<Arc<V2State>>) -> Json<Vec<PollutionCandidate>> {
let storage = gather_storage(&s).await;
let mut candidates = Vec::new();
for (node, reply) in &storage {
// fingerprint → hit_count
let hot_map: std::collections::HashMap<&str, u64> = reply
.hot_refs
.iter()
.map(|hr| (hr.fingerprint_hex.as_str(), hr.hit_count))
.collect();
// blob_id → size_bytes
let blob_map: std::collections::HashMap<&str, u64> = reply
.blobs_sample
.iter()
.map(|b| (b.blob_id_hex.as_str(), b.size_bytes))
.collect();
for dr in &reply.refs_sample {
let size_bytes = match blob_map.get(dr.blob_id_hex.as_str()) {
Some(&s) if s > 0 => s,
_ => continue, // skip unknown-size entries
};
let hit_count = *hot_map.get(dr.fingerprint_hex.as_str()).unwrap_or(&0);
let pollution_score = size_bytes as f64 / (hit_count as f64 + 1.0);
candidates.push(PollutionCandidate {
node: node.clone(),
fingerprint_hex: dr.fingerprint_hex.clone(),
blob_id_hex: dr.blob_id_hex.clone(),
size_bytes,
hit_count,
pollution_score,
});
}
}
// Highest pollution score first (largest cold blobs at the top).
candidates.sort_by(|a, b| {
b.pollution_score
.partial_cmp(&a.pollution_score)
.unwrap_or(std::cmp::Ordering::Equal)
});
candidates.truncate(50);
Json(candidates)
}
// ───────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────
pub fn build(state: Arc<V2State>) -> Router { pub fn build(state: Arc<V2State>) -> Router {
@@ -1584,6 +1772,9 @@ pub fn build(state: Arc<V2State>) -> Router {
.route("/api/v2/node/:name/status", get(handle_node_status)) .route("/api/v2/node/:name/status", get(handle_node_status))
.route("/api/v2/node/:name/metrics-history", get(handle_metrics_history)) .route("/api/v2/node/:name/metrics-history", get(handle_metrics_history))
.route("/api/v2/anomalies", get(handle_anomalies)) .route("/api/v2/anomalies", get(handle_anomalies))
.route("/api/v2/hot-refs", get(handle_hot_refs))
.route("/api/v2/pollution", get(handle_pollution))
.route("/api/v2/maintenance", get(handle_maintenance))
.route("/api/v2/storage/blobs", get(handle_blobs)) .route("/api/v2/storage/blobs", get(handle_blobs))
.route("/api/v2/storage/tags", get(handle_tags)) .route("/api/v2/storage/tags", get(handle_tags))
.route("/api/v2/storage/refs", get(handle_refs)) .route("/api/v2/storage/refs", get(handle_refs))
@@ -0,0 +1,36 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useState } from 'react';
import { api, fmtBytes, fmtAge } from '../lib/api';
function kindLabel(kind) {
if (kind === 'gc_orphan')
return 'Orphan GC';
if (kind === 'gc_eviction_scored')
return 'Scored Eviction';
if (kind === 'scrub')
return 'Scrub';
return kind;
}
function kindColor(kind) {
if (kind === 'gc_eviction_scored')
return 'text-amber-300';
if (kind === 'scrub')
return 'text-blue-400';
return 'text-slate-300';
}
export function MaintenancePanel() {
const [rows, setRows] = useState([]);
const [err, setErr] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api
.maintenance()
.then((r) => { setRows(r); setErr(null); })
.catch((e) => setErr(String(e)))
.finally(() => setLoading(false));
}, []);
return (_jsxs("div", { className: "rounded border border-slate-800 bg-slate-900/40 p-4", children: [_jsx("h2", { className: "text-sm font-semibold text-slate-300 uppercase tracking-wider mb-3", children: "GC / Maintenance Log" }), loading && _jsx("div", { className: "text-slate-500 text-sm", children: "Loading\u2026" }), err && _jsx("div", { className: "text-amber-400 text-sm", children: err }), !loading && !err && rows.length === 0 && (_jsx("div", { className: "text-slate-500 text-sm italic", children: "No maintenance events recorded yet." })), rows.length > 0 && (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsxs("tr", { className: "text-left text-xs text-slate-500 uppercase tracking-wider border-b border-slate-800", children: [_jsx("th", { className: "pr-3 pb-2", children: "Age" }), _jsx("th", { className: "pr-3 pb-2", children: "Node" }), _jsx("th", { className: "pr-3 pb-2", children: "Type" }), _jsx("th", { className: "pr-3 pb-2 text-right", children: "Scanned" }), _jsx("th", { className: "pr-3 pb-2 text-right", children: "Removed" }), _jsx("th", { className: "pr-3 pb-2 text-right", children: "Reclaimed" }), _jsx("th", { className: "pb-2", children: "Status" })] }) }), _jsx("tbody", { children: rows.map((row, i) => (_jsxs("tr", { className: "border-b border-slate-800/50 hover:bg-slate-800/20", children: [_jsx("td", { className: "pr-3 py-1.5 text-slate-400 whitespace-nowrap", children: fmtAge(row.unix_ts) }), _jsx("td", { className: "pr-3 py-1.5 font-mono text-xs text-slate-300", children: row.node }), _jsx("td", { className: `pr-3 py-1.5 font-medium ${kindColor(row.kind)}`, children: kindLabel(row.kind) }), _jsx("td", { className: "pr-3 py-1.5 text-right text-slate-300", children: row.chunks_scanned.toLocaleString() }), _jsx("td", { className: "pr-3 py-1.5 text-right text-slate-300", children: row.chunks_removed > 0
? _jsx("span", { className: "text-amber-300", children: row.chunks_removed.toLocaleString() })
: _jsx("span", { className: "text-slate-600", children: "0" }) }), _jsx("td", { className: "pr-3 py-1.5 text-right text-slate-300", children: row.bytes_reclaimed > 0 ? fmtBytes(row.bytes_reclaimed) : '—' }), _jsx("td", { className: "py-1.5", children: row.error
? _jsx("span", { className: "text-red-400 text-xs", children: row.error })
: _jsx("span", { className: "text-emerald-500 text-xs", children: "ok" }) })] }, i))) })] }) }))] }));
}
@@ -0,0 +1,93 @@
import { useEffect, useState } from 'react';
import { api, MaintenanceRow, fmtBytes, fmtAge } from '../lib/api';
function kindLabel(kind: string): string {
if (kind === 'gc_orphan') return 'Orphan GC';
if (kind === 'gc_eviction_scored') return 'Scored Eviction';
if (kind === 'scrub') return 'Scrub';
return kind;
}
function kindColor(kind: string): string {
if (kind === 'gc_eviction_scored') return 'text-amber-300';
if (kind === 'scrub') return 'text-blue-400';
return 'text-slate-300';
}
export function MaintenancePanel() {
const [rows, setRows] = useState<MaintenanceRow[]>([]);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api
.maintenance()
.then((r) => { setRows(r); setErr(null); })
.catch((e) => setErr(String(e)))
.finally(() => setLoading(false));
}, []);
return (
<div className="rounded border border-slate-800 bg-slate-900/40 p-4">
<h2 className="text-sm font-semibold text-slate-300 uppercase tracking-wider mb-3">
GC / Maintenance Log
</h2>
{loading && <div className="text-slate-500 text-sm">Loading…</div>}
{err && <div className="text-amber-400 text-sm">{err}</div>}
{!loading && !err && rows.length === 0 && (
<div className="text-slate-500 text-sm italic">No maintenance events recorded yet.</div>
)}
{rows.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="text-left text-xs text-slate-500 uppercase tracking-wider border-b border-slate-800">
<th className="pr-3 pb-2">Age</th>
<th className="pr-3 pb-2">Node</th>
<th className="pr-3 pb-2">Type</th>
<th className="pr-3 pb-2 text-right">Scanned</th>
<th className="pr-3 pb-2 text-right">Removed</th>
<th className="pr-3 pb-2 text-right">Reclaimed</th>
<th className="pb-2">Status</th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i} className="border-b border-slate-800/50 hover:bg-slate-800/20">
<td className="pr-3 py-1.5 text-slate-400 whitespace-nowrap">
{fmtAge(row.unix_ts)}
</td>
<td className="pr-3 py-1.5 font-mono text-xs text-slate-300">
{row.node}
</td>
<td className={`pr-3 py-1.5 font-medium ${kindColor(row.kind)}`}>
{kindLabel(row.kind)}
</td>
<td className="pr-3 py-1.5 text-right text-slate-300">
{row.chunks_scanned.toLocaleString()}
</td>
<td className="pr-3 py-1.5 text-right text-slate-300">
{row.chunks_removed > 0
? <span className="text-amber-300">{row.chunks_removed.toLocaleString()}</span>
: <span className="text-slate-600">0</span>}
</td>
<td className="pr-3 py-1.5 text-right text-slate-300">
{row.bytes_reclaimed > 0 ? fmtBytes(row.bytes_reclaimed) : '—'}
</td>
<td className="py-1.5">
{row.error
? <span className="text-red-400 text-xs">{row.error}</span>
: <span className="text-emerald-500 text-xs">ok</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,40 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useState } from 'react';
import { api, fmtBytes } from '../lib/api';
function ScoreBar({ score, max }) {
const pct = max > 0 ? Math.min(100, (score / max) * 100) : 0;
const color = pct > 66
? 'bg-red-500'
: pct > 33
? 'bg-amber-500'
: 'bg-emerald-500';
return (_jsx("div", { className: "w-20 h-1.5 bg-zinc-700 rounded-full overflow-hidden", children: _jsx("div", { className: `h-full rounded-full ${color}`, style: { width: `${pct}%` } }) }));
}
export function PollutionPanel() {
const [candidates, setCandidates] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
api
.pollution()
.then((rows) => {
setCandidates(rows);
setLoading(false);
})
.catch((e) => {
setError(String(e));
setLoading(false);
});
}, []);
if (loading) {
return (_jsx("div", { className: "rounded-xl border border-zinc-700/50 bg-zinc-800/40 p-4 text-zinc-400 text-sm", children: "Loading pollution candidates\u2026" }));
}
if (error) {
return (_jsx("div", { className: "rounded-xl border border-red-700/40 bg-zinc-800/40 p-4 text-red-400 text-sm", children: error }));
}
if (candidates.length === 0) {
return (_jsxs("div", { className: "rounded-xl border border-zinc-700/50 bg-zinc-800/40 p-4", children: [_jsx("h2", { className: "text-sm font-semibold text-zinc-300 mb-1", children: "Cache Pollution" }), _jsx("p", { className: "text-zinc-500 text-sm", children: "No blobs tracked yet \u2014 pollution scores accumulate as builds run." })] }));
}
const maxScore = candidates[0]?.pollution_score ?? 1;
return (_jsxs("div", { className: "rounded-xl border border-zinc-700/50 bg-zinc-800/40 p-4", children: [_jsxs("div", { className: "flex items-baseline justify-between mb-3", children: [_jsxs("h2", { className: "text-sm font-semibold text-zinc-300", children: ["Cache Pollution", _jsx("span", { className: "ml-2 text-xs text-zinc-500 font-normal", children: "T2.8" })] }), _jsxs("span", { className: "text-xs text-zinc-500", children: [candidates.length, " entries"] })] }), _jsx("p", { className: "text-xs text-zinc-500 mb-3", children: "Large blobs with few recorded accesses since last restart. High-score entries consume hot-tier space disproportionate to their build value." }), _jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-xs text-left", children: [_jsx("thead", { children: _jsxs("tr", { className: "text-zinc-500 border-b border-zinc-700/50", children: [_jsx("th", { className: "pb-2 pr-4 font-medium", children: "Node" }), _jsx("th", { className: "pb-2 pr-4 font-medium", children: "Fingerprint" }), _jsx("th", { className: "pb-2 pr-4 font-medium text-right", children: "Size" }), _jsx("th", { className: "pb-2 pr-4 font-medium text-right", children: "Hits" }), _jsx("th", { className: "pb-2 font-medium", children: "Score" })] }) }), _jsx("tbody", { children: candidates.map((c) => (_jsxs("tr", { className: "border-b border-zinc-700/30 last:border-0 hover:bg-zinc-700/20", children: [_jsx("td", { className: "py-2 pr-4", children: _jsx("span", { className: "text-zinc-400 font-mono", children: c.node }) }), _jsx("td", { className: "py-2 pr-4", children: _jsxs("code", { className: "font-mono text-xs bg-zinc-700 px-1 py-0.5 rounded", children: [c.fingerprint_hex.slice(0, 12), "\u2026"] }) }), _jsx("td", { className: "py-2 pr-4 text-right text-zinc-300 font-mono", children: fmtBytes(c.size_bytes) }), _jsx("td", { className: "py-2 pr-4 text-right", children: _jsx("span", { className: c.hit_count === 0 ? 'text-red-400' : 'text-zinc-400', children: c.hit_count === 0 ? '0 ✗' : c.hit_count.toLocaleString() }) }), _jsx("td", { className: "py-2", children: _jsx(ScoreBar, { score: c.pollution_score, max: maxScore }) })] }, `${c.node}-${c.fingerprint_hex}`))) })] }) })] }));
}
@@ -0,0 +1,124 @@
import { useEffect, useState } from 'react';
import { api, PollutionCandidate, fmtBytes } from '../lib/api';
function ScoreBar({ score, max }: { score: number; max: number }) {
const pct = max > 0 ? Math.min(100, (score / max) * 100) : 0;
const color =
pct > 66
? 'bg-red-500'
: pct > 33
? 'bg-amber-500'
: 'bg-emerald-500';
return (
<div className="w-20 h-1.5 bg-zinc-700 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
</div>
);
}
export function PollutionPanel() {
const [candidates, setCandidates] = useState<PollutionCandidate[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api
.pollution()
.then((rows) => {
setCandidates(rows);
setLoading(false);
})
.catch((e) => {
setError(String(e));
setLoading(false);
});
}, []);
if (loading) {
return (
<div className="rounded-xl border border-zinc-700/50 bg-zinc-800/40 p-4 text-zinc-400 text-sm">
Loading pollution candidates…
</div>
);
}
if (error) {
return (
<div className="rounded-xl border border-red-700/40 bg-zinc-800/40 p-4 text-red-400 text-sm">
{error}
</div>
);
}
if (candidates.length === 0) {
return (
<div className="rounded-xl border border-zinc-700/50 bg-zinc-800/40 p-4">
<h2 className="text-sm font-semibold text-zinc-300 mb-1">Cache Pollution</h2>
<p className="text-zinc-500 text-sm">
No blobs tracked yet — pollution scores accumulate as builds run.
</p>
</div>
);
}
const maxScore = candidates[0]?.pollution_score ?? 1;
return (
<div className="rounded-xl border border-zinc-700/50 bg-zinc-800/40 p-4">
<div className="flex items-baseline justify-between mb-3">
<h2 className="text-sm font-semibold text-zinc-300">
Cache Pollution
<span className="ml-2 text-xs text-zinc-500 font-normal">T2.8</span>
</h2>
<span className="text-xs text-zinc-500">{candidates.length} entries</span>
</div>
<p className="text-xs text-zinc-500 mb-3">
Large blobs with few recorded accesses since last restart. High-score entries
consume hot-tier space disproportionate to their build value.
</p>
<div className="overflow-x-auto">
<table className="w-full text-xs text-left">
<thead>
<tr className="text-zinc-500 border-b border-zinc-700/50">
<th className="pb-2 pr-4 font-medium">Node</th>
<th className="pb-2 pr-4 font-medium">Fingerprint</th>
<th className="pb-2 pr-4 font-medium text-right">Size</th>
<th className="pb-2 pr-4 font-medium text-right">Hits</th>
<th className="pb-2 font-medium">Score</th>
</tr>
</thead>
<tbody>
{candidates.map((c) => (
<tr
key={`${c.node}-${c.fingerprint_hex}`}
className="border-b border-zinc-700/30 last:border-0 hover:bg-zinc-700/20"
>
<td className="py-2 pr-4">
<span className="text-zinc-400 font-mono">{c.node}</span>
</td>
<td className="py-2 pr-4">
<code className="font-mono text-xs bg-zinc-700 px-1 py-0.5 rounded">
{c.fingerprint_hex.slice(0, 12)}…
</code>
</td>
<td className="py-2 pr-4 text-right text-zinc-300 font-mono">
{fmtBytes(c.size_bytes)}
</td>
<td className="py-2 pr-4 text-right">
<span className={c.hit_count === 0 ? 'text-red-400' : 'text-zinc-400'}>
{c.hit_count === 0 ? '0 ✗' : c.hit_count.toLocaleString()}
</span>
</td>
<td className="py-2">
<ScoreBar score={c.pollution_score} max={maxScore} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,39 @@
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
import { useEffect, useState } from 'react';
import { api, fmtAge } from '../lib/api';
function FpBadge({ hex }) {
return (_jsxs("code", { className: "font-mono text-xs bg-zinc-700 px-1 py-0.5 rounded", children: [hex.slice(0, 12), "\u2026"] }));
}
function NodePill({ name, variant }) {
const colors = variant === 'hot'
? 'bg-orange-900/50 text-orange-300 border border-orange-700/40'
: 'bg-zinc-700/60 text-zinc-400 border border-zinc-600/40';
return (_jsx("span", { className: `inline-block px-1.5 py-0.5 rounded text-xs mr-1 ${colors}`, children: name }));
}
export function WarmingCandidatesPanel() {
const [candidates, setCandidates] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
api
.hotRefs()
.then((rows) => {
setCandidates(rows);
setLoading(false);
})
.catch((e) => {
setError(String(e));
setLoading(false);
});
}, []);
if (loading) {
return (_jsx("div", { className: "rounded-xl border border-zinc-700/50 bg-zinc-800/40 p-4 text-zinc-400 text-sm", children: "Loading warming candidates\u2026" }));
}
if (error) {
return (_jsx("div", { className: "rounded-xl border border-red-700/40 bg-zinc-800/40 p-4 text-red-400 text-sm", children: error }));
}
if (candidates.length === 0) {
return (_jsxs("div", { className: "rounded-xl border border-zinc-700/50 bg-zinc-800/40 p-4", children: [_jsx("h2", { className: "text-sm font-semibold text-zinc-300 mb-1", children: "Warming Candidates" }), _jsx("p", { className: "text-zinc-500 text-sm", children: "No asymmetric cache entries yet \u2014 all hot fingerprints are present on every node, or no access data has been collected." })] }));
}
return (_jsxs("div", { className: "rounded-xl border border-zinc-700/50 bg-zinc-800/40 p-4", children: [_jsxs("div", { className: "flex items-baseline justify-between mb-3", children: [_jsxs("h2", { className: "text-sm font-semibold text-zinc-300", children: ["Warming Candidates", _jsx("span", { className: "ml-2 text-xs text-zinc-500 font-normal", children: "T2.7" })] }), _jsxs("span", { className: "text-xs text-zinc-500", children: [candidates.length, " fingerprints"] })] }), _jsx("p", { className: "text-xs text-zinc-500 mb-3", children: "Fingerprints frequently accessed on some nodes but absent on others. Pre-positioning these blobs eliminates cross-node forwarding on the next build." }), _jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-xs text-left", children: [_jsx("thead", { children: _jsxs("tr", { className: "text-zinc-500 border-b border-zinc-700/50", children: [_jsx("th", { className: "pb-2 pr-4 font-medium", children: "Fingerprint" }), _jsx("th", { className: "pb-2 pr-4 font-medium", children: "Hot on" }), _jsx("th", { className: "pb-2 pr-4 font-medium", children: "Missing on" }), _jsx("th", { className: "pb-2 pr-4 font-medium text-right", children: "Hits" }), _jsx("th", { className: "pb-2 font-medium text-right", children: "Last seen" })] }) }), _jsx("tbody", { children: candidates.map((c) => (_jsxs("tr", { className: "border-b border-zinc-700/30 last:border-0 hover:bg-zinc-700/20", children: [_jsx("td", { className: "py-2 pr-4", children: _jsx(FpBadge, { hex: c.fingerprint_hex }) }), _jsx("td", { className: "py-2 pr-4", children: c.hot_on.map((n) => (_jsx(NodePill, { name: n, variant: "hot" }, n))) }), _jsx("td", { className: "py-2 pr-4", children: c.missing_on.map((n) => (_jsx(NodePill, { name: n, variant: "missing" }, n))) }), _jsx("td", { className: "py-2 pr-4 text-right text-zinc-300 font-mono", children: c.max_hit_count.toLocaleString() }), _jsx("td", { className: "py-2 text-right text-zinc-400", children: c.last_hit_unix > 0 ? fmtAge(c.last_hit_unix) : '—' })] }, c.fingerprint_hex))) })] }) })] }));
}
@@ -0,0 +1,128 @@
import { useEffect, useState } from 'react';
import { api, WarmingCandidate, fmtAge } from '../lib/api';
function FpBadge({ hex }: { hex: string }) {
return (
<code className="font-mono text-xs bg-zinc-700 px-1 py-0.5 rounded">
{hex.slice(0, 12)}…
</code>
);
}
function NodePill({ name, variant }: { name: string; variant: 'hot' | 'missing' }) {
const colors =
variant === 'hot'
? 'bg-orange-900/50 text-orange-300 border border-orange-700/40'
: 'bg-zinc-700/60 text-zinc-400 border border-zinc-600/40';
return (
<span className={`inline-block px-1.5 py-0.5 rounded text-xs mr-1 ${colors}`}>
{name}
</span>
);
}
export function WarmingCandidatesPanel() {
const [candidates, setCandidates] = useState<WarmingCandidate[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api
.hotRefs()
.then((rows) => {
setCandidates(rows);
setLoading(false);
})
.catch((e) => {
setError(String(e));
setLoading(false);
});
}, []);
if (loading) {
return (
<div className="rounded-xl border border-zinc-700/50 bg-zinc-800/40 p-4 text-zinc-400 text-sm">
Loading warming candidates…
</div>
);
}
if (error) {
return (
<div className="rounded-xl border border-red-700/40 bg-zinc-800/40 p-4 text-red-400 text-sm">
{error}
</div>
);
}
if (candidates.length === 0) {
return (
<div className="rounded-xl border border-zinc-700/50 bg-zinc-800/40 p-4">
<h2 className="text-sm font-semibold text-zinc-300 mb-1">Warming Candidates</h2>
<p className="text-zinc-500 text-sm">
No asymmetric cache entries yet — all hot fingerprints are present on every node, or
no access data has been collected.
</p>
</div>
);
}
return (
<div className="rounded-xl border border-zinc-700/50 bg-zinc-800/40 p-4">
<div className="flex items-baseline justify-between mb-3">
<h2 className="text-sm font-semibold text-zinc-300">
Warming Candidates
<span className="ml-2 text-xs text-zinc-500 font-normal">T2.7</span>
</h2>
<span className="text-xs text-zinc-500">{candidates.length} fingerprints</span>
</div>
<p className="text-xs text-zinc-500 mb-3">
Fingerprints frequently accessed on some nodes but absent on others. Pre-positioning these
blobs eliminates cross-node forwarding on the next build.
</p>
<div className="overflow-x-auto">
<table className="w-full text-xs text-left">
<thead>
<tr className="text-zinc-500 border-b border-zinc-700/50">
<th className="pb-2 pr-4 font-medium">Fingerprint</th>
<th className="pb-2 pr-4 font-medium">Hot on</th>
<th className="pb-2 pr-4 font-medium">Missing on</th>
<th className="pb-2 pr-4 font-medium text-right">Hits</th>
<th className="pb-2 font-medium text-right">Last seen</th>
</tr>
</thead>
<tbody>
{candidates.map((c) => (
<tr
key={c.fingerprint_hex}
className="border-b border-zinc-700/30 last:border-0 hover:bg-zinc-700/20"
>
<td className="py-2 pr-4">
<FpBadge hex={c.fingerprint_hex} />
</td>
<td className="py-2 pr-4">
{c.hot_on.map((n) => (
<NodePill key={n} name={n} variant="hot" />
))}
</td>
<td className="py-2 pr-4">
{c.missing_on.map((n) => (
<NodePill key={n} name={n} variant="missing" />
))}
</td>
<td className="py-2 pr-4 text-right text-zinc-300 font-mono">
{c.max_hit_count.toLocaleString()}
</td>
<td className="py-2 text-right text-zinc-400">
{c.last_hit_unix > 0 ? fmtAge(c.last_hit_unix) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+3
View File
@@ -29,6 +29,9 @@ export const api = {
nodeStatus: (name) => get(`/v2/node/${name}/status`), nodeStatus: (name) => get(`/v2/node/${name}/status`),
metricsHistory: (name, limit = 60) => get(`/v2/node/${name}/metrics-history?limit=${limit}`), metricsHistory: (name, limit = 60) => get(`/v2/node/${name}/metrics-history?limit=${limit}`),
anomalies: () => get('/v2/anomalies'), anomalies: () => get('/v2/anomalies'),
hotRefs: () => get('/v2/hot-refs'),
pollution: () => get('/v2/pollution'),
maintenance: () => get('/v2/maintenance'),
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)}`),
refs: (limit = 200, offset = 0) => get(`/v2/storage/refs?limit=${limit}&offset=${offset}`), refs: (limit = 200, offset = 0) => get(`/v2/storage/refs?limit=${limit}&offset=${offset}`),
+30
View File
@@ -170,6 +170,33 @@ export interface AnomalyStatus {
samples_used: number; samples_used: number;
} }
export interface WarmingCandidate {
fingerprint_hex: string;
hot_on: string[];
missing_on: string[];
max_hit_count: number;
last_hit_unix: number;
}
export interface PollutionCandidate {
node: string;
fingerprint_hex: string;
blob_id_hex: string;
size_bytes: number;
hit_count: number;
pollution_score: number;
}
export interface MaintenanceRow {
node: string;
kind: string;
unix_ts: number;
chunks_scanned: number;
chunks_removed: number;
bytes_reclaimed: number;
error: string | null;
}
export const api = { export const api = {
fleet: () => get<FleetSnapshot>('/v2/fleet'), fleet: () => get<FleetSnapshot>('/v2/fleet'),
projects: () => get<ProjectRow[]>('/v2/projects'), projects: () => get<ProjectRow[]>('/v2/projects'),
@@ -177,6 +204,9 @@ export const api = {
metricsHistory: (name: string, limit = 60) => metricsHistory: (name: string, limit = 60) =>
get<MetricSample[]>(`/v2/node/${name}/metrics-history?limit=${limit}`), get<MetricSample[]>(`/v2/node/${name}/metrics-history?limit=${limit}`),
anomalies: () => get<AnomalyStatus[]>('/v2/anomalies'), anomalies: () => get<AnomalyStatus[]>('/v2/anomalies'),
hotRefs: () => get<WarmingCandidate[]>('/v2/hot-refs'),
pollution: () => get<PollutionCandidate[]>('/v2/pollution'),
maintenance: () => get<MaintenanceRow[]>('/v2/maintenance'),
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}`),
tags: (prefix = '') => tags: (prefix = '') =>
+3 -1
View File
@@ -3,6 +3,8 @@ 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'; import { ProjectsPanel } from '../components/ProjectsPanel';
import { WarmingCandidatesPanel } from '../components/WarmingCandidatesPanel';
import { PollutionPanel } from '../components/PollutionPanel';
// 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() {
@@ -39,5 +41,5 @@ export function CommandCenter() {
const alertNodes = anomalies.filter((a) => a.level === 'alert'); const alertNodes = anomalies.filter((a) => a.level === 'alert');
const warnNodes = anomalies.filter((a) => a.level === 'warn'); const warnNodes = anomalies.filter((a) => a.level === 'warn');
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 })), alertNodes.length > 0 && (_jsxs("div", { className: "rounded border border-red-800 bg-red-950/40 px-4 py-3 text-sm flex items-start gap-3", children: [_jsx("span", { className: "text-red-400 font-bold mt-0.5", children: "\u25CF" }), _jsxs("div", { children: [_jsx("span", { className: "text-red-300 font-semibold", children: "Metric anomaly detected \u2014 " }), _jsxs("span", { className: "text-red-200", children: [alertNodes.map((a) => a.node).join(', '), " deviating >2\u03C3 from baseline", alertNodes.length === 1 && ` (score ${alertNodes[0].score.toFixed(1)})`] })] })] })), alertNodes.length === 0 && warnNodes.length > 0 && (_jsxs("div", { className: "rounded border border-amber-800 bg-amber-950/40 px-4 py-3 text-sm flex items-start gap-3", children: [_jsx("span", { className: "text-amber-400 font-bold mt-0.5", children: "\u25CF" }), _jsxs("div", { children: [_jsx("span", { className: "text-amber-300 font-semibold", children: "Metric drift \u2014 " }), _jsxs("span", { className: "text-amber-200", children: [warnNodes.map((a) => a.node).join(', '), " showing unusual patterns"] })] })] })), 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, anomalyLevel: anomalyMap[n.node_name] }, 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 })), alertNodes.length > 0 && (_jsxs("div", { className: "rounded border border-red-800 bg-red-950/40 px-4 py-3 text-sm flex items-start gap-3", children: [_jsx("span", { className: "text-red-400 font-bold mt-0.5", children: "\u25CF" }), _jsxs("div", { children: [_jsx("span", { className: "text-red-300 font-semibold", children: "Metric anomaly detected \u2014 " }), _jsxs("span", { className: "text-red-200", children: [alertNodes.map((a) => a.node).join(', '), " deviating >2\u03C3 from baseline", alertNodes.length === 1 && ` (score ${alertNodes[0].score.toFixed(1)})`] })] })] })), alertNodes.length === 0 && warnNodes.length > 0 && (_jsxs("div", { className: "rounded border border-amber-800 bg-amber-950/40 px-4 py-3 text-sm flex items-start gap-3", children: [_jsx("span", { className: "text-amber-400 font-bold mt-0.5", children: "\u25CF" }), _jsxs("div", { children: [_jsx("span", { className: "text-amber-300 font-semibold", children: "Metric drift \u2014 " }), _jsxs("span", { className: "text-amber-200", children: [warnNodes.map((a) => a.node).join(', '), " showing unusual patterns"] })] })] })), 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, anomalyLevel: anomalyMap[n.node_name] }, 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)))] })] }), _jsx(ProjectsPanel, {})] })); [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, {}), _jsx(WarmingCandidatesPanel, {}), _jsx(PollutionPanel, {})] }));
} }
+6
View File
@@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
import { api, FleetSnapshot, AnomalyStatus, fmtBytes, fmtAge } from '../lib/api'; import { api, FleetSnapshot, AnomalyStatus, fmtBytes, fmtAge } from '../lib/api';
import { NodeCard } from '../components/NodeCard'; import { NodeCard } from '../components/NodeCard';
import { ProjectsPanel } from '../components/ProjectsPanel'; import { ProjectsPanel } from '../components/ProjectsPanel';
import { WarmingCandidatesPanel } from '../components/WarmingCandidatesPanel';
import { PollutionPanel } from '../components/PollutionPanel';
// 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.
@@ -140,6 +142,10 @@ export function CommandCenter() {
</section> </section>
<ProjectsPanel /> <ProjectsPanel />
<WarmingCandidatesPanel />
<PollutionPanel />
</div> </div>
); );
} }
+2 -1
View File
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
import { Link } from 'wouter'; import { Link } from 'wouter';
import { api, fmtBytes } from '../lib/api'; import { api, fmtBytes } from '../lib/api';
import { StatTile } from '../components/StatTile'; import { StatTile } from '../components/StatTile';
import { MaintenancePanel } from '../components/MaintenancePanel';
export function NodeDetail({ name }) { export function NodeDetail({ name }) {
const [status, setStatus] = useState(null); const [status, setStatus] = useState(null);
const [err, setErr] = useState(null); const [err, setErr] = useState(null);
@@ -15,5 +16,5 @@ export function NodeDetail({ name }) {
}) })
.catch((e) => setErr(String(e))); .catch((e) => setErr(String(e)));
}, [name]); }, [name]);
return (_jsxs("div", { className: "space-y-6", children: [_jsxs("div", { children: [_jsx(Link, { href: "/", children: _jsx("a", { className: "text-sm text-slate-500 hover:text-slate-300", children: "\u2190 fleet" }) }), _jsx("h1", { className: "text-2xl font-semibold text-slate-100 mt-2", children: name })] }), err && (_jsxs("div", { className: "rounded border border-amber-800 bg-amber-950/40 p-3 text-amber-300 text-sm", children: [err, _jsxs("div", { className: "mt-2 text-xs text-slate-400", children: ["Cross-node lookup lands in a follow-on PR. Until then this page shows detail only when you're already viewing the dashboard hosted by ", name, ". Try opening", ' ', _jsxs("span", { className: "font-mono", children: ["http://", name, ":7700/v2/#/nodes/", name] }), ' ', "directly."] })] })), status && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3", children: [_jsx(StatTile, { label: "blobs", value: status.blob_count.toLocaleString(), color: "ok" }), _jsx(StatTile, { label: "tags", value: status.tag_count, color: "ok" }), _jsx(StatTile, { label: "refs", value: status.ref_count, color: "ok" }), _jsx(StatTile, { label: "snapshots", value: status.snapshot_count, color: "ok" }), _jsx(StatTile, { label: "ref-tracking", value: status.ref_tracking_count, color: "ok" }), _jsx(StatTile, { label: "store size", value: fmtBytes(status.blob_store_bytes), color: "ok" })] }), _jsxs("div", { className: "rounded border border-slate-800 bg-slate-900/40 p-4 text-sm", children: [_jsx("div", { className: "text-slate-500 uppercase text-xs tracking-wider", children: "blob store root" }), _jsx("div", { className: "font-mono mt-1", children: status.blob_store_root ?? '—' })] })] }))] })); return (_jsxs("div", { className: "space-y-6", children: [_jsxs("div", { children: [_jsx(Link, { href: "/", children: _jsx("a", { className: "text-sm text-slate-500 hover:text-slate-300", children: "\u2190 fleet" }) }), _jsx("h1", { className: "text-2xl font-semibold text-slate-100 mt-2", children: name })] }), err && (_jsxs("div", { className: "rounded border border-amber-800 bg-amber-950/40 p-3 text-amber-300 text-sm", children: [err, _jsxs("div", { className: "mt-2 text-xs text-slate-400", children: ["Cross-node lookup lands in a follow-on PR. Until then this page shows detail only when you're already viewing the dashboard hosted by ", name, ". Try opening", ' ', _jsxs("span", { className: "font-mono", children: ["http://", name, ":7700/v2/#/nodes/", name] }), ' ', "directly."] })] })), status && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3", children: [_jsx(StatTile, { label: "blobs", value: status.blob_count.toLocaleString(), color: "ok" }), _jsx(StatTile, { label: "tags", value: status.tag_count, color: "ok" }), _jsx(StatTile, { label: "refs", value: status.ref_count, color: "ok" }), _jsx(StatTile, { label: "snapshots", value: status.snapshot_count, color: "ok" }), _jsx(StatTile, { label: "ref-tracking", value: status.ref_tracking_count, color: "ok" }), _jsx(StatTile, { label: "store size", value: fmtBytes(status.blob_store_bytes), color: "ok" })] }), _jsxs("div", { className: "rounded border border-slate-800 bg-slate-900/40 p-4 text-sm", children: [_jsx("div", { className: "text-slate-500 uppercase text-xs tracking-wider", children: "blob store root" }), _jsx("div", { className: "font-mono mt-1", children: status.blob_store_root ?? '—' })] })] })), _jsx(MaintenancePanel, {})] }));
} }
+3
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { Link } from 'wouter'; import { Link } from 'wouter';
import { api, NodeStatusV2, fmtBytes } from '../lib/api'; import { api, NodeStatusV2, fmtBytes } from '../lib/api';
import { StatTile } from '../components/StatTile'; import { StatTile } from '../components/StatTile';
import { MaintenancePanel } from '../components/MaintenancePanel';
interface Props { interface Props {
name: string; name: string;
@@ -69,6 +70,8 @@ export function NodeDetail({ name }: Props) {
</div> </div>
</> </>
)} )}
<MaintenancePanel />
</div> </div>
); );
} }
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/NodeCard.tsx","./src/components/NodeHistorySparklines.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"} {"root":["./src/App.tsx","./src/main.tsx","./src/components/MaintenancePanel.tsx","./src/components/NodeCard.tsx","./src/components/NodeHistorySparklines.tsx","./src/components/PollutionPanel.tsx","./src/components/ProjectsPanel.tsx","./src/components/StatTile.tsx","./src/components/StorageBar.tsx","./src/components/WarmingCandidatesPanel.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"}