Skip to main content

bee_tui/components/
health.rs

1//! S1 — Health gates screen (`docs/PLAN.md` § 8.S1).
2//!
3//! Renders a vertical list of health gates derived from the latest
4//! [`HealthSnapshot`]. Each gate carries a status (✓ / ⚠ / ✗ / ·),
5//! a value line, and an optional `why` line that encodes the tribal
6//! knowledge surfaced in `docs/research/05-operator-pain-points.md`
7//! (e.g. "storageRadius decreases ONLY on the 30-min reserve worker
8//! tick" — the #1 thing operators stare at and don't understand).
9
10use 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/// Tri-state outcome with an `Unknown` for "data not yet loaded".
30#[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/// One row of the gates list.
60#[derive(Debug, Clone)]
61pub struct Gate {
62    pub label: &'static str,
63    pub status: GateStatus,
64    pub value: String,
65    /// Inline tooltip rendered as a dim italic continuation line. Used
66    /// to encode tribal-knowledge hints (e.g. "wait for the next
67    /// 30-min reserve worker tick").
68    pub why: Option<String>,
69}
70
71/// S1 component. Subscribes to the [`HealthSnapshot`] watch channel
72/// from the [`crate::watch::BeeWatch`] hub plus the [`TopologySnapshot`]
73/// stream that drives the bin-saturation gate.
74pub 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    /// Pure, snapshot-driven gate computation. Exposed for snapshot
105    /// tests so they can stub the inputs and assert the resulting
106    /// gate list without a running app loop.
107    pub fn gates_for(snap: &HealthSnapshot, topology: Option<&TopologySnapshot>) -> Vec<Gate> {
108        Self::gates_for_with_stamps(snap, topology, None)
109    }
110
111    /// Same as [`Self::gates_for`] but with an optional stamps
112    /// snapshot — when present, the returned list includes a
113    /// "Stamp TTL" gate aggregating the worst usable batch's
114    /// remaining TTL. Plumbed separately so the existing visual
115    /// `Health` screen (which doesn't pull stamps) keeps the same
116    /// gate count it had before; the alerter and `:diagnose` bundle
117    /// pass the snapshot in.
118    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        // 1. API reachable -------------------------------------------------
134        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        // 2. Chain RPC -----------------------------------------------------
156        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        // 3. Wallet funded -------------------------------------------------
182        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        // 4. Warmup complete + 5. Peers + 7. Reserve  (all from /status)
214        if let Some(s) = &snap.status {
215            // 4
216            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            // 5
232            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            // 7
250            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        // 6. Bin saturation — derived from /topology populations vs
276        // the bee-go SaturationPeers=8 constant. We flag any bin at
277        // or below the kademlia depth that has fewer than 8
278        // connected peers; far bins past the depth are expected to
279        // be sparse and don't trigger this gate.
280        gates.push(bin_saturation_gate(topology));
281
282        // 8 / 9 / 10 — redistribution -------------------------------------
283        if let Some(r) = &snap.redistribution {
284            // 8
285            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            // 9
322            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            // 10
338            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
367/// "Stamp TTL" gate. Aggregates over usable batches (`usable=true`,
368/// non-empty TTL) and reports the worst-case bucket. Pending batches
369/// (`usable=false`) and zero-batch nodes are reported as Unknown
370/// (no opinion) rather than Pass — operators on a fresh node would
371/// be surprised by a green stamp gate when no batches exist.
372fn 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
432/// Threshold for the bin-saturation gate. Mirrors bee-go's
433/// `SaturationPeers` constant (`pkg/topology/kademlia/kademlia.go:54`).
434const SATURATION_PEERS: u64 = 8;
435/// Cap on the number of starving bin numbers listed inline in the
436/// gate's value string. Avoids one mega-line when a brand-new node
437/// reports every bin as starving.
438const 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    // Only flag bins at or below the kademlia depth — bins beyond
456    // depth are expected to be sparse during normal operation.
457    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), // header
518            Constraint::Min(0),    // gates list
519            Constraint::Length(1), // footer
520        ])
521        .split(area);
522
523        // ---- Header --------------------------------------------------
524        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        // ---- Gates ---------------------------------------------------
554        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        // ---- Footer (keymap) -----------------------------------------
586        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        // Pending batches don't count.
646        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        // 3 days < 7d planning threshold but > 24h urgent threshold.
662        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        // Worst-batch wins.
669        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}