carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Console-side read path for unit economics: assembling what the operator
2// /fleet/economics dashboard (Task 13) renders out of sweepUsage's per-day
3// accumulation (economicspass.go), an active pricing scenario
4// (internal/economics), and the two most recent real bills. Like
5// metricsview.go this is a bucket sweep plus an in-process cache — nothing
6// here writes anything.
7package console
8
9import (
10 "context"
11 "encoding/json"
12 "errors"
13 "fmt"
14 "html/template"
15 "log"
16 "os"
17 "sort"
18 "strings"
19 "sync"
20 "time"
21
22 "github.com/carlosframework/platform/internal/economics"
23 "github.com/carlosframework/platform/internal/registry"
24 "github.com/carlosframework/platform/internal/usage"
25)
26
27// economicsCacheTTL/economicsStaleServeCeiling/economicsSweepTimeout mirror
28// metricsview.go's metricsCacheTTL/metricsStaleServeCeiling/
29// metricsSweepTimeout exactly (same 60s freshness window, same 2-minute
30// stale-while-revalidate ceiling) — see those constants' doc comments for
31// the full reasoning, which applies here unchanged: LoadEconomics is called
32// from the same kind of per-request path (the dashboard page) and must not
33// make every request pay for a fresh bucket sweep.
34const (
35 economicsCacheTTL = 60 * time.Second
36 economicsStaleServeCeiling = 2 * time.Minute
37 economicsSweepTimeout = 15 * time.Second
38)
39
40// EconomicsView is one priced period, ready to render.
41type EconomicsView struct {
42 GeneratedAt time.Time
43 Period string
44 Scenario economics.Scenario // active (bucket) or DefaultScenario fallback
45 // ScenarioWarning is set when active.json named a scenario that could
46 // not be loaded/parsed/validated — never set when active.json is simply
47 // absent (a fresh deployment, not a fault; see loadActiveScenario).
48 ScenarioWarning string
49 // Warning is set when the SWEEP itself degraded — currently only "no
50 // registry configured/reachable", which makes every sampled unit
51 // attribute to release.OpsAccount instead of its real account (see
52 // loadRoutesForEconomics). Distinct from ScenarioWarning: the two can
53 // both be set at once and Task 13 renders them as separate notices.
54 Warning string
55 Scenarios []string // names under economics/scenarios/
56 Accounts []economics.AccountEconomics // priced under Scenario, sorted by CostUSD desc, Account asc on ties
57 Meters map[string]map[string]float64 // account -> meter (for embedding)
58 Boxes []BoxEconomics
59 Bills []economics.Bill // this month + previous, if present
60 CoveredDays, PeriodDays int
61 // DaysInMonth is the CURRENT real calendar month's length (statementsPass's
62 // own daysInMonth derivation: time.Date(y, mo+1, 0, ...).Day()) —
63 // deliberately NOT PeriodDays, which on "mtd" is only the number of days
64 // elapsed so far this month. economics.Project's mtd->full-month
65 // projection needs the FULL month's length as its target denominator's
66 // mate; PeriodDays there would make the projected figure equal plain MTD
67 // whenever coverage is complete, which is a labelled projection that
68 // doesn't project (the honesty rule's whole point). Populated on every
69 // view regardless of Period — only "mtd" ever renders it.
70 DaysInMonth int
71 DataJSON template.JS // the embedded payload — see economicsPayload
72 // Tiers is every live account's fleet-pool tier, keyed by sqid (Store.
73 // AccountTiers, Task 4) — Task 6's econAccountRow reads it at render.
74 // An account absent from this map (usage with no console row at all,
75 // e.g. a fleet-only account whose owner never signed up) carries pool
76 // semantics, same as an account present with the explicit "" tier — see
77 // tierOf. Shared, read-only, like Scenario/Boxes/Bills — see
78 // copyEconomicsView's doc comment.
79 Tiers map[string]string
80 // Names is every live account's display name, keyed by sqid (the SAME
81 // s.store.Accounts() read buildEconomicsView already performs to build
82 // the universe/Tiers, Account.Sqid -> Account.Name) — account-names
83 // batch. Display + search only: the join/key universe (meters,
84 // statements, tiers, cursors, form values) stays sqid-shaped everywhere
85 // unchanged; this map exists purely so the render layer can show a
86 // human name next to the sqid it already has. An account absent from
87 // this map (usage with no matching console account row at all, e.g. a
88 // fleet-only account whose owner never signed up) has no name to show —
89 // same "index returns the zero value" shape as Tiers/tierOf, and the
90 // render layer falls back to the bare sqid for it, exactly as before
91 // this map existed. Shared, read-only, like Scenario/Boxes/Bills/Tiers
92 // — see copyEconomicsView's doc comment; gets no defensive copy of its
93 // own for the same reason Tiers doesn't.
94 Names map[string]string
95 // Pool is this period's fleet-pool ledger (Task 5) — see PoolLedger.
96 Pool PoolLedger
97}
98
99// PoolLedger is one period's fleet-pool revenue-sharing standing: how much
100// the pool holds (a share of paying revenue plus a day-accrued monthly
101// top-up — see poolTopupAccrual), how much pool-tier accounts have drawn
102// against it in cost, and the headroom between the two — which MAY be
103// negative (the pool is overdrawn this period) and must be rendered
104// honestly, not clamped to zero. See buildEconomicsView's doc comment for
105// the exact formula; the
106// dashboard's inline knob JS (Task 6) recomputes this same ledger
107// client-side from DataJSON's "tiers"/"scenario"/"meters" rather than this
108// struct being marshalled itself — nothing here carries a json tag.
109type PoolLedger struct {
110 PoolUSD, DrawUSD, HeadroomUSD float64
111 PayingRevenueUSD float64 // the revenue the frac applies to
112}
113
114// BoxEconomics is one box, priced under Scenario over the period.
115type BoxEconomics struct {
116 Box string
117 Hours float64
118 CostUSD float64
119 IdleUSD float64
120 RevenueUSD float64 // Σ over tenants: their revenue × (this box's allocated share / their total allocated cost)
121 Utilization float64 // 1 - IdleUSD/CostUSD (0 when CostUSD is 0)
122 EgressMetered bool
123}
124
125// economicsPayload is DataJSON's shape: everything Task 13's inline knob JS
126// needs to redo AllocateBox/PriceAccount client-side when an operator tweaks
127// a scenario knob live, without a server round trip. Field names are frozen
128// by that JS — change both together (economics.go's PriceAccount doc carries
129// the same warning for the pricing formulas themselves). Every field,
130// including the nested economics.BoxLoad/BoxAccountLoad values inside
131// BoxLoads, carries an explicit snake_case json tag (compute.go) — one
132// convention for the whole payload, not a mix of Go field names and
133// snake_case. json.Marshal HTML-escapes '<', '>' and '&' by default (unlike
134// an Encoder with SetEscapeHTML(false)) — that default is exactly what
135// keeps this safe to embed inside a literal <script> block, so it must stay
136// in effect: never swap this for an Encoder with escaping turned off.
137type economicsPayload struct {
138 Scenario economics.Scenario `json:"scenario"`
139 Meters map[string]map[string]float64 `json:"meters"`
140 BoxLoads map[string]economics.BoxLoad `json:"boxLoads"`
141 BoxSpecs map[string]economics.BoxSpec `json:"boxSpecs"`
142 Bills []economics.Bill `json:"bills"`
143 CoveredDays int `json:"coveredDays"`
144 PeriodDays int `json:"periodDays"`
145 // DaysInMonth mirrors EconomicsView.DaysInMonth (this struct's doc
146 // comment above explains why it must NOT be confused with PeriodDays) —
147 // additive key, the JS twin of the Go-side project() call's third
148 // argument.
149 DaysInMonth int `json:"daysInMonth"`
150 // Tiers mirrors EconomicsView.Tiers (Task 5) — additive key. The pool
151 // ledger itself is NOT part of this payload: "pool" scenario knobs
152 // already travel inside Scenario above, so the JS twin recomputes
153 // PoolLedger client-side from Tiers + Scenario.Pool + Meters the same
154 // way buildEconomicsView does (Task 6), rather than this struct
155 // shipping a second, potentially stale copy of the same numbers.
156 Tiers map[string]string `json:"tiers"`
157 // Names mirrors EconomicsView.Names (account-names batch) — additive
158 // key, sqid -> display name, for any client-side rendering that wants a
159 // human name next to a sqid without a server round trip. Nothing in
160 // econ_maths_js reads it today (the recompute paths key everything by
161 // sqid, per the join/key universe staying sqid-shaped — see
162 // EconomicsView.Names's own doc comment); it travels here purely for
163 // parity with how "tiers" already does, and so it's available the
164 // moment a future knob-drawer feature wants it.
165 Names map[string]string `json:"names"`
166 // PoolTopupUnitAccrual is Σ 1/daysInMonth(d) over [from, to] — the same
167 // per-day sum poolTopupAccrual folds against Scenario.Pool.TopupUSD
168 // server-side, but with topupUSD fixed at 1 so it carries no dollar
169 // value of its own: poolTopupAccrual(from, to, x) == x *
170 // PoolTopupUnitAccrual for any x, by that function's own linearity (each
171 // day contributes topupUSD/daysInMonth(that day), so scaling topupUSD
172 // scales the whole sum). The JS twin (econ_maths_js's poolLedger)
173 // multiplies this dimensionless figure by whatever topup_usd the
174 // operator's live knob currently reads, reproducing poolTopupAccrual
175 // EXACTLY under any knob value with no client-side re-derivation of the
176 // per-day loop — the carried ruling (Task 6 review) this field exists to
177 // satisfy: the alternative (shipping the accrued DOLLAR figure for the
178 // knob's STARTING value and trying to rescale it) breaks at
179 // oldTopup==0, this doesn't. Additive key.
180 PoolTopupUnitAccrual float64 `json:"poolTopupUnitAccrual"`
181}
182
183// economicsCacheEntry is one period's cached sweep — the metricsCache
184// pattern (metricsview.go:187-338) copied per period rather than once: mu
185// guards the fields and is never held across bucket IO; sweeping/done
186// serialise the IO instead, so a cold sweep for "mtd" never blocks a caller
187// asking for "last". See metricsCache's doc comment for the full reasoning
188// behind every field here — it applies verbatim, just keyed by period.
189type economicsCacheEntry struct {
190 mu sync.Mutex
191 at time.Time
192 view *EconomicsView
193 sweeping bool
194 done chan struct{}
195}
196
197// economicsCache holds one economicsCacheEntry per period ("mtd", "last",
198// "7d") — a small map rather than three named fields, since LoadEconomics
199// normalizes any period string to one of those three before ever touching
200// the cache (normalizeEconomicsPeriod), so the map never grows past three
201// entries. entries itself is guarded by mu; each entry's own mu guards that
202// entry's fields, exactly as metricsCache guards its single set.
203type economicsCache struct {
204 mu sync.Mutex
205 entries map[string]*economicsCacheEntry
206}
207
208// entry returns the stable cache slot for period, creating it on first use.
209func (s *Server) economicsCacheEntry(period string) *economicsCacheEntry {
210 c := &s.economicsCache
211 c.mu.Lock()
212 defer c.mu.Unlock()
213 if c.entries == nil {
214 c.entries = map[string]*economicsCacheEntry{}
215 }
216 e := c.entries[period]
217 if e == nil {
218 e = &economicsCacheEntry{}
219 c.entries[period] = e
220 }
221 return e
222}
223
224// normalizeEconomicsPeriod maps any unrecognized period string to "mtd" —
225// the brief's default, and the shape that keeps the cache map bounded to
226// exactly three entries regardless of what a caller passes.
227func normalizeEconomicsPeriod(period string) string {
228 switch period {
229 case "mtd", "last", "7d":
230 return period
231 default:
232 return "mtd"
233 }
234}
235
236// economicsPeriodBounds turns a normalized period into a [from, to] range,
237// evaluated against now: "mtd" is the first of this month through now;
238// "last" is the WHOLE previous calendar month; "7d" is now-7d through now.
239// Previous-month arithmetic goes through the first of THIS month rather than
240// now.AddDate(0, -1, 0) directly, to dodge time.AddDate's day-overflow
241// behaviour (e.g. Mar 31 minus one month normalizing to Mar 3, not Feb 28) —
242// day 1 never overflows.
243func economicsPeriodBounds(period string, now time.Time) (from, to time.Time) {
244 now = now.UTC()
245 firstOfThisMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
246 switch period {
247 case "last":
248 firstOfLastMonth := firstOfThisMonth.AddDate(0, -1, 0)
249 lastOfLastMonth := firstOfThisMonth.AddDate(0, 0, -1)
250 return firstOfLastMonth, lastOfLastMonth
251 case "7d":
252 return now.AddDate(0, 0, -7), now
253 default: // "mtd"
254 return firstOfThisMonth, now
255 }
256}
257
258// LoadEconomics prices usage over period ("mtd" default, "last", "7d") under
259// the active scenario, caching the result per period in-process — the same
260// three-state freshness logic as LoadMetrics (metricsview.go:257-338):
261// FRESH within economicsCacheTTL returns immediately; STALE-WITHIN-CEILING
262// (past the TTL but within economicsStaleServeCeiling) also returns
263// immediately and kicks a background refresh; COLD or STALE-PAST-THE-CEILING
264// blocks for a real sweep and surfaces its error. See LoadMetrics's doc
265// comment for the full reasoning — it applies here verbatim, just against a
266// per-period cache entry instead of one shared cache.
267//
268// The returned *EconomicsView is otherwise READ-ONLY: it (or its unexported
269// twin) is shared with the in-process cache and any other concurrent caller
270// of the same period. copyEconomicsView gives each caller its own Accounts
271// backing array and top-level Meters map specifically so a caller may
272// freely sort.Slice(view.Accounts, ...) or add/remove top-level Meters
273// entries without racing the cache or another caller doing the same —
274// Task 13's dashboard sort is the motivating case. Every OTHER field
275// (Scenario, Boxes, Bills, Scenarios, DataJSON, Tiers, Names, Pool, ...) is
276// still shared: treat those, and the inner per-account meter maps Meters'
277// values point at, as immutable. Tiers (Task 5) and Names (account-names
278// batch) are both read-only exactly like Scenario or Bills — nothing
279// mutates either after buildEconomicsView returns, so neither gets a
280// defensive copy of its own. Pool is a plain value struct
281// (PoolLedger), copied for free by copyEconomicsView's `cp := *v`, same as
282// every other non-pointer, non-slice, non-map field on EconomicsView.
283func (s *Server) LoadEconomics(ctx context.Context, period string) (*EconomicsView, error) {
284 period = normalizeEconomicsPeriod(period)
285 c := s.economicsCacheEntry(period)
286 for {
287 c.mu.Lock()
288 if !c.at.IsZero() && time.Since(c.at) < economicsCacheTTL {
289 view := c.view
290 c.mu.Unlock()
291 return copyEconomicsView(view), nil
292 }
293 if !c.at.IsZero() && time.Since(c.at) <= economicsStaleServeCeiling {
294 view := c.view
295 if !c.sweeping {
296 s.startBackgroundEconomicsSweep(c, period)
297 }
298 c.mu.Unlock()
299 return copyEconomicsView(view), nil
300 }
301 if c.sweeping {
302 wait := c.done
303 c.mu.Unlock()
304 select {
305 case <-wait:
306 continue
307 case <-ctx.Done():
308 return nil, ctx.Err()
309 }
310 }
311 c.sweeping = true
312 c.done = make(chan struct{})
313 done := c.done
314 c.mu.Unlock()
315
316 var (
317 view *EconomicsView
318 err = errors.New("console: economics sweep did not complete")
319 )
320 func() {
321 defer func() {
322 c.mu.Lock()
323 if err == nil {
324 c.at = time.Now()
325 c.view = view
326 }
327 c.sweeping = false
328 close(done)
329 c.mu.Unlock()
330 }()
331 view, err = s.buildEconomicsView(ctx, period)
332 }()
333
334 if err != nil {
335 return nil, err
336 }
337 return copyEconomicsView(view), nil
338 }
339}
340
341// copyEconomicsView returns a shallow copy of v safe for a caller to sort or
342// otherwise rearrange in place without racing the cache: Accounts gets a
343// fresh backing array (Task 14's dashboard sorts it) and Meters gets a fresh
344// top-level map (inner per-account maps are still shared by reference —
345// cheap at this size, and no known caller rewrites a meter value in place
346// rather than reading it). See LoadEconomics's doc comment for the full
347// read-only contract this exists to enforce.
348func copyEconomicsView(v *EconomicsView) *EconomicsView {
349 cp := *v
350 cp.Accounts = append([]economics.AccountEconomics(nil), v.Accounts...)
351 cp.Meters = make(map[string]map[string]float64, len(v.Meters))
352 for acct, mm := range v.Meters {
353 cp.Meters[acct] = mm
354 }
355 return &cp
356}
357
358// startBackgroundEconomicsSweep mirrors startBackgroundSweep
359// (metricsview.go:361-391): launches buildEconomicsView on its own
360// goroutine, detached (context.Background(), since there is no caller
361// behind it — buildEconomicsView bounds itself with economicsSweepTimeout),
362// with an explicit recover since no net/http per-connection recover sits
363// above it. A failed or panicked background sweep is only logged: no caller
364// is waiting on its error, and the stale cache it leaves untouched is still
365// valid data. Must be called with c.mu held.
366func (s *Server) startBackgroundEconomicsSweep(c *economicsCacheEntry, period string) {
367 c.sweeping = true
368 c.done = make(chan struct{})
369 done := c.done
370 go func() {
371 var (
372 view *EconomicsView
373 err = errors.New("console: background economics sweep did not complete")
374 )
375 defer func() {
376 rec := recover()
377 c.mu.Lock()
378 if err == nil {
379 c.at = time.Now()
380 c.view = view
381 }
382 c.sweeping = false
383 close(done)
384 c.mu.Unlock()
385 switch {
386 case rec != nil:
387 log.Printf("console: background economics sweep panic, cache left at its last good sweep: %v", rec)
388 case err != nil:
389 log.Printf("console: background economics sweep: %v", err)
390 }
391 }()
392 view, err = s.buildEconomicsView(context.Background(), period)
393 }()
394}
395
396// buildEconomicsView is the bucket half of LoadEconomics: sweepUsage over
397// the period's bounds, the active scenario, priced accounts and boxes, the
398// two most recent bills, and the embedded payload — on a context detached
399// from the caller's (WithoutCancel) and bounded by economicsSweepTimeout,
400// exactly like sweepAll (metricsview.go:404-411). It holds no lock.
401func (s *Server) buildEconomicsView(ctx context.Context, period string) (*EconomicsView, error) {
402 sweepCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), economicsSweepTimeout)
403 defer cancel()
404
405 now := time.Now().UTC()
406 from, to := economicsPeriodBounds(period, now)
407 // daysInMonth is the CURRENT real month's length, statementsPass's own
408 // derivation (economicspass.go) — see EconomicsView.DaysInMonth's doc
409 // comment for why this must be the full month, not PeriodDays.
410 daysInMonth := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, time.UTC).Day()
411
412 routes, warning := s.loadRoutesForEconomics(sweepCtx)
413
414 sweep, err := s.sweepUsage(sweepCtx, from, to, routes)
415 if err != nil {
416 return nil, fmt.Errorf("console: load economics: %w", err)
417 }
418 if err := s.foldStorageAndCensus(sweepCtx, from, to, sweep.PerAccount); err != nil {
419 return nil, fmt.Errorf("console: load economics: %w", err)
420 }
421
422 // Fleet meters (Task 3's fleetMeters) fold into sweep.PerAccount exactly
423 // like statementsPass's own union merge (economicspass.go) — an account
424 // with ONLY fleet usage and no platform route still needs its meter
425 // keys before universe below is built from sweep.PerAccount's keys, or
426 // it would never get a priced row at all.
427 //
428 // Unlike statementsPass (a WRITE pass, which refuses the whole run on a
429 // fleetMeters error — see that function's doc comment), THIS is a READ
430 // path: a bad registry read or a bucket hiccup under a fleet prefix
431 // degrades to "no fleet meters this render" plus a visible Warning,
432 // never a failed dashboard — the same asymmetry loadRoutesForEconomics
433 // already draws for the registry itself. Called once per
434 // buildEconomicsView call, which is already the unit the 60s
435 // stale-while-revalidate cache (economicsCacheTTL) bounds — no separate
436 // throttling needed here. A registry-less console would otherwise show
437 // TWO banners for the SAME missing registry (loadRoutesForEconomics's
438 // and this one, since fleetMeters opens that identical registry) — see
439 // the warning=="" guard below, which keeps it to one banner, one cause.
440 if fleetMM, ferr := s.fleetMeters(sweepCtx, from, to); ferr != nil {
441 log.Printf("console: load economics: fleet meters unavailable: %v", ferr)
442 // Suppress a second banner when routes are ALREADY degraded
443 // (warning != "" here can only mean loadRoutesForEconomics found no
444 // usable registry, the one thing that precedes this call): fleetMeters
445 // opens that exact same registry, so its failure here is the same
446 // root cause restated, not a second independent fault. One banner,
447 // one cause — still logged above for whoever reads the process log.
448 if warning == "" {
449 warning = fmt.Sprintf("fleet metering unavailable: %v", ferr)
450 }
451 } else {
452 for acct, mm := range fleetMM {
453 dst := sweep.PerAccount[acct]
454 if dst == nil {
455 dst = map[string]float64{}
456 sweep.PerAccount[acct] = dst
457 }
458 for meter, v := range mm {
459 dst[meter] += v
460 }
461 }
462 }
463
464 sc, scenarioWarning := s.loadActiveScenario(sweepCtx)
465
466 scenarioNames, err := s.listScenarioNames(sweepCtx)
467 if err != nil {
468 return nil, fmt.Errorf("console: load economics: %w", err)
469 }
470
471 // Union of accounts to price: every account with usage this period plus
472 // every live console account — the same "an account with zero usage
473 // still gets a row" reasoning statementsPass documents (economicspass.go)
474 // applies to the dashboard just as much as to statements.
475 accounts, err := s.store.Accounts()
476 if err != nil {
477 return nil, fmt.Errorf("console: load economics: %w", err)
478 }
479 tiers, err := s.store.AccountTiers()
480 if err != nil {
481 return nil, fmt.Errorf("console: load economics: %w", err)
482 }
483 universe := map[string]bool{}
484 for acct := range sweep.PerAccount {
485 universe[acct] = true
486 }
487 // names (account-names batch): sqid -> display name, from the SAME
488 // accounts read the universe/Tiers loop above already performs — see
489 // EconomicsView.Names's own doc comment for the display-only contract
490 // this map exists under.
491 names := make(map[string]string, len(accounts))
492 for _, a := range accounts {
493 universe[a.Sqid] = true
494 names[a.Sqid] = a.Name
495 }
496
497 // Allocate each box's period cost across the accounts that used it
498 // (economics.AllocateBox), and remember each account's TOTAL allocated
499 // cost across every box it touched — PriceAccount needs that total, and
500 // the per-box revenue attribution below needs each box's SHARE of it.
501 type boxAlloc struct {
502 perAccount map[string]float64
503 idleUSD, totalUSD float64
504 }
505 allocByBox := make(map[string]boxAlloc, len(sweep.PerBox))
506 totalAllocated := map[string]float64{}
507 for box, load := range sweep.PerBox {
508 perAcct, idle, total := economics.AllocateBox(sc.RateCard, sc.Allocation, load)
509 allocByBox[box] = boxAlloc{perAccount: perAcct, idleUSD: idle, totalUSD: total}
510 for acct, usd := range perAcct {
511 totalAllocated[acct] += usd
512 }
513 }
514
515 meters := make(map[string]map[string]float64, len(universe))
516 accountRows := make([]economics.AccountEconomics, 0, len(universe))
517 for acct := range universe {
518 mm := sweep.PerAccount[acct]
519 if mm == nil {
520 mm = map[string]float64{}
521 }
522 meters[acct] = mm
523 row := economics.PriceAccount(sc, acct, mm, totalAllocated[acct])
524 // Tier-aware revenue (Task 5): PriceAccount stays tier-unaware by
525 // design (see its own doc comment) — a pool/try/"" account's
526 // revenue is pooled, not individually priced, so this view
527 // overrides the priced row for those tiers AFTER PriceAccount runs.
528 // Its cost stands untouched. FreeTierPos is overridden too, not
529 // left at whatever PriceAccount computed: a fleet-only pool account
530 // can carry real (fleet) revenue under PriceAccount's tier-unaware
531 // maths, so naively zeroing revenue and recomputing "inside" off
532 // that zero would misreport it as sitting inside the free tier
533 // rather than pooled — the carried ruling this override exists to
534 // satisfy.
535 if isPoolTier(tierOf(tiers, acct)) {
536 row.RevenueUSD = 0
537 row.MarginFrac = 0
538 row.FreeTierPos = "pooled"
539 }
540 accountRows = append(accountRows, row)
541 }
542 // CostUSD desc, Account asc on ties — a stable tie-break so the
543 // zero-cost tail (every account with no usage this period, all tied at
544 // 0) doesn't visibly reshuffle on every 60s background sweep.
545 sort.Slice(accountRows, func(i, j int) bool {
546 if accountRows[i].CostUSD != accountRows[j].CostUSD {
547 return accountRows[i].CostUSD > accountRows[j].CostUSD
548 }
549 return accountRows[i].Account < accountRows[j].Account
550 })
551
552 // PoolLedger (Task 5): PayingRevenueUSD sums the revenue of every
553 // account priced normally (hosted/fleet/dedicated — i.e. everything
554 // NOT pool-tier, since accountRows already carries the RevenueUSD=0
555 // override above for pool/try/"" accounts, summing them contributes
556 // nothing extra either way); DrawUSD sums the COST of every pool-tier
557 // account, which is real spend the pool must cover since those
558 // accounts carry no individual revenue of their own. TopupUSD is
559 // accrued day-by-day over [from, to] — see poolTopupAccrual — NOT the
560 // single PeriodDays/DaysInMonth fraction the brief states literally:
561 // that fraction is dishonest on period=last, whose PeriodDays counts
562 // LAST month's days while DaysInMonth (EconomicsView.DaysInMonth's own
563 // doc comment) is pinned to THIS month's — a Jan-from-Feb "last" view
564 // would credit 31/28 of a month's top-up, over 100%. Headroom is
565 // Pool-Draw and MAY be negative — rendered honestly, never clamped
566 // (brief).
567 var payingRevenue, poolDraw float64
568 for _, row := range accountRows {
569 if isPoolTier(tierOf(tiers, row.Account)) {
570 poolDraw += row.CostUSD
571 } else {
572 payingRevenue += row.RevenueUSD
573 }
574 }
575 // poolTopupUnitAccrual: see economicsPayload.PoolTopupUnitAccrual's doc
576 // comment on the field this feeds — topupUSD fixed at 1 turns
577 // poolTopupAccrual into the dimensionless per-day sum the JS twin scales
578 // by the live topup knob.
579 poolTopupUnitAccrual := poolTopupAccrual(from, to, 1)
580 poolUSD := sc.Pool.RevenueFrac*payingRevenue + sc.Pool.TopupUSD*poolTopupUnitAccrual
581 pool := PoolLedger{
582 PoolUSD: poolUSD,
583 DrawUSD: poolDraw,
584 HeadroomUSD: poolUSD - poolDraw,
585 PayingRevenueUSD: payingRevenue,
586 }
587
588 // Revenue attribution to boxes: for each account, boxShare =
589 // allocated[box][acct] / totalAllocated[acct] (0 when the total is 0),
590 // box.RevenueUSD += acct.RevenueUSD * boxShare (brief step 3 formula).
591 boxRows := make([]BoxEconomics, 0, len(sweep.PerBox))
592 for box, load := range sweep.PerBox {
593 alloc := allocByBox[box]
594 var revenue float64
595 for _, ae := range accountRows {
596 total := totalAllocated[ae.Account]
597 if total == 0 {
598 continue
599 }
600 share := alloc.perAccount[ae.Account] / total
601 revenue += ae.RevenueUSD * share
602 }
603 util := 0.0
604 if alloc.totalUSD > 0 {
605 util = 1 - alloc.idleUSD/alloc.totalUSD
606 }
607 boxRows = append(boxRows, BoxEconomics{
608 Box: box,
609 Hours: load.Hours,
610 CostUSD: alloc.totalUSD,
611 IdleUSD: alloc.idleUSD,
612 RevenueUSD: revenue,
613 Utilization: util,
614 EgressMetered: sweep.EgressMetered[box], // absent boxes default false — see usageSweep.EgressMetered
615 })
616 }
617 sort.Slice(boxRows, func(i, j int) bool { return boxRows[i].Box < boxRows[j].Box })
618
619 bills, err := s.loadRecentBills(sweepCtx, now)
620 if err != nil {
621 return nil, fmt.Errorf("console: load economics: %w", err)
622 }
623
624 payload := economicsPayload{
625 Scenario: sc,
626 Meters: meters,
627 BoxLoads: sweep.PerBox,
628 BoxSpecs: sc.RateCard.Boxes,
629 Bills: bills,
630 CoveredDays: sweep.CoveredDays,
631 PeriodDays: sweep.PeriodDays,
632 DaysInMonth: daysInMonth,
633 Tiers: tiers,
634 Names: names,
635 PoolTopupUnitAccrual: poolTopupUnitAccrual,
636 }
637 raw, err := json.Marshal(payload)
638 if err != nil {
639 return nil, fmt.Errorf("console: load economics: marshal payload: %w", err)
640 }
641
642 return &EconomicsView{
643 GeneratedAt: now,
644 Period: period,
645 Scenario: sc,
646 ScenarioWarning: scenarioWarning,
647 Warning: warning,
648 Scenarios: scenarioNames,
649 Accounts: accountRows,
650 Meters: meters,
651 Boxes: boxRows,
652 Bills: bills,
653 CoveredDays: sweep.CoveredDays,
654 PeriodDays: sweep.PeriodDays,
655 DaysInMonth: daysInMonth,
656 DataJSON: template.JS(raw),
657 Tiers: tiers,
658 Names: names,
659 Pool: pool,
660 }, nil
661}
662
663// tierOf returns acct's fleet-pool tier from tiers — "" (pool semantics,
664// same as the explicit "pool" tier — see isPoolTier) both when acct is
665// simply absent from tiers (usage with no matching console account row,
666// e.g. a fleet-only account) and when it's present with the explicit ""
667// tier (a live account never yet placed). A plain map index already
668// returns the zero value for a missing key, so this exists purely to give
669// that lookup one named call site, per the brief.
670func tierOf(tiers map[string]string, acct string) string {
671 return tiers[acct]
672}
673
674// isPoolTier reports whether tier draws from the shared fleet-pool rather
675// than being individually priced — the "", "try" and "pool" tiers (Task 4's
676// validAccountTiers minus the three that price normally: hosted, fleet,
677// dedicated).
678func isPoolTier(tier string) bool {
679 switch tier {
680 case "", "try", "pool":
681 return true
682 default:
683 return false
684 }
685}
686
687// poolTopupAccrual accrues Scenario.Pool.TopupUSD — a flat MONTHLY figure —
688// day-by-day over [from, to] (both truncated to UTC midnight, inclusive):
689// precedent foldStorageAndCensus's per-day denominator (economicspass.go),
690// applied here because the naive single-fraction reading of the brief
691// (TopupUSD * PeriodDays/DaysInMonth, using ONE daysInMonth for the whole
692// period) is dishonest whenever the period isn't wholly inside the CURRENT
693// calendar month: period=last's PeriodDays counts last month's days while
694// buildEconomicsView's daysInMonth is pinned to THIS month's (see
695// EconomicsView.DaysInMonth's doc comment) — a Jan-from-Feb "last" view
696// would credit 31/28 of a month's top-up, over 100%. Each day here instead
697// contributes topupUSD/daysInMonth(that day's OWN calendar month), so:
698// - "mtd" (wholly inside the current month) sums to exactly the old
699// PeriodDays/DaysInMonth fraction — unchanged behavior.
700// - "last" (the WHOLE previous calendar month) sums to exactly topupUSD —
701// one full month's top-up, never more or less.
702// - "7d" straddling a month boundary charges each day at its own month's
703// rate rather than borrowing one denominator for days in a different
704// month.
705func poolTopupAccrual(from, to time.Time, topupUSD float64) float64 {
706 fromDay := from.UTC().Truncate(24 * time.Hour)
707 toDay := to.UTC().Truncate(24 * time.Hour)
708 var total float64
709 for d := fromDay; !d.After(toDay); d = d.AddDate(0, 0, 1) {
710 y, mo, _ := d.Date()
711 dim := time.Date(y, mo+1, 0, 0, 0, 0, 0, time.UTC).Day()
712 total += topupUSD / float64(dim)
713 }
714 return total
715}
716
717// loadRoutesForEconomics resolves the routes sweepUsage needs to attribute
718// sampled units to accounts, DEGRADING rather than failing when the
719// registry is missing or unreadable — the opposite of openPassRegistry's
720// refusal, and deliberately so: that refusal protects a WRITE pass from
721// reading registry.Open's freshly-created empty sqlite as "zero routes" and
722// writing a permanent, uncorrectable all-zero day. This is a READ path —
723// there is nothing to protect by refusing. A nil routes list simply means
724// unitOwner falls every sampled unit through to release.OpsAccount
725// (indistinguishable from platform overhead, same as any other unmatched
726// unit), and the dashboard still renders with a visible warning rather than
727// a blank 500 — precedent: commit 0958fa3, instances health degrading on a
728// console with no registry rather than failing the page.
729func (s *Server) loadRoutesForEconomics(ctx context.Context) (routes []registry.Route, warning string) {
730 reg, err := s.openPassRegistry()
731 if err != nil {
732 return nil, fmt.Sprintf("no registry available (%v); usage cannot be attributed to accounts by route this sweep", err)
733 }
734 routes, err = reg.List()
735 if err != nil {
736 return nil, fmt.Sprintf("registry: %v; usage cannot be attributed to accounts by route this sweep", err)
737 }
738 return routes, ""
739}
740
741// loadActiveScenario resolves economics.ActiveKey -> named scenario ->
742// Validate, per the spec's fallback rule: economics/active.json simply not
743// existing yet is a FRESH DEPLOYMENT, not a fault — DefaultScenario with no
744// warning. Every OTHER failure past that point (active.json unreadable or
745// corrupt, the scenario it names missing, corrupt, or failing Validate) IS a
746// fault worth a visible warning, but still degrades to DefaultScenario
747// rather than failing the whole view — the dashboard must still render.
748func (s *Server) loadActiveScenario(ctx context.Context) (sc economics.Scenario, warning string) {
749 raw, _, err := s.cfg.Releases.Get(ctx, economics.ActiveKey)
750 if err != nil {
751 if errors.Is(err, os.ErrNotExist) {
752 return economics.DefaultScenario(), ""
753 }
754 return economics.DefaultScenario(), fmt.Sprintf("read %s: %v; using the default scenario", economics.ActiveKey, err)
755 }
756 var active economics.Active
757 if jerr := json.Unmarshal(raw, &active); jerr != nil {
758 return economics.DefaultScenario(), fmt.Sprintf("%s is corrupt: %v; using the default scenario", economics.ActiveKey, jerr)
759 }
760
761 scRaw, _, err := s.cfg.Releases.Get(ctx, economics.ScenarioKey(active.Name))
762 if err != nil {
763 return economics.DefaultScenario(), fmt.Sprintf("active scenario %q: %v; using the default scenario", active.Name, err)
764 }
765 var scenario economics.Scenario
766 if jerr := json.Unmarshal(scRaw, &scenario); jerr != nil {
767 return economics.DefaultScenario(), fmt.Sprintf("active scenario %q is corrupt: %v; using the default scenario", active.Name, jerr)
768 }
769 if verr := scenario.Validate(); verr != nil {
770 return economics.DefaultScenario(), fmt.Sprintf("active scenario %q is invalid: %v; using the default scenario", active.Name, verr)
771 }
772 return scenario, ""
773}
774
775// listScenarioNames lists economics/scenarios/*.json and strips both the
776// prefix and the extension — the flat set of names a scenario picker (Task
777// 13) offers, alongside "default" (DefaultScenario is never itself stored).
778func (s *Server) listScenarioNames(ctx context.Context) ([]string, error) {
779 keys, err := s.cfg.Releases.List(ctx, economics.ScenariosPrefix)
780 if err != nil {
781 return nil, fmt.Errorf("list %s: %w", economics.ScenariosPrefix, err)
782 }
783 names := make([]string, 0, len(keys))
784 for _, key := range keys {
785 rest := strings.TrimPrefix(key, economics.ScenariosPrefix)
786 name := strings.TrimSuffix(rest, ".json")
787 if name == "" || name == rest || strings.Contains(name, "/") {
788 continue // defensive: skip anything not a flat "<name>.json" scenario file
789 }
790 names = append(names, name)
791 }
792 sort.Strings(names)
793 return names, nil
794}
795
796// loadRecentBills reads economics.BillKey for now's month and the one
797// before it — regardless of the requested period, per the brief — and
798// simply omits a month with no bill object rather than erroring: a missing
799// bill is a normal, ongoing state (this month's hasn't been pulled yet),
800// not a fault.
801func (s *Server) loadRecentBills(ctx context.Context, now time.Time) ([]economics.Bill, error) {
802 firstOfThisMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
803 firstOfLastMonth := firstOfThisMonth.AddDate(0, -1, 0)
804 months := []string{usage.MonthOf(now), usage.MonthOf(firstOfLastMonth)}
805
806 var bills []economics.Bill
807 for _, month := range months {
808 raw, _, err := s.cfg.Releases.Get(ctx, economics.BillKey(month))
809 if err != nil {
810 if errors.Is(err, os.ErrNotExist) {
811 continue
812 }
813 return nil, fmt.Errorf("bill %s: %w", month, err)
814 }
815 var b economics.Bill
816 if jerr := json.Unmarshal(raw, &b); jerr != nil {
817 log.Printf("console: load economics: parse %s: %v", economics.BillKey(month), jerr)
818 continue
819 }
820 bills = append(bills, b)
821 }
822 return bills, nil
823}
824