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