1use 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
22fn 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
33const 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
48pub struct SystemCollector {
51 system: System,
52 networks: Networks,
53 disks: Disks,
54 users: Users,
55 #[cfg(not(target_os = "linux"))]
57 components: sysinfo::Components,
58}
59
60impl SystemCollector {
61 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 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 .without_tasks()
93 .with_user(UpdateKind::OnlyIfNotSet)
94 .with_cmd(UpdateKind::OnlyIfNotSet),
95 );
96 self.networks.refresh(true);
97 self.disks.refresh_specifics(true, disk_refresh_kind());
98 }
99
100 pub fn host(&self) -> HostInfo {
102 HostInfo {
103 hostname: System::host_name().unwrap_or_default(),
104 os: System::long_os_version().unwrap_or_default(),
105 kernel: System::kernel_version().unwrap_or_default(),
106 uptime_secs: System::uptime(),
107 rigtop_version: env!("CARGO_PKG_VERSION").to_owned(),
108 reboot_required: None,
109 }
110 }
111
112 pub fn cpu(&self) -> CpuStats {
114 let cpus = self.system.cpus();
115 let load = System::load_average();
116 CpuStats {
117 model: cpus
118 .first()
119 .map(|c| c.brand().trim().to_owned())
120 .unwrap_or_default(),
121 total_percent: self.system.global_cpu_usage(),
122 per_core_percent: cpus.iter().map(|c| c.cpu_usage()).collect(),
123 per_core_mhz: cpus.iter().map(|c| c.frequency()).collect(),
124 load_average: cfg!(unix).then_some([load.one, load.five, load.fifteen]),
126 temperatures: Vec::new(),
127 per_core_celsius: Vec::new(),
128 }
129 }
130
131 pub fn memory(&self) -> MemoryStats {
133 let s = &self.system;
134 let available_bytes = if cfg!(target_os = "macos") {
139 s.total_memory().saturating_sub(s.used_memory())
140 } else {
141 s.available_memory()
142 };
143 #[cfg(target_os = "macos")]
144 let cached_bytes = macos_cached_bytes();
145 #[cfg(not(target_os = "macos"))]
146 let cached_bytes = None;
147 MemoryStats {
148 total_bytes: s.total_memory(),
149 used_bytes: s.used_memory(),
150 available_bytes,
151 free_bytes: s.free_memory(),
152 cached_bytes,
153 swap_total_bytes: s.total_swap(),
154 swap_used_bytes: s.used_swap(),
155 oom_kills: Vec::new(),
156 }
157 }
158
159 pub fn networks(&self, elapsed_secs: Option<f64>) -> Vec<NetworkStats> {
162 let mut networks: Vec<NetworkStats> = self
163 .networks
164 .iter()
165 .filter(|(name, _)| name.as_str() != "lo")
167 .map(|(name, data)| NetworkStats {
168 name: name.clone(),
169 rx_bytes_per_sec: rate(data.received(), elapsed_secs),
170 tx_bytes_per_sec: rate(data.transmitted(), elapsed_secs),
171 rx_total_bytes: data.total_received(),
172 tx_total_bytes: data.total_transmitted(),
173 addresses: addresses(data.ip_networks()),
174 ..Default::default()
176 })
177 .collect();
178 networks.sort_by(|a, b| a.name.cmp(&b.name));
179 networks
180 }
181
182 pub fn filesystems(&self) -> Vec<FilesystemUsage> {
185 let mut by_device: HashMap<String, FilesystemUsage> = HashMap::new();
186 for disk in self.disks.list() {
187 let fs_type = disk.file_system().to_string_lossy().into_owned();
188 if PSEUDO_FILESYSTEMS.contains(&fs_type.as_str()) || disk.total_space() == 0 {
189 continue;
190 }
191 let device = disk.name().to_string_lossy().into_owned();
192 let mount_point = disk.mount_point().to_string_lossy().into_owned();
193 let key = if cfg!(windows) || device.is_empty() {
197 mount_point.clone()
198 } else {
199 device.clone()
200 };
201 let entry = by_device.entry(key).or_insert_with(|| FilesystemUsage {
202 device,
203 fs_type,
204 mount_points: Vec::new(),
205 total_bytes: disk.total_space(),
206 available_bytes: disk.available_space(),
207 full_in_secs: None,
208 });
209 entry.mount_points.push(mount_point);
210 }
211 let mut filesystems: Vec<FilesystemUsage> = by_device.into_values().collect();
212 for fs in &mut filesystems {
213 fs.mount_points.sort_by_key(|m| (m.len(), m.clone()));
214 }
215 filesystems.sort_by(|a, b| {
217 let key = |f: &FilesystemUsage| f.mount_points.first().cloned().unwrap_or_default();
218 (key(a).len(), key(a)).cmp(&(key(b).len(), key(b)))
219 });
220 filesystems
221 }
222
223 #[cfg(not(target_os = "linux"))]
226 pub fn volume_io(&self, elapsed_secs: Option<f64>) -> Vec<DiskIo> {
227 let mut volumes: Vec<DiskIo> = self
228 .disks
229 .list()
230 .iter()
231 .filter(|disk| disk.total_space() > 0)
232 .map(|disk| {
233 let usage = disk.usage();
234 let mount = disk.mount_point().to_string_lossy();
236 let name = mount.trim_end_matches(['\\', '/']);
237 DiskIo {
238 name: if name.is_empty() {
239 mount.to_string()
240 } else {
241 name.to_owned()
242 },
243 read_bytes_per_sec: rate(usage.read_bytes, elapsed_secs),
244 write_bytes_per_sec: rate(usage.written_bytes, elapsed_secs),
245 ..Default::default()
246 }
247 })
248 .collect();
249 volumes.sort_by(|a, b| a.name.cmp(&b.name));
250 volumes.dedup_by(|a, b| a.name == b.name);
251 volumes
252 }
253
254 #[cfg(not(target_os = "linux"))]
258 pub fn components(&mut self) -> (Vec<SensorChip>, Vec<Temperature>) {
259 self.components.refresh(true);
260 let temperatures: Vec<Temperature> = self
261 .components
262 .list()
263 .iter()
264 .filter_map(|c| {
265 Some(Temperature {
266 key: format!("system/{}", c.label()),
267 label: c.label().to_owned(),
268 celsius: c.temperature()?,
269 max_celsius: c.max(),
270 critical_celsius: c.critical(),
271 })
272 })
273 .collect();
274 let cpu: Vec<Temperature> = temperatures
275 .iter()
276 .filter(|t| is_cpu_sensor_label(&t.label))
277 .cloned()
278 .collect();
279 let chips = if temperatures.is_empty() {
280 Vec::new()
281 } else {
282 vec![SensorChip {
283 key: "system".to_owned(),
284 name: "system".to_owned(),
285 temperatures,
286 fans: Vec::new(),
287 }]
288 };
289 (chips, cpu)
290 }
291
292 pub fn processes(&mut self, elapsed_secs: Option<f64>) -> Vec<ProcessInfo> {
295 let unknown_uid = self.system.processes().values().any(|p| {
298 p.user_id()
299 .is_some_and(|uid| self.users.get_user_by_id(uid).is_none())
300 });
301 if unknown_uid {
302 self.users.refresh();
303 }
304
305 self.system
306 .processes()
307 .values()
308 .filter(|p| p.thread_kind() != Some(ThreadKind::Userland))
312 .map(|p| {
313 let disk = p.disk_usage();
314 let user = p
315 .user_id()
316 .map(|uid| {
317 self.users
318 .get_user_by_id(uid)
319 .map(|u| u.name().to_owned())
320 .unwrap_or_else(|| uid.to_string())
321 })
322 .unwrap_or_default();
323 let command = p
324 .cmd()
325 .iter()
326 .map(|arg| arg.to_string_lossy())
327 .collect::<Vec<_>>()
328 .join(" ");
329 let name = p.name().to_string_lossy();
330 let leading: Vec<_> = p
331 .cmd()
332 .iter()
333 .take(2)
334 .map(|a| a.to_string_lossy())
335 .collect();
336 let leading: Vec<&str> = leading.iter().map(|a| a.as_ref()).collect();
337 ProcessInfo {
338 pid: p.pid().as_u32(),
339 parent_pid: p.parent().map(|pid| pid.as_u32()),
340 name: full_name(&name, &leading),
341 command,
342 user,
343 state: p.status().to_string(),
344 cpu_percent: p.cpu_usage(),
345 memory_bytes: p.memory(),
346 threads: None,
349 nice: None,
351 read_bytes_per_sec: rate(disk.read_bytes, elapsed_secs),
352 write_bytes_per_sec: rate(disk.written_bytes, elapsed_secs),
353 run_time_secs: p.run_time(),
354 kernel_thread: p.thread_kind() == Some(ThreadKind::Kernel),
355 container: None,
357 }
358 })
359 .collect()
360 }
361}
362
363impl Default for SystemCollector {
364 fn default() -> Self {
365 Self::new()
366 }
367}
368
369#[cfg_attr(target_os = "linux", allow(dead_code))]
372fn is_cpu_sensor_label(label: &str) -> bool {
373 let label = label.to_lowercase();
374 ["cpu", "package", "tctl", "tdie", "tccd", "core "]
375 .iter()
376 .any(|word| label.contains(word))
377}
378
379#[cfg(target_os = "macos")]
382fn macos_cached_bytes() -> Option<u64> {
383 use std::sync::OnceLock;
384
385 static HOST: OnceLock<libc::mach_port_t> = OnceLock::new();
387 #[allow(deprecated)]
389 let host = *HOST.get_or_init(|| unsafe { libc::mach_host_self() });
390
391 let mut stats: libc::vm_statistics64 = unsafe { std::mem::zeroed() };
393 let mut count = libc::HOST_VM_INFO64_COUNT;
394 let result = unsafe {
397 libc::host_statistics64(
398 host,
399 libc::HOST_VM_INFO64,
400 (&mut stats as *mut libc::vm_statistics64).cast(),
401 &mut count,
402 )
403 };
404 if result != libc::KERN_SUCCESS {
405 return None;
406 }
407 let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
409 let page_size = u64::try_from(page_size).ok().filter(|&size| size > 0)?;
410 Some(u64::from(stats.external_page_count) * page_size)
411}
412
413fn rate(bytes: u64, elapsed_secs: Option<f64>) -> f64 {
415 match elapsed_secs {
416 Some(secs) if secs > 0.0 => bytes as f64 / secs,
417 _ => 0.0,
418 }
419}
420
421const COMM_LEN: usize = 15;
424
425fn full_name(name: &str, leading: &[&str]) -> String {
431 let file = |arg: &str| {
432 arg.rsplit(['/', '\\'])
433 .next()
434 .unwrap_or_default()
435 .trim()
436 .to_owned()
437 };
438 if name == "MainThread"
439 && let Some(program) = leading.first().map(|a| file(a)).filter(|f| !f.is_empty())
440 {
441 return program;
442 }
443 if cfg!(target_os = "linux") && name.len() == COMM_LEN {
445 for arg in leading {
446 let file = file(arg);
447 if file.len() > name.len() && file.starts_with(name) {
448 return file;
449 }
450 }
451 }
452 name.to_owned()
453}
454
455fn addresses(networks: &[sysinfo::IpNetwork]) -> Vec<String> {
459 let mut found: Vec<&sysinfo::IpNetwork> = networks
460 .iter()
461 .filter(|n| match n.addr {
462 std::net::IpAddr::V4(_) => true,
463 std::net::IpAddr::V6(v6) => v6.segments()[0] & 0xffc0 != 0xfe80,
465 })
466 .collect();
467 found.sort_by_key(|n| (n.addr.is_ipv6(), n.addr));
468 found.dedup_by_key(|n| n.addr);
469 found
470 .into_iter()
471 .map(|n| format!("{}/{}", n.addr, n.prefix))
472 .collect()
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 #[test]
480 fn names_cut_short_or_renamed_take_the_program_file() {
481 let linux = cfg!(target_os = "linux");
482 let cut = full_name(
483 "gnome-keyring-d",
484 &["/usr/bin/gnome-keyring-daemon", "--start"],
485 );
486 assert_eq!(
487 cut,
488 if linux {
489 "gnome-keyring-daemon"
490 } else {
491 "gnome-keyring-d"
492 }
493 );
494 let script = full_name(
495 "networkd-dispat",
496 &["/usr/bin/python3", "/usr/bin/networkd-dispatcher"],
497 );
498 assert_eq!(
499 script,
500 if linux {
501 "networkd-dispatcher"
502 } else {
503 "networkd-dispat"
504 }
505 );
506 assert_eq!(
507 full_name("MainThread", &["/usr/local/bin/node", "server.js"]),
508 "node"
509 );
510 assert_eq!(full_name("python3", &["/usr/bin/python3.12"]), "python3");
512 assert_eq!(
515 full_name(
516 "Isolated Web Co",
517 &["/usr/lib/firefox/firefox", "-contentproc"]
518 ),
519 "Isolated Web Co"
520 );
521 assert_eq!(
522 full_name("postgres", &["postgres: checkpointer"]),
523 "postgres"
524 );
525 assert_eq!(full_name("kworker/0:1H-ev", &[]), "kworker/0:1H-ev");
527 assert_eq!(full_name("MainThread", &[]), "MainThread");
528 }
529
530 #[test]
531 fn cpu_sensor_labels() {
532 for label in [
533 "CPU",
534 "CPU Package",
535 "Tctl",
536 "Core 3",
537 "coretemp Package id 0",
538 ] {
539 assert!(is_cpu_sensor_label(label), "{label}");
540 }
541 for label in ["GPU", "nvme Composite", "acpitz"] {
542 assert!(!is_cpu_sensor_label(label), "{label}");
543 }
544 }
545
546 #[cfg(target_os = "macos")]
547 #[test]
548 fn macos_memory_adds_up() {
549 let memory = SystemCollector::new().memory();
550 assert!(memory.total_bytes > 0);
551 assert!(memory.used_bytes + memory.available_bytes <= memory.total_bytes);
552 let cached = memory.cached_bytes.expect("host_statistics64 answers");
553 assert!(cached > 0 && cached < memory.total_bytes, "{cached}");
554 }
555}