Skip to main content

netscli_core/
stats.rs

1use serde::Serialize;
2use std::sync::Mutex;
3use std::time::{Duration, Instant};
4use sysinfo::Networks;
5
6#[derive(Debug, Clone, Serialize)]
7pub struct NetworkStats {
8    pub upload_mbps: f64,
9    pub download_mbps: f64,
10    pub upload_active: bool,
11    pub download_active: bool,
12    pub available: bool,
13}
14
15/// All mutable monitor state lives here so a single lock acquisition guarantees
16/// a consistent snapshot (no interleaved reads between rate samples).
17struct MonitorState {
18    networks: Networks,
19    selected_interface: Option<String>,
20    /// Last time `get_stats()` ran and refreshed `networks`.
21    last_update: Instant,
22    /// (rx, tx) at `last_update` — used for activity detection.
23    last_stats: (u64, u64),
24    /// Most recent computed (upload_mbps, download_mbps).
25    last_rates: (f64, f64),
26    /// Last time we saw new (upload, download) bytes; drives active-indicator hold.
27    last_active: (Instant, Instant),
28    /// When each rate was last sampled for smoothing.
29    last_rate_update: (Instant, Instant),
30    /// (rx, tx) at `last_rate_update` — basis for the next rate computation.
31    last_rate_stats: (u64, u64),
32}
33
34pub struct NetworkMonitor {
35    state: Mutex<MonitorState>,
36}
37
38impl NetworkMonitor {
39    const REFRESH_INTERVAL: Duration = Duration::from_millis(100);
40    const RATE_INTERVAL: Duration = Duration::from_millis(400);
41    const ACTIVE_HOLD: Duration = Duration::from_millis(200);
42
43    pub fn new() -> Self {
44        let mut networks = Networks::new_with_refreshed_list();
45        networks.refresh(true);
46
47        let (rx, tx, _) = sum_traffic(&networks, None);
48        let now = Instant::now();
49        let inactive = now - Self::ACTIVE_HOLD - Self::ACTIVE_HOLD;
50
51        Self {
52            state: Mutex::new(MonitorState {
53                networks,
54                selected_interface: None,
55                last_update: now,
56                last_stats: (rx, tx),
57                last_rates: (0.0, 0.0),
58                last_active: (inactive, inactive),
59                last_rate_update: (now, now),
60                last_rate_stats: (rx, tx),
61            }),
62        }
63    }
64
65    fn lock(&self) -> std::sync::MutexGuard<'_, MonitorState> {
66        self.state.lock().unwrap_or_else(|e| e.into_inner())
67    }
68
69    pub fn set_interface(&self, interface: Option<String>) {
70        let mut s = self.lock();
71        s.selected_interface = interface;
72        s.networks.refresh(true);
73        let (rx, tx, _) = sum_traffic(&s.networks, s.selected_interface.as_deref());
74
75        let now = Instant::now();
76        let inactive = now - Self::ACTIVE_HOLD - Self::ACTIVE_HOLD;
77
78        s.last_update = now;
79        s.last_stats = (rx, tx);
80        s.last_rates = (0.0, 0.0);
81        s.last_active = (inactive, inactive);
82        s.last_rate_update = (now, now);
83        s.last_rate_stats = (rx, tx);
84    }
85
86    pub fn selected_interface(&self) -> Option<String> {
87        self.lock().selected_interface.clone()
88    }
89
90    pub fn available_interfaces(&self) -> Vec<String> {
91        let mut s = self.lock();
92        s.networks.refresh(true);
93        let mut names: Vec<String> = s.networks.keys().map(|n| n.to_string()).collect();
94        names.sort_unstable();
95        names
96    }
97
98    pub fn get_stats(&self) -> NetworkStats {
99        let mut s = self.lock();
100        let now = Instant::now();
101        let elapsed = now.duration_since(s.last_update);
102
103        // Throttle refreshes — frequent callers (TUI ticks) get cached rates
104        // to avoid noisy rate spikes from tiny sampling intervals.
105        if elapsed < Self::REFRESH_INTERVAL {
106            let (upload_mbps, download_mbps) = s.last_rates;
107            return NetworkStats {
108                upload_mbps,
109                download_mbps,
110                upload_active: now.duration_since(s.last_active.0) < Self::ACTIVE_HOLD,
111                download_active: now.duration_since(s.last_active.1) < Self::ACTIVE_HOLD,
112                available: true,
113            };
114        }
115
116        s.networks.refresh(true);
117        let (current_rx, current_tx, available) =
118            sum_traffic(&s.networks, s.selected_interface.as_deref());
119        if !available {
120            return NetworkStats {
121                upload_mbps: 0.0,
122                download_mbps: 0.0,
123                upload_active: false,
124                download_active: false,
125                available: false,
126            };
127        }
128
129        // Activity detection — any byte since last refresh keeps the light on.
130        let (seen_rx, seen_tx) = s.last_stats;
131        if current_tx.saturating_sub(seen_tx) > 0 {
132            s.last_active.0 = now;
133        }
134        if current_rx.saturating_sub(seen_rx) > 0 {
135            s.last_active.1 = now;
136        }
137        s.last_stats = (current_rx, current_tx);
138
139        // Smoothed rates: recompute only every RATE_INTERVAL to avoid jitter
140        // from sub-100ms sampling windows.
141        let (mut upload_mbps, mut download_mbps) = s.last_rates;
142        let (mut last_upload, mut last_download) = s.last_rate_update;
143        let (mut rate_rx, mut rate_tx) = s.last_rate_stats;
144
145        if now.duration_since(last_upload) >= Self::RATE_INTERVAL {
146            upload_mbps = bytes_to_mbps(
147                current_tx.saturating_sub(rate_tx),
148                now.duration_since(last_upload),
149            );
150            rate_tx = current_tx;
151            last_upload = now;
152        }
153        if now.duration_since(last_download) >= Self::RATE_INTERVAL {
154            download_mbps = bytes_to_mbps(
155                current_rx.saturating_sub(rate_rx),
156                now.duration_since(last_download),
157            );
158            rate_rx = current_rx;
159            last_download = now;
160        }
161
162        s.last_update = now;
163        s.last_rate_update = (last_upload, last_download);
164        s.last_rate_stats = (rate_rx, rate_tx);
165        s.last_rates = (upload_mbps, download_mbps);
166
167        NetworkStats {
168            upload_mbps,
169            download_mbps,
170            upload_active: now.duration_since(s.last_active.0) < Self::ACTIVE_HOLD,
171            download_active: now.duration_since(s.last_active.1) < Self::ACTIVE_HOLD,
172            available: true,
173        }
174    }
175}
176
177impl Default for NetworkMonitor {
178    fn default() -> Self {
179        Self::new()
180    }
181}
182
183fn sum_traffic(networks: &Networks, selected: Option<&str>) -> (u64, u64, bool) {
184    if let Some(selected) = selected {
185        for (name, network) in networks {
186            if name == selected {
187                return (network.total_received(), network.total_transmitted(), true);
188            }
189        }
190        return (0, 0, false);
191    }
192
193    let mut rx = 0;
194    let mut tx = 0;
195    let mut any = false;
196    for (_, network) in networks {
197        any = true;
198        rx += network.total_received();
199        tx += network.total_transmitted();
200    }
201    (rx, tx, any)
202}
203
204/// Convert a byte delta observed over `elapsed` into megabits-per-second.
205///
206/// Returns 0.0 on a zero-duration window. Previously this clamped to
207/// 999.99 Mbps "to avoid display overflow" — that silently hides 1 GbE /
208/// 10 GbE link activity on reasonably modern hosts, so we trust the caller
209/// to format the number appropriately.
210fn bytes_to_mbps(bytes: u64, elapsed: Duration) -> f64 {
211    let secs = elapsed.as_secs_f64();
212    if secs <= 0.0 {
213        return 0.0;
214    }
215    (bytes as f64 * 8.0) / (1_000_000.0 * secs)
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn bytes_to_mbps_typical() {
224        // 1_000_000 bytes in 1 second = 8 Mbps
225        assert!((bytes_to_mbps(1_000_000, Duration::from_secs(1)) - 8.0).abs() < 1e-6);
226    }
227
228    #[test]
229    fn bytes_to_mbps_zero_elapsed_safe() {
230        assert_eq!(bytes_to_mbps(1_000_000, Duration::from_secs(0)), 0.0);
231    }
232
233    #[test]
234    fn bytes_to_mbps_reports_true_value_for_gbe() {
235        // 1.25 GB in 1s = 10 Gbps = 10_000 Mbps. Previously this was
236        // silently clamped to 999.99 which hid gigabit+ link activity.
237        let actual = bytes_to_mbps(1_250_000_000, Duration::from_secs(1));
238        assert!(
239            (actual - 10_000.0).abs() < 1.0,
240            "expected ~10_000 Mbps, got {actual}"
241        );
242    }
243
244    #[test]
245    fn new_monitor_reports_available_on_refresh() {
246        // Smoke test: constructing and calling get_stats should never panic
247        // and should report `available=true` if any network interface exists
248        // on the test host (all CI runners do).
249        let monitor = NetworkMonitor::new();
250        let stats = monitor.get_stats();
251        // Not asserting `available` since some sandboxed CI may lack interfaces;
252        // just ensuring the call path is sound.
253        assert!(stats.upload_mbps >= 0.0 && stats.download_mbps >= 0.0);
254    }
255
256    #[test]
257    fn selected_interface_round_trips() {
258        let monitor = NetworkMonitor::new();
259        assert_eq!(monitor.selected_interface(), None);
260        monitor.set_interface(Some("lo0".to_string()));
261        assert_eq!(monitor.selected_interface(), Some("lo0".to_string()));
262        monitor.set_interface(None);
263        assert_eq!(monitor.selected_interface(), None);
264    }
265}