| 1 | // The economics accounts page's list controls (Task 14) — listquery.go's |
| 2 | // shape (instanceQuery/pageInstanceRows/cursor rules), applied to a |
| 3 | // []economics.AccountEconomics-backed row instead of an InstanceRow: a |
| 4 | // substring match on the account, a free-tier-position filter, and an |
| 5 | // opaque after-this-account cursor. See listquery.go's own package comment |
| 6 | // for why filters live server-side. |
| 7 | package console |
| 8 | |
| 9 | import ( |
| 10 | "encoding/base64" |
| 11 | "encoding/json" |
| 12 | "net/url" |
| 13 | "sort" |
| 14 | "strings" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/economics" |
| 17 | ) |
| 18 | |
| 19 | // econAccountPageSize is /fleet/economics/accounts's page length — the same |
| 20 | // 50 the Running tab uses (instancePageSize). |
| 21 | const econAccountPageSize = 50 |
| 22 | |
| 23 | // econPosWords is every economics.AccountEconomics.FreeTierPos value, in the |
| 24 | // order the header lists them — three of them ("inside", "partial", |
| 25 | // "paying") are economics.PriceAccount's own words; the fourth, "pooled", |
| 26 | // is never produced by PriceAccount at all (see its doc comment) — it is |
| 27 | // buildEconomicsView's override for try/pool/"" tier accounts |
| 28 | // (economicsview.go). A new position joins PriceAccount OR |
| 29 | // buildEconomicsView's override, this list, and the chip-freetier-<word> |
| 30 | // CSS rule in layout.html — nothing else. |
| 31 | var econPosWords = []string{"inside", "partial", "paying", "pooled"} |
| 32 | |
| 33 | // econAccountRow is one priced account joined with its own meter readings — |
| 34 | // EconomicsView.Accounts and EconomicsView.Meters, keyed by the same |
| 35 | // account (release.RouteAccount universe). |
| 36 | type econAccountRow struct { |
| 37 | Account string |
| 38 | // Name is the account's display name (EconomicsView.Names, account-names |
| 39 | // batch), joined at row-build time (handleEconomicsAccounts) exactly |
| 40 | // like Meters/Econ already are — "" for a usage-only ghost account with |
| 41 | // no console row at all, the same shape the render layer already |
| 42 | // handles for a missing Tiers entry. Display + search only (pageEconAccounts's |
| 43 | // Q filter below); Account stays the sqid this row's every other field |
| 44 | // (cursor, form values, data-account attribute) keys off. |
| 45 | Name string |
| 46 | Meters map[string]float64 |
| 47 | Econ economics.AccountEconomics |
| 48 | } |
| 49 | |
| 50 | // econAccountQuery is one request's list controls. Zero value = everything, |
| 51 | // first page. |
| 52 | type econAccountQuery struct { |
| 53 | // Q is a substring match against EITHER the account's sqid OR its |
| 54 | // display name (econAccountRow.Name, account-names batch), trimmed, and |
| 55 | // matched CASE-INSENSITIVELY against both — a deliberate divergence |
| 56 | // from the Running tab's instanceQuery.Q (listquery.go), which stays a |
| 57 | // case-sensitive substring match on host. Sqids are opaque and short |
| 58 | // (case never mattered in practice), but names are operator-typed |
| 59 | // English words an operator expects to find regardless of case — Paul's |
| 60 | // names request implies human-friendly search, not wire-shaped |
| 61 | // exactness, so this Q's matching rule is intentionally NOT the same as |
| 62 | // every other list-control Q in this package. |
| 63 | Q string |
| 64 | Pos string // FreeTierPos filter ("" = all) |
| 65 | Cursor string // opaque; list resumes after the account inside it |
| 66 | // Period is the normalized period this page's rows were priced under |
| 67 | // ("mtd"/"last"/"7d" — EconomicsView.Period, NOT the raw "period" query |
| 68 | // param, which LoadEconomics's normalizeEconomicsPeriod may have |
| 69 | // silently rewritten). Set by the handler AFTER LoadEconomics, never by |
| 70 | // parseEconAccountQuery: every link this page emits (search form, |
| 71 | // pos-filter chips, cursor pager, period nav) must carry the SAME |
| 72 | // period that produced the rows on screen, or "Next 50" on a non-mtd |
| 73 | // period silently resumes a cursor computed against a different |
| 74 | // period's row order under mtd's numbers — the bug this field exists |
| 75 | // to close. "" behaves identically to "mtd" everywhere below (both are |
| 76 | // the default, so both are omitted from a built URL). |
| 77 | Period string |
| 78 | } |
| 79 | |
| 80 | func parseEconAccountQuery(v url.Values) econAccountQuery { |
| 81 | return econAccountQuery{ |
| 82 | Q: strings.TrimSpace(v.Get("q")), |
| 83 | Pos: econPosFromParam(v.Get("pos")), |
| 84 | Cursor: v.Get("cursor"), |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | // econPosFromParam validates a "pos" query param against econPosWords. An |
| 89 | // unrecognized value returns "" — no filter, exactly healthFromSlug's rule |
| 90 | // for a mistyped URL: show everything rather than a mysteriously empty list. |
| 91 | func econPosFromParam(v string) string { |
| 92 | for _, w := range econPosWords { |
| 93 | if w == v { |
| 94 | return w |
| 95 | } |
| 96 | } |
| 97 | return "" |
| 98 | } |
| 99 | |
| 100 | type econAccountCursor struct { |
| 101 | Account string `json:"account"` |
| 102 | } |
| 103 | |
| 104 | func encodeEconAccountCursor(account string) string { |
| 105 | b, _ := json.Marshal(econAccountCursor{Account: account}) |
| 106 | return base64.RawURLEncoding.EncodeToString(b) |
| 107 | } |
| 108 | |
| 109 | // decodeEconAccountCursor degrades on any malformed input to "no cursor" — |
| 110 | // page one, never an error page (decodeInstanceCursor's rule). |
| 111 | func decodeEconAccountCursor(s string) (string, bool) { |
| 112 | b, err := base64.RawURLEncoding.DecodeString(s) |
| 113 | if err != nil { |
| 114 | return "", false |
| 115 | } |
| 116 | var c econAccountCursor |
| 117 | if json.Unmarshal(b, &c) != nil || c.Account == "" { |
| 118 | return "", false |
| 119 | } |
| 120 | return c.Account, true |
| 121 | } |
| 122 | |
| 123 | // econPosLink is one header chip: a free-tier position, its count over ALL |
| 124 | // the page's rows, and the link that filters to it — healthLink's shape, |
| 125 | // plus a precomputed Href (unlike healthLink, whose template builds hrefs |
| 126 | // inline) so period-carrying is done once, correctly, in Go rather than |
| 127 | // re-derived at every call site in the template. |
| 128 | type econPosLink struct { |
| 129 | Word string |
| 130 | Count int |
| 131 | Active bool |
| 132 | Href string |
| 133 | } |
| 134 | |
| 135 | // econAccountsPage is one screen of the accounts list: the rows to render |
| 136 | // plus the numbers and links the header states. |
| 137 | type econAccountsPage struct { |
| 138 | Rows []econAccountRow |
| 139 | Counts map[string]int // free-tier position -> count over ALL rows |
| 140 | Total int // rows matching Q+Pos, across all pages |
| 141 | Links []econPosLink // non-zero positions, econPosWords order |
| 142 | AllURL string // clears Pos, keeps Q+Period — the "All" link |
| 143 | NextCursor string // "" on the last page |
| 144 | NextURL string // the whole next-page href; "" on the last page |
| 145 | } |
| 146 | |
| 147 | // econLinkURL builds one of this page's own-filter links (AllURL, a |
| 148 | // econPosLink.Href, or NextURL before the cursor is appended): Q and |
| 149 | // Period (when set and non-default) always carry forward; pos is whatever |
| 150 | // the caller wants that ONE link to filter to ("" clears the filter). |
| 151 | // Never carries Cursor — every caller of this helper is building a link to |
| 152 | // page ONE of a (possibly still-filtered) view, not a cursor resume. |
| 153 | func econLinkURL(q econAccountQuery, pos string) url.Values { |
| 154 | v := url.Values{} |
| 155 | if q.Q != "" { |
| 156 | v.Set("q", q.Q) |
| 157 | } |
| 158 | if pos != "" { |
| 159 | v.Set("pos", pos) |
| 160 | } |
| 161 | if q.Period != "" && q.Period != "mtd" { |
| 162 | v.Set("period", q.Period) |
| 163 | } |
| 164 | return v |
| 165 | } |
| 166 | |
| 167 | // econPeriodURL builds the href for the period-switch nav |
| 168 | // (economics_accounts.html): switches to period, preserves Q and Pos (an |
| 169 | // operator's search/filter survives a period switch), but drops Cursor — |
| 170 | // a cursor decodes to an account-name resume point computed against ONE |
| 171 | // period's row order; carrying it into a different period's differently |
| 172 | // filtered/ordered set is meaningless, so a period switch always lands on |
| 173 | // page one of the (still-filtered) view. Registered as a template func |
| 174 | // (server.go) since it takes two arguments, unlike the single-struct |
| 175 | // econAccountsPage fields above. |
| 176 | func econPeriodURL(q econAccountQuery, period string) string { |
| 177 | v := url.Values{} |
| 178 | if q.Q != "" { |
| 179 | v.Set("q", q.Q) |
| 180 | } |
| 181 | if q.Pos != "" { |
| 182 | v.Set("pos", q.Pos) |
| 183 | } |
| 184 | if period != "" && period != "mtd" { |
| 185 | v.Set("period", period) |
| 186 | } |
| 187 | return "?" + v.Encode() |
| 188 | } |
| 189 | |
| 190 | // pageEconAccounts sorts rows by account name ASC (a stable cursor order |
| 191 | // independent of EconomicsView.Accounts's own CostUSD-desc ordering — see |
| 192 | // economicsview.go's doc comment on that sort), applies q, and cuts one |
| 193 | // page. Counts cover every row regardless of filter, exactly like |
| 194 | // pageInstanceRows's Counts. |
| 195 | func pageEconAccounts(rows []econAccountRow, q econAccountQuery, limit int) econAccountsPage { |
| 196 | sorted := append([]econAccountRow(nil), rows...) |
| 197 | sort.Slice(sorted, func(i, j int) bool { return sorted[i].Account < sorted[j].Account }) |
| 198 | |
| 199 | // ql is the lower-cased Q, computed once rather than per row — see |
| 200 | // econAccountQuery.Q's doc comment for why this match is |
| 201 | // case-insensitive against BOTH the sqid and the name, unlike every |
| 202 | // other Q in this package. |
| 203 | ql := strings.ToLower(q.Q) |
| 204 | |
| 205 | p := econAccountsPage{Counts: map[string]int{}} |
| 206 | var matched []econAccountRow |
| 207 | for _, r := range sorted { |
| 208 | p.Counts[r.Econ.FreeTierPos]++ |
| 209 | if ql != "" && !strings.Contains(strings.ToLower(r.Account), ql) && !strings.Contains(strings.ToLower(r.Name), ql) { |
| 210 | continue |
| 211 | } |
| 212 | if q.Pos != "" && r.Econ.FreeTierPos != q.Pos { |
| 213 | continue |
| 214 | } |
| 215 | matched = append(matched, r) |
| 216 | } |
| 217 | p.Total = len(matched) |
| 218 | start := 0 |
| 219 | if after, ok := decodeEconAccountCursor(q.Cursor); ok { |
| 220 | for i, r := range matched { |
| 221 | if r.Account > after { |
| 222 | start = i |
| 223 | break |
| 224 | } |
| 225 | start = i + 1 |
| 226 | } |
| 227 | } |
| 228 | end := start + limit |
| 229 | if end < len(matched) { |
| 230 | p.NextCursor = encodeEconAccountCursor(matched[end-1].Account) |
| 231 | } else { |
| 232 | end = len(matched) |
| 233 | } |
| 234 | p.Rows = matched[start:end] |
| 235 | p.AllURL = "?" + econLinkURL(q, "").Encode() |
| 236 | for _, w := range econPosWords { |
| 237 | if p.Counts[w] == 0 { |
| 238 | continue |
| 239 | } |
| 240 | p.Links = append(p.Links, econPosLink{Word: w, Count: p.Counts[w], Active: q.Pos == w, Href: "?" + econLinkURL(q, w).Encode()}) |
| 241 | } |
| 242 | if p.NextCursor != "" { |
| 243 | v := econLinkURL(q, q.Pos) |
| 244 | v.Set("cursor", p.NextCursor) |
| 245 | p.NextURL = "?" + v.Encode() |
| 246 | } |
| 247 | return p |
| 248 | } |
| 249 | |