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/tenant.rs

//! `docs/v2/07-console.md` screen 3 — a tenant.
//!
//! One number is big: the tenant's standing on the product's headline meter,
//! with `AT CAP` when a wall is being refused on. One trend line. The one
//! action, `[ How much? ]`, opens the order form ([`CapacityOrder`]): the caps
//! the tenant is to be ALLOWED, quoted BEFORE money moves — the price from the
//! deployment's list (`free` when there is none), the cost the provider would
//! charge, and whether it can be served with the refusal in the product's own
//! words — then confirmed. USING lists the per-tenant meters in plain words
//! (`fmt::plain_meter`; the wire name is in the title) with `wall` / `billed` /
//! `not billed` / `still costs` beside each, read off the price list
//! (`fmt::billing_word`). BACKED BY is what was BOUGHT, distinct from what is
//! allowed. `History →` opens the Activity screen scoped to this tenant. The
//! entitlement fact and the operator's hand-set door live behind a disclosure —
//! they are the detail, not the page.
//!
//! **ACTIVITY, under USING, is the other kind of number.** Every bar in USING
//! is a STOCK reading — how much is on the disk — and it reads the same for a
//! tenant who pushed this morning and one who stopped in March. The Activity
//! panel is what MOVED: the last thirty days as four strips of daily bars
//! (`fmt::activity_series`), the days the product said no marked in red, and
//! one line saying when this tenant was last seen at all. The flow meters are
//! therefore NOT bars in USING (`fmt::is_flow_meter`); a Unix millisecond
//! drawn as a quantity a tenant is using is a bar making a claim that is not
//! true.

use std::collections::BTreeMap;

use leptos::prelude::*;
use leptos_router::hooks::use_query_map;
use serde_json::{json, Value};

use crate::api::Fail;
use crate::fmt::{
    activity_empty_line, activity_series, billing_word, can_serve_words, caps_query, cost_words,
    date, form_value, holding_line, is_flow_meter, last_seen_line, list_for, meter_value,
    month_day, on_tenant_screen, order_caps, parse_date_ms, price_words, primary_meter, since,
    standing, standing_of, trend_line, unit_price,
};
use crate::pages::{
    activity_href, now_ms, product_href, ActivityJson, BundleJson, BundlesJson, QuoteJson,
    TenantDetailJson,
};
use crate::ui::{
    resolved, Badge, BarRow, Bars, Empty, FailBox, PageHeader, Panel, StateBadge, Tone, Track,
};

#[component]
pub fn TenantDetail() -> impl IntoView {
    let query = use_query_map();
    let product = Memo::new(move |_| query.read().get("product").unwrap_or_default());
    let tenant = Memo::new(move |_| query.read().get("tenant").unwrap_or_default());
    let feed = crate::feed::use_feed();
    let reload = RwSignal::new(0u32);
    let data = LocalResource::new(move || {
        reload.get();
        feed.reread.get();
        let (p, t) = (product.get(), tenant.get());
        async move {
            crate::api::get::<TenantDetailJson>(&format!(
                "/api/tenants/{}/{}",
                crate::fmt::percent_encode(&p),
                crate::fmt::percent_encode(&t)
            ))
            .await
        }
    });
    let retry = Callback::new(move |()| reload.update(|n| *n += 1));
    // **THE ACTIVITY READ IS ITS OWN, and it is not the `activity_href` in
    // `pages/mod.rs`** — that one is the SALES screen (`/ledger`), a different
    // meaning of the word, and neither is renamed here. This is a data route
    // with no page behind it, so it needs no href function at all; the path is
    // built where it is asked for.
    //
    // Separate from the tenant read for one reason: `/activity` calls the
    // PRODUCT and the tenant read does not, so a slow or unreachable box would
    // otherwise hold up the whole page — the bars, the quota, the order form.
    // It is deliberately NOT tied to `feed.reread` either: a day series changes
    // by the day, and re-asking the product on every reading that lands is a
    // poll dressed as a page.
    let activity_reload = RwSignal::new(0u32);
    let activity = LocalResource::new(move || {
        activity_reload.get();
        let (p, t) = (product.get(), tenant.get());
        async move {
            crate::api::get::<ActivityJson>(&format!(
                "/api/tenants/{}/{}/activity",
                crate::fmt::percent_encode(&p),
                crate::fmt::percent_encode(&t)
            ))
            .await
        }
    });
    let activity_retry = Callback::new(move |()| activity_reload.update(|n| *n += 1));
    let back = move || product_href(&product.get());
    let back_label = move || format!("← {}", product.get());
    // THE ORDER FORM LIVES OUTSIDE THE RELOADING VIEW. The page re-reads on
    // every feed tick, and a form created inside `resolved(...)` was rebuilt
    // with it — an operator typing a number, or reading a quote, lost it the
    // moment a reading arrived. MEASURED in the browser matrix: the panel
    // opened, and was gone before the quote came back. So the open/closed
    // state and the detail the form was opened on are held here, and the
    // form is created once, when it opens.
    let ordering = RwSignal::new(false);
    let latest = RwSignal::new(None::<TenantDetailJson>);
    Effect::new(move |_| {
        if let Some(Ok(d)) = data.get() {
            latest.set(Some(d));
        }
    });

    view! {
        <PageHeader title=move || tenant.get()>
            <a class="btn quiet" href=back>{back_label}</a>
        </PageHeader>
        <Show when=move || ordering.get()>
            {move || latest.get_untracked().map(|d| view! {
                <QuickBuy detail=d.clone() on_done=Callback::new(move |()| reload.update(|n| *n += 1))/>
                <CapacityOrder detail=d on_done=Callback::new(move |()| reload.update(|n| *n += 1))/>
            })}
        </Show>
        {move || resolved(data.get(), Some(retry), move |d: TenantDetailJson| {
            let now = now_ms();
            let caps = d.fact.as_ref().map(|f| f.caps.clone()).unwrap_or_default();
            let meter = primary_meter(d.trend.as_ref(), &d.meters).map(str::to_owned);
            let enforcement_of = |name: &str| d.meters.iter().find(|m| m.name == name).map(|m| m.enforcement.clone()).unwrap_or_default();
            // The big number: this tenant's standing on the headline meter.
            let head = match (&d.row, &meter) {
                (Some(row), Some(m)) => Some(standing(row, m, &enforcement_of(m))),
                _ => None,
            };
            let trend = head.as_ref().and_then(|s| s.used.map(|u| (u, s.cap))).and_then(|(u, cap)| trend_line(d.trend.as_ref(), u, cap));
            // The USING words are read off THIS product's list — the market's
            // when several, the first otherwise, none when free.
            let list = list_for(&d.prices, &d.product, None).cloned();
            // A flow meter is not a bar: it belongs to the Activity panel
            // below, where a day is a day and a timestamp is a date.
            let bars: Vec<BarRow> = d.meters.iter().filter(|m| on_tenant_screen(&m.scope) && !is_flow_meter(&m.name)).map(|m| BarRow {
                name: m.name.clone(),
                meaning: m.meaning.clone(),
                standing: standing_of(&m.name, d.row.as_ref().and_then(|r| r.meters.get(&m.name).copied()), caps.get(&m.name).copied(), &m.enforcement),
                billing: billing_word(&m.name, &m.enforcement, unit_price(m, list.as_ref())),
            }).collect();
            let measured = d.row.as_ref().and_then(|r| r.measured_at_unix_ms);
            // WHEN THIS TENANT WAS LAST SEEN AT ALL — off the reading the page
            // already has, not off the activity call, so a product that will
            // not answer the day series still cannot erase the one line that
            // says whether anybody is there. A product that reports NEITHER
            // meter gets no line: `never seen` about a product that does not
            // count flow would be a claim about a customer made from a gap in
            // a plugin.
            let last_seen = d.row.as_ref().filter(|r| r.meters.contains_key("last_push_unix_ms") || r.meters.contains_key("last_fetch_unix_ms")).map(|r| {
                let at = |m: &str| r.meters.get(m).copied().unwrap_or(0);
                last_seen_line(at("last_push_unix_ms"), at("last_fetch_unix_ms"), now)
            });
            let fact = d.fact.clone();
            let pending_push = d.pending_push.clone();
            let holdings = d.holdings.clone();
            let history = activity_href(&d.product, Some(&d.tenant));
            let tenant_id = d.tenant.clone();
            let for_operator = d.clone();
            view! {
                <div class="stack">
                    <section class="big" aria-label="This tenant">
                        {match &head {
                            Some(s) => view! {
                                <div class="number mono">{s.text()}</div>
                                <div class="big-bar"><Track state=s.state width=s.width label=tenant_id.clone()/></div>
                                <p class=if s.state == crate::fmt::BarState::AtLimit { "trend mono at-cap" } else { "trend mono" }>
                                    {if s.state == crate::fmt::BarState::AtLimit { format!("{} · ", s.word()) } else { String::new() }}
                                    {trend.clone().unwrap_or_default()}
                                </p>
                            }.into_any(),
                            None => view! { <div class="number hint">"no wall to measure against"</div> }.into_any(),
                        }}
                        <Show when=move || crate::app::session().get().is_some_and(|s| s.is_operator())>
                            <button class="btn primary" on:click=move |_| ordering.update(|s| *s = !*s)>"How much?"</button>
                        </Show>
                    </section>
                    <Panel title="Using" actions=Box::new(move || view! {
                        <span class="hint">{match measured {
                            Some(at) => format!("measured {}", since(at, now)),
                            None => "not measured yet".to_owned(),
                        }}</span>
                    }.into_any())>
                        <div class="panel-body"><Bars meters=bars/></div>
                    </Panel>
                    <Panel title="Activity" actions=Box::new(move || match last_seen.clone() {
                        Some(line) => view! { <span class="hint" title="the later of this tenant's last push and last fetch, over the product's whole kept window">{line}</span> }.into_any(),
                        None => view! { <span class="hint">"this product does not report flow"</span> }.into_any(),
                    })>
                        {move || resolved(activity.get(), Some(activity_retry), |a: ActivityJson| {
                            if a.days.is_empty() {
                                // NOT a blank strip: the two silences read the
                                // same without these words (`fmt` decides which).
                                return view! { <Empty title="Nothing moved" detail=activity_empty_line(a.days_asked, a.retained_days)/> }.into_any();
                            }
                            let asked = a.days_asked;
                            // The x axis is the window's two ends; every bar
                            // carries its own day in its title. One date
                            // formatter (`fmt::month_day`) for all three.
                            let from = a.days.first().map(|d| month_day(d.day_unix_ms)).unwrap_or_default();
                            let to = a.days.last().map(|d| month_day(d.day_unix_ms)).unwrap_or_default();
                            // The key is drawn only when there is something to
                            // key: a red legend over a month with no refusals
                            // is a warning about nothing, and an operator who
                            // sees it every time stops reading it.
                            let any_refused = a.days.iter().any(|d| d.meters.get(crate::fmt::REFUSED_METER).is_some_and(|n| *n > 0));
                            view! {
                                <div class="panel-body">
                                    <div class="days">
                                        {activity_series(&a.days).into_iter().map(|s| {
                                            let total = s.total_text();
                                            let label = format!("{}: {total} over the last {asked} days", s.word);
                                            view! {
                                                <span class="name" title=s.meter>{s.word}</span>
                                                <span class="strip" role="img" aria-label=label>
                                                    {s.bars.into_iter().map(|b| view! {
                                                        <span class=if b.refused { "day refused" } else { "day" } title=b.title>
                                                            // A day that moved nothing draws no bar element at
                                                            // all; the cell stays, so its refusal mark and its
                                                            // title still say what happened on it.
                                                            {(b.width > 0).then(|| view! { <i style=format!("height:{}%", b.width)></i> })}
                                                        </span>
                                                    }).collect_view()}
                                                </span>
                                                <span class="value">{total}</span>
                                            }
                                        }).collect_view()}
                                    </div>
                                    <p class="axis hint">
                                        <span>{from}</span>
                                        {any_refused.then(|| view! { <span class="refused-key" title="a day the product refused a transfer; the count is in that day's title">"■ refused"</span> })}
                                        <span>{to}</span>
                                    </p>
                                </div>
                            }.into_any()
                        })}
                    </Panel>
                    <Panel title="Backed by">
                        {if holdings.is_empty() {
                            view! { <Empty title="Nothing bought" detail="Nothing bought at the cloud for this customer."/> }.into_any()
                        } else {
                            view! {
                                <div class="holding-list">
                                    {holdings.into_iter().map(|h| {
                                        let line = holding_line(&h);
                                        let (tone, why) = if h.provisioned {
                                            (Tone::Pass, "monetize bought this and is paying for it; the nonpay purge gives it back")
                                        } else {
                                            (Tone::Plain, "the pool already covered this: real capacity monetize does not own and must not return")
                                        };
                                        view! {
                                            <details>
                                                <summary><span class="mono">{line}</span><span title=why><Badge tone=tone label=if h.provisioned { "bought" } else { "on hand" }/></span></summary>
                                                <dl class="facts">
                                                    <dt>"Order"</dt><dd>{h.reference}</dd>
                                                    <dt>"Provider id"</dt><dd>{if h.id.is_empty() { "— (on hand)".to_owned() } else { h.id }}</dd>
                                                </dl>
                                            </details>
                                        }
                                    }).collect_view()}
                                </div>
                            }.into_any()
                        }}
                    </Panel>
                    <a class="btn quiet" href=history>"History →"</a>
                    <details class="holder entitlement">
                        <summary>
                            "Entitlement"
                            {match &fact {
                                Some(f) => view! { " " <StateBadge state=f.state.clone()/> " " <span class="mono hint">{if f.order.is_empty() { "hand-set".to_owned() } else { f.order.clone() }}</span> }.into_any(),
                                None => view! { " " <Badge tone=Tone::Plain label="no fact: the product treats this tenant as Free"/> }.into_any(),
                            }}
                            {pending_push.as_ref().map(|_| view! { " " <Badge tone=Tone::Warn label="not yet acknowledged by the product — retried every poll"/> })}
                        </summary>
                        {pending_push.map(|p| view! {
                            <p class="hint">{format!(
                                "{} failed push(es) since {}. The product said: {}",
                                p.attempts,
                                date(p.since_unix_ms),
                                p.last_error
                            )}</p>
                        })}
                        {fact.map(|f| view! {
                            <dl class="facts">
                                <dt>"State"</dt><dd><StateBadge state=f.state.clone()/></dd>
                                <dt>"Order"</dt><dd>{if f.order.is_empty() { "— (hand-set)".to_owned() } else { f.order.clone() }}</dd>
                                <dt>"Paid until"</dt><dd>{f.paid_until_unix_ms.map(date).unwrap_or_else(|| "—".into())}</dd>
                                <dt>"Source"</dt><dd>{f.source.clone()}</dd>
                                // Plain words on the surface, the wire name in the title (`docs/v2/07-console.md` rule 3) — even behind a disclosure.
                                <dt>"Caps"</dt><dd>{if f.caps.is_empty() { view! { "product defaults" }.into_any() } else { f.caps.iter().map(|(k, v)| view! { <span title=k.clone()>{crate::fmt::plain_meter(k)}" = "{meter_value(k, *v)}</span>" " }).collect_view().into_any() }}</dd>
                                // The console CHECKED this: the BFF fetched monetize's
                                // verifying key and verified the Ed25519 signature.
                                <dt>"Signature"</dt><dd>{if f.signed {
                                    view! { <Badge tone=Tone::Pass label="verified against monetize's key"/> }.into_any()
                                } else {
                                    view! { <Badge tone=Tone::Bad label="does not verify"/> }.into_any()
                                }}</dd>
                            </dl>
                        })}
                        <Show when=move || crate::app::session().get().is_some_and(|s| s.is_operator())>
                            <OperatorAction detail=for_operator.clone() on_done=Callback::new(move |()| reload.update(|n| *n += 1))/>
                        </Show>
                    </details>
                </div>
            }
        })}
    }
}

/// Set state by hand, two steps: arm, then confirm.
#[component]
fn OperatorAction(detail: TenantDetailJson, on_done: Callback<()>) -> impl IntoView {
    let current = detail.fact.clone();
    let label = RwSignal::new(String::new());
    let state = RwSignal::new(
        current
            .as_ref()
            .map(|f| f.state.clone())
            .unwrap_or_else(|| "free".into()),
    );
    let paid_until = RwSignal::new(
        current
            .as_ref()
            .and_then(|f| f.paid_until_unix_ms)
            .map(date)
            .unwrap_or_default(),
    );
    let armed = RwSignal::new(false);
    let busy = RwSignal::new(false);
    let failed = RwSignal::new(None::<Fail>);
    let done = RwSignal::new(None::<String>);
    let (product, tenant) = (detail.product.clone(), detail.tenant.clone());

    // A `Callback` is `Copy`, so the reactive arm/confirm block below can hold it.
    let submit = Callback::new(move |()| {
        if busy.get_untracked() {
            return;
        }
        let until = paid_until.get_untracked();
        let paid_until_ms = if until.trim().is_empty() {
            None
        } else {
            match parse_date_ms(&until) {
                Some(ms) => Some(ms),
                None => {
                    failed.set(Some(Fail {
                        status: 0,
                        code: String::new(),
                        message: format!("{until:?} is not a YYYY-MM-DD date"),
                    }));
                    return;
                }
            }
        };
        busy.set(true);
        failed.set(None);
        let body = json!({ "label": label.get_untracked().trim(), "state": state.get_untracked(), "paid_until_unix_ms": paid_until_ms });
        let path = format!(
            "/api/entitlement/{}/{}",
            crate::fmt::percent_encode(&product),
            crate::fmt::percent_encode(&tenant)
        );
        leptos::task::spawn_local(async move {
            match crate::api::post::<_, Value>(&path, &body).await {
                Ok(r) => {
                    let pushed = r["pushed"].as_bool().unwrap_or(false);
                    done.set(Some(if pushed {
                        "Saved.".into()
                    } else {
                        format!(
                            "Saved. It reaches the product shortly ({}).",
                            r["push_error"].as_str().unwrap_or("")
                        )
                    }));
                    armed.set(false);
                    on_done.run(());
                }
                Err(f) => failed.set(Some(f)),
            }
            busy.set(false);
        });
    });

    view! {
        <Panel title="Operator action">
            <div class="panel-body stack">
                <p class="hint">"Gives room without payment: for trials, refunds and support."</p>
                <div class="row" style="align-items:flex-end">
                    <div class="field"><label for="label">"Label (optional)"</label>
                        <input id="label" type="text" placeholder="trial until October" prop:value=move || label.get() on:input=move |ev| label.set(event_target_value(&ev))/>
                    </div>
                    <div class="field"><label for="state">"State"</label>
                        <select id="state" on:change=move |ev| state.set(event_target_value(&ev))>
                            {["free", "paid", "grace", "suspended", "retention"].into_iter().map(|s| view! { <option value=s selected=move || state.get() == s>{crate::fmt::state_label(s)}</option> }).collect_view()}
                        </select>
                    </div>
                    <div class="field"><label for="until">"Paid until (UTC)"</label>
                        <input id="until" type="date" prop:value=move || paid_until.get() on:input=move |ev| paid_until.set(event_target_value(&ev))/>
                    </div>
                    {move || if armed.get() {
                        view! {
                            <button class="btn danger" disabled=move || busy.get() on:click=move |_| submit.run(())>"Confirm: set entitlement"</button>
                            <button class="btn quiet" on:click=move |_| armed.set(false)>"Cancel"</button>
                        }.into_any()
                    } else {
                        view! { <button class="btn primary" on:click=move |_| { done.set(None); armed.set(true) }>"Set entitlement…"</button> }.into_any()
                    }}
                </div>
                {move || done.get().map(|m| view! { <div class="notice">{m}</div> })}
                {move || failed.get().map(|f| view! { <FailBox fail=f/> })}
            </div>
        </Panel>
    }
}

// **How much?** — an ORDER: the caps the tenant is to be allowed, quoted,
// then confirmed.
//
// It is deliberately NOT merged with `Entitlement.Set`. That writes a verdict
// and bypasses payment; this reserves money, buys the delta's iron and
// captures — so it can leave a PENDING attempt waiting on a customer, which a
// hand-set fact never does. Sharing a form there would blur exactly the
// distinction the ledger exists to keep.
//
// **The quote comes BEFORE the confirm button, and nothing moves for it.**
// `GET /api/quote` answers three things an operator must see before money
// moves: the PRICE (the deployment's list applied to the delta over what the
// tenant holds — `free` when there is no list, which is the open-source
// deployment), the COST (what the provider would charge us for the iron —
// `this box` on the server's own disk), and CAN SERVE (the product's own

/// **THE BUY BUTTONS.** One per offer this deployment declares, ordered on the
/// press, with no numbers to type and no quote step in front.
///
/// [`CapacityOrder`] beside it is the form that asks WHAT and HOW MUCH and
/// prices it before confirming. That is the right shape for an unusual order
/// and the wrong one for the ordinary case, which is "give this tenant the
/// next 50 GiB" — an operator asked for a button and got two boxes wanting a
/// meter name and a byte count.
///
/// Nothing here computes: `/api/bundles` already carries the label, the price
/// **in monetize's own words** (never minor units re-rendered here), whether
/// the product can serve it, and its reason when it cannot. The press posts
/// the bundle's ID to `/api/purchase`, and `Purchase.Start` adds the bundle's
/// `cap_delta` to what the tenant holds (`caps_of_bundle`). The arithmetic
/// lives once, in monetize.
///
/// **Whatever is declared appears.** Today `[products.gunnar.bundles]` names
/// only `pack_bytes` offers, so the buttons are disk; a deployment that
/// declares CPU or RAM meters and prices them gets those buttons with no
/// change here. A bundle the product cannot serve is shown with its reason and
/// its button disabled, rather than hidden — an operator asking "where is the
/// 100 GiB button" deserves the answer.
#[component]
fn QuickBuy(detail: TenantDetailJson, on_done: Callback<()>) -> impl IntoView {
    let (product, tenant) = (detail.product.clone(), detail.tenant.clone());
    let market = detail
        .prices
        .iter()
        .find(|l| l.product == detail.product)
        .map(|l| l.market.clone())
        .unwrap_or_default();
    let bundles = RwSignal::new(None::<Vec<BundleJson>>);
    let busy = RwSignal::new(String::new());
    let failed = RwSignal::new(None::<Fail>);
    let started = RwSignal::new(None::<Value>);

    {
        let path = format!(
            "/api/bundles?product={}&tenant={}&market={}",
            crate::fmt::percent_encode(&product),
            crate::fmt::percent_encode(&tenant),
            crate::fmt::percent_encode(&market)
        );
        leptos::task::spawn_local(async move {
            match crate::api::get::<BundlesJson>(&path).await {
                Ok(b) => bundles.set(Some(b.bundles)),
                Err(f) => {
                    bundles.set(Some(Vec::new()));
                    failed.set(Some(f));
                }
            }
        });
    }

    let buy = {
        let (product, tenant, market) = (product.clone(), tenant.clone(), market.clone());
        Callback::new(move |id: String| {
            if !busy.get_untracked().is_empty() {
                return;
            }
            busy.set(id.clone());
            failed.set(None);
            let mut body =
                json!({ "product": product.clone(), "tenant": tenant.clone(), "bundle": id });
            if !market.is_empty() {
                body["market"] = json!(market.clone());
            }
            leptos::task::spawn_local(async move {
                match crate::api::post::<_, Value>("/api/purchase", &body).await {
                    Ok(r) => {
                        started.set(Some(r));
                        on_done.run(());
                    }
                    Err(f) => failed.set(Some(f)),
                }
                busy.set(String::new());
            });
        })
    };

    view! {
        <Panel title="Buy">
            <div class="panel-body stack">
                <p class="hint">"One click buys it. It is added to what this customer has."</p>
                {move || match bundles.get() {
                    None => view! { <p class="hint">"asking what is for sale…"</p> }.into_any(),
                    Some(rows) if rows.is_empty() => view! {
                        <Empty title="Nothing is for sale" detail="Nothing for sale for this product."/>
                    }.into_any(),
                    Some(rows) => view! {
                        <div class="row wrap">
                            {rows.into_iter().map(|b| {
                                let (id, label, price, serve, why) = (b.id.clone(), b.label.clone(), b.price_words.clone(), b.can_serve, b.reason.clone());
                                let pressed = id.clone();
                                view! {
                                    <div class="stack">
                                        <button
                                            class="btn primary"
                                            disabled=move || !serve || !busy.get().is_empty()
                                            on:click=move |_| buy.run(pressed.clone())
                                        >
                                            {move || if busy.get() == id { "buying…".to_owned() } else { format!("{label} · {price}") }}
                                        </button>
                                        {(!serve).then(|| view! { <span class="hint">{why.clone()}</span> })}
                                    </div>
                                }
                            }).collect_view()}
                        </div>
                    }.into_any(),
                }}
                {move || failed.get().map(|f| view! { <FailBox fail=f/> })}
                {move || started.get().map(|r| view! { <Outcome result=r/> })}
            </div>
        </Panel>
    }
}
/// `can_absorb`, the cloud's answer and the box's ceilings). A refusal is
/// rendered whole: `FailBox` and the can-serve line carry monetize's sentence
/// verbatim, because for gunnar that sentence says what is missing and why —
/// on a sealed appliance it ends by saying a bought disk would be billed and
/// invisible — and the operator's next move is a conversation about a
/// reinstall, not a hunt for a typo.
///
/// The form lists the tenant-scope meters that have a price-list UNIT (the
/// ones an order can buy — disk always; CPU and RAM only where the product
/// meters them per tenant), pre-filled with what the tenant holds, in GiB for
/// bytes. Only a changed meter is sent; the server carries the rest forward.
#[component]
fn CapacityOrder(detail: TenantDetailJson, on_done: Callback<()>) -> impl IntoView {
    let vendors = detail.vendors.clone();
    let current_caps: BTreeMap<String, u64> = detail
        .fact
        .as_ref()
        .map(|f| f.caps.clone())
        .unwrap_or_default();
    // What can be ordered: a meter with a unit, on the tenant screen.
    let orderable: Vec<(String, String, String)> = detail
        .meters
        .iter()
        .filter(|m| !m.unit.is_empty() && on_tenant_screen(&m.scope))
        .map(|m| {
            (
                m.name.clone(),
                crate::fmt::plain_meter(&m.name),
                if m.name.ends_with("_bytes") {
                    "GiB".to_owned()
                } else {
                    String::new()
                },
            )
        })
        .collect();
    let markets: Vec<String> = detail
        .prices
        .iter()
        .filter(|l| l.product == detail.product)
        .map(|l| l.market.clone())
        .collect();
    let typed = RwSignal::new(
        orderable
            .iter()
            .map(|(name, _, _)| {
                (
                    name.clone(),
                    form_value(name, current_caps.get(name).copied().unwrap_or(0)),
                )
            })
            .collect::<BTreeMap<String, u64>>(),
    );
    let market = RwSignal::new(markets.first().cloned().unwrap_or_default());
    let vendor = RwSignal::new(vendors.first().cloned().unwrap_or_default());
    let payer = RwSignal::new(String::new());
    let quote = RwSignal::new(None::<QuoteJson>);
    let quoting = RwSignal::new(false);
    let busy = RwSignal::new(false);
    let failed = RwSignal::new(None::<Fail>);
    let started = RwSignal::new(None::<Value>);
    let (product, tenant) = (detail.product.clone(), detail.tenant.clone());
    let period = detail.policy.period.clone();
    let nothing_to_order = orderable.is_empty();

    // THE QUOTE. A read; nothing is reserved. Every change to the numbers
    // invalidates the last one, so a confirm button never sits under a stale price.
    let ask = {
        let (product, tenant) = (product.clone(), tenant.clone());
        let current = current_caps.clone();
        Callback::new(move |()| {
            if quoting.get_untracked() {
                return;
            }
            let caps = order_caps(&current, &typed.get_untracked());
            if caps.is_empty() {
                failed.set(Some(Fail {
                    status: 0,
                    code: String::new(),
                    message: "nothing changed: the tenant already has exactly this".into(),
                }));
                return;
            }
            quoting.set(true);
            failed.set(None);
            quote.set(None);
            let path = format!(
                "/api/quote?product={}&tenant={}&caps={}&market={}",
                crate::fmt::percent_encode(&product),
                crate::fmt::percent_encode(&tenant),
                crate::fmt::percent_encode(&caps_query(&caps)),
                crate::fmt::percent_encode(&market.get_untracked())
            );
            leptos::task::spawn_local(async move {
                match crate::api::get::<QuoteJson>(&path).await {
                    Ok(q) => quote.set(Some(q)),
                    Err(f) => failed.set(Some(f)),
                }
                quoting.set(false);
            });
        })
    };

    // THE ORDER: the same caps the quote priced.
    let submit = {
        let (product, tenant) = (product.clone(), tenant.clone());
        let current = current_caps.clone();
        Callback::new(move |()| {
            if busy.get_untracked() {
                return;
            }
            let caps = order_caps(&current, &typed.get_untracked());
            busy.set(true);
            failed.set(None);
            let mut body =
                json!({ "product": product.clone(), "tenant": tenant.clone(), "caps": caps });
            let m = market.get_untracked();
            if !m.is_empty() {
                body["market"] = json!(m);
            }
            // Omitted rather than sent empty, so monetize's own defaults apply:
            // the only vendor serving the market, the server's clock.
            let v = vendor.get_untracked();
            if !v.is_empty() {
                body["vendor"] = json!(v);
            }
            let p = payer.get_untracked();
            if !p.trim().is_empty() {
                body["payer"] = json!(p.trim());
            }
            leptos::task::spawn_local(async move {
                match crate::api::post::<_, Value>("/api/purchase", &body).await {
                    Ok(r) => {
                        started.set(Some(r));
                        quote.set(None);
                        on_done.run(());
                    }
                    Err(f) => failed.set(Some(f)),
                }
                busy.set(false);
            });
        })
    };

    view! {
        <Panel title="How much?">
            <div class="panel-body stack">
                <p class="hint">"How much this customer may use. Only the increase is charged."</p>
                {if nothing_to_order {
                    view! { <Empty title="Nothing to order" detail="Nothing to order for this product."/> }.into_any()
                } else {
                    let fields = orderable.clone();
                    let mkts = markets.clone();
                    let vends = vendors.clone();
                    view! {
                        <div class="row" style="align-items:flex-end">
                            {fields.into_iter().map(|(name, plain, unit)| {
                                let id = format!("order-{name}");
                                let for_id = id.clone();
                                let key = name.clone();
                                let key2 = name.clone();
                                let label = if unit.is_empty() { plain.clone() } else { format!("{plain} ({unit})") };
                                view! {
                                    <div class="field"><label for=for_id title=name.clone()>{label}</label>
                                        <input id=id type="number" min="0" step="1"
                                            prop:value=move || typed.get().get(&key).copied().unwrap_or(0).to_string()
                                            on:input=move |ev| { let v = event_target_value(&ev).trim().parse::<u64>().unwrap_or(0); typed.update(|t| { t.insert(key2.clone(), v); }); quote.set(None); }/>
                                    </div>
                                }
                            }).collect_view()}
                            {(mkts.len() > 1).then(|| view! {
                                <div class="field"><label for="order-market">"Country"</label>
                                    <select id="order-market" on:change=move |ev| { market.set(event_target_value(&ev)); quote.set(None); }>
                                        {mkts.into_iter().map(|m| { let (v, sel) = (m.clone(), m.clone()); view! { <option value=v selected=move || market.get() == sel>{m}</option> } }).collect_view()}
                                    </select>
                                </div>
                            })}
                            <button class="btn" disabled=move || quoting.get() on:click=move |_| ask.run(())>{move || if quoting.get() { "Quoting…" } else { "Quote" }}</button>
                        </div>
                        {move || quote.get().map(|q| {
                            let (tone, serve) = can_serve_words(&q);
                            let buys_iron = !q.resources.is_empty();
                            let delta = crate::fmt::caps_phrase(&q.delta);
                            let iron: Vec<String> = q.resources.iter().map(|r| r.detail.clone()).collect();
                            let free = q.price_minor == 0;
                            let vends = vends.clone();
                            let period = if q.period.is_empty() { period.clone() } else { q.period.clone() };
                            view! {
                                <div class="quote stack" aria-label="Quote">
                                    <dl class="facts">
                                        <dt>"Sells"</dt><dd class="mono">{delta}{if iron.is_empty() { String::new() } else { format!(" — {}", iron.join(", ")) }}</dd>
                                        <dt>"Price"</dt><dd class="mono" title="what the customer pays">{price_words(q.price_minor, &q.currency)}{if free || period.is_empty() { String::new() } else { format!(" / {period}") }}</dd>
                                        <dt>"Cost"</dt><dd class="mono" title="what the cloud provider charges us">{cost_words(q.cost_minor, &q.cost_currency, buys_iron)}</dd>
                                        <dt>"Can serve"</dt><dd><Badge tone=tone label=if q.can_serve { "yes" } else { "no" }/>{(!q.can_serve).then(|| view! { <p class="detail">{serve.trim_start_matches("can serve: no — ").to_owned()}</p> })}</dd>
                                    </dl>
                                    {q.can_serve.then(|| view! {
                                        <div class="row" style="align-items:flex-end">
                                            {(!free && vends.len() > 1).then(|| view! {
                                                <div class="field"><label for="order-vendor">"Vendor"</label>
                                                    <select id="order-vendor" on:change=move |ev| vendor.set(event_target_value(&ev))>
                                                        {vends.clone().into_iter().map(|v| { let (id, sel) = (v.clone(), v.clone()); view! { <option value=id selected=move || vendor.get() == sel>{v}</option> } }).collect_view()}
                                                    </select>
                                                </div>
                                            })}
                                            {(!free).then(|| view! {
                                                <div class="field"><label for="order-payer">"Payer hint (optional)"</label>
                                                    <input id="order-payer" type="text" placeholder="+46701234567" prop:value=move || payer.get() on:input=move |ev| payer.set(event_target_value(&ev))/>
                                                </div>
                                            })}
                                            {(!free && vends.is_empty()).then(|| view! { <span class="hint">"No payment provider is set up, so a paid order cannot go through."</span> })}
                                            <button class="btn danger" disabled=move || busy.get() on:click=move |_| submit.run(())>
                                                {move || if busy.get() { "Working…" } else if free { "Confirm: place the order (free)" } else { "Confirm: take the money" }}
                                            </button>
                                        </div>
                                    })}
                                </div>
                            }
                        })}
                    }.into_any()
                }}
                {move || started.get().map(|r| view! { <Outcome result=r/> })}
                {move || failed.get().map(|f| view! { <FailBox fail=f/> })}
            </div>
        </Panel>
    }
}

/// What a placed order says. PENDING is the normal answer for a vendor that
/// needs the customer, so it is NOT shown as a failure — the redirect is the
/// next step, not an error.
#[component]
fn Outcome(result: Value) -> impl IntoView {
    let state = result["state"].as_str().unwrap_or("UNKNOWN").to_owned();
    let reference = result["reference"].as_str().unwrap_or_default().to_owned();
    let redirect = result["redirect_url"]
        .as_str()
        .unwrap_or_default()
        .to_owned();
    let reason = result["reason"].as_str().unwrap_or_default().to_owned();
    let tone = match state.as_str() {
        "PAID" => Tone::Pass,
        "PENDING" => Tone::Live,
        "DECLINED" | "RELEASED" => Tone::Bad,
        _ => Tone::Warn,
    };
    view! {
        <div class="notice stack">
            <div class="row"><StateWord state=state.clone() tone=tone/><span class="mono">{reference}</span></div>
            {(!reason.is_empty()).then(|| view! { <p class="hint">{reason}</p> })}
            {(!redirect.is_empty()).then(|| { let href = redirect.clone(); view! {
                <p class="hint">
                    "The vendor needs the customer. Send them to "
                    // rel=noreferrer as well as noopener: a payment URL carries
                    // the reference, and the vendor need not learn our origin.
                    <a href=href target="_blank" rel="noopener noreferrer" class="mono">{redirect.clone()}</a>
                </p>
            }})}
            {(state == "PENDING").then(|| view! { <p class="hint">"Not charged yet. It finishes on its own."</p> })}
        </div>
    }
}

#[component]
fn StateWord(state: String, tone: Tone) -> impl IntoView {
    view! { <Badge tone=tone label=state/> }
}