Skip to main content

rigtop_core/
demo.rs

1//! Made-up hosts for trying the interface and taking screenshots without
2//! real hardware (`rigtop --demo`, `rigtop-gui --demo`): a workstation
3//! training on two GPUs, a laptop on battery and a NAS that needs attention.
4//!
5//! The readings follow fixed formulas of time, so every run looks the same.
6//! Every poll is one second of made-up time, whatever the interval, so a
7//! short interval fast-forwards (handy to fill the graphs for screenshots).
8
9use 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/// Which made-up machine a [`DemoSource`] plays.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum DemoHost {
27    /// 16 cores, two NVIDIA GPUs, three NVMe drives in btrfs RAID.
28    Workstation,
29    /// An APU laptop on battery and Wi-Fi.
30    Laptop,
31    /// Four hard drives in mdadm RAID 5, resyncing, one with reallocated
32    /// sectors, and a nearly full filesystem.
33    Nas,
34}
35
36impl DemoHost {
37    /// All demo hosts, in overview order.
38    pub const ALL: [DemoHost; 3] = [DemoHost::Workstation, DemoHost::Laptop, DemoHost::Nas];
39
40    /// Host name shown in tabs and cards.
41    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
50/// The demo hosts as host specs, in place of this machine and `[[hosts]]`.
51pub 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/// A [`DataSource`] with made-up readings that change over time.
63#[derive(Debug, Clone)]
64pub struct DemoSource {
65    host: DemoHost,
66    /// Seconds of made-up time.
67    time: f64,
68    rx_total: f64,
69    tx_total: f64,
70}
71
72impl DemoSource {
73    /// A source playing `host`.
74    pub fn new(host: DemoHost) -> Self {
75        Self {
76            host,
77            time: 0.0,
78            // Some traffic since boot.
79            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        // Nothing to stop: pretend it worked.
108        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
126/// A smooth, repeatable wobble in 0..1 for series `seed` at time `t`, with
127/// changes over roughly `period` seconds.
128fn 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    // Smoothstep between random values at whole cells.
133    let s = frac * frac * (3.0 - 2.0 * frac);
134    at(cell) * (1.0 - s) + at(cell + 1.0) * s
135}
136
137/// A pseudo-random value in 0..1 (splitmix64).
138fn 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
146/// 0..1 while a job that runs `busy` seconds out of every `period` is on,
147/// with soft edges.
148fn 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
179/// Per-core usage around `total`, some cores busier than others.
180fn 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/// A process; `cpu` in percent of one core.
190#[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    // A build every two minutes on top of a steady training run.
218    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    // The training run dips while it evaluates every 5 minutes.
224    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    // Inference requests come and go.
228    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    // Background services and desktop programs, mostly idle.
339    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        // cargo and a few rustc jobs while building.
469        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        // Traffic adds up.
1041        assert!(later.networks[0].rx_total_bytes > first.networks[0].rx_total_bytes);
1042        assert_eq!(later.process_count, later.processes.len());
1043        // GPU processes are in the process list.
1044        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}