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