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