crates/monetize-ui-web/src/plugins.rs
//! **THE BOX'S PLUGINS** (owner 2026-10-17) — the decisions the Sites page's "Update plugins"
//! takes, compiled for the host and tested there; `pages::sites` only lays them out.
//!
//! ```text
//! Plugins: [Update plugins] → queued at once (monetize's job); the page reads it again
//! per plugin — name · role · unchanged | replaced | refused — monetize's words
//! ```
//!
//! The console decides nothing: which plugins, whether one verifies and what became of it
//! are monetize's words. The press is one short POST that QUEUES the job; the page then reads
//! where it stands until it is done — never one long call.
use serde::{Deserialize, Serialize};
use crate::fmt::Tone;
pub const UPDATE_PLUGINS_API: &str = "/api/plugins/update";
pub const PLUGINS_STATUS_API: &str = "/api/plugins/status";
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct PluginRowJson {
pub name: String,
pub role: String,
pub outcome: String,
pub detail: String,
pub blake3: String,
}
/// monetize's word on a plugin job.
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct PluginJobJson {
pub job: String,
pub state: String,
pub apply: bool,
pub rows: Vec<PluginRowJson>,
pub error: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct PluginsBody {
pub apply: bool,
}
/// What the button sends: take the plugins (write what verifies).
pub fn plugins_body() -> PluginsBody {
PluginsBody { apply: true }
}
/// While the job is queued or running the page reads it again.
pub fn plugins_in_flight(j: &PluginJobJson) -> bool {
matches!(j.state.as_str(), "queued" | "running")
}
/// The status read: the job the press answered (empty: the newest).
pub fn status_path(job: &str) -> String {
format!("{PLUGINS_STATUS_API}?job={job}")
}
/// **"Update plugins"**: whether it may be pressed and why not — an admin's, never while a
/// job runs.
pub fn plugins_button(j: Option<&PluginJobJson>, admin: bool) -> (bool, String) {
if !admin {
return (false, "an admin updates the plugins".into());
}
if j.is_some_and(plugins_in_flight) {
return (false, "a plugin job is running".into());
}
(true, String::new())
}
/// One row's tone: replaced is news, refused is bad, unchanged is plain.
pub fn outcome_tone(outcome: &str) -> Tone {
match outcome {
"replaced" | "would-replace" => Tone::Live,
"refused" => Tone::Bad,
_ => Tone::Plain,
}
}
/// The job's one line above its rows. A job that ended with monetize's refusal (no list to
/// read, no bucket) says that refusal, never "Done: 0 replaced"; every plugin the same as the
/// release's is "All N plugins are current".
pub fn job_words(j: &PluginJobJson) -> String {
match j.state.as_str() {
"failed" => format!("The plugin update failed: {}", j.error),
"done" if !j.error.trim().is_empty() => {
format!("No plugin was updated: {}", j.error.trim())
}
"done" if j.rows.is_empty() => {
"No plugin was updated: the release lists no plugin for this box.".into()
}
"done"
if j.rows
.iter()
.all(|r| r.outcome == "unchanged" || r.outcome == "held") =>
{
let n = j.rows.len();
let held = j.rows.iter().filter(|r| r.outcome == "held").count();
let all = if n == 1 {
"The 1 plugin is current".to_string()
} else {
format!("All {n} plugins are current")
};
if held == 0 {
format!("{all}.")
} else {
format!("{all} ({held} held until their product's first site).")
}
}
"done" => {
let n = |o: &str| j.rows.iter().filter(|r| r.outcome == o).count();
let mut parts = vec![
format!("{} replaced", n("replaced")),
format!("{} unchanged", n("unchanged")),
format!("{} refused", n("refused")),
];
if n("would-replace") > 0 {
parts.push(format!("{} would be replaced", n("would-replace")));
}
format!("Done: {}.", parts.join(", "))
}
s => format!("The plugin update is {s}…"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row(name: &str, outcome: &str) -> PluginRowJson {
PluginRowJson {
name: name.into(),
role: "vendor".into(),
outcome: outcome.into(),
detail: "d".into(),
blake3: String::new(),
}
}
/// ★ **"Update plugins"** (owner 2026-10-17): the press takes them (apply), is an admin's
/// and never while a job runs; the page reads a queued or running job again and stops when
/// it is done or failed; each row's tone is its outcome; the job's line is monetize's.
#[test]
fn update_plugins_queues_reads_again_while_it_runs_and_carries_each_row() {
assert_eq!(plugins_body(), PluginsBody { apply: true });
assert_eq!(
status_path("plugins-2"),
"/api/plugins/status?job=plugins-2"
);
let running = PluginJobJson {
job: "plugins-2".into(),
state: "running".into(),
apply: true,
..Default::default()
};
assert!(
plugins_in_flight(&running)
&& plugins_in_flight(&PluginJobJson {
state: "queued".into(),
..running.clone()
})
);
let done = PluginJobJson {
state: "done".into(),
rows: vec![
row("stripe.wasm", "replaced"),
row("upcloud.wasm", "refused"),
row("dns_cloudflare.wasm", "unchanged"),
],
..running.clone()
};
assert!(
!plugins_in_flight(&done)
&& !plugins_in_flight(&PluginJobJson {
state: "failed".into(),
..running.clone()
})
);
assert_eq!(plugins_button(None, true), (true, String::new()));
assert_eq!(plugins_button(Some(&done), true), (true, String::new()));
assert!(
!plugins_button(Some(&running), true).0,
"never while a job runs"
);
assert!(!plugins_button(None, false).0, "an admin's");
assert_eq!(
(
outcome_tone("replaced"),
outcome_tone("refused"),
outcome_tone("unchanged")
),
(Tone::Live, Tone::Bad, Tone::Plain)
);
assert!(
job_words(&done).contains("1 replaced")
&& job_words(&done).contains("1 refused")
&& job_words(&done).contains("1 unchanged"),
"{}",
job_words(&done)
);
assert!(
job_words(&running).contains("running"),
"{}",
job_words(&running)
);
let failed = PluginJobJson {
state: "failed".into(),
error: "[plugins-list-unsigned] x".into(),
..running
};
assert!(
job_words(&failed).contains("[plugins-list-unsigned] x"),
"{}",
job_words(&failed)
);
}
/// ★ **t2-fast (test2, the EMPTY box):** a product guest held until its first site is current
/// for the box: every row unchanged or held is "All N plugins are current", the held ones said.
#[test]
fn held_product_guests_count_as_current() {
let done = PluginJobJson {
job: "p".into(),
state: "done".into(),
apply: true,
rows: vec![
row("gunnar.wasm", "held"),
row("holger.wasm", "held"),
row("stripe.wasm", "unchanged"),
row("upcloud.wasm", "unchanged"),
row("dns.wasm", "unchanged"),
],
..Default::default()
};
assert_eq!(
job_words(&done),
"All 5 plugins are current (2 held until their product's first site)."
);
assert_eq!(outcome_tone("held"), Tone::Plain);
}
/// ★ **"Done: 0 replaced, 0 unchanged, 0 refused"** (test2, owner 2026-11-03): a job that
/// ended with monetize's refusal says it; an empty list says so; all current says so.
#[test]
fn a_job_that_took_nothing_says_why_and_all_current_says_so() {
let refused = PluginJobJson {
job: "p".into(),
state: "done".into(),
apply: true,
error: "[plugins-no-bucket] no release object names the bucket".into(),
..Default::default()
};
let w = job_words(&refused);
assert!(
w.contains("[plugins-no-bucket]") && !w.contains("0 replaced"),
"{w}"
);
let empty = PluginJobJson {
error: String::new(),
..refused.clone()
};
assert_eq!(
job_words(&empty),
"No plugin was updated: the release lists no plugin for this box."
);
let names = [
"stripe.wasm",
"upcloud.wasm",
"dns_cloudflare.wasm",
"holger.wasm",
"gunnar.wasm",
"stripe2.wasm",
];
let current = PluginJobJson {
rows: names.iter().map(|n| row(n, "unchanged")).collect(),
..empty.clone()
};
assert_eq!(job_words(¤t), "All 6 plugins are current.");
let one = PluginJobJson {
rows: vec![row("stripe.wasm", "unchanged")],
..empty.clone()
};
assert_eq!(job_words(&one), "The 1 plugin is current.");
let mixed = PluginJobJson {
rows: vec![
row("stripe.wasm", "unchanged"),
row("upcloud.wasm", "replaced"),
],
..empty
};
assert!(
job_words(&mixed).starts_with("Done: 1 replaced, 1 unchanged"),
"{}",
job_words(&mixed)
);
}
}