Skip to content

Commit ded38e2

Browse files
committed
Add yearly request heatmap drilldown
1 parent ccb7a7a commit ded38e2

10 files changed

Lines changed: 400 additions & 169 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ All endpoints are mounted under `<APP_BASE_PATH>/api/v1`. Protected endpoints re
9393
| GET | `/status` | drain status (last pop, errors, totals) |
9494
| POST | `/sync` | trigger metadata refresh |
9595
| GET | `/usage/overview` | summary + hourly + daily + range-sized 15-minute health grid |
96+
| GET | `/usage/health` | year request matrix + optional selected-day 5-minute detail |
9697
| GET | `/usage/analysis` | aggregations by API / model / both |
9798
| GET | `/usage/events` | paginated raw events |
9899
| GET | `/usage/events/filters` | distinct models + sources |

internal/api/usage_handler.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ func usageHealthHandler(deps UsageDeps) gin.HandlerFunc {
6464
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
6565
return
6666
}
67-
out, err := deps.Service.Health(c.Request.Context(), f, c.Query("month"), time.Now().In(time.Local))
67+
out, err := deps.Service.Health(c.Request.Context(), f, c.Query("year"), c.Query("day"), time.Now().In(time.Local))
6868
if err != nil {
6969
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
7070
return

internal/storage/sqlite/usage.go

Lines changed: 88 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -594,35 +594,70 @@ func (s *Store) healthGrid(ctx context.Context, f storage.UsageFilter, now time.
594594
return s.healthGridRange(ctx, healthFilter)
595595
}
596596

597-
// BuildUsageHealthGrid returns an uncapped day/hour matrix for an explicit
598-
// range. The request matrix uses this for natural months, including 31-day
599-
// months, while the overview summary keeps its compact rolling-window grid.
600-
func (s *Store) BuildUsageHealthGrid(ctx context.Context, f storage.UsageFilter, start, end time.Time) ([][]storage.HealthCell, error) {
597+
func (s *Store) BuildUsageHealthDays(ctx context.Context, f storage.UsageFilter, start, end time.Time) ([]storage.UsageHealthDay, error) {
601598
healthFilter := f
602599
healthFilter.Start = start
603600
healthFilter.End = end
604-
return s.healthGridRange(ctx, healthFilter)
601+
type row struct {
602+
Date string
603+
Total int64
604+
Failed int64
605+
}
606+
var rows []row
607+
if err := s.applyFilter(ctx, healthFilter).
608+
Select(`strftime('%Y-%m-%d', timestamp, 'localtime') AS date,
609+
COUNT(*) AS total,
610+
SUM(CASE WHEN failed = 1 THEN 1 ELSE 0 END) AS failed`).
611+
Group("date").
612+
Scan(&rows).Error; err != nil {
613+
return nil, err
614+
}
615+
byDate := make(map[string]storage.UsageHealthDay, len(rows))
616+
for _, r := range rows {
617+
t, err := time.ParseInLocation("2006-01-02", r.Date, time.Local)
618+
if err != nil {
619+
continue
620+
}
621+
byDate[r.Date] = storage.UsageHealthDay{Date: r.Date, Bucket: t, Total: r.Total, Failed: r.Failed}
622+
}
623+
days := make([]storage.UsageHealthDay, 0, int(end.Sub(start).Hours()/24))
624+
for day := start; day.Before(end); day = day.AddDate(0, 0, 1) {
625+
key := day.Format("2006-01-02")
626+
if cell, ok := byDate[key]; ok {
627+
days = append(days, cell)
628+
} else {
629+
days = append(days, storage.UsageHealthDay{Date: key, Bucket: day})
630+
}
631+
}
632+
return days, nil
605633
}
606634

607-
func (s *Store) ListUsageEventMonths(ctx context.Context, f storage.UsageFilter) ([]storage.UsageHealthMonth, error) {
635+
func (s *Store) BuildUsageHealthDetail(ctx context.Context, f storage.UsageFilter, day time.Time) ([][]storage.HealthCell, error) {
636+
healthFilter := f
637+
healthFilter.Start = day
638+
healthFilter.End = day.AddDate(0, 0, 1)
639+
return s.healthDetailRange(ctx, healthFilter)
640+
}
641+
642+
func (s *Store) ListUsageEventYears(ctx context.Context, f storage.UsageFilter) ([]storage.UsageHealthYear, error) {
608643
type row struct {
609-
Month string
644+
Year int
610645
Total int64
611646
}
612647
var rows []row
613648
if err := s.applyFilter(ctx, f).
614-
Select(`strftime('%Y-%m', timestamp, 'localtime') AS month, COUNT(*) AS total`).
615-
Group("month").
616-
Order("month DESC").
649+
Select(`CAST(strftime('%Y', timestamp, 'localtime') AS INTEGER) AS year, COUNT(*) AS total`).
650+
Group("year").
651+
Order("year DESC").
617652
Scan(&rows).Error; err != nil {
618653
return nil, err
619654
}
620-
out := make([]storage.UsageHealthMonth, 0, len(rows))
655+
out := make([]storage.UsageHealthYear, 0, len(rows))
621656
for _, r := range rows {
622-
if r.Month == "" {
657+
if r.Year <= 0 {
623658
continue
624659
}
625-
out = append(out, storage.UsageHealthMonth{Month: r.Month, Total: r.Total})
660+
out = append(out, storage.UsageHealthYear{Year: r.Year, Total: r.Total})
626661
}
627662
return out, nil
628663
}
@@ -672,6 +707,46 @@ func (s *Store) healthGridRange(ctx context.Context, healthFilter storage.UsageF
672707
return grid, nil
673708
}
674709

710+
func (s *Store) healthDetailRange(ctx context.Context, healthFilter storage.UsageFilter) ([][]storage.HealthCell, error) {
711+
type row struct {
712+
Bucket string
713+
Total int64
714+
Failed int64
715+
}
716+
var rows []row
717+
if err := s.applyFilter(ctx, healthFilter).
718+
Select(`strftime('%Y-%m-%d %H:%M', datetime((strftime('%s', timestamp) / 300) * 300, 'unixepoch')) AS bucket,
719+
COUNT(*) AS total,
720+
SUM(CASE WHEN failed = 1 THEN 1 ELSE 0 END) AS failed`).
721+
Group("bucket").
722+
Scan(&rows).Error; err != nil {
723+
return nil, err
724+
}
725+
bucketMap := make(map[time.Time]storage.HealthCell, len(rows))
726+
for _, r := range rows {
727+
t, err := time.Parse("2006-01-02 15:04", r.Bucket)
728+
if err != nil {
729+
continue
730+
}
731+
bucketMap[t.UTC()] = storage.HealthCell{Bucket: t.UTC(), Total: r.Total, Failed: r.Failed}
732+
}
733+
734+
grid := make([][]storage.HealthCell, 6)
735+
for row := 0; row < 6; row++ {
736+
grid[row] = make([]storage.HealthCell, 48)
737+
for col := 0; col < 48; col++ {
738+
offset := time.Duration(row*48+col) * 5 * time.Minute
739+
b := healthFilter.Start.Add(offset).UTC()
740+
if cell, ok := bucketMap[b]; ok {
741+
grid[row][col] = cell
742+
} else {
743+
grid[row][col] = storage.HealthCell{Bucket: b}
744+
}
745+
}
746+
}
747+
return grid, nil
748+
}
749+
675750
func foldBuckets(rows []bucketRow, start, end time.Time, step time.Duration, prices map[string]storage.ModelPriceSetting) []storage.UsageBucket {
676751
merged := make(map[time.Time]*storage.UsageBucket)
677752
for _, r := range rows {

internal/storage/store.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ type Store interface {
2626

2727
// Aggregations
2828
BuildUsageOverview(ctx context.Context, f UsageFilter, prices map[string]ModelPriceSetting) (*UsageOverview, error)
29-
BuildUsageHealthGrid(ctx context.Context, f UsageFilter, start, end time.Time) ([][]HealthCell, error)
30-
ListUsageEventMonths(ctx context.Context, f UsageFilter) ([]UsageHealthMonth, error)
29+
BuildUsageHealthDays(ctx context.Context, f UsageFilter, start, end time.Time) ([]UsageHealthDay, error)
30+
BuildUsageHealthDetail(ctx context.Context, f UsageFilter, day time.Time) ([][]HealthCell, error)
31+
ListUsageEventYears(ctx context.Context, f UsageFilter) ([]UsageHealthYear, error)
3132
ListUsageEvents(ctx context.Context, f UsageFilter, p Page, prices map[string]ModelPriceSetting) (*UsageEventsPage, error)
3233
ListUsageEventFilterOptions(ctx context.Context, f UsageFilter) (*UsageEventFilterOptions, error)
3334
ListUsageEventAPIKeys(ctx context.Context, f UsageFilter) ([]string, error)

internal/storage/types.go

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -199,19 +199,30 @@ type UsageOverview struct {
199199
GeneratedAt time.Time `json:"generated_at"`
200200
}
201201

202-
// UsageHealthMonth is one month option available for the request matrix.
203-
type UsageHealthMonth struct {
204-
Month string `json:"month"`
205-
Total int64 `json:"total"`
202+
// UsageHealthYear is one year option available for the request matrix.
203+
type UsageHealthYear struct {
204+
Year int `json:"year"`
205+
Total int64 `json:"total"`
206206
}
207207

208-
// UsageHealthMatrix is the month-sized request matrix payload.
208+
// UsageHealthDay is one day cell in the year-sized request matrix.
209+
type UsageHealthDay struct {
210+
Date string `json:"date"`
211+
Bucket time.Time `json:"bucket"`
212+
Total int64 `json:"total"`
213+
Failed int64 `json:"failed"`
214+
}
215+
216+
// UsageHealthMatrix is the year-sized request matrix payload plus optional
217+
// selected-day 5-minute detail rows.
209218
type UsageHealthMatrix struct {
210-
Month string `json:"month"`
211-
Start time.Time `json:"start"`
212-
End time.Time `json:"end"`
213-
Grid [][]HealthCell `json:"grid"`
214-
Months []UsageHealthMonth `json:"months"`
219+
Year int `json:"year"`
220+
Start time.Time `json:"start"`
221+
End time.Time `json:"end"`
222+
Days []UsageHealthDay `json:"days"`
223+
Years []UsageHealthYear `json:"years"`
224+
SelectedDay string `json:"selected_day,omitempty"`
225+
Detail [][]HealthCell `json:"detail,omitempty"`
215226
}
216227

217228
// UsageSummary is the aggregated totals shown in the overview header.

internal/usage/service.go

Lines changed: 58 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -134,37 +134,42 @@ func (s *Service) Overview(ctx context.Context, f Filter) (*storage.UsageOvervie
134134
return s.store.BuildUsageOverview(ctx, f.toStorage(), prices)
135135
}
136136

137-
// Health returns the month-sized request matrix and the available month list.
138-
func (s *Service) Health(ctx context.Context, f Filter, monthKey string, now time.Time) (*storage.UsageHealthMatrix, error) {
139-
start, err := parseMonth(monthKey, now)
137+
// Health returns the year-sized request matrix and optional selected-day detail.
138+
func (s *Service) Health(ctx context.Context, f Filter, yearKey, dayKey string, now time.Time) (*storage.UsageHealthMatrix, error) {
139+
year, err := parseYear(yearKey, now)
140140
if err != nil {
141141
return nil, err
142142
}
143-
end := start.AddDate(0, 1, 0)
143+
start := time.Date(year, time.January, 1, 0, 0, 0, 0, time.Local)
144+
end := start.AddDate(1, 0, 0)
144145
sf := f.toStorage()
145146
sf.Range = ""
146147
sf.Start = time.Time{}
147148
sf.End = time.Time{}
148149

149-
months, err := s.store.ListUsageEventMonths(ctx, sf)
150+
years, err := s.store.ListUsageEventYears(ctx, sf)
150151
if err != nil {
151152
return nil, err
152153
}
153-
month := start.Format("2006-01")
154154
localNow := now.In(time.Local)
155-
currentMonth := time.Date(localNow.Year(), localNow.Month(), 1, 0, 0, 0, 0, time.Local).Format("2006-01")
156-
months = ensureMonthOption(ensureMonthOption(months, month), currentMonth)
155+
years = ensureYearOption(ensureYearOption(years, year), localNow.Year())
157156

158-
grid, err := s.store.BuildUsageHealthGrid(ctx, sf, start, end)
157+
days, err := s.store.BuildUsageHealthDays(ctx, sf, start, end)
158+
if err != nil {
159+
return nil, err
160+
}
161+
selectedDay, detail, err := s.healthDetail(ctx, sf, start, end, dayKey)
159162
if err != nil {
160163
return nil, err
161164
}
162165
return &storage.UsageHealthMatrix{
163-
Month: month,
164-
Start: start,
165-
End: end,
166-
Grid: grid,
167-
Months: months,
166+
Year: year,
167+
Start: start,
168+
End: end,
169+
Days: days,
170+
Years: years,
171+
SelectedDay: selectedDay,
172+
Detail: detail,
168173
}, nil
169174
}
170175

@@ -358,27 +363,54 @@ func parseTime(in string) (time.Time, error) {
358363
return time.Time{}, fmt.Errorf("unrecognized time %q", in)
359364
}
360365

361-
func parseMonth(in string, now time.Time) (time.Time, error) {
366+
func (s *Service) healthDetail(ctx context.Context, f storage.UsageFilter, yearStart, yearEnd time.Time, dayKey string) (string, [][]storage.HealthCell, error) {
367+
dayKey = strings.TrimSpace(dayKey)
368+
if dayKey == "" {
369+
return "", nil, nil
370+
}
371+
day, err := parseDay(dayKey)
372+
if err != nil {
373+
return "", nil, err
374+
}
375+
if day.Before(yearStart) || !day.Before(yearEnd) {
376+
return "", nil, fmt.Errorf("day %q is outside selected year", dayKey)
377+
}
378+
detail, err := s.store.BuildUsageHealthDetail(ctx, f, day)
379+
if err != nil {
380+
return "", nil, err
381+
}
382+
return day.Format("2006-01-02"), detail, nil
383+
}
384+
385+
func parseYear(in string, now time.Time) (int, error) {
362386
in = strings.TrimSpace(in)
363387
if in == "" {
364-
local := now.In(time.Local)
365-
return time.Date(local.Year(), local.Month(), 1, 0, 0, 0, 0, time.Local), nil
388+
return now.In(time.Local).Year(), nil
389+
}
390+
t, err := time.ParseInLocation("2006", in, time.Local)
391+
if err != nil {
392+
return 0, fmt.Errorf("parse year: %w", err)
366393
}
367-
t, err := time.ParseInLocation("2006-01", in, time.Local)
394+
return t.Year(), nil
395+
}
396+
397+
func parseDay(in string) (time.Time, error) {
398+
in = strings.TrimSpace(in)
399+
t, err := time.ParseInLocation("2006-01-02", in, time.Local)
368400
if err != nil {
369-
return time.Time{}, fmt.Errorf("parse month: %w", err)
401+
return time.Time{}, fmt.Errorf("parse day: %w", err)
370402
}
371-
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.Local), nil
403+
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local), nil
372404
}
373405

374-
func ensureMonthOption(months []storage.UsageHealthMonth, month string) []storage.UsageHealthMonth {
375-
for _, m := range months {
376-
if m.Month == month {
377-
return months
406+
func ensureYearOption(years []storage.UsageHealthYear, year int) []storage.UsageHealthYear {
407+
for _, y := range years {
408+
if y.Year == year {
409+
return years
378410
}
379411
}
380-
out := append([]storage.UsageHealthMonth{{Month: month}}, months...)
381-
sort.SliceStable(out, func(i, j int) bool { return out[i].Month > out[j].Month })
412+
out := append([]storage.UsageHealthYear{{Year: year}}, years...)
413+
sort.SliceStable(out, func(i, j int) bool { return out[i].Year > out[j].Year })
382414
return out
383415
}
384416

web/src/api/client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,9 @@ export const api = {
138138
async overview(filter: Filter): Promise<UsageOverview> {
139139
return request<UsageOverview>("/usage/overview" + buildQuery(filter));
140140
},
141-
async health(filter: Filter, month?: string): Promise<UsageHealthMatrix> {
141+
async health(filter: Filter, year?: string, day?: string): Promise<UsageHealthMatrix> {
142142
return request<UsageHealthMatrix>(
143-
"/usage/health" + buildFacetQuery(filter, { month }),
143+
"/usage/health" + buildFacetQuery(filter, { year, day }),
144144
);
145145
},
146146
async analysis(filter: Filter): Promise<UsageAnalysis> {

web/src/api/types.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,17 +74,26 @@ export interface UsageOverview {
7474
generated_at: string;
7575
}
7676

77-
export interface UsageHealthMonth {
78-
month: string;
77+
export interface UsageHealthYear {
78+
year: number;
7979
total: number;
8080
}
8181

82+
export interface UsageHealthDay {
83+
date: string;
84+
bucket: string;
85+
total: number;
86+
failed: number;
87+
}
88+
8289
export interface UsageHealthMatrix {
83-
month: string;
90+
year: number;
8491
start: string;
8592
end: string;
86-
grid: HealthCell[][];
87-
months: UsageHealthMonth[];
93+
days: UsageHealthDay[];
94+
years: UsageHealthYear[];
95+
selected_day?: string;
96+
detail?: HealthCell[][];
8897
}
8998

9099
export interface UsageEventRecord {

0 commit comments

Comments
 (0)