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