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/pages/iron.rs

//! **IRON — the ACCOUNT's** (MAP-SEG 1, owner 2026-10-26): `/iron`, the operator's alone
//! (`/api/iron` is an admin's). Every estate's machines and disks at the cloud, each row
//! naming its estate, then the orphans (at the provider, in no order: we pay for them)
//! and the missing (sold, and not at the provider). An estate's own Map shows its own
//! rows only; what belongs to no ONE estate, and the orphans, are here. Every word is a
//! cell of `fmt` (`cloud_rows`, `cloud_where`, `cloud_needs_you`, `cloud_summary_line`).

use leptos::prelude::*;
use serde::Deserialize;

use crate::fmt::{
    backup_line, backups_summary, cloud_needs_you, cloud_rows, cloud_summary_line, cloud_where,
};
use crate::model::{CloudBackupJson, CloudJson, CloudOwnedJson};
use crate::ui::{resolved, Badge, Empty, PageHeader, Panel, Tone};

/// `GET /api/iron`.
pub const IRON_API: &str = "/api/iron";

/// One estate's group on an account (MAP-3): its owned rows and its backups, as the server
/// grouped them (`monetize_ui_server::iron::accounts`).
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct IronEstateJson {
    #[serde(default)]
    pub estate: String,
    /// The group's heading: the estate, or "the account's own (no estate)".
    #[serde(default)]
    pub name: String,
    #[serde(default)]
    pub owned: Vec<CloudOwnedJson>,
    #[serde(default)]
    pub backups: Vec<CloudBackupJson>,
}

/// One cloud ACCOUNT (MAP-3: listed once, naming every product on it): a cloud block, its
/// title, and its estates.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct IronAccountJson {
    #[serde(default)]
    pub title: String,
    #[serde(default)]
    pub products: Vec<String>,
    #[serde(default)]
    pub estates: Vec<IronEstateJson>,
    #[serde(flatten)]
    pub cloud: CloudJson,
}

#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct IronJson {
    #[serde(default)]
    pub accounts: Vec<IronAccountJson>,
}

#[component]
pub fn Iron() -> impl IntoView {
    let data = LocalResource::new(|| async { crate::api::get::<IronJson>(IRON_API).await });
    view! {
        <PageHeader title="Iron" subtitle="The cloud account: every estate's machines, disks and backups, and what no order owns."/>
        {move || resolved(data.get(), None, |j: IronJson| {
            if j.accounts.is_empty() {
                return view! { <Empty title="No cloud" detail="This box lists no cloud account."/> }.into_any();
            }
            view! {
                <div class="stack">
                    {j.accounts.into_iter().map(|a| {
                        let c = a.cloud;
                        let summary = cloud_summary_line(&c);
                        let tasks = cloud_needs_you(&c);
                        let unavailable = (!c.available).then(|| if c.error.is_empty() { format!("{} could not be listed", c.provider) } else { format!("{} could not be listed: {}", c.provider, c.error) });
                        view! {
                            <Panel title=a.title.clone() actions=Box::new(move || view! { <span class="hint mono">{summary.clone()}</span> }.into_any())>
                                {unavailable.map(|s| view! { <div class="panel-body cloud-task"><Badge tone=Tone::Warn label="⚠"/>" "{s}</div> })}
                                {a.estates.into_iter().map(|e| {
                                    let rows = cloud_rows(&CloudJson { owned: e.owned, ..Default::default() });
                                    let counted = backups_summary(&e.backups);
                                    view! {
                                        <h3>{if counted.is_empty() { e.name.clone() } else { format!("{} · {counted}", e.name) }}</h3>
                                        <div class="cloud-list">
                                            {rows.into_iter().map(|r| { let place = cloud_where(&r); view! {
                                                <div class="cloud-row" title=r.reference.clone()>
                                                    <span class="id mono">{r.id.clone()}</span>
                                                    <span class="size mono">{r.size.clone()}</span>
                                                    <span class="where hint">{place}</span>
                                                    <span class="ref hint mono">{format!("({})", r.reference)}</span>
                                                </div>
                                            }}).collect_view()}
                                            {e.backups.into_iter().map(|b| view! {
                                                <div class="cloud-row backup" title=b.id.clone()>
                                                    <span class="hint">{backup_line(&b)}</span>
                                                </div>
                                            }).collect_view()}
                                        </div>
                                    }
                                }).collect_view()}
                                {(!tasks.is_empty()).then(|| view! {
                                    <div class="cloud-tasks">
                                        <h3>{format!("Needs you · {}", tasks.len())}</h3>
                                        {tasks.into_iter().map(|task| view! {
                                            <div class="cloud-task">
                                                <Badge tone=Tone::Warn label=format!("⚠ {}", task.what)/>
                                                <span class="mono">{task.subject}</span>
                                                <span class="mono">{task.detail}</span>
                                                <span>{format!("— {}", task.why)}</span>
                                            </div>
                                        }).collect_view()}
                                    </div>
                                })}
                            </Panel>
                        }
                    }).collect_view()}
                </div>
            }.into_any()
        })}
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// ★ MAP-3: the page reads the server's accounts as they are sent: one account, its title,
    /// its estates with their backups, the account's block flattened beside them.
    #[test]
    fn the_page_reads_one_account_with_its_estates_and_backups() {
        let j: IronJson = serde_json::from_value(serde_json::json!({ "accounts": [{
            "provider": "upcloud", "account": "upcloud", "products": ["gunnar", "holger"], "title": "upcloud · gunnar, holger",
            "available": true, "error": "", "currency": "EUR", "owned": [], "orphans": [], "missing": [], "backups": [{ "id": "storage/bk1", "gib": 10, "estate": "holger-rs", "words": "backup of holger-web-ui-holgerrs, 2026-10-27" }],
            "estates": [{ "estate": "holger-rs", "name": "holger-rs", "owned": [], "backups": [{ "id": "storage/bk1", "gib": 10, "estate": "holger-rs", "words": "backup of holger-web-ui-holgerrs, 2026-10-27" }] }],
        }]}))
        .unwrap();
        assert_eq!(j.accounts.len(), 1);
        let a = &j.accounts[0];
        assert_eq!(
            (
                a.title.as_str(),
                a.cloud.provider.as_str(),
                a.cloud.backups.len()
            ),
            ("upcloud · gunnar, holger", "upcloud", 1)
        );
        assert_eq!(
            backup_line(&a.estates[0].backups[0]),
            "backup of holger-web-ui-holgerrs, 2026-10-27 · 10.0 GiB"
        );
    }
}