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    Fan, FilesystemUsage, GpuProcess, GpuStats, HostInfo, MdArray, MdMember, MdSync, MemoryStats,
16    NetworkStats, PowerStats, ProcessInfo, RaidStats, SensorChip, SmartDevice, Snapshot,
17    Temperature, WirelessLink,
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    for process in &mut processes {
529        if process.name == "rustc" {
530            process.parent_pid = Some(60_100);
531        } else if let Some(&(_, parent)) = parents.iter().find(|(pid, _)| *pid == process.pid) {
532            process.parent_pid = Some(parent);
533        }
534    }
535    let nvme = |i: u32, celsius: f64| SensorChip {
536        key: format!("nvme:nvme{i}"),
537        name: "nvme".to_owned(),
538        temperatures: vec![temp(&format!("nvme:nvme{i}"), 1, "Composite", celsius)],
539        fans: Vec::new(),
540    };
541    let drive_temp = |i: u32| 44.0 + 4.0 * i as f64 + 5.0 * build;
542    // The processes' reads and writes, spread over the three drives of the
543    // RAID0 (the build also reads sources and writes objects).
544    let disk_io = |i: u64| {
545        let busy = build * (0.5 + 0.5 * wobble(20 + i, t, 2.0));
546        DiskIo {
547            name: format!("nvme{i}n1"),
548            read_bytes_per_sec: train_read / 3.0 + 30e6 * busy + 1e6 * wobble(30 + i, t, 3.0),
549            write_bytes_per_sec: 40e6 * busy + 40e6 / 3.0 * download,
550            busy_percent: Some((25.0 * busy + 3.0 * download + 4.0 * train_read / 60e6) as f32),
551            temperature_c: Some(drive_temp(i as u32) as f32),
552        }
553    };
554    let smart = |i: u32| SmartDevice {
555        device: format!("nvme{i}"),
556        protocol: "NVMe".to_owned(),
557        model: "Samsung SSD 990 PRO 2TB".to_owned(),
558        passed: Some(true),
559        temperature_c: Some(drive_temp(i) as f32),
560        power_on_hours: Some(4_210 + 13 * i as u64),
561        percentage_used: Some(3),
562        available_spare_percent: Some(100),
563        available_spare_threshold_percent: Some(10),
564        critical_warning: Some(0),
565        media_errors: Some(0),
566        data_written_bytes: Some(41 * TIB + i as u64 * TIB),
567        collected_at_ms: Some(now_ms() - 140_000),
568        ..Default::default()
569    };
570    let used_memory = 38.0 + 6.0 * build + 1.5 * wobble(11, t, 20.0);
571    Snapshot {
572        host: HostInfo {
573            hostname: "workstation".to_owned(),
574            os: "Linux (Ubuntu 26.04)".to_owned(),
575            kernel: "7.0.0-38-generic".to_owned(),
576            uptime_secs: 9 * 86_400 + 4 * 3600 + t as u64,
577            rigtop_version: env!("CARGO_PKG_VERSION").to_owned(),
578        },
579        cpu: CpuStats {
580            model: "AMD Ryzen 9 9950X3D 16-Core Processor".to_owned(),
581            total_percent: cpu_total as f32,
582            per_core_mhz: per_core.iter().map(|u| 3_000 + (*u as u64) * 25).collect(),
583            per_core_percent: per_core,
584            load_average: Some([
585                0.4 + 22.0 * build,
586                3.0 + 9.0 * duty(t + 30.0, 120.0, 90.0),
587                6.2,
588            ]),
589            temperatures: vec![
590                temp(k10, 1, "Tctl", tctl),
591                temp(k10, 3, "Tccd1", tctl - 8.0),
592                temp(k10, 4, "Tccd2", tctl - 9.0),
593            ],
594        },
595        memory: MemoryStats {
596            total_bytes: 96 * GIB,
597            used_bytes: (used_memory * GIB as f64) as u64,
598            available_bytes: ((96.0 - used_memory) * GIB as f64) as u64,
599            free_bytes: 12 * GIB,
600            cached_bytes: Some(44 * GIB),
601            swap_total_bytes: 8 * GIB,
602            swap_used_bytes: 256 << 20,
603        },
604        gpus: vec![
605            GpuStats {
606                index: 0,
607                vendor: "NVIDIA".to_owned(),
608                name: "NVIDIA GeForce RTX 4090".to_owned(),
609                utilization_percent: Some(gpu0_util.clamp(0.0, 100.0) as f32),
610                memory_utilization_percent: Some((gpu0_util * 0.6) as f32),
611                memory_used_bytes: Some(21 * GIB + GIB / 3),
612                memory_total_bytes: Some(24 * GIB),
613                temperature_c: Some((52.0 + 0.2 * gpu0_util) as f32),
614                power_w: Some(gpu0_w as f32),
615                power_limit_w: Some(450.0),
616                fan_percent: Some((30.0 + 0.3 * gpu0_util) as f32),
617                clock_mhz: Some(2_520),
618                processes: vec![GpuProcess {
619                    pid: train_pid,
620                    memory_bytes: Some(21 * GIB),
621                    sm_percent: Some(gpu0_util.clamp(0.0, 100.0) as f32),
622                }],
623                ..Default::default()
624            },
625            GpuStats {
626                index: 1,
627                vendor: "NVIDIA".to_owned(),
628                name: "NVIDIA RTX 4000 Ada Generation".to_owned(),
629                utilization_percent: Some(gpu1_util as f32),
630                memory_utilization_percent: Some((gpu1_util * 0.4) as f32),
631                memory_used_bytes: Some(9 * GIB + GIB * 4 / 5),
632                memory_total_bytes: Some(20 * GIB),
633                temperature_c: Some((44.0 + 0.2 * gpu1_util) as f32),
634                power_w: Some(gpu1_w as f32),
635                power_limit_w: Some(130.0),
636                fan_percent: Some((30.0 + 0.4 * gpu1_util) as f32),
637                clock_mhz: Some(if gpu1_util > 5.0 { 2_175 } else { 210 }),
638                processes: vec![GpuProcess {
639                    pid: ollama_pid,
640                    memory_bytes: Some(9 * GIB + GIB / 2),
641                    sm_percent: Some(gpu1_util as f32),
642                }],
643                ..Default::default()
644            },
645        ],
646        sensors: vec![
647            SensorChip {
648                key: k10.to_owned(),
649                name: "k10temp".to_owned(),
650                temperatures: vec![
651                    temp(k10, 1, "Tctl", tctl),
652                    temp(k10, 3, "Tccd1", tctl - 8.0),
653                    temp(k10, 4, "Tccd2", tctl - 9.0),
654                ],
655                fans: Vec::new(),
656            },
657            SensorChip {
658                key: nct.to_owned(),
659                name: "nct6686".to_owned(),
660                temperatures: vec![
661                    temp(nct, 1, "Thermistor 14", 38.0 + 0.1 * cpu_total),
662                    temp(nct, 2, "AMD TSI Addr 98h", tctl - 2.0),
663                ],
664                fans: vec![
665                    fan(nct, 1, 850.0 + 9.0 * cpu_total),
666                    fan(nct, 2, 0.0),
667                    fan(nct, 3, 780.0 + 4.0 * cpu_total),
668                ],
669            },
670            nvme(0, drive_temp(0)),
671            nvme(1, drive_temp(1)),
672            nvme(2, drive_temp(2)),
673        ],
674        power: PowerStats {
675            cpu_package_w: Some(cpu_w as f32),
676            gpus_w: Some((gpu0_w + gpu1_w) as f32),
677            igpu_w: None,
678            total_w: Some((cpu_w + gpu0_w + gpu1_w) as f32),
679            battery: None,
680        },
681        disks: (0..3).map(disk_io).collect(),
682        filesystems: vec![
683            FilesystemUsage {
684                device: "/dev/nvme0n1p2".to_owned(),
685                fs_type: "btrfs".to_owned(),
686                mount_points: vec!["/".to_owned(), "/home".to_owned()],
687                total_bytes: 5 * TIB + TIB / 2,
688                available_bytes: 3 * TIB + TIB / 10,
689            },
690            FilesystemUsage {
691                device: "/dev/nvme0n1p1".to_owned(),
692                fs_type: "vfat".to_owned(),
693                mount_points: vec!["/boot/efi".to_owned()],
694                total_bytes: GIB,
695                available_bytes: GIB - (60 << 20),
696            },
697        ],
698        raid: RaidStats {
699            btrfs: vec![BtrfsFilesystem {
700                fsid: "6f1c2a9e-0d4b-4e58-9a3e-2b7c51d0e8f4".to_owned(),
701                label: String::new(),
702                mount_points: vec!["/".to_owned(), "/home".to_owned()],
703                devices: vec![
704                    "nvme0n1p2".to_owned(),
705                    "nvme1n1".to_owned(),
706                    "nvme2n1".to_owned(),
707                ],
708                device_stats: (1..=3)
709                    .map(|devid| BtrfsDeviceStats {
710                        devid,
711                        name: if devid == 1 {
712                            "nvme0n1p2".to_owned()
713                        } else {
714                            format!("nvme{}n1", devid - 1)
715                        },
716                        size_bytes: Some(2 * TIB - (1 << 33)),
717                        used_bytes: Some(TIB - (1 << 36)),
718                        ..Default::default()
719                    })
720                    .collect(),
721                allocations: vec![
722                    BtrfsAllocation {
723                        kind: "data".to_owned(),
724                        profile: "raid0".to_owned(),
725                        total_bytes: 2 * TIB + TIB / 2,
726                        used_bytes: 2 * TIB + TIB / 3,
727                    },
728                    BtrfsAllocation {
729                        kind: "metadata".to_owned(),
730                        profile: "raid1c3".to_owned(),
731                        total_bytes: 12 * GIB,
732                        used_bytes: 9 * GIB,
733                    },
734                    BtrfsAllocation {
735                        kind: "system".to_owned(),
736                        profile: "raid1c3".to_owned(),
737                        total_bytes: 32 << 20,
738                        used_bytes: 1 << 20,
739                    },
740                ],
741                scrub: Some(BtrfsScrub {
742                    started_at_ms: now_ms() - 3 * 86_400_000,
743                    duration_secs: 1_920,
744                    state: "finished".to_owned(),
745                    bytes_scrubbed: 2 * TIB + TIB / 3,
746                    ..Default::default()
747                }),
748            }],
749            md: Vec::new(),
750        },
751        smart: (0..3).map(smart).collect(),
752        networks: vec![
753            NetworkStats {
754                name: "enp9s0".to_owned(),
755                rx_bytes_per_sec: 280e6 * download + 60e3 * wobble(12, t, 1.5),
756                tx_bytes_per_sec: 4e6 * download + 25e3 * wobble(13, t, 2.0),
757                link_speed_mbps: Some(10_000),
758                link_up: Some(true),
759                ..Default::default()
760            },
761            NetworkStats {
762                name: "tailscale0".to_owned(),
763                rx_bytes_per_sec: 9e3 * wobble(14, t, 3.0),
764                tx_bytes_per_sec: 40e3 * wobble(15, t, 4.0),
765                rx_total_bytes: 3 * GIB,
766                tx_total_bytes: 9 * GIB,
767                link_up: Some(true),
768                ..Default::default()
769            },
770        ],
771        processes,
772        ..Default::default()
773    }
774}
775
776fn laptop(t: f64) -> Snapshot {
777    let busy = (wobble(40, t, 8.0) - 0.3).max(0.0) / 0.7;
778    let cpu_total = 6.0 + 35.0 * busy;
779    let per_core = cores(200, t, 16, cpu_total);
780    let tctl = 46.0 + 0.5 * cpu_total;
781    let soc_w = 5.0 + 0.4 * cpu_total;
782    let k10 = "k10temp:0000:00:18.3";
783    Snapshot {
784        host: HostInfo {
785            hostname: "laptop".to_owned(),
786            os: "Linux (CachyOS)".to_owned(),
787            kernel: "7.0.2-cachyos".to_owned(),
788            uptime_secs: 8 * 86_400 + 10 * 3600 + t as u64,
789            // An agent nobody updated yet.
790            rigtop_version: "0.8.2".to_owned(),
791        },
792        cpu: CpuStats {
793            model: "AMD Ryzen 7 PRO 7840U w/ Radeon 780M Graphics".to_owned(),
794            total_percent: cpu_total as f32,
795            per_core_mhz: per_core.iter().map(|u| 1_400 + (*u as u64) * 35).collect(),
796            per_core_percent: per_core,
797            load_average: Some([0.8 + 3.0 * busy, 1.6, 1.4]),
798            temperatures: vec![temp(k10, 1, "Tctl", tctl)],
799        },
800        memory: MemoryStats {
801            total_bytes: 30 * GIB,
802            used_bytes: 15 * GIB + (busy * 2.0 * GIB as f64) as u64,
803            available_bytes: 14 * GIB,
804            free_bytes: 3 * GIB,
805            cached_bytes: Some(10 * GIB),
806            swap_total_bytes: 30 * GIB,
807            swap_used_bytes: 0,
808        },
809        gpus: vec![GpuStats {
810            index: 0,
811            vendor: "AMD".to_owned(),
812            name: "AMD Radeon 780M".to_owned(),
813            integrated: true,
814            utilization_percent: Some((4.0 + 20.0 * busy) as f32),
815            memory_used_bytes: Some(GIB + GIB / 2),
816            memory_total_bytes: Some(2 * GIB),
817            temperature_c: Some((tctl - 3.0) as f32),
818            clock_mhz: Some(800),
819            ..Default::default()
820        }],
821        sensors: vec![SensorChip {
822            key: k10.to_owned(),
823            name: "k10temp".to_owned(),
824            temperatures: vec![temp(k10, 1, "Tctl", tctl)],
825            fans: Vec::new(),
826        }],
827        power: PowerStats {
828            igpu_w: Some(soc_w as f32),
829            total_w: Some(soc_w as f32),
830            battery: Some(BatteryStats {
831                percent: Some((78.0 - t / 600.0).max(5.0) as f32),
832                status: "Discharging".to_owned(),
833                power_w: Some((soc_w + 3.0) as f32),
834            }),
835            ..Default::default()
836        },
837        disks: vec![DiskIo {
838            name: "nvme0n1".to_owned(),
839            read_bytes_per_sec: 6e6 * busy,
840            write_bytes_per_sec: 1e6 * busy,
841            busy_percent: Some((4.0 * busy) as f32),
842            temperature_c: Some((38.0 + 4.0 * busy) as f32),
843        }],
844        filesystems: vec![FilesystemUsage {
845            device: "/dev/nvme0n1p2".to_owned(),
846            fs_type: "ext4".to_owned(),
847            mount_points: vec!["/".to_owned()],
848            total_bytes: 930 * GIB,
849            available_bytes: 352 * GIB,
850        }],
851        networks: vec![NetworkStats {
852            name: "wlp1s0".to_owned(),
853            rx_bytes_per_sec: 1.5e6 * busy + 20e3 * wobble(41, t, 2.0),
854            tx_bytes_per_sec: 0.3e6 * busy + 8e3 * wobble(42, t, 3.0),
855            link_up: Some(true),
856            wireless: Some(WirelessLink {
857                rx_bitrate_mbps: Some(1_201.0),
858                tx_bitrate_mbps: Some(864.0),
859                signal_dbm: Some(-54),
860            }),
861            ..Default::default()
862        }],
863        processes: vec![
864            process(
865                2_810,
866                "plasmashell",
867                "/usr/bin/plasmashell",
868                "demo",
869                1.0 + 8.0 * busy,
870                520 << 20,
871                24,
872                8 * 86_400,
873            ),
874            process(
875                4_420,
876                "code",
877                "/opt/visual-studio-code/code",
878                "demo",
879                2.0 + 30.0 * busy,
880                900 << 20,
881                38,
882                86_400,
883            ),
884            process(
885                4_980,
886                "firefox",
887                "/usr/lib/firefox/firefox",
888                "demo",
889                1.5 + 10.0 * wobble(43, t, 6.0),
890                1_800 << 20,
891                96,
892                3 * 86_400,
893            ),
894            process(
895                5_102,
896                "rigtop",
897                "rigtop --agent",
898                "demo",
899                0.6,
900                10 << 20,
901                3,
902                3_600,
903            ),
904            process(
905                1,
906                "systemd",
907                "/sbin/init",
908                "root",
909                0.0,
910                12 << 20,
911                1,
912                8 * 86_400,
913            ),
914        ],
915        ..Default::default()
916    }
917}
918
919fn nas(t: f64) -> Snapshot {
920    let cpu_total = 18.0 + 10.0 * wobble(60, t, 5.0);
921    let per_core = cores(300, t, 4, cpu_total);
922    let resync = 42.0 + t / 120.0;
923    let disks = ["sda", "sdb", "sdc", "sdd"];
924    let smart = |i: usize| SmartDevice {
925        device: disks[i].to_owned(),
926        protocol: "ATA".to_owned(),
927        model: "WDC WD80EFPX-68C4ZN0".to_owned(),
928        passed: Some(true),
929        temperature_c: Some(38.0 + i as f32),
930        power_on_hours: Some(21_400 + 200 * i as u64),
931        reallocated_sectors: Some(if i == 2 { 8 } else { 0 }),
932        pending_sectors: Some(0),
933        offline_uncorrectable: Some(0),
934        collected_at_ms: Some(now_ms() - 300_000),
935        ..Default::default()
936    };
937    Snapshot {
938        host: HostInfo {
939            hostname: "nas".to_owned(),
940            os: "Linux (Debian 13)".to_owned(),
941            kernel: "6.12.48-amd64".to_owned(),
942            uptime_secs: 41 * 86_400 + t as u64,
943            rigtop_version: env!("CARGO_PKG_VERSION").to_owned(),
944        },
945        cpu: CpuStats {
946            model: "Intel(R) Celeron(R) N5105 @ 2.00GHz".to_owned(),
947            total_percent: cpu_total as f32,
948            per_core_mhz: vec![2_000; 4],
949            per_core_percent: per_core,
950            load_average: Some([1.9, 2.1, 2.0]),
951            temperatures: vec![temp("coretemp:coretemp.0", 1, "Package id 0", 52.0)],
952        },
953        memory: MemoryStats {
954            total_bytes: 16 * GIB,
955            used_bytes: 3 * GIB,
956            available_bytes: 12 * GIB,
957            free_bytes: GIB,
958            cached_bytes: Some(11 * GIB),
959            ..Default::default()
960        },
961        disks: (0..4)
962            .map(|i| DiskIo {
963                name: disks[i].to_owned(),
964                read_bytes_per_sec: 150e6 + 20e6 * wobble(61 + i as u64, t, 4.0),
965                write_bytes_per_sec: if i == 3 { 148e6 } else { 1e6 },
966                busy_percent: Some(88.0 + 8.0 * wobble(65 + i as u64, t, 3.0) as f32),
967                temperature_c: Some(38.0 + i as f32),
968            })
969            .collect(),
970        filesystems: vec![
971            FilesystemUsage {
972                device: "/dev/md0".to_owned(),
973                fs_type: "ext4".to_owned(),
974                mount_points: vec!["/srv".to_owned()],
975                total_bytes: 21 * TIB + TIB / 2,
976                available_bytes: 2 * TIB - TIB / 5,
977            },
978            FilesystemUsage {
979                device: "/dev/sde2".to_owned(),
980                fs_type: "ext4".to_owned(),
981                mount_points: vec!["/".to_owned()],
982                total_bytes: 230 * GIB,
983                available_bytes: 180 * GIB,
984            },
985        ],
986        raid: RaidStats {
987            btrfs: Vec::new(),
988            md: vec![MdArray {
989                name: "md0".to_owned(),
990                active: true,
991                level: "raid5".to_owned(),
992                members: disks
993                    .iter()
994                    .enumerate()
995                    .map(|(role, name)| MdMember {
996                        name: (*name).to_owned(),
997                        role: role as u32,
998                        ..Default::default()
999                    })
1000                    .collect(),
1001                expected_devices: Some(4),
1002                working_devices: Some(4),
1003                sync: Some(MdSync {
1004                    action: "resync".to_owned(),
1005                    percent: resync.min(99.9) as f32,
1006                    finish_minutes: Some((100.0 - resync).max(0.0) as f32 * 9.0),
1007                    speed_kib_per_sec: Some(148_000),
1008                }),
1009                ..Default::default()
1010            }],
1011        },
1012        smart: (0..4).map(smart).collect(),
1013        networks: vec![NetworkStats {
1014            name: "eno1".to_owned(),
1015            rx_bytes_per_sec: 2e6 * wobble(70, t, 6.0),
1016            tx_bytes_per_sec: 95e6 * ((wobble(71, t, 10.0) - 0.3).max(0.0) / 0.7),
1017            link_speed_mbps: Some(2_500),
1018            link_up: Some(true),
1019            ..Default::default()
1020        }],
1021        processes: vec![
1022            // The kernel's resync thread is not counted per process.
1023            process(812, "md0_resync", "", "root", 9.0, 0, 1, 3 * 3600),
1024            // Serves the files the network panel sends.
1025            with_io(
1026                process(
1027                    1_204,
1028                    "smbd",
1029                    "/usr/sbin/smbd --foreground --no-process-group",
1030                    "root",
1031                    6.0 + 20.0 * wobble(72, t, 10.0),
1032                    120 << 20,
1033                    4,
1034                    41 * 86_400,
1035                ),
1036                95e6 * ((wobble(71, t, 10.0) - 0.3).max(0.0) / 0.7),
1037                1e6 * wobble(74, t, 5.0),
1038            ),
1039            with_io(
1040                process(
1041                    1_330,
1042                    "jellyfin",
1043                    "/usr/lib/jellyfin/bin/jellyfin",
1044                    "jellyfin",
1045                    3.0 + 4.0 * wobble(73, t, 7.0),
1046                    700 << 20,
1047                    30,
1048                    41 * 86_400,
1049                ),
1050                4e6 + 4e6 * wobble(75, t, 7.0),
1051                0.0,
1052            ),
1053            process(
1054                1_002,
1055                "rigtop",
1056                "rigtop --agent",
1057                "admin",
1058                0.4,
1059                9 << 20,
1060                3,
1061                3_600,
1062            ),
1063            process(
1064                1,
1065                "systemd",
1066                "/sbin/init",
1067                "root",
1068                0.0,
1069                11 << 20,
1070                1,
1071                41 * 86_400,
1072            ),
1073        ],
1074        ..Default::default()
1075    }
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080    use super::*;
1081    use crate::view::{Severity, alerts};
1082
1083    #[test]
1084    fn demo_hosts_change_over_time_and_repeat_exactly() {
1085        let mut a = DemoSource::new(DemoHost::Workstation);
1086        let mut b = DemoSource::new(DemoHost::Workstation);
1087        let first = a.poll().unwrap();
1088        b.poll().unwrap();
1089        let mut later = first.clone();
1090        for _ in 0..30 {
1091            later = a.poll().unwrap();
1092            b.poll().unwrap();
1093        }
1094        assert_ne!(first.cpu.total_percent, later.cpu.total_percent);
1095        assert_eq!(
1096            b.poll().unwrap().cpu.total_percent,
1097            a.poll().unwrap().cpu.total_percent
1098        );
1099        // Traffic adds up.
1100        assert!(later.networks[0].rx_total_bytes > first.networks[0].rx_total_bytes);
1101        assert_eq!(later.process_count, later.processes.len());
1102        // GPU processes are in the process list.
1103        for gpu in &later.gpus {
1104            for p in &gpu.processes {
1105                assert!(later.processes.iter().any(|q| q.pid == p.pid));
1106            }
1107        }
1108    }
1109
1110    #[test]
1111    fn the_nas_needs_attention() {
1112        let snapshot = DemoSource::new(DemoHost::Nas).poll().unwrap();
1113        let found = alerts(&snapshot);
1114        let texts: Vec<&str> = found.iter().map(|a| a.text.as_str()).collect();
1115        assert_eq!(found[0].severity, Severity::Critical);
1116        assert!(texts.contains(&"sdc: SMART warning"), "{texts:?}");
1117        assert!(
1118            texts.iter().any(|t| t.starts_with("md0 resync")),
1119            "{texts:?}"
1120        );
1121        assert!(texts.iter().any(|t| t.starts_with("/srv ")), "{texts:?}");
1122        assert!(alerts(&DemoSource::new(DemoHost::Workstation).poll().unwrap()).is_empty());
1123    }
1124
1125    #[test]
1126    fn wobble_stays_in_range() {
1127        for i in 0..1000 {
1128            let v = wobble(7, i as f64 * 0.37, 3.0);
1129            assert!((0.0..=1.0).contains(&v));
1130        }
1131    }
1132}