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