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>
7 files changed,
+766
−131
internal/console/econlistquery.go+164 −0internal/console/econlistquery_test.go+124 −0internal/console/economicspage_test.go+74 −0internal/console/server.go+52 −0internal/console/templates/economics.html+180 −131internal/console/templates/economics_accounts.html+162 −0internal/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> · |
| <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}} · scenario <strong>{{.Economics.Scenario.Name}}</strong> ({{.Economics.Scenario.Mode}}) · {{.Economics.CoveredDays}}/{{.Economics.PeriodDays}} days metered</p> |
| + |
| +<nav class="small" aria-label="Period"> |
| + Period: |
| + <a href="?period=mtd"{{if eq .Economics.Period "mtd"}} aria-current="page"{{end}}>Month to date</a> · |
| + <a href="?period=last"{{if eq .Economics.Period "last"}} aria-current="page"{{end}}>Last month</a> · |
| + <a href="?period=7d"{{if eq .Economics.Period "7d"}} aria-current="page"{{end}}>Last 7 days</a> |
| +</nav> |
| +<p class="small"><a href="/fleet/economics">← 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}} |
| + · <a href="?pos={{.Word}}{{if $.EconQuery.Q}}&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}} →</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); } |