1
0
forked from wrenn/wrenn
Reviewed-on: wrenn/wrenn#33
This commit is contained in:
2026-04-18 08:57:07 +00:00
parent 512c043c5c
commit 23dca7d9ff
18 changed files with 1018 additions and 129 deletions

View 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)
}

View File

@ -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:

View File

@ -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)

View 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,
})
}