1use 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
46pub const STATS_WINDOW: usize = 100;
51
52#[derive(Debug, Clone, PartialEq)]
54pub struct CallStats {
55 pub sample_size: usize,
57 pub p50_ms: Option<u64>,
59 pub p99_ms: Option<u64>,
62 pub error_rate_pct: f64,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Default)]
69pub struct ChainStateView {
70 pub block: Option<u64>,
71 pub chain_tip: Option<u64>,
72 pub delta: Option<i64>,
77 pub total_amount: Option<String>,
78 pub current_price: Option<String>,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct PendingTxRow {
84 pub nonce: u64,
85 pub hash_short: String,
86 pub to_short: String,
87 pub hash_full: String,
91 pub to_full: String,
94 pub created: String,
97 pub description: String,
100 pub age_seconds: Option<i64>,
107}
108
109pub const PENDING_TX_WARN_AGE_SECS: i64 = 300;
115pub const PENDING_TX_FAIL_AGE_SECS: i64 = 1800;
118
119#[derive(Debug, Clone, PartialEq)]
121pub struct ApiHealthView {
122 pub bee_endpoint: String,
123 pub call_stats: CallStats,
124 pub chain: ChainStateView,
125 pub pending: Vec<PendingTxRow>,
126}
127
128pub struct ApiHealth {
129 api: Arc<ApiClient>,
130 health_rx: watch::Receiver<HealthSnapshot>,
131 transactions_rx: watch::Receiver<TransactionsSnapshot>,
132 health: HealthSnapshot,
133 transactions: TransactionsSnapshot,
134 log_capture: Option<LogCapture>,
135}
136
137impl ApiHealth {
138 pub fn new(
139 api: Arc<ApiClient>,
140 health_rx: watch::Receiver<HealthSnapshot>,
141 transactions_rx: watch::Receiver<TransactionsSnapshot>,
142 log_capture: Option<LogCapture>,
143 ) -> Self {
144 let health = health_rx.borrow().clone();
145 let transactions = transactions_rx.borrow().clone();
146 Self {
147 api,
148 health_rx,
149 transactions_rx,
150 health,
151 transactions,
152 log_capture,
153 }
154 }
155
156 fn pull_latest(&mut self) {
157 self.health = self.health_rx.borrow().clone();
158 self.transactions = self.transactions_rx.borrow().clone();
159 }
160
161 pub fn view_for(
165 bee_endpoint: &str,
166 recent_calls: &[LogEntry],
167 health: &HealthSnapshot,
168 transactions: &TransactionsSnapshot,
169 ) -> ApiHealthView {
170 ApiHealthView {
171 bee_endpoint: bee_endpoint.to_string(),
172 call_stats: call_stats_for(recent_calls),
173 chain: chain_state_view(health),
174 pending: pending_rows(transactions),
175 }
176 }
177}
178
179pub fn call_stats_for(entries: &[LogEntry]) -> CallStats {
183 let recent: Vec<&LogEntry> = entries.iter().rev().take(STATS_WINDOW).collect();
184 let total = recent.len();
185 if total == 0 {
186 return CallStats {
187 sample_size: 0,
188 p50_ms: None,
189 p99_ms: None,
190 error_rate_pct: 0.0,
191 };
192 }
193 let mut latencies: Vec<u64> = recent.iter().filter_map(|e| e.elapsed_ms).collect();
194 latencies.sort_unstable();
195 let with_latency = latencies.len();
196 let p50_ms = percentile(&latencies, 50);
197 let p99_ms = percentile(&latencies, 99);
198 let with_status: Vec<u16> = recent.iter().filter_map(|e| e.status).collect();
202 let errors = with_status.iter().filter(|s| **s >= 400).count();
203 let error_rate_pct = if with_status.is_empty() {
204 0.0
205 } else {
206 (errors as f64) * 100.0 / (with_status.len() as f64)
207 };
208 CallStats {
209 sample_size: with_latency,
210 p50_ms,
211 p99_ms,
212 error_rate_pct,
213 }
214}
215
216fn percentile(sorted: &[u64], pct: u32) -> Option<u64> {
219 if sorted.is_empty() {
220 return None;
221 }
222 let n = sorted.len();
223 let rank = (pct as usize * n).div_ceil(100);
225 let idx = rank.saturating_sub(1).min(n - 1);
226 Some(sorted[idx])
227}
228
229fn chain_state_view(health: &HealthSnapshot) -> ChainStateView {
230 let Some(cs) = &health.chain_state else {
231 return ChainStateView::default();
232 };
233 let delta = (cs.chain_tip as i64) - (cs.block as i64);
234 ChainStateView {
235 block: Some(cs.block),
236 chain_tip: Some(cs.chain_tip),
237 delta: Some(delta),
238 total_amount: Some(cs.total_amount.to_string()),
239 current_price: Some(cs.current_price.to_string()),
240 }
241}
242
243fn pending_rows(transactions: &TransactionsSnapshot) -> Vec<PendingTxRow> {
244 let now_unix = std::time::SystemTime::now()
245 .duration_since(std::time::UNIX_EPOCH)
246 .map(|d| d.as_secs() as i64)
247 .unwrap_or(0);
248 transactions
249 .pending
250 .iter()
251 .map(|t| {
252 let age_seconds = parse_rfc3339_to_unix(&t.created).map(|ts| now_unix - ts);
253 PendingTxRow {
254 nonce: t.nonce,
255 hash_short: short_hex(&t.transaction_hash),
256 to_short: short_hex(&t.to),
257 hash_full: t.transaction_hash.trim_start_matches("0x").to_string(),
258 to_full: t.to.trim_start_matches("0x").to_string(),
259 created: t.created.clone(),
260 description: t.description.clone(),
261 age_seconds,
262 }
263 })
264 .collect()
265}
266
267pub fn parse_rfc3339_to_unix(s: &str) -> Option<i64> {
272 if s.is_empty() {
273 return None;
274 }
275 time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339)
276 .ok()
277 .map(|odt| odt.unix_timestamp())
278}
279
280pub fn format_age_humanised(age_seconds: Option<i64>) -> String {
285 match age_seconds {
286 None => "—".into(),
287 Some(s) if s < 0 => "now".into(),
288 Some(s) if s < 60 => format!("{s}s"),
289 Some(s) if s < 3_600 => {
290 let m = s / 60;
291 let r = s % 60;
292 format!("{m}m {r:>2}s")
293 }
294 Some(s) => {
295 let h = s / 3_600;
296 let m = (s % 3_600) / 60;
297 format!("{h}h {m:>2}m")
298 }
299 }
300}
301
302fn short_hex(s: &str) -> String {
303 let trimmed = s.trim_start_matches("0x");
304 if trimmed.len() > 12 {
305 format!("{}…{}", &trimmed[..6], &trimmed[trimmed.len() - 4..])
306 } else {
307 trimmed.to_string()
308 }
309}
310
311impl Component for ApiHealth {
312 fn update(&mut self, action: Action) -> Result<Option<Action>> {
313 if matches!(action, Action::Tick) {
314 self.pull_latest();
315 }
316 Ok(None)
317 }
318
319 fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
320 let chunks = Layout::vertical([
321 Constraint::Length(3), Constraint::Length(7), Constraint::Length(5), Constraint::Min(0), Constraint::Length(1), ])
327 .split(area);
328
329 let recent: Vec<LogEntry> = self
330 .log_capture
331 .as_ref()
332 .map(|c| c.snapshot())
333 .unwrap_or_default();
334 let view = Self::view_for(&self.api.url, &recent, &self.health, &self.transactions);
335 let t = theme::active();
336
337 let header_l1 = Line::from(vec![
339 Span::styled(
340 "RPC / API HEALTH",
341 Style::default().add_modifier(Modifier::BOLD),
342 ),
343 Span::raw(" endpoint "),
344 Span::styled(view.bee_endpoint.clone(), Style::default().fg(t.info)),
345 ]);
346 let header_l2 = Line::from(Span::styled(
347 " Bee doesn't expose its eth RPC URL or remote chain tip; this view measures the local Bee API instead.",
348 Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
349 ));
350 frame.render_widget(
351 Paragraph::new(vec![header_l1, header_l2])
352 .block(Block::default().borders(Borders::BOTTOM)),
353 chunks[0],
354 );
355
356 let cs = &view.call_stats;
358 let p50 = cs
359 .p50_ms
360 .map(|v| format!("{v} ms"))
361 .unwrap_or_else(|| "—".into());
362 let p99 = cs
363 .p99_ms
364 .map(|v| format!("{v} ms"))
365 .unwrap_or_else(|| "—".into());
366 let err_color = if cs.error_rate_pct >= 5.0 {
367 t.fail
368 } else if cs.error_rate_pct >= 1.0 {
369 t.warn
370 } else {
371 t.pass
372 };
373 let stats_lines = vec![
374 Line::from(vec![Span::styled(
375 " CALL STATS",
376 Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
377 )]),
378 Line::from(vec![
379 Span::raw(" p50 latency "),
380 Span::styled(p50, Style::default().fg(t.pass)),
381 ]),
382 Line::from(vec![
383 Span::raw(" p99 latency "),
384 Span::styled(p99, Style::default().fg(t.warn)),
385 ]),
386 Line::from(vec![
387 Span::raw(" error rate "),
388 Span::styled(
389 format!("{:.2}%", cs.error_rate_pct),
390 Style::default().fg(err_color).add_modifier(Modifier::BOLD),
391 ),
392 ]),
393 Line::from(vec![
394 Span::raw(" sample size "),
395 Span::styled(
396 format!("{} call(s) (last {STATS_WINDOW})", cs.sample_size),
397 Style::default().fg(t.dim),
398 ),
399 ]),
400 ];
401 frame.render_widget(
402 Paragraph::new(stats_lines).block(Block::default().borders(Borders::BOTTOM)),
403 chunks[1],
404 );
405
406 let block_str = view
408 .chain
409 .block
410 .map(|b| b.to_string())
411 .unwrap_or_else(|| "—".into());
412 let tip_str = view
413 .chain
414 .chain_tip
415 .map(|b| b.to_string())
416 .unwrap_or_else(|| "—".into());
417 let delta_str = view
418 .chain
419 .delta
420 .map(|d| format!("{d:+}"))
421 .unwrap_or_else(|| "—".into());
422 let chain_lines = vec![
423 Line::from(vec![Span::styled(
424 " CHAIN STATE (Bee's view, not the wider network)",
425 Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
426 )]),
427 Line::from(vec![
428 Span::raw(" block "),
429 Span::styled(block_str, Style::default().fg(t.pass)),
430 Span::raw(" chain tip "),
431 Span::styled(tip_str, Style::default().fg(t.pass)),
432 Span::raw(" Δ "),
433 Span::styled(delta_str, Style::default().fg(t.warn)),
434 ]),
435 ];
436 frame.render_widget(
437 Paragraph::new(chain_lines).block(Block::default().borders(Borders::BOTTOM)),
438 chunks[2],
439 );
440
441 let mut pending_lines = vec![Line::from(Span::styled(
443 format!(" PENDING TRANSACTIONS ({})", view.pending.len()),
444 Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
445 ))];
446 if view.pending.is_empty() {
447 pending_lines.push(Line::from(Span::styled(
448 " (no pending operator transactions — all confirmed)",
449 Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
450 )));
451 } else {
452 pending_lines.push(Line::from(Span::styled(
453 " NONCE HASH TO AGE DESCRIPTION",
454 Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
455 )));
456 for r in &view.pending {
457 let age_str = format_age_humanised(r.age_seconds);
458 let age_style = match r.age_seconds {
459 Some(s) if s >= PENDING_TX_FAIL_AGE_SECS => {
460 Style::default().fg(t.fail).add_modifier(Modifier::BOLD)
461 }
462 Some(s) if s >= PENDING_TX_WARN_AGE_SECS => Style::default().fg(t.warn),
463 _ => Style::default(),
464 };
465 pending_lines.push(Line::from(vec![
466 Span::raw(" "),
467 Span::raw(format!("{:<6} ", r.nonce)),
468 Span::styled(
469 format!("{:<14} ", r.hash_short),
470 Style::default().fg(t.info),
471 ),
472 Span::raw(format!("{:<15} ", r.to_short)),
473 Span::styled(format!("{age_str:<10} "), age_style),
474 Span::styled(truncate(&r.description, 30), Style::default().fg(t.dim)),
475 ]));
476 pending_lines.push(Line::from(vec![
480 Span::styled(" hash 0x", Style::default().fg(t.dim)),
481 Span::styled(r.hash_full.clone(), Style::default().fg(t.info)),
482 Span::styled(" to 0x", Style::default().fg(t.dim)),
483 Span::styled(r.to_full.clone(), Style::default().fg(t.info)),
484 ]));
485 }
486 pending_lines.push(Line::from(Span::styled(
489 format!(
490 " └─ age >= {}m colours warn; >= {}m colours fail (likely under-priced gas)",
491 PENDING_TX_WARN_AGE_SECS / 60,
492 PENDING_TX_FAIL_AGE_SECS / 60
493 ),
494 Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
495 )));
496 }
497 frame.render_widget(Paragraph::new(pending_lines), chunks[3]);
498
499 frame.render_widget(
501 Paragraph::new(Line::from(vec![
502 Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
503 Span::raw(" switch screen "),
504 Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
505 Span::raw(" help "),
506 Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
507 Span::raw(" quit "),
508 Span::styled(
509 "stats live-update from S10's command-log capture",
510 Style::default().fg(t.dim),
511 ),
512 ])),
513 chunks[4],
514 );
515
516 Ok(())
517 }
518}
519
520fn truncate(s: &str, max: usize) -> String {
521 if s.chars().count() <= max {
522 s.to_string()
523 } else {
524 let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
525 out.push('…');
526 out
527 }
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533
534 fn entry(method: &str, status: Option<u16>, elapsed_ms: Option<u64>) -> LogEntry {
535 LogEntry {
536 ts: String::new(),
537 method: method.into(),
538 url: "http://localhost:1633/".into(),
539 status,
540 elapsed_ms,
541 message: String::new(),
542 }
543 }
544
545 #[test]
546 fn parse_rfc3339_z_form() {
547 let ts = parse_rfc3339_to_unix("2026-05-07T08:12:03Z").expect("must parse");
549 assert!(ts > 1_700_000_000); }
551
552 #[test]
553 fn parse_rfc3339_offset_form() {
554 let ts = parse_rfc3339_to_unix("2026-05-07T08:12:03+00:00").expect("must parse");
555 assert!(ts > 1_700_000_000);
556 }
557
558 #[test]
559 fn parse_rfc3339_returns_none_on_garbage() {
560 assert_eq!(parse_rfc3339_to_unix(""), None);
561 assert_eq!(parse_rfc3339_to_unix("not a date"), None);
562 assert_eq!(parse_rfc3339_to_unix("2026"), None);
563 }
564
565 #[test]
566 fn format_age_humanised_seconds() {
567 assert_eq!(format_age_humanised(Some(0)), "0s");
568 assert_eq!(format_age_humanised(Some(45)), "45s");
569 assert_eq!(format_age_humanised(Some(59)), "59s");
570 }
571
572 #[test]
573 fn format_age_humanised_minutes() {
574 assert_eq!(format_age_humanised(Some(60)), "1m 0s");
575 assert_eq!(format_age_humanised(Some(125)), "2m 5s");
576 assert_eq!(format_age_humanised(Some(3_599)), "59m 59s");
577 }
578
579 #[test]
580 fn format_age_humanised_hours() {
581 assert_eq!(format_age_humanised(Some(3_600)), "1h 0m");
582 assert_eq!(format_age_humanised(Some(8 * 3_600 + 15 * 60)), "8h 15m");
583 }
584
585 #[test]
586 fn format_age_humanised_special_cases() {
587 assert_eq!(format_age_humanised(None), "—");
588 assert_eq!(format_age_humanised(Some(-3)), "now");
591 }
592
593 #[test]
594 fn call_stats_empty_sample() {
595 let stats = call_stats_for(&[]);
596 assert_eq!(stats.sample_size, 0);
597 assert_eq!(stats.p50_ms, None);
598 assert_eq!(stats.p99_ms, None);
599 assert_eq!(stats.error_rate_pct, 0.0);
600 }
601
602 #[test]
603 fn call_stats_all_successful() {
604 let entries: Vec<LogEntry> = (1..=100)
605 .map(|i| entry("GET", Some(200), Some(i)))
606 .collect();
607 let stats = call_stats_for(&entries);
608 assert_eq!(stats.sample_size, 100);
609 assert_eq!(stats.p50_ms, Some(50));
610 assert_eq!(stats.p99_ms, Some(99));
611 assert_eq!(stats.error_rate_pct, 0.0);
612 }
613
614 #[test]
615 fn call_stats_mixed_errors() {
616 let mut entries: Vec<LogEntry> = (1..=10)
617 .map(|i| entry("GET", Some(200), Some(i * 10)))
618 .collect();
619 entries.push(entry("POST", Some(500), Some(50)));
620 entries.push(entry("POST", Some(404), Some(15)));
621 let stats = call_stats_for(&entries);
622 assert!((stats.error_rate_pct - 16.666_666_666_666_668).abs() < 1e-9);
624 }
625
626 #[test]
627 fn percentile_single_element() {
628 assert_eq!(percentile(&[42], 50), Some(42));
629 assert_eq!(percentile(&[42], 99), Some(42));
630 }
631
632 #[test]
633 fn percentile_empty_returns_none() {
634 assert_eq!(percentile(&[], 50), None);
635 }
636
637 #[test]
638 fn short_hex_truncates_long_address() {
639 let s = short_hex("0xabcdef0123456789abcdef0123456789");
640 assert!(s.contains('…'));
641 assert!(s.starts_with("abcdef"));
642 }
643}