1use std::sync::Arc;
11
12use color_eyre::Result;
13use num_bigint::BigInt;
14use ratatui::{
15 Frame,
16 layout::{Constraint, Layout, Rect},
17 style::{Color, Modifier, Style},
18 text::{Line, Span},
19 widgets::{Block, Borders, Paragraph},
20};
21use tokio::sync::watch;
22
23use super::Component;
24use crate::action::Action;
25use crate::api::ApiClient;
26use crate::theme;
27use crate::watch::{HealthSnapshot, TopologySnapshot};
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum GateStatus {
32 Pass,
33 Warn,
34 Fail,
35 Unknown,
36}
37
38impl GateStatus {
39 fn glyph(self) -> &'static str {
40 let g = theme::active().glyphs;
41 match self {
42 Self::Pass => g.pass,
43 Self::Warn => g.warn,
44 Self::Fail => g.fail,
45 Self::Unknown => g.bullet,
46 }
47 }
48 fn color(self) -> Color {
49 let t = theme::active();
50 match self {
51 Self::Pass => t.pass,
52 Self::Warn => t.warn,
53 Self::Fail => t.fail,
54 Self::Unknown => t.dim,
55 }
56 }
57}
58
59#[derive(Debug, Clone)]
61pub struct Gate {
62 pub label: &'static str,
63 pub status: GateStatus,
64 pub value: String,
65 pub why: Option<String>,
69}
70
71pub struct Health {
75 api: Arc<ApiClient>,
76 rx: watch::Receiver<HealthSnapshot>,
77 topology_rx: watch::Receiver<TopologySnapshot>,
78 snapshot: HealthSnapshot,
79 topology: TopologySnapshot,
80}
81
82impl Health {
83 pub fn new(
84 api: Arc<ApiClient>,
85 rx: watch::Receiver<HealthSnapshot>,
86 topology_rx: watch::Receiver<TopologySnapshot>,
87 ) -> Self {
88 let snapshot = rx.borrow().clone();
89 let topology = topology_rx.borrow().clone();
90 Self {
91 api,
92 rx,
93 topology_rx,
94 snapshot,
95 topology,
96 }
97 }
98
99 fn pull_latest(&mut self) {
100 self.snapshot = self.rx.borrow().clone();
101 self.topology = self.topology_rx.borrow().clone();
102 }
103
104 pub fn gates_for(snap: &HealthSnapshot, topology: Option<&TopologySnapshot>) -> Vec<Gate> {
108 Self::gates_for_with_stamps(snap, topology, None)
109 }
110
111 pub fn gates_for_with_stamps(
119 snap: &HealthSnapshot,
120 topology: Option<&TopologySnapshot>,
121 stamps: Option<&crate::watch::StampsSnapshot>,
122 ) -> Vec<Gate> {
123 let mut gates = Self::gates_for_inner(snap, topology);
124 if let Some(s) = stamps {
125 gates.push(stamp_ttl_gate(s));
126 }
127 gates
128 }
129
130 fn gates_for_inner(snap: &HealthSnapshot, topology: Option<&TopologySnapshot>) -> Vec<Gate> {
131 let mut gates = Vec::with_capacity(10);
132
133 gates.push(match snap.last_ping {
135 Some(d) => Gate {
136 label: "API reachable",
137 status: GateStatus::Pass,
138 value: format!("({}ms)", d.as_millis()),
139 why: None,
140 },
141 None if snap.last_update.is_none() => Gate {
142 label: "API reachable",
143 status: GateStatus::Unknown,
144 value: "loading…".into(),
145 why: None,
146 },
147 None => Gate {
148 label: "API reachable",
149 status: GateStatus::Fail,
150 value: "no /health response".into(),
151 why: snap.last_error.clone(),
152 },
153 });
154
155 if let Some(cs) = &snap.chain_state {
157 let delta = cs.chain_tip.saturating_sub(cs.block);
158 let (status, why) = if delta == 0 {
159 (GateStatus::Pass, None)
160 } else if delta < 50 {
161 (
162 GateStatus::Warn,
163 Some(format!("chain head {delta} blocks ahead")),
164 )
165 } else {
166 (
167 GateStatus::Fail,
168 Some(format!("RPC out of sync: {delta} blocks behind tip")),
169 )
170 };
171 gates.push(Gate {
172 label: "Chain RPC",
173 status,
174 value: format!("block {} · Δ +{delta}", cs.block),
175 why,
176 });
177 } else {
178 gates.push(unknown("Chain RPC"));
179 }
180
181 if let Some(w) = &snap.wallet {
183 let zero = BigInt::from(0);
184 let bzz = w.bzz_balance.as_ref().unwrap_or(&zero);
185 let native = w.native_token_balance.as_ref().unwrap_or(&zero);
186 let value = format!("BZZ {bzz} · native {native}");
187 if bzz == &zero && native == &zero {
188 gates.push(Gate {
189 label: "Wallet funded",
190 status: GateStatus::Fail,
191 value: "0 BZZ · 0 native".into(),
192 why: Some("fund the operator wallet to participate".into()),
193 });
194 } else if bzz == &zero || native == &zero {
195 gates.push(Gate {
196 label: "Wallet funded",
197 status: GateStatus::Warn,
198 value,
199 why: Some("partial funding — need both BZZ (storage) and native (gas)".into()),
200 });
201 } else {
202 gates.push(Gate {
203 label: "Wallet funded",
204 status: GateStatus::Pass,
205 value,
206 why: None,
207 });
208 }
209 } else {
210 gates.push(unknown("Wallet funded"));
211 }
212
213 if let Some(s) = &snap.status {
215 if s.is_warming_up {
217 gates.push(Gate {
218 label: "Warmup complete",
219 status: GateStatus::Warn,
220 value: "warming up".into(),
221 why: Some("first-launch warmup can take 5–60 minutes".into()),
222 });
223 } else {
224 gates.push(Gate {
225 label: "Warmup complete",
226 status: GateStatus::Pass,
227 value: "ready".into(),
228 why: None,
229 });
230 }
231 let n = s.connected_peers;
233 let (pstatus, pwhy) = if n == 0 {
234 (GateStatus::Fail, Some("no peers — node is isolated".into()))
235 } else if n < 8 {
236 (
237 GateStatus::Warn,
238 Some(format!("only {n} connected — bins likely starving")),
239 )
240 } else {
241 (GateStatus::Pass, None)
242 };
243 gates.push(Gate {
244 label: "Peers",
245 status: pstatus,
246 value: format!("{n} connected"),
247 why: pwhy,
248 });
249 let total = s.reserve_size;
251 let in_radius = s.reserve_size_within_radius;
252 let (rstatus, rwhy) = if total == 0 && !s.is_warming_up {
253 (
254 GateStatus::Warn,
255 Some("reserve empty after warmup — check sync rate".into()),
256 )
257 } else {
258 (GateStatus::Pass, None)
259 };
260 gates.push(Gate {
261 label: "Reserve",
262 status: rstatus,
263 value: format!(
264 "{total} chunks (in-radius: {in_radius}) · radius {}",
265 s.storage_radius
266 ),
267 why: rwhy,
268 });
269 } else {
270 gates.push(unknown("Warmup complete"));
271 gates.push(unknown("Peers"));
272 gates.push(unknown("Reserve"));
273 }
274
275 gates.push(bin_saturation_gate(topology));
281
282 if let Some(r) = &snap.redistribution {
284 if r.is_healthy {
286 gates.push(Gate {
287 label: "Healthy for redistribution",
288 status: GateStatus::Pass,
289 value: "yes".into(),
290 why: None,
291 });
292 } else if let Some(s) = &snap.status {
293 let radius = s.storage_radius;
294 let committed = s.committed_depth;
295 if radius < committed {
296 gates.push(Gate {
297 label: "Healthy for redistribution",
298 status: GateStatus::Fail,
299 value: format!("storageRadius ({radius}) < committed ({committed})"),
300 why: Some(
301 "storageRadius decreases ONLY on the 30-min reserve worker tick — wait it out or check reserve fill"
302 .into(),
303 ),
304 });
305 } else {
306 gates.push(Gate {
307 label: "Healthy for redistribution",
308 status: GateStatus::Fail,
309 value: "isHealthy=false".into(),
310 why: Some("check reserve fill, fully-synced status, freeze status".into()),
311 });
312 }
313 } else {
314 gates.push(Gate {
315 label: "Healthy for redistribution",
316 status: GateStatus::Fail,
317 value: "isHealthy=false".into(),
318 why: None,
319 });
320 }
321 if r.is_frozen {
323 gates.push(Gate {
324 label: "Not frozen",
325 status: GateStatus::Fail,
326 value: format!("frozen since round {}", r.last_frozen_round),
327 why: Some("invalid commit/reveal or desynced reserve in a recent round".into()),
328 });
329 } else {
330 gates.push(Gate {
331 label: "Not frozen",
332 status: GateStatus::Pass,
333 value: "active".into(),
334 why: None,
335 });
336 }
337 if r.has_sufficient_funds {
339 gates.push(Gate {
340 label: "Sufficient funds to play",
341 status: GateStatus::Pass,
342 value: "yes".into(),
343 why: None,
344 });
345 } else {
346 gates.push(Gate {
347 label: "Sufficient funds to play",
348 status: GateStatus::Fail,
349 value: "insufficient gas runway".into(),
350 why: Some("top up the operator wallet's native-token balance".into()),
351 });
352 }
353 } else {
354 for label in [
355 "Healthy for redistribution",
356 "Not frozen",
357 "Sufficient funds to play",
358 ] {
359 gates.push(unknown(label));
360 }
361 }
362
363 gates
364 }
365}
366
367fn stamp_ttl_gate(s: &crate::watch::StampsSnapshot) -> Gate {
373 if s.last_update.is_none() {
374 return unknown("Stamp TTL");
375 }
376 let usable: Vec<&bee::postage::PostageBatch> = s.batches.iter().filter(|b| b.usable).collect();
377 if usable.is_empty() {
378 return Gate {
379 label: "Stamp TTL",
380 status: GateStatus::Unknown,
381 value: "no usable batches".into(),
382 why: None,
383 };
384 }
385 let worst = usable.iter().min_by_key(|b| b.batch_ttl).copied().unwrap();
386 let ttl = worst.batch_ttl;
387 let hex = worst.batch_id.to_hex();
388 let id_short: &str = if hex.len() > 8 { &hex[..8] } else { &hex };
389 let value = format!(
390 "worst-batch {id_short} · TTL {}",
391 crate::components::stamps::format_ttl_seconds(ttl),
392 );
393 if ttl <= crate::components::stamps::TOPUP_URGENT_SECS {
394 Gate {
395 label: "Stamp TTL",
396 status: GateStatus::Fail,
397 value,
398 why: Some(format!(
399 "topup URGENT — under {}h threshold",
400 crate::components::stamps::TOPUP_URGENT_SECS / 3600
401 )),
402 }
403 } else if ttl <= crate::components::stamps::TOPUP_SOON_SECS {
404 Gate {
405 label: "Stamp TTL",
406 status: GateStatus::Warn,
407 value,
408 why: Some(format!(
409 "topup soon — under {}d planning threshold",
410 crate::components::stamps::TOPUP_SOON_SECS / 86_400
411 )),
412 }
413 } else {
414 Gate {
415 label: "Stamp TTL",
416 status: GateStatus::Pass,
417 value,
418 why: None,
419 }
420 }
421}
422
423fn unknown(label: &'static str) -> Gate {
424 Gate {
425 label,
426 status: GateStatus::Unknown,
427 value: "—".into(),
428 why: None,
429 }
430}
431
432const SATURATION_PEERS: u64 = 8;
435const STARVING_LIST_CAP: usize = 5;
439
440fn bin_saturation_gate(topology: Option<&TopologySnapshot>) -> Gate {
441 let Some(snap) = topology else {
442 return unknown("Bin saturation");
443 };
444 if let Some(err) = &snap.last_error {
445 return Gate {
446 label: "Bin saturation",
447 status: GateStatus::Unknown,
448 value: format!("topology error: {err}"),
449 why: None,
450 };
451 }
452 let Some(t) = &snap.topology else {
453 return unknown("Bin saturation");
454 };
455 let starving: Vec<u8> = t
458 .bins
459 .iter()
460 .enumerate()
461 .filter_map(|(i, b)| {
462 let bin = i as u8;
463 if bin <= t.depth && b.connected < SATURATION_PEERS {
464 Some(bin)
465 } else {
466 None
467 }
468 })
469 .collect();
470 if starving.is_empty() {
471 Gate {
472 label: "Bin saturation",
473 status: GateStatus::Pass,
474 value: format!(
475 "all bins ≤ depth ({}) saturated (≥{SATURATION_PEERS})",
476 t.depth
477 ),
478 why: None,
479 }
480 } else {
481 let listed: Vec<String> = starving
482 .iter()
483 .take(STARVING_LIST_CAP)
484 .map(|b| format!("bin {b}"))
485 .collect();
486 let suffix = if starving.len() > STARVING_LIST_CAP {
487 format!(" (+{} more)", starving.len() - STARVING_LIST_CAP)
488 } else {
489 String::new()
490 };
491 Gate {
492 label: "Bin saturation",
493 status: GateStatus::Warn,
494 value: format!(
495 "{} starving: {}{suffix}",
496 starving.len(),
497 listed.join(", ")
498 ),
499 why: Some(
500 "manually `connect` more peers or wait — kademlia fills bins as the node sees more traffic"
501 .into(),
502 ),
503 }
504 }
505}
506
507impl Component for Health {
508 fn update(&mut self, action: Action) -> Result<Option<Action>> {
509 if matches!(action, Action::Tick) {
510 self.pull_latest();
511 }
512 Ok(None)
513 }
514
515 fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
516 let chunks = Layout::vertical([
517 Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), ])
521 .split(area);
522
523 let header_line1 = Line::from(vec![
525 Span::styled("HEALTH", Style::default().add_modifier(Modifier::BOLD)),
526 Span::raw(" "),
527 Span::styled(
528 format!("{} · {}", self.api.name, self.api.url),
529 Style::default().fg(theme::active().info),
530 ),
531 Span::raw(if self.api.authenticated { " 🔒" } else { "" }),
532 ]);
533 let mut header_line2 = vec![Span::raw("ping: ")];
534 let t = theme::active();
535 match self.snapshot.last_ping {
536 Some(d) => header_line2.push(Span::styled(
537 format!("{}ms", d.as_millis()),
538 Style::default().fg(t.pass),
539 )),
540 None => header_line2.push(Span::styled("—", Style::default().fg(t.dim))),
541 };
542 if let Some(err) = &self.snapshot.last_error {
543 header_line2.push(Span::raw(" "));
544 let (color, msg) = theme::classify_header_error(err);
545 header_line2.push(Span::styled(msg, Style::default().fg(color)));
546 }
547 frame.render_widget(
548 Paragraph::new(vec![header_line1, Line::from(header_line2)])
549 .block(Block::default().borders(Borders::BOTTOM)),
550 chunks[0],
551 );
552
553 let mut lines: Vec<Line> = Vec::new();
555 for g in Self::gates_for(&self.snapshot, Some(&self.topology)) {
556 lines.push(Line::from(vec![
557 Span::raw(" "),
558 Span::styled(
559 g.status.glyph(),
560 Style::default()
561 .fg(g.status.color())
562 .add_modifier(Modifier::BOLD),
563 ),
564 Span::raw(" "),
565 Span::styled(
566 format!("{:<28}", g.label),
567 Style::default().add_modifier(Modifier::BOLD),
568 ),
569 Span::raw(g.value),
570 ]));
571 if let Some(why) = g.why {
572 lines.push(Line::from(vec![
573 Span::raw(" └─ "),
574 Span::styled(
575 why,
576 Style::default()
577 .fg(theme::active().dim)
578 .add_modifier(Modifier::ITALIC),
579 ),
580 ]));
581 }
582 }
583 frame.render_widget(Paragraph::new(lines), chunks[1]);
584
585 frame.render_widget(
587 Paragraph::new(Line::from(vec![
588 Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
589 Span::raw(" switch screen "),
590 Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
591 Span::raw(" help "),
592 Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
593 Span::raw(" quit "),
594 ])),
595 chunks[2],
596 );
597
598 Ok(())
599 }
600}
601
602#[cfg(test)]
603mod stamp_ttl_tests {
604 use super::*;
605 use crate::components::stamps::{TOPUP_SOON_SECS, TOPUP_URGENT_SECS};
606 use crate::watch::StampsSnapshot;
607 use bee::postage::PostageBatch;
608 use std::time::Instant;
609
610 fn batch(ttl_secs: i64, usable: bool) -> PostageBatch {
611 PostageBatch {
612 batch_id: bee::swarm::BatchId::new(&[0xab; 32]).unwrap(),
613 amount: None,
614 start: 0,
615 owner: String::new(),
616 depth: 22,
617 bucket_depth: 16,
618 immutable: true,
619 batch_ttl: ttl_secs,
620 utilization: 0,
621 usable,
622 exists: true,
623 label: "test".into(),
624 block_number: 0,
625 }
626 }
627
628 fn loaded(batches: Vec<PostageBatch>) -> StampsSnapshot {
629 StampsSnapshot {
630 batches,
631 last_error: None,
632 last_update: Some(Instant::now()),
633 }
634 }
635
636 #[test]
637 fn stamp_ttl_unknown_when_not_loaded() {
638 let snap = StampsSnapshot::default();
639 let g = stamp_ttl_gate(&snap);
640 assert_eq!(g.status, GateStatus::Unknown);
641 }
642
643 #[test]
644 fn stamp_ttl_unknown_when_no_usable_batches() {
645 let snap = loaded(vec![batch(30 * 86_400, false)]);
647 let g = stamp_ttl_gate(&snap);
648 assert_eq!(g.status, GateStatus::Unknown);
649 assert!(g.value.contains("no usable"));
650 }
651
652 #[test]
653 fn stamp_ttl_pass_when_all_above_planning_threshold() {
654 let snap = loaded(vec![batch(30 * 86_400, true), batch(10 * 86_400, true)]);
655 let g = stamp_ttl_gate(&snap);
656 assert_eq!(g.status, GateStatus::Pass);
657 }
658
659 #[test]
660 fn stamp_ttl_warn_when_within_planning_window() {
661 let ttl = 3 * 86_400;
663 assert!(ttl <= TOPUP_SOON_SECS);
664 assert!(ttl > TOPUP_URGENT_SECS);
665 let snap = loaded(vec![batch(30 * 86_400, true), batch(ttl, true)]);
666 let g = stamp_ttl_gate(&snap);
667 assert_eq!(g.status, GateStatus::Warn);
668 assert!(g.value.contains("3d") || g.value.contains("72h"));
670 }
671
672 #[test]
673 fn stamp_ttl_fail_when_under_urgent_threshold() {
674 let snap = loaded(vec![batch(30 * 86_400, true), batch(12 * 3600, true)]);
675 let g = stamp_ttl_gate(&snap);
676 assert_eq!(g.status, GateStatus::Fail);
677 }
678
679 #[test]
680 fn gates_for_with_stamps_appends_one_extra_gate() {
681 let snap = HealthSnapshot::default();
682 let baseline = Health::gates_for(&snap, None);
683 let with_stamps =
684 Health::gates_for_with_stamps(&snap, None, Some(&StampsSnapshot::default()));
685 assert_eq!(with_stamps.len(), baseline.len() + 1);
686 assert_eq!(with_stamps.last().unwrap().label, "Stamp TTL");
687 }
688}