diff --git a/db/queries/metrics.sql b/db/queries/metrics.sql index 6cd805f..43171e5 100644 --- a/db/queries/metrics.sql +++ b/db/queries/metrics.sql @@ -2,12 +2,17 @@ INSERT INTO sandbox_metrics_snapshots (team_id, running_count, vcpus_reserved, memory_mb_reserved) VALUES ($1, $2, $3, $4); --- name: GetCurrentMetrics :one -SELECT running_count, vcpus_reserved, memory_mb_reserved, sampled_at -FROM sandbox_metrics_snapshots -WHERE team_id = $1 -ORDER BY sampled_at DESC -LIMIT 1; +-- name: GetLiveMetrics :one +-- Reads directly from sandboxes for accurate real-time current values. +-- CPU reserved = running + starting only (paused VMs release CPU). +-- RAM reserved = running + starting + ceil(paused/2) (capacity held for resume). +SELECT + (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 SELECT @@ -24,12 +29,12 @@ WHERE sampled_at < NOW() - INTERVAL '60 days'; -- name: SampleSandboxMetrics :many -- 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 team_id, (COUNT(*) FILTER (WHERE status IN ('running', 'starting')))::INTEGER AS running_count, - (COALESCE(SUM(vcpus) FILTER (WHERE status IN ('running', 'starting')), 0) - + CEIL(COALESCE(SUM(vcpus) FILTER (WHERE status = 'paused'), 0)::NUMERIC / 2))::INTEGER AS vcpus_reserved, + (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 diff --git a/frontend/src/lib/components/StatsPanel.svelte b/frontend/src/lib/components/StatsPanel.svelte index 948e520..f067447 100644 --- a/frontend/src/lib/components/StatsPanel.svelte +++ b/frontend/src/lib/components/StatsPanel.svelte @@ -85,8 +85,10 @@ // Chart colors (resolved from CSS vars, must match app.css) const C_ACCENT = '#5e8c58'; 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_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_TICK = '#454340'; const FONT_MONO = "'JetBrains Mono', monospace"; @@ -160,14 +162,14 @@ { label: 'vCPUs', data: [], - borderColor: C_ACCENT, - backgroundColor: C_ACCENT_FILL, + borderColor: C_BLUE, + backgroundColor: C_BLUE_FILL, borderWidth: 1.5, fill: false, tension: 0, pointRadius: 0, pointHoverRadius: 4, - pointHoverBackgroundColor: C_ACCENT, + pointHoverBackgroundColor: C_BLUE, yAxisID: 'y', }, { @@ -248,148 +250,171 @@ } -
+
- -
- Usage Statistics -
-
- {#each RANGES as r, i} - - {/each} -
- {#if onlaunch} - - {/if} -
-
- - -
- - -
-
- Running Now + +
+
+
+

Usage Statistics

{#if !loading} - - + + Live {/if}
-
- {loading ? '—' : (stats?.current.running_count ?? 0)} +

Resource consumption across all capsules.

+
+
+ +
+ {#each RANGES as r, i} + + {/each} +
+ {#if onlaunch} + + {/if} +
+
+ + +
+ + +
+
+ + Running Capsules +
+
+
+
Now
+
+ {loading ? '—' : (stats?.current.running_count ?? 0)} +
+
+
+
Peak · 30d
+
+ {loading ? '—' : (stats?.peaks.running_count ?? 0)} +
+
-
capsules
- -
- Peak Running -
- {loading ? '—' : (stats?.peaks.running_count ?? 0)} + +
+
+ + CPU · vCPUs +
+
+
+
Reserved now
+
+ {loading ? '—' : (stats?.current.vcpus_reserved ?? 0)} +
+
+
+
Peak · 30d
+
+ {loading ? '—' : (stats?.peaks.vcpus ?? 0)} +
+
-
30-day max
- -
- Peak CPU -
- {loading ? '—' : (stats?.peaks.vcpus ?? 0)} + +
+
+ + RAM
-
vCPUs reserved · 30d max
-
- - -
- Peak RAM -
- {loading ? '—' : fmtGB(stats?.peaks.memory_mb ?? 0)} +
+
+
Reserved now
+
+ {loading ? '—' : fmtGB(stats?.current.memory_mb_reserved ?? 0)} +
+
+
+
Peak · 30d
+
+ {loading ? '—' : fmtGB(stats?.peaks.memory_mb ?? 0)} +
+
-
reserved · 30d max
{#if error} -
- Failed to load stats: {error} +
+ + + + Failed to load stats: {error}
{/if} - -
-
-
-
Running Capsules
-
- - {loading ? '—' : (stats?.current.running_count ?? 0)} - - now + +
+ + +
+
+
+ +
Running Capsules
-
- {#if !loading && stats && stats.series.labels.length === 0} -
- Metrics will appear here once capsules have run. First data arrives within 10 seconds. -
- {:else} -
+
- {/if} -
+
- -
-
-
-
Reserved CPU & RAM
-
- - {loading ? '—' : (stats?.current.vcpus_reserved ?? 0)} + +
+
+
+ + + CPU - vCPUs - - {loading ? '—' : fmtGB(stats?.current.memory_mb_reserved ?? 0)} + / + + + RAM - RAM
-
- {#if !loading && stats && stats.series.labels.length === 0} -
- Metrics will appear here once capsules have run. First data arrives within 10 seconds. -
- {:else} -
+
- {/if} +
+
diff --git a/internal/api/handlers_stats.go b/internal/api/handlers_stats.go index 06fe978..9222ffa 100644 --- a/internal/api/handlers_stats.go +++ b/internal/api/handlers_stats.go @@ -18,10 +18,9 @@ func newStatsHandler(svc *service.StatsService) *statsHandler { } type statsCurrentResponse struct { - RunningCount int32 `json:"running_count"` - VCPUsReserved int32 `json:"vcpus_reserved"` - MemoryMBReserved int32 `json:"memory_mb_reserved"` - SampledAt string `json:"sampled_at,omitempty"` + RunningCount int32 `json:"running_count"` + VCPUsReserved int32 `json:"vcpus_reserved"` + MemoryMBReserved int32 `json:"memory_mb_reserved"` } 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 { resp.Series.Labels[i] = pt.Bucket.UTC().Format(time.RFC3339) resp.Series.Running[i] = pt.RunningCount diff --git a/internal/db/metrics.sql.go b/internal/db/metrics.sql.go index 1bcc226..afa56d7 100644 --- a/internal/db/metrics.sql.go +++ b/internal/db/metrics.sql.go @@ -7,34 +7,31 @@ package db import ( "context" - - "github.com/jackc/pgx/v5/pgtype" ) -const getCurrentMetrics = `-- name: GetCurrentMetrics :one -SELECT running_count, vcpus_reserved, memory_mb_reserved, sampled_at -FROM sandbox_metrics_snapshots +const getLiveMetrics = `-- name: GetLiveMetrics :one +SELECT + (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 -ORDER BY sampled_at DESC -LIMIT 1 ` -type GetCurrentMetricsRow struct { - RunningCount int32 `json:"running_count"` - VcpusReserved int32 `json:"vcpus_reserved"` - MemoryMbReserved int32 `json:"memory_mb_reserved"` - SampledAt pgtype.Timestamptz `json:"sampled_at"` +type GetLiveMetricsRow struct { + RunningCount int32 `json:"running_count"` + VcpusReserved int32 `json:"vcpus_reserved"` + MemoryMbReserved int32 `json:"memory_mb_reserved"` } -func (q *Queries) GetCurrentMetrics(ctx context.Context, teamID string) (GetCurrentMetricsRow, error) { - row := q.db.QueryRow(ctx, getCurrentMetrics, teamID) - var i GetCurrentMetricsRow - err := row.Scan( - &i.RunningCount, - &i.VcpusReserved, - &i.MemoryMbReserved, - &i.SampledAt, - ) +// Reads directly from sandboxes for accurate real-time current values. +// CPU reserved = running + starting only (paused VMs release CPU). +// RAM reserved = running + starting + ceil(paused/2) (capacity held for resume). +func (q *Queries) GetLiveMetrics(ctx context.Context, teamID string) (GetLiveMetricsRow, error) { + row := q.db.QueryRow(ctx, getLiveMetrics, teamID) + var i GetLiveMetricsRow + err := row.Scan(&i.RunningCount, &i.VcpusReserved, &i.MemoryMbReserved) return i, err } @@ -97,8 +94,7 @@ const sampleSandboxMetrics = `-- name: SampleSandboxMetrics :many SELECT team_id, (COUNT(*) FILTER (WHERE status IN ('running', 'starting')))::INTEGER AS running_count, - (COALESCE(SUM(vcpus) FILTER (WHERE status IN ('running', 'starting')), 0) - + CEIL(COALESCE(SUM(vcpus) FILTER (WHERE status = 'paused'), 0)::NUMERIC / 2))::INTEGER AS vcpus_reserved, + (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 @@ -114,7 +110,8 @@ type SampleSandboxMetricsRow struct { } // 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) { rows, err := q.db.Query(ctx, sampleSandboxMetrics) if err != nil { diff --git a/internal/service/stats.go b/internal/service/stats.go index 38ac79d..1a075aa 100644 --- a/internal/service/stats.go +++ b/internal/service/stats.go @@ -50,12 +50,11 @@ type StatPoint struct { 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 { RunningCount int32 VCPUsReserved int32 MemoryMBReserved int32 - SampledAt time.Time } // 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) } - // Current snapshot. - var current CurrentStats - cur, err := s.DB.GetCurrentMetrics(ctx, teamID) - if err != nil && !errors.Is(err, pgx.ErrNoRows) { - return CurrentStats{}, PeakStats{}, nil, fmt.Errorf("get current metrics: %w", err) + // Current live values — read directly from sandboxes so we always reflect + // the true state even when no capsules are running. + cur, err := s.DB.GetLiveMetrics(ctx, teamID) + if err != nil { + return CurrentStats{}, PeakStats{}, nil, fmt.Errorf("get live metrics: %w", err) } - if err == nil { - current = CurrentStats{ - RunningCount: cur.RunningCount, - VCPUsReserved: cur.VcpusReserved, - MemoryMBReserved: cur.MemoryMbReserved, - SampledAt: cur.SampledAt.Time, - } + current := CurrentStats{ + RunningCount: cur.RunningCount, + VCPUsReserved: cur.VcpusReserved, + MemoryMBReserved: cur.MemoryMbReserved, } // 30-day peaks.