Skip to main content

nntp_proxy/tui/
app.rs

1//! TUI application state and logic
2
3use crate::config::Server;
4use crate::metrics::{MetricsCollector, MetricsSnapshot};
5use crate::router::BackendSelector;
6use crate::tui::dashboard::{
7    BackendDisplay, BackendView, BufferPoolStats, DashboardMetrics, DashboardState,
8    DashboardUserStats, RemoteBackendView, RemoteDashboardState,
9};
10use crate::tui::log_capture::LogBuffer;
11use crate::tui::rate_estimator::{CumulativeCount, RateEstimate, RateEstimator, RatePerSecond};
12use crate::types::tui::{CommandsPerSecond, HistorySize, Throughput, Timestamp};
13use std::collections::{HashMap, HashSet, VecDeque};
14use std::sync::Arc;
15
16/// TUI view mode - controls what is displayed
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
18pub enum ViewMode {
19    /// Normal view - shows all panels
20    #[default]
21    Normal,
22    /// Log fullscreen - shows only title and logs (mostly fullscreen)
23    LogFullscreen,
24}
25
26/// Historical throughput data point (generic over traffic direction)
27#[allow(clippy::struct_field_names)]
28#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
29pub struct ThroughputPoint {
30    /// Smoothed bytes sent per second
31    sent_per_sec: Throughput,
32    /// Smoothed bytes received per second
33    received_per_sec: Throughput,
34    /// Smoothed commands processed per second (backend only)
35    commands_per_sec: Option<CommandsPerSecond>,
36    /// Raw interval bytes sent per second
37    raw_sent_per_sec: Option<Throughput>,
38    /// Raw interval bytes received per second
39    raw_received_per_sec: Option<Throughput>,
40    /// Raw interval commands processed per second (backend only)
41    raw_commands_per_sec: Option<CommandsPerSecond>,
42}
43
44impl ThroughputPoint {
45    /// Create a new backend throughput point
46    #[must_use]
47    pub const fn new_backend(
48        _timestamp: Timestamp,
49        sent_per_sec: Throughput,
50        received_per_sec: Throughput,
51        commands_per_sec: CommandsPerSecond,
52    ) -> Self {
53        Self {
54            sent_per_sec,
55            received_per_sec,
56            commands_per_sec: Some(commands_per_sec),
57            raw_sent_per_sec: Some(sent_per_sec),
58            raw_received_per_sec: Some(received_per_sec),
59            raw_commands_per_sec: Some(commands_per_sec),
60        }
61    }
62
63    /// Create a new backend throughput point from raw and smoothed estimates.
64    #[must_use]
65    #[allow(clippy::too_many_arguments)]
66    pub const fn new_backend_estimate(
67        _timestamp: Timestamp,
68        raw_sent_per_sec: Throughput,
69        sent_per_sec: Throughput,
70        raw_received_per_sec: Throughput,
71        received_per_sec: Throughput,
72        raw_commands_per_sec: CommandsPerSecond,
73        commands_per_sec: CommandsPerSecond,
74    ) -> Self {
75        Self {
76            sent_per_sec,
77            received_per_sec,
78            commands_per_sec: Some(commands_per_sec),
79            raw_sent_per_sec: Some(raw_sent_per_sec),
80            raw_received_per_sec: Some(raw_received_per_sec),
81            raw_commands_per_sec: Some(raw_commands_per_sec),
82        }
83    }
84
85    /// Create a new client throughput point
86    #[must_use]
87    pub const fn new_client(
88        _timestamp: Timestamp,
89        sent_per_sec: Throughput,
90        received_per_sec: Throughput,
91    ) -> Self {
92        Self {
93            sent_per_sec,
94            received_per_sec,
95            commands_per_sec: None,
96            raw_sent_per_sec: Some(sent_per_sec),
97            raw_received_per_sec: Some(received_per_sec),
98            raw_commands_per_sec: None,
99        }
100    }
101
102    /// Create a new client throughput point from raw and smoothed estimates.
103    #[must_use]
104    pub const fn new_client_estimate(
105        _timestamp: Timestamp,
106        raw_sent_per_sec: Throughput,
107        sent_per_sec: Throughput,
108        raw_received_per_sec: Throughput,
109        received_per_sec: Throughput,
110    ) -> Self {
111        Self {
112            sent_per_sec,
113            received_per_sec,
114            commands_per_sec: None,
115            raw_sent_per_sec: Some(raw_sent_per_sec),
116            raw_received_per_sec: Some(raw_received_per_sec),
117            raw_commands_per_sec: None,
118        }
119    }
120
121    /// Get sent bytes per second
122    #[must_use]
123    #[inline]
124    pub const fn sent_per_sec(&self) -> Throughput {
125        self.sent_per_sec
126    }
127
128    /// Get received bytes per second
129    #[must_use]
130    #[inline]
131    pub const fn received_per_sec(&self) -> Throughput {
132        self.received_per_sec
133    }
134
135    /// Get commands per second (if backend point)
136    #[must_use]
137    #[inline]
138    pub const fn commands_per_sec(&self) -> Option<CommandsPerSecond> {
139        self.commands_per_sec
140    }
141
142    /// Get raw sent bytes per second.
143    #[must_use]
144    #[inline]
145    pub fn raw_sent_per_sec(&self) -> Throughput {
146        self.raw_sent_per_sec.unwrap_or(self.sent_per_sec)
147    }
148
149    /// Get raw received bytes per second.
150    #[must_use]
151    #[inline]
152    pub fn raw_received_per_sec(&self) -> Throughput {
153        self.raw_received_per_sec.unwrap_or(self.received_per_sec)
154    }
155
156    /// Get raw commands per second (if backend point).
157    #[must_use]
158    #[inline]
159    pub fn raw_commands_per_sec(&self) -> Option<CommandsPerSecond> {
160        self.raw_commands_per_sec.or(self.commands_per_sec)
161    }
162}
163
164/// Circular buffer for throughput history
165#[derive(Debug, Clone)]
166struct ThroughputHistory {
167    points: VecDeque<ThroughputPoint>,
168    capacity: HistorySize,
169}
170
171impl ThroughputHistory {
172    /// Create a new history with the given capacity
173    #[must_use]
174    fn new(capacity: HistorySize) -> Self {
175        Self {
176            points: VecDeque::with_capacity(capacity.get()),
177            capacity,
178        }
179    }
180
181    /// Add a point, removing oldest if at capacity
182    fn push(&mut self, point: ThroughputPoint) {
183        if self.points.len() >= self.capacity.get() {
184            self.points.pop_front();
185        }
186        self.points.push_back(point);
187    }
188
189    /// Get all points
190    #[must_use]
191    const fn points(&self) -> &VecDeque<ThroughputPoint> {
192        &self.points
193    }
194
195    /// Get the latest point
196    #[must_use]
197    fn latest(&self) -> Option<&ThroughputPoint> {
198        self.points.back()
199    }
200}
201
202#[derive(Debug, Clone, Default)]
203struct BackendRateEstimators {
204    sent: RateEstimator,
205    received: RateEstimator,
206    commands: RateEstimator,
207}
208
209#[derive(Debug, Clone, Default)]
210struct UserRateEstimators {
211    sent: RateEstimator,
212    received: RateEstimator,
213}
214
215/// TUI application builder
216///
217/// Provides a fluent API for constructing `TuiApp` instances with optional configuration.
218/// This replaces the multiple constructor pattern (new, `with_log_buffer`, `with_history_size`)
219/// with a single, flexible builder.
220///
221/// # Examples
222///
223/// ```ignore
224/// use nntp_proxy::tui::TuiAppBuilder;
225///
226/// // Basic app
227/// let app = TuiAppBuilder::new(metrics, router, servers).build();
228///
229/// // With log buffer
230/// let app = TuiAppBuilder::new(metrics, router, servers)
231///     .with_log_buffer(log_buffer)
232///     .build();
233///
234/// // With custom history size
235/// let app = TuiAppBuilder::new(metrics, router, servers)
236///     .with_history_size(HistorySize::new(120))
237///     .build();
238/// ```
239pub struct TuiAppBuilder {
240    metrics: MetricsCollector,
241    router: Arc<BackendSelector>,
242    servers: Arc<[Server]>,
243    cache: Option<Arc<crate::cache::UnifiedCache>>,
244    buffer_pool: Option<crate::pool::BufferPool>,
245    log_buffer: Option<LogBuffer>,
246    history_size: HistorySize,
247}
248
249impl TuiAppBuilder {
250    /// Create a new TUI app builder
251    #[must_use]
252    pub const fn new(
253        metrics: MetricsCollector,
254        router: Arc<BackendSelector>,
255        servers: Arc<[Server]>,
256    ) -> Self {
257        Self {
258            metrics,
259            router,
260            servers,
261            cache: None,
262            buffer_pool: None,
263            log_buffer: None,
264            history_size: HistorySize::DEFAULT,
265        }
266    }
267
268    /// Set the article cache for monitoring cache statistics
269    #[must_use]
270    pub fn with_cache(mut self, cache: Arc<crate::cache::UnifiedCache>) -> Self {
271        self.cache = Some(cache);
272        self
273    }
274
275    /// Set the buffer pool for monitoring I/O buffer statistics
276    #[must_use]
277    pub fn with_buffer_pool(mut self, buffer_pool: crate::pool::BufferPool) -> Self {
278        self.buffer_pool = Some(buffer_pool);
279        self
280    }
281
282    /// Set the log buffer for displaying recent log messages
283    #[must_use]
284    pub fn with_log_buffer(mut self, log_buffer: LogBuffer) -> Self {
285        self.log_buffer = Some(log_buffer);
286        self
287    }
288
289    /// Set custom history size (default is 60 points)
290    #[must_use]
291    pub const fn with_history_size(mut self, history_size: HistorySize) -> Self {
292        self.history_size = history_size;
293        self
294    }
295
296    /// Build the `TuiApp`
297    #[must_use]
298    pub fn build(self) -> TuiApp {
299        use crate::tui::SystemMonitor;
300
301        let snapshot = Arc::new(self.metrics.snapshot(self.cache.as_deref()));
302        let backend_count = self.servers.len();
303
304        // Initialize empty history for each backend
305        let backend_history = (0..backend_count)
306            .map(|_| ThroughputHistory::new(self.history_size))
307            .collect();
308        let backend_rate_estimators = (0..backend_count)
309            .map(|_| BackendRateEstimators::default())
310            .collect();
311
312        TuiApp {
313            metrics: self.metrics,
314            router: self.router,
315            servers: self.servers,
316            cache: self.cache,
317            buffer_pool: self.buffer_pool,
318            snapshot,
319            backend_history,
320            backend_rate_estimators,
321            client_history: ThroughputHistory::new(self.history_size),
322            client_sent_estimator: RateEstimator::default(),
323            client_received_estimator: RateEstimator::default(),
324            user_rate_estimators: HashMap::new(),
325            previous_snapshot: None,
326            last_update: Timestamp::now(),
327            log_buffer: Arc::new(self.log_buffer.unwrap_or_default()),
328            view_mode: ViewMode::default(),
329            show_details: false,
330            system_monitor: SystemMonitor::new(),
331            system_stats: crate::tui::SystemStats::default(),
332        }
333    }
334}
335
336/// TUI application state
337pub struct TuiApp {
338    /// Metrics collector (shared with proxy)
339    metrics: MetricsCollector,
340    /// Router for getting pending command counts
341    router: Arc<BackendSelector>,
342    /// Server configurations for display names
343    servers: Arc<[Server]>,
344    /// Current metrics snapshot (Arc for zero-cost sharing)
345    snapshot: Arc<MetricsSnapshot>,
346    /// Historical throughput data per backend
347    backend_history: Vec<ThroughputHistory>,
348    /// Per-backend rate estimators
349    backend_rate_estimators: Vec<BackendRateEstimators>,
350    /// Historical client throughput (global)
351    client_history: ThroughputHistory,
352    /// Global client-to-backend rate estimator
353    client_sent_estimator: RateEstimator,
354    /// Global backend-to-client rate estimator
355    client_received_estimator: RateEstimator,
356    /// Per-user byte rate estimators keyed by username
357    user_rate_estimators: HashMap<String, UserRateEstimators>,
358    /// Previous snapshot for calculating deltas (Arc for zero-cost sharing)
359    previous_snapshot: Option<Arc<MetricsSnapshot>>,
360    /// Last update time
361    last_update: Timestamp,
362    /// Log buffer for displaying recent log messages
363    log_buffer: Arc<LogBuffer>,
364    /// Current view mode (normal or log fullscreen)
365    view_mode: ViewMode,
366    /// Show detailed metrics (timing breakdown, etc.)
367    show_details: bool,
368    /// System resource monitor
369    system_monitor: crate::tui::SystemMonitor,
370    /// Current system stats (CPU and memory)
371    system_stats: crate::tui::SystemStats,
372    /// Article cache (optional - only present in caching mode)
373    cache: Option<Arc<crate::cache::UnifiedCache>>,
374    /// Buffer pool for I/O operations (optional - for monitoring buffer stats)
375    buffer_pool: Option<crate::pool::BufferPool>,
376}
377
378impl TuiApp {
379    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // TUI smoothed rates are non-negative display values.
380    const fn rate_as_u64(rate: RatePerSecond) -> u64 {
381        rate.get() as u64
382    }
383
384    /// Create a new TUI application
385    ///
386    /// **Note:** Prefer using `TuiAppBuilder` for more flexibility.
387    /// This method is a convenience wrapper.
388    #[must_use]
389    pub fn new(
390        metrics: MetricsCollector,
391        router: Arc<BackendSelector>,
392        servers: Arc<[Server]>,
393    ) -> Self {
394        TuiAppBuilder::new(metrics, router, servers).build()
395    }
396
397    fn estimate_or_decay(
398        estimator: &mut RateEstimator,
399        total: u64,
400        now: Timestamp,
401    ) -> RateEstimate {
402        estimator
403            .record(CumulativeCount::new(total), now)
404            .unwrap_or_else(|| RateEstimate::new(RatePerSecond::zero(), estimator.rate_at(now)))
405    }
406
407    fn throughput_estimate(
408        estimator: &mut RateEstimator,
409        total: u64,
410        now: Timestamp,
411    ) -> (Throughput, Throughput) {
412        let estimate = Self::estimate_or_decay(estimator, total, now);
413        (
414            Throughput::new(estimate.raw().get()),
415            Throughput::new(estimate.smoothed().get()),
416        )
417    }
418
419    fn command_estimate(
420        estimator: &mut RateEstimator,
421        total: u64,
422        now: Timestamp,
423    ) -> (CommandsPerSecond, CommandsPerSecond) {
424        let estimate = Self::estimate_or_decay(estimator, total, now);
425        (
426            CommandsPerSecond::new(estimate.raw().get()),
427            CommandsPerSecond::new(estimate.smoothed().get()),
428        )
429    }
430
431    fn build_backend_estimated_throughput_point(
432        now: Timestamp,
433        estimators: &mut BackendRateEstimators,
434        stats: &crate::metrics::BackendStats,
435    ) -> ThroughputPoint {
436        let (raw_sent, sent) =
437            Self::throughput_estimate(&mut estimators.sent, stats.bytes_sent.as_u64(), now);
438        let (raw_received, received) =
439            Self::throughput_estimate(&mut estimators.received, stats.bytes_received.as_u64(), now);
440        let (raw_commands, commands) =
441            Self::command_estimate(&mut estimators.commands, stats.total_commands.get(), now);
442
443        ThroughputPoint::new_backend_estimate(
444            now,
445            raw_sent,
446            sent,
447            raw_received,
448            received,
449            raw_commands,
450            commands,
451        )
452    }
453
454    fn estimate_user_rates(
455        now: Timestamp,
456        estimators: &mut UserRateEstimators,
457        current: &crate::metrics::UserStats,
458    ) -> crate::metrics::UserStats {
459        use crate::types::BytesPerSecondRate;
460
461        let sent = Self::estimate_or_decay(&mut estimators.sent, current.bytes_sent.as_u64(), now);
462        let received = Self::estimate_or_decay(
463            &mut estimators.received,
464            current.bytes_received.as_u64(),
465            now,
466        );
467
468        crate::metrics::UserStats {
469            username: current.username.clone(),
470            active_connections: current.active_connections,
471            total_connections: current.total_connections,
472            bytes_sent: current.bytes_sent,
473            bytes_received: current.bytes_received,
474            total_commands: current.total_commands,
475            errors: current.errors,
476            bytes_sent_per_sec: BytesPerSecondRate::new(Self::rate_as_u64(sent.smoothed())),
477            bytes_received_per_sec: BytesPerSecondRate::new(Self::rate_as_u64(received.smoothed())),
478        }
479    }
480
481    fn empty_user_estimators_at(now: Timestamp) -> UserRateEstimators {
482        let mut estimators = UserRateEstimators::default();
483        estimators.sent.record(CumulativeCount::new(0), now);
484        estimators.received.record(CumulativeCount::new(0), now);
485        estimators
486    }
487
488    fn prune_user_estimators(&mut self, user_stats: &[crate::metrics::UserStats]) {
489        let active_users = user_stats
490            .iter()
491            .map(|stats| stats.username.as_str())
492            .collect::<HashSet<_>>();
493        self.user_rate_estimators
494            .retain(|username, _| active_users.contains(username.as_str()));
495    }
496
497    fn seed_rate_estimators(&mut self, snapshot: &crate::metrics::MetricsSnapshot, now: Timestamp) {
498        self.client_sent_estimator.record(
499            CumulativeCount::new(snapshot.client_to_backend_bytes.as_u64()),
500            now,
501        );
502        self.client_received_estimator.record(
503            CumulativeCount::new(snapshot.backend_to_client_bytes.as_u64()),
504            now,
505        );
506
507        self.backend_rate_estimators
508            .iter_mut()
509            .zip(snapshot.backend_stats.iter())
510            .for_each(|(estimators, stats)| {
511                estimators
512                    .sent
513                    .record(CumulativeCount::new(stats.bytes_sent.as_u64()), now);
514                estimators
515                    .received
516                    .record(CumulativeCount::new(stats.bytes_received.as_u64()), now);
517                estimators
518                    .commands
519                    .record(CumulativeCount::new(stats.total_commands.get()), now);
520            });
521
522        self.user_rate_estimators.clear();
523        snapshot.user_stats.iter().for_each(|stats| {
524            let mut estimators = UserRateEstimators::default();
525            estimators
526                .sent
527                .record(CumulativeCount::new(stats.bytes_sent.as_u64()), now);
528            estimators
529                .received
530                .record(CumulativeCount::new(stats.bytes_received.as_u64()), now);
531            self.user_rate_estimators
532                .insert(stats.username.clone(), estimators);
533        });
534    }
535
536    /// Update metrics snapshot and calculate throughput
537    pub fn update(&mut self) {
538        // Update system stats (CPU, memory)
539        self.system_stats = self.system_monitor.update();
540
541        let new_snapshot = Arc::new(
542            self.metrics
543                .snapshot(self.cache.as_deref())
544                .with_pool_status(&self.router),
545        );
546        let now = Timestamp::now();
547
548        if self.previous_snapshot.is_some() {
549            let (raw_client_sent_rate, client_sent_rate) = Self::throughput_estimate(
550                &mut self.client_sent_estimator,
551                new_snapshot.client_to_backend_bytes.as_u64(),
552                now,
553            );
554            let (raw_client_recv_rate, client_recv_rate) = Self::throughput_estimate(
555                &mut self.client_received_estimator,
556                new_snapshot.backend_to_client_bytes.as_u64(),
557                now,
558            );
559
560            let client_point = ThroughputPoint::new_client_estimate(
561                now,
562                raw_client_sent_rate,
563                client_sent_rate,
564                raw_client_recv_rate,
565                client_recv_rate,
566            );
567            self.client_history.push(client_point);
568
569            self.backend_history
570                .iter_mut()
571                .zip(self.backend_rate_estimators.iter_mut())
572                .zip(new_snapshot.backend_stats.iter())
573                .for_each(|((history, estimators), stats)| {
574                    history.push(Self::build_backend_estimated_throughput_point(
575                        now, estimators, stats,
576                    ));
577                });
578
579            self.prune_user_estimators(&new_snapshot.user_stats);
580            let user_stats = new_snapshot
581                .user_stats
582                .iter()
583                .map(|stats| {
584                    let last_update = self.last_update;
585                    let estimators = self
586                        .user_rate_estimators
587                        .entry(stats.username.clone())
588                        .or_insert_with(|| Self::empty_user_estimators_at(last_update));
589                    Self::estimate_user_rates(now, estimators, stats)
590                })
591                .collect();
592
593            // Build enriched snapshot (shares backend_stats via Arc)
594            self.snapshot = Arc::new(crate::metrics::MetricsSnapshot {
595                user_stats,
596                backend_stats: Arc::clone(&new_snapshot.backend_stats),
597                ..*new_snapshot
598            });
599            self.previous_snapshot = Some(new_snapshot);
600        } else {
601            self.seed_rate_estimators(&new_snapshot, now);
602            // First update - no previous snapshot
603            self.previous_snapshot = Some(Arc::clone(&new_snapshot));
604            self.snapshot = new_snapshot;
605        }
606
607        self.last_update = now;
608    }
609
610    /// Get current metrics snapshot
611    #[must_use]
612    pub fn snapshot(&self) -> &MetricsSnapshot {
613        &self.snapshot
614    }
615
616    /// Get server configurations
617    #[must_use]
618    pub fn servers(&self) -> &[Server] {
619        &self.servers
620    }
621
622    /// Get client throughput history (global)
623    #[must_use]
624    pub const fn client_throughput_history(&self) -> &VecDeque<ThroughputPoint> {
625        self.client_history.points()
626    }
627
628    /// Get latest client throughput
629    #[must_use]
630    pub fn latest_client_throughput(&self) -> Option<&ThroughputPoint> {
631        self.client_history.latest()
632    }
633
634    /// Get pending command count for a backend
635    #[must_use]
636    pub fn backend_pending_count(&self, backend_idx: usize) -> usize {
637        use crate::types::BackendId;
638        self.router
639            .backend_load(BackendId::from_index(backend_idx))
640            .map_or(0, |pending| pending.get())
641    }
642
643    /// Get load ratio for a backend (pending / `max_connections`)
644    #[must_use]
645    pub fn backend_load_ratio(&self, backend_idx: usize) -> Option<f64> {
646        use crate::types::BackendId;
647        self.router
648            .backend_load_ratio(BackendId::from_index(backend_idx))
649            .map(|ratio| ratio.get())
650    }
651
652    /// Get stateful connection count for a backend
653    #[must_use]
654    pub fn backend_stateful_count(&self, backend_idx: usize) -> usize {
655        use crate::types::BackendId;
656        self.router
657            .stateful_count(BackendId::from_index(backend_idx))
658            .map_or(0, |count| count.get())
659    }
660
661    /// Get traffic share percentage for a backend
662    #[must_use]
663    pub fn backend_traffic_share(&self, backend_idx: usize) -> Option<f64> {
664        use crate::types::BackendId;
665        self.router
666            .backend_traffic_share(BackendId::from_index(backend_idx))
667            .map(|share| share.get())
668    }
669
670    /// Get throughput history for a backend
671    #[must_use]
672    ///
673    /// # Panics
674    /// Panics if `backend_idx` is out of range for the configured backend history.
675    pub fn throughput_history(&self, backend_idx: usize) -> &VecDeque<ThroughputPoint> {
676        self.backend_history(backend_idx)
677            .expect("backend history index should be valid")
678            .points()
679    }
680
681    /// Get latest backend throughput for a backend
682    #[must_use]
683    pub fn latest_backend_throughput(&self, backend_idx: usize) -> Option<&ThroughputPoint> {
684        self.backend_history(backend_idx)
685            .and_then(ThroughputHistory::latest)
686    }
687
688    /// Get log buffer for displaying recent log messages
689    #[must_use]
690    pub const fn log_buffer(&self) -> &Arc<LogBuffer> {
691        &self.log_buffer
692    }
693
694    /// Get current view mode
695    #[must_use]
696    pub const fn view_mode(&self) -> ViewMode {
697        self.view_mode
698    }
699
700    #[must_use]
701    fn backend_history(&self, backend_idx: usize) -> Option<&ThroughputHistory> {
702        self.backend_history.get(backend_idx)
703    }
704
705    /// Toggle between normal and log fullscreen view
706    pub const fn toggle_log_fullscreen(&mut self) {
707        self.view_mode = match self.view_mode {
708            ViewMode::Normal => ViewMode::LogFullscreen,
709            ViewMode::LogFullscreen => ViewMode::Normal,
710        };
711    }
712
713    /// Toggle detailed metrics display
714    pub const fn toggle_details(&mut self) {
715        self.show_details = !self.show_details;
716    }
717
718    /// Get current details display state
719    #[must_use]
720    pub const fn show_details(&self) -> bool {
721        self.show_details
722    }
723
724    /// Get current system stats (CPU and memory)
725    #[must_use]
726    pub const fn system_stats(&self) -> &crate::tui::SystemStats {
727        &self.system_stats
728    }
729
730    /// Get buffer pool (optional - for monitoring buffer stats)
731    #[must_use]
732    pub const fn buffer_pool(&self) -> Option<&crate::pool::BufferPool> {
733        self.buffer_pool.as_ref()
734    }
735
736    /// Build a serializable snapshot of the current dashboard state.
737    #[must_use]
738    pub fn snapshot_state(&self) -> DashboardState {
739        self.snapshot_state_with_limits(None, None)
740    }
741
742    /// Build a serializable snapshot of the current dashboard state with a bounded log tail.
743    #[must_use]
744    pub fn snapshot_state_with_log_limit(&self, log_limit: Option<usize>) -> DashboardState {
745        self.snapshot_state_with_limits(log_limit, None)
746    }
747
748    /// Build a serializable snapshot with bounded log and history tails.
749    #[must_use]
750    pub fn snapshot_state_with_limits(
751        &self,
752        log_limit: Option<usize>,
753        history_limit: Option<usize>,
754    ) -> DashboardState {
755        let buffer_pool = self.buffer_pool.as_ref().map(Self::snapshot_buffer_pool);
756        let mut metrics = DashboardMetrics::from_snapshot(self.snapshot.as_ref());
757        metrics.in_flight_requests = self
758            .servers
759            .iter()
760            .enumerate()
761            .map(|(idx, _)| self.backend_pending_count(idx))
762            .sum();
763        DashboardState {
764            metrics,
765            backend_views: self.snapshot_backend_views(history_limit),
766            top_users: self.snapshot_top_users(),
767            client_history: Self::snapshot_history_points(
768                self.client_history.points(),
769                history_limit,
770            ),
771            system_stats: self.system_stats.clone(),
772            view_mode: self.view_mode,
773            show_details: self.show_details,
774            log_lines: self.snapshot_log_lines(log_limit),
775            buffer_pool,
776        }
777    }
778
779    /// Build a serializable attached-dashboard snapshot with only remote-render fields.
780    #[must_use]
781    pub fn snapshot_remote_state_with_limits(
782        &self,
783        log_limit: Option<usize>,
784        history_limit: Option<usize>,
785        top_user_limit: Option<usize>,
786    ) -> RemoteDashboardState {
787        let mut metrics = DashboardMetrics::from_snapshot(self.snapshot.as_ref());
788        metrics.in_flight_requests = self
789            .servers
790            .iter()
791            .enumerate()
792            .map(|(idx, _)| self.backend_pending_count(idx))
793            .sum();
794        RemoteDashboardState {
795            metrics,
796            backend_views: self.snapshot_remote_backend_views(history_limit),
797            top_users: self.snapshot_top_users_with_limit(top_user_limit),
798            latest_client_throughput: self.client_history.latest().cloned(),
799            system_stats: self.system_stats.clone(),
800            log_lines: self.snapshot_log_lines(log_limit),
801        }
802    }
803
804    fn snapshot_log_lines(&self, log_limit: Option<usize>) -> Vec<String> {
805        match log_limit {
806            Some(limit) => self.log_buffer.recent_lines(limit),
807            None => self.log_buffer.all_lines(),
808        }
809    }
810
811    fn snapshot_backend_views(&self, history_limit: Option<usize>) -> Vec<BackendView> {
812        self.snapshot
813            .backend_stats
814            .iter()
815            .zip(self.servers.iter())
816            .enumerate()
817            .map(|(i, (stats, server))| self.snapshot_backend_view(i, server, stats, history_limit))
818            .collect()
819    }
820
821    fn snapshot_remote_backend_views(
822        &self,
823        history_limit: Option<usize>,
824    ) -> Vec<RemoteBackendView> {
825        self.snapshot
826            .backend_stats
827            .iter()
828            .zip(self.servers.iter())
829            .enumerate()
830            .map(|(i, (stats, server))| {
831                self.snapshot_remote_backend_view(i, server, stats, history_limit)
832            })
833            .collect()
834    }
835
836    fn snapshot_backend_view(
837        &self,
838        backend_idx: usize,
839        server: &Server,
840        stats: &crate::metrics::BackendStats,
841        history_limit: Option<usize>,
842    ) -> BackendView {
843        BackendView {
844            server: BackendDisplay {
845                host: server.host.clone(),
846                port: server.port,
847                name: server.name.clone(),
848                max_connections: server.max_connections,
849            },
850            stats: stats.clone(),
851            active_connections: stats.active_connections.get(),
852            health_status: stats.health_status,
853            pending_count: self.backend_pending_count(backend_idx),
854            load_ratio: self.backend_load_ratio(backend_idx),
855            stateful_count: self.backend_stateful_count(backend_idx),
856            traffic_share: self.backend_traffic_share(backend_idx),
857            history: Self::snapshot_throughput_history(
858                self.backend_history(backend_idx),
859                history_limit,
860            ),
861        }
862    }
863
864    fn snapshot_remote_backend_view(
865        &self,
866        backend_idx: usize,
867        server: &Server,
868        stats: &crate::metrics::BackendStats,
869        history_limit: Option<usize>,
870    ) -> RemoteBackendView {
871        RemoteBackendView {
872            server: BackendDisplay {
873                host: server.host.clone(),
874                port: server.port,
875                name: server.name.clone(),
876                max_connections: server.max_connections,
877            },
878            stats: stats.clone(),
879            active_connections: stats.active_connections.get(),
880            health_status: stats.health_status,
881            pending_count: self.backend_pending_count(backend_idx),
882            stateful_count: self.backend_stateful_count(backend_idx),
883            traffic_share: self.backend_traffic_share(backend_idx),
884            history: Self::snapshot_throughput_history(
885                self.backend_history(backend_idx),
886                history_limit,
887            ),
888        }
889    }
890
891    fn snapshot_throughput_history(
892        history: Option<&ThroughputHistory>,
893        history_limit: Option<usize>,
894    ) -> Vec<ThroughputPoint> {
895        history
896            .map(|history| Self::snapshot_history_points(history.points(), history_limit))
897            .unwrap_or_default()
898    }
899
900    fn snapshot_history_points(
901        points: &VecDeque<ThroughputPoint>,
902        history_limit: Option<usize>,
903    ) -> Vec<ThroughputPoint> {
904        let start = history_limit.map_or(0, |limit| points.len().saturating_sub(limit));
905        points.iter().skip(start).cloned().collect()
906    }
907
908    fn snapshot_buffer_pool(pool: &crate::pool::BufferPool) -> BufferPoolStats {
909        let (available, in_use, total) = pool.stats();
910        BufferPoolStats {
911            available,
912            in_use,
913            total,
914        }
915    }
916
917    fn snapshot_top_users(&self) -> Vec<DashboardUserStats> {
918        self.snapshot_top_users_with_limit(Some(10))
919    }
920
921    fn snapshot_top_users_with_limit(
922        &self,
923        top_user_limit: Option<usize>,
924    ) -> Vec<DashboardUserStats> {
925        let mut top_users = self
926            .snapshot
927            .user_stats
928            .iter()
929            .map(DashboardUserStats::from_user_stats)
930            .collect::<Vec<_>>();
931        top_users.sort_by_key(|user| std::cmp::Reverse(user.total_bytes()));
932        if let Some(limit) = top_user_limit {
933            top_users.truncate(limit);
934        }
935        top_users
936    }
937}
938
939#[cfg(test)]
940mod tests {
941    use super::*;
942    use crate::config::Server;
943    use crate::metrics::MetricsCollector;
944    use crate::router::BackendSelector;
945    use crate::types::Port;
946    use std::sync::Arc;
947    use std::time::Duration;
948
949    /// Helper to create test servers
950    fn create_test_servers(count: usize) -> Arc<[Server]> {
951        (0..count)
952            .map(|i| {
953                Server::builder(
954                    format!("backend{i}.example.com"),
955                    Port::try_new(119).unwrap(),
956                )
957                .name(format!("Backend {i}"))
958                .build()
959                .unwrap()
960            })
961            .collect::<Vec<_>>()
962            .into()
963    }
964
965    /// Helper to create test `TuiApp`
966    fn create_test_app(backend_count: usize) -> TuiApp {
967        let metrics = MetricsCollector::new(backend_count);
968        let router = Arc::new(BackendSelector::new());
969        let servers = create_test_servers(backend_count);
970        TuiApp::new(metrics, router, servers)
971    }
972
973    fn assert_f64_eq(actual: f64, expected: f64) {
974        assert_eq!(actual.to_bits(), expected.to_bits());
975    }
976
977    /// Test for the bug where `previous_snapshot` was set to self.snapshot instead of `new_snapshot`.
978    /// This caused the TUI to skip every other snapshot, calculating deltas over 2x the time period
979    /// and showing 2x the actual throughput.
980    ///
981    /// The bug: `previous_snapshot` = Some(self.snapshot.clone())  // OLD snapshot
982    /// The fix: `previous_snapshot` = `Some(new_snapshot.clone())`   // NEW snapshot (just used)
983    #[test]
984    fn test_previous_snapshot_uses_new_snapshot_not_old() {
985        let metrics = MetricsCollector::new(1);
986        let router = Arc::new(BackendSelector::new());
987        let servers: Arc<[Server]> = vec![
988            Server::builder("test.example.com", Port::try_new(119).unwrap())
989                .name("Test Server".to_string())
990                .build()
991                .unwrap(),
992        ]
993        .into();
994
995        let mut app = TuiApp::new(metrics.clone(), router, servers);
996
997        // Update 1: 1000 bytes total
998        metrics.record_backend_to_client_bytes_for(crate::types::BackendId::from_index(0), 1000);
999        app.update();
1000        assert_eq!(app.snapshot().backend_to_client_bytes.as_u64(), 1000);
1001        // After update 1, previous_snapshot should be snapshot from update 1 (1000)
1002        assert_eq!(
1003            app.previous_snapshot
1004                .as_ref()
1005                .unwrap()
1006                .backend_to_client_bytes
1007                .as_u64(),
1008            1000
1009        );
1010
1011        // Update 2: 2000 bytes total
1012        metrics.record_backend_to_client_bytes_for(crate::types::BackendId::from_index(0), 1000);
1013        app.update();
1014        assert_eq!(app.snapshot().backend_to_client_bytes.as_u64(), 2000);
1015        // After update 2, previous_snapshot should be snapshot from update 2 (2000)
1016        // BUG would have left it at update 1 (1000)
1017        assert_eq!(
1018            app.previous_snapshot
1019                .as_ref()
1020                .unwrap()
1021                .backend_to_client_bytes
1022                .as_u64(),
1023            2000,
1024            "previous_snapshot should be updated to the new_snapshot (2000), not left as old self.snapshot (1000)"
1025        );
1026
1027        // Update 3: 3000 bytes total
1028        metrics.record_backend_to_client_bytes_for(crate::types::BackendId::from_index(0), 1000);
1029        app.update();
1030        assert_eq!(app.snapshot().backend_to_client_bytes.as_u64(), 3000);
1031        // After update 3, previous_snapshot should be snapshot from update 3 (3000)
1032        // BUG would have it at update 2 (2000), causing next delta to be (4000-2000)=2000 instead of (4000-3000)=1000
1033        assert_eq!(
1034            app.previous_snapshot
1035                .as_ref()
1036                .unwrap()
1037                .backend_to_client_bytes
1038                .as_u64(),
1039            3000,
1040            "previous_snapshot should be 3000 (from update 3), not 2000 (from update 2) - bug would cause 2x deltas"
1041        );
1042    }
1043
1044    #[test]
1045    fn test_initial_state() {
1046        let app = create_test_app(2);
1047
1048        // Initial state checks
1049        assert_eq!(app.snapshot().active_connections, 0);
1050        assert_eq!(app.snapshot().total_connections, 0);
1051        assert_eq!(app.snapshot().backend_stats.len(), 2);
1052        assert!(app.previous_snapshot.is_none());
1053        assert!(app.latest_client_throughput().is_none());
1054    }
1055
1056    #[test]
1057    fn test_throughput_history_initialization() {
1058        let app = create_test_app(3);
1059
1060        // Should have empty histories for all backends
1061        assert_eq!(app.backend_history.len(), 3);
1062        for i in 0..3 {
1063            assert_eq!(app.throughput_history(i).len(), 0);
1064            assert!(app.latest_backend_throughput(i).is_none());
1065        }
1066
1067        // Client history should also be empty
1068        assert_eq!(app.client_throughput_history().len(), 0);
1069    }
1070
1071    #[test]
1072    fn test_first_update_establishes_baseline() {
1073        let metrics = MetricsCollector::new(1);
1074        let router = Arc::new(BackendSelector::new());
1075        let servers = create_test_servers(1);
1076        let mut app = TuiApp::new(metrics.clone(), router, servers);
1077
1078        // Simulate some traffic
1079        metrics.record_backend_to_client_bytes_for(crate::types::BackendId::from_index(0), 1000);
1080
1081        // First update
1082        app.update();
1083
1084        // Should have a previous snapshot now
1085        assert!(app.previous_snapshot.is_some());
1086
1087        // But no throughput points yet (need delta)
1088        assert!(app.latest_client_throughput().is_none());
1089        assert!(app.latest_backend_throughput(0).is_none());
1090    }
1091
1092    #[test]
1093    fn test_throughput_calculation_with_time_delta() {
1094        let metrics = MetricsCollector::new(1);
1095        let router = Arc::new(BackendSelector::new());
1096        let servers = create_test_servers(1);
1097        let mut app = TuiApp::new(metrics.clone(), router, servers);
1098
1099        // First update (baseline)
1100        app.update();
1101
1102        // Wait a bit and simulate traffic
1103        std::thread::sleep(Duration::from_millis(100));
1104        metrics.record_backend_to_client_bytes_for(crate::types::BackendId::from_index(0), 100_000);
1105
1106        // Second update
1107        app.update();
1108
1109        // Now should have throughput data
1110        assert!(app.latest_client_throughput().is_some());
1111        assert!(app.latest_backend_throughput(0).is_some());
1112
1113        let client_throughput = app.latest_client_throughput().unwrap();
1114        assert!(client_throughput.received_per_sec().get() > 0.0);
1115    }
1116
1117    #[test]
1118    fn test_history_buffer_circular() {
1119        let metrics = MetricsCollector::new(1);
1120        let router = Arc::new(BackendSelector::new());
1121        let servers = create_test_servers(1);
1122        let mut app = TuiAppBuilder::new(metrics.clone(), router, servers)
1123            .with_history_size(HistorySize::new(5)) // Small history for testing
1124            .build();
1125
1126        // First update (baseline)
1127        app.update();
1128
1129        // Add more updates than history capacity
1130        for i in 0..10 {
1131            std::thread::sleep(Duration::from_millis(10));
1132            metrics
1133                .record_backend_to_client_bytes_for(crate::types::BackendId::from_index(0), 1000);
1134            app.update();
1135
1136            // History should cap at 5
1137            let len = app.client_throughput_history().len();
1138            assert!(
1139                len <= 5,
1140                "History at iteration {i} should be <= 5, got {len}"
1141            );
1142        }
1143
1144        // Final check: history should be exactly at capacity
1145        assert_eq!(app.client_throughput_history().len(), 5);
1146    }
1147
1148    #[test]
1149    fn test_per_backend_throughput_independence() {
1150        let metrics = MetricsCollector::new(3);
1151        let router = Arc::new(BackendSelector::new());
1152        let servers = create_test_servers(3);
1153        let mut app = TuiApp::new(metrics.clone(), router, servers);
1154
1155        // Baseline
1156        app.update();
1157
1158        std::thread::sleep(Duration::from_millis(100));
1159
1160        // Different traffic per backend
1161        metrics
1162            .record_backend_to_client_bytes_for(crate::types::BackendId::from_index(0), 1_000_000);
1163        metrics
1164            .record_backend_to_client_bytes_for(crate::types::BackendId::from_index(1), 2_000_000);
1165        metrics
1166            .record_backend_to_client_bytes_for(crate::types::BackendId::from_index(2), 3_000_000);
1167
1168        app.update();
1169
1170        // Each backend should have different throughput
1171        let t0 = app
1172            .latest_backend_throughput(0)
1173            .unwrap()
1174            .received_per_sec()
1175            .get();
1176        let t1 = app
1177            .latest_backend_throughput(1)
1178            .unwrap()
1179            .received_per_sec()
1180            .get();
1181        let t2 = app
1182            .latest_backend_throughput(2)
1183            .unwrap()
1184            .received_per_sec()
1185            .get();
1186
1187        assert!(t0 < t1, "Backend 0 should have less than backend 1");
1188        assert!(t1 < t2, "Backend 1 should have less than backend 2");
1189    }
1190
1191    #[test]
1192    fn test_with_log_buffer() {
1193        use crate::tui::log_capture::LogBuffer;
1194
1195        let log_buffer = LogBuffer::new();
1196        log_buffer.push("Test log 1".to_string());
1197        log_buffer.push("Test log 2".to_string());
1198
1199        let metrics = MetricsCollector::new(1);
1200        let router = Arc::new(BackendSelector::new());
1201        let servers = create_test_servers(1);
1202
1203        let app = TuiAppBuilder::new(metrics, router, servers)
1204            .with_log_buffer(log_buffer)
1205            .build();
1206
1207        // Should have access to log buffer
1208        let logs = app.log_buffer().recent_lines(2);
1209        assert_eq!(logs.len(), 2);
1210        assert_eq!(logs[0], "Test log 1");
1211        assert_eq!(logs[1], "Test log 2");
1212    }
1213
1214    #[test]
1215    fn test_throughput_point_accessors() {
1216        let point = ThroughputPoint::new_backend(
1217            Timestamp::now(),
1218            Throughput::new(1000.0),
1219            Throughput::new(2000.0),
1220            CommandsPerSecond::new(50.0),
1221        );
1222
1223        assert_f64_eq(point.sent_per_sec().get(), 1000.0);
1224        assert_f64_eq(point.received_per_sec().get(), 2000.0);
1225        assert_f64_eq(point.commands_per_sec().unwrap().get(), 50.0);
1226
1227        let client_point = ThroughputPoint::new_client(
1228            Timestamp::now(),
1229            Throughput::new(500.0),
1230            Throughput::new(1500.0),
1231        );
1232
1233        assert_f64_eq(client_point.sent_per_sec().get(), 500.0);
1234        assert_f64_eq(client_point.received_per_sec().get(), 1500.0);
1235        assert!(client_point.commands_per_sec().is_none());
1236    }
1237
1238    #[test]
1239    fn test_throughput_history_latest() {
1240        let mut history = ThroughputHistory::new(HistorySize::new(10));
1241
1242        assert!(history.latest().is_none());
1243
1244        let point1 = ThroughputPoint::new_client(
1245            Timestamp::now(),
1246            Throughput::new(100.0),
1247            Throughput::new(200.0),
1248        );
1249        history.push(point1);
1250
1251        assert!(history.latest().is_some());
1252        assert_f64_eq(history.latest().unwrap().sent_per_sec().get(), 100.0);
1253
1254        let point2 = ThroughputPoint::new_client(
1255            Timestamp::now(),
1256            Throughput::new(300.0),
1257            Throughput::new(400.0),
1258        );
1259        history.push(point2);
1260
1261        // Latest should be point2
1262        assert_f64_eq(history.latest().unwrap().sent_per_sec().get(), 300.0);
1263    }
1264
1265    // Tests for TuiAppBuilder
1266    #[test]
1267    fn test_builder_basic() {
1268        let metrics = MetricsCollector::new(2);
1269        let router = Arc::new(BackendSelector::new());
1270        let servers = create_test_servers(2);
1271
1272        let app = TuiAppBuilder::new(metrics, router, servers).build();
1273
1274        assert_eq!(app.snapshot().backend_stats.len(), 2);
1275        assert_eq!(app.backend_history.len(), 2);
1276    }
1277
1278    #[test]
1279    fn test_builder_with_log_buffer() {
1280        use crate::tui::log_capture::LogBuffer;
1281
1282        let log_buffer = LogBuffer::new();
1283        log_buffer.push("Test log".to_string());
1284
1285        let metrics = MetricsCollector::new(1);
1286        let router = Arc::new(BackendSelector::new());
1287        let servers = create_test_servers(1);
1288
1289        let app = TuiAppBuilder::new(metrics, router, servers)
1290            .with_log_buffer(log_buffer)
1291            .build();
1292
1293        let logs = app.log_buffer().recent_lines(1);
1294        assert_eq!(logs.len(), 1);
1295        assert_eq!(logs[0], "Test log");
1296    }
1297
1298    #[test]
1299    fn test_builder_chaining() {
1300        use crate::tui::log_capture::LogBuffer;
1301
1302        let log_buffer = LogBuffer::new();
1303        let metrics = MetricsCollector::new(3);
1304        let router = Arc::new(BackendSelector::new());
1305        let servers = create_test_servers(3);
1306
1307        let app = TuiAppBuilder::new(metrics, router, servers)
1308            .with_log_buffer(log_buffer)
1309            .with_history_size(HistorySize::new(90))
1310            .build();
1311
1312        assert_eq!(app.backend_history.len(), 3);
1313    }
1314
1315    #[test]
1316    fn test_snapshot_state_serialization_round_trip() {
1317        use crate::tui::log_capture::LogBuffer;
1318
1319        let log_buffer = LogBuffer::new();
1320        log_buffer.push("Dashboard log line".to_string());
1321
1322        let metrics = MetricsCollector::new(1);
1323        let router = Arc::new(BackendSelector::new());
1324        let servers = Arc::from(vec![
1325            Server::builder("backend.example.com", Port::try_new(119).unwrap())
1326                .name("Backend")
1327                .username("backend-user")
1328                .password("backend-pass")
1329                .build()
1330                .unwrap(),
1331        ]);
1332
1333        let app = TuiAppBuilder::new(metrics, router, servers)
1334            .with_log_buffer(log_buffer)
1335            .build();
1336
1337        let snapshot = app.snapshot_state();
1338        assert_eq!(snapshot.backend_views.len(), 1);
1339        assert_eq!(snapshot.top_users.len(), 0);
1340        assert_eq!(snapshot.client_history.len(), 0);
1341        assert_eq!(snapshot.log_lines, vec!["Dashboard log line".to_string()]);
1342        assert_eq!(snapshot.view_mode, ViewMode::Normal);
1343        assert!(!snapshot.show_details);
1344
1345        let json = serde_json::to_string(&snapshot).expect("snapshot should serialize");
1346        assert!(!json.contains("backend-user"));
1347        assert!(!json.contains("backend-pass"));
1348        let decoded: DashboardState =
1349            serde_json::from_str(&json).expect("snapshot should deserialize");
1350
1351        assert_eq!(decoded.backend_views.len(), 1);
1352        assert_eq!(decoded.top_users.len(), 0);
1353        assert_eq!(decoded.log_lines, vec!["Dashboard log line".to_string()]);
1354        assert_eq!(decoded.view_mode, ViewMode::Normal);
1355        assert!(!decoded.show_details);
1356        assert!(decoded.buffer_pool.is_none());
1357        assert_eq!(decoded.backend_views[0].server.name.as_str(), "Backend");
1358    }
1359
1360    #[test]
1361    fn test_snapshot_state_with_log_limit_keeps_recent_tail() {
1362        use crate::tui::log_capture::LogBuffer;
1363
1364        let log_buffer = LogBuffer::new();
1365        for i in 0..5 {
1366            log_buffer.push(format!("Dashboard log line {i}"));
1367        }
1368
1369        let metrics = MetricsCollector::new(1);
1370        let router = Arc::new(BackendSelector::new());
1371        let servers = create_test_servers(1);
1372
1373        let app = TuiAppBuilder::new(metrics, router, servers)
1374            .with_log_buffer(log_buffer)
1375            .build();
1376
1377        let snapshot = app.snapshot_state_with_log_limit(Some(2));
1378        assert_eq!(
1379            snapshot.log_lines,
1380            vec![
1381                "Dashboard log line 3".to_string(),
1382                "Dashboard log line 4".to_string()
1383            ]
1384        );
1385    }
1386
1387    #[test]
1388    fn test_snapshot_state_collects_history_and_buffer_pool() {
1389        use crate::pool::BufferPool;
1390        use crate::tui::log_capture::LogBuffer;
1391        use crate::types::BufferSize;
1392
1393        let log_buffer = LogBuffer::new();
1394        let metrics = MetricsCollector::new(1);
1395        let router = Arc::new(BackendSelector::new());
1396        let servers = create_test_servers(1);
1397        let buffer_pool = BufferPool::new(BufferSize::try_new(8192).unwrap(), 4);
1398
1399        let mut app = TuiAppBuilder::new(metrics, router, servers)
1400            .with_log_buffer(log_buffer)
1401            .with_buffer_pool(buffer_pool)
1402            .build();
1403
1404        let client_point = ThroughputPoint::new_client(
1405            Timestamp::now(),
1406            Throughput::new(10.0),
1407            Throughput::new(20.0),
1408        );
1409        let backend_point = ThroughputPoint::new_backend(
1410            Timestamp::now(),
1411            Throughput::new(30.0),
1412            Throughput::new(40.0),
1413            CommandsPerSecond::new(5.0),
1414        );
1415
1416        app.client_history.push(client_point.clone());
1417        app.backend_history[0].push(backend_point.clone());
1418
1419        let snapshot = app.snapshot_state();
1420
1421        assert_eq!(snapshot.client_history.len(), 1);
1422        assert_f64_eq(
1423            snapshot.client_history[0].sent_per_sec().get(),
1424            client_point.sent_per_sec().get(),
1425        );
1426        assert_f64_eq(
1427            snapshot.client_history[0].received_per_sec().get(),
1428            client_point.received_per_sec().get(),
1429        );
1430        assert_eq!(snapshot.backend_views.len(), 1);
1431        assert_eq!(snapshot.backend_views[0].history.len(), 1);
1432        assert_eq!(snapshot.top_users.len(), 0);
1433        assert_f64_eq(
1434            snapshot.backend_views[0].history[0].sent_per_sec().get(),
1435            backend_point.sent_per_sec().get(),
1436        );
1437        assert_f64_eq(
1438            snapshot.backend_views[0].history[0]
1439                .received_per_sec()
1440                .get(),
1441            backend_point.received_per_sec().get(),
1442        );
1443        assert_f64_eq(
1444            snapshot.backend_views[0].history[0]
1445                .commands_per_sec()
1446                .unwrap()
1447                .get(),
1448            backend_point.commands_per_sec().unwrap().get(),
1449        );
1450        assert_eq!(
1451            snapshot.buffer_pool.as_ref().map(|pool| pool.total),
1452            Some(4)
1453        );
1454    }
1455
1456    #[test]
1457    fn test_snapshot_state_serializes_dashboard_metrics_and_top_users() {
1458        use crate::metrics::{CommandCount, DiskCacheStats, ErrorCount};
1459
1460        let metrics = MetricsCollector::new(1);
1461        let router = Arc::new(BackendSelector::new());
1462        let servers = create_test_servers(1);
1463        let mut app = TuiAppBuilder::new(metrics, router, servers).build();
1464
1465        app.snapshot = Arc::new(MetricsSnapshot {
1466            total_connections: 42,
1467            active_connections: 3,
1468            stateful_sessions: 2,
1469            client_to_backend_bytes: crate::types::ClientToBackendBytes::new(100),
1470            backend_to_client_bytes: crate::types::BackendToClientBytes::new(250),
1471            uptime: Duration::from_secs(61),
1472            user_stats: vec![crate::metrics::UserStats {
1473                username: "alice".to_string(),
1474                active_connections: 2,
1475                total_connections: crate::types::TotalConnections::new(9),
1476                bytes_sent: crate::types::BytesSent::new(500),
1477                bytes_received: crate::types::BytesReceived::new(700),
1478                bytes_sent_per_sec: crate::types::BytesPerSecondRate::new(11),
1479                bytes_received_per_sec: crate::types::BytesPerSecondRate::new(13),
1480                total_commands: CommandCount::new(17),
1481                errors: ErrorCount::new(1),
1482            }],
1483            cache_entries: 7,
1484            cache_size_bytes: 8192,
1485            cache_hit_rate: 12.5,
1486            disk_cache: Some(DiskCacheStats {
1487                disk_hits: 3,
1488                disk_hit_rate: 50.0,
1489                disk_capacity: 1024,
1490                bytes_written: 2048,
1491                bytes_read: 1024,
1492                write_ios: 4,
1493                read_ios: 5,
1494            }),
1495            pipeline_batches: 6,
1496            pipeline_commands: 12,
1497            pipeline_requests_queued: 8,
1498            pipeline_requests_completed: 7,
1499            ..MetricsSnapshot::default()
1500        });
1501
1502        let snapshot = app.snapshot_state();
1503        let json = serde_json::to_string(&snapshot).expect("snapshot should serialize");
1504        let decoded: DashboardState =
1505            serde_json::from_str(&json).expect("snapshot should deserialize");
1506
1507        assert_eq!(decoded.metrics.total_connections, 42);
1508        assert_eq!(decoded.metrics.active_connections, 3);
1509        assert_eq!(decoded.metrics.stateful_sessions, 2);
1510        assert_eq!(decoded.metrics.cache_entries, 7);
1511        assert_eq!(decoded.metrics.cache_size_bytes, 8192);
1512        assert_eq!(decoded.metrics.cache_hit_rate, 12.5);
1513        assert_eq!(decoded.metrics.pipeline_batches, 6);
1514        assert_eq!(decoded.metrics.pipeline_commands, 12);
1515        assert_eq!(decoded.top_users.len(), 1);
1516        assert_eq!(decoded.top_users[0].username, "alice");
1517        assert_eq!(decoded.top_users[0].active_connections, 2);
1518        assert_eq!(decoded.top_users[0].bytes_sent_per_sec.get(), 11);
1519        assert_eq!(decoded.top_users[0].bytes_received_per_sec.get(), 13);
1520    }
1521
1522    #[test]
1523    fn test_in_flight_requests_use_same_pending_count_as_backend_load() {
1524        let metrics = MetricsCollector::new(1);
1525        let mut router = BackendSelector::new();
1526        let provider = crate::pool::DeadpoolConnectionProvider::new(
1527            "backend.example.com".to_string(),
1528            119,
1529            "Backend".to_string(),
1530            10,
1531            None,
1532            None,
1533        );
1534        let backend_id = crate::types::BackendId::from_index(0);
1535        router.add_backend(
1536            crate::types::ServerName::try_new("Backend".to_string()).unwrap(),
1537            provider,
1538            0,
1539        );
1540        router.mark_backend_pending(backend_id);
1541        router.mark_backend_pending(backend_id);
1542        router.mark_backend_pending(backend_id);
1543        router.mark_backend_pending(backend_id);
1544        router.mark_backend_pending(backend_id);
1545
1546        let servers: Arc<[Server]> = vec![
1547            Server::builder("backend.example.com", Port::try_new(119).unwrap())
1548                .name("Backend")
1549                .build()
1550                .unwrap(),
1551        ]
1552        .into();
1553
1554        let app = TuiApp::new(metrics, Arc::new(router), servers);
1555        let snapshot = app.snapshot_state();
1556
1557        assert_eq!(snapshot.backend_views[0].pending_count, 5);
1558        assert_eq!(snapshot.metrics.in_flight_requests, 5);
1559    }
1560
1561    #[test]
1562    fn test_throughput_point_preserves_raw_and_smoothed_rates_for_dashboard_serde() {
1563        let point = ThroughputPoint::new_backend_estimate(
1564            Timestamp::now(),
1565            Throughput::new(10_000.0),
1566            Throughput::new(2_500.0),
1567            Throughput::new(20_000.0),
1568            Throughput::new(5_000.0),
1569            CommandsPerSecond::new(100.0),
1570            CommandsPerSecond::new(25.0),
1571        );
1572
1573        assert_f64_eq(point.raw_sent_per_sec().get(), 10_000.0);
1574        assert_f64_eq(point.sent_per_sec().get(), 2_500.0);
1575        assert_f64_eq(point.raw_received_per_sec().get(), 20_000.0);
1576        assert_f64_eq(point.received_per_sec().get(), 5_000.0);
1577        assert_f64_eq(point.raw_commands_per_sec().unwrap().get(), 100.0);
1578        assert_f64_eq(point.commands_per_sec().unwrap().get(), 25.0);
1579
1580        let json = serde_json::to_string(&point).expect("point should serialize");
1581        let decoded: ThroughputPoint =
1582            serde_json::from_str(&json).expect("point should deserialize");
1583        assert_f64_eq(decoded.raw_sent_per_sec().get(), 10_000.0);
1584        assert_f64_eq(decoded.sent_per_sec().get(), 2_500.0);
1585    }
1586
1587    #[test]
1588    fn test_tui_update_uses_smoothed_backend_and_user_rates() {
1589        let metrics = MetricsCollector::new(1);
1590        let router = Arc::new(BackendSelector::new());
1591        let servers = create_test_servers(1);
1592        let mut app = TuiApp::new(metrics.clone(), router, servers);
1593
1594        app.update();
1595
1596        std::thread::sleep(Duration::from_millis(20));
1597        metrics.record_backend_to_client_bytes_for(crate::types::BackendId::from_index(0), 1_000);
1598        metrics.user_bytes_received(Some("alice"), 1_000);
1599        app.update();
1600
1601        std::thread::sleep(Duration::from_millis(20));
1602        metrics.record_backend_to_client_bytes_for(crate::types::BackendId::from_index(0), 100_000);
1603        metrics.user_bytes_received(Some("alice"), 100_000);
1604        app.update();
1605
1606        let backend = app.latest_backend_throughput(0).unwrap();
1607        assert!(
1608            backend.received_per_sec().get() < backend.raw_received_per_sec().get(),
1609            "backend display rate should use smoothed value"
1610        );
1611
1612        let user = app
1613            .snapshot()
1614            .user_stats
1615            .iter()
1616            .find(|user| user.username == "alice")
1617            .expect("alice stats should exist");
1618        let raw_received = backend.raw_received_per_sec().get() as u64;
1619        assert!(
1620            user.bytes_received_per_sec.get() < raw_received,
1621            "user display rate should use smoothed value"
1622        );
1623    }
1624}