1use core::time::Duration;
9
10use crate::model::{Measurement, MetricState, Severity};
11use crate::units::Percent;
12
13#[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,
20 Memory,
22 Disk,
24 Network,
26 Swap,
28 Load,
30 PsiCpu,
32 PsiMemory,
34 PsiIo,
36}
37
38impl PressureId {
39 #[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 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#[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 #[default]
76 Normal,
77 Watch,
79 Critical,
81}
82
83impl PressureState {
84 #[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 #[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 #[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#[derive(Clone, Debug, PartialEq)]
118#[cfg_attr(feature = "serde", derive(serde::Serialize))]
119pub struct PressureSignal {
120 pub id: PressureId,
122 pub state: MetricState<PressureState>,
124 pub severity: MetricState<Percent>,
129 pub raw: Option<Measurement>,
131 pub rule: &'static str,
135 pub held_for: Option<Duration>,
137}
138
139impl PressureSignal {
140 #[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 #[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 #[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#[derive(Clone, Copy, Debug, PartialEq)]
185#[cfg_attr(feature = "serde", derive(serde::Serialize))]
186pub struct PsiResource {
187 pub some_avg10: Percent,
189 pub some_avg60: Percent,
191 pub some_avg300: Percent,
193 pub full_avg10: MetricState<Percent>,
195 pub full_avg60: MetricState<Percent>,
197 pub full_avg300: MetricState<Percent>,
199 pub total_stalled: Duration,
201}
202
203#[derive(Clone, Copy, Debug, PartialEq)]
205#[cfg_attr(feature = "serde", derive(serde::Serialize))]
206pub struct PsiSnapshot {
207 pub cpu: PsiResource,
209 pub memory: PsiResource,
211 pub io: PsiResource,
213}
214
215#[derive(Clone, Debug, PartialEq)]
217#[cfg_attr(feature = "serde", derive(serde::Serialize))]
218pub struct PressureSnapshot {
219 pub signals: Vec<PressureSignal>,
221 pub psi: MetricState<PsiSnapshot>,
223}
224
225impl PressureSnapshot {
226 #[must_use]
228 pub fn signal(&self, id: PressureId) -> Option<&PressureSignal> {
229 self.signals.iter().find(|signal| signal.id == id)
230 }
231
232 #[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 #[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 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 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}