Skip to main content

omnyssh_core/ssh/
metrics.rs

1//! Metric collection and parsing for remote servers.
2//!
3//! Metrics are gathered via SSH commands and parsed into typed
4//! values. All parsers return `Option<f32>` — they never panic.
5//! Unknown or truncated output results in `None`, which the UI
6//! renders as "N/A".
7//!
8//! Commands used per OS family:
9//! - CPU:    `top -bn1` (Linux) / `top -l 1 -n 0` (macOS)
10//! - RAM:    `free -b` (Linux) / `vm_stat` + `sysctl hw.memsize` (macOS)
11//! - Disk:   `df -k /`
12//! - Uptime: `uptime`
13//! - Load:   `cat /proc/loadavg` (Linux only)
14//!
15//! Severity thresholds:
16//!   Ok    < 60 %
17//!   Warn  60–85 %
18//!   Crit  > 85 %
19
20use crate::event::ProcessInfo;
21
22// ---------------------------------------------------------------------------
23// Severity threshold helper (used by card renderer)
24// ---------------------------------------------------------------------------
25
26/// Severity of a metric percentage. The UI maps each level to a colour.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ThresholdLevel {
29    Ok,
30    Warn,
31    Crit,
32}
33
34/// Returns the severity level for a metric percentage.
35pub fn threshold_level(percent: f64) -> ThresholdLevel {
36    if percent < 60.0 {
37        ThresholdLevel::Ok
38    } else if percent <= 85.0 {
39        ThresholdLevel::Warn
40    } else {
41        ThresholdLevel::Crit
42    }
43}
44
45// ---------------------------------------------------------------------------
46// CPU parsers
47// ---------------------------------------------------------------------------
48
49/// Parse CPU idle percentage from Linux `top -bn1` output.
50///
51/// Handles both modern procps-ng format (`%Cpu(s): 2.3 us, ... 96.7 id`)
52/// and older format (`Cpu(s):  2.3%us, ... 96.7%id`).
53/// Also handles Alpine BusyBox format (`CPU:   4% usr   1% sys  94% idle`).
54///
55/// Returns `100 - idle_percent` as the used percentage.
56pub fn parse_cpu_top(output: &str) -> Option<f64> {
57    for line in output.lines() {
58        let trimmed = line.trim();
59
60        // Alpine BusyBox: "CPU:   4% usr   1% sys   0% nic  94% idle ..."
61        if trimmed.starts_with("CPU:") {
62            return parse_cpu_busybox(trimmed);
63        }
64
65        // Modern procps-ng: "%Cpu(s):  2.3 us,  0.7 sy,  0.0 ni, 96.7 id, ..."
66        // Older format:    "Cpu(s):  2.3%us,  0.7%sy,  0.0%ni, 96.7%id, ..."
67        let lower = trimmed.to_lowercase();
68        if lower.starts_with("%cpu") || lower.starts_with("cpu(s)") {
69            return parse_cpu_linux_top_line(trimmed);
70        }
71    }
72    None
73}
74
75fn parse_cpu_busybox(line: &str) -> Option<f64> {
76    // Format: "CPU:   4% usr   1% sys   0% nic  94% idle   0% io   0% irq   1% sirq"
77    // Find "idle" preceded by a percentage.
78    let words: Vec<&str> = line.split_whitespace().collect();
79    for i in 0..words.len().saturating_sub(1) {
80        if words[i + 1] == "idle" {
81            let pct_str = words[i].trim_end_matches('%');
82            if let Ok(idle) = pct_str.parse::<f64>() {
83                return Some((100.0 - idle).clamp(0.0, 100.0));
84            }
85        }
86    }
87    None
88}
89
90fn parse_cpu_linux_top_line(line: &str) -> Option<f64> {
91    // Strip leading label (everything up to and including the colon).
92    let after_colon = line.split_once(':')?.1;
93
94    // Find the idle field — it's after "id" or "idle" token.
95    // Fields are comma-separated: "  2.3 us,  0.7 sy, ..., 96.7 id, ..."
96    // or "%Cpu(s):  2.3 us,  0.7 sy,  0.0 ni, 96.7 id,  0.3 wa, ..."
97    let fields: Vec<&str> = after_colon.split(',').collect();
98    for field in fields {
99        let field = field.trim();
100        // Field looks like "96.7 id" or "96.7%id"
101        let parts: Vec<&str> = field.splitn(2, [' ', '%']).collect();
102        if parts.len() == 2 {
103            let label = parts[1].trim().to_lowercase();
104            if label == "id" || label == "idle" || label.starts_with("id,") {
105                let val_str = parts[0].trim_end_matches('%');
106                if let Ok(idle) = val_str.parse::<f64>() {
107                    return Some((100.0 - idle).clamp(0.0, 100.0));
108                }
109            }
110        }
111    }
112    None
113}
114
115/// Parse CPU usage from macOS `top -l 1 -n 0` output.
116///
117/// Format: `CPU usage: 3.17% user, 1.56% sys, 95.26% idle`
118pub fn parse_cpu_top_macos(output: &str) -> Option<f64> {
119    for line in output.lines() {
120        let lower = line.trim().to_lowercase();
121        if lower.starts_with("cpu usage:") {
122            // Find "idle" value
123            for part in line.split(',') {
124                let part = part.trim();
125                if part.to_lowercase().ends_with("idle") {
126                    // "95.26% idle" → 95.26
127                    let token = part.split_whitespace().next()?;
128                    let idle: f64 = token.trim_end_matches('%').parse().ok()?;
129                    return Some((100.0 - idle).clamp(0.0, 100.0));
130                }
131            }
132        }
133    }
134    None
135}
136
137/// Parse CPU usage from Linux `/proc/stat`.
138///
139/// First line: `cpu  user nice system idle iowait irq softirq steal guest guest_nice`
140/// Returns (user+system+...) / total * 100.
141pub fn parse_cpu_proc_stat(output: &str) -> Option<f64> {
142    let line = output.lines().next()?;
143    let mut parts = line.split_whitespace();
144    let label = parts.next()?;
145    if !label.starts_with("cpu") {
146        return None;
147    }
148    // columns: user nice system idle iowait irq softirq steal guest guest_nice
149    let values: Vec<u64> = parts.filter_map(|s| s.parse().ok()).collect();
150    if values.len() < 4 {
151        return None;
152    }
153    let idle = values[3] + values.get(4).copied().unwrap_or(0); // idle + iowait
154    let total: u64 = values.iter().sum();
155    if total == 0 {
156        return None;
157    }
158    // Use f64 to preserve precision for large counters (u64 values can exceed f32 range).
159    Some(((total - idle) as f64 / total as f64 * 100.0).clamp(0.0, 100.0))
160}
161
162// ---------------------------------------------------------------------------
163// RAM parsers
164// ---------------------------------------------------------------------------
165
166/// Parse RAM usage from Linux `free -b` output.
167///
168/// Uses the `available` column when present (modern `free`), otherwise falls
169/// back to `free` column (busybox / older `free`).
170///
171/// Formula: `(total - available) / total * 100`
172pub fn parse_ram_free(output: &str) -> Option<f64> {
173    // Skip header line, parse "Mem:" line.
174    let mem_line = output
175        .lines()
176        .find(|l| l.trim_start().starts_with("Mem:"))?;
177    let fields: Vec<&str> = mem_line.split_whitespace().collect();
178    // Modern: Mem: total used free shared buff/cache available  (7 columns)
179    // Busybox: Mem: total used free  (4 columns)
180    // Use f64 — RAM values are in bytes (u64 range); f32 loses precision above 16 MiB.
181    let total: f64 = fields.get(1)?.parse().ok()?;
182    if total == 0.0 {
183        return None;
184    }
185    if let Some(available_str) = fields.get(6) {
186        // 7-column format: available in column 6
187        let available: f64 = available_str.parse().ok()?;
188        Some(((total - available) / total * 100.0).clamp(0.0, 100.0))
189    } else if let Some(free_str) = fields.get(3) {
190        // 4-column busybox: free in column 3
191        let free: f64 = free_str.parse().ok()?;
192        Some(((total - free) / total * 100.0).clamp(0.0, 100.0))
193    } else {
194        None
195    }
196}
197
198/// Parse RAM usage from macOS `vm_stat` + `sysctl hw.memsize` output.
199///
200/// `vm_stat_output` is the output of `vm_stat`.
201/// `memsize_output` is the output of `sysctl hw.memsize` (e.g. `hw.memsize: 8589934592`).
202pub fn parse_ram_vmstat(vm_stat_output: &str, memsize_output: &str) -> Option<f64> {
203    // Parse total memory from sysctl
204    let total_bytes: f64 = memsize_output.split(':').nth(1)?.trim().parse().ok()?;
205    if total_bytes == 0.0 {
206        return None;
207    }
208
209    // Parse page size from vm_stat (first line: "Mach Virtual Memory Statistics: (page size of 16384 bytes)")
210    let page_size: f64 = vm_stat_output
211        .lines()
212        .next()
213        .and_then(|l| {
214            let idx = l.find("page size of")?;
215            let rest = &l[idx + "page size of".len()..];
216            rest.split_whitespace().next()?.parse().ok()
217        })
218        .unwrap_or(4096.0);
219
220    // Count free + speculative pages
221    let mut free_pages: f64 = 0.0;
222    let mut speculative_pages: f64 = 0.0;
223    for line in vm_stat_output.lines() {
224        if let Some(val) = parse_vmstat_line(line, "Pages free:") {
225            free_pages = val;
226        } else if let Some(val) = parse_vmstat_line(line, "Pages speculative:") {
227            speculative_pages = val;
228        }
229    }
230    let available_bytes = (free_pages + speculative_pages) * page_size;
231    Some(((total_bytes - available_bytes) / total_bytes * 100.0).clamp(0.0, 100.0))
232}
233
234fn parse_vmstat_line(line: &str, prefix: &str) -> Option<f64> {
235    if line.trim_start().starts_with(prefix) {
236        line.split(':')
237            .nth(1)?
238            .trim()
239            .trim_end_matches('.')
240            .parse()
241            .ok()
242    } else {
243        None
244    }
245}
246
247// ---------------------------------------------------------------------------
248// Disk parsers
249// ---------------------------------------------------------------------------
250
251/// Parse disk usage of `/` from `df -k /` output.
252///
253/// Handles Linux format (`Use%` column) and macOS format (`Capacity` column).
254/// Returns the numeric percentage as `f64`.
255pub fn parse_disk_df(output: &str) -> Option<f64> {
256    // The percentage is in a column labelled "Use%" or "Capacity".
257    // Skip the header and take the first data line.
258    let mut lines = output.lines();
259    let header = lines.next()?;
260    let data_line = lines.next()?;
261
262    let header_fields: Vec<&str> = header.split_whitespace().collect();
263    let data_fields: Vec<&str> = data_line.split_whitespace().collect();
264
265    // Find the index of Use% or Capacity in the header.
266    let pct_col = header_fields.iter().position(|h| {
267        *h == "Use%" || *h == "Capacity" || h.ends_with("Use%") || h.ends_with("Capacity")
268    })?;
269
270    let pct_str = data_fields.get(pct_col)?;
271    let pct_str = pct_str.trim_end_matches('%');
272    pct_str.parse::<f64>().ok().map(|p| p.clamp(0.0, 100.0))
273}
274
275// ---------------------------------------------------------------------------
276// Uptime parser
277// ---------------------------------------------------------------------------
278
279/// Extract the human-readable uptime string from `uptime` output.
280///
281/// Works on Linux (`14:23:45 up 2 days, 3:45, 2 users`) and
282/// macOS (`14:23  up 2 days, 3:45, 2 users`).
283/// Returns the portion after "up" and before the next comma-delimited field
284/// that doesn't look like a time.
285pub fn parse_uptime(output: &str) -> Option<String> {
286    let line = output.lines().next()?;
287    // Find "up " and take everything up to "user" or end of meaningful part.
288    let up_idx = line.to_lowercase().find(" up ")?;
289    let after_up = line[up_idx + 4..].trim();
290
291    // The uptime is everything before the user count ("N users" or "N user").
292    // Split on ", " and take parts until we hit something with "user".
293    let mut parts: Vec<&str> = Vec::new();
294    for part in after_up.split(", ") {
295        if part.trim().contains("user") {
296            break;
297        }
298        parts.push(part.trim());
299    }
300
301    let uptime_str = if parts.is_empty() {
302        after_up.to_string()
303    } else {
304        parts.join(", ")
305    };
306
307    // If uptime contains days, remove time portion (everything after "days")
308    // User request: show only "251 days" without ", 1:13"
309    if let Some(days_idx) = uptime_str.find(" days") {
310        let end = days_idx + 5; // " days".len()
311        Some(uptime_str[..end].to_string())
312    } else if let Some(day_idx) = uptime_str.find(" day,") {
313        let end = day_idx + 4; // " day".len()
314        Some(uptime_str[..end].to_string())
315    } else {
316        // For short uptimes (hours/minutes), keep as-is
317        Some(uptime_str)
318    }
319}
320
321/// Extract load averages from `cat /proc/loadavg` output.
322///
323/// Format: `0.15 0.10 0.08 1/423 12345`
324/// Returns the first three space-separated values as a display string.
325pub fn parse_loadavg(output: &str) -> Option<String> {
326    let line = output.lines().next()?;
327    let parts: Vec<&str> = line.split_whitespace().collect();
328    if parts.len() >= 3 {
329        Some(format!("{} {} {}", parts[0], parts[1], parts[2]))
330    } else {
331        None
332    }
333}
334
335// ---------------------------------------------------------------------------
336// Process list parser
337// ---------------------------------------------------------------------------
338
339/// Parse the top processes by CPU usage from `ps` output.
340///
341/// Expects lines of `%CPU %MEM COMMAND`, sorted by the remote `ps`. An optional
342/// header line and any malformed line are skipped (their first two columns are
343/// not numeric). Returns at most 3 entries in input order, or `None` if empty.
344pub fn parse_top_processes(output: &str) -> Option<Vec<ProcessInfo>> {
345    let mut procs: Vec<ProcessInfo> = Vec::new();
346    for line in output.lines() {
347        if procs.len() == 3 {
348            break;
349        }
350        let mut fields = line.split_whitespace();
351        let (Some(cpu_str), Some(mem_str)) = (fields.next(), fields.next()) else {
352            continue;
353        };
354        // The first two columns must be numeric — this skips the header row.
355        let (Ok(cpu), Ok(mem)) = (cpu_str.parse::<f64>(), mem_str.parse::<f64>()) else {
356            continue;
357        };
358        let name = fields.collect::<Vec<&str>>().join(" ");
359        if name.is_empty() {
360            continue;
361        }
362        procs.push(ProcessInfo {
363            name,
364            cpu_percent: cpu,
365            mem_percent: mem,
366        });
367    }
368    if procs.is_empty() {
369        None
370    } else {
371        Some(procs)
372    }
373}
374
375// ---------------------------------------------------------------------------
376// Tests
377// ---------------------------------------------------------------------------
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    // ---- CPU ----
384
385    #[test]
386    fn test_cpu_top_ubuntu_procps_ng() {
387        let out = "%Cpu(s):  2.3 us,  0.7 sy,  0.0 ni, 96.7 id,  0.3 wa,  0.0 hi,  0.0 si,  0.0 st";
388        let result = parse_cpu_top(out).expect("should parse");
389        assert!((result - 3.3).abs() < 0.1, "got {result}");
390    }
391
392    #[test]
393    fn test_cpu_top_centos_old_format() {
394        let out = "Cpu(s):  2.3%us,  0.7%sy,  0.0%ni, 96.7%id,  0.3%wa,  0.0%hi,  0.0%si,  0.0%st";
395        let result = parse_cpu_top(out).expect("should parse");
396        assert!((result - 3.3).abs() < 0.1, "got {result}");
397    }
398
399    #[test]
400    fn test_cpu_top_alpine_busybox() {
401        let out = "CPU:   4% usr   1% sys   0% nic  94% idle   0% io   0% irq   1% sirq";
402        let result = parse_cpu_top(out).expect("should parse");
403        assert!((result - 6.0).abs() < 0.1, "got {result}");
404    }
405
406    #[test]
407    fn test_cpu_top_macos() {
408        let out = "CPU usage: 3.17% user, 1.56% sys, 95.26% idle";
409        let result = parse_cpu_top_macos(out).expect("should parse");
410        assert!((result - 4.74).abs() < 0.1, "got {result}");
411    }
412
413    #[test]
414    fn test_cpu_top_macos_full_output() {
415        let out = "Processes: 412 total, 2 running, 410 sleeping, 2178 threads\n\
416                   2024/01/15 14:23:05\n\
417                   Load Avg: 1.52, 1.74, 1.89\n\
418                   CPU usage: 5.71% user, 2.57% sys, 91.71% idle\n\
419                   SharedLibs: 438M resident, 108M data, 24M linkedit.";
420        let result = parse_cpu_top_macos(out).expect("should parse");
421        assert!((result - 8.29).abs() < 0.1, "got {result}");
422    }
423
424    #[test]
425    fn test_cpu_proc_stat() {
426        // cpu  74608 2520 24433 1117073 6176 4054 0 0 0 0
427        let out = "cpu  74608 2520 24433 1117073 6176 4054 0 0 0 0";
428        let result = parse_cpu_proc_stat(out).expect("should parse");
429        assert!(result > 0.0 && result < 100.0, "got {result}");
430    }
431
432    #[test]
433    fn test_cpu_empty_returns_none() {
434        assert!(parse_cpu_top("").is_none());
435        assert!(parse_cpu_top_macos("").is_none());
436        assert!(parse_cpu_proc_stat("").is_none());
437    }
438
439    #[test]
440    fn test_cpu_unrecognized_returns_none() {
441        assert!(parse_cpu_top("no cpu info here").is_none());
442    }
443
444    // ---- RAM ----
445
446    #[test]
447    fn test_ram_free_modern_7col() {
448        // free -b output with available column
449        let out =
450            "              total        used        free      shared  buff/cache   available\n\
451                   Mem:    8192000000  3145728000   512000000   134217728  4534272000  4915200000\n\
452                   Swap:   2147483648           0  2147483648";
453        let result = parse_ram_free(out).expect("should parse");
454        // (8192000000 - 4915200000) / 8192000000 * 100 ≈ 39.99
455        assert!((result - 40.0).abs() < 1.0, "got {result}");
456    }
457
458    #[test]
459    fn test_ram_free_busybox_4col() {
460        // Alpine / busybox free output (no buff/cache, no available)
461        let out = "              total        used        free\n\
462                   Mem:       1018736      524288      494448";
463        let result = parse_ram_free(out).expect("should parse");
464        // (1018736 - 494448) / 1018736 ≈ 51.5
465        assert!((result - 51.5).abs() < 1.0, "got {result}");
466    }
467
468    #[test]
469    fn test_ram_empty_returns_none() {
470        assert!(parse_ram_free("").is_none());
471        assert!(parse_ram_vmstat("", "").is_none());
472    }
473
474    #[test]
475    fn test_ram_vmstat_macos() {
476        let vm_stat = "Mach Virtual Memory Statistics: (page size of 16384 bytes)\n\
477                       Pages free:                               23456.\n\
478                       Pages active:                           456789.\n\
479                       Pages inactive:                         123456.\n\
480                       Pages speculative:                        12345.\n\
481                       Pages throttled:                              0.\n\
482                       Pages wired down:                        98765.\n";
483        let memsize = "hw.memsize: 17179869184";
484        let result = parse_ram_vmstat(vm_stat, memsize).expect("should parse");
485        assert!(result > 0.0 && result <= 100.0, "got {result}");
486    }
487
488    // ---- Disk ----
489
490    #[test]
491    fn test_disk_df_linux() {
492        let out = "Filesystem     1K-blocks    Used Available Use% Mounted on\n\
493                   /dev/sda1       51475068 9000000  39841436  19% /";
494        let result = parse_disk_df(out).expect("should parse");
495        assert!((result - 19.0).abs() < 0.1, "got {result}");
496    }
497
498    #[test]
499    fn test_disk_df_macos() {
500        let out = "Filesystem   1024-blocks      Used Available Capacity iused ifree %iused  Mounted on\n\
501                   /dev/disk3s5   994662584 516879368 400765064    57% 5488234 4293478045    0%   /";
502        let result = parse_disk_df(out).expect("should parse");
503        assert!((result - 57.0).abs() < 0.1, "got {result}");
504    }
505
506    #[test]
507    fn test_disk_df_100pct() {
508        let out = "Filesystem     1K-blocks    Used Available Use% Mounted on\n\
509                   /dev/sda1       51475068 51475068          0 100% /";
510        let result = parse_disk_df(out).expect("should parse");
511        assert!((result - 100.0).abs() < 0.1, "got {result}");
512    }
513
514    #[test]
515    fn test_disk_empty_returns_none() {
516        assert!(parse_disk_df("").is_none());
517        assert!(parse_disk_df("only header\n").is_none());
518    }
519
520    // ---- Uptime ----
521
522    #[test]
523    fn test_uptime_linux_days() {
524        let out = " 14:23:45 up 2 days,  3:45,  2 users,  load average: 0.15, 0.10, 0.08";
525        let result = parse_uptime(out).expect("should parse");
526        // When uptime includes days, only show days portion (user request: no time after days)
527        assert_eq!(result, "2 days", "expected '2 days' only, got: {result}");
528    }
529
530    #[test]
531    fn test_uptime_linux_hours() {
532        let out = " 10:00:00 up  3:45,  1 user,  load average: 0.00, 0.01, 0.05";
533        let result = parse_uptime(out).expect("should parse");
534        assert!(result.contains("3:45"), "missing '3:45': {result}");
535    }
536
537    #[test]
538    fn test_uptime_empty_returns_none() {
539        assert!(parse_uptime("").is_none());
540    }
541
542    // ---- Load avg ----
543
544    #[test]
545    fn test_loadavg() {
546        let out = "0.15 0.10 0.08 1/423 12345\n";
547        let result = parse_loadavg(out).expect("should parse");
548        assert_eq!(result, "0.15 0.10 0.08");
549    }
550
551    // ---- Top processes ----
552
553    #[test]
554    fn test_top_processes_linux() {
555        let out = "%CPU %MEM COMMAND\n\
556                   12.3  4.5 firefox\n\
557                    8.1  2.0 node\n\
558                    3.0  1.1 sshd";
559        let procs = parse_top_processes(out).expect("should parse");
560        assert_eq!(procs.len(), 3);
561        assert_eq!(procs[0].name, "firefox");
562        assert!((procs[0].cpu_percent - 12.3).abs() < 0.01);
563        assert!((procs[0].mem_percent - 4.5).abs() < 0.01);
564        assert_eq!(procs[2].name, "sshd");
565    }
566
567    #[test]
568    fn test_top_processes_macos_header() {
569        let out = "%CPU %MEM COMM\n  5.0  3.2 WindowServer";
570        let procs = parse_top_processes(out).expect("should parse");
571        assert_eq!(procs.len(), 1);
572        assert_eq!(procs[0].name, "WindowServer");
573    }
574
575    #[test]
576    fn test_top_processes_command_with_spaces() {
577        let out = "%CPU %MEM COMMAND\n 2.5  9.0 postgres: writer process";
578        let procs = parse_top_processes(out).expect("should parse");
579        assert_eq!(procs[0].name, "postgres: writer process");
580    }
581
582    #[test]
583    fn test_top_processes_skips_malformed_lines() {
584        let out = "%CPU %MEM COMMAND\n\
585                   garbage line\n\
586                   10.0  1.0 redis\n\
587                   \n\
588                   only-one-field";
589        let procs = parse_top_processes(out).expect("should parse");
590        assert_eq!(procs.len(), 1);
591        assert_eq!(procs[0].name, "redis");
592    }
593
594    #[test]
595    fn test_top_processes_caps_at_three() {
596        let out = "%CPU %MEM COMMAND\n\
597                   5.0 1.0 a\n4.0 1.0 b\n3.0 1.0 c\n2.0 1.0 d\n1.0 1.0 e";
598        let procs = parse_top_processes(out).expect("should parse");
599        assert_eq!(procs.len(), 3);
600        assert_eq!(procs[2].name, "c");
601    }
602
603    #[test]
604    fn test_top_processes_no_header() {
605        // The collector now suppresses the `ps` header, so every line is data.
606        let out = "12.3  4.5 firefox\n 3.0  1.1 sshd";
607        let procs = parse_top_processes(out).expect("should parse");
608        assert_eq!(procs.len(), 2);
609        assert_eq!(procs[0].name, "firefox");
610        assert_eq!(procs[1].name, "sshd");
611    }
612
613    #[test]
614    fn test_top_processes_empty_returns_none() {
615        assert!(parse_top_processes("").is_none());
616        assert!(parse_top_processes("%CPU %MEM COMMAND\n").is_none());
617    }
618
619    // ---- Threshold level ----
620
621    #[test]
622    fn test_threshold_level() {
623        assert_eq!(threshold_level(0.0), ThresholdLevel::Ok);
624        assert_eq!(threshold_level(59.9), ThresholdLevel::Ok);
625        assert_eq!(threshold_level(60.0), ThresholdLevel::Warn);
626        assert_eq!(threshold_level(85.0), ThresholdLevel::Warn);
627        assert_eq!(threshold_level(85.1), ThresholdLevel::Crit);
628        assert_eq!(threshold_level(100.0), ThresholdLevel::Crit);
629    }
630}