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 created: String,
90 pub description: String,
93}
94
95#[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 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
155pub 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 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
192fn percentile(sorted: &[u64], pct: u32) -> Option<u64> {
195 if sorted.is_empty() {
196 return None;
197 }
198 let n = sorted.len();
199 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), Constraint::Length(7), Constraint::Length(5), Constraint::Min(0), Constraint::Length(1), ])
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 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 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 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 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 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 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}