Skip to main content

gunnar_server/
glance.rs

1//! **The machine at a glance** (`StorageReality.glance`, owner 2026-10-04):
2//! the four lines the console's Machine card shows — `/data`'s shape, the
3//! off-stripe copies on `/serialdat`, the free space, the disk speed — each
4//! with gunnar's own verdict.
5//!
6//! The console is a thin layer (owner, 2026-10-04): it draws these lines as
7//! they come and decides none of them, so every verdict is made HERE, once,
8//! and the CLI prints the same words.
9//!
10//! # The mislabel this replaces
11//!
12//! The console's Machine page drew `serialdat.dev` and `serialdat.class` as
13//! "Backing device / Backing class" directly under "Data root" — so on every
14//! estate it said `/data` was backed by `/dev/vda3 (p3)`, the boot disk's
15//! third partition, which is `/serialdat`'s. `/data` is the stripe the
16//! installer laid over the data disks (`data.shape`, `data.N.dev`). The two
17//! are now two separate lines, each naming its own disk.
18
19use gunnar_proto::{FilesystemUse, GlanceLine};
20
21pub const OK: &str = "ok";
22pub const WARN: &str = "warn";
23pub const BAD: &str = "bad";
24pub const UNKNOWN: &str = "unknown";
25
26/// Used share at which the free-space line turns amber and red: the marks
27/// the estate's growth acts on (grow at 80 % used, emergency at 90 %).
28pub const WARN_USED_PERCENT: u64 = 80;
29pub const BAD_USED_PERCENT: u64 = 90;
30
31/// Everything the four lines are read from — all of it already measured or
32/// configured elsewhere; nothing here reads a disk.
33#[derive(Debug, Clone, Default)]
34pub struct Facts<'a> {
35    /// The layout config is present (and verified or integrity-checked).
36    pub record_present: bool,
37    /// `data.mountpoint`, else the data root.
38    pub data_label: &'a str,
39    /// `data.shape`: `raid0` | `plain` | `p3` | "".
40    pub data_shape: &'a str,
41    /// `data.N.dev`, in order.
42    pub data_devices: &'a [String],
43    /// `serialdat.mountpoint`, else the configured slow root.
44    pub serialdat_label: &'a str,
45    /// `serialdat.class`: `p3` | `device` | `fallback` | "".
46    pub serialdat_class: &'a str,
47    /// `serialdat.dev`.
48    pub serialdat_device: &'a str,
49    /// The one off-stripe verdict: `off-stripe` | `same-device` | `absent`.
50    pub off_stripe: &'a str,
51    pub off_stripe_health: &'a [String],
52    /// The data root's filesystem, measured on this call.
53    pub data_fs: Option<&'a FilesystemUse>,
54    /// The start-up disk test's outcome, `None` when it has not run.
55    pub disk: Option<&'a crate::disktest::Outcome>,
56}
57
58/// `12.3 GiB`, binary, like every other byte figure an operator reads here.
59pub fn human(n: u64) -> String {
60    const K: f64 = 1024.0;
61    let v = n as f64;
62    if v >= K * K * K * K {
63        format!("{:.1} TiB", v / (K * K * K * K))
64    } else if v >= K * K * K {
65        format!("{:.1} GiB", v / (K * K * K))
66    } else if v >= K * K {
67        format!("{:.1} MiB", v / (K * K))
68    } else if v >= K {
69        format!("{:.1} KiB", v / K)
70    } else {
71        format!("{n} B")
72    }
73}
74
75fn line(key: &str, line: String, state: &str, detail: String) -> GlanceLine {
76    GlanceLine {
77        key: key.to_owned(),
78        line,
79        state: state.to_owned(),
80        detail,
81    }
82}
83
84/// **`/data`, from the installer's own record** — never from the mount table.
85pub fn data_line(f: &Facts) -> GlanceLine {
86    let label = f.data_label;
87    if !f.record_present {
88        return line(
89            "data",
90            format!("{label}: shape not recorded"),
91            UNKNOWN,
92            "This box has no layout config (or it did not verify), so the installer's shape of the \
93             data set is not known — and it is not guessed from what happens to be mounted."
94                .into(),
95        );
96    }
97    let devices = f.data_devices.join(", ");
98    match f.data_shape {
99        "raid0" if f.data_devices.len() >= 2 => line(
100            "data",
101            format!("{label} = raid0 over {devices}"),
102            OK,
103            "The repositories are on the data stripe, laid by the installer over the data disks."
104                .into(),
105        ),
106        "plain" | "raid0" if !f.data_devices.is_empty() => line(
107            "data",
108            format!("{label} = {} on {devices}", f.data_shape),
109            OK,
110            "The repositories are on a data disk of their own (one disk, no stripe).".into(),
111        ),
112        "p3" => line(
113            "data",
114            format!("{label} = p3 on the boot disk"),
115            WARN,
116            "No data disk was found at install time: the repositories live on the boot disk's \
117             third partition, beside the system."
118                .into(),
119        ),
120        "" => line(
121            "data",
122            format!("{label}: the layout config names no shape"),
123            UNKNOWN,
124            "The config carries no `data.shape`.".into(),
125        ),
126        other => line(
127            "data",
128            if devices.is_empty() {
129                format!("{label} = {other}")
130            } else {
131                format!("{label} = {other} over {devices}")
132            },
133            UNKNOWN,
134            format!("`data.shape = {other}` is a shape this build has no verdict for."),
135        ),
136    }
137}
138
139/// **`/serialdat` — the off-stripe copies' own disk**, and the verdict on it.
140pub fn serialdat_line(f: &Facts) -> GlanceLine {
141    let label = f.serialdat_label;
142    let dev = f.serialdat_device;
143    let text = match (f.serialdat_class, dev) {
144        ("p3", "") => format!("{label} on the boot disk (P3)"),
145        ("p3", dev) => format!("{label} on {dev} (boot disk P3)"),
146        ("device", "") => format!("{label} on a disk of its own"),
147        ("device", dev) => format!("{label} on {dev} (its own disk)"),
148        ("fallback", _) => {
149            format!("{label} is a directory on the data set (no partition of its own)")
150        }
151        _ if label.is_empty() => "/serialdat: nothing said where it lives".to_owned(),
152        _ => format!("{label}: nothing said what disk it is on"),
153    };
154    let state = match f.off_stripe {
155        "off-stripe" => OK,
156        "same-device" | "absent" => BAD,
157        _ => UNKNOWN,
158    };
159    let verdict = match f.off_stripe {
160        "off-stripe" => " — the off-stripe copies are on another failure domain",
161        "same-device" => {
162            " — the off-stripe copies share a device with the data (no second failure domain)"
163        }
164        "absent" => " — there is no off-stripe copy",
165        _ => "",
166    };
167    line(
168        "serialdat",
169        format!("{text}{verdict}"),
170        state,
171        f.off_stripe_health.join(" "),
172    )
173}
174
175/// **Free space on the data root**, measured on this call.
176pub fn free_line(f: &Facts) -> GlanceLine {
177    let label = f.data_label;
178    match f.data_fs {
179        Some(fs) if fs.measured && fs.total_bytes > 0 => {
180            let used = fs.total_bytes.saturating_sub(fs.free_bytes);
181            let pct = used.saturating_mul(100) / fs.total_bytes;
182            let state = if pct >= BAD_USED_PERCENT {
183                BAD
184            } else if pct >= WARN_USED_PERCENT {
185                WARN
186            } else {
187                OK
188            };
189            line(
190                "free",
191                format!("{label}: {} free of {} ({pct} % used)", human(fs.free_bytes), human(fs.total_bytes)),
192                state,
193                format!(
194                    "Amber from {WARN_USED_PERCENT} % used, red from {BAD_USED_PERCENT} % — the marks the \
195                     estate's growth acts on."
196                ),
197            )
198        }
199        Some(fs) => line(
200            "free",
201            format!("{label}: free space not measured"),
202            UNKNOWN,
203            if fs.error.is_empty() {
204                "statvfs answered no size".to_owned()
205            } else {
206                fs.error.clone()
207            },
208        ),
209        None => line(
210            "free",
211            format!("{label}: free space not measured"),
212            UNKNOWN,
213            String::new(),
214        ),
215    }
216}
217
218/// **The disk speed the start-up test measured.**
219pub fn speed_line(f: &Facts) -> GlanceLine {
220    use crate::disktest::Outcome;
221    match f.disk {
222        Some(Outcome::Done(r)) => line(
223            "speed",
224            format!("disk speed: {}", r.row()),
225            OK,
226            "Measured once at start-up on the data root: sequential write and read, and 4 KiB \
227             writes each followed by fsync."
228                .into(),
229        ),
230        Some(Outcome::Skipped { free, need }) => line(
231            "speed",
232            format!(
233                "disk speed: not measured — {} free, the test needs {}",
234                human(*free),
235                human(*need)
236            ),
237            WARN,
238            "The start-up test never fills the disk it measures.".into(),
239        ),
240        Some(Outcome::Failed(why)) => line(
241            "speed",
242            format!("disk speed: the test failed — {why}"),
243            BAD,
244            String::new(),
245        ),
246        None => line(
247            "speed",
248            "disk speed: not measured yet".to_owned(),
249            UNKNOWN,
250            "The start-up disk test has not finished a run in this process (it runs on the \
251             appliance, or with GUNNAR_DISKTEST set)."
252                .into(),
253        ),
254    }
255}
256
257/// The four lines, in the card's order.
258pub fn lines(f: &Facts) -> Vec<GlanceLine> {
259    vec![data_line(f), serialdat_line(f), free_line(f), speed_line(f)]
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::disktest::{Outcome, ReadHow, Report};
266    use std::time::Duration;
267
268    fn devs(d: &[&str]) -> Vec<String> {
269        d.iter().map(|s| s.to_string()).collect()
270    }
271
272    fn estate<'a>(devices: &'a [String], fs: &'a FilesystemUse) -> Facts<'a> {
273        Facts {
274            record_present: true,
275            data_label: "/data",
276            data_shape: "raid0",
277            data_devices: devices,
278            serialdat_label: "/serialdat",
279            serialdat_class: "p3",
280            serialdat_device: "/dev/vda3",
281            off_stripe: "off-stripe",
282            off_stripe_health: &[],
283            data_fs: Some(fs),
284            disk: None,
285        }
286    }
287
288    fn fs(total: u64, free: u64) -> FilesystemUse {
289        FilesystemUse {
290            what: "data root".into(),
291            path: "/data".into(),
292            measured: true,
293            total_bytes: total,
294            free_bytes: free,
295            error: String::new(),
296        }
297    }
298
299    /// ★ **The mislabel, fixed**: `/data` names ITS devices, and `/dev/vda3`
300    /// is said once — on the `/serialdat` line, as the boot disk's P3.
301    #[test]
302    fn data_names_its_own_devices_and_serialdat_names_the_boot_disk() {
303        let d = devs(&["/dev/vdb", "/dev/vdc"]);
304        let fs = fs(100 << 30, 60 << 30);
305        let f = estate(&d, &fs);
306        let data = data_line(&f);
307        assert_eq!(
308            (data.line.as_str(), data.state.as_str()),
309            ("/data = raid0 over /dev/vdb, /dev/vdc", "ok")
310        );
311        assert!(
312            !data.line.contains("vda3"),
313            "the data line must not name the boot disk: {}",
314            data.line
315        );
316        let sd = serialdat_line(&f);
317        assert!(
318            sd.line
319                .starts_with("/serialdat on /dev/vda3 (boot disk P3)"),
320            "{}",
321            sd.line
322        );
323        assert_eq!(sd.state, "ok");
324        let keys: Vec<_> = lines(&f).into_iter().map(|l| l.key).collect();
325        assert_eq!(keys, ["data", "serialdat", "free", "speed"]);
326    }
327
328    /// `same-device` and `absent` are never green; an unknown word is never
329    /// green either.
330    #[test]
331    fn the_off_stripe_copies_are_green_only_when_off_stripe() {
332        let d = devs(&["/dev/vdb"]);
333        let fs = fs(1, 1);
334        for (word, state) in [
335            ("off-stripe", "ok"),
336            ("same-device", "bad"),
337            ("absent", "bad"),
338            ("", "unknown"),
339            ("weird", "unknown"),
340        ] {
341            let mut f = estate(&d, &fs);
342            f.off_stripe = word;
343            assert_eq!(serialdat_line(&f).state, state, "{word}");
344        }
345    }
346
347    #[test]
348    fn the_data_shapes_each_have_their_words() {
349        let one = devs(&["/dev/vdb"]);
350        let none: Vec<String> = Vec::new();
351        let fs = fs(1, 1);
352        let mut f = estate(&one, &fs);
353        f.data_shape = "plain";
354        assert_eq!(
355            (data_line(&f).line.as_str(), data_line(&f).state.as_str()),
356            ("/data = plain on /dev/vdb", "ok")
357        );
358        let mut f = estate(&none, &fs);
359        f.data_shape = "p3";
360        assert_eq!(data_line(&f).state, "warn");
361        f.record_present = false;
362        assert_eq!(data_line(&f).state, "unknown", "no record: not guessed");
363    }
364
365    /// Amber at 80 % used, red at 90 %; an unmeasured filesystem is unknown,
366    /// never a number.
367    #[test]
368    fn free_space_turns_amber_then_red_and_unmeasured_is_unknown() {
369        let d = devs(&["/dev/vdb", "/dev/vdc"]);
370        for (free, state) in [(50u64, "ok"), (19, "warn"), (5, "bad")] {
371            let fs = fs(100, free);
372            assert_eq!(
373                free_line(&estate(&d, &fs)).state,
374                state,
375                "{free} free of 100"
376            );
377        }
378        let broken = FilesystemUse {
379            measured: false,
380            error: "EIO".into(),
381            ..fs(0, 0)
382        };
383        let l = free_line(&estate(&d, &broken));
384        assert_eq!(l.state, "unknown");
385        assert!(
386            !l.line.chars().any(|c| c.is_ascii_digit()),
387            "no number that was not read: {}",
388            l.line
389        );
390    }
391
392    #[test]
393    fn the_disk_speed_is_green_only_when_measured() {
394        let d = devs(&["/dev/vdb", "/dev/vdc"]);
395        let fs = fs(1, 1);
396        let mut f = estate(&d, &fs);
397        assert_eq!(speed_line(&f).state, "unknown");
398        let done = Outcome::Done(Report {
399            seq_write_bps: 8.12e8,
400            seq_read_bps: 1.0e9,
401            read_how: ReadHow::Direct,
402            sync_ops_per_s: 3100.0,
403            elapsed: Duration::from_millis(1600),
404            file_bytes: 1 << 30,
405            write_elapsed: Duration::from_millis(800),
406            read_elapsed: Duration::from_millis(500),
407            sync_ops: 4960,
408            sync_elapsed: Duration::from_millis(300),
409        });
410        f.disk = Some(&done);
411        let l = speed_line(&f);
412        assert_eq!(l.state, "ok");
413        assert!(l.line.contains("812 MB/s"), "{}", l.line);
414        let failed = Outcome::Failed("EIO".into());
415        f.disk = Some(&failed);
416        assert_eq!(speed_line(&f).state, "bad");
417    }
418}