| new file mode 100644 |
| index 000000000..e781220eb |
| --- /dev/null |
| +++ b/internal/console/templates/economics.html |
| @@ -0,0 +1,593 @@ |
| +{{/* /fleet/economics (Task 13): the operator unit-economics dashboard. |
| + Everything on this page renders server-side from EconomicsView, which |
| + is already priced under the active scenario (economicsview.go) β the |
| + ONE inline <script> at the foot only RE-computes in place when an |
| + operator tweaks a knob, transliterating |
| + internal/economics/compute.go's AllocateBox/PriceAccount/Project |
| + function-for-function (same names, JS-cased) so drift between the two |
| + is reviewable at a glance. With JS off, every number above stays |
| + exactly what the server rendered, and the knob drawer's only working |
| + control is the save-scenario form at its foot: it round-trips the |
| + CURRENTLY active scenario's JSON unchanged (Task 12's |
| + /fleet/economics/scenarios door), just under a new name and/or |
| + activated β the knobs above it don't feed the save without JS, |
| + because there is no field-by-field parsing on the server side for |
| + them, only whole-JSON parsing (server.go's handleEconomicsScenarioSave |
| + comment). */}} |
| +{{define "economics"}}{{template "top" .}} |
| +<h1>Fleet economics</h1> |
| +{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| +{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| +{{if .Economics.Warning}}<p class="banner error">{{icon "triangle-alert"}} {{.Economics.Warning}}</p>{{end}} |
| +{{if .Economics.ScenarioWarning}}<p class="banner error">{{icon "triangle-alert"}} {{.Economics.ScenarioWarning}}</p>{{end}} |
| + |
| +<p class="small">Generated {{fmtTime .Economics.GeneratedAt}} · scenario <strong>{{.Economics.Scenario.Name}}</strong> ({{.Economics.Scenario.Mode}}) · {{.Economics.CoveredDays}}/{{.Economics.PeriodDays}} days metered</p> |
| + |
| +<nav class="small" aria-label="Period"> |
| + Period: |
| + <a href="?period=mtd"{{if eq .Economics.Period "mtd"}} aria-current="page"{{end}}>Month to date</a> · |
| + <a href="?period=last"{{if eq .Economics.Period "last"}} aria-current="page"{{end}}>Last month</a> · |
| + <a href="?period=7d"{{if eq .Economics.Period "7d"}} aria-current="page"{{end}}>Last 7 days</a> |
| +</nav> |
| + |
| +{{/* Headline tiles: EconomicsView.Accounts (allocated+metered cost and |
| + revenue per tenant) plus every box's unallocated idle headroom plus |
| + fixed overheads (brief: "period totals from EconomicsView.Accounts + |
| + idle + overheads") β see sumAcctCost/sumBoxIdle/overheadUSD (server.go) |
| + for the exact formula, mirrored by the inline script's recompute(). */}} |
| +{{$revenue := sumAcctRevenue .Economics.Accounts}} |
| +{{$cost := add (sumAcctCost .Economics.Accounts) (sumBoxIdle .Economics.Boxes) (overheadUSD .Economics.Scenario .Economics.PeriodDays)}} |
| +{{$profit := sub $revenue $cost}} |
| +{{$margin := marginOf $revenue $cost}} |
| +<section class="tiles" aria-label="Headline economics"> |
| + <div class="tile"> |
| + <h2>Would-be revenue</h2> |
| + <p class="tile-value" id="tile-revenue">{{usd $revenue}}</p> |
| + <p class="tile-detail">{{.Economics.CoveredDays}}/{{.Economics.PeriodDays}} days metered</p> |
| + </div> |
| + <div class="tile"> |
| + <h2>Cost</h2> |
| + <p class="tile-value" id="tile-cost">{{usd $cost}}</p> |
| + <p class="tile-detail">{{.Economics.CoveredDays}}/{{.Economics.PeriodDays}} days metered</p> |
| + </div> |
| + <div class="tile"> |
| + <h2>Profit</h2> |
| + <p class="tile-value" id="tile-profit">{{usd $profit}}</p> |
| + <p class="tile-detail">{{.Economics.CoveredDays}}/{{.Economics.PeriodDays}} days metered</p> |
| + </div> |
| + <div class="tile"> |
| + <h2>Achieved margin</h2> |
| + <p class="tile-value" id="tile-margin">{{pctf $margin}}</p> |
| + <p class="tile-detail">{{.Economics.CoveredDays}}/{{.Economics.PeriodDays}} days metered</p> |
| + </div> |
| +</section> |
| + |
| +<h2>Accounts</h2> |
| +{{if .Economics.Accounts}} |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>Account</th><th>Cost</th><th>Revenue</th><th>Margin</th><th>Free tier</th></tr> |
| + {{range .Economics.Accounts}} |
| + <tr data-account="{{.Account}}"> |
| + <td>{{.Account}}</td> |
| + <td class="js-acct-cost">{{usd .CostUSD}}</td> |
| + <td class="js-acct-revenue">{{usd .RevenueUSD}}</td> |
| + <td class="js-acct-margin">{{pctf .MarginFrac}}</td> |
| + <td class="js-acct-freetier">{{.FreeTierPos}}</td> |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +{{else}}<p class="small">No accounts to price this period.</p>{{end}} |
| + |
| +<h2>Boxes</h2> |
| +{{if .Economics.Boxes}} |
| +<div class="tablewrap"> |
| +<table> |
| + <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> |
| + {{range .Economics.Boxes}} |
| + <tr data-box="{{.Box}}"> |
| + <td>{{.Box}}</td> |
| + <td class="js-hours">{{printf "%.2f" .Hours}}</td> |
| + <td class="js-cost">{{usd .CostUSD}}</td> |
| + <td class="js-idle">{{usd .IdleUSD}}</td> |
| + <td> |
| + <span class="bar"><span class="bar-fill" style="width:{{printf "%.0f" (mul .Utilization 100.0)}}%"></span></span> |
| + <span class="js-util-pct">{{pctf .Utilization}}</span> |
| + </td> |
| + <td>{{if .EgressMetered}}<span class="js-egress">metered</span>{{else}}<span class="js-egress chip chip-stale">unmetered</span>{{end}}</td> |
| + <td class="js-revenue">{{usd .RevenueUSD}}</td> |
| + <td class="js-profit">{{usd (sub .RevenueUSD .CostUSD)}}</td> |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +{{else}}<p class="small">No box has published metrics this period.</p>{{end}} |
| + |
| +<div id="econ-live"> |
| +{{$ec2 := sumBoxesEC2 .Economics.Boxes .Economics.Scenario.RateCard}} |
| +{{$ebs := sumBoxesEBS .Economics.Boxes .Economics.Scenario.RateCard}} |
| +{{$s3storage := mul (sumMeter .Economics.Meters "storage_gb_month") .Economics.Scenario.RateCard.StorageGBMonthUSD}} |
| +{{$s3requests := mul (sumMeter .Economics.Meters "requests_1k") .Economics.Scenario.RateCard.RequestsPer1kUSD}} |
| +{{$egress := mul (sumMeter .Economics.Meters "egress_gb") .Economics.Scenario.RateCard.EgressGBUSD}} |
| +{{$overheads := overheadUSD .Economics.Scenario .Economics.PeriodDays}} |
| +{{$modelTotal := add $ec2 $ebs $s3storage $s3requests $egress $overheads}} |
| +<h2>Model vs. bill</h2> |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>Model component</th><th>USD</th></tr> |
| + <tr><td>EC2 (box hours)</td><td id="model-ec2">{{usd $ec2}}</td></tr> |
| + <tr><td>EBS</td><td id="model-ebs">{{usd $ebs}}</td></tr> |
| + <tr><td>S3 storage</td><td id="model-s3-storage">{{usd $s3storage}}</td></tr> |
| + <tr><td>S3 requests</td><td id="model-s3-requests">{{usd $s3requests}}</td></tr> |
| + <tr><td>Egress</td><td id="model-egress">{{usd $egress}}</td></tr> |
| + <tr><td>Overheads</td><td id="model-overheads">{{usd $overheads}}</td></tr> |
| + <tr><th>Model total</th><th id="model-total">{{usd $modelTotal}}</th></tr> |
| +</table> |
| +</div> |
| + |
| +{{$curBill := billForMonth .Economics.Bills (periodMonth .Economics.Period)}} |
| +{{if $curBill}} |
| +{{/* "Counts as CARLOS" checkbox default (Paul's rule, brief step 3): a |
| + scenario with an EMPTY bill_services counts EVERY line on the bill β |
| + an old scenario, or one nobody has touched, must not silently zero the |
| + reconciliation. Once anything is unchecked, bill_services carries |
| + exactly the checked set. billServiceChecked (server.go) and the inline |
| + script's JS twin of the same name are the ONE place this rule is |
| + encoded β nowhere else re-derives it. */}} |
| +{{$billChecked := sumBillServicesChecked $curBill.Services .Economics.Scenario.BillServices}} |
| +{{$delta := sub $billChecked $modelTotal}} |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>Bill service ({{$curBill.Month}}, {{$curBill.Source}})</th><th>USD</th><th>Counts as CARLOS</th></tr> |
| + {{range $svc, $amt := $curBill.Services}} |
| + <tr> |
| + <td>{{$svc}}</td> |
| + <td>{{usd $amt}}</td> |
| + <td><input type="checkbox" class="bill-service-checkbox" data-service="{{$svc}}" data-amount="{{$amt}}"{{if billServiceChecked $.Economics.Scenario.BillServices $svc}} checked{{end}}></td> |
| + </tr> |
| + {{end}} |
| + <tr><th>Bill total (counted)</th><th id="bill-total-checked">{{usd $billChecked}}</th><th></th></tr> |
| +</table> |
| +</div> |
| +<p>Delta, bill minus model: <strong id="breakdown-delta">{{usd $delta}}</strong></p> |
| +{{else}} |
| +<p class="small">No bill on file for {{periodMonth .Economics.Period}} β enter one manually.</p> |
| +<form method="post" action="/fleet/economics/bill"> |
| + <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| + <div class="knob-row"> |
| + <label for="bill-month">Month</label> |
| + <input type="text" id="bill-month" name="month" pattern="\d{4}-\d{2}" value="{{periodMonth .Economics.Period}}" required> |
| + </div> |
| + <div class="knob-row"> |
| + <label for="bill-total">Total USD</label> |
| + <input type="number" step="0.01" min="0" id="bill-total" name="total" required> |
| + </div> |
| + <div class="knob-row"> |
| + <label for="bill-lines">Services, one "Service=USD" per line</label> |
| + </div> |
| + <p><textarea id="bill-lines" name="lines" rows="6" cols="48" placeholder="EC2=120.50 S3=42.10"></textarea></p> |
| + <button type="submit">Save bill</button> |
| +</form> |
| +{{end}} |
| + |
| +<details class="reveal" id="knob-drawer"> |
| +<summary>Pricing knobs</summary> |
| + |
| +<h3>Rate card — boxes</h3> |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>Box</th><th>$/hour</th><th>Cores</th><th>Mem GB</th><th>EBS GB</th></tr> |
| + {{range $boxKey, $spec := .Economics.Scenario.RateCard.Boxes}} |
| + <tr> |
| + <td>{{if eq $boxKey ""}}default{{else}}{{$boxKey}}{{end}}</td> |
| + <td><input type="number" step="0.0001" min="0" data-box-key="{{$boxKey}}" data-field="hour_usd" value="{{$spec.HourUSD}}"></td> |
| + <td><input type="number" step="0.1" min="0" data-box-key="{{$boxKey}}" data-field="cores" value="{{$spec.Cores}}"></td> |
| + <td><input type="number" step="0.1" min="0" data-box-key="{{$boxKey}}" data-field="mem_gb" value="{{$spec.MemGB}}"></td> |
| + <td><input type="number" step="1" min="0" data-box-key="{{$boxKey}}" data-field="ebs_gb" value="{{$spec.EBSGB}}"></td> |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| + |
| +<h3>Rate card — storage & transfer</h3> |
| +<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> |
| +<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> |
| +<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> |
| +<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> |
| + |
| +<h3>Overheads</h3> |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>Line item</th><th>$/month</th></tr> |
| + {{range $i, $li := .Economics.Scenario.RateCard.Overheads}} |
| + <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> |
| + {{end}} |
| +</table> |
| +</div> |
| + |
| +<h3>Allocation</h3> |
| +<div class="knob-row"> |
| + <label>Policy</label> |
| + <label><input type="radio" name="alloc-policy" value="cpu"{{if eq .Economics.Scenario.Allocation.Policy "cpu"}} checked{{end}}> CPU</label> |
| + <label><input type="radio" name="alloc-policy" value="mem"{{if eq .Economics.Scenario.Allocation.Policy "mem"}} checked{{end}}> Memory</label> |
| + <label><input type="radio" name="alloc-policy" value="blend"{{if eq .Economics.Scenario.Allocation.Policy "blend"}} checked{{end}}> Blend</label> |
| +</div> |
| +<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> |
| + |
| +<h3>Mode & margin</h3> |
| +<div class="knob-row"> |
| + <label>Pricing mode</label> |
| + <label><input type="radio" name="mode" value="cost-plus"{{if eq .Economics.Scenario.Mode "cost-plus"}} checked{{end}}> Cost-plus</label> |
| + <label><input type="radio" name="mode" value="rate-sheet"{{if eq .Economics.Scenario.Mode "rate-sheet"}} checked{{end}}> Rate sheet</label> |
| +</div> |
| +<div class="knob-row"> |
| + <label for="knob-margin">Margin (cost-plus)</label> |
| + <input type="range" id="knob-margin" min="0" max="0.95" step="0.01" value="{{.Economics.Scenario.Margin}}"> |
| + <span id="margin-multiple">{{marginMultiple .Economics.Scenario.Margin}}</span> |
| +</div> |
| + |
| +<h3>Rate sheet</h3> |
| +<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> |
| +<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> |
| +<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> |
| +<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> |
| + |
| +<h3>Free tier</h3> |
| +<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> |
| +<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> |
| +<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> |
| +<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> |
| + |
| +<h3>Save as scenario</h3> |
| +<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.</p> |
| +<form method="post" action="/fleet/economics/scenarios"> |
| + <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| + <input type="hidden" name="scenario" id="scenario-json-field" value="{{scenarioJSON .Economics.Scenario}}"> |
| + <div class="knob-row"> |
| + <label for="scenario-name">Name</label> |
| + <input type="text" id="scenario-name" name="name" pattern="[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?" required> |
| + </div> |
| + <label><input type="checkbox" name="activate" value="1"> Activate</label> |
| + <button type="submit">Save scenario</button> |
| +</form> |
| +</details> |
| +</div> |
| + |
| +<script type="application/json" id="econ-data">{{.Economics.DataJSON}}</script> |
| +<script> |
| +(function () { |
| + "use strict"; |
| + // The JSON payload script tag is written just above, IN SOURCE ORDER, |
| + // deliberately before this script: a browser parses and inserts each |
| + // <script> as it's encountered, so by the time this one executes, |
| + // #econ-data is already in the DOM for getElementById to find. |
| + var dataEl = document.getElementById("econ-data"); |
| + if (!dataEl) { return; } |
| + var payload; |
| + try { payload = JSON.parse(dataEl.textContent); } catch (e) { return; } |
| + |
| + // HoursPerMonth mirrors internal/economics/compute.go's exported constant |
| + // of the same name β the proration every hour-denominated rate uses to |
| + // read against a GB-month figure. |
| + var HOURS_PER_MONTH = 730; |
| + |
| + function numVal(id) { |
| + var el = document.getElementById(id); |
| + if (!el) { return 0; } |
| + var v = parseFloat(el.value); |
| + return isNaN(v) ? 0 : v; |
| + } |
| + function radioVal(name) { |
| + var el = document.querySelector('input[name="' + name + '"]:checked'); |
| + return el ? el.value : ""; |
| + } |
| + function setText(id, text) { |
| + var el = document.getElementById(id); |
| + if (el) { el.textContent = text; } |
| + } |
| + function setCellText(row, sel, text) { |
| + var el = row.querySelector(sel); |
| + if (el) { el.textContent = text; } |
| + } |
| + |
| + // usd/pctf/marginMultiple are the JS twins of the same-named template |
| + // funcs (server.go) β same rounding, same sign handling, so a knob change |
| + // never disagrees with the page's own initial render in HOW a number is |
| + // shown, only in what the number IS. |
| + function usd(v) { |
| + if (isNaN(v) || !isFinite(v)) { return "β"; } |
| + var neg = v < 0; |
| + if (neg) { v = -v; } |
| + var s = "$" + v.toFixed(2); |
| + return neg ? "-" + s : s; |
| + } |
| + function pctf(v) { return Math.round(v * 100) + "%"; } |
| + function marginMultiple(margin) { |
| + if (margin < 0 || margin >= 1) { return "β"; } |
| + return (1 / (1 - margin)).toFixed(1) + "Γ"; |
| + } |
| + |
| + // getBoxSpec mirrors AllocateBox's own box-then-default lookup |
| + // (compute.go: rc.Boxes[b.Box], falling back to rc.Boxes[""]). |
| + function getBoxSpec(rateCard, box) { |
| + var boxes = rateCard.boxes || {}; |
| + return boxes[box] || boxes[""] || {hour_usd: 0, cores: 0, mem_gb: 0, ebs_gb: 0}; |
| + } |
| + |
| + // billable mirrors compute.go's billable: a meter reading minus its free- |
| + // tier allowance, floored at zero. |
| + function billable(meters, meter, allowance) { |
| + var v = (meters[meter] || 0) - allowance; |
| + return v < 0 ? 0 : v; |
| + } |
| + |
| + // allocateBox is economics.AllocateBox transliterated: same three |
| + // returns (perAccount, idleUSD, totalUSD), same policy switch, same |
| + // early return for a box with zero metered hours (100% idle, nothing to |
| + // allocate β AllocateBox's own comment on that branch applies here |
| + // unchanged). |
| + function allocateBox(rateCard, allocation, boxLoad) { |
| + var spec = getBoxSpec(rateCard, boxLoad.box); |
| + var totalUSD = spec.hour_usd * boxLoad.hours + spec.ebs_gb * rateCard.ebs_gb_month_usd * (boxLoad.hours / HOURS_PER_MONTH); |
| + var perAccount = {}; |
| + if (boxLoad.hours <= 0) { |
| + return {perAccount: perAccount, idleUSD: totalUSD, totalUSD: totalUSD}; |
| + } |
| + var allocated = 0; |
| + var byAccount = boxLoad.by_account || {}; |
| + Object.keys(byAccount).forEach(function (acct) { |
| + var load = byAccount[acct]; |
| + var cpuShare = 0, memShare = 0; |
| + if (spec.cores > 0) { cpuShare = load.cpu_core_hours / (spec.cores * boxLoad.hours); } |
| + if (spec.mem_gb > 0) { memShare = load.mem_gb_hours / (spec.mem_gb * boxLoad.hours); } |
| + var share; |
| + if (allocation.policy === "cpu") { share = cpuShare; } |
| + else if (allocation.policy === "mem") { share = memShare; } |
| + else { share = allocation.cpu_weight * cpuShare + (1 - allocation.cpu_weight) * memShare; } |
| + var usdShare = share * totalUSD; |
| + perAccount[acct] = usdShare; |
| + allocated += usdShare; |
| + }); |
| + var idleUSD = totalUSD - allocated; |
| + if (idleUSD < 0) { idleUSD = 0; } |
| + return {perAccount: perAccount, idleUSD: idleUSD, totalUSD: totalUSD}; |
| + } |
| + |
| + // priceAccount is economics.PriceAccount transliterated: same cost/ |
| + // revenue/margin/freeTierPos formulas, same cost-plus vs rate-sheet |
| + // branch on sc.mode. |
| + function priceAccount(sc, account, meters, allocatedUSD) { |
| + var rc = sc.rate_card; |
| + var cost = allocatedUSD + |
| + (meters.egress_gb || 0) * rc.egress_gb_usd + |
| + (meters.requests_1k || 0) * rc.requests_per_1k_usd + |
| + (meters.storage_gb_month || 0) * rc.storage_gb_month_usd; |
| + |
| + var ft = sc.free_tier; |
| + var bEgress = billable(meters, "egress_gb", ft.egress_gb); |
| + var bStorage = billable(meters, "storage_gb_month", ft.storage_gb); |
| + var bApps = billable(meters, "app_months", ft.apps); |
| + var bMem = billable(meters, "mem_gb_hours", ft.mem_gb_hours); |
| + var computeFrac = 1.0; |
| + var m = meters.mem_gb_hours || 0; |
| + if (m > 0) { computeFrac = bMem / m; } |
| + |
| + var revenue; |
| + if (sc.mode === "rate-sheet") { |
| + revenue = bApps * sc.sheet.app_month_usd + |
| + bEgress * sc.sheet.egress_gb_usd + |
| + bStorage * sc.sheet.storage_gb_month_usd + |
| + (meters.instance_hours || 0) * sc.sheet.instance_hour_usd; |
| + } else { // cost-plus |
| + var billableCost = allocatedUSD * computeFrac + |
| + bEgress * rc.egress_gb_usd + |
| + (meters.requests_1k || 0) * rc.requests_per_1k_usd + |
| + bStorage * rc.storage_gb_month_usd; |
| + revenue = billableCost / (1 - sc.margin); |
| + } |
| + |
| + var margin = 0; |
| + if (revenue > 0) { margin = (revenue - cost) / revenue; } |
| + |
| + var anyUsage = false; |
| + Object.keys(meters).forEach(function (k) { if (meters[k] > 0) { anyUsage = true; } }); |
| + var clipped = bEgress < (meters.egress_gb || 0) || |
| + bStorage < (meters.storage_gb_month || 0) || |
| + bApps < (meters.app_months || 0) || |
| + bMem < (meters.mem_gb_hours || 0); |
| + var pos = "paying"; |
| + if (revenue === 0 && anyUsage) { pos = "inside"; } |
| + else if (clipped) { pos = "partial"; } |
| + |
| + return {account: account, costUSD: cost, revenueUSD: revenue, marginFrac: margin, freeTierPos: pos}; |
| + } |
| + |
| + // overheadUSD mirrors the server.go template func of the same name: the |
| + // rate card's fixed monthly line items, prorated over the period by |
| + // periodDays, not coveredDays (see that func's doc comment for why). |
| + function overheadUSD(sc, periodDays) { |
| + var monthly = 0; |
| + (sc.rate_card.overheads || []).forEach(function (li) { monthly += li.usd_month; }); |
| + return monthly * periodDays * 24 / HOURS_PER_MONTH; |
| + } |
| + |
| + // sumMeter mirrors the server.go template func: one named meter, summed |
| + // across every account in the payload's "meters" map. |
| + function sumMeter(meters, meter) { |
| + var sum = 0; |
| + Object.keys(meters).forEach(function (a) { sum += (meters[a][meter] || 0); }); |
| + return sum; |
| + } |
| + |
| + // billServiceChecked is the SAME "counts as CARLOS" default rule as the |
| + // server.go template func of the same name β see economics.html's |
| + // template comment by the bill table for the reasoning, one sentence of |
| + // which applies here unchanged: keep it in exactly one place per side |
| + // (Go, JS), never re-derive it. |
| + function billServiceChecked(billServices, service) { |
| + if (!billServices || billServices.length === 0) { return true; } |
| + return billServices.indexOf(service) !== -1; |
| + } |
| + |
| + // readScenarioFromDOM clones the payload's starting scenario (so any |
| + // field this page has no knob for β Schema, Name β survives untouched) |
| + // and overwrites every field the drawer DOES expose from its current |
| + // input values, plus bill_services from the checkbox row. Reading the |
| + // WHOLE scenario back out on every change, rather than patching one |
| + // field, keeps this the single place that must agree with every input's |
| + // id/data-* attribute β the same "one source of truth" reasoning as |
| + // billServiceChecked above. |
| + function readScenarioFromDOM(base) { |
| + var sc = JSON.parse(JSON.stringify(base)); |
| + sc.rate_card.boxes = sc.rate_card.boxes || {}; |
| + document.querySelectorAll("[data-box-key]").forEach(function (el) { |
| + var key = el.getAttribute("data-box-key"); |
| + var field = el.getAttribute("data-field"); |
| + if (!sc.rate_card.boxes[key]) { sc.rate_card.boxes[key] = {}; } |
| + sc.rate_card.boxes[key][field] = parseFloat(el.value) || 0; |
| + }); |
| + sc.rate_card.ebs_gb_month_usd = numVal("knob-ebs-gb-month-usd"); |
| + sc.rate_card.storage_gb_month_usd = numVal("knob-storage-gb-month-usd"); |
| + sc.rate_card.requests_per_1k_usd = numVal("knob-requests-per-1k-usd"); |
| + sc.rate_card.egress_gb_usd = numVal("knob-egress-gb-usd"); |
| + document.querySelectorAll(".knob-overhead").forEach(function (el) { |
| + var i = parseInt(el.getAttribute("data-index"), 10); |
| + if (sc.rate_card.overheads && sc.rate_card.overheads[i]) { |
| + sc.rate_card.overheads[i].usd_month = parseFloat(el.value) || 0; |
| + } |
| + }); |
| + sc.allocation.policy = radioVal("alloc-policy") || sc.allocation.policy; |
| + sc.allocation.cpu_weight = numVal("knob-cpu-weight"); |
| + sc.mode = radioVal("mode") || sc.mode; |
| + sc.margin = numVal("knob-margin"); |
| + sc.sheet.app_month_usd = numVal("knob-sheet-app-month-usd"); |
| + sc.sheet.egress_gb_usd = numVal("knob-sheet-egress-gb-usd"); |
| + sc.sheet.storage_gb_month_usd = numVal("knob-sheet-storage-gb-month-usd"); |
| + sc.sheet.instance_hour_usd = numVal("knob-sheet-instance-hour-usd"); |
| + sc.free_tier.apps = numVal("knob-ft-apps"); |
| + sc.free_tier.egress_gb = numVal("knob-ft-egress-gb"); |
| + sc.free_tier.mem_gb_hours = numVal("knob-ft-mem-gb-hours"); |
| + sc.free_tier.storage_gb = numVal("knob-ft-storage-gb"); |
| + |
| + var boxes = document.querySelectorAll(".bill-service-checkbox"); |
| + var checked = []; |
| + boxes.forEach(function (el) { if (el.checked) { checked.push(el.getAttribute("data-service")); } }); |
| + // Empty means "every service counts" (billServiceChecked's default) β |
| + // so leaving every box checked writes [] back, not an equivalent, |
| + // brittle explicit list of every service name on the bill. |
| + sc.bill_services = (boxes.length > 0 && checked.length < boxes.length) ? checked : []; |
| + return sc; |
| + } |
| + |
| + function recompute() { |
| + var sc = readScenarioFromDOM(payload.scenario); |
| + setText("margin-multiple", marginMultiple(sc.margin)); |
| + |
| + var boxResults = {}; |
| + var totalAllocated = {}; |
| + Object.keys(payload.boxLoads).forEach(function (box) { |
| + var alloc = allocateBox(sc.rate_card, sc.allocation, payload.boxLoads[box]); |
| + boxResults[box] = alloc; |
| + Object.keys(alloc.perAccount).forEach(function (acct) { |
| + totalAllocated[acct] = (totalAllocated[acct] || 0) + alloc.perAccount[acct]; |
| + }); |
| + }); |
| + |
| + var acctSet = {}; |
| + Object.keys(payload.meters).forEach(function (a) { acctSet[a] = true; }); |
| + Object.keys(totalAllocated).forEach(function (a) { acctSet[a] = true; }); |
| + var accounts = {}; |
| + Object.keys(acctSet).forEach(function (acct) { |
| + accounts[acct] = priceAccount(sc, acct, payload.meters[acct] || {}, totalAllocated[acct] || 0); |
| + }); |
| + |
| + var revenueTotal = 0, acctCostTotal = 0, idleTotal = 0; |
| + Object.keys(accounts).forEach(function (a) { |
| + revenueTotal += accounts[a].revenueUSD; |
| + acctCostTotal += accounts[a].costUSD; |
| + }); |
| + Object.keys(boxResults).forEach(function (b) { idleTotal += boxResults[b].idleUSD; }); |
| + var overheadTotal = overheadUSD(sc, payload.periodDays); |
| + var costTotal = acctCostTotal + idleTotal + overheadTotal; |
| + var profitTotal = revenueTotal - costTotal; |
| + var marginTotal = revenueTotal > 0 ? profitTotal / revenueTotal : 0; |
| + |
| + setText("tile-revenue", usd(revenueTotal)); |
| + setText("tile-cost", usd(costTotal)); |
| + setText("tile-profit", usd(profitTotal)); |
| + setText("tile-margin", pctf(marginTotal)); |
| + |
| + document.querySelectorAll("[data-account]").forEach(function (row) { |
| + var a = accounts[row.getAttribute("data-account")]; |
| + if (!a) { return; } |
| + setCellText(row, ".js-acct-cost", usd(a.costUSD)); |
| + setCellText(row, ".js-acct-revenue", usd(a.revenueUSD)); |
| + setCellText(row, ".js-acct-margin", pctf(a.marginFrac)); |
| + setCellText(row, ".js-acct-freetier", a.freeTierPos); |
| + }); |
| + |
| + document.querySelectorAll("[data-box]").forEach(function (row) { |
| + var box = row.getAttribute("data-box"); |
| + var load = payload.boxLoads[box]; |
| + var alloc = boxResults[box]; |
| + if (!load || !alloc) { return; } |
| + var revenue = 0; |
| + Object.keys(alloc.perAccount).forEach(function (acct) { |
| + var total = totalAllocated[acct]; |
| + if (!total) { return; } |
| + var share = alloc.perAccount[acct] / total; |
| + revenue += (accounts[acct] ? accounts[acct].revenueUSD : 0) * share; |
| + }); |
| + var util = alloc.totalUSD > 0 ? 1 - alloc.idleUSD / alloc.totalUSD : 0; |
| + setCellText(row, ".js-hours", load.hours.toFixed(2)); |
| + setCellText(row, ".js-cost", usd(alloc.totalUSD)); |
| + setCellText(row, ".js-idle", usd(alloc.idleUSD)); |
| + setCellText(row, ".js-util-pct", pctf(util)); |
| + var fill = row.querySelector(".bar-fill"); |
| + if (fill) { fill.style.width = Math.round(util * 100) + "%"; } |
| + setCellText(row, ".js-revenue", usd(revenue)); |
| + setCellText(row, ".js-profit", usd(revenue - alloc.totalUSD)); |
| + }); |
| + |
| + var ec2 = 0, ebs = 0; |
| + Object.keys(payload.boxLoads).forEach(function (box) { |
| + var load = payload.boxLoads[box]; |
| + var spec = getBoxSpec(sc.rate_card, box); |
| + ec2 += spec.hour_usd * load.hours; |
| + ebs += spec.ebs_gb * sc.rate_card.ebs_gb_month_usd * (load.hours / HOURS_PER_MONTH); |
| + }); |
| + var s3storage = sumMeter(payload.meters, "storage_gb_month") * sc.rate_card.storage_gb_month_usd; |
| + var s3requests = sumMeter(payload.meters, "requests_1k") * sc.rate_card.requests_per_1k_usd; |
| + var egress = sumMeter(payload.meters, "egress_gb") * sc.rate_card.egress_gb_usd; |
| + var modelTotal = ec2 + ebs + s3storage + s3requests + egress + overheadTotal; |
| + setText("model-ec2", usd(ec2)); |
| + setText("model-ebs", usd(ebs)); |
| + setText("model-s3-storage", usd(s3storage)); |
| + setText("model-s3-requests", usd(s3requests)); |
| + setText("model-egress", usd(egress)); |
| + setText("model-overheads", usd(overheadTotal)); |
| + setText("model-total", usd(modelTotal)); |
| + |
| + var billTotalEl = document.getElementById("bill-total-checked"); |
| + if (billTotalEl) { |
| + var billTotal = 0; |
| + document.querySelectorAll(".bill-service-checkbox").forEach(function (el) { |
| + if (el.checked) { billTotal += parseFloat(el.getAttribute("data-amount")) || 0; } |
| + }); |
| + billTotalEl.textContent = usd(billTotal); |
| + setText("breakdown-delta", usd(billTotal - modelTotal)); |
| + } |
| + |
| + var field = document.getElementById("scenario-json-field"); |
| + if (field) { field.value = JSON.stringify(sc); } |
| + } |
| + |
| + var live = document.getElementById("econ-live"); |
| + if (live) { |
| + live.addEventListener("input", recompute); |
| + live.addEventListener("change", recompute); |
| + } |
| +})(); |
| +</script> |
| +{{template "bottom" .}}{{end}} |