From 92aab0910441c64f1a81424de6960f5ac37a5e96 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Sat, 18 Apr 2026 14:29:09 +0600 Subject: [PATCH] Add daily usage metrics (CPU-minutes, RAM GB-minutes) Introduce pre-computed daily usage rollups from sandbox_metrics_snapshots. An hourly background worker aggregates completed days, while today's usage is computed live from snapshots at query time for freshness. Backend: new daily_usage table, rollup worker, UsageService, and GET /v1/capsules/usage endpoint with date range filtering (up to 92 days). Frontend: replace Usage page placeholder with bar charts (Chart.js), summary total cards, and preset/custom date range controls. --- db/migrations/20260418072009_daily_usage.sql | 11 + db/queries/metrics.sql | 32 ++ frontend/src/lib/api/usage.ts | 28 + .../src/routes/dashboard/usage/+page.svelte | 536 +++++++++++++++--- internal/api/handlers_usage.go | 92 +++ internal/api/openapi.yaml | 54 ++ internal/api/server.go | 10 + internal/api/usage_rollup.go | 84 +++ pkg/cpserver/run.go | 4 + pkg/db/metrics.sql.go | 129 +++++ pkg/db/models.go | 7 + pkg/service/stats.go | 88 +++ 12 files changed, 983 insertions(+), 92 deletions(-) create mode 100644 db/migrations/20260418072009_daily_usage.sql create mode 100644 frontend/src/lib/api/usage.ts create mode 100644 internal/api/handlers_usage.go create mode 100644 internal/api/usage_rollup.go diff --git a/db/migrations/20260418072009_daily_usage.sql b/db/migrations/20260418072009_daily_usage.sql new file mode 100644 index 0000000..e1c801e --- /dev/null +++ b/db/migrations/20260418072009_daily_usage.sql @@ -0,0 +1,11 @@ +-- +goose Up +CREATE TABLE daily_usage ( + team_id UUID NOT NULL, + day DATE NOT NULL, + cpu_minutes NUMERIC(18, 4) NOT NULL DEFAULT 0, + ram_mb_minutes NUMERIC(18, 4) NOT NULL DEFAULT 0, + PRIMARY KEY (team_id, day) +); + +-- +goose Down +DROP TABLE daily_usage; diff --git a/db/queries/metrics.sql b/db/queries/metrics.sql index 6c612c6..7355823 100644 --- a/db/queries/metrics.sql +++ b/db/queries/metrics.sql @@ -73,3 +73,35 @@ SELECT + COALESCE(SUM(CEIL(memory_mb::NUMERIC / 2)) FILTER (WHERE status = 'paused'), 0))::INTEGER AS memory_mb_reserved FROM sandboxes GROUP BY team_id; + +-- name: GetTeamsWithSnapshots :many +SELECT DISTINCT team_id +FROM sandbox_metrics_snapshots +WHERE sampled_at > NOW() - INTERVAL '93 days'; + +-- name: ComputeDailyUsageForDay :one +SELECT + COALESCE(SUM(vcpus_reserved * 10.0 / 60.0), 0)::NUMERIC(18,4) AS cpu_minutes, + COALESCE(SUM(memory_mb_reserved * 10.0 / 60.0), 0)::NUMERIC(18,4) AS ram_mb_minutes +FROM sandbox_metrics_snapshots +WHERE team_id = $1 + AND sampled_at >= $2 + AND sampled_at < $3; + +-- name: UpsertDailyUsage :exec +INSERT INTO daily_usage (team_id, day, cpu_minutes, ram_mb_minutes) +VALUES ($1, $2, $3, $4) +ON CONFLICT (team_id, day) DO UPDATE + SET cpu_minutes = EXCLUDED.cpu_minutes, + ram_mb_minutes = EXCLUDED.ram_mb_minutes; + +-- name: GetDailyUsage :many +SELECT day, cpu_minutes, ram_mb_minutes +FROM daily_usage +WHERE team_id = $1 + AND day >= $2 + AND day <= $3 +ORDER BY day ASC; + +-- name: DeleteDailyUsageByTeam :exec +DELETE FROM daily_usage WHERE team_id = $1; diff --git a/frontend/src/lib/api/usage.ts b/frontend/src/lib/api/usage.ts new file mode 100644 index 0000000..86f97cd --- /dev/null +++ b/frontend/src/lib/api/usage.ts @@ -0,0 +1,28 @@ +import { apiFetch, type ApiResult } from '$lib/api/client'; + +export type UsagePoint = { + date: string; + cpu_minutes: number; + ram_mb_minutes: number; +}; + +export type UsageResponse = { + from: string; + to: string; + points: UsagePoint[]; +}; + +export async function fetchUsage(from: string, to: string): Promise> { + return apiFetch('GET', `/api/v1/capsules/usage?from=${from}&to=${to}`); +} + +export function formatDate(d: Date): string { + return d.toISOString().slice(0, 10); +} + +export function defaultRange(): { from: string; to: string } { + const to = new Date(); + const from = new Date(to); + from.setDate(from.getDate() - 29); + return { from: formatDate(from), to: formatDate(to) }; +} diff --git a/frontend/src/routes/dashboard/usage/+page.svelte b/frontend/src/routes/dashboard/usage/+page.svelte index 216a8f5..21c32a8 100644 --- a/frontend/src/routes/dashboard/usage/+page.svelte +++ b/frontend/src/routes/dashboard/usage/+page.svelte @@ -1,39 +1,287 @@ @@ -41,77 +289,170 @@
- -
-

- Usage -

-

- Resource consumption and execution metrics across your team. + + +

+

+ Usage +

+

+ Resource consumption and execution metrics across your team. +

+
+
+ + +
+ + +
+ +
+ {#each PRESETS as p, i} + + {/each} +
+ + +
+ + to + +
+
+ + + {#if error} +
+ {error} + +
+ {/if} + + {#if loading} +
+
+ + + + Loading usage data... +
+
+ {:else if data && data.points.length === 0} + +
+
+
+
+ + + + + +
+
+

+ No usage data yet

-
+

+ Usage will appear here once capsules have been running. +

+
+ {:else if data} + +
+ + +
+
+ + Total CPU-Minutes +
+
+
+ {fmtNumber(totalCpuMinutes)} +
+
+ {fromInput} — {toInput} +
+
+
+ + +
+
+ + Total RAM GB-Minutes +
+
+
+ {fmtNumber(totalRamGBMinutes)} +
+
+ {fromInput} — {toInput} +
+
+
+
- -
- {#if status === 'loading'} -
-
- - - - Loading usage data... -
-
- {:else if status === 'error'} -
- {errorMsg} - -
- {:else if status === 'not_available'} -
- -
-
-
- - - - -
-
-

- Cloud Feature -

-

- Usage tracking is available on Wrenn Cloud. -

+ +
- -
- - - - - - - This instance is running in self-hosted mode - + +
+
+
+ + CPU · Minutes
- {:else} - -
- Usage data will be displayed here. +
+
- {/if} +
+ + +
+
+
+ + RAM · GB-Minutes +
+
+
+ +
+
+
-
+ {/if} + +
@@ -122,3 +463,14 @@ All systems operational
+ + diff --git a/internal/api/handlers_usage.go b/internal/api/handlers_usage.go new file mode 100644 index 0000000..7d7f9ec --- /dev/null +++ b/internal/api/handlers_usage.go @@ -0,0 +1,92 @@ +package api + +import ( + "log/slog" + "net/http" + "time" + + "git.omukk.dev/wrenn/wrenn/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/service" +) + +type usageHandler struct { + svc *service.UsageService +} + +func newUsageHandler(svc *service.UsageService) *usageHandler { + return &usageHandler{svc: svc} +} + +type usagePointResponse struct { + Date string `json:"date"` + CPUMinutes float64 `json:"cpu_minutes"` + RAMMBMinutes float64 `json:"ram_mb_minutes"` +} + +type usageResponse struct { + From string `json:"from"` + To string `json:"to"` + Points []usagePointResponse `json:"points"` +} + +// GetUsage handles GET /v1/capsules/usage?from=YYYY-MM-DD&to=YYYY-MM-DD +func (h *usageHandler) GetUsage(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + + now := time.Now().UTC() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + + var from, to time.Time + if s := r.URL.Query().Get("from"); s != "" { + var err error + from, err = time.Parse("2006-01-02", s) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "from must be YYYY-MM-DD") + return + } + } else { + from = today.AddDate(0, 0, -29) + } + + if s := r.URL.Query().Get("to"); s != "" { + var err error + to, err = time.Parse("2006-01-02", s) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "to must be YYYY-MM-DD") + return + } + } else { + to = today + } + + if from.After(to) { + writeError(w, http.StatusBadRequest, "invalid_request", "from must be before or equal to to") + return + } + if to.Sub(from).Hours()/24 > 92 { + writeError(w, http.StatusBadRequest, "invalid_request", "range cannot exceed 92 days") + return + } + + points, err := h.svc.GetUsage(r.Context(), ac.TeamID, from, to) + if err != nil { + slog.Error("usage handler: get usage failed", "team_id", ac.TeamID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "failed to retrieve usage") + return + } + + resp := usageResponse{ + From: from.Format("2006-01-02"), + To: to.Format("2006-01-02"), + Points: make([]usagePointResponse, len(points)), + } + for i, pt := range points { + resp.Points[i] = usagePointResponse{ + Date: pt.Day.Format("2006-01-02"), + CPUMinutes: pt.CPUMinutes, + RAMMBMinutes: pt.RAMMBMinutes, + } + } + + writeJSON(w, http.StatusOK, resp) +} diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml index f4c369d..998ef68 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -921,6 +921,38 @@ paths: "400": $ref: "#/components/responses/BadRequest" + /v1/capsules/usage: + get: + summary: Get daily CPU and RAM usage for your team + operationId: getCapsuleUsage + tags: [capsules] + security: + - apiKeyAuth: [] + parameters: + - name: from + in: query + required: false + schema: + type: string + format: date + description: Start date (YYYY-MM-DD). Defaults to 30 days ago. + - name: to + in: query + required: false + schema: + type: string + format: date + description: End date (YYYY-MM-DD). Defaults to today. + responses: + "200": + description: Daily usage data for the team + content: + application/json: + schema: + $ref: "#/components/schemas/UsageResponse" + "400": + $ref: "#/components/responses/BadRequest" + /v1/capsules/{id}: parameters: - name: id @@ -2432,6 +2464,28 @@ components: after this duration of inactivity (no exec or ping). 0 means no auto-pause. + UsageResponse: + type: object + properties: + from: + type: string + format: date + to: + type: string + format: date + points: + type: array + items: + type: object + properties: + date: + type: string + format: date + cpu_minutes: + type: number + ram_mb_minutes: + type: number + CapsuleStats: type: object properties: diff --git a/internal/api/server.go b/internal/api/server.go index 9e81340..25d1e2f 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -54,6 +54,13 @@ func New( r := chi.NewRouter() r.Use(requestLogger()) + // Apply extension middleware before routes so it wraps all OSS routes. + for _, ext := range extensions { + if mp, ok := ext.(cpextension.MiddlewareProvider); ok { + r.Use(mp.Middlewares(sctx)...) + } + } + // Shared service layer. sandboxSvc := &service.SandboxService{DB: queries, Pool: pool, Scheduler: sched} apiKeySvc := &service.APIKeyService{DB: queries} @@ -63,6 +70,7 @@ func New( userSvc := &service.UserService{DB: queries, SandboxSvc: sandboxSvc} auditSvc := &service.AuditService{DB: queries} statsSvc := &service.StatsService{DB: queries, Pool: pgPool} + usageSvc := &service.UsageService{DB: queries} buildSvc := &service.BuildService{DB: queries, Redis: rdb, Pool: pool, Scheduler: sched} sandbox := newSandboxHandler(sandboxSvc, al) @@ -80,6 +88,7 @@ func New( usersH := newUsersHandler(queries, userSvc) auditH := newAuditHandler(auditSvc) statsH := newStatsHandler(statsSvc) + usageH := newUsageHandler(usageSvc) metricsH := newSandboxMetricsHandler(queries, pool) buildH := newBuildHandler(buildSvc, queries, pool) channelH := newChannelHandler(channelSvc, al) @@ -159,6 +168,7 @@ func New( r.Post("/", sandbox.Create) r.Get("/", sandbox.List) r.Get("/stats", statsH.GetStats) + r.Get("/usage", usageH.GetUsage) r.Route("/{id}", func(r chi.Router) { r.Get("/", sandbox.Get) diff --git a/internal/api/usage_rollup.go b/internal/api/usage_rollup.go new file mode 100644 index 0000000..b4bcccc --- /dev/null +++ b/internal/api/usage_rollup.go @@ -0,0 +1,84 @@ +package api + +import ( + "context" + "log/slog" + "time" + + "github.com/jackc/pgx/v5/pgtype" + + "git.omukk.dev/wrenn/wrenn/pkg/db" +) + +// DailyUsageRollup pre-computes daily CPU-minute and RAM-MB-minute totals +// from sandbox_metrics_snapshots. It runs on startup and then every interval. +type DailyUsageRollup struct { + db *db.Queries + interval time.Duration +} + +// NewDailyUsageRollup creates a DailyUsageRollup. +func NewDailyUsageRollup(queries *db.Queries, interval time.Duration) *DailyUsageRollup { + return &DailyUsageRollup{db: queries, interval: interval} +} + +// Start runs the rollup loop until the context is cancelled. +func (r *DailyUsageRollup) Start(ctx context.Context) { + go func() { + ticker := time.NewTicker(r.interval) + defer ticker.Stop() + + // Run immediately on startup. + r.run(ctx) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + r.run(ctx) + } + } + }() +} + +func (r *DailyUsageRollup) run(ctx context.Context) { + teams, err := r.db.GetTeamsWithSnapshots(ctx) + if err != nil { + slog.Warn("usage rollup: failed to get teams", "error", err) + return + } + + now := time.Now().UTC() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + yesterday := today.AddDate(0, 0, -1) + + for _, teamID := range teams { + // Only roll up yesterday (fully completed day). Today's usage is + // computed live at query time by UsageService. + if err := r.rollupDay(ctx, teamID, yesterday); err != nil { + slog.Warn("usage rollup: failed", "team_id", teamID, "day", yesterday.Format("2006-01-02"), "error", err) + } + } +} + +func (r *DailyUsageRollup) rollupDay(ctx context.Context, teamID pgtype.UUID, day time.Time) error { + dayStart := day + dayEnd := day.Add(24 * time.Hour) + + row, err := r.db.ComputeDailyUsageForDay(ctx, db.ComputeDailyUsageForDayParams{ + TeamID: teamID, + SampledAt: pgtype.Timestamptz{Time: dayStart, Valid: true}, + SampledAt_2: pgtype.Timestamptz{Time: dayEnd, Valid: true}, + }) + if err != nil { + return err + } + + return r.db.UpsertDailyUsage(ctx, db.UpsertDailyUsageParams{ + TeamID: teamID, + Day: pgtype.Date{Time: day, Valid: true}, + CpuMinutes: row.CpuMinutes, + RamMbMinutes: row.RamMbMinutes, + }) +} diff --git a/pkg/cpserver/run.go b/pkg/cpserver/run.go index 32a819a..d248a97 100644 --- a/pkg/cpserver/run.go +++ b/pkg/cpserver/run.go @@ -210,6 +210,10 @@ func Run(opts ...Option) { sampler := api.NewMetricsSampler(queries, 10*time.Second) sampler.Start(ctx) + // Start daily usage rollup (pre-computes CPU-minutes and RAM-MB-minutes). + rollup := api.NewDailyUsageRollup(queries, time.Hour) + rollup.Start(ctx) + // Start extension background workers. for _, ext := range o.extensions { for _, worker := range ext.BackgroundWorkers(sctx) { diff --git a/pkg/db/metrics.sql.go b/pkg/db/metrics.sql.go index 886daca..ebb5c0d 100644 --- a/pkg/db/metrics.sql.go +++ b/pkg/db/metrics.sql.go @@ -11,6 +11,43 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const computeDailyUsageForDay = `-- name: ComputeDailyUsageForDay :one +SELECT + COALESCE(SUM(vcpus_reserved * 10.0 / 60.0), 0)::NUMERIC(18,4) AS cpu_minutes, + COALESCE(SUM(memory_mb_reserved * 10.0 / 60.0), 0)::NUMERIC(18,4) AS ram_mb_minutes +FROM sandbox_metrics_snapshots +WHERE team_id = $1 + AND sampled_at >= $2 + AND sampled_at < $3 +` + +type ComputeDailyUsageForDayParams struct { + TeamID pgtype.UUID `json:"team_id"` + SampledAt pgtype.Timestamptz `json:"sampled_at"` + SampledAt_2 pgtype.Timestamptz `json:"sampled_at_2"` +} + +type ComputeDailyUsageForDayRow struct { + CpuMinutes pgtype.Numeric `json:"cpu_minutes"` + RamMbMinutes pgtype.Numeric `json:"ram_mb_minutes"` +} + +func (q *Queries) ComputeDailyUsageForDay(ctx context.Context, arg ComputeDailyUsageForDayParams) (ComputeDailyUsageForDayRow, error) { + row := q.db.QueryRow(ctx, computeDailyUsageForDay, arg.TeamID, arg.SampledAt, arg.SampledAt_2) + var i ComputeDailyUsageForDayRow + err := row.Scan(&i.CpuMinutes, &i.RamMbMinutes) + return i, err +} + +const deleteDailyUsageByTeam = `-- name: DeleteDailyUsageByTeam :exec +DELETE FROM daily_usage WHERE team_id = $1 +` + +func (q *Queries) DeleteDailyUsageByTeam(ctx context.Context, teamID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteDailyUsageByTeam, teamID) + return err +} + const deleteMetricPointsByTeam = `-- name: DeleteMetricPointsByTeam :exec DELETE FROM sandbox_metric_points WHERE sandbox_id IN (SELECT id FROM sandboxes WHERE team_id = $1) @@ -55,6 +92,47 @@ func (q *Queries) DeleteSandboxMetricPointsByTier(ctx context.Context, arg Delet return err } +const getDailyUsage = `-- name: GetDailyUsage :many +SELECT day, cpu_minutes, ram_mb_minutes +FROM daily_usage +WHERE team_id = $1 + AND day >= $2 + AND day <= $3 +ORDER BY day ASC +` + +type GetDailyUsageParams struct { + TeamID pgtype.UUID `json:"team_id"` + Day pgtype.Date `json:"day"` + Day_2 pgtype.Date `json:"day_2"` +} + +type GetDailyUsageRow struct { + Day pgtype.Date `json:"day"` + CpuMinutes pgtype.Numeric `json:"cpu_minutes"` + RamMbMinutes pgtype.Numeric `json:"ram_mb_minutes"` +} + +func (q *Queries) GetDailyUsage(ctx context.Context, arg GetDailyUsageParams) ([]GetDailyUsageRow, error) { + rows, err := q.db.Query(ctx, getDailyUsage, arg.TeamID, arg.Day, arg.Day_2) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetDailyUsageRow + for rows.Next() { + var i GetDailyUsageRow + if err := rows.Scan(&i.Day, &i.CpuMinutes, &i.RamMbMinutes); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getLiveMetrics = `-- name: GetLiveMetrics :one SELECT (COUNT(*) FILTER (WHERE status IN ('running', 'starting')))::INTEGER AS running_count, @@ -149,6 +227,32 @@ func (q *Queries) GetSandboxMetricPoints(ctx context.Context, arg GetSandboxMetr return items, nil } +const getTeamsWithSnapshots = `-- name: GetTeamsWithSnapshots :many +SELECT DISTINCT team_id +FROM sandbox_metrics_snapshots +WHERE sampled_at > NOW() - INTERVAL '93 days' +` + +func (q *Queries) GetTeamsWithSnapshots(ctx context.Context) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, getTeamsWithSnapshots) + if err != nil { + return nil, err + } + defer rows.Close() + var items []pgtype.UUID + for rows.Next() { + var team_id pgtype.UUID + if err := rows.Scan(&team_id); err != nil { + return nil, err + } + items = append(items, team_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const insertMetricsSnapshot = `-- name: InsertMetricsSnapshot :exec INSERT INTO sandbox_metrics_snapshots (team_id, running_count, vcpus_reserved, memory_mb_reserved) VALUES ($1, $2, $3, $4) @@ -267,3 +371,28 @@ func (q *Queries) SampleSandboxMetrics(ctx context.Context) ([]SampleSandboxMetr } return items, nil } + +const upsertDailyUsage = `-- name: UpsertDailyUsage :exec +INSERT INTO daily_usage (team_id, day, cpu_minutes, ram_mb_minutes) +VALUES ($1, $2, $3, $4) +ON CONFLICT (team_id, day) DO UPDATE + SET cpu_minutes = EXCLUDED.cpu_minutes, + ram_mb_minutes = EXCLUDED.ram_mb_minutes +` + +type UpsertDailyUsageParams struct { + TeamID pgtype.UUID `json:"team_id"` + Day pgtype.Date `json:"day"` + CpuMinutes pgtype.Numeric `json:"cpu_minutes"` + RamMbMinutes pgtype.Numeric `json:"ram_mb_minutes"` +} + +func (q *Queries) UpsertDailyUsage(ctx context.Context, arg UpsertDailyUsageParams) error { + _, err := q.db.Exec(ctx, upsertDailyUsage, + arg.TeamID, + arg.Day, + arg.CpuMinutes, + arg.RamMbMinutes, + ) + return err +} diff --git a/pkg/db/models.go b/pkg/db/models.go index 3111952..e5faf66 100644 --- a/pkg/db/models.go +++ b/pkg/db/models.go @@ -41,6 +41,13 @@ type Channel struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type DailyUsage struct { + TeamID pgtype.UUID `json:"team_id"` + Day pgtype.Date `json:"day"` + CpuMinutes pgtype.Numeric `json:"cpu_minutes"` + RamMbMinutes pgtype.Numeric `json:"ram_mb_minutes"` +} + type Host struct { ID pgtype.UUID `json:"id"` Type string `json:"type"` diff --git a/pkg/service/stats.go b/pkg/service/stats.go index d756a74..1b5cb6d 100644 --- a/pkg/service/stats.go +++ b/pkg/service/stats.go @@ -158,3 +158,91 @@ func (s *StatsService) queryTimeSeries(ctx context.Context, teamID pgtype.UUID, } return points, rows.Err() } + +// UsagePoint is one daily usage data point. +type UsagePoint struct { + Day time.Time + CPUMinutes float64 + RAMMBMinutes float64 +} + +// UsageService queries pre-computed daily usage rollups. For the current +// day it computes usage live from sandbox_metrics_snapshots so the value +// is always up-to-date rather than stale until the next hourly rollup. +type UsageService struct { + DB *db.Queries +} + +// GetUsage returns daily CPU-minute and RAM-MB-minute totals for a team +// within the given date range (inclusive). Past days come from the +// pre-computed daily_usage table; today is computed live from snapshots. +func (s *UsageService) GetUsage(ctx context.Context, teamID pgtype.UUID, from, to time.Time) ([]UsagePoint, error) { + now := time.Now().UTC() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + + // Clamp the pre-computed query to exclude today (it hasn't been rolled up). + precomputedTo := to + if !to.Before(today) { + precomputedTo = today.AddDate(0, 0, -1) + } + + var points []UsagePoint + + // Fetch pre-computed days (from..min(to, yesterday)). + if !from.After(precomputedTo) { + rows, err := s.DB.GetDailyUsage(ctx, db.GetDailyUsageParams{ + TeamID: teamID, + Day: pgtype.Date{Time: from, Valid: true}, + Day_2: pgtype.Date{Time: precomputedTo, Valid: true}, + }) + if err != nil { + return nil, fmt.Errorf("get daily usage: %w", err) + } + + points = make([]UsagePoint, 0, len(rows)+1) + for _, r := range rows { + cpu, err := r.CpuMinutes.Float64Value() + if err != nil { + return nil, fmt.Errorf("convert cpu_minutes: %w", err) + } + ram, err := r.RamMbMinutes.Float64Value() + if err != nil { + return nil, fmt.Errorf("convert ram_mb_minutes: %w", err) + } + points = append(points, UsagePoint{ + Day: r.Day.Time, + CPUMinutes: cpu.Float64, + RAMMBMinutes: ram.Float64, + }) + } + } + + // Compute today live from snapshots if the range includes today. + if !to.Before(today) && !from.After(today) { + todayEnd := today.Add(24 * time.Hour) + row, err := s.DB.ComputeDailyUsageForDay(ctx, db.ComputeDailyUsageForDayParams{ + TeamID: teamID, + SampledAt: pgtype.Timestamptz{Time: today, Valid: true}, + SampledAt_2: pgtype.Timestamptz{Time: todayEnd, Valid: true}, + }) + if err != nil { + return nil, fmt.Errorf("compute today usage: %w", err) + } + + cpu, err := row.CpuMinutes.Float64Value() + if err != nil { + return nil, fmt.Errorf("convert today cpu_minutes: %w", err) + } + ram, err := row.RamMbMinutes.Float64Value() + if err != nil { + return nil, fmt.Errorf("convert today ram_mb_minutes: %w", err) + } + points = append(points, UsagePoint{ + Day: today, + CPUMinutes: cpu.Float64, + RAMMBMinutes: ram.Float64, + }) + } + + return points, nil +}