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