// The economics accounts page's list controls (Task 14) — listquery.go's // shape (instanceQuery/pageInstanceRows/cursor rules), applied to a // []economics.AccountEconomics-backed row instead of an InstanceRow: a // substring match on the account, a free-tier-position filter, and an // opaque after-this-account cursor. See listquery.go's own package comment // for why filters live server-side. package console import ( "encoding/base64" "encoding/json" "net/url" "sort" "strings" "github.com/carlosframework/platform/internal/economics" ) // econAccountPageSize is /fleet/economics/accounts's page length — the same // 50 the Running tab uses (instancePageSize). const econAccountPageSize = 50 // econPosWords is every economics.AccountEconomics.FreeTierPos value, in the // order the header lists them — three of them ("inside", "partial", // "paying") are economics.PriceAccount's own words; the fourth, "pooled", // is never produced by PriceAccount at all (see its doc comment) — it is // buildEconomicsView's override for try/pool/"" tier accounts // (economicsview.go). A new position joins PriceAccount OR // buildEconomicsView's override, this list, and the chip-freetier- // CSS rule in layout.html — nothing else. var econPosWords = []string{"inside", "partial", "paying", "pooled"} // econAccountRow is one priced account joined with its own meter readings — // EconomicsView.Accounts and EconomicsView.Meters, keyed by the same // account (release.RouteAccount universe). type econAccountRow struct { Account string // 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 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 // ("mtd"/"last"/"7d" — EconomicsView.Period, NOT the raw "period" query // param, which LoadEconomics's normalizeEconomicsPeriod may have // silently rewritten). Set by the handler AFTER LoadEconomics, never by // parseEconAccountQuery: every link this page emits (search form, // pos-filter chips, cursor pager, period nav) must carry the SAME // period that produced the rows on screen, or "Next 50" on a non-mtd // period silently resumes a cursor computed against a different // period's row order under mtd's numbers — the bug this field exists // to close. "" behaves identically to "mtd" everywhere below (both are // the default, so both are omitted from a built URL). Period string } func parseEconAccountQuery(v url.Values) econAccountQuery { return econAccountQuery{ Q: strings.TrimSpace(v.Get("q")), Pos: econPosFromParam(v.Get("pos")), Cursor: v.Get("cursor"), } } // econPosFromParam validates a "pos" query param against econPosWords. An // unrecognized value returns "" — no filter, exactly healthFromSlug's rule // for a mistyped URL: show everything rather than a mysteriously empty list. func econPosFromParam(v string) string { for _, w := range econPosWords { if w == v { return w } } return "" } type econAccountCursor struct { Account string `json:"account"` } func encodeEconAccountCursor(account string) string { b, _ := json.Marshal(econAccountCursor{Account: account}) return base64.RawURLEncoding.EncodeToString(b) } // decodeEconAccountCursor degrades on any malformed input to "no cursor" — // page one, never an error page (decodeInstanceCursor's rule). func decodeEconAccountCursor(s string) (string, bool) { b, err := base64.RawURLEncoding.DecodeString(s) if err != nil { return "", false } var c econAccountCursor if json.Unmarshal(b, &c) != nil || c.Account == "" { return "", false } return c.Account, true } // econPosLink is one header chip: a free-tier position, its count over ALL // the page's rows, and the link that filters to it — healthLink's shape, // plus a precomputed Href (unlike healthLink, whose template builds hrefs // inline) so period-carrying is done once, correctly, in Go rather than // re-derived at every call site in the template. type econPosLink struct { Word string Count int Active bool Href string } // econAccountsPage is one screen of the accounts list: the rows to render // plus the numbers and links the header states. type econAccountsPage struct { Rows []econAccountRow Counts map[string]int // free-tier position -> count over ALL rows Total int // rows matching Q+Pos, across all pages Links []econPosLink // non-zero positions, econPosWords order AllURL string // clears Pos, keeps Q+Period — the "All" link NextCursor string // "" on the last page NextURL string // the whole next-page href; "" on the last page } // econLinkURL builds one of this page's own-filter links (AllURL, a // econPosLink.Href, or NextURL before the cursor is appended): Q and // Period (when set and non-default) always carry forward; pos is whatever // the caller wants that ONE link to filter to ("" clears the filter). // Never carries Cursor — every caller of this helper is building a link to // page ONE of a (possibly still-filtered) view, not a cursor resume. func econLinkURL(q econAccountQuery, pos string) url.Values { v := url.Values{} if q.Q != "" { v.Set("q", q.Q) } if pos != "" { v.Set("pos", pos) } if q.Period != "" && q.Period != "mtd" { v.Set("period", q.Period) } return v } // econPeriodURL builds the href for the period-switch nav // (economics_accounts.html): switches to period, preserves Q and Pos (an // operator's search/filter survives a period switch), but drops Cursor — // a cursor decodes to an account-name resume point computed against ONE // period's row order; carrying it into a different period's differently // filtered/ordered set is meaningless, so a period switch always lands on // page one of the (still-filtered) view. Registered as a template func // (server.go) since it takes two arguments, unlike the single-struct // econAccountsPage fields above. func econPeriodURL(q econAccountQuery, period string) string { v := url.Values{} if q.Q != "" { v.Set("q", q.Q) } if q.Pos != "" { v.Set("pos", q.Pos) } if period != "" && period != "mtd" { v.Set("period", period) } return "?" + v.Encode() } // pageEconAccounts sorts rows by account name ASC (a stable cursor order // independent of EconomicsView.Accounts's own CostUSD-desc ordering — see // economicsview.go's doc comment on that sort), applies q, and cuts one // page. Counts cover every row regardless of filter, exactly like // pageInstanceRows's Counts. func pageEconAccounts(rows []econAccountRow, q econAccountQuery, limit int) econAccountsPage { 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 ql != "" && !strings.Contains(strings.ToLower(r.Account), ql) && !strings.Contains(strings.ToLower(r.Name), ql) { continue } if q.Pos != "" && r.Econ.FreeTierPos != q.Pos { continue } matched = append(matched, r) } p.Total = len(matched) start := 0 if after, ok := decodeEconAccountCursor(q.Cursor); ok { for i, r := range matched { if r.Account > after { start = i break } start = i + 1 } } end := start + limit if end < len(matched) { p.NextCursor = encodeEconAccountCursor(matched[end-1].Account) } else { end = len(matched) } p.Rows = matched[start:end] p.AllURL = "?" + econLinkURL(q, "").Encode() for _, w := range econPosWords { if p.Counts[w] == 0 { continue } p.Links = append(p.Links, econPosLink{Word: w, Count: p.Counts[w], Active: q.Pos == w, Href: "?" + econLinkURL(q, w).Encode()}) } if p.NextCursor != "" { v := econLinkURL(q, q.Pos) v.Set("cursor", p.NextCursor) p.NextURL = "?" + v.Encode() } return p }