mockforge_tui/screens/
analytics.rs1use std::time::Instant;
4
5use crossterm::event::KeyEvent;
6use ratatui::{
7 layout::Rect,
8 style::Style,
9 text::{Line, Span},
10 widgets::{Block, Borders, Paragraph},
11 Frame,
12};
13use tokio::sync::mpsc;
14
15use crate::api::client::MockForgeClient;
16use crate::event::Event;
17use crate::screens::Screen;
18use crate::theme::Theme;
19
20const FETCH_INTERVAL: u64 = 10;
21
22pub struct AnalyticsScreen {
23 data: Option<serde_json::Value>,
24 error: Option<String>,
25 last_fetch: Option<Instant>,
26}
27
28impl AnalyticsScreen {
29 pub fn new() -> Self {
30 Self {
31 data: None,
32 error: None,
33 last_fetch: None,
34 }
35 }
36}
37
38impl Screen for AnalyticsScreen {
39 fn title(&self) -> &str {
40 "Analytics"
41 }
42
43 fn handle_key(&mut self, _key: KeyEvent) -> bool {
44 false
45 }
46
47 fn render(&self, frame: &mut Frame, area: Rect) {
48 let Some(ref data) = self.data else {
49 let loading = Paragraph::new("Loading analytics...").style(Theme::dim()).block(
50 Block::default()
51 .title(" Analytics ")
52 .borders(Borders::ALL)
53 .border_style(Theme::dim()),
54 );
55 frame.render_widget(loading, area);
56 return;
57 };
58
59 let block = Block::default()
60 .title(" Analytics Summary ")
61 .title_style(Theme::title())
62 .borders(Borders::ALL)
63 .border_style(Theme::dim())
64 .style(Theme::surface());
65
66 let total_requests = data
67 .get("request_rate")
68 .or_else(|| data.get("total_requests"))
69 .and_then(|v| v.as_f64())
70 .unwrap_or(0.0);
71 let unique_endpoints = data.get("unique_endpoints").and_then(|v| v.as_u64()).unwrap_or(0);
72 let error_rate = data
73 .get("error_rate_percent")
74 .or_else(|| data.get("error_rate"))
75 .and_then(|v| v.as_f64())
76 .unwrap_or(0.0);
77
78 let error_style = if error_rate > 0.05 {
79 Theme::error()
80 } else {
81 Theme::success()
82 };
83
84 let lines = vec![
85 Line::from(""),
86 Line::from(vec![
87 Span::styled(" Request Rate: ", Theme::dim()),
88 Span::styled(format!("{total_requests:.1}/s"), Style::default().fg(Theme::FG)),
89 ]),
90 Line::from(vec![
91 Span::styled(" Unique Endpoints: ", Theme::dim()),
92 Span::styled(unique_endpoints.to_string(), Style::default().fg(Theme::FG)),
93 ]),
94 Line::from(vec![
95 Span::styled(" Error Rate: ", Theme::dim()),
96 Span::styled(format!("{:.1}%", error_rate * 100.0), error_style),
97 ]),
98 ];
99
100 let paragraph = Paragraph::new(lines).block(block);
101 frame.render_widget(paragraph, area);
102 }
103
104 fn tick(&mut self, client: &MockForgeClient, tx: &mpsc::UnboundedSender<Event>) {
105 let should_fetch =
106 self.last_fetch.map_or(true, |t| t.elapsed().as_secs() >= FETCH_INTERVAL);
107 if !should_fetch {
108 return;
109 }
110 self.last_fetch = Some(Instant::now());
111
112 let client = client.clone();
113 let tx = tx.clone();
114 tokio::spawn(async move {
115 match client.get_analytics_summary().await {
116 Ok(data) => {
117 let json = serde_json::json!({
118 "request_rate": data.request_rate,
119 "unique_endpoints": data.unique_endpoints,
120 "error_rate_percent": data.error_rate_percent,
121 "p95_latency_ms": data.p95_latency_ms,
122 "active_connections": data.active_connections,
123 });
124 let payload = serde_json::to_string(&json).unwrap_or_default();
125 let _ = tx.send(Event::Data {
126 screen: "analytics",
127 payload,
128 });
129 }
130 Err(e) => {
131 let _ = tx.send(Event::ApiError {
132 screen: "analytics",
133 message: e.to_string(),
134 });
135 }
136 }
137 });
138 }
139
140 fn on_data(&mut self, payload: &str) {
141 match serde_json::from_str::<serde_json::Value>(payload) {
142 Ok(data) => {
143 self.data = Some(data);
144 self.error = None;
145 }
146 Err(e) => {
147 self.error = Some(format!("Parse error: {e}"));
148 }
149 }
150 }
151
152 fn on_error(&mut self, message: &str) {
153 self.error = Some(message.to_string());
154 }
155
156 fn error(&self) -> Option<&str> {
157 self.error.as_deref()
158 }
159
160 fn force_refresh(&mut self) {
161 self.last_fetch = None;
162 }
163
164 fn status_hint(&self) -> &str {
165 "r:refresh"
166 }
167}