diff --git a/internal/gateway/api.go b/internal/gateway/api.go index 9ff93dc..8a58645 100644 --- a/internal/gateway/api.go +++ b/internal/gateway/api.go @@ -433,6 +433,32 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) { } } key := exportKey(r) + // ?period=day|week|month|all switches the whole payload to a calendar + // window (UTC) aggregated off the audit files, instead of the + // since-process-start totals. The CSV exports below are unaffected: they + // take an explicit from/to range and stream, so a period selector there + // would only be a second way to spell the same bounds. + if p := periodFromQuery(r.URL.Query()); p != PeriodAll { + if !ValidPeriod(p) { + writeError(w, http.StatusBadRequest, "bad_period", + "period must be one of day, week, month, all") + return + } + out := g.stats.PeriodSnapshot(p, key, time.Now()) + writeJSON(w, http.StatusOK, map[string]interface{}{ + "period": out.Period, + "from": out.From, + "total": out.Total, + "by_key": out.ByKey, + "by_model": out.Models, + "by_source": out.Srcs, + "by_status": out.Status, + "buckets": out.Bucket, + "truncated": out.Truncated, + "key_names": g.keyNamesFor(), + }) + return + } if r.URL.Query().Get("export") == "csv" { from, _ := strconv.ParseInt(r.URL.Query().Get("from"), 10, 64) to, _ := strconv.ParseInt(r.URL.Query().Get("to"), 10, 64) @@ -540,14 +566,22 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) { return } snap := g.stats.Snapshot(limit, key) - keyNames := map[string]string{} - for _, k := range g.core.ListKeys() { - keyNames[keyID(k.Key)] = k.Name - } - snap["key_names"] = keyNames + snap["key_names"] = g.keyNamesFor() writeJSON(w, http.StatusOK, snap) } +// keyNamesFor is the masked-id -> display-name map every stats payload needs. +// It is keyed by keyID (the mask), not the raw key, because that is what the +// aggregate rows carry — building it in one place stops the period branch and +// the lifetime branch from drifting apart. +func (g *Gateway) keyNamesFor() map[string]string { + names := map[string]string{} + for _, k := range g.core.ListKeys() { + names[keyID(k.Key)] = k.Name + } + return names +} + // handleStatsRecordsAPI pages the request records straight off the audit files. // The dashboard loads only its first screen and asks for the next page as the // user scrolls, so neither side holds the full history: the server keeps no diff --git a/internal/gateway/stats_period.go b/internal/gateway/stats_period.go new file mode 100644 index 0000000..5cf831f --- /dev/null +++ b/internal/gateway/stats_period.go @@ -0,0 +1,234 @@ +package gateway + +import ( + "sort" + "strconv" + "strings" + "time" +) + +// Period is a reporting window for the usage dashboard. The dashboard used to +// have exactly one view — everything since process start — which made a quiet +// morning indistinguishable from a quiet week. Periods give the operator a +// scale to read the numbers at: today vs this week vs this month vs all time. +// +// The set is deliberately calendar-based and UTC-anchored. A rolling 24h window +// would put "today" and "the last day" at different boundaries, so the same +// number would move between two cards depending on when you looked; calendar +// days are what people actually mean by "today". UTC also matches the billing +// plugin's peak-window arithmetic, so a peak-rate hour does not land in a +// different day in the cost view than in the usage view. +type Period string + +const ( + // PeriodDay is the current UTC calendar day. + PeriodDay Period = "day" + // PeriodWeek is the current ISO week (Mon 00:00 UTC to now). + PeriodWeek Period = "week" + // PeriodMonth is the current UTC calendar month. + PeriodMonth Period = "month" + // PeriodAll is since process start — the only view backed by the + // in-memory aggregates, and the only one available when no audit file is + // configured. + PeriodAll Period = "all" +) + +// ValidPeriod reports whether p is a period the aggregator understands. +// An unknown period is a client error, not a silent fallback to "all": a +// dashboard that quietly shows lifetime totals when the caller asked for today +// is worse than one that refuses. +func ValidPeriod(p Period) bool { + switch p { + case PeriodDay, PeriodWeek, PeriodMonth, PeriodAll: + return true + } + return false +} + +// periodStart returns the inclusive start of the window for p at time now. +// Only PeriodDay/Week/Month are meaningful here; PeriodAll returns 0, which +// every "from > 0" bounds check treats as unbounded. +func periodStart(p Period, now time.Time) int64 { + now = now.UTC() + switch p { + case PeriodDay: + return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).UnixMilli() + case PeriodWeek: + // ISO week starts Monday. Go's Weekday() is Sunday=0, so the shift + // below is 1 on Sunday and 0 on Monday..Saturday. + off := (int(now.Weekday()) + 6) % 7 + d := now.AddDate(0, 0, -off) + return time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, time.UTC).UnixMilli() + case PeriodMonth: + return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).UnixMilli() + } + return 0 +} + +// PeriodBucket is one labelled point on the dashboard's timeline. Buckets are +// the aggregation grain for a period: hourly for a day (so a spike is visible +// inside "today"), daily for a week or month (so a week is not 7×24 points +// wide and a month is not 31×24), and empty for "all" — a lifetime total has +// no meaningful timeline, and pretending otherwise by drawing 500 hourly +// buckets of rolling memory would be a different lie. +type PeriodBucket struct { + Bucket string `json:"bucket"` + Stat +} + +// PeriodSnapshot is the period-scoped twin of Snapshot's payload: the same +// totals and the same by_* rows, plus a timeline. It deliberately mirrors +// Snapshot's field names so the dashboard's table painters work unchanged — +// paintModelTable(st.by_model) reads rows of {name, ...Stat}, and that shape +// does not care where the numbers came from. +type PeriodSnapshot struct { + Period Period `json:"period"` + From int64 `json:"from"` // unix millis, 0 when PeriodAll + Total Stat `json:"total"` + ByKey []StatsRow `json:"by_key"` + Models []StatsRow `json:"by_model"` + Srcs []StatsRow `json:"by_source"` + Status []agrRow `json:"by_status"` + Bucket []PeriodBucket `json:"buckets"` + // Truncated marks that the window was clipped by the retained audit + // history, so the numbers are a lower bound rather than the true period + // total. The dashboard shows this next to the numbers rather than letting + // a rotated-away week read as "that week had no traffic". + Truncated bool `json:"truncated"` +} + +// bucketKey maps a record's timestamp onto the timeline grain for p. +// Daily buckets are stamped at UTC midnight; hourly buckets carry the hour. +func bucketKey(p Period, ms int64) string { + t := time.UnixMilli(ms).UTC() + if p == PeriodDay { + return t.Format("2006-01-02T15") + } + return t.Format("2006-01-02") +} + +// bucketOf returns the bucket label plus the truncated-flag side effects of +// walking a file: a record older than the requested window means the window +// starts before the retained history, and the file may have been cut short. +func (s *Stats) PeriodSnapshot(p Period, key string, now time.Time) PeriodSnapshot { + out := PeriodSnapshot{Period: p} + from := periodStart(p, now) + out.From = from + + // "all" is exactly what Snapshot already answers, from the in-memory + // aggregates, and it is the one view that must keep working with no audit + // file configured at all (a fresh dev setup, or an operator who turned + // auditing off). Serving it from the same code path keeps the dashboard's + // "total" card identical whether or not a period is selected. + if p == PeriodAll { + snap := s.Snapshot(firstScreenRecords, key) + if tot, ok := snap["total"].(Stat); ok { + out.Total = tot + } + out.ByKey, _ = snap["by_key"].([]StatsRow) + out.Models, _ = snap["by_model"].([]StatsRow) + out.Srcs, _ = snap["by_source"].([]StatsRow) + out.Status, _ = snap["by_status"].([]agrRow) + // replay_partial is the same "these numbers came from a bounded + // tail" caveat, carried through under this view's own name. + out.Truncated, _ = snap["replay_partial"].(bool) + return out + } + + // A bounded window is aggregated from the audit files, because the + // in-memory aggregates are lifetime totals and the ring buffer holds only + // maxRecs records (500 by default). Reading the ring would silently + // under-report any period longer than the last few hundred requests. + total := Stat{} + byKey := map[string]*Stat{} + byModel := map[string]*Stat{} + bySrc := map[string]*Stat{} + byStatus := map[string]*Stat{} + buckets := map[string]*Stat{} + + var seen int + err := s.StreamAuditRecords(from, 0, key, func(r Req) error { + seen++ + incStatus(&total, "", r) + inc(byKey, r.Key, r) + if r.Model != "" { + inc(byModel, r.Model, r) + } + inc(bySrc, r.Source, r) + if r.Status != 0 { + inc(byStatus, strconv.Itoa(r.Status), r) + } + k := bucketKey(p, r.Time) + b := buckets[k] + if b == nil { + b = &Stat{} + buckets[k] = b + } + incStatus(b, k, r) + return nil + }) + + out.Total = total + out.ByKey = rows(byKey) + out.Models = rows(byModel) + out.Srcs = rows(bySrc) + bs := make([]agrRow, 0, len(byStatus)) + for code, st := range byStatus { + bs = append(bs, agrRow{Name: code, Stat: *st}) + } + sort.Slice(bs, func(i, j int) bool { + ci, _ := strconv.Atoi(bs[i].Name) + cj, _ := strconv.Atoi(bs[j].Name) + return ci < cj + }) + out.Status = bs + + ks := make([]string, 0, len(buckets)) + for k := range buckets { + ks = append(ks, k) + } + // Chronological, string-sorted: "2006-01-02T15" and "2006-01-02" both + // sort lexicographically in time order, so no date parsing is needed. + sort.Strings(ks) + out.Bucket = make([]PeriodBucket, 0, len(ks)) + for _, k := range ks { + out.Bucket = append(out.Bucket, PeriodBucket{Bucket: k, Stat: *buckets[k]}) + } + + // The window is only "complete" if the audit walk actually reached back + // far enough to cover it. Two ways it cannot: + // + // 1. The walk found nothing at all in a window that certainly had + // traffic, because the files holding it rotated away. + // 2. The walk errored part-way (I/O), leaving a partial total. + // + // Case 2 is reported from err; case 1 from seen == 0 combined with the + // caller having asked for a bounded window. It is deliberately + // conservative: a genuinely empty hour is rare enough that flagging it as + // possibly-truncated costs one tooltip, whereas silently under-reporting a + // month because rotation ate it is a wrong number with no indication. + if err != nil || (seen == 0 && p != PeriodAll) { + out.Truncated = true + } + return out +} + +// periodFromQuery parses the ?period= parameter. An empty value means "all" so +// that existing callers of /api/stats keep seeing exactly what they saw. +// A malformed value is rejected by the caller (ValidPeriod) rather than +// defaulting, so a typo in a bookmarked URL surfaces as an error instead of +// silently switching the operator to lifetime totals. +func periodFromQuery(q map[string][]string) Period { + v := strings.ToLower(strings.TrimSpace(firstQuery(q, "period"))) + if v == "" { + return PeriodAll + } + return Period(v) +} + +func firstQuery(q map[string][]string, key string) string { + if vs, ok := q[key]; ok && len(vs) > 0 { + return vs[0] + } + return "" +} diff --git a/internal/gateway/stats_period_test.go b/internal/gateway/stats_period_test.go new file mode 100644 index 0000000..ae86ae7 --- /dev/null +++ b/internal/gateway/stats_period_test.go @@ -0,0 +1,321 @@ +package gateway + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" +) + +// auditPeriod writes records straight into an audit file, which is what the +// period aggregator reads. Going through Record() would also rotate/prune the +// file; here the file shape is the thing under test. +func auditPeriod(t *testing.T, path string, recs ...Req) { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatalf("create audit file: %v", err) + } + defer f.Close() + for _, r := range recs { + if r.Time == 0 { + t.Fatal("test record needs an explicit timestamp") + } + if r.Type == "" { + r.Type = "chat" + } + b, err := json.Marshal(r) + if err != nil { + t.Fatalf("marshal record: %v", err) + } + if _, err := f.Write(append(b, '\n')); err != nil { + t.Fatalf("write record: %v", err) + } + } +} + +func periodStats(t *testing.T, auditPath string) *Stats { + t.Helper() + s := NewStats(10) + s.mu.Lock() + s.auditPath = auditPath + s.mu.Unlock() + return s +} + +// TestPeriodWindowBoundaries is the boundary contract: a day window covers +// exactly today-from-midnight, a week window starts Monday (not Sunday — the +// Go Weekday() trap), and a month window starts on the 1st. All UTC. +func TestPeriodWindowBoundaries(t *testing.T) { + // Wednesday 2026-03-11 15:30 UTC. + now := time.Date(2026, 3, 11, 15, 30, 0, 0, time.UTC) + day := time.Date(2026, 3, 11, 0, 0, 0, 0, time.UTC) + if got := time.UnixMilli(periodStart(PeriodDay, now)).UTC(); !got.Equal(day) { + t.Errorf("day window starts %s, want %s", got, day) + } + // Wednesday minus 2 days = Monday the 9th. + monday := time.Date(2026, 3, 9, 0, 0, 0, 0, time.UTC) + if got := time.UnixMilli(periodStart(PeriodWeek, now)).UTC(); !got.Equal(monday) { + t.Errorf("week window starts %s, want Monday %s", got, monday) + } + // Same instant on a Sunday must still start on the PRECEDING Monday, + // never on the Sunday itself. This is the off-by-one that + // int(now.Weekday()) would introduce (Sunday=0 → no shift). + sun := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) // Sunday + prevMon := time.Date(2026, 3, 9, 0, 0, 0, 0, time.UTC) + if got := time.UnixMilli(periodStart(PeriodWeek, sun)).UTC(); !got.Equal(prevMon) { + t.Errorf("Sunday week window starts %s, want the Monday before it %s", got, prevMon) + } + first := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + if got := time.UnixMilli(periodStart(PeriodMonth, now)).UTC(); !got.Equal(first) { + t.Errorf("month window starts %s, want %s", got, first) + } + if got := periodStart(PeriodAll, now); got != 0 { + t.Errorf("all window from = %d, want 0 (unbounded)", got) + } +} + +// TestPeriodAggregationExcludesOlderRecords is the reason the aggregator reads +// the audit file: a record outside the window must not be counted, even though +// it sits in the very same file, right next to records that are. +func TestPeriodAggregationExcludesOlderRecords(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.jsonl") + now := time.Date(2026, 3, 11, 15, 0, 0, 0, time.UTC) + h := func(n int) int64 { return now.Add(-time.Duration(n) * time.Hour).UnixMilli() } + + auditPeriod(t, path, + // inside today + Req{Time: h(1), Model: "m", Source: "s", Prompt: 100, Compl: 10, LatMs: 5, OK: true}, + Req{Time: h(2), Model: "m", Source: "s", Prompt: 200, Compl: 20, LatMs: 7, OK: true}, + // outside today (yesterday) but inside the week + Req{Time: now.AddDate(0, 0, -1).UnixMilli(), Model: "old", Source: "s", Prompt: 999, Compl: 99, OK: true}, + ) + s := periodStats(t, path) + + day := s.PeriodSnapshot(PeriodDay, "", now) + if day.Total.Reqs != 2 { + t.Errorf("day window counted %d requests, want 2 (yesterday's record must be excluded)", day.Total.Reqs) + } + if day.Total.Prompt != 300 { + t.Errorf("day prompt tokens = %d, want 300", day.Total.Prompt) + } + + week := s.PeriodSnapshot(PeriodWeek, "", now) + if week.Total.Reqs != 3 { + t.Errorf("week window counted %d requests, want 3", week.Total.Reqs) + } + if week.Total.Prompt != 1299 { + t.Errorf("week prompt tokens = %d, want 1299", week.Total.Prompt) + } +} + +// TestPeriodDimensionsAggregateIndependently checks the by_* rows actually +// split by their own key rather than all collapsing into one row. +func TestPeriodDimensionsAggregateIndependently(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.jsonl") + now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC) + auditPeriod(t, path, + Req{Time: now.UnixMilli(), Key: "k1", Model: "alpha", Source: "s1", Prompt: 10, OK: true}, + Req{Time: now.UnixMilli(), Key: "k1", Model: "beta", Source: "s2", Prompt: 20, OK: true}, + Req{Time: now.UnixMilli(), Key: "k2", Model: "alpha", Source: "s1", Prompt: 30, OK: true}, + ) + s := periodStats(t, path) + got := s.PeriodSnapshot(PeriodDay, "", now) + + if len(got.Models) != 2 { + t.Fatalf("by_model has %d rows, want 2: %+v", len(got.Models), got.Models) + } + // alpha = 10 + 30 + for _, r := range got.Models { + if r.Name == "alpha" && r.Prompt != 40 { + t.Errorf("alpha prompt = %d, want 40", r.Prompt) + } + } + if len(got.Srcs) != 2 { + t.Errorf("by_source has %d rows, want 2: %+v", len(got.Srcs), got.Srcs) + } + if len(got.ByKey) != 2 { + t.Errorf("by_key has %d rows, want 2: %+v", len(got.ByKey), got.ByKey) + } + // The rows must add up to the total, or the dashboard shows a total that + // disagrees with its own table. + var sum int64 + for _, r := range got.Models { + sum += r.Prompt + } + if sum != got.Total.Prompt { + t.Errorf("by_model prompts sum to %d but total is %d — the table would contradict the KPI", sum, got.Total.Prompt) + } +} + +// TestPeriodDayBucketsAreHourlyAndOrdered pins the timeline grain and its +// order. Day = hourly (a spike must be visible inside "today"); week/month = +// daily. Sorted chronologically, because a chart fed unsorted buckets draws +// nonsense. +func TestPeriodDayBucketsAreHourlyAndOrdered(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.jsonl") + now := time.Date(2026, 3, 11, 15, 0, 0, 0, time.UTC) + // Deliberately out of order in the file. + auditPeriod(t, path, + Req{Time: now.Add(-2 * time.Hour).UnixMilli(), Model: "m", Prompt: 1, OK: true}, + Req{Time: now.Add(-4 * time.Hour).UnixMilli(), Model: "m", Prompt: 1, OK: true}, + Req{Time: now.UnixMilli(), Model: "m", Prompt: 1, OK: true}, + ) + s := periodStats(t, path) + got := s.PeriodSnapshot(PeriodDay, "", now) + if len(got.Bucket) != 3 { + t.Fatalf("day buckets = %d, want 3 (hourly): %+v", len(got.Bucket), got.Bucket) + } + for i := 1; i < len(got.Bucket); i++ { + if got.Bucket[i-1].Bucket >= got.Bucket[i].Bucket { + t.Errorf("day buckets not chronological at %d: %q >= %q", + i, got.Bucket[i-1].Bucket, got.Bucket[i].Bucket) + } + } + // Weekly grain is daily, so the same three records collapse to one day. + week := s.PeriodSnapshot(PeriodWeek, "", now) + if len(week.Bucket) != 1 { + t.Errorf("week buckets = %d, want 1 (daily grain): %+v", len(week.Bucket), week.Bucket) + } +} + +// TestPeriodKeyFilterIsolatesKeys guards the multi-tenant boundary: asking for +// one key must never return another key's rows in any dimension. +func TestPeriodKeyFilterIsolatesKeys(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.jsonl") + now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC) + auditPeriod(t, path, + Req{Time: now.UnixMilli(), Key: "alice", Model: "m", Source: "s", Prompt: 10, OK: true}, + Req{Time: now.UnixMilli(), Key: "bob", Model: "m", Source: "s", Prompt: 999, OK: true}, + ) + s := periodStats(t, path) + got := s.PeriodSnapshot(PeriodDay, "alice", now) + if got.Total.Reqs != 1 || got.Total.Prompt != 10 { + t.Errorf("alice's window = %d reqs / %d prompt, want 1 / 10 — another key leaked in", + got.Total.Reqs, got.Total.Prompt) + } + if len(got.Srcs) != 1 || got.Srcs[0].Prompt != 10 { + t.Errorf("by_source leaked: %+v", got.Srcs) + } +} + +// TestPeriodAllUsesLifetimeAggregates checks "all" still answers from the +// in-memory aggregates (no audit file needed) and carries the same rows the +// dashboard already renders. +func TestPeriodAllUsesLifetimeAggregates(t *testing.T) { + s := NewStats(100) + now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC) + s.Record(Req{Time: now.AddDate(0, 0, -40).UnixMilli(), Key: "k", Model: "m", Source: "s", Prompt: 7, OK: true}) + s.Record(Req{Time: now.UnixMilli(), Key: "k", Model: "m", Source: "s", Prompt: 3, OK: true}) + + got := s.PeriodSnapshot(PeriodAll, "", now) + if got.Total.Reqs != 2 || got.Total.Prompt != 10 { + t.Errorf("all view = %d reqs / %d prompt, want 2 / 10 (lifetime)", got.Total.Reqs, got.Total.Prompt) + } + if len(got.Models) != 1 || got.Models[0].Prompt != 10 { + t.Errorf("all view by_model = %+v, want one row with 10 prompt", got.Models) + } + // A lifetime total has no timeline to draw; buckets must stay empty + // rather than inventing 500 hourly points. + if len(got.Bucket) != 0 { + t.Errorf("all view produced %d buckets, want 0", len(got.Bucket)) + } +} + +// TestPeriodEmptyWindowFlagsTruncated covers the "rotated away" case: a bounded +// window with nothing to show may mean "quiet day" or "the files are gone". +// The aggregator must not report a confidently wrong zero. +func TestPeriodEmptyWindowFlagsTruncated(t *testing.T) { + dir := t.TempDir() + s := periodStats(t, filepath.Join(dir, "audit.jsonl")) // file does not exist + now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC) + got := s.PeriodSnapshot(PeriodDay, "", now) + if got.Total.Reqs != 0 { + t.Errorf("empty window reqs = %d, want 0", got.Total.Reqs) + } + if !got.Truncated { + t.Error("★ an empty bounded window must be flagged truncated — otherwise a " + + "rotated-away week reads as 'no traffic that week'") + } +} + +// TestValidPeriodRejectsUnknown guards the deliberate strictness: a typo must +// be an error, not a silent fallback to lifetime totals. +func TestValidPeriodRejectsUnknown(t *testing.T) { + for _, ok := range []Period{PeriodDay, PeriodWeek, PeriodMonth, PeriodAll} { + if !ValidPeriod(ok) { + t.Errorf("ValidPeriod(%q) = false, want true", ok) + } + } + for _, bad := range []Period{"", "year", "hour", "today", "DAY "} { + if ValidPeriod(bad) { + t.Errorf("ValidPeriod(%q) = true, want false", bad) + } + } +} + +// TestPeriodStatusDimensionCountsFailures covers by_status, which nothing else +// exercised — an aggregation branch with no test is exactly how a period view +// silently loses the failure pie. M8 (disabling the status dimension) is +// caught only because this test exists. +func TestPeriodStatusDimensionCountsFailures(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.jsonl") + now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC) + auditPeriod(t, path, + Req{Time: now.UnixMilli(), Model: "m", Source: "s", Status: 200, OK: true, Prompt: 5}, + Req{Time: now.UnixMilli(), Model: "m", Source: "s", Status: 502, OK: false, Prompt: 1}, + Req{Time: now.UnixMilli(), Model: "m", Source: "s", Status: 502, OK: false, Prompt: 1}, + Req{Time: now.UnixMilli(), Model: "m", Source: "s", Status: 429, OK: false, Prompt: 1}, + ) + s := periodStats(t, path) + got := s.PeriodSnapshot(PeriodDay, "", now) + + byCode := map[string]int64{} + for _, r := range got.Status { + byCode[r.Name] = r.Reqs + } + if len(got.Status) != 3 { + t.Fatalf("by_status has %d rows, want 3 (200/502/429): %+v", len(got.Status), got.Status) + } + if byCode["200"] != 1 || byCode["502"] != 2 || byCode["429"] != 1 { + t.Errorf("by_status counts = %v, want 200:1 502:2 429:1", byCode) + } + // Status codes must sort numerically: "429" before "502" lexically would + // render the failure legend in the wrong order. + if got.Status[0].Name != "200" || got.Status[1].Name != "429" || got.Status[2].Name != "502" { + t.Errorf("by_status not numerically sorted: %s, %s, %s", + got.Status[0].Name, got.Status[1].Name, got.Status[2].Name) + } + // The failure rows must agree with the OK/Err split on the total, or the + // pie and the KPI row contradict each other. + if got.Total.OK != 1 || got.Total.Err != 3 { + t.Errorf("total ok/err = %d/%d, want 1/3", got.Total.OK, got.Total.Err) + } +} + +func TestPeriodFromQuery(t *testing.T) { + cases := []struct { + query string + want Period + }{ + {"", PeriodAll}, // no selector → unchanged legacy behaviour + {"?period=day", PeriodDay}, + {"?period=WEEK", PeriodWeek}, + {"?period=month", PeriodMonth}, + {"?period=all", PeriodAll}, + } + for _, c := range cases { + r := httptest.NewRequest(http.MethodGet, "/api/stats"+c.query, nil) + if got := periodFromQuery(r.URL.Query()); got != c.want { + t.Errorf("periodFromQuery(%q) = %q, want %q", c.query, got, c.want) + } + } +} diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 367ad95..ec25559 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -1038,6 +1038,13 @@ recsEnd: "已到最早记录", recsRotated: "审计日志已轮转,已从最新记录重新加载", recsNewest: "回到最新", + periodLabel: "统计周期", + periodDay: "今日", + periodWeek: "本周", + periodMonth: "本月", + periodAll: "全部", + periodTruncated: + "统计周期可能不完整(审计日志已轮转),数值为下限。", recsPartial: "部分审计日志无法读取,统计可能不完整;完整历史请导出 CSV", thTokens: "Tokens", @@ -1294,6 +1301,13 @@ recsEnd: "Reached the oldest record", recsRotated: "The audit log rotated; reloaded from the newest record", recsNewest: "Back to newest", + periodLabel: "Reporting period", + periodDay: "Today", + periodWeek: "This week", + periodMonth: "This month", + periodAll: "All time", + periodTruncated: + "This window may be incomplete (audit log rotated); figures are a lower bound.", recsPartial: "Some audit files could not be read, so these totals may be incomplete; export CSV for the full history", thTokens: "Tokens", @@ -1558,6 +1572,18 @@ return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; }; let statsKeyF = ""; // active key filter for records ('' = all) + // Usage reporting window. "all" keeps the legacy since-start totals; + // day/week/month are calendar windows (UTC) aggregated from the audit + // files server-side. Persisted so a reload keeps the operator's + // chosen scale instead of silently snapping back to lifetime totals. + let statsPeriod = (() => { + try { + const v = localStorage.getItem("gw_stats_period"); + return ["day", "week", "month", "all"].includes(v) ? v : "all"; + } catch (e) { + return "all"; + } + })(); /* ---------- records: on-demand paging ---------- * The records table holds only what is on screen. The first screen comes @@ -1683,6 +1709,17 @@ return; } pane.innerHTML = ` +