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