crates/gunnar-ui-server/src/api/topology.rs
//! **The estate's boxes, as gunnar knows them** — `Admin.Topology`, carried and
//! drawn (owner, 2026-10-04).
//!
//! The Overview's Machine card is a picture of the estate: this box (primary
//! or twin), its twins and standbys with their live state, the console(s)
//! calling it, the monetize it registers with. A solo gunnar is one box; a
//! front and a server two; with a twin three.
//!
//! # The console decides nothing here
//!
//! The owner's rule for this console is that it is a THIN layer: it calls
//! gunnar and renders. Which boxes exist, what each runs and whether it is up
//! are decided by gunnar (`gunnar_grpc::topology`), each row with the sentence
//! that says how gunnar knows. This module carries the rows as they came
//! ([`topology`]) and hands them, mapped field for field, to the ONE estate
//! renderer the suite has — `nordisk-estate-svg`, the same board monetize's
//! console draws with `EstateAdmin.Draw` (the answer's `svg`). A role or state
//! word this build does not know is drawn as `unknown`, never as the nearest
//! word it does know.
//!
//! # An ADMIN read
//!
//! Both routes sit under `/api/machine`, which `role_floor` already holds at
//! admin (`ADMIN_READS`): the rows name the estate's addresses and principals.
//! gunnar itself requires a server-wide Read for the call.
use axum::extract::State;
use axum::Json;
use nordisk_estate_svg::{estate_svg, Disk, Estate, Liveness, Machine, Role};
use serde::Serialize;
use crate::api::{ApiError, ApiResult};
use crate::app::AppState;
use crate::pb;
/// `GET /api/machine/topology`, as gunnar answered it.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct Topology {
/// Empty when gunnar answered; its words (or this console's, when it sent
/// none) for a gunnar that does not serve `Admin.Topology`.
pub unanswered: String,
/// The estate's name from the pairing feed; empty on an unpaired gunnar.
pub estate: String,
pub server_unix_ms: i64,
pub silent_after_ms: u64,
/// This box first, then the others — gunnar's order.
pub boxes: Vec<TopologyBox>,
/// gunnar holds the installer's estate record: the boxes are exactly the
/// laid ones. False on a solo gunnar or a box installed before it.
pub recorded: bool,
pub variant: String,
/// `<repository> <commit>`, as gunnar sent them.
pub versions: Vec<String>,
/// **The picture, in the same answer** — these rows drawn by the shared
/// renderer as a LIVE board, so a console polling this one route swaps
/// the drawing in place exactly when it changed (owner, 2026-10-04: "make
/// it svg dynamic"). Empty when gunnar did not answer.
pub svg: String,
}
/// One box, every field as gunnar sent it.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct TopologyBox {
pub name: String,
pub role: String,
pub address: String,
pub state: String,
pub why: String,
pub services: Vec<String>,
pub this_box: bool,
/// `None` when gunnar has no evidence with a time on it.
pub last_seen_unix_ms: Option<i64>,
pub seen_ago_ms: u64,
/// `None` when the box's volume was not measured — never two zeroes.
pub disk_total_bytes: Option<u64>,
pub disk_used_bytes: Option<u64>,
pub sources: Vec<String>,
// What the installer laid (empty without a record), as gunnar sent it.
pub laid_as: String,
pub public_address: String,
pub utility_address: String,
pub hostname: String,
pub title: String,
pub provider_size: String,
/// Declared, not measured; `None` when not declared.
pub declared_vcpu: Option<u32>,
pub declared_ram_mb: Option<u64>,
pub declared_system_disk_gb: Option<u64>,
pub declared_data: String,
/// gunnar's few words for the state (`silent for 4 min`), as sent.
pub headline: String,
}
/// gunnar's message → the console's JSON, with the live drawing of exactly
/// these rows. Carried, never re-decided.
pub fn render(r: pb::TopologyResponse) -> Topology {
let mut t = rows(r);
t.svg = estate_svg(&board(&t));
t
}
fn rows(r: pb::TopologyResponse) -> Topology {
Topology {
unanswered: String::new(),
estate: r.estate,
server_unix_ms: r.server_unix_ms,
silent_after_ms: r.silent_after_ms,
recorded: r.recorded,
variant: r.variant,
versions: r.versions,
svg: String::new(),
boxes: r
.boxes
.into_iter()
.map(|b| TopologyBox {
name: b.name,
role: b.role,
address: b.address,
state: b.state,
why: b.why,
services: b.services,
this_box: b.this_box,
last_seen_unix_ms: (b.last_seen_unix_ms > 0).then_some(b.last_seen_unix_ms),
seen_ago_ms: b.seen_ago_ms,
disk_total_bytes: b.disk_measured.then_some(b.disk_total_bytes),
disk_used_bytes: b.disk_measured.then_some(b.disk_used_bytes),
sources: b.sources,
laid_as: b.laid_as,
public_address: b.public_address,
utility_address: b.utility_address,
hostname: b.hostname,
title: b.title,
provider_size: b.provider_size,
declared_vcpu: (b.declared_vcpu > 0).then_some(b.declared_vcpu),
declared_ram_mb: (b.declared_ram_mb > 0).then_some(b.declared_ram_mb),
declared_system_disk_gb: (b.declared_system_disk_gb > 0).then_some(b.declared_system_disk_gb),
declared_data: b.declared_data,
headline: b.headline,
})
.collect(),
}
}
/// The words for a gunnar that does not serve the call (an older build).
fn unserved_words(message: &str) -> String {
if message.trim().is_empty() {
"this Gunnar does not serve Admin.Topology (it is older than the estate picture)".to_owned()
} else {
message.to_owned()
}
}
/// Ask gunnar. `Unimplemented` is a state of the card, not a failure of it.
async fn ask(state: &AppState) -> Result<Topology, ApiError> {
match state.gunnar.admin().topology(pb::TopologyRequest {}).await {
Ok(r) => Ok(render(r.into_inner())),
Err(status) if status.code() == tonic::Code::Unimplemented => {
let mut t = Topology { unanswered: unserved_words(status.message()), ..Topology::default() };
t.svg = estate_svg(&board(&t));
Ok(t)
}
Err(status) => Err(ApiError::from(status)),
}
}
/// `GET /api/machine/topology` — the rows, as gunnar sent them.
pub async fn topology(State(state): State<AppState>) -> ApiResult<Topology> {
Ok(Json(ask(&state).await?))
}
/// **The rows, mapped onto the renderer's board — field for field.**
///
/// * name → the chip's name; services → its title (`gunnar`, `console ·
/// monetize`), "this box" marked;
/// * role and state → the renderer's own words, through `from_gunnar`, which
/// never rounds an unknown word to a known one;
/// * address → the mono slot; the measured disk → the disk slot and bar;
/// * why → the chip's sentence.
///
/// `disk_only`: a topology carries no plan and no growth verdict, and the
/// board draws nothing for what nobody was asked.
pub fn board(t: &Topology) -> Estate {
let machines = t
.boxes
.iter()
.map(|b| {
let known = Role::from_gunnar(&b.role);
let mut title = b.services.join(" · ");
if known.is_none() && !b.role.is_empty() {
// A role word this build has no column for: drawn as a box of
// its own, with gunnar's word on it rather than a guessed one.
title = if title.is_empty() { b.role.clone() } else { format!("{} · {title}", b.role) };
}
if b.this_box {
title = if title.is_empty() { "this box".to_owned() } else { format!("this box · {title}") };
}
// What the installer declared about the box, in words — the
// estate's own, never a reading.
if let (Some(cpu), Some(ram)) = (b.declared_vcpu, b.declared_ram_mb) {
title = format!("{title} · {cpu} vCPU {} GB", ram / 1024);
}
Machine {
id: b.name.clone(),
role: known.unwrap_or(Role::Console),
title,
cloud: String::new(),
// The address the installer laid it at, else gunnar's evidence.
server_id: if b.utility_address.is_empty() { b.address.clone() } else { b.utility_address.clone() },
plan: None,
declared_vcpu: None,
declared_ram_mb: None,
measured_cpu_millicores: None,
measured_ram_bytes: None,
disk: match (b.disk_total_bytes, b.disk_used_bytes) {
(Some(total), Some(used)) if total > 0 => Some(Disk { used_bytes: used, total_bytes: total, growable: false }),
_ => None,
},
liveness: Liveness::from_gunnar(&b.state),
// gunnar's sentence (with its seconds) stays in the rows; the
// live board draws its few words, so the drawing changes when
// the STATE does and a polling card swaps it then.
note: String::new(),
headline: b.headline.clone(),
}
})
.collect();
let n = t.boxes.len();
let subtitle = if !t.unanswered.is_empty() {
t.unanswered.clone()
} else if t.recorded {
format!("{n} box{} as the installer laid them{}, each with its live state", if n == 1 { "" } else { "es" }, if t.variant.is_empty() { String::new() } else { format!(" ({})", t.variant) })
} else {
format!("{n} box{} this Gunnar has evidence of, each with how it knows", if n == 1 { "" } else { "es" })
};
Estate {
// The board's title, `<product> estate`: an unnamed estate is "Gunnar estate".
product: if t.estate.is_empty() { "Gunnar".to_owned() } else { t.estate.clone() },
machines,
subtitle: Some(subtitle),
disk_only: true,
live: true,
..Estate::default()
}
}
#[cfg(test)]
mod tests {
/// ★ UW-19: **the estate picture rides `/api/machine/topology`** (its `svg`, which the
/// Overview's Machine card draws inline); no second `.svg` route nobody calls.
#[test]
fn the_picture_has_one_route_and_it_is_the_topology_read() {
let serve = include_str!("../serve.rs");
assert!(serve.contains("\"/api/machine/topology\""), "the topology read is routed");
assert!(!serve.contains("topology.svg"), "a second picture route nobody calls");
}
use super::*;
fn row(name: &str, role: &str, state: &str, this_box: bool) -> pb::TopologyBox {
pb::TopologyBox {
name: name.into(),
role: role.into(),
state: state.into(),
this_box,
services: vec!["gunnar".into()],
why: format!("{name} is {state} (test)"),
headline: format!("{state} (gunnar)"),
..Default::default()
}
}
/// ★ **The rows travel as gunnar sent them**: no box added, none dropped,
/// none re-ordered, no state re-decided — and an unmeasured disk carries no
/// numbers rather than two zeroes.
#[test]
fn the_rows_are_carried_and_nothing_is_decided_here() {
let mut twin = row("vm3", "twin", "behind", false);
twin.disk_measured = true;
twin.disk_total_bytes = 20 << 30;
twin.disk_used_bytes = 1 << 30;
let t = render(pb::TopologyResponse {
boxes: vec![row("vm2", "primary", "up", true), twin, row("gunnar-ui", "console", "silent", false)],
server_unix_ms: 7,
silent_after_ms: 60_000,
estate: "test2.gunnar.rs".into(),
..Default::default()
});
let shape: Vec<_> = t.boxes.iter().map(|b| (b.name.as_str(), b.role.as_str(), b.state.as_str())).collect();
assert_eq!(shape, [("vm2", "primary", "up"), ("vm3", "twin", "behind"), ("gunnar-ui", "console", "silent")]);
assert_eq!((t.boxes[1].disk_total_bytes, t.boxes[1].disk_used_bytes), (Some(20 << 30), Some(1 << 30)));
assert_eq!((t.boxes[0].disk_total_bytes, t.boxes[0].last_seen_unix_ms), (None, None), "not measured is not 0");
}
/// ★ **The picture is the shared renderer's, drawn from exactly these
/// rows**: one chip per box, gunnar's state word on each lamp, the
/// sentence on the chip — and no plan, growth or shop words, which this
/// open console never says.
#[test]
fn the_board_has_one_chip_per_box_with_gunnars_words() {
let t = render(pb::TopologyResponse {
boxes: vec![row("vm2", "primary", "up", true), row("vm3", "twin", "behind", false), row("gunnar-ui", "console", "up", false)],
..Default::default()
});
let svg = estate_svg(&board(&t));
for name in ["vm2", "vm3", "gunnar-ui"] {
assert!(svg.contains(&format!(">{name}<")), "{name} is not on the board");
}
assert!(svg.contains(">behind<") && svg.contains(">up<"));
assert!(svg.contains("this box · gunnar"), "this box is marked");
assert!(svg.contains(">behind (gunnar)<"), "gunnar's words are drawn");
assert!(!svg.contains("vm3 is behind (test)"), "gunnar's per-poll sentence stays in the rows");
assert!(svg.contains("3 boxes this Gunnar has evidence of"));
for word in ["plan", "buyer", "grow", "iron comes from"] {
assert!(!svg.contains(word), "`{word}` on the open console's board");
}
}
/// A role or state word this build does not know is drawn as gunnar's
/// own word and as `not asked` — never as the nearest known one.
#[test]
fn an_unknown_word_is_not_rounded_to_a_known_one() {
let t = render(pb::TopologyResponse { boxes: vec![row("x", "satellite", "flickering", false)], ..Default::default() });
let b = board(&t);
assert_eq!(b.machines[0].liveness, Liveness::Unknown);
assert!(b.machines[0].title.starts_with("satellite"), "{}", b.machines[0].title);
}
/// A gunnar older than the call is a sentence on an empty board, not an
/// error and not a made-up box.
#[test]
fn an_older_gunnar_is_named_not_drawn() {
let t = Topology { unanswered: unserved_words(""), ..Topology::default() };
let b = board(&t);
assert!(b.machines.is_empty());
assert!(b.subtitle.as_deref().is_some_and(|s| s.contains("does not serve Admin.Topology")));
}
/// ★ **The installer's record travels and is drawn**: the laid facts reach
/// the JSON verbatim (undeclared is `None`, never 0), the chip names its
/// declared size and its laid address, and the board says it is the
/// installer's picture.
#[test]
fn the_installers_record_is_carried_and_drawn() {
let mut main = row("main", "primary", "up", true);
main.laid_as = "main".into();
main.utility_address = "10.13.8.101".into();
main.address = "10.99.0.2".into();
main.declared_vcpu = 4;
main.declared_ram_mb = 16384;
main.declared_data = "4 × 10 GB maxiops, md-raid0".into();
let t = render(pb::TopologyResponse {
boxes: vec![main, row("front", "front", "unknown", false)],
recorded: true,
variant: "gunnar-twin-one".into(),
versions: vec!["gunnar abc".into()],
..Default::default()
});
assert!(t.recorded && t.versions == ["gunnar abc"]);
assert_eq!((t.boxes[0].declared_vcpu, t.boxes[0].declared_ram_mb), (Some(4), Some(16384)));
assert_eq!((t.boxes[1].declared_vcpu, t.boxes[1].declared_system_disk_gb), (None, None), "undeclared is None, not 0");
let svg = estate_svg(&board(&t));
assert!(svg.contains("4 vCPU 16 GB"), "the declared size is on the chip");
assert!(svg.contains(">10.13.8.101<"), "the laid utility address is the chip's address");
assert!(svg.contains("as the installer laid them (gunnar-twin-one)"));
}
/// ★ **The picture is LIVE and decided by gunnar** (owner, 2026-10-04: "the
/// picture needs to be a bit dynamic, if twin dies" / "make it svg
/// dynamic"): the route's answer carries the drawing; each chip is a named
/// group with gunnar's state word, the silent twin is drawn with gunnar's
/// own words ("silent for 4 min") and a still red link; and the drawing
/// carries no per-poll prose (gunnar's sentence with its seconds stays in
/// the rows), so it changes when the STATE does.
#[test]
fn the_answer_carries_a_live_drawing_with_gunnars_words() {
let mut twin = row("vm3", "twin", "silent", false);
twin.headline = "silent for 4 min".into();
twin.why = "twin `gunnar-twin` polled 4 min ago (lag 0 entries): no poll for 242 s".into();
let mut me = row("vm2", "primary", "up", true);
me.headline = "answering".into();
let t = render(pb::TopologyResponse { boxes: vec![me, twin], ..Default::default() });
assert_eq!(t.boxes[1].headline, "silent for 4 min", "gunnar's words travel");
assert!(t.svg.contains(r#"data-box="vm3" data-state="silent""#), "the answer carries no live drawing: {}", t.svg);
assert!(t.svg.contains(">silent for 4 min<"));
assert!(t.svg.contains(r#"data-link="vm3" data-state="silent""#));
assert!(!t.svg.contains("242 s"), "per-poll prose in the drawing would swap it every poll");
assert_eq!(estate_svg(&board(&t)), t.svg, "the .svg route and the answer are one drawing");
}
/// ★ **The picture follows the console's theme** (owner, 2026-10-05: "make
/// it follow the chosen theme of the web site; we have 2 themes"). The
/// renderer draws every colour as a page token (`TOKENS`), and the card
/// shows it inline: so the console's stylesheet must map EVERY token onto
/// one of its own theme tokens — once, never as a per-theme colour that
/// could drift — or that part of the picture stays skade's dark blue on
/// the white console.
#[test]
fn the_console_maps_every_colour_the_picture_is_drawn_with() {
let css = include_str!("../../../gunnar-ui-web/styles/console.css");
let at = css.find("\n.estate-picture {").expect("console.css has no `.estate-picture` block mapping the picture's tokens");
let block = &css[at..at + css[at..].find('}').unwrap()];
for (name, _) in nordisk_estate_svg::TOKENS {
assert!(block.contains(&format!("\n {name}: var(--")), "console.css does not map {name} onto a theme token: {block}");
}
for line in block.lines().map(str::trim).filter(|l| l.starts_with("--")) {
let value = line.split_once(':').map(|(_, v)| v.trim().trim_end_matches(';')).unwrap_or("");
assert!(value.starts_with("var(--") && !value.contains('#') && !value.contains("oklch") && !value.contains("rgb"), "a colour of its own, not the theme's token: {line}");
}
// The third theme (owner, 2026-10-05: "surprise") gives the board
// values of its own — every name again, and again only its tokens.
let at = css
.find("\n:root[data-theme=\"surprise\"] .estate-picture {")
.expect("console.css has no surprise `.estate-picture` block");
let block = &css[at..at + css[at..].find('}').unwrap()];
for (name, _) in nordisk_estate_svg::TOKENS {
assert!(block.contains(&format!("\n {name}: var(--")), "the surprise theme does not map {name}: {block}");
}
for line in block.lines().map(str::trim).filter(|l| l.starts_with("--")) {
let value = line.split_once(':').map(|(_, v)| v.trim().trim_end_matches(';')).unwrap_or("");
assert!(value.starts_with("var(--") && !value.contains('#') && !value.contains("oklch") && !value.contains("rgb"), "surprise: a colour of its own, not the theme's token: {line}");
}
// And the picture this console draws names no colour but its tokens'.
let t = render(pb::TopologyResponse { boxes: vec![row("vm2", "primary", "up", true), row("vm3", "twin", "silent", false)], ..Default::default() });
assert_eq!(t.svg.matches('#').count(), t.svg.matches(", #").count() + t.svg.matches("url(#").count(), "a colour outside a token: {}", t.svg);
}
/// ★ **front | main | twin, in reading order** (owner, 2026-10-05), from
/// gunnar's order — this box first — without re-ordering the rows.
#[test]
fn the_picture_reads_front_main_twin_from_gunnars_order() {
let t = render(pb::TopologyResponse {
boxes: vec![row("vm2", "primary", "up", true), row("vm3", "twin", "up", false), row("edge", "front", "up", false), row("gunnar-ui", "console", "up", false)],
..Default::default()
});
assert_eq!(t.boxes.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["vm2", "vm3", "edge", "gunnar-ui"], "the rows stay gunnar's");
let at = |name: &str| t.svg.find(&format!(r#"data-box="{name}""#)).unwrap_or_else(|| panic!("{name} is not drawn"));
assert!(at("edge") < at("vm2") && at("vm2") < at("vm3") && at("vm3") < at("gunnar-ui"), "drawn front, main, twin, then the row below: {}", t.svg);
}
}