From 2349f585ae70510a874dfe29fd039376040fc9cb Mon Sep 17 00:00:00 2001 From: pptx704 Date: Wed, 25 Mar 2026 12:55:23 +0600 Subject: [PATCH 01/16] Bolder, more delightful frontend across all pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app.css: replace flat --shadow-sm token with real shadows; add --shadow-card and --shadow-dialog tokens; add @keyframes status-ping and .animate-status-ping utility (outward ring ripple, GPU-composited via will-change) for live running status dots - login: headline 5rem → 6.5rem with tighter leading/tracking; expand container to 460px; add sage-green dot grid texture layer beneath the mouse-reactive glow for industrial depth - capsules: upgrade all running dots (header chip + row indicators + status bar) from opacity-fade to ring ripple; apply --shadow-dialog to Launch and Snapshot dialogs - keys: apply --shadow-dialog to all three dialogs - audit: remove duplicate @keyframes fadeUp and iconFloat (redundant with app.css definitions, audit's fadeUp also subtly diverged) - sidebar: active indicator bar taller and thicker (h-5 w-[3px] → h-6 w-1); active bg more vivid (accent/12%); label font-medium → font-semibold; team dialog gets --shadow-dialog --- frontend/src/app.css | 24 +++++++++++++++++-- frontend/src/lib/components/Sidebar.svelte | 12 +++++----- .../src/routes/dashboard/audit/+page.svelte | 10 +------- .../routes/dashboard/capsules/+page.svelte | 20 +++++++++------- .../src/routes/dashboard/keys/+page.svelte | 6 ++--- frontend/src/routes/login/+page.svelte | 19 ++++++++++----- 6 files changed, 56 insertions(+), 35 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index 0a6b995..9c2e326 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -69,8 +69,10 @@ --radius-avatar: 5px; --radius-logo: 6px; - /* Shadows — flat aesthetic */ - --shadow-sm: 0 0 #0000; + /* Shadows */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.35), 0 1px 2px rgba(0, 0, 0, 0.2); + --shadow-card: 0 4px 12px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.25); + --shadow-dialog: 0 16px 48px rgba(0, 0, 0, 0.6), 0 4px 12px rgba(0, 0, 0, 0.35); } /* Base styles */ @@ -131,6 +133,24 @@ body { } } +/* Outward ring ripple — for live/running status dots; more delightful than opacity-only */ +@keyframes status-ping { + 0% { + transform: scale(1); + opacity: 0.8; + } + 80%, + 100% { + transform: scale(2.8); + opacity: 0; + } +} + +.animate-status-ping { + animation: status-ping 2s cubic-bezier(0, 0, 0.2, 1) infinite; + will-change: transform, opacity; +} + /* Fade-up entrance animation */ @keyframes fadeUp { from { diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte index c9a7afc..b7e65f5 100644 --- a/frontend/src/lib/components/Sidebar.svelte +++ b/frontend/src/lib/components/Sidebar.svelte @@ -342,19 +342,19 @@ {:else if isActive(item.href)} {#if !collapsed}
{/if} {#if !collapsed} - + {item.label} {/if} @@ -396,7 +396,7 @@

Create Team diff --git a/frontend/src/routes/dashboard/audit/+page.svelte b/frontend/src/routes/dashboard/audit/+page.svelte index 31556bb..1cd4e93 100644 --- a/frontend/src/routes/dashboard/audit/+page.svelte +++ b/frontend/src/routes/dashboard/audit/+page.svelte @@ -556,15 +556,7 @@

- - - { if (e.key === 'Escape') openMenuId = null; }} /> - - - Wrenn — Capsules - - -
- - -
-
- -
- -
-
-

- Capsules -

-

- Isolated VMs. Start cold in under a second — pause, snapshot, or destroy at will. -

-
- -
- -
- - - - - {runningCount} - running now -
-
-
- - -
- - -
-
- - - {#if activeTab === 'stats'} -
-
- {@render metricCell('Concurrent Capsules', String(runningCount), '5-sec avg', 'limit: 20', true)} - {@render metricCell('Start Rate / Second', '0.000', '5-sec avg', null, true)} - {@render metricCell('Peak Concurrent', String(runningCount), '30-day max', 'limit: 20', false)} -
- - {@render chartCard('Concurrent Capsules', String(runningCount), 'average')} - {@render chartCard('Start Rate Per Second', '0.000', 'average')} -
- {:else} -
- -
-
- - - - -
- {filteredCapsules.length} total - -
- - - - - - - - -
- - {#if error} -
- {error} -
- {/if} - - -
- -
-
ID
-
Template
- {@render sortableHeader('CPU', 'vcpus')} - {@render sortableHeader('Memory', 'memory_mb')} - {@render sortableHeader('Idle Timeout', 'timeout_sec')} - {@render sortableHeader('Started', 'started_at')} - {@render sortableHeader('Status', 'status')} -
- - {#if loading && capsules.length === 0} -
-
- - - - Loading capsules... -
-
- {:else if filteredCapsules.length === 0} -
-
-
-
- - - - - -
-
-

- No capsules yet -

-

- Each capsule is an isolated VM. Launch one to get started. -

- -
- {:else} - {#each filteredCapsules as capsule, i (capsule.id)} - {@const stripeColor = capsule.status === 'running' ? 'bg-[var(--color-accent)]' : capsule.status === 'paused' ? 'bg-[var(--color-amber)]' : 'bg-[var(--color-text-muted)]'} -
- -
- - -
- {#if capsule.status === 'running'} - - - - - {:else if capsule.status === 'paused'} - - {:else} - - {/if} - {#if searchQuery && capsule.id.toLowerCase().includes(searchQuery.toLowerCase())} - {@const matchIdx = capsule.id.toLowerCase().indexOf(searchQuery.toLowerCase())} - {capsule.id.slice(0, matchIdx)}{capsule.id.slice(matchIdx, matchIdx + searchQuery.length)}{capsule.id.slice(matchIdx + searchQuery.length)} - {:else} - {capsule.id} - {/if} -
- - -
- {capsule.template} -
- - -
- {capsule.vcpus} -
- - -
- {capsule.memory_mb}MB -
- - -
- {capsule.timeout_sec ? `${capsule.timeout_sec}s` : '—'} -
- - -
- {formatTime(capsule.started_at)} - {#if capsule.last_active_at} - {timeAgo(capsule.last_active_at)} - {/if} -
- - -
- {#if actionLoading === capsule.id} - - - - - - {:else} - - - {/if} -
-
- {/each} - {/if} -
-
- {/if} -
- - -
-
- - - - - All systems operational -
-
-
-
- - -{#if openMenuId} - {@const openCapsule = capsules.find((c) => c.id === openMenuId)} - {#if openCapsule} -
- {#if openCapsule.status === 'running'} - - - {:else if openCapsule.status === 'paused'} - - - {/if} -
- -
- {/if} -{/if} - - -{#if snapshotTarget} -
- -
{ if (!snapshotting) snapshotTarget = null; }} - onkeydown={(e) => { if (e.key === 'Escape' && !snapshotting) snapshotTarget = null; }} - >
- -
- -
-
- - - - -
-
-

Capture snapshot

-

{snapshotTarget.capsule.id}

-
-
- -
- {#if snapshotTarget.pauseFirst} -
- - - - - -

This capsule will be paused first — memory state is captured at rest.

-
- {:else} -

The capsule's current memory state will be captured and stored as a reusable snapshot.

- {/if} - - {#if snapshotError} -
- {snapshotError} -
- {/if} - -
-
- - optional -
- { if (e.key === 'Enter' && !snapshotting) handleSnapshotConfirm(); }} - /> -

Leave blank to use an auto-generated name.

-
- -
- - -
-
-
-
-{/if} - - -{#if showCreateDialog} -
- -
{ if (!creating) showCreateDialog = false; }} - onkeydown={(e) => { if (e.key === 'Escape' && !creating) showCreateDialog = false; }} - >
- -
-

Launch Capsule

-

Configure resources and launch. The VM will be ready in under a second.

- - {#if createError} -
- {createError} -
- {/if} - -
-
- - -
- -
-
- - -
-
- - -
-
- -
- - -
-
- -
- - -
-
-
-{/if} - - -{#if destroyTarget} -
- -
{ if (!destroying) destroyTarget = null; }} - onkeydown={(e) => { if (e.key === 'Escape' && !destroying) destroyTarget = null; }} - >
- -
-

Destroy Capsule

-

- Terminate {destroyTarget.id} and destroy all data inside it. This cannot be undone. -

- - {#if destroyError} -
- {destroyError} -
- {/if} - -
- - -
-
-
-{/if} - - -{#snippet sortableHeader(label: string, key: SortKey)} - -{/snippet} - -{#snippet metricCell(label: string, value: string, sublabel: string, extra: string | null, hasBorderRight: boolean)} -
-
- {label} - - - Live - -
-
{value}
-
- {sublabel} - {#if extra} - | - {extra} - {/if} -
-
-{/snippet} - -{#snippet chartCard(label: string, value: string, sublabel: string)} -
-
-
-
{label}
-
- {value} - {sublabel} - - - Live - -
-
- -
- {#each ['5m', '1H', '6H', '24H', '30D'] as range, i} - - {/each} -
-
- -
-
- 4 - 3 - 2 - 1 - 0 -
- - - {#each [0, 45, 90, 135, 180] as y} - - {/each} - - - -
- {#each ['03:01', '03:02', '03:03', '03:04', '03:05'] as t} - {t} - {/each} -
-
-
-{/snippet} diff --git a/frontend/src/routes/dashboard/capsules/+page.ts b/frontend/src/routes/dashboard/capsules/+page.ts new file mode 100644 index 0000000..029fe7c --- /dev/null +++ b/frontend/src/routes/dashboard/capsules/+page.ts @@ -0,0 +1,5 @@ +import { redirect } from '@sveltejs/kit'; + +export function load() { + throw redirect(307, '/dashboard/capsules/list'); +} diff --git a/frontend/src/routes/dashboard/capsules/list/+page.svelte b/frontend/src/routes/dashboard/capsules/list/+page.svelte new file mode 100644 index 0000000..a04a9e7 --- /dev/null +++ b/frontend/src/routes/dashboard/capsules/list/+page.svelte @@ -0,0 +1,734 @@ + + + + + + { if (e.key === 'Escape') openMenuId = null; }} /> + +
+ +
+
+ + + + +
+ {filteredCapsules.length} total + +
+ + + + + + + + +
+ + {#if error} +
+ {error} +
+ {/if} + + +
+ +
+
ID
+
Template
+ {@render sortableHeader('CPU', 'vcpus')} + {@render sortableHeader('Memory', 'memory_mb')} + {@render sortableHeader('Idle Timeout', 'timeout_sec')} + {@render sortableHeader('Started', 'started_at')} + {@render sortableHeader('Status', 'status')} +
+ + {#if loading && capsules.length === 0} +
+
+ + + + Loading capsules... +
+
+ {:else if filteredCapsules.length === 0} +
+
+
+
+ + + + + +
+
+

+ No capsules yet +

+

+ Each capsule is an isolated VM. Launch one to get started. +

+ +
+ {:else} + {#each filteredCapsules as capsule, i (capsule.id)} + {@const stripeColor = capsule.status === 'running' ? 'bg-[var(--color-accent)]' : capsule.status === 'paused' ? 'bg-[var(--color-amber)]' : 'bg-[var(--color-text-muted)]'} +
+ +
+ + +
+ {#if capsule.status === 'running'} + + + + + {:else if capsule.status === 'paused'} + + {:else} + + {/if} + {#if searchQuery && capsule.id.toLowerCase().includes(searchQuery.toLowerCase())} + {@const matchIdx = capsule.id.toLowerCase().indexOf(searchQuery.toLowerCase())} + {capsule.id.slice(0, matchIdx)}{capsule.id.slice(matchIdx, matchIdx + searchQuery.length)}{capsule.id.slice(matchIdx + searchQuery.length)} + {:else} + {capsule.id} + {/if} +
+ + +
+ {capsule.template} +
+ + +
+ {capsule.vcpus} +
+ + +
+ {capsule.memory_mb}MB +
+ + +
+ {capsule.timeout_sec ? `${capsule.timeout_sec}s` : '—'} +
+ + +
+ {formatTime(capsule.started_at)} + {#if capsule.last_active_at} + {timeAgo(capsule.last_active_at)} + {/if} +
+ + +
+ {#if actionLoading === capsule.id} + + + + + + {:else} + + {/if} +
+
+ {/each} + {/if} +
+
+ + +{#if openMenuId} + {@const openCapsule = capsules.find((c) => c.id === openMenuId)} + {#if openCapsule} +
+ {#if openCapsule.status === 'running'} + + + {:else if openCapsule.status === 'paused'} + + + {/if} +
+ +
+ {/if} +{/if} + + +{#if snapshotTarget} +
+ +
{ if (!snapshotting) snapshotTarget = null; }} + onkeydown={(e) => { if (e.key === 'Escape' && !snapshotting) snapshotTarget = null; }} + >
+ +
+
+
+ + + + +
+
+

Capture snapshot

+

{snapshotTarget.capsule.id}

+
+
+ +
+ {#if snapshotTarget.pauseFirst} +
+ + + + + +

This capsule will be paused first — memory state is captured at rest.

+
+ {:else} +

The capsule's current memory state will be captured and stored as a reusable snapshot.

+ {/if} + + {#if snapshotError} +
+ {snapshotError} +
+ {/if} + +
+
+ + optional +
+ { if (e.key === 'Enter' && !snapshotting) handleSnapshotConfirm(); }} + /> +

Leave blank to use an auto-generated name.

+
+ +
+ + +
+
+
+
+{/if} + + +{#if destroyTarget} +
+ +
{ if (!destroying) destroyTarget = null; }} + onkeydown={(e) => { if (e.key === 'Escape' && !destroying) destroyTarget = null; }} + >
+
+

Destroy Capsule

+

+ Terminate {destroyTarget.id} and destroy all data inside it. This cannot be undone. +

+ + {#if destroyError} +
+ {destroyError} +
+ {/if} + +
+ + +
+
+
+{/if} + + + { showCreateDialog = false; }} + oncreated={handleCapsuleCreated} +/> + +{#snippet sortableHeader(label: string, key: SortKey)} + +{/snippet} diff --git a/frontend/src/routes/dashboard/capsules/stats/+page.svelte b/frontend/src/routes/dashboard/capsules/stats/+page.svelte new file mode 100644 index 0000000..4b2e637 --- /dev/null +++ b/frontend/src/routes/dashboard/capsules/stats/+page.svelte @@ -0,0 +1,17 @@ + + + { showCreateDialog = true; }} + launchDisabled={!auth.teamId} +/> + + { showCreateDialog = false; }} +/> diff --git a/internal/api/handlers_stats.go b/internal/api/handlers_stats.go new file mode 100644 index 0000000..06fe978 --- /dev/null +++ b/internal/api/handlers_stats.go @@ -0,0 +1,100 @@ +package api + +import ( + "log/slog" + "net/http" + "time" + + "git.omukk.dev/wrenn/sandbox/internal/auth" + "git.omukk.dev/wrenn/sandbox/internal/service" +) + +type statsHandler struct { + svc *service.StatsService +} + +func newStatsHandler(svc *service.StatsService) *statsHandler { + return &statsHandler{svc: svc} +} + +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"` +} + +type statsPeaksResponse struct { + RunningCount int32 `json:"running_count"` + VCPUs int32 `json:"vcpus"` + MemoryMB int32 `json:"memory_mb"` +} + +type statsSeriesResponse struct { + Labels []string `json:"labels"` + Running []int32 `json:"running"` + VCPUs []int32 `json:"vcpus"` + MemoryMB []int32 `json:"memory_mb"` +} + +type statsResponse struct { + Range string `json:"range"` + Current statsCurrentResponse `json:"current"` + Peaks statsPeaksResponse `json:"peaks"` + Series statsSeriesResponse `json:"series"` +} + +// GetStats handles GET /v1/sandboxes/stats?range=5m|1h|6h|24h|30d +func (h *statsHandler) GetStats(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + + rangeParam := r.URL.Query().Get("range") + if rangeParam == "" { + rangeParam = string(service.Range1h) + } + tr := service.TimeRange(rangeParam) + if !service.ValidRange(tr) { + writeError(w, http.StatusBadRequest, "invalid_request", "range must be one of: 5m, 1h, 6h, 24h, 30d") + return + } + + current, peaks, series, err := h.svc.GetStats(r.Context(), ac.TeamID, tr) + if err != nil { + slog.Error("stats handler: get stats failed", "team_id", ac.TeamID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "failed to retrieve stats") + return + } + + resp := statsResponse{ + Range: rangeParam, + Current: statsCurrentResponse{ + RunningCount: current.RunningCount, + VCPUsReserved: current.VCPUsReserved, + MemoryMBReserved: current.MemoryMBReserved, + }, + Peaks: statsPeaksResponse{ + RunningCount: peaks.RunningCount, + VCPUs: peaks.VCPUs, + MemoryMB: peaks.MemoryMB, + }, + Series: statsSeriesResponse{ + Labels: make([]string, len(series)), + Running: make([]int32, len(series)), + VCPUs: make([]int32, len(series)), + MemoryMB: make([]int32, len(series)), + }, + } + + 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 + resp.Series.VCPUs[i] = pt.VCPUsReserved + resp.Series.MemoryMB[i] = pt.MemoryMBReserved + } + + writeJSON(w, http.StatusOK, resp) +} diff --git a/internal/api/metrics_sampler.go b/internal/api/metrics_sampler.go new file mode 100644 index 0000000..7ea3cd0 --- /dev/null +++ b/internal/api/metrics_sampler.go @@ -0,0 +1,68 @@ +package api + +import ( + "context" + "log/slog" + "time" + + "git.omukk.dev/wrenn/sandbox/internal/db" +) + +// MetricsSampler records per-team sandbox resource usage to +// sandbox_metrics_snapshots every interval. It also prunes rows older than +// 60 days on each tick to keep the table bounded. +type MetricsSampler struct { + db *db.Queries + interval time.Duration +} + +// NewMetricsSampler creates a MetricsSampler. +func NewMetricsSampler(queries *db.Queries, interval time.Duration) *MetricsSampler { + return &MetricsSampler{db: queries, interval: interval} +} + +// Start runs the sampler loop until the context is cancelled. +func (s *MetricsSampler) Start(ctx context.Context) { + go func() { + ticker := time.NewTicker(s.interval) + defer ticker.Stop() + + // Sample immediately on startup. + s.run(ctx) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.run(ctx) + } + } + }() +} + +func (s *MetricsSampler) run(ctx context.Context) { + s.prune(ctx) + if err := s.sample(ctx); err != nil { + slog.Warn("metrics sampler: sample failed", "error", err) + } +} + +func (s *MetricsSampler) sample(ctx context.Context) error { + rows, err := s.db.SampleSandboxMetrics(ctx) + if err != nil { + return err + } + for _, row := range rows { + if err := s.db.InsertMetricsSnapshot(ctx, db.InsertMetricsSnapshotParams(row)); err != nil { + slog.Warn("metrics sampler: insert snapshot failed", "team_id", row.TeamID, "error", err) + } + } + return nil +} + +func (s *MetricsSampler) prune(ctx context.Context) { + if err := s.db.PruneOldMetrics(ctx); err != nil { + slog.Warn("metrics sampler: prune failed", "error", err) + } +} diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml index e46fabc..86e88c6 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -613,6 +613,32 @@ paths: items: $ref: "#/components/schemas/Sandbox" + /v1/sandboxes/stats: + get: + summary: Get sandbox usage stats for your team + operationId: getSandboxStats + tags: [sandboxes] + security: + - apiKeyAuth: [] + parameters: + - name: range + in: query + required: false + schema: + type: string + enum: [5m, 1h, 6h, 24h, 30d] + default: 1h + description: Time window for the time-series data. + responses: + "200": + description: Sandbox stats for the team + content: + application/json: + schema: + $ref: "#/components/schemas/SandboxStats" + "400": + $ref: "#/components/responses/BadRequest" + /v1/sandboxes/{id}: parameters: - name: id @@ -1578,6 +1604,57 @@ components: after this duration of inactivity (no exec or ping). 0 means no auto-pause. + SandboxStats: + type: object + properties: + range: + type: string + enum: [5m, 1h, 6h, 24h, 30d] + current: + type: object + properties: + running_count: + type: integer + vcpus_reserved: + type: integer + memory_mb_reserved: + type: integer + sampled_at: + type: string + format: date-time + nullable: true + peaks: + type: object + description: Maximum values over the last 30 days. + properties: + running_count: + type: integer + vcpus: + type: integer + memory_mb: + type: integer + series: + type: object + description: Parallel arrays for chart rendering. + properties: + labels: + type: array + items: + type: string + format: date-time + running: + type: array + items: + type: integer + vcpus: + type: array + items: + type: integer + memory_mb: + type: array + items: + type: integer + Sandbox: type: object properties: diff --git a/internal/api/server.go b/internal/api/server.go index 366a122..636d1d1 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -46,6 +46,7 @@ func New( hostSvc := &service.HostService{DB: queries, Redis: rdb, JWT: jwtSecret, Pool: pool} teamSvc := &service.TeamService{DB: queries, Pool: pgPool, HostPool: pool} auditSvc := &service.AuditService{DB: queries} + statsSvc := &service.StatsService{DB: queries, Pool: pgPool} al := audit.New(queries) @@ -62,6 +63,7 @@ func New( teamH := newTeamHandler(teamSvc, al) usersH := newUsersHandler(teamSvc) auditH := newAuditHandler(auditSvc) + statsH := newStatsHandler(statsSvc) // OpenAPI spec and docs. r.Get("/openapi.yaml", serveOpenAPI) @@ -109,6 +111,7 @@ func New( r.Use(requireAPIKeyOrJWT(queries, jwtSecret)) r.Post("/", sandbox.Create) r.Get("/", sandbox.List) + r.Get("/stats", statsH.GetStats) r.Route("/{id}", func(r chi.Router) { r.Get("/", sandbox.Get) diff --git a/internal/db/metrics.sql.go b/internal/db/metrics.sql.go new file mode 100644 index 0000000..1bcc226 --- /dev/null +++ b/internal/db/metrics.sql.go @@ -0,0 +1,141 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: metrics.sql + +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 +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"` +} + +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, + ) + return i, err +} + +const getPeakMetrics = `-- name: GetPeakMetrics :one +SELECT + COALESCE(MAX(running_count), 0)::INTEGER AS peak_running_count, + COALESCE(MAX(vcpus_reserved), 0)::INTEGER AS peak_vcpus, + COALESCE(MAX(memory_mb_reserved), 0)::INTEGER AS peak_memory_mb +FROM sandbox_metrics_snapshots +WHERE team_id = $1 + AND sampled_at > NOW() - INTERVAL '30 days' +` + +type GetPeakMetricsRow struct { + PeakRunningCount int32 `json:"peak_running_count"` + PeakVcpus int32 `json:"peak_vcpus"` + PeakMemoryMb int32 `json:"peak_memory_mb"` +} + +func (q *Queries) GetPeakMetrics(ctx context.Context, teamID string) (GetPeakMetricsRow, error) { + row := q.db.QueryRow(ctx, getPeakMetrics, teamID) + var i GetPeakMetricsRow + err := row.Scan(&i.PeakRunningCount, &i.PeakVcpus, &i.PeakMemoryMb) + return i, err +} + +const insertMetricsSnapshot = `-- name: InsertMetricsSnapshot :exec +INSERT INTO sandbox_metrics_snapshots (team_id, running_count, vcpus_reserved, memory_mb_reserved) +VALUES ($1, $2, $3, $4) +` + +type InsertMetricsSnapshotParams struct { + TeamID string `json:"team_id"` + RunningCount int32 `json:"running_count"` + VcpusReserved int32 `json:"vcpus_reserved"` + MemoryMbReserved int32 `json:"memory_mb_reserved"` +} + +func (q *Queries) InsertMetricsSnapshot(ctx context.Context, arg InsertMetricsSnapshotParams) error { + _, err := q.db.Exec(ctx, insertMetricsSnapshot, + arg.TeamID, + arg.RunningCount, + arg.VcpusReserved, + arg.MemoryMbReserved, + ) + return err +} + +const pruneOldMetrics = `-- name: PruneOldMetrics :exec +DELETE FROM sandbox_metrics_snapshots +WHERE sampled_at < NOW() - INTERVAL '60 days' +` + +func (q *Queries) PruneOldMetrics(ctx context.Context) error { + _, err := q.db.Exec(ctx, pruneOldMetrics) + return err +} + +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(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 status IN ('running', 'starting', 'paused') +GROUP BY team_id +` + +type SampleSandboxMetricsRow struct { + TeamID string `json:"team_id"` + RunningCount int32 `json:"running_count"` + VcpusReserved int32 `json:"vcpus_reserved"` + MemoryMbReserved int32 `json:"memory_mb_reserved"` +} + +// Aggregates per-team resource usage from the live sandboxes table. +// paused sandboxes count at 50% (ceil) for capacity reservation. +func (q *Queries) SampleSandboxMetrics(ctx context.Context) ([]SampleSandboxMetricsRow, error) { + rows, err := q.db.Query(ctx, sampleSandboxMetrics) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SampleSandboxMetricsRow + for rows.Next() { + var i SampleSandboxMetricsRow + if err := rows.Scan( + &i.TeamID, + &i.RunningCount, + &i.VcpusReserved, + &i.MemoryMbReserved, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/models.go b/internal/db/models.go index 00cbf70..df2981e 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -99,6 +99,15 @@ type Sandbox struct { TeamID string `json:"team_id"` } +type SandboxMetricsSnapshot struct { + ID int64 `json:"id"` + TeamID string `json:"team_id"` + SampledAt pgtype.Timestamptz `json:"sampled_at"` + RunningCount int32 `json:"running_count"` + VcpusReserved int32 `json:"vcpus_reserved"` + MemoryMbReserved int32 `json:"memory_mb_reserved"` +} + type Team struct { ID string `json:"id"` Name string `json:"name"` diff --git a/internal/service/stats.go b/internal/service/stats.go new file mode 100644 index 0000000..38ac79d --- /dev/null +++ b/internal/service/stats.go @@ -0,0 +1,157 @@ +package service + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "git.omukk.dev/wrenn/sandbox/internal/db" +) + +// TimeRange identifies a chart time window. +type TimeRange string + +const ( + Range5m TimeRange = "5m" + Range1h TimeRange = "1h" + Range6h TimeRange = "6h" + Range24h TimeRange = "24h" + Range30d TimeRange = "30d" +) + +type rangeConfig struct { + bucketSec int // bucket width in seconds for time-series aggregation + intervalLiteral string // PostgreSQL interval literal for the lookback window +} + +var rangeConfigs = map[TimeRange]rangeConfig{ + Range5m: {bucketSec: 3, intervalLiteral: "5 minutes"}, + Range1h: {bucketSec: 30, intervalLiteral: "1 hour"}, + Range6h: {bucketSec: 180, intervalLiteral: "6 hours"}, + Range24h: {bucketSec: 720, intervalLiteral: "24 hours"}, + Range30d: {bucketSec: 21600, intervalLiteral: "30 days"}, +} + +// ValidRange returns true if r is a known TimeRange value. +func ValidRange(r TimeRange) bool { + _, ok := rangeConfigs[r] + return ok +} + +// StatPoint is one bucketed data point in the time-series. +type StatPoint struct { + Bucket time.Time + RunningCount int32 + VCPUsReserved int32 + MemoryMBReserved int32 +} + +// CurrentStats holds the most recent sampled values for a team. +type CurrentStats struct { + RunningCount int32 + VCPUsReserved int32 + MemoryMBReserved int32 + SampledAt time.Time +} + +// PeakStats holds the 30-day maximum values for a team. +type PeakStats struct { + RunningCount int32 + VCPUs int32 + MemoryMB int32 +} + +// StatsService computes sandbox metrics for the dashboard. +type StatsService struct { + DB *db.Queries + Pool *pgxpool.Pool +} + +// GetStats returns current stats, 30-day peaks, and a time-series for the +// given team and time range. If no snapshots exist yet, zeros are returned. +func (s *StatsService) GetStats(ctx context.Context, teamID string, r TimeRange) (CurrentStats, PeakStats, []StatPoint, error) { + cfg, ok := rangeConfigs[r] + if !ok { + 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) + } + if err == nil { + current = CurrentStats{ + RunningCount: cur.RunningCount, + VCPUsReserved: cur.VcpusReserved, + MemoryMBReserved: cur.MemoryMbReserved, + SampledAt: cur.SampledAt.Time, + } + } + + // 30-day peaks. + var peaks PeakStats + pk, err := s.DB.GetPeakMetrics(ctx, teamID) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return CurrentStats{}, PeakStats{}, nil, fmt.Errorf("get peak metrics: %w", err) + } + if err == nil { + peaks = PeakStats{ + RunningCount: pk.PeakRunningCount, + VCPUs: pk.PeakVcpus, + MemoryMB: pk.PeakMemoryMb, + } + } + + // Time-series — dynamic bucket width, executed via pgx directly. + series, err := s.queryTimeSeries(ctx, teamID, cfg) + if err != nil { + return CurrentStats{}, PeakStats{}, nil, fmt.Errorf("get time series: %w", err) + } + + return current, peaks, series, nil +} + +// timeSeriesSQL uses an epoch-floor trick to bucket rows by an arbitrary +// integer number of seconds without requiring TimescaleDB. +// +// $1 = bucket width in seconds (integer) +// $2 = team_id +// $3 = lookback interval literal (e.g. '1 hour') +const timeSeriesSQL = ` +SELECT + to_timestamp(floor(extract(epoch FROM sampled_at) / $1) * $1) AS bucket, + AVG(running_count)::INTEGER AS running_count, + AVG(vcpus_reserved)::INTEGER AS vcpus_reserved, + AVG(memory_mb_reserved)::INTEGER AS memory_mb_reserved +FROM sandbox_metrics_snapshots +WHERE team_id = $2 + AND sampled_at >= NOW() - $3::INTERVAL +GROUP BY bucket +ORDER BY bucket ASC +` + +func (s *StatsService) queryTimeSeries(ctx context.Context, teamID string, cfg rangeConfig) ([]StatPoint, error) { + rows, err := s.Pool.Query(ctx, timeSeriesSQL, cfg.bucketSec, teamID, cfg.intervalLiteral) + if err != nil { + return nil, err + } + defer rows.Close() + + var points []StatPoint + for rows.Next() { + var p StatPoint + var bucket time.Time + if err := rows.Scan(&bucket, &p.RunningCount, &p.VCPUsReserved, &p.MemoryMBReserved); err != nil { + return nil, err + } + p.Bucket = bucket + points = append(points, p) + } + return points, rows.Err() +} From 47b0ed5b52f002c83f689443604590991c311fa0 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Wed, 25 Mar 2026 15:11:46 +0600 Subject: [PATCH 03/16] Fix metrics correctness, redesign stats page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- db/queries/metrics.sql | 23 +- frontend/src/lib/components/StatsPanel.svelte | 249 ++++++++++-------- internal/api/handlers_stats.go | 11 +- internal/db/metrics.sql.go | 45 ++-- internal/service/stats.go | 24 +- 5 files changed, 185 insertions(+), 167 deletions(-) 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. From 930da8a5787800e2105e020312d357ae725a9322 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Wed, 25 Mar 2026 15:24:21 +0600 Subject: [PATCH 04/16] Move metrics to dedicated nav item, simplify capsules page - Add Metrics nav item to sidebar with bar chart icon - Create /dashboard/metrics page wrapping StatsPanel - Remove tabs from capsules page (list is now the only view) - Flatten capsules route: /capsules directly shows the list, removing the /list and /stats sub-routes - Strip redundant title/subtitle from StatsPanel (page header provides context) --- frontend/src/lib/components/Sidebar.svelte | 4 +- frontend/src/lib/components/StatsPanel.svelte | 26 +- .../lib/components/icons/IconMetrics.svelte | 20 + frontend/src/lib/components/icons/index.ts | 1 + .../routes/dashboard/capsules/+layout.svelte | 36 +- .../routes/dashboard/capsules/+page.svelte | 733 ++++++++++++++++- .../src/routes/dashboard/capsules/+page.ts | 5 - .../dashboard/capsules/list/+page.svelte | 734 ------------------ .../dashboard/capsules/stats/+page.svelte | 17 - .../src/routes/dashboard/metrics/+page.svelte | 57 ++ 10 files changed, 825 insertions(+), 808 deletions(-) create mode 100644 frontend/src/lib/components/icons/IconMetrics.svelte delete mode 100644 frontend/src/routes/dashboard/capsules/+page.ts delete mode 100644 frontend/src/routes/dashboard/capsules/list/+page.svelte delete mode 100644 frontend/src/routes/dashboard/capsules/stats/+page.svelte create mode 100644 frontend/src/routes/dashboard/metrics/+page.svelte diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte index b7e65f5..1b11726 100644 --- a/frontend/src/lib/components/Sidebar.svelte +++ b/frontend/src/lib/components/Sidebar.svelte @@ -21,7 +21,8 @@ IconDocs, IconAudit, IconServer, - IconShield + IconShield, + IconMetrics } from './icons'; let { collapsed = $bindable(false) }: { collapsed: boolean } = $props(); @@ -47,6 +48,7 @@ const platformItems: NavItem[] = [ { label: 'Capsules', icon: IconMonitor, href: '/dashboard/capsules' }, + { label: 'Metrics', icon: IconMetrics, href: '/dashboard/metrics' }, { label: 'Templates', icon: IconBox, href: '/dashboard/snapshots' } ]; diff --git a/frontend/src/lib/components/StatsPanel.svelte b/frontend/src/lib/components/StatsPanel.svelte index f067447..e5380c3 100644 --- a/frontend/src/lib/components/StatsPanel.svelte +++ b/frontend/src/lib/components/StatsPanel.svelte @@ -250,22 +250,18 @@ } -
+
- -
-
-
-

Usage Statistics

- {#if !loading} - - - Live - - {/if} -
-

Resource consumption across all capsules.

-
+ +
+ {#if !loading} + + + Live + + {:else} +
+ {/if}
diff --git a/frontend/src/lib/components/icons/IconMetrics.svelte b/frontend/src/lib/components/icons/IconMetrics.svelte new file mode 100644 index 0000000..3110642 --- /dev/null +++ b/frontend/src/lib/components/icons/IconMetrics.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/icons/index.ts b/frontend/src/lib/components/icons/index.ts index fa90069..babf0a5 100644 --- a/frontend/src/lib/components/icons/index.ts +++ b/frontend/src/lib/components/icons/index.ts @@ -26,3 +26,4 @@ export { default as IconBox } from './IconBox.svelte'; export { default as IconServer } from './IconServer.svelte'; export { default as IconGear } from './IconGear.svelte'; export { default as IconShield } from './IconShield.svelte'; +export { default as IconMetrics } from './IconMetrics.svelte'; diff --git a/frontend/src/routes/dashboard/capsules/+layout.svelte b/frontend/src/routes/dashboard/capsules/+layout.svelte index 1f85886..8551513 100644 --- a/frontend/src/routes/dashboard/capsules/+layout.svelte +++ b/frontend/src/routes/dashboard/capsules/+layout.svelte @@ -1,7 +1,6 @@ @@ -26,8 +21,7 @@
-
- +

@@ -39,7 +33,6 @@

-
@@ -54,33 +47,6 @@
- - -
{@render children()} diff --git a/frontend/src/routes/dashboard/capsules/+page.svelte b/frontend/src/routes/dashboard/capsules/+page.svelte index 9c19d67..a04a9e7 100644 --- a/frontend/src/routes/dashboard/capsules/+page.svelte +++ b/frontend/src/routes/dashboard/capsules/+page.svelte @@ -1,3 +1,734 @@ + + + + + { if (e.key === 'Escape') openMenuId = null; }} /> + +
+ +
+
+ + + + +
+ {filteredCapsules.length} total + +
+ + + + + + + + +
+ + {#if error} +
+ {error} +
+ {/if} + + +
+ +
+
ID
+
Template
+ {@render sortableHeader('CPU', 'vcpus')} + {@render sortableHeader('Memory', 'memory_mb')} + {@render sortableHeader('Idle Timeout', 'timeout_sec')} + {@render sortableHeader('Started', 'started_at')} + {@render sortableHeader('Status', 'status')} +
+ + {#if loading && capsules.length === 0} +
+
+ + + + Loading capsules... +
+
+ {:else if filteredCapsules.length === 0} +
+
+
+
+ + + + + +
+
+

+ No capsules yet +

+

+ Each capsule is an isolated VM. Launch one to get started. +

+ +
+ {:else} + {#each filteredCapsules as capsule, i (capsule.id)} + {@const stripeColor = capsule.status === 'running' ? 'bg-[var(--color-accent)]' : capsule.status === 'paused' ? 'bg-[var(--color-amber)]' : 'bg-[var(--color-text-muted)]'} +
+ +
+ + +
+ {#if capsule.status === 'running'} + + + + + {:else if capsule.status === 'paused'} + + {:else} + + {/if} + {#if searchQuery && capsule.id.toLowerCase().includes(searchQuery.toLowerCase())} + {@const matchIdx = capsule.id.toLowerCase().indexOf(searchQuery.toLowerCase())} + {capsule.id.slice(0, matchIdx)}{capsule.id.slice(matchIdx, matchIdx + searchQuery.length)}{capsule.id.slice(matchIdx + searchQuery.length)} + {:else} + {capsule.id} + {/if} +
+ + +
+ {capsule.template} +
+ + +
+ {capsule.vcpus} +
+ + +
+ {capsule.memory_mb}MB +
+ + +
+ {capsule.timeout_sec ? `${capsule.timeout_sec}s` : '—'} +
+ + +
+ {formatTime(capsule.started_at)} + {#if capsule.last_active_at} + {timeAgo(capsule.last_active_at)} + {/if} +
+ + +
+ {#if actionLoading === capsule.id} + + + + + + {:else} + + {/if} +
+
+ {/each} + {/if} +
+
+ + +{#if openMenuId} + {@const openCapsule = capsules.find((c) => c.id === openMenuId)} + {#if openCapsule} +
+ {#if openCapsule.status === 'running'} + + + {:else if openCapsule.status === 'paused'} + + + {/if} +
+ +
+ {/if} +{/if} + + +{#if snapshotTarget} +
+ +
{ if (!snapshotting) snapshotTarget = null; }} + onkeydown={(e) => { if (e.key === 'Escape' && !snapshotting) snapshotTarget = null; }} + >
+ +
+
+
+ + + + +
+
+

Capture snapshot

+

{snapshotTarget.capsule.id}

+
+
+ +
+ {#if snapshotTarget.pauseFirst} +
+ + + + + +

This capsule will be paused first — memory state is captured at rest.

+
+ {:else} +

The capsule's current memory state will be captured and stored as a reusable snapshot.

+ {/if} + + {#if snapshotError} +
+ {snapshotError} +
+ {/if} + +
+
+ + optional +
+ { if (e.key === 'Enter' && !snapshotting) handleSnapshotConfirm(); }} + /> +

Leave blank to use an auto-generated name.

+
+ +
+ + +
+
+
+
+{/if} + + +{#if destroyTarget} +
+ +
{ if (!destroying) destroyTarget = null; }} + onkeydown={(e) => { if (e.key === 'Escape' && !destroying) destroyTarget = null; }} + >
+
+

Destroy Capsule

+

+ Terminate {destroyTarget.id} and destroy all data inside it. This cannot be undone. +

+ + {#if destroyError} +
+ {destroyError} +
+ {/if} + +
+ + +
+
+
+{/if} + + + { showCreateDialog = false; }} + oncreated={handleCapsuleCreated} +/> + +{#snippet sortableHeader(label: string, key: SortKey)} + +{/snippet} diff --git a/frontend/src/routes/dashboard/capsules/+page.ts b/frontend/src/routes/dashboard/capsules/+page.ts deleted file mode 100644 index 029fe7c..0000000 --- a/frontend/src/routes/dashboard/capsules/+page.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from '@sveltejs/kit'; - -export function load() { - throw redirect(307, '/dashboard/capsules/list'); -} diff --git a/frontend/src/routes/dashboard/capsules/list/+page.svelte b/frontend/src/routes/dashboard/capsules/list/+page.svelte deleted file mode 100644 index a04a9e7..0000000 --- a/frontend/src/routes/dashboard/capsules/list/+page.svelte +++ /dev/null @@ -1,734 +0,0 @@ - - - - - - { if (e.key === 'Escape') openMenuId = null; }} /> - -
- -
-
- - - - -
- {filteredCapsules.length} total - -
- - - - - - - - -
- - {#if error} -
- {error} -
- {/if} - - -
- -
-
ID
-
Template
- {@render sortableHeader('CPU', 'vcpus')} - {@render sortableHeader('Memory', 'memory_mb')} - {@render sortableHeader('Idle Timeout', 'timeout_sec')} - {@render sortableHeader('Started', 'started_at')} - {@render sortableHeader('Status', 'status')} -
- - {#if loading && capsules.length === 0} -
-
- - - - Loading capsules... -
-
- {:else if filteredCapsules.length === 0} -
-
-
-
- - - - - -
-
-

- No capsules yet -

-

- Each capsule is an isolated VM. Launch one to get started. -

- -
- {:else} - {#each filteredCapsules as capsule, i (capsule.id)} - {@const stripeColor = capsule.status === 'running' ? 'bg-[var(--color-accent)]' : capsule.status === 'paused' ? 'bg-[var(--color-amber)]' : 'bg-[var(--color-text-muted)]'} -
- -
- - -
- {#if capsule.status === 'running'} - - - - - {:else if capsule.status === 'paused'} - - {:else} - - {/if} - {#if searchQuery && capsule.id.toLowerCase().includes(searchQuery.toLowerCase())} - {@const matchIdx = capsule.id.toLowerCase().indexOf(searchQuery.toLowerCase())} - {capsule.id.slice(0, matchIdx)}{capsule.id.slice(matchIdx, matchIdx + searchQuery.length)}{capsule.id.slice(matchIdx + searchQuery.length)} - {:else} - {capsule.id} - {/if} -
- - -
- {capsule.template} -
- - -
- {capsule.vcpus} -
- - -
- {capsule.memory_mb}MB -
- - -
- {capsule.timeout_sec ? `${capsule.timeout_sec}s` : '—'} -
- - -
- {formatTime(capsule.started_at)} - {#if capsule.last_active_at} - {timeAgo(capsule.last_active_at)} - {/if} -
- - -
- {#if actionLoading === capsule.id} - - - - - - {:else} - - {/if} -
-
- {/each} - {/if} -
-
- - -{#if openMenuId} - {@const openCapsule = capsules.find((c) => c.id === openMenuId)} - {#if openCapsule} -
- {#if openCapsule.status === 'running'} - - - {:else if openCapsule.status === 'paused'} - - - {/if} -
- -
- {/if} -{/if} - - -{#if snapshotTarget} -
- -
{ if (!snapshotting) snapshotTarget = null; }} - onkeydown={(e) => { if (e.key === 'Escape' && !snapshotting) snapshotTarget = null; }} - >
- -
-
-
- - - - -
-
-

Capture snapshot

-

{snapshotTarget.capsule.id}

-
-
- -
- {#if snapshotTarget.pauseFirst} -
- - - - - -

This capsule will be paused first — memory state is captured at rest.

-
- {:else} -

The capsule's current memory state will be captured and stored as a reusable snapshot.

- {/if} - - {#if snapshotError} -
- {snapshotError} -
- {/if} - -
-
- - optional -
- { if (e.key === 'Enter' && !snapshotting) handleSnapshotConfirm(); }} - /> -

Leave blank to use an auto-generated name.

-
- -
- - -
-
-
-
-{/if} - - -{#if destroyTarget} -
- -
{ if (!destroying) destroyTarget = null; }} - onkeydown={(e) => { if (e.key === 'Escape' && !destroying) destroyTarget = null; }} - >
-
-

Destroy Capsule

-

- Terminate {destroyTarget.id} and destroy all data inside it. This cannot be undone. -

- - {#if destroyError} -
- {destroyError} -
- {/if} - -
- - -
-
-
-{/if} - - - { showCreateDialog = false; }} - oncreated={handleCapsuleCreated} -/> - -{#snippet sortableHeader(label: string, key: SortKey)} - -{/snippet} diff --git a/frontend/src/routes/dashboard/capsules/stats/+page.svelte b/frontend/src/routes/dashboard/capsules/stats/+page.svelte deleted file mode 100644 index 4b2e637..0000000 --- a/frontend/src/routes/dashboard/capsules/stats/+page.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - { showCreateDialog = true; }} - launchDisabled={!auth.teamId} -/> - - { showCreateDialog = false; }} -/> diff --git a/frontend/src/routes/dashboard/metrics/+page.svelte b/frontend/src/routes/dashboard/metrics/+page.svelte new file mode 100644 index 0000000..271c757 --- /dev/null +++ b/frontend/src/routes/dashboard/metrics/+page.svelte @@ -0,0 +1,57 @@ + + + + Wrenn — Metrics + + +
+ + +
+
+
+

+ Metrics +

+

+ Resource usage and performance across all capsules. +

+
+ + { showCreateDialog = true; }} + launchDisabled={!auth.teamId} + /> +
+ +
+
+ + + + + All systems operational +
+
+
+
+ + { showCreateDialog = false; }} +/> From e3750f79f9ab6d658e885f46d69bb94c10237d15 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Wed, 25 Mar 2026 15:50:19 +0600 Subject: [PATCH 05/16] Fix metrics sampler to record zero-value snapshots when idle SampleSandboxMetrics previously filtered WHERE status IN ('running', 'starting', 'paused'), which returned no rows when all capsules were stopped. This caused zero snapshots to be skipped, leaving the time-series charts with no trailing data points instead of showing the expected zero values. Remove the WHERE filter so the query groups by all teams that have any sandbox row. The per-status FILTER clauses on the aggregates already produce correct zero counts for stopped capsules. Also includes the per-VM RAM ceiling formula change (sum(ceil(each/2)) instead of ceil(sum/2)). --- db/queries/metrics.sql | 12 +++++++----- internal/db/metrics.sql.go | 12 +++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/db/queries/metrics.sql b/db/queries/metrics.sql index 43171e5..325df8d 100644 --- a/db/queries/metrics.sql +++ b/db/queries/metrics.sql @@ -5,12 +5,12 @@ VALUES ($1, $2, $3, $4); -- 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). +-- RAM reserved = running + starting + sum(ceil(each_paused/2)) (per-VM ceiling). 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 + + COALESCE(SUM(CEIL(memory_mb::NUMERIC / 2)) FILTER (WHERE status = 'paused'), 0))::INTEGER AS memory_mb_reserved FROM sandboxes WHERE team_id = $1; @@ -29,14 +29,16 @@ WHERE sampled_at < NOW() - INTERVAL '60 days'; -- name: SampleSandboxMetrics :many -- Aggregates per-team resource usage from the live sandboxes table. +-- Groups by all teams that have any sandbox row (including stopped) so that +-- zero-value snapshots are recorded when all capsules are stopped, keeping the +-- time-series charts continuous rather than trailing off into empty space. -- CPU reserved = running + starting only (paused VMs release CPU). --- RAM reserved = running + starting + ceil(paused/2) (capacity held for resume). +-- RAM reserved = running + starting + sum(ceil(each_paused/2)) (per-VM ceiling). SELECT team_id, (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 + + COALESCE(SUM(CEIL(memory_mb::NUMERIC / 2)) FILTER (WHERE status = 'paused'), 0))::INTEGER AS memory_mb_reserved FROM sandboxes -WHERE status IN ('running', 'starting', 'paused') GROUP BY team_id; diff --git a/internal/db/metrics.sql.go b/internal/db/metrics.sql.go index afa56d7..dffc039 100644 --- a/internal/db/metrics.sql.go +++ b/internal/db/metrics.sql.go @@ -14,7 +14,7 @@ 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 + + COALESCE(SUM(CEIL(memory_mb::NUMERIC / 2)) FILTER (WHERE status = 'paused'), 0))::INTEGER AS memory_mb_reserved FROM sandboxes WHERE team_id = $1 ` @@ -27,7 +27,7 @@ type GetLiveMetricsRow struct { // 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). +// RAM reserved = running + starting + sum(ceil(each_paused/2)) (per-VM ceiling). func (q *Queries) GetLiveMetrics(ctx context.Context, teamID string) (GetLiveMetricsRow, error) { row := q.db.QueryRow(ctx, getLiveMetrics, teamID) var i GetLiveMetricsRow @@ -96,9 +96,8 @@ 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 + + COALESCE(SUM(CEIL(memory_mb::NUMERIC / 2)) FILTER (WHERE status = 'paused'), 0))::INTEGER AS memory_mb_reserved FROM sandboxes -WHERE status IN ('running', 'starting', 'paused') GROUP BY team_id ` @@ -110,8 +109,11 @@ type SampleSandboxMetricsRow struct { } // Aggregates per-team resource usage from the live sandboxes table. +// Groups by all teams that have any sandbox row (including stopped) so that +// zero-value snapshots are recorded when all capsules are stopped, keeping the +// time-series charts continuous rather than trailing off into empty space. // CPU reserved = running + starting only (paused VMs release CPU). -// RAM reserved = running + starting + ceil(paused/2) (capacity held for resume). +// RAM reserved = running + starting + sum(ceil(each_paused/2)) (per-VM ceiling). func (q *Queries) SampleSandboxMetrics(ctx context.Context) ([]SampleSandboxMetricsRow, error) { rows, err := q.db.Query(ctx, sampleSandboxMetrics) if err != nil { From 45793e181cc9495aa2d92a3c25703c4fdf7eec79 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Wed, 25 Mar 2026 16:08:38 +0600 Subject: [PATCH 06/16] Move metrics to after templates in sidebar nav --- frontend/src/lib/components/Sidebar.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte index 1b11726..fa6ff29 100644 --- a/frontend/src/lib/components/Sidebar.svelte +++ b/frontend/src/lib/components/Sidebar.svelte @@ -48,8 +48,8 @@ const platformItems: NavItem[] = [ { label: 'Capsules', icon: IconMonitor, href: '/dashboard/capsules' }, - { label: 'Metrics', icon: IconMetrics, href: '/dashboard/metrics' }, - { label: 'Templates', icon: IconBox, href: '/dashboard/snapshots' } + { label: 'Templates', icon: IconBox, href: '/dashboard/snapshots' }, + { label: 'Metrics', icon: IconMetrics, href: '/dashboard/metrics' } ]; let currentTeamIsByoc = $derived( From a69b0f579c932a9af45f059eb923763e07d46b22 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Wed, 25 Mar 2026 16:39:25 +0600 Subject: [PATCH 07/16] Split CPU and RAM into separate side-by-side charts CPU (vCPUs) and RAM (GB) use different units and scales, so combining them on a dual-axis chart was misleading. Each now has its own chart card, laid out side-by-side. --- frontend/src/lib/components/StatsPanel.svelte | 165 ++++++++++-------- 1 file changed, 88 insertions(+), 77 deletions(-) diff --git a/frontend/src/lib/components/StatsPanel.svelte b/frontend/src/lib/components/StatsPanel.svelte index e5380c3..49a565b 100644 --- a/frontend/src/lib/components/StatsPanel.svelte +++ b/frontend/src/lib/components/StatsPanel.svelte @@ -16,11 +16,14 @@ let error = $state(null); let canvasRunning: HTMLCanvasElement; - let canvasResource: HTMLCanvasElement; + let canvasCpu: HTMLCanvasElement; + let canvasRam: HTMLCanvasElement; // eslint-disable-next-line @typescript-eslint/no-explicit-any let chartRunning: any = null; // eslint-disable-next-line @typescript-eslint/no-explicit-any - let chartResource: any = null; + let chartCpu: any = null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let chartRam: any = null; let pollInterval: ReturnType | null = null; @@ -48,11 +51,15 @@ chartRunning.data.datasets[0].data = Array.from(stats.series.running); chartRunning.update(); } - if (chartResource) { - chartResource.data.labels = labels; - chartResource.data.datasets[0].data = Array.from(stats.series.vcpus); - chartResource.data.datasets[1].data = Array.from(stats.series.memory_mb).map((mb) => +(mb / 1024).toFixed(2)); - chartResource.update(); + if (chartCpu) { + chartCpu.data.labels = labels; + chartCpu.data.datasets[0].data = Array.from(stats.series.vcpus); + chartCpu.update(); + } + if (chartRam) { + chartRam.data.labels = labels; + chartRam.data.datasets[0].data = Array.from(stats.series.memory_mb).map((mb) => +(mb / 1024).toFixed(2)); + chartRam.update(); } } @@ -154,63 +161,61 @@ options: BASE_CHART_OPTIONS, }); - chartResource = new Chart(canvasResource, { + chartCpu = new Chart(canvasCpu, { type: 'line', data: { labels: [], - datasets: [ - { - label: 'vCPUs', - data: [], - borderColor: C_BLUE, - backgroundColor: C_BLUE_FILL, - borderWidth: 1.5, - fill: false, - tension: 0, - pointRadius: 0, - pointHoverRadius: 4, - pointHoverBackgroundColor: C_BLUE, - yAxisID: 'y', + datasets: [{ + data: [], + borderColor: C_BLUE, + backgroundColor: C_BLUE_FILL, + borderWidth: 1.5, + fill: true, + tension: 0, + pointRadius: 0, + pointHoverRadius: 4, + pointHoverBackgroundColor: C_BLUE, + }], + }, + options: { + ...BASE_CHART_OPTIONS, + scales: { + ...BASE_CHART_OPTIONS.scales, + y: { + ...BASE_CHART_OPTIONS.scales.y, + ticks: { + ...BASE_CHART_OPTIONS.scales.y.ticks, + callback: (v: number) => `${v}`, + }, }, - { - label: 'RAM (GB)', - data: [], - borderColor: C_AMBER, - backgroundColor: C_AMBER_FILL, - borderWidth: 1.5, - fill: false, - tension: 0, - pointRadius: 0, - pointHoverRadius: 4, - pointHoverBackgroundColor: C_AMBER, - yAxisID: 'yRam', - }, - ], + }, + }, + }); + + chartRam = new Chart(canvasRam, { + type: 'line', + data: { + labels: [], + datasets: [{ + data: [], + borderColor: C_AMBER, + backgroundColor: C_AMBER_FILL, + borderWidth: 1.5, + fill: true, + tension: 0, + pointRadius: 0, + pointHoverRadius: 4, + pointHoverBackgroundColor: C_AMBER, + }], }, options: { ...BASE_CHART_OPTIONS, plugins: { ...BASE_CHART_OPTIONS.plugins, - legend: { - display: true, - position: 'top' as const, - align: 'end' as const, - labels: { - color: C_TICK, - font: { family: FONT_MONO, size: 10 }, - boxWidth: 12, - padding: 12, - }, - }, tooltip: { ...BASE_CHART_OPTIONS.plugins.tooltip, callbacks: { - label: (ctx: { dataset: { label?: string }; parsed: { y: number } }) => { - if (ctx.dataset.label === 'RAM (GB)') { - return ` RAM: ${ctx.parsed.y.toFixed(1)} GB`; - } - return ` vCPUs: ${ctx.parsed.y}`; - }, + label: (ctx: { parsed: { y: number } }) => ` ${ctx.parsed.y.toFixed(1)} GB`, }, }, }, @@ -218,16 +223,10 @@ ...BASE_CHART_OPTIONS.scales, y: { ...BASE_CHART_OPTIONS.scales.y, - position: 'left' as const, - title: { display: true, text: 'vCPUs', color: C_TICK, font: { family: FONT_MONO, size: 10 } }, - }, - yRam: { - grid: { color: C_GRID }, - ticks: { color: C_TICK, font: { family: FONT_MONO, size: 10 } }, - border: { color: C_GRID }, - beginAtZero: true, - position: 'right' as const, - title: { display: true, text: 'GB', color: C_TICK, font: { family: FONT_MONO, size: 10 } }, + ticks: { + ...BASE_CHART_OPTIONS.scales.y.ticks, + callback: (v: number) => `${(+v).toFixed(1)} GB`, + }, }, }, }, @@ -242,7 +241,8 @@ onDestroy(() => { if (pollInterval) clearInterval(pollInterval); chartRunning?.destroy(); - chartResource?.destroy(); + chartCpu?.destroy(); + chartRam?.destroy(); }); function fmtGB(mb: number): string { @@ -391,24 +391,35 @@
- -
-
-
- + +
+ + +
+
+
- CPU - - / - - - RAM - + CPU · vCPUs +
+
+
+
-
- + + +
+
+
+ + RAM · GB +
+
+
+ +
+
From b0e6f5ffb36f1437ecc2f65dbfc9eda685940b9d Mon Sep 17 00:00:00 2001 From: pptx704 Date: Wed, 25 Mar 2026 18:18:04 +0600 Subject: [PATCH 08/16] Bolder stats page layout with stronger visual hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Accent stripes: 3px → 5px; indicator dots: 6px → 8px - Peak values step down to text-[1.714rem]/text-secondary so Now values read as the clear hero - Now labels: semibold + uppercase for weight parity with the metric - Cell padding py-5 → py-6; outer gap-7/pt-4 → gap-8/pt-6 for breathing room - Chart fills: 7-8% → 11-13% opacity; lines: 1.5 → 2px - Tick labels brighter (#635f5c), grid lines slightly more visible - Running capsules chart: min-height 220 → 260px --- frontend/src/lib/components/StatsPanel.svelte | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/frontend/src/lib/components/StatsPanel.svelte b/frontend/src/lib/components/StatsPanel.svelte index 49a565b..d7a2f12 100644 --- a/frontend/src/lib/components/StatsPanel.svelte +++ b/frontend/src/lib/components/StatsPanel.svelte @@ -91,13 +91,13 @@ // 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_ACCENT_FILL = 'rgba(94,140,88,0.13)'; const C_BLUE = '#5a9fd4'; - const C_BLUE_FILL = 'rgba(90,159,212,0.07)'; + const C_BLUE_FILL = 'rgba(90,159,212,0.11)'; const C_AMBER = '#d4a73c'; - const C_AMBER_FILL = 'rgba(212,167,60,0.07)'; - const C_GRID = 'rgba(255,255,255,0.04)'; - const C_TICK = '#454340'; + const C_AMBER_FILL = 'rgba(212,167,60,0.11)'; + const C_GRID = 'rgba(255,255,255,0.05)'; + const C_TICK = '#635f5c'; const FONT_MONO = "'JetBrains Mono', monospace"; const BASE_CHART_OPTIONS = { @@ -150,7 +150,7 @@ data: [], borderColor: C_ACCENT, backgroundColor: C_ACCENT_FILL, - borderWidth: 1.5, + borderWidth: 2, fill: true, tension: 0, pointRadius: 0, @@ -169,7 +169,7 @@ data: [], borderColor: C_BLUE, backgroundColor: C_BLUE_FILL, - borderWidth: 1.5, + borderWidth: 2, fill: true, tension: 0, pointRadius: 0, @@ -200,7 +200,7 @@ data: [], borderColor: C_AMBER, backgroundColor: C_AMBER_FILL, - borderWidth: 1.5, + borderWidth: 2, fill: true, tension: 0, pointRadius: 0, @@ -250,7 +250,7 @@ } -
+
@@ -298,21 +298,21 @@
-
+
- + Running Capsules
-
-
Now
-
+
+
Now
+
{loading ? '—' : (stats?.current.running_count ?? 0)}
-
+
Peak · 30d
-
+
{loading ? '—' : (stats?.peaks.running_count ?? 0)}
@@ -320,21 +320,21 @@
-
+
- + CPU · vCPUs
-
-
Reserved now
-
+
+
Reserved now
+
{loading ? '—' : (stats?.current.vcpus_reserved ?? 0)}
-
+
Peak · 30d
-
+
{loading ? '—' : (stats?.peaks.vcpus ?? 0)}
@@ -342,21 +342,21 @@
-
+
- + RAM
-
-
Reserved now
-
+
+
Reserved now
+
{loading ? '—' : fmtGB(stats?.current.memory_mb_reserved ?? 0)}
-
+
Peak · 30d
-
+
{loading ? '—' : fmtGB(stats?.peaks.memory_mb ?? 0)}
@@ -382,11 +382,11 @@
- +
Running Capsules
-
+
@@ -398,7 +398,7 @@
- + CPU · vCPUs
@@ -411,7 +411,7 @@
- + RAM · GB
From 8d5ba3873a77fdaef8a7ccfc3e296c602eb90854 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Wed, 25 Mar 2026 19:44:13 +0600 Subject: [PATCH 09/16] Fix capsules table blink on background poll refresh Poll fetches now silently update data without triggering loading states, spinner animations, or row fadeUp re-animations. Only manual refresh shows the spin indicator. --- .../routes/dashboard/capsules/+page.svelte | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/frontend/src/routes/dashboard/capsules/+page.svelte b/frontend/src/routes/dashboard/capsules/+page.svelte index a04a9e7..e231601 100644 --- a/frontend/src/routes/dashboard/capsules/+page.svelte +++ b/frontend/src/routes/dashboard/capsules/+page.svelte @@ -56,6 +56,9 @@ // Briefly highlight a newly created capsule row let newCapsuleId = $state(null); + // Track whether initial load animation has played (suppress on poll refreshes) + let initialAnimationDone = $state(false); + let filteredCapsules = $derived.by(() => { let list = searchQuery ? capsules.filter((c) => c.id.toLowerCase().includes(searchQuery.toLowerCase())) @@ -121,26 +124,32 @@ } } - async function fetchCapsules() { + async function fetchCapsules(manual = false) { const wasEmpty = capsules.length === 0; if (wasEmpty) loading = true; - spinning = true; - const spinTimer = new Promise((resolve) => setTimeout(resolve, SPIN_DURATION)); + if (manual) { + spinning = true; + var spinTimer = new Promise((resolve) => setTimeout(resolve, SPIN_DURATION)); + } - error = null; const result = await listCapsules(); if (result.ok) { capsules = result.data; - } else { - error = result.error; } loading = false; + // Mark initial entrance animation as done after first successful fetch + if (!initialAnimationDone) { + setTimeout(() => { initialAnimationDone = true; }, 400 + (capsules.length * 40)); + } + if (autoRefresh) countdown = REFRESH_INTERVAL; - await spinTimer; - spinning = false; + if (manual) { + await spinTimer!; + spinning = false; + } } async function handlePause(id: string) { @@ -297,7 +306,7 @@ {/each} @@ -322,14 +333,18 @@ {#each filteredSnapshots as snapshot, i (snapshot.name)} - {@const stripeColor = snapshot.type === 'snapshot' ? 'bg-[var(--color-accent)]' : 'bg-[var(--color-blue)]'} + {@const isSnapshot = snapshot.type === 'snapshot'} + {@const typeColor = isSnapshot ? 'var(--color-accent)' : 'var(--color-blue)'}
-
+ +
+
{snapshot.name} @@ -337,8 +352,8 @@
- {#if snapshot.type === 'snapshot'} - + {#if isSnapshot} + {:else} - + Image @@ -356,7 +371,12 @@
{#if snapshot.type === 'snapshot' && snapshot.vcpus != null} - {snapshot.vcpus} + + + + + {snapshot.vcpus} + {:else} {/if} @@ -365,7 +385,12 @@
{#if snapshot.type === 'snapshot' && snapshot.memory_mb != null} - {snapshot.memory_mb} MB + + + + + {snapshot.memory_mb} MB + {:else} {/if} @@ -373,7 +398,7 @@
- {formatBytes(snapshot.size_bytes)} + {formatBytes(snapshot.size_bytes)}
@@ -694,4 +719,12 @@ .snapshot-row:hover .row-stripe { transform: scaleY(1); } + + /* Type-tinted row hover backgrounds */ + .snapshot-row.type-snapshot:hover { + background: rgba(94, 140, 88, 0.04); + } + .snapshot-row.type-image:hover { + background: rgba(90, 159, 212, 0.04); + } From 6eacf0f735f58a280c7c255d103dca42ecee68e0 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Wed, 25 Mar 2026 21:53:09 +0600 Subject: [PATCH 14/16] Fix LIKE pattern injection in user email search Escape LIKE metacharacters (% and _) in the email prefix before passing to the SQL query, and enforce the documented '@' requirement to prevent broad user enumeration. Move search logic out of TeamService into usersHandler since it is a site-wide lookup, not team-scoped. --- internal/api/handlers_users.go | 24 ++++++++++++++---------- internal/api/server.go | 2 +- internal/service/team.go | 7 ------- 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/internal/api/handlers_users.go b/internal/api/handlers_users.go index 549e213..8269d3c 100644 --- a/internal/api/handlers_users.go +++ b/internal/api/handlers_users.go @@ -4,34 +4,38 @@ import ( "net/http" "strings" + "github.com/jackc/pgx/v5/pgtype" + "git.omukk.dev/wrenn/sandbox/internal/auth" - "git.omukk.dev/wrenn/sandbox/internal/service" + "git.omukk.dev/wrenn/sandbox/internal/db" ) type usersHandler struct { - svc *service.TeamService + db *db.Queries } -func newUsersHandler(svc *service.TeamService) *usersHandler { - return &usersHandler{svc: svc} +func newUsersHandler(db *db.Queries) *usersHandler { + return &usersHandler{db: db} } // Search handles GET /v1/users/search?email= // Returns up to 10 users whose email starts with the given prefix. -// The prefix must be at least 3 characters long. +// The prefix must be at least 3 characters long and contain "@". func (h *usersHandler) Search(w http.ResponseWriter, r *http.Request) { auth.MustFromContext(r.Context()) // ensure authenticated prefix := strings.TrimSpace(r.URL.Query().Get("email")) - if len(prefix) < 3 { - writeError(w, http.StatusBadRequest, "invalid_request", "email prefix must be at least 3 characters") + if len(prefix) < 3 || !strings.Contains(prefix, "@") { + writeError(w, http.StatusBadRequest, "invalid_request", "email prefix must be at least 3 characters and contain '@'") return } - results, err := h.svc.SearchUsersByEmailPrefix(r.Context(), prefix) + // Escape LIKE metacharacters to prevent pattern injection. + escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(prefix) + + results, err := h.db.SearchUsersByEmailPrefix(r.Context(), pgtype.Text{String: escaped, Valid: true}) if err != nil { - status, code, msg := serviceErrToHTTP(err) - writeError(w, status, code, msg) + writeError(w, http.StatusInternalServerError, "internal", "search failed") return } diff --git a/internal/api/server.go b/internal/api/server.go index 67043e8..918476b 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -61,7 +61,7 @@ func New( apiKeys := newAPIKeyHandler(apiKeySvc, al) hostH := newHostHandler(hostSvc, queries, al) teamH := newTeamHandler(teamSvc, al) - usersH := newUsersHandler(teamSvc) + usersH := newUsersHandler(queries) auditH := newAuditHandler(auditSvc) statsH := newStatsHandler(statsSvc) metricsH := newSandboxMetricsHandler(queries, pool) diff --git a/internal/service/team.go b/internal/service/team.go index 0c1739e..d4c911c 100644 --- a/internal/service/team.go +++ b/internal/service/team.go @@ -9,7 +9,6 @@ import ( "connectrpc.com/connect" "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "git.omukk.dev/wrenn/sandbox/internal/db" @@ -369,12 +368,6 @@ func (s *TeamService) LeaveTeam(ctx context.Context, teamID, callerUserID string return nil } -// SearchUsersByEmailPrefix returns up to 10 users whose email starts with the given prefix. -// The prefix must contain "@" to prevent broad enumeration. -func (s *TeamService) SearchUsersByEmailPrefix(ctx context.Context, prefix string) ([]db.SearchUsersByEmailPrefixRow, error) { - return s.DB.SearchUsersByEmailPrefix(ctx, pgtype.Text{String: prefix, Valid: true}) -} - // SetBYOC enables the BYOC feature flag for a team. Once enabled, BYOC cannot // be disabled — it is a one-way transition. // Admin-only — the caller must verify admin status before invoking this. From 27ff828e60516edd13b24edc34a5cc18639996ae Mon Sep 17 00:00:00 2001 From: pptx704 Date: Wed, 25 Mar 2026 21:53:19 +0600 Subject: [PATCH 15/16] Push GetSandboxMetricPoints time filter into SQL The query was fetching all rows for a (sandbox_id, tier) pair and filtering by timestamp in Go. For repeatedly-paused sandboxes the 24h tier can accumulate up to 30 days of data, causing up to 120x over-fetching for a 6h range request. Add AND ts >= $3 to the query so Postgres filters on the primary key (sandbox_id, tier, ts) directly. Drop the redundant Go-side loop. --- db/queries/metrics.sql | 2 +- internal/api/handlers_metrics.go | 18 ++++++++---------- internal/db/metrics.sql.go | 5 +++-- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/db/queries/metrics.sql b/db/queries/metrics.sql index 3b6ad0b..f58d480 100644 --- a/db/queries/metrics.sql +++ b/db/queries/metrics.sql @@ -35,7 +35,7 @@ ON CONFLICT (sandbox_id, tier, ts) DO NOTHING; -- name: GetSandboxMetricPoints :many SELECT ts, cpu_pct, mem_bytes, disk_bytes FROM sandbox_metric_points -WHERE sandbox_id = $1 AND tier = $2 +WHERE sandbox_id = $1 AND tier = $2 AND ts >= $3 ORDER BY ts ASC; -- name: DeleteSandboxMetricPoints :exec diff --git a/internal/api/handlers_metrics.go b/internal/api/handlers_metrics.go index a2ab0b9..793349e 100644 --- a/internal/api/handlers_metrics.go +++ b/internal/api/handlers_metrics.go @@ -123,22 +123,20 @@ func (h *sandboxMetricsHandler) getFromDB(ctx context.Context, w http.ResponseWr rows, err := h.db.GetSandboxMetricPoints(ctx, db.GetSandboxMetricPointsParams{ SandboxID: sandboxID, Tier: mapping.tier, + Ts: time.Now().Add(-mapping.cutoff).Unix(), }) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "failed to read metrics") return } - threshold := time.Now().Add(-mapping.cutoff).Unix() - var points []metricPointResponse - for _, row := range rows { - if row.Ts >= threshold { - points = append(points, metricPointResponse{ - TimestampUnix: row.Ts, - CPUPct: row.CpuPct, - MemBytes: row.MemBytes, - DiskBytes: row.DiskBytes, - }) + points := make([]metricPointResponse, len(rows)) + for i, row := range rows { + points[i] = metricPointResponse{ + TimestampUnix: row.Ts, + CPUPct: row.CpuPct, + MemBytes: row.MemBytes, + DiskBytes: row.DiskBytes, } } diff --git a/internal/db/metrics.sql.go b/internal/db/metrics.sql.go index b719caa..8050155 100644 --- a/internal/db/metrics.sql.go +++ b/internal/db/metrics.sql.go @@ -86,13 +86,14 @@ func (q *Queries) GetPeakMetrics(ctx context.Context, teamID string) (GetPeakMet const getSandboxMetricPoints = `-- name: GetSandboxMetricPoints :many SELECT ts, cpu_pct, mem_bytes, disk_bytes FROM sandbox_metric_points -WHERE sandbox_id = $1 AND tier = $2 +WHERE sandbox_id = $1 AND tier = $2 AND ts >= $3 ORDER BY ts ASC ` type GetSandboxMetricPointsParams struct { SandboxID string `json:"sandbox_id"` Tier string `json:"tier"` + Ts int64 `json:"ts"` } type GetSandboxMetricPointsRow struct { @@ -103,7 +104,7 @@ type GetSandboxMetricPointsRow struct { } func (q *Queries) GetSandboxMetricPoints(ctx context.Context, arg GetSandboxMetricPointsParams) ([]GetSandboxMetricPointsRow, error) { - rows, err := q.db.Query(ctx, getSandboxMetricPoints, arg.SandboxID, arg.Tier) + rows, err := q.db.Query(ctx, getSandboxMetricPoints, arg.SandboxID, arg.Tier, arg.Ts) if err != nil { return nil, err } From ed7880bc6c4336bc6a54eb09a8b056559cbc9dc4 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Wed, 25 Mar 2026 22:31:05 +0600 Subject: [PATCH 16/16] Add per-capsule stats detail page with live CPU/RAM charts - New detail page at /dashboard/capsules/[id] with Stats and Files tabs - Stats tab shows capsule info card (status, template, CPU, memory, disk, started, idle timeout) and two stacked Chart.js charts with live values - Metrics API client with 10s polling and moving-average smoothing - Capsule ID in list table is now a clickable link to the detail page - Layout breadcrumb header (Capsules > sb-xxx) with back navigation - Fix metrics sampler: use v.PID() directly as Firecracker PID since unshare -m execs (not forks) through the bash/ip-netns-exec/firecracker chain, so all share the same PID. Removes unused findChildPID. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/lib/api/capsules.ts | 4 + frontend/src/lib/api/metrics.ts | 25 + .../routes/dashboard/capsules/+layout.svelte | 68 +- .../routes/dashboard/capsules/+page.svelte | 4 +- .../dashboard/capsules/[id]/+page.svelte | 582 ++++++++++++++++++ internal/sandbox/manager.go | 25 +- internal/sandbox/proc.go | 22 - 7 files changed, 665 insertions(+), 65 deletions(-) create mode 100644 frontend/src/lib/api/metrics.ts create mode 100644 frontend/src/routes/dashboard/capsules/[id]/+page.svelte diff --git a/frontend/src/lib/api/capsules.ts b/frontend/src/lib/api/capsules.ts index c51737a..cc4ad79 100644 --- a/frontend/src/lib/api/capsules.ts +++ b/frontend/src/lib/api/capsules.ts @@ -20,6 +20,10 @@ export async function listCapsules(): Promise> { return apiFetch('GET', '/api/v1/sandboxes'); } +export async function getCapsule(id: string): Promise> { + return apiFetch('GET', `/api/v1/sandboxes/${id}`); +} + export type CreateCapsuleParams = { template?: string; vcpus?: number; diff --git a/frontend/src/lib/api/metrics.ts b/frontend/src/lib/api/metrics.ts new file mode 100644 index 0000000..baf9f11 --- /dev/null +++ b/frontend/src/lib/api/metrics.ts @@ -0,0 +1,25 @@ +import { apiFetch, type ApiResult } from '$lib/api/client'; + +export type MetricRange = '5m' | '10m' | '1h' | '6h' | '24h'; + +export type MetricPoint = { + timestamp_unix: number; + cpu_pct: number; + mem_bytes: number; + disk_bytes: number; +}; + +export type MetricsResponse = { + sandbox_id: string; + range: MetricRange; + points: MetricPoint[]; +}; + +export async function fetchSandboxMetrics(id: string, range: MetricRange): Promise> { + return apiFetch('GET', `/api/v1/sandboxes/${id}/metrics?range=${range}`); +} + +export const METRIC_RANGES: MetricRange[] = ['5m', '10m', '1h', '6h', '24h']; + +// All ranges poll every 10 seconds. +export const METRIC_POLL_INTERVAL = 10_000; diff --git a/frontend/src/routes/dashboard/capsules/+layout.svelte b/frontend/src/routes/dashboard/capsules/+layout.svelte index 8551513..d9f93f8 100644 --- a/frontend/src/routes/dashboard/capsules/+layout.svelte +++ b/frontend/src/routes/dashboard/capsules/+layout.svelte @@ -1,4 +1,5 @@ + + + Wrenn — {sandboxId} + + + + +{#if capsuleLoading} +
+
+ + + + Loading capsule... +
+
+{:else if capsuleError} +
+
+ + + + {capsuleError} +
+
+{:else if capsule} +
+ + +
+ + + +
+ + + {#if activeTab === 'metrics'} +
+ + +
+ {#if metricsAvailable && !metricsLoading} + + + Live + + {:else} +
+ {/if} + + {#if metricsAvailable} +
+ {#each METRIC_RANGES as r, i} + + {/each} +
+ {/if} +
+ + +
+
+ + +
+
Status
+ + {#if capsule.status === 'running'} + + + + + {/if} + {capsule.status} + +
+ + +
+
Template
+ {capsule.template} +
+ + +
+
CPU
+
+ {capsule.vcpus} + vCPU{capsule.vcpus !== 1 ? 's' : ''} +
+
+ + +
+
Memory
+
+ {capsule.memory_mb} + MB +
+
+ + +
+
Disk
+ +
+ + +
+
Started
+ {fmtDate(capsule.started_at)} +
+ + +
+
Idle Timeout
+ {fmtTimeout(capsule.timeout_sec)} +
+ +
+
+ + {#if metricsError} +
+ + + + Failed to load metrics: {metricsError} +
+ {/if} + + {#if metricsAvailable} + +
+ + +
+
+
+ + CPU Usage +
+ {#if latestCpu !== null} +
+ {latestCpu.toFixed(1)} + % +
+ {:else if metricsLoading} + + {/if} +
+
+ +
+
+ + +
+
+
+ + RAM Usage +
+ {#if latestRamMB !== null} +
+ {latestRamMB.toFixed(0)} + MB +
+ {:else if metricsLoading} + + {/if} +
+
+ +
+
+ +
+ {:else} + +
+ + + Live stats are only available for running or paused capsules — + current status: {capsule.status} + +
+ {/if} + +
+ {/if} +
+{/if} diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go index 2da0944..9a795b5 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -1226,31 +1226,22 @@ func warnErr(msg string, id string, err error) { } } -// startSampler resolves the Firecracker child PID and starts a background -// goroutine that samples CPU/mem/disk at 500ms intervals into the ring buffer. +// startSampler resolves the Firecracker PID and starts a background goroutine +// that samples CPU/mem/disk at 500ms intervals into the ring buffer. // Must be called after the sandbox is registered in m.boxes. func (m *Manager) startSampler(sb *sandboxState) { - // Resolve the Firecracker PID (child of unshare wrapper). v, ok := m.vm.Get(sb.ID) if !ok { slog.Warn("metrics: VM not found, skipping sampler", "id", sb.ID) return } - unshPID := v.PID() - var fcPID int - for attempt := 0; attempt < 5; attempt++ { - var err error - fcPID, err = findChildPID(unshPID) - if err == nil { - break - } - if attempt == 4 { - slog.Warn("metrics: could not resolve FC PID, skipping sampler", "id", sb.ID, "error", err) - return - } - time.Sleep(50 * time.Millisecond) - } + // v.PID() is the cmd.Process.Pid of the "unshare -m -- bash -c script" + // invocation. Because unshare(2) modifies the current process's namespace + // before exec-replacing itself with bash, and bash exec-replaces itself + // with ip-netns-exec, which exec-replaces itself with firecracker, the + // entire exec chain occupies the same PID. v.PID() IS the Firecracker PID. + fcPID := v.PID() sb.fcPID = fcPID sb.ring = newMetricsRing() diff --git a/internal/sandbox/proc.go b/internal/sandbox/proc.go index eb9a78f..855d3c1 100644 --- a/internal/sandbox/proc.go +++ b/internal/sandbox/proc.go @@ -8,28 +8,6 @@ import ( "syscall" ) -// findChildPID reads the direct child PID of a given parent process. -// The Firecracker process is a direct child of the unshare wrapper because -// the init script uses `exec ip netns exec ... firecracker`, which replaces -// bash with ip-netns-exec, which in turn execs firecracker — same PID, -// direct child of unshare. -func findChildPID(parentPID int) (int, error) { - path := fmt.Sprintf("/proc/%d/task/%d/children", parentPID, parentPID) - data, err := os.ReadFile(path) - if err != nil { - return 0, fmt.Errorf("read children: %w", err) - } - fields := strings.Fields(string(data)) - if len(fields) == 0 { - return 0, fmt.Errorf("no child processes found for PID %d", parentPID) - } - pid, err := strconv.Atoi(fields[0]) - if err != nil { - return 0, fmt.Errorf("parse child PID %q: %w", fields[0], err) - } - return pid, nil -} - // cpuStat holds raw CPU jiffies read from /proc/{pid}/stat. type cpuStat struct { utime uint64