carlos / platform Public

console: per-account economics table with search and cursor pagination

Task 14: GET /fleet/economics/accounts.

- econlistquery.go: econAccountQuery/pageEconAccounts/econAccountsPage,
  listquery.go's shape (instanceQuery/pageInstanceRows) applied to
  economics.AccountEconomics rows. Cursor = base64 RawURL JSON
  {"account":...}, resumes strictly-after; rows re-sorted by account name
  ASC for a stable cursor order independent of EconomicsView.Accounts's own
  CostUSD-desc sort. Pos filters FreeTierPos (inside|partial|paying) with
  healthLink-style count links.
- server.go: handleEconomicsAccounts (handleEconomics's gate verbatim),
  route registered next to Task 13's; page.EconAccounts/EconQuery fields.
- economics_accounts.html: account/meter/cost/revenue/margin/free-tier
  table, search + position filter (app_instances.html's form idiom), cursor
  pager. Columns say "app-months"/"instance-hours" rather than the brief's
  "apps"/"instances" — those meters are month-fraction/hour-sum
  accumulators, never a live count (dashboard-truth rule).
- economics.html refactored: the knob drawer and the pure calc JS
  (AllocateBox/PriceAccount transliterations) extracted into shared
  `econ_knob_drawer`/`econ_maths_js` template defines, included by both
  pages — avoids a second ~300-line copy while giving the accounts page its
  own working knob-recompute (smaller recompute(): re-prices only the
  account rows on screen, still against the full boxLoads payload).
- layout.html: chip-freetier-{inside,partial,paying} chip classes.
- Tests: econlistquery_test.go (pure pagination — 120 rows, page1/page2,
  Q filter, Pos filter + counts, garbage cursor); economicspage_test.go
  gains TestEconomicsAccountsOperatorOnly (404 parity) and
  TestEconomicsAccountsPageContent (meter value + free-tier chip render).

go build/vet/test all clean (all packages), gofmt clean, GOOS=darwin
cross-compile clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a6713a8042396c45c92853baa60e6b50fb983e42 parent cb00fee
7 files changed, +766 −131
  • internal/console/econlistquery.go +164 −0
  • internal/console/econlistquery_test.go +124 −0
  • internal/console/economicspage_test.go +74 −0
  • internal/console/server.go +52 −0
  • internal/console/templates/economics.html +180 −131
  • internal/console/templates/economics_accounts.html +162 −0
  • internal/console/templates/layout.html +10 −0
diff --git a/internal/console/econlistquery.go b/internal/console/econlistquery.go
new file mode 100644
index 000000000..82dde571e
--- /dev/null
+++ b/internal/console/econlistquery.go
@@ -0,0 +1,164 @@
+// The economics accounts page's list controls (Task 14) — listquery.go's
+// shape (instanceQuery/pageInstanceRows/cursor rules), applied to a
+// []economics.AccountEconomics-backed row instead of an InstanceRow: a
+// substring match on the account, a free-tier-position filter, and an
+// opaque after-this-account cursor. See listquery.go's own package comment
+// for why filters live server-side.
+package console
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "net/url"
+ "sort"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/economics"
+)
+
+// econAccountPageSize is /fleet/economics/accounts's page length — the same
+// 50 the Running tab uses (instancePageSize).
+const econAccountPageSize = 50
+
+// econPosWords is every economics.AccountEconomics.FreeTierPos value, in the
+// order the header lists them. A new position joins PriceAccount
+// (internal/economics/compute.go), this list, and nothing else.
+var econPosWords = []string{"inside", "partial", "paying"}
+
+// econAccountRow is one priced account joined with its own meter readings —
+// EconomicsView.Accounts and EconomicsView.Meters, keyed by the same
+// account (release.RouteAccount universe).
+type econAccountRow struct {
+ Account string
+ Meters map[string]float64
+ Econ economics.AccountEconomics
+}
+
+// econAccountQuery is one request's list controls. Zero value = everything,
+// first page.
+type econAccountQuery struct {
+ Q string // substring match on account, trimmed
+ Pos string // FreeTierPos filter ("" = all)
+ Cursor string // opaque; list resumes after the account inside it
+}
+
+func parseEconAccountQuery(v url.Values) econAccountQuery {
+ return econAccountQuery{
+ Q: strings.TrimSpace(v.Get("q")),
+ Pos: econPosFromParam(v.Get("pos")),
+ Cursor: v.Get("cursor"),
+ }
+}
+
+// econPosFromParam validates a "pos" query param against econPosWords. An
+// unrecognized value returns "" — no filter, exactly healthFromSlug's rule
+// for a mistyped URL: show everything rather than a mysteriously empty list.
+func econPosFromParam(v string) string {
+ for _, w := range econPosWords {
+ if w == v {
+ return w
+ }
+ }
+ return ""
+}
+
+type econAccountCursor struct {
+ Account string `json:"account"`
+}
+
+func encodeEconAccountCursor(account string) string {
+ b, _ := json.Marshal(econAccountCursor{Account: account})
+ return base64.RawURLEncoding.EncodeToString(b)
+}
+
+// decodeEconAccountCursor degrades on any malformed input to "no cursor" —
+// page one, never an error page (decodeInstanceCursor's rule).
+func decodeEconAccountCursor(s string) (string, bool) {
+ b, err := base64.RawURLEncoding.DecodeString(s)
+ if err != nil {
+ return "", false
+ }
+ var c econAccountCursor
+ if json.Unmarshal(b, &c) != nil || c.Account == "" {
+ return "", false
+ }
+ return c.Account, true
+}
+
+// econPosLink is one header chip: a free-tier position, its count over ALL
+// the page's rows, and the link that filters to it — healthLink's shape.
+type econPosLink struct {
+ Word string
+ Count int
+ Active bool
+}
+
+// econAccountsPage is one screen of the accounts list: the rows to render
+// plus the numbers and links the header states.
+type econAccountsPage struct {
+ Rows []econAccountRow
+ Counts map[string]int // free-tier position -> count over ALL rows
+ Total int // rows matching Q+Pos, across all pages
+ Links []econPosLink // non-zero positions, econPosWords order
+ NextCursor string // "" on the last page
+ NextURL string // the whole next-page href; "" on the last page
+}
+
+// pageEconAccounts sorts rows by account name ASC (a stable cursor order
+// independent of EconomicsView.Accounts's own CostUSD-desc ordering — see
+// economicsview.go's doc comment on that sort), applies q, and cuts one
+// page. Counts cover every row regardless of filter, exactly like
+// pageInstanceRows's Counts.
+func pageEconAccounts(rows []econAccountRow, q econAccountQuery, limit int) econAccountsPage {
+ sorted := append([]econAccountRow(nil), rows...)
+ sort.Slice(sorted, func(i, j int) bool { return sorted[i].Account < sorted[j].Account })
+
+ p := econAccountsPage{Counts: map[string]int{}}
+ var matched []econAccountRow
+ for _, r := range sorted {
+ p.Counts[r.Econ.FreeTierPos]++
+ if q.Q != "" && !strings.Contains(r.Account, q.Q) {
+ continue
+ }
+ if q.Pos != "" && r.Econ.FreeTierPos != q.Pos {
+ continue
+ }
+ matched = append(matched, r)
+ }
+ p.Total = len(matched)
+ start := 0
+ if after, ok := decodeEconAccountCursor(q.Cursor); ok {
+ for i, r := range matched {
+ if r.Account > after {
+ start = i
+ break
+ }
+ start = i + 1
+ }
+ }
+ end := start + limit
+ if end < len(matched) {
+ p.NextCursor = encodeEconAccountCursor(matched[end-1].Account)
+ } else {
+ end = len(matched)
+ }
+ p.Rows = matched[start:end]
+ for _, w := range econPosWords {
+ if p.Counts[w] == 0 {
+ continue
+ }
+ p.Links = append(p.Links, econPosLink{Word: w, Count: p.Counts[w], Active: q.Pos == w})
+ }
+ if p.NextCursor != "" {
+ v := url.Values{}
+ if q.Q != "" {
+ v.Set("q", q.Q)
+ }
+ if q.Pos != "" {
+ v.Set("pos", q.Pos)
+ }
+ v.Set("cursor", p.NextCursor)
+ p.NextURL = "?" + v.Encode()
+ }
+ return p
+}
diff --git a/internal/console/econlistquery_test.go b/internal/console/econlistquery_test.go
new file mode 100644
index 000000000..d915decfe
--- /dev/null
+++ b/internal/console/econlistquery_test.go
@@ -0,0 +1,124 @@
+package console
+
+import (
+ "fmt"
+ "net/url"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/economics"
+)
+
+// econRowsFor builds n synthetic rows named acct-000..acct-(n-1), all
+// "paying", for the pure pagination tests below — pageInstanceRows's own
+// rowsFor helper (listquery_test.go), rewritten for econAccountRow.
+func econRowsFor(n int) []econAccountRow {
+ out := make([]econAccountRow, n)
+ for i := range out {
+ acct := fmt.Sprintf("acct-%03d", i)
+ out[i] = econAccountRow{
+ Account: acct,
+ Meters: map[string]float64{},
+ Econ: economics.AccountEconomics{Account: acct, FreeTierPos: "paying"},
+ }
+ }
+ return out
+}
+
+func TestPageEconAccountsFirstAndSecondPage(t *testing.T) {
+ rows := econRowsFor(120)
+ p1 := pageEconAccounts(rows, econAccountQuery{}, econAccountPageSize)
+ if len(p1.Rows) != 50 || p1.NextCursor == "" {
+ t.Fatalf("page1: got %d rows, cursor %q", len(p1.Rows), p1.NextCursor)
+ }
+ if p1.Rows[0].Account != "acct-000" || p1.Rows[49].Account != "acct-049" {
+ t.Fatalf("page1 bounds: first %q last %q", p1.Rows[0].Account, p1.Rows[49].Account)
+ }
+ if p1.Total != 120 {
+ t.Fatalf("p1.Total = %d, want 120", p1.Total)
+ }
+
+ p2 := pageEconAccounts(rows, econAccountQuery{Cursor: p1.NextCursor}, econAccountPageSize)
+ if len(p2.Rows) != 50 || p2.Rows[0].Account != "acct-050" {
+ t.Fatalf("page2: got %d rows starting %q, want 50 starting acct-050", len(p2.Rows), p2.Rows[0].Account)
+ }
+
+ p3 := pageEconAccounts(rows, econAccountQuery{Cursor: p2.NextCursor}, econAccountPageSize)
+ if len(p3.Rows) != 20 || p3.NextCursor != "" {
+ t.Fatalf("page3: got %d rows, cursor %q, want 20 rows and no next cursor", len(p3.Rows), p3.NextCursor)
+ }
+ if p3.Rows[0].Account != "acct-100" || p3.Rows[19].Account != "acct-119" {
+ t.Fatalf("page3 bounds: first %q last %q", p3.Rows[0].Account, p3.Rows[19].Account)
+ }
+}
+
+func TestPageEconAccountsQFilter(t *testing.T) {
+ rows := econRowsFor(120)
+ // "acct-01" substring-matches acct-010..acct-019 (10 rows).
+ p := pageEconAccounts(rows, econAccountQuery{Q: "acct-01"}, econAccountPageSize)
+ if p.Total != 10 || len(p.Rows) != 10 {
+ t.Fatalf("Q filter: Total=%d Rows=%d, want 10/10", p.Total, len(p.Rows))
+ }
+ for _, r := range p.Rows {
+ if r.Account[:7] != "acct-01" {
+ t.Fatalf("row %q does not match Q filter", r.Account)
+ }
+ }
+}
+
+func TestPageEconAccountsPosFilter(t *testing.T) {
+ rows := econRowsFor(120)
+ rows[0].Econ.FreeTierPos = "inside"
+ rows[1].Econ.FreeTierPos = "inside"
+ rows[2].Econ.FreeTierPos = "partial"
+
+ p := pageEconAccounts(rows, econAccountQuery{Pos: "inside"}, econAccountPageSize)
+ if p.Total != 2 {
+ t.Fatalf("Pos=inside: Total = %d, want 2", p.Total)
+ }
+ for _, r := range p.Rows {
+ if r.Econ.FreeTierPos != "inside" {
+ t.Fatalf("row %q has FreeTierPos %q, want inside", r.Account, r.Econ.FreeTierPos)
+ }
+ }
+
+ // Counts totals every position across ALL rows, filtered or not.
+ if p.Counts["inside"] != 2 || p.Counts["partial"] != 1 || p.Counts["paying"] != 117 {
+ t.Fatalf("Counts = %+v, want inside:2 partial:1 paying:117", p.Counts)
+ }
+
+ var insideLink, payingLink *econPosLink
+ for i := range p.Links {
+ switch p.Links[i].Word {
+ case "inside":
+ insideLink = &p.Links[i]
+ case "paying":
+ payingLink = &p.Links[i]
+ }
+ }
+ if insideLink == nil || !insideLink.Active || insideLink.Count != 2 {
+ t.Fatalf("inside link = %+v, want Active=true Count=2", insideLink)
+ }
+ if payingLink == nil || payingLink.Active || payingLink.Count != 117 {
+ t.Fatalf("paying link = %+v, want Active=false Count=117", payingLink)
+ }
+}
+
+func TestPageEconAccountsGarbageCursorStartsOver(t *testing.T) {
+ rows := econRowsFor(5)
+ p := pageEconAccounts(rows, econAccountQuery{Cursor: "!!not-base64!!"}, econAccountPageSize)
+ if len(p.Rows) != 5 {
+ t.Fatalf("bad cursor must degrade to page one: got %d rows", len(p.Rows))
+ }
+}
+
+func TestParseEconAccountQuery(t *testing.T) {
+ q := parseEconAccountQuery(url.Values{"q": {" acct-01 "}, "pos": {"inside"}, "cursor": {"abc"}})
+ if q.Q != "acct-01" || q.Pos != "inside" || q.Cursor != "abc" {
+ t.Fatalf("%+v", q)
+ }
+ // An unrecognized pos value is no filter, not an error.
+ q2 := parseEconAccountQuery(url.Values{"pos": {"nonsense"}})
+ if q2.Pos != "" {
+ t.Fatalf("unknown pos = %q, want \"\" (no filter)", q2.Pos)
+ }
+}
diff --git a/internal/console/economicspage_test.go b/internal/console/economicspage_test.go
index ef035b6ea..0980a29bb 100644
--- a/internal/console/economicspage_test.go
+++ b/internal/console/economicspage_test.go
@@ -231,3 +231,77 @@ func TestEconomicsPageBillServicesGuardShape(t *testing.T) {
t.Fatalf("/fleet/economics still claims a Project transliteration in its header comment")
}
}
+
+// TestEconomicsAccountsOperatorOnly is TestEconomicsOperatorOnly's shape,
+// cloned against /fleet/economics/accounts (Task 14): 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.
+func TestEconomicsAccountsOperatorOnly(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/accounts"); status != http.StatusNotFound || body != bodyGhost {
+ t.Fatalf("signed-out /fleet/economics/accounts = %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/accounts"); status != http.StatusNotFound || body != bodyGhost {
+ t.Fatalf("non-operator /fleet/economics/accounts = %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/accounts")
+ if status != 200 || !strings.Contains(body, "id=\"econ-data\"") {
+ t.Errorf("operator economics accounts 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/accounts"); status != http.StatusNotFound || body != bodyGhost {
+ t.Fatalf("revoked operator /fleet/economics/accounts = %d %q, want the same 404", status, body)
+ }
+}
+
+// TestEconomicsAccountsPageContent proves the page renders one real
+// account row from LoadEconomics, joined with its own meters: seeds the
+// same golden fixture as TestEconomicsPageContent (one box, one route, 2GB
+// egress over 4000 requests for fixtureAcct — DefaultScenario's free tier
+// fully covers that egress, so PriceAccount marks the row "partial", per
+// PriceAccount's own clipped rule: any one meter benefiting from the free
+// tier is enough, independent of the others). Asserts the account name, its
+// egress-GB meter cell (2.00, exactly the seeded 2e9 bytes), and the
+// free-tier chip all render.
+func TestEconomicsAccountsPageContent(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/accounts")
+ if status != 200 {
+ t.Fatalf("/fleet/economics/accounts = %d, want 200:\n%s", status, body)
+ }
+ for _, want := range []string{
+ fixtureAcct,
+ ">2.00<", // the egress-GB meter cell (2e9 bytes seeded)
+ `class="chip chip-freetier-partial"`, // the free-tier chip, this fixture's position
+ `id="econ-data"`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("/fleet/economics/accounts missing %q in:\n%s", want, body)
+ }
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 454ee4ab0..2f9b656a8 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -582,6 +582,9 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("GET /fleet", s.handleFleet)
s.mux.HandleFunc("GET /fleet/metrics", s.handleFleetMetrics)
s.mux.HandleFunc("GET /fleet/economics", s.handleEconomics)
+ // Per-account table (Task 14): same gate, its own search/filter/cursor
+ // controls (econlistquery.go) over the same LoadEconomics rows.
+ s.mux.HandleFunc("GET /fleet/economics/accounts", s.handleEconomicsAccounts)
// Unit economics (Task 12): scenario/bill writes, operator-only, same
// gate shape as handleFleet above. GET /fleet/economics is handled
// above by handleEconomics (Task 13). Registered WITHOUT a method
@@ -823,6 +826,15 @@ type page struct {
// above.
Economics *EconomicsView
+ // /fleet/economics/accounts: the per-account table (Task 14) —
+ // EconAccounts is the computed page (rows, counts, links, pager),
+ // EconQuery is the parsed request so the search box and filter links
+ // re-render what the viewer asked for. Economics (above) is ALSO set on
+ // this page: the template embeds the same econ-data payload so the
+ // shared knob drawer recomputes this page's rows too.
+ EconAccounts econAccountsPage
+ EconQuery econAccountQuery
+
// 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.
@@ -1690,6 +1702,46 @@ func (s *Server) handleEconomics(w http.ResponseWriter, r *http.Request) {
s.render(w, http.StatusOK, "economics", p)
}
+// handleEconomicsAccounts serves the per-account economics table (Task 14)
+// — handleEconomics's gate and LoadEconomics call verbatim, joined with
+// econlistquery.go's search/free-tier-position/cursor controls. view.Accounts
+// (priced, CostUSD-desc from buildEconomicsView) and view.Meters (raw
+// per-account readings, same account keys) are joined into econAccountRow
+// here; pageEconAccounts re-sorts by account name for a stable cursor order,
+// per its own doc comment.
+func (s *Server) handleEconomicsAccounts(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 accounts: 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 accounts: %v", err)
+ s.renderMessage(w, r, http.StatusInternalServerError, "Unavailable", "Economics could not be read.")
+ return
+ }
+ rows := make([]econAccountRow, 0, len(view.Accounts))
+ for _, ae := range view.Accounts {
+ rows = append(rows, econAccountRow{Account: ae.Account, Meters: view.Meters[ae.Account], Econ: ae})
+ }
+ q := parseEconAccountQuery(r.URL.Query())
+ pg := pageEconAccounts(rows, q, econAccountPageSize)
+ p := page{Title: "Fleet economics: accounts", Address: addr, CSRF: raw, Economics: view, EconAccounts: pg, EconQuery: q}
+ p.Flash, p.FlashErr = s.takeFlash(w, r)
+ s.render(w, http.StatusOK, "economics_accounts", 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
index 399fef64e..991ca8f4a 100644
--- a/internal/console/templates/economics.html
+++ b/internal/console/templates/economics.html
@@ -13,7 +13,17 @@
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). */}}
+ comment).
+
+ The knob drawer markup (define "econ_knob_drawer") and the pure
+ calculation functions the inline script needs (define "econ_maths_js")
+ both live at the bottom of this file and are INCLUDED here rather than
+ only defined here — Task 14's /fleet/economics/accounts page templates
+ the same two blocks (economics_accounts.html) so its own knob drawer
+ and its own recompute() share one source for both the editable knobs
+ and the maths, instead of a second ~300-line copy. See
+ economics_accounts.html's header comment for that page's (smaller)
+ recompute(). */}}
{{define "economics"}}{{template "top" .}}
<h1>Fleet economics</h1>
{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
@@ -29,6 +39,7 @@
<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>
+<p class="small"><a href="/fleet/economics/accounts">Every account, searchable →</a></p>
{{/* Headline tiles: EconomicsView.Accounts (allocated+metered cost and
revenue per tenant) plus every box's unallocated idle headroom plus
@@ -79,6 +90,7 @@
</table>
</div>
{{else}}<p class="small">No accounts to price this period.</p>{{end}}
+<p class="small"><a href="/fleet/economics/accounts">Every account, searchable →</a></p>
<h2>Boxes</h2>
{{if .Economics.Boxes}}
@@ -186,6 +198,147 @@
</form>
{{end}}
+{{template "econ_knob_drawer" .}}
+</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; }
+
+ {{template "econ_maths_js" .}}
+
+ 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}}
+
+{{/* econ_knob_drawer: the "Pricing knobs" <details> — every rate-card,
+ allocation, mode/margin, rate-sheet and free-tier input, plus the
+ save-as-scenario form. Shared verbatim between /fleet/economics (Task
+ 13) and /fleet/economics/accounts (Task 14): both pages price the
+ SAME EconomicsView.Scenario, so one set of knob markup and one
+ save-scenario door (POST /fleet/economics/scenarios, which always
+ redirects to /fleet/economics regardless of which page submitted it —
+ see handleEconomicsScenarioSave) serves both. Takes the whole page `.`
+ — reads .Economics.Scenario and .CSRF, exactly as when this was inline
+ in "economics". */}}
+{{define "econ_knob_drawer"}}
<details class="reveal" id="knob-drawer">
<summary>Pricing knobs</summary>
@@ -255,7 +408,7 @@
<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>
+<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>
<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}}">
@@ -268,31 +421,37 @@
{{/* Hidden by default; the inline script shows it (display:"") exactly
when every "counts as CARLOS" checkbox is unchecked — the state the
save field refuses to persist (see the template comment by the bill
- table). */}}
+ table). Only /fleet/economics ever has bill checkboxes on screen, so
+ this note only ever becomes visible there — but the element must
+ exist on both pages: setBillServicesZeroNote (econ_maths_js) looks
+ it up by id and simply no-ops via its `if (el)` guard when absent. */}}
<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>
</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; }
+{{end}}
+{{/* econ_maths_js: every pure (no page-specific DOM target) function the
+ knob-recompute scripts need — economics.AllocateBox/PriceAccount
+ transliterated, plus the small DOM/formatting helpers and
+ readScenarioFromDOM, which reads the econ_knob_drawer inputs back into
+ a live-edited Scenario. Emits BARE JavaScript, no <script> tags and no
+ wrapping IIFE of its own: each including page's own <script> already
+ opens one (see "economics" above and economics_accounts.html), and a
+ template action splices this in as plain text at that point — so every
+ function/var here shares that ONE enclosing scope with the
+ including page's own dataEl/payload preamble and its own recompute().
+ Takes `.` for template-call symmetry with econ_knob_drawer; nothing
+ inside reads it (this block is pure JS, no Go value substitution). */}}
+{{define "econ_maths_js"}}
// lastNonEmptyBillServices remembers the most recent "counts as CARLOS"
// selection that had at least one box checked, seeded from the scenario
// the page loaded with — readScenarioFromDOM's zero-checked branch reads
// this instead of ever serializing an empty (== "count everything",
// the opposite meaning) selection. null until either the loaded scenario
- // or this session has seen a non-empty selection.
+ // or this session has seen a non-empty selection. Only /fleet/economics
+ // ever has bill checkboxes to derive this from; on a page with none, the
+ // branch that would update it never runs, and this stays whatever the
+ // loaded scenario seeded it to.
var lastNonEmptyBillServices = (payload.scenario.bill_services && payload.scenario.bill_services.length > 0)
? payload.scenario.bill_services
: null;
@@ -539,118 +698,8 @@
// (inside the same #econ-live listener scope) would fire recompute()
// and clobber a reconciliation selection made against a DIFFERENT
// period's bill into [] — the fix-round finding this whole branch
- // exists for.
+ // exists for. On /fleet/economics/accounts, which has no bill-entry
+ // form at all, boxes.length is always 0, so this branch never runs.
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}}
+{{end}}
diff --git a/internal/console/templates/economics_accounts.html b/internal/console/templates/economics_accounts.html
new file mode 100644
index 000000000..6a39b73f8
--- /dev/null
+++ b/internal/console/templates/economics_accounts.html
@@ -0,0 +1,162 @@
+{{/* /fleet/economics/accounts (Task 14): every account, one row each,
+ searchable and cursor-paginated (econlistquery.go: econAccountQuery/
+ pageEconAccounts — instanceQuery/pageInstanceRows's shape, applied to
+ economics.AccountEconomics instead of an InstanceRow). Columns are
+ honest about what the census meters actually hold: app_months and
+ instance_hours are month-fraction and hour-sum accumulators (see
+ internal/usage.Meter*), never a literal "how many apps/instances right
+ now" count, so the headers below say "app-months"/"instance-hours"
+ rather than the design brief's shorthand "apps"/"instances" — the
+ dashboard-truth rule (CLAUDE.md) that a total must never claim a
+ precision the underlying meter doesn't have.
+
+ Recompute: this page shares Task 13's knob drawer (define
+ "econ_knob_drawer", economics.html) and its pure maths functions
+ (define "econ_maths_js", same file) rather than re-deriving either —
+ see economics.html's header comment for why splicing econ_maths_js
+ into THIS page's own <script> IIFE keeps one scope. This page's own
+ recompute() (below) is deliberately smaller than economics.html's: no
+ tiles, no box table, no model-vs-bill — it only re-prices the account
+ rows currently in the DOM (this page's one table, one page of up to
+ 50 accounts), via the same allocateBox/priceAccount over the FULL
+ payload.boxLoads (a row's allocated cost depends on every box it
+ touched, not just the rows on screen). The meter columns (CPU-h, GB-h,
+ egress, storage) are raw readings, not knob-derived, so they never
+ change under recompute. */}}
+{{define "economics_accounts"}}{{template "top" .}}
+<h1>Fleet economics: accounts</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>
+<p class="small"><a href="/fleet/economics">&larr; Back to the dashboard</a></p>
+
+{{if or .EconAccounts.Rows .EconQuery.Q .EconQuery.Pos}}
+<form method="get" class="listctl">
+ <input type="search" name="q" value="{{.EconQuery.Q}}" placeholder="Filter accounts" aria-label="Filter accounts">
+ {{if .EconQuery.Pos}}<input type="hidden" name="pos" value="{{.EconQuery.Pos}}">{{end}}
+ <button>Filter</button>
+</form>
+<p class="small">
+ <a href="?{{if .EconQuery.Q}}q={{.EconQuery.Q}}{{end}}"{{if not .EconQuery.Pos}} aria-current="true"{{end}}>All</a>
+ {{range .EconAccounts.Links}}
+ &middot; <a href="?pos={{.Word}}{{if $.EconQuery.Q}}&amp;q={{$.EconQuery.Q}}{{end}}"{{if .Active}} aria-current="true"{{end}}>{{.Word}} {{.Count}}</a>
+ {{end}}
+</p>
+{{end}}
+
+{{if .EconAccounts.Rows}}
+<div class="tablewrap">
+<table>
+ <tr>
+ <th scope="col">Account</th>
+ <th scope="col">App-months</th>
+ <th scope="col">Instance-hours</th>
+ <th scope="col">CPU-h</th>
+ <th scope="col">GB-h</th>
+ <th scope="col">Egress GB</th>
+ <th scope="col">Storage GB-month</th>
+ <th scope="col">Cost</th>
+ <th scope="col">Would-be revenue</th>
+ <th scope="col">Margin</th>
+ <th scope="col">Free tier</th>
+ </tr>
+ {{range .EconAccounts.Rows}}
+ <tr data-account="{{.Account}}">
+ <td>{{.Account}}</td>
+ <td>{{printf "%.2f" (index .Meters "app_months")}}</td>
+ <td>{{printf "%.2f" (index .Meters "instance_hours")}}</td>
+ <td>{{printf "%.2f" (index .Meters "cpu_core_hours")}}</td>
+ <td>{{printf "%.2f" (index .Meters "mem_gb_hours")}}</td>
+ <td>{{printf "%.2f" (index .Meters "egress_gb")}}</td>
+ <td>{{printf "%.2f" (index .Meters "storage_gb_month")}}</td>
+ <td class="js-acct-cost">{{usd .Econ.CostUSD}}</td>
+ <td class="js-acct-revenue">{{usd .Econ.RevenueUSD}}</td>
+ <td class="js-acct-margin">{{pctf .Econ.MarginFrac}}</td>
+ <td class="js-acct-freetier"><span class="chip chip-freetier-{{.Econ.FreeTierPos}}">{{.Econ.FreeTierPos}}</span></td>
+ </tr>
+ {{end}}
+</table>
+</div>
+<p class="small">App-months and instance-hours are accumulators, not live counts —
+a fraction of a calendar month and a sum of hours run, the meters this platform
+actually keeps. CPU-h/GB-h are box-side compute meters; egress and storage are
+edge/S3 meters. Cost and would-be revenue are this account's own share plus its
+allocated box cost, under the active scenario.</p>
+{{if .EconAccounts.NextURL}}<p class="small"><a href="{{.EconAccounts.NextURL}}">Next {{len .EconAccounts.Rows}} shown of {{.EconAccounts.Total}} &rarr;</a></p>{{end}}
+{{else if or .EconQuery.Q .EconQuery.Pos}}
+<p>No accounts match.</p>
+{{else}}
+<p class="small">No accounts to price this period.</p>
+{{end}}
+
+<div id="econ-live">
+{{template "econ_knob_drawer" .}}
+</div>
+
+<script type="application/json" id="econ-data">{{.Economics.DataJSON}}</script>
+<script>
+(function () {
+ "use strict";
+ // Same source-order contract as economics.html's script: the payload
+ // <script> tag above is parsed and in the DOM before this one runs.
+ var dataEl = document.getElementById("econ-data");
+ if (!dataEl) { return; }
+ var payload;
+ try { payload = JSON.parse(dataEl.textContent); } catch (e) { return; }
+
+ {{template "econ_maths_js" .}}
+
+ // recompute re-prices only the account rows THIS page rendered (its one
+ // page of up to econAccountPageSize accounts) — allocateBox still runs
+ // over the FULL payload.boxLoads/totalAllocated (a row's allocated cost
+ // depends on every box it touched, not just the rows on screen), but the
+ // DOM write-back is scoped to "[data-account]" rows actually present,
+ // exactly like economics.html's account-row loop, minus the tiles/box
+ // table/model-vs-bill sections this page doesn't render.
+ function recompute() {
+ var sc = readScenarioFromDOM(payload.scenario);
+ setText("margin-multiple", marginMultiple(sc.margin));
+
+ var totalAllocated = {};
+ Object.keys(payload.boxLoads).forEach(function (box) {
+ var alloc = allocateBox(sc.rate_card, sc.allocation, payload.boxLoads[box]);
+ Object.keys(alloc.perAccount).forEach(function (acct) {
+ totalAllocated[acct] = (totalAllocated[acct] || 0) + alloc.perAccount[acct];
+ });
+ });
+
+ document.querySelectorAll("[data-account]").forEach(function (row) {
+ var acct = row.getAttribute("data-account");
+ var a = priceAccount(sc, acct, payload.meters[acct] || {}, totalAllocated[acct] || 0);
+ setCellText(row, ".js-acct-cost", usd(a.costUSD));
+ setCellText(row, ".js-acct-revenue", usd(a.revenueUSD));
+ setCellText(row, ".js-acct-margin", pctf(a.marginFrac));
+ var chip = row.querySelector(".js-acct-freetier .chip");
+ if (chip) {
+ chip.textContent = a.freeTierPos;
+ chip.className = "chip chip-freetier-" + a.freeTierPos;
+ }
+ });
+
+ 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 75fafb938..12fb4dc7a 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -188,6 +188,16 @@
.chip-health-attention { background: var(--warn, #a6790f); color: var(--bg); }
.chip-health-notdeployed { border: 1px solid var(--rule); color: var(--muted); }
.chip-health-unknown { border: 1px solid var(--warn, #a6790f); color: var(--warn, #a6790f); }
+ /* /fleet/economics/accounts (Task 14): FreeTierPos chips. "inside" (all
+ usage covered by the free tier, zero revenue) is the pure-cost-sink
+ case worth a second look, so it gets the same bordered warn tone as
+ chip-stale/chip-health-unknown — a fact to watch, not yet an outage.
+ "partial" (some usage billable, some free-tier-covered — the ordinary
+ lifecycle state) is a neutral accent border, chip-canary's tone.
+ "paying" (nothing left free) is chip-health-ok's bordered good tone. */
+ .chip-freetier-inside { border: 1px solid var(--warn, #a6790f); color: var(--warn, #a6790f); }
+ .chip-freetier-partial { border: 1px solid var(--accent); color: var(--accent); }
+ .chip-freetier-paying { border: 1px solid var(--good); color: var(--good); }
.chip .icon { vertical-align: -0.1em; margin-right: 0.2em; }
.chip-up { background: var(--accent-soft); color: #2f7a4f; }
.chip-down { background: var(--accent-soft); color: var(--bad); }