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