economics: render account NAMES beside sqids, search matches both
EconomicsView gains Names (sqid -> display name), populated in buildEconomicsView from the same s.store.Accounts() read that already builds Tiers/the universe. Added to economicsPayload as the additive "names" key. Shared read-only, same copy/doc treatment as Tiers. Both /fleet/economics and /fleet/economics/accounts now render a known account's NAME as primary text with the sqid muted+small beside it (reusing the existing .small class, which already carries the muted treatment - there's no separate .muted class in these templates). A usage-only ghost row (no console account row) or an empty name still renders the bare sqid, as before. The join/key universe (meters, tiers, cursors, form values, data-account attributes) stays sqid-shaped throughout - display and search only. econAccountRow gains a Name field, populated at row-build time in handleEconomicsAccounts. econAccountQuery.Q now matches EITHER the sqid or the name, case-insensitively against both - a deliberate divergence from the Running tab's case-sensitive Q, documented at the field. No JS changes needed: both pages' recompute() already key exclusively off the data-account attribute (the sqid), never displayed text.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8 files changed,
+213
−12
internal/console/econlistquery.go+28 −4internal/console/econlistquery_test.go+38 −0internal/console/economicspage_test.go+67 −0internal/console/economicsview.go+38 −5internal/console/economicsview_test.go+23 −0internal/console/server.go+1 −1internal/console/templates/economics.html+6 −1internal/console/templates/economics_accounts.html+12 −1
diff --git a/internal/console/econlistquery.go b/internal/console/econlistquery.go| index 4131a9eaa..30ce42d3f 100644 |
| --- a/internal/console/econlistquery.go |
| +++ b/internal/console/econlistquery.go |
| @@ -35,14 +35,32 @@ var econPosWords = []string{"inside", "partial", "paying", "pooled"} |
| // account (release.RouteAccount universe). |
| type econAccountRow struct { |
| Account string |
| - Meters map[string]float64 |
| - Econ economics.AccountEconomics |
| + // Name is the account's display name (EconomicsView.Names, account-names |
| + // batch), joined at row-build time (handleEconomicsAccounts) exactly |
| + // like Meters/Econ already are — "" for a usage-only ghost account with |
| + // no console row at all, the same shape the render layer already |
| + // handles for a missing Tiers entry. Display + search only (pageEconAccounts's |
| + // Q filter below); Account stays the sqid this row's every other field |
| + // (cursor, form values, data-account attribute) keys off. |
| + Name string |
| + Meters map[string]float64 |
| + Econ economics.AccountEconomics |
| } |
| // econAccountQuery is one request's list controls. Zero value = everything, |
| // first page. |
| type econAccountQuery struct { |
| - Q string // substring match on account, trimmed |
| + // Q is a substring match against EITHER the account's sqid OR its |
| + // display name (econAccountRow.Name, account-names batch), trimmed, and |
| + // matched CASE-INSENSITIVELY against both — a deliberate divergence |
| + // from the Running tab's instanceQuery.Q (listquery.go), which stays a |
| + // case-sensitive substring match on host. Sqids are opaque and short |
| + // (case never mattered in practice), but names are operator-typed |
| + // English words an operator expects to find regardless of case — Paul's |
| + // names request implies human-friendly search, not wire-shaped |
| + // exactness, so this Q's matching rule is intentionally NOT the same as |
| + // every other list-control Q in this package. |
| + Q string |
| Pos string // FreeTierPos filter ("" = all) |
| Cursor string // opaque; list resumes after the account inside it |
| // Period is the normalized period this page's rows were priced under |
| @@ -178,11 +196,17 @@ func pageEconAccounts(rows []econAccountRow, q econAccountQuery, limit int) econ |
| sorted := append([]econAccountRow(nil), rows...) |
| sort.Slice(sorted, func(i, j int) bool { return sorted[i].Account < sorted[j].Account }) |
| + // ql is the lower-cased Q, computed once rather than per row — see |
| + // econAccountQuery.Q's doc comment for why this match is |
| + // case-insensitive against BOTH the sqid and the name, unlike every |
| + // other Q in this package. |
| + ql := strings.ToLower(q.Q) |
| + |
| p := econAccountsPage{Counts: map[string]int{}} |
| var matched []econAccountRow |
| for _, r := range sorted { |
| p.Counts[r.Econ.FreeTierPos]++ |
| - if q.Q != "" && !strings.Contains(r.Account, q.Q) { |
| + if ql != "" && !strings.Contains(strings.ToLower(r.Account), ql) && !strings.Contains(strings.ToLower(r.Name), ql) { |
| continue |
| } |
| if q.Pos != "" && r.Econ.FreeTierPos != q.Pos { |
diff --git a/internal/console/econlistquery_test.go b/internal/console/econlistquery_test.go| index f9d4fcf2e..e39324c56 100644 |
| --- a/internal/console/econlistquery_test.go |
| +++ b/internal/console/econlistquery_test.go |
| @@ -144,6 +144,44 @@ func TestPageEconAccountsPooledFilterAndCount(t *testing.T) { |
| } |
| } |
| +// TestPageEconAccountsQMatchesSqidOrNameCaseInsensitive (account-names |
| +// batch): Q matches EITHER the sqid or the display Name, and the WHOLE match |
| +// is case-insensitive — a deliberate divergence from the Running tab's Q |
| +// (listquery.go's instanceQuery.Q, a case-sensitive substring match on |
| +// host), per Paul's names request implying human-friendly search rather than |
| +// wire-shaped exactness. econRowsFor's rows carry no Name by default (zero |
| +// value ""), so a Q that matches nothing's Name still falls through to the |
| +// sqid half exactly as before. |
| +func TestPageEconAccountsQMatchesSqidOrNameCaseInsensitive(t *testing.T) { |
| + rows := econRowsFor(5) |
| + rows[0].Name = "Ops Team" |
| + rows[1].Name = "Widgets Inc" |
| + |
| + // Matches by name. |
| + p := pageEconAccounts(rows, econAccountQuery{Q: "ops"}, econAccountPageSize) |
| + if p.Total != 1 || p.Rows[0].Account != rows[0].Account { |
| + t.Fatalf("Q=ops (name match): Total=%d, want 1 matching %q", p.Total, rows[0].Account) |
| + } |
| + |
| + // Name match is case-insensitive. |
| + p2 := pageEconAccounts(rows, econAccountQuery{Q: "OPS TEAM"}, econAccountPageSize) |
| + if p2.Total != 1 || p2.Rows[0].Account != rows[0].Account { |
| + t.Fatalf("Q=OPS TEAM (uppercase name match): Total=%d, want 1 matching %q", p2.Total, rows[0].Account) |
| + } |
| + |
| + // Sqid still matches, now case-insensitively too. |
| + p3 := pageEconAccounts(rows, econAccountQuery{Q: "ACCT-002"}, econAccountPageSize) |
| + if p3.Total != 1 || p3.Rows[0].Account != "acct-002" { |
| + t.Fatalf("Q=ACCT-002 (uppercase sqid match): Total=%d, want 1 matching acct-002", p3.Total) |
| + } |
| + |
| + // A query matching neither sqid nor name: no rows. |
| + p4 := pageEconAccounts(rows, econAccountQuery{Q: "nonesuch"}, econAccountPageSize) |
| + if p4.Total != 0 { |
| + t.Fatalf("Q=nonesuch: Total=%d, want 0", p4.Total) |
| + } |
| +} |
| + |
| func TestPageEconAccountsGarbageCursorStartsOver(t *testing.T) { |
| rows := econRowsFor(5) |
| p := pageEconAccounts(rows, econAccountQuery{Cursor: "!!not-base64!!"}, econAccountPageSize) |
diff --git a/internal/console/economicspage_test.go b/internal/console/economicspage_test.go| index 81843e5e2..a45cf2e53 100644 |
| --- a/internal/console/economicspage_test.go |
| +++ b/internal/console/economicspage_test.go |
| @@ -506,6 +506,73 @@ func TestEconomicsAccountsPageThreadsPeriod(t *testing.T) { |
| } |
| } |
| +// TestEconomicsAccountsPageShowsAccountName (account-names batch): a row for |
| +// an account with a known console name renders the NAME as primary text |
| +// with the sqid muted+small beside it (class="small", the existing |
| +// secondary-text treatment — apps.html's StableAge span is the precedent; |
| +// there is no separate ".muted" CSS class, only the ".small" rule, which |
| +// already carries var(--muted)). A usage-only ghost row — real traffic under |
| +// a sqid with no accounts-table row at all, same shape as |
| +// TestEconomicsAccountsPageTierFormOnlyForConsoleRows — renders the bare |
| +// sqid alone, exactly as before this batch. Checked on BOTH pages: the |
| +// dashboard's own accounts table (economics.html, which has no econAccountRow |
| +// and must look Name up from EconomicsView.Names) and the searchable |
| +// accounts page (economics_accounts.html, whose econAccountRow now carries |
| +// its own Name field). |
| +func TestEconomicsAccountsPageShowsAccountName(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + |
| + 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) |
| + } |
| + |
| + for _, path := range []string{"/fleet/economics", "/fleet/economics/accounts"} { |
| + status, _, body := get(t, c, fx.ts.URL+path) |
| + if status != 200 { |
| + t.Fatalf("%s = %d, want 200:\n%s", path, status, body) |
| + } |
| + |
| + fixtureRowStart := strings.Index(body, fmt.Sprintf(`data-account="%s"`, fixtureAcct)) |
| + if fixtureRowStart < 0 { |
| + t.Fatalf("%s missing a row for %q in:\n%s", path, fixtureAcct, body) |
| + } |
| + fixtureRowEnd := strings.Index(body[fixtureRowStart:], "</tr>") |
| + if fixtureRowEnd < 0 { |
| + t.Fatalf("%s unterminated row for %q in:\n%s", path, fixtureAcct, body) |
| + } |
| + fixtureRow := body[fixtureRowStart : fixtureRowStart+fixtureRowEnd] |
| + if !strings.Contains(fixtureRow, "ops <span class=\"small\">"+fixtureAcct+"</span>") { |
| + t.Fatalf("%s: known-name row missing NAME %q as primary text with the sqid muted+small beside it:\n%s", path, "ops", fixtureRow) |
| + } |
| + |
| + ghostRowStart := strings.Index(body, fmt.Sprintf(`data-account="%s"`, ghostAcct)) |
| + if ghostRowStart < 0 { |
| + t.Fatalf("%s missing a row for %q in:\n%s", path, ghostAcct, body) |
| + } |
| + ghostRowEnd := strings.Index(body[ghostRowStart:], "</tr>") |
| + if ghostRowEnd < 0 { |
| + t.Fatalf("%s unterminated row for %q in:\n%s", path, ghostAcct, body) |
| + } |
| + ghostRow := body[ghostRowStart : ghostRowStart+ghostRowEnd] |
| + if !strings.Contains(ghostRow, ">"+ghostAcct+"<") { |
| + t.Fatalf("%s: ghost row missing the bare sqid %q:\n%s", path, ghostAcct, ghostRow) |
| + } |
| + if strings.Contains(ghostRow, `<span class="small">`) { |
| + t.Fatalf("%s: ghost row (no console name) must not render a muted-sqid span:\n%s", path, ghostRow) |
| + } |
| + } |
| +} |
| + |
| // ---- Task 6: pool tile, v2 knobs, tier chips ------------------------------- |
| // TestEconomicsPagePoolTile proves the dashboard renders id="tile-pool" with |
diff --git a/internal/console/economicsview.go b/internal/console/economicsview.go| index 40a74de75..bb6c0ac25 100644 |
| --- a/internal/console/economicsview.go |
| +++ b/internal/console/economicsview.go |
| @@ -77,6 +77,21 @@ type EconomicsView struct { |
| // tierOf. Shared, read-only, like Scenario/Boxes/Bills — see |
| // copyEconomicsView's doc comment. |
| Tiers map[string]string |
| + // Names is every live account's display name, keyed by sqid (the SAME |
| + // s.store.Accounts() read buildEconomicsView already performs to build |
| + // the universe/Tiers, Account.Sqid -> Account.Name) — account-names |
| + // batch. Display + search only: the join/key universe (meters, |
| + // statements, tiers, cursors, form values) stays sqid-shaped everywhere |
| + // unchanged; this map exists purely so the render layer can show a |
| + // human name next to the sqid it already has. An account absent from |
| + // this map (usage with no matching console account row at all, e.g. a |
| + // fleet-only account whose owner never signed up) has no name to show — |
| + // same "index returns the zero value" shape as Tiers/tierOf, and the |
| + // render layer falls back to the bare sqid for it, exactly as before |
| + // this map existed. Shared, read-only, like Scenario/Boxes/Bills/Tiers |
| + // — see copyEconomicsView's doc comment; gets no defensive copy of its |
| + // own for the same reason Tiers doesn't. |
| + Names map[string]string |
| // Pool is this period's fleet-pool ledger (Task 5) — see PoolLedger. |
| Pool PoolLedger |
| } |
| @@ -139,6 +154,15 @@ 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"` |
| + // Names mirrors EconomicsView.Names (account-names batch) — additive |
| + // key, sqid -> display name, for any client-side rendering that wants a |
| + // human name next to a sqid without a server round trip. Nothing in |
| + // econ_maths_js reads it today (the recompute paths key everything by |
| + // sqid, per the join/key universe staying sqid-shaped — see |
| + // EconomicsView.Names's own doc comment); it travels here purely for |
| + // parity with how "tiers" already does, and so it's available the |
| + // moment a future knob-drawer feature wants it. |
| + Names map[string]string `json:"names"` |
| // 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 |
| @@ -248,11 +272,12 @@ func economicsPeriodBounds(period string, now time.Time) (from, to time.Time) { |
| // freely sort.Slice(view.Accounts, ...) or add/remove top-level Meters |
| // entries without racing the cache or another caller doing the same — |
| // Task 13's dashboard sort is the motivating case. Every OTHER field |
| -// (Scenario, Boxes, Bills, Scenarios, DataJSON, Tiers, Pool, ...) is still |
| -// shared: treat those, and the inner per-account meter maps Meters' values |
| -// point at, as immutable. Tiers (Task 5) is read-only exactly like Scenario |
| -// or Bills — nothing mutates it after buildEconomicsView returns, so it |
| -// gets no defensive copy of its own. Pool is a plain value struct |
| +// (Scenario, Boxes, Bills, Scenarios, DataJSON, Tiers, Names, Pool, ...) is |
| +// still shared: treat those, and the inner per-account meter maps Meters' |
| +// values point at, as immutable. Tiers (Task 5) and Names (account-names |
| +// batch) are both read-only exactly like Scenario or Bills — nothing |
| +// mutates either after buildEconomicsView returns, so neither gets a |
| +// defensive copy of its own. Pool is a plain value struct |
| // (PoolLedger), copied for free by copyEconomicsView's `cp := *v`, same as |
| // every other non-pointer, non-slice, non-map field on EconomicsView. |
| func (s *Server) LoadEconomics(ctx context.Context, period string) (*EconomicsView, error) { |
| @@ -459,8 +484,14 @@ func (s *Server) buildEconomicsView(ctx context.Context, period string) (*Econom |
| for acct := range sweep.PerAccount { |
| universe[acct] = true |
| } |
| + // names (account-names batch): sqid -> display name, from the SAME |
| + // accounts read the universe/Tiers loop above already performs — see |
| + // EconomicsView.Names's own doc comment for the display-only contract |
| + // this map exists under. |
| + names := make(map[string]string, len(accounts)) |
| for _, a := range accounts { |
| universe[a.Sqid] = true |
| + names[a.Sqid] = a.Name |
| } |
| // Allocate each box's period cost across the accounts that used it |
| @@ -600,6 +631,7 @@ func (s *Server) buildEconomicsView(ctx context.Context, period string) (*Econom |
| PeriodDays: sweep.PeriodDays, |
| DaysInMonth: daysInMonth, |
| Tiers: tiers, |
| + Names: names, |
| PoolTopupUnitAccrual: poolTopupUnitAccrual, |
| } |
| raw, err := json.Marshal(payload) |
| @@ -623,6 +655,7 @@ func (s *Server) buildEconomicsView(ctx context.Context, period string) (*Econom |
| DaysInMonth: daysInMonth, |
| DataJSON: template.JS(raw), |
| Tiers: tiers, |
| + Names: names, |
| Pool: pool, |
| }, nil |
| } |
diff --git a/internal/console/economicsview_test.go b/internal/console/economicsview_test.go| index 4252e60fa..cd3f63dd7 100644 |
| --- a/internal/console/economicsview_test.go |
| +++ b/internal/console/economicsview_test.go |
| @@ -173,6 +173,29 @@ func TestLoadEconomicsGolden(t *testing.T) { |
| } |
| } |
| +// TestLoadEconomicsNamesFromAccounts (account-names batch): EconomicsView.Names |
| +// is populated sqid->display-name from the SAME s.store.Accounts() read |
| +// buildEconomicsView already performs for the universe/Tiers — the fixture's |
| +// built-in account is named "ops" (newConsoleFixture / store.go's backfill), |
| +// keyed by fixtureAcct (its sqid, "bab"), exactly like Tiers is keyed. The |
| +// embedded payload carries the same map under "names" (economicsPayload), |
| +// the JS twin of view.Names, for parity with how "tiers" already travels. |
| +func TestLoadEconomicsNamesFromAccounts(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + |
| + view, err := fx.srv.LoadEconomics(ctx, "mtd") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, want := view.Names[fixtureAcct], "ops"; got != want { |
| + t.Fatalf("Names[%q] = %q, want %q (the fixture account's own name)", fixtureAcct, got, want) |
| + } |
| + if !strings.Contains(string(view.DataJSON), `"names"`) { |
| + t.Fatalf("DataJSON missing %q: %s", `"names"`, view.DataJSON) |
| + } |
| +} |
| + |
| // TestLoadEconomicsTierRevenueAndPool: tier-aware pricing (Task 5). A |
| // hosted-tier account prices normally — economics.PriceAccount's own |
| // RevenueUSD stands. A pool-tier account, even one with real cost AND fleet |
diff --git a/internal/console/server.go b/internal/console/server.go| index 5a9868c3f..c9adc66c4 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -1842,7 +1842,7 @@ func (s *Server) handleEconomicsAccounts(w http.ResponseWriter, r *http.Request) |
| } |
| rows := make([]econAccountRow, 0, len(view.Accounts)) |
| for _, ae := range view.Accounts { |
| - rows = append(rows, econAccountRow{Account: ae.Account, Meters: view.Meters[ae.Account], Econ: ae}) |
| + rows = append(rows, econAccountRow{Account: ae.Account, Name: view.Names[ae.Account], Meters: view.Meters[ae.Account], Econ: ae}) |
| } |
| q := parseEconAccountQuery(r.URL.Query()) |
| // Period is the NORMALIZED value LoadEconomics actually priced rows |
diff --git a/internal/console/templates/economics.html b/internal/console/templates/economics.html| index d3ebb05c6..f599e2a08 100644 |
| --- a/internal/console/templates/economics.html |
| +++ b/internal/console/templates/economics.html |
| @@ -112,8 +112,13 @@ |
| <table> |
| <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}} |
| + {{$name := index $.Economics.Names .Account}} |
| <tr data-account="{{.Account}}"> |
| - <td>{{.Account}}</td> |
| + {{/* Account-names batch: same treatment as economics_accounts.html's |
| + row (see that template's comment on this exact markup) — this page |
| + has no econAccountRow, so the name is looked up straight from |
| + EconomicsView.Names by sqid rather than carried on the row. */}} |
| + <td>{{if $name}}{{$name}} <span class="small">{{.Account}}</span>{{else}}{{.Account}}{{end}}</td> |
| <td class="js-acct-cost">{{usd .CostUSD}}</td> |
| <td class="js-acct-revenue">{{usd .RevenueUSD}}</td> |
| <td class="js-acct-margin">{{marginCell .MarginFrac .FreeTierPos}}</td> |
diff --git a/internal/console/templates/economics_accounts.html b/internal/console/templates/economics_accounts.html| index 163d9d4e4..6db05ef24 100644 |
| --- a/internal/console/templates/economics_accounts.html |
| +++ b/internal/console/templates/economics_accounts.html |
| @@ -107,7 +107,18 @@ |
| AccountBySqid lookup). */}} |
| {{$known := tierKnown $.Economics.Tiers .Account}} |
| <tr data-account="{{.Account}}"> |
| - <td>{{.Account}}</td> |
| + {{/* Account-names batch: a known console name renders as the primary |
| + text with the sqid muted+small beside it — the ".small" class |
| + already IS the muted treatment (layout.html: font-size 0.9rem, |
| + color var(--muted); there is no separate ".muted" class anywhere in |
| + these templates, so this reuses ".small" rather than inventing |
| + one — apps.html's StableAge span is the same secondary-text |
| + pattern). A usage-only ghost row (no console account row at all, so |
| + .Name is "") or an explicitly empty name renders the bare sqid |
| + alone, exactly as before this batch. The row's data-account |
| + attribute above and every other field on this row (the tier form |
| + below) still key off .Account, the sqid — display only. */}} |
| + <td>{{if .Name}}{{.Name}} <span class="small">{{.Account}}</span>{{else}}{{.Account}}{{end}}</td> |
| <td>{{printf "%.2f" (index .Meters "app_months")}}</td> |
| <td>{{printf "%.2f" (index .Meters "instance_hours")}}</td> |
| <td>{{printf "%.2f" (index .Meters "cpu_core_hours")}}</td> |