Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-ui-web/src/dataset.rs

//! **What the Storage page's data-set panel says**, on the host side of the
//! wasm boundary.
//!
//! Same layout as [`crate::machine`]: `pages::storage` is wasm-only, so every
//! rule about what the panel may say lives here, where `cargo test` runs it.
//!
//! The panel answers one question an operator of a box with no shell cannot
//! answer any other way: **how much storage does this box have** — total,
//! used, free of the store root, how the set is laid out (`4 × 2.0G raid0,
//! xfs`), and whether it can grow. It is read-only and the same on every
//! build; nothing on it is a limit, because the open appliance has none.

use serde::Deserialize;

use crate::fmt;

/// The data set, as `GET /api/dataset` sends it.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct DataSet {
    pub root: String,
    pub total_bytes: u64,
    pub used_bytes: u64,
    pub free_bytes: u64,
    /// gunnar's layout words verbatim; empty when it could not tell.
    #[serde(default)]
    pub layout: String,
    pub members: Option<u32>,
    pub member_size: Option<String>,
    pub level: Option<String>,
    pub filesystem: Option<String>,
    pub growth: String,
}

/// Every growth word the server can send, in the order the panel knows them.
pub const GROWTH_WORDS: &[&str] = &["sealed", "at_restart", "at_runtime", "operator_only", "unknown"];

/// The short label for the growth word.
pub fn growth_label(word: &str) -> &'static str {
    match word {
        "sealed" => "Sealed",
        "at_restart" => "Grows at restart",
        "at_runtime" => "Grows while running",
        "operator_only" => "Grown out of band",
        _ => "Unknown",
    }
}

/// One sentence saying what the growth word means for this box.
pub fn growth_detail(word: &str) -> &'static str {
    match word {
        "sealed" => {
            "This set cannot gain space while the box runs, and a disk attached to the machine \
             is not used. More space means a fresh install onto a larger set."
        }
        "at_restart" => {
            "Enlarge every member to one equal size and restart: the boot stack grows the set \
             and the next reading shows the larger total. An extra disk attached to the machine \
             is not used."
        }
        "at_runtime" => {
            "Enlarge every member to one equal size and start a grow: the box drains, grows the \
             set in place and serves again in minutes, without a restart."
        }
        "operator_only" => {
            "Gunnar does not grow this filesystem itself; whoever runs the host can extend it, \
             and the next reading shows the larger total."
        }
        _ => "Gunnar could not tell what the store root sits on, so whether it can grow is not known.",
    }
}

/// `4 × 2.0G raid0, xfs` / `xfs` / gunnar's verbatim words / `None` when
/// gunnar said nothing.
pub fn layout_line(set: &DataSet) -> Option<String> {
    match (&set.members, &set.member_size, &set.level, &set.filesystem) {
        (Some(n), Some(size), Some(level), Some(fs)) => Some(format!("{n} \u{d7} {size} {level}, {fs}")),
        (None, None, None, Some(fs)) => Some(fs.clone()),
        _ if !set.layout.trim().is_empty() => Some(set.layout.clone()),
        _ => None,
    }
}

/// Used share of the total, whole percent, or `None` for an unmeasured total.
pub fn used_percent(set: &DataSet) -> Option<u32> {
    if set.total_bytes == 0 {
        return None;
    }
    Some(((set.used_bytes as u128 * 100 + set.total_bytes as u128 / 2) / set.total_bytes as u128) as u32)
}

/// `1.2 GB of 8.0 GB used (15 %)` — the panel's summary line.
pub fn summary(set: &DataSet) -> String {
    match used_percent(set) {
        Some(p) => format!(
            "{} used of {} ({p} %), {} free",
            fmt::bytes(set.used_bytes),
            fmt::bytes(set.total_bytes),
            fmt::bytes(set.free_bytes)
        ),
        None => "the store root could not be measured".to_owned(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn appliance() -> DataSet {
        DataSet {
            root: "/data".into(),
            total_bytes: 8_578_400_256,
            used_bytes: 284_254_208,
            free_bytes: 8_294_146_048,
            layout: "4\u{d7}2.0G raid0 xfs".into(),
            members: Some(4),
            member_size: Some("2.0G".into()),
            level: Some("raid0".into()),
            filesystem: Some("xfs".into()),
            growth: "at_restart".into(),
        }
    }

    #[test]
    fn the_layout_line_names_the_members_the_level_and_the_filesystem() {
        assert_eq!(layout_line(&appliance()).as_deref(), Some("4 \u{d7} 2.0G raid0, xfs"));
        let single = DataSet {
            layout: "xfs".into(),
            filesystem: Some("xfs".into()),
            ..DataSet::default()
        };
        assert_eq!(layout_line(&single).as_deref(), Some("xfs"));
        // Words the server could not split are shown as they came.
        let odd = DataSet {
            layout: "zfs mirror".into(),
            ..DataSet::default()
        };
        assert_eq!(layout_line(&odd).as_deref(), Some("zfs mirror"));
        assert_eq!(layout_line(&DataSet::default()), None);
    }

    #[test]
    fn the_used_share_is_rounded_and_an_unmeasured_total_has_none() {
        assert_eq!(used_percent(&appliance()), Some(3));
        let half = DataSet {
            total_bytes: 200,
            used_bytes: 100,
            free_bytes: 100,
            ..DataSet::default()
        };
        assert_eq!(used_percent(&half), Some(50));
        assert_eq!(used_percent(&DataSet::default()), None);
        assert!(summary(&appliance()).contains("free"));
        assert_eq!(summary(&DataSet::default()), "the store root could not be measured");
    }

    #[test]
    fn every_growth_word_has_its_own_label_and_sentence() {
        let labels: Vec<&str> = GROWTH_WORDS.iter().map(|w| growth_label(w)).collect();
        let mut unique = labels.clone();
        unique.sort();
        unique.dedup();
        assert_eq!(unique.len(), GROWTH_WORDS.len(), "{labels:?}");
        // A word from a newer server is not rounded to a known one.
        assert_eq!(growth_label("hotplug"), "Unknown");
        assert_eq!(growth_detail("hotplug"), growth_detail("unknown"));
    }

    /// The panel's every sentence, on the open console: no money, no limit.
    #[test]
    fn nothing_the_panel_can_say_is_about_money_or_a_limit() {
        let mut said = vec![summary(&appliance()), layout_line(&appliance()).unwrap()];
        for w in GROWTH_WORDS {
            said.push(growth_label(w).to_owned());
            said.push(growth_detail(w).to_owned());
        }
        for text in &said {
            for word in text.split(|c: char| !c.is_ascii_alphanumeric()).map(str::to_ascii_lowercase) {
                assert!(
                    !["price", "pay", "paid", "buy", "bought", "sell", "sold", "billed", "billing", "quota", "plan", "invoice", "money", "purchase"]
                        .contains(&word.as_str()),
                    "`{word}` in {text:?}"
                );
            }
        }
    }
}