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

nordisk/monetize

crates/monetize-ui-web/src/self_box.rs

//! **"Update this box"** (owner 2026-10-18) — the decisions the Sites page's "This box" panel
//! takes, compiled for the host and tested there; `pages::sites` only lays them out.
//!
//! ```text
//! This box:  runs <release> · slot a           [Update this box to <release>]
//!            asked → restarting → on trial (confirms after 60 s of serving) → updated, confirmed
//! ```
//!
//! The console decides nothing: what runs, what is next and whether it may be taken live are
//! the box's words; the press is one short POST, then the page reads where it stands.

use serde::{Deserialize, Serialize};

pub const SELF_API: &str = "/api/self";
pub const SELF_UPDATE_API: &str = "/api/self/update";
/// This box's certificates (owner 2026-10-25), and an admin's "try now".
pub const CERTS_API: &str = "/api/self/certs";
pub const CERTS_TRY_API: &str = "/api/self/certs/try";

/// One certificate as the box says it: its words are the box's (edda estate-front's lines).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct CertJson {
    pub host: String,
    pub hosts: Vec<String>,
    pub lines: Vec<String>,
    pub try_now: bool,
    pub alert: Option<String>,
}

/// `GET /api/self/certs`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct CertsJson {
    pub certificates: Vec<CertJson>,
}

/// The answer of a try now.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct TriedJson {
    pub words: String,
}

/// **"Try now"**: `(may be pressed, why not)` — an admin's, and only when the box says so.
pub fn cert_button(c: &CertJson, admin: bool) -> (bool, String) {
    if !admin {
        return (false, "an admin asks the box to try now".into());
    }
    if !c.try_now {
        return (
            false,
            "the last attempt failed Let's Encrypt's validation; the box waits it out".into(),
        );
    }
    (true, String::new())
}

/// The body of a try now.
pub fn try_body(c: &CertJson) -> serde_json::Value {
    serde_json::json!({ "host": c.host })
}

/// The box's words (`GET /api/self`).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SelfLookJson {
    pub app: String,
    pub slot: String,
    pub base: String,
    pub live_update: bool,
    pub next_app: String,
    pub next_build: String,
    pub plan: String,
    pub why: String,
    pub asked: bool,
    pub last: String,
}

fn short(h: &str) -> &str {
    &h[..h.len().min(12)]
}

/// The next release's name: its build, else its app pin.
pub fn next_name(l: &SelfLookJson) -> String {
    if l.next_build.trim().is_empty() {
        short(l.next_app.trim()).to_string()
    } else {
        short(l.next_build.trim()).to_string()
    }
}

/// Whether the slot is on trial (`b trial`).
pub fn on_trial(l: &SelfLookJson) -> bool {
    l.slot.split_whitespace().nth(1).is_some()
}

/// **The panel's line**: where the box stands, in plain words.
pub fn box_words(l: &SelfLookJson) -> String {
    let next = next_name(l);
    let slot = l.slot.split_whitespace().next().unwrap_or("").to_string();
    if l.asked {
        return format!("Asked: the box takes {next} within seconds, restarts into it and confirms after 60 s of serving");
    }
    if on_trial(l) {
        return format!(
            "On trial: {next} serves and confirms after 60 s (else the box rolls back by itself)"
        );
    }
    let said = match l.plan.as_str() {
        "rolled-back" => format!("Rolled back: {next} is not on this box's record of confirmed releases — not offered again; a newer release will be"),
        "nothing" => format!("Up to date — this box runs {next} (slot {slot})"),
        "live" => format!("Release {next} is ready for this box (it runs {}, slot {slot})", short(l.app.trim())),
        _ if !l.why.trim().is_empty() => format!("Release {next} is not taken live: {}", l.why.trim()),
        _ => format!("Release {next}: the box does not say how it would take it"),
    };
    let last = l.last.trim();
    if last.starts_with("REFUSED") && l.plan != "nothing" {
        format!("{said} — the last try: {last}")
    } else {
        said
    }
}

/// **"Update this box to <release>"**: `(may be pressed, its label, why not)` — an admin's,
/// only when the box says it may be taken live and none is asked yet.
pub fn box_button(l: Option<&SelfLookJson>, admin: bool) -> (bool, String, String) {
    let Some(l) = l else {
        return (
            false,
            String::new(),
            "the box has not said what it runs yet".into(),
        );
    };
    let label = format!("Update this box to {}", next_name(l));
    if !admin {
        return (false, label, "an admin updates the box".into());
    }
    if l.asked {
        return (false, label, "asked: the box is taking it".into());
    }
    match l.plan.as_str() {
        "live" => (true, label, String::new()),
        "nothing" => (false, label, "this box runs it already".into()),
        _ => (false, label, l.why.trim().to_string()),
    }
}

/// While an update is asked or on trial the page reads the box again (and a box that does not
/// answer is restarting).
pub fn box_following(l: &SelfLookJson) -> bool {
    l.asked || on_trial(l) || l.plan == "wait"
}

/// Whether the box says the next release is one it rolled back (the panel says it in red).
pub fn rolled_back(l: &SelfLookJson) -> bool {
    l.plan == "rolled-back"
}

/// What the page says while the box does not answer after an update was asked.
pub const RESTARTING: &str = "The box is restarting into the new release…";

#[cfg(test)]
mod tests {
    #[test]
    fn the_certificate_panel_takes_the_boxs_words_and_try_now_is_an_admins_when_the_box_allows() {
        let c: super::CertsJson = serde_json::from_str(r#"{"certificates":[{"host":"a.test","hosts":["a.test"],"lines":["valid until:  no valid certificate"],"try_now":false}]}"#).unwrap();
        let c = &c.certificates[0];
        assert_eq!(
            c.lines,
            vec!["valid until:  no valid certificate".to_string()]
        );
        assert!(
            !super::cert_button(c, true).0 && super::cert_button(c, true).1.contains("validation")
        );
        let ok = super::CertJson {
            try_now: true,
            ..c.clone()
        };
        assert!(super::cert_button(&ok, true).0 && !super::cert_button(&ok, false).0);
        assert_eq!(super::try_body(&ok)["host"], "a.test");
    }

    use super::*;

    fn look(plan: &str) -> SelfLookJson {
        SelfLookJson {
            app: "aaaaaaaaaaaaaaaa".into(),
            slot: "a".into(),
            base: "base1".into(),
            live_update: true,
            next_app: "bbbbbbbbbbbbbbbb".into(),
            next_build: "fd0ed14".into(),
            plan: plan.into(),
            ..Default::default()
        }
    }

    #[test]
    fn the_panel_names_the_next_release_and_only_an_admin_takes_it_live() {
        let l = look("live");
        assert_eq!(
            box_words(&l),
            "Release fd0ed14 is ready for this box (it runs aaaaaaaaaaaa, slot a)"
        );
        assert_eq!(
            box_button(Some(&l), true),
            (
                true,
                "Update this box to fd0ed14".to_string(),
                String::new()
            )
        );
        assert!(
            !box_button(Some(&l), false).0,
            "a viewer never updates the box"
        );
        assert!(!box_following(&l));
        let none = look("nothing");
        assert_eq!(
            box_words(&none),
            "Up to date — this box runs fd0ed14 (slot a)"
        );
        assert!(!box_button(Some(&none), true).0);
        let base = SelfLookJson {
            why: "live update is OFF on this box: the kit's keep upgrade".into(),
            ..look("base")
        };
        assert_eq!(
            box_button(Some(&base), true).2,
            "live update is OFF on this box: the kit's keep upgrade"
        );
        assert!(
            !box_button(None, true).0,
            "nothing read yet: nothing pressed"
        );
    }

    #[test]
    fn an_asked_update_is_followed_through_its_trial_to_confirmed() {
        let asked = SelfLookJson {
            asked: true,
            ..look("live")
        };
        assert!(
            box_following(&asked) && !box_button(Some(&asked), true).0,
            "asked once"
        );
        assert_eq!(box_words(&asked), "Asked: the box takes fd0ed14 within seconds, restarts into it and confirms after 60 s of serving");
        let trial = SelfLookJson {
            slot: "b trial".into(),
            app: "bbbbbbbbbbbbbbbb".into(),
            ..look("wait")
        };
        assert!(on_trial(&trial) && box_following(&trial));
        assert_eq!(
            box_words(&trial),
            "On trial: fd0ed14 serves and confirms after 60 s (else the box rolls back by itself)"
        );
        let refused = SelfLookJson {
            last: "REFUSED [self-fetch] the archive".into(),
            ..look("live")
        };
        assert_eq!(box_words(&refused), "Release fd0ed14 is ready for this box (it runs aaaaaaaaaaaa, slot a) — the last try: REFUSED [self-fetch] the archive");
    }

    /// ★ **A release the box rolled back is said in red and never offered again** (owner
    /// 2026-10-18).
    #[test]
    fn a_rolled_back_release_is_said_red_and_not_offered() {
        let back = SelfLookJson {
            plan: "rolled-back".into(),
            why: "rolled back: 1cbf59e — not offered again".into(),
            ..look("rolled-back")
        };
        // M-FOLLOW 3: the box says what it knows — its record — never "never confirmed".
        assert_eq!(box_words(&back), "Rolled back: fd0ed14 is not on this box's record of confirmed releases — not offered again; a newer release will be");
        assert!(rolled_back(&back) && !box_button(Some(&back), true).0 && !box_following(&back));
        assert!(!rolled_back(&look("live")));
    }
}