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