forked from wrenn/wrenn
Fix metrics correctness, redesign stats page
- Replace stale snapshot read (GetCurrentMetrics) with live query (GetLiveMetrics) against sandboxes table — always returns correct zeros when no capsules are running - Fix CPU reserved formula: running + starting only; paused VMs no longer contribute vCPUs (RAM reservation for paused unchanged) - Merge top cards into 3 paired Now/Peak cards with colored accent borders (green/blue/amber matching chart colors) - Move Live badge from Running Capsules card to page-level header - Add colored category dots to card and chart headers - Charts stacked vertically, flex-1 to fill remaining page height - vCPUs chart color changed to blue (#5a9fd4), RAM stays amber
This commit is contained in:
@ -2,12 +2,17 @@
|
|||||||
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);
|
||||||
|
|
||||||
-- name: GetCurrentMetrics :one
|
-- name: GetLiveMetrics :one
|
||||||
SELECT running_count, vcpus_reserved, memory_mb_reserved, sampled_at
|
-- Reads directly from sandboxes for accurate real-time current values.
|
||||||
FROM sandbox_metrics_snapshots
|
-- CPU reserved = running + starting only (paused VMs release CPU).
|
||||||
WHERE team_id = $1
|
-- RAM reserved = running + starting + ceil(paused/2) (capacity held for resume).
|
||||||
ORDER BY sampled_at DESC
|
SELECT
|
||||||
LIMIT 1;
|
(COUNT(*) FILTER (WHERE status IN ('running', 'starting')))::INTEGER AS running_count,
|
||||||
|
(COALESCE(SUM(vcpus) FILTER (WHERE status IN ('running', 'starting')), 0))::INTEGER AS vcpus_reserved,
|
||||||
|
(COALESCE(SUM(memory_mb) FILTER (WHERE status IN ('running', 'starting')), 0)
|
||||||
|
+ CEIL(COALESCE(SUM(memory_mb) FILTER (WHERE status = 'paused'), 0)::NUMERIC / 2))::INTEGER AS memory_mb_reserved
|
||||||
|
FROM sandboxes
|
||||||
|
WHERE team_id = $1;
|
||||||
|
|
||||||
-- name: GetPeakMetrics :one
|
-- name: GetPeakMetrics :one
|
||||||
SELECT
|
SELECT
|
||||||
@ -24,12 +29,12 @@ WHERE sampled_at < NOW() - INTERVAL '60 days';
|
|||||||
|
|
||||||
-- name: SampleSandboxMetrics :many
|
-- name: SampleSandboxMetrics :many
|
||||||
-- Aggregates per-team resource usage from the live sandboxes table.
|
-- Aggregates per-team resource usage from the live sandboxes table.
|
||||||
-- paused sandboxes count at 50% (ceil) for capacity reservation.
|
-- CPU reserved = running + starting only (paused VMs release CPU).
|
||||||
|
-- RAM reserved = running + starting + ceil(paused/2) (capacity held for resume).
|
||||||
SELECT
|
SELECT
|
||||||
team_id,
|
team_id,
|
||||||
(COUNT(*) FILTER (WHERE status IN ('running', 'starting')))::INTEGER AS running_count,
|
(COUNT(*) FILTER (WHERE status IN ('running', 'starting')))::INTEGER AS running_count,
|
||||||
(COALESCE(SUM(vcpus) FILTER (WHERE status IN ('running', 'starting')), 0)
|
(COALESCE(SUM(vcpus) FILTER (WHERE status IN ('running', 'starting')), 0))::INTEGER AS vcpus_reserved,
|
||||||
+ CEIL(COALESCE(SUM(vcpus) FILTER (WHERE status = 'paused'), 0)::NUMERIC / 2))::INTEGER AS vcpus_reserved,
|
|
||||||
(COALESCE(SUM(memory_mb) FILTER (WHERE status IN ('running', 'starting')), 0)
|
(COALESCE(SUM(memory_mb) FILTER (WHERE status IN ('running', 'starting')), 0)
|
||||||
+ CEIL(COALESCE(SUM(memory_mb) FILTER (WHERE status = 'paused'), 0)::NUMERIC / 2))::INTEGER AS memory_mb_reserved
|
+ CEIL(COALESCE(SUM(memory_mb) FILTER (WHERE status = 'paused'), 0)::NUMERIC / 2))::INTEGER AS memory_mb_reserved
|
||||||
FROM sandboxes
|
FROM sandboxes
|
||||||
|
|||||||
@ -85,8 +85,10 @@
|
|||||||
// Chart colors (resolved from CSS vars, must match app.css)
|
// Chart colors (resolved from CSS vars, must match app.css)
|
||||||
const C_ACCENT = '#5e8c58';
|
const C_ACCENT = '#5e8c58';
|
||||||
const C_ACCENT_FILL = 'rgba(94,140,88,0.08)';
|
const C_ACCENT_FILL = 'rgba(94,140,88,0.08)';
|
||||||
|
const C_BLUE = '#5a9fd4';
|
||||||
|
const C_BLUE_FILL = 'rgba(90,159,212,0.07)';
|
||||||
const C_AMBER = '#d4a73c';
|
const C_AMBER = '#d4a73c';
|
||||||
const C_AMBER_FILL = 'rgba(212,167,60,0.06)';
|
const C_AMBER_FILL = 'rgba(212,167,60,0.07)';
|
||||||
const C_GRID = 'rgba(255,255,255,0.04)';
|
const C_GRID = 'rgba(255,255,255,0.04)';
|
||||||
const C_TICK = '#454340';
|
const C_TICK = '#454340';
|
||||||
const FONT_MONO = "'JetBrains Mono', monospace";
|
const FONT_MONO = "'JetBrains Mono', monospace";
|
||||||
@ -160,14 +162,14 @@
|
|||||||
{
|
{
|
||||||
label: 'vCPUs',
|
label: 'vCPUs',
|
||||||
data: [],
|
data: [],
|
||||||
borderColor: C_ACCENT,
|
borderColor: C_BLUE,
|
||||||
backgroundColor: C_ACCENT_FILL,
|
backgroundColor: C_BLUE_FILL,
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
fill: false,
|
fill: false,
|
||||||
tension: 0,
|
tension: 0,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
pointHoverRadius: 4,
|
pointHoverRadius: 4,
|
||||||
pointHoverBackgroundColor: C_ACCENT,
|
pointHoverBackgroundColor: C_BLUE,
|
||||||
yAxisID: 'y',
|
yAxisID: 'y',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -248,20 +250,32 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-8 space-y-5" style="animation: fadeUp 0.35s ease both">
|
<div class="flex flex-col gap-7 p-8" style="min-height: calc(100dvh - 200px); animation: fadeUp 0.35s ease both">
|
||||||
|
|
||||||
<!-- Header row: title + range selector + launch button -->
|
<!-- Header: title + controls -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-end justify-between">
|
||||||
<span class="text-meta font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">Usage Statistics</span>
|
<div>
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<h2 class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">Usage Statistics</h2>
|
||||||
|
{#if !loading}
|
||||||
|
<span class="flex items-center gap-1 rounded-[3px] border border-[var(--color-accent)]/25 bg-[var(--color-accent-glow-mid)] px-1.5 py-0.5 text-badge font-semibold uppercase tracking-[0.05em] text-[var(--color-accent-mid)]">
|
||||||
|
<span class="h-[5px] w-[5px] rounded-full bg-[var(--color-accent)]" style="animation: wrenn-glow 2.5s ease-in-out infinite"></span>
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">Resource consumption across all capsules.</p>
|
||||||
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Range selector -->
|
||||||
<div class="flex overflow-hidden rounded-[var(--radius-button)] border border-[var(--color-border)]">
|
<div class="flex overflow-hidden rounded-[var(--radius-button)] border border-[var(--color-border)]">
|
||||||
{#each RANGES as r, i}
|
{#each RANGES as r, i}
|
||||||
<button
|
<button
|
||||||
onclick={() => setRange(r)}
|
onclick={() => setRange(r)}
|
||||||
class="px-2.5 py-1 font-mono text-label transition-colors duration-150
|
class="px-3 py-1.5 font-mono text-label transition-colors duration-150
|
||||||
{range === r
|
{range === r
|
||||||
? 'bg-[var(--color-bg-5)] text-[var(--color-text-bright)]'
|
? 'bg-[var(--color-bg-5)] text-[var(--color-text-bright)]'
|
||||||
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'}
|
: '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)]' : ''}"
|
{i > 0 ? 'border-l border-[var(--color-border)]' : ''}"
|
||||||
>
|
>
|
||||||
{r}
|
{r}
|
||||||
@ -284,112 +298,123 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 4 stat cards -->
|
<!-- Stat cards: 3 paired cards (now / 30d peak) -->
|
||||||
<div class="flex overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border)]">
|
<div class="grid grid-cols-3 overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border)]">
|
||||||
|
|
||||||
<!-- Current Running -->
|
<!-- Running capsules -->
|
||||||
<div class="flex-1 border-r border-[var(--color-border)] bg-[var(--color-bg-2)] px-5 py-5 transition-colors duration-150 hover:bg-[var(--color-bg-3)]">
|
<div class="border-r border-[var(--color-border)]" style="box-shadow: inset 3px 0 0 var(--color-accent)">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-bg-3)] px-6 py-3">
|
||||||
<span class="text-meta font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">Running Now</span>
|
<span class="h-[6px] w-[6px] rounded-full bg-[var(--color-accent)]"></span>
|
||||||
{#if !loading}
|
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Running Capsules</span>
|
||||||
<span class="rounded-[3px] bg-[var(--color-accent-glow-mid)] px-1.5 py-0.5 text-badge font-semibold uppercase tracking-[0.04em] text-[var(--color-accent-mid)]">
|
|
||||||
<span class="mr-0.5 inline-block h-[5px] w-[5px] rounded-full bg-[var(--color-accent)]" style="animation: wrenn-glow 2.5s ease-in-out infinite"></span>
|
|
||||||
Live
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 font-serif text-[2.571rem] tracking-[-0.04em] text-[var(--color-text-bright)]">
|
<div class="grid grid-cols-2 divide-x divide-[var(--color-border)]">
|
||||||
|
<div class="bg-[var(--color-bg-3)] px-6 py-5 transition-colors duration-150 hover:bg-[var(--color-bg-4)]">
|
||||||
|
<div class="text-label text-[var(--color-text-muted)]">Now</div>
|
||||||
|
<div class="mt-1.5 font-serif text-[2.571rem] leading-none tracking-[-0.04em] {(!loading && (stats?.current.running_count ?? 0) > 0) ? 'text-[var(--color-accent-bright)]' : 'text-[var(--color-text-bright)]'}">
|
||||||
{loading ? '—' : (stats?.current.running_count ?? 0)}
|
{loading ? '—' : (stats?.current.running_count ?? 0)}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-label text-[var(--color-text-tertiary)]">capsules</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="bg-[var(--color-bg-2)] px-6 py-5 transition-colors duration-150 hover:bg-[var(--color-bg-3)]">
|
||||||
<!-- Peak Running 30d -->
|
<div class="text-label text-[var(--color-text-muted)]">Peak · 30d</div>
|
||||||
<div class="flex-1 border-r border-[var(--color-border)] bg-[var(--color-bg-2)] px-5 py-5 transition-colors duration-150 hover:bg-[var(--color-bg-3)]">
|
<div class="mt-1.5 font-serif text-[2.571rem] leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">
|
||||||
<span class="text-meta font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">Peak Running</span>
|
|
||||||
<div class="mt-1 font-serif text-[2.571rem] tracking-[-0.04em] text-[var(--color-text-bright)]">
|
|
||||||
{loading ? '—' : (stats?.peaks.running_count ?? 0)}
|
{loading ? '—' : (stats?.peaks.running_count ?? 0)}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-label text-[var(--color-text-tertiary)]">30-day max</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Peak CPU 30d -->
|
<!-- Reserved CPU -->
|
||||||
<div class="flex-1 border-r border-[var(--color-border)] bg-[var(--color-bg-2)] px-5 py-5 transition-colors duration-150 hover:bg-[var(--color-bg-3)]">
|
<div class="border-r border-[var(--color-border)]" style="box-shadow: inset 3px 0 0 #5a9fd4">
|
||||||
<span class="text-meta font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">Peak CPU</span>
|
<div class="flex items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-bg-3)] px-6 py-3">
|
||||||
<div class="mt-1 font-serif text-[2.571rem] tracking-[-0.04em] text-[var(--color-text-bright)]">
|
<span class="h-[6px] w-[6px] rounded-full" style="background: #5a9fd4"></span>
|
||||||
|
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">CPU · vCPUs</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 divide-x divide-[var(--color-border)]">
|
||||||
|
<div class="bg-[var(--color-bg-3)] px-6 py-5 transition-colors duration-150 hover:bg-[var(--color-bg-4)]">
|
||||||
|
<div class="text-label text-[var(--color-text-muted)]">Reserved now</div>
|
||||||
|
<div class="mt-1.5 font-serif text-[2.571rem] leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">
|
||||||
|
{loading ? '—' : (stats?.current.vcpus_reserved ?? 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-[var(--color-bg-2)] px-6 py-5 transition-colors duration-150 hover:bg-[var(--color-bg-3)]">
|
||||||
|
<div class="text-label text-[var(--color-text-muted)]">Peak · 30d</div>
|
||||||
|
<div class="mt-1.5 font-serif text-[2.571rem] leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">
|
||||||
{loading ? '—' : (stats?.peaks.vcpus ?? 0)}
|
{loading ? '—' : (stats?.peaks.vcpus ?? 0)}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-label text-[var(--color-text-tertiary)]">vCPUs reserved · 30d max</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Peak RAM 30d -->
|
<!-- Reserved RAM -->
|
||||||
<div class="flex-1 bg-[var(--color-bg-2)] px-5 py-5 transition-colors duration-150 hover:bg-[var(--color-bg-3)]">
|
<div style="box-shadow: inset 3px 0 0 #d4a73c">
|
||||||
<span class="text-meta font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">Peak RAM</span>
|
<div class="flex items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-bg-3)] px-6 py-3">
|
||||||
<div class="mt-1 font-serif text-[2.571rem] tracking-[-0.04em] text-[var(--color-text-bright)]">
|
<span class="h-[6px] w-[6px] rounded-full" style="background: #d4a73c"></span>
|
||||||
|
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">RAM</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 divide-x divide-[var(--color-border)]">
|
||||||
|
<div class="bg-[var(--color-bg-3)] px-6 py-5 transition-colors duration-150 hover:bg-[var(--color-bg-4)]">
|
||||||
|
<div class="text-label text-[var(--color-text-muted)]">Reserved now</div>
|
||||||
|
<div class="mt-1.5 font-serif text-[2.571rem] leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">
|
||||||
|
{loading ? '—' : fmtGB(stats?.current.memory_mb_reserved ?? 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-[var(--color-bg-2)] px-6 py-5 transition-colors duration-150 hover:bg-[var(--color-bg-3)]">
|
||||||
|
<div class="text-label text-[var(--color-text-muted)]">Peak · 30d</div>
|
||||||
|
<div class="mt-1.5 font-serif text-[2.571rem] leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">
|
||||||
{loading ? '—' : fmtGB(stats?.peaks.memory_mb ?? 0)}
|
{loading ? '—' : fmtGB(stats?.peaks.memory_mb ?? 0)}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-label text-[var(--color-text-tertiary)]">reserved · 30d max</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error state -->
|
<!-- Error state -->
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="rounded-[var(--radius-card)] border border-[var(--color-red)]/20 bg-[var(--color-red)]/5 px-4 py-3 text-ui text-[var(--color-red)]/70">
|
<div class="flex items-center gap-3 rounded-[var(--radius-card)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/8 px-4 py-3">
|
||||||
Failed to load stats: {error}
|
<svg class="shrink-0 text-[var(--color-red)]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-ui text-[var(--color-red)]">Failed to load stats: {error}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Running Capsules chart -->
|
<!-- Charts -->
|
||||||
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
|
<div class="flex flex-1 flex-col gap-5">
|
||||||
<div class="flex items-center justify-between px-5 pt-5 pb-3">
|
|
||||||
<div>
|
<!-- Running Capsules -->
|
||||||
<div class="text-meta font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">Running Capsules</div>
|
<div class="flex flex-col rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
|
||||||
<div class="mt-0.5 flex items-baseline gap-2">
|
<div class="border-b border-[var(--color-border)] px-6 py-4">
|
||||||
<span class="font-serif text-[2.143rem] tracking-[-0.04em] text-[var(--color-text-bright)]">
|
<div class="flex items-center gap-2">
|
||||||
{loading ? '—' : (stats?.current.running_count ?? 0)}
|
<span class="h-[6px] w-[6px] rounded-full bg-[var(--color-accent)]"></span>
|
||||||
</span>
|
<div class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Running Capsules</div>
|
||||||
<span class="text-ui text-[var(--color-text-secondary)]">now</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="relative flex-1 px-5 pb-5 pt-3" style="min-height: 220px">
|
||||||
{#if !loading && stats && stats.series.labels.length === 0}
|
|
||||||
<div class="flex h-[200px] items-center justify-center text-ui text-[var(--color-text-muted)]">
|
|
||||||
Metrics will appear here once capsules have run. First data arrives within 10 seconds.
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="relative h-[200px] px-5 pb-5">
|
|
||||||
<canvas bind:this={canvasRunning}></canvas>
|
<canvas bind:this={canvasRunning}></canvas>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Reserved CPU & RAM chart -->
|
<!-- Reserved CPU & RAM -->
|
||||||
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
|
<div class="flex flex-col rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
|
||||||
<div class="flex items-center justify-between px-5 pt-5 pb-3">
|
<div class="border-b border-[var(--color-border)] px-6 py-4">
|
||||||
<div>
|
<div class="flex items-center gap-3">
|
||||||
<div class="text-meta font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">Reserved CPU & RAM</div>
|
<span class="flex items-center gap-1.5">
|
||||||
<div class="mt-0.5 flex items-baseline gap-2">
|
<span class="h-[6px] w-[6px] rounded-full" style="background: #5a9fd4"></span>
|
||||||
<span class="font-serif text-[2.143rem] tracking-[-0.04em] text-[var(--color-text-bright)]">
|
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">CPU</span>
|
||||||
{loading ? '—' : (stats?.current.vcpus_reserved ?? 0)}
|
|
||||||
</span>
|
</span>
|
||||||
<span class="text-ui text-[var(--color-text-secondary)]">vCPUs</span>
|
<span class="text-label text-[var(--color-text-muted)]">/</span>
|
||||||
<span class="font-serif text-[2.143rem] tracking-[-0.04em] text-[var(--color-text-bright)]">
|
<span class="flex items-center gap-1.5">
|
||||||
{loading ? '—' : fmtGB(stats?.current.memory_mb_reserved ?? 0)}
|
<span class="h-[6px] w-[6px] rounded-full" style="background: #d4a73c"></span>
|
||||||
|
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">RAM</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="text-ui text-[var(--color-text-secondary)]">RAM</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="relative flex-1 px-5 pb-5 pt-3" style="min-height: 220px">
|
||||||
{#if !loading && stats && stats.series.labels.length === 0}
|
|
||||||
<div class="flex h-[200px] items-center justify-center text-ui text-[var(--color-text-muted)]">
|
|
||||||
Metrics will appear here once capsules have run. First data arrives within 10 seconds.
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="relative h-[200px] px-5 pb-5">
|
|
||||||
<canvas bind:this={canvasResource}></canvas>
|
<canvas bind:this={canvasResource}></canvas>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -21,7 +21,6 @@ type statsCurrentResponse struct {
|
|||||||
RunningCount int32 `json:"running_count"`
|
RunningCount int32 `json:"running_count"`
|
||||||
VCPUsReserved int32 `json:"vcpus_reserved"`
|
VCPUsReserved int32 `json:"vcpus_reserved"`
|
||||||
MemoryMBReserved int32 `json:"memory_mb_reserved"`
|
MemoryMBReserved int32 `json:"memory_mb_reserved"`
|
||||||
SampledAt string `json:"sampled_at,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type statsPeaksResponse struct {
|
type statsPeaksResponse struct {
|
||||||
@ -85,10 +84,6 @@ func (h *statsHandler) GetStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if !current.SampledAt.IsZero() {
|
|
||||||
resp.Current.SampledAt = current.SampledAt.UTC().Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, pt := range series {
|
for i, pt := range series {
|
||||||
resp.Series.Labels[i] = pt.Bucket.UTC().Format(time.RFC3339)
|
resp.Series.Labels[i] = pt.Bucket.UTC().Format(time.RFC3339)
|
||||||
resp.Series.Running[i] = pt.RunningCount
|
resp.Series.Running[i] = pt.RunningCount
|
||||||
|
|||||||
@ -7,34 +7,31 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const getCurrentMetrics = `-- name: GetCurrentMetrics :one
|
const getLiveMetrics = `-- name: GetLiveMetrics :one
|
||||||
SELECT running_count, vcpus_reserved, memory_mb_reserved, sampled_at
|
SELECT
|
||||||
FROM sandbox_metrics_snapshots
|
(COUNT(*) FILTER (WHERE status IN ('running', 'starting')))::INTEGER AS running_count,
|
||||||
|
(COALESCE(SUM(vcpus) FILTER (WHERE status IN ('running', 'starting')), 0))::INTEGER AS vcpus_reserved,
|
||||||
|
(COALESCE(SUM(memory_mb) FILTER (WHERE status IN ('running', 'starting')), 0)
|
||||||
|
+ CEIL(COALESCE(SUM(memory_mb) FILTER (WHERE status = 'paused'), 0)::NUMERIC / 2))::INTEGER AS memory_mb_reserved
|
||||||
|
FROM sandboxes
|
||||||
WHERE team_id = $1
|
WHERE team_id = $1
|
||||||
ORDER BY sampled_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetCurrentMetricsRow struct {
|
type GetLiveMetricsRow struct {
|
||||||
RunningCount int32 `json:"running_count"`
|
RunningCount int32 `json:"running_count"`
|
||||||
VcpusReserved int32 `json:"vcpus_reserved"`
|
VcpusReserved int32 `json:"vcpus_reserved"`
|
||||||
MemoryMbReserved int32 `json:"memory_mb_reserved"`
|
MemoryMbReserved int32 `json:"memory_mb_reserved"`
|
||||||
SampledAt pgtype.Timestamptz `json:"sampled_at"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetCurrentMetrics(ctx context.Context, teamID string) (GetCurrentMetricsRow, error) {
|
// Reads directly from sandboxes for accurate real-time current values.
|
||||||
row := q.db.QueryRow(ctx, getCurrentMetrics, teamID)
|
// CPU reserved = running + starting only (paused VMs release CPU).
|
||||||
var i GetCurrentMetricsRow
|
// RAM reserved = running + starting + ceil(paused/2) (capacity held for resume).
|
||||||
err := row.Scan(
|
func (q *Queries) GetLiveMetrics(ctx context.Context, teamID string) (GetLiveMetricsRow, error) {
|
||||||
&i.RunningCount,
|
row := q.db.QueryRow(ctx, getLiveMetrics, teamID)
|
||||||
&i.VcpusReserved,
|
var i GetLiveMetricsRow
|
||||||
&i.MemoryMbReserved,
|
err := row.Scan(&i.RunningCount, &i.VcpusReserved, &i.MemoryMbReserved)
|
||||||
&i.SampledAt,
|
|
||||||
)
|
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,8 +94,7 @@ const sampleSandboxMetrics = `-- name: SampleSandboxMetrics :many
|
|||||||
SELECT
|
SELECT
|
||||||
team_id,
|
team_id,
|
||||||
(COUNT(*) FILTER (WHERE status IN ('running', 'starting')))::INTEGER AS running_count,
|
(COUNT(*) FILTER (WHERE status IN ('running', 'starting')))::INTEGER AS running_count,
|
||||||
(COALESCE(SUM(vcpus) FILTER (WHERE status IN ('running', 'starting')), 0)
|
(COALESCE(SUM(vcpus) FILTER (WHERE status IN ('running', 'starting')), 0))::INTEGER AS vcpus_reserved,
|
||||||
+ CEIL(COALESCE(SUM(vcpus) FILTER (WHERE status = 'paused'), 0)::NUMERIC / 2))::INTEGER AS vcpus_reserved,
|
|
||||||
(COALESCE(SUM(memory_mb) FILTER (WHERE status IN ('running', 'starting')), 0)
|
(COALESCE(SUM(memory_mb) FILTER (WHERE status IN ('running', 'starting')), 0)
|
||||||
+ CEIL(COALESCE(SUM(memory_mb) FILTER (WHERE status = 'paused'), 0)::NUMERIC / 2))::INTEGER AS memory_mb_reserved
|
+ CEIL(COALESCE(SUM(memory_mb) FILTER (WHERE status = 'paused'), 0)::NUMERIC / 2))::INTEGER AS memory_mb_reserved
|
||||||
FROM sandboxes
|
FROM sandboxes
|
||||||
@ -114,7 +110,8 @@ type SampleSandboxMetricsRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Aggregates per-team resource usage from the live sandboxes table.
|
// Aggregates per-team resource usage from the live sandboxes table.
|
||||||
// paused sandboxes count at 50% (ceil) for capacity reservation.
|
// CPU reserved = running + starting only (paused VMs release CPU).
|
||||||
|
// RAM reserved = running + starting + ceil(paused/2) (capacity held for resume).
|
||||||
func (q *Queries) SampleSandboxMetrics(ctx context.Context) ([]SampleSandboxMetricsRow, error) {
|
func (q *Queries) SampleSandboxMetrics(ctx context.Context) ([]SampleSandboxMetricsRow, error) {
|
||||||
rows, err := q.db.Query(ctx, sampleSandboxMetrics)
|
rows, err := q.db.Query(ctx, sampleSandboxMetrics)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -50,12 +50,11 @@ type StatPoint struct {
|
|||||||
MemoryMBReserved int32
|
MemoryMBReserved int32
|
||||||
}
|
}
|
||||||
|
|
||||||
// CurrentStats holds the most recent sampled values for a team.
|
// CurrentStats holds the live values for a team, read directly from sandboxes.
|
||||||
type CurrentStats struct {
|
type CurrentStats struct {
|
||||||
RunningCount int32
|
RunningCount int32
|
||||||
VCPUsReserved int32
|
VCPUsReserved int32
|
||||||
MemoryMBReserved int32
|
MemoryMBReserved int32
|
||||||
SampledAt time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PeakStats holds the 30-day maximum values for a team.
|
// PeakStats holds the 30-day maximum values for a team.
|
||||||
@ -79,19 +78,16 @@ func (s *StatsService) GetStats(ctx context.Context, teamID string, r TimeRange)
|
|||||||
return CurrentStats{}, PeakStats{}, nil, fmt.Errorf("unknown range: %s", r)
|
return CurrentStats{}, PeakStats{}, nil, fmt.Errorf("unknown range: %s", r)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current snapshot.
|
// Current live values — read directly from sandboxes so we always reflect
|
||||||
var current CurrentStats
|
// the true state even when no capsules are running.
|
||||||
cur, err := s.DB.GetCurrentMetrics(ctx, teamID)
|
cur, err := s.DB.GetLiveMetrics(ctx, teamID)
|
||||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
if err != nil {
|
||||||
return CurrentStats{}, PeakStats{}, nil, fmt.Errorf("get current metrics: %w", err)
|
return CurrentStats{}, PeakStats{}, nil, fmt.Errorf("get live metrics: %w", err)
|
||||||
}
|
}
|
||||||
if err == nil {
|
current := CurrentStats{
|
||||||
current = CurrentStats{
|
|
||||||
RunningCount: cur.RunningCount,
|
RunningCount: cur.RunningCount,
|
||||||
VCPUsReserved: cur.VcpusReserved,
|
VCPUsReserved: cur.VcpusReserved,
|
||||||
MemoryMBReserved: cur.MemoryMbReserved,
|
MemoryMBReserved: cur.MemoryMbReserved,
|
||||||
SampledAt: cur.SampledAt.Time,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 30-day peaks.
|
// 30-day peaks.
|
||||||
|
|||||||
Reference in New Issue
Block a user