Skip to main content

rigtop_core/
system.rs

1//! Portable statistics via the `sysinfo` crate: CPU load, memory, processes,
2//! network traffic and filesystem capacity.
3//!
4//! Platform-specific extras (sensors, RAID, per-device I/O) live in
5//! [`crate::linux`] and are merged by [`crate::source::LocalSource`]. On other
6//! platforms this module also provides per-volume I/O and the temperatures
7//! `sysinfo` can see.
8
9use std::collections::HashMap;
10
11use sysinfo::{
12    CpuRefreshKind, DiskRefreshKind, Disks, MemoryRefreshKind, Networks, ProcessRefreshKind,
13    ProcessesToUpdate, System, ThreadKind, UpdateKind, Users,
14};
15
16use crate::snapshot::{
17    CpuStats, FilesystemUsage, HostInfo, MemoryStats, NetworkStats, ProcessInfo,
18};
19#[cfg(not(target_os = "linux"))]
20use crate::snapshot::{DiskIo, SensorChip, Temperature};
21
22/// What to refresh for disks: capacity everywhere; per-volume I/O counters
23/// only where `/proc/diskstats` (per physical device) is not available.
24fn disk_refresh_kind() -> DiskRefreshKind {
25    let kind = DiskRefreshKind::nothing().with_storage();
26    if cfg!(target_os = "linux") {
27        kind
28    } else {
29        kind.with_io_usage()
30    }
31}
32
33/// Filesystem types that are never interesting in a disk panel.
34const PSEUDO_FILESYSTEMS: &[&str] = &[
35    "tmpfs",
36    "devtmpfs",
37    "squashfs",
38    "overlay",
39    "efivarfs",
40    "ramfs",
41    "proc",
42    "sysfs",
43    "cgroup2",
44    "fuse.portal",
45    "fuse.snapfuse",
46];
47
48/// Wraps the `sysinfo` handles that must persist between refreshes
49/// (CPU usage and rates are computed from differences).
50pub struct SystemCollector {
51    system: System,
52    networks: Networks,
53    disks: Disks,
54    users: Users,
55    /// Temperature sensors, where hwmon is not available.
56    #[cfg(not(target_os = "linux"))]
57    components: sysinfo::Components,
58}
59
60impl SystemCollector {
61    /// Creates the collector and takes an initial reading.
62    pub fn new() -> Self {
63        let mut collector = Self {
64            system: System::new(),
65            networks: Networks::new_with_refreshed_list(),
66            disks: Disks::new_with_refreshed_list_specifics(disk_refresh_kind()),
67            users: Users::new_with_refreshed_list(),
68            #[cfg(not(target_os = "linux"))]
69            components: sysinfo::Components::new_with_refreshed_list(),
70        };
71        collector.refresh();
72        collector
73    }
74
75    /// Refreshes everything this collector reports.
76    pub fn refresh(&mut self) {
77        self.system
78            .refresh_cpu_specifics(CpuRefreshKind::nothing().with_cpu_usage().with_frequency());
79        self.system
80            .refresh_memory_specifics(MemoryRefreshKind::everything());
81        self.system.refresh_processes_specifics(
82            ProcessesToUpdate::All,
83            true,
84            ProcessRefreshKind::nothing()
85                .with_cpu()
86                .with_memory()
87                .with_disk_usage()
88                .with_tasks()
89                .with_user(UpdateKind::OnlyIfNotSet)
90                .with_cmd(UpdateKind::OnlyIfNotSet),
91        );
92        self.networks.refresh(true);
93        self.disks.refresh_specifics(true, disk_refresh_kind());
94    }
95
96    /// Host name, OS and uptime.
97    pub fn host(&self) -> HostInfo {
98        HostInfo {
99            hostname: System::host_name().unwrap_or_default(),
100            os: System::long_os_version().unwrap_or_default(),
101            kernel: System::kernel_version().unwrap_or_default(),
102            uptime_secs: System::uptime(),
103        }
104    }
105
106    /// CPU load and frequencies. Temperatures are filled in by the caller.
107    pub fn cpu(&self) -> CpuStats {
108        let cpus = self.system.cpus();
109        let load = System::load_average();
110        CpuStats {
111            model: cpus
112                .first()
113                .map(|c| c.brand().trim().to_owned())
114                .unwrap_or_default(),
115            total_percent: self.system.global_cpu_usage(),
116            per_core_percent: cpus.iter().map(|c| c.cpu_usage()).collect(),
117            per_core_mhz: cpus.iter().map(|c| c.frequency()).collect(),
118            // Windows has no load average (sysinfo reports zeros).
119            load_average: cfg!(unix).then_some([load.one, load.five, load.fifteen]),
120            temperatures: Vec::new(),
121        }
122    }
123
124    /// Memory and swap. `cached_bytes` is filled in by the caller where available.
125    pub fn memory(&self) -> MemoryStats {
126        let s = &self.system;
127        MemoryStats {
128            total_bytes: s.total_memory(),
129            used_bytes: s.used_memory(),
130            available_bytes: s.available_memory(),
131            free_bytes: s.free_memory(),
132            cached_bytes: None,
133            swap_total_bytes: s.total_swap(),
134            swap_used_bytes: s.used_swap(),
135        }
136    }
137
138    /// Network traffic per interface. `elapsed_secs` is the time since the
139    /// previous refresh; rates are zero when it is unknown.
140    pub fn networks(&self, elapsed_secs: Option<f64>) -> Vec<NetworkStats> {
141        let mut networks: Vec<NetworkStats> = self
142            .networks
143            .iter()
144            // Loopback traffic is local IPC, not network activity.
145            .filter(|(name, _)| name.as_str() != "lo")
146            .map(|(name, data)| NetworkStats {
147                name: name.clone(),
148                rx_bytes_per_sec: rate(data.received(), elapsed_secs),
149                tx_bytes_per_sec: rate(data.transmitted(), elapsed_secs),
150                rx_total_bytes: data.total_received(),
151                tx_total_bytes: data.total_transmitted(),
152                // Link state is platform-specific and filled in by the caller.
153                ..Default::default()
154            })
155            .collect();
156        networks.sort_by(|a, b| a.name.cmp(&b.name));
157        networks
158    }
159
160    /// Mounted filesystems, merged by source device so that btrfs subvolumes
161    /// and bind mounts appear once.
162    pub fn filesystems(&self) -> Vec<FilesystemUsage> {
163        let mut by_device: HashMap<String, FilesystemUsage> = HashMap::new();
164        for disk in self.disks.list() {
165            let fs_type = disk.file_system().to_string_lossy().into_owned();
166            if PSEUDO_FILESYSTEMS.contains(&fs_type.as_str()) || disk.total_space() == 0 {
167                continue;
168            }
169            let device = disk.name().to_string_lossy().into_owned();
170            let mount_point = disk.mount_point().to_string_lossy().into_owned();
171            // On Unix the name is the source device, which merges btrfs
172            // subvolumes and bind mounts. On Windows it is the volume label,
173            // often empty or shared, so each drive letter stands alone.
174            let key = if cfg!(windows) || device.is_empty() {
175                mount_point.clone()
176            } else {
177                device.clone()
178            };
179            let entry = by_device.entry(key).or_insert_with(|| FilesystemUsage {
180                device,
181                fs_type,
182                mount_points: Vec::new(),
183                total_bytes: disk.total_space(),
184                available_bytes: disk.available_space(),
185            });
186            entry.mount_points.push(mount_point);
187        }
188        let mut filesystems: Vec<FilesystemUsage> = by_device.into_values().collect();
189        for fs in &mut filesystems {
190            fs.mount_points.sort_by_key(|m| (m.len(), m.clone()));
191        }
192        // Order by the most prominent mount point ("/" first).
193        filesystems.sort_by(|a, b| {
194            let key = |f: &FilesystemUsage| f.mount_points.first().cloned().unwrap_or_default();
195            (key(a).len(), key(a)).cmp(&(key(b).len(), key(b)))
196        });
197        filesystems
198    }
199
200    /// I/O rates per mounted volume, for platforms without per-device
201    /// counters (Linux uses `/proc/diskstats` instead).
202    #[cfg(not(target_os = "linux"))]
203    pub fn volume_io(&self, elapsed_secs: Option<f64>) -> Vec<DiskIo> {
204        let mut volumes: Vec<DiskIo> = self
205            .disks
206            .list()
207            .iter()
208            .filter(|disk| disk.total_space() > 0)
209            .map(|disk| {
210                let usage = disk.usage();
211                // "C:\" -> "C:"; elsewhere the mount point as is.
212                let mount = disk.mount_point().to_string_lossy();
213                let name = mount.trim_end_matches(['\\', '/']);
214                DiskIo {
215                    name: if name.is_empty() {
216                        mount.to_string()
217                    } else {
218                        name.to_owned()
219                    },
220                    read_bytes_per_sec: rate(usage.read_bytes, elapsed_secs),
221                    write_bytes_per_sec: rate(usage.written_bytes, elapsed_secs),
222                    ..Default::default()
223                }
224            })
225            .collect();
226        volumes.sort_by(|a, b| a.name.cmp(&b.name));
227        volumes.dedup_by(|a, b| a.name == b.name);
228        volumes
229    }
230
231    /// Temperatures `sysinfo` can read (on Windows only what WMI exposes,
232    /// often nothing without vendor drivers), as one sensor chip plus the
233    /// readings that look like CPU sensors.
234    #[cfg(not(target_os = "linux"))]
235    pub fn components(&mut self) -> (Vec<SensorChip>, Vec<Temperature>) {
236        self.components.refresh(true);
237        let temperatures: Vec<Temperature> = self
238            .components
239            .list()
240            .iter()
241            .filter_map(|c| {
242                Some(Temperature {
243                    key: format!("system/{}", c.label()),
244                    label: c.label().to_owned(),
245                    celsius: c.temperature()?,
246                    max_celsius: c.max(),
247                    critical_celsius: c.critical(),
248                })
249            })
250            .collect();
251        let cpu: Vec<Temperature> = temperatures
252            .iter()
253            .filter(|t| is_cpu_sensor_label(&t.label))
254            .cloned()
255            .collect();
256        let chips = if temperatures.is_empty() {
257            Vec::new()
258        } else {
259            vec![SensorChip {
260                key: "system".to_owned(),
261                name: "system".to_owned(),
262                temperatures,
263                fans: Vec::new(),
264            }]
265        };
266        (chips, cpu)
267    }
268
269    /// All processes (threads excluded). `elapsed_secs` converts the I/O
270    /// byte counts since the previous refresh into rates.
271    pub fn processes(&mut self, elapsed_secs: Option<f64>) -> Vec<ProcessInfo> {
272        // Resolve uids lazily: refresh the user list only when an unknown uid shows up
273        // (e.g. a user created after rigtop started).
274        let unknown_uid = self.system.processes().values().any(|p| {
275            p.user_id()
276                .is_some_and(|uid| self.users.get_user_by_id(uid).is_none())
277        });
278        if unknown_uid {
279            self.users.refresh();
280        }
281
282        self.system
283            .processes()
284            .values()
285            // On Linux, sysinfo also lists the threads of each process as
286            // entries; drop them. Kernel threads (kworker, ...) are kept and
287            // flagged, like btop does.
288            .filter(|p| p.thread_kind() != Some(ThreadKind::Userland))
289            .map(|p| {
290                let disk = p.disk_usage();
291                let user = p
292                    .user_id()
293                    .map(|uid| {
294                        self.users
295                            .get_user_by_id(uid)
296                            .map(|u| u.name().to_owned())
297                            .unwrap_or_else(|| uid.to_string())
298                    })
299                    .unwrap_or_default();
300                let command = p
301                    .cmd()
302                    .iter()
303                    .map(|arg| arg.to_string_lossy())
304                    .collect::<Vec<_>>()
305                    .join(" ");
306                ProcessInfo {
307                    pid: p.pid().as_u32(),
308                    parent_pid: p.parent().map(|pid| pid.as_u32()),
309                    name: p.name().to_string_lossy().into_owned(),
310                    command,
311                    user,
312                    state: p.status().to_string(),
313                    cpu_percent: p.cpu_usage(),
314                    memory_bytes: p.memory(),
315                    // The task set includes the main thread itself.
316                    threads: p.tasks().map(|t| t.len().max(1) as u32),
317                    read_bytes_per_sec: rate(disk.read_bytes, elapsed_secs),
318                    write_bytes_per_sec: rate(disk.written_bytes, elapsed_secs),
319                    run_time_secs: p.run_time(),
320                    kernel_thread: p.thread_kind() == Some(ThreadKind::Kernel),
321                }
322            })
323            .collect()
324    }
325}
326
327impl Default for SystemCollector {
328    fn default() -> Self {
329        Self::new()
330    }
331}
332
333/// Whether a sensor label from `sysinfo` describes the CPU (e.g. "CPU",
334/// "CPU Package", "Tctl", "Core 0").
335#[cfg_attr(target_os = "linux", allow(dead_code))]
336fn is_cpu_sensor_label(label: &str) -> bool {
337    let label = label.to_lowercase();
338    ["cpu", "package", "tctl", "tdie", "tccd", "core "]
339        .iter()
340        .any(|word| label.contains(word))
341}
342
343/// Converts a byte count accumulated over `elapsed_secs` into bytes per second.
344fn rate(bytes: u64, elapsed_secs: Option<f64>) -> f64 {
345    match elapsed_secs {
346        Some(secs) if secs > 0.0 => bytes as f64 / secs,
347        _ => 0.0,
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn cpu_sensor_labels() {
357        for label in [
358            "CPU",
359            "CPU Package",
360            "Tctl",
361            "Core 3",
362            "coretemp Package id 0",
363        ] {
364            assert!(is_cpu_sensor_label(label), "{label}");
365        }
366        for label in ["GPU", "nvme Composite", "acpitz"] {
367            assert!(!is_cpu_sensor_label(label), "{label}");
368        }
369    }
370}