| 1 | {{/* /fleet/economics (Task 13): the operator unit-economics dashboard. |
| 2 | Everything on this page renders server-side from EconomicsView, which |
| 3 | is already priced under the active scenario (economicsview.go) — the |
| 4 | ONE inline <script> at the foot only RE-computes in place when an |
| 5 | operator tweaks a knob, transliterating |
| 6 | internal/economics/compute.go's AllocateBox/PriceAccount/Project |
| 7 | function-for-function (same names, JS-cased) so drift between the two |
| 8 | is reviewable at a glance. With JS off, every number above stays |
| 9 | exactly what the server rendered, and the knob drawer's only working |
| 10 | control is the save-scenario form at its foot: it round-trips the |
| 11 | CURRENTLY active scenario's JSON unchanged (Task 12's |
| 12 | /fleet/economics/scenarios door), just under a new name and/or |
| 13 | activated — the knobs above it don't feed the save without JS, |
| 14 | because there is no field-by-field parsing on the server side for |
| 15 | them, only whole-JSON parsing (server.go's handleEconomicsScenarioSave |
| 16 | comment). |
| 17 | |
| 18 | The knob drawer markup (define "econ_knob_drawer") and the pure |
| 19 | calculation functions the inline script needs (define "econ_maths_js") |
| 20 | both live at the bottom of this file and are INCLUDED here rather than |
| 21 | only defined here — Task 14's /fleet/economics/accounts page templates |
| 22 | the same two blocks (economics_accounts.html) so its own knob drawer |
| 23 | and its own recompute() share one source for both the editable knobs |
| 24 | and the maths, instead of a second ~300-line copy. See |
| 25 | economics_accounts.html's header comment for that page's (smaller) |
| 26 | recompute(). |
| 27 | |
| 28 | DataJSON payload keys (economicsPayload, economicsview.go) this page's |
| 29 | JS reads — every key here must match that struct's json tag exactly, |
| 30 | the same rule the struct's own doc comment states from the Go side: |
| 31 | scenario, meters, boxLoads, boxSpecs, bills, coveredDays, periodDays, |
| 32 | daysInMonth, tiers, poolTopupUnitAccrual (Task 6 — the dimensionless |
| 33 | Σ 1/daysInMonth(d) over the period; econ_maths_js's poolLedger scales |
| 34 | it by the live pool.topup_usd knob rather than re-deriving the per-day |
| 35 | sum client-side — see that field's own doc comment for why). */}} |
| 36 | {{define "economics"}}{{template "top" .}} |
| 37 | <h1>Fleet economics</h1> |
| 38 | {{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| 39 | {{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| 40 | {{if .Economics.Warning}}<p class="banner error">{{icon "triangle-alert"}} {{.Economics.Warning}}</p>{{end}} |
| 41 | {{if .Economics.ScenarioWarning}}<p class="banner error">{{icon "triangle-alert"}} {{.Economics.ScenarioWarning}}</p>{{end}} |
| 42 | |
| 43 | <p class="small">Generated {{fmtTime .Economics.GeneratedAt}} · scenario <strong>{{.Economics.Scenario.Name}}</strong> ({{.Economics.Scenario.Mode}}) · {{.Economics.CoveredDays}}/{{.Economics.PeriodDays}} days metered</p> |
| 44 | |
| 45 | <nav class="small" aria-label="Period"> |
| 46 | Period: |
| 47 | <a href="?period=mtd"{{if eq .Economics.Period "mtd"}} aria-current="page"{{end}}>Month to date</a> · |
| 48 | <a href="?period=last"{{if eq .Economics.Period "last"}} aria-current="page"{{end}}>Last month</a> · |
| 49 | <a href="?period=7d"{{if eq .Economics.Period "7d"}} aria-current="page"{{end}}>Last 7 days</a> |
| 50 | </nav> |
| 51 | <p class="small"><a href="/fleet/economics/accounts">Every account, searchable →</a></p> |
| 52 | |
| 53 | {{/* Headline tiles: EconomicsView.Accounts (allocated+metered cost and |
| 54 | revenue per tenant) plus every box's unallocated idle headroom plus |
| 55 | fixed overheads (brief: "period totals from EconomicsView.Accounts + |
| 56 | idle + overheads") — see sumAcctCost/sumBoxIdle/overheadUSD (server.go) |
| 57 | for the exact formula, mirrored by the inline script's recompute(). */}} |
| 58 | {{$revenue := sumAcctRevenue .Economics.Accounts}} |
| 59 | {{$cost := add (sumAcctCost .Economics.Accounts) (sumBoxIdle .Economics.Boxes) (overheadUSD .Economics.Scenario .Economics.PeriodDays)}} |
| 60 | {{$profit := sub $revenue $cost}} |
| 61 | {{$margin := marginOf $revenue $cost}} |
| 62 | {{/* $showProjection: the spec's "month-to-date with projection" promise — |
| 63 | only meaningful on "mtd" (the other periods are already whole, |
| 64 | closed ranges with nothing left to project), and only once at least |
| 65 | one day of this month has actually been swept (economics.Project |
| 66 | itself returns 0 for CoveredDays<=0, which would render a false, |
| 67 | confident-looking "$0.00 for the month" rather than simply omitting |
| 68 | the line). The margin tile has no projected line — a ratio doesn't |
| 69 | project the way a dollar total does. */}} |
| 70 | {{$showProjection := and (eq .Economics.Period "mtd") (gt .Economics.CoveredDays 0)}} |
| 71 | <section class="tiles" aria-label="Headline economics"> |
| 72 | <div class="tile"> |
| 73 | <h2>Would-be revenue</h2> |
| 74 | <p class="tile-value" id="tile-revenue">{{usd $revenue}}</p> |
| 75 | <p class="tile-detail">{{.Economics.CoveredDays}}/{{.Economics.PeriodDays}} days metered</p> |
| 76 | {{if $showProjection}}<p class="tile-detail" id="tile-revenue-projected">projected {{usd (project $revenue .Economics.CoveredDays .Economics.DaysInMonth)}} for the month</p>{{end}} |
| 77 | </div> |
| 78 | <div class="tile"> |
| 79 | <h2>Cost</h2> |
| 80 | <p class="tile-value" id="tile-cost">{{usd $cost}}</p> |
| 81 | <p class="tile-detail">{{.Economics.CoveredDays}}/{{.Economics.PeriodDays}} days metered</p> |
| 82 | {{if $showProjection}}<p class="tile-detail" id="tile-cost-projected">projected {{usd (project $cost .Economics.CoveredDays .Economics.DaysInMonth)}} for the month</p>{{end}} |
| 83 | </div> |
| 84 | <div class="tile"> |
| 85 | <h2>Profit</h2> |
| 86 | <p class="tile-value" id="tile-profit">{{usd $profit}}</p> |
| 87 | <p class="tile-detail">{{.Economics.CoveredDays}}/{{.Economics.PeriodDays}} days metered</p> |
| 88 | {{if $showProjection}}<p class="tile-detail" id="tile-profit-projected">projected {{usd (project $profit .Economics.CoveredDays .Economics.DaysInMonth)}} for the month</p>{{end}} |
| 89 | </div> |
| 90 | <div class="tile"> |
| 91 | <h2>Achieved margin</h2> |
| 92 | <p class="tile-value" id="tile-margin">{{pctf $margin}}</p> |
| 93 | <p class="tile-detail">{{.Economics.CoveredDays}}/{{.Economics.PeriodDays}} days metered</p> |
| 94 | </div> |
| 95 | {{/* Fleet pool tile (Task 6): PoolLedger's own doc comment (economicsview.go) |
| 96 | for the exact formula — a share of paying revenue plus a day-accrued |
| 97 | monthly top-up, against the pool-tier accounts' own real spend. Headroom |
| 98 | MAY be negative (the pool is overdrawn this period) and renders honestly, |
| 99 | never clamped — the negative case carries the page's existing warning |
| 100 | styling (.error, same class every other on-page warning uses) on the |
| 101 | headroom figure alone, not the whole tile. */}} |
| 102 | <div class="tile" id="tile-pool"> |
| 103 | <h2>Fleet pool</h2> |
| 104 | <p class="tile-value">POOL <span id="tile-pool-usd">{{usd .Economics.Pool.PoolUSD}}</span> · draw <span id="tile-pool-draw">{{usd .Economics.Pool.DrawUSD}}</span> · headroom <span id="tile-pool-headroom"{{if lt .Economics.Pool.HeadroomUSD 0.0}} class="error"{{end}}>{{usd .Economics.Pool.HeadroomUSD}}</span></p> |
| 105 | <p class="tile-detail">{{.Economics.CoveredDays}}/{{.Economics.PeriodDays}} days metered</p> |
| 106 | </div> |
| 107 | </section> |
| 108 | |
| 109 | <h2>Accounts</h2> |
| 110 | {{if .Economics.Accounts}} |
| 111 | <div class="tablewrap"> |
| 112 | <table> |
| 113 | <tr><th>Account</th><th>Cost</th><th>Revenue</th><th 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><th>Free tier</th></tr> |
| 114 | {{range .Economics.Accounts}} |
| 115 | {{$name := index $.Economics.Names .Account}} |
| 116 | <tr data-account="{{.Account}}"> |
| 117 | {{/* Account-names batch: same treatment as economics_accounts.html's |
| 118 | row (see that template's comment on this exact markup) — this page |
| 119 | has no econAccountRow, so the name is looked up straight from |
| 120 | EconomicsView.Names by sqid rather than carried on the row. */}} |
| 121 | <td>{{if $name}}{{$name}} <span class="small">{{.Account}}</span>{{else}}{{.Account}}{{end}}</td> |
| 122 | <td class="js-acct-cost">{{usd .CostUSD}}</td> |
| 123 | <td class="js-acct-revenue">{{usd .RevenueUSD}}</td> |
| 124 | <td class="js-acct-margin">{{marginCell .MarginFrac .FreeTierPos}}</td> |
| 125 | <td class="js-acct-freetier">{{.FreeTierPos}}</td> |
| 126 | </tr> |
| 127 | {{end}} |
| 128 | </table> |
| 129 | </div> |
| 130 | {{/* MINOR 3 (fix round, Task 6 review): the Margin column's title= |
| 131 | tooltip is invisible on touch/keyboard — this legend carries the |
| 132 | same two notes visibly, for everyone. */}} |
| 133 | <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> |
| 134 | {{else}}<p class="small">No accounts to price this period.</p>{{end}} |
| 135 | <p class="small"><a href="/fleet/economics/accounts">Every account, searchable →</a></p> |
| 136 | |
| 137 | <h2>Boxes</h2> |
| 138 | {{if .Economics.Boxes}} |
| 139 | <div class="tablewrap"> |
| 140 | <table> |
| 141 | <tr><th>Box</th><th>Hours</th><th>Cost</th><th>Idle</th><th>Utilization</th><th>Egress</th><th>Revenue</th><th>Profit</th></tr> |
| 142 | {{range .Economics.Boxes}} |
| 143 | <tr data-box="{{.Box}}"> |
| 144 | <td>{{.Box}}</td> |
| 145 | <td class="js-hours">{{printf "%.2f" .Hours}}</td> |
| 146 | <td class="js-cost">{{usd .CostUSD}}</td> |
| 147 | <td class="js-idle">{{usd .IdleUSD}}</td> |
| 148 | <td> |
| 149 | <span class="bar"><span class="bar-fill" style="width:{{printf "%.0f" (mul .Utilization 100.0)}}%"></span></span> |
| 150 | <span class="js-util-pct">{{pctf .Utilization}}</span> |
| 151 | </td> |
| 152 | <td>{{if .EgressMetered}}<span class="js-egress">metered</span>{{else}}<span class="js-egress chip chip-stale">unmetered</span>{{end}}</td> |
| 153 | <td class="js-revenue">{{usd .RevenueUSD}}</td> |
| 154 | <td class="js-profit">{{usd (sub .RevenueUSD .CostUSD)}}</td> |
| 155 | </tr> |
| 156 | {{end}} |
| 157 | </table> |
| 158 | </div> |
| 159 | {{else}}<p class="small">No box has published metrics this period.</p>{{end}} |
| 160 | |
| 161 | <div id="econ-live"> |
| 162 | {{$ec2 := sumBoxesEC2 .Economics.Boxes .Economics.Scenario.RateCard}} |
| 163 | {{$ebs := sumBoxesEBS .Economics.Boxes .Economics.Scenario.RateCard}} |
| 164 | {{$s3storage := mul (sumMeter .Economics.Meters "storage_gb_month") .Economics.Scenario.RateCard.StorageGBMonthUSD}} |
| 165 | {{$s3requests := mul (sumMeter .Economics.Meters "requests_1k") .Economics.Scenario.RateCard.RequestsPer1kUSD}} |
| 166 | {{$egress := mul (sumMeter .Economics.Meters "egress_gb") .Economics.Scenario.RateCard.EgressGBUSD}} |
| 167 | {{$backhaulGB := sumMeter .Economics.Meters "backhaul_gb"}} |
| 168 | {{$backhaul := mul $backhaulGB .Economics.Scenario.RateCard.InterRegionGBUSD}} |
| 169 | {{$overheads := overheadUSD .Economics.Scenario .Economics.PeriodDays}} |
| 170 | {{$modelTotal := add $ec2 $ebs $s3storage $s3requests $egress $backhaul $overheads}} |
| 171 | <h2>Model vs. bill</h2> |
| 172 | <div class="tablewrap"> |
| 173 | <table> |
| 174 | <tr><th>Model component</th><th>USD</th></tr> |
| 175 | <tr><td>EC2 (box hours)</td><td id="model-ec2">{{usd $ec2}}</td></tr> |
| 176 | <tr><td>EBS</td><td id="model-ebs">{{usd $ebs}}</td></tr> |
| 177 | <tr><td>S3 storage</td><td id="model-s3-storage">{{usd $s3storage}}</td></tr> |
| 178 | <tr><td>S3 requests</td><td id="model-s3-requests">{{usd $s3requests}}</td></tr> |
| 179 | <tr><td>Egress</td><td id="model-egress">{{usd $egress}}</td></tr> |
| 180 | <tr><td>Backhaul (inter-region leg) <span class="small">{{printf "%.2f" $backhaulGB}} GB{{with sumMeter .Economics.Meters "egress_gb"}}{{if gt . 0.0}} · {{printf "%.1f" (mul (div $backhaulGB .) 100.0)}}% of egress{{end}}{{end}} · absorbed, in no account's bill{{if and (gt $backhaulGB 0.0) (eq .Economics.Scenario.RateCard.InterRegionGBUSD 0.0)}} · <strong>rate unset</strong> — set "Inter-region $/GB" in the knobs{{end}}</span></td><td id="model-backhaul">{{usd $backhaul}}</td></tr> |
| 181 | <tr><td>Overheads</td><td id="model-overheads">{{usd $overheads}}</td></tr> |
| 182 | <tr><th>Model total</th><th id="model-total">{{usd $modelTotal}}</th></tr> |
| 183 | </table> |
| 184 | </div> |
| 185 | |
| 186 | {{$curBill := billForMonth .Economics.Bills (periodMonth .Economics.Period)}} |
| 187 | {{if $curBill}} |
| 188 | {{/* "Counts as CARLOS" checkbox default (Paul's rule, brief step 3): a |
| 189 | scenario with an EMPTY bill_services counts EVERY line on the bill — |
| 190 | an old scenario, or one nobody has touched, must not silently zero the |
| 191 | reconciliation. Once anything is unchecked, bill_services carries |
| 192 | exactly the checked set. billServiceChecked (server.go) and the inline |
| 193 | script's JS twin of the same name are the ONE place this rule is |
| 194 | encoded — nowhere else re-derives it. |
| 195 | |
| 196 | Two corollaries the inline script's readScenarioFromDOM enforces |
| 197 | (controller ruling, fix round 1): |
| 198 | - Empty-means-all is only a READING of an empty list, never a |
| 199 | WRITING instruction: when this period's page shows no checkboxes |
| 200 | at all (no bill on screen), the save field must pass the base |
| 201 | scenario's bill_services through untouched, not overwrite it to |
| 202 | [] — the manual bill-entry form shares #econ-live's delegated |
| 203 | listener, so a keystroke there must not clobber a reconciliation |
| 204 | selection made on a DIFFERENT period's checkboxes. |
| 205 | - "Count zero services" is not a meaningful reconciliation state |
| 206 | (there is no tri-state to invent between "all" and "some"): when |
| 207 | every checkbox is unchecked, the hidden save field keeps the last |
| 208 | selection that had at least one box checked, and the drawer shows |
| 209 | a note that the all-unchecked view is live-only, not saveable. */}} |
| 210 | {{$billChecked := sumBillServicesChecked $curBill.Services .Economics.Scenario.BillServices}} |
| 211 | {{$delta := sub $billChecked $modelTotal}} |
| 212 | <div class="tablewrap"> |
| 213 | <table> |
| 214 | <tr><th>Bill service ({{$curBill.Month}}, {{$curBill.Source}})</th><th>USD</th><th>Counts as CARLOS</th></tr> |
| 215 | {{range $svc, $amt := $curBill.Services}} |
| 216 | <tr> |
| 217 | <td>{{$svc}}</td> |
| 218 | <td>{{usd $amt}}</td> |
| 219 | <td><input type="checkbox" class="bill-service-checkbox" data-service="{{$svc}}" data-amount="{{$amt}}"{{if billServiceChecked $.Economics.Scenario.BillServices $svc}} checked{{end}}></td> |
| 220 | </tr> |
| 221 | {{end}} |
| 222 | <tr><th>Bill total (counted)</th><th id="bill-total-checked">{{usd $billChecked}}</th><th></th></tr> |
| 223 | </table> |
| 224 | </div> |
| 225 | <p>Delta, bill minus model: <strong id="breakdown-delta">{{usd $delta}}</strong></p> |
| 226 | {{else}} |
| 227 | <p class="small">No bill on file for {{periodMonth .Economics.Period}} — enter one manually.</p> |
| 228 | <form method="post" action="/fleet/economics/bill"> |
| 229 | <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| 230 | <div class="knob-row"> |
| 231 | <label for="bill-month">Month</label> |
| 232 | <input type="text" id="bill-month" name="month" pattern="\d{4}-\d{2}" value="{{periodMonth .Economics.Period}}" required> |
| 233 | </div> |
| 234 | <div class="knob-row"> |
| 235 | <label for="bill-total">Total USD</label> |
| 236 | <input type="number" step="0.01" min="0" id="bill-total" name="total" required> |
| 237 | </div> |
| 238 | <div class="knob-row"> |
| 239 | <label for="bill-lines">Services, one "Service=USD" per line</label> |
| 240 | </div> |
| 241 | <p><textarea id="bill-lines" name="lines" rows="6" cols="48" placeholder="EC2=120.50 S3=42.10"></textarea></p> |
| 242 | <button type="submit">Save bill</button> |
| 243 | </form> |
| 244 | {{end}} |
| 245 | |
| 246 | {{template "econ_knob_drawer" .}} |
| 247 | </div> |
| 248 | |
| 249 | <script type="application/json" id="econ-data">{{.Economics.DataJSON}}</script> |
| 250 | <script> |
| 251 | (function () { |
| 252 | "use strict"; |
| 253 | // The JSON payload script tag is written just above, IN SOURCE ORDER, |
| 254 | // deliberately before this script: a browser parses and inserts each |
| 255 | // <script> as it's encountered, so by the time this one executes, |
| 256 | // #econ-data is already in the DOM for getElementById to find. |
| 257 | var dataEl = document.getElementById("econ-data"); |
| 258 | if (!dataEl) { return; } |
| 259 | var payload; |
| 260 | try { payload = JSON.parse(dataEl.textContent); } catch (e) { return; } |
| 261 | |
| 262 | {{template "econ_maths_js" .}} |
| 263 | |
| 264 | function recompute() { |
| 265 | var sc = readScenarioFromDOM(payload.scenario); |
| 266 | setText("margin-multiple", marginMultiple(sc.margin)); |
| 267 | |
| 268 | var boxResults = {}; |
| 269 | var totalAllocated = {}; |
| 270 | Object.keys(payload.boxLoads).forEach(function (box) { |
| 271 | var alloc = allocateBox(sc.rate_card, sc.allocation, payload.boxLoads[box]); |
| 272 | boxResults[box] = alloc; |
| 273 | Object.keys(alloc.perAccount).forEach(function (acct) { |
| 274 | totalAllocated[acct] = (totalAllocated[acct] || 0) + alloc.perAccount[acct]; |
| 275 | }); |
| 276 | }); |
| 277 | |
| 278 | var acctSet = {}; |
| 279 | Object.keys(payload.meters).forEach(function (a) { acctSet[a] = true; }); |
| 280 | Object.keys(totalAllocated).forEach(function (a) { acctSet[a] = true; }); |
| 281 | var tiers = payload.tiers || {}; |
| 282 | var accounts = {}; |
| 283 | Object.keys(acctSet).forEach(function (acct) { |
| 284 | var a = priceAccount(sc, acct, payload.meters[acct] || {}, totalAllocated[acct] || 0); |
| 285 | // Tier-aware view-layer override (economics-v2 Task 5/Task 6), |
| 286 | // applied AFTER priceAccount exactly like buildEconomicsView applies |
| 287 | // it after PriceAccount: a pool/try/""-tier account's revenue is |
| 288 | // pooled, not individually priced, so a knob tweak must never flip a |
| 289 | // pooled row back to priced by recomputing it "inside" the free tier. |
| 290 | if (isPoolTier(tierOf(tiers, acct))) { |
| 291 | a.revenueUSD = 0; |
| 292 | a.marginFrac = 0; |
| 293 | a.freeTierPos = "pooled"; |
| 294 | } |
| 295 | accounts[acct] = a; |
| 296 | }); |
| 297 | |
| 298 | var revenueTotal = 0, acctCostTotal = 0, idleTotal = 0; |
| 299 | Object.keys(accounts).forEach(function (a) { |
| 300 | revenueTotal += accounts[a].revenueUSD; |
| 301 | acctCostTotal += accounts[a].costUSD; |
| 302 | }); |
| 303 | Object.keys(boxResults).forEach(function (b) { idleTotal += boxResults[b].idleUSD; }); |
| 304 | var overheadTotal = overheadUSD(sc, payload.periodDays); |
| 305 | var costTotal = acctCostTotal + idleTotal + overheadTotal; |
| 306 | var profitTotal = revenueTotal - costTotal; |
| 307 | var marginTotal = revenueTotal > 0 ? profitTotal / revenueTotal : 0; |
| 308 | |
| 309 | setText("tile-revenue", usd(revenueTotal)); |
| 310 | setText("tile-cost", usd(costTotal)); |
| 311 | setText("tile-profit", usd(profitTotal)); |
| 312 | setText("tile-margin", pctf(marginTotal)); |
| 313 | |
| 314 | {{/* The three lines below are wrapped in a Go template (not just |
| 315 | JS) "mtd" check, deliberately: setText's own `if (el)` guard would |
| 316 | already no-op on a period that never rendered the element, but |
| 317 | leaving the literal word live in every period's JS source would |
| 318 | defeat the honesty rule (a non-mtd page must not carry the word |
| 319 | at all, not merely fail to display it) — see |
| 320 | TestEconomicsPageMTDShowsProjection. */}} |
| 321 | {{if eq .Economics.Period "mtd"}} |
| 322 | setText("tile-revenue-projected", "projected " + usd(project(revenueTotal, payload.coveredDays, payload.daysInMonth)) + " for the month"); |
| 323 | setText("tile-cost-projected", "projected " + usd(project(costTotal, payload.coveredDays, payload.daysInMonth)) + " for the month"); |
| 324 | setText("tile-profit-projected", "projected " + usd(project(profitTotal, payload.coveredDays, payload.daysInMonth)) + " for the month"); |
| 325 | {{end}} |
| 326 | |
| 327 | document.querySelectorAll("[data-account]").forEach(function (row) { |
| 328 | var a = accounts[row.getAttribute("data-account")]; |
| 329 | if (!a) { return; } |
| 330 | setCellText(row, ".js-acct-cost", usd(a.costUSD)); |
| 331 | setCellText(row, ".js-acct-revenue", usd(a.revenueUSD)); |
| 332 | setCellText(row, ".js-acct-margin", marginCell(a.marginFrac, a.freeTierPos)); |
| 333 | setCellText(row, ".js-acct-freetier", a.freeTierPos); |
| 334 | }); |
| 335 | |
| 336 | // Fleet pool tile (Task 6): recomputed from the SAME accounts map the |
| 337 | // account rows above just used, so a knob tweak (revenue_frac, |
| 338 | // topup_usd, or anything upstream of an account's own revenue/cost) |
| 339 | // keeps the tile in lockstep with the account table on screen. |
| 340 | var pool = poolLedger(sc, tiers, accounts); |
| 341 | setText("tile-pool-usd", usd(pool.poolUSD)); |
| 342 | setText("tile-pool-draw", usd(pool.drawUSD)); |
| 343 | var poolHeadroomEl = document.getElementById("tile-pool-headroom"); |
| 344 | if (poolHeadroomEl) { |
| 345 | poolHeadroomEl.textContent = usd(pool.headroomUSD); |
| 346 | poolHeadroomEl.classList.toggle("error", pool.headroomUSD < 0); |
| 347 | } |
| 348 | |
| 349 | document.querySelectorAll("[data-box]").forEach(function (row) { |
| 350 | var box = row.getAttribute("data-box"); |
| 351 | var load = payload.boxLoads[box]; |
| 352 | var alloc = boxResults[box]; |
| 353 | if (!load || !alloc) { return; } |
| 354 | var revenue = 0; |
| 355 | Object.keys(alloc.perAccount).forEach(function (acct) { |
| 356 | var total = totalAllocated[acct]; |
| 357 | if (!total) { return; } |
| 358 | var share = alloc.perAccount[acct] / total; |
| 359 | revenue += (accounts[acct] ? accounts[acct].revenueUSD : 0) * share; |
| 360 | }); |
| 361 | var util = alloc.totalUSD > 0 ? 1 - alloc.idleUSD / alloc.totalUSD : 0; |
| 362 | setCellText(row, ".js-hours", load.hours.toFixed(2)); |
| 363 | setCellText(row, ".js-cost", usd(alloc.totalUSD)); |
| 364 | setCellText(row, ".js-idle", usd(alloc.idleUSD)); |
| 365 | setCellText(row, ".js-util-pct", pctf(util)); |
| 366 | var fill = row.querySelector(".bar-fill"); |
| 367 | if (fill) { fill.style.width = Math.round(util * 100) + "%"; } |
| 368 | setCellText(row, ".js-revenue", usd(revenue)); |
| 369 | setCellText(row, ".js-profit", usd(revenue - alloc.totalUSD)); |
| 370 | }); |
| 371 | |
| 372 | var ec2 = 0, ebs = 0; |
| 373 | Object.keys(payload.boxLoads).forEach(function (box) { |
| 374 | var load = payload.boxLoads[box]; |
| 375 | var spec = getBoxSpec(sc.rate_card, box); |
| 376 | ec2 += spec.hour_usd * load.hours; |
| 377 | ebs += spec.ebs_gb * sc.rate_card.ebs_gb_month_usd * (load.hours / HOURS_PER_MONTH); |
| 378 | }); |
| 379 | var s3storage = sumMeter(payload.meters, "storage_gb_month") * sc.rate_card.storage_gb_month_usd; |
| 380 | var s3requests = sumMeter(payload.meters, "requests_1k") * sc.rate_card.requests_per_1k_usd; |
| 381 | var egress = sumMeter(payload.meters, "egress_gb") * sc.rate_card.egress_gb_usd; |
| 382 | var backhaul = sumMeter(payload.meters, "backhaul_gb") * (sc.rate_card.inter_region_gb_usd || 0); |
| 383 | var modelTotal = ec2 + ebs + s3storage + s3requests + egress + backhaul + overheadTotal; |
| 384 | setText("model-ec2", usd(ec2)); |
| 385 | setText("model-ebs", usd(ebs)); |
| 386 | setText("model-s3-storage", usd(s3storage)); |
| 387 | setText("model-s3-requests", usd(s3requests)); |
| 388 | setText("model-egress", usd(egress)); |
| 389 | setText("model-backhaul", usd(backhaul)); |
| 390 | setText("model-overheads", usd(overheadTotal)); |
| 391 | setText("model-total", usd(modelTotal)); |
| 392 | |
| 393 | var billTotalEl = document.getElementById("bill-total-checked"); |
| 394 | if (billTotalEl) { |
| 395 | var billTotal = 0; |
| 396 | document.querySelectorAll(".bill-service-checkbox").forEach(function (el) { |
| 397 | if (el.checked) { billTotal += parseFloat(el.getAttribute("data-amount")) || 0; } |
| 398 | }); |
| 399 | billTotalEl.textContent = usd(billTotal); |
| 400 | setText("breakdown-delta", usd(billTotal - modelTotal)); |
| 401 | } |
| 402 | |
| 403 | var field = document.getElementById("scenario-json-field"); |
| 404 | if (field) { field.value = JSON.stringify(sc); } |
| 405 | } |
| 406 | |
| 407 | var live = document.getElementById("econ-live"); |
| 408 | if (live) { |
| 409 | live.addEventListener("input", recompute); |
| 410 | live.addEventListener("change", recompute); |
| 411 | } |
| 412 | })(); |
| 413 | </script> |
| 414 | {{template "bottom" .}}{{end}} |
| 415 | |
| 416 | {{/* econ_knob_drawer: the "Pricing knobs" <details> — every rate-card, |
| 417 | allocation, mode/margin, rate-sheet and free-tier input, plus the |
| 418 | save-as-scenario form. Shared verbatim between /fleet/economics (Task |
| 419 | 13) and /fleet/economics/accounts (Task 14): both pages price the |
| 420 | SAME EconomicsView.Scenario, so one set of knob markup and one |
| 421 | save-scenario door (POST /fleet/economics/scenarios, which always |
| 422 | redirects to /fleet/economics regardless of which page submitted it — |
| 423 | see handleEconomicsScenarioSave) serves both. Takes the whole page `.` |
| 424 | — reads .Economics.Scenario and .CSRF, exactly as when this was inline |
| 425 | in "economics". */}} |
| 426 | {{define "econ_knob_drawer"}} |
| 427 | <details class="reveal" id="knob-drawer"> |
| 428 | <summary>Pricing knobs</summary> |
| 429 | |
| 430 | <h3>Rate card — boxes</h3> |
| 431 | <div class="tablewrap"> |
| 432 | <table> |
| 433 | <tr><th>Box</th><th>$/hour</th><th>Cores</th><th>Mem GB</th><th>EBS GB</th></tr> |
| 434 | {{range $boxKey, $spec := .Economics.Scenario.RateCard.Boxes}} |
| 435 | <tr> |
| 436 | <td>{{if eq $boxKey ""}}default{{else}}{{$boxKey}}{{end}}</td> |
| 437 | <td><input type="number" step="0.0001" min="0" data-box-key="{{$boxKey}}" data-field="hour_usd" value="{{$spec.HourUSD}}"></td> |
| 438 | <td><input type="number" step="0.1" min="0" data-box-key="{{$boxKey}}" data-field="cores" value="{{$spec.Cores}}"></td> |
| 439 | <td><input type="number" step="0.1" min="0" data-box-key="{{$boxKey}}" data-field="mem_gb" value="{{$spec.MemGB}}"></td> |
| 440 | <td><input type="number" step="1" min="0" data-box-key="{{$boxKey}}" data-field="ebs_gb" value="{{$spec.EBSGB}}"></td> |
| 441 | </tr> |
| 442 | {{end}} |
| 443 | </table> |
| 444 | </div> |
| 445 | |
| 446 | <h3>Rate card — storage & transfer</h3> |
| 447 | <div class="knob-row"><label for="knob-ebs-gb-month-usd">EBS $/GB-month</label><input type="number" step="0.001" min="0" id="knob-ebs-gb-month-usd" value="{{.Economics.Scenario.RateCard.EBSGBMonthUSD}}"></div> |
| 448 | <div class="knob-row"><label for="knob-storage-gb-month-usd">S3 storage $/GB-month</label><input type="number" step="0.001" min="0" id="knob-storage-gb-month-usd" value="{{.Economics.Scenario.RateCard.StorageGBMonthUSD}}"></div> |
| 449 | <div class="knob-row"><label for="knob-requests-per-1k-usd">S3 requests $/1k</label><input type="number" step="0.001" min="0" id="knob-requests-per-1k-usd" value="{{.Economics.Scenario.RateCard.RequestsPer1kUSD}}"></div> |
| 450 | <div class="knob-row"><label for="knob-egress-gb-usd">Egress $/GB</label><input type="number" step="0.001" min="0" id="knob-egress-gb-usd" value="{{.Economics.Scenario.RateCard.EgressGBUSD}}"></div> |
| 451 | <div class="knob-row"><label for="knob-inter-region-gb-usd">Inter-region $/GB (backhaul leg)</label><input type="number" step="0.001" min="0" id="knob-inter-region-gb-usd" value="{{.Economics.Scenario.RateCard.InterRegionGBUSD}}"></div> |
| 452 | |
| 453 | <h3>Overheads</h3> |
| 454 | <div class="tablewrap"> |
| 455 | <table> |
| 456 | <tr><th>Line item</th><th>$/month</th></tr> |
| 457 | {{range $i, $li := .Economics.Scenario.RateCard.Overheads}} |
| 458 | <tr><td>{{$li.Name}}</td><td><input type="number" step="0.01" min="0" class="knob-overhead" data-index="{{$i}}" value="{{$li.USDMonth}}"></td></tr> |
| 459 | {{end}} |
| 460 | </table> |
| 461 | </div> |
| 462 | |
| 463 | <h3>Allocation</h3> |
| 464 | <div class="knob-row"> |
| 465 | <label>Policy</label> |
| 466 | <label><input type="radio" name="alloc-policy" value="cpu"{{if eq .Economics.Scenario.Allocation.Policy "cpu"}} checked{{end}}> CPU</label> |
| 467 | <label><input type="radio" name="alloc-policy" value="mem"{{if eq .Economics.Scenario.Allocation.Policy "mem"}} checked{{end}}> Memory</label> |
| 468 | <label><input type="radio" name="alloc-policy" value="blend"{{if eq .Economics.Scenario.Allocation.Policy "blend"}} checked{{end}}> Blend</label> |
| 469 | </div> |
| 470 | <div class="knob-row"><label for="knob-cpu-weight">Blend CPU weight</label><input type="number" step="0.05" min="0" max="1" id="knob-cpu-weight" value="{{.Economics.Scenario.Allocation.CPUWeight}}"></div> |
| 471 | |
| 472 | <h3>Mode & margin</h3> |
| 473 | <div class="knob-row"> |
| 474 | <label>Pricing mode</label> |
| 475 | <label><input type="radio" name="mode" value="cost-plus"{{if eq .Economics.Scenario.Mode "cost-plus"}} checked{{end}}> Cost-plus</label> |
| 476 | <label><input type="radio" name="mode" value="rate-sheet"{{if eq .Economics.Scenario.Mode "rate-sheet"}} checked{{end}}> Rate sheet</label> |
| 477 | </div> |
| 478 | <div class="knob-row"> |
| 479 | <label for="knob-margin">Margin (cost-plus)</label> |
| 480 | <input type="range" id="knob-margin" min="0" max="0.95" step="0.01" value="{{.Economics.Scenario.Margin}}"> |
| 481 | <span id="margin-multiple">{{marginMultiple .Economics.Scenario.Margin}}</span> |
| 482 | </div> |
| 483 | |
| 484 | <h3>Rate sheet</h3> |
| 485 | <div class="knob-row"><label for="knob-sheet-app-month-usd">$/app-month</label><input type="number" step="0.01" min="0" id="knob-sheet-app-month-usd" value="{{.Economics.Scenario.Sheet.AppMonthUSD}}"></div> |
| 486 | <div class="knob-row"><label for="knob-sheet-egress-gb-usd">$/GB egress</label><input type="number" step="0.01" min="0" id="knob-sheet-egress-gb-usd" value="{{.Economics.Scenario.Sheet.EgressGBUSD}}"></div> |
| 487 | <div class="knob-row"><label for="knob-sheet-storage-gb-month-usd">$/GB-month storage</label><input type="number" step="0.01" min="0" id="knob-sheet-storage-gb-month-usd" value="{{.Economics.Scenario.Sheet.StorageGBMonthUSD}}"></div> |
| 488 | <div class="knob-row"><label for="knob-sheet-instance-hour-usd">$/instance-hour</label><input type="number" step="0.001" min="0" id="knob-sheet-instance-hour-usd" value="{{.Economics.Scenario.Sheet.InstanceHourUSD}}"></div> |
| 489 | {{/* economics-v2 (Task 6): fleet box + mem-overage rate-sheet knobs — the |
| 490 | same three RateSheet fields compute.go's PriceAccount reads under |
| 491 | fleetRevenue and the rate-sheet mem-overage term, both modes. */}} |
| 492 | <div class="knob-row"><label for="knob-sheet-box-month-server-usd">$/box-month (server)</label><input type="number" step="0.01" min="0" id="knob-sheet-box-month-server-usd" value="{{.Economics.Scenario.Sheet.BoxMonthServerUSD}}"></div> |
| 493 | <div class="knob-row"><label for="knob-sheet-box-month-device-usd">$/box-month (device)</label><input type="number" step="0.01" min="0" id="knob-sheet-box-month-device-usd" value="{{.Economics.Scenario.Sheet.BoxMonthDeviceUSD}}"></div> |
| 494 | <div class="knob-row"><label for="knob-sheet-mem-gb-hour-usd">$/GB-hour mem overage (rate-sheet)</label><input type="number" step="0.001" min="0" id="knob-sheet-mem-gb-hour-usd" value="{{.Economics.Scenario.Sheet.MemGBHourUSD}}"></div> |
| 495 | |
| 496 | <h3>Fleet volume break</h3> |
| 497 | <div class="knob-row"><label for="knob-fleet-break-min-boxes">Min avg boxes/month</label><input type="number" step="0.1" min="0" id="knob-fleet-break-min-boxes" value="{{.Economics.Scenario.FleetBreak.MinBoxes}}"></div> |
| 498 | <div class="knob-row"><label for="knob-fleet-break-discount">Discount at/above min</label><input type="number" step="0.01" min="0" max="0.99" id="knob-fleet-break-discount" value="{{.Economics.Scenario.FleetBreak.Discount}}"></div> |
| 499 | |
| 500 | <h3>Fleet pool</h3> |
| 501 | <div class="knob-row"><label for="knob-pool-revenue-frac">Revenue share to pool</label><input type="number" step="0.01" min="0" max="1" id="knob-pool-revenue-frac" value="{{.Economics.Scenario.Pool.RevenueFrac}}"></div> |
| 502 | <div class="knob-row"><label for="knob-pool-topup-usd">Monthly top-up $</label><input type="number" step="0.01" min="0" id="knob-pool-topup-usd" value="{{.Economics.Scenario.Pool.TopupUSD}}"></div> |
| 503 | |
| 504 | <h3>Free tier</h3> |
| 505 | <div class="knob-row"><label for="knob-ft-apps">Apps</label><input type="number" step="1" min="0" id="knob-ft-apps" value="{{.Economics.Scenario.FreeTier.Apps}}"></div> |
| 506 | <div class="knob-row"><label for="knob-ft-egress-gb">Egress GB</label><input type="number" step="1" min="0" id="knob-ft-egress-gb" value="{{.Economics.Scenario.FreeTier.EgressGB}}"></div> |
| 507 | <div class="knob-row"><label for="knob-ft-mem-gb-hours">Mem GB-hours</label><input type="number" step="1" min="0" id="knob-ft-mem-gb-hours" value="{{.Economics.Scenario.FreeTier.MemGBHours}}"></div> |
| 508 | <div class="knob-row"><label for="knob-ft-storage-gb">Storage GB</label><input type="number" step="1" min="0" id="knob-ft-storage-gb" value="{{.Economics.Scenario.FreeTier.StorageGB}}"></div> |
| 509 | |
| 510 | <h3>Save as scenario</h3> |
| 511 | <p class="small">With JavaScript on, this saves the knobs above exactly as tweaked. With it off, this round-trips the currently active scenario unchanged, under a new name and/or activated. Saving from either page lands back on the dashboard (/fleet/economics) — the one save door both pages share.</p> |
| 512 | <form method="post" action="/fleet/economics/scenarios"> |
| 513 | <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| 514 | <input type="hidden" name="scenario" id="scenario-json-field" value="{{scenarioJSON .Economics.Scenario}}"> |
| 515 | <div class="knob-row"> |
| 516 | <label for="scenario-name">Name</label> |
| 517 | <input type="text" id="scenario-name" name="name" pattern="[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?" required> |
| 518 | </div> |
| 519 | <label><input type="checkbox" name="activate" value="1"> Activate</label> |
| 520 | <button type="submit">Save scenario</button> |
| 521 | {{/* Hidden by default; the inline script shows it (display:"") exactly |
| 522 | when every "counts as CARLOS" checkbox is unchecked — the state the |
| 523 | save field refuses to persist (see the template comment by the bill |
| 524 | table). Only /fleet/economics ever has bill checkboxes on screen, so |
| 525 | this note only ever becomes visible there — but the element must |
| 526 | exist on both pages: setBillServicesZeroNote (econ_maths_js) looks |
| 527 | it up by id and simply no-ops via its `if (el)` guard when absent. */}} |
| 528 | <p class="small error" id="bill-services-zero-note" style="display:none">At least one service must count — unchecking all shows a zero model line but is not saveable as a selection.</p> |
| 529 | </form> |
| 530 | </details> |
| 531 | {{end}} |
| 532 | |
| 533 | {{/* econ_maths_js: every pure (no page-specific DOM target) function the |
| 534 | knob-recompute scripts need — economics.AllocateBox/PriceAccount |
| 535 | transliterated, plus the small DOM/formatting helpers and |
| 536 | readScenarioFromDOM, which reads the econ_knob_drawer inputs back into |
| 537 | a live-edited Scenario. Emits BARE JavaScript, no <script> tags and no |
| 538 | wrapping IIFE of its own: each including page's own <script> already |
| 539 | opens one (see "economics" above and economics_accounts.html), and a |
| 540 | template action splices this in as plain text at that point — so every |
| 541 | function/var here shares that ONE enclosing scope with the |
| 542 | including page's own dataEl/payload preamble and its own recompute(). |
| 543 | Takes `.` for template-call symmetry with econ_knob_drawer; nothing |
| 544 | inside reads it (this block is pure JS, no Go value substitution). */}} |
| 545 | {{define "econ_maths_js"}} |
| 546 | // lastNonEmptyBillServices remembers the most recent "counts as CARLOS" |
| 547 | // selection that had at least one box checked, seeded from the scenario |
| 548 | // the page loaded with — readScenarioFromDOM's zero-checked branch reads |
| 549 | // this instead of ever serializing an empty (== "count everything", |
| 550 | // the opposite meaning) selection. null until either the loaded scenario |
| 551 | // or this session has seen a non-empty selection. Only /fleet/economics |
| 552 | // ever has bill checkboxes to derive this from; on a page with none, the |
| 553 | // branch that would update it never runs, and this stays whatever the |
| 554 | // loaded scenario seeded it to. |
| 555 | var lastNonEmptyBillServices = (payload.scenario.bill_services && payload.scenario.bill_services.length > 0) |
| 556 | ? payload.scenario.bill_services |
| 557 | : null; |
| 558 | function setBillServicesZeroNote(show) { |
| 559 | var el = document.getElementById("bill-services-zero-note"); |
| 560 | if (el) { el.style.display = show ? "" : "none"; } |
| 561 | } |
| 562 | |
| 563 | // HoursPerMonth mirrors internal/economics/compute.go's exported constant |
| 564 | // of the same name — the proration every hour-denominated rate uses to |
| 565 | // read against a GB-month figure. |
| 566 | var HOURS_PER_MONTH = 730; |
| 567 | |
| 568 | function numVal(id) { |
| 569 | var el = document.getElementById(id); |
| 570 | if (!el) { return 0; } |
| 571 | var v = parseFloat(el.value); |
| 572 | return isNaN(v) ? 0 : v; |
| 573 | } |
| 574 | function radioVal(name) { |
| 575 | var el = document.querySelector('input[name="' + name + '"]:checked'); |
| 576 | return el ? el.value : ""; |
| 577 | } |
| 578 | function setText(id, text) { |
| 579 | var el = document.getElementById(id); |
| 580 | if (el) { el.textContent = text; } |
| 581 | } |
| 582 | function setCellText(row, sel, text) { |
| 583 | var el = row.querySelector(sel); |
| 584 | if (el) { el.textContent = text; } |
| 585 | } |
| 586 | |
| 587 | // usd/pctf/marginMultiple are the JS twins of the same-named template |
| 588 | // funcs (server.go) — same rounding, same sign handling, so a knob change |
| 589 | // never disagrees with the page's own initial render in HOW a number is |
| 590 | // shown, only in what the number IS. |
| 591 | function usd(v) { |
| 592 | if (isNaN(v) || !isFinite(v)) { return "—"; } |
| 593 | var neg = v < 0; |
| 594 | if (neg) { v = -v; } |
| 595 | var s = "$" + v.toFixed(2); |
| 596 | return neg ? "-" + s : s; |
| 597 | } |
| 598 | function pctf(v) { return Math.round(v * 100) + "%"; } |
| 599 | // project is economics.Project (compute.go) transliterated: scale a |
| 600 | // month-to-date figure to covered/period days, 0 when nothing has been |
| 601 | // covered yet (never a divide-by-zero NaN). recompute() only calls this |
| 602 | // on the one period whose tiles carry a second, scaled-up line at all — |
| 603 | // see that call site's own template guard. |
| 604 | function project(mtd, covered, days) { |
| 605 | if (covered <= 0) { return 0; } |
| 606 | return mtd * days / covered; |
| 607 | } |
| 608 | function marginMultiple(margin) { |
| 609 | if (margin < 0 || margin >= 1) { return "—"; } |
| 610 | return (1 / (1 - margin)).toFixed(1) + "×"; |
| 611 | } |
| 612 | |
| 613 | // getBoxSpec mirrors AllocateBox's own box-then-default lookup |
| 614 | // (compute.go: rc.Boxes[b.Box], falling back to rc.Boxes[""]). |
| 615 | function getBoxSpec(rateCard, box) { |
| 616 | var boxes = rateCard.boxes || {}; |
| 617 | return boxes[box] || boxes[""] || {hour_usd: 0, cores: 0, mem_gb: 0, ebs_gb: 0}; |
| 618 | } |
| 619 | |
| 620 | // billable mirrors compute.go's billable: a meter reading minus its free- |
| 621 | // tier allowance, floored at zero. |
| 622 | function billable(meters, meter, allowance) { |
| 623 | var v = (meters[meter] || 0) - allowance; |
| 624 | return v < 0 ? 0 : v; |
| 625 | } |
| 626 | |
| 627 | // allocateBox is economics.AllocateBox transliterated: same three |
| 628 | // returns (perAccount, idleUSD, totalUSD), same policy switch, same |
| 629 | // early return for a box with zero metered hours (100% idle, nothing to |
| 630 | // allocate — AllocateBox's own comment on that branch applies here |
| 631 | // unchanged). |
| 632 | function allocateBox(rateCard, allocation, boxLoad) { |
| 633 | var spec = getBoxSpec(rateCard, boxLoad.box); |
| 634 | var totalUSD = spec.hour_usd * boxLoad.hours + spec.ebs_gb * rateCard.ebs_gb_month_usd * (boxLoad.hours / HOURS_PER_MONTH); |
| 635 | var perAccount = {}; |
| 636 | if (boxLoad.hours <= 0) { |
| 637 | return {perAccount: perAccount, idleUSD: totalUSD, totalUSD: totalUSD}; |
| 638 | } |
| 639 | var allocated = 0; |
| 640 | var byAccount = boxLoad.by_account || {}; |
| 641 | Object.keys(byAccount).forEach(function (acct) { |
| 642 | var load = byAccount[acct]; |
| 643 | var cpuShare = 0, memShare = 0; |
| 644 | if (spec.cores > 0) { cpuShare = load.cpu_core_hours / (spec.cores * boxLoad.hours); } |
| 645 | if (spec.mem_gb > 0) { memShare = load.mem_gb_hours / (spec.mem_gb * boxLoad.hours); } |
| 646 | var share; |
| 647 | if (allocation.policy === "cpu") { share = cpuShare; } |
| 648 | else if (allocation.policy === "mem") { share = memShare; } |
| 649 | else { share = allocation.cpu_weight * cpuShare + (1 - allocation.cpu_weight) * memShare; } |
| 650 | var usdShare = share * totalUSD; |
| 651 | perAccount[acct] = usdShare; |
| 652 | allocated += usdShare; |
| 653 | }); |
| 654 | var idleUSD = totalUSD - allocated; |
| 655 | if (idleUSD < 0) { idleUSD = 0; } |
| 656 | return {perAccount: perAccount, idleUSD: idleUSD, totalUSD: totalUSD}; |
| 657 | } |
| 658 | |
| 659 | // priceAccount is economics.PriceAccount transliterated: same cost/ |
| 660 | // revenue/margin/freeTierPos formulas, same cost-plus vs rate-sheet |
| 661 | // branch on sc.mode. |
| 662 | function priceAccount(sc, account, meters, allocatedUSD) { |
| 663 | var rc = sc.rate_card; |
| 664 | var cost = allocatedUSD + |
| 665 | (meters.egress_gb || 0) * rc.egress_gb_usd + |
| 666 | (meters.requests_1k || 0) * rc.requests_per_1k_usd + |
| 667 | (meters.storage_gb_month || 0) * rc.storage_gb_month_usd; |
| 668 | |
| 669 | var ft = sc.free_tier; |
| 670 | var bEgress = billable(meters, "egress_gb", ft.egress_gb); |
| 671 | var bStorage = billable(meters, "storage_gb_month", ft.storage_gb); |
| 672 | var bApps = billable(meters, "app_months", ft.apps); |
| 673 | var bMem = billable(meters, "mem_gb_hours", ft.mem_gb_hours); |
| 674 | var computeFrac = 1.0; |
| 675 | var m = meters.mem_gb_hours || 0; |
| 676 | if (m > 0) { computeFrac = bMem / m; } |
| 677 | |
| 678 | var revenue; |
| 679 | if (sc.mode === "rate-sheet") { |
| 680 | revenue = bApps * sc.sheet.app_month_usd + |
| 681 | bEgress * sc.sheet.egress_gb_usd + |
| 682 | bStorage * sc.sheet.storage_gb_month_usd + |
| 683 | (meters.instance_hours || 0) * sc.sheet.instance_hour_usd + |
| 684 | // Mem overage, rate-sheet mode only — the SAME bMem binding used |
| 685 | // above for computeFrac and the clipped check below, per |
| 686 | // compute.go's PriceAccount (economics-v2 Task 2/Task 6). |
| 687 | bMem * sc.sheet.mem_gb_hour_usd; |
| 688 | } else { // cost-plus |
| 689 | var billableCost = allocatedUSD * computeFrac + |
| 690 | bEgress * rc.egress_gb_usd + |
| 691 | (meters.requests_1k || 0) * rc.requests_per_1k_usd + |
| 692 | bStorage * rc.storage_gb_month_usd; |
| 693 | revenue = billableCost / (1 - sc.margin); |
| 694 | } |
| 695 | |
| 696 | // Fleet box revenue (both modes) — economics-v2 (Task 2/Task 6), |
| 697 | // compute.go's PriceAccount transliterated verbatim: the fleet term |
| 698 | // sits OUTSIDE the cost-plus/rate-sheet branch above, meter reads |
| 699 | // guarded against undefined (an account with no fleet usage never |
| 700 | // wrote these keys), volume break applied only when a floor is set AND |
| 701 | // cleared. |
| 702 | var fleetRevenue = ((meters.fleet_box_hours_server || 0) / HOURS_PER_MONTH) * sc.sheet.box_month_server_usd + |
| 703 | ((meters.fleet_box_hours_device || 0) / HOURS_PER_MONTH) * sc.sheet.box_month_device_usd; |
| 704 | var avgBoxes = ((meters.fleet_box_hours_server || 0) + (meters.fleet_box_hours_device || 0)) / HOURS_PER_MONTH; |
| 705 | if (sc.fleet_break.min_boxes > 0 && avgBoxes >= sc.fleet_break.min_boxes) { |
| 706 | fleetRevenue *= (1 - sc.fleet_break.discount); |
| 707 | } |
| 708 | revenue += fleetRevenue; |
| 709 | |
| 710 | var margin = 0; |
| 711 | if (revenue > 0) { margin = (revenue - cost) / revenue; } |
| 712 | |
| 713 | var anyUsage = false; |
| 714 | Object.keys(meters).forEach(function (k) { |
| 715 | if (k === "backhaul_gb" || k === "backhaul_requests_1k") { return; } // data, not usage (#288) |
| 716 | if (meters[k] > 0) { anyUsage = true; } |
| 717 | }); |
| 718 | var clipped = bEgress < (meters.egress_gb || 0) || |
| 719 | bStorage < (meters.storage_gb_month || 0) || |
| 720 | bApps < (meters.app_months || 0) || |
| 721 | bMem < (meters.mem_gb_hours || 0); |
| 722 | var pos = "paying"; |
| 723 | if (revenue === 0 && anyUsage) { pos = "inside"; } |
| 724 | else if (clipped) { pos = "partial"; } |
| 725 | |
| 726 | return {account: account, costUSD: cost, revenueUSD: revenue, marginFrac: margin, freeTierPos: pos}; |
| 727 | } |
| 728 | |
| 729 | // tierOf/isPoolTier mirror economicsview.go's same-named functions |
| 730 | // (economics-v2 Task 5/Task 6): "" — whether from an explicit blank tier |
| 731 | // or the account simply being absent from payload.tiers entirely (a |
| 732 | // fleet-only account whose owner never signed up) — is pool semantics, |
| 733 | // same as the explicit "try" and "pool" tiers. |
| 734 | function tierOf(tiers, acct) { |
| 735 | return tiers[acct] || ""; |
| 736 | } |
| 737 | function isPoolTier(tier) { |
| 738 | return tier === "" || tier === "try" || tier === "pool"; |
| 739 | } |
| 740 | |
| 741 | // marginCell is the JS twin of server.go's "marginCell" template func: an |
| 742 | // em-dash for a pooled row, never "0%" — see that func's doc comment. |
| 743 | function marginCell(frac, pos) { |
| 744 | return pos === "pooled" ? "—" : pctf(frac); |
| 745 | } |
| 746 | |
| 747 | // poolLedger mirrors buildEconomicsView's PoolLedger construction |
| 748 | // (economicsview.go, economics-v2 Task 5/Task 6): paying revenue sums |
| 749 | // every non-pool-tier row's revenue, draw sums every pool-tier row's |
| 750 | // cost, and the topup term is payload.poolTopupUnitAccrual (the |
| 751 | // dimensionless Σ 1/daysInMonth(d) the server accrued this period — |
| 752 | // see economicsPayload's doc comment) times the CURRENT topup_usd knob, |
| 753 | // reproducing poolTopupAccrual exactly under any knob value without |
| 754 | // re-deriving the per-day sum client-side. `rows` is the accounts map |
| 755 | // this page's recompute() already built (account -> priceAccount's |
| 756 | // result, AFTER the tier override below has been applied) — its |
| 757 | // revenueUSD is already 0 for a pool-tier row, but this still checks the |
| 758 | // tier explicitly for draw/paying-revenue attribution, exactly like the |
| 759 | // Go it mirrors, rather than relying on that zero. |
| 760 | function poolLedger(sc, tiers, rows) { |
| 761 | var payingRevenue = 0, poolDraw = 0; |
| 762 | Object.keys(rows).forEach(function (acct) { |
| 763 | var r = rows[acct]; |
| 764 | if (isPoolTier(tierOf(tiers, acct))) { |
| 765 | poolDraw += r.costUSD; |
| 766 | } else { |
| 767 | payingRevenue += r.revenueUSD; |
| 768 | } |
| 769 | }); |
| 770 | var poolUSD = sc.pool.revenue_frac * payingRevenue + sc.pool.topup_usd * payload.poolTopupUnitAccrual; |
| 771 | return {poolUSD: poolUSD, drawUSD: poolDraw, headroomUSD: poolUSD - poolDraw, payingRevenueUSD: payingRevenue}; |
| 772 | } |
| 773 | |
| 774 | // overheadUSD mirrors the server.go template func of the same name: the |
| 775 | // rate card's fixed monthly line items, prorated over the period by |
| 776 | // periodDays, not coveredDays (see that func's doc comment for why). |
| 777 | function overheadUSD(sc, periodDays) { |
| 778 | var monthly = 0; |
| 779 | (sc.rate_card.overheads || []).forEach(function (li) { monthly += li.usd_month; }); |
| 780 | return monthly * periodDays * 24 / HOURS_PER_MONTH; |
| 781 | } |
| 782 | |
| 783 | // sumMeter mirrors the server.go template func: one named meter, summed |
| 784 | // across every account in the payload's "meters" map. |
| 785 | function sumMeter(meters, meter) { |
| 786 | var sum = 0; |
| 787 | Object.keys(meters).forEach(function (a) { sum += (meters[a][meter] || 0); }); |
| 788 | return sum; |
| 789 | } |
| 790 | |
| 791 | // billServiceChecked is the SAME "counts as CARLOS" default rule as the |
| 792 | // server.go template func of the same name — see economics.html's |
| 793 | // template comment by the bill table for the reasoning, one sentence of |
| 794 | // which applies here unchanged: keep it in exactly one place per side |
| 795 | // (Go, JS), never re-derive it. |
| 796 | function billServiceChecked(billServices, service) { |
| 797 | if (!billServices || billServices.length === 0) { return true; } |
| 798 | return billServices.indexOf(service) !== -1; |
| 799 | } |
| 800 | |
| 801 | // readScenarioFromDOM clones the payload's starting scenario (so any |
| 802 | // field this page has no knob for — Schema, Name — survives untouched) |
| 803 | // and overwrites every field the drawer DOES expose from its current |
| 804 | // input values, plus bill_services from the checkbox row. Reading the |
| 805 | // WHOLE scenario back out on every change, rather than patching one |
| 806 | // field, keeps this the single place that must agree with every input's |
| 807 | // id/data-* attribute — the same "one source of truth" reasoning as |
| 808 | // billServiceChecked above. |
| 809 | function readScenarioFromDOM(base) { |
| 810 | var sc = JSON.parse(JSON.stringify(base)); |
| 811 | sc.rate_card.boxes = sc.rate_card.boxes || {}; |
| 812 | document.querySelectorAll("[data-box-key]").forEach(function (el) { |
| 813 | var key = el.getAttribute("data-box-key"); |
| 814 | var field = el.getAttribute("data-field"); |
| 815 | if (!sc.rate_card.boxes[key]) { sc.rate_card.boxes[key] = {}; } |
| 816 | sc.rate_card.boxes[key][field] = parseFloat(el.value) || 0; |
| 817 | }); |
| 818 | sc.rate_card.ebs_gb_month_usd = numVal("knob-ebs-gb-month-usd"); |
| 819 | sc.rate_card.storage_gb_month_usd = numVal("knob-storage-gb-month-usd"); |
| 820 | sc.rate_card.requests_per_1k_usd = numVal("knob-requests-per-1k-usd"); |
| 821 | sc.rate_card.egress_gb_usd = numVal("knob-egress-gb-usd"); |
| 822 | sc.rate_card.inter_region_gb_usd = numVal("knob-inter-region-gb-usd"); |
| 823 | document.querySelectorAll(".knob-overhead").forEach(function (el) { |
| 824 | var i = parseInt(el.getAttribute("data-index"), 10); |
| 825 | if (sc.rate_card.overheads && sc.rate_card.overheads[i]) { |
| 826 | sc.rate_card.overheads[i].usd_month = parseFloat(el.value) || 0; |
| 827 | } |
| 828 | }); |
| 829 | sc.allocation.policy = radioVal("alloc-policy") || sc.allocation.policy; |
| 830 | sc.allocation.cpu_weight = numVal("knob-cpu-weight"); |
| 831 | sc.mode = radioVal("mode") || sc.mode; |
| 832 | sc.margin = numVal("knob-margin"); |
| 833 | sc.sheet.app_month_usd = numVal("knob-sheet-app-month-usd"); |
| 834 | sc.sheet.egress_gb_usd = numVal("knob-sheet-egress-gb-usd"); |
| 835 | sc.sheet.storage_gb_month_usd = numVal("knob-sheet-storage-gb-month-usd"); |
| 836 | sc.sheet.instance_hour_usd = numVal("knob-sheet-instance-hour-usd"); |
| 837 | // economics-v2 (Task 6): the seven new knobs, read back exactly like |
| 838 | // every knob above — one place, matching the drawer's ids one-to-one. |
| 839 | sc.sheet.box_month_server_usd = numVal("knob-sheet-box-month-server-usd"); |
| 840 | sc.sheet.box_month_device_usd = numVal("knob-sheet-box-month-device-usd"); |
| 841 | sc.sheet.mem_gb_hour_usd = numVal("knob-sheet-mem-gb-hour-usd"); |
| 842 | sc.fleet_break.min_boxes = numVal("knob-fleet-break-min-boxes"); |
| 843 | sc.fleet_break.discount = numVal("knob-fleet-break-discount"); |
| 844 | sc.pool.revenue_frac = numVal("knob-pool-revenue-frac"); |
| 845 | sc.pool.topup_usd = numVal("knob-pool-topup-usd"); |
| 846 | sc.free_tier.apps = numVal("knob-ft-apps"); |
| 847 | sc.free_tier.egress_gb = numVal("knob-ft-egress-gb"); |
| 848 | sc.free_tier.mem_gb_hours = numVal("knob-ft-mem-gb-hours"); |
| 849 | sc.free_tier.storage_gb = numVal("knob-ft-storage-gb"); |
| 850 | |
| 851 | // See the template comment by the bill table (the two corollaries |
| 852 | // block) for both rules enforced below. |
| 853 | var boxes = document.querySelectorAll(".bill-service-checkbox"); |
| 854 | if (boxes.length > 0) { |
| 855 | var checked = []; |
| 856 | boxes.forEach(function (el) { if (el.checked) { checked.push(el.getAttribute("data-service")); } }); |
| 857 | if (checked.length > 0) { |
| 858 | // Empty means "every service counts" (billServiceChecked's |
| 859 | // default) — so leaving every box checked writes [] back, not an |
| 860 | // equivalent, brittle explicit list of every service name on the |
| 861 | // bill. |
| 862 | sc.bill_services = checked.length < boxes.length ? checked : []; |
| 863 | lastNonEmptyBillServices = sc.bill_services; |
| 864 | setBillServicesZeroNote(false); |
| 865 | } else { |
| 866 | // All unchecked: "count zero services" is not a representable |
| 867 | // reconciliation state (empty already means "count everything", |
| 868 | // the opposite meaning). Keep the save field at the last |
| 869 | // selection that had at least one box checked this session, or — |
| 870 | // if the operator hasn't touched a checkbox yet — the base |
| 871 | // scenario's own saved list, either way never []. The live |
| 872 | // model/bill numbers on screen may still show the all-unchecked |
| 873 | // $0 total; only the SAVED state declines to represent it, which |
| 874 | // is why the note below exists. |
| 875 | sc.bill_services = lastNonEmptyBillServices !== null ? lastNonEmptyBillServices : sc.bill_services; |
| 876 | setBillServicesZeroNote(true); |
| 877 | } |
| 878 | } |
| 879 | // boxes.length === 0: no bill is on screen for this period, so there |
| 880 | // is nothing to derive a selection from — sc.bill_services stays |
| 881 | // exactly what the clone of base already carries, untouched. Without |
| 882 | // this early-out, a keystroke in the unrelated manual-bill-entry form |
| 883 | // (inside the same #econ-live listener scope) would fire recompute() |
| 884 | // and clobber a reconciliation selection made against a DIFFERENT |
| 885 | // period's bill into [] — the fix-round finding this whole branch |
| 886 | // exists for. On /fleet/economics/accounts, which has no bill-entry |
| 887 | // form at all, boxes.length is always 0, so this branch never runs. |
| 888 | return sc; |
| 889 | } |
| 890 | {{end}} |
| 891 | |