Skip to main content

muxtop_core/
network.rs

1use std::collections::VecDeque;
2use std::time::Instant;
3
4use bincode::{Decode, Encode};
5use serde::{Deserialize, Serialize};
6
7/// Per-interface network snapshot.
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Encode, Decode)]
9pub struct NetworkInterfaceSnapshot {
10    pub name: String,
11    pub bytes_rx: u64,
12    pub bytes_tx: u64,
13    pub packets_rx: u64,
14    pub packets_tx: u64,
15    pub errors_rx: u64,
16    pub errors_tx: u64,
17    pub mac_address: String,
18    /// Whether this interface has seen any traffic (cumulative rx or tx > 0).
19    /// Note: sysinfo 0.34 does not expose OS-level link state, so this is a
20    /// traffic-based heuristic — not a true up/down indicator.
21    pub is_up: bool,
22}
23
24/// Aggregated network snapshot across all interfaces.
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Encode, Decode)]
26pub struct NetworkSnapshot {
27    pub interfaces: Vec<NetworkInterfaceSnapshot>,
28    pub total_rx: u64,
29    pub total_tx: u64,
30}
31
32impl NetworkSnapshot {
33    /// Collect network snapshot from sysinfo Networks.
34    pub fn collect(networks: &sysinfo::Networks) -> Self {
35        let mut total_rx: u64 = 0;
36        let mut total_tx: u64 = 0;
37
38        let interfaces: Vec<NetworkInterfaceSnapshot> = networks
39            .iter()
40            .map(|(name, data)| {
41                let bytes_rx = data.total_received();
42                let bytes_tx = data.total_transmitted();
43                total_rx = total_rx.saturating_add(bytes_rx);
44                total_tx = total_tx.saturating_add(bytes_tx);
45
46                NetworkInterfaceSnapshot {
47                    name: name.clone(),
48                    bytes_rx,
49                    bytes_tx,
50                    packets_rx: data.total_packets_received(),
51                    packets_tx: data.total_packets_transmitted(),
52                    errors_rx: data.total_errors_on_received(),
53                    errors_tx: data.total_errors_on_transmitted(),
54                    mac_address: data.mac_address().to_string(),
55                    is_up: bytes_rx > 0 || bytes_tx > 0,
56                }
57            })
58            .collect();
59
60        Self {
61            interfaces,
62            total_rx,
63            total_tx,
64        }
65    }
66}
67
68/// Timestamped network snapshot for history tracking.
69#[derive(Debug, Clone)]
70struct TimestampedSnapshot {
71    snapshot: NetworkSnapshot,
72    timestamp: Instant,
73}
74
75/// Circular buffer storing network snapshots for bandwidth and sparkline calculations.
76///
77/// Bandwidth is computed as bytes/s using timestamps from consecutive snapshots.
78/// Sparkline values are byte deltas between consecutive samples (not normalized
79/// to time — suitable for fixed-interval display).
80#[derive(Debug, Clone)]
81pub struct NetworkHistory {
82    samples: VecDeque<TimestampedSnapshot>,
83    capacity: usize,
84}
85
86impl NetworkHistory {
87    /// Create a new history buffer with the given capacity.
88    /// Capacity is clamped to a minimum of 2 (needed for delta computation).
89    pub fn new(capacity: usize) -> Self {
90        let capacity = capacity.max(2);
91        Self {
92            samples: VecDeque::with_capacity(capacity),
93            capacity,
94        }
95    }
96
97    /// Push a new snapshot, evicting the oldest if at capacity.
98    pub fn push(&mut self, snapshot: NetworkSnapshot) {
99        if self.samples.len() >= self.capacity {
100            self.samples.pop_front();
101        }
102        self.samples.push_back(TimestampedSnapshot {
103            snapshot,
104            timestamp: Instant::now(),
105        });
106    }
107
108    /// Number of samples currently stored.
109    pub fn len(&self) -> usize {
110        self.samples.len()
111    }
112
113    /// Whether the buffer is empty.
114    pub fn is_empty(&self) -> bool {
115        self.samples.is_empty()
116    }
117
118    /// Compute RX bandwidth in bytes/s for a given interface over the last interval.
119    /// Returns 0.0 if fewer than 2 samples or interface not found.
120    pub fn bandwidth_rx(&self, iface: &str) -> f64 {
121        self.bandwidth(iface, |i| i.bytes_rx)
122    }
123
124    /// Compute TX bandwidth in bytes/s for a given interface over the last interval.
125    /// Returns 0.0 if fewer than 2 samples or interface not found.
126    pub fn bandwidth_tx(&self, iface: &str) -> f64 {
127        self.bandwidth(iface, |i| i.bytes_tx)
128    }
129
130    /// Return the last N RX bandwidth values for sparkline rendering.
131    /// Each value is the byte delta between consecutive samples.
132    pub fn sparkline_rx(&self, iface: &str, points: usize) -> Vec<u64> {
133        self.sparkline(iface, points, |i| i.bytes_rx)
134    }
135
136    /// Return the last N TX bandwidth values for sparkline rendering.
137    pub fn sparkline_tx(&self, iface: &str, points: usize) -> Vec<u64> {
138        self.sparkline(iface, points, |i| i.bytes_tx)
139    }
140
141    fn find_iface_value(
142        snapshot: &NetworkSnapshot,
143        iface: &str,
144        extract: &impl Fn(&NetworkInterfaceSnapshot) -> u64,
145    ) -> Option<u64> {
146        snapshot
147            .interfaces
148            .iter()
149            .find(|i| i.name == iface)
150            .map(extract)
151    }
152
153    fn bandwidth(&self, iface: &str, extract: impl Fn(&NetworkInterfaceSnapshot) -> u64) -> f64 {
154        if self.samples.len() < 2 {
155            return 0.0;
156        }
157        let prev = &self.samples[self.samples.len() - 2];
158        let curr = &self.samples[self.samples.len() - 1];
159
160        let prev_val = Self::find_iface_value(&prev.snapshot, iface, &extract).unwrap_or(0);
161        let curr_val = Self::find_iface_value(&curr.snapshot, iface, &extract).unwrap_or(0);
162
163        // Handle counter reset (interface bounce): treat negative delta as 0.
164        let delta = curr_val.saturating_sub(prev_val) as f64;
165        let elapsed = curr.timestamp.duration_since(prev.timestamp).as_secs_f64();
166
167        if elapsed > 0.0 { delta / elapsed } else { 0.0 }
168    }
169
170    fn sparkline(
171        &self,
172        iface: &str,
173        points: usize,
174        extract: impl Fn(&NetworkInterfaceSnapshot) -> u64,
175    ) -> Vec<u64> {
176        if self.samples.len() < 2 || points == 0 {
177            return Vec::new();
178        }
179
180        let n = points.min(self.samples.len() - 1);
181        let start = self.samples.len() - n - 1;
182        let mut result = Vec::with_capacity(n);
183
184        for i in start..self.samples.len() - 1 {
185            let prev_val =
186                Self::find_iface_value(&self.samples[i].snapshot, iface, &extract).unwrap_or(0);
187            let curr_val =
188                Self::find_iface_value(&self.samples[i + 1].snapshot, iface, &extract).unwrap_or(0);
189            result.push(curr_val.saturating_sub(prev_val));
190        }
191
192        result
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn test_network_types_send_clone() {
202        fn assert_send_clone<T: Send + Clone>() {}
203        assert_send_clone::<NetworkInterfaceSnapshot>();
204        assert_send_clone::<NetworkSnapshot>();
205        assert_send_clone::<NetworkHistory>();
206    }
207
208    #[test]
209    fn test_interface_snapshot_from_sysinfo() {
210        let networks = sysinfo::Networks::new_with_refreshed_list();
211        let snapshot = NetworkSnapshot::collect(&networks);
212        // On any real system there should be at least one interface (lo/lo0).
213        assert!(
214            !snapshot.interfaces.is_empty(),
215            "should have at least one network interface"
216        );
217        for iface in &snapshot.interfaces {
218            assert!(!iface.name.is_empty(), "interface name should not be empty");
219        }
220    }
221
222    #[test]
223    fn test_network_snapshot_totals_consistent() {
224        let networks = sysinfo::Networks::new_with_refreshed_list();
225        let snapshot = NetworkSnapshot::collect(&networks);
226
227        let sum_rx: u64 = snapshot.interfaces.iter().map(|i| i.bytes_rx).sum();
228        let sum_tx: u64 = snapshot.interfaces.iter().map(|i| i.bytes_tx).sum();
229        assert_eq!(
230            snapshot.total_rx, sum_rx,
231            "total_rx should equal sum of interface bytes_rx"
232        );
233        assert_eq!(
234            snapshot.total_tx, sum_tx,
235            "total_tx should equal sum of interface bytes_tx"
236        );
237    }
238
239    /// Helper to create a synthetic NetworkSnapshot with one interface.
240    fn make_snapshot(iface: &str, rx: u64, tx: u64) -> NetworkSnapshot {
241        NetworkSnapshot {
242            interfaces: vec![NetworkInterfaceSnapshot {
243                name: iface.into(),
244                bytes_rx: rx,
245                bytes_tx: tx,
246                packets_rx: 0,
247                packets_tx: 0,
248                errors_rx: 0,
249                errors_tx: 0,
250                mac_address: "00:00:00:00:00:00".into(),
251                is_up: rx > 0 || tx > 0,
252            }],
253            total_rx: rx,
254            total_tx: tx,
255        }
256    }
257
258    #[test]
259    fn test_history_empty() {
260        let history = NetworkHistory::new(60);
261        assert!(history.is_empty());
262        assert_eq!(history.len(), 0);
263        assert_eq!(history.bandwidth_rx("eth0"), 0.0);
264        assert_eq!(history.bandwidth_tx("eth0"), 0.0);
265        assert!(history.sparkline_rx("eth0", 30).is_empty());
266        assert!(history.sparkline_tx("eth0", 30).is_empty());
267    }
268
269    #[test]
270    fn test_history_single_snapshot() {
271        let mut history = NetworkHistory::new(60);
272        history.push(make_snapshot("eth0", 1000, 500));
273        assert_eq!(history.len(), 1);
274        assert_eq!(history.bandwidth_rx("eth0"), 0.0);
275        assert!(history.sparkline_rx("eth0", 30).is_empty());
276    }
277
278    #[test]
279    fn test_bandwidth_calculation() {
280        let mut history = NetworkHistory::new(60);
281        history.push(make_snapshot("eth0", 1000, 500));
282        // Sleep briefly so elapsed > 0 for bandwidth division.
283        std::thread::sleep(std::time::Duration::from_millis(10));
284        history.push(make_snapshot("eth0", 2000, 800));
285
286        let bw_rx = history.bandwidth_rx("eth0");
287        let bw_tx = history.bandwidth_tx("eth0");
288        // With ~10ms elapsed: 1000 bytes / 0.01s ≈ 100_000 bytes/s
289        // We just verify it's positive and in a plausible range.
290        assert!(bw_rx > 0.0, "bandwidth_rx should be positive, got {bw_rx}");
291        assert!(bw_tx > 0.0, "bandwidth_tx should be positive, got {bw_tx}");
292    }
293
294    #[test]
295    fn test_bandwidth_counter_reset() {
296        let mut history = NetworkHistory::new(60);
297        history.push(make_snapshot("eth0", 5000, 3000));
298        std::thread::sleep(std::time::Duration::from_millis(10));
299        // Counter reset: new value < old value
300        history.push(make_snapshot("eth0", 100, 50));
301
302        // saturating_sub handles this: 100 - 5000 = 0
303        assert_eq!(history.bandwidth_rx("eth0"), 0.0);
304        assert_eq!(history.bandwidth_tx("eth0"), 0.0);
305    }
306
307    #[test]
308    fn test_bandwidth_unknown_interface() {
309        let mut history = NetworkHistory::new(60);
310        history.push(make_snapshot("eth0", 1000, 500));
311        std::thread::sleep(std::time::Duration::from_millis(10));
312        history.push(make_snapshot("eth0", 2000, 800));
313
314        assert_eq!(history.bandwidth_rx("nonexistent"), 0.0);
315    }
316
317    #[test]
318    fn test_history_capacity_eviction() {
319        let mut history = NetworkHistory::new(60);
320        for i in 0..70 {
321            history.push(make_snapshot("eth0", i * 100, i * 50));
322        }
323        assert_eq!(history.len(), 60);
324    }
325
326    #[test]
327    fn test_history_capacity_minimum() {
328        // Capacity 0 should be clamped to 2.
329        let history = NetworkHistory::new(0);
330        assert_eq!(history.capacity, 2);
331
332        let history = NetworkHistory::new(1);
333        assert_eq!(history.capacity, 2);
334    }
335
336    #[test]
337    fn test_sparkline_data() {
338        let mut history = NetworkHistory::new(60);
339        // Push 5 snapshots: 0, 100, 300, 600, 1000
340        for &rx in &[0u64, 100, 300, 600, 1000] {
341            history.push(make_snapshot("eth0", rx, 0));
342        }
343
344        let spark = history.sparkline_rx("eth0", 10);
345        // 4 deltas from 5 samples: 100, 200, 300, 400
346        assert_eq!(spark, vec![100, 200, 300, 400]);
347    }
348
349    #[test]
350    fn test_sparkline_limited_points() {
351        let mut history = NetworkHistory::new(60);
352        for &rx in &[0u64, 100, 300, 600, 1000] {
353            history.push(make_snapshot("eth0", rx, 0));
354        }
355
356        let spark = history.sparkline_rx("eth0", 2);
357        // Last 2 deltas: 300, 400
358        assert_eq!(spark, vec![300, 400]);
359    }
360
361    #[test]
362    fn test_sparkline_zero_points() {
363        let mut history = NetworkHistory::new(60);
364        history.push(make_snapshot("eth0", 0, 0));
365        history.push(make_snapshot("eth0", 100, 50));
366
367        let spark = history.sparkline_rx("eth0", 0);
368        assert!(spark.is_empty());
369    }
370
371    #[test]
372    fn test_sparkline_unknown_interface() {
373        let mut history = NetworkHistory::new(60);
374        history.push(make_snapshot("eth0", 0, 0));
375        history.push(make_snapshot("eth0", 100, 50));
376
377        let spark = history.sparkline_rx("nonexistent", 10);
378        // Unknown interface values are 0, so deltas are 0
379        assert_eq!(spark, vec![0]);
380    }
381
382    #[test]
383    fn test_network_snapshot_empty_interfaces() {
384        let snapshot = NetworkSnapshot {
385            interfaces: vec![],
386            total_rx: 0,
387            total_tx: 0,
388        };
389        assert!(snapshot.interfaces.is_empty());
390        assert_eq!(snapshot.total_rx, 0);
391        assert_eq!(snapshot.total_tx, 0);
392    }
393
394    #[test]
395    fn test_multi_interface_snapshot() {
396        let snap = NetworkSnapshot {
397            interfaces: vec![
398                NetworkInterfaceSnapshot {
399                    name: "eth0".into(),
400                    bytes_rx: 1000,
401                    bytes_tx: 500,
402                    packets_rx: 10,
403                    packets_tx: 5,
404                    errors_rx: 0,
405                    errors_tx: 0,
406                    mac_address: "aa:bb:cc:dd:ee:f0".into(),
407                    is_up: true,
408                },
409                NetworkInterfaceSnapshot {
410                    name: "wlan0".into(),
411                    bytes_rx: 2000,
412                    bytes_tx: 1000,
413                    packets_rx: 20,
414                    packets_tx: 10,
415                    errors_rx: 1,
416                    errors_tx: 0,
417                    mac_address: "aa:bb:cc:dd:ee:f1".into(),
418                    is_up: true,
419                },
420                NetworkInterfaceSnapshot {
421                    name: "lo".into(),
422                    bytes_rx: 500,
423                    bytes_tx: 500,
424                    packets_rx: 5,
425                    packets_tx: 5,
426                    errors_rx: 0,
427                    errors_tx: 0,
428                    mac_address: "00:00:00:00:00:00".into(),
429                    is_up: true,
430                },
431            ],
432            total_rx: 3500,
433            total_tx: 2000,
434        };
435        assert_eq!(snap.interfaces.len(), 3);
436        let sum_rx: u64 = snap.interfaces.iter().map(|i| i.bytes_rx).sum();
437        let sum_tx: u64 = snap.interfaces.iter().map(|i| i.bytes_tx).sum();
438        assert_eq!(sum_rx, snap.total_rx);
439        assert_eq!(sum_tx, snap.total_tx);
440    }
441
442    fn make_multi_iface_snapshot(
443        eth0_rx: u64,
444        eth0_tx: u64,
445        wlan0_rx: u64,
446        wlan0_tx: u64,
447    ) -> NetworkSnapshot {
448        NetworkSnapshot {
449            interfaces: vec![
450                NetworkInterfaceSnapshot {
451                    name: "eth0".into(),
452                    bytes_rx: eth0_rx,
453                    bytes_tx: eth0_tx,
454                    packets_rx: 0,
455                    packets_tx: 0,
456                    errors_rx: 0,
457                    errors_tx: 0,
458                    mac_address: "00:00:00:00:00:00".into(),
459                    is_up: eth0_rx > 0 || eth0_tx > 0,
460                },
461                NetworkInterfaceSnapshot {
462                    name: "wlan0".into(),
463                    bytes_rx: wlan0_rx,
464                    bytes_tx: wlan0_tx,
465                    packets_rx: 0,
466                    packets_tx: 0,
467                    errors_rx: 0,
468                    errors_tx: 0,
469                    mac_address: "00:00:00:00:00:01".into(),
470                    is_up: wlan0_rx > 0 || wlan0_tx > 0,
471                },
472            ],
473            total_rx: eth0_rx + wlan0_rx,
474            total_tx: eth0_tx + wlan0_tx,
475        }
476    }
477
478    #[test]
479    fn test_history_multi_interface_bandwidth() {
480        let mut history = NetworkHistory::new(60);
481        history.push(make_multi_iface_snapshot(1000, 500, 2000, 1000));
482        std::thread::sleep(std::time::Duration::from_millis(10));
483        history.push(make_multi_iface_snapshot(2000, 800, 2500, 1200));
484
485        let eth0_bw = history.bandwidth_rx("eth0");
486        let wlan0_bw = history.bandwidth_rx("wlan0");
487
488        // eth0 delta = 1000, wlan0 delta = 500 in same time interval
489        // So eth0 should have higher bandwidth
490        assert!(eth0_bw > 0.0, "eth0 rx bandwidth should be positive");
491        assert!(wlan0_bw > 0.0, "wlan0 rx bandwidth should be positive");
492        assert!(
493            eth0_bw > wlan0_bw,
494            "eth0 (delta 1000) should have higher bandwidth than wlan0 (delta 500)"
495        );
496    }
497
498    #[test]
499    fn test_sparkline_tx_data() {
500        let mut history = NetworkHistory::new(60);
501        for &tx in &[0u64, 100, 300, 700, 1500] {
502            history.push(make_snapshot("eth0", 0, tx));
503        }
504        let spark = history.sparkline_tx("eth0", 10);
505        // 4 TX deltas: 100, 200, 400, 800
506        assert_eq!(spark, vec![100, 200, 400, 800]);
507    }
508
509    #[test]
510    fn test_history_multi_interface_sparkline() {
511        let mut history = NetworkHistory::new(60);
512        // Push 3 multi-iface snapshots
513        history.push(make_multi_iface_snapshot(0, 0, 0, 0));
514        history.push(make_multi_iface_snapshot(100, 50, 200, 100));
515        history.push(make_multi_iface_snapshot(300, 150, 500, 250));
516
517        let eth0_spark = history.sparkline_rx("eth0", 10);
518        let wlan0_spark = history.sparkline_rx("wlan0", 10);
519
520        assert_eq!(eth0_spark, vec![100, 200]); // deltas: 100, 200
521        assert_eq!(wlan0_spark, vec![200, 300]); // deltas: 200, 300
522    }
523
524    #[test]
525    fn test_interface_is_up_heuristic() {
526        let down = NetworkInterfaceSnapshot {
527            name: "eth1".into(),
528            bytes_rx: 0,
529            bytes_tx: 0,
530            packets_rx: 0,
531            packets_tx: 0,
532            errors_rx: 0,
533            errors_tx: 0,
534            mac_address: "00:00:00:00:00:00".into(),
535            is_up: false,
536        };
537        assert!(!down.is_up, "interface with zero traffic should be down");
538
539        let up = NetworkInterfaceSnapshot {
540            name: "eth0".into(),
541            bytes_rx: 100,
542            bytes_tx: 0,
543            packets_rx: 1,
544            packets_tx: 0,
545            errors_rx: 0,
546            errors_tx: 0,
547            mac_address: "00:00:00:00:00:00".into(),
548            is_up: true,
549        };
550        assert!(up.is_up, "interface with rx traffic should be up");
551    }
552
553    #[test]
554    fn test_all_structs_are_debug() {
555        let iface = NetworkInterfaceSnapshot {
556            name: "eth0".into(),
557            bytes_rx: 1000,
558            bytes_tx: 500,
559            packets_rx: 10,
560            packets_tx: 5,
561            errors_rx: 0,
562            errors_tx: 0,
563            mac_address: "00:00:00:00:00:00".into(),
564            is_up: true,
565        };
566        assert!(!format!("{iface:?}").is_empty());
567
568        let snap = NetworkSnapshot {
569            interfaces: vec![iface],
570            total_rx: 1000,
571            total_tx: 500,
572        };
573        assert!(!format!("{snap:?}").is_empty());
574    }
575}