carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

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}} &middot; scenario <strong>{{.Economics.Scenario.Name}}</strong> ({{.Economics.Scenario.Mode}}) &middot; {{.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> &middot;
48 <a href="?period=last"{{if eq .Economics.Period "last"}} aria-current="page"{{end}}>Last month</a> &middot;
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> &middot; draw <span id="tile-pool-draw">{{usd .Economics.Pool.DrawUSD}}</span> &middot; 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 &quot;pooled&quot;) 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&#10;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 &mdash; 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 &mdash; storage &amp; 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 &amp; 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