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
|
||||
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;
|
||||
|
||||
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">
|
||||
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}`;
|
||||
}
|
||||
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 {
|
||||
status = 'available';
|
||||
error = result.error;
|
||||
}
|
||||
} catch {
|
||||
status = 'error';
|
||||
errorMsg = 'Unable to connect to the server';
|
||||
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,6 +289,7 @@
|
||||
</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)]">
|
||||
@ -54,7 +303,57 @@
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-8" style="animation: fadeUp 0.35s ease both">
|
||||
{#if status === 'loading'}
|
||||
|
||||
<!-- 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">
|
||||
@ -63,55 +362,97 @@
|
||||
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'}
|
||||
{:else if data && data.points.length === 0}
|
||||
<!-- Empty state -->
|
||||
<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" />
|
||||
<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)]">
|
||||
Cloud Feature
|
||||
No usage data yet
|
||||
</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 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)]">
|
||||
|
||||
<!-- 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-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>
|
||||
{: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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="mt-6 flex flex-col gap-5">
|
||||
|
||||
<!-- 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>
|
||||
<div class="relative flex-1 px-5 pb-5 pt-3" style="min-height: 260px">
|
||||
<canvas bind:this={canvasCpu}></canvas>
|
||||
</div>
|
||||
</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>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
</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>
|
||||
|
||||
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":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
|
||||
/v1/capsules/usage:
|
||||
get:
|
||||
summary: Get daily CPU and RAM usage for your team
|
||||
operationId: getCapsuleUsage
|
||||
tags: [capsules]
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- name: from
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
|
||||
- name: to
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
description: End date (YYYY-MM-DD). Defaults to today.
|
||||
responses:
|
||||
"200":
|
||||
description: Daily usage data for the team
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UsageResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
|
||||
/v1/capsules/{id}:
|
||||
parameters:
|
||||
- name: id
|
||||
@ -2432,6 +2464,28 @@ components:
|
||||
after this duration of inactivity (no exec or ping). 0 means
|
||||
no auto-pause.
|
||||
|
||||
UsageResponse:
|
||||
type: object
|
||||
properties:
|
||||
from:
|
||||
type: string
|
||||
format: date
|
||||
to:
|
||||
type: string
|
||||
format: date
|
||||
points:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
date:
|
||||
type: string
|
||||
format: date
|
||||
cpu_minutes:
|
||||
type: number
|
||||
ram_mb_minutes:
|
||||
type: number
|
||||
|
||||
CapsuleStats:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@ -54,6 +54,13 @@ func New(
|
||||
r := chi.NewRouter()
|
||||
r.Use(requestLogger())
|
||||
|
||||
// Apply extension middleware before routes so it wraps all OSS routes.
|
||||
for _, ext := range extensions {
|
||||
if mp, ok := ext.(cpextension.MiddlewareProvider); ok {
|
||||
r.Use(mp.Middlewares(sctx)...)
|
||||
}
|
||||
}
|
||||
|
||||
// Shared service layer.
|
||||
sandboxSvc := &service.SandboxService{DB: queries, Pool: pool, Scheduler: sched}
|
||||
apiKeySvc := &service.APIKeyService{DB: queries}
|
||||
@ -63,6 +70,7 @@ func New(
|
||||
userSvc := &service.UserService{DB: queries, SandboxSvc: sandboxSvc}
|
||||
auditSvc := &service.AuditService{DB: queries}
|
||||
statsSvc := &service.StatsService{DB: queries, Pool: pgPool}
|
||||
usageSvc := &service.UsageService{DB: queries}
|
||||
buildSvc := &service.BuildService{DB: queries, Redis: rdb, Pool: pool, Scheduler: sched}
|
||||
|
||||
sandbox := newSandboxHandler(sandboxSvc, al)
|
||||
@ -80,6 +88,7 @@ func New(
|
||||
usersH := newUsersHandler(queries, userSvc)
|
||||
auditH := newAuditHandler(auditSvc)
|
||||
statsH := newStatsHandler(statsSvc)
|
||||
usageH := newUsageHandler(usageSvc)
|
||||
metricsH := newSandboxMetricsHandler(queries, pool)
|
||||
buildH := newBuildHandler(buildSvc, queries, pool)
|
||||
channelH := newChannelHandler(channelSvc, al)
|
||||
@ -159,6 +168,7 @@ func New(
|
||||
r.Post("/", sandbox.Create)
|
||||
r.Get("/", sandbox.List)
|
||||
r.Get("/stats", statsH.GetStats)
|
||||
r.Get("/usage", usageH.GetUsage)
|
||||
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Get("/", sandbox.Get)
|
||||
|
||||
84
internal/api/usage_rollup.go
Normal file
84
internal/api/usage_rollup.go
Normal file
@ -0,0 +1,84 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
)
|
||||
|
||||
// DailyUsageRollup pre-computes daily CPU-minute and RAM-MB-minute totals
|
||||
// from sandbox_metrics_snapshots. It runs on startup and then every interval.
|
||||
type DailyUsageRollup struct {
|
||||
db *db.Queries
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
// NewDailyUsageRollup creates a DailyUsageRollup.
|
||||
func NewDailyUsageRollup(queries *db.Queries, interval time.Duration) *DailyUsageRollup {
|
||||
return &DailyUsageRollup{db: queries, interval: interval}
|
||||
}
|
||||
|
||||
// Start runs the rollup loop until the context is cancelled.
|
||||
func (r *DailyUsageRollup) Start(ctx context.Context) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(r.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run immediately on startup.
|
||||
r.run(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
r.run(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (r *DailyUsageRollup) run(ctx context.Context) {
|
||||
teams, err := r.db.GetTeamsWithSnapshots(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("usage rollup: failed to get teams", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
yesterday := today.AddDate(0, 0, -1)
|
||||
|
||||
for _, teamID := range teams {
|
||||
// Only roll up yesterday (fully completed day). Today's usage is
|
||||
// computed live at query time by UsageService.
|
||||
if err := r.rollupDay(ctx, teamID, yesterday); err != nil {
|
||||
slog.Warn("usage rollup: failed", "team_id", teamID, "day", yesterday.Format("2006-01-02"), "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *DailyUsageRollup) rollupDay(ctx context.Context, teamID pgtype.UUID, day time.Time) error {
|
||||
dayStart := day
|
||||
dayEnd := day.Add(24 * time.Hour)
|
||||
|
||||
row, err := r.db.ComputeDailyUsageForDay(ctx, db.ComputeDailyUsageForDayParams{
|
||||
TeamID: teamID,
|
||||
SampledAt: pgtype.Timestamptz{Time: dayStart, Valid: true},
|
||||
SampledAt_2: pgtype.Timestamptz{Time: dayEnd, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.db.UpsertDailyUsage(ctx, db.UpsertDailyUsageParams{
|
||||
TeamID: teamID,
|
||||
Day: pgtype.Date{Time: day, Valid: true},
|
||||
CpuMinutes: row.CpuMinutes,
|
||||
RamMbMinutes: row.RamMbMinutes,
|
||||
})
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user