1use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
9
10use crate::config::Config;
11use crate::gpu::NvmlCollector;
12use crate::smart::SmartReader;
13#[cfg(target_os = "linux")]
14use crate::snapshot::device_matches_disk;
15use crate::snapshot::{PowerStats, Snapshot};
16use crate::system::SystemCollector;
17
18#[cfg(target_os = "linux")]
19use crate::linux::{
20 self, SysRoots, diskstats::DiskStatsMeter, net::NetLinkReader, rapl::RaplMeter,
21};
22
23pub const MIN_POLL_INTERVAL: Duration = sysinfo::MINIMUM_CPU_UPDATE_INTERVAL;
26
27#[derive(Debug, thiserror::Error)]
29pub enum SourceError {
30 #[error("I/O error: {0}")]
32 Io(#[from] std::io::Error),
33 #[error("source disconnected: {0}")]
35 Disconnected(String),
36 #[error("remote: {0}")]
38 Remote(String),
39 #[error("cannot signal process {pid}: {source}")]
41 Signal {
42 pid: u32,
44 source: std::io::Error,
46 },
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
51pub enum ProcessSignal {
52 Terminate,
54 Kill,
56}
57
58pub trait DataSource {
60 fn poll(&mut self) -> Result<Snapshot, SourceError>;
66
67 fn signal_process(&mut self, pid: u32, signal: ProcessSignal) -> Result<(), SourceError>;
70
71 fn set_interval(&mut self, _interval: Duration) {}
75
76 fn set_process_limit(&mut self, _limit: Option<usize>) {}
79}
80
81impl<T: DataSource + ?Sized> DataSource for Box<T> {
83 fn poll(&mut self) -> Result<Snapshot, SourceError> {
84 (**self).poll()
85 }
86
87 fn signal_process(&mut self, pid: u32, signal: ProcessSignal) -> Result<(), SourceError> {
88 (**self).signal_process(pid, signal)
89 }
90
91 fn set_interval(&mut self, interval: Duration) {
92 (**self).set_interval(interval);
93 }
94
95 fn set_process_limit(&mut self, limit: Option<usize>) {
96 (**self).set_process_limit(limit);
97 }
98}
99
100pub struct LocalSource {
102 system: SystemCollector,
103 nvml: NvmlCollector,
104 smart: SmartReader,
105 last_poll: Option<Instant>,
106 #[cfg(target_os = "linux")]
107 roots: SysRoots,
108 #[cfg(target_os = "linux")]
109 rapl: RaplMeter,
110 #[cfg(target_os = "linux")]
111 diskstats: DiskStatsMeter,
112 #[cfg(target_os = "linux")]
113 net_links: NetLinkReader,
114 #[cfg(target_os = "linux")]
115 failed_units: linux::systemd::FailedUnitsReader,
116 #[cfg(target_os = "linux")]
117 containers: linux::containers::ContainerTracker,
118 #[cfg(target_os = "linux")]
120 scrub_dirs: Vec<std::path::PathBuf>,
121 #[cfg(target_os = "linux")]
123 drm_gpus: linux::drm::DrmGpuReader,
124 #[cfg(windows)]
126 lhm: crate::lhm::LhmReader,
127}
128
129impl LocalSource {
130 pub fn new() -> Self {
132 Self::with_config(&Config::default())
133 }
134
135 pub fn with_config(config: &Config) -> Self {
142 let mut source = Self {
143 system: SystemCollector::new(),
144 nvml: NvmlCollector::new(),
145 smart: SmartReader::new(&config.smart.dir),
146 last_poll: None,
147 #[cfg(target_os = "linux")]
148 roots: SysRoots::default(),
149 #[cfg(target_os = "linux")]
150 rapl: RaplMeter::new(),
151 #[cfg(target_os = "linux")]
152 diskstats: DiskStatsMeter::new(),
153 #[cfg(target_os = "linux")]
154 net_links: NetLinkReader::new(),
155 #[cfg(target_os = "linux")]
156 failed_units: linux::systemd::FailedUnitsReader::new(),
157 #[cfg(target_os = "linux")]
158 containers: linux::containers::ContainerTracker::new(),
159 #[cfg(target_os = "linux")]
160 drm_gpus: linux::drm::DrmGpuReader::new(),
161 #[cfg(windows)]
164 lhm: crate::lhm::LhmReader::new(&config.lhm.url),
165 #[cfg(target_os = "linux")]
166 scrub_dirs: vec![
167 config.btrfs.scrub_dir.clone(),
168 linux::btrfs::BTRFS_PROGS_STATUS_DIR.into(),
169 ],
170 };
171 let _ = source.collect();
173 source
174 }
175
176 pub fn gpu_unavailable_reason(&self) -> Option<&str> {
178 self.nvml.init_error()
179 }
180
181 fn collect(&mut self) -> Snapshot {
182 let now = Instant::now();
183 let elapsed = self
184 .last_poll
185 .map(|prev| now.saturating_duration_since(prev).as_secs_f64());
186 self.last_poll = Some(now);
187
188 self.system.refresh();
189 let processes = self.system.processes(elapsed);
190 let gpus = self.nvml.collect();
191 let gpus_w = sum_present(gpus.iter().map(|g| g.power_w));
192
193 let mut snapshot = Snapshot {
194 timestamp_ms: SystemTime::now()
195 .duration_since(UNIX_EPOCH)
196 .map(|d| d.as_millis() as u64)
197 .unwrap_or(0),
198 host: self.system.host(),
199 cpu: self.system.cpu(),
200 memory: self.system.memory(),
201 networks: self.system.networks(elapsed),
202 filesystems: self.system.filesystems(),
203 process_count: processes.len(),
204 processes,
205 gpus,
206 smart: self.smart.read(),
207 power: PowerStats {
208 gpus_w,
209 ..Default::default()
210 },
211 ..Default::default()
212 };
213
214 #[cfg(target_os = "linux")]
215 self.collect_linux(&mut snapshot, now);
216 #[cfg(not(target_os = "linux"))]
217 self.collect_portable(&mut snapshot, elapsed);
218
219 let power = &mut snapshot.power;
220 power.total_w = sum_present([power.cpu_or_soc_w().1, power.gpus_w]);
221 snapshot
222 }
223
224 #[cfg(not(target_os = "linux"))]
227 fn collect_portable(&mut self, snapshot: &mut Snapshot, elapsed: Option<f64>) {
228 snapshot.disks = self.system.volume_io(elapsed);
229 let (chips, cpu_temperatures) = self.system.components();
230 snapshot.sensors = chips;
231 snapshot.cpu.temperatures = cpu_temperatures;
232 #[cfg(windows)]
233 {
234 crate::windows::annotate_links(&mut snapshot.networks);
235 match self.lhm.read(Instant::now()) {
238 Some(lhm) => {
239 if !lhm.chips.is_empty() {
240 snapshot.sensors = lhm.chips;
241 }
242 if !lhm.cpu_temperatures.is_empty() {
243 snapshot.cpu.temperatures = lhm.cpu_temperatures;
244 }
245 snapshot.power.cpu_package_w = lhm.cpu_package_w;
246 if !lhm.smart.is_empty() {
247 snapshot.smart = lhm.smart;
248 }
249 }
250 None => snapshot.lhm_error = self.lhm.problem().map(str::to_owned),
251 }
252 }
253 }
254
255 #[cfg(target_os = "linux")]
257 fn collect_linux(&mut self, snapshot: &mut Snapshot, now: Instant) {
258 let chips = linux::hwmon::read_chips(&self.roots);
259
260 snapshot.cpu.temperatures = chips
261 .iter()
262 .filter(|c| linux::hwmon::is_cpu_chip(&c.chip))
263 .flat_map(|c| c.chip.temperatures.iter().cloned())
264 .collect();
265 snapshot.memory.cached_bytes = linux::meminfo::read_cached_bytes(&self.roots);
266 snapshot.power.cpu_package_w = self.rapl.sample(&self.roots, now);
267 snapshot.power.igpu_w = sum_present(
270 chips
271 .iter()
272 .filter(|c| c.is_apu)
273 .flat_map(|c| c.power_w.iter().map(|(_, w)| Some(*w))),
274 );
275 snapshot.power.battery = linux::battery::read_battery(&self.roots);
276
277 let first = snapshot.gpus.len() as u32;
279 snapshot.gpus.extend(self.drm_gpus.read(&self.roots, first));
280 snapshot.power.gpus_w = sum_present(snapshot.gpus.iter().map(|g| g.power_w));
281
282 let mut disks = self.diskstats.sample(&self.roots, now);
283 for disk in &mut disks {
284 disk.temperature_c = nvme_temperature(&chips, &disk.name);
285 }
286 snapshot.disks = disks;
287
288 let mounts = linux::mounts::read_mounts(&self.roots);
289 snapshot.raid.btrfs = linux::btrfs::read_filesystems(&self.roots, &mounts);
290 for fs in &mut snapshot.raid.btrfs {
291 fs.scrub = linux::btrfs::read_scrub(&self.scrub_dirs, &fs.fsid);
292 }
293 self.net_links
294 .annotate(&self.roots, &mut snapshot.networks, now);
295 snapshot.raid.md = linux::mdstat::read_arrays(&self.roots);
296 snapshot.raid.zfs = linux::zfs::read_pools(&self.roots);
297 snapshot.failed_units = self.failed_units.read(now);
298 for process in &mut snapshot.processes {
300 process.threads = linux::procstat::thread_count(&self.roots, process.pid);
301 }
302 self.containers
303 .annotate(&self.roots, &mut snapshot.processes, now);
304
305 snapshot.sensors = chips.into_iter().map(|c| c.chip).collect();
306 }
307}
308
309impl Default for LocalSource {
310 fn default() -> Self {
311 Self::new()
312 }
313}
314
315impl DataSource for LocalSource {
316 fn poll(&mut self) -> Result<Snapshot, SourceError> {
317 Ok(self.collect())
318 }
319
320 fn signal_process(&mut self, pid: u32, signal: ProcessSignal) -> Result<(), SourceError> {
321 send_signal(pid, signal).map_err(|source| SourceError::Signal { pid, source })
322 }
323
324 fn set_process_limit(&mut self, limit: Option<usize>) {
327 self.nvml.set_process_utilization(limit.is_none());
328 }
329}
330
331#[cfg(unix)]
333fn send_signal(pid: u32, signal: ProcessSignal) -> std::io::Result<()> {
334 let pid = libc::pid_t::try_from(pid)
337 .ok()
338 .filter(|p| *p > 0)
339 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
340 let sig = match signal {
341 ProcessSignal::Terminate => libc::SIGTERM,
342 ProcessSignal::Kill => libc::SIGKILL,
343 };
344 if unsafe { libc::kill(pid, sig) } == 0 {
346 Ok(())
347 } else {
348 Err(std::io::Error::last_os_error())
349 }
350}
351
352#[cfg(windows)]
353use crate::windows::send_signal;
354
355#[cfg(not(any(unix, windows)))]
357fn send_signal(_pid: u32, _signal: ProcessSignal) -> std::io::Result<()> {
358 Err(std::io::Error::from(std::io::ErrorKind::Unsupported))
359}
360
361fn sum_present(values: impl IntoIterator<Item = Option<f32>>) -> Option<f32> {
363 values
364 .into_iter()
365 .flatten()
366 .fold(None, |acc, v| Some(acc.unwrap_or(0.0) + v))
367}
368
369#[cfg(target_os = "linux")]
372fn nvme_temperature(chips: &[linux::hwmon::HwmonChip], disk: &str) -> Option<f32> {
373 chips.iter().find_map(|c| {
374 let controller = c.chip.key.strip_prefix("nvme:")?;
375 device_matches_disk(controller, disk).then_some(())?;
376 c.chip.temperatures.first().map(|t| t.celsius)
378 })
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 #[cfg(unix)]
386 #[test]
387 fn signal_rejects_group_pids_and_reports_missing_process() {
388 assert_eq!(
389 send_signal(0, ProcessSignal::Terminate).unwrap_err().kind(),
390 std::io::ErrorKind::InvalidInput
391 );
392 assert_eq!(
393 send_signal(u32::MAX, ProcessSignal::Kill)
394 .unwrap_err()
395 .kind(),
396 std::io::ErrorKind::InvalidInput
397 );
398 let mut child = std::process::Command::new("true").spawn().unwrap();
400 let pid = child.id();
401 child.wait().unwrap();
402 let err = send_signal(pid, ProcessSignal::Terminate).unwrap_err();
403 assert_eq!(err.raw_os_error(), Some(libc::ESRCH));
404 }
405
406 #[cfg(unix)]
407 #[test]
408 fn signal_terminates_a_child_process() {
409 let mut child = std::process::Command::new("sleep")
410 .arg("30")
411 .spawn()
412 .unwrap();
413 send_signal(child.id(), ProcessSignal::Kill).unwrap();
414 let status = child.wait().unwrap();
415 assert!(!status.success());
416 }
417
418 #[test]
419 fn sum_present_ignores_missing_values() {
420 assert_eq!(sum_present([None, None]), None);
421 assert_eq!(sum_present([Some(1.5), None, Some(2.0)]), Some(3.5));
422 }
423
424 #[cfg(target_os = "linux")]
425 #[test]
426 fn nvme_temperature_matches_controller_exactly() {
427 use crate::linux::hwmon::HwmonChip;
428 use crate::snapshot::{SensorChip, Temperature};
429
430 let chip = |key: &str, celsius: f32| HwmonChip {
431 chip: SensorChip {
432 key: key.to_owned(),
433 name: "nvme".to_owned(),
434 temperatures: vec![Temperature {
435 label: "Composite".to_owned(),
436 celsius,
437 ..Default::default()
438 }],
439 fans: Vec::new(),
440 },
441 power_w: Vec::new(),
442 is_apu: false,
443 };
444 let chips = vec![chip("nvme:nvme1", 40.0), chip("nvme:nvme10", 50.0)];
445 assert_eq!(nvme_temperature(&chips, "nvme10n1"), Some(50.0));
446 assert_eq!(nvme_temperature(&chips, "nvme1n1"), Some(40.0));
447 assert_eq!(nvme_temperature(&chips, "sda"), None);
448 }
449}