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::watch::HealthSnapshot;
27
28#[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#[derive(Debug, Clone)]
58pub struct Gate {
59 pub label: &'static str,
60 pub status: GateStatus,
61 pub value: String,
62 pub why: Option<String>,
66}
67
68pub 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 pub fn gates_for(snap: &HealthSnapshot) -> Vec<Gate> {
90 let mut gates = Vec::with_capacity(10);
91
92 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 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 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 if let Some(s) = &snap.status {
174 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 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 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 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 if let Some(r) = &snap.redistribution {
244 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 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 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), Constraint::Min(0), Constraint::Length(1), ])
350 .split(area);
351
352 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 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 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}