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")]
118 scrub_dirs: Vec<std::path::PathBuf>,
119 #[cfg(target_os = "linux")]
121 drm_gpus: linux::drm::DrmGpuReader,
122 #[cfg(windows)]
124 lhm: crate::lhm::LhmReader,
125}
126
127impl LocalSource {
128 pub fn new() -> Self {
130 Self::with_config(&Config::default())
131 }
132
133 pub fn with_config(config: &Config) -> Self {
140 let mut source = Self {
141 system: SystemCollector::new(),
142 nvml: NvmlCollector::new(),
143 smart: SmartReader::new(&config.smart.dir),
144 last_poll: None,
145 #[cfg(target_os = "linux")]
146 roots: SysRoots::default(),
147 #[cfg(target_os = "linux")]
148 rapl: RaplMeter::new(),
149 #[cfg(target_os = "linux")]
150 diskstats: DiskStatsMeter::new(),
151 #[cfg(target_os = "linux")]
152 net_links: NetLinkReader::new(),
153 #[cfg(target_os = "linux")]
154 failed_units: linux::systemd::FailedUnitsReader::new(),
155 #[cfg(target_os = "linux")]
156 drm_gpus: linux::drm::DrmGpuReader::new(),
157 #[cfg(windows)]
160 lhm: crate::lhm::LhmReader::new(&config.lhm.url),
161 #[cfg(target_os = "linux")]
162 scrub_dirs: vec![
163 config.btrfs.scrub_dir.clone(),
164 linux::btrfs::BTRFS_PROGS_STATUS_DIR.into(),
165 ],
166 };
167 let _ = source.collect();
169 source
170 }
171
172 pub fn gpu_unavailable_reason(&self) -> Option<&str> {
174 self.nvml.init_error()
175 }
176
177 fn collect(&mut self) -> Snapshot {
178 let now = Instant::now();
179 let elapsed = self
180 .last_poll
181 .map(|prev| now.saturating_duration_since(prev).as_secs_f64());
182 self.last_poll = Some(now);
183
184 self.system.refresh();
185 let processes = self.system.processes(elapsed);
186 let gpus = self.nvml.collect();
187 let gpus_w = sum_present(gpus.iter().map(|g| g.power_w));
188
189 let mut snapshot = Snapshot {
190 timestamp_ms: SystemTime::now()
191 .duration_since(UNIX_EPOCH)
192 .map(|d| d.as_millis() as u64)
193 .unwrap_or(0),
194 host: self.system.host(),
195 cpu: self.system.cpu(),
196 memory: self.system.memory(),
197 networks: self.system.networks(elapsed),
198 filesystems: self.system.filesystems(),
199 process_count: processes.len(),
200 processes,
201 gpus,
202 smart: self.smart.read(),
203 power: PowerStats {
204 gpus_w,
205 ..Default::default()
206 },
207 ..Default::default()
208 };
209
210 #[cfg(target_os = "linux")]
211 self.collect_linux(&mut snapshot, now);
212 #[cfg(not(target_os = "linux"))]
213 self.collect_portable(&mut snapshot, elapsed);
214
215 let power = &mut snapshot.power;
216 power.total_w = sum_present([power.cpu_or_soc_w().1, power.gpus_w]);
217 snapshot
218 }
219
220 #[cfg(not(target_os = "linux"))]
223 fn collect_portable(&mut self, snapshot: &mut Snapshot, elapsed: Option<f64>) {
224 snapshot.disks = self.system.volume_io(elapsed);
225 let (chips, cpu_temperatures) = self.system.components();
226 snapshot.sensors = chips;
227 snapshot.cpu.temperatures = cpu_temperatures;
228 #[cfg(windows)]
229 {
230 crate::windows::annotate_links(&mut snapshot.networks);
231 match self.lhm.read(Instant::now()) {
234 Some(lhm) => {
235 if !lhm.chips.is_empty() {
236 snapshot.sensors = lhm.chips;
237 }
238 if !lhm.cpu_temperatures.is_empty() {
239 snapshot.cpu.temperatures = lhm.cpu_temperatures;
240 }
241 snapshot.power.cpu_package_w = lhm.cpu_package_w;
242 if !lhm.smart.is_empty() {
243 snapshot.smart = lhm.smart;
244 }
245 }
246 None => snapshot.lhm_error = self.lhm.problem().map(str::to_owned),
247 }
248 }
249 }
250
251 #[cfg(target_os = "linux")]
253 fn collect_linux(&mut self, snapshot: &mut Snapshot, now: Instant) {
254 let chips = linux::hwmon::read_chips(&self.roots);
255
256 snapshot.cpu.temperatures = chips
257 .iter()
258 .filter(|c| linux::hwmon::is_cpu_chip(&c.chip))
259 .flat_map(|c| c.chip.temperatures.iter().cloned())
260 .collect();
261 snapshot.memory.cached_bytes = linux::meminfo::read_cached_bytes(&self.roots);
262 snapshot.power.cpu_package_w = self.rapl.sample(&self.roots, now);
263 snapshot.power.igpu_w = sum_present(
266 chips
267 .iter()
268 .filter(|c| c.is_apu)
269 .flat_map(|c| c.power_w.iter().map(|(_, w)| Some(*w))),
270 );
271 snapshot.power.battery = linux::battery::read_battery(&self.roots);
272
273 let first = snapshot.gpus.len() as u32;
275 snapshot.gpus.extend(self.drm_gpus.read(&self.roots, first));
276 snapshot.power.gpus_w = sum_present(snapshot.gpus.iter().map(|g| g.power_w));
277
278 let mut disks = self.diskstats.sample(&self.roots, now);
279 for disk in &mut disks {
280 disk.temperature_c = nvme_temperature(&chips, &disk.name);
281 }
282 snapshot.disks = disks;
283
284 let mounts = linux::mounts::read_mounts(&self.roots);
285 snapshot.raid.btrfs = linux::btrfs::read_filesystems(&self.roots, &mounts);
286 for fs in &mut snapshot.raid.btrfs {
287 fs.scrub = linux::btrfs::read_scrub(&self.scrub_dirs, &fs.fsid);
288 }
289 self.net_links
290 .annotate(&self.roots, &mut snapshot.networks, now);
291 snapshot.raid.md = linux::mdstat::read_arrays(&self.roots);
292 snapshot.raid.zfs = linux::zfs::read_pools(&self.roots);
293 snapshot.failed_units = self.failed_units.read(now);
294 for process in &mut snapshot.processes {
296 process.threads = linux::procstat::thread_count(&self.roots, process.pid);
297 }
298
299 snapshot.sensors = chips.into_iter().map(|c| c.chip).collect();
300 }
301}
302
303impl Default for LocalSource {
304 fn default() -> Self {
305 Self::new()
306 }
307}
308
309impl DataSource for LocalSource {
310 fn poll(&mut self) -> Result<Snapshot, SourceError> {
311 Ok(self.collect())
312 }
313
314 fn signal_process(&mut self, pid: u32, signal: ProcessSignal) -> Result<(), SourceError> {
315 send_signal(pid, signal).map_err(|source| SourceError::Signal { pid, source })
316 }
317
318 fn set_process_limit(&mut self, limit: Option<usize>) {
321 self.nvml.set_process_utilization(limit.is_none());
322 }
323}
324
325#[cfg(unix)]
327fn send_signal(pid: u32, signal: ProcessSignal) -> std::io::Result<()> {
328 let pid = libc::pid_t::try_from(pid)
331 .ok()
332 .filter(|p| *p > 0)
333 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
334 let sig = match signal {
335 ProcessSignal::Terminate => libc::SIGTERM,
336 ProcessSignal::Kill => libc::SIGKILL,
337 };
338 if unsafe { libc::kill(pid, sig) } == 0 {
340 Ok(())
341 } else {
342 Err(std::io::Error::last_os_error())
343 }
344}
345
346#[cfg(windows)]
347use crate::windows::send_signal;
348
349#[cfg(not(any(unix, windows)))]
351fn send_signal(_pid: u32, _signal: ProcessSignal) -> std::io::Result<()> {
352 Err(std::io::Error::from(std::io::ErrorKind::Unsupported))
353}
354
355fn sum_present(values: impl IntoIterator<Item = Option<f32>>) -> Option<f32> {
357 values
358 .into_iter()
359 .flatten()
360 .fold(None, |acc, v| Some(acc.unwrap_or(0.0) + v))
361}
362
363#[cfg(target_os = "linux")]
366fn nvme_temperature(chips: &[linux::hwmon::HwmonChip], disk: &str) -> Option<f32> {
367 chips.iter().find_map(|c| {
368 let controller = c.chip.key.strip_prefix("nvme:")?;
369 device_matches_disk(controller, disk).then_some(())?;
370 c.chip.temperatures.first().map(|t| t.celsius)
372 })
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378
379 #[cfg(unix)]
380 #[test]
381 fn signal_rejects_group_pids_and_reports_missing_process() {
382 assert_eq!(
383 send_signal(0, ProcessSignal::Terminate).unwrap_err().kind(),
384 std::io::ErrorKind::InvalidInput
385 );
386 assert_eq!(
387 send_signal(u32::MAX, ProcessSignal::Kill)
388 .unwrap_err()
389 .kind(),
390 std::io::ErrorKind::InvalidInput
391 );
392 let mut child = std::process::Command::new("true").spawn().unwrap();
394 let pid = child.id();
395 child.wait().unwrap();
396 let err = send_signal(pid, ProcessSignal::Terminate).unwrap_err();
397 assert_eq!(err.raw_os_error(), Some(libc::ESRCH));
398 }
399
400 #[cfg(unix)]
401 #[test]
402 fn signal_terminates_a_child_process() {
403 let mut child = std::process::Command::new("sleep")
404 .arg("30")
405 .spawn()
406 .unwrap();
407 send_signal(child.id(), ProcessSignal::Kill).unwrap();
408 let status = child.wait().unwrap();
409 assert!(!status.success());
410 }
411
412 #[test]
413 fn sum_present_ignores_missing_values() {
414 assert_eq!(sum_present([None, None]), None);
415 assert_eq!(sum_present([Some(1.5), None, Some(2.0)]), Some(3.5));
416 }
417
418 #[cfg(target_os = "linux")]
419 #[test]
420 fn nvme_temperature_matches_controller_exactly() {
421 use crate::linux::hwmon::HwmonChip;
422 use crate::snapshot::{SensorChip, Temperature};
423
424 let chip = |key: &str, celsius: f32| HwmonChip {
425 chip: SensorChip {
426 key: key.to_owned(),
427 name: "nvme".to_owned(),
428 temperatures: vec![Temperature {
429 label: "Composite".to_owned(),
430 celsius,
431 ..Default::default()
432 }],
433 fans: Vec::new(),
434 },
435 power_w: Vec::new(),
436 is_apu: false,
437 };
438 let chips = vec![chip("nvme:nvme1", 40.0), chip("nvme:nvme10", 50.0)];
439 assert_eq!(nvme_temperature(&chips, "nvme10n1"), Some(50.0));
440 assert_eq!(nvme_temperature(&chips, "nvme1n1"), Some(40.0));
441 assert_eq!(nvme_temperature(&chips, "sda"), None);
442 }
443}