console: economics dashboard fix round — stale doc, tier-form gate, visible margin legend
Task 6 review fix round (DOM-executed, 26/26 equivalence diffs green on the prior pass): IMPORTANT 1 — internal/economics/compute.go: PriceAccount's doc comment claimed the fleet/mem formulas were "NOT yet mirrored in the page JS" — stale since the prior commit actually mirrored them. Rewritten to the standing contract form: the formulas ARE mirrored in econ_maths_js (templates/economics.html priceAccount) and the two must change together. IMPORTANT 2 — economics_accounts.html: the per-row tier-set form rendered for USAGE-ONLY accounts absent from Economics.Tiers (no accounts-table row at all — e.g. a fleet-only account whose owner never signed up), whose POST to /fleet/economics/tier can only ever fail with "No such account." html/template's "index" can't tell "absent from the map" apart from "present with the explicit '' tier", so a new tierKnown(tiers, acct) template func (server.go, via the comma-ok map form) makes the distinction explicit; the form now renders only when tierKnown is true, a plain unassigned chip otherwise. New test TestEconomicsAccountsPageTierFormOnlyForConsoleRows seeds a usage-only account (EdgeRow.Account read directly by sweepUsage, independent of any registry route or console account row) alongside the untouched console-row fixture account, and asserts the former's row has the unassigned chip with NO form while the latter (a real, merely untiered, account) still gets one. MINOR 3 — the pooled-em-dash and 100%-fleet-margin explanations were title= tooltips only, invisible on touch/keyboard. Added a matching one-line visible legend under each accounts table (both pages), keeping the tooltips. TestEconomicsPageFleetMarginLegend now asserts both the tooltip and the legend paragraph explicitly, not just "the text appears somewhere" (which the tooltip alone would already satisfy). gofmt -l clean on touched .go files; go build ./..., go vet ./..., go test ./internal/console/ ./internal/economics/ -count=1, and the full go test ./... -count=1 (27 packages) all pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+140
−16
internal/console/economicspage_test.go+99 −10internal/console/server.go+15 −0internal/console/templates/economics.html+4 −0internal/console/templates/economics_accounts.html+18 −1internal/economics/compute.go+4 −5
diff --git a/internal/console/economicspage_test.go b/internal/console/economicspage_test.go| index c67b423ad..81843e5e2 100644 |
| --- a/internal/console/economicspage_test.go |
| +++ b/internal/console/economicspage_test.go |
| @@ -611,12 +611,16 @@ func TestEconomicsPagePooledMarginIsDash(t *testing.T) { |
| } |
| } |
| -// 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. |
| +// TestEconomicsPageFleetMarginLegend is carried ruling 4 PLUS the fix-round |
| +// MINOR 3 (Task 6 review): 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). The title= tooltip on the Margin header is invisible on |
| +// touch/keyboard, so both notes ALSO need a visible one-line legend under |
| +// each table — this asserts the tooltip AND the legend paragraph, on both |
| +// pages, not just "the text appears somewhere" (which the tooltip alone |
| +// would already satisfy and silently let the legend regress). |
| 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 |
| @@ -631,8 +635,12 @@ func TestEconomicsPageFleetMarginLegend(t *testing.T) { |
| 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()) |
| + html := buf.String() |
| + if !strings.Contains(html, `title="Pooled rows`) { |
| + t.Fatalf("/fleet/economics missing the Margin header's title= tooltip:\n%s", html) |
| + } |
| + if !strings.Contains(html, `<p class="small">Margin: pooled rows show an em-dash`) { |
| + t.Fatalf("/fleet/economics missing the VISIBLE margin legend line (MINOR 3 — tooltips alone are invisible on touch/keyboard):\n%s", html) |
| } |
| rows := pageEconAccounts([]econAccountRow{{Account: fixtureAcct, Meters: map[string]float64{}, Econ: row}}, econAccountQuery{}, econAccountPageSize) |
| @@ -641,8 +649,12 @@ func TestEconomicsPageFleetMarginLegend(t *testing.T) { |
| 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()) |
| + htmlAcct := bufAcct.String() |
| + if !strings.Contains(htmlAcct, `title="Pooled rows`) { |
| + t.Fatalf("/fleet/economics/accounts missing the Margin header's title= tooltip:\n%s", htmlAcct) |
| + } |
| + if !strings.Contains(htmlAcct, `<p class="small">Margin: pooled rows show an em-dash`) { |
| + t.Fatalf("/fleet/economics/accounts missing the VISIBLE margin legend line (MINOR 3):\n%s", htmlAcct) |
| } |
| } |
| @@ -784,3 +796,80 @@ func TestEconomicsAccountsPageTierChipAndForm(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// TestEconomicsAccountsPageTierFormOnlyForConsoleRows is the fix-round |
| +// regression for IMPORTANT 2 (Task 6 review): a usage-only account — real |
| +// traffic (usage.EdgeRow.Account is read directly by sweepUsage, |
| +// economicspass.go, independent of any registry route) under a sqid that |
| +// never got a console account row (accounts table) — is absent from |
| +// EconomicsView.Tiers entirely (Store.AccountTiers only ever selects live |
| +// accounts-table rows). Its row must still render a plain "unassigned" |
| +// chip, but WITHOUT the per-row tier-set form: POSTing that form's account |
| +// sqid to /fleet/economics/tier can only ever fail with "No such account." |
| +// (handleEconomicsTierSave's AccountBySqid lookup). A REAL console row that |
| +// simply has no tier assigned yet (the explicit "" default — fixtureAcct |
| +// here, left untouched) is a different case entirely and must still get |
| +// the form: index can't tell the two apart, which is exactly why tierKnown |
| +// (server.go) exists. |
| +func TestEconomicsAccountsPageTierFormOnlyForConsoleRows(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + seedEconomicsGolden(t, fx) |
| + |
| + // A usage-only account: edge traffic under a sqid with no accounts-table |
| + // row at all. A DIFFERENT box than seedEconomicsGolden's "box1", so this |
| + // writes its own EdgeDay object rather than colliding with the one |
| + // seedEconomicsGolden already created (putStoreJSON is create-only). |
| + const ghostAcct = "ghostacct" |
| + date := usage.DayOf(time.Now()) |
| + putStoreJSON(t, fx.rel, usage.EdgeDayKey("boxghost", date), usage.EdgeDay{ |
| + V: 1, Box: "boxghost", Date: date, |
| + Hours: map[string]map[string]usage.EdgeRow{"09": { |
| + "ghost.example.com": {Account: ghostAcct, App: "ghostapp", Requests: 100, BytesOut: 1e6}, |
| + }}, |
| + }) |
| + |
| + 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) |
| + } |
| + |
| + // The usage-only ghost account's row: unassigned chip, no form. |
| + rowStart := strings.Index(body, fmt.Sprintf(`data-account="%s"`, ghostAcct)) |
| + if rowStart < 0 { |
| + t.Fatalf("/fleet/economics/accounts missing a row for the usage-only account %q in:\n%s", ghostAcct, body) |
| + } |
| + rowEnd := strings.Index(body[rowStart:], "</tr>") |
| + if rowEnd < 0 { |
| + t.Fatalf("unterminated row for %q in:\n%s", ghostAcct, body) |
| + } |
| + ghostRow := body[rowStart : rowStart+rowEnd] |
| + if !strings.Contains(ghostRow, `class="chip chip-tier-unassigned"`) { |
| + t.Fatalf("usage-only account %q row missing the unassigned chip:\n%s", ghostAcct, ghostRow) |
| + } |
| + if strings.Contains(ghostRow, "<form") { |
| + t.Fatalf("usage-only account %q row must NOT render the tier-set form (no console row to set a tier on):\n%s", ghostAcct, ghostRow) |
| + } |
| + |
| + // A real console-row account with no tier assigned yet (fixtureAcct, |
| + // untouched — the explicit "" default) still gets the form. |
| + fixtureRowStart := strings.Index(body, fmt.Sprintf(`data-account="%s"`, fixtureAcct)) |
| + if fixtureRowStart < 0 { |
| + t.Fatalf("/fleet/economics/accounts missing a row for %q in:\n%s", fixtureAcct, body) |
| + } |
| + fixtureRowEnd := strings.Index(body[fixtureRowStart:], "</tr>") |
| + if fixtureRowEnd < 0 { |
| + t.Fatalf("unterminated row for %q in:\n%s", fixtureAcct, body) |
| + } |
| + fixtureRow := body[fixtureRowStart : fixtureRowStart+fixtureRowEnd] |
| + if !strings.Contains(fixtureRow, `class="chip chip-tier-unassigned"`) { |
| + t.Fatalf("console-row account %q (untiered) row missing the unassigned chip:\n%s", fixtureAcct, fixtureRow) |
| + } |
| + if !strings.Contains(fixtureRow, "<form") || !strings.Contains(fixtureRow, "/fleet/economics/tier") { |
| + t.Fatalf("console-row account %q (untiered) row must still render the tier-set form:\n%s", fixtureAcct, fixtureRow) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index b8d4596fb..5a9868c3f 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -217,6 +217,21 @@ var templates = template.Must(template.New("").Funcs(template.FuncMap{ |
| } |
| return tier |
| }, |
| + // tierKnown reports whether acct has a real console account row at all |
| + // (present as a key in EconomicsView.Tiers, via the comma-ok form — |
| + // html/template's "index" can't distinguish absent from an explicit "" |
| + // tier, which is exactly the distinction this exists for). A usage-only |
| + // account (fleet or edge traffic with no matching accounts-table row — |
| + // e.g. a fleet-only account whose owner never signed up) is absent from |
| + // the map entirely: economics_accounts.html renders its tier chip but |
| + // WITHOUT the per-row tier-set form, since POSTing that form's account |
| + // sqid to /fleet/economics/tier can only ever fail with "No such |
| + // account." (handleEconomicsTierSave's AccountBySqid lookup) — fix |
| + // round, Task 6 review, IMPORTANT 2. |
| + "tierKnown": func(tiers map[string]string, acct string) bool { |
| + _, ok := tiers[acct] |
| + return ok |
| + }, |
| "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 5075f4995..d3ebb05c6 100644 |
| --- a/internal/console/templates/economics.html |
| +++ b/internal/console/templates/economics.html |
| @@ -122,6 +122,10 @@ |
| {{end}} |
| </table> |
| </div> |
| +{{/* MINOR 3 (fix round, Task 6 review): the Margin column's title= |
| + tooltip is invisible on touch/keyboard — this legend carries the |
| + same two notes visibly, for everyone. */}} |
| +<p class="small">Margin: pooled rows show an em-dash — their revenue is shared through the fleet pool, not individually priced. Fleet-tier rows can legitimately show 100% margin — fleet boxes are customer-owned hardware, zero cost to the platform, by design.</p> |
| {{else}}<p class="small">No accounts to price this period.</p>{{end}} |
| <p class="small"><a href="/fleet/economics/accounts">Every account, searchable →</a></p> |
diff --git a/internal/console/templates/economics_accounts.html b/internal/console/templates/economics_accounts.html| index e20bff66c..163d9d4e4 100644 |
| --- a/internal/console/templates/economics_accounts.html |
| +++ b/internal/console/templates/economics_accounts.html |
| @@ -96,6 +96,16 @@ |
| </tr> |
| {{range .EconAccounts.Rows}} |
| {{$tier := index $.Economics.Tiers .Account}} |
| + {{/* tierKnown (fix round, Task 6 review IMPORTANT 2): "index" above |
| + can't distinguish "absent from the map entirely" (a usage-only |
| + account with NO console/accounts-table row — e.g. a fleet-only |
| + account whose owner never signed up) from "present with the |
| + explicit "" tier" (a real console account nobody has tiered yet) — |
| + both read $tier=="". Only the second case gets the tier-set form: |
| + POSTing the first's sqid to /fleet/economics/tier can only ever |
| + fail with "No such account." (handleEconomicsTierSave's |
| + AccountBySqid lookup). */}} |
| + {{$known := tierKnown $.Economics.Tiers .Account}} |
| <tr data-account="{{.Account}}"> |
| <td>{{.Account}}</td> |
| <td>{{printf "%.2f" (index .Meters "app_months")}}</td> |
| @@ -110,9 +120,11 @@ |
| <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> |
| + {{if $known}} |
| {{/* Progressive: a plain <select> + submit button, no JS required — |
| the door (POST /fleet/economics/tier, handleEconomicsTierSave) |
| - already exists (Task 4/5). */}} |
| + already exists (Task 4/5). Only rendered for a REAL console |
| + account (see $known's doc comment above). */}} |
| <form method="post" action="/fleet/economics/tier"> |
| <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| <input type="hidden" name="account" value="{{.Account}}"> |
| @@ -126,6 +138,7 @@ |
| </select> |
| <button type="submit">Set</button> |
| </form> |
| + {{end}} |
| </td> |
| </tr> |
| {{end}} |
| @@ -136,6 +149,10 @@ a fraction of a calendar month and a sum of hours run, the meters this platform |
| actually keeps. CPU-h/GB-h are box-side compute meters; egress and storage are |
| edge/S3 meters. Cost and would-be revenue are this account's own share plus its |
| allocated box cost, under the active scenario.</p> |
| +{{/* MINOR 3 (fix round, Task 6 review): the Margin column's title= |
| + tooltip is invisible on touch/keyboard — this legend carries the |
| + same two notes visibly, for everyone. */}} |
| +<p class="small">Margin: pooled rows show an em-dash — their revenue is shared through the fleet pool, not individually priced. Fleet-tier rows can legitimately show 100% margin — fleet boxes are customer-owned hardware, zero cost to the platform, by design.</p> |
| {{if .EconAccounts.NextURL}}<p class="small"><a href="{{.EconAccounts.NextURL}}">Next {{len .EconAccounts.Rows}} shown of {{.EconAccounts.Total}} →</a></p>{{end}} |
| {{else if or .EconQuery.Q .EconQuery.Pos}} |
| <p>No accounts match.</p> |
diff --git a/internal/economics/compute.go b/internal/economics/compute.go| index b10b3e471..cf98f26a0 100644 |
| --- a/internal/economics/compute.go |
| +++ b/internal/economics/compute.go |
| @@ -91,11 +91,10 @@ func billable(meters map[string]float64, meter string, allowance float64) float6 |
| } |
| // PriceAccount prices one account's meters plus its allocated box cost |
| -// under sc. These formulas are mirrored verbatim by the dashboard's inline |
| -// knob JS (economics.html) — change both together or the page will disagree |
| -// with the server it loaded from. Fleet box and mem-overage formulas are |
| -// NOT yet mirrored in the page JS; the dashboard task must transliterate them. |
| -// Until then, knob recompute disagrees with server render for fleet-metered accounts. |
| +// under sc. These formulas — including the fleet box revenue and rate-sheet |
| +// mem-overage terms — are mirrored verbatim by the dashboard's inline knob |
| +// JS (priceAccount, econ_maths_js define, templates/economics.html): change |
| +// both together, or the page will disagree with the server it loaded from. |
| func PriceAccount(sc Scenario, account string, meters map[string]float64, allocatedUSD float64) AccountEconomics { |
| rc := sc.RateCard |
| cost := allocatedUSD + |