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