Skip to main content

monitrs_core/model/
network.rs

1//! Network interface metrics.
2//!
3//! §7.4 and §26: *network percentage is meaningless without known link
4//! capacity*. [`NetworkSnapshot::utilization`] is therefore
5//! [`MetricState::TemporarilyUnavailable`] with
6//! [`UnavailableReason::LinkSpeedUnknown`] rather than a fabricated number
7//! whenever the link speed is absent, which is the common case on Wi-Fi and in
8//! virtual machines.
9//!
10//! Per-process network attribution is out of scope for v1 (§3.2, §7.4), so no
11//! type here carries a process identity.
12
13use std::net::IpAddr;
14
15use crate::model::{MetricState, UnavailableReason};
16use crate::units::{Percent, Rate};
17
18/// Operational state of a link.
19#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize))]
21#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
22pub enum LinkState {
23    /// Carrier present and the interface is administratively up.
24    Up,
25    /// Administratively down or no carrier.
26    Down,
27    /// Waiting for an external event, e.g. an unassociated Wi-Fi interface.
28    Dormant,
29    /// The platform did not report an operational state.
30    #[default]
31    Unknown,
32}
33
34impl LinkState {
35    /// A redundant non-color cue (§5.2).
36    #[must_use]
37    pub const fn symbol(self) -> char {
38        match self {
39            Self::Up => '+',
40            Self::Down => '-',
41            Self::Dormant => '.',
42            Self::Unknown => '?',
43        }
44    }
45
46    /// Lower-case label.
47    #[must_use]
48    pub const fn label(self) -> &'static str {
49        match self {
50            Self::Up => "up",
51            Self::Down => "down",
52            Self::Dormant => "dormant",
53            Self::Unknown => "unknown",
54        }
55    }
56}
57
58/// What kind of interface this is.
59#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
60#[cfg_attr(feature = "serde", derive(serde::Serialize))]
61#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
62pub enum InterfaceKind {
63    /// A hardware interface.
64    Physical,
65    /// The loopback interface.
66    Loopback,
67    /// A bridge, VLAN, bond, or container veth.
68    Virtual,
69    /// A VPN or other tunnel.
70    Tunnel,
71    /// Not classifiable from the interface name and flags alone.
72    #[default]
73    Unknown,
74}
75
76/// An address assigned to an interface.
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78#[cfg_attr(feature = "serde", derive(serde::Serialize))]
79pub struct InterfaceAddress {
80    /// The address.
81    pub ip: IpAddr,
82    /// Prefix length, where reported.
83    pub prefix_len: Option<u8>,
84}
85
86/// Error and drop counters.
87#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
88#[cfg_attr(feature = "serde", derive(serde::Serialize))]
89pub struct InterfaceErrors {
90    /// Receive errors since boot.
91    pub rx_errors: u64,
92    /// Transmit errors since boot.
93    pub tx_errors: u64,
94    /// Receive drops since boot.
95    pub rx_dropped: u64,
96    /// Transmit drops since boot.
97    pub tx_dropped: u64,
98}
99
100impl InterfaceErrors {
101    /// Whether any counter is non-zero.
102    #[must_use]
103    pub const fn any(&self) -> bool {
104        self.rx_errors > 0 || self.tx_errors > 0 || self.rx_dropped > 0 || self.tx_dropped > 0
105    }
106}
107
108/// Cumulative byte and packet counters.
109#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
110#[cfg_attr(feature = "serde", derive(serde::Serialize))]
111pub struct TrafficTotals {
112    /// Bytes received.
113    pub rx_bytes: u64,
114    /// Bytes transmitted.
115    pub tx_bytes: u64,
116    /// Packets received.
117    pub rx_packets: u64,
118    /// Packets transmitted.
119    pub tx_packets: u64,
120}
121
122/// State of one network interface.
123#[derive(Clone, Debug, PartialEq)]
124#[cfg_attr(feature = "serde", derive(serde::Serialize))]
125pub struct NetworkSnapshot {
126    /// Interface name, e.g. `en0` or `eth0`.
127    pub name: Box<str>,
128    /// Classification.
129    pub kind: InterfaceKind,
130    /// Operational state.
131    pub state: MetricState<LinkState>,
132    /// Assigned addresses.
133    pub addresses: Vec<InterfaceAddress>,
134    /// Hardware address, where readable.
135    pub mac: Option<Box<str>>,
136    /// Receive throughput.
137    pub rx: MetricState<Rate>,
138    /// Transmit throughput.
139    pub tx: MetricState<Rate>,
140    /// Received packets per second.
141    pub rx_packets: MetricState<Rate>,
142    /// Transmitted packets per second.
143    pub tx_packets: MetricState<Rate>,
144    /// Error and drop counters.
145    pub errors: MetricState<InterfaceErrors>,
146    /// Negotiated link speed, where the platform reports it.
147    pub link_speed_mbps: MetricState<u64>,
148    /// Totals accumulated since monitrs launched.
149    ///
150    /// Distinct from `os_totals`: this one starts at zero and is always
151    /// meaningful, whereas the OS counter may have wrapped or been reset (§7.4).
152    pub since_launch: TrafficTotals,
153    /// The OS's own counters, where exposed.
154    pub os_totals: MetricState<TrafficTotals>,
155}
156
157impl NetworkSnapshot {
158    /// Link utilization, or an explicit unavailability.
159    ///
160    /// Not a stored field: deriving it on demand makes it impossible to persist
161    /// a utilization that was computed without a known link speed (§7.4). The
162    /// higher of the two directions is used, since a duplex link saturates in
163    /// whichever direction fills first.
164    #[must_use]
165    pub fn utilization(&self) -> MetricState<Percent> {
166        let Some(&speed_mbps) = self.link_speed_mbps.fresh() else {
167            return MetricState::TemporarilyUnavailable(UnavailableReason::LinkSpeedUnknown);
168        };
169        if speed_mbps == 0 {
170            return MetricState::TemporarilyUnavailable(UnavailableReason::LinkSpeedUnknown);
171        }
172        let (Some(rx), Some(tx)) = (self.rx.fresh(), self.tx.fresh()) else {
173            return MetricState::WarmingUp;
174        };
175        // Link speeds are quoted in megabits; throughput is measured in bytes.
176        let capacity_bytes_per_second = speed_mbps as f64 * 1_000_000.0 / 8.0;
177        let busiest = rx.per_second().max(tx.per_second());
178        // Narrowing a percentage to f32 is intentional; `Percent::new` rejects
179        // any value the narrowing could not represent.
180        #[allow(clippy::cast_possible_truncation)]
181        let percent = ((busiest / capacity_bytes_per_second) * 100.0) as f32;
182        Percent::new(percent).map_or(
183            MetricState::TemporarilyUnavailable(UnavailableReason::ParseFailed),
184            MetricState::Available,
185        )
186    }
187
188    /// An interface whose counters exist but whose rates need a second sample.
189    #[must_use]
190    pub fn warming_up(name: Box<str>, kind: InterfaceKind) -> Self {
191        Self {
192            name,
193            kind,
194            state: MetricState::WarmingUp,
195            addresses: Vec::new(),
196            mac: None,
197            rx: MetricState::WarmingUp,
198            tx: MetricState::WarmingUp,
199            rx_packets: MetricState::WarmingUp,
200            tx_packets: MetricState::WarmingUp,
201            errors: MetricState::WarmingUp,
202            link_speed_mbps: MetricState::WarmingUp,
203            since_launch: TrafficTotals::default(),
204            os_totals: MetricState::WarmingUp,
205        }
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    fn interface() -> NetworkSnapshot {
214        NetworkSnapshot::warming_up("en0".into(), InterfaceKind::Physical)
215    }
216
217    #[test]
218    fn utilization_is_unavailable_without_a_known_link_speed() {
219        let mut nic = interface();
220        nic.rx = MetricState::Available(Rate::new(18_200_000.0).expect("valid"));
221        nic.tx = MetricState::Available(Rate::new(2_300_000.0).expect("valid"));
222        nic.link_speed_mbps = MetricState::Unsupported;
223
224        assert_eq!(
225            nic.utilization(),
226            MetricState::TemporarilyUnavailable(UnavailableReason::LinkSpeedUnknown),
227            "§7.4 forbids a utilization percentage without known capacity"
228        );
229    }
230
231    #[test]
232    fn a_zero_link_speed_is_treated_as_unknown_not_as_infinite_utilization() {
233        let mut nic = interface();
234        nic.rx = MetricState::Available(Rate::new(1_000.0).expect("valid"));
235        nic.tx = MetricState::Available(Rate::ZERO);
236        nic.link_speed_mbps = MetricState::Available(0);
237        assert_eq!(
238            nic.utilization(),
239            MetricState::TemporarilyUnavailable(UnavailableReason::LinkSpeedUnknown)
240        );
241    }
242
243    #[test]
244    fn utilization_uses_the_busier_direction_of_a_duplex_link() {
245        let mut nic = interface();
246        // 1 Gbit/s = 125 MB/s. 62.5 MB/s in one direction is 50%.
247        nic.rx = MetricState::Available(Rate::new(62_500_000.0).expect("valid"));
248        nic.tx = MetricState::Available(Rate::new(1_000.0).expect("valid"));
249        nic.link_speed_mbps = MetricState::Available(1_000);
250
251        let percent = *nic
252            .utilization()
253            .fresh()
254            .expect("speed and rates are known");
255        assert!((percent.value() - 50.0).abs() < 0.1, "got {percent}");
256    }
257
258    #[test]
259    fn utilization_warms_up_while_rates_are_still_unknown() {
260        let mut nic = interface();
261        nic.link_speed_mbps = MetricState::Available(1_000);
262        assert!(nic.utilization().is_warming_up());
263    }
264
265    #[test]
266    fn utilization_can_exceed_one_hundred_percent_rather_than_being_clamped() {
267        // Reported link speeds are frequently wrong (aggregated links, stale
268        // Wi-Fi negotiation). Clamping would hide that; the value is honest.
269        let mut nic = interface();
270        nic.rx = MetricState::Available(Rate::new(250_000_000.0).expect("valid"));
271        nic.tx = MetricState::Available(Rate::ZERO);
272        nic.link_speed_mbps = MetricState::Available(1_000);
273        let percent = *nic.utilization().fresh().expect("known");
274        assert!(percent.value() > 100.0, "got {percent}");
275    }
276
277    #[test]
278    fn link_state_symbols_are_distinguishable_without_color() {
279        let mut symbols: Vec<char> = [
280            LinkState::Up,
281            LinkState::Down,
282            LinkState::Dormant,
283            LinkState::Unknown,
284        ]
285        .iter()
286        .map(|s| s.symbol())
287        .collect();
288        symbols.sort_unstable();
289        symbols.dedup();
290        assert_eq!(symbols.len(), 4);
291    }
292
293    #[test]
294    fn error_counters_report_whether_anything_is_wrong() {
295        assert!(!InterfaceErrors::default().any());
296        assert!(
297            InterfaceErrors {
298                rx_dropped: 1,
299                ..InterfaceErrors::default()
300            }
301            .any()
302        );
303    }
304}