carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
7package console
8
9import (
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).
21const 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.
31var 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).
36type 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.
52type 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
80func 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.
91func econPosFromParam(v string) string {
92 for _, w := range econPosWords {
93 if w == v {
94 return w
95 }
96 }
97 return ""
98}
99
100type econAccountCursor struct {
101 Account string `json:"account"`
102}
103
104func 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).
111func 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.
128type 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.
137type 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.
153func 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.
176func 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.
195func 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