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 zfs_arc_bytes: None,
157 }
158 }
159
160 pub fn networks(&self, elapsed_secs: Option<f64>) -> Vec<NetworkStats> {
163 let mut networks: Vec<NetworkStats> = self
164 .networks
165 .iter()
166 .filter(|(name, _)| name.as_str() != "lo")
168 .map(|(name, data)| NetworkStats {
169 name: name.clone(),
170 rx_bytes_per_sec: rate(data.received(), elapsed_secs),
171 tx_bytes_per_sec: rate(data.transmitted(), elapsed_secs),
172 rx_total_bytes: data.total_received(),
173 tx_total_bytes: data.total_transmitted(),
174 addresses: addresses(data.ip_networks()),
175 ..Default::default()
177 })
178 .collect();
179 networks.sort_by(|a, b| a.name.cmp(&b.name));
180 networks
181 }
182
183 pub fn filesystems(&self) -> Vec<FilesystemUsage> {
186 let mut by_device: HashMap<String, FilesystemUsage> = HashMap::new();
187 for disk in self.disks.list() {
188 let fs_type = disk.file_system().to_string_lossy().into_owned();
189 if PSEUDO_FILESYSTEMS.contains(&fs_type.as_str()) || disk.total_space() == 0 {
190 continue;
191 }
192 let device = disk.name().to_string_lossy().into_owned();
193 let mount_point = disk.mount_point().to_string_lossy().into_owned();
194 let key = if cfg!(windows) || device.is_empty() {
198 mount_point.clone()
199 } else {
200 device.clone()
201 };
202 let entry = by_device.entry(key).or_insert_with(|| FilesystemUsage {
203 device,
204 fs_type,
205 mount_points: Vec::new(),
206 total_bytes: disk.total_space(),
207 available_bytes: disk.available_space(),
208 full_in_secs: None,
209 });
210 entry.mount_points.push(mount_point);
211 }
212 let mut filesystems: Vec<FilesystemUsage> = by_device.into_values().collect();
213 for fs in &mut filesystems {
214 fs.mount_points.sort_by_key(|m| (m.len(), m.clone()));
215 }
216 filesystems.sort_by(|a, b| {
218 let key = |f: &FilesystemUsage| f.mount_points.first().cloned().unwrap_or_default();
219 (key(a).len(), key(a)).cmp(&(key(b).len(), key(b)))
220 });
221 filesystems
222 }
223
224 #[cfg(not(target_os = "linux"))]
227 pub fn volume_io(&self, elapsed_secs: Option<f64>) -> Vec<DiskIo> {
228 let mut volumes: Vec<DiskIo> = self
229 .disks
230 .list()
231 .iter()
232 .filter(|disk| disk.total_space() > 0)
233 .map(|disk| {
234 let usage = disk.usage();
235 let mount = disk.mount_point().to_string_lossy();
237 let name = mount.trim_end_matches(['\\', '/']);
238 DiskIo {
239 name: if name.is_empty() {
240 mount.to_string()
241 } else {
242 name.to_owned()
243 },
244 read_bytes_per_sec: rate(usage.read_bytes, elapsed_secs),
245 write_bytes_per_sec: rate(usage.written_bytes, elapsed_secs),
246 ..Default::default()
247 }
248 })
249 .collect();
250 volumes.sort_by(|a, b| a.name.cmp(&b.name));
251 volumes.dedup_by(|a, b| a.name == b.name);
252 volumes
253 }
254
255 #[cfg(not(target_os = "linux"))]
259 pub fn components(&mut self) -> (Vec<SensorChip>, Vec<Temperature>) {
260 self.components.refresh(true);
261 let temperatures: Vec<Temperature> = self
262 .components
263 .list()
264 .iter()
265 .filter_map(|c| {
266 Some(Temperature {
267 key: format!("system/{}", c.label()),
268 label: c.label().to_owned(),
269 celsius: c.temperature()?,
270 max_celsius: c.max(),
271 critical_celsius: c.critical(),
272 })
273 })
274 .collect();
275 let cpu: Vec<Temperature> = temperatures
276 .iter()
277 .filter(|t| is_cpu_sensor_label(&t.label))
278 .cloned()
279 .collect();
280 let chips = if temperatures.is_empty() {
281 Vec::new()
282 } else {
283 vec![SensorChip {
284 key: "system".to_owned(),
285 name: "system".to_owned(),
286 temperatures,
287 fans: Vec::new(),
288 }]
289 };
290 (chips, cpu)
291 }
292
293 pub fn processes(&mut self, elapsed_secs: Option<f64>) -> Vec<ProcessInfo> {
296 let unknown_uid = self.system.processes().values().any(|p| {
299 p.user_id()
300 .is_some_and(|uid| self.users.get_user_by_id(uid).is_none())
301 });
302 if unknown_uid {
303 self.users.refresh();
304 }
305
306 self.system
307 .processes()
308 .values()
309 .filter(|p| p.thread_kind() != Some(ThreadKind::Userland))
313 .map(|p| {
314 let disk = p.disk_usage();
315 let user = p
316 .user_id()
317 .map(|uid| {
318 self.users
319 .get_user_by_id(uid)
320 .map(|u| u.name().to_owned())
321 .unwrap_or_else(|| uid.to_string())
322 })
323 .unwrap_or_default();
324 let command = p
325 .cmd()
326 .iter()
327 .map(|arg| arg.to_string_lossy())
328 .collect::<Vec<_>>()
329 .join(" ");
330 let name = p.name().to_string_lossy();
331 let leading: Vec<_> = p
332 .cmd()
333 .iter()
334 .take(2)
335 .map(|a| a.to_string_lossy())
336 .collect();
337 let leading: Vec<&str> = leading.iter().map(|a| a.as_ref()).collect();
338 ProcessInfo {
339 pid: p.pid().as_u32(),
340 parent_pid: p.parent().map(|pid| pid.as_u32()),
341 name: full_name(&name, &leading),
342 command,
343 user,
344 state: p.status().to_string(),
345 cpu_percent: p.cpu_usage(),
346 memory_bytes: p.memory(),
347 threads: None,
350 nice: None,
352 read_bytes_per_sec: rate(disk.read_bytes, elapsed_secs),
353 write_bytes_per_sec: rate(disk.written_bytes, elapsed_secs),
354 run_time_secs: p.run_time(),
355 kernel_thread: p.thread_kind() == Some(ThreadKind::Kernel),
356 container: None,
358 }
359 })
360 .collect()
361 }
362}
363
364impl Default for SystemCollector {
365 fn default() -> Self {
366 Self::new()
367 }
368}
369
370#[cfg_attr(target_os = "linux", allow(dead_code))]
373fn is_cpu_sensor_label(label: &str) -> bool {
374 let label = label.to_lowercase();
375 ["cpu", "package", "tctl", "tdie", "tccd", "core "]
376 .iter()
377 .any(|word| label.contains(word))
378}
379
380#[cfg(target_os = "macos")]
383fn macos_cached_bytes() -> Option<u64> {
384 use std::sync::OnceLock;
385
386 static HOST: OnceLock<libc::mach_port_t> = OnceLock::new();
388 #[allow(deprecated)]
390 let host = *HOST.get_or_init(|| unsafe { libc::mach_host_self() });
391
392 let mut stats: libc::vm_statistics64 = unsafe { std::mem::zeroed() };
394 let mut count = libc::HOST_VM_INFO64_COUNT;
395 let result = unsafe {
398 libc::host_statistics64(
399 host,
400 libc::HOST_VM_INFO64,
401 (&mut stats as *mut libc::vm_statistics64).cast(),
402 &mut count,
403 )
404 };
405 if result != libc::KERN_SUCCESS {
406 return None;
407 }
408 let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
410 let page_size = u64::try_from(page_size).ok().filter(|&size| size > 0)?;
411 Some(u64::from(stats.external_page_count) * page_size)
412}
413
414fn rate(bytes: u64, elapsed_secs: Option<f64>) -> f64 {
416 match elapsed_secs {
417 Some(secs) if secs > 0.0 => bytes as f64 / secs,
418 _ => 0.0,
419 }
420}
421
422const COMM_LEN: usize = 15;
425
426fn full_name(name: &str, leading: &[&str]) -> String {
432 let file = |arg: &str| {
433 arg.rsplit(['/', '\\'])
434 .next()
435 .unwrap_or_default()
436 .trim()
437 .to_owned()
438 };
439 if name == "MainThread"
440 && let Some(program) = leading.first().map(|a| file(a)).filter(|f| !f.is_empty())
441 {
442 return program;
443 }
444 if cfg!(target_os = "linux") && name.len() == COMM_LEN {
446 for arg in leading {
447 let file = file(arg);
448 if file.len() > name.len() && file.starts_with(name) {
449 return file;
450 }
451 }
452 }
453 name.to_owned()
454}
455
456fn addresses(networks: &[sysinfo::IpNetwork]) -> Vec<String> {
460 let mut found: Vec<&sysinfo::IpNetwork> = networks
461 .iter()
462 .filter(|n| match n.addr {
463 std::net::IpAddr::V4(_) => true,
464 std::net::IpAddr::V6(v6) => v6.segments()[0] & 0xffc0 != 0xfe80,
466 })
467 .collect();
468 found.sort_by_key(|n| (n.addr.is_ipv6(), n.addr));
469 found.dedup_by_key(|n| n.addr);
470 found
471 .into_iter()
472 .map(|n| format!("{}/{}", n.addr, n.prefix))
473 .collect()
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479
480 #[test]
481 fn names_cut_short_or_renamed_take_the_program_file() {
482 let linux = cfg!(target_os = "linux");
483 let cut = full_name(
484 "gnome-keyring-d",
485 &["/usr/bin/gnome-keyring-daemon", "--start"],
486 );
487 assert_eq!(
488 cut,
489 if linux {
490 "gnome-keyring-daemon"
491 } else {
492 "gnome-keyring-d"
493 }
494 );
495 let script = full_name(
496 "networkd-dispat",
497 &["/usr/bin/python3", "/usr/bin/networkd-dispatcher"],
498 );
499 assert_eq!(
500 script,
501 if linux {
502 "networkd-dispatcher"
503 } else {
504 "networkd-dispat"
505 }
506 );
507 assert_eq!(
508 full_name("MainThread", &["/usr/local/bin/node", "server.js"]),
509 "node"
510 );
511 assert_eq!(full_name("python3", &["/usr/bin/python3.12"]), "python3");
513 assert_eq!(
516 full_name(
517 "Isolated Web Co",
518 &["/usr/lib/firefox/firefox", "-contentproc"]
519 ),
520 "Isolated Web Co"
521 );
522 assert_eq!(
523 full_name("postgres", &["postgres: checkpointer"]),
524 "postgres"
525 );
526 assert_eq!(full_name("kworker/0:1H-ev", &[]), "kworker/0:1H-ev");
528 assert_eq!(full_name("MainThread", &[]), "MainThread");
529 }
530
531 #[test]
532 fn cpu_sensor_labels() {
533 for label in [
534 "CPU",
535 "CPU Package",
536 "Tctl",
537 "Core 3",
538 "coretemp Package id 0",
539 ] {
540 assert!(is_cpu_sensor_label(label), "{label}");
541 }
542 for label in ["GPU", "nvme Composite", "acpitz"] {
543 assert!(!is_cpu_sensor_label(label), "{label}");
544 }
545 }
546
547 #[cfg(target_os = "macos")]
548 #[test]
549 fn macos_memory_adds_up() {
550 let memory = SystemCollector::new().memory();
551 assert!(memory.total_bytes > 0);
552 assert!(memory.used_bytes + memory.available_bytes <= memory.total_bytes);
553 let cached = memory.cached_bytes.expect("host_statistics64 answers");
554 assert!(cached > 0 && cached < memory.total_bytes, "{cached}");
555 }
556}