Skip to main content

nntp_proxy/tui/
dashboard.rs

1//! Serializable dashboard state shared between local and attached TUI modes.
2
3use crate::metrics::{
4    BackendHealthStatus, BackendStats, CommandCount, DiskCacheStats, ErrorCount, MetricsSnapshot,
5    UserStats,
6};
7use crate::tui::app::{ThroughputPoint, ViewMode};
8use crate::tui::system_stats::SystemStats;
9use crate::types::{
10    BackendToClientBytes, BytesPerSecondRate, BytesReceived, BytesSent, ClientToBackendBytes,
11    HostName, MaxConnections, Port, ServerName, TotalConnections,
12};
13use std::time::Duration;
14
15/// Snapshot of the I/O buffer pool used by the summary panel.
16#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize)]
17pub struct BufferPoolStats {
18    pub available: usize,
19    pub in_use: usize,
20    pub total: usize,
21}
22
23/// A backend entry rendered in the backend list and chart panels.
24#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
25pub struct BackendDisplay {
26    pub host: HostName,
27    pub port: Port,
28    pub name: ServerName,
29    pub max_connections: MaxConnections,
30}
31
32/// A backend entry rendered in the backend list and chart panels.
33#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
34pub struct BackendView {
35    pub server: BackendDisplay,
36    pub stats: BackendStats,
37    pub active_connections: usize,
38    pub health_status: BackendHealthStatus,
39    pub pending_count: usize,
40    pub load_ratio: Option<f64>,
41    pub stateful_count: usize,
42    pub traffic_share: Option<f64>,
43    pub history: Vec<ThroughputPoint>,
44}
45
46impl BackendView {
47    #[must_use]
48    pub fn latest_throughput(&self) -> Option<&ThroughputPoint> {
49        self.history.last()
50    }
51}
52
53/// A backend entry sent to attached dashboard clients.
54#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
55pub struct RemoteBackendView {
56    pub server: BackendDisplay,
57    pub stats: BackendStats,
58    pub active_connections: usize,
59    pub health_status: BackendHealthStatus,
60    pub pending_count: usize,
61    pub stateful_count: usize,
62    pub traffic_share: Option<f64>,
63    pub history: Vec<ThroughputPoint>,
64}
65
66impl RemoteBackendView {
67    #[must_use]
68    pub fn latest_throughput(&self) -> Option<&ThroughputPoint> {
69        self.history.last()
70    }
71}
72
73/// Serialized user stats used by the dashboard payload.
74#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
75pub struct DashboardUserStats {
76    pub username: String,
77    pub active_connections: usize,
78    pub total_connections: TotalConnections,
79    pub bytes_sent: BytesSent,
80    pub bytes_received: BytesReceived,
81    pub bytes_sent_per_sec: BytesPerSecondRate,
82    pub bytes_received_per_sec: BytesPerSecondRate,
83    pub total_commands: CommandCount,
84    pub errors: ErrorCount,
85}
86
87impl DashboardUserStats {
88    #[must_use]
89    pub fn from_user_stats(user: &UserStats) -> Self {
90        Self {
91            username: user.username.clone(),
92            active_connections: user.active_connections,
93            total_connections: user.total_connections,
94            bytes_sent: user.bytes_sent,
95            bytes_received: user.bytes_received,
96            bytes_sent_per_sec: user.bytes_sent_per_sec,
97            bytes_received_per_sec: user.bytes_received_per_sec,
98            total_commands: user.total_commands,
99            errors: user.errors,
100        }
101    }
102
103    #[must_use]
104    pub const fn total_bytes(&self) -> u64 {
105        self.bytes_sent
106            .as_u64()
107            .saturating_add(self.bytes_received.as_u64())
108    }
109}
110
111/// Serialized metrics used by the dashboard payload.
112#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
113pub struct DashboardMetrics {
114    pub total_connections: u64,
115    pub active_connections: usize,
116    pub stateful_sessions: usize,
117    pub client_to_backend_bytes: ClientToBackendBytes,
118    pub backend_to_client_bytes: BackendToClientBytes,
119    pub uptime: Duration,
120    pub cache_entries: u64,
121    pub cache_size_bytes: u64,
122    pub cache_hit_rate: f64,
123    pub disk_cache: Option<DiskCacheStats>,
124    pub pipeline_batches: u64,
125    pub pipeline_commands: u64,
126    pub pipeline_requests_queued: u64,
127    pub pipeline_requests_completed: u64,
128    #[serde(default)]
129    pub in_flight_requests: usize,
130}
131
132impl DashboardMetrics {
133    #[must_use]
134    pub fn from_snapshot(snapshot: &MetricsSnapshot) -> Self {
135        Self {
136            total_connections: snapshot.total_connections,
137            active_connections: snapshot.active_connections,
138            stateful_sessions: snapshot.stateful_sessions,
139            client_to_backend_bytes: snapshot.client_to_backend_bytes,
140            backend_to_client_bytes: snapshot.backend_to_client_bytes,
141            uptime: snapshot.uptime,
142            cache_entries: snapshot.cache_entries,
143            cache_size_bytes: snapshot.cache_size_bytes,
144            cache_hit_rate: snapshot.cache_hit_rate,
145            disk_cache: snapshot.disk_cache,
146            pipeline_batches: snapshot.pipeline_batches,
147            pipeline_commands: snapshot.pipeline_commands,
148            pipeline_requests_queued: snapshot.pipeline_requests_queued,
149            pipeline_requests_completed: snapshot.pipeline_requests_completed,
150            in_flight_requests: 0,
151        }
152    }
153
154    #[must_use]
155    pub fn format_uptime(&self) -> String {
156        let secs = self.uptime.as_secs();
157        let hours = secs / 3600;
158        let minutes = (secs % 3600) / 60;
159        let seconds = secs % 60;
160
161        if hours > 0 {
162            format!("{hours}h {minutes}m {seconds}s")
163        } else if minutes > 0 {
164            format!("{minutes}m {seconds}s")
165        } else {
166            format!("{seconds}s")
167        }
168    }
169
170    #[must_use]
171    pub const fn total_bytes(&self) -> u64 {
172        self.client_to_backend_bytes.as_u64() + self.backend_to_client_bytes.as_u64()
173    }
174}
175
176/// Full dashboard state, suitable for rendering locally or over websocket.
177#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
178pub struct DashboardState {
179    pub metrics: DashboardMetrics,
180    pub backend_views: Vec<BackendView>,
181    pub top_users: Vec<DashboardUserStats>,
182    pub client_history: Vec<ThroughputPoint>,
183    pub system_stats: SystemStats,
184    pub view_mode: ViewMode,
185    pub show_details: bool,
186    pub log_lines: Vec<String>,
187    pub buffer_pool: Option<BufferPoolStats>,
188}
189
190/// Slim dashboard state sent to attached dashboard clients.
191#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
192pub struct RemoteDashboardState {
193    pub metrics: DashboardMetrics,
194    pub backend_views: Vec<RemoteBackendView>,
195    pub top_users: Vec<DashboardUserStats>,
196    pub latest_client_throughput: Option<ThroughputPoint>,
197    pub system_stats: SystemStats,
198    pub log_lines: Vec<String>,
199}
200
201impl DashboardState {
202    #[must_use]
203    fn backend_view(&self, backend_idx: usize) -> Option<&BackendView> {
204        self.backend_views.get(backend_idx)
205    }
206
207    #[must_use]
208    pub fn latest_client_throughput(&self) -> Option<&ThroughputPoint> {
209        self.client_history.last()
210    }
211
212    #[must_use]
213    pub fn latest_backend_throughput(&self, backend_idx: usize) -> Option<&ThroughputPoint> {
214        self.backend_view(backend_idx)
215            .and_then(BackendView::latest_throughput)
216    }
217
218    #[must_use]
219    pub fn throughput_history(&self, backend_idx: usize) -> Option<&[ThroughputPoint]> {
220        self.backend_view(backend_idx)
221            .map(|view| view.history.as_slice())
222    }
223
224    #[must_use]
225    pub fn backend_pending_count(&self, backend_idx: usize) -> usize {
226        self.backend_view(backend_idx)
227            .map_or(0, |view| view.pending_count)
228    }
229
230    #[must_use]
231    pub fn backend_load_ratio(&self, backend_idx: usize) -> Option<f64> {
232        self.backend_view(backend_idx)
233            .and_then(|view| view.load_ratio)
234    }
235
236    #[must_use]
237    pub fn backend_stateful_count(&self, backend_idx: usize) -> usize {
238        self.backend_view(backend_idx)
239            .map_or(0, |view| view.stateful_count)
240    }
241
242    #[must_use]
243    pub fn backend_traffic_share(&self, backend_idx: usize) -> Option<f64> {
244        self.backend_view(backend_idx)
245            .and_then(|view| view.traffic_share)
246    }
247
248    #[must_use]
249    pub fn buffer_pool(&self) -> Option<&BufferPoolStats> {
250        self.buffer_pool.as_ref()
251    }
252}
253
254impl RemoteDashboardState {
255    #[must_use]
256    fn backend_view(&self, backend_idx: usize) -> Option<&RemoteBackendView> {
257        self.backend_views.get(backend_idx)
258    }
259
260    #[must_use]
261    pub fn latest_client_throughput(&self) -> Option<&ThroughputPoint> {
262        self.latest_client_throughput.as_ref()
263    }
264
265    #[must_use]
266    pub fn backend_pending_count(&self, backend_idx: usize) -> usize {
267        self.backend_view(backend_idx)
268            .map_or(0, |view| view.pending_count)
269    }
270
271    #[must_use]
272    pub fn backend_stateful_count(&self, backend_idx: usize) -> usize {
273        self.backend_view(backend_idx)
274            .map_or(0, |view| view.stateful_count)
275    }
276
277    #[must_use]
278    pub fn backend_traffic_share(&self, backend_idx: usize) -> Option<f64> {
279        self.backend_view(backend_idx)
280            .and_then(|view| view.traffic_share)
281    }
282}
283
284impl From<BackendView> for RemoteBackendView {
285    fn from(view: BackendView) -> Self {
286        Self {
287            server: view.server,
288            stats: view.stats,
289            active_connections: view.active_connections,
290            health_status: view.health_status,
291            pending_count: view.pending_count,
292            stateful_count: view.stateful_count,
293            traffic_share: view.traffic_share,
294            history: view.history,
295        }
296    }
297}
298
299impl From<DashboardState> for RemoteDashboardState {
300    fn from(state: DashboardState) -> Self {
301        Self {
302            metrics: state.metrics,
303            backend_views: state
304                .backend_views
305                .into_iter()
306                .map(RemoteBackendView::from)
307                .collect(),
308            top_users: state.top_users,
309            latest_client_throughput: state.client_history.last().cloned(),
310            system_stats: state.system_stats,
311            log_lines: state.log_lines,
312        }
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use crate::metrics::{BackendHealthStatus, BackendStats};
320    use crate::tui::app::{ThroughputPoint, ViewMode};
321    use crate::types::Port;
322    use crate::types::tui::{Throughput, Timestamp};
323
324    fn sample_backend_view() -> BackendView {
325        BackendView {
326            server: BackendDisplay {
327                host: HostName::try_new("backend.example.com".to_string()).unwrap(),
328                port: Port::try_new(119).unwrap(),
329                name: ServerName::try_new("Backend".to_string()).unwrap(),
330                max_connections: MaxConnections::try_new(10).unwrap(),
331            },
332            stats: BackendStats::default(),
333            active_connections: 1,
334            health_status: BackendHealthStatus::Healthy,
335            pending_count: 2,
336            load_ratio: Some(0.5),
337            stateful_count: 3,
338            traffic_share: Some(42.0),
339            history: vec![ThroughputPoint::new_backend(
340                Timestamp::now(),
341                Throughput::new(1.0),
342                Throughput::new(2.0),
343                crate::types::tui::CommandsPerSecond::new(3.0),
344            )],
345        }
346    }
347
348    #[test]
349    fn backend_accessors_handle_out_of_range_indices() {
350        let state = DashboardState {
351            metrics: DashboardMetrics::default(),
352            backend_views: vec![sample_backend_view()],
353            top_users: Vec::new(),
354            client_history: Vec::new(),
355            system_stats: SystemStats::default(),
356            view_mode: ViewMode::Normal,
357            show_details: false,
358            log_lines: Vec::new(),
359            buffer_pool: None,
360        };
361
362        assert!(state.latest_backend_throughput(1).is_none());
363        assert!(state.throughput_history(1).is_none());
364        assert_eq!(state.backend_pending_count(1), 0);
365        assert!(state.backend_load_ratio(1).is_none());
366        assert_eq!(state.backend_stateful_count(1), 0);
367        assert!(state.backend_traffic_share(1).is_none());
368    }
369
370    #[test]
371    fn remote_dashboard_state_keeps_latest_client_point_and_drops_local_only_fields() {
372        let latest_client = ThroughputPoint::new_client(
373            Timestamp::now(),
374            Throughput::new(10.0),
375            Throughput::new(20.0),
376        );
377        let state = DashboardState {
378            metrics: DashboardMetrics::default(),
379            backend_views: vec![sample_backend_view()],
380            top_users: Vec::new(),
381            client_history: vec![
382                ThroughputPoint::new_client(
383                    Timestamp::now(),
384                    Throughput::new(1.0),
385                    Throughput::new(2.0),
386                ),
387                latest_client.clone(),
388            ],
389            system_stats: SystemStats::default(),
390            view_mode: ViewMode::LogFullscreen,
391            show_details: true,
392            log_lines: vec!["hello".to_string()],
393            buffer_pool: Some(BufferPoolStats {
394                available: 1,
395                in_use: 2,
396                total: 3,
397            }),
398        };
399
400        let remote = RemoteDashboardState::from(state);
401
402        assert_eq!(remote.backend_views.len(), 1);
403        assert_eq!(remote.log_lines, vec!["hello".to_string()]);
404        assert_eq!(
405            remote
406                .latest_client_throughput()
407                .map(|point| point.sent_per_sec().get()),
408            Some(latest_client.sent_per_sec().get())
409        );
410    }
411}