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