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/disks.rs

//! **What the admin disk panel says**, on the host side of the wasm boundary.
//!
//! Same layout as [`crate::dataset`]: the panel (`pages::disks`) is wasm-only,
//! so every sentence it prints about a disk is decided here, where `cargo test`
//! runs it. The rule the whole panel keeps: **a number nobody measured is not
//! drawn** — an unmeasured mount says why, an md member whose size was not
//! found says so, and the read cache's high/low-water state is named as not
//! reported rather than drawn as "below".

use serde::Deserialize;

use crate::fmt;

/// `GET /api/disks` (admin only).
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct Disks {
    pub data_set: Option<DataSet>,
    pub system: Option<Mount>,
    #[serde(default)]
    pub mounts: Vec<Mount>,
    #[serde(default)]
    pub devices: Vec<Device>,
    pub read_cache: Option<ReadCache>,
    #[serde(default)]
    pub twins: Vec<TwinDisk>,
    pub measured_at_unix_ms: Option<i64>,
    #[serde(default)]
    pub problems: Vec<String>,
    #[serde(default)]
    pub unanswered: Vec<String>,
    pub disk_speed: Option<DiskSpeed>,
    #[serde(default)]
    pub write_amplification: NotMeasured,
    #[serde(default)]
    pub priority_transfer: NotMeasured,
    pub serialdat: Option<Serialdat>,
    pub twin_box: Option<TwinBox>,
    #[serde(default)]
    pub warnings: Vec<String>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct DiskSpeed {
    pub test: String,
    pub seq_write_bps: Option<u64>,
    pub seq_read_bps: Option<u64>,
    pub sync_milliops_per_s: Option<u64>,
    #[serde(default)]
    pub read_how: String,
    #[serde(default)]
    pub reason: String,
    pub bytes: Option<u64>,
    pub write_us: Option<u64>,
    pub read_us: Option<u64>,
    pub sync_ops: Option<u64>,
    pub sync_us: Option<u64>,
    pub run_us: Option<u64>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct NotMeasured {
    pub value: Option<String>,
    #[serde(default)]
    pub why: String,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct Serialdat {
    pub mount: Mount,
    #[serde(default)]
    pub breakdown: Vec<PathRow>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct PathRow {
    pub label: String,
    #[serde(default)]
    pub path: String,
    pub bytes: Option<u64>,
    #[serde(default)]
    pub error: String,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct TwinBox {
    pub address: String,
    #[serde(default)]
    pub mounts: Vec<Mount>,
    pub serialdat: Option<Serialdat>,
    #[serde(default)]
    pub unanswered: String,
}

/// `812 MB/s` (decimal, as disks are sold).
pub fn rate(bps: u64) -> String {
    let b = bps as f64;
    if b >= 1e9 {
        format!("{:.1} GB/s", b / 1e9)
    } else {
        format!("{:.0} MB/s", b / 1e6)
    }
}

fn dur(us: u64) -> String {
    if us >= 1_000_000 {
        format!("{:.1} s", us as f64 / 1e6)
    } else {
        format!("{} ms", us / 1000)
    }
}

/// The measured rates, in one line; an absent rate says why, never 0.
pub fn speed_line(s: &DiskSpeed) -> String {
    if s.test != "measured" {
        return format!("not measured ({})", s.test.replace('_', " "));
    }
    let write = s.seq_write_bps.map(rate).unwrap_or_else(|| "\u{2014}".to_owned());
    let read = match s.seq_read_bps {
        Some(r) => rate(r),
        None => "\u{2014} (the page cache answered)".to_owned(),
    };
    let sync = s
        .sync_milliops_per_s
        .map(|m| format!("{} ops/s", m / 1000))
        .unwrap_or_else(|| "\u{2014}".to_owned());
    format!("write {write} \u{b7} read {read} \u{b7} 4 KiB write+fsync {sync}")
}

/// **The bytes and the time behind the rates**, or `None` from a gunnar that
/// does not send them.
pub fn speed_basis(s: &DiskSpeed) -> Option<String> {
    let bytes = s.bytes?;
    let mut parts = vec![format!("{} written in {}", fmt::bytes(bytes), dur(s.write_us?))];
    if let Some(r) = s.read_us {
        parts.push(format!("read back in {}", dur(r)));
    }
    if let (Some(ops), Some(us)) = (s.sync_ops, s.sync_us) {
        parts.push(format!("{ops} fsync'd 4 KiB writes in {}", dur(us)));
    }
    if let Some(run) = s.run_us {
        parts.push(format!("whole run {}", dur(run)))
    }
    Some(parts.join("; "))
}

/// `N/A — why`, or the value when a gunnar reports one.
pub fn na_line(n: &NotMeasured) -> String {
    match &n.value {
        Some(v) => v.clone(),
        None if n.why.is_empty() => "N/A".to_owned(),
        None => format!("N/A \u{2014} {}", n.why),
    }
}

/// One /serialdat row: `audit trail 1.0 GiB`, or why it was not read.
pub fn path_line(p: &PathRow) -> String {
    match p.bytes {
        Some(b) => format!("{} {}", p.label, fmt::bytes(b)),
        None if p.error.is_empty() => format!("{} not measured", p.label),
        None => format!("{} not measured: {}", p.label, p.error),
    }
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct DataSet {
    pub root: String,
    pub total_bytes: u64,
    pub free_bytes: u64,
    pub used_bytes: u64,
    pub floor_bytes: u64,
    pub servable_bytes: u64,
    #[serde(default)]
    pub layout: String,
    pub growth: String,
    #[serde(default)]
    pub device: String,
    #[serde(default)]
    pub raid_level: String,
    #[serde(default)]
    pub members: Vec<Member>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct Member {
    pub name: String,
    pub size_bytes: Option<u64>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct Mount {
    pub source: String,
    pub mount_point: String,
    pub fs_type: String,
    #[serde(default)]
    pub read_only: bool,
    #[serde(default)]
    pub labels: Vec<String>,
    pub measured: bool,
    #[serde(default)]
    pub error: String,
    pub total_bytes: Option<u64>,
    pub free_bytes: Option<u64>,
    pub available_bytes: Option<u64>,
    pub used_bytes: Option<u64>,
    pub reserved_bytes: Option<u64>,
    pub floor_bytes: Option<u64>,
    pub servable_bytes: Option<u64>,
    pub used_permille: Option<u32>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct Device {
    pub name: String,
    pub size_bytes: u64,
    #[serde(default)]
    pub rotational: bool,
    #[serde(default)]
    pub removable: bool,
    #[serde(default)]
    pub read_only: bool,
    #[serde(default)]
    pub partitions: Vec<(String, u64)>,
    #[serde(default)]
    pub holders: Vec<String>,
    #[serde(default)]
    pub members: Vec<String>,
    #[serde(default)]
    pub raid_level: String,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct ReadCache {
    pub bytes: u64,
    pub budget_bytes: u64,
    pub of_budget_permille: Option<u32>,
    pub of_data_used_permille: Option<u32>,
    pub hysteresis: Option<String>,
    #[serde(default)]
    pub hysteresis_note: String,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct TwinDisk {
    pub principal: String,
    pub address: String,
    pub state: String,
    pub total_bytes: Option<u64>,
    pub used_bytes: Option<u64>,
    pub fill_permille: Option<u32>,
}

/// `12.3 %` from a per-mille.
pub fn percent(permille: u32) -> String {
    format!("{}.{} %", permille / 10, permille % 10)
}

/// One mount, in one line: used of total and the share, or why there is none.
pub fn mount_line(m: &Mount) -> String {
    if !m.measured {
        return if m.error.is_empty() {
            "not measured".to_owned()
        } else {
            format!("not measured: {}", m.error)
        };
    }
    match (m.used_bytes, m.total_bytes) {
        (Some(used), Some(total)) => {
            let share = m.used_permille.map(|p| format!(" \u{b7} {}", percent(p))).unwrap_or_default();
            format!("{} used of {}{share}", fmt::bytes(used), fmt::bytes(total))
        }
        _ => "not measured".to_owned(),
    }
}

/// Free / floor / servable of one measured mount, or empty.
pub fn mount_room(m: &Mount) -> String {
    match (m.available_bytes, m.floor_bytes, m.servable_bytes) {
        (Some(free), Some(floor), Some(servable)) if m.measured => format!(
            "{} free \u{b7} floor {} \u{b7} {} may be handed out",
            fmt::bytes(free),
            fmt::bytes(floor),
            fmt::bytes(servable)
        ),
        _ => String::new(),
    }
}

/// What gunnar puts on a mount, or a dash.
pub fn labels_line(m: &Mount) -> String {
    if m.labels.is_empty() {
        "\u{2014}".to_owned()
    } else {
        m.labels.join(", ")
    }
}

/// The data set's members: `vdb1 150.0 GiB, vdc1 150.0 GiB` — a member whose
/// size was not found says so rather than borrowing a neighbour's.
pub fn members_line(set: &DataSet) -> Option<String> {
    if set.members.is_empty() {
        return None;
    }
    Some(
        set.members
            .iter()
            .map(|m| match m.size_bytes {
                Some(size) => format!("{} {}", m.name, fmt::bytes(size)),
                None => format!("{} (size not found)", m.name),
            })
            .collect::<Vec<_>>()
            .join(", "),
    )
}

/// The read cache: bytes, of budget, share of the data set's use.
pub fn cache_line(c: &ReadCache) -> String {
    let budget = match (c.budget_bytes, c.of_budget_permille) {
        (0, _) => " \u{b7} no budget".to_owned(),
        (b, Some(p)) => format!(" \u{b7} {} of a {} budget", percent(p), fmt::bytes(b)),
        (b, None) => format!(" \u{b7} budget {}", fmt::bytes(b)),
    };
    let share = c
        .of_data_used_permille
        .map(|p| format!(" \u{b7} {} of what the data set holds", percent(p)))
        .unwrap_or_default();
    format!("{}{budget}{share}", fmt::bytes(c.bytes))
}

/// The high/low-water state, or the reason there is none. Never a guess.
pub fn hysteresis_line(c: &ReadCache) -> String {
    match &c.hysteresis {
        Some(state) => state.clone(),
        None if !c.hysteresis_note.is_empty() => format!("not reported: {}", c.hysteresis_note),
        None => "not reported".to_owned(),
    }
}

/// One twin's volume, or why there is no figure.
pub fn twin_line(t: &TwinDisk) -> String {
    match (t.used_bytes, t.total_bytes, t.fill_permille) {
        (Some(used), Some(total), Some(fill)) => {
            format!("{} used of {} \u{b7} {}", fmt::bytes(used), fmt::bytes(total), percent(fill))
        }
        _ => "volume not reported".to_owned(),
    }
}

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

    const GIB: u64 = 1 << 30;

    #[test]
    fn a_measured_mount_says_used_of_total_and_an_unmeasured_one_says_why() {
        let m = Mount {
            measured: true,
            used_bytes: Some(4 * GIB),
            total_bytes: Some(10 * GIB),
            used_permille: Some(400),
            available_bytes: Some(5 * GIB),
            floor_bytes: Some(2 * GIB),
            servable_bytes: Some(3 * GIB),
            ..Mount::default()
        };
        assert_eq!(mount_line(&m), "4.0 GiB used of 10.0 GiB \u{b7} 40.0 %");
        assert!(mount_room(&m).contains("floor 2.0 GiB"));
        let broken = Mount { measured: false, error: "statvfs(/x): EIO".into(), ..Mount::default() };
        assert_eq!(mount_line(&broken), "not measured: statvfs(/x): EIO");
        assert_eq!(mount_room(&broken), "", "no room drawn for a mount nobody measured");
    }

    #[test]
    fn a_member_without_a_size_is_not_given_one() {
        let set = DataSet {
            members: vec![Member { name: "vdb1".into(), size_bytes: Some(150 * GIB) }, Member { name: "vdq9".into(), size_bytes: None }],
            ..DataSet::default()
        };
        assert_eq!(members_line(&set).as_deref(), Some("vdb1 150.0 GiB, vdq9 (size not found)"));
        assert_eq!(members_line(&DataSet::default()), None);
    }

    #[test]
    fn the_read_cache_names_its_share_and_does_not_invent_a_hysteresis_state() {
        let c = ReadCache {
            bytes: 20 * GIB,
            budget_bytes: 40 * GIB,
            of_budget_permille: Some(500),
            of_data_used_permille: Some(200),
            hysteresis: None,
            hysteresis_note: "arrives with znippy-plugin-git 0.1.6".into(),
        };
        assert_eq!(cache_line(&c), "20.0 GiB \u{b7} 50.0 % of a 40.0 GiB budget \u{b7} 20.0 % of what the data set holds");
        assert!(hysteresis_line(&c).starts_with("not reported"), "{}", hysteresis_line(&c));
    }

    #[test]
    fn a_twin_that_did_not_measure_its_volume_is_not_empty() {
        assert_eq!(twin_line(&TwinDisk::default()), "volume not reported");
        let t = TwinDisk { used_bytes: Some(GIB), total_bytes: Some(4 * GIB), fill_permille: Some(250), ..TwinDisk::default() };
        assert_eq!(twin_line(&t), "1.0 GiB used of 4.0 GiB \u{b7} 25.0 %");
    }

    #[test]
    fn the_disk_speed_names_its_bytes_and_times_and_an_unread_rate_is_not_zero() {
        let s = DiskSpeed {
            test: "measured".into(),
            seq_write_bps: Some(812_000_000),
            seq_read_bps: None,
            sync_milliops_per_s: Some(3_100_400),
            bytes: Some(256 << 20),
            write_us: Some(330_000),
            read_us: Some(20_000),
            sync_ops: Some(1550),
            sync_us: Some(500_000),
            run_us: Some(1_600_000),
            ..DiskSpeed::default()
        };
        assert_eq!(speed_line(&s), "write 812 MB/s \u{b7} read \u{2014} (the page cache answered) \u{b7} 4 KiB write+fsync 3100 ops/s");
        assert_eq!(
            speed_basis(&s).as_deref(),
            Some("256.0 MiB written in 330 ms; read back in 20 ms; 1550 fsync'd 4 KiB writes in 500 ms; whole run 1.6 s")
        );
        let unrun = DiskSpeed { test: "not_run".into(), ..DiskSpeed::default() };
        assert_eq!(speed_line(&unrun), "not measured (not run)");
        assert_eq!(speed_basis(&unrun), None);
    }

    #[test]
    fn what_is_not_measured_is_na_never_zero() {
        let n = NotMeasured { value: None, why: "no counter".into() };
        assert_eq!(na_line(&n), "N/A \u{2014} no counter");
        assert!(!na_line(&n).contains('0'));
        assert_eq!(path_line(&PathRow { label: "crash record".into(), bytes: None, error: "EACCES".into(), ..PathRow::default() }), "crash record not measured: EACCES");
        assert_eq!(path_line(&PathRow { label: "audit trail".into(), bytes: Some(1 << 30), ..PathRow::default() }), "audit trail 1.0 GiB");
    }

    /// The server's own JSON (the shape `api::disks` serialises) reads back.
    #[test]
    fn the_servers_answer_deserialises() {
        let json = r#"{"data_set":{"root":"/data","total_bytes":3,"free_bytes":2,"used_bytes":1,"floor_bytes":0,"servable_bytes":2,"layout":"xfs","growth":"sealed","device":"md0","raid_level":"raid0","members":[{"name":"vdb1","size_bytes":null}]},"system":null,"mounts":[],"devices":[{"name":"vdb","size_bytes":9,"rotational":false,"removable":false,"read_only":false,"partitions":[["vdb1",8]],"holders":["md0"],"members":[],"raid_level":""}],"read_cache":null,"twins":[],"measured_at_unix_ms":null,"problems":[],"unanswered":["Admin.StorageInfo: x"]}"#;
        let d: Disks = serde_json::from_str(json).expect("the server's shape");
        assert_eq!(d.devices[0].partitions[0], ("vdb1".to_owned(), 8));
        assert_eq!(d.unanswered.len(), 1);
    }
}