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:
co-authored by
Claude Fable 5.1
parent
5e7f7dfc83
commit
425ce58127
@@ -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).
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
tracing::info!(
|
||||||
chunks_scanned = r.chunks_scanned,
|
chunks_scanned = r.chunks_scanned,
|
||||||
chunks_removed = r.chunks_removed,
|
chunks_removed = r.chunks_removed,
|
||||||
bytes_reclaimed = r.bytes_reclaimed,
|
bytes_reclaimed = r.bytes_reclaimed,
|
||||||
"auto-GC swept orphan chunks"
|
"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 => {
|
||||||
|
tracing::info!(
|
||||||
chunks_removed = r.chunks_removed,
|
chunks_removed = r.chunks_removed,
|
||||||
bytes_reclaimed = r.bytes_reclaimed,
|
bytes_reclaimed = r.bytes_reclaimed,
|
||||||
max_gb = gb,
|
max_gb = gb,
|
||||||
pinned_blobs = pinned.len(),
|
pinned_blobs = pinned.len(),
|
||||||
snapshot_pins = snapshot_pin_count,
|
snapshot_pins = snapshot_pin_count,
|
||||||
"auto-GC evicted LRU blobs to hit size cap"
|
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).
|
||||||
|
|||||||
@@ -188,10 +188,14 @@ pub async fn run(cfg: Config, mut manifest: Manifest) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = snap_tick.tick() => {
|
_ = snap_tick.tick() => {
|
||||||
|
let dataset = &cfg.warm.zfs_dataset;
|
||||||
|
if dataset.is_empty() || dataset == "none" {
|
||||||
|
tracing::debug!("zfs_dataset=none — skipping snapshot");
|
||||||
|
} else {
|
||||||
let ts = chrono::Utc::now().format("%Y-%m-%d-%H%M").to_string();
|
let ts = chrono::Utc::now().format("%Y-%m-%d-%H%M").to_string();
|
||||||
tracing::info!("taking snapshot {}", ts);
|
tracing::info!("taking snapshot {}", ts);
|
||||||
if let Err(e) = snapshot::run_snapshot_cycle(
|
if let Err(e) = snapshot::run_snapshot_cycle(
|
||||||
&zfs, &cfg.warm.zfs_dataset, &ts,
|
&zfs, dataset, &ts,
|
||||||
cfg.warm.snapshot_retain_hours as usize,
|
cfg.warm.snapshot_retain_hours as usize,
|
||||||
cfg.warm.snapshot_retain_days as usize,
|
cfg.warm.snapshot_retain_days as usize,
|
||||||
cfg.warm.snapshot_retain_weeks as usize,
|
cfg.warm.snapshot_retain_weeks as usize,
|
||||||
@@ -199,14 +203,17 @@ pub async fn run(cfg: Config, mut manifest: Manifest) -> Result<()> {
|
|||||||
tracing::error!("snapshot failed: {:#}", e);
|
tracing::error!("snapshot failed: {:#}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
_ = repl_tick.tick() => {
|
_ = repl_tick.tick() => {
|
||||||
|
let dataset = &cfg.warm.zfs_dataset;
|
||||||
|
if !dataset.is_empty() && dataset != "none" {
|
||||||
if let Some(rep) = &cfg.replication {
|
if let Some(rep) = &cfg.replication {
|
||||||
if let (Some(host), Some(user), Some(dest)) = (
|
if let (Some(host), Some(user), Some(dest)) = (
|
||||||
&rep.send_to_host, &rep.send_to_user, &rep.cold_dataset_on_peer
|
&rep.send_to_host, &rep.send_to_user, &rep.cold_dataset_on_peer
|
||||||
) {
|
) {
|
||||||
tracing::info!("replicating warm → cold on {}", host);
|
tracing::info!("replicating warm → cold on {}", host);
|
||||||
if let Err(e) = snapshot::replicate_to_cold(
|
if let Err(e) = snapshot::replicate_to_cold(
|
||||||
&zfs, &cfg.warm.zfs_dataset, user, host, dest
|
&zfs, dataset, user, host, dest
|
||||||
) {
|
) {
|
||||||
tracing::error!("replication failed: {:#}", e);
|
tracing::error!("replication failed: {:#}", e);
|
||||||
}
|
}
|
||||||
@@ -215,6 +222,7 @@ pub async fn run(cfg: Config, mut manifest: Manifest) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+193
-2
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}`),
|
||||||
|
|||||||
@@ -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,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, {})] }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, {})] }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
{"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"}
|
||||||
Reference in New Issue
Block a user