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