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().fg(t.dim).add_modifier(Modifier::ITALIC),
280        ));
281        frame.render_widget(
282            Paragraph::new(vec![header_l1, header_l2])
283                .block(Block::default().borders(Borders::BOTTOM)),
284            chunks[0],
285        );
286
287        // Call stats
288        let cs = &view.call_stats;
289        let p50 = cs
290            .p50_ms
291            .map(|v| format!("{v} ms"))
292            .unwrap_or_else(|| "—".into());
293        let p99 = cs
294            .p99_ms
295            .map(|v| format!("{v} ms"))
296            .unwrap_or_else(|| "—".into());
297        let err_color = if cs.error_rate_pct >= 5.0 {
298            t.fail
299        } else if cs.error_rate_pct >= 1.0 {
300            t.warn
301        } else {
302            t.pass
303        };
304        let stats_lines = vec![
305            Line::from(vec![Span::styled(
306                "  CALL STATS",
307                Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
308            )]),
309            Line::from(vec![
310                Span::raw("    p50 latency   "),
311                Span::styled(p50, Style::default().fg(t.pass)),
312            ]),
313            Line::from(vec![
314                Span::raw("    p99 latency   "),
315                Span::styled(p99, Style::default().fg(t.warn)),
316            ]),
317            Line::from(vec![
318                Span::raw("    error rate    "),
319                Span::styled(
320                    format!("{:.2}%", cs.error_rate_pct),
321                    Style::default().fg(err_color).add_modifier(Modifier::BOLD),
322                ),
323            ]),
324            Line::from(vec![
325                Span::raw("    sample size   "),
326                Span::styled(
327                    format!("{} call(s) (last {STATS_WINDOW})", cs.sample_size),
328                    Style::default().fg(t.dim),
329                ),
330            ]),
331        ];
332        frame.render_widget(
333            Paragraph::new(stats_lines).block(Block::default().borders(Borders::BOTTOM)),
334            chunks[1],
335        );
336
337        // Chain state
338        let block_str = view
339            .chain
340            .block
341            .map(|b| b.to_string())
342            .unwrap_or_else(|| "—".into());
343        let tip_str = view
344            .chain
345            .chain_tip
346            .map(|b| b.to_string())
347            .unwrap_or_else(|| "—".into());
348        let delta_str = view
349            .chain
350            .delta
351            .map(|d| format!("{d:+}"))
352            .unwrap_or_else(|| "—".into());
353        let chain_lines = vec![
354            Line::from(vec![Span::styled(
355                "  CHAIN STATE  (Bee's view, not the wider network)",
356                Style::default().fg(t.dim).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().fg(t.dim).add_modifier(Modifier::BOLD),
376        ))];
377        if view.pending.is_empty() {
378            pending_lines.push(Line::from(Span::styled(
379                "  (no pending operator transactions — all confirmed)",
380                Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
381            )));
382        } else {
383            pending_lines.push(Line::from(Span::styled(
384                "  NONCE  HASH           TO              CREATED                DESCRIPTION",
385                Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
386            )));
387            for r in &view.pending {
388                pending_lines.push(Line::from(vec![
389                    Span::raw("  "),
390                    Span::raw(format!("{:<6} ", r.nonce)),
391                    Span::styled(
392                        format!("{:<14} ", r.hash_short),
393                        Style::default().fg(t.info),
394                    ),
395                    Span::raw(format!("{:<15} ", r.to_short)),
396                    Span::raw(format!("{:<22} ", truncate(&r.created, 22))),
397                    Span::styled(truncate(&r.description, 30), Style::default().fg(t.dim)),
398                ]));
399            }
400        }
401        frame.render_widget(Paragraph::new(pending_lines), chunks[3]);
402
403        // Footer
404        frame.render_widget(
405            Paragraph::new(Line::from(vec![
406                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
407                Span::raw(" switch screen  "),
408                Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
409                Span::raw(" help  "),
410                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
411                Span::raw(" quit  "),
412                Span::styled(
413                    "stats live-update from S10's command-log capture",
414                    Style::default().fg(t.dim),
415                ),
416            ])),
417            chunks[4],
418        );
419
420        Ok(())
421    }
422}
423
424fn truncate(s: &str, max: usize) -> String {
425    if s.chars().count() <= max {
426        s.to_string()
427    } else {
428        let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
429        out.push('…');
430        out
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    fn entry(method: &str, status: Option<u16>, elapsed_ms: Option<u64>) -> LogEntry {
439        LogEntry {
440            ts: String::new(),
441            method: method.into(),
442            url: "http://localhost:1633/".into(),
443            status,
444            elapsed_ms,
445            message: String::new(),
446        }
447    }
448
449    #[test]
450    fn call_stats_empty_sample() {
451        let stats = call_stats_for(&[]);
452        assert_eq!(stats.sample_size, 0);
453        assert_eq!(stats.p50_ms, None);
454        assert_eq!(stats.p99_ms, None);
455        assert_eq!(stats.error_rate_pct, 0.0);
456    }
457
458    #[test]
459    fn call_stats_all_successful() {
460        let entries: Vec<LogEntry> = (1..=100)
461            .map(|i| entry("GET", Some(200), Some(i)))
462            .collect();
463        let stats = call_stats_for(&entries);
464        assert_eq!(stats.sample_size, 100);
465        assert_eq!(stats.p50_ms, Some(50));
466        assert_eq!(stats.p99_ms, Some(99));
467        assert_eq!(stats.error_rate_pct, 0.0);
468    }
469
470    #[test]
471    fn call_stats_mixed_errors() {
472        let mut entries: Vec<LogEntry> = (1..=10)
473            .map(|i| entry("GET", Some(200), Some(i * 10)))
474            .collect();
475        entries.push(entry("POST", Some(500), Some(50)));
476        entries.push(entry("POST", Some(404), Some(15)));
477        let stats = call_stats_for(&entries);
478        // 12 entries, 2 errors → 16.67%.
479        assert!((stats.error_rate_pct - 16.666_666_666_666_668).abs() < 1e-9);
480    }
481
482    #[test]
483    fn percentile_single_element() {
484        assert_eq!(percentile(&[42], 50), Some(42));
485        assert_eq!(percentile(&[42], 99), Some(42));
486    }
487
488    #[test]
489    fn percentile_empty_returns_none() {
490        assert_eq!(percentile(&[], 50), None);
491    }
492
493    #[test]
494    fn short_hex_truncates_long_address() {
495        let s = short_hex("0xabcdef0123456789abcdef0123456789");
496        assert!(s.contains('…'));
497        assert!(s.starts_with("abcdef"));
498    }
499}