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

nordisk/monetize

crates/monetize-server/src/estate_svg.rs

//! **The estate, drawn — monetize's half**: what THIS server knows about the
//! board (its `[machines]`, its clouds) and what its product plugin answered
//! (`Measured`), put in front of the ONE renderer.
//!
//! The renderer — [`estate_svg`] and every type it draws from — lives in the
//! `nordisk-estate-svg` crate since 2026-10-04 (owner: one shared drawing for
//! monetize's console AND gunnar's, whose Machine card draws `Admin.Topology`
//! with it). It is re-exported here unchanged, so
//! `monetize_server::estate_svg::{estate_svg, Estate, Machine, …}` still names
//! it, and nothing re-draws the board on the far side of `EstateAdmin.Draw`.
//!
//! This module keeps only what reads monetize's own state: [`Measured`] (the
//! plugin's half) and [`roll_call`] / [`from_config`] (the config's half).
//! **It is a rendering's input, not an authority**: nothing here measures, and
//! a number nobody measured stays absent rather than guessed.

pub use nordisk_estate_svg::{estate_svg, CloudRow, Disk, Estate, Liveness, Machine, Role};

/// **What the live boxes answered**, handed in rather than fetched.
///
/// Keyed by machine id. A machine with no entry is drawn `Unknown` — nobody
/// asked — which is a different fact from `Silent`, and this module refuses to
/// collapse the two. See [`Liveness`].
#[derive(Clone, Debug, Default)]
pub struct Measured {
    /// id → what was read off that box, this minute.
    pub seen: std::collections::BTreeMap<String, (Liveness, Option<Disk>)>,
    /// How many tenants the product holds, when the caller knows.
    pub tenants: Option<u64>,
    /// **The appliance's measured cpu ceiling**, in millicores, when the
    /// product reported one. Not per machine: `Admin.Capacity` answers for the
    /// box it is, which is the appliance.
    pub appliance_cpu_millicores: Option<u64>,
    /// The same, for memory, in bytes.
    pub appliance_ram_bytes: Option<u64>,
}

impl Measured {
    /// **Ask a product's plugin what its boxes look like right now.**
    ///
    /// This is the bridge, and the direction matters: the plugin is monetize's
    /// CLIENT into the product, so it is the one thing in this process that can
    /// read a live box. The config says what the estate IS; the plugin says how
    /// it is doing; [`from_config`] puts the two together and neither
    /// one gets to invent the other's half.
    ///
    /// Role-based, because the plugin does not know machine ids and must not
    /// have to:
    ///
    /// * the **appliance** takes `Product::servable()`, the store's own
    ///   measurement;
    /// * a **twin** and the **front** are asked nothing: a box's report carries no
    ///   row for either, so they stay `Unknown` — which is the true answer and
    ///   not a placeholder.
    ///
    /// **A plugin that refuses is a dash, never a zero.** holger's plugin reads
    /// what a repository HOLDS (2026-09-18) and still answers
    /// `Servable::Unmeasured` by name, because holger has no capacity RPC and
    /// nothing in its server has ever measured a filesystem. That honesty has to
    /// survive the trip: a `0` here would draw an empty disk on a box nobody has
    /// measured, which is the one thing a capacity picture must never say — and
    /// a plugin that CAN answer some questions and not this one is the case that
    /// makes the distinction easy to lose.
    pub fn from_product(
        product: monetize_product::Served<'_>,
        machines: &[(String, Role)],
    ) -> Measured {
        use monetize_product::Servable;

        let mut measured_cpu = None;
        let mut measured_ram = None;
        let appliance_disk = match product.servable() {
            Servable::Measured { servable_bytes, total_bytes, growth, ceilings } => {
                measured_cpu = ceilings.get("cpu_millicores").copied();
                measured_ram = ceilings.get("ram_bytes").copied();
                // `servable_bytes` is what is LEFT; the picture wants used of
                // total, so used is the difference. A product that measures
                // what is left but not what there is in total reports
                // `total_bytes = 0`, and that is not a disk — it is a dash.
                // **`Growth::Unknown` is not a yes.** Only the two arms that
                // say the set can actually gain capacity count as growable;
                // `Sealed`, `OperatorOnly` and `Unknown` all draw "cannot
                // grow", because a picture that promised growth on a box that
                // cannot have it is how a disk gets bought for a machine where
                // a disk is billed and invisible.
                let growable = matches!(
                    growth,
                    monetize_product::Growth::AtRestart(_) | monetize_product::Growth::AtRuntime(_)
                );
                (total_bytes > 0).then(|| Disk {
                    used_bytes: total_bytes.saturating_sub(servable_bytes),
                    total_bytes,
                    growable,
                })
            }
            Servable::Unmeasured(_) => None,
        };

        let mut seen = std::collections::BTreeMap::new();
        for (id, role) in machines {
            let answer = match role {
                Role::Appliance => match appliance_disk {
                    Some(d) => (Liveness::Up, Some(d)),
                    // The store did not answer. The box may be perfectly well;
                    // what is unknown is its capacity, not its existence.
                    None => (Liveness::Unknown, None),
                },
                // A box's report carries no twin rows (the appliance's twin lanes went
                // with its grow drive, 2026-11-02): a twin is drawn from the config.
                // The plugin has no door onto the front, and the roles only
                // gunnar's topology draws are never in monetize's roll call.
                Role::Twin | Role::Front | Role::Standby | Role::Console | Role::Monetize | Role::Upstream => (Liveness::Unknown, None),
            };
            seen.insert(id.clone(), answer);
        }
        Measured { seen, tenants: None, appliance_cpu_millicores: measured_cpu, appliance_ram_bytes: measured_ram }
    }
}

/// The machines this config names for a product, as
/// [`Measured::from_product`] wants them: id and role, in the order the
/// board lays them out.
pub fn roll_call(cfg: &crate::config::Config, product: &str) -> Vec<(String, Role)> {
    let mut v: Vec<(String, Role)> = cfg
        .machines
        .iter()
        .filter(|(_, m)| m.product.is_empty() || m.product == product)
        .map(|(id, m)| (id.clone(), role_of(&m.role)))
        .collect();
    v.sort_by(|a, b| order(a.1).cmp(&order(b.1)).then_with(|| a.0.cmp(&b.0)));
    v
}

/// One machine of a site as its fleet report says: its role, when its newest
/// report landed (0: never), and the `serves_at` it reported.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Reported {
    pub role: String,
    pub heard_unix_ms: u64,
    pub serves_at: String,
}

/// **A site's board from its live fleet reports** (owner 2026-10-12: the ONE
/// box declares no `[machines]`, so test2's board drew no chip and the console
/// hid it). One chip per machine that has reported in — front, main, twin, in
/// that order — up when heard within `silent_after_ms`, silent otherwise. Its
/// host is the one it reported; nothing else is invented.
pub fn from_fleet(product: &str, boxes: &[Reported], now_unix_ms: u64, silent_after_ms: u64) -> Estate {
    let mut machines: Vec<Machine> = boxes
        .iter()
        .filter(|b| b.heard_unix_ms > 0)
        .map(|b| Machine {
            id: b.role.clone(),
            role: role_of(&b.role),
            title: String::new(),
            cloud: String::new(),
            server_id: b.serves_at.clone(),
            plan: None,
            declared_vcpu: None,
            declared_ram_mb: None,
            measured_cpu_millicores: None,
            measured_ram_bytes: None,
            disk: None,
            liveness: if now_unix_ms.saturating_sub(b.heard_unix_ms) <= silent_after_ms { Liveness::Up } else { Liveness::Silent },
            note: String::new(),
            headline: String::new(),
        })
        .collect();
    machines.sort_by(|a, b| order(a.role).cmp(&order(b.role)).then_with(|| a.id.cmp(&b.id)));
    Estate { product: product.to_owned(), machines, ..Estate::default() }
}

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

    fn r(role: &str, heard: u64) -> Reported {
        Reported { role: role.into(), heard_unix_ms: heard, serves_at: format!("{role}.test2.gunnar.rs:443") }
    }

    /// ★ test2's front, main and twin, drawn from what reported in: three chips,
    /// in estate order, the silent one silent; a box never heard is no chip.
    #[test]
    fn a_sites_board_is_its_reported_machines() {
        let e = from_fleet("gunnar", &[r("twin", 1_000), r("main", 1_000), r("front", 500), r("spare", 0)], 1_000, 400);
        assert_eq!(e.machines.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(), ["front", "main", "twin"]);
        assert_eq!(e.machines.iter().map(|m| m.liveness).collect::<Vec<_>>(), [Liveness::Silent, Liveness::Up, Liveness::Up]);
        assert_eq!(e.machines[1].server_id, "main.test2.gunnar.rs:443");
        let svg = estate_svg(&e);
        assert!(svg.starts_with("<svg") && svg.contains("main.test2.gunnar.rs:443"), "the board carries the machines: {svg}");
    }
}

/// **Build the picture's input from the config and whatever was measured.**
///
/// The config supplies the topology and every DECLARED number; [`Measured`]
/// supplies every READ one. Nothing here does I/O, and nothing here invents
/// a number: a machine nobody measured keeps its declared plan and gets no
/// disk, which the drawing renders as `— · not measured`.
///
/// `product` selects one estate out of a monetize that serves several; a
/// machine whose `product` is empty belongs to whichever one is asked for,
/// because the single-product deployment should not have to write the same
/// word on every row.
///
/// Machines are ordered front → appliance → twin and then by id, so the
/// board is laid out by what a box is FOR rather than by what somebody
/// happened to name it. The order is total, so the output stays
/// deterministic.
pub fn from_config(cfg: &crate::config::Config, product: &str, live: &Measured) -> Estate {
    let mut machines: Vec<Machine> = cfg
        .machines
        .iter()
        .filter(|(_, m)| m.product.is_empty() || m.product == product)
        .map(|(id, m)| {
            let (liveness, disk) = live
                .seen
                .get(id)
                .cloned()
                .unwrap_or((Liveness::Unknown, None));
            Machine {
                id: id.clone(),
                role: role_of(&m.role),
                title: m.title.clone(),
                cloud: m.cloud.clone(),
                server_id: m.server.clone(),
                plan: (!m.plan.is_empty()).then(|| m.plan.clone()),
                declared_vcpu: (m.vcpu > 0).then_some(m.vcpu),
                declared_ram_mb: (m.ram_mb > 0).then_some(m.ram_mb),
                // The measured pair belongs to the box `Admin.Capacity`
                // answers for, which is the appliance. Another role keeps
                // its declared plan, because nothing read that box.
                measured_cpu_millicores: (role_of(&m.role) == Role::Appliance)
                    .then_some(live.appliance_cpu_millicores)
                    .flatten(),
                measured_ram_bytes: (role_of(&m.role) == Role::Appliance)
                    .then_some(live.appliance_ram_bytes)
                    .flatten(),
                // **The config's `grow` is a claim; the measurement wins the
                // bytes but not this flag.** Whether a box CAN grow is a
                // property of its boot stack and its provider, which no
                // reading of free space reveals.
                disk: disk.map(|d| Disk { growable: m.grow, ..d }),
                liveness,
                note: m.note.clone(),
                headline: String::new(),
            }
        })
        .collect();
    machines.sort_by(|a, b| order(a.role).cmp(&order(b.role)).then_with(|| a.id.cmp(&b.id)));

    let clouds = cfg
        .clouds
        .iter()
        .filter(|(_, c)| c.enabled)
        .map(|(name, c)| CloudRow {
            name: name.clone(),
            // A cloud with no ceiling is the box's own iron: `CloudConfig`
            // requires a cap of everything that can spend, so the absence
            // IS the answer and no second field has to agree with it.
            spends: c.cap.is_some(),
            is_default: cfg.default_cloud.as_deref() == Some(name.as_str()),
        })
        .collect();

    Estate { product: product.to_owned(), machines, clouds, tenants: live.tenants, ..Estate::default() }
}

/// An unknown role word is drawn as an appliance rather than refused here: this
/// is a renderer, and a picture that panics is worse than a picture with a box
/// in the wrong column. The refusal belongs at config load, where an operator
/// is still reading error messages.
fn role_of(word: &str) -> Role {
    match word {
        "front" => Role::Front,
        "twin" => Role::Twin,
        _ => Role::Appliance,
    }
}

fn order(r: Role) -> u8 {
    match r {
        Role::Front => 0,
        Role::Appliance => 1,
        Role::Twin => 2,
        // Roles gunnar's topology draws and monetize's config never names
        // (`role_of` cannot produce them); ordered after, for totality.
        Role::Standby => 3,
        Role::Console => 4,
        Role::Monetize => 5,
        Role::Upstream => 6,
    }
}

#[cfg(test)]
mod tests {
    //! The config's half and the plugin's half. The renderer's own rules are
    //! asserted beside it, in `nordisk-estate-svg`.
    use super::*;

    /// **`Growth::Unknown` is not a yes.** A product that could not tell draws
    /// "cannot grow", because a picture that promised growth on a sealed box is
    /// how a disk gets bought for a machine where a disk is billed and
    /// invisible.
    #[test]
    fn only_a_real_growth_answer_draws_as_growable() {
        use monetize_product::{Growth, Servable};
        for (growth, want) in [
            (Growth::Unknown("no answer".into()), false),
            (Growth::Sealed("fresh install only".into()), false),
            (Growth::OperatorOnly("by hand".into()), false),
            (Growth::AtRestart("enlarge the members".into()), true),
            (Growth::AtRuntime("drains and grows".into()), true),
        ] {
            let p = one_box(Servable::Measured {
                servable_bytes: 10 << 30,
                total_bytes: 40 << 30,
                growth,
                ceilings: Default::default(),
            });
            let roll = vec![("box".to_owned(), Role::Appliance)];
            let m = Measured::from_product(monetize_product::Served::new(p.facts().leak(), &p), &roll);
            let (_, disk) = m.seen.get("box").expect("the box was asked");
            assert_eq!(disk.expect("a disk").growable, want);
        }
    }

    /// **A plugin that refuses is a dash, never a zero.** holger's plugin
    /// answers `Unmeasured` by name — holger has no capacity RPC — even though
    /// it can read every repository's size. A `0` here would draw an empty disk
    /// on a box nobody measured, which is the one thing a capacity picture must
    /// never say.
    #[test]
    fn an_unwired_plugin_leaves_the_box_unmeasured() {
        use monetize_product::Servable;
        let p = one_box(Servable::Unmeasured("this plugin is not wired".into()));
        let roll = vec![("box".to_owned(), Role::Appliance)];
        let m = Measured::from_product(monetize_product::Served::new(p.facts().leak(), &p), &roll);
        let (live, disk) = m.seen.get("box").expect("the box was asked");
        assert_eq!(*live, Liveness::Unknown, "an unmeasured box was called live");
        assert!(disk.is_none(), "a plugin that said nothing produced a disk");
        assert!(m.appliance_cpu_millicores.is_none() && m.appliance_ram_bytes.is_none());
    }

    /// The config's topology and the plugin's readings meet here, and neither
    /// invents the other's half: the role order is the board's, and a machine
    /// nobody asked about is `Unknown` rather than absent.
    #[test]
    fn from_config_lays_the_board_out_by_role() {
        let toml = r#"
default_cloud = "upcloud"
[clouds.upcloud]
enabled = true
cap = { total_minor = 2500, currency = "Eur" }
[clouds.server]
enabled = true
pool_gib = 100
[machines.zeta-twin]
role = "twin"
cloud = "upcloud"
server = "0000-twin"
[machines.alpha-front]
role = "front"
cloud = "upcloud"
server = "0000-front"
plan = "2xCPU-4GB"
vcpu = 2
ram_mb = 4096
[machines.mid-appliance]
role = "appliance"
cloud = "upcloud"
server = "0000-appliance"
"#;
        let cfg = crate::config::Config::from_toml(toml).expect("a config");
        let roll = roll_call(&cfg, "gunnar");
        assert_eq!(
            roll.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(),
            ["alpha-front", "mid-appliance", "zeta-twin"],
            "the board is laid out by name instead of by what a box is for"
        );

        let e = from_config(&cfg, "gunnar", &Measured::default());
        assert_eq!(e.machines.len(), 3);
        assert!(e.machines.iter().all(|m| m.liveness == Liveness::Unknown), "nobody asked, yet somebody answered");
        // The clouds come through with `spends` decided by the presence of a
        // ceiling, which `CloudConfig` already requires of anything that can.
        let up = e.clouds.iter().find(|c| c.name == "upcloud").expect("upcloud");
        assert!(up.spends && up.is_default);
        let own = e.clouds.iter().find(|c| c.name == "server").expect("server");
        assert!(!own.spends && !own.is_default, "the box's own iron was drawn as a shop");
    }

    /// A main that reports one thing, what it can serve, so the growth and the unmeasured
    /// rows above do not need a whole appliance.
    fn one_box(servable: monetize_product::Servable) -> monetize_product::pulled::Pulled {
        let main = monetize_product::pulled::HeldMain::new("onebox.rs");
        main.report(|r| r.servable = Some(servable));
        main.serve(monetize_product::ProductFacts::new("onebox", &[]))
    }
}