Skip to main content

mockforge_tui/screens/
analytics.rs

1//! Analytics summary screen — total requests, unique endpoints, error rate.
2
3use 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}