Skip to main content

rigtop_core/
demo.rs

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