Skip to main content

monitrs_core/model/
pressure.rs

1//! Pressure Radar signals (§2.3).
2//!
3//! Every signal must show four things: the raw metric, its normalized severity,
4//! **the rule used to derive the state**, and an explicit unavailable state. The
5//! rule text is part of the data, not documentation, so a user can always see
6//! why a signal turned amber without reading the source.
7
8use core::time::Duration;
9
10use crate::model::{Measurement, MetricState, Severity};
11use crate::units::Percent;
12
13/// Which resource a signal describes.
14#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
15#[cfg_attr(feature = "serde", derive(serde::Serialize))]
16#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
17pub enum PressureId {
18    /// CPU saturation.
19    Cpu,
20    /// Memory availability.
21    Memory,
22    /// Disk device pressure.
23    Disk,
24    /// Network saturation. Only meaningful with a known link speed (§2.3).
25    Network,
26    /// Swap activity.
27    Swap,
28    /// Sustained run-queue or load pressure.
29    Load,
30    /// Linux PSI, CPU resource.
31    PsiCpu,
32    /// Linux PSI, memory resource.
33    PsiMemory,
34    /// Linux PSI, I/O resource.
35    PsiIo,
36}
37
38impl PressureId {
39    /// The short, fixed-width label used in the radar panel (§5.5).
40    #[must_use]
41    pub const fn label(self) -> &'static str {
42        match self {
43            Self::Cpu => "CPU",
44            Self::Memory => "MEM",
45            Self::Disk => "DISK",
46            Self::Network => "NET",
47            Self::Swap => "SWAP",
48            Self::Load => "LOAD",
49            Self::PsiCpu => "PSI-CPU",
50            Self::PsiMemory => "PSI-MEM",
51            Self::PsiIo => "PSI-IO",
52        }
53    }
54
55    /// The order signals appear in the radar, most important first.
56    pub const DISPLAY_ORDER: [Self; 9] = [
57        Self::Cpu,
58        Self::Memory,
59        Self::Disk,
60        Self::Network,
61        Self::Swap,
62        Self::Load,
63        Self::PsiCpu,
64        Self::PsiMemory,
65        Self::PsiIo,
66    ];
67}
68
69/// The three states a pressure signal can be in (§2.3).
70#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
71#[cfg_attr(feature = "serde", derive(serde::Serialize))]
72#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
73pub enum PressureState {
74    /// Nothing to act on.
75    #[default]
76    Normal,
77    /// Elevated; worth watching.
78    Watch,
79    /// Actively degrading the system.
80    Critical,
81}
82
83impl PressureState {
84    /// The redundant ASCII cue. §2.3 names these exact characters, and §5.2
85    /// forbids color from being the only indicator.
86    #[must_use]
87    pub const fn symbol(self) -> char {
88        match self {
89            Self::Normal => '.',
90            Self::Watch => '!',
91            Self::Critical => 'X',
92        }
93    }
94
95    /// Lower-case label.
96    #[must_use]
97    pub const fn label(self) -> &'static str {
98        match self {
99            Self::Normal => "normal",
100            Self::Watch => "watch",
101            Self::Critical => "critical",
102        }
103    }
104
105    /// The equivalent diagnostic severity.
106    #[must_use]
107    pub const fn severity(self) -> Severity {
108        match self {
109            Self::Normal => Severity::Info,
110            Self::Watch => Severity::Watch,
111            Self::Critical => Severity::Critical,
112        }
113    }
114}
115
116/// One radar signal.
117#[derive(Clone, Debug, PartialEq)]
118#[cfg_attr(feature = "serde", derive(serde::Serialize))]
119pub struct PressureSignal {
120    /// Which resource.
121    pub id: PressureId,
122    /// The derived state, or why it could not be derived.
123    pub state: MetricState<PressureState>,
124    /// Normalized `0..=100` severity, for sorting and bar length.
125    ///
126    /// Separate from `state` because two signals can both be `Watch` while one
127    /// is far closer to critical.
128    pub severity: MetricState<Percent>,
129    /// The raw metric the state was derived from (§2.3).
130    pub raw: Option<Measurement>,
131    /// Human-readable statement of the rule that produced `state` (§2.3).
132    ///
133    /// For example: `"available < 15% of total for 10 of 15 samples"`.
134    pub rule: &'static str,
135    /// How long the signal has held its current state, for hysteresis display.
136    pub held_for: Option<Duration>,
137}
138
139impl PressureSignal {
140    /// A signal this platform cannot produce at all.
141    #[must_use]
142    pub const fn unsupported(id: PressureId, rule: &'static str) -> Self {
143        Self {
144            id,
145            state: MetricState::Unsupported,
146            severity: MetricState::Unsupported,
147            raw: None,
148            rule,
149            held_for: None,
150        }
151    }
152
153    /// A signal awaiting the samples its rule requires.
154    #[must_use]
155    pub const fn warming_up(id: PressureId, rule: &'static str) -> Self {
156        Self {
157            id,
158            state: MetricState::WarmingUp,
159            severity: MetricState::WarmingUp,
160            raw: None,
161            rule,
162            held_for: None,
163        }
164    }
165
166    /// The character shown in the radar's leftmost column.
167    ///
168    /// Falls back to the availability symbol when no state could be derived, so
169    /// an unknown signal reads as `?` rather than as `normal` (§5.5).
170    #[must_use]
171    pub fn symbol(&self) -> char {
172        match self.state.displayable() {
173            Some((state, _)) => state.symbol(),
174            None => self.state.symbol(),
175        }
176    }
177}
178
179/// The Linux PSI figures for one resource.
180///
181/// `some` is the share of time at least one task was stalled; `full` is the
182/// share where *every* runnable task was stalled. `full` is absent for the CPU
183/// resource on many kernels, which is why it is a [`MetricState`].
184#[derive(Clone, Copy, Debug, PartialEq)]
185#[cfg_attr(feature = "serde", derive(serde::Serialize))]
186pub struct PsiResource {
187    /// 10-second `some` average.
188    pub some_avg10: Percent,
189    /// 60-second `some` average.
190    pub some_avg60: Percent,
191    /// 300-second `some` average.
192    pub some_avg300: Percent,
193    /// 10-second `full` average.
194    pub full_avg10: MetricState<Percent>,
195    /// 60-second `full` average.
196    pub full_avg60: MetricState<Percent>,
197    /// 300-second `full` average.
198    pub full_avg300: MetricState<Percent>,
199    /// Cumulative stall time, useful as a monotonic counter.
200    pub total_stalled: Duration,
201}
202
203/// All three Linux PSI resources.
204#[derive(Clone, Copy, Debug, PartialEq)]
205#[cfg_attr(feature = "serde", derive(serde::Serialize))]
206pub struct PsiSnapshot {
207    /// `/proc/pressure/cpu`.
208    pub cpu: PsiResource,
209    /// `/proc/pressure/memory`.
210    pub memory: PsiResource,
211    /// `/proc/pressure/io`.
212    pub io: PsiResource,
213}
214
215/// The whole radar.
216#[derive(Clone, Debug, PartialEq)]
217#[cfg_attr(feature = "serde", derive(serde::Serialize))]
218pub struct PressureSnapshot {
219    /// Signals in [`PressureId::DISPLAY_ORDER`].
220    pub signals: Vec<PressureSignal>,
221    /// Raw PSI figures, Linux only.
222    pub psi: MetricState<PsiSnapshot>,
223}
224
225impl PressureSnapshot {
226    /// Looks up one signal.
227    #[must_use]
228    pub fn signal(&self, id: PressureId) -> Option<&PressureSignal> {
229        self.signals.iter().find(|signal| signal.id == id)
230    }
231
232    /// The most severe state any signal reports.
233    ///
234    /// Unavailable signals are skipped rather than counted as normal, so a
235    /// system whose pressure cannot be measured does not read as healthy.
236    #[must_use]
237    pub fn worst_state(&self) -> MetricState<PressureState> {
238        let worst = self
239            .signals
240            .iter()
241            .filter_map(|signal| signal.state.fresh().copied())
242            .max();
243        match worst {
244            Some(state) => MetricState::Available(state),
245            None => MetricState::WarmingUp,
246        }
247    }
248
249    /// A radar with every signal warming up, for the first frame.
250    #[must_use]
251    pub fn warming_up() -> Self {
252        Self {
253            signals: PressureId::DISPLAY_ORDER
254                .iter()
255                .map(|&id| PressureSignal::warming_up(id, "awaiting samples"))
256                .collect(),
257            psi: MetricState::WarmingUp,
258        }
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::model::{MeasuredValue, UnavailableReason};
266
267    #[test]
268    fn state_symbols_are_exactly_the_specified_characters() {
269        assert_eq!(PressureState::Normal.symbol(), '.');
270        assert_eq!(PressureState::Watch.symbol(), '!');
271        assert_eq!(PressureState::Critical.symbol(), 'X');
272    }
273
274    #[test]
275    fn an_unavailable_signal_shows_a_question_mark_not_normal() {
276        // This is the `? NET unknown` row in the §5.5 mockup.
277        let signal = PressureSignal {
278            id: PressureId::Network,
279            state: MetricState::TemporarilyUnavailable(UnavailableReason::LinkSpeedUnknown),
280            severity: MetricState::TemporarilyUnavailable(UnavailableReason::LinkSpeedUnknown),
281            raw: Some(Measurement::new(
282                "throughput",
283                MeasuredValue::Count(18_000_000),
284            )),
285            rule: "utilization requires a known link speed",
286            held_for: None,
287        };
288        assert_eq!(signal.symbol(), '?');
289        assert_ne!(signal.symbol(), PressureState::Normal.symbol());
290    }
291
292    #[test]
293    fn an_unsupported_signal_is_distinguishable_from_a_normal_one() {
294        let signal = PressureSignal::unsupported(PressureId::PsiIo, "Linux only");
295        assert_eq!(signal.symbol(), '-');
296        assert!(signal.state.is_unsupported());
297    }
298
299    #[test]
300    fn worst_state_ignores_unavailable_signals_rather_than_treating_them_as_healthy() {
301        let mut snapshot = PressureSnapshot::warming_up();
302        // Everything warming up: the system is not "normal", it is unmeasured.
303        assert!(snapshot.worst_state().is_warming_up());
304
305        if let Some(signal) = snapshot.signals.first_mut() {
306            signal.state = MetricState::Available(PressureState::Watch);
307        }
308        assert_eq!(
309            snapshot.worst_state(),
310            MetricState::Available(PressureState::Watch)
311        );
312
313        if let Some(signal) = snapshot.signals.get_mut(1) {
314            signal.state = MetricState::Available(PressureState::Critical);
315        }
316        assert_eq!(
317            snapshot.worst_state(),
318            MetricState::Available(PressureState::Critical)
319        );
320    }
321
322    #[test]
323    fn warming_up_radar_contains_every_signal_in_display_order() {
324        let snapshot = PressureSnapshot::warming_up();
325        assert_eq!(snapshot.signals.len(), PressureId::DISPLAY_ORDER.len());
326        for (signal, expected) in snapshot.signals.iter().zip(PressureId::DISPLAY_ORDER) {
327            assert_eq!(signal.id, expected);
328        }
329        assert!(snapshot.signal(PressureId::Memory).is_some());
330    }
331
332    #[test]
333    fn every_signal_carries_the_rule_that_derived_it() {
334        let snapshot = PressureSnapshot::warming_up();
335        for signal in &snapshot.signals {
336            assert!(!signal.rule.is_empty(), "{:?} has no rule text", signal.id);
337        }
338    }
339
340    #[test]
341    fn labels_are_short_enough_for_the_radar_column() {
342        for id in PressureId::DISPLAY_ORDER {
343            assert!(id.label().len() <= 8, "{id:?} label is too wide");
344            assert!(id.label().is_ascii());
345        }
346    }
347}