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

//! **What the Machine page decides**, on the host side of the wasm boundary.
//!
//! Same argument as [`crate::room`] and [`crate::activity`], and the same
//! layout: `pages::machine` is `#[cfg(target_arch = "wasm32")]`, so nothing
//! written beside the view is compiled — let alone run — by `cargo test` on
//! the host. That is fine for a view and not fine for this page, whose whole
//! value is a handful of rules about what may and may not be said. No leptos,
//! no `web-sys`; the page below re-exports these and does nothing but draw.
//!
//! # The page exists because the appliance has no shell
//!
//! `:22` is closed and PID 1 is `gunnar-server`. Nobody will ever run `lsblk`
//! on this box. So this is the only place an administrator can see what the
//! machine is — and that raises the cost of every sentence on it, because
//! there is nothing to check it against.
//!
//! # Three layers, and they age differently
//!
//! 1. **What the installer decided** ([`InstallRecord`]) — historical, written
//!    once, and carried as gunnar sends it: a key/value table this console
//!    does not parse. See [`InstallRecord::fields`] for why that is a feature.
//! 2. **What is true now** ([`Filesystem`]) — measured on the call, and only
//!    for the paths gunnar was told to use.
//! 3. **What gunnar is configured to do** ([`Placement`]) — a setting, which
//!    is a statement about the next byte and not about the bytes already
//!    there.
//!
//! Conflating them is how a reader takes a stale fact for a live one, so the
//! page draws them as three panels with three different words for time, and
//! **nothing here derives a layer-1 answer from layer-2 data**. See
//! [`layer_one`].
//!
//! # The two rules that outrank completeness
//!
//! **[`OffStripe::SameDevice`] is never good news.** A copy that shares a
//! device with what it copies dies in the event it exists for, and it is worse
//! than no copy at all, because somebody will trust it. It gets an alarm, and
//! the state where there simply is no copy gets none — see [`OffStripe`].
//!
//! **A number that was not read is not rendered.** Every byte figure arrives
//! as an `Option` with a reason beside it, and every function here that turns
//! one into text answers `None` rather than a zero, a dash-shaped guess or a
//! half-measured total.

use serde::Deserialize;

use crate::fmt;

/// The machine, as `GET /api/machine` sends it.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct Machine {
    /// The server's own words when it could not be asked about the machine at
    /// all — either because it does not serve `Admin.StorageInfo`, or because
    /// it serves it and predates the `reality` field. Empty when it answered.
    #[serde(default)]
    pub unanswered: String,

    #[serde(default)]
    pub record: Option<InstallRecord>,
    #[serde(default)]
    pub record_path: String,
    #[serde(default)]
    pub record_absent: String,

    #[serde(default)]
    pub filesystems: Vec<Filesystem>,

    #[serde(default)]
    pub placement: Option<Placement>,

    /// **gunnar's own few lines** (`StorageReality.glance`): `/data`'s shape,
    /// the off-stripe copies, the free space, the disk speed — each with
    /// gunnar's verdict. The Overview's Machine card draws them AS THEY CAME;
    /// see [`glance_grade`]. Empty from a gunnar older than the field.
    #[serde(default)]
    pub glance: Vec<Glance>,
}

/// **How often the Overview's Machine card asks again** while its page is
/// visible (owner, 2026-10-04: "the picture needs to be a bit dynamic, if twin
/// dies etc."). gunnar's own silence bound is a minute; ten seconds is how
/// quickly the card shows what gunnar decided, not a judgement of its own.
pub const MACHINE_CARD_POLL_MS: u32 = 10_000;

/// `GET /api/machine/topology`, as the card reads it: the rows (for the words
/// a screen reader is given) and the live drawing.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct Topology {
    #[serde(default)]
    pub unanswered: String,
    #[serde(default)]
    pub boxes: Vec<TopologyBox>,
    /// The shared renderer's live board of these rows, drawn by the BFF.
    #[serde(default)]
    pub svg: String,
}

/// One box as gunnar sent it.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct TopologyBox {
    #[serde(default)]
    pub name: String,
    #[serde(default)]
    pub role: String,
    #[serde(default)]
    pub state: String,
    #[serde(default)]
    pub headline: String,
}

/// ★ **The picture in words** — its accessible name: each box, its role and
/// gunnar's own words for its state, as sent. Nothing judged here.
pub fn estate_words(t: &Topology) -> String {
    if t.boxes.is_empty() {
        return format!("The estate: {}", t.unanswered);
    }
    let rows: Vec<String> = t.boxes.iter().map(|b| format!("{} ({}): {}", b.name, b.role, if b.headline.is_empty() { &b.state } else { &b.headline })).collect();
    format!("The estate, {} box{}: {}", t.boxes.len(), if t.boxes.len() == 1 { "" } else { "es" }, rows.join("; "))
}

/// ★ **Swap the drawing in, or keep the one on screen** — only when gunnar's
/// answer drew something DIFFERENT, so an unchanged estate never repaints (no
/// flicker, the pulses keep their phase), and never to nothing: an answer with
/// no drawing keeps the last picture until gunnar answers again.
///
/// And never to a drawing that could do more than draw ([`drawable`]): the
/// card puts it in with `inner_html`, so this is the only gate between the
/// answer and the page (stinky #20).
pub fn swap(on_screen: &str, answer: &str) -> bool {
    !answer.is_empty() && answer != on_screen && drawable(answer)
}

/// The elements the suite's estate renderer (`nordisk-estate-svg`) draws.
/// Nothing that links, embeds, styles or scripts.
const DRAWN_ELEMENTS: &[&str] = &["svg", "g", "defs", "linearGradient", "stop", "rect", "circle", "line", "text", "animate"];

/// The attributes it writes. No `href`, no `style`, no `on…`.
const DRAWN_ATTRIBUTES: &[&str] = &[
    "xmlns", "viewBox", "id", "x", "y", "x1", "y1", "x2", "y2", "cx", "cy", "r", "rx", "width", "height", "offset",
    "fill", "stroke", "stroke-width", "stroke-dasharray", "stop-color", "font-family", "font-size", "font-weight",
    "text-anchor", "attributeName", "values", "dur", "repeatCount", "data-box", "data-link", "data-state",
];

/// What an `<animate>` may change: how strongly a line is drawn, never what
/// it links to.
const ANIMATED: &[&str] = &["stroke-opacity", "opacity", "fill-opacity"];

const SVG_NAMESPACE: &str = "http://www.w3.org/2000/svg";

/// ★ **Could this answer only draw?** One `<svg>` root holding nothing but
/// the elements and attributes the shared renderer writes, every value
/// double-quoted, every `&` one of the five XML entities, no comment, CDATA or
/// processing instruction, no colon outside `xmlns` and the `data-` words
/// (so no `javascript:`), no `url(` but to a gradient in the same drawing,
/// and every element closed in order. Anything else, including what a browser
/// would forgive, is not drawn: a checker that is stricter than the HTML
/// parser cannot be talked past by it.
pub fn drawable(svg: &str) -> bool {
    let s = svg.trim();
    let mut open: Vec<&str> = Vec::new();
    let mut closed_root = false;
    let mut at = 0;
    while at < s.len() {
        let rest = &s[at..];
        let Some(lt) = rest.find('<') else { return false };
        let words = &rest[..lt];
        if closed_root || !escaped(words) || (open.is_empty() && !words.trim().is_empty()) {
            return false;
        }
        let tag = &rest[lt + 1..];
        let Some(gt) = tag.find('>') else { return false };
        let inner = &tag[..gt];
        at += lt + 1 + gt + 1;
        if let Some(name) = inner.strip_prefix('/') {
            if open.pop() != Some(name) {
                return false;
            }
            closed_root = open.is_empty();
            continue;
        }
        let (inner, empty) = match inner.strip_suffix('/') {
            Some(inner) => (inner, true),
            None => (inner, false),
        };
        let name_end = inner.find(|c: char| c.is_ascii_whitespace()).unwrap_or(inner.len());
        let name = &inner[..name_end];
        if !DRAWN_ELEMENTS.contains(&name) || open.is_empty() != (name == "svg") || !attributes(name, &inner[name_end..]) {
            return false;
        }
        if empty {
            closed_root = open.is_empty();
        } else {
            open.push(name);
        }
    }
    closed_root
}

/// `name="value"` pairs, each after whitespace, each one [`attribute`] allows.
fn attributes(element: &str, mut rest: &str) -> bool {
    loop {
        let pair = rest.trim_start_matches(|c: char| c.is_ascii_whitespace());
        if pair.is_empty() {
            return true;
        }
        if pair.len() == rest.len() {
            return false;
        }
        let Some((name, after)) = pair.split_once('=') else { return false };
        let Some(after) = after.strip_prefix('"') else { return false };
        let Some((value, after)) = after.split_once('"') else { return false };
        if !attribute(element, name, value) {
            return false;
        }
        rest = after;
    }
}

fn attribute(element: &str, name: &str, value: &str) -> bool {
    if !DRAWN_ATTRIBUTES.contains(&name) || !escaped(value) || value.contains('<') {
        return false;
    }
    match name {
        "xmlns" => element == "svg" && value == SVG_NAMESPACE,
        "attributeName" => element == "animate" && ANIMATED.contains(&value),
        "id" => !value.is_empty() && value.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
        _ if name.starts_with("data-") => true,
        _ => !value.contains(':') && value.split("url(").skip(1).all(|after| after.starts_with('#')),
    }
}

/// Text with every `&` one of the five XML entities the renderer writes —
/// no numeric reference that could spell a word the checks above refuse.
fn escaped(text: &str) -> bool {
    text.split('&').skip(1).all(|after| ["amp;", "lt;", "gt;", "quot;", "apos;"].iter().any(|e| after.starts_with(e)))
}

/// One line of the machine at a glance, as gunnar sent it.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct Glance {
    /// `data` | `serialdat` | `free` | `speed`.
    #[serde(default)]
    pub key: String,
    #[serde(default)]
    pub line: String,
    /// `ok` | `warn` | `bad` | `unknown`.
    #[serde(default)]
    pub state: String,
    /// Why — behind the line's "?".
    #[serde(default)]
    pub detail: String,
}

/// ★ **gunnar's verdict word, drawn — never re-decided.** `ok` is the green
/// lamp, `warn` amber, `bad` red; anything else (`unknown`, or a word from a
/// gunnar newer than this build) is a plain, colourless marker — never the
/// safest-looking colour this console knows.
pub fn glance_grade(state: &str) -> Grade {
    match state {
        "ok" => Grade::Good,
        "warn" => Grade::Caution,
        "bad" => Grade::Alarm,
        _ => Grade::Neutral,
    }
}

/// The word on a glance line's badge: gunnar's own, upper-cased for the
/// three it judged, `unknown` for anything else.
pub fn glance_label(state: &str) -> &'static str {
    match state {
        "ok" => "OK",
        "warn" => "LOOK",
        "bad" => "BAD",
        _ => "unknown",
    }
}

/// Layer 1: what the installer decided, **in the installer's own keys**.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct InstallRecord {
    #[serde(default)]
    pub version: u32,
    /// gunnar's two words: `verified` or `INTEGRITY ONLY`.
    #[serde(default)]
    pub signature: String,
    #[serde(default)]
    pub signature_verified: bool,
    /// The installer's own sentence saying what is NOT verified.
    #[serde(default)]
    pub signature_note: String,
    /// ★ **Every field of the config, unparsed, in key order.**
    ///
    /// Not folded back into disks, partitions and sets, and gunnar's own
    /// comment is why: *a key this build does not know is still shown, because
    /// the admin reading the page may know what it means when this binary does
    /// not*. A console that reconstructed a structure here would go blind on
    /// every key a later installer adds, and on a box with no shell there is
    /// no second way to see it.
    #[serde(default)]
    pub fields: Vec<RecordField>,
}

/// One `key=value` from the install record, as the installer wrote it.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct RecordField {
    #[serde(default)]
    pub key: String,
    #[serde(default)]
    pub value: String,
}

/// Layer 2: one configured filesystem, measured on this call.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct Filesystem {
    /// `data root` | `configured directory`.
    #[serde(default)]
    pub what: String,
    #[serde(default)]
    pub path: String,
    pub total_bytes: Option<u64>,
    pub free_bytes: Option<u64>,
    /// Why there are no numbers. Empty when there are.
    #[serde(default)]
    pub error: String,
}

/// Layer 3: gunnar's placement settings.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct Placement {
    #[serde(default)]
    pub data_root: String,
    /// **`/data`'s own shape** (`data.shape` from the installer's layout
    /// config: `raid0` | `plain` | `p3`). Empty when nothing said.
    #[serde(default)]
    pub data_shape: String,
    /// `data.<N>.dev`: the devices `/data` is made of.
    #[serde(default)]
    pub data_devices: Vec<String>,
    #[serde(default)]
    pub slow_root: String,
    /// **How gunnar learned where to write.** "We were told" and "we fell
    /// back" are different facts, and this is the field that holds the
    /// difference. See [`slow_root_line`].
    #[serde(default)]
    pub slow_root_source: String,
    /// **`/serialdat`'s disk** (`serialdat.dev`) — NOT `/data`'s. The old
    /// Machine page drew it as "Backing device" right under "Data root", and
    /// every estate read as if its repositories lived on the boot disk's P3
    /// (owner, 2026-10-04). See [`serialdat_on`].
    #[serde(default)]
    pub backing_device: String,
    /// `p3` | `device` | `fallback` — `/serialdat`'s class.
    #[serde(default)]
    pub backing_class: String,
    pub off_stripe: Option<String>,
    /// Whole sentences, each naming a copy AND its limitation. Never "OK".
    #[serde(default)]
    pub off_stripe_health: Vec<String>,
    #[serde(default)]
    pub rows: Vec<PlacementRow>,
}

/// Layer 3: one thing gunnar writes, and where.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct PlacementRow {
    #[serde(default)]
    pub what: String,
    #[serde(default)]
    pub path: String,
    #[serde(default)]
    pub requires_off_stripe: bool,
    pub off_stripe: Option<String>,
    /// **Does gunnar write here today?** A directory that exists is not a
    /// feature.
    #[serde(default)]
    pub wired: bool,
    /// When `wired` is false, why not.
    #[serde(default)]
    pub note: String,
}

/// **How many paths are configured and not yet written to**, in a sentence
/// that agrees with its number (owner 2026-10-14: "2 of these paths is …").
pub fn not_written_line(n: usize) -> String {
    format!("{} of these paths {} configured and not yet written to.", crate::fmt::count(n as u64), if n == 1 { "is" } else { "are" })
}

#[cfg(test)]
mod not_written_line_tests {
    #[test]
    fn the_sentence_agrees_with_its_number() {
        assert_eq!(super::not_written_line(1), "1 of these paths is configured and not yet written to.");
        assert_eq!(super::not_written_line(2), "2 of these paths are configured and not yet written to.");
    }
}

/// How loudly something should be drawn.
///
/// The console's own four states live in `ui::Tone`, which is behind the wasm
/// gate; this is the same ladder on the testable side, and `pages::machine`
/// maps it across. Keeping a second copy is deliberate — the alternative is
/// deciding severity inside a view, where no test can reach the decision.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Grade {
    /// Working as intended.
    Good,
    /// True, and somebody should know.
    Caution,
    /// Not working, or a promise that will not be kept.
    Alarm,
    /// A fact with no verdict attached.
    Neutral,
}

/// ★ **Whether a copy lives somewhere that can die on its own — in three
/// honest states, and a fourth for "nobody said".**
///
/// The three are **not a scale**, and this type exists so that no view can
/// treat them as one:
///
/// * [`OffStripe::Elsewhere`] — the copy is in another failure domain. Good.
/// * [`OffStripe::SameDevice`] — there is a copy and it will die with its
///   original. **Never good news, and never a green tick.** It is worse than
///   having none, because a copy that is shown as present is a copy somebody
///   will reach for on the day the original is gone. It is graded
///   [`Grade::Alarm`] on purpose, one step ABOVE the state where there is no
///   copy at all.
/// * [`OffStripe::None`] — there is no copy. Honest, and not an alarm by
///   itself: nobody is going to trust a thing that is not there.
/// * [`OffStripe::NotSaid`] — nothing was reported about this. A state of the
///   ANSWER, not of the machine — it is also what a placement row that does
///   not REQUIRE an off-stripe copy carries — and the page draws it as a dash
///   with the reason in the title rather than as any of the three above.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OffStripe {
    Elsewhere,
    SameDevice,
    None,
    NotSaid,
}

impl OffStripe {
    /// Read the word the server sent.
    ///
    /// gunnar spells these `off-stripe` / `same-device` / `absent`; the BFF
    /// translates them into this console's older vocabulary once, in
    /// `api::machine::off_stripe_word`, and this is the only reader of the
    /// result. An unknown word is [`OffStripe::NotSaid`] and never a guess: a
    /// console that mapped a word it does not know onto the nearest one it
    /// does would be inventing a topology, which is the whole failure this
    /// page is built against.
    pub fn read(word: Option<&str>) -> OffStripe {
        match word {
            Some("elsewhere") => OffStripe::Elsewhere,
            Some("same_device") => OffStripe::SameDevice,
            Some("none") => OffStripe::None,
            _ => OffStripe::NotSaid,
        }
    }

    /// The badge's word, or `None` when there is nothing honest to badge.
    pub fn label(self) -> Option<&'static str> {
        match self {
            OffStripe::Elsewhere => Some("off-stripe"),
            OffStripe::SameDevice => Some("same device"),
            OffStripe::None => Some("no second copy"),
            OffStripe::NotSaid => None,
        }
    }

    /// How loudly. **[`OffStripe::SameDevice`] is never [`Grade::Good`]**, and
    /// it is louder than [`OffStripe::None`].
    pub fn grade(self) -> Grade {
        match self {
            OffStripe::Elsewhere => Grade::Good,
            OffStripe::SameDevice => Grade::Alarm,
            OffStripe::None => Grade::Neutral,
            OffStripe::NotSaid => Grade::Neutral,
        }
    }

    /// The sentence under the badge, in the words an operator reads.
    pub fn detail(self) -> &'static str {
        match self {
            OffStripe::Elsewhere => {
                "On a device that is not in the striped set, so it survives the set."
            }
            OffStripe::SameDevice => {
                "This copy shares a device with what it copies: it dies in the \
                 event it exists for. That is worse than having none, because it \
                 is there to be trusted."
            }
            OffStripe::None => "There is no second copy. Nothing here is claiming otherwise.",
            OffStripe::NotSaid => {
                "Nothing was reported about where this sits, and nothing is being \
                 inferred from the filesystems below."
            }
        }
    }
}

/// Why there is no install record to draw.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Absence {
    /// **gunnar answered, and this machine has no layout config.** Expected on
    /// any appliance installed before `korp-installer` began writing one, and
    /// also on one whose config did not verify — gunnar reports that as ABSENT
    /// rather than as present-and-suspect, and `why` is what says which.
    NoRecord { path: String, why: String },
    /// **gunnar could not be asked** — it does not serve `Admin.StorageInfo`,
    /// or it does and predates the `reality` field. Not a fact about the
    /// machine at all.
    NotAnswered { why: String },
}

impl Absence {
    /// The heading. Says which of the two absences this is, because the fixes
    /// are unrelated: one is a reinstall, the other is a server upgrade.
    pub fn title(&self) -> &'static str {
        match self {
            Absence::NoRecord { .. } => "The install record is absent",
            Absence::NotAnswered { .. } => "This Gunnar does not report the machine",
        }
    }

    /// ★ The paragraph, and the promise it makes is a NEGATIVE one: the page
    /// will not answer layer 1 out of layer 2.
    ///
    /// Saying so is not padding. The filesystems are on the same screen, and
    /// the obvious thing for a reader — or for a later maintainer — to do is
    /// to fill this panel in from them. `statvfs` can say how full `/data` is.
    /// It can never say that a fifth disk was refused for being 512e, and a
    /// page that quietly reconstructed the first would be read as having
    /// answered the second.
    pub fn detail(&self) -> String {
        match self {
            Absence::NoRecord { path, why } => {
                let why = if why.trim().is_empty() {
                    "Gunnar gave no reason.".to_owned()
                } else {
                    format!("Gunnar says: {}", sentence(why))
                };
                let path = if path.trim().is_empty() {
                    String::new()
                } else {
                    format!(" It was looked for at {path}.")
                };
                format!(
                    "{why}{path} What the installer decided — which disk became boot and \
                     why it won, which disks were rejected and on what grounds — is not \
                     recoverable from a running machine, and this page will not guess at \
                     it from the filesystems below. An appliance installed before the \
                     record existed simply has none."
                )
            }
            Absence::NotAnswered { why } => format!(
                "Gunnar says: {} The server this console is talking to is older than \
                 the machine report, so nothing on this page was measured — neither the \
                 installer's decisions nor the filesystems. Nothing below is being \
                 inferred in its place.",
                sentence(why)
            ),
        }
    }
}

/// gunnar's words, ended once.
///
/// Its messages arrive with and without a full stop — `no file at that path`
/// and `statvfs failed.` are both real — and both are quoted mid-paragraph
/// here. Without this the page reads *no file at that path It was looked for
/// at …*, which is the kind of seam that makes a careful page look careless.
fn sentence(words: &str) -> String {
    let trimmed = words.trim_end();
    if trimmed.ends_with(['.', '!', '?', ':', ';']) {
        trimmed.to_owned()
    } else {
        format!("{trimmed}.")
    }
}

/// ★ **Layer 1, or the honest reason there is none.**
///
/// The one function the page's first panel goes through, and the reason it is
/// a `Result` rather than an `Option`: an `Option` has one empty state, and
/// this has two that must never be drawn the same way. *This machine has no
/// record* is a fact about the machine. *This gunnar cannot be asked* is a
/// fact about the server, on a machine whose layout may be perfectly well
/// recorded.
pub fn layer_one(machine: &Machine) -> Result<&InstallRecord, Absence> {
    if !machine.unanswered.trim().is_empty() {
        return Err(Absence::NotAnswered {
            why: machine.unanswered.clone(),
        });
    }
    match &machine.record {
        Some(record) => Ok(record),
        None => Err(Absence::NoRecord {
            path: machine.record_path.clone(),
            why: machine.record_absent.clone(),
        }),
    }
}

/// ★ **What the record's signature is worth, as a badge and a grade.**
///
/// Three states and gunnar's own two words for the first two. `INTEGRITY
/// ONLY` is [`Grade::Caution`] and never [`Grade::Good`]: the body's blake3
/// matched and **nothing else is claimed**, which is a checksum and not a
/// signature. It is not [`Grade::Alarm`] either, because it is the state every
/// appliance in the field is in today and a permanent red is a red nobody
/// reads by the second week.
///
/// A signature that FAILED to verify never reaches here — gunnar makes the
/// whole record absent instead — so this is never a third, quieter kind of
/// failure.
pub fn signature(record: &InstallRecord) -> (String, Grade) {
    if record.signature_verified {
        return (
            "Signed, and the signature verified against the root key enrolled on this \
             estate."
                .to_owned(),
            Grade::Good,
        );
    }
    let note = if record.signature_note.trim().is_empty() {
        String::new()
    } else {
        format!(" {}", sentence(&record.signature_note))
    };
    if record.signature.trim().is_empty() {
        return (
            format!(
                "Gunnar did not say what this record's signature is worth, so nothing here \
                 is claiming it was checked.{note}"
            ),
            Grade::Neutral,
        );
    }
    (
        format!(
            "{}: the body's checksum matches and nothing else is claimed. This is not a \
             signature.{note}",
            record.signature.trim()
        ),
        Grade::Caution,
    )
}

/// The badge's own short word for the same thing.
pub fn signature_label(record: &InstallRecord) -> &'static str {
    if record.signature_verified {
        "verified"
    } else if record.signature.trim().is_empty() {
        "not reported"
    } else {
        "integrity only"
    }
}

/// `94.5 GB free of 137 GB`, or `None` when it was not measured.
///
/// Both halves or neither. "94.5 GB free of 0 B" is the shape a proto3 zero
/// makes of a failed `statvfs`, and it is the most dangerous line this page
/// could print — it reads as a disk with nothing on it.
pub fn free_of_total(fs: &Filesystem) -> Option<String> {
    match (fs.free_bytes, fs.total_bytes) {
        (Some(free), Some(total)) => {
            Some(format!("{} free of {}", fmt::bytes(free), fmt::bytes(total)))
        }
        _ => None,
    }
}

/// How full, 0–100, for the bar. `None` when it was not measured, and `None`
/// on a zero-sized filesystem rather than a division by zero.
///
/// Used is `total - free`, which is arithmetic on two numbers that WERE read
/// and not a third number nobody measured. `saturating_sub` because the
/// reserved blocks are already out of `free`, which can make the two cross.
pub fn fill_percent(fs: &Filesystem) -> Option<u32> {
    let (total, free) = (fs.total_bytes?, fs.free_bytes?);
    if total == 0 {
        return None;
    }
    let used = total.saturating_sub(free);
    Some(((used.saturating_mul(100)) / total).min(100) as u32)
}

/// Why a filesystem has no numbers, in words. Empty is never returned as a
/// reason.
pub fn unmeasured_reason(fs: &Filesystem) -> String {
    if fs.error.trim().is_empty() {
        "Gunnar sent no figures and no reason for their absence.".to_owned()
    } else {
        fs.error.clone()
    }
}

/// ★ **The slow root, and whether gunnar was TOLD or fell back.**
///
/// `slow_root_source` and not "is the string empty": a box whose layout config
/// names `/serialdat` and a box with no config at all can end up writing to
/// the same directory, and they are not the same machine. One is configured;
/// the other is a default that will move the day the default moves.
pub fn slow_root_line(placement: &Placement) -> String {
    let source = placement.slow_root_source.trim();
    let root = placement.slow_root.trim();
    match (root.is_empty(), source.is_empty()) {
        (true, true) => "Gunnar did not report a slow root, and nothing here is standing \
                         in for one."
            .to_owned(),
        (true, false) => format!(
            "No slow root is named. Gunnar writes where it does because: {}",
            sentence(source)
        ),
        (false, true) => format!(
            "Slow root: {root}. Gunnar did not say how it learned that, so this page \
             cannot tell a configured root from a default."
        ),
        (false, false) => format!("Slow root: {root} — {source}"),
    }
}

/// ★ **What `/data` is made of**, from the installer's `data.*` keys and
/// nothing else: `raid0 over /dev/vdb, /dev/vdc`. `None` when nothing said —
/// never filled from `/serialdat`'s device.
pub fn data_on(placement: &Placement) -> Option<String> {
    let shape = placement.data_shape.trim();
    let devices = placement.data_devices.join(", ");
    match (shape.is_empty(), devices.is_empty()) {
        (true, true) => None,
        (false, true) => Some(shape.to_owned()),
        (true, false) => Some(devices),
        (false, false) => Some(format!("{shape} over {devices}")),
    }
}

/// ★ **Where `/serialdat` is**, labelled as `/serialdat`'s: `/dev/vda3 (p3)`.
/// `None` when nothing said. The ONE place `backing_device` becomes text, and
/// the page puts it under "/serialdat on", never under the data root.
pub fn serialdat_on(placement: &Placement) -> Option<String> {
    let dev = placement.backing_device.trim();
    let class = placement.backing_class.trim();
    match (dev.is_empty(), class.is_empty()) {
        (true, true) => None,
        (true, false) => Some(class.to_owned()),
        (false, true) => Some(dev.to_owned()),
        (false, false) => Some(format!("{dev} ({class})")),
    }
}

/// ★ **The rows that need another failure domain, and the ones that do not.**
///
/// Split rather than sorted: a placement row that does not require an
/// off-stripe copy carries no verdict about one, and listing it beside the
/// three that do — under a column whose empty cell reads as "fine" — is how a
/// reader concludes the mirror is somewhere it is not. The required ones come
/// first because they are the reason the table is on the page.
pub fn by_requirement(placement: &Placement) -> (Vec<PlacementRow>, Vec<PlacementRow>) {
    let (required, rest): (Vec<_>, Vec<_>) = placement
        .rows
        .iter()
        .cloned()
        .partition(|row| row.requires_off_stripe);
    (required, rest)
}

/// ★ **How many configured rows gunnar does not actually write to yet.**
///
/// `wired` is gunnar's own field and this page does not second-guess it: a
/// directory that exists is not a feature. The count is what lets the panel
/// say so once at the top instead of leaving a reader to notice it row by row.
pub fn unwired(placement: &Placement) -> usize {
    placement.rows.iter().filter(|row| !row.wired).count()
}

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

    fn filesystem(path: &str) -> Filesystem {
        Filesystem {
            what: "configured directory".to_owned(),
            path: path.to_owned(),
            ..Filesystem::default()
        }
    }

    /// ★ **`same_device` is never good news, and the four states stay four.**
    ///
    /// The assertion the page is FOR. A view that treated the three states as
    /// a scale would draw the worst of them as an almost-good one, and the
    /// person reading this page is often reading it because something already
    /// broke.
    #[test]
    fn a_copy_on_the_same_device_is_graded_worse_than_no_copy_at_all() {
        assert_eq!(OffStripe::read(Some("elsewhere")), OffStripe::Elsewhere);
        assert_eq!(OffStripe::read(Some("same_device")), OffStripe::SameDevice);
        assert_eq!(OffStripe::read(Some("none")), OffStripe::None);
        assert_eq!(OffStripe::read(None), OffStripe::NotSaid);
        // A word from a gunnar newer than this console is not rounded to the
        // nearest word this console knows.
        assert_eq!(OffStripe::read(Some("mirrored")), OffStripe::NotSaid);

        assert_eq!(OffStripe::Elsewhere.grade(), Grade::Good);
        assert_ne!(
            OffStripe::SameDevice.grade(),
            Grade::Good,
            "a copy that dies with its original was drawn as working"
        );
        assert_eq!(OffStripe::SameDevice.grade(), Grade::Alarm);
        // Louder than having nothing, which is the ordering that matters.
        assert_eq!(OffStripe::None.grade(), Grade::Neutral);

        // And the silence is not one of the three.
        assert_eq!(OffStripe::NotSaid.label(), None);
        for state in [OffStripe::Elsewhere, OffStripe::SameDevice, OffStripe::None] {
            assert!(state.label().is_some());
        }
    }

    /// ★ **The two absences are two.**
    ///
    /// A missing record and a gunnar that cannot be asked have nothing in
    /// common: one is a fact about this machine, the other about the server
    /// talking about it, and they are fixed by unrelated things.
    #[test]
    fn a_missing_record_and_an_older_gunnar_are_not_the_same_absence() {
        let no_record = Machine {
            record_path: "/boot/korp/layout.conf".to_owned(),
            record_absent: "no file at that path".to_owned(),
            ..Machine::default()
        };
        let older = Machine {
            unanswered: "this gunnar serves Admin.StorageInfo but sends no `reality`"
                .to_owned(),
            ..Machine::default()
        };

        let a = layer_one(&no_record).expect_err("no record");
        let b = layer_one(&older).expect_err("no answer");
        assert_ne!(a.title(), b.title());
        assert!(a.detail().contains("/boot/korp/layout.conf"));
        assert!(a.detail().contains("no file at that path"));
        assert!(b.detail().contains("older than"));
    }

    /// ★ **The page promises, in words, not to reconstruct layer 1 from layer
    /// 2** — and the promise is asserted, because the whole point is that the
    /// filesystems are right there on the same screen to be tempted by.
    #[test]
    fn an_absent_record_says_the_page_will_not_infer_one() {
        let machine = Machine {
            record_absent: "no file".to_owned(),
            filesystems: vec![filesystem("/data")],
            ..Machine::default()
        };

        let absence = layer_one(&machine).expect_err("no record");
        let detail = absence.detail().to_lowercase();
        assert!(detail.contains("not recoverable from a running machine"));
        assert!(detail.contains("will not guess"));
    }

    /// A record that IS there comes back as itself, keys and all.
    #[test]
    fn a_present_record_is_handed_over_whole() {
        let machine = Machine {
            record: Some(InstallRecord {
                version: 3,
                fields: vec![RecordField {
                    key: "data.raid".to_owned(),
                    value: "raid0".to_owned(),
                }],
                ..InstallRecord::default()
            }),
            ..Machine::default()
        };

        let record = layer_one(&machine).expect("present");
        assert_eq!(record.version, 3);
        assert_eq!(record.fields[0].key, "data.raid");
    }

    /// An older gunnar wins over a record: with no answer there is no record
    /// to have an opinion about.
    #[test]
    fn no_answer_outranks_everything_else_in_the_message() {
        let machine = Machine {
            unanswered: "no such method".to_owned(),
            record: Some(InstallRecord::default()),
            ..Machine::default()
        };

        assert!(matches!(
            layer_one(&machine),
            Err(Absence::NotAnswered { .. })
        ));
    }

    /// ★ **An unsigned record is never drawn as a verified one.**
    ///
    /// `INTEGRITY ONLY` is the state every appliance in the field is in, and
    /// it is a checksum: the body matches itself. Grading it [`Grade::Good`]
    /// would put a green tick on the sentence *nothing else is claimed*.
    #[test]
    fn integrity_only_is_a_caution_and_never_a_verified_signature() {
        let verified = InstallRecord {
            signature: "verified".to_owned(),
            signature_verified: true,
            ..InstallRecord::default()
        };
        let (line, grade) = signature(&verified);
        assert_eq!(grade, Grade::Good);
        assert!(line.contains("verified"));
        assert_eq!(signature_label(&verified), "verified");

        let integrity = InstallRecord {
            signature: "INTEGRITY ONLY".to_owned(),
            signature_verified: false,
            signature_note: "no KORP_ROOT_SIGNING_KEY is enrolled on this estate".to_owned(),
            ..InstallRecord::default()
        };
        let (line, grade) = signature(&integrity);
        assert_ne!(grade, Grade::Good, "a checksum was drawn as a signature");
        assert_eq!(grade, Grade::Caution);
        assert!(line.contains("not a signature"));
        // The installer's own sentence about what is NOT verified is on the
        // page, not swallowed.
        assert!(line.contains("KORP_ROOT_SIGNING_KEY"));
        assert_eq!(signature_label(&integrity), "integrity only");

        // And a gunnar that said nothing at all gets no verdict either way.
        let silent = InstallRecord::default();
        let (line, grade) = signature(&silent);
        assert_eq!(grade, Grade::Neutral);
        assert!(line.contains("did not say"));
        assert_eq!(signature_label(&silent), "not reported");
    }

    /// ★ **Half a measurement is no measurement.**
    ///
    /// The failed-`statvfs` shape: a total with no free, or a free with no
    /// total, must draw nothing at all rather than "0 B" or "94 GB free of
    /// 0 B".
    #[test]
    fn a_half_measured_filesystem_renders_no_figure_and_no_bar() {
        let measured = Filesystem {
            total_bytes: Some(128_000_000_000),
            free_bytes: Some(96_000_000_000),
            ..filesystem("/data")
        };
        assert!(free_of_total(&measured).is_some());
        assert_eq!(fill_percent(&measured), Some(25));

        for half in [
            Filesystem {
                total_bytes: Some(128_000_000_000),
                ..filesystem("/data")
            },
            Filesystem {
                free_bytes: Some(96_000_000_000),
                ..filesystem("/data")
            },
            filesystem("/data"),
        ] {
            assert_eq!(free_of_total(&half), None);
            assert_eq!(fill_percent(&half), None);
        }

        // A zero-sized filesystem is not 100 % full and is not a panic.
        let empty = Filesystem {
            total_bytes: Some(0),
            free_bytes: Some(0),
            ..filesystem("/data")
        };
        assert_eq!(fill_percent(&empty), None);

        // And more free than total — the reserved blocks crossing over — is
        // 0 % rather than an underflow.
        let crossed = Filesystem {
            total_bytes: Some(100),
            free_bytes: Some(120),
            ..filesystem("/data")
        };
        assert_eq!(fill_percent(&crossed), Some(0));
    }

    /// An unmeasured filesystem always has a reason on screen, even when
    /// gunnar sent none.
    #[test]
    fn an_unmeasured_filesystem_always_carries_a_reason() {
        assert!(unmeasured_reason(&filesystem("/data")).contains("no reason"));
        assert_eq!(
            unmeasured_reason(&Filesystem {
                error: "statvfs: os error 5".to_owned(),
                ..filesystem("/data")
            }),
            "statvfs: os error 5"
        );
    }

    /// ★ **A row that does not require an off-stripe copy is not listed as a
    /// row that has one.**
    ///
    /// The column's empty cell reads as "fine", so the rows that make no such
    /// promise are drawn apart from the ones that do rather than mixed in
    /// under it.
    #[test]
    fn the_rows_that_must_be_off_stripe_are_drawn_apart_from_the_ones_that_need_not_be() {
        let placement = Placement {
            rows: vec![
                PlacementRow {
                    what: "packs".to_owned(),
                    requires_off_stripe: false,
                    wired: true,
                    ..PlacementRow::default()
                },
                PlacementRow {
                    what: "metadata mirror".to_owned(),
                    requires_off_stripe: true,
                    off_stripe: Some("same_device".to_owned()),
                    wired: true,
                    ..PlacementRow::default()
                },
                PlacementRow {
                    what: "archives".to_owned(),
                    requires_off_stripe: false,
                    wired: false,
                    note: "no archive-out has run on this box".to_owned(),
                    ..PlacementRow::default()
                },
            ],
            ..Placement::default()
        };

        let (required, rest) = by_requirement(&placement);
        assert_eq!(required.len(), 1);
        assert_eq!(required[0].what, "metadata mirror");
        assert_eq!(
            OffStripe::read(required[0].off_stripe.as_deref()),
            OffStripe::SameDevice
        );
        assert_eq!(rest.len(), 2);
        // And the one gunnar does not write to yet is counted, so the panel
        // can say so once rather than leave it to be noticed.
        assert_eq!(unwired(&placement), 1);
    }

    /// ★ A slow root gunnar was TOLD about and one it fell back to are two
    /// different configurations, and the page says which.
    #[test]
    fn a_configured_slow_root_is_not_a_default_one() {
        let told = slow_root_line(&Placement {
            slow_root: "/serialdat".to_owned(),
            slow_root_source: "named by the layout config".to_owned(),
            ..Placement::default()
        });
        assert!(told.contains("/serialdat"));
        assert!(told.contains("named by the layout config"));

        let fell_back = slow_root_line(&Placement {
            slow_root: "/data/slow".to_owned(),
            slow_root_source: "the documented default under the data root, because there \
                               is no layout config"
                .to_owned(),
            ..Placement::default()
        });
        assert!(fell_back.contains("documented default"));
        assert_ne!(told, fell_back);

        // Nothing said at all is not a configuration either.
        let silent = slow_root_line(&Placement::default());
        assert!(silent.contains("did not report"));

        // A root with no source does not get to pass as a configured one.
        let rootless_source = slow_root_line(&Placement {
            slow_root: "/serialdat".to_owned(),
            ..Placement::default()
        });
        assert!(rootless_source.contains("did not say how it learned"));
    }

    /// ★ **The mislabel, fixed for good** (owner, 2026-10-04): `/data` names
    /// ITS devices, `/serialdat`'s disk is said as `/serialdat`'s — and the
    /// page that draws them puts `backing_device` under a "/serialdat" label
    /// and nowhere else.
    #[test]
    fn data_and_serialdat_each_name_their_own_disk() {
        let p = Placement {
            data_root: "/data".into(),
            data_shape: "raid0".into(),
            data_devices: vec!["/dev/vdb".into(), "/dev/vdc".into()],
            backing_device: "/dev/vda3".into(),
            backing_class: "p3".into(),
            ..Placement::default()
        };
        assert_eq!(data_on(&p).as_deref(), Some("raid0 over /dev/vdb, /dev/vdc"));
        assert!(!data_on(&p).unwrap().contains("vda3"), "the data line names the boot disk");
        assert_eq!(serialdat_on(&p).as_deref(), Some("/dev/vda3 (p3)"));
        assert_eq!(data_on(&Placement::default()), None, "nothing said is not a shape");

        // The view: `backing_device` is drawn ONLY through `serialdat_on`, and
        // that is drawn only under a label that names /serialdat.
        // The CODE of the page — its comments may tell the story of the old
        // labels, the view may not draw them.
        let page = crate::code::of(include_str!("pages/machine.rs"));
        assert!(!page.contains("backing_device"), "the page reads backing_device itself instead of serialdat_on");
        assert!(!page.contains("Backing device") && !page.contains("Backing class"), "the old labels are back");
        let at = page.find("serialdat_on(").expect("the page draws serialdat_on");
        let from = (0..=at.saturating_sub(400)).rev().find(|i| page.is_char_boundary(*i)).unwrap_or(0);
        let before = &page[from..at];
        assert!(before.contains("/serialdat on"), "serialdat_on is drawn under a label that does not name /serialdat");
    }

    /// gunnar's verdict word is drawn as it came, and a word this build does
    /// not know is colourless.
    #[test]
    fn a_glance_state_is_drawn_never_re_decided() {
        assert_eq!(glance_grade("ok"), Grade::Good);
        assert_eq!(glance_grade("warn"), Grade::Caution);
        assert_eq!(glance_grade("bad"), Grade::Alarm);
        for other in ["unknown", "", "OK", "green"] {
            assert_eq!(glance_grade(other), Grade::Neutral, "{other}");
            assert_eq!(glance_label(other), "unknown");
        }
    }

    fn bx(name: &str, role: &str, state: &str, headline: &str) -> TopologyBox {
        TopologyBox { name: name.into(), role: role.into(), state: state.into(), headline: headline.into() }
    }

    /// The picture's words are gunnar's rows, verbatim.
    #[test]
    fn the_picture_is_named_in_gunnars_words() {
        let t = Topology { boxes: vec![bx("vm2", "primary", "up", "answering"), bx("vm3", "twin", "silent", "silent for 4 min")], ..Default::default() };
        assert_eq!(estate_words(&t), "The estate, 2 boxes: vm2 (primary): answering; vm3 (twin): silent for 4 min");
        let none = Topology { unanswered: "this gunnar does not serve Admin.Topology".into(), ..Default::default() };
        assert_eq!(estate_words(&none), "The estate: this gunnar does not serve Admin.Topology");
    }

    /// ★ **Swapped in only when the drawing changed, and never to nothing.**
    #[test]
    fn the_drawing_is_swapped_only_when_it_changed() {
        assert!(!swap("<svg>a</svg>", "<svg>a</svg>"), "an unchanged answer repainted the picture");
        assert!(swap("<svg>a</svg>", "<svg>b</svg>"));
        assert!(swap("", "<svg>a</svg>"), "the first answer is drawn");
        assert!(!swap("<svg>a</svg>", ""), "an answer with no drawing blanked the picture");
    }

    /// ★ **A drawing that could do anything but draw never reaches the page**
    /// (stinky #20). The card puts the drawing in with `inner_html`, so
    /// whatever the answer holds is parsed as markup: a `<script>`, an `on…`
    /// handler, a `javascript:` link, a `<foreignObject>` with HTML in it.
    /// The shared renderer escapes every word it draws today, but this side
    /// must not depend on that: such an answer is never swapped in.
    #[test]
    fn a_drawing_that_could_run_script_is_never_swapped_in() {
        let hostile = [
            r#"<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>"#,
            r#"<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"></svg>"#,
            r#"<svg xmlns="http://www.w3.org/2000/svg"><rect onclick="alert(1)"/></svg>"#,
            r#"<svg xmlns="http://www.w3.org/2000/svg"><a href="javascript:alert(1)"><text>x</text></a></svg>"#,
            r#"<svg xmlns="http://www.w3.org/2000/svg"><foreignObject><img src=x onerror="alert(1)"></foreignObject></svg>"#,
            r#"<svg xmlns="http://www.w3.org/2000/svg"><animate attributeName="href" values="javascript:alert(1)"/></svg>"#,
            r#"<svg xmlns="http://www.w3.org/2000/svg"><rect fill="url(https://elsewhere.example/x#y)"/></svg>"#,
            r#"<svg xmlns="http://www.w3.org/2000/svg"><text>&#106;avascript</text><style>*{}</style></svg>"#,
            r#"<svg xmlns="http://www.w3.org/2000/svg"><!-- --><text>x</text></svg>"#,
            r#"<svg xmlns="http://www.w3.org/2000/svg"><text><![CDATA[<script>alert(1)</script>]]></text></svg>"#,
            r#"<svg xmlns="http://www.w3.org/2000/svg"><text x=1>unquoted</text></svg>"#,
            r#"<svg xmlns="http://www.w3.org/2000/svg"><text>never closed</svg>"#,
            r#"<img src=x onerror="alert(1)">"#,
            r#"<svg xmlns="http://www.w3.org/2000/svg"></svg><script>alert(1)</script>"#,
        ];
        for answer in hostile {
            assert!(!swap("", answer), "drawn: {answer}");
            assert!(!swap("<svg>a</svg>", answer), "swapped in: {answer}");
        }
    }

    /// ★ **…and every drawing the suite's one renderer makes is drawn** — the
    /// live board and the still one, both themes, with somebody else's words
    /// on the chips (a box named `<script>`, a headline with quotes and `&`).
    #[test]
    fn every_drawing_the_shared_renderer_makes_is_swapped_in() {
        use nordisk_estate_svg::{estate_svg, estate_svg_themed, Disk, Estate, Liveness, Machine, Role, Theme};
        let chip = |id: &str, role: Role, state: &str, headline: &str| Machine {
            id: id.to_owned(),
            role,
            title: "this box · gunnar-server · 4 vCPU 16 GB".to_owned(),
            cloud: String::new(),
            server_id: "10.13.8.101".to_owned(),
            plan: None,
            declared_vcpu: None,
            declared_ram_mb: None,
            measured_cpu_millicores: None,
            measured_ram_bytes: None,
            disk: Some(Disk { used_bytes: 3 << 30, total_bytes: 40 << 30, growable: false }),
            liveness: Liveness::from_word(state),
            note: "said \"it's <b>gone</b>\" & left".to_owned(),
            headline: headline.to_owned(),
        };
        let machines = vec![
            chip("vm1", Role::Appliance, "up", "following"),
            chip("<script>alert(1)</script>", Role::Twin, "behind", "behind 120 entries"),
            chip("vm3\" onload=\"alert(1)", Role::Standby, "silent", "silent for 4 min"),
            chip("vm4", Role::Console, "unknown", ""),
        ];
        for live in [true, false] {
            for disk_only in [true, false] {
                let estate = Estate {
                    product: "Gunnar & <co>".to_owned(),
                    machines: machines.clone(),
                    subtitle: Some("4 boxes \"as laid\"".to_owned()),
                    disk_only,
                    live,
                    ..Estate::default()
                };
                for drawing in [estate_svg(&estate), estate_svg_themed(&estate, &Theme::MONOCHROME)] {
                    assert!(swap("", &drawing), "the renderer's own drawing was refused (live={live}): {drawing}");
                }
            }
        }
        let nothing = Estate { product: "Gunnar".to_owned(), subtitle: Some("this Gunnar does not serve Admin.Topology".to_owned()), live: true, disk_only: true, ..Estate::default() };
        assert!(swap("", &estate_svg(&nothing)), "the board for an unanswered gunnar was refused");
    }

    /// ★ **The card is live and thin** (owner, 2026-10-04: "make it svg
    /// dynamic"): it asks again on its own while the page is visible, draws the
    /// SVG INLINE (no <img> to blank between fetches), and swaps only through
    /// `swap`. Held to the page's source.
    #[test]
    fn the_machine_card_polls_inline_and_swaps_only_on_change() {
        let page = crate::code::of(include_str!("pages/overview.rs"));
        let at = page.find("fn MachineCard").expect("the Machine card");
        let card = &page[at..at + page[at..].find("\n}\n").expect("its end")];
        for must in ["MACHINE_CARD_POLL_MS", ".hidden()", "inner_html", "machine::swap(", "machine::estate_words("] {
            assert!(card.contains(must), "the Machine card lacks `{must}`");
        }
        assert!(!card.contains("<img"), "the card still draws the picture as an <img>");
    }
}