1
0
forked from wrenn/wrenn

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.
This commit is contained in:
2026-04-18 14:29:09 +06:00
parent e7670e4449
commit 92aab09104
12 changed files with 983 additions and 92 deletions

View File

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

View File

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

View File

@ -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<ApiResult<UsageResponse>> {
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) };
}

View File

@ -1,39 +1,287 @@
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '$lib/auth.svelte';
import { onMount, onDestroy } from 'svelte';
import { fetchUsage, defaultRange, formatDate, type UsageResponse } from '$lib/api/usage';
type EndpointStatus = 'loading' | 'available' | 'not_available' | 'error';
let status = $state<EndpointStatus>('loading');
let errorMsg = $state<string | null>(null);
// ─── State ────────────────────────────────────────────────────────────────
async function probe() {
status = 'loading';
errorMsg = null;
try {
const headers: Record<string, string> = {};
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
const PRESETS = ['7d', '30d', '90d'] as const;
type Preset = (typeof PRESETS)[number];
const res = await fetch('/api/v1/usage', { headers });
if (res.status === 404) {
status = 'not_available';
} else if (!res.ok) {
status = 'error';
try {
const data = await res.json();
errorMsg = data?.error?.message ?? `Server returned ${res.status}`;
} catch {
errorMsg = `Server returned ${res.status}`;
}
} else {
status = 'available';
}
} catch {
status = 'error';
errorMsg = 'Unable to connect to the server';
let preset = $state<Preset | null>('30d');
let fromInput = $state('');
let toInput = $state('');
let data = $state<UsageResponse | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let canvasCpu = $state<HTMLCanvasElement | null>(null);
let canvasRam = $state<HTMLCanvasElement | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let chartCpu: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let chartRam: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let ChartJS: any = null;
// ─── Derived ──────────────────────────────────────────────────────────────
let totalCpuMinutes = $derived(
data?.points.reduce((sum, p) => sum + p.cpu_minutes, 0) ?? 0
);
let totalRamGBMinutes = $derived(
(data?.points.reduce((sum, p) => sum + p.ram_mb_minutes, 0) ?? 0) / 1024
);
// ─── Chart config ─────────────────────────────────────────────────────────
const C_BLUE = '#5a9fd4';
const C_BLUE_FILL = 'rgba(90,159,212,0.55)';
const C_AMBER = '#d4a73c';
const C_AMBER_FILL = 'rgba(212,167,60,0.55)';
const C_GRID = 'rgba(255,255,255,0.05)';
const C_TICK = '#635f5c';
const FONT_MONO = "'JetBrains Mono', monospace";
const BASE_CHART_OPTIONS = {
responsive: true,
maintainAspectRatio: false,
animation: false as const,
interaction: { mode: 'index' as const, intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: '#141817',
borderColor: '#1f2321',
borderWidth: 1,
titleColor: '#454340',
bodyColor: '#d4cfc8',
titleFont: { family: FONT_MONO, size: 10 },
bodyFont: { family: FONT_MONO, size: 11 },
padding: 10,
},
},
scales: {
x: {
grid: { display: false },
ticks: { color: C_TICK, font: { family: FONT_MONO, size: 10 }, maxTicksLimit: 12, maxRotation: 0 },
border: { color: C_GRID },
},
y: {
grid: { color: C_GRID },
ticks: { color: C_TICK, font: { family: FONT_MONO, size: 10 }, precision: 0 },
border: { color: C_GRID },
beginAtZero: true,
},
},
};
// ─── Data loading ─────────────────────────────────────────────────────────
async function load() {
loading = true;
error = null;
const result = await fetchUsage(fromInput, toInput);
if (result.ok) {
data = result.data;
} else {
error = result.error;
}
loading = false;
updateCharts();
}
let boundCpuCanvas: HTMLCanvasElement | null = null;
let boundRamCanvas: HTMLCanvasElement | null = null;
function initCharts() {
if (!ChartJS || !canvasCpu || !canvasRam) return;
// Skip if already bound to these exact canvas elements.
if (boundCpuCanvas === canvasCpu && boundRamCanvas === canvasRam) {
updateCharts();
return;
}
// Destroy stale instances if canvases were re-mounted.
chartCpu?.destroy();
chartRam?.destroy();
boundCpuCanvas = canvasCpu;
boundRamCanvas = canvasRam;
chartCpu = new ChartJS(canvasCpu, {
type: 'bar',
data: {
labels: [],
datasets: [{
data: [],
backgroundColor: C_BLUE_FILL,
borderColor: C_BLUE,
borderWidth: 1,
borderRadius: 2,
hoverBackgroundColor: C_BLUE,
}],
},
options: {
...BASE_CHART_OPTIONS,
plugins: {
...BASE_CHART_OPTIONS.plugins,
tooltip: {
...BASE_CHART_OPTIONS.plugins.tooltip,
callbacks: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
label: (ctx: any) => ` ${ctx.parsed.y.toFixed(1)} min`,
},
},
},
scales: {
...BASE_CHART_OPTIONS.scales,
y: {
...BASE_CHART_OPTIONS.scales.y,
ticks: {
...BASE_CHART_OPTIONS.scales.y.ticks,
callback: (v: string | number) => `${(+v).toFixed(0)}`,
},
},
},
},
});
chartRam = new ChartJS(canvasRam, {
type: 'bar',
data: {
labels: [],
datasets: [{
data: [],
backgroundColor: C_AMBER_FILL,
borderColor: C_AMBER,
borderWidth: 1,
borderRadius: 2,
hoverBackgroundColor: C_AMBER,
}],
},
options: {
...BASE_CHART_OPTIONS,
plugins: {
...BASE_CHART_OPTIONS.plugins,
tooltip: {
...BASE_CHART_OPTIONS.plugins.tooltip,
callbacks: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
label: (ctx: any) => ` ${ctx.parsed.y.toFixed(2)} GB-min`,
},
},
},
scales: {
...BASE_CHART_OPTIONS.scales,
y: {
...BASE_CHART_OPTIONS.scales.y,
ticks: {
...BASE_CHART_OPTIONS.scales.y.ticks,
callback: (v: string | number) => `${(+v).toFixed(1)}`,
},
},
},
},
});
updateCharts();
}
function updateCharts() {
if (!data) return;
// Build a lookup from date string → point for O(1) access.
const pointMap = new Map(data.points.map((p) => [p.date, p]));
// Generate a label + value for every day in the range so bars are
// evenly distributed and days with no usage show as zero.
const labels: string[] = [];
const cpuData: number[] = [];
const ramData: number[] = [];
// Use UTC dates to avoid timezone-induced date shifts when
// comparing against the YYYY-MM-DD keys from the API.
const from = new Date(fromInput + 'T00:00:00Z');
const to = new Date(toInput + 'T00:00:00Z');
for (const d = new Date(from); d <= to; d.setUTCDate(d.getUTCDate() + 1)) {
const key = d.toISOString().slice(0, 10);
const pt = pointMap.get(key);
labels.push(new Date(key + 'T00:00:00').toLocaleDateString([], { month: 'short', day: 'numeric' }));
cpuData.push(pt ? +pt.cpu_minutes.toFixed(2) : 0);
ramData.push(pt ? +(pt.ram_mb_minutes / 1024).toFixed(2) : 0);
}
if (chartCpu) {
chartCpu.data.labels = labels;
chartCpu.data.datasets[0].data = cpuData;
chartCpu.update();
}
if (chartRam) {
chartRam.data.labels = labels;
chartRam.data.datasets[0].data = ramData;
chartRam.update();
}
}
onMount(probe);
// ─── Range controls ───────────────────────────────────────────────────────
function applyPreset(p: Preset) {
preset = p;
const to = new Date();
const from = new Date(to);
const days = p === '7d' ? 6 : p === '30d' ? 29 : 89;
from.setDate(from.getDate() - days);
fromInput = formatDate(from);
toInput = formatDate(to);
load();
}
function onDateChange() {
// Clear preset highlight when custom dates are used
const to = new Date();
const from7 = new Date(to); from7.setDate(from7.getDate() - 6);
const from30 = new Date(to); from30.setDate(from30.getDate() - 29);
const from90 = new Date(to); from90.setDate(from90.getDate() - 89);
const todayStr = formatDate(to);
if (toInput === todayStr) {
if (fromInput === formatDate(from7)) preset = '7d';
else if (fromInput === formatDate(from30)) preset = '30d';
else if (fromInput === formatDate(from90)) preset = '90d';
else preset = null;
}
load();
}
// ─── Formatting ───────────────────────────────────────────────────────────
function fmtNumber(n: number): string {
if (n >= 1000) return n.toLocaleString('en-US', { maximumFractionDigits: 1 });
return n.toFixed(1);
}
// ─── Lifecycle ────────────────────────────────────────────────────────────
// When canvas elements appear in the DOM (after data loads), init charts.
$effect(() => {
if (canvasCpu && canvasRam && ChartJS) {
initCharts();
}
});
onMount(async () => {
const { from, to } = defaultRange();
fromInput = from;
toInput = to;
const mod = await import('chart.js/auto');
ChartJS = mod.Chart;
await load();
});
onDestroy(() => {
chartCpu?.destroy();
chartRam?.destroy();
});
</script>
<svelte:head>
@ -41,77 +289,170 @@
</svelte:head>
<main class="flex-1 overflow-y-auto bg-[var(--color-bg-0)]">
<!-- Header -->
<div class="px-7 pt-8">
<h1 class="font-serif text-page text-[var(--color-text-bright)]">
Usage
</h1>
<p class="mt-2 text-ui text-[var(--color-text-secondary)]">
Resource consumption and execution metrics across your team.
<!-- Header -->
<div class="px-7 pt-8">
<h1 class="font-serif text-page text-[var(--color-text-bright)]">
Usage
</h1>
<p class="mt-2 text-ui text-[var(--color-text-secondary)]">
Resource consumption and execution metrics across your team.
</p>
<div class="mt-6 border-b border-[var(--color-border)]"></div>
</div>
<!-- Content -->
<div class="p-8" style="animation: fadeUp 0.35s ease both">
<!-- Controls row -->
<div class="flex items-center justify-between">
<!-- Preset range selector -->
<div class="flex overflow-hidden rounded-[var(--radius-button)] border border-[var(--color-border)]">
{#each PRESETS as p, i}
<button
onclick={() => applyPreset(p)}
class="px-3 py-1.5 font-mono text-label transition-colors duration-150
{preset === p
? 'bg-[var(--color-bg-5)] text-[var(--color-text-bright)]'
: 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-secondary)]'}
{i > 0 ? 'border-l border-[var(--color-border)]' : ''}"
>
{p}
</button>
{/each}
</div>
<!-- Date inputs -->
<div class="flex items-center gap-2">
<input
type="date"
bind:value={fromInput}
onchange={onDateChange}
class="rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-2.5 py-1.5 font-mono text-label text-[var(--color-text-secondary)] transition-colors duration-150 focus:border-[var(--color-accent)] focus:outline-none"
/>
<span class="text-meta text-[var(--color-text-muted)]">to</span>
<input
type="date"
bind:value={toInput}
onchange={onDateChange}
class="rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-2.5 py-1.5 font-mono text-label text-[var(--color-text-secondary)] transition-colors duration-150 focus:border-[var(--color-accent)] focus:outline-none"
/>
</div>
</div>
<!-- Error state -->
{#if error}
<div class="mt-6 flex items-center justify-between gap-4 rounded-[var(--radius-card)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-4 py-3 text-ui text-[var(--color-red)]">
<span>{error}</span>
<button
onclick={load}
class="shrink-0 font-semibold underline-offset-2 hover:underline"
>
Try again
</button>
</div>
{/if}
{#if loading}
<div class="flex items-center justify-center py-24">
<div class="flex items-center gap-3 text-ui text-[var(--color-text-secondary)]">
<svg class="animate-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Loading usage data...
</div>
</div>
{:else if data && data.points.length === 0}
<!-- Empty state -->
<div class="flex flex-col items-center justify-center py-[72px]">
<div class="relative mb-5">
<div class="absolute inset-0 -m-6 rounded-full" style="background: radial-gradient(circle, rgba(90,159,212,0.06) 0%, transparent 70%)"></div>
<div class="relative flex h-14 w-14 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-blue)]/20 bg-[var(--color-bg-3)]" style="animation: iconFloat 4s ease-in-out infinite">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-blue)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="12" width="4" height="9" rx="1" />
<rect x="10" y="7" width="4" height="14" rx="1" />
<rect x="17" y="3" width="4" height="18" rx="1" />
</svg>
</div>
</div>
<p class="font-serif text-heading text-[var(--color-text-bright)]">
No usage data yet
</p>
<div class="mt-6 border-b border-[var(--color-border)]"></div>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
Usage will appear here once capsules have been running.
</p>
</div>
{:else if data}
<!-- Summary cards -->
<div class="mt-6 grid grid-cols-2 overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border)]">
<!-- CPU-minutes total -->
<div class="border-r border-[var(--color-border)]" style="box-shadow: inset 5px 0 0 #5a9fd4">
<div class="flex items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-bg-3)] px-6 py-3">
<span class="h-2 w-2 rounded-full" style="background: #5a9fd4"></span>
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Total CPU-Minutes</span>
</div>
<div class="bg-[var(--color-bg-3)] px-6 py-6 transition-colors duration-150 hover:bg-[var(--color-bg-4)]">
<div class="font-serif text-[2.571rem] leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">
{fmtNumber(totalCpuMinutes)}
</div>
<div class="mt-2 font-mono text-meta text-[var(--color-text-muted)]">
{fromInput}{toInput}
</div>
</div>
</div>
<!-- RAM GB-minutes total -->
<div style="box-shadow: inset 5px 0 0 #d4a73c">
<div class="flex items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-bg-3)] px-6 py-3">
<span class="h-2 w-2 rounded-full" style="background: #d4a73c"></span>
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Total RAM GB-Minutes</span>
</div>
<div class="bg-[var(--color-bg-3)] px-6 py-6 transition-colors duration-150 hover:bg-[var(--color-bg-4)]">
<div class="font-serif text-[2.571rem] leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">
{fmtNumber(totalRamGBMinutes)}
</div>
<div class="mt-2 font-mono text-meta text-[var(--color-text-muted)]">
{fromInput}{toInput}
</div>
</div>
</div>
</div>
<!-- Content -->
<div class="p-8" style="animation: fadeUp 0.35s ease both">
{#if status === 'loading'}
<div class="flex items-center justify-center py-24">
<div class="flex items-center gap-3 text-ui text-[var(--color-text-secondary)]">
<svg class="animate-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Loading usage data...
</div>
</div>
{:else if status === 'error'}
<div class="mb-4 flex items-center justify-between gap-4 rounded-[var(--radius-card)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-4 py-3 text-ui text-[var(--color-red)]">
<span>{errorMsg}</span>
<button
onclick={probe}
class="shrink-0 font-semibold underline-offset-2 hover:underline"
>
Try again
</button>
</div>
{:else if status === 'not_available'}
<div class="flex flex-col items-center justify-center py-[72px]">
<!-- Icon with glow -->
<div class="relative mb-5">
<div class="absolute inset-0 -m-6 rounded-full" style="background: radial-gradient(circle, rgba(90,159,212,0.06) 0%, transparent 70%)"></div>
<div class="relative flex h-14 w-14 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-blue)]/20 bg-[var(--color-bg-3)]" style="animation: iconFloat 4s ease-in-out infinite">
<!-- Usage/chart icon -->
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-blue)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</div>
</div>
<p class="font-serif text-heading text-[var(--color-text-bright)]">
Cloud Feature
</p>
<p class="mt-2 max-w-sm text-center text-ui leading-relaxed text-[var(--color-text-tertiary)]">
Usage tracking is available on Wrenn Cloud.
</p>
<!-- Charts -->
<div class="mt-6 flex flex-col gap-5">
<!-- Info badge -->
<div class="mt-6 flex items-center gap-2.5 rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-4 py-3">
<svg class="shrink-0" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted)" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
<span class="text-meta text-[var(--color-text-secondary)]">
This instance is running in self-hosted mode
</span>
<!-- CPU chart -->
<div class="flex flex-col rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
<div class="border-b border-[var(--color-border)] px-6 py-4">
<div class="flex items-center gap-2">
<span class="h-2 w-2 rounded-full" style="background: #5a9fd4"></span>
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">CPU · Minutes</span>
</div>
</div>
{:else}
<!-- Available state — placeholder for when the endpoint exists -->
<div class="text-ui text-[var(--color-text-secondary)]">
Usage data will be displayed here.
<div class="relative flex-1 px-5 pb-5 pt-3" style="min-height: 260px">
<canvas bind:this={canvasCpu}></canvas>
</div>
{/if}
</div>
<!-- RAM chart -->
<div class="flex flex-col rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
<div class="border-b border-[var(--color-border)] px-6 py-4">
<div class="flex items-center gap-2">
<span class="h-2 w-2 rounded-full" style="background: #d4a73c"></span>
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">RAM · GB-Minutes</span>
</div>
</div>
<div class="relative flex-1 px-5 pb-5 pt-3" style="min-height: 260px">
<canvas bind:this={canvasRam}></canvas>
</div>
</div>
</div>
</main>
{/if}
</div>
</main>
<footer class="flex h-7 shrink-0 items-center justify-end border-t border-[var(--color-border)] bg-[var(--color-bg-1)] px-7">
<div class="flex items-center gap-1.5">
@ -122,3 +463,14 @@
<span class="font-mono text-label uppercase tracking-[0.04em] text-[var(--color-text-secondary)]">All systems operational</span>
</div>
</footer>
<style>
/* Dark theme date input overrides */
input[type='date']::-webkit-calendar-picker-indicator {
filter: invert(0.6);
cursor: pointer;
}
input[type='date']::-webkit-datetime-edit {
color: var(--color-text-secondary);
}
</style>

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

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

View File

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

View File

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

View File

@ -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"`

View File

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