Skip to main content

nexcore_network/
monitor.rs

1// Copyright (c) 2026 Matthew Campion, PharmD; NexVigilant
2// All Rights Reserved. See LICENSE file for details.
3
4//! Traffic monitoring — bandwidth, latency, and connection quality tracking.
5//!
6//! Tier: T2-C (N Quantity + ν Frequency + σ Sequence)
7//!
8//! Monitors are quantitative (N) measurements taken at regular frequency (ν)
9//! forming time-series sequences (σ). Used for:
10//! - Bandwidth metering (especially on cellular)
11//! - Quality-of-service decisions
12//! - Guardian security anomaly detection
13
14use crate::interface::InterfaceId;
15use nexcore_chrono::DateTime;
16use serde::{Deserialize, Serialize};
17
18/// Traffic counters for a single interface.
19///
20/// Tier: T2-P (N Quantity — byte counts)
21#[non_exhaustive]
22#[derive(Debug, Clone, Default, Serialize, Deserialize)]
23pub struct TrafficCounters {
24    /// Bytes sent.
25    pub bytes_sent: u64,
26    /// Bytes received.
27    pub bytes_received: u64,
28    /// Packets sent.
29    pub packets_sent: u64,
30    /// Packets received.
31    pub packets_received: u64,
32    /// Packets dropped (send failures).
33    pub packets_dropped: u64,
34    /// Errors encountered.
35    pub errors: u64,
36}
37
38impl TrafficCounters {
39    /// Total bytes (sent + received).
40    pub fn total_bytes(&self) -> u64 {
41        self.bytes_sent.saturating_add(self.bytes_received)
42    }
43
44    /// Total packets (sent + received).
45    pub fn total_packets(&self) -> u64 {
46        self.packets_sent.saturating_add(self.packets_received)
47    }
48
49    /// Packet loss rate (0.0 - 1.0).
50    pub fn packet_loss_rate(&self) -> f64 {
51        let total = self.total_packets().saturating_add(self.packets_dropped);
52        if total == 0 {
53            return 0.0;
54        }
55        // u64→f64: packet counters fit exactly in f64 mantissa for any realistic traffic volume
56        #[allow(
57            clippy::as_conversions,
58            reason = "u64 packet counts fit exactly in f64 mantissa for any realistic traffic volume"
59        )]
60        let dropped = self.packets_dropped as f64;
61        #[allow(
62            clippy::as_conversions,
63            reason = "u64 packet counts fit exactly in f64 mantissa for any realistic traffic volume"
64        )]
65        let total_f = total as f64;
66        dropped / total_f
67    }
68
69    /// Record bytes sent.
70    pub fn record_sent(&mut self, bytes: u64) {
71        self.bytes_sent = self.bytes_sent.saturating_add(bytes);
72        self.packets_sent = self.packets_sent.saturating_add(1);
73    }
74
75    /// Record bytes received.
76    pub fn record_received(&mut self, bytes: u64) {
77        self.bytes_received = self.bytes_received.saturating_add(bytes);
78        self.packets_received = self.packets_received.saturating_add(1);
79    }
80
81    /// Record a dropped packet.
82    pub fn record_dropped(&mut self) {
83        self.packets_dropped = self.packets_dropped.saturating_add(1);
84    }
85
86    /// Record an error.
87    pub fn record_error(&mut self) {
88        self.errors = self.errors.saturating_add(1);
89    }
90
91    /// Reset all counters.
92    pub fn reset(&mut self) {
93        *self = Self::default();
94    }
95
96    /// Human-readable byte count.
97    pub fn bytes_sent_human(&self) -> String {
98        format_bytes(self.bytes_sent)
99    }
100
101    /// Human-readable byte count.
102    pub fn bytes_received_human(&self) -> String {
103        format_bytes(self.bytes_received)
104    }
105}
106
107/// Format bytes into human-readable form.
108fn format_bytes(bytes: u64) -> String {
109    const KB: u64 = 1024;
110    const MB: u64 = 1024 * 1024;
111    const GB: u64 = 1024 * 1024 * 1024;
112
113    // u64→f64: these are display-only divisions; precision loss beyond 2^53 is acceptable
114    #[allow(
115        clippy::as_conversions,
116        reason = "display-only conversion; precision loss beyond 2^53 bytes (~8 exabytes) is acceptable for human-readable formatting"
117    )]
118    if bytes >= GB {
119        format!("{:.1} GB", bytes as f64 / GB as f64)
120    } else if bytes >= MB {
121        format!("{:.1} MB", bytes as f64 / MB as f64)
122    } else if bytes >= KB {
123        format!("{:.1} KB", bytes as f64 / KB as f64)
124    } else {
125        format!("{bytes} B")
126    }
127}
128
129/// A latency measurement sample.
130///
131/// Tier: T2-P (N Quantity + ν Frequency)
132#[non_exhaustive]
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct LatencySample {
135    /// Round-trip time in microseconds.
136    pub rtt_us: u64,
137    /// When this measurement was taken.
138    pub timestamp: DateTime,
139}
140
141/// Connection quality assessment.
142///
143/// Tier: T2-C (N + κ — quantified comparison)
144#[non_exhaustive]
145#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
146pub enum ConnectionQuality {
147    /// Connection is unusable.
148    Unusable = 0,
149    /// Very poor quality.
150    Poor = 1,
151    /// Acceptable but degraded.
152    Fair = 2,
153    /// Normal quality.
154    Good = 3,
155    /// Excellent quality.
156    Excellent = 4,
157}
158
159impl ConnectionQuality {
160    /// Compute quality from latency and packet loss.
161    pub fn from_metrics(latency_ms: u64, packet_loss_pct: f64) -> Self {
162        if packet_loss_pct > 10.0 || latency_ms > 1000 {
163            Self::Unusable
164        } else if packet_loss_pct > 5.0 || latency_ms > 500 {
165            Self::Poor
166        } else if packet_loss_pct > 2.0 || latency_ms > 200 {
167            Self::Fair
168        } else if packet_loss_pct > 0.5 || latency_ms > 50 {
169            Self::Good
170        } else {
171            Self::Excellent
172        }
173    }
174
175    /// Human-readable label.
176    pub const fn label(&self) -> &'static str {
177        match self {
178            Self::Unusable => "Unusable",
179            Self::Poor => "Poor",
180            Self::Fair => "Fair",
181            Self::Good => "Good",
182            Self::Excellent => "Excellent",
183        }
184    }
185}
186
187/// Per-interface traffic monitor.
188///
189/// Tier: T2-C (N + ν + σ — quantified periodic measurement sequence)
190#[non_exhaustive]
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct InterfaceMonitor {
193    /// Interface being monitored.
194    pub interface_id: InterfaceId,
195    /// Traffic counters.
196    pub counters: TrafficCounters,
197    /// Recent latency samples (ring buffer, newest last).
198    pub latency_samples: Vec<LatencySample>,
199    /// Maximum latency samples to keep.
200    pub max_samples: usize,
201    /// When monitoring started.
202    pub started_at: DateTime,
203}
204
205impl InterfaceMonitor {
206    /// Create a new monitor for an interface.
207    pub fn new(interface_id: InterfaceId) -> Self {
208        Self {
209            interface_id,
210            counters: TrafficCounters::default(),
211            latency_samples: Vec::new(),
212            max_samples: 100,
213            started_at: DateTime::now(),
214        }
215    }
216
217    /// Record a latency measurement.
218    pub fn record_latency(&mut self, rtt_us: u64) {
219        if self.latency_samples.len() >= self.max_samples {
220            self.latency_samples.remove(0);
221        }
222        self.latency_samples.push(LatencySample {
223            rtt_us,
224            timestamp: DateTime::now(),
225        });
226    }
227
228    /// Average latency in microseconds.
229    pub fn avg_latency_us(&self) -> Option<u64> {
230        if self.latency_samples.is_empty() {
231            return None;
232        }
233        let sum: u64 = self.latency_samples.iter().map(|s| s.rtt_us).sum();
234        // usize→u64: sample count is bounded by max_samples (100 by default), well within u64
235        let count = u64::try_from(self.latency_samples.len())
236            .unwrap_or(1)
237            .max(1);
238        #[allow(
239            clippy::arithmetic_side_effects,
240            reason = "count is derived from try_from with a minimum of 1, so division is always safe — no division by zero, no overflow"
241        )]
242        Some(sum / count)
243    }
244
245    /// Average latency in milliseconds.
246    pub fn avg_latency_ms(&self) -> Option<u64> {
247        self.avg_latency_us().map(|us| us / 1000)
248    }
249
250    /// Min latency in microseconds.
251    pub fn min_latency_us(&self) -> Option<u64> {
252        self.latency_samples.iter().map(|s| s.rtt_us).min()
253    }
254
255    /// Max latency in microseconds.
256    pub fn max_latency_us(&self) -> Option<u64> {
257        self.latency_samples.iter().map(|s| s.rtt_us).max()
258    }
259
260    /// Current connection quality assessment.
261    pub fn quality(&self) -> ConnectionQuality {
262        let latency_ms = self.avg_latency_ms().unwrap_or(0);
263        let loss = self.counters.packet_loss_rate() * 100.0;
264        ConnectionQuality::from_metrics(latency_ms, loss)
265    }
266
267    /// Summary string.
268    pub fn summary(&self) -> String {
269        let latency = self
270            .avg_latency_ms()
271            .map_or("N/A".to_string(), |ms| format!("{ms}ms"));
272        format!(
273            "{}: ↑{} ↓{} latency={} quality={}",
274            self.interface_id.as_str(),
275            self.counters.bytes_sent_human(),
276            self.counters.bytes_received_human(),
277            latency,
278            self.quality().label(),
279        )
280    }
281}
282
283/// System-wide network monitor aggregating all interfaces.
284///
285/// Tier: T3 (Σ Sum — aggregation of all interface monitors)
286#[derive(Debug, Default)]
287pub struct NetworkMonitor {
288    /// Per-interface monitors.
289    monitors: Vec<InterfaceMonitor>,
290}
291
292impl NetworkMonitor {
293    /// Create a new system-wide monitor.
294    pub fn new() -> Self {
295        Self::default()
296    }
297
298    /// Add an interface to monitor.
299    pub fn add_interface(&mut self, interface_id: InterfaceId) {
300        if !self.monitors.iter().any(|m| m.interface_id == interface_id) {
301            self.monitors.push(InterfaceMonitor::new(interface_id));
302        }
303    }
304
305    /// Remove an interface.
306    pub fn remove_interface(&mut self, interface_id: &InterfaceId) {
307        self.monitors.retain(|m| &m.interface_id != interface_id);
308    }
309
310    /// Get a specific interface monitor.
311    pub fn get(&self, interface_id: &InterfaceId) -> Option<&InterfaceMonitor> {
312        self.monitors
313            .iter()
314            .find(|m| &m.interface_id == interface_id)
315    }
316
317    /// Get a mutable interface monitor.
318    pub fn get_mut(&mut self, interface_id: &InterfaceId) -> Option<&mut InterfaceMonitor> {
319        self.monitors
320            .iter_mut()
321            .find(|m| &m.interface_id == interface_id)
322    }
323
324    /// Total bytes across all interfaces.
325    pub fn total_bytes(&self) -> u64 {
326        self.monitors.iter().map(|m| m.counters.total_bytes()).sum()
327    }
328
329    /// Total bytes sent across all interfaces.
330    pub fn total_bytes_sent(&self) -> u64 {
331        self.monitors.iter().map(|m| m.counters.bytes_sent).sum()
332    }
333
334    /// Total bytes received across all interfaces.
335    pub fn total_bytes_received(&self) -> u64 {
336        self.monitors
337            .iter()
338            .map(|m| m.counters.bytes_received)
339            .sum()
340    }
341
342    /// Number of monitored interfaces.
343    pub fn interface_count(&self) -> usize {
344        self.monitors.len()
345    }
346
347    /// Summary of all interfaces.
348    pub fn summary(&self) -> String {
349        format!(
350            "Network: {} interfaces, ↑{} ↓{}",
351            self.monitors.len(),
352            format_bytes(self.total_bytes_sent()),
353            format_bytes(self.total_bytes_received()),
354        )
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    fn eth0() -> InterfaceId {
363        InterfaceId::new("eth0")
364    }
365
366    fn wlan0() -> InterfaceId {
367        InterfaceId::new("wlan0")
368    }
369
370    #[test]
371    fn traffic_counters_default() {
372        let c = TrafficCounters::default();
373        assert_eq!(c.total_bytes(), 0);
374        assert_eq!(c.total_packets(), 0);
375        assert!((c.packet_loss_rate() - 0.0).abs() < f64::EPSILON);
376    }
377
378    #[test]
379    fn traffic_counters_record() {
380        let mut c = TrafficCounters::default();
381        c.record_sent(1000);
382        c.record_received(2000);
383        assert_eq!(c.bytes_sent, 1000);
384        assert_eq!(c.bytes_received, 2000);
385        assert_eq!(c.packets_sent, 1);
386        assert_eq!(c.packets_received, 1);
387        assert_eq!(c.total_bytes(), 3000);
388    }
389
390    #[test]
391    fn traffic_counters_packet_loss() {
392        let mut c = TrafficCounters::default();
393        for _ in 0..9 {
394            c.record_sent(100);
395        }
396        c.record_dropped();
397        // 9 sent + 1 dropped = 10 total attempts, 1 lost = 10%
398        let rate = c.packet_loss_rate();
399        assert!((rate - 0.1).abs() < 0.01);
400    }
401
402    #[test]
403    fn traffic_counters_reset() {
404        let mut c = TrafficCounters::default();
405        c.record_sent(1000);
406        c.record_received(2000);
407        c.reset();
408        assert_eq!(c.total_bytes(), 0);
409    }
410
411    #[test]
412    fn format_bytes_display() {
413        assert_eq!(format_bytes(500), "500 B");
414        assert_eq!(format_bytes(1500), "1.5 KB");
415        assert_eq!(format_bytes(1_500_000), "1.4 MB");
416        assert_eq!(format_bytes(1_500_000_000), "1.4 GB");
417    }
418
419    #[test]
420    fn connection_quality_from_metrics() {
421        assert_eq!(
422            ConnectionQuality::from_metrics(10, 0.0),
423            ConnectionQuality::Excellent
424        );
425        assert_eq!(
426            ConnectionQuality::from_metrics(100, 1.0),
427            ConnectionQuality::Good
428        );
429        assert_eq!(
430            ConnectionQuality::from_metrics(300, 3.0),
431            ConnectionQuality::Fair
432        );
433        assert_eq!(
434            ConnectionQuality::from_metrics(700, 6.0),
435            ConnectionQuality::Poor
436        );
437        assert_eq!(
438            ConnectionQuality::from_metrics(2000, 15.0),
439            ConnectionQuality::Unusable
440        );
441    }
442
443    #[test]
444    fn connection_quality_ordering() {
445        assert!(ConnectionQuality::Excellent > ConnectionQuality::Good);
446        assert!(ConnectionQuality::Good > ConnectionQuality::Fair);
447        assert!(ConnectionQuality::Fair > ConnectionQuality::Poor);
448        assert!(ConnectionQuality::Poor > ConnectionQuality::Unusable);
449    }
450
451    #[test]
452    fn interface_monitor_latency() {
453        let mut m = InterfaceMonitor::new(eth0());
454        m.record_latency(10_000); // 10ms
455        m.record_latency(20_000); // 20ms
456        m.record_latency(30_000); // 30ms
457
458        assert_eq!(m.avg_latency_us(), Some(20_000));
459        assert_eq!(m.avg_latency_ms(), Some(20));
460        assert_eq!(m.min_latency_us(), Some(10_000));
461        assert_eq!(m.max_latency_us(), Some(30_000));
462    }
463
464    #[test]
465    fn interface_monitor_no_latency() {
466        let m = InterfaceMonitor::new(eth0());
467        assert!(m.avg_latency_us().is_none());
468        assert!(m.avg_latency_ms().is_none());
469    }
470
471    #[test]
472    fn interface_monitor_quality() {
473        let mut m = InterfaceMonitor::new(eth0());
474        m.record_latency(5_000); // 5ms = excellent
475        assert_eq!(m.quality(), ConnectionQuality::Excellent);
476    }
477
478    #[test]
479    fn interface_monitor_ring_buffer() {
480        let mut m = InterfaceMonitor::new(eth0());
481        m.max_samples = 3;
482        for i in 0..5 {
483            m.record_latency(i * 1000);
484        }
485        assert_eq!(m.latency_samples.len(), 3);
486        // Should have the last 3: 2000, 3000, 4000
487        assert_eq!(m.latency_samples[0].rtt_us, 2000);
488    }
489
490    #[test]
491    fn network_monitor_add_remove() {
492        let mut nm = NetworkMonitor::new();
493        nm.add_interface(eth0());
494        nm.add_interface(wlan0());
495        assert_eq!(nm.interface_count(), 2);
496
497        nm.remove_interface(&eth0());
498        assert_eq!(nm.interface_count(), 1);
499    }
500
501    #[test]
502    fn network_monitor_no_duplicates() {
503        let mut nm = NetworkMonitor::new();
504        nm.add_interface(eth0());
505        nm.add_interface(eth0()); // duplicate
506        assert_eq!(nm.interface_count(), 1);
507    }
508
509    #[test]
510    fn network_monitor_total_bytes() {
511        let mut nm = NetworkMonitor::new();
512        nm.add_interface(eth0());
513        nm.add_interface(wlan0());
514
515        if let Some(m) = nm.get_mut(&eth0()) {
516            m.counters.record_sent(1000);
517            m.counters.record_received(2000);
518        }
519        if let Some(m) = nm.get_mut(&wlan0()) {
520            m.counters.record_sent(500);
521            m.counters.record_received(1500);
522        }
523
524        assert_eq!(nm.total_bytes_sent(), 1500);
525        assert_eq!(nm.total_bytes_received(), 3500);
526        assert_eq!(nm.total_bytes(), 5000);
527    }
528
529    #[test]
530    fn network_monitor_summary() {
531        let nm = NetworkMonitor::new();
532        let s = nm.summary();
533        assert!(s.contains("Network"));
534        assert!(s.contains("0 interfaces"));
535    }
536
537    #[test]
538    fn interface_monitor_summary() {
539        let m = InterfaceMonitor::new(eth0());
540        let s = m.summary();
541        assert!(s.contains("eth0"));
542    }
543}