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

nordisk/monetize

UI.md

The console — what it is, and what it must stop being

Written 2026-09-05, against a live console whose first tab was a black page and a mass of text: the details belong behind a disclosure, not on the surface.

This is the spec. BASE-MODEL.md is the model; this is what the model looks like on a screen. Build against this, and delete what it replaces.


What this console IS

An administrative system for resource allocation. Money arrives, resources get bought, tenants consume them. The operator needs three things and nothing else on the surface:

  1. each individual — what one tenant consumes, is allowed, and is backed by
  2. the system as a whole — the fleet total against what the iron can serve
  3. trends — where both are heading, and how long there is

monetize serves many products. gunnar is one row, not the application. holger and any further product are peers. Nothing may assume a single product.

The six rules of the surface

  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, never on the page. The cannot grow sentence is the test case: it is important and it is three lines long, so it is a badge.
  3. Plain words on the surface, jargon underneath. "wall", not Enforced. "still costs", not tombstoned_bytes. The precise name appears in the detail.
  4. A trend is one line. ▲ 12 GiB/wk · Full in ~9 weeks. Never a chart the operator has to interpret. A projection carries its uncertainty (~) or it is a confident wrong date someone plans a re-image around.
  5. Sort by who needs attention, never alphabetically. The tenant at their cap is the first row.
  6. Outcomes, not steps. Sold bob 90 GiB · 1 980.00 SEK is the row; the six ledger steps live behind a disclosure. An operator reads outcomes and opens steps only when something is wrong.

The ESTATE is the top level (owner 2026-10-02)

The console administers several estates — gunnar.rs, a company's gunnar.acme.se, test2.gunnar.rs, holger.rs — and every one of them is an equal, full estate. There are no roles and no labels: nothing on any screen calls an estate production, test or private. The one difference between two estates of the same product is a SETTING — which Stripe keys it holds, live or test — shown as Stripe: live / Stripe: test on the Overview and under Settings.

[ gunnar.rs ▾ ]   Overview                                         You ▾
  gunnar                 ┌──────────────┐
    gunnar.rs            │ Map          │  the same left menu for EVERY estate
    gunnar.acme.se       │ Customers    │
    test2.gunnar.rs      │ Money · Tax  │
  holger                 │ Offer        │
    holger.rs            │ Events       │
    test2.holger.rs      │ Settings     │
                         └──────────────┘

Where the screens below now live: 1 · Products → the product lines on Map, the spend on Money, the terms on Offer; 2 · A product → Customers (tenants, sales, silent, friction) and Map (fleet, iron, growth, the board); 3 · A tenant → Customers; 4 · Activity → Money, its refused deliveries → Events; 5 · Tax → Money → Tax.

ACCOUNTS — ONE monetize (5.4)

Accounts in the top bar (it replaces the old top-level Customers page; /customers opens it too). One monetize serves several products, each billed through its OWN Stripe. An account is (product, tenant); members are never accounts. A Private Gunnar is another ROW on its gunnar account.

Accounts                                     GET /api/accounts
product | tenant | rows                                  | Stripe               | status
gunnar  | acme   | SaaS plan · 1 Private Gunnar instance | stripe/gunnar · live | paid until 2026-11-05
holger  | acme   | SaaS plan                             | stripe/holger · live | paid until 2026-11-05

/account?product=gunnar&tenant=acme          GET /api/accounts/gunnar/acme
kind             | plan / stop        | host           | last heard | billing                | since
saas             | 10gb               | —              | —          | paid until 2026-11-05  | 2025-10-01
private-instance | 132.00 EUR a month | acme.gunnar.rs | never      | ready until 2027-04-07 | 2025-10-08
Payments: when | reference | outcome | what

Pull model: monetize never reaches into an estate; every estate calls monetize. last heard is how long ago a private instance's estate last called home (2 min ago), never until the pull channel lands; — for the SaaS plan. The cells live in monetize_ui_web::accounts (host-tested); tests/accounts.rs draws both screens from the server's answers.

GUARDIAN: repair cases per estate (5.4 resilience)

Guardian in the top bar. /guardian lists every estate (main/twin last heard, auto-fix, open case). /guardian/estate?instance=… shows the boxes, the four buttons Recharge from twin · Review twin · New twin · New server, the auto-fix switch, the cases (newest first) and the newest case's steps, evidence, log and receipt.

[ Recharge from twin ] [*Review twin*] [*New twin*] [ New server ]
Auto-fix: OFF (opt-in) — a person approves every repair

The five screens

1 · Products — the whole estate, one line each

monetize                                              ⌄ alice

  PRODUCTS

  ● gunnar        9 tenants     184 / 300 GiB      ▲ 12 GiB/wk
                  ────────────────────────░░░░     61 %

  ○ holger        not connected

  Lifetime      €2.26 spent  ·  €10.00 cap        ██░░░░░░░░  23 %

A product is a row. The dot is reachability. The spend line is the LIFETIME cap (the €10 is a total, ever, not a month's — so the word is Lifetime, and this month appears only when a monthly window is configured), because that is the number that stops everything. The figure is the ledger's fold, never the cap's cache; no ceiling is said in words, because an unbounded account is the one dangerous thing this screen must not hide.

2 · A product — the fleet

← Products                                              gunnar

         184 GiB used of 300
         ██████████████████████░░░░░░░░
         Full in ~9 weeks  (12 GiB/wk, last 30 d)
                                              ⚠ cannot grow

  TENANTS                                        sort: closest ⌄
  alice        10.2 / 10 GiB   ████████████  AT CAP    →
  bob          3.1 / 10 GiB   ████░░░░░░░░  31 %      →
  + 6 more                                               ⌄

  RECENTLY BOUGHT
  Sep 4   Sold bob 90 GiB           1 980.00 SEK    ✓
  Sep 4   Refunded zz-visibility 1 GiB     1.00 SEK    ↩ refunded
                                              all activity →

A RECENTLY BOUGHT line names the DELTA — what was sold, 90 GiB on a tenant now allowed 100 — and the price the customer paid; the provider's cost sits in the row's title, never added to it.

⚠ cannot grow carries GROWTH_SEALED — a badge holding the paragraph that says a bought disk would be billed and invisible, and that the remedy is a fresh install onto a larger set, not another disk.

ESTATE is the same product's IRON, drawn: one board, one chip per machine (GET /api/estates/<estate>/map, an image/svg+xml rendered by monetize, not by this console). Each chip carries the provider id an operator pastes into a ticket, the plan, and the disk — with the plan labelled plan says and a reading labelled measured, because they are different kinds of fact. A box nobody could ask draws a dash, never a zero. A board with NO chips is the honest picture of a deployment that has not declared [machines], and the caption under it says so and names the verb that writes the block.

3 · A tenant

← gunnar                                              alice

         10.2 GiB of 10 GiB
         ████████████████████  AT CAP
         ↑ 1.4 GiB/wk — hit the cap 3 days ago

                    [ How much? ]

  USING
  Repository data      10.2 GiB / 10 GiB   ████████  wall
  Cache                10.9 GiB            ─────     not billed
  Deleted, not purged  83 MB               ─────     still costs
  Repositories         26                  ─────

  BACKED BY
  10 GiB disk · se-sto1 · bought Sep 4 · 220.00 SEK      ⌄

  History                                                →

wall / billed / not billed / still costs are rule 3, and they are read off the PRICE LIST, not invented: a meter is billed iff the product's list charges for its unit in the tenant's market, wall is what Enforced says whatever the list says, still costs is deleted-not-purged bytes the list still charges for. With no list at all — the open-source deployment — everything but a wall reads not billed. BACKED BY is rule 4 of the base model made visible — what was bought, distinct from what is allowed.

How much? is the order. A form of caps — disk in GiB; CPU and RAM only where the product meters them per tenant — pre-filled with what the tenant holds, answered BEFORE any money moves with three lines:

  Sells      90.0 GiB — 90 GiB on gunnar-appliance
  Price      1 980.00 SEK / month          ("free" when the list says 0, or there is none)
  Cost       20.70 EUR                     ("this box · nothing to buy" on the server's own iron)
  Can serve  yes                           (or "no", with the product's sentence, whole)
                              [ Confirm: take the money ]

Only the raise over what the tenant holds is priced and bought (an order for 100 GiB on a tenant holding 10 sells 90 — measured before the delta existed: 110 GiB held and billed where 100 was asked). A downgrade, a product that cannot deliver, a box past its ceiling and a price nobody linked can take are refused by name before the confirm button does anything. There is no plan to pick from: the price list is monetize.toml's and is not a screen.

4 · Activity — the ledger, de-jargoned

← gunnar                                             Activity

  NEEDS YOU  1
  ⚠ zz-visibility — paid, never delivered      Sep 4    →

  Sep 4   Sold bob 90 GiB            1 980.00 SEK    ✓
          └ reserved → bought disk → charged → granted  ⌄
  Sep 4   Refunded zz-visibility 1 GiB      1.00 SEK    ↩

Open attempts float to NEEDS YOU, because an unfinished attempt is the only row that is actually a task. Everything else is history.


5 · Tax — the same ledger, in somebody else's form

                                                   Tax   ← 2026 →
  SE · filed quarterly · turnover 250 000.00 SEK in 2025
  [ 2026-Q1 ] 2026-Q2  2026-Q3  2026-Q4

  SE · 2026-Q1
  05  Momspliktig försäljning som inte ingår i …    1 500.00 SEK
  10  Utgående moms 25 %                              375.00 SEK
  20  Inköp av varor från annat EU-land                       —
  21  Inköp av tjänster från annat EU-land, …          443.50 SEK
  30  Utgående moms 25 %                              110.88 SEK
  48  Ingående moms att dra av                        110.88 SEK
  49  Moms att betala eller få tillbaka               375.00 SEK

  ⤓ Download monetize-se-2026-Q1.csv

Box · label · amount, in the authority's own numbering and the authority's own words, and a CSV of exactly those lines for the accountant. Datasheet tone: no advice, no "you owe", no rhetoric.

A — is not a zero. An authority that asks for a 0 in a box means the digit; a box monetize could not fill is a box nobody computed, and the page never blurs the two. Which boxes exist, what the rates are and how long a period is belong to a JURISDICTION, and a jurisdiction is a linked implementation — so a deployment that has linked none still gets this page, listing what a return is folded from with — in every amount and the reason in the notes. An empty screen would be a lie about a deployment that has simply not said where it files.


What must be BUILT (it does not exist)

What must be DELETED (it is what he is looking at)

Scope is the PRODUCT's call, not a global rule

Each product declares its own meters and their scope. gunnar today: disk per tenant, CPU/RAM per product — a tenant does not own cores. Another product may legitimately meter CPU per user, or disk only in total. Product::meters() is already that seam; nothing outside a product decides which of its meters are per-tenant. The fleet total sums only Scope::Tenant, whatever a product declares, because summing a figure that was never measured per tenant is arithmetic on nothing.

ON THE LIST, not built: resource allocation in gunnar's own web UI

gunnar-ui already shows a namespace its measured usage (/usage). It should also show allocation — what room there is, what a namespace is allowed.

The open-source gunnar-ui has no money in it and must not learn any. No price, no order, no invoice, no "upgrade", no billing link — not in a string, not in a tooltip, not behind a feature flag that could be flipped on by accident. Resources only: used, allowed, remaining. A self-hoster who will never pay anyone must find the page exactly as useful as a paying tenant does.

How the paying half reaches a tenant is a later problem and is deliberately not solved here. Whatever the answer, it does not put money vocabulary into the open-source console.

What must NEVER appear

From BASE-MODEL.md: no "top up" or credit affordance (there is no balance); no number that cannot be rebuilt by folding the ledger; no measurement taken by the console itself; no projection stated as a date without its uncertainty.