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