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            rigtop_version: env!("CARGO_PKG_VERSION").to_owned(),
533        },
534        cpu: CpuStats {
535            model: "AMD Ryzen 9 9950X3D 16-Core Processor".to_owned(),
536            total_percent: cpu_total as f32,
537            per_core_mhz: per_core.iter().map(|u| 3_000 + (*u as u64) * 25).collect(),
538            per_core_percent: per_core,
539            load_average: Some([
540                0.4 + 22.0 * build,
541                3.0 + 9.0 * duty(t + 30.0, 120.0, 90.0),
542                6.2,
543            ]),
544            temperatures: vec![
545                temp(k10, 1, "Tctl", tctl),
546                temp(k10, 3, "Tccd1", tctl - 8.0),
547                temp(k10, 4, "Tccd2", tctl - 9.0),
548            ],
549        },
550        memory: MemoryStats {
551            total_bytes: 96 * GIB,
552            used_bytes: (used_memory * GIB as f64) as u64,
553            available_bytes: ((96.0 - used_memory) * GIB as f64) as u64,
554            free_bytes: 12 * GIB,
555            cached_bytes: Some(44 * GIB),
556            swap_total_bytes: 8 * GIB,
557            swap_used_bytes: 256 << 20,
558        },
559        gpus: vec![
560            GpuStats {
561                index: 0,
562                vendor: "NVIDIA".to_owned(),
563                name: "NVIDIA GeForce RTX 4090".to_owned(),
564                utilization_percent: Some(gpu0_util.clamp(0.0, 100.0) as f32),
565                memory_utilization_percent: Some((gpu0_util * 0.6) as f32),
566                memory_used_bytes: Some(21 * GIB + GIB / 3),
567                memory_total_bytes: Some(24 * GIB),
568                temperature_c: Some((52.0 + 0.2 * gpu0_util) as f32),
569                power_w: Some(gpu0_w as f32),
570                power_limit_w: Some(450.0),
571                fan_percent: Some((30.0 + 0.3 * gpu0_util) as f32),
572                clock_mhz: Some(2_520),
573                processes: vec![GpuProcess {
574                    pid: train_pid,
575                    memory_bytes: Some(21 * GIB),
576                    sm_percent: Some(gpu0_util.clamp(0.0, 100.0) as f32),
577                }],
578                ..Default::default()
579            },
580            GpuStats {
581                index: 1,
582                vendor: "NVIDIA".to_owned(),
583                name: "NVIDIA RTX 4000 Ada Generation".to_owned(),
584                utilization_percent: Some(gpu1_util as f32),
585                memory_utilization_percent: Some((gpu1_util * 0.4) as f32),
586                memory_used_bytes: Some(9 * GIB + GIB * 4 / 5),
587                memory_total_bytes: Some(20 * GIB),
588                temperature_c: Some((44.0 + 0.2 * gpu1_util) as f32),
589                power_w: Some(gpu1_w as f32),
590                power_limit_w: Some(130.0),
591                fan_percent: Some((30.0 + 0.4 * gpu1_util) as f32),
592                clock_mhz: Some(if gpu1_util > 5.0 { 2_175 } else { 210 }),
593                processes: vec![GpuProcess {
594                    pid: ollama_pid,
595                    memory_bytes: Some(9 * GIB + GIB / 2),
596                    sm_percent: Some(gpu1_util as f32),
597                }],
598                ..Default::default()
599            },
600        ],
601        sensors: vec![
602            SensorChip {
603                key: k10.to_owned(),
604                name: "k10temp".to_owned(),
605                temperatures: vec![
606                    temp(k10, 1, "Tctl", tctl),
607                    temp(k10, 3, "Tccd1", tctl - 8.0),
608                    temp(k10, 4, "Tccd2", tctl - 9.0),
609                ],
610                fans: Vec::new(),
611            },
612            SensorChip {
613                key: nct.to_owned(),
614                name: "nct6686".to_owned(),
615                temperatures: vec![
616                    temp(nct, 1, "Thermistor 14", 38.0 + 0.1 * cpu_total),
617                    temp(nct, 2, "AMD TSI Addr 98h", tctl - 2.0),
618                ],
619                fans: vec![
620                    fan(nct, 1, 850.0 + 9.0 * cpu_total),
621                    fan(nct, 2, 0.0),
622                    fan(nct, 3, 780.0 + 4.0 * cpu_total),
623                ],
624            },
625            nvme(0, drive_temp(0)),
626            nvme(1, drive_temp(1)),
627            nvme(2, drive_temp(2)),
628        ],
629        power: PowerStats {
630            cpu_package_w: Some(cpu_w as f32),
631            gpus_w: Some((gpu0_w + gpu1_w) as f32),
632            igpu_w: None,
633            total_w: Some((cpu_w + gpu0_w + gpu1_w) as f32),
634            battery: None,
635        },
636        disks: (0..3).map(disk_io).collect(),
637        filesystems: vec![
638            FilesystemUsage {
639                device: "/dev/nvme0n1p2".to_owned(),
640                fs_type: "btrfs".to_owned(),
641                mount_points: vec!["/".to_owned(), "/home".to_owned()],
642                total_bytes: 5 * TIB + TIB / 2,
643                available_bytes: 3 * TIB + TIB / 10,
644            },
645            FilesystemUsage {
646                device: "/dev/nvme0n1p1".to_owned(),
647                fs_type: "vfat".to_owned(),
648                mount_points: vec!["/boot/efi".to_owned()],
649                total_bytes: GIB,
650                available_bytes: GIB - (60 << 20),
651            },
652        ],
653        raid: RaidStats {
654            btrfs: vec![BtrfsFilesystem {
655                fsid: "6f1c2a9e-0d4b-4e58-9a3e-2b7c51d0e8f4".to_owned(),
656                label: String::new(),
657                mount_points: vec!["/".to_owned(), "/home".to_owned()],
658                devices: vec![
659                    "nvme0n1p2".to_owned(),
660                    "nvme1n1".to_owned(),
661                    "nvme2n1".to_owned(),
662                ],
663                device_stats: (1..=3)
664                    .map(|devid| BtrfsDeviceStats {
665                        devid,
666                        name: if devid == 1 {
667                            "nvme0n1p2".to_owned()
668                        } else {
669                            format!("nvme{}n1", devid - 1)
670                        },
671                        size_bytes: Some(2 * TIB - (1 << 33)),
672                        used_bytes: Some(TIB - (1 << 36)),
673                        ..Default::default()
674                    })
675                    .collect(),
676                allocations: vec![
677                    BtrfsAllocation {
678                        kind: "data".to_owned(),
679                        profile: "raid0".to_owned(),
680                        total_bytes: 2 * TIB + TIB / 2,
681                        used_bytes: 2 * TIB + TIB / 3,
682                    },
683                    BtrfsAllocation {
684                        kind: "metadata".to_owned(),
685                        profile: "raid1c3".to_owned(),
686                        total_bytes: 12 * GIB,
687                        used_bytes: 9 * GIB,
688                    },
689                    BtrfsAllocation {
690                        kind: "system".to_owned(),
691                        profile: "raid1c3".to_owned(),
692                        total_bytes: 32 << 20,
693                        used_bytes: 1 << 20,
694                    },
695                ],
696                scrub: Some(BtrfsScrub {
697                    started_at_ms: now_ms() - 3 * 86_400_000,
698                    duration_secs: 1_920,
699                    state: "finished".to_owned(),
700                    bytes_scrubbed: 2 * TIB + TIB / 3,
701                    ..Default::default()
702                }),
703            }],
704            md: Vec::new(),
705        },
706        smart: (0..3).map(smart).collect(),
707        networks: vec![
708            NetworkStats {
709                name: "enp9s0".to_owned(),
710                rx_bytes_per_sec: 280e6 * download + 60e3 * wobble(12, t, 1.5),
711                tx_bytes_per_sec: 4e6 * download + 25e3 * wobble(13, t, 2.0),
712                link_speed_mbps: Some(10_000),
713                link_up: Some(true),
714                ..Default::default()
715            },
716            NetworkStats {
717                name: "tailscale0".to_owned(),
718                rx_bytes_per_sec: 9e3 * wobble(14, t, 3.0),
719                tx_bytes_per_sec: 40e3 * wobble(15, t, 4.0),
720                rx_total_bytes: 3 * GIB,
721                tx_total_bytes: 9 * GIB,
722                link_up: Some(true),
723                ..Default::default()
724            },
725        ],
726        processes,
727        ..Default::default()
728    }
729}
730
731fn laptop(t: f64) -> Snapshot {
732    let busy = (wobble(40, t, 8.0) - 0.3).max(0.0) / 0.7;
733    let cpu_total = 6.0 + 35.0 * busy;
734    let per_core = cores(200, t, 16, cpu_total);
735    let tctl = 46.0 + 0.5 * cpu_total;
736    let soc_w = 5.0 + 0.4 * cpu_total;
737    let k10 = "k10temp:0000:00:18.3";
738    Snapshot {
739        host: HostInfo {
740            hostname: "laptop".to_owned(),
741            os: "Linux (CachyOS)".to_owned(),
742            kernel: "7.0.2-cachyos".to_owned(),
743            uptime_secs: 8 * 86_400 + 10 * 3600 + t as u64,
744            // An agent nobody updated yet.
745            rigtop_version: "0.8.2".to_owned(),
746        },
747        cpu: CpuStats {
748            model: "AMD Ryzen 7 PRO 7840U w/ Radeon 780M Graphics".to_owned(),
749            total_percent: cpu_total as f32,
750            per_core_mhz: per_core.iter().map(|u| 1_400 + (*u as u64) * 35).collect(),
751            per_core_percent: per_core,
752            load_average: Some([0.8 + 3.0 * busy, 1.6, 1.4]),
753            temperatures: vec![temp(k10, 1, "Tctl", tctl)],
754        },
755        memory: MemoryStats {
756            total_bytes: 30 * GIB,
757            used_bytes: 15 * GIB + (busy * 2.0 * GIB as f64) as u64,
758            available_bytes: 14 * GIB,
759            free_bytes: 3 * GIB,
760            cached_bytes: Some(10 * GIB),
761            swap_total_bytes: 30 * GIB,
762            swap_used_bytes: 0,
763        },
764        gpus: vec![GpuStats {
765            index: 0,
766            vendor: "AMD".to_owned(),
767            name: "AMD Radeon 780M".to_owned(),
768            integrated: true,
769            utilization_percent: Some((4.0 + 20.0 * busy) as f32),
770            memory_used_bytes: Some(GIB + GIB / 2),
771            memory_total_bytes: Some(2 * GIB),
772            temperature_c: Some((tctl - 3.0) as f32),
773            clock_mhz: Some(800),
774            ..Default::default()
775        }],
776        sensors: vec![SensorChip {
777            key: k10.to_owned(),
778            name: "k10temp".to_owned(),
779            temperatures: vec![temp(k10, 1, "Tctl", tctl)],
780            fans: Vec::new(),
781        }],
782        power: PowerStats {
783            igpu_w: Some(soc_w as f32),
784            total_w: Some(soc_w as f32),
785            battery: Some(BatteryStats {
786                percent: Some((78.0 - t / 600.0).max(5.0) as f32),
787                status: "Discharging".to_owned(),
788                power_w: Some((soc_w + 3.0) as f32),
789            }),
790            ..Default::default()
791        },
792        disks: vec![DiskIo {
793            name: "nvme0n1".to_owned(),
794            read_bytes_per_sec: 6e6 * busy,
795            write_bytes_per_sec: 1e6 * busy,
796            busy_percent: Some((4.0 * busy) as f32),
797            temperature_c: Some((38.0 + 4.0 * busy) as f32),
798        }],
799        filesystems: vec![FilesystemUsage {
800            device: "/dev/nvme0n1p2".to_owned(),
801            fs_type: "ext4".to_owned(),
802            mount_points: vec!["/".to_owned()],
803            total_bytes: 930 * GIB,
804            available_bytes: 352 * GIB,
805        }],
806        networks: vec![NetworkStats {
807            name: "wlp1s0".to_owned(),
808            rx_bytes_per_sec: 1.5e6 * busy + 20e3 * wobble(41, t, 2.0),
809            tx_bytes_per_sec: 0.3e6 * busy + 8e3 * wobble(42, t, 3.0),
810            link_up: Some(true),
811            wireless: Some(WirelessLink {
812                rx_bitrate_mbps: Some(1_201.0),
813                tx_bitrate_mbps: Some(864.0),
814                signal_dbm: Some(-54),
815            }),
816            ..Default::default()
817        }],
818        processes: vec![
819            process(
820                2_810,
821                "plasmashell",
822                "/usr/bin/plasmashell",
823                "demo",
824                1.0 + 8.0 * busy,
825                520 << 20,
826                24,
827                8 * 86_400,
828            ),
829            process(
830                4_420,
831                "code",
832                "/opt/visual-studio-code/code",
833                "demo",
834                2.0 + 30.0 * busy,
835                900 << 20,
836                38,
837                86_400,
838            ),
839            process(
840                4_980,
841                "firefox",
842                "/usr/lib/firefox/firefox",
843                "demo",
844                1.5 + 10.0 * wobble(43, t, 6.0),
845                1_800 << 20,
846                96,
847                3 * 86_400,
848            ),
849            process(
850                5_102,
851                "rigtop",
852                "rigtop --agent",
853                "demo",
854                0.6,
855                10 << 20,
856                3,
857                3_600,
858            ),
859            process(
860                1,
861                "systemd",
862                "/sbin/init",
863                "root",
864                0.0,
865                12 << 20,
866                1,
867                8 * 86_400,
868            ),
869        ],
870        ..Default::default()
871    }
872}
873
874fn nas(t: f64) -> Snapshot {
875    let cpu_total = 18.0 + 10.0 * wobble(60, t, 5.0);
876    let per_core = cores(300, t, 4, cpu_total);
877    let resync = 42.0 + t / 120.0;
878    let disks = ["sda", "sdb", "sdc", "sdd"];
879    let smart = |i: usize| SmartDevice {
880        device: disks[i].to_owned(),
881        protocol: "ATA".to_owned(),
882        model: "WDC WD80EFPX-68C4ZN0".to_owned(),
883        passed: Some(true),
884        temperature_c: Some(38.0 + i as f32),
885        power_on_hours: Some(21_400 + 200 * i as u64),
886        reallocated_sectors: Some(if i == 2 { 8 } else { 0 }),
887        pending_sectors: Some(0),
888        offline_uncorrectable: Some(0),
889        collected_at_ms: Some(now_ms() - 300_000),
890        ..Default::default()
891    };
892    Snapshot {
893        host: HostInfo {
894            hostname: "nas".to_owned(),
895            os: "Linux (Debian 13)".to_owned(),
896            kernel: "6.12.48-amd64".to_owned(),
897            uptime_secs: 41 * 86_400 + t as u64,
898            rigtop_version: env!("CARGO_PKG_VERSION").to_owned(),
899        },
900        cpu: CpuStats {
901            model: "Intel(R) Celeron(R) N5105 @ 2.00GHz".to_owned(),
902            total_percent: cpu_total as f32,
903            per_core_mhz: vec![2_000; 4],
904            per_core_percent: per_core,
905            load_average: Some([1.9, 2.1, 2.0]),
906            temperatures: vec![temp("coretemp:coretemp.0", 1, "Package id 0", 52.0)],
907        },
908        memory: MemoryStats {
909            total_bytes: 16 * GIB,
910            used_bytes: 3 * GIB,
911            available_bytes: 12 * GIB,
912            free_bytes: GIB,
913            cached_bytes: Some(11 * GIB),
914            ..Default::default()
915        },
916        disks: (0..4)
917            .map(|i| DiskIo {
918                name: disks[i].to_owned(),
919                read_bytes_per_sec: 150e6 + 20e6 * wobble(61 + i as u64, t, 4.0),
920                write_bytes_per_sec: if i == 3 { 148e6 } else { 1e6 },
921                busy_percent: Some(88.0 + 8.0 * wobble(65 + i as u64, t, 3.0) as f32),
922                temperature_c: Some(38.0 + i as f32),
923            })
924            .collect(),
925        filesystems: vec![
926            FilesystemUsage {
927                device: "/dev/md0".to_owned(),
928                fs_type: "ext4".to_owned(),
929                mount_points: vec!["/srv".to_owned()],
930                total_bytes: 21 * TIB + TIB / 2,
931                available_bytes: 2 * TIB - TIB / 5,
932            },
933            FilesystemUsage {
934                device: "/dev/sde2".to_owned(),
935                fs_type: "ext4".to_owned(),
936                mount_points: vec!["/".to_owned()],
937                total_bytes: 230 * GIB,
938                available_bytes: 180 * GIB,
939            },
940        ],
941        raid: RaidStats {
942            btrfs: Vec::new(),
943            md: vec![MdArray {
944                name: "md0".to_owned(),
945                active: true,
946                level: "raid5".to_owned(),
947                members: disks
948                    .iter()
949                    .enumerate()
950                    .map(|(role, name)| MdMember {
951                        name: (*name).to_owned(),
952                        role: role as u32,
953                        ..Default::default()
954                    })
955                    .collect(),
956                expected_devices: Some(4),
957                working_devices: Some(4),
958                sync: Some(MdSync {
959                    action: "resync".to_owned(),
960                    percent: resync.min(99.9) as f32,
961                    finish_minutes: Some((100.0 - resync).max(0.0) as f32 * 9.0),
962                    speed_kib_per_sec: Some(148_000),
963                }),
964                ..Default::default()
965            }],
966        },
967        smart: (0..4).map(smart).collect(),
968        networks: vec![NetworkStats {
969            name: "eno1".to_owned(),
970            rx_bytes_per_sec: 2e6 * wobble(70, t, 6.0),
971            tx_bytes_per_sec: 95e6 * ((wobble(71, t, 10.0) - 0.3).max(0.0) / 0.7),
972            link_speed_mbps: Some(2_500),
973            link_up: Some(true),
974            ..Default::default()
975        }],
976        processes: vec![
977            process(812, "md0_resync", "", "root", 9.0, 0, 1, 3 * 3600),
978            process(
979                1_204,
980                "smbd",
981                "/usr/sbin/smbd --foreground --no-process-group",
982                "root",
983                6.0 + 20.0 * wobble(72, t, 10.0),
984                120 << 20,
985                4,
986                41 * 86_400,
987            ),
988            process(
989                1_330,
990                "jellyfin",
991                "/usr/lib/jellyfin/bin/jellyfin",
992                "jellyfin",
993                3.0 + 4.0 * wobble(73, t, 7.0),
994                700 << 20,
995                30,
996                41 * 86_400,
997            ),
998            process(
999                1_002,
1000                "rigtop",
1001                "rigtop --agent",
1002                "admin",
1003                0.4,
1004                9 << 20,
1005                3,
1006                3_600,
1007            ),
1008            process(
1009                1,
1010                "systemd",
1011                "/sbin/init",
1012                "root",
1013                0.0,
1014                11 << 20,
1015                1,
1016                41 * 86_400,
1017            ),
1018        ],
1019        ..Default::default()
1020    }
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025    use super::*;
1026    use crate::view::{Severity, alerts};
1027
1028    #[test]
1029    fn demo_hosts_change_over_time_and_repeat_exactly() {
1030        let mut a = DemoSource::new(DemoHost::Workstation);
1031        let mut b = DemoSource::new(DemoHost::Workstation);
1032        let first = a.poll().unwrap();
1033        b.poll().unwrap();
1034        let mut later = first.clone();
1035        for _ in 0..30 {
1036            later = a.poll().unwrap();
1037            b.poll().unwrap();
1038        }
1039        assert_ne!(first.cpu.total_percent, later.cpu.total_percent);
1040        assert_eq!(
1041            b.poll().unwrap().cpu.total_percent,
1042            a.poll().unwrap().cpu.total_percent
1043        );
1044        // Traffic adds up.
1045        assert!(later.networks[0].rx_total_bytes > first.networks[0].rx_total_bytes);
1046        assert_eq!(later.process_count, later.processes.len());
1047        // GPU processes are in the process list.
1048        for gpu in &later.gpus {
1049            for p in &gpu.processes {
1050                assert!(later.processes.iter().any(|q| q.pid == p.pid));
1051            }
1052        }
1053    }
1054
1055    #[test]
1056    fn the_nas_needs_attention() {
1057        let snapshot = DemoSource::new(DemoHost::Nas).poll().unwrap();
1058        let found = alerts(&snapshot);
1059        let texts: Vec<&str> = found.iter().map(|a| a.text.as_str()).collect();
1060        assert_eq!(found[0].severity, Severity::Critical);
1061        assert!(texts.contains(&"sdc: SMART warning"), "{texts:?}");
1062        assert!(
1063            texts.iter().any(|t| t.starts_with("md0 resync")),
1064            "{texts:?}"
1065        );
1066        assert!(texts.iter().any(|t| t.starts_with("/srv ")), "{texts:?}");
1067        assert!(alerts(&DemoSource::new(DemoHost::Workstation).poll().unwrap()).is_empty());
1068    }
1069
1070    #[test]
1071    fn wobble_stays_in_range() {
1072        for i in 0..1000 {
1073            let v = wobble(7, i as f64 * 0.37, 3.0);
1074            assert!((0.0..=1.0).contains(&v));
1075        }
1076    }
1077}