From f8ae821240f33d615a9e91cdfeb6c026b7970782 Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Sat, 4 Jul 2026 08:50:46 +0000 Subject: feat(web,api): add Budget/Roles dashboard -- final phase of the harness redesign (Phase 9b) Two new tabs, matching Phase 9a's conventions: Budget (per-provider spend meters, escalation funnel, spend-over-time) and Roles (version history, draft activation, readable escalation-ladder view) -- the human-facing surface for the token-husbanding value proposition and the Phase 5/8 versioned role-config system. New backend (minimal, additive, matching existing endpoint conventions -- unauthenticated like /api/budget and /api/roles/*): - internal/storage/dashboard.go: QueryEscalationFunnel (executions grouped by escalation_rung + agent, count + cost) and QuerySpendTimeseries (cost per provider bucketed hourly/daily, mirroring QueryDashboardStats' existing bucketing expressions). Documented honestly: rung 0 is not exclusively "resolved locally" -- escalation_rung defaults to 0 uniformly, so non-role-typed executions (which never climb a ladder) land there too, alongside role-typed tasks genuinely resolved at tier 0. A role-only variant would need a join against tasks.config_json with no queryable role column on executions -- intentionally out of scope. - internal/api/dashboard.go: GET /api/escalation-funnel, GET /api/spend-timeseries (both ?window=5h|24h|7d|, default 24h). - internal/storage.ListRoleNames + GET /api/roles: the "which roles exist" gap -- there was no way to discover role names before this, only to list versions for a role you already knew the name of. Chart-form decisions (dataviz skill, invoked before writing chart code): horizontal stacked bar for the escalation funnel (rung order already encodes the funnel shape positionally; color only needed for per-provider segments within each rung); multi-line for spend-over-time; a fixed-order categorical palette from the skill's validated palette.md slots for provider identity (re-validated against this app's dark surface, passing); a separate status (good/warning/critical) palette for budget meters, deliberately distinct from both --state-* and the provider palette. Caught and fixed a real bug during visual QA: converging near-zero end-labels on the spend chart were overlapping (an anti-pattern the skill explicitly flags) -- fixed with a 14px minimum-gap check before direct-labeling an endpoint, leaning on the legend/tooltip otherwise. Verified with a real running server, real seeded data (65 executions across rungs 0-2 with a realistic provider mix, 2 roles with active/draft/retired role_configs versions written directly via internal/storage), and a real headless-browser session (reusing Phase 9a's Chromium/proxy scaffinding): confirmed correct rung totals/percentages, provider legends, a 3-line spend chart, live window-selector re-render, correct active-version highlighting on the role panel, and a real Activate click on a draft version -- verified via both DOM re-render and a direct backend GET that it truly persisted. go build/vet/test -race -count=1 all pass, full suite. node --test web/test/*.mjs: 291/291 passing (16 new). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs --- internal/api/dashboard_test.go | 131 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 internal/api/dashboard_test.go (limited to 'internal/api/dashboard_test.go') diff --git a/internal/api/dashboard_test.go b/internal/api/dashboard_test.go new file mode 100644 index 0000000..2b80de3 --- /dev/null +++ b/internal/api/dashboard_test.go @@ -0,0 +1,131 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/thepeterstone/claudomator/internal/storage" + "github.com/thepeterstone/claudomator/internal/task" +) + +func TestHandleGetEscalationFunnel_AggregatesWithinDefaultWindow(t *testing.T) { + srv, store := testServer(t) + tk := createTaskWithState(t, store, "funnel-task", task.StateCompleted) + + now := time.Now().UTC() + mustCreateAPIExecution(t, store, "f1", tk.ID, "local", 0, 0, now.Add(-1*time.Hour)) + mustCreateAPIExecution(t, store, "f2", tk.ID, "local", 0, 0, now.Add(-2*time.Hour)) + mustCreateAPIExecution(t, store, "f3", tk.ID, "anthropic", 1, 0.10, now.Add(-3*time.Hour)) + // Outside the default 24h window. + mustCreateAPIExecution(t, store, "f4", tk.ID, "local", 0, 0, now.Add(-48*time.Hour)) + + req := httptest.NewRequest("GET", "/api/escalation-funnel", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d: %s", w.Code, w.Body.String()) + } + var buckets []storage.EscalationBucket + if err := json.Unmarshal(w.Body.Bytes(), &buckets); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(buckets) != 2 { + t.Fatalf("expected 2 buckets (rung 0/local, rung 1/anthropic), got %d: %+v", len(buckets), buckets) + } + for _, b := range buckets { + if b.Rung == 0 && b.Agent == "local" && b.Count != 2 { + t.Errorf("rung0/local count = %d, want 2", b.Count) + } + if b.Rung == 1 && b.Agent == "anthropic" && (b.Count != 1 || b.CostUSD != 0.10) { + t.Errorf("rung1/anthropic = %+v, want count 1 cost 0.10", b) + } + } +} + +func TestHandleGetEscalationFunnel_WindowParam_Narrows(t *testing.T) { + srv, store := testServer(t) + tk := createTaskWithState(t, store, "funnel-window-task", task.StateCompleted) + now := time.Now().UTC() + mustCreateAPIExecution(t, store, "fw1", tk.ID, "local", 0, 0, now.Add(-10*time.Hour)) + + req := httptest.NewRequest("GET", "/api/escalation-funnel?window=5h", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + var buckets []storage.EscalationBucket + if err := json.Unmarshal(w.Body.Bytes(), &buckets); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(buckets) != 0 { + t.Errorf("expected no buckets within a 5h window, got %+v", buckets) + } +} + +func TestHandleGetSpendTimeseries_ReturnsPerProviderPoints(t *testing.T) { + srv, store := testServer(t) + tk := createTaskWithState(t, store, "spend-task", task.StateCompleted) + now := time.Now().UTC() + mustCreateAPIExecution(t, store, "s1", tk.ID, "local", 0, 0.01, now.Add(-1*time.Hour)) + mustCreateAPIExecution(t, store, "s2", tk.ID, "anthropic", 0, 0.20, now.Add(-1*time.Hour)) + + req := httptest.NewRequest("GET", "/api/spend-timeseries", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d: %s", w.Code, w.Body.String()) + } + var points []storage.SpendPoint + if err := json.Unmarshal(w.Body.Bytes(), &points); err != nil { + t.Fatalf("unmarshal: %v", err) + } + agents := map[string]bool{} + for _, p := range points { + agents[p.Agent] = true + } + if !agents["local"] || !agents["anthropic"] { + t.Errorf("expected points for both local and anthropic, got %+v", points) + } +} + +func TestHandleGetSpendTimeseries_EmptyDB_ReturnsEmptyArray(t *testing.T) { + srv, _ := testServer(t) + req := httptest.NewRequest("GET", "/api/spend-timeseries", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d", w.Code) + } + var points []storage.SpendPoint + if err := json.Unmarshal(w.Body.Bytes(), &points); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(points) != 0 { + t.Errorf("expected empty slice, got %+v", points) + } +} + +// mustCreateAPIExecution mirrors createExecution (executions_test.go) but +// also sets Agent/EscalationRung, which the funnel/spend-timeseries +// endpoints aggregate on. +func mustCreateAPIExecution(t *testing.T, store *storage.DB, id, taskID, agent string, rung int, cost float64, start time.Time) { + t.Helper() + exec := &storage.Execution{ + ID: id, + TaskID: taskID, + StartTime: start, + EndTime: start.Add(time.Minute), + Status: "COMPLETED", + CostUSD: cost, + Agent: agent, + EscalationRung: rung, + } + if err := store.CreateExecution(exec); err != nil { + t.Fatalf("createExecution(%s): %v", id, err) + } +} -- cgit v1.2.3