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 { key: key.to_owned(), line, state: state.to_owned(), detail }
77}
78
79/// **`/data`, from the installer's own record** — never from the mount table.
80pub fn data_line(f: &Facts) -> GlanceLine {
81    let label = f.data_label;
82    if !f.record_present {
83        return line(
84            "data",
85            format!("{label}: shape not recorded"),
86            UNKNOWN,
87            "This box has no layout config (or it did not verify), so the installer's shape of the \
88             data set is not known — and it is not guessed from what happens to be mounted."
89                .into(),
90        );
91    }
92    let devices = f.data_devices.join(", ");
93    match f.data_shape {
94        "raid0" if f.data_devices.len() >= 2 => line(
95            "data",
96            format!("{label} = raid0 over {devices}"),
97            OK,
98            "The repositories are on the data stripe, laid by the installer over the data disks."
99                .into(),
100        ),
101        "plain" | "raid0" if !f.data_devices.is_empty() => line(
102            "data",
103            format!("{label} = {} on {devices}", f.data_shape),
104            OK,
105            "The repositories are on a data disk of their own (one disk, no stripe).".into(),
106        ),
107        "p3" => line(
108            "data",
109            format!("{label} = p3 on the boot disk"),
110            WARN,
111            "No data disk was found at install time: the repositories live on the boot disk's \
112             third partition, beside the system."
113                .into(),
114        ),
115        "" => line(
116            "data",
117            format!("{label}: the layout config names no shape"),
118            UNKNOWN,
119            "The config carries no `data.shape`.".into(),
120        ),
121        other => line(
122            "data",
123            if devices.is_empty() { format!("{label} = {other}") } else { format!("{label} = {other} over {devices}") },
124            UNKNOWN,
125            format!("`data.shape = {other}` is a shape this build has no verdict for."),
126        ),
127    }
128}
129
130/// **`/serialdat` — the off-stripe copies' own disk**, and the verdict on it.
131pub fn serialdat_line(f: &Facts) -> GlanceLine {
132    let label = f.serialdat_label;
133    let dev = f.serialdat_device;
134    let text = match (f.serialdat_class, dev) {
135        ("p3", "") => format!("{label} on the boot disk (P3)"),
136        ("p3", dev) => format!("{label} on {dev} (boot disk P3)"),
137        ("device", "") => format!("{label} on a disk of its own"),
138        ("device", dev) => format!("{label} on {dev} (its own disk)"),
139        ("fallback", _) => format!("{label} is a directory on the data set (no partition of its own)"),
140        _ if label.is_empty() => "/serialdat: nothing said where it lives".to_owned(),
141        _ => format!("{label}: nothing said what disk it is on"),
142    };
143    let state = match f.off_stripe {
144        "off-stripe" => OK,
145        "same-device" | "absent" => BAD,
146        _ => UNKNOWN,
147    };
148    let verdict = match f.off_stripe {
149        "off-stripe" => " — the off-stripe copies are on another failure domain",
150        "same-device" => " — the off-stripe copies share a device with the data (no second failure domain)",
151        "absent" => " — there is no off-stripe copy",
152        _ => "",
153    };
154    line("serialdat", format!("{text}{verdict}"), state, f.off_stripe_health.join(" "))
155}
156
157/// **Free space on the data root**, measured on this call.
158pub fn free_line(f: &Facts) -> GlanceLine {
159    let label = f.data_label;
160    match f.data_fs {
161        Some(fs) if fs.measured && fs.total_bytes > 0 => {
162            let used = fs.total_bytes.saturating_sub(fs.free_bytes);
163            let pct = used.saturating_mul(100) / fs.total_bytes;
164            let state = if pct >= BAD_USED_PERCENT {
165                BAD
166            } else if pct >= WARN_USED_PERCENT {
167                WARN
168            } else {
169                OK
170            };
171            line(
172                "free",
173                format!("{label}: {} free of {} ({pct} % used)", human(fs.free_bytes), human(fs.total_bytes)),
174                state,
175                format!(
176                    "Amber from {WARN_USED_PERCENT} % used, red from {BAD_USED_PERCENT} % — the marks the \
177                     estate's growth acts on."
178                ),
179            )
180        }
181        Some(fs) => line(
182            "free",
183            format!("{label}: free space not measured"),
184            UNKNOWN,
185            if fs.error.is_empty() { "statvfs answered no size".to_owned() } else { fs.error.clone() },
186        ),
187        None => line("free", format!("{label}: free space not measured"), UNKNOWN, String::new()),
188    }
189}
190
191/// **The disk speed the start-up test measured.**
192pub fn speed_line(f: &Facts) -> GlanceLine {
193    use crate::disktest::Outcome;
194    match f.disk {
195        Some(Outcome::Done(r)) => line(
196            "speed",
197            format!("disk speed: {}", r.row()),
198            OK,
199            "Measured once at start-up on the data root: sequential write and read, and 4 KiB \
200             writes each followed by fsync."
201                .into(),
202        ),
203        Some(Outcome::Skipped { free, need }) => line(
204            "speed",
205            format!("disk speed: not measured — {} free, the test needs {}", human(*free), human(*need)),
206            WARN,
207            "The start-up test never fills the disk it measures.".into(),
208        ),
209        Some(Outcome::Failed(why)) => line("speed", format!("disk speed: the test failed — {why}"), BAD, String::new()),
210        None => line(
211            "speed",
212            "disk speed: not measured yet".to_owned(),
213            UNKNOWN,
214            "The start-up disk test has not finished a run in this process (it runs on the \
215             appliance, or with GUNNAR_DISKTEST set)."
216                .into(),
217        ),
218    }
219}
220
221/// The four lines, in the card's order.
222pub fn lines(f: &Facts) -> Vec<GlanceLine> {
223    vec![data_line(f), serialdat_line(f), free_line(f), speed_line(f)]
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crate::disktest::{Outcome, ReadHow, Report};
230    use std::time::Duration;
231
232    fn devs(d: &[&str]) -> Vec<String> {
233        d.iter().map(|s| s.to_string()).collect()
234    }
235
236    fn estate<'a>(devices: &'a [String], fs: &'a FilesystemUse) -> Facts<'a> {
237        Facts {
238            record_present: true,
239            data_label: "/data",
240            data_shape: "raid0",
241            data_devices: devices,
242            serialdat_label: "/serialdat",
243            serialdat_class: "p3",
244            serialdat_device: "/dev/vda3",
245            off_stripe: "off-stripe",
246            off_stripe_health: &[],
247            data_fs: Some(fs),
248            disk: None,
249        }
250    }
251
252    fn fs(total: u64, free: u64) -> FilesystemUse {
253        FilesystemUse { what: "data root".into(), path: "/data".into(), measured: true, total_bytes: total, free_bytes: free, error: String::new() }
254    }
255
256    /// ★ **The mislabel, fixed**: `/data` names ITS devices, and `/dev/vda3`
257    /// is said once — on the `/serialdat` line, as the boot disk's P3.
258    #[test]
259    fn data_names_its_own_devices_and_serialdat_names_the_boot_disk() {
260        let d = devs(&["/dev/vdb", "/dev/vdc"]);
261        let fs = fs(100 << 30, 60 << 30);
262        let f = estate(&d, &fs);
263        let data = data_line(&f);
264        assert_eq!((data.line.as_str(), data.state.as_str()), ("/data = raid0 over /dev/vdb, /dev/vdc", "ok"));
265        assert!(!data.line.contains("vda3"), "the data line must not name the boot disk: {}", data.line);
266        let sd = serialdat_line(&f);
267        assert!(sd.line.starts_with("/serialdat on /dev/vda3 (boot disk P3)"), "{}", sd.line);
268        assert_eq!(sd.state, "ok");
269        let keys: Vec<_> = lines(&f).into_iter().map(|l| l.key).collect();
270        assert_eq!(keys, ["data", "serialdat", "free", "speed"]);
271    }
272
273    /// `same-device` and `absent` are never green; an unknown word is never
274    /// green either.
275    #[test]
276    fn the_off_stripe_copies_are_green_only_when_off_stripe() {
277        let d = devs(&["/dev/vdb"]);
278        let fs = fs(1, 1);
279        for (word, state) in [("off-stripe", "ok"), ("same-device", "bad"), ("absent", "bad"), ("", "unknown"), ("weird", "unknown")] {
280            let mut f = estate(&d, &fs);
281            f.off_stripe = word;
282            assert_eq!(serialdat_line(&f).state, state, "{word}");
283        }
284    }
285
286    #[test]
287    fn the_data_shapes_each_have_their_words() {
288        let one = devs(&["/dev/vdb"]);
289        let none: Vec<String> = Vec::new();
290        let fs = fs(1, 1);
291        let mut f = estate(&one, &fs);
292        f.data_shape = "plain";
293        assert_eq!((data_line(&f).line.as_str(), data_line(&f).state.as_str()), ("/data = plain on /dev/vdb", "ok"));
294        let mut f = estate(&none, &fs);
295        f.data_shape = "p3";
296        assert_eq!(data_line(&f).state, "warn");
297        f.record_present = false;
298        assert_eq!(data_line(&f).state, "unknown", "no record: not guessed");
299    }
300
301    /// Amber at 80 % used, red at 90 %; an unmeasured filesystem is unknown,
302    /// never a number.
303    #[test]
304    fn free_space_turns_amber_then_red_and_unmeasured_is_unknown() {
305        let d = devs(&["/dev/vdb", "/dev/vdc"]);
306        for (free, state) in [(50u64, "ok"), (19, "warn"), (5, "bad")] {
307            let fs = fs(100, free);
308            assert_eq!(free_line(&estate(&d, &fs)).state, state, "{free} free of 100");
309        }
310        let broken = FilesystemUse { measured: false, error: "EIO".into(), ..fs(0, 0) };
311        let l = free_line(&estate(&d, &broken));
312        assert_eq!(l.state, "unknown");
313        assert!(!l.line.chars().any(|c| c.is_ascii_digit()), "no number that was not read: {}", l.line);
314    }
315
316    #[test]
317    fn the_disk_speed_is_green_only_when_measured() {
318        let d = devs(&["/dev/vdb", "/dev/vdc"]);
319        let fs = fs(1, 1);
320        let mut f = estate(&d, &fs);
321        assert_eq!(speed_line(&f).state, "unknown");
322        let done = Outcome::Done(Report {
323            seq_write_bps: 8.12e8,
324            seq_read_bps: 1.0e9,
325            read_how: ReadHow::Direct,
326            sync_ops_per_s: 3100.0,
327            elapsed: Duration::from_millis(1600),
328            file_bytes: 1 << 30,
329            write_elapsed: Duration::from_millis(800),
330            read_elapsed: Duration::from_millis(500),
331            sync_ops: 4960,
332            sync_elapsed: Duration::from_millis(300),
333        });
334        f.disk = Some(&done);
335        let l = speed_line(&f);
336        assert_eq!(l.state, "ok");
337        assert!(l.line.contains("812 MB/s"), "{}", l.line);
338        let failed = Outcome::Failed("EIO".into());
339        f.disk = Some(&failed);
340        assert_eq!(speed_line(&f).state, "bad");
341    }
342}