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:
11
db/migrations/20260418072009_daily_usage.sql
Normal file
11
db/migrations/20260418072009_daily_usage.sql
Normal 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;
|
||||||
@ -73,3 +73,35 @@ SELECT
|
|||||||
+ COALESCE(SUM(CEIL(memory_mb::NUMERIC / 2)) FILTER (WHERE status = 'paused'), 0))::INTEGER AS memory_mb_reserved
|
+ COALESCE(SUM(CEIL(memory_mb::NUMERIC / 2)) FILTER (WHERE status = 'paused'), 0))::INTEGER AS memory_mb_reserved
|
||||||
FROM sandboxes
|
FROM sandboxes
|
||||||
GROUP BY team_id;
|
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;
|
||||||
|
|||||||
28
frontend/src/lib/api/usage.ts
Normal file
28
frontend/src/lib/api/usage.ts
Normal 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) };
|
||||||
|
}
|
||||||
@ -1,39 +1,287 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { auth } from '$lib/auth.svelte';
|
import { fetchUsage, defaultRange, formatDate, type UsageResponse } from '$lib/api/usage';
|
||||||
|
|
||||||
type EndpointStatus = 'loading' | 'available' | 'not_available' | 'error';
|
// ─── State ────────────────────────────────────────────────────────────────
|
||||||
let status = $state<EndpointStatus>('loading');
|
|
||||||
let errorMsg = $state<string | null>(null);
|
|
||||||
|
|
||||||
async function probe() {
|
const PRESETS = ['7d', '30d', '90d'] as const;
|
||||||
status = 'loading';
|
type Preset = (typeof PRESETS)[number];
|
||||||
errorMsg = null;
|
|
||||||
try {
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
|
|
||||||
|
|
||||||
const res = await fetch('/api/v1/usage', { headers });
|
let preset = $state<Preset | null>('30d');
|
||||||
if (res.status === 404) {
|
let fromInput = $state('');
|
||||||
status = 'not_available';
|
let toInput = $state('');
|
||||||
} else if (!res.ok) {
|
let data = $state<UsageResponse | null>(null);
|
||||||
status = 'error';
|
let loading = $state(true);
|
||||||
try {
|
let error = $state<string | null>(null);
|
||||||
const data = await res.json();
|
|
||||||
errorMsg = data?.error?.message ?? `Server returned ${res.status}`;
|
let canvasCpu = $state<HTMLCanvasElement | null>(null);
|
||||||
} catch {
|
let canvasRam = $state<HTMLCanvasElement | null>(null);
|
||||||
errorMsg = `Server returned ${res.status}`;
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
}
|
let chartCpu: any = null;
|
||||||
} else {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
status = 'available';
|
let chartRam: any = null;
|
||||||
}
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
} catch {
|
let ChartJS: any = null;
|
||||||
status = 'error';
|
|
||||||
errorMsg = 'Unable to connect to the server';
|
// ─── 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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@ -41,77 +289,170 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<main class="flex-1 overflow-y-auto bg-[var(--color-bg-0)]">
|
<main class="flex-1 overflow-y-auto bg-[var(--color-bg-0)]">
|
||||||
<!-- Header -->
|
|
||||||
<div class="px-7 pt-8">
|
<!-- Header -->
|
||||||
<h1 class="font-serif text-page text-[var(--color-text-bright)]">
|
<div class="px-7 pt-8">
|
||||||
Usage
|
<h1 class="font-serif text-page text-[var(--color-text-bright)]">
|
||||||
</h1>
|
Usage
|
||||||
<p class="mt-2 text-ui text-[var(--color-text-secondary)]">
|
</h1>
|
||||||
Resource consumption and execution metrics across your team.
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Charts -->
|
||||||
<div class="p-8" style="animation: fadeUp 0.35s ease both">
|
<div class="mt-6 flex flex-col gap-5">
|
||||||
{#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>
|
|
||||||
|
|
||||||
<!-- Info badge -->
|
<!-- CPU chart -->
|
||||||
<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">
|
<div class="flex flex-col rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
|
||||||
<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">
|
<div class="border-b border-[var(--color-border)] px-6 py-4">
|
||||||
<circle cx="12" cy="12" r="10" />
|
<div class="flex items-center gap-2">
|
||||||
<line x1="12" y1="16" x2="12" y2="12" />
|
<span class="h-2 w-2 rounded-full" style="background: #5a9fd4"></span>
|
||||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">CPU · Minutes</span>
|
||||||
</svg>
|
|
||||||
<span class="text-meta text-[var(--color-text-secondary)]">
|
|
||||||
This instance is running in self-hosted mode
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
<div class="relative flex-1 px-5 pb-5 pt-3" style="min-height: 260px">
|
||||||
<!-- Available state — placeholder for when the endpoint exists -->
|
<canvas bind:this={canvasCpu}></canvas>
|
||||||
<div class="text-ui text-[var(--color-text-secondary)]">
|
|
||||||
Usage data will be displayed here.
|
|
||||||
</div>
|
</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>
|
</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">
|
<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">
|
<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>
|
<span class="font-mono text-label uppercase tracking-[0.04em] text-[var(--color-text-secondary)]">All systems operational</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</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>
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
@ -921,6 +921,38 @@ paths:
|
|||||||
"400":
|
"400":
|
||||||
$ref: "#/components/responses/BadRequest"
|
$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}:
|
/v1/capsules/{id}:
|
||||||
parameters:
|
parameters:
|
||||||
- name: id
|
- name: id
|
||||||
@ -2432,6 +2464,28 @@ components:
|
|||||||
after this duration of inactivity (no exec or ping). 0 means
|
after this duration of inactivity (no exec or ping). 0 means
|
||||||
no auto-pause.
|
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:
|
CapsuleStats:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@ -54,6 +54,13 @@ func New(
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(requestLogger())
|
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.
|
// Shared service layer.
|
||||||
sandboxSvc := &service.SandboxService{DB: queries, Pool: pool, Scheduler: sched}
|
sandboxSvc := &service.SandboxService{DB: queries, Pool: pool, Scheduler: sched}
|
||||||
apiKeySvc := &service.APIKeyService{DB: queries}
|
apiKeySvc := &service.APIKeyService{DB: queries}
|
||||||
@ -63,6 +70,7 @@ func New(
|
|||||||
userSvc := &service.UserService{DB: queries, SandboxSvc: sandboxSvc}
|
userSvc := &service.UserService{DB: queries, SandboxSvc: sandboxSvc}
|
||||||
auditSvc := &service.AuditService{DB: queries}
|
auditSvc := &service.AuditService{DB: queries}
|
||||||
statsSvc := &service.StatsService{DB: queries, Pool: pgPool}
|
statsSvc := &service.StatsService{DB: queries, Pool: pgPool}
|
||||||
|
usageSvc := &service.UsageService{DB: queries}
|
||||||
buildSvc := &service.BuildService{DB: queries, Redis: rdb, Pool: pool, Scheduler: sched}
|
buildSvc := &service.BuildService{DB: queries, Redis: rdb, Pool: pool, Scheduler: sched}
|
||||||
|
|
||||||
sandbox := newSandboxHandler(sandboxSvc, al)
|
sandbox := newSandboxHandler(sandboxSvc, al)
|
||||||
@ -80,6 +88,7 @@ func New(
|
|||||||
usersH := newUsersHandler(queries, userSvc)
|
usersH := newUsersHandler(queries, userSvc)
|
||||||
auditH := newAuditHandler(auditSvc)
|
auditH := newAuditHandler(auditSvc)
|
||||||
statsH := newStatsHandler(statsSvc)
|
statsH := newStatsHandler(statsSvc)
|
||||||
|
usageH := newUsageHandler(usageSvc)
|
||||||
metricsH := newSandboxMetricsHandler(queries, pool)
|
metricsH := newSandboxMetricsHandler(queries, pool)
|
||||||
buildH := newBuildHandler(buildSvc, queries, pool)
|
buildH := newBuildHandler(buildSvc, queries, pool)
|
||||||
channelH := newChannelHandler(channelSvc, al)
|
channelH := newChannelHandler(channelSvc, al)
|
||||||
@ -159,6 +168,7 @@ func New(
|
|||||||
r.Post("/", sandbox.Create)
|
r.Post("/", sandbox.Create)
|
||||||
r.Get("/", sandbox.List)
|
r.Get("/", sandbox.List)
|
||||||
r.Get("/stats", statsH.GetStats)
|
r.Get("/stats", statsH.GetStats)
|
||||||
|
r.Get("/usage", usageH.GetUsage)
|
||||||
|
|
||||||
r.Route("/{id}", func(r chi.Router) {
|
r.Route("/{id}", func(r chi.Router) {
|
||||||
r.Get("/", sandbox.Get)
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -210,6 +210,10 @@ func Run(opts ...Option) {
|
|||||||
sampler := api.NewMetricsSampler(queries, 10*time.Second)
|
sampler := api.NewMetricsSampler(queries, 10*time.Second)
|
||||||
sampler.Start(ctx)
|
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.
|
// Start extension background workers.
|
||||||
for _, ext := range o.extensions {
|
for _, ext := range o.extensions {
|
||||||
for _, worker := range ext.BackgroundWorkers(sctx) {
|
for _, worker := range ext.BackgroundWorkers(sctx) {
|
||||||
|
|||||||
@ -11,6 +11,43 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"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
|
const deleteMetricPointsByTeam = `-- name: DeleteMetricPointsByTeam :exec
|
||||||
DELETE FROM sandbox_metric_points
|
DELETE FROM sandbox_metric_points
|
||||||
WHERE sandbox_id IN (SELECT id FROM sandboxes WHERE team_id = $1)
|
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
|
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
|
const getLiveMetrics = `-- name: GetLiveMetrics :one
|
||||||
SELECT
|
SELECT
|
||||||
(COUNT(*) FILTER (WHERE status IN ('running', 'starting')))::INTEGER AS running_count,
|
(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
|
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
|
const insertMetricsSnapshot = `-- name: InsertMetricsSnapshot :exec
|
||||||
INSERT INTO sandbox_metrics_snapshots (team_id, running_count, vcpus_reserved, memory_mb_reserved)
|
INSERT INTO sandbox_metrics_snapshots (team_id, running_count, vcpus_reserved, memory_mb_reserved)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
@ -267,3 +371,28 @@ func (q *Queries) SampleSandboxMetrics(ctx context.Context) ([]SampleSandboxMetr
|
|||||||
}
|
}
|
||||||
return items, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -41,6 +41,13 @@ type Channel struct {
|
|||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
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 {
|
type Host struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
|||||||
@ -158,3 +158,91 @@ func (s *StatsService) queryTimeSeries(ctx context.Context, teamID pgtype.UUID,
|
|||||||
}
|
}
|
||||||
return points, rows.Err()
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user