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        let mut gates = Vec::with_capacity(10);
109
110        // 1. API reachable -------------------------------------------------
111        gates.push(match snap.last_ping {
112            Some(d) => Gate {
113                label: "API reachable",
114                status: GateStatus::Pass,
115                value: format!("({}ms)", d.as_millis()),
116                why: None,
117            },
118            None if snap.last_update.is_none() => Gate {
119                label: "API reachable",
120                status: GateStatus::Unknown,
121                value: "loading…".into(),
122                why: None,
123            },
124            None => Gate {
125                label: "API reachable",
126                status: GateStatus::Fail,
127                value: "no /health response".into(),
128                why: snap.last_error.clone(),
129            },
130        });
131
132        // 2. Chain RPC -----------------------------------------------------
133        if let Some(cs) = &snap.chain_state {
134            let delta = cs.chain_tip.saturating_sub(cs.block);
135            let (status, why) = if delta == 0 {
136                (GateStatus::Pass, None)
137            } else if delta < 50 {
138                (
139                    GateStatus::Warn,
140                    Some(format!("chain head {delta} blocks ahead")),
141                )
142            } else {
143                (
144                    GateStatus::Fail,
145                    Some(format!("RPC out of sync: {delta} blocks behind tip")),
146                )
147            };
148            gates.push(Gate {
149                label: "Chain RPC",
150                status,
151                value: format!("block {} · Δ +{delta}", cs.block),
152                why,
153            });
154        } else {
155            gates.push(unknown("Chain RPC"));
156        }
157
158        // 3. Wallet funded -------------------------------------------------
159        if let Some(w) = &snap.wallet {
160            let zero = BigInt::from(0);
161            let bzz = w.bzz_balance.as_ref().unwrap_or(&zero);
162            let native = w.native_token_balance.as_ref().unwrap_or(&zero);
163            let value = format!("BZZ {bzz} · native {native}");
164            if bzz == &zero && native == &zero {
165                gates.push(Gate {
166                    label: "Wallet funded",
167                    status: GateStatus::Fail,
168                    value: "0 BZZ · 0 native".into(),
169                    why: Some("fund the operator wallet to participate".into()),
170                });
171            } else if bzz == &zero || native == &zero {
172                gates.push(Gate {
173                    label: "Wallet funded",
174                    status: GateStatus::Warn,
175                    value,
176                    why: Some("partial funding — need both BZZ (storage) and native (gas)".into()),
177                });
178            } else {
179                gates.push(Gate {
180                    label: "Wallet funded",
181                    status: GateStatus::Pass,
182                    value,
183                    why: None,
184                });
185            }
186        } else {
187            gates.push(unknown("Wallet funded"));
188        }
189
190        // 4. Warmup complete + 5. Peers + 7. Reserve  (all from /status)
191        if let Some(s) = &snap.status {
192            // 4
193            if s.is_warming_up {
194                gates.push(Gate {
195                    label: "Warmup complete",
196                    status: GateStatus::Warn,
197                    value: "warming up".into(),
198                    why: Some("first-launch warmup can take 5–60 minutes".into()),
199                });
200            } else {
201                gates.push(Gate {
202                    label: "Warmup complete",
203                    status: GateStatus::Pass,
204                    value: "ready".into(),
205                    why: None,
206                });
207            }
208            // 5
209            let n = s.connected_peers;
210            let (pstatus, pwhy) = if n == 0 {
211                (GateStatus::Fail, Some("no peers — node is isolated".into()))
212            } else if n < 8 {
213                (
214                    GateStatus::Warn,
215                    Some(format!("only {n} connected — bins likely starving")),
216                )
217            } else {
218                (GateStatus::Pass, None)
219            };
220            gates.push(Gate {
221                label: "Peers",
222                status: pstatus,
223                value: format!("{n} connected"),
224                why: pwhy,
225            });
226            // 7
227            let total = s.reserve_size;
228            let in_radius = s.reserve_size_within_radius;
229            let (rstatus, rwhy) = if total == 0 && !s.is_warming_up {
230                (
231                    GateStatus::Warn,
232                    Some("reserve empty after warmup — check sync rate".into()),
233                )
234            } else {
235                (GateStatus::Pass, None)
236            };
237            gates.push(Gate {
238                label: "Reserve",
239                status: rstatus,
240                value: format!(
241                    "{total} chunks (in-radius: {in_radius}) · radius {}",
242                    s.storage_radius
243                ),
244                why: rwhy,
245            });
246        } else {
247            gates.push(unknown("Warmup complete"));
248            gates.push(unknown("Peers"));
249            gates.push(unknown("Reserve"));
250        }
251
252        // 6. Bin saturation — derived from /topology populations vs
253        // the bee-go SaturationPeers=8 constant. We flag any bin at
254        // or below the kademlia depth that has fewer than 8
255        // connected peers; far bins past the depth are expected to
256        // be sparse and don't trigger this gate.
257        gates.push(bin_saturation_gate(topology));
258
259        // 8 / 9 / 10 — redistribution -------------------------------------
260        if let Some(r) = &snap.redistribution {
261            // 8
262            if r.is_healthy {
263                gates.push(Gate {
264                    label: "Healthy for redistribution",
265                    status: GateStatus::Pass,
266                    value: "yes".into(),
267                    why: None,
268                });
269            } else if let Some(s) = &snap.status {
270                let radius = s.storage_radius;
271                let committed = s.committed_depth;
272                if radius < committed {
273                    gates.push(Gate {
274                        label: "Healthy for redistribution",
275                        status: GateStatus::Fail,
276                        value: format!("storageRadius ({radius}) < committed ({committed})"),
277                        why: Some(
278                            "storageRadius decreases ONLY on the 30-min reserve worker tick — wait it out or check reserve fill"
279                                .into(),
280                        ),
281                    });
282                } else {
283                    gates.push(Gate {
284                        label: "Healthy for redistribution",
285                        status: GateStatus::Fail,
286                        value: "isHealthy=false".into(),
287                        why: Some("check reserve fill, fully-synced status, freeze status".into()),
288                    });
289                }
290            } else {
291                gates.push(Gate {
292                    label: "Healthy for redistribution",
293                    status: GateStatus::Fail,
294                    value: "isHealthy=false".into(),
295                    why: None,
296                });
297            }
298            // 9
299            if r.is_frozen {
300                gates.push(Gate {
301                    label: "Not frozen",
302                    status: GateStatus::Fail,
303                    value: format!("frozen since round {}", r.last_frozen_round),
304                    why: Some("invalid commit/reveal or desynced reserve in a recent round".into()),
305                });
306            } else {
307                gates.push(Gate {
308                    label: "Not frozen",
309                    status: GateStatus::Pass,
310                    value: "active".into(),
311                    why: None,
312                });
313            }
314            // 10
315            if r.has_sufficient_funds {
316                gates.push(Gate {
317                    label: "Sufficient funds to play",
318                    status: GateStatus::Pass,
319                    value: "yes".into(),
320                    why: None,
321                });
322            } else {
323                gates.push(Gate {
324                    label: "Sufficient funds to play",
325                    status: GateStatus::Fail,
326                    value: "insufficient gas runway".into(),
327                    why: Some("top up the operator wallet's native-token balance".into()),
328                });
329            }
330        } else {
331            for label in [
332                "Healthy for redistribution",
333                "Not frozen",
334                "Sufficient funds to play",
335            ] {
336                gates.push(unknown(label));
337            }
338        }
339
340        gates
341    }
342}
343
344fn unknown(label: &'static str) -> Gate {
345    Gate {
346        label,
347        status: GateStatus::Unknown,
348        value: "—".into(),
349        why: None,
350    }
351}
352
353/// Threshold for the bin-saturation gate. Mirrors bee-go's
354/// `SaturationPeers` constant (`pkg/topology/kademlia/kademlia.go:54`).
355const SATURATION_PEERS: u64 = 8;
356/// Cap on the number of starving bin numbers listed inline in the
357/// gate's value string. Avoids one mega-line when a brand-new node
358/// reports every bin as starving.
359const STARVING_LIST_CAP: usize = 5;
360
361fn bin_saturation_gate(topology: Option<&TopologySnapshot>) -> Gate {
362    let Some(snap) = topology else {
363        return unknown("Bin saturation");
364    };
365    if let Some(err) = &snap.last_error {
366        return Gate {
367            label: "Bin saturation",
368            status: GateStatus::Unknown,
369            value: format!("topology error: {err}"),
370            why: None,
371        };
372    }
373    let Some(t) = &snap.topology else {
374        return unknown("Bin saturation");
375    };
376    // Only flag bins at or below the kademlia depth — bins beyond
377    // depth are expected to be sparse during normal operation.
378    let starving: Vec<u8> = t
379        .bins
380        .iter()
381        .enumerate()
382        .filter_map(|(i, b)| {
383            let bin = i as u8;
384            if bin <= t.depth && b.connected < SATURATION_PEERS {
385                Some(bin)
386            } else {
387                None
388            }
389        })
390        .collect();
391    if starving.is_empty() {
392        Gate {
393            label: "Bin saturation",
394            status: GateStatus::Pass,
395            value: format!(
396                "all bins ≤ depth ({}) saturated (≥{SATURATION_PEERS})",
397                t.depth
398            ),
399            why: None,
400        }
401    } else {
402        let listed: Vec<String> = starving
403            .iter()
404            .take(STARVING_LIST_CAP)
405            .map(|b| format!("bin {b}"))
406            .collect();
407        let suffix = if starving.len() > STARVING_LIST_CAP {
408            format!(" (+{} more)", starving.len() - STARVING_LIST_CAP)
409        } else {
410            String::new()
411        };
412        Gate {
413            label: "Bin saturation",
414            status: GateStatus::Warn,
415            value: format!(
416                "{} starving: {}{suffix}",
417                starving.len(),
418                listed.join(", ")
419            ),
420            why: Some(
421                "manually `connect` more peers or wait — kademlia fills bins as the node sees more traffic"
422                    .into(),
423            ),
424        }
425    }
426}
427
428impl Component for Health {
429    fn update(&mut self, action: Action) -> Result<Option<Action>> {
430        if matches!(action, Action::Tick) {
431            self.pull_latest();
432        }
433        Ok(None)
434    }
435
436    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
437        let chunks = Layout::vertical([
438            Constraint::Length(3), // header
439            Constraint::Min(0),    // gates list
440            Constraint::Length(1), // footer
441        ])
442        .split(area);
443
444        // ---- Header --------------------------------------------------
445        let header_line1 = Line::from(vec![
446            Span::styled("HEALTH", Style::default().add_modifier(Modifier::BOLD)),
447            Span::raw("  "),
448            Span::styled(
449                format!("{} · {}", self.api.name, self.api.url),
450                Style::default().fg(theme::active().info),
451            ),
452            Span::raw(if self.api.authenticated { "  🔒" } else { "" }),
453        ]);
454        let mut header_line2 = vec![Span::raw("ping: ")];
455        let t = theme::active();
456        match self.snapshot.last_ping {
457            Some(d) => header_line2.push(Span::styled(
458                format!("{}ms", d.as_millis()),
459                Style::default().fg(t.pass),
460            )),
461            None => header_line2.push(Span::styled("—", Style::default().fg(t.dim))),
462        };
463        if let Some(err) = &self.snapshot.last_error {
464            header_line2.push(Span::raw("  "));
465            let (color, msg) = theme::classify_header_error(err);
466            header_line2.push(Span::styled(msg, Style::default().fg(color)));
467        }
468        frame.render_widget(
469            Paragraph::new(vec![header_line1, Line::from(header_line2)])
470                .block(Block::default().borders(Borders::BOTTOM)),
471            chunks[0],
472        );
473
474        // ---- Gates ---------------------------------------------------
475        let mut lines: Vec<Line> = Vec::new();
476        for g in Self::gates_for(&self.snapshot, Some(&self.topology)) {
477            lines.push(Line::from(vec![
478                Span::raw("  "),
479                Span::styled(
480                    g.status.glyph(),
481                    Style::default()
482                        .fg(g.status.color())
483                        .add_modifier(Modifier::BOLD),
484                ),
485                Span::raw("  "),
486                Span::styled(
487                    format!("{:<28}", g.label),
488                    Style::default().add_modifier(Modifier::BOLD),
489                ),
490                Span::raw(g.value),
491            ]));
492            if let Some(why) = g.why {
493                lines.push(Line::from(vec![
494                    Span::raw("       └─ "),
495                    Span::styled(
496                        why,
497                        Style::default()
498                            .fg(theme::active().dim)
499                            .add_modifier(Modifier::ITALIC),
500                    ),
501                ]));
502            }
503        }
504        frame.render_widget(Paragraph::new(lines), chunks[1]);
505
506        // ---- Footer (keymap) -----------------------------------------
507        frame.render_widget(
508            Paragraph::new(Line::from(vec![
509                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
510                Span::raw(" switch screen  "),
511                Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
512                Span::raw(" help  "),
513                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
514                Span::raw(" quit  "),
515            ])),
516            chunks[2],
517        );
518
519        Ok(())
520    }
521}