1use 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#[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#[derive(Debug, Clone)]
61pub struct Gate {
62 pub label: &'static str,
63 pub status: GateStatus,
64 pub value: String,
65 pub why: Option<String>,
69}
70
71pub 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 pub fn gates_for(snap: &HealthSnapshot, topology: Option<&TopologySnapshot>) -> Vec<Gate> {
108 let mut gates = Vec::with_capacity(10);
109
110 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 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 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 if let Some(s) = &snap.status {
192 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 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 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 gates.push(bin_saturation_gate(topology));
258
259 if let Some(r) = &snap.redistribution {
261 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 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 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
353const SATURATION_PEERS: u64 = 8;
356const 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 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), Constraint::Min(0), Constraint::Length(1), ])
442 .split(area);
443
444 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 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 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}