Skip to main content

bee_tui/components/
api_health.rs

1//! S8 — RPC / API health screen (`docs/PLAN.md` § 8.S8).
2//!
3//! PLAN's framing was Gnosis-RPC latency + remote block height, but
4//! Bee doesn't expose its eth RPC URL nor a remote chain-tip
5//! reference. Pivoting the screen to what we *can* measure:
6//!
7//! - **Bee API call stats** — latency p50 / p99 + error rate computed
8//!   from the same `tracing` capture that powers the S10 command-log
9//!   pane. This is the more operator-relevant metric anyway: a slow
10//!   Bee API tells you the local node is sluggish, regardless of the
11//!   underlying RPC.
12//! - **Chain state** — `block` / `chain_tip` / their delta from
13//!   `/chainstate`. Bee's own view of the chain.
14//! - **Pending operator transactions** — `/transactions` with hash,
15//!   nonce, and creation timestamp so a stuck postage-topup or
16//!   stake-deposit doesn't disappear into the void.
17//!
18//! The "Bee doesn't expose its eth RPC URL or remote block height"
19//! gap is acknowledged inline so operators see what *isn't*
20//! measured rather than assuming silence equals success.
21//!
22//! Render delegates to the pure [`ApiHealth::view_for`] so the
23//! snapshot tests in `tests/s8_api_health_view.rs` pin every
24//! statistical edge (empty samples, all-success, mixed errors)
25//! without launching a TUI.
26
27use std::sync::Arc;
28
29use color_eyre::Result;
30use ratatui::{
31    Frame,
32    layout::{Constraint, Layout, Rect},
33    style::{Color, Modifier, Style},
34    text::{Line, Span},
35    widgets::{Block, Borders, Paragraph},
36};
37use tokio::sync::watch;
38
39use super::Component;
40use crate::action::Action;
41use crate::api::ApiClient;
42use crate::log_capture::{LogCapture, LogEntry};
43use crate::theme;
44use crate::watch::{HealthSnapshot, TransactionsSnapshot};
45
46/// Window of recent calls considered for the latency / error-rate
47/// summary. Tracks the LogCapture's own ring-buffer capacity (200 in
48/// `log_capture::install`) — lifting the cap above that just yields
49/// the same numbers since older entries are gone.
50pub const STATS_WINDOW: usize = 100;
51
52/// Aggregated call statistics over a window of [`LogEntry`] records.
53#[derive(Debug, Clone, PartialEq)]
54pub struct CallStats {
55    /// Number of entries that contributed (had `elapsed_ms` set).
56    pub sample_size: usize,
57    /// Median latency in milliseconds. `None` if `sample_size == 0`.
58    pub p50_ms: Option<u64>,
59    /// 99th-percentile latency in milliseconds. `None` if
60    /// `sample_size == 0`.
61    pub p99_ms: Option<u64>,
62    /// Percentage of entries with `status >= 400`. `0.0` when no
63    /// entries have a status code attached.
64    pub error_rate_pct: f64,
65}
66
67/// Bee's view of the chain.
68#[derive(Debug, Clone, PartialEq, Eq, Default)]
69pub struct ChainStateView {
70    pub block: Option<u64>,
71    pub chain_tip: Option<u64>,
72    /// `chain_tip - block`, surfaced separately so the renderer can
73    /// colour-code it without re-doing the subtraction. Negative
74    /// values shouldn't happen on a healthy node but are technically
75    /// possible during chain reorgs — the field is signed for that.
76    pub delta: Option<i64>,
77    pub total_amount: Option<String>,
78    pub current_price: Option<String>,
79}
80
81/// One row of the pending-transactions table.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct PendingTxRow {
84    pub nonce: u64,
85    pub hash_short: String,
86    pub to_short: String,
87    /// RFC 3339 creation timestamp, rendered verbatim (no parser is
88    /// in scope yet). Empty if Bee didn't supply one.
89    pub created: String,
90    /// Operator-supplied description from the `description` field.
91    /// Empty for system-issued txs.
92    pub description: String,
93}
94
95/// Aggregated view fed to renderer and snapshot tests.
96#[derive(Debug, Clone, PartialEq)]
97pub struct ApiHealthView {
98    pub bee_endpoint: String,
99    pub call_stats: CallStats,
100    pub chain: ChainStateView,
101    pub pending: Vec<PendingTxRow>,
102}
103
104pub struct ApiHealth {
105    api: Arc<ApiClient>,
106    health_rx: watch::Receiver<HealthSnapshot>,
107    transactions_rx: watch::Receiver<TransactionsSnapshot>,
108    health: HealthSnapshot,
109    transactions: TransactionsSnapshot,
110    log_capture: Option<LogCapture>,
111}
112
113impl ApiHealth {
114    pub fn new(
115        api: Arc<ApiClient>,
116        health_rx: watch::Receiver<HealthSnapshot>,
117        transactions_rx: watch::Receiver<TransactionsSnapshot>,
118        log_capture: Option<LogCapture>,
119    ) -> Self {
120        let health = health_rx.borrow().clone();
121        let transactions = transactions_rx.borrow().clone();
122        Self {
123            api,
124            health_rx,
125            transactions_rx,
126            health,
127            transactions,
128            log_capture,
129        }
130    }
131
132    fn pull_latest(&mut self) {
133        self.health = self.health_rx.borrow().clone();
134        self.transactions = self.transactions_rx.borrow().clone();
135    }
136
137    /// Pure view computation. The log entries arrive as a slice rather
138    /// than a `LogCapture` handle so tests can stub deterministic
139    /// samples without spinning up the global tracing layer.
140    pub fn view_for(
141        bee_endpoint: &str,
142        recent_calls: &[LogEntry],
143        health: &HealthSnapshot,
144        transactions: &TransactionsSnapshot,
145    ) -> ApiHealthView {
146        ApiHealthView {
147            bee_endpoint: bee_endpoint.to_string(),
148            call_stats: call_stats_for(recent_calls),
149            chain: chain_state_view(health),
150            pending: pending_rows(transactions),
151        }
152    }
153}
154
155/// Compute call stats over the last [`STATS_WINDOW`] entries that
156/// have `elapsed_ms` populated. Latency percentiles are computed via
157/// nearest-rank on the sorted sample.
158pub fn call_stats_for(entries: &[LogEntry]) -> CallStats {
159    let recent: Vec<&LogEntry> = entries.iter().rev().take(STATS_WINDOW).collect();
160    let total = recent.len();
161    if total == 0 {
162        return CallStats {
163            sample_size: 0,
164            p50_ms: None,
165            p99_ms: None,
166            error_rate_pct: 0.0,
167        };
168    }
169    let mut latencies: Vec<u64> = recent.iter().filter_map(|e| e.elapsed_ms).collect();
170    latencies.sort_unstable();
171    let with_latency = latencies.len();
172    let p50_ms = percentile(&latencies, 50);
173    let p99_ms = percentile(&latencies, 99);
174    // Error rate is computed against entries that *do* carry a
175    // status — entries without one (in-flight or non-HTTP events)
176    // shouldn't pull the rate down.
177    let with_status: Vec<u16> = recent.iter().filter_map(|e| e.status).collect();
178    let errors = with_status.iter().filter(|s| **s >= 400).count();
179    let error_rate_pct = if with_status.is_empty() {
180        0.0
181    } else {
182        (errors as f64) * 100.0 / (with_status.len() as f64)
183    };
184    CallStats {
185        sample_size: with_latency,
186        p50_ms,
187        p99_ms,
188        error_rate_pct,
189    }
190}
191
192/// Nearest-rank percentile on a pre-sorted slice. Returns `None` for
193/// the empty slice. `pct` is in `0..=100`.
194fn percentile(sorted: &[u64], pct: u32) -> Option<u64> {
195    if sorted.is_empty() {
196        return None;
197    }
198    let n = sorted.len();
199    // Nearest-rank: ceil(pct/100 * n) - 1 (clamped to a valid index).
200    let rank = (pct as usize * n).div_ceil(100);
201    let idx = rank.saturating_sub(1).min(n - 1);
202    Some(sorted[idx])
203}
204
205fn chain_state_view(health: &HealthSnapshot) -> ChainStateView {
206    let Some(cs) = &health.chain_state else {
207        return ChainStateView::default();
208    };
209    let delta = (cs.chain_tip as i64) - (cs.block as i64);
210    ChainStateView {
211        block: Some(cs.block),
212        chain_tip: Some(cs.chain_tip),
213        delta: Some(delta),
214        total_amount: Some(cs.total_amount.to_string()),
215        current_price: Some(cs.current_price.to_string()),
216    }
217}
218
219fn pending_rows(transactions: &TransactionsSnapshot) -> Vec<PendingTxRow> {
220    transactions
221        .pending
222        .iter()
223        .map(|t| PendingTxRow {
224            nonce: t.nonce,
225            hash_short: short_hex(&t.transaction_hash),
226            to_short: short_hex(&t.to),
227            created: t.created.clone(),
228            description: t.description.clone(),
229        })
230        .collect()
231}
232
233fn short_hex(s: &str) -> String {
234    let trimmed = s.trim_start_matches("0x");
235    if trimmed.len() > 12 {
236        format!("{}…{}", &trimmed[..6], &trimmed[trimmed.len() - 4..])
237    } else {
238        trimmed.to_string()
239    }
240}
241
242impl Component for ApiHealth {
243    fn update(&mut self, action: Action) -> Result<Option<Action>> {
244        if matches!(action, Action::Tick) {
245            self.pull_latest();
246        }
247        Ok(None)
248    }
249
250    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
251        let chunks = Layout::vertical([
252            Constraint::Length(3), // header
253            Constraint::Length(7), // call stats
254            Constraint::Length(5), // chain state
255            Constraint::Min(0),    // pending tx table
256            Constraint::Length(1), // footer
257        ])
258        .split(area);
259
260        let recent: Vec<LogEntry> = self
261            .log_capture
262            .as_ref()
263            .map(|c| c.snapshot())
264            .unwrap_or_default();
265        let view = Self::view_for(&self.api.url, &recent, &self.health, &self.transactions);
266        let t = theme::active();
267
268        // Header
269        let header_l1 = Line::from(vec![
270            Span::styled(
271                "RPC / API HEALTH",
272                Style::default().add_modifier(Modifier::BOLD),
273            ),
274            Span::raw("   endpoint  "),
275            Span::styled(view.bee_endpoint.clone(), Style::default().fg(t.info)),
276        ]);
277        let header_l2 = Line::from(Span::styled(
278            "  Bee doesn't expose its eth RPC URL or remote chain tip; this view measures the local Bee API instead.",
279            Style::default()
280                .fg(t.dim)
281                .add_modifier(Modifier::ITALIC),
282        ));
283        frame.render_widget(
284            Paragraph::new(vec![header_l1, header_l2])
285                .block(Block::default().borders(Borders::BOTTOM)),
286            chunks[0],
287        );
288
289        // Call stats
290        let cs = &view.call_stats;
291        let p50 = cs.p50_ms.map(|v| format!("{v} ms")).unwrap_or_else(|| "—".into());
292        let p99 = cs.p99_ms.map(|v| format!("{v} ms")).unwrap_or_else(|| "—".into());
293        let err_color = if cs.error_rate_pct >= 5.0 {
294            t.fail
295        } else if cs.error_rate_pct >= 1.0 {
296            t.warn
297        } else {
298            t.pass
299        };
300        let stats_lines = vec![
301            Line::from(vec![Span::styled(
302                "  CALL STATS",
303                Style::default()
304                    .fg(t.dim)
305                    .add_modifier(Modifier::BOLD),
306            )]),
307            Line::from(vec![
308                Span::raw("    p50 latency   "),
309                Span::styled(p50, Style::default().fg(t.pass)),
310            ]),
311            Line::from(vec![
312                Span::raw("    p99 latency   "),
313                Span::styled(p99, Style::default().fg(t.warn)),
314            ]),
315            Line::from(vec![
316                Span::raw("    error rate    "),
317                Span::styled(
318                    format!("{:.2}%", cs.error_rate_pct),
319                    Style::default().fg(err_color).add_modifier(Modifier::BOLD),
320                ),
321            ]),
322            Line::from(vec![
323                Span::raw("    sample size   "),
324                Span::styled(
325                    format!("{} call(s) (last {STATS_WINDOW})", cs.sample_size),
326                    Style::default().fg(t.dim),
327                ),
328            ]),
329        ];
330        frame.render_widget(
331            Paragraph::new(stats_lines).block(Block::default().borders(Borders::BOTTOM)),
332            chunks[1],
333        );
334
335        // Chain state
336        let block_str = view
337            .chain
338            .block
339            .map(|b| b.to_string())
340            .unwrap_or_else(|| "—".into());
341        let tip_str = view
342            .chain
343            .chain_tip
344            .map(|b| b.to_string())
345            .unwrap_or_else(|| "—".into());
346        let delta_str = view
347            .chain
348            .delta
349            .map(|d| format!("{d:+}"))
350            .unwrap_or_else(|| "—".into());
351        let chain_lines = vec![
352            Line::from(vec![Span::styled(
353                "  CHAIN STATE  (Bee's view, not the wider network)",
354                Style::default()
355                    .fg(t.dim)
356                    .add_modifier(Modifier::BOLD),
357            )]),
358            Line::from(vec![
359                Span::raw("    block "),
360                Span::styled(block_str, Style::default().fg(t.pass)),
361                Span::raw("   chain tip "),
362                Span::styled(tip_str, Style::default().fg(t.pass)),
363                Span::raw("   Δ "),
364                Span::styled(delta_str, Style::default().fg(t.warn)),
365            ]),
366        ];
367        frame.render_widget(
368            Paragraph::new(chain_lines).block(Block::default().borders(Borders::BOTTOM)),
369            chunks[2],
370        );
371
372        // Pending tx table
373        let mut pending_lines = vec![Line::from(Span::styled(
374            format!("  PENDING TRANSACTIONS  ({})", view.pending.len()),
375            Style::default()
376                .fg(t.dim)
377                .add_modifier(Modifier::BOLD),
378        ))];
379        if view.pending.is_empty() {
380            pending_lines.push(Line::from(Span::styled(
381                "  (no pending operator transactions — all confirmed)",
382                Style::default()
383                    .fg(t.dim)
384                    .add_modifier(Modifier::ITALIC),
385            )));
386        } else {
387            pending_lines.push(Line::from(Span::styled(
388                "  NONCE  HASH           TO              CREATED                DESCRIPTION",
389                Style::default()
390                    .fg(t.dim)
391                    .add_modifier(Modifier::BOLD),
392            )));
393            for r in &view.pending {
394                pending_lines.push(Line::from(vec![
395                    Span::raw("  "),
396                    Span::raw(format!("{:<6} ", r.nonce)),
397                    Span::styled(
398                        format!("{:<14} ", r.hash_short),
399                        Style::default().fg(t.info),
400                    ),
401                    Span::raw(format!("{:<15} ", r.to_short)),
402                    Span::raw(format!("{:<22} ", truncate(&r.created, 22))),
403                    Span::styled(
404                        truncate(&r.description, 30),
405                        Style::default().fg(t.dim),
406                    ),
407                ]));
408            }
409        }
410        frame.render_widget(Paragraph::new(pending_lines), chunks[3]);
411
412        // Footer
413        frame.render_widget(
414            Paragraph::new(Line::from(vec![
415                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
416                Span::raw(" switch screen  "),
417                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
418                Span::raw(" quit  "),
419                Span::styled(
420                    "stats live-update from S10's command-log capture",
421                    Style::default().fg(t.dim),
422                ),
423            ])),
424            chunks[4],
425        );
426
427        Ok(())
428    }
429}
430
431fn truncate(s: &str, max: usize) -> String {
432    if s.chars().count() <= max {
433        s.to_string()
434    } else {
435        let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
436        out.push('…');
437        out
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    fn entry(method: &str, status: Option<u16>, elapsed_ms: Option<u64>) -> LogEntry {
446        LogEntry {
447            ts: String::new(),
448            method: method.into(),
449            url: "http://localhost:1633/".into(),
450            status,
451            elapsed_ms,
452            message: String::new(),
453        }
454    }
455
456    #[test]
457    fn call_stats_empty_sample() {
458        let stats = call_stats_for(&[]);
459        assert_eq!(stats.sample_size, 0);
460        assert_eq!(stats.p50_ms, None);
461        assert_eq!(stats.p99_ms, None);
462        assert_eq!(stats.error_rate_pct, 0.0);
463    }
464
465    #[test]
466    fn call_stats_all_successful() {
467        let entries: Vec<LogEntry> = (1..=100)
468            .map(|i| entry("GET", Some(200), Some(i)))
469            .collect();
470        let stats = call_stats_for(&entries);
471        assert_eq!(stats.sample_size, 100);
472        assert_eq!(stats.p50_ms, Some(50));
473        assert_eq!(stats.p99_ms, Some(99));
474        assert_eq!(stats.error_rate_pct, 0.0);
475    }
476
477    #[test]
478    fn call_stats_mixed_errors() {
479        let mut entries: Vec<LogEntry> = (1..=10)
480            .map(|i| entry("GET", Some(200), Some(i * 10)))
481            .collect();
482        entries.push(entry("POST", Some(500), Some(50)));
483        entries.push(entry("POST", Some(404), Some(15)));
484        let stats = call_stats_for(&entries);
485        // 12 entries, 2 errors → 16.67%.
486        assert!((stats.error_rate_pct - 16.666_666_666_666_668).abs() < 1e-9);
487    }
488
489    #[test]
490    fn percentile_single_element() {
491        assert_eq!(percentile(&[42], 50), Some(42));
492        assert_eq!(percentile(&[42], 99), Some(42));
493    }
494
495    #[test]
496    fn percentile_empty_returns_none() {
497        assert_eq!(percentile(&[], 50), None);
498    }
499
500    #[test]
501    fn short_hex_truncates_long_address() {
502        let s = short_hex("0xabcdef0123456789abcdef0123456789");
503        assert!(s.contains('…'));
504        assert!(s.starts_with("abcdef"));
505    }
506}