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", &[]))
}
}