Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

docs/v2/07-console.md

7. The operator console and the command line

Every page, label and button below is as the console renders it on monetize main c9b473a5 (crates/monetize-ui-web/src/pages/*.rs, routes in app.rs:106-130), with the backend call behind it (crates/monetize-ui-server/src/serve.rs:26-203) and the gRPC service that answers. W = crates/monetize-ui-web/src, S = crates/monetize-ui-server/src.

7.1 What the console is

A client-side Leptos page served by the ONE box's own read-only backend (monetize-ui-server, the same shape as gunnar-ui-server), which talks to monetize over loopback. There are no roles: an operator is anyone signed in with a passkey (or the console is open on loopback with neither passkeys nor a token; S/auth.rs:8-13). Every write (every non-GET under /api/, the invites, a passkey registration) needs a fresh touch of the key, the step-up: the browser answers a 403 stepup with a touch and retries once (S/auth.rs:63-67; W/api.rs:50-85).

The six rules of the surface (the code names them by number in its comments):

  1. One number per screen is big. Everything else is a row.
  2. Never a wall of text. A paragraph lives behind a badge you tap.
  3. Plain words on the surface, jargon underneath. "wall", not Enforced; a test forbids monetize.toml, the poller, MONETIZE_UI_ on a page (W/pages/mod.rs:94-138).
  4. A trend is one line: ▲ 12 GiB/wk · Full in ~9 weeks, with its uncertainty (~).
  5. Sort by who needs attention, never alphabetically.
  6. Outcomes, not steps. Sold bob 90 GiB · 1 980.00 SEK is the row; the ledger steps live behind a disclosure.

On every page: the top bar (W/app.rs:90-104) with the brand "monetize" (to /), the estate picker (Pick an estate, grouped by product, from GET /api/estates), the links Overview, Accounts, Guardian, Test buy, Sites, Iron, and the "You ▾" menu (the user name or "unlocked console", a "Passkeys" link, a "Log out" button). The CODE RED banner reads GET /api/code-red every 10 s and shows each line with its "Next: …" and an "Ack" button (POST /api/code-red/{key}/ack → EstateOrder.AckCodeRed). Estate pages hold one live feed (GET /api/events, an Events.Watch stream; a 5 s poll when the stream is gone) and show "live", "connecting …" or "feed ended".

7.2 The pages

Passkeys, /login (W/pages/login.rs)

element what it does
panel "Sign in", button Sign in with a passkey GET /auth/passkey/login/start → WebAuthn → POST /auth/passkey/login/finish
panel "Enrol the first operator" / "Add another key" / "Enrol a passkey": fields Admin token (first enrol only: the break-glass console_admin_token), Device label (optional); buttons Enrol this key / Add this key, Cancel GET /auth/passkey/register/start → POST /auth/passkey/register/finish
panel "Operators" (signed in): hint "Every operator can do everything here."; field Name; select The link works for 1 hour / 1 day / 7 days; button Invite an operator; list "Open invites" with Revoke POST /auth/invites, POST /auth/invites/withdraw; an invite lives 24 h by default, 7 days at most
panels "Unlocked console", "Passkeys are off" shown when the console is open, or MONETIZE_UI_WEBAUTHN_RP_ID is unset

Join, /invite (W/pages/invite.rs)

"Join this console · An operator invited you." Field Device label (optional), button Register my passkey (POST /auth/invite/start {secret, label} → WebAuthn → POST /auth/passkey/register/finish).

Overview, / (W/pages/overview.rs)

"Overview · Every estate. Pick one to work in it." (GET /api/overview → Estates.Overview; GET /api/estates/{e}/summary per row.)

element what it shows or does
table "Estates": Map (thumbnail), Estate (link to its map; products, host), Machines, Customers, Revenue/mo (net of VAT, last 30 days; test money in brackets), Stripe (live/test), Health health words: "serving", "N paid at the vendor need you", " down", " not asked yet", not installed, unreached, unfed
ended estates behind a quiet <details> line
the last row, "New estate": fields Name (the declared sites), Product, Ready mail to; button Order POST /api/estates/new → Estates.NewEstate (the operator bearer); the answer carries monetize's words and the pay link
section "Live revenue, last 30 days" the big number

The estate shell, /e/<estate>/… (W/pages/shell.rs)

Sidebar: the estate name and its serving badge ("checking …", "not responding", "monetize · · N paid at the vendor need you"); the menu Map, Customers, Money (with Tax), Offer, Events, Settings.

Map, /e/<e>/map (W/pages/shell.rs:117-178)

" · Map · Front, main and twin, and how each is doing now."

element what
panel "Board" the estate drawn (GET /api/estates/{e}/map → EstateAdmin.Draw, the shared estate-svg); empty states: no machines, unreached/unfed, "The map did not load"
product lines (W/pages/products.rs:35-90) per product: the reachability dot (what the product last said to the poller, never a call during the page load, crates/monetize-server/src/health.rs), the id, tenants, the fleet figure and bar, the rate, the cloud; links to Customers
panel "Machines" role, host, who
per product, "Machines" (W/pages/tenants.rs:236-322) the fleet number, the oversold and growth badges, the cloud panel (owned rows: id, size, where, → tenant, ref; badges "play iron", "this box"; "Needs you · N" tasks)

Customers, /e/<e>/customers[?product=] (W/pages/tenants.rs)

" · Customers". Panel "Tenants", sort: closest to cap (id, figure, bar, word, a state badge); panel "Recently bought" with the link all activity →; panels "Silent" and "Friction". Empty states: "No products", "No tenants yet", "Nothing bought yet". (GET /api/products, GET /api/products/{p}/tenants?estate=.)

Customer, /e/<e>/customer?product=&tenant= (W/pages/tenant.rs)

element what it does
the big number and bar; panel "Using" (every meter); panel "Activity" (day strips, "■ refused"); panel "Backed by" (bought / on hand, Order, Provider id); link History → GET /api/tenants/{p}/{t} (Usage.Read, Entitlement.Get, Ledger.Query, Entitlement.Held, Entitlement.PublicKey), …/activity (Usage.Activity)
<details> "Entitlement" State, Order, Paid until, Source, Caps; "Signature: verified against monetize's key" or "does not verify"; a pending-push warning
button How much? (operator) opens panel "Buy" (one button per bundle, " · ") and panel "How much?" (a number per orderable meter, Country, button Quote: Sells, Price, Cost, Can serve; fields Vendor, Payer hint (optional); button Confirm: take the money or Confirm: place the order (free)) GET /api/bundles → Purchase.Bundles; GET /api/quote → Purchase.Quote; POST /api/purchase → Purchase.Start
panel "Operator action" (hint "Gives room without payment: for trials, refunds and support."): fields Label (optional), State free/paid/grace/suspended/retention, Paid until (UTC); button Set entitlement…, then Confirm: set entitlement / Cancel POST /api/entitlement/{p}/{t} → Entitlement.Set (a signed fact with source operator)

Money, /e/<e>/money[?product=&tenant=] (W/pages/ledger.rs, money.rs)

" · Money". Every money button is a MoneyAction: the first press arms it and asks for the words it records, the second press confirms, Cancel disarms (W/pages/money.rs:14-86).

panel rows and buttons
"Needs you · N" one row per unfinished attempt: date, ⚠ line, Open checkout; Abandon… / Confirm: abandon it and release what it holds with reason (POST /api/purchase/abandon → Purchase.Abandon); → the tenant
"Paid at the vendor" the payments the vendor reports that the ledger has not closed (GET /api/paid-at-vendor → ReconcileAdmin.ListPaidAtVendor)
"Owed back · N" (hint "Paid, no account made.") Refund… / Confirm: refund the whole payment with reason (SignupAdmin.Refund); Refunded by hand… / Confirm: close it out with note (SignupAdmin.Settle)
"Paid signups · N" SignupAdmin.List
"History" one line per attempt (date, line, price, tax, mark), the steps behind a disclosure
"Spent at the clouds" per provider: "default · purchases go here", the spend against the ceiling as a bar; an unbounded cap in words

Tax, /e/<e>/tax (W/pages/tax.rs)

"Tax owed per country, as each payment recorded it. Nothing is filed for you: these are the figures to enter." Buttons ← / → step the year; the period buttons (GET /api/tax/periods?year= → Tax.Periods); the table "Tax return boxes" (Box, Label, Amount; GET /api/tax/report → Tax.Report); link Download .csv.

Offer, /e/<e>/offer (W/pages/products.rs:126-169)

"Prices, terms, and what a customer is offered." Panel "Prices" (Product, Country, Disk as "free" or "X / GiB / period"; the policy line "billed per … · N days of grace · kept N days after that"); panel "Bundles" (field Customer, button Show offer → Purchase.Bundles; table Bundle, Price, Can serve).

Events, /e/<e>/events (W/pages/ledger.rs:128-168)

"Newest first: refused payment messages and every ledger step." The refused webhook deliveries (CallbackAdmin.ListRefusals) and the panel "Ledger steps" (Ledger.Query).

Settings, /e/<e>/settings (W/pages/settings.rs)

"Current settings. They change with an install or upgrade." Panels "Payment keys" (Stripe live/test, every vendor), "Names" (Estate, Reached at, Serves, Clouds), "Keys" ("This console signs with" a fingerprint, or "no key: a loopback bearer"; "Answering"), "Passkeys" (label · operator · since · last used · DISABLED; Sign every session out… / Confirm: sign out everywhere → POST /auth/sessions/revoke-all), "Backup" (Back up now… / Confirm: export the ledger → Ledger.Export).

Accounts, /accounts and /account?product=&tenant= (W/pages/accounts.rs)

"Every account, by product and customer." (Accounts.List.) One account: table "Rows" (kind, plan / stop, host, last heard, billing, since) with the server-offered settles Set up again / Confirm: set it up again and Refund / Confirm: refund the order (POST /api/estate-orders/{company}/settle → EstateOrder.Settle; offered for a run in phase owed or held), a private row's Setup log / Hide the setup log (Accounts.InstanceLog); table "Payments" (when, reference, outcome, what).

Guardian, /guardian and /guardian/estate?instance= (W/pages/guardian.rs)

"Repair cases for every estate. Auto-fix is off unless turned on." Table "Estates" (estate, product, main, twin, auto-fix, open case; Guardian.Cases). One estate:

element what it does
panel "Boxes" role, last heard, address
panel "Repair": buttons Recharge from twin, Review twin, New twin, New server (the recommended one styled danger; all disabled while a case is approved or running) POST /api/guardian/{i}/press {remedy, case_id} → Guardian.Press (a strong session)
"Auto-fix: ON/OFF (opt-in)"; button Turn auto-fix on / Turn auto-fix off Guardian.SetAutoFix
"Estate bearer: live until … / EXPIRED / REVOKED / none — revoke it at the door (attach-revoke)" the estate's ONE bearer (365 days)
panel "Cases" (opened, finding, state, remedy, approved by, outcome); the newest case: steps (step, state, detail), evidence, log, receipt
the update block (as on Sites) and the terminate block: "Terminate this estate: its machines, disks and DNS names go. Nothing is refunded. Type to confirm." button Terminate POST /api/sites/terminate {estate, confirm} → EstateOrder.Terminate

Test buy, /test-buy (W/pages/test_buy.rs)

"Buy as a customer would. A real account (and machines, for a private instance) is made. Each button says what it costs." One panel per product with the product's declared test actions (Gunnar: hosted-10gb, private-small; Holger: repository-10gb, private-small); the button reads " · " and adds " · LIVE — you will be charged" on a live key; disabled with its reason ("a test buy needs an admin passkey"). Press: POST /api/test-purchase/start → TestPurchase.Start (chapter 1 §1.2). Panel "Runs" (started, product, action, account, instance, state, where); a run shows "mail goes to …" and, on a live key, the link "LIVE — you will be charged: pay at the vendor's page".

Sites, /sites (W/pages/sites.rs)

"Your own sites. Ordering one sets it up; what it costs is on its row."

element what it does
panel "This box": button Update this box to (disabled: "an admin updates the box", "asked: the box is taking it") POST /api/self/update → the box's self-update (chapter 2 §2.7)
panel "Certificates": per host the lines and Try now POST /api/self/certs/try
panel "Plugins": button Update plugins; a table per plugin (unchanged / replaced / held / refused) POST /api/plugins/update → Plugins.Update (chapter 2 §2.6)
button New site → the form "New site" / "Edit ": Name, Domain, Product, First admin, One of my own sites, May be terminated, Stripe account, Mode test/live, Stripe secret key, Webhook secret ("keep the one held"), Term days, Term months, Grace hours, Margin basis points, Media; buttons Add site / Save, Cancel; <details> "Changes" POST /api/sites/put → SiteBook.PutSite (secrets are kept in <data>/solo/app-secrets.json, shown as a fingerprint)
per site: Edit; the state badge; "Running at https://…"; the link Pay while an order waits for payment; the settles (armed, then confirmed) GET /api/sites/book; POST /api/estate-orders/{e}/settle
the order form (not ordered yet): Size, First admin's e-mail, Seed key (public, optional), button Order ; else "Cannot be ordered: …" POST /api/sites/order → EstateOrder.OrderSite (EUR 0.50 at the page)
a ready site: Update to , the status line, the switch Live updates — on/off EstateOrder.UpdateEstate, UpdateStatus, SetLiveUpdate
the product's own orders: each kind with its params and Queue; the answer EstateOrder.ProductOrderKinds, ProductOrder, ProductOrderAnswer
Logs / Hide logs with "plain text" per machine EstateOrder.NarrateLog
a terminable site: "Terminate this site: its machines, disks and DNS names go. Type to confirm." button Terminate EstateOrder.Terminate

Iron, /iron (W/pages/iron.rs)

"The cloud account: every estate's machines, disks and backups, and what no order owns." One panel per cloud account; per estate the owned rows (id, size, where, ref) and the backup lines; "Needs you · N" tasks (orphans, missing). (GET /api/iron → Products.List

7.3 Sessions and sign-in

The session cookie is __Host-monetize_ui_session, HttpOnly, 12 h; the step-up window is the identity core's (S/auth.rs:37-61). Passkeys, the session MAC key and the revocation epoch live in <data>/monetize-ui/state.redb. The identity core (nornir-identity) is a private site, invite only, no mail: the inviter hands the link over. The break-glass token (console_admin_token, fed by the kit) enrols the FIRST operator only; the door's POST /passkeys/reset disables every passkey so it can be used again. With neither passkeys nor a token the console runs open and refuses to bind anywhere but loopback. A standalone monetize-ui-server has no operator client: refunds, settles, the site verbs, CODE RED, logs and plugins work only in the ONE box's console (S/main.rs:234-240; crates/monetize-admin/src/console.rs:103-115).

7.4 The command line: monetize

crates/monetize-cli. Every command is ONE request at the box's door :8475 over mutual TLS with the operator's installer certificate (--door/MONETIZE_DOOR, --material/MONETIZE_MATERIAL); the door forwards it to monetize with the box's operator bearer. "Every answer is the box's own; this tool decides nothing." The kit runs it as cargo xtask site|estate|box …. There is no feed verb: the feed is the kit's cargo xtask feed (PUT /feed), chapter 2 §2.4.

verb door → service
site list GET /site/list → ListSites
site order <site> [--size s] --admin <addr> [--seed-key <file>] [--return <url>] POST /site/order → OrderSite
site test-order <site> [--size] --admin <addr> [--seed-key] POST /site/test-order: paid on the box with the vendor's TEST card, refused on a live key, no return page
site status <site> Status
site narrate <site> [<vm>] [--tail n]; site installer-log <site> <vm> [--since n] NarrateLog; InstallLog
site recheck | retry | abandon <site> POST /site/settle → Settle ("Check again", "Set up again", "Abandon this install")
site open-pin <site> Settle open-pin: every unpinned role's pin window opens without end; a pinned key is never replaced
site cancel <site> Cancel
site change-plan <site> [--main-plan] [--front-plan] [--data-gib n] [--set f=n]… [--test] ChangePlan
site terminate <site> --confirm <name> Terminate; --backup is refused [terminate-no-backup]; nothing is refunded
site first-link <site> [--to addr] FirstLink (the first sign-in link again)
site refeed <site> [--only mail_relay|public_repositories|public_site]… Refeed (the refeed order)
site kinds <site>; site do <site> <kind> [k=v]… [--file k=path] [--no-wait]; site do-answer <site> <seq> ProductOrderKinds, ProductOrder, ProductOrderAnswer (the product's own orders, e.g. Holger's downstream_invite)
site show [<site>]; site add <site> --domain … --first-admin … [fields]; site edit <site> [fields] SiteBook.ShowSite / PutSite; fields: --domain --product --first-admin --operator-site yes|no --terminable yes|no --vendor stripe/<acct> --stripe-key-file --webhook-secret-file --mode test|live --term-days --term-months --grace-hours --margin-bp --media-from (secrets from files, never printed)
estate update <e> [--release id] [--front-first]; estate update-status <e>; estate live-update <e> on|off; estate update-cancel <e> [--apply] UpdateEstate, UpdateStatus, SetLiveUpdate, CancelUpdate
estate adopt <site> --front --main --twin --feed-out <file> [--machines] [--zone] [--release-of r=id]… [--again] AdoptEstate: an estate laid elsewhere is taken in; the feed is written 0600 to the file
box running; box update [--apply]; box update --back [--apply]; box certs [--try host]; box plugins [--apply]; box inventory /self/running, /self/update, /self/certs, POST /box/plugins → Plugins.Update, GET /cloud/inventory
estates GET /estates/overview → Estates.Overview
front certs --at https://<site>/<door> (--token-file | --cookie-file) [--ca] [--try] the one command that asks a product front, not the ONE box

The door's other routes the kit uses (crates/monetize-admin/src/door.rs:1868-1957): PUT/GET /feed (the GET returns key names only), PUT /feed/next, GET /feed/applied, GET /offer, POST /passkeys/reset, POST /attach/token, POST /attach/revoke, POST /restore, GET /money/log (the money lines, 2000 by default, ?tail=N up to 50 000).

7.5 The alert mail

Every line monetize writes for its operator starts ALERT <code> (chapter 9). PID 1 hears it, keeps it by its code and mails it to secret.alert_to through the box's own relay (secret.smtp_url): the first mail at once, then at most one per 10 min per code; code-red never waits and stays raised on the console's banner until the step that clears it succeeds (crates/monetize-admin/src/alerts.rs:1-45). The money lines (signup, callback, reconcile, nonpay, card check, payment, purchase, renewal, refund, site order, estate order, test run) are also kept at <data>/money/lines.log (8 MiB, rotated once).