console: pool tile, v2 knobs and tier chips on the economics pages
Task 6 of the economics-v2 plan: /fleet/economics gains a fleet-pool tile (id="tile-pool", POOL/draw/headroom, negative headroom in the page's existing warning styling) and knob-drawer rows for the seven new economics-v2 scenario knobs (box_month_server_usd, box_month_device_usd, mem_gb_hour_usd, fleet_break.min_boxes, fleet_break.discount, pool.revenue_frac, pool.topup_usd). /fleet/economics/accounts gains a Tier column (chip-tier-<tier> plus a progressive per-row form posting to the existing /fleet/economics/tier door). The inline knob JS (econ_maths_js, shared by both pages) is extended to match: priceAccount now carries the fleet-revenue and rate-sheet mem-overage terms compute.go's PriceAccount has had since Task 2 (the JS twin was missing both), every new meter read is guarded against undefined, and a tierOf/isPoolTier pair mirrors economicsview.go's tier-aware view-layer override — applied AFTER priceAccount in both pages' recompute(), so a knob tweak can never flip a pooled row back to priced. A new poolLedger(scenario, tiers, rows) JS function mirrors buildEconomicsView's PoolLedger construction, scaling the server's new poolTopupUnitAccrual payload field (Σ 1/daysInMonth(d) over the period, additive economicsPayload key) by the live topup_usd knob — exact under any knob value, no client-side re-derivation of the per-day accrual loop. A pooled row's margin cell renders an em-dash (server template and JS twin, marginCell) rather than a misleading "0%"; the margin column carries a title= tooltip explaining that fleet-tier accounts legitimately show 100% margin (customer-owned hardware, zero cost to the platform, by design). go build ./..., go vet ./..., go test ./... -count=1 all clean; GOOS=darwin cross-compile clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+537
−24
internal/console/economicspage_test.go+279 −0internal/console/economicsview.go+31 −10internal/console/server.go+24 −0internal/console/templates/economics.html+138 −6internal/console/templates/economics_accounts.html+51 −8internal/console/templates/layout.html+14 −0
diff --git a/internal/console/economicspage_test.go b/internal/console/economicspage_test.go| index c62ddc7ab..c67b423ad 100644 |
| --- a/internal/console/economicspage_test.go |
| +++ b/internal/console/economicspage_test.go |
| @@ -505,3 +505,282 @@ func TestEconomicsAccountsPageThreadsPeriod(t *testing.T) { |
| t.Fatalf("missing the Month-to-date period link preserving pos=partial with no period/cursor:\n%s", body) |
| } |
| } |
| + |
| +// ---- Task 6: pool tile, v2 knobs, tier chips ------------------------------- |
| + |
| +// TestEconomicsPagePoolTile proves the dashboard renders id="tile-pool" with |
| +// the "POOL $X · draw $Y · headroom $Z" figures straight from |
| +// EconomicsView.Pool — templates.ExecuteTemplate against a hand-built view |
| +// (TestEconomicsPageMTDShowsProjection's pattern), so the expected dollar |
| +// strings are exact, known numbers rather than whatever a real sweep |
| +// produces. |
| +func TestEconomicsPagePoolTile(t *testing.T) { |
| + view := &EconomicsView{ |
| + Period: "mtd", |
| + Pool: PoolLedger{PoolUSD: 123.45, DrawUSD: 50, HeadroomUSD: 73.45, PayingRevenueUSD: 1000}, |
| + } |
| + p := page{Title: "Fleet economics", CSRF: "tok", Economics: view} |
| + var buf bytes.Buffer |
| + if err := templates.ExecuteTemplate(&buf, "economics", p); err != nil { |
| + t.Fatalf("render economics: %v", err) |
| + } |
| + html := buf.String() |
| + for _, want := range []string{ |
| + `id="tile-pool"`, |
| + "POOL", |
| + `id="tile-pool-usd">` + usd(123.45), |
| + "draw", |
| + `id="tile-pool-draw">` + usd(50), |
| + "headroom", |
| + `id="tile-pool-headroom">` + usd(73.45), |
| + } { |
| + if !strings.Contains(html, want) { |
| + t.Fatalf("/fleet/economics missing pool tile string %q in:\n%s", want, html) |
| + } |
| + } |
| +} |
| + |
| +// TestEconomicsPagePoolTileNegativeHeadroomWarns: a negative HeadroomUSD (the |
| +// pool is overdrawn this period) must render honestly — never clamped to |
| +// zero (PoolLedger's own doc comment) — AND in the page's existing warning |
| +// styling, per the brief. The headroom span carries class="error" (the same |
| +// class every other on-page warning uses — layout.html's ".error { color: |
| +// var(--bad); }") exactly when HeadroomUSD is negative, never otherwise. |
| +func TestEconomicsPagePoolTileNegativeHeadroomWarns(t *testing.T) { |
| + view := &EconomicsView{ |
| + Period: "mtd", |
| + Pool: PoolLedger{PoolUSD: 10, DrawUSD: 50, HeadroomUSD: -40}, |
| + } |
| + p := page{Title: "Fleet economics", CSRF: "tok", Economics: view} |
| + var buf bytes.Buffer |
| + if err := templates.ExecuteTemplate(&buf, "economics", p); err != nil { |
| + t.Fatalf("render economics: %v", err) |
| + } |
| + html := buf.String() |
| + if !strings.Contains(html, `id="tile-pool-headroom" class="error"`) { |
| + t.Fatalf("/fleet/economics: negative headroom must carry the warning class:\n%s", html) |
| + } |
| + if !strings.Contains(html, `class="error">`+usd(-40)) { |
| + t.Fatalf("/fleet/economics missing the honest negative headroom figure %q in:\n%s", usd(-40), html) |
| + } |
| + |
| + // Positive headroom: no warning class at all. |
| + viewOK := &EconomicsView{Period: "mtd", Pool: PoolLedger{PoolUSD: 50, DrawUSD: 10, HeadroomUSD: 40}} |
| + pOK := page{Title: "Fleet economics", CSRF: "tok", Economics: viewOK} |
| + var bufOK bytes.Buffer |
| + if err := templates.ExecuteTemplate(&bufOK, "economics", pOK); err != nil { |
| + t.Fatalf("render economics (positive headroom): %v", err) |
| + } |
| + if strings.Contains(bufOK.String(), `id="tile-pool-headroom" class="error"`) { |
| + t.Fatalf("/fleet/economics: positive headroom must not carry the warning class:\n%s", bufOK.String()) |
| + } |
| +} |
| + |
| +// TestEconomicsPagePooledMarginIsDash is the carried ruling (Task 6 review, |
| +// point 3): a pooled row's margin cell renders an em-dash, never "0%" — |
| +// PriceAccount's MarginFrac is genuinely 0 for the pool override |
| +// (buildEconomicsView), but "0%" reads as a account LOSING money, not one |
| +// that simply isn't individually priced. Checked on both /fleet/economics |
| +// and /fleet/economics/accounts, server-rendered (no JS in this test suite). |
| +func TestEconomicsPagePooledMarginIsDash(t *testing.T) { |
| + pooledRow := economics.AccountEconomics{Account: fixtureAcct, CostUSD: 5, RevenueUSD: 0, MarginFrac: 0, FreeTierPos: "pooled"} |
| + |
| + view := &EconomicsView{Period: "mtd", Accounts: []economics.AccountEconomics{pooledRow}} |
| + p := page{Title: "Fleet economics", CSRF: "tok", Economics: view} |
| + var buf bytes.Buffer |
| + if err := templates.ExecuteTemplate(&buf, "economics", p); err != nil { |
| + t.Fatalf("render economics: %v", err) |
| + } |
| + html := buf.String() |
| + if !strings.Contains(html, `class="js-acct-margin">—<`) { |
| + t.Fatalf("/fleet/economics: pooled row's margin cell must be an em-dash, not a percentage:\n%s", html) |
| + } |
| + if strings.Contains(html, `class="js-acct-margin">0%<`) { |
| + t.Fatalf("/fleet/economics: pooled row's margin cell must not render \"0%%\":\n%s", html) |
| + } |
| + |
| + rows := pageEconAccounts([]econAccountRow{{Account: fixtureAcct, Meters: map[string]float64{}, Econ: pooledRow}}, econAccountQuery{}, econAccountPageSize) |
| + pAcct := page{Title: "Fleet economics: accounts", CSRF: "tok", Economics: view, EconAccounts: rows, EconQuery: econAccountQuery{}} |
| + var bufAcct bytes.Buffer |
| + if err := templates.ExecuteTemplate(&bufAcct, "economics_accounts", pAcct); err != nil { |
| + t.Fatalf("render economics_accounts: %v", err) |
| + } |
| + htmlAcct := bufAcct.String() |
| + if !strings.Contains(htmlAcct, `class="js-acct-margin">—<`) { |
| + t.Fatalf("/fleet/economics/accounts: pooled row's margin cell must be an em-dash, not a percentage:\n%s", htmlAcct) |
| + } |
| +} |
| + |
| +// TestEconomicsPageFleetMarginLegend is carried ruling 4: fleet-only |
| +// accounts read 100%% margin by design (customer hardware, zero cost to the |
| +// platform — PriceAccount's cost formula has no fleet term at all), and a |
| +// number that looks like a modelling error gets an explanation somewhere on |
| +// the page (dashboard-truth rule, CLAUDE.md) — either a title= tooltip on |
| +// the margin cell/header or a legend line. Checked on both pages. |
| +func TestEconomicsPageFleetMarginLegend(t *testing.T) { |
| + // A margin-column explanation only means anything once the accounts |
| + // table itself renders (both pages hide the table entirely with zero |
| + // rows), so this seeds one row on each — not a claim that the row |
| + // itself is a fleet account, just enough for the header/legend markup |
| + // carrying the explanation to be on screen at all. |
| + row := economics.AccountEconomics{Account: fixtureAcct, CostUSD: 0, RevenueUSD: 10, MarginFrac: 1, FreeTierPos: "paying"} |
| + |
| + view := &EconomicsView{Period: "mtd", Accounts: []economics.AccountEconomics{row}} |
| + p := page{Title: "Fleet economics", CSRF: "tok", Economics: view} |
| + var buf bytes.Buffer |
| + if err := templates.ExecuteTemplate(&buf, "economics", p); err != nil { |
| + t.Fatalf("render economics: %v", err) |
| + } |
| + if !strings.Contains(buf.String(), "customer-owned hardware") && !strings.Contains(buf.String(), "customer hardware") { |
| + t.Fatalf("/fleet/economics missing a fleet-margin explanation (title= or legend):\n%s", buf.String()) |
| + } |
| + |
| + rows := pageEconAccounts([]econAccountRow{{Account: fixtureAcct, Meters: map[string]float64{}, Econ: row}}, econAccountQuery{}, econAccountPageSize) |
| + pAcct := page{Title: "Fleet economics: accounts", CSRF: "tok", Economics: view, EconAccounts: rows, EconQuery: econAccountQuery{}} |
| + var bufAcct bytes.Buffer |
| + if err := templates.ExecuteTemplate(&bufAcct, "economics_accounts", pAcct); err != nil { |
| + t.Fatalf("render economics_accounts: %v", err) |
| + } |
| + if !strings.Contains(bufAcct.String(), "customer-owned hardware") && !strings.Contains(bufAcct.String(), "customer hardware") { |
| + t.Fatalf("/fleet/economics/accounts missing a fleet-margin explanation (title= or legend):\n%s", bufAcct.String()) |
| + } |
| +} |
| + |
| +// TestEconomicsKnobDrawerHasV2Knobs proves the shared knob drawer |
| +// (econ_knob_drawer) exposes all seven new economics-v2 knobs the brief |
| +// names, with values sourced from the active scenario — box_month_server_usd |
| +// and box_month_device_usd and mem_gb_hour_usd (Sheet), fleet_break.min_boxes |
| +// and fleet_break.discount, pool.revenue_frac and pool.topup_usd. |
| +func TestEconomicsKnobDrawerHasV2Knobs(t *testing.T) { |
| + sc := economics.DefaultScenario() |
| + sc.Sheet.BoxMonthServerUSD = 7.5 |
| + sc.Sheet.BoxMonthDeviceUSD = 1.5 |
| + sc.Sheet.MemGBHourUSD = 0.02 |
| + sc.FleetBreak.MinBoxes = 12 |
| + sc.FleetBreak.Discount = 0.3 |
| + sc.Pool.RevenueFrac = 0.15 |
| + sc.Pool.TopupUSD = 250 |
| + |
| + view := &EconomicsView{Period: "mtd", Scenario: sc} |
| + p := page{Title: "Fleet economics", CSRF: "tok", Economics: view} |
| + var buf bytes.Buffer |
| + if err := templates.ExecuteTemplate(&buf, "economics", p); err != nil { |
| + t.Fatalf("render economics: %v", err) |
| + } |
| + html := buf.String() |
| + for _, want := range []string{ |
| + `id="knob-sheet-box-month-server-usd" value="7.5"`, |
| + `id="knob-sheet-box-month-device-usd" value="1.5"`, |
| + `id="knob-sheet-mem-gb-hour-usd" value="0.02"`, |
| + `id="knob-fleet-break-min-boxes" value="12"`, |
| + `id="knob-fleet-break-discount" value="0.3"`, |
| + `id="knob-pool-revenue-frac" value="0.15"`, |
| + `id="knob-pool-topup-usd" value="250"`, |
| + } { |
| + if !strings.Contains(html, want) { |
| + t.Fatalf("knob drawer missing %q in:\n%s", want, html) |
| + } |
| + } |
| +} |
| + |
| +// TestEconomicsJSTwinsShape is a structural stand-in for a browser test (this |
| +// suite has no headless DOM — same rationale as |
| +// TestEconomicsPageBillServicesGuardShape): greps the served page's inline |
| +// script for the exact shapes the carried rulings (Task 6 review) demand, so |
| +// a future edit that drops one of them fails a test instead of only a manual |
| +// re-review. |
| +// |
| +// Ruling 1 (JS meter guards + fleet formulas): every new meter read is |
| +// guarded against undefined, the fleet revenue term sits OUTSIDE the |
| +// cost-plus/rate-sheet branch (mirroring compute.go's PriceAccount exactly), |
| +// the volume-break discount applies only when min_boxes>0 AND avg boxes |
| +// clears it, and the rate-sheet arm's mem overage term is present. |
| +// |
| +// Ruling 2 (tier-aware JS): a tierOf/isPoolTier pair mirrors |
| +// economicsview.go's same-named functions, and poolLedger mirrors |
| +// buildEconomicsView's PoolLedger construction — including the exact |
| +// linear-scaling topup term (payload.poolTopupUnitAccrual × the live |
| +// topup_usd knob) rather than any client-side re-derivation of the per-day |
| +// sum. |
| +func TestEconomicsJSTwinsShape(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + seedEconomicsGolden(t, fx) |
| + c := signInPaul(t, fx) |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + status, _, body := get(t, c, fx.ts.URL+"/fleet/economics") |
| + if status != 200 { |
| + t.Fatalf("/fleet/economics = %d, want 200:\n%s", status, body) |
| + } |
| + |
| + for _, want := range []string{ |
| + // Ruling 1: guarded fleet meter reads, fleet term outside the mode |
| + // branch, the SAME bMem binding used in the rate-sheet arm's mem |
| + // overage term. |
| + `(meters.fleet_box_hours_server || 0)`, |
| + `(meters.fleet_box_hours_device || 0)`, |
| + "bMem * sc.sheet.mem_gb_hour_usd", |
| + "revenue += fleetRevenue", |
| + "sc.fleet_break.min_boxes > 0 && avgBoxes >= sc.fleet_break.min_boxes", |
| + // Ruling 2: tier helpers + pool ledger twin, exact linear topup scaling. |
| + "function tierOf(", |
| + "function isPoolTier(", |
| + "function poolLedger(", |
| + "sc.pool.topup_usd * payload.poolTopupUnitAccrual", |
| + // Ruling 3: marginCell renders the em-dash, never "0%", for a pooled row. |
| + "function marginCell(", |
| + `pos === "pooled"`, |
| + // readScenarioFromDOM reads all seven new knobs back into the scenario. |
| + "sc.sheet.box_month_server_usd = numVal(", |
| + "sc.sheet.box_month_device_usd = numVal(", |
| + "sc.sheet.mem_gb_hour_usd = numVal(", |
| + "sc.fleet_break.min_boxes = numVal(", |
| + "sc.fleet_break.discount = numVal(", |
| + "sc.pool.revenue_frac = numVal(", |
| + "sc.pool.topup_usd = numVal(", |
| + // recompute() applies the tier override AFTER priceAccount, so a knob |
| + // tweak can never flip a pooled row back to priced. |
| + "var tiers = payload.tiers || {};", |
| + "isPoolTier(tierOf(tiers, acct))", |
| + `a.freeTierPos = "pooled";`, |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("/fleet/economics inline script missing %q", want) |
| + } |
| + } |
| +} |
| + |
| +// TestEconomicsAccountsPageTierChipAndForm proves the accounts page renders |
| +// a chip-tier-<tier> chip for a tiered account and a per-row operator form |
| +// (progressive: a plain <select>+submit, works without JS) posting to |
| +// /fleet/economics/tier — the door economicspass.go's |
| +// handleEconomicsTierSave already serves (Task 4/5). |
| +func TestEconomicsAccountsPageTierChipAndForm(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + seedEconomicsGolden(t, fx) |
| + if err := fx.st.SetAccountTier(OpsAccountID, "hosted"); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + c := signInPaul(t, fx) |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + status, _, body := get(t, c, fx.ts.URL+"/fleet/economics/accounts") |
| + if status != 200 { |
| + t.Fatalf("/fleet/economics/accounts = %d, want 200:\n%s", status, body) |
| + } |
| + for _, want := range []string{ |
| + `class="chip chip-tier-hosted"`, |
| + `action="/fleet/economics/tier"`, |
| + fmt.Sprintf(`name="account" value="%s"`, fixtureAcct), |
| + `<select name="tier">`, |
| + `value="hosted" selected`, |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("/fleet/economics/accounts missing %q in:\n%s", want, body) |
| + } |
| + } |
| +} |
diff --git a/internal/console/economicsview.go b/internal/console/economicsview.go| index 290e722a3..40a74de75 100644 |
| --- a/internal/console/economicsview.go |
| +++ b/internal/console/economicsview.go |
| @@ -139,6 +139,21 @@ type economicsPayload struct { |
| // way buildEconomicsView does (Task 6), rather than this struct |
| // shipping a second, potentially stale copy of the same numbers. |
| Tiers map[string]string `json:"tiers"` |
| + // PoolTopupUnitAccrual is Σ 1/daysInMonth(d) over [from, to] — the same |
| + // per-day sum poolTopupAccrual folds against Scenario.Pool.TopupUSD |
| + // server-side, but with topupUSD fixed at 1 so it carries no dollar |
| + // value of its own: poolTopupAccrual(from, to, x) == x * |
| + // PoolTopupUnitAccrual for any x, by that function's own linearity (each |
| + // day contributes topupUSD/daysInMonth(that day), so scaling topupUSD |
| + // scales the whole sum). The JS twin (econ_maths_js's poolLedger) |
| + // multiplies this dimensionless figure by whatever topup_usd the |
| + // operator's live knob currently reads, reproducing poolTopupAccrual |
| + // EXACTLY under any knob value with no client-side re-derivation of the |
| + // per-day loop — the carried ruling (Task 6 review) this field exists to |
| + // satisfy: the alternative (shipping the accrued DOLLAR figure for the |
| + // knob's STARTING value and trying to rescale it) breaks at |
| + // oldTopup==0, this doesn't. Additive key. |
| + PoolTopupUnitAccrual float64 `json:"poolTopupUnitAccrual"` |
| } |
| // economicsCacheEntry is one period's cached sweep — the metricsCache |
| @@ -526,7 +541,12 @@ func (s *Server) buildEconomicsView(ctx context.Context, period string) (*Econom |
| payingRevenue += row.RevenueUSD |
| } |
| } |
| - poolUSD := sc.Pool.RevenueFrac*payingRevenue + poolTopupAccrual(from, to, sc.Pool.TopupUSD) |
| + // poolTopupUnitAccrual: see economicsPayload.PoolTopupUnitAccrual's doc |
| + // comment on the field this feeds — topupUSD fixed at 1 turns |
| + // poolTopupAccrual into the dimensionless per-day sum the JS twin scales |
| + // by the live topup knob. |
| + poolTopupUnitAccrual := poolTopupAccrual(from, to, 1) |
| + poolUSD := sc.Pool.RevenueFrac*payingRevenue + sc.Pool.TopupUSD*poolTopupUnitAccrual |
| pool := PoolLedger{ |
| PoolUSD: poolUSD, |
| DrawUSD: poolDraw, |
| @@ -571,15 +591,16 @@ func (s *Server) buildEconomicsView(ctx context.Context, period string) (*Econom |
| } |
| payload := economicsPayload{ |
| - Scenario: sc, |
| - Meters: meters, |
| - BoxLoads: sweep.PerBox, |
| - BoxSpecs: sc.RateCard.Boxes, |
| - Bills: bills, |
| - CoveredDays: sweep.CoveredDays, |
| - PeriodDays: sweep.PeriodDays, |
| - DaysInMonth: daysInMonth, |
| - Tiers: tiers, |
| + Scenario: sc, |
| + Meters: meters, |
| + BoxLoads: sweep.PerBox, |
| + BoxSpecs: sc.RateCard.Boxes, |
| + Bills: bills, |
| + CoveredDays: sweep.CoveredDays, |
| + PeriodDays: sweep.PeriodDays, |
| + DaysInMonth: daysInMonth, |
| + Tiers: tiers, |
| + PoolTopupUnitAccrual: poolTopupUnitAccrual, |
| } |
| raw, err := json.Marshal(payload) |
| if err != nil { |
diff --git a/internal/console/server.go b/internal/console/server.go| index 03a05580e..b8d4596fb 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -193,6 +193,30 @@ var templates = template.Must(template.New("").Funcs(template.FuncMap{ |
| } |
| return fmt.Sprintf("%.1f×", 1/(1-margin)) |
| }, |
| + // marginCell renders MarginFrac as pctf normally, but an em-dash for a |
| + // pooled row (FreeTierPos=="pooled") — Task 6's carried ruling: a |
| + // pool/try/""-tier account's revenue is pooled, not individually priced |
| + // (buildEconomicsView's tier override), so its MarginFrac is genuinely |
| + // 0 — but "0%" reads as an account LOSING money, not one that simply |
| + // isn't priced on its own. The JS twin (econ_maths_js's marginCell) |
| + // mirrors this exactly. |
| + "marginCell": func(frac float64, pos string) string { |
| + if pos == "pooled" { |
| + return "—" |
| + } |
| + return fmt.Sprintf("%.0f%%", frac*100) |
| + }, |
| + // tierWord renders a fleet-pool tier (store.go's validAccountTiers) for |
| + // display: "" (never assigned) reads "unassigned" rather than a blank |
| + // chip label; every other value (try/pool/hosted/fleet/dedicated) |
| + // passes through unchanged. Feeds both a tier chip's visible text and |
| + // its "chip-tier-<word>" class (economics_accounts.html, Task 6). |
| + "tierWord": func(tier string) string { |
| + if tier == "" { |
| + return "unassigned" |
| + } |
| + return tier |
| + }, |
| "add": func(vs ...float64) float64 { |
| var sum float64 |
| for _, v := range vs { |
diff --git a/internal/console/templates/economics.html b/internal/console/templates/economics.html| index f204a2962..5075f4995 100644 |
| --- a/internal/console/templates/economics.html |
| +++ b/internal/console/templates/economics.html |
| @@ -23,7 +23,16 @@ |
| and its own recompute() share one source for both the editable knobs |
| and the maths, instead of a second ~300-line copy. See |
| economics_accounts.html's header comment for that page's (smaller) |
| - recompute(). */}} |
| + recompute(). |
| + |
| + DataJSON payload keys (economicsPayload, economicsview.go) this page's |
| + JS reads — every key here must match that struct's json tag exactly, |
| + the same rule the struct's own doc comment states from the Go side: |
| + scenario, meters, boxLoads, boxSpecs, bills, coveredDays, periodDays, |
| + daysInMonth, tiers, poolTopupUnitAccrual (Task 6 — the dimensionless |
| + Σ 1/daysInMonth(d) over the period; econ_maths_js's poolLedger scales |
| + it by the live pool.topup_usd knob rather than re-deriving the per-day |
| + sum client-side — see that field's own doc comment for why). */}} |
| {{define "economics"}}{{template "top" .}} |
| <h1>Fleet economics</h1> |
| {{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| @@ -83,19 +92,31 @@ |
| <p class="tile-value" id="tile-margin">{{pctf $margin}}</p> |
| <p class="tile-detail">{{.Economics.CoveredDays}}/{{.Economics.PeriodDays}} days metered</p> |
| </div> |
| + {{/* Fleet pool tile (Task 6): PoolLedger's own doc comment (economicsview.go) |
| + for the exact formula — a share of paying revenue plus a day-accrued |
| + monthly top-up, against the pool-tier accounts' own real spend. Headroom |
| + MAY be negative (the pool is overdrawn this period) and renders honestly, |
| + never clamped — the negative case carries the page's existing warning |
| + styling (.error, same class every other on-page warning uses) on the |
| + headroom figure alone, not the whole tile. */}} |
| + <div class="tile" id="tile-pool"> |
| + <h2>Fleet pool</h2> |
| + <p class="tile-value">POOL <span id="tile-pool-usd">{{usd .Economics.Pool.PoolUSD}}</span> · draw <span id="tile-pool-draw">{{usd .Economics.Pool.DrawUSD}}</span> · headroom <span id="tile-pool-headroom"{{if lt .Economics.Pool.HeadroomUSD 0.0}} class="error"{{end}}>{{usd .Economics.Pool.HeadroomUSD}}</span></p> |
| + <p class="tile-detail">{{.Economics.CoveredDays}}/{{.Economics.PeriodDays}} days metered</p> |
| + </div> |
| </section> |
| <h2>Accounts</h2> |
| {{if .Economics.Accounts}} |
| <div class="tablewrap"> |
| <table> |
| - <tr><th>Account</th><th>Cost</th><th>Revenue</th><th>Margin</th><th>Free tier</th></tr> |
| + <tr><th>Account</th><th>Cost</th><th>Revenue</th><th title="Pooled rows (free-tier position "pooled") show an em-dash: their revenue is shared through the fleet pool, not individually priced, so a margin fraction has nothing to divide. Fleet-tier rows can legitimately show 100%: fleet boxes are customer-owned hardware, zero cost to the platform, by design.">Margin</th><th>Free tier</th></tr> |
| {{range .Economics.Accounts}} |
| <tr data-account="{{.Account}}"> |
| <td>{{.Account}}</td> |
| <td class="js-acct-cost">{{usd .CostUSD}}</td> |
| <td class="js-acct-revenue">{{usd .RevenueUSD}}</td> |
| - <td class="js-acct-margin">{{pctf .MarginFrac}}</td> |
| + <td class="js-acct-margin">{{marginCell .MarginFrac .FreeTierPos}}</td> |
| <td class="js-acct-freetier">{{.FreeTierPos}}</td> |
| </tr> |
| {{end}} |
| @@ -245,9 +266,21 @@ |
| var acctSet = {}; |
| Object.keys(payload.meters).forEach(function (a) { acctSet[a] = true; }); |
| Object.keys(totalAllocated).forEach(function (a) { acctSet[a] = true; }); |
| + var tiers = payload.tiers || {}; |
| var accounts = {}; |
| Object.keys(acctSet).forEach(function (acct) { |
| - accounts[acct] = priceAccount(sc, acct, payload.meters[acct] || {}, totalAllocated[acct] || 0); |
| + var a = priceAccount(sc, acct, payload.meters[acct] || {}, totalAllocated[acct] || 0); |
| + // Tier-aware view-layer override (economics-v2 Task 5/Task 6), |
| + // applied AFTER priceAccount exactly like buildEconomicsView applies |
| + // it after PriceAccount: a pool/try/""-tier account's revenue is |
| + // pooled, not individually priced, so a knob tweak must never flip a |
| + // pooled row back to priced by recomputing it "inside" the free tier. |
| + if (isPoolTier(tierOf(tiers, acct))) { |
| + a.revenueUSD = 0; |
| + a.marginFrac = 0; |
| + a.freeTierPos = "pooled"; |
| + } |
| + accounts[acct] = a; |
| }); |
| var revenueTotal = 0, acctCostTotal = 0, idleTotal = 0; |
| @@ -284,10 +317,23 @@ |
| if (!a) { return; } |
| setCellText(row, ".js-acct-cost", usd(a.costUSD)); |
| setCellText(row, ".js-acct-revenue", usd(a.revenueUSD)); |
| - setCellText(row, ".js-acct-margin", pctf(a.marginFrac)); |
| + setCellText(row, ".js-acct-margin", marginCell(a.marginFrac, a.freeTierPos)); |
| setCellText(row, ".js-acct-freetier", a.freeTierPos); |
| }); |
| + // Fleet pool tile (Task 6): recomputed from the SAME accounts map the |
| + // account rows above just used, so a knob tweak (revenue_frac, |
| + // topup_usd, or anything upstream of an account's own revenue/cost) |
| + // keeps the tile in lockstep with the account table on screen. |
| + var pool = poolLedger(sc, tiers, accounts); |
| + setText("tile-pool-usd", usd(pool.poolUSD)); |
| + setText("tile-pool-draw", usd(pool.drawUSD)); |
| + var poolHeadroomEl = document.getElementById("tile-pool-headroom"); |
| + if (poolHeadroomEl) { |
| + poolHeadroomEl.textContent = usd(pool.headroomUSD); |
| + poolHeadroomEl.classList.toggle("error", pool.headroomUSD < 0); |
| + } |
| + |
| document.querySelectorAll("[data-box]").forEach(function (row) { |
| var box = row.getAttribute("data-box"); |
| var load = payload.boxLoads[box]; |
| @@ -425,6 +471,20 @@ |
| <div class="knob-row"><label for="knob-sheet-egress-gb-usd">$/GB egress</label><input type="number" step="0.01" min="0" id="knob-sheet-egress-gb-usd" value="{{.Economics.Scenario.Sheet.EgressGBUSD}}"></div> |
| <div class="knob-row"><label for="knob-sheet-storage-gb-month-usd">$/GB-month storage</label><input type="number" step="0.01" min="0" id="knob-sheet-storage-gb-month-usd" value="{{.Economics.Scenario.Sheet.StorageGBMonthUSD}}"></div> |
| <div class="knob-row"><label for="knob-sheet-instance-hour-usd">$/instance-hour</label><input type="number" step="0.001" min="0" id="knob-sheet-instance-hour-usd" value="{{.Economics.Scenario.Sheet.InstanceHourUSD}}"></div> |
| +{{/* economics-v2 (Task 6): fleet box + mem-overage rate-sheet knobs — the |
| + same three RateSheet fields compute.go's PriceAccount reads under |
| + fleetRevenue and the rate-sheet mem-overage term, both modes. */}} |
| +<div class="knob-row"><label for="knob-sheet-box-month-server-usd">$/box-month (server)</label><input type="number" step="0.01" min="0" id="knob-sheet-box-month-server-usd" value="{{.Economics.Scenario.Sheet.BoxMonthServerUSD}}"></div> |
| +<div class="knob-row"><label for="knob-sheet-box-month-device-usd">$/box-month (device)</label><input type="number" step="0.01" min="0" id="knob-sheet-box-month-device-usd" value="{{.Economics.Scenario.Sheet.BoxMonthDeviceUSD}}"></div> |
| +<div class="knob-row"><label for="knob-sheet-mem-gb-hour-usd">$/GB-hour mem overage (rate-sheet)</label><input type="number" step="0.001" min="0" id="knob-sheet-mem-gb-hour-usd" value="{{.Economics.Scenario.Sheet.MemGBHourUSD}}"></div> |
| + |
| +<h3>Fleet volume break</h3> |
| +<div class="knob-row"><label for="knob-fleet-break-min-boxes">Min avg boxes/month</label><input type="number" step="0.1" min="0" id="knob-fleet-break-min-boxes" value="{{.Economics.Scenario.FleetBreak.MinBoxes}}"></div> |
| +<div class="knob-row"><label for="knob-fleet-break-discount">Discount at/above min</label><input type="number" step="0.01" min="0" max="0.99" id="knob-fleet-break-discount" value="{{.Economics.Scenario.FleetBreak.Discount}}"></div> |
| + |
| +<h3>Fleet pool</h3> |
| +<div class="knob-row"><label for="knob-pool-revenue-frac">Revenue share to pool</label><input type="number" step="0.01" min="0" max="1" id="knob-pool-revenue-frac" value="{{.Economics.Scenario.Pool.RevenueFrac}}"></div> |
| +<div class="knob-row"><label for="knob-pool-topup-usd">Monthly top-up $</label><input type="number" step="0.01" min="0" id="knob-pool-topup-usd" value="{{.Economics.Scenario.Pool.TopupUSD}}"></div> |
| <h3>Free tier</h3> |
| <div class="knob-row"><label for="knob-ft-apps">Apps</label><input type="number" step="1" min="0" id="knob-ft-apps" value="{{.Economics.Scenario.FreeTier.Apps}}"></div> |
| @@ -605,7 +665,11 @@ |
| revenue = bApps * sc.sheet.app_month_usd + |
| bEgress * sc.sheet.egress_gb_usd + |
| bStorage * sc.sheet.storage_gb_month_usd + |
| - (meters.instance_hours || 0) * sc.sheet.instance_hour_usd; |
| + (meters.instance_hours || 0) * sc.sheet.instance_hour_usd + |
| + // Mem overage, rate-sheet mode only — the SAME bMem binding used |
| + // above for computeFrac and the clipped check below, per |
| + // compute.go's PriceAccount (economics-v2 Task 2/Task 6). |
| + bMem * sc.sheet.mem_gb_hour_usd; |
| } else { // cost-plus |
| var billableCost = allocatedUSD * computeFrac + |
| bEgress * rc.egress_gb_usd + |
| @@ -614,6 +678,20 @@ |
| revenue = billableCost / (1 - sc.margin); |
| } |
| + // Fleet box revenue (both modes) — economics-v2 (Task 2/Task 6), |
| + // compute.go's PriceAccount transliterated verbatim: the fleet term |
| + // sits OUTSIDE the cost-plus/rate-sheet branch above, meter reads |
| + // guarded against undefined (an account with no fleet usage never |
| + // wrote these keys), volume break applied only when a floor is set AND |
| + // cleared. |
| + var fleetRevenue = ((meters.fleet_box_hours_server || 0) / HOURS_PER_MONTH) * sc.sheet.box_month_server_usd + |
| + ((meters.fleet_box_hours_device || 0) / HOURS_PER_MONTH) * sc.sheet.box_month_device_usd; |
| + var avgBoxes = ((meters.fleet_box_hours_server || 0) + (meters.fleet_box_hours_device || 0)) / HOURS_PER_MONTH; |
| + if (sc.fleet_break.min_boxes > 0 && avgBoxes >= sc.fleet_break.min_boxes) { |
| + fleetRevenue *= (1 - sc.fleet_break.discount); |
| + } |
| + revenue += fleetRevenue; |
| + |
| var margin = 0; |
| if (revenue > 0) { margin = (revenue - cost) / revenue; } |
| @@ -630,6 +708,51 @@ |
| return {account: account, costUSD: cost, revenueUSD: revenue, marginFrac: margin, freeTierPos: pos}; |
| } |
| + // tierOf/isPoolTier mirror economicsview.go's same-named functions |
| + // (economics-v2 Task 5/Task 6): "" — whether from an explicit blank tier |
| + // or the account simply being absent from payload.tiers entirely (a |
| + // fleet-only account whose owner never signed up) — is pool semantics, |
| + // same as the explicit "try" and "pool" tiers. |
| + function tierOf(tiers, acct) { |
| + return tiers[acct] || ""; |
| + } |
| + function isPoolTier(tier) { |
| + return tier === "" || tier === "try" || tier === "pool"; |
| + } |
| + |
| + // marginCell is the JS twin of server.go's "marginCell" template func: an |
| + // em-dash for a pooled row, never "0%" — see that func's doc comment. |
| + function marginCell(frac, pos) { |
| + return pos === "pooled" ? "—" : pctf(frac); |
| + } |
| + |
| + // poolLedger mirrors buildEconomicsView's PoolLedger construction |
| + // (economicsview.go, economics-v2 Task 5/Task 6): paying revenue sums |
| + // every non-pool-tier row's revenue, draw sums every pool-tier row's |
| + // cost, and the topup term is payload.poolTopupUnitAccrual (the |
| + // dimensionless Σ 1/daysInMonth(d) the server accrued this period — |
| + // see economicsPayload's doc comment) times the CURRENT topup_usd knob, |
| + // reproducing poolTopupAccrual exactly under any knob value without |
| + // re-deriving the per-day sum client-side. `rows` is the accounts map |
| + // this page's recompute() already built (account -> priceAccount's |
| + // result, AFTER the tier override below has been applied) — its |
| + // revenueUSD is already 0 for a pool-tier row, but this still checks the |
| + // tier explicitly for draw/paying-revenue attribution, exactly like the |
| + // Go it mirrors, rather than relying on that zero. |
| + function poolLedger(sc, tiers, rows) { |
| + var payingRevenue = 0, poolDraw = 0; |
| + Object.keys(rows).forEach(function (acct) { |
| + var r = rows[acct]; |
| + if (isPoolTier(tierOf(tiers, acct))) { |
| + poolDraw += r.costUSD; |
| + } else { |
| + payingRevenue += r.revenueUSD; |
| + } |
| + }); |
| + var poolUSD = sc.pool.revenue_frac * payingRevenue + sc.pool.topup_usd * payload.poolTopupUnitAccrual; |
| + return {poolUSD: poolUSD, drawUSD: poolDraw, headroomUSD: poolUSD - poolDraw, payingRevenueUSD: payingRevenue}; |
| + } |
| + |
| // overheadUSD mirrors the server.go template func of the same name: the |
| // rate card's fixed monthly line items, prorated over the period by |
| // periodDays, not coveredDays (see that func's doc comment for why). |
| @@ -692,6 +815,15 @@ |
| sc.sheet.egress_gb_usd = numVal("knob-sheet-egress-gb-usd"); |
| sc.sheet.storage_gb_month_usd = numVal("knob-sheet-storage-gb-month-usd"); |
| sc.sheet.instance_hour_usd = numVal("knob-sheet-instance-hour-usd"); |
| + // economics-v2 (Task 6): the seven new knobs, read back exactly like |
| + // every knob above — one place, matching the drawer's ids one-to-one. |
| + sc.sheet.box_month_server_usd = numVal("knob-sheet-box-month-server-usd"); |
| + sc.sheet.box_month_device_usd = numVal("knob-sheet-box-month-device-usd"); |
| + sc.sheet.mem_gb_hour_usd = numVal("knob-sheet-mem-gb-hour-usd"); |
| + sc.fleet_break.min_boxes = numVal("knob-fleet-break-min-boxes"); |
| + sc.fleet_break.discount = numVal("knob-fleet-break-discount"); |
| + sc.pool.revenue_frac = numVal("knob-pool-revenue-frac"); |
| + sc.pool.topup_usd = numVal("knob-pool-topup-usd"); |
| sc.free_tier.apps = numVal("knob-ft-apps"); |
| sc.free_tier.egress_gb = numVal("knob-ft-egress-gb"); |
| sc.free_tier.mem_gb_hours = numVal("knob-ft-mem-gb-hours"); |
diff --git a/internal/console/templates/economics_accounts.html b/internal/console/templates/economics_accounts.html| index f8c693f67..e20bff66c 100644 |
| --- a/internal/console/templates/economics_accounts.html |
| +++ b/internal/console/templates/economics_accounts.html |
| @@ -16,13 +16,22 @@ |
| see economics.html's header comment for why splicing econ_maths_js |
| into THIS page's own <script> IIFE keeps one scope. This page's own |
| recompute() (below) is deliberately smaller than economics.html's: no |
| - tiles, no box table, no model-vs-bill — it only re-prices the account |
| - rows currently in the DOM (this page's one table, one page of up to |
| + tiles, no box table, no model-vs-bill, no pool-tile update (that lives |
| + only on /fleet/economics) — it only re-prices the account rows |
| + currently in the DOM (this page's one table, one page of up to |
| 50 accounts), via the same allocateBox/priceAccount over the FULL |
| payload.boxLoads (a row's allocated cost depends on every box it |
| - touched, not just the rows on screen). The meter columns (CPU-h, GB-h, |
| - egress, storage) are raw readings, not knob-derived, so they never |
| - change under recompute. */}} |
| + touched, not just the rows on screen), plus the SAME tier-aware |
| + view-layer override economics.html's recompute() applies (Task 5/ |
| + Task 6): payload.tiers is read, and a pool/try/""-tier row's revenue, |
| + margin and free-tier chip are overridden to pooled AFTER priceAccount |
| + runs, exactly matching buildEconomicsView's server-side override — a |
| + knob tweak must never flip a pooled row back to priced. The meter |
| + columns (CPU-h, GB-h, egress, storage) are raw readings, not |
| + knob-derived, so they never change under recompute; neither does the |
| + Tier column (Task 6) — an account's fleet-pool tier is an operator |
| + assignment via the per-row form below, not something a scenario knob |
| + can move. */}} |
| {{define "economics_accounts"}}{{template "top" .}} |
| <h1>Fleet economics: accounts</h1> |
| {{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| @@ -77,10 +86,16 @@ |
| <th scope="col">Storage GB-month</th> |
| <th scope="col">Cost</th> |
| <th scope="col">Would-be revenue</th> |
| - <th scope="col">Margin</th> |
| + <th scope="col" title="Pooled rows (free-tier position "pooled") show an em-dash: their revenue is shared through the fleet pool, not individually priced, so a margin fraction has nothing to divide. Fleet-tier rows can legitimately show 100%: fleet boxes are customer-owned hardware, zero cost to the platform, by design.">Margin</th> |
| <th scope="col">Free tier</th> |
| + {{/* Tier (Task 6): the operator's fleet-pool ASSIGNMENT for this |
| + account (store.go's validAccountTiers), distinct from the Free |
| + tier column's PRICED POSITION — "pooled" over there can come from |
| + any of try/pool/"" here. */}} |
| + <th scope="col">Tier</th> |
| </tr> |
| {{range .EconAccounts.Rows}} |
| + {{$tier := index $.Economics.Tiers .Account}} |
| <tr data-account="{{.Account}}"> |
| <td>{{.Account}}</td> |
| <td>{{printf "%.2f" (index .Meters "app_months")}}</td> |
| @@ -91,8 +106,27 @@ |
| <td>{{printf "%.2f" (index .Meters "storage_gb_month")}}</td> |
| <td class="js-acct-cost">{{usd .Econ.CostUSD}}</td> |
| <td class="js-acct-revenue">{{usd .Econ.RevenueUSD}}</td> |
| - <td class="js-acct-margin">{{pctf .Econ.MarginFrac}}</td> |
| + <td class="js-acct-margin">{{marginCell .Econ.MarginFrac .Econ.FreeTierPos}}</td> |
| <td class="js-acct-freetier"><span class="chip chip-freetier-{{.Econ.FreeTierPos}}">{{.Econ.FreeTierPos}}</span></td> |
| + <td> |
| + <span class="chip chip-tier-{{tierWord $tier}}">{{tierWord $tier}}</span> |
| + {{/* Progressive: a plain <select> + submit button, no JS required — |
| + the door (POST /fleet/economics/tier, handleEconomicsTierSave) |
| + already exists (Task 4/5). */}} |
| + <form method="post" action="/fleet/economics/tier"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="account" value="{{.Account}}"> |
| + <select name="tier"> |
| + <option value=""{{if eq $tier ""}} selected{{end}}>unassigned</option> |
| + <option value="try"{{if eq $tier "try"}} selected{{end}}>try</option> |
| + <option value="pool"{{if eq $tier "pool"}} selected{{end}}>pool</option> |
| + <option value="hosted"{{if eq $tier "hosted"}} selected{{end}}>hosted</option> |
| + <option value="fleet"{{if eq $tier "fleet"}} selected{{end}}>fleet</option> |
| + <option value="dedicated"{{if eq $tier "dedicated"}} selected{{end}}>dedicated</option> |
| + </select> |
| + <button type="submit">Set</button> |
| + </form> |
| + </td> |
| </tr> |
| {{end}} |
| </table> |
| @@ -145,12 +179,21 @@ allocated box cost, under the active scenario.</p> |
| }); |
| }); |
| + var tiers = payload.tiers || {}; |
| document.querySelectorAll("[data-account]").forEach(function (row) { |
| var acct = row.getAttribute("data-account"); |
| var a = priceAccount(sc, acct, payload.meters[acct] || {}, totalAllocated[acct] || 0); |
| + // Tier-aware view-layer override (economics-v2 Task 5/Task 6), the |
| + // SAME override economics.html's recompute() applies — see this |
| + // page's header comment. |
| + if (isPoolTier(tierOf(tiers, acct))) { |
| + a.revenueUSD = 0; |
| + a.marginFrac = 0; |
| + a.freeTierPos = "pooled"; |
| + } |
| setCellText(row, ".js-acct-cost", usd(a.costUSD)); |
| setCellText(row, ".js-acct-revenue", usd(a.revenueUSD)); |
| - setCellText(row, ".js-acct-margin", pctf(a.marginFrac)); |
| + setCellText(row, ".js-acct-margin", marginCell(a.marginFrac, a.freeTierPos)); |
| var chip = row.querySelector(".js-acct-freetier .chip"); |
| if (chip) { |
| chip.textContent = a.freeTierPos; |
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html| index 3acbf59af..386a384a8 100644 |
| --- a/internal/console/templates/layout.html |
| +++ b/internal/console/templates/layout.html |
| @@ -203,6 +203,20 @@ |
| .chip-freetier-partial { border: 1px solid var(--accent); color: var(--accent); } |
| .chip-freetier-paying { border: 1px solid var(--good); color: var(--good); } |
| .chip-freetier-pooled { border: 1px solid var(--rule); color: var(--muted); } |
| + /* /fleet/economics/accounts (Task 6): fleet-pool TIER chips (store.go's |
| + validAccountTiers) — the operator's ASSIGNMENT, distinct from the |
| + freetier chips above (a PRICED POSITION). "unassigned" (never set) and |
| + "try"/"pool" (both draw from the shared pool, same as an unassigned |
| + account) share chip-freetier-pooled's muted, nothing-to-act-on tone. |
| + "hosted"/"fleet"/"dedicated" are individually priced, each its own |
| + bordered tone so the three read apart at a glance without implying a |
| + ranking between them. */ |
| + .chip-tier-unassigned { border: 1px solid var(--rule); color: var(--muted); } |
| + .chip-tier-try { border: 1px solid var(--rule); color: var(--muted); } |
| + .chip-tier-pool { border: 1px solid var(--rule); color: var(--muted); } |
| + .chip-tier-hosted { border: 1px solid var(--good); color: var(--good); } |
| + .chip-tier-fleet { border: 1px solid var(--accent); color: var(--accent); } |
| + .chip-tier-dedicated { border: 1px solid var(--warn, #a6790f); color: var(--warn, #a6790f); } |
| .chip .icon { vertical-align: -0.1em; margin-right: 0.2em; } |
| .chip-up { background: var(--accent-soft); color: #2f7a4f; } |
| .chip-down { background: var(--accent-soft); color: var(--bad); } |