1use std::time::{SystemTime, UNIX_EPOCH};
10
11use crate::config::Config;
12use crate::hosts::{HostKind, HostSpec};
13use crate::snapshot::{
14 BatteryStats, BtrfsAllocation, BtrfsDeviceStats, BtrfsFilesystem, BtrfsScrub, CpuStats, DiskIo,
15 EccErrors, Fan, FilesystemUsage, GpuProcess, GpuStats, HostInfo, MdArray, MdMember, MdSync,
16 MemoryStats, NetworkStats, PcieLink, PowerStats, Pressure, PressureLevels, ProcessInfo,
17 RaidStats, SensorChip, SmartDevice, Snapshot, Temperature, UpsStats, WirelessLink, ZfsDevice,
18 ZfsPool,
19};
20use crate::source::{DataSource, ProcessSignal, SourceError};
21
22const GIB: u64 = 1 << 30;
23const TIB: u64 = 1 << 40;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum DemoHost {
28 Workstation,
30 Laptop,
32 Nas,
35}
36
37impl DemoHost {
38 pub const ALL: [DemoHost; 3] = [DemoHost::Workstation, DemoHost::Laptop, DemoHost::Nas];
40
41 pub fn name(self) -> &'static str {
43 match self {
44 DemoHost::Workstation => "workstation",
45 DemoHost::Laptop => "laptop",
46 DemoHost::Nas => "nas",
47 }
48 }
49}
50
51pub fn host_specs(config: &Config) -> Vec<HostSpec> {
53 DemoHost::ALL
54 .iter()
55 .map(|&host| HostSpec {
56 name: host.name().to_owned(),
57 kind: HostKind::Demo(host),
58 sections: config.overview.sections.clone(),
59 alerts: config.alerts.clone(),
60 })
61 .collect()
62}
63
64#[derive(Debug, Clone)]
66pub struct DemoSource {
67 host: DemoHost,
68 time: f64,
70 rx_total: f64,
71 tx_total: f64,
72 watched_pids: Vec<u32>,
74 public_ip: bool,
76}
77
78impl DemoSource {
79 pub fn new(host: DemoHost) -> Self {
81 Self {
82 host,
83 time: 0.0,
84 rx_total: 412.0 * GIB as f64,
86 tx_total: 38.0 * GIB as f64,
87 watched_pids: Vec::new(),
88 public_ip: false,
89 }
90 }
91}
92
93impl DataSource for DemoSource {
94 fn poll(&mut self) -> Result<Snapshot, SourceError> {
95 self.time += 1.0;
96 let mut snapshot = match self.host {
97 DemoHost::Workstation => workstation(self.time),
98 DemoHost::Laptop => laptop(self.time),
99 DemoHost::Nas => nas(self.time),
100 };
101 for net in &mut snapshot.networks {
102 if net.name == primary_interface(self.host) {
103 self.rx_total += net.rx_bytes_per_sec;
104 self.tx_total += net.tx_bytes_per_sec;
105 net.rx_total_bytes = self.rx_total as u64;
106 net.tx_total_bytes = self.tx_total as u64;
107 }
108 }
109 snapshot.timestamp_ms = now_ms();
110 snapshot.process_count = snapshot.processes.len();
111 snapshot.watched_pids = self.watched_pids.clone();
112 snapshot.public_ip = self.public_ip.then(|| match self.host {
115 DemoHost::Laptop => "198.51.100.23".to_owned(),
116 _ => "203.0.113.7".to_owned(),
117 });
118 Ok(snapshot)
119 }
120
121 fn signal_process(&mut self, _pid: u32, _signal: ProcessSignal) -> Result<(), SourceError> {
122 Ok(())
124 }
125
126 fn watch_processes(&mut self, pids: Vec<u32>) {
127 self.watched_pids = pids;
128 }
129
130 fn set_public_ip(&mut self, lookup: Option<crate::public_ip::PublicIpLookup>) {
131 self.public_ip = lookup.is_some();
132 }
133}
134
135fn primary_interface(host: DemoHost) -> &'static str {
136 match host {
137 DemoHost::Workstation => "enp9s0",
138 DemoHost::Laptop => "wlp1s0",
139 DemoHost::Nas => "eno1",
140 }
141}
142
143fn now_ms() -> u64 {
144 SystemTime::now()
145 .duration_since(UNIX_EPOCH)
146 .map_or(0, |d| d.as_millis() as u64)
147}
148
149fn wobble(seed: u64, t: f64, period: f64) -> f64 {
152 let x = t / period;
153 let (cell, frac) = (x.floor(), x.fract());
154 let at = |c: f64| hash(seed ^ (c as i64 as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15));
155 let s = frac * frac * (3.0 - 2.0 * frac);
157 at(cell) * (1.0 - s) + at(cell + 1.0) * s
158}
159
160fn hash(seed: u64) -> f64 {
162 let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
163 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
164 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
165 z ^= z >> 31;
166 (z >> 11) as f64 / (1u64 << 53) as f64
167}
168
169fn duty(t: f64, period: f64, busy: f64) -> f64 {
172 let phase = t.rem_euclid(period);
173 let ramp = 4.0;
174 if phase < ramp {
175 phase / ramp
176 } else if phase < busy {
177 1.0
178 } else if phase < busy + ramp {
179 1.0 - (phase - busy) / ramp
180 } else {
181 0.0
182 }
183}
184
185fn temp(chip: &str, n: u32, label: &str, celsius: f64) -> Temperature {
186 Temperature {
187 key: format!("{chip}/temp{n}"),
188 label: label.to_owned(),
189 celsius: celsius as f32,
190 ..Default::default()
191 }
192}
193
194fn fan(chip: &str, n: u32, rpm: f64) -> Fan {
195 Fan {
196 key: format!("{chip}/fan{n}"),
197 label: format!("fan{n}"),
198 rpm: rpm as u32,
199 }
200}
201
202fn cores(seed: u64, t: f64, count: usize, total: f64) -> Vec<f32> {
204 (0..count)
205 .map(|i| {
206 let spread = wobble(seed + i as u64, t, 3.0 + (i % 5) as f64);
207 ((total * (0.55 + 0.9 * spread)) + 2.0 * spread).clamp(0.0, 100.0) as f32
208 })
209 .collect()
210}
211
212#[allow(clippy::too_many_arguments)]
214fn process(
215 pid: u32,
216 name: &str,
217 command: &str,
218 user: &str,
219 cpu: f64,
220 memory: u64,
221 threads: u32,
222 run_time_secs: u64,
223) -> ProcessInfo {
224 ProcessInfo {
225 pid,
226 parent_pid: Some(1),
227 name: name.to_owned(),
228 command: command.to_owned(),
229 user: user.to_owned(),
230 state: if cpu > 1.0 { "R" } else { "S" }.to_owned(),
231 cpu_percent: cpu.max(0.0) as f32,
232 memory_bytes: memory,
233 threads: Some(threads),
234 run_time_secs,
235 ..Default::default()
236 }
237}
238
239fn with_io(process: ProcessInfo, read: f64, write: f64) -> ProcessInfo {
241 ProcessInfo {
242 read_bytes_per_sec: read.max(0.0),
243 write_bytes_per_sec: write.max(0.0),
244 ..process
245 }
246}
247
248fn workstation(t: f64) -> Snapshot {
249 let build = duty(t, 120.0, 45.0);
251 let cpu_total = 9.0 + 70.0 * build + 6.0 * wobble(1, t, 4.0);
252 let per_core = cores(100, t, 32, cpu_total);
253 let tctl = 48.0 + 0.38 * cpu_total + 2.0 * wobble(2, t, 6.0);
254 let cpu_w = 38.0 + 1.6 * cpu_total;
255 let eval = duty(t + 200.0, 300.0, 40.0);
257 let gpu0_util = 97.0 - 60.0 * eval + 3.0 * wobble(3, t, 2.0);
258 let gpu0_w = 120.0 + 3.0 * gpu0_util;
259 let requests = (wobble(4, t, 9.0) - 0.35).max(0.0) / 0.65;
261 let gpu1_util = 85.0 * requests;
262 let gpu1_w = 22.0 + 1.1 * gpu1_util;
263 let download = (wobble(5, t, 15.0) - 0.4).max(0.0) / 0.6;
264 let k10 = "k10temp:0000:00:18.3";
265 let nct = "nct6686:nct6683.2592";
266 let train_pid = 48_211;
267 let train_read = (45e6 + 15e6 * wobble(9, t, 3.0)) * (1.0 - 0.8 * eval);
269 let ollama_pid = 2_204;
270 let mut processes = vec![
271 with_io(
272 process(
273 train_pid,
274 "python",
275 "python train.py --config configs/vit-large.yaml",
276 "demo",
277 160.0 + 40.0 * wobble(6, t, 3.0) - 80.0 * eval,
278 18 * GIB + GIB / 3,
279 64,
280 5 * 3600 + t as u64,
281 ),
282 train_read,
283 0.2e6,
284 ),
285 process(
286 ollama_pid,
287 "ollama",
288 "/usr/local/bin/ollama serve",
289 "ollama",
290 4.0 + 60.0 * requests,
291 3 * GIB,
292 42,
293 9 * 86_400,
294 ),
295 process(
297 4_810,
298 "qemu-system-x86_64",
299 "/usr/bin/qemu-system-x86_64 -name guest=win11-dev,debug-threads=on -machine pc-q35-9.2",
300 "libvirt-qemu",
301 3.0 + 3.0 * wobble(62, t, 4.0),
302 8 * GIB,
303 12,
304 2 * 86_400,
305 ),
306 process(
307 3_120,
308 "gnome-shell",
309 "/usr/bin/gnome-shell",
310 "demo",
311 3.0 + 2.0 * wobble(7, t, 2.0),
312 GIB,
313 28,
314 9 * 86_400,
315 ),
316 with_io(
318 process(
319 5_310,
320 "firefox",
321 "/usr/lib/firefox/firefox",
322 "demo",
323 2.0 + 6.0 * wobble(8, t, 5.0),
324 2 * GIB + GIB / 2,
325 112,
326 2 * 86_400,
327 ),
328 0.0,
329 40e6 * download,
330 ),
331 process(
332 6_002,
333 "code",
334 "/usr/share/code/code",
335 "demo",
336 1.0 + 3.0 * wobble(9, t, 4.0),
337 GIB + GIB / 4,
338 40,
339 86_400,
340 ),
341 process(
342 7_734,
343 "rigtop-gui",
344 "rigtop-gui",
345 "demo",
346 2.0,
347 180 << 20,
348 12,
349 3_600,
350 ),
351 process(
352 1_402,
353 "postgres",
354 "postgres: checkpointer",
355 "postgres",
356 0.3,
357 420 << 20,
358 1,
359 9 * 86_400,
360 ),
361 process(
362 1_388,
363 "dockerd",
364 "/usr/bin/dockerd -H fd://",
365 "root",
366 0.4,
367 96 << 20,
368 30,
369 9 * 86_400,
370 ),
371 process(
372 1_105,
373 "sshd",
374 "sshd: /usr/sbin/sshd -D",
375 "root",
376 0.0,
377 12 << 20,
378 1,
379 9 * 86_400,
380 ),
381 process(
382 1,
383 "systemd",
384 "/sbin/init",
385 "root",
386 0.1,
387 14 << 20,
388 1,
389 9 * 86_400,
390 ),
391 ];
392 let idle: [(u32, &str, &str, &str, u64, u32); 22] = [
394 (
395 2_301,
396 "jupyter-lab",
397 "python -m jupyterlab --no-browser",
398 "demo",
399 310,
400 6,
401 ),
402 (
403 2_388,
404 "tensorboard",
405 "python -m tensorboard.main --logdir runs",
406 "demo",
407 520,
408 18,
409 ),
410 (
411 3_044,
412 "Xwayland",
413 "/usr/bin/Xwayland :0 -rootless",
414 "demo",
415 110,
416 9,
417 ),
418 (3_090, "pipewire", "/usr/bin/pipewire", "demo", 24, 3),
419 (3_092, "wireplumber", "/usr/bin/wireplumber", "demo", 30, 6),
420 (
421 3_401,
422 "tmux: server",
423 "tmux new-session -s work",
424 "demo",
425 9,
426 1,
427 ),
428 (3_420, "zsh", "-zsh", "demo", 7, 1),
429 (
430 3_520,
431 "node",
432 "node /usr/lib/node_modules/vite/bin/vite.js",
433 "demo",
434 260,
435 11,
436 ),
437 (3_610, "claude", "claude", "demo", 330, 19),
438 (1_201, "containerd", "/usr/bin/containerd", "root", 52, 18),
439 (
440 1_266,
441 "redis-server",
442 "/usr/bin/redis-server 127.0.0.1:6379",
443 "redis",
444 18,
445 5,
446 ),
447 (1_270, "nginx", "nginx: worker process", "www-data", 11, 1),
448 (
449 512,
450 "systemd-journal",
451 "/usr/lib/systemd/systemd-journald",
452 "root",
453 64,
454 1,
455 ),
456 (
457 880,
458 "NetworkManager",
459 "/usr/sbin/NetworkManager --no-daemon",
460 "root",
461 21,
462 4,
463 ),
464 (902, "tailscaled", "/usr/sbin/tailscaled", "root", 48, 14),
465 (
466 930,
467 "nvidia-persiste",
468 "/usr/bin/nvidia-persistenced",
469 "nvidia-persistenced",
470 2,
471 1,
472 ),
473 (1_010, "cupsd", "/usr/sbin/cupsd -l", "root", 8, 1),
474 (1_050, "smartd", "/usr/sbin/smartd -n", "root", 6, 1),
475 (
476 3_150,
477 "gnome-terminal-",
478 "/usr/libexec/gnome-terminal-server",
479 "demo",
480 90,
481 5,
482 ),
483 (
484 3_180,
485 "nautilus",
486 "/usr/bin/nautilus --gapplication-service",
487 "demo",
488 140,
489 9,
490 ),
491 (
492 3_230,
493 "evolution-alarm",
494 "/usr/libexec/evolution-data-server/evolution-alarm-notify",
495 "demo",
496 70,
497 7,
498 ),
499 (
500 3_260,
501 "ibus-daemon",
502 "/usr/bin/ibus-daemon --panel disable",
503 "demo",
504 15,
505 4,
506 ),
507 ];
508 for (i, &(pid, name, command, user, mib, threads)) in idle.iter().enumerate() {
509 let cpu = (wobble(200 + i as u64, t, 4.0) - 0.6).max(0.0) * 2.0;
510 processes.push(process(
511 pid,
512 name,
513 command,
514 user,
515 cpu,
516 mib << 20,
517 threads,
518 9 * 86_400 - pid as u64 * 7,
519 ));
520 }
521 if build > 0.0 {
522 processes.push(process(
524 60_100,
525 "cargo",
526 "cargo build --release",
527 "demo",
528 1.0,
529 200 << 20,
530 8,
531 40,
532 ));
533 for i in 0..6 {
534 processes.push(with_io(
535 process(
536 60_120 + i,
537 "rustc",
538 "rustc --crate-name rigtop_core --edition=2024",
539 "demo",
540 (90.0 + 10.0 * wobble(10 + i as u64, t, 2.0)) * build,
541 (600 + 100 * i as u64) << 20,
542 2,
543 20,
544 ),
545 3e6 * build,
546 12e6 * build * wobble(40 + i as u64, t, 2.0),
547 ));
548 }
549 }
550 let parents = [
552 (60_100, 3_420),
553 (train_pid, 3_420),
554 (2_388, 3_420),
555 (3_610, 3_420),
556 (3_420, 3_401),
557 (3_520, 6_002),
558 (3_044, 3_120),
559 (3_150, 3_120),
560 (3_180, 3_120),
561 ];
562 let containers = [
564 (1_402, "app-db-1"),
565 (1_266, "app-redis-1"),
566 (1_270, "app-web-1"),
567 (4_810, "win11-dev"),
568 ];
569 for process in &mut processes {
570 if process.name == "rustc" {
571 process.parent_pid = Some(60_100);
572 } else if let Some(&(_, parent)) = parents.iter().find(|(pid, _)| *pid == process.pid) {
573 process.parent_pid = Some(parent);
574 }
575 if let Some(&(_, name)) = containers.iter().find(|(pid, _)| *pid == process.pid) {
576 process.container = Some(name.to_owned());
577 }
578 }
579 let nvme = |i: u32, celsius: f64| SensorChip {
580 key: format!("nvme:nvme{i}"),
581 name: "nvme".to_owned(),
582 temperatures: vec![temp(&format!("nvme:nvme{i}"), 1, "Composite", celsius)],
583 fans: Vec::new(),
584 };
585 let drive_temp = |i: u32| 44.0 + 4.0 * i as f64 + 5.0 * build;
586 let disk_io = |i: u64| {
589 let busy = build * (0.5 + 0.5 * wobble(20 + i, t, 2.0));
590 DiskIo {
591 name: format!("nvme{i}n1"),
592 read_bytes_per_sec: train_read / 3.0 + 30e6 * busy + 1e6 * wobble(30 + i, t, 3.0),
593 write_bytes_per_sec: 40e6 * busy + 40e6 / 3.0 * download,
594 busy_percent: Some((25.0 * busy + 3.0 * download + 4.0 * train_read / 60e6) as f32),
595 latency_ms: Some((0.08 + 0.5 * busy) as f32),
596 temperature_c: Some(drive_temp(i as u32) as f32),
597 }
598 };
599 let smart = |i: u32| SmartDevice {
600 device: format!("nvme{i}"),
601 protocol: "NVMe".to_owned(),
602 model: "Samsung SSD 990 PRO 2TB".to_owned(),
603 passed: Some(true),
604 temperature_c: Some(drive_temp(i) as f32),
605 power_on_hours: Some(4_210 + 13 * i as u64),
606 percentage_used: Some(3),
607 available_spare_percent: Some(100),
608 available_spare_threshold_percent: Some(10),
609 critical_warning: Some(0),
610 media_errors: Some(0),
611 data_written_bytes: Some(41 * TIB + i as u64 * TIB),
612 collected_at_ms: Some(now_ms() - 140_000),
613 ..Default::default()
614 };
615 let used_memory = 38.0 + 6.0 * build + 1.5 * wobble(11, t, 20.0);
616 Snapshot {
617 pressure: Some(Pressure {
619 cpu: PressureLevels {
620 some_avg10: (25.0 * build) as f32,
621 some_avg60: (20.0 * build) as f32,
622 ..Default::default()
623 },
624 ..Default::default()
625 }),
626 host: HostInfo {
627 hostname: "workstation".to_owned(),
628 os: "Linux (Ubuntu 26.04)".to_owned(),
629 kernel: "7.0.0-38-generic".to_owned(),
630 uptime_secs: 9 * 86_400 + 4 * 3600 + t as u64,
631 rigtop_version: env!("CARGO_PKG_VERSION").to_owned(),
632 reboot_required: None,
633 },
634 cpu: CpuStats {
635 model: "AMD Ryzen 9 9950X3D 16-Core Processor".to_owned(),
636 total_percent: cpu_total as f32,
637 per_core_mhz: per_core.iter().map(|u| 3_000 + (*u as u64) * 25).collect(),
638 per_core_percent: per_core,
639 load_average: Some([
640 0.4 + 22.0 * build,
641 3.0 + 9.0 * duty(t + 30.0, 120.0, 90.0),
642 6.2,
643 ]),
644 temperatures: vec![
645 temp(k10, 1, "Tctl", tctl),
646 temp(k10, 3, "Tccd1", tctl - 8.0),
647 temp(k10, 4, "Tccd2", tctl - 9.0),
648 ],
649 per_core_celsius: Vec::new(),
650 },
651 memory: MemoryStats {
652 total_bytes: 96 * GIB,
653 used_bytes: (used_memory * GIB as f64) as u64,
654 available_bytes: ((96.0 - used_memory) * GIB as f64) as u64,
655 free_bytes: 12 * GIB,
656 cached_bytes: Some(44 * GIB),
657 swap_total_bytes: 8 * GIB,
658 swap_used_bytes: 256 << 20,
659 ..Default::default()
660 },
661 gpus: vec![
662 GpuStats {
663 index: 0,
664 vendor: "NVIDIA".to_owned(),
665 name: "NVIDIA GeForce RTX 4090".to_owned(),
666 utilization_percent: Some(gpu0_util.clamp(0.0, 100.0) as f32),
667 memory_utilization_percent: Some((gpu0_util * 0.6) as f32),
668 memory_used_bytes: Some(21 * GIB + GIB / 3),
669 memory_total_bytes: Some(24 * GIB),
670 temperature_c: Some((52.0 + 0.2 * gpu0_util) as f32),
671 power_w: Some(gpu0_w as f32),
672 power_limit_w: Some(450.0),
673 fan_percent: Some((30.0 + 0.3 * gpu0_util) as f32),
674 clock_mhz: Some(2_520),
675 throttle_reasons: if gpu0_util > 90.0 {
677 vec!["power cap".to_owned()]
678 } else {
679 Vec::new()
680 },
681 pcie: Some(PcieLink {
682 generation: 4,
683 width: 8,
684 max_generation: 4,
685 max_width: 16,
686 }),
687 processes: vec![GpuProcess {
688 pid: train_pid,
689 memory_bytes: Some(21 * GIB),
690 sm_percent: Some(gpu0_util.clamp(0.0, 100.0) as f32),
691 }],
692 ..Default::default()
693 },
694 GpuStats {
695 index: 1,
696 vendor: "NVIDIA".to_owned(),
697 name: "NVIDIA RTX 4000 Ada Generation".to_owned(),
698 utilization_percent: Some(gpu1_util as f32),
699 memory_utilization_percent: Some((gpu1_util * 0.4) as f32),
700 memory_used_bytes: Some(9 * GIB + GIB * 4 / 5),
701 memory_total_bytes: Some(20 * GIB),
702 temperature_c: Some((44.0 + 0.2 * gpu1_util) as f32),
703 power_w: Some(gpu1_w as f32),
704 power_limit_w: Some(130.0),
705 fan_percent: Some((30.0 + 0.4 * gpu1_util) as f32),
706 clock_mhz: Some(if gpu1_util > 5.0 { 2_175 } else { 210 }),
707 ecc: Some(EccErrors::default()),
708 processes: vec![GpuProcess {
709 pid: ollama_pid,
710 memory_bytes: Some(9 * GIB + GIB / 2),
711 sm_percent: Some(gpu1_util as f32),
712 }],
713 ..Default::default()
714 },
715 ],
716 sensors: vec![
717 SensorChip {
718 key: k10.to_owned(),
719 name: "k10temp".to_owned(),
720 temperatures: vec![
721 temp(k10, 1, "Tctl", tctl),
722 temp(k10, 3, "Tccd1", tctl - 8.0),
723 temp(k10, 4, "Tccd2", tctl - 9.0),
724 ],
725 fans: Vec::new(),
726 },
727 SensorChip {
728 key: nct.to_owned(),
729 name: "nct6686".to_owned(),
730 temperatures: vec![
731 temp(nct, 1, "Thermistor 14", 38.0 + 0.1 * cpu_total),
732 temp(nct, 2, "AMD TSI Addr 98h", tctl - 2.0),
733 ],
734 fans: vec![
735 fan(nct, 1, 850.0 + 9.0 * cpu_total),
736 fan(nct, 2, 0.0),
737 fan(nct, 3, 780.0 + 4.0 * cpu_total),
738 ],
739 },
740 nvme(0, drive_temp(0)),
741 nvme(1, drive_temp(1)),
742 nvme(2, drive_temp(2)),
743 ],
744 power: PowerStats {
745 cpu_package_w: Some(cpu_w as f32),
746 gpus_w: Some((gpu0_w + gpu1_w) as f32),
747 igpu_w: None,
748 total_w: Some((cpu_w + gpu0_w + gpu1_w) as f32),
749 battery: None,
750 },
751 disks: (0..3).map(disk_io).collect(),
752 filesystems: vec![
753 FilesystemUsage {
754 device: "/dev/nvme0n1p2".to_owned(),
755 fs_type: "btrfs".to_owned(),
756 mount_points: vec!["/".to_owned(), "/home".to_owned()],
757 total_bytes: 5 * TIB + TIB / 2,
758 available_bytes: 3 * TIB + TIB / 10,
759 full_in_secs: None,
760 },
761 FilesystemUsage {
762 device: "/dev/nvme0n1p1".to_owned(),
763 fs_type: "vfat".to_owned(),
764 mount_points: vec!["/boot/efi".to_owned()],
765 total_bytes: GIB,
766 available_bytes: GIB - (60 << 20),
767 full_in_secs: None,
768 },
769 ],
770 raid: RaidStats {
771 btrfs: vec![BtrfsFilesystem {
772 fsid: "6f1c2a9e-0d4b-4e58-9a3e-2b7c51d0e8f4".to_owned(),
773 label: String::new(),
774 mount_points: vec!["/".to_owned(), "/home".to_owned()],
775 devices: vec![
776 "nvme0n1p2".to_owned(),
777 "nvme1n1".to_owned(),
778 "nvme2n1".to_owned(),
779 ],
780 device_stats: (1..=3)
781 .map(|devid| BtrfsDeviceStats {
782 devid,
783 name: if devid == 1 {
784 "nvme0n1p2".to_owned()
785 } else {
786 format!("nvme{}n1", devid - 1)
787 },
788 size_bytes: Some(2 * TIB - (1 << 33)),
789 used_bytes: Some(TIB - (1 << 36)),
790 ..Default::default()
791 })
792 .collect(),
793 allocations: vec![
794 BtrfsAllocation {
795 kind: "data".to_owned(),
796 profile: "raid0".to_owned(),
797 total_bytes: 2 * TIB + TIB / 2,
798 used_bytes: 2 * TIB + TIB / 3,
799 },
800 BtrfsAllocation {
801 kind: "metadata".to_owned(),
802 profile: "raid1c3".to_owned(),
803 total_bytes: 12 * GIB,
804 used_bytes: 9 * GIB,
805 },
806 BtrfsAllocation {
807 kind: "system".to_owned(),
808 profile: "raid1c3".to_owned(),
809 total_bytes: 32 << 20,
810 used_bytes: 1 << 20,
811 },
812 ],
813 scrub: Some(BtrfsScrub {
814 started_at_ms: now_ms() - 3 * 86_400_000,
815 duration_secs: 1_920,
816 state: "finished".to_owned(),
817 bytes_scrubbed: 2 * TIB + TIB / 3,
818 ..Default::default()
819 }),
820 }],
821 md: Vec::new(),
822 zfs: Vec::new(),
823 },
824 smart: (0..3).map(smart).collect(),
825 networks: vec![
826 NetworkStats {
827 name: "enp9s0".to_owned(),
828 addresses: vec!["192.0.2.10/24".to_owned(), "2001:db8::10/64".to_owned()],
829 rx_bytes_per_sec: 280e6 * download + 60e3 * wobble(12, t, 1.5),
830 tx_bytes_per_sec: 4e6 * download + 25e3 * wobble(13, t, 2.0),
831 link_speed_mbps: Some(10_000),
832 link_up: Some(true),
833 ..Default::default()
834 },
835 NetworkStats {
836 name: "tailscale0".to_owned(),
837 addresses: vec!["198.51.100.10/32".to_owned()],
838 rx_bytes_per_sec: 9e3 * wobble(14, t, 3.0),
839 tx_bytes_per_sec: 40e3 * wobble(15, t, 4.0),
840 rx_total_bytes: 3 * GIB,
841 tx_total_bytes: 9 * GIB,
842 link_up: Some(true),
843 ..Default::default()
844 },
845 ],
846 processes,
847 ..Default::default()
848 }
849}
850
851fn laptop(t: f64) -> Snapshot {
852 let busy = (wobble(40, t, 8.0) - 0.3).max(0.0) / 0.7;
853 let cpu_total = 6.0 + 35.0 * busy;
854 let per_core = cores(200, t, 16, cpu_total);
855 let tctl = 46.0 + 0.5 * cpu_total;
856 let soc_w = 5.0 + 0.4 * cpu_total;
857 let k10 = "k10temp:0000:00:18.3";
858 Snapshot {
859 host: HostInfo {
860 hostname: "laptop".to_owned(),
861 os: "Linux (CachyOS)".to_owned(),
862 kernel: "7.0.2-cachyos".to_owned(),
863 uptime_secs: 8 * 86_400 + 10 * 3600 + t as u64,
864 rigtop_version: "0.8.2".to_owned(),
866 reboot_required: None,
867 },
868 cpu: CpuStats {
869 model: "AMD Ryzen 7 PRO 7840U w/ Radeon 780M Graphics".to_owned(),
870 total_percent: cpu_total as f32,
871 per_core_mhz: per_core.iter().map(|u| 1_400 + (*u as u64) * 35).collect(),
872 per_core_percent: per_core,
873 load_average: Some([0.8 + 3.0 * busy, 1.6, 1.4]),
874 temperatures: vec![temp(k10, 1, "Tctl", tctl)],
875 per_core_celsius: Vec::new(),
876 },
877 memory: MemoryStats {
878 total_bytes: 30 * GIB,
879 used_bytes: 15 * GIB + (busy * 2.0 * GIB as f64) as u64,
880 available_bytes: 14 * GIB,
881 free_bytes: 3 * GIB,
882 cached_bytes: Some(10 * GIB),
883 swap_total_bytes: 30 * GIB,
884 swap_used_bytes: 0,
885 ..Default::default()
886 },
887 gpus: vec![GpuStats {
888 index: 0,
889 vendor: "AMD".to_owned(),
890 name: "AMD Radeon 780M".to_owned(),
891 integrated: true,
892 utilization_percent: Some((4.0 + 20.0 * busy) as f32),
893 memory_used_bytes: Some(GIB + GIB / 2),
894 memory_total_bytes: Some(2 * GIB),
895 temperature_c: Some((tctl - 3.0) as f32),
896 clock_mhz: Some(800),
897 ..Default::default()
898 }],
899 sensors: vec![SensorChip {
900 key: k10.to_owned(),
901 name: "k10temp".to_owned(),
902 temperatures: vec![temp(k10, 1, "Tctl", tctl)],
903 fans: Vec::new(),
904 }],
905 power: PowerStats {
906 igpu_w: Some(soc_w as f32),
907 total_w: Some(soc_w as f32),
908 battery: Some(BatteryStats {
909 percent: Some((78.0 - t / 600.0).max(5.0) as f32),
910 status: "Discharging".to_owned(),
911 power_w: Some((soc_w + 3.0) as f32),
912 time_left_secs: Some(
915 ((78.0 - t / 600.0).max(5.0) / 100.0 * 44.0 / 12.0 * 3600.0) as u64,
916 ),
917 health_percent: Some(77.0),
918 }),
919 ..Default::default()
920 },
921 disks: vec![DiskIo {
922 name: "nvme0n1".to_owned(),
923 read_bytes_per_sec: 6e6 * busy,
924 write_bytes_per_sec: 1e6 * busy,
925 busy_percent: Some((4.0 * busy) as f32),
926 latency_ms: Some((0.1 + 0.3 * busy) as f32),
927 temperature_c: Some((38.0 + 4.0 * busy) as f32),
928 }],
929 filesystems: vec![FilesystemUsage {
930 device: "/dev/nvme0n1p2".to_owned(),
931 fs_type: "ext4".to_owned(),
932 mount_points: vec!["/".to_owned()],
933 total_bytes: 930 * GIB,
934 available_bytes: 352 * GIB,
935 full_in_secs: None,
936 }],
937 networks: vec![NetworkStats {
938 name: "wlp1s0".to_owned(),
939 addresses: vec!["192.0.2.24/24".to_owned()],
940 rx_bytes_per_sec: 1.5e6 * busy + 20e3 * wobble(41, t, 2.0),
941 tx_bytes_per_sec: 0.3e6 * busy + 8e3 * wobble(42, t, 3.0),
942 link_up: Some(true),
943 wireless: Some(WirelessLink {
944 rx_bitrate_mbps: Some(1_201.0),
945 tx_bitrate_mbps: Some(864.0),
946 signal_dbm: Some(-54),
947 }),
948 ..Default::default()
949 }],
950 processes: vec![
951 process(
952 2_810,
953 "plasmashell",
954 "/usr/bin/plasmashell",
955 "demo",
956 1.0 + 8.0 * busy,
957 520 << 20,
958 24,
959 8 * 86_400,
960 ),
961 process(
962 4_420,
963 "code",
964 "/opt/visual-studio-code/code",
965 "demo",
966 2.0 + 30.0 * busy,
967 900 << 20,
968 38,
969 86_400,
970 ),
971 process(
972 4_980,
973 "firefox",
974 "/usr/lib/firefox/firefox",
975 "demo",
976 1.5 + 10.0 * wobble(43, t, 6.0),
977 1_800 << 20,
978 96,
979 3 * 86_400,
980 ),
981 process(
982 5_102,
983 "rigtop",
984 "rigtop --agent",
985 "demo",
986 0.6,
987 10 << 20,
988 3,
989 3_600,
990 ),
991 process(
992 1,
993 "systemd",
994 "/sbin/init",
995 "root",
996 0.0,
997 12 << 20,
998 1,
999 8 * 86_400,
1000 ),
1001 ],
1002 ..Default::default()
1003 }
1004}
1005
1006fn nas(t: f64) -> Snapshot {
1007 let cpu_total = 18.0 + 10.0 * wobble(60, t, 5.0);
1008 let per_core = cores(300, t, 4, cpu_total);
1009 let resync = 42.0 + t / 120.0;
1010 let disks = ["sda", "sdb", "sdc", "sdd"];
1011 let smart = |i: usize| SmartDevice {
1012 device: disks[i].to_owned(),
1013 protocol: "ATA".to_owned(),
1014 model: "WDC WD80EFPX-68C4ZN0".to_owned(),
1015 passed: Some(true),
1016 temperature_c: Some(38.0 + i as f32),
1017 power_on_hours: Some(21_400 + 200 * i as u64),
1018 reallocated_sectors: Some(if i == 2 { 8 } else { 0 }),
1019 pending_sectors: Some(0),
1020 offline_uncorrectable: Some(0),
1021 collected_at_ms: Some(now_ms() - 300_000),
1022 ..Default::default()
1023 };
1024 Snapshot {
1025 pressure: Some(Pressure {
1027 io: PressureLevels {
1028 some_avg10: 14.0,
1029 some_avg60: 12.0,
1030 full_avg10: 6.0,
1031 full_avg60: 5.0,
1032 },
1033 ..Default::default()
1034 }),
1035 host: HostInfo {
1036 hostname: "nas".to_owned(),
1037 os: "Linux (Debian 13)".to_owned(),
1038 kernel: "6.12.48-amd64".to_owned(),
1039 uptime_secs: 41 * 86_400 + t as u64,
1040 rigtop_version: env!("CARGO_PKG_VERSION").to_owned(),
1041 reboot_required: Some("updated: linux-image-6.12.57-amd64".to_owned()),
1043 },
1044 cpu: CpuStats {
1045 model: "Intel(R) Celeron(R) N5105 @ 2.00GHz".to_owned(),
1046 total_percent: cpu_total as f32,
1047 per_core_mhz: vec![2_000; 4],
1048 per_core_celsius: per_core.iter().map(|u| Some(44.0 + u * 0.1)).collect(),
1050 per_core_percent: per_core,
1051 load_average: Some([1.9, 2.1, 2.0]),
1052 temperatures: vec![temp("coretemp:coretemp.0", 1, "Package id 0", 52.0)],
1053 },
1054 memory: MemoryStats {
1055 total_bytes: 16 * GIB,
1056 used_bytes: 3 * GIB,
1057 available_bytes: 12 * GIB,
1058 free_bytes: GIB,
1059 cached_bytes: Some(11 * GIB),
1060 ..Default::default()
1061 },
1062 disks: (0..4)
1063 .map(|i| DiskIo {
1064 name: disks[i].to_owned(),
1065 read_bytes_per_sec: 150e6 + 20e6 * wobble(61 + i as u64, t, 4.0),
1066 write_bytes_per_sec: if i == 3 { 148e6 } else { 1e6 },
1067 busy_percent: Some(88.0 + 8.0 * wobble(65 + i as u64, t, 3.0) as f32),
1068 latency_ms: Some(if i == 2 {
1070 140.0 + 40.0 * wobble(69, t, 5.0) as f32
1071 } else {
1072 9.0 + 3.0 * wobble(66 + i as u64, t, 4.0) as f32
1073 }),
1074 temperature_c: Some(38.0 + i as f32),
1075 })
1076 .collect(),
1077 filesystems: vec![
1078 FilesystemUsage {
1079 device: "/dev/md0".to_owned(),
1080 fs_type: "ext4".to_owned(),
1081 mount_points: vec!["/srv".to_owned()],
1082 total_bytes: 21 * TIB + TIB / 2,
1083 available_bytes: 2 * TIB - TIB / 5,
1084 full_in_secs: None,
1085 },
1086 FilesystemUsage {
1087 device: "/dev/sde2".to_owned(),
1088 fs_type: "ext4".to_owned(),
1089 mount_points: vec!["/".to_owned()],
1090 total_bytes: 230 * GIB,
1091 available_bytes: 180 * GIB,
1092 full_in_secs: None,
1093 },
1094 ],
1095 raid: RaidStats {
1096 btrfs: Vec::new(),
1097 md: vec![MdArray {
1098 name: "md0".to_owned(),
1099 active: true,
1100 level: "raid5".to_owned(),
1101 members: disks
1102 .iter()
1103 .enumerate()
1104 .map(|(role, name)| MdMember {
1105 name: (*name).to_owned(),
1106 role: role as u32,
1107 ..Default::default()
1108 })
1109 .collect(),
1110 expected_devices: Some(4),
1111 working_devices: Some(4),
1112 sync: Some(MdSync {
1113 action: "resync".to_owned(),
1114 percent: resync.min(99.9) as f32,
1115 finish_minutes: Some((100.0 - resync).max(0.0) as f32 * 9.0),
1116 speed_kib_per_sec: Some(148_000),
1117 }),
1118 ..Default::default()
1119 }],
1120 zfs: vec![ZfsPool {
1121 name: "backup".to_owned(),
1122 state: "ONLINE".to_owned(),
1123 devices: ["mirror-0", "ata-WDC_WD120EFBX-1", "ata-WDC_WD120EFBX-2"]
1124 .iter()
1125 .enumerate()
1126 .map(|(i, name)| ZfsDevice {
1127 name: (*name).to_owned(),
1128 depth: u8::from(i > 0),
1129 state: "ONLINE".to_owned(),
1130 ..Default::default()
1131 })
1132 .collect(),
1133 scan: Some(
1134 "scrub repaired 0B in 03:12:44 with 0 errors on Sun Oct 25 06:12:44 2026"
1135 .to_owned(),
1136 ),
1137 data_errors: Some(0),
1138 size_bytes: Some(12 << 40),
1139 allocated_bytes: Some(7 << 40),
1140 }],
1141 },
1142 smart: (0..4).map(smart).collect(),
1143 networks: vec![NetworkStats {
1144 name: "eno1".to_owned(),
1145 addresses: vec!["192.0.2.5/24".to_owned()],
1146 rx_bytes_per_sec: 2e6 * wobble(70, t, 6.0),
1147 tx_bytes_per_sec: 95e6 * ((wobble(71, t, 10.0) - 0.3).max(0.0) / 0.7),
1148 link_speed_mbps: Some(2_500),
1149 link_up: Some(true),
1150 ..Default::default()
1151 }],
1152 processes: vec![
1153 process(812, "md0_resync", "", "root", 9.0, 0, 1, 3 * 3600),
1155 with_io(
1157 process(
1158 1_204,
1159 "smbd",
1160 "/usr/sbin/smbd --foreground --no-process-group",
1161 "root",
1162 6.0 + 20.0 * wobble(72, t, 10.0),
1163 120 << 20,
1164 4,
1165 41 * 86_400,
1166 ),
1167 95e6 * ((wobble(71, t, 10.0) - 0.3).max(0.0) / 0.7),
1168 1e6 * wobble(74, t, 5.0),
1169 ),
1170 with_io(
1171 process(
1172 1_330,
1173 "jellyfin",
1174 "/usr/lib/jellyfin/bin/jellyfin",
1175 "jellyfin",
1176 3.0 + 4.0 * wobble(73, t, 7.0),
1177 700 << 20,
1178 30,
1179 41 * 86_400,
1180 ),
1181 4e6 + 4e6 * wobble(75, t, 7.0),
1182 0.0,
1183 ),
1184 process(
1185 1_002,
1186 "rigtop",
1187 "rigtop --agent",
1188 "admin",
1189 0.4,
1190 9 << 20,
1191 3,
1192 3_600,
1193 ),
1194 process(
1195 1,
1196 "systemd",
1197 "/sbin/init",
1198 "root",
1199 0.0,
1200 11 << 20,
1201 1,
1202 41 * 86_400,
1203 ),
1204 ],
1205 ups: vec![UpsStats {
1207 name: "ups".to_owned(),
1208 model: Some("APC Back-UPS Pro 900".to_owned()),
1209 status: vec!["OL".to_owned()],
1210 charge_percent: Some(100.0),
1211 runtime_secs: Some(42 * 60),
1212 load_percent: Some(19.0 + 2.0 * wobble(61, t, 7.0) as f32),
1213 power_w: Some(102.0 + 11.0 * wobble(61, t, 7.0) as f32),
1214 input_voltage: Some(231.0),
1215 error: None,
1216 }],
1217 ..Default::default()
1218 }
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223 use super::*;
1224 use crate::view::{Severity, alerts};
1225
1226 #[test]
1227 fn demo_hosts_change_over_time_and_repeat_exactly() {
1228 let mut a = DemoSource::new(DemoHost::Workstation);
1229 let mut b = DemoSource::new(DemoHost::Workstation);
1230 let first = a.poll().unwrap();
1231 b.poll().unwrap();
1232 let mut later = first.clone();
1233 for _ in 0..30 {
1234 later = a.poll().unwrap();
1235 b.poll().unwrap();
1236 }
1237 assert_ne!(first.cpu.total_percent, later.cpu.total_percent);
1238 assert_eq!(
1239 b.poll().unwrap().cpu.total_percent,
1240 a.poll().unwrap().cpu.total_percent
1241 );
1242 assert!(later.networks[0].rx_total_bytes > first.networks[0].rx_total_bytes);
1244 assert_eq!(later.process_count, later.processes.len());
1245 for gpu in &later.gpus {
1247 for p in &gpu.processes {
1248 assert!(later.processes.iter().any(|q| q.pid == p.pid));
1249 }
1250 }
1251 }
1252
1253 #[test]
1254 fn the_nas_needs_attention() {
1255 let snapshot = DemoSource::new(DemoHost::Nas).poll().unwrap();
1256 let found = alerts(&snapshot);
1257 let texts: Vec<&str> = found.iter().map(|a| a.text.as_str()).collect();
1258 assert_eq!(found[0].severity, Severity::Critical);
1259 assert!(texts.contains(&"sdc: SMART warning"), "{texts:?}");
1260 assert!(
1261 texts.iter().any(|t| t.starts_with("md0 resync")),
1262 "{texts:?}"
1263 );
1264 assert!(texts.iter().any(|t| t.starts_with("/srv ")), "{texts:?}");
1265 assert!(alerts(&DemoSource::new(DemoHost::Workstation).poll().unwrap()).is_empty());
1266 }
1267
1268 #[test]
1269 fn wobble_stays_in_range() {
1270 for i in 0..1000 {
1271 let v = wobble(7, i as f64 * 0.37, 3.0);
1272 assert!((0.0..=1.0).contains(&v));
1273 }
1274 }
1275}