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::watch::{HealthSnapshot, TransactionsSnapshot};
44
45pub const STATS_WINDOW: usize = 100;
50
51#[derive(Debug, Clone, PartialEq)]
53pub struct CallStats {
54 pub sample_size: usize,
56 pub p50_ms: Option<u64>,
58 pub p99_ms: Option<u64>,
61 pub error_rate_pct: f64,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Default)]
68pub struct ChainStateView {
69 pub block: Option<u64>,
70 pub chain_tip: Option<u64>,
71 pub delta: Option<i64>,
76 pub total_amount: Option<String>,
77 pub current_price: Option<String>,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct PendingTxRow {
83 pub nonce: u64,
84 pub hash_short: String,
85 pub to_short: String,
86 pub created: String,
89 pub description: String,
92}
93
94#[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 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
154pub 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 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
191fn percentile(sorted: &[u64], pct: u32) -> Option<u64> {
194 if sorted.is_empty() {
195 return None;
196 }
197 let n = sorted.len();
198 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), Constraint::Length(7), Constraint::Length(5), Constraint::Min(0), Constraint::Length(1), ])
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 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 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 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 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 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 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}