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