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