carlos / platform Public

console: /fleet/economics dashboard with live scenario knobs

Task 13 of the unit-economics plan: handleEconomics (handleFleetMetrics's
gate shape verbatim) serves headline tiles, per-account and per-box tables,
a model-vs-bill breakdown with per-service "counts as CARLOS" checkboxes,
a pricing-knob drawer, and a period selector, all server-rendered from
LoadEconomics. One inline <script> transliterates AllocateBox/PriceAccount
(internal/economics/compute.go) function-for-function to recompute live on
a knob change; with JS off the server numbers stand and the drawer degrades
to the save-scenario form round-trip.

economics.Scenario gains BillServices (additive, omitempty) β€” the checkbox
state round-tripped through a saved scenario, empty meaning "count every
service" by the one rule both the Go template func and its JS twin share.

internal/console/economicspage_test.go: operator gate-parity (byte-identical
404 signed-out/non-operator/revoked, matching TestFleetOperatorOnly), content
(coverage label, box label, account name, id="econ-data"), and honesty
(a box with metrics but no usage/edge day renders "unmetered").

πŸ€– Generated with Claude Code
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 88a71f37a9af2d2a59920398031b4e681e55cea4 parent 684b76b
5 files changed, +1036 −2
  • internal/console/economicspage_test.go +172 −0
  • internal/console/server.go +248 −2
  • internal/console/templates/economics.html +593 −0
  • internal/console/templates/layout.html +12 −0
  • internal/economics/scenario.go +11 −0
diff --git a/internal/console/economicspage_test.go b/internal/console/economicspage_test.go
new file mode 100644
index 000000000..8f6a4436a
--- /dev/null
+++ b/internal/console/economicspage_test.go
@@ -0,0 +1,172 @@
+package console
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/economics"
+ "github.com/carlosframework/platform/internal/metrics"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/usage"
+)
+
+// TestEconomicsOperatorOnly is TestFleetOperatorOnly's shape (server_test.go)
+// against /fleet/economics: signed-out and non-operator both get the exact
+// 404 an unrouted path gets, an operator gets 200 with the page's content,
+// and revoking sends it straight back to that same 404 β€” the gate this page
+// promises is byte-identical to every other operator-only route, never a
+// separate "you can't see this" page that would itself confirm the route
+// exists.
+func TestEconomicsOperatorOnly(t *testing.T) {
+ fx := newConsoleFixture(t)
+
+ out := newBrowser(t)
+ statusGhost, _, bodyGhost := get(t, out, fx.ts.URL+"/this-route-does-not-exist")
+ if statusGhost != http.StatusNotFound {
+ t.Fatalf("sanity: a missing route = %d, want 404", statusGhost)
+ }
+ if status, _, body := get(t, out, fx.ts.URL+"/fleet/economics"); status != http.StatusNotFound || body != bodyGhost {
+ t.Fatalf("signed-out /fleet/economics = %d %q, want the same 404 as a missing route (%d %q)", status, body, statusGhost, bodyGhost)
+ }
+
+ // Signed in and owns kass, but is not an operator: still the same 404.
+ owner := signInPaul(t, fx)
+ if status, _, body := get(t, owner, fx.ts.URL+"/fleet/economics"); status != http.StatusNotFound || body != bodyGhost {
+ t.Fatalf("non-operator /fleet/economics = %d %q, want the same 404 as a missing route", status, body)
+ }
+
+ if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+ status, _, body := get(t, owner, fx.ts.URL+"/fleet/economics")
+ if status != 200 || !strings.Contains(body, "id=\"econ-data\"") {
+ t.Errorf("operator economics view: %d %s", status, body)
+ }
+
+ // Revoked: back to the exact same 404.
+ if err := fx.st.SetOperator("paul@keymail.dev", false); err != nil {
+ t.Fatal(err)
+ }
+ if status, _, body := get(t, owner, fx.ts.URL+"/fleet/economics"); status != http.StatusNotFound || body != bodyGhost {
+ t.Fatalf("revoked operator /fleet/economics = %d %q, want the same 404", status, body)
+ }
+}
+
+// seedEconomicsGolden reuses TestLoadEconomicsGolden's seeding
+// (economicsview_test.go): one box ("box1"), one route, an hour of cpu/mem
+// samples, and matching edge-traffic for the same route's account β€” enough
+// for LoadEconomics to price a real account and a real box under the
+// default scenario.
+func seedEconomicsGolden(t *testing.T, fx *consoleFixture) {
+ t.Helper()
+ reg, err := registry.Open(fx.regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{Host: "a.example.com", Kind: "instance",
+ Status: "ready", App: fixtureApp, Version: "v1"}); err != nil {
+ t.Fatal(err)
+ }
+
+ date := usage.DayOf(time.Now())
+ roll := metrics.DayRollup{V: 1, Box: "box1", Date: date, Hours: map[string]metrics.HourAgg{
+ "09": {Units: map[string]map[string]metrics.Agg{
+ "carlos-app@a.example.com.service": {
+ "cpu": {Avg: 0.5, N: 60},
+ "mem_bytes": {Avg: 1e9, N: 60},
+ },
+ }},
+ }}
+ putStoreJSON(t, fx.rel, metrics.DayKey("box1", date), roll)
+
+ putStoreJSON(t, fx.rel, usage.EdgeDayKey("box1", date), usage.EdgeDay{
+ V: 1, Box: "box1", Date: date,
+ Hours: map[string]map[string]usage.EdgeRow{"09": {
+ "a.example.com": {Account: fixtureAcct, App: fixtureApp, Requests: 4000, BytesOut: 2e9},
+ }},
+ })
+}
+
+// TestEconomicsPageContent proves the page renders real numbers from
+// LoadEconomics β€” not just chrome: the coverage label, the box's own label,
+// an account name, and the embedded payload every id="econ-data" the
+// content test above already looked for by string is the SAME script tag
+// this test confirms carries actual JSON.
+func TestEconomicsPageContent(t *testing.T) {
+ fx := newConsoleFixture(t)
+ seedEconomicsGolden(t, fx)
+
+ c := signInPaul(t, fx)
+ if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+
+ status, _, body := get(t, c, fx.ts.URL+"/fleet/economics")
+ if status != 200 {
+ t.Fatalf("/fleet/economics = %d, want 200:\n%s", status, body)
+ }
+ for _, want := range []string{"days metered", "box1", fixtureAcct, `id="econ-data"`} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("/fleet/economics missing %q in:\n%s", want, body)
+ }
+ }
+}
+
+// TestEconomicsPageHonestUnmetered: a box with a metrics day rollup but no
+// usage/edge day ever written must render "unmetered" for that box's egress
+// β€” never a blank or a fabricated zero. See BoxEconomics.EgressMetered and
+// usageSweep.EgressMetered's doc comments (economicspass.go): a box simply
+// absent from the edge-boxes listing is unmetered, not "confirmed no
+// egress".
+func TestEconomicsPageHonestUnmetered(t *testing.T) {
+ fx := newConsoleFixture(t)
+ reg, err := registry.Open(fx.regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{Host: "a.example.com", Kind: "instance",
+ Status: "ready", App: fixtureApp, Version: "v1"}); err != nil {
+ t.Fatal(err)
+ }
+
+ date := usage.DayOf(time.Now())
+ roll := metrics.DayRollup{V: 1, Box: "darkbox", Date: date, Hours: map[string]metrics.HourAgg{
+ "09": {Units: map[string]map[string]metrics.Agg{
+ "carlos-app@a.example.com.service": {
+ "cpu": {Avg: 0.5, N: 60},
+ "mem_bytes": {Avg: 1e9, N: 60},
+ },
+ }},
+ }}
+ putStoreJSON(t, fx.rel, metrics.DayKey("darkbox", date), roll)
+ // Deliberately no usage.EdgeDayKey("darkbox", date) object: darkbox has
+ // metrics but has never written an edge day.
+
+ c := signInPaul(t, fx)
+ if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+ status, _, body := get(t, c, fx.ts.URL+"/fleet/economics")
+ if status != 200 {
+ t.Fatalf("/fleet/economics = %d, want 200:\n%s", status, body)
+ }
+ if !strings.Contains(body, "darkbox") {
+ t.Fatalf("/fleet/economics missing box label %q in:\n%s", "darkbox", body)
+ }
+ if !strings.Contains(body, "unmetered") {
+ t.Fatalf("/fleet/economics missing %q for a box with no usage/edge day:\n%s", "unmetered", body)
+ }
+}
+
+// economics.DefaultScenario is used implicitly by every test above (no
+// active.json seeded) β€” sanity-checked once here so a future change to it
+// that breaks Validate fails loudly at the source, not as a mystery 500 on
+// this page.
+func TestEconomicsDefaultScenarioValid(t *testing.T) {
+ sc := economics.DefaultScenario()
+ if err := sc.Validate(); err != nil {
+ t.Fatalf("DefaultScenario: %v", err)
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index d102a600e..454ee4ab0 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -10,10 +10,12 @@ import (
"crypto/ed25519"
"crypto/subtle"
"embed"
+ "encoding/json"
"errors"
"fmt"
"html/template"
"log"
+ "math"
"net"
"net/http"
"net/url"
@@ -27,11 +29,13 @@ import (
"github.com/carlosframework/platform/internal/applog"
"github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/economics"
"github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/status"
+ "github.com/carlosframework/platform/internal/usage"
)
//go:embed templates/*.html
@@ -155,6 +159,204 @@ var templates = template.Must(template.New("").Funcs(template.FuncMap{
// parse yields "", which the template's own {{if}} already handles as
// "not placed" β€” the same reading appPlacement itself takes.
"fleetName": func(target string) string { f, _ := registry.FleetPlacement(target); return f },
+
+ // ---- /fleet/economics (Task 13): server-side arithmetic mirroring
+ // internal/economics/compute.go, so the page's initial render needs no
+ // JS to show real numbers β€” see economics.html's inline script for the
+ // live-recompute twin of every function below (same names, JS-cased).
+
+ // usd renders a signed dollar figure β€” negative (a box or account
+ // running at a loss under the active scenario) as "-$N.NN", never a
+ // bare minus glued to the sign the reader expects at the front.
+ "usd": func(v float64) string {
+ if math.IsNaN(v) || math.IsInf(v, 0) {
+ return "β€”"
+ }
+ if v < 0 {
+ return fmt.Sprintf("-$%.2f", -v)
+ }
+ return fmt.Sprintf("$%.2f", v)
+ },
+ // pctf is pct (above) for a plain, never-nil float64 β€” margin and
+ // utilization fractions (economics.AccountEconomics.MarginFrac,
+ // BoxEconomics.Utilization) are always populated, never a *float64
+ // "no reading yet" case pct exists to dash out.
+ "pctf": func(v float64) string { return fmt.Sprintf("%.0f%%", v*100) },
+ // marginMultiple renders cost-plus's margin knob the way an operator
+ // thinks about markup, not the accounting fraction it's stored as:
+ // multiple = 1/(1-margin), so the default 0.8 reads "5.0Γ— cost" per the
+ // brief. Out of Validate's legal [0,1) range is "β€”", never a divide by
+ // zero or a negative multiple.
+ "marginMultiple": func(margin float64) string {
+ if margin < 0 || margin >= 1 {
+ return "β€”"
+ }
+ return fmt.Sprintf("%.1fΓ—", 1/(1-margin))
+ },
+ "add": func(vs ...float64) float64 {
+ var sum float64
+ for _, v := range vs {
+ sum += v
+ }
+ return sum
+ },
+ "sub": func(a, b float64) float64 { return a - b },
+ "mul": func(a, b float64) float64 { return a * b },
+ // marginOf is economics.PriceAccount's margin formula
+ // ((revenue-cost)/revenue, 0 when revenue is 0), applied to the
+ // dashboard's period TOTALS rather than one account β€” the "achieved
+ // margin" headline tile.
+ "marginOf": func(revenue, cost float64) float64 {
+ if revenue <= 0 {
+ return 0
+ }
+ return (revenue - cost) / revenue
+ },
+ // sumAcctCost/sumAcctRevenue/sumBoxIdle fold EconomicsView.Accounts /
+ // .Boxes into the headline tiles' period totals: total cost is every
+ // account's allocated+metered cost, PLUS every box's unallocated idle
+ // headroom (never smeared across tenants β€” economics.AllocateBox's own
+ // doc comment), PLUS fixed overheads (overheadUSD below); total revenue
+ // is simply the sum of what every account would owe.
+ "sumAcctCost": func(accts []economics.AccountEconomics) float64 {
+ var sum float64
+ for _, a := range accts {
+ sum += a.CostUSD
+ }
+ return sum
+ },
+ "sumAcctRevenue": func(accts []economics.AccountEconomics) float64 {
+ var sum float64
+ for _, a := range accts {
+ sum += a.RevenueUSD
+ }
+ return sum
+ },
+ "sumBoxIdle": func(boxes []BoxEconomics) float64 {
+ var sum float64
+ for _, b := range boxes {
+ sum += b.IdleUSD
+ }
+ return sum
+ },
+ // overheadUSD prorates the rate card's fixed monthly LineItems
+ // (domains, DNS, ...) over the period being viewed, by the same
+ // hours-over-HoursPerMonth proration economics.AllocateBox uses for a
+ // box's EBS line β€” periodDays rather than CoveredDays, deliberately:
+ // overhead is a standing monthly bill that runs whether or not usage
+ // was fully sampled, so it prorates by calendar days in the period, not
+ // by how much of it the sweep actually covered.
+ "overheadUSD": func(sc economics.Scenario, periodDays int) float64 {
+ var monthly float64
+ for _, li := range sc.RateCard.Overheads {
+ monthly += li.USDMonth
+ }
+ return monthly * float64(periodDays) * 24 / economics.HoursPerMonth
+ },
+ // sumMeter totals one named meter (usage.Meter* β€” "egress_gb",
+ // "requests_1k", "storage_gb_month", ...) across every account in
+ // EconomicsView.Meters, for the model-side breakdown's S3/egress lines.
+ "sumMeter": func(meters map[string]map[string]float64, meter string) float64 {
+ var sum float64
+ for _, mm := range meters {
+ sum += mm[meter]
+ }
+ return sum
+ },
+ // sumBoxesEC2/sumBoxesEBS split each box's total cost (BoxEconomics
+ // carries the sum, not the two components) back into the rate card's
+ // two box-cost lines for the model-side breakdown, using the same
+ // box-then-default spec lookup economics.AllocateBox performs.
+ "sumBoxesEC2": func(boxes []BoxEconomics, rc economics.RateCard) float64 {
+ var sum float64
+ for _, b := range boxes {
+ spec, ok := rc.Boxes[b.Box]
+ if !ok {
+ spec = rc.Boxes[""]
+ }
+ sum += spec.HourUSD * b.Hours
+ }
+ return sum
+ },
+ "sumBoxesEBS": func(boxes []BoxEconomics, rc economics.RateCard) float64 {
+ var sum float64
+ for _, b := range boxes {
+ spec, ok := rc.Boxes[b.Box]
+ if !ok {
+ spec = rc.Boxes[""]
+ }
+ sum += spec.EBSGB * rc.EBSGBMonthUSD * (b.Hours / economics.HoursPerMonth)
+ }
+ return sum
+ },
+ // periodMonth/billForMonth resolve which of EconomicsView.Bills (this
+ // month + last, always both if present regardless of period β€”
+ // loadRecentBills's doc comment) corresponds to the period on screen,
+ // so the breakdown reconciles against the RIGHT bill: "last" looks at
+ // last calendar month's, "mtd"/"7d" both look at this one.
+ "periodMonth": func(period string) string {
+ now := time.Now().UTC()
+ if period == "last" {
+ firstOfThisMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
+ return usage.MonthOf(firstOfThisMonth.AddDate(0, -1, 0))
+ }
+ return usage.MonthOf(now)
+ },
+ "billForMonth": func(bills []economics.Bill, month string) *economics.Bill {
+ for i := range bills {
+ if bills[i].Month == month {
+ return &bills[i]
+ }
+ }
+ return nil
+ },
+ // billServiceChecked is the "counts as CARLOS" default rule, ONE place
+ // for both the checkbox render and the delta sum below: an EMPTY
+ // Scenario.BillServices counts every service (an old scenario, or one
+ // nobody has ever unchecked anything on), a NON-empty one counts only
+ // what's named. The inline JS mirrors this exact rule (billServiceChecked)
+ // when a knob or checkbox changes.
+ "billServiceChecked": func(billServices []string, service string) bool {
+ if len(billServices) == 0 {
+ return true
+ }
+ for _, s := range billServices {
+ if s == service {
+ return true
+ }
+ }
+ return false
+ },
+ // sumBillServicesChecked totals a bill's Services map, restricted to the
+ // ones billServiceChecked keeps β€” the breakdown's bill-side total.
+ "sumBillServicesChecked": func(services map[string]float64, billServices []string) float64 {
+ var sum float64
+ for name, v := range services {
+ if len(billServices) == 0 {
+ sum += v
+ continue
+ }
+ for _, s := range billServices {
+ if s == name {
+ sum += v
+ break
+ }
+ }
+ }
+ return sum
+ },
+ // scenarioJSON marshals one economics.Scenario for the save-scenario
+ // form's hidden field β€” the value the door at /fleet/economics/scenarios
+ // parses back (handleEconomicsScenarioSave). html/template still
+ // attribute-escapes this like any other string substitution; that's
+ // correct and wanted here (a form value), unlike DataJSON's <script>
+ // embed which needs the OPPOSITE (unescaped) treatment β€” see
+ // economicsPayload's doc comment for why that one stays a raw
+ // template.JS.
+ "scenarioJSON": func(sc economics.Scenario) (string, error) {
+ raw, err := json.Marshal(sc)
+ return string(raw), err
+ },
}).ParseFS(tmplFS, "templates/*.html"))
const (
@@ -379,9 +581,11 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("POST /auth/signout", s.handleSignout)
s.mux.HandleFunc("GET /fleet", s.handleFleet)
s.mux.HandleFunc("GET /fleet/metrics", s.handleFleetMetrics)
+ s.mux.HandleFunc("GET /fleet/economics", s.handleEconomics)
// Unit economics (Task 12): scenario/bill writes, operator-only, same
- // gate shape as handleFleet above. GET /fleet/economics itself is
- // Task 13. Registered WITHOUT a method prefix, deliberately: a
+ // gate shape as handleFleet above. GET /fleet/economics is handled
+ // above by handleEconomics (Task 13). Registered WITHOUT a method
+ // prefix, deliberately: a
// "POST /path" pattern answers a GET with 405 + Allow, which leaks that
// the route exists to someone who merely guessed the URL β€” the exact
// probe handleFleet's 404-to-everyone gate exists to prevent. Each
@@ -612,6 +816,13 @@ type page struct {
Metrics []BoxMetricsView
MetricAlerts []AlertRow
+ // /fleet/economics: the operator unit-economics dashboard (Task 13),
+ // from LoadEconomics (economicsview.go). nil only on a rendering path
+ // this handler never takes (handleEconomics always loads one before
+ // rendering) β€” every field on it is itself nil-safe, matching Metrics
+ // above.
+ Economics *EconomicsView
+
// App page, "Resources": this app's OWN instances' unit readings (Task
// 12), one row per route host. Nil renders no section at all β€” metrics
// are decoration on this page and must never be the reason it fails.
@@ -1444,6 +1655,41 @@ func (s *Server) handleFleetMetrics(w http.ResponseWriter, r *http.Request) {
s.render(w, http.StatusOK, "metrics", page{Title: "Fleet metrics", Address: addr, CSRF: raw, Metrics: boxes, MetricAlerts: alerts})
}
+// handleEconomics serves the operator unit-economics dashboard (Task 13) β€”
+// handleFleetMetrics's shape verbatim: same gate (session -> IsOperator ->
+// bare http.NotFound, so a non-operator's probe is byte-identical to a
+// missing route), same read-only LoadEconomics call keyed off the period
+// query param, same render-and-done body. Flash is threaded through because
+// this page is also where the scenario-save and bill-save POST doors
+// (economicspass.go's handleEconomicsScenarioSave/handleEconomicsBillSave)
+// redirect back to.
+func (s *Server) handleEconomics(w http.ResponseWriter, r *http.Request) {
+ addr, raw, ok := s.session(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ isOp, err := s.store.IsOperator(addr)
+ if err != nil {
+ log.Printf("console: economics: is-operator: %v", err)
+ http.NotFound(w, r)
+ return
+ }
+ if !isOp {
+ http.NotFound(w, r)
+ return
+ }
+ view, err := s.LoadEconomics(r.Context(), r.URL.Query().Get("period"))
+ if err != nil {
+ log.Printf("console: economics: %v", err)
+ s.renderMessage(w, r, http.StatusInternalServerError, "Unavailable", "Economics could not be read.")
+ return
+ }
+ p := page{Title: "Fleet economics", Address: addr, CSRF: raw, Economics: view}
+ p.Flash, p.FlashErr = s.takeFlash(w, r)
+ s.render(w, http.StatusOK, "economics", p)
+}
+
// ---- app pages --------------------------------------------------------------
// handleAccountHome is the account's app list and switcher β€” the page /
diff --git a/internal/console/templates/economics.html b/internal/console/templates/economics.html
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}} &middot; scenario <strong>{{.Economics.Scenario.Name}}</strong> ({{.Economics.Scenario.Mode}}) &middot; {{.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> &middot;
+ <a href="?period=last"{{if eq .Economics.Period "last"}} aria-current="page"{{end}}>Last month</a> &middot;
+ <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&#10;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 &mdash; 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 &mdash; storage &amp; 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 &amp; 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}}
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html
index ebf25f568..75fafb938 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -192,6 +192,18 @@
.chip-up { background: var(--accent-soft); color: #2f7a4f; }
.chip-down { background: var(--accent-soft); color: var(--bad); }
.chips { display: flex; flex-wrap: wrap; margin: 0 0 1rem; }
+ /* /fleet/economics (Task 13): a box's utilization as a filled bar, CSS
+ width only β€” the initial render needs no JS, and the inline knob script
+ updates .bar-fill's width style in place on recompute. */
+ .bar { display: inline-block; width: 6rem; height: 0.5rem; border-radius: 999px; background: var(--accent-soft); vertical-align: middle; overflow: hidden; }
+ .bar-fill { display: block; height: 100%; background: var(--accent); }
+ /* The knob drawer: one row per rate-card number, label then control,
+ narrower than .settings-row's button-row spacing since these are all
+ text/number inputs with no submit button riding along on the same line. */
+ .knob-row { display: flex; gap: 0.6rem; align-items: center; margin: 0 0 0.5rem; flex-wrap: wrap; }
+ .knob-row label { min-width: 12rem; color: var(--muted); font-size: 0.9rem; }
+ .knob-row input[type=number], .knob-row input[type=text] { width: 8rem; }
+ .knob-row input[type=range] { width: 12rem; }
/* The app flow diagram (flow.go): the release path drawn as the pipeline it
is, with the addresses each channel feeds hanging under it.
diff --git a/internal/economics/scenario.go b/internal/economics/scenario.go
index f9406c8cb..cdc52f7d5 100644
--- a/internal/economics/scenario.go
+++ b/internal/economics/scenario.go
@@ -68,6 +68,17 @@ type Scenario struct {
Margin float64 `json:"margin"` // cost-plus: (price-cost)/price, so 0.8 = 5x cost
Sheet RateSheet `json:"sheet"`
FreeTier FreeTier `json:"free_tier"`
+ // BillServices names which of a bill's Services lines the dashboard's
+ // model-vs-bill reconciliation (Task 13) counts as CARLOS spend β€” the
+ // "counts as CARLOS" checkbox state, round-tripped through the saved
+ // scenario so a knob change survives a reload. Additive (omitempty):
+ // every scenario stored before this field existed simply has none, and
+ // per the dashboard's own default rule (documented where it reads this
+ // field) an empty list means "count every service", not "count none" β€”
+ // so an old scenario's reconciliation is unchanged by this field's
+ // absence. Schema stays 1: this is a new optional key, not a shape
+ // change existing readers must refuse.
+ BillServices []string `json:"bill_services,omitempty"`
}
// Active is economics/active.json: which scenario the dashboards load.