diff options
| author | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-04 08:50:46 +0000 |
|---|---|---|
| committer | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-04 08:50:46 +0000 |
| commit | f8ae821240f33d615a9e91cdfeb6c026b7970782 (patch) | |
| tree | 458ce067db47a92248f163c26ce5fcf71299f3b0 /internal/api/dashboard.go | |
| parent | d105eca610b0e737c3313e4978d6a917b4f55d10 (diff) | |
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|<duration>, 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs
Diffstat (limited to 'internal/api/dashboard.go')
| -rw-r--r-- | internal/api/dashboard.go | 75 |
1 files changed, 75 insertions, 0 deletions
diff --git a/internal/api/dashboard.go b/internal/api/dashboard.go new file mode 100644 index 0000000..1238295 --- /dev/null +++ b/internal/api/dashboard.go @@ -0,0 +1,75 @@ +package api + +import ( + "net/http" + "time" + + "github.com/thepeterstone/claudomator/internal/storage" +) + +// parseWindow resolves a ?window= query param to a lookback duration, +// defaulting to def when absent/unrecognized. Mirrors +// handleGetDashboardStats' "24h vs 7d" special-casing, generalized with a +// few friendly aliases plus a time.ParseDuration fallback for anything else +// (e.g. "2h", "90m"). +func parseWindow(v string, def time.Duration) time.Duration { + switch v { + case "": + return def + case "5h": + return 5 * time.Hour + case "24h": + return 24 * time.Hour + case "7d": + return 7 * 24 * time.Hour + default: + if d, err := time.ParseDuration(v); err == nil && d > 0 { + return d + } + return def + } +} + +// handleGetEscalationFunnel returns the (escalation_rung, agent) aggregate +// that drives the budget dashboard's escalation funnel — the harness's core +// "resolve locally, escalate only when needed" story made visible. +// GET /api/escalation-funnel?window=24h|5h|7d|<Go duration> +// +// Defaults to a 24h window: wider than the budget accountant's 5h rolling +// spend window (which exists to gate live dispatch), since this is a +// historical/observability view, not a live gate — 24h gives a more +// representative sample of rung distribution without requiring the caller +// to know the accountant's configured window. +func (s *Server) handleGetEscalationFunnel(w http.ResponseWriter, r *http.Request) { + window := parseWindow(r.URL.Query().Get("window"), 24*time.Hour) + since := time.Now().Add(-window) + + buckets, err := s.store.QueryEscalationFunnel(since) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if buckets == nil { + buckets = []storage.EscalationBucket{} + } + writeJSON(w, http.StatusOK, buckets) +} + +// handleGetSpendTimeseries returns cost-per-provider bucketed over time — +// hourly for windows of a day or less, daily otherwise (mirroring +// QueryDashboardStats' own hourly-throughput/daily-billing split). +// GET /api/spend-timeseries?window=24h|5h|7d|<Go duration> +func (s *Server) handleGetSpendTimeseries(w http.ResponseWriter, r *http.Request) { + window := parseWindow(r.URL.Query().Get("window"), 24*time.Hour) + since := time.Now().Add(-window) + + points, err := s.store.QuerySpendTimeseries(since, window <= 26*time.Hour) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if points == nil { + points = []storage.SpendPoint{} + } + writeJSON(w, http.StatusOK, points) +} |
