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);
}
}