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::watch::HealthSnapshot;
27
28/// Tri-state outcome with an `Unknown` for "data not yet loaded".
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum GateStatus {
31    Pass,
32    Warn,
33    Fail,
34    Unknown,
35}
36
37impl GateStatus {
38    fn glyph(self) -> &'static str {
39        match self {
40            Self::Pass => "✓",
41            Self::Warn => "⚠",
42            Self::Fail => "✗",
43            Self::Unknown => "·",
44        }
45    }
46    fn color(self) -> Color {
47        match self {
48            Self::Pass => Color::Green,
49            Self::Warn => Color::Yellow,
50            Self::Fail => Color::Red,
51            Self::Unknown => Color::DarkGray,
52        }
53    }
54}
55
56/// One row of the gates list.
57#[derive(Debug, Clone)]
58pub struct Gate {
59    pub label: &'static str,
60    pub status: GateStatus,
61    pub value: String,
62    /// Inline tooltip rendered as a dim italic continuation line. Used
63    /// to encode tribal-knowledge hints (e.g. "wait for the next
64    /// 30-min reserve worker tick").
65    pub why: Option<String>,
66}
67
68/// S1 component. Subscribes to the [`HealthSnapshot`] watch channel
69/// from the [`crate::watch::BeeWatch`] hub and renders a gate list.
70pub struct Health {
71    api: Arc<ApiClient>,
72    rx: watch::Receiver<HealthSnapshot>,
73    snapshot: HealthSnapshot,
74}
75
76impl Health {
77    pub fn new(api: Arc<ApiClient>, rx: watch::Receiver<HealthSnapshot>) -> Self {
78        let snapshot = rx.borrow().clone();
79        Self { api, rx, snapshot }
80    }
81
82    fn pull_latest(&mut self) {
83        self.snapshot = self.rx.borrow().clone();
84    }
85
86    /// Pure, snapshot-driven gate computation. Exposed for snapshot
87    /// tests so they can stub a [`HealthSnapshot`] and assert the
88    /// resulting gate list without a running app loop.
89    pub fn gates_for(snap: &HealthSnapshot) -> Vec<Gate> {
90        let mut gates = Vec::with_capacity(10);
91
92        // 1. API reachable -------------------------------------------------
93        gates.push(match snap.last_ping {
94            Some(d) => Gate {
95                label: "API reachable",
96                status: GateStatus::Pass,
97                value: format!("({}ms)", d.as_millis()),
98                why: None,
99            },
100            None if snap.last_update.is_none() => Gate {
101                label: "API reachable",
102                status: GateStatus::Unknown,
103                value: "loading…".into(),
104                why: None,
105            },
106            None => Gate {
107                label: "API reachable",
108                status: GateStatus::Fail,
109                value: "no /health response".into(),
110                why: snap.last_error.clone(),
111            },
112        });
113
114        // 2. Chain RPC -----------------------------------------------------
115        if let Some(cs) = &snap.chain_state {
116            let delta = cs.chain_tip.saturating_sub(cs.block);
117            let (status, why) = if delta == 0 {
118                (GateStatus::Pass, None)
119            } else if delta < 50 {
120                (
121                    GateStatus::Warn,
122                    Some(format!("chain head {delta} blocks ahead")),
123                )
124            } else {
125                (
126                    GateStatus::Fail,
127                    Some(format!("RPC out of sync: {delta} blocks behind tip")),
128                )
129            };
130            gates.push(Gate {
131                label: "Chain RPC",
132                status,
133                value: format!("block {} · Δ +{delta}", cs.block),
134                why,
135            });
136        } else {
137            gates.push(unknown("Chain RPC"));
138        }
139
140        // 3. Wallet funded -------------------------------------------------
141        if let Some(w) = &snap.wallet {
142            let zero = BigInt::from(0);
143            let bzz = w.bzz_balance.as_ref().unwrap_or(&zero);
144            let native = w.native_token_balance.as_ref().unwrap_or(&zero);
145            let value = format!("BZZ {bzz} · native {native}");
146            if bzz == &zero && native == &zero {
147                gates.push(Gate {
148                    label: "Wallet funded",
149                    status: GateStatus::Fail,
150                    value: "0 BZZ · 0 native".into(),
151                    why: Some("fund the operator wallet to participate".into()),
152                });
153            } else if bzz == &zero || native == &zero {
154                gates.push(Gate {
155                    label: "Wallet funded",
156                    status: GateStatus::Warn,
157                    value,
158                    why: Some("partial funding — need both BZZ (storage) and native (gas)".into()),
159                });
160            } else {
161                gates.push(Gate {
162                    label: "Wallet funded",
163                    status: GateStatus::Pass,
164                    value,
165                    why: None,
166                });
167            }
168        } else {
169            gates.push(unknown("Wallet funded"));
170        }
171
172        // 4. Warmup complete + 5. Peers + 7. Reserve  (all from /status)
173        if let Some(s) = &snap.status {
174            // 4
175            if s.is_warming_up {
176                gates.push(Gate {
177                    label: "Warmup complete",
178                    status: GateStatus::Warn,
179                    value: "warming up".into(),
180                    why: Some("first-launch warmup can take 5–60 minutes".into()),
181                });
182            } else {
183                gates.push(Gate {
184                    label: "Warmup complete",
185                    status: GateStatus::Pass,
186                    value: "ready".into(),
187                    why: None,
188                });
189            }
190            // 5
191            let n = s.connected_peers;
192            let (pstatus, pwhy) = if n == 0 {
193                (GateStatus::Fail, Some("no peers — node is isolated".into()))
194            } else if n < 8 {
195                (
196                    GateStatus::Warn,
197                    Some(format!("only {n} connected — bins likely starving")),
198                )
199            } else {
200                (GateStatus::Pass, None)
201            };
202            gates.push(Gate {
203                label: "Peers",
204                status: pstatus,
205                value: format!("{n} connected"),
206                why: pwhy,
207            });
208            // 7
209            let total = s.reserve_size;
210            let in_radius = s.reserve_size_within_radius;
211            let (rstatus, rwhy) = if total == 0 && !s.is_warming_up {
212                (
213                    GateStatus::Warn,
214                    Some("reserve empty after warmup — check sync rate".into()),
215                )
216            } else {
217                (GateStatus::Pass, None)
218            };
219            gates.push(Gate {
220                label: "Reserve",
221                status: rstatus,
222                value: format!(
223                    "{total} chunks (in-radius: {in_radius}) · radius {}",
224                    s.storage_radius
225                ),
226                why: rwhy,
227            });
228        } else {
229            gates.push(unknown("Warmup complete"));
230            gates.push(unknown("Peers"));
231            gates.push(unknown("Reserve"));
232        }
233
234        // 6. Bin saturation — DEFERRED to v0.2 (needs /topology poller)
235        gates.push(Gate {
236            label: "Bin saturation",
237            status: GateStatus::Unknown,
238            value: "(/topology not polled yet)".into(),
239            why: Some("v0.2: per-bin starvation detection".into()),
240        });
241
242        // 8 / 9 / 10 — redistribution -------------------------------------
243        if let Some(r) = &snap.redistribution {
244            // 8
245            if r.is_healthy {
246                gates.push(Gate {
247                    label: "Healthy for redistribution",
248                    status: GateStatus::Pass,
249                    value: "yes".into(),
250                    why: None,
251                });
252            } else if let Some(s) = &snap.status {
253                let radius = s.storage_radius;
254                let committed = s.committed_depth;
255                if radius < committed {
256                    gates.push(Gate {
257                        label: "Healthy for redistribution",
258                        status: GateStatus::Fail,
259                        value: format!("storageRadius ({radius}) < committed ({committed})"),
260                        why: Some(
261                            "storageRadius decreases ONLY on the 30-min reserve worker tick — wait it out or check reserve fill"
262                                .into(),
263                        ),
264                    });
265                } else {
266                    gates.push(Gate {
267                        label: "Healthy for redistribution",
268                        status: GateStatus::Fail,
269                        value: "isHealthy=false".into(),
270                        why: Some("check reserve fill, fully-synced status, freeze status".into()),
271                    });
272                }
273            } else {
274                gates.push(Gate {
275                    label: "Healthy for redistribution",
276                    status: GateStatus::Fail,
277                    value: "isHealthy=false".into(),
278                    why: None,
279                });
280            }
281            // 9
282            if r.is_frozen {
283                gates.push(Gate {
284                    label: "Not frozen",
285                    status: GateStatus::Fail,
286                    value: format!("frozen since round {}", r.last_frozen_round),
287                    why: Some("invalid commit/reveal or desynced reserve in a recent round".into()),
288                });
289            } else {
290                gates.push(Gate {
291                    label: "Not frozen",
292                    status: GateStatus::Pass,
293                    value: "active".into(),
294                    why: None,
295                });
296            }
297            // 10
298            if r.has_sufficient_funds {
299                gates.push(Gate {
300                    label: "Sufficient funds to play",
301                    status: GateStatus::Pass,
302                    value: "yes".into(),
303                    why: None,
304                });
305            } else {
306                gates.push(Gate {
307                    label: "Sufficient funds to play",
308                    status: GateStatus::Fail,
309                    value: "insufficient gas runway".into(),
310                    why: Some("top up the operator wallet's native-token balance".into()),
311                });
312            }
313        } else {
314            for label in [
315                "Healthy for redistribution",
316                "Not frozen",
317                "Sufficient funds to play",
318            ] {
319                gates.push(unknown(label));
320            }
321        }
322
323        gates
324    }
325}
326
327fn unknown(label: &'static str) -> Gate {
328    Gate {
329        label,
330        status: GateStatus::Unknown,
331        value: "—".into(),
332        why: None,
333    }
334}
335
336impl Component for Health {
337    fn update(&mut self, action: Action) -> Result<Option<Action>> {
338        if matches!(action, Action::Tick) {
339            self.pull_latest();
340        }
341        Ok(None)
342    }
343
344    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
345        let chunks = Layout::vertical([
346            Constraint::Length(3), // header
347            Constraint::Min(0),    // gates list
348            Constraint::Length(1), // footer
349        ])
350        .split(area);
351
352        // ---- Header --------------------------------------------------
353        let header_line1 = Line::from(vec![
354            Span::styled("HEALTH", Style::default().add_modifier(Modifier::BOLD)),
355            Span::raw("  "),
356            Span::styled(
357                format!("{} · {}", self.api.name, self.api.url),
358                Style::default().fg(Color::Cyan),
359            ),
360            Span::raw(if self.api.authenticated { "  🔒" } else { "" }),
361        ]);
362        let mut header_line2 = vec![Span::raw("ping: ")];
363        match self.snapshot.last_ping {
364            Some(d) => header_line2.push(Span::styled(
365                format!("{}ms", d.as_millis()),
366                Style::default().fg(Color::Green),
367            )),
368            None => header_line2.push(Span::styled("—", Style::default().fg(Color::DarkGray))),
369        };
370        if let Some(err) = &self.snapshot.last_error {
371            header_line2.push(Span::raw("  "));
372            header_line2.push(Span::styled(
373                format!("error: {err}"),
374                Style::default().fg(Color::Red),
375            ));
376        }
377        frame.render_widget(
378            Paragraph::new(vec![header_line1, Line::from(header_line2)])
379                .block(Block::default().borders(Borders::BOTTOM)),
380            chunks[0],
381        );
382
383        // ---- Gates ---------------------------------------------------
384        let mut lines: Vec<Line> = Vec::new();
385        for g in Self::gates_for(&self.snapshot) {
386            lines.push(Line::from(vec![
387                Span::raw("  "),
388                Span::styled(
389                    g.status.glyph(),
390                    Style::default()
391                        .fg(g.status.color())
392                        .add_modifier(Modifier::BOLD),
393                ),
394                Span::raw("  "),
395                Span::styled(
396                    format!("{:<28}", g.label),
397                    Style::default().add_modifier(Modifier::BOLD),
398                ),
399                Span::raw(g.value),
400            ]));
401            if let Some(why) = g.why {
402                lines.push(Line::from(vec![
403                    Span::raw("       └─ "),
404                    Span::styled(
405                        why,
406                        Style::default()
407                            .fg(Color::DarkGray)
408                            .add_modifier(Modifier::ITALIC),
409                    ),
410                ]));
411            }
412        }
413        frame.render_widget(Paragraph::new(lines), chunks[1]);
414
415        // ---- Footer (keymap) -----------------------------------------
416        frame.render_widget(
417            Paragraph::new(Line::from(vec![
418                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
419                Span::raw(" quit  "),
420                Span::styled(
421                    " Ctrl+C ",
422                    Style::default().fg(Color::Black).bg(Color::White),
423                ),
424                Span::raw(" quit  "),
425            ])),
426            chunks[2],
427        );
428
429        Ok(())
430    }
431}