| 1 | {{/* /fleet/economics/accounts (Task 14): every account, one row each, |
| 2 | searchable and cursor-paginated (econlistquery.go: econAccountQuery/ |
| 3 | pageEconAccounts — instanceQuery/pageInstanceRows's shape, applied to |
| 4 | economics.AccountEconomics instead of an InstanceRow). Columns are |
| 5 | honest about what the census meters actually hold: app_months and |
| 6 | instance_hours are month-fraction and hour-sum accumulators (see |
| 7 | internal/usage.Meter*), never a literal "how many apps/instances right |
| 8 | now" count, so the headers below say "app-months"/"instance-hours" |
| 9 | rather than the design brief's shorthand "apps"/"instances" — the |
| 10 | dashboard-truth rule (CLAUDE.md) that a total must never claim a |
| 11 | precision the underlying meter doesn't have. |
| 12 | |
| 13 | Recompute: this page shares Task 13's knob drawer (define |
| 14 | "econ_knob_drawer", economics.html) and its pure maths functions |
| 15 | (define "econ_maths_js", same file) rather than re-deriving either — |
| 16 | see economics.html's header comment for why splicing econ_maths_js |
| 17 | into THIS page's own <script> IIFE keeps one scope. This page's own |
| 18 | recompute() (below) is deliberately smaller than economics.html's: no |
| 19 | tiles, no box table, no model-vs-bill, no pool-tile update (that lives |
| 20 | only on /fleet/economics) — it only re-prices the account rows |
| 21 | currently in the DOM (this page's one table, one page of up to |
| 22 | 50 accounts), via the same allocateBox/priceAccount over the FULL |
| 23 | payload.boxLoads (a row's allocated cost depends on every box it |
| 24 | touched, not just the rows on screen), plus the SAME tier-aware |
| 25 | view-layer override economics.html's recompute() applies (Task 5/ |
| 26 | Task 6): payload.tiers is read, and a pool/try/""-tier row's revenue, |
| 27 | margin and free-tier chip are overridden to pooled AFTER priceAccount |
| 28 | runs, exactly matching buildEconomicsView's server-side override — a |
| 29 | knob tweak must never flip a pooled row back to priced. The meter |
| 30 | columns (CPU-h, GB-h, egress, storage) are raw readings, not |
| 31 | knob-derived, so they never change under recompute; neither does the |
| 32 | Tier column (Task 6) — an account's fleet-pool tier is an operator |
| 33 | assignment via the per-row form below, not something a scenario knob |
| 34 | can move. */}} |
| 35 | {{define "economics_accounts"}}{{template "top" .}} |
| 36 | <h1>Fleet economics: accounts</h1> |
| 37 | {{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| 38 | {{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| 39 | {{if .Economics.Warning}}<p class="banner error">{{icon "triangle-alert"}} {{.Economics.Warning}}</p>{{end}} |
| 40 | {{if .Economics.ScenarioWarning}}<p class="banner error">{{icon "triangle-alert"}} {{.Economics.ScenarioWarning}}</p>{{end}} |
| 41 | |
| 42 | <p class="small">Generated {{fmtTime .Economics.GeneratedAt}} · scenario <strong>{{.Economics.Scenario.Name}}</strong> ({{.Economics.Scenario.Mode}}) · {{.Economics.CoveredDays}}/{{.Economics.PeriodDays}} days metered</p> |
| 43 | |
| 44 | {{/* Period nav: switching period keeps the operator's search/filter |
| 45 | (Q/Pos) — econPeriodURL — but drops any cursor, since a cursor resumes |
| 46 | an account-name position computed against ONE period's row order and |
| 47 | means nothing against another period's. */}} |
| 48 | <nav class="small" aria-label="Period"> |
| 49 | Period: |
| 50 | <a href="{{econPeriodURL .EconQuery "mtd"}}"{{if eq .Economics.Period "mtd"}} aria-current="page"{{end}}>Month to date</a> · |
| 51 | <a href="{{econPeriodURL .EconQuery "last"}}"{{if eq .Economics.Period "last"}} aria-current="page"{{end}}>Last month</a> · |
| 52 | <a href="{{econPeriodURL .EconQuery "7d"}}"{{if eq .Economics.Period "7d"}} aria-current="page"{{end}}>Last 7 days</a> |
| 53 | </nav> |
| 54 | <p class="small"><a href="/fleet/economics">← Back to the dashboard</a></p> |
| 55 | |
| 56 | {{if or .EconAccounts.Rows .EconQuery.Q .EconQuery.Pos}} |
| 57 | {{/* The hidden period field only needs to exist for a non-default period: |
| 58 | "mtd" is what an absent "period" param already means, matching |
| 59 | econLinkURL/econPeriodURL's own "omit when mtd" rule — so a search |
| 60 | submitted on the default period doesn't grow a redundant ?period=mtd |
| 61 | into every subsequent link. */}} |
| 62 | <form method="get" class="listctl"> |
| 63 | <input type="search" name="q" value="{{.EconQuery.Q}}" placeholder="Filter accounts" aria-label="Filter accounts"> |
| 64 | {{if .EconQuery.Pos}}<input type="hidden" name="pos" value="{{.EconQuery.Pos}}">{{end}} |
| 65 | {{if ne .EconQuery.Period "mtd"}}<input type="hidden" name="period" value="{{.EconQuery.Period}}">{{end}} |
| 66 | <button>Filter</button> |
| 67 | </form> |
| 68 | <p class="small"> |
| 69 | <a href="{{.EconAccounts.AllURL}}"{{if not .EconQuery.Pos}} aria-current="true"{{end}}>All</a> |
| 70 | {{range .EconAccounts.Links}} |
| 71 | · <a href="{{.Href}}"{{if .Active}} aria-current="true"{{end}}>{{.Word}} {{.Count}}</a> |
| 72 | {{end}} |
| 73 | </p> |
| 74 | {{end}} |
| 75 | |
| 76 | {{if .EconAccounts.Rows}} |
| 77 | <div class="tablewrap"> |
| 78 | <table> |
| 79 | <tr> |
| 80 | <th scope="col">Account</th> |
| 81 | <th scope="col">App-months</th> |
| 82 | <th scope="col">Instance-hours</th> |
| 83 | <th scope="col">CPU-h</th> |
| 84 | <th scope="col">GB-h</th> |
| 85 | <th scope="col">Egress GB</th> |
| 86 | <th scope="col" title="the part of Egress GB that left its home box for a region edge first — the inter-region leg is a platform cost, absorbed, in no account's bill">Backhaul GB</th> |
| 87 | <th scope="col">Storage GB-month</th> |
| 88 | <th scope="col">Cost</th> |
| 89 | <th scope="col">Would-be revenue</th> |
| 90 | <th scope="col" title="Pooled rows (free-tier position "pooled") 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> |
| 91 | <th scope="col">Free tier</th> |
| 92 | {{/* Tier (Task 6): the operator's fleet-pool ASSIGNMENT for this |
| 93 | account (store.go's validAccountTiers), distinct from the Free |
| 94 | tier column's PRICED POSITION — "pooled" over there can come from |
| 95 | any of try/pool/"" here. */}} |
| 96 | <th scope="col">Tier</th> |
| 97 | </tr> |
| 98 | {{range .EconAccounts.Rows}} |
| 99 | {{$tier := index $.Economics.Tiers .Account}} |
| 100 | {{/* tierKnown (fix round, Task 6 review IMPORTANT 2): "index" above |
| 101 | can't distinguish "absent from the map entirely" (a usage-only |
| 102 | account with NO console/accounts-table row — e.g. a fleet-only |
| 103 | account whose owner never signed up) from "present with the |
| 104 | explicit "" tier" (a real console account nobody has tiered yet) — |
| 105 | both read $tier=="". Only the second case gets the tier-set form: |
| 106 | POSTing the first's sqid to /fleet/economics/tier can only ever |
| 107 | fail with "No such account." (handleEconomicsTierSave's |
| 108 | AccountBySqid lookup). */}} |
| 109 | {{$known := tierKnown $.Economics.Tiers .Account}} |
| 110 | <tr data-account="{{.Account}}"> |
| 111 | {{/* Account-names batch: a known console name renders as the primary |
| 112 | text with the sqid muted+small beside it — the ".small" class |
| 113 | already IS the muted treatment (layout.html: font-size 0.9rem, |
| 114 | color var(--muted); there is no separate ".muted" class anywhere in |
| 115 | these templates, so this reuses ".small" rather than inventing |
| 116 | one). A usage-only ghost row (no console account row at all, so |
| 117 | .Name is "") or an explicitly empty name renders the bare sqid |
| 118 | alone, exactly as before this batch. The row's data-account |
| 119 | attribute above and every other field on this row (the tier form |
| 120 | below) still key off .Account, the sqid — display only. */}} |
| 121 | <td>{{if .Name}}{{.Name}} <span class="small">{{.Account}}</span>{{else}}{{.Account}}{{end}}</td> |
| 122 | <td>{{printf "%.2f" (index .Meters "app_months")}}</td> |
| 123 | <td>{{printf "%.2f" (index .Meters "instance_hours")}}</td> |
| 124 | <td>{{printf "%.2f" (index .Meters "cpu_core_hours")}}</td> |
| 125 | <td>{{printf "%.2f" (index .Meters "mem_gb_hours")}}</td> |
| 126 | <td>{{printf "%.2f" (index .Meters "egress_gb")}}</td> |
| 127 | <td>{{printf "%.2f" (index .Meters "backhaul_gb")}}</td> |
| 128 | <td>{{printf "%.2f" (index .Meters "storage_gb_month")}}</td> |
| 129 | <td class="js-acct-cost">{{usd .Econ.CostUSD}}</td> |
| 130 | <td class="js-acct-revenue">{{usd .Econ.RevenueUSD}}</td> |
| 131 | <td class="js-acct-margin">{{marginCell .Econ.MarginFrac .Econ.FreeTierPos}}</td> |
| 132 | <td class="js-acct-freetier"><span class="chip chip-freetier-{{.Econ.FreeTierPos}}">{{.Econ.FreeTierPos}}</span></td> |
| 133 | <td> |
| 134 | <span class="chip chip-tier-{{tierWord $tier}}">{{tierWord $tier}}</span> |
| 135 | {{if $known}} |
| 136 | {{/* Progressive: a plain <select> + submit button, no JS required — |
| 137 | the door (POST /fleet/economics/tier, handleEconomicsTierSave) |
| 138 | already exists (Task 4/5). Only rendered for a REAL console |
| 139 | account (see $known's doc comment above). */}} |
| 140 | <form method="post" action="/fleet/economics/tier"> |
| 141 | <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| 142 | <input type="hidden" name="account" value="{{.Account}}"> |
| 143 | <select name="tier"> |
| 144 | <option value=""{{if eq $tier ""}} selected{{end}}>unassigned</option> |
| 145 | <option value="try"{{if eq $tier "try"}} selected{{end}}>try</option> |
| 146 | <option value="pool"{{if eq $tier "pool"}} selected{{end}}>pool</option> |
| 147 | <option value="hosted"{{if eq $tier "hosted"}} selected{{end}}>hosted</option> |
| 148 | <option value="fleet"{{if eq $tier "fleet"}} selected{{end}}>fleet</option> |
| 149 | <option value="dedicated"{{if eq $tier "dedicated"}} selected{{end}}>dedicated</option> |
| 150 | </select> |
| 151 | <button type="submit">Set</button> |
| 152 | </form> |
| 153 | {{end}} |
| 154 | </td> |
| 155 | </tr> |
| 156 | {{end}} |
| 157 | </table> |
| 158 | </div> |
| 159 | <p class="small">App-months and instance-hours are accumulators, not live counts — |
| 160 | a fraction of a calendar month and a sum of hours run, the meters this platform |
| 161 | actually keeps. CPU-h/GB-h are box-side compute meters; egress and storage are |
| 162 | edge/S3 meters. Cost and would-be revenue are this account's own share plus its |
| 163 | allocated box cost, under the active scenario.</p> |
| 164 | {{/* MINOR 3 (fix round, Task 6 review): the Margin column's title= |
| 165 | tooltip is invisible on touch/keyboard — this legend carries the |
| 166 | same two notes visibly, for everyone. */}} |
| 167 | <p class="small">Margin: pooled rows show an em-dash — their revenue is shared through the fleet pool, not individually priced. Fleet-tier rows can legitimately show 100% margin — fleet boxes are customer-owned hardware, zero cost to the platform, by design.</p> |
| 168 | {{if .EconAccounts.NextURL}}<p class="small"><a href="{{.EconAccounts.NextURL}}">Next {{len .EconAccounts.Rows}} shown of {{.EconAccounts.Total}} →</a></p>{{end}} |
| 169 | {{else if or .EconQuery.Q .EconQuery.Pos}} |
| 170 | <p>No accounts match.</p> |
| 171 | {{else}} |
| 172 | <p class="small">No accounts to price this period.</p> |
| 173 | {{end}} |
| 174 | |
| 175 | <div id="econ-live"> |
| 176 | {{template "econ_knob_drawer" .}} |
| 177 | </div> |
| 178 | |
| 179 | <script type="application/json" id="econ-data">{{.Economics.DataJSON}}</script> |
| 180 | <script> |
| 181 | (function () { |
| 182 | "use strict"; |
| 183 | // Same source-order contract as economics.html's script: the payload |
| 184 | // <script> tag above is parsed and in the DOM before this one runs. |
| 185 | var dataEl = document.getElementById("econ-data"); |
| 186 | if (!dataEl) { return; } |
| 187 | var payload; |
| 188 | try { payload = JSON.parse(dataEl.textContent); } catch (e) { return; } |
| 189 | |
| 190 | {{template "econ_maths_js" .}} |
| 191 | |
| 192 | // recompute re-prices only the account rows THIS page rendered (its one |
| 193 | // page of up to econAccountPageSize accounts) — allocateBox still runs |
| 194 | // over the FULL payload.boxLoads/totalAllocated (a row's allocated cost |
| 195 | // depends on every box it touched, not just the rows on screen), but the |
| 196 | // DOM write-back is scoped to "[data-account]" rows actually present, |
| 197 | // exactly like economics.html's account-row loop, minus the tiles/box |
| 198 | // table/model-vs-bill sections this page doesn't render. |
| 199 | function recompute() { |
| 200 | var sc = readScenarioFromDOM(payload.scenario); |
| 201 | setText("margin-multiple", marginMultiple(sc.margin)); |
| 202 | |
| 203 | var totalAllocated = {}; |
| 204 | Object.keys(payload.boxLoads).forEach(function (box) { |
| 205 | var alloc = allocateBox(sc.rate_card, sc.allocation, payload.boxLoads[box]); |
| 206 | Object.keys(alloc.perAccount).forEach(function (acct) { |
| 207 | totalAllocated[acct] = (totalAllocated[acct] || 0) + alloc.perAccount[acct]; |
| 208 | }); |
| 209 | }); |
| 210 | |
| 211 | var tiers = payload.tiers || {}; |
| 212 | document.querySelectorAll("[data-account]").forEach(function (row) { |
| 213 | var acct = row.getAttribute("data-account"); |
| 214 | var a = priceAccount(sc, acct, payload.meters[acct] || {}, totalAllocated[acct] || 0); |
| 215 | // Tier-aware view-layer override (economics-v2 Task 5/Task 6), the |
| 216 | // SAME override economics.html's recompute() applies — see this |
| 217 | // page's header comment. |
| 218 | if (isPoolTier(tierOf(tiers, acct))) { |
| 219 | a.revenueUSD = 0; |
| 220 | a.marginFrac = 0; |
| 221 | a.freeTierPos = "pooled"; |
| 222 | } |
| 223 | setCellText(row, ".js-acct-cost", usd(a.costUSD)); |
| 224 | setCellText(row, ".js-acct-revenue", usd(a.revenueUSD)); |
| 225 | setCellText(row, ".js-acct-margin", marginCell(a.marginFrac, a.freeTierPos)); |
| 226 | var chip = row.querySelector(".js-acct-freetier .chip"); |
| 227 | if (chip) { |
| 228 | chip.textContent = a.freeTierPos; |
| 229 | chip.className = "chip chip-freetier-" + a.freeTierPos; |
| 230 | } |
| 231 | }); |
| 232 | |
| 233 | var field = document.getElementById("scenario-json-field"); |
| 234 | if (field) { field.value = JSON.stringify(sc); } |
| 235 | } |
| 236 | |
| 237 | var live = document.getElementById("econ-live"); |
| 238 | if (live) { |
| 239 | live.addEventListener("input", recompute); |
| 240 | live.addEventListener("change", recompute); |
| 241 | } |
| 242 | })(); |
| 243 | </script> |
| 244 | {{template "bottom" .}}{{end}} |
| 245 | |