carlos / platform Public

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>
Paul Campbell f2a4a9aacb510ca1005c756233f84fbe1acb2799 parent 5e46779
6 files changed, +537 −24
  • internal/console/economicspage_test.go +279 −0
  • internal/console/economicsview.go +31 −10
  • internal/console/server.go +24 −0
  • internal/console/templates/economics.html +138 −6
  • internal/console/templates/economics_accounts.html +51 −8
  • internal/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> &middot; draw <span id="tile-pool-draw">{{usd .Economics.Pool.DrawUSD}}</span> &middot; 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 &quot;pooled&quot;) 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 &quot;pooled&quot;) 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); }