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