carlos / platform Public

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>
Paul Campbell 53d70b5f5da52f7eac20152b41bcd263fac89f2b parent c4fb730
8 files changed, +213 −12
  • internal/console/econlistquery.go +28 −4
  • internal/console/econlistquery_test.go +38 −0
  • internal/console/economicspage_test.go +67 −0
  • internal/console/economicsview.go +38 −5
  • internal/console/economicsview_test.go +23 −0
  • internal/console/server.go +1 −1
  • internal/console/templates/economics.html +6 −1
  • internal/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 &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}}
+ {{$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>