Files
yetone d1d4b7ed2a usage: a heatmap of the last 53 weeks on the Requests tab, by tokens, requests or cost (#1369, nianlee-official)
What changed: the Requests tab of the Usage page shows a heatmap card like
CC Switch's: a cell per day for the last 53 weeks, Monday on top, month
names over the weeks and weekday names at the edge, a Tokens / Requests /
Cost switch (kept in localStorage), a five-shade Less…More legend, and a
tip on each day with its date, tokens, requests and cost.

How: GET /api/usage/heatmap (usage.HeatmapOf) reads QueryPage's cached
request index over a hidden period, "heatmap", which starts on the Monday 52
weeks before this week's. Its rows are summed by local calendar day in both
the compact and the ledger path. So no raw log is scanned per render, and the
days count what the chart counts:
- the page's agent/provider/model/key filters, but not the period or day
  picked;
- the gateway's own rejections left out;
- cost of priced calls only, with an unpriced count.
The days are in the local time zone and the shades use theme tokens
(.l0–.l4, as in the sessions calendar). There are no left-border accents and
no native <select>. A click never scrolls the page. A narrow window scrolls
the card sideways inside itself, with the newest week in sight (kept there
as it's laid out or resized, until the reader scrolls back), and the weekday
names stay at the card's edge. The card is hidden when no day has calls. New
strings are in zh, zh-TW, ja and de.

Run:
- go test -tags nogui ./internal/usage ./internal/gui (new TestHeatmapDays,
  TestHeatmapOf, TestUsageHeatmapRoute); gofmt -l clean; go vet darwin,
  GOOS=linux and GOOS=windows go vet -tags nogui on both packages; go build
  for darwin, linux and windows.
- New internal/gui/tests/usage-heatmap.test.cjs in Chromium and WebKit, at
  420 and 1100, in en, zh, zh-TW, ja and de, plus dark and the empty case.
  It checks:
  - the cells, labels, legend and the busiest and quiet days;
  - the newest week in sight and only the card scrolling sideways;
  - the tip's text and that it stays inside the card;
  - that a metric click moves nothing and is kept;
  - that the filters, but no period or day, are sent.
- Breaking checks: with the keep-at-end guard reverted, 5 of its 11 subtests
  fail in WebKit; with the server's heatmap dropped, TestUsageHeatmapRoute
  fails.
- i18n-keys, click-scroll, gui-de, gui-ja, gui-zh-tw, sessions-usage-open
  and all usage-*.test.cjs GUI tests pass in both engines (161 each).
- A sandboxed `magpie web` (temp HOME, 2605 fake calls over 400 days, ports
  3671/3672): the card fills 369 days at 1280 and 420 wide, and the tip
  reads "2026年10月1日周四 / 2.6M token · 23 个请求 · ≈$1.05+".
2026-10-09 11:33:47 +08:00

410 lines
17 KiB
Go

package gui
import (
"encoding/csv"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/yetone/magpie/internal/sessions"
"github.com/yetone/magpie/internal/usage"
)
// holdUsageClock stops usage.Clock at at for the test and gives at back, for
// the test to stamp its calls by: midnight then never falls between a call
// and the period it is asked for in.
func holdUsageClock(t *testing.T, at time.Time) time.Time {
t.Helper()
old := usage.Clock
usage.Clock = func() time.Time { return at }
t.Cleanup(func() { usage.Clock = old })
return at
}
func TestUsageLedgerDayRoutes(t *testing.T) {
home := sandboxHome(t)
if err := os.MkdirAll(filepath.Join(home, "Downloads"), 0o755); err != nil {
t.Fatal(err)
}
start := usage.Today.Since(holdUsageClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))).AddDate(0, 0, -1)
for i, at := range []time.Time{start.Add(-time.Second), start, start.Add(12 * time.Hour), start.AddDate(0, 0, 1)} {
usage.Append(usage.Record{Time: at, Agent: "codex", Provider: "relay", Model: "m", Input: 10 + i, Output: 1, Status: 200})
}
mux := http.NewServeMux()
usageRoutes(mux, folderOnly{})
q := url.Values{"period": {"7d"}, "day": {start.Format(time.DateOnly)}, "limit": {"1"}, "offset": {"1"}, "provider": {"relay"}}
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", "/api/usage/requests?"+q.Encode(), nil))
var l ledgerJSON
if err := json.Unmarshal(w.Body.Bytes(), &l); w.Code != 200 || err != nil {
t.Fatalf("day: %d %s", w.Code, w.Body)
}
if l.Day != q.Get("day") || l.Total != 2 || l.Calls != 2 || l.Input != 23 || len(l.Rows) != 1 || l.Rows[0].Input != 11 {
t.Fatalf("day's page: %+v", l)
}
var calls int
for _, p := range l.Series {
calls += p.Calls
}
if l.Bucket != "day" || len(l.Series) != 7 || calls != 4 || len(l.ChartBy["provider"]) != 1 || l.ChartBy["provider"][0].Calls != 4 {
t.Fatalf("the whole chart: %+v", l)
}
w = httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", "/api/usage/requests.csv?"+q.Encode(), nil))
csvData := w.Body.String()
rows, err := csv.NewReader(w.Body).ReadAll()
if w.Code != 200 || err != nil || len(rows) != 3 {
t.Fatalf("day's CSV: %d %s (%v)", w.Code, w.Body, err)
}
want := "magpie-requests-day-" + l.Day
if got := w.Header().Get("Content-Disposition"); got != `attachment; filename="`+want+`.csv"` {
t.Fatalf("day's CSV filename: %s", got)
}
for _, suffix := range []string{"", "-2"} {
w = httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("POST", "/api/usage/requests/export?"+q.Encode(), nil))
var out struct {
Path string
Rows int
}
if err := json.Unmarshal(w.Body.Bytes(), &out); w.Code != 200 || err != nil || out.Rows != 2 || out.Path != filepath.Join("~", "Downloads", want+suffix+".csv") {
t.Fatalf("day's export: %d %s", w.Code, w.Body)
}
b, err := os.ReadFile(filepath.Join(home, "Downloads", want+suffix+".csv"))
if err != nil || string(b) != csvData {
t.Fatalf("saved CSV differs from the download: %v", err)
}
}
for _, row := range rows[1:] {
at, err := time.Parse(time.RFC3339Nano, row[0])
if err != nil || at.In(time.Local).Format(time.DateOnly) != l.Day {
t.Fatalf("CSV included another day: %v", row)
}
}
}
// The Usage page's Requests: a page of the ledger at a time, newest first,
// with the filters' rows counted on every page, and Export CSV writing all
// of them to Downloads, never over an earlier file.
func TestUsageLedgerRoutes(t *testing.T) {
home := sandboxHome(t)
if err := os.MkdirAll(filepath.Join(home, "Downloads"), 0o755); err != nil {
t.Fatal(err)
}
now := holdUsageClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
at := usage.Today.Since(now).Add(time.Second)
for i, r := range []usage.Record{
{RouteID: 123, Agent: "codex", Provider: "relay", Model: "gpt-6-sol", Requested: "sol", Served: "gpt-6-luna", Input: 10, Output: 1, Status: 200},
{Agent: "claude", Provider: "anthropic", Model: "claude-sonnet-5", Requested: "sonnet", Input: 20, Output: 2, Status: 200},
{RouteID: 123, Agent: "codex", Provider: "relay", Model: "gpt-6-sol", Requested: "sol", Status: 429},
} {
r.Time = at.Add(time.Duration(i) * time.Second)
usage.Append(r)
}
mux := http.NewServeMux()
usageRoutes(mux, folderOnly{})
get := func(q string) ledgerJSON {
t.Helper()
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", "/api/usage/requests?"+q, nil))
var l ledgerJSON
if err := json.Unmarshal(w.Body.Bytes(), &l); w.Code != 200 || err != nil {
t.Fatalf("%s: %d %s", q, w.Code, w.Body)
}
return l
}
l := get("period=all&route=123")
if l.Total != 2 || l.Rows[0].RouteID != 123 || l.Rows[1].RouteID != 123 {
t.Fatalf("route attempts: %+v", l)
}
if l = get("period=all&route=999"); l.Total != 0 {
t.Fatalf("missing route: %+v", l)
}
l = get("period=today&offset=1&limit=1")
if l.Total != 3 || l.Offset != 1 || len(l.Rows) != 1 || l.Rows[0].Requested != "sonnet" {
t.Fatalf("second page: %+v", l)
}
if len(l.Agents) != 2 || l.Calls != 3 || l.Errors != 1 {
t.Fatalf("agents and totals: %+v", l)
}
l = get("period=today")
if len(l.Rows) != 3 || l.Rows[0].Status != 429 || !l.Rows[2].Swapped || l.Rows[2].Served != "gpt-6-luna" {
t.Fatalf("newest first, the swap marked: %+v", l.Rows)
}
// the chart over them: every row of the filter, by the hour, however the page is cut
series := func(l ledgerJSON) (calls, in int) {
for _, p := range l.Series {
calls, in = calls+p.Calls, in+p.Input
}
return
}
paged := get("period=today&offset=1&limit=1")
if calls, in := series(paged); paged.Bucket != "hour" || len(paged.Series) != 24 || calls != 3 || in != 30 {
t.Fatalf("the chart's hours: %s, %d points, %d calls, %d in", paged.Bucket, len(paged.Series), calls, in)
}
if calls, _ := series(get("period=today&agent=claude")); calls != 1 {
t.Fatalf("the chart follows the filter: %d calls", calls)
}
// switching to a provider: its rows and its chart, and every provider still
// there to switch to, in the filter and in the ranking by provider
l = get("period=today&provider=relay")
if l.Total != 2 || l.Calls != 2 || len(l.Providers) != 2 || l.Providers[1].Name != "relay" || l.Providers[1].Icon != "generic" {
t.Fatalf("one provider: %+v", l)
}
if calls, _ := series(l); calls != 2 || len(l.By["provider"]) != 2 || len(l.By["agent"]) != 1 || l.By["agent"][0].ID != "codex" {
t.Fatalf("its chart and rankings: %d calls, %+v", calls, l.By)
}
if by := get("period=today").By; len(by["provider"]) != 2 || by["provider"][0].ID != "anthropic" || by["provider"][0].Calls != 1 || len(by["model"]) != 2 {
t.Fatalf("the rankings: %+v", by)
}
if l = get("period=today&provider=nobody"); l.Total != 0 || len(l.Providers) != 2 || len(l.By["provider"]) != 2 {
t.Fatalf("an unknown provider: %+v", l)
}
if l = get("period=today&failed=1"); l.Total != 1 || l.Rows[0].Status != 429 {
t.Fatalf("failed only: %+v", l)
}
if l = get("period=today&agent=claude"); l.Total != 1 || l.Rows[0].Agent != "claude" {
t.Fatalf("one agent: %+v", l)
}
if l = get("period=today&q=LUNA"); l.Total != 1 || l.Rows[0].Served != "gpt-6-luna" {
t.Fatalf("search: %+v", l)
}
if l = get("period=today&offset=99"); l.Total != 3 || len(l.Rows) != 0 {
t.Fatalf("past the end: %+v", l)
}
export := func() (string, int) {
t.Helper()
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("POST", "/api/usage/requests/export?period=today&agent=codex", strings.NewReader("{}")))
var out struct {
Path string
Rows int
}
if err := json.Unmarshal(w.Body.Bytes(), &out); w.Code != 200 || err != nil {
t.Fatalf("export: %d %s", w.Code, w.Body)
}
return out.Path, out.Rows
}
p1, n := export()
p2, _ := export()
day := now.Format("2006-01-02")
if n != 2 || p1 != filepath.Join("~", "Downloads", "magpie-requests-today-"+day+".csv") || p2 != filepath.Join("~", "Downloads", "magpie-requests-today-"+day+"-2.csv") {
t.Fatalf("export: %s %s %d", p1, p2, n)
}
b, err := os.ReadFile(filepath.Join(home, "Downloads", "magpie-requests-today-"+day+".csv"))
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(string(b)), "\n")
if len(lines) != 3 || !strings.HasPrefix(lines[0], "time,agent,requested_model,") || !strings.Contains(lines[2], ",sol,relay,,gpt-6-sol,gpt-6-luna,true,") {
t.Fatalf("csv:\n%s", b)
}
}
// What was said in a request comes from the agent's session file when the
// row is opened: a call the file has by its time, or a gateway request by the
// span it took; and why not when it can't be told — no session named, an agent
// whose files magpie doesn't read, a call the files don't have.
func TestUsageRequestContent(t *testing.T) {
home := sandboxHome(t)
dir := filepath.Join(home, ".claude", "projects", "-work-app")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
base := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
at := func(s int) string { return base.Add(time.Duration(s) * time.Second).Format("2006-01-02T15:04:05.000Z") }
lines := []string{
`{"type":"user","timestamp":"` + at(1) + `","sessionId":"sess1","message":{"role":"user","content":"What is 2+2?"}}`,
`{"type":"assistant","timestamp":"` + at(3) + `","sessionId":"sess1","requestId":"req_a","message":{"id":"m1","model":"claude-opus-5-5","content":[{"type":"text","text":"Four."}],"usage":{"input_tokens":5,"output_tokens":2}}}`,
}
if err := os.WriteFile(filepath.Join(dir, "sess1.jsonl"), []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
usageRoutes(mux, folderOnly{})
get := func(agent, session string, from, to time.Time) (contentJSON, int) {
t.Helper()
q := url.Values{"agent": {agent}, "session": {session}, "from": {from.Format(time.RFC3339Nano)}, "to": {to.Format(time.RFC3339Nano)}}
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", "/api/usage/requests/content?"+q.Encode(), nil))
var c contentJSON
if w.Code == 200 {
if err := json.Unmarshal(w.Body.Bytes(), &c); err != nil {
t.Fatalf("%s", w.Body)
}
}
return c, w.Code
}
call := base.Add(3 * time.Second)
// a call of the file, by its own time
c, _ := get("claude-desktop", "sess1", call.Add(-time.Millisecond), call.Add(time.Millisecond))
if !c.Found || c.Model != "claude-opus-5-5" || len(c.Input) != 1 || c.Input[0].Text != "What is 2+2?" || len(c.Output) != 1 || c.Output[0].Text != "Four." {
t.Fatalf("a call by its time: %+v", c)
}
// a request the gateway logged, began a little before the file wrote the call
if c, _ = get("claude", "sess1", base.Add(time.Second), base.Add(35*time.Second)); !c.Found || c.Output[0].Text != "Four." {
t.Fatalf("by the span of the request: %+v", c)
}
for _, x := range []struct {
agent, session, why string
from, to time.Time
}{
{"claude", "", "session", call, call},
{"Bob", "sess1", "agent", call, call},
{"claude", "nobody", "missing", call, call},
{"claude", "sess1", "missing", call.Add(time.Hour), call.Add(2 * time.Hour)},
} {
if c, _ = get(x.agent, x.session, x.from, x.to); c.Found || c.Why != x.why || c.Input == nil || c.Output == nil {
t.Errorf("%s/%s: found %v, why %q, want %q", x.agent, x.session, c.Found, c.Why, x.why)
}
}
if _, code := get("claude", "sess1", call, call); code != 200 {
t.Fatal(code)
}
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", "/api/usage/requests/content?agent=claude&session=s&from=now&to=never", nil))
if w.Code != http.StatusBadRequest {
t.Fatalf("times that are not: %d", w.Code)
}
}
// Provider/agent facets come from the same snapshot as rows, even with both
// filters set. Calls without recorded routing are shown as local sessions.
func TestLedgerSingleSnapshotAndLocalSession(t *testing.T) {
sandboxHome(t)
now := holdUsageClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
before := usage.LogCalls
reads := 0
usage.LogCalls = func(time.Time) []sessions.Call {
reads++
return []sessions.Call{{Time: now, Agent: "claude", Session: "s", Model: "claude-sonnet-5", Tokens: sessions.Tokens{Input: 10, Output: 2}}}
}
t.Cleanup(func() { usage.LogCalls = before })
l := ledgerPage(usage.Today, usage.Filter{Provider: usage.UnknownProvider, Agent: "claude"}, 0, 1)
if reads != 1 {
t.Fatalf("logs loaded %d times", reads)
}
if l.Total != 1 || l.Rows[0].ProviderName != "Local session" || len(l.Providers) != 1 || l.Providers[0].Name != "Local session" || l.By["provider"][0].Name != "Local session" {
t.Fatalf("local session names %+v", l)
}
if l.Rows[0].Provider != usage.UnknownProvider || l.Rows[0].Host != "" {
t.Fatalf("model must not establish route/account: %+v", l.Rows[0])
}
}
// A model at a provider is named by both, with the provider's icon, so
// one model's speed at each provider reads apart (inaction on Discord).
func TestLedgerNamesModelAtProvider(t *testing.T) {
sandboxHome(t)
now := holdUsageClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
before := usage.LogCalls
usage.LogCalls = func(time.Time) []sessions.Call {
return []sessions.Call{{Time: now, Agent: "claude", Session: "s", Model: "claude-sonnet-5", Tokens: sessions.Tokens{Input: 10, Output: 2}}}
}
t.Cleanup(func() { usage.LogCalls = before })
l := ledgerPage(usage.Today, usage.Filter{}, 0, 1)
at := l.By["modelAt"]
if len(at) != 1 || at[0].ID != usage.ModelAtKey(usage.UnknownProvider, "claude-sonnet-5") || at[0].Name != "claude-sonnet-5 · Local session" {
t.Fatalf("model at provider %+v", at)
}
}
func TestCSVStamp(t *testing.T) {
now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local)
for _, day := range []string{"", "2026-02-30", "../../other", "2026-09-30\""} {
want := "magpie-requests-7d-2026-09-30"
if got := csvStamp(usage.Period("7d"), day, now); got != want {
t.Fatalf("%q: %s", day, got)
}
}
}
// midnightUsageClock sets usage.Clock to read a tenth of a second before
// midnight the first time and a tenth of a second after it from then on, as
// when midnight falls while an answer is worked out. It is set again for
// each answer.
func midnightUsageClock(t *testing.T, midnight time.Time) {
t.Helper()
var read atomic.Bool
old := usage.Clock
usage.Clock = func() time.Time {
if read.Swap(true) {
return midnight.Add(100 * time.Millisecond)
}
return midnight.Add(-100 * time.Millisecond)
}
t.Cleanup(func() { usage.Clock = old })
}
// A period's CSV asked for as midnight falls is named for the day of the
// calls in it, downloaded or saved to Downloads: its rows and its name are
// read at one moment, not the rows of one day under the next day's name.
func TestUsageCSVAtMidnight(t *testing.T) {
home := sandboxHome(t)
if err := os.MkdirAll(filepath.Join(home, "Downloads"), 0o755); err != nil {
t.Fatal(err)
}
midnight := time.Date(2026, 10, 1, 0, 0, 0, 0, time.Local)
usage.Append(usage.Record{Time: midnight.Add(-time.Minute), Agent: "codex", Provider: "relay", Model: "m", Input: 10, Output: 1, Status: 200})
mux := http.NewServeMux()
usageRoutes(mux, folderOnly{})
want := "magpie-requests-today-2026-09-30.csv"
midnightUsageClock(t, midnight)
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", "/api/usage/requests.csv?period=today", nil))
rows, err := csv.NewReader(w.Body).ReadAll()
if got := w.Header().Get("Content-Disposition"); w.Code != 200 || err != nil || len(rows) != 2 || got != `attachment; filename="`+want+`"` {
t.Errorf("download: %d, %s with %d rows (%v), want %s with 2", w.Code, got, len(rows), err, want)
}
midnightUsageClock(t, midnight)
w = httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("POST", "/api/usage/requests/export?period=today", nil))
var out struct {
Path string
Rows int
}
if err := json.Unmarshal(w.Body.Bytes(), &out); w.Code != 200 || err != nil || out.Rows != 1 || out.Path != filepath.Join("~", "Downloads", want) {
t.Errorf("export: %d %s, want %s with 1 row", w.Code, w.Body, want)
}
}
// GET /api/usage/heatmap (#1369): the last 53 weeks a day each, of the
// requests the page's filters keep (here the provider), every day's though a
// day is picked on the page.
func TestUsageHeatmapRoute(t *testing.T) {
sandboxHome(t)
now := holdUsageClock(t, time.Date(2026, 10, 9, 12, 0, 0, 0, time.Local))
for i, at := range []time.Time{now.AddDate(0, 0, -380), now.AddDate(0, 0, -100), now.AddDate(0, 0, -1), now.Add(-time.Hour)} {
usage.Append(usage.Record{Time: at, Agent: "codex", Provider: "relay", Model: "m", Input: 10 + i, Output: 1, Status: 200})
}
usage.Append(usage.Record{Time: now.Add(-time.Hour), Agent: "claude", Provider: "anthropic", Model: "m", Input: 500, Status: 200})
mux := http.NewServeMux()
usageRoutes(mux, folderOnly{})
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", "/api/usage/heatmap?provider=relay&day=2026-10-09&period=today", nil))
var h usage.Heatmap
if err := json.Unmarshal(w.Body.Bytes(), &h); w.Code != 200 || err != nil {
t.Fatalf("heatmap: %d %s", w.Code, w.Body)
}
if h.From != "2025-10-06" || h.To != "2026-10-09" || len(h.Days) != 3 {
t.Fatalf("heatmap: %s", w.Body)
}
if d := h.Days[0]; d.Date != now.AddDate(0, 0, -100).Format(time.DateOnly) || d.Calls != 1 || d.Tokens != 12 {
t.Fatalf("100 days ago: %+v", d)
}
if d := h.Days[2]; d.Date != "2026-10-09" || d.Calls != 1 || d.Tokens != 14 {
t.Fatalf("today: %+v", d)
}
}