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(
131        snap: &HealthSnapshot,
132        topology: Option<&TopologySnapshot>,
133    ) -> Vec<Gate> {
134        let mut gates = Vec::with_capacity(10);
135
136        // 1. API reachable -------------------------------------------------
137        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        // 2. Chain RPC -----------------------------------------------------
159        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        // 3. Wallet funded -------------------------------------------------
185        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        // 4. Warmup complete + 5. Peers + 7. Reserve  (all from /status)
217        if let Some(s) = &snap.status {
218            // 4
219            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            // 5
235            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            // 7
253            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        // 6. Bin saturation — derived from /topology populations vs
279        // the bee-go SaturationPeers=8 constant. We flag any bin at
280        // or below the kademlia depth that has fewer than 8
281        // connected peers; far bins past the depth are expected to
282        // be sparse and don't trigger this gate.
283        gates.push(bin_saturation_gate(topology));
284
285        // 8 / 9 / 10 — redistribution -------------------------------------
286        if let Some(r) = &snap.redistribution {
287            // 8
288            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            // 9
325            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            // 10
341            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
370/// "Stamp TTL" gate. Aggregates over usable batches (`usable=true`,
371/// non-empty TTL) and reports the worst-case bucket. Pending batches
372/// (`usable=false`) and zero-batch nodes are reported as Unknown
373/// (no opinion) rather than Pass — operators on a fresh node would
374/// be surprised by a green stamp gate when no batches exist.
375fn 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
436/// Threshold for the bin-saturation gate. Mirrors bee-go's
437/// `SaturationPeers` constant (`pkg/topology/kademlia/kademlia.go:54`).
438const SATURATION_PEERS: u64 = 8;
439/// Cap on the number of starving bin numbers listed inline in the
440/// gate's value string. Avoids one mega-line when a brand-new node
441/// reports every bin as starving.
442const 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    // Only flag bins at or below the kademlia depth — bins beyond
460    // depth are expected to be sparse during normal operation.
461    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), // header
522            Constraint::Min(0),    // gates list
523            Constraint::Length(1), // footer
524        ])
525        .split(area);
526
527        // ---- Header --------------------------------------------------
528        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        // ---- Gates ---------------------------------------------------
558        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        // ---- Footer (keymap) -----------------------------------------
590        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        // Pending batches don't count.
650        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        // 3 days < 7d planning threshold but > 24h urgent threshold.
666        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        // Worst-batch wins.
673        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}