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/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(&current), "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)
        );
    }
}