forked from wrenn/wrenn
92
internal/api/handlers_usage.go
Normal file
92
internal/api/handlers_usage.go
Normal file
@ -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)
|
||||
}
|
||||
@ -2,7 +2,7 @@ openapi: "3.1.0"
|
||||
info:
|
||||
title: Wrenn API
|
||||
description: MicroVM-based code execution platform API.
|
||||
version: "0.1.0"
|
||||
version: "0.1.2"
|
||||
|
||||
servers:
|
||||
- url: http://localhost:8080
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
84
internal/api/usage_rollup.go
Normal file
84
internal/api/usage_rollup.go
Normal file
@ -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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user