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:?}"
);
}
}
}
}