From 01819642cc6d6b488b4deb6c16f084c180074838 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Sun, 3 May 2026 14:27:49 +0600 Subject: [PATCH 1/5] fix: drop page cache before snapshot to reduce memory dump size Linux keeps freed memory as page cache, which Firecracker snapshots as non-zero blocks. A 16GB VM with 12GB stale cache would write all 12GB to disk. Dropping pagecache (not dentries/inodes) in /snapshot/prepare before blocking the reclaimer shrinks snapshots to actual working set size with minimal resume latency impact. --- envd-rs/src/http/snapshot.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/envd-rs/src/http/snapshot.rs b/envd-rs/src/http/snapshot.rs index 977b6bd..e507d8f 100644 --- a/envd-rs/src/http/snapshot.rs +++ b/envd-rs/src/http/snapshot.rs @@ -10,12 +10,22 @@ use crate::state::AppState; /// POST /snapshot/prepare — quiesce subsystems before Firecracker snapshot. /// /// In Rust there is no GC dance. We just: -/// 1. Stop port subsystem -/// 2. Close idle connections via conntracker -/// 3. Set needs_restore flag +/// 1. Drop page cache to shrink snapshot size +/// 2. Stop port subsystem +/// 3. Close idle connections via conntracker +/// 4. Set needs_restore flag pub async fn post_snapshot_prepare(State(state): State>) -> impl IntoResponse { - // Block memory reclaimer before anything else — prevents drop_caches - // from running mid-freeze which would corrupt kernel page table state. + // Drop page cache BEFORE blocking the reclaimer — avoids snapshotting + // gigabytes of stale cache that inflates the memory dump on disk. + // "1" = pagecache only (keep dentries/inodes for faster resume). + if let Err(e) = std::fs::write("/proc/sys/vm/drop_caches", "1") { + tracing::warn!(error = %e, "snapshot/prepare: drop_caches failed"); + } else { + tracing::info!("snapshot/prepare: page cache dropped"); + } + + // Block memory reclaimer — prevents drop_caches from running mid-freeze + // which would corrupt kernel page table state. state.snapshot_in_progress.store(true, Ordering::Release); if let Some(ref ps) = state.port_subsystem { From 4954b19d7c6becbc74e2ee6e66ac113b8bb94f10 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Sun, 3 May 2026 15:09:21 +0600 Subject: [PATCH 2/5] fix: merge capsule data in-place to prevent visual refresh on poll Replaces full array assignment with granular merge that reuses existing Svelte proxy objects, so only rows with actual data changes re-render. --- .../routes/dashboard/capsules/+page.svelte | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/dashboard/capsules/+page.svelte b/frontend/src/routes/dashboard/capsules/+page.svelte index d7fd84c..97b2ad0 100644 --- a/frontend/src/routes/dashboard/capsules/+page.svelte +++ b/frontend/src/routes/dashboard/capsules/+page.svelte @@ -120,6 +120,25 @@ } } + function mergeCapsuleData(incoming: Capsule[]) { + const existingMap = new Map(capsules.map((c) => [c.id, c])); + const merged: Capsule[] = []; + for (const fresh of incoming) { + const existing = existingMap.get(fresh.id); + if (existing) { + for (const key of Object.keys(fresh) as (keyof Capsule)[]) { + if (existing[key] !== fresh[key]) { + (existing as any)[key] = fresh[key]; + } + } + merged.push(existing); + } else { + merged.push(fresh); + } + } + capsules = merged; + } + async function fetchCapsules(manual = false) { const wasEmpty = capsules.length === 0; if (wasEmpty) loading = true; @@ -131,7 +150,11 @@ const result = await listCapsules(); if (result.ok) { - capsules = result.data; + if (wasEmpty) { + capsules = result.data; + } else { + mergeCapsuleData(result.data); + } error = null; } else { error = result.error; From cac6fcd6262295fd764cb11ac9461601dfb0130d Mon Sep 17 00:00:00 2001 From: pptx704 Date: Sun, 3 May 2026 15:24:34 +0600 Subject: [PATCH 3/5] feat: admin grant/revoke from admin panel Add PUT /v1/admin/users/{id}/admin endpoint and frontend UI for granting and revoking platform admin status. Uses atomic conditional SQL (RevokeUserAdmin) to prevent race conditions that could remove the last admin. Includes idempotency check, audit logging, and confirmation dialog with self-demotion warning. --- db/queries/users.sql | 6 ++ frontend/src/lib/api/admin-users.ts | 4 + frontend/src/routes/admin/users/+page.svelte | 107 ++++++++++++++++++- internal/api/handlers_users.go | 55 ++++++++++ internal/api/openapi.yaml | 48 +++++++++ internal/api/server.go | 1 + pkg/audit/logger.go | 8 ++ pkg/db/users.sql.go | 15 +++ 8 files changed, 242 insertions(+), 2 deletions(-) diff --git a/db/queries/users.sql b/db/queries/users.sql index 81d3fe2..48b532c 100644 --- a/db/queries/users.sql +++ b/db/queries/users.sql @@ -22,6 +22,12 @@ RETURNING *; -- name: SetUserAdmin :exec UPDATE users SET is_admin = $2, updated_at = NOW() WHERE id = $1; +-- name: RevokeUserAdmin :execrows +UPDATE users u SET is_admin = false, updated_at = NOW() +WHERE u.id = $1 + AND u.is_admin = true + AND (SELECT COUNT(*) FROM users WHERE is_admin = true AND status != 'deleted') > 1; + -- name: GetAdminUsers :many SELECT * FROM users WHERE is_admin = TRUE ORDER BY created_at; diff --git a/frontend/src/lib/api/admin-users.ts b/frontend/src/lib/api/admin-users.ts index c5dd339..e22137a 100644 --- a/frontend/src/lib/api/admin-users.ts +++ b/frontend/src/lib/api/admin-users.ts @@ -26,3 +26,7 @@ export async function listAdminUsers(page: number = 1): Promise> { return apiFetch('PUT', `/api/v1/admin/users/${id}/active`, { active }); } + +export async function setUserAdmin(id: string, admin: boolean): Promise> { + return apiFetch('PUT', `/api/v1/admin/users/${id}/admin`, { admin }); +} diff --git a/frontend/src/routes/admin/users/+page.svelte b/frontend/src/routes/admin/users/+page.svelte index 3630f4f..2935c9f 100644 --- a/frontend/src/routes/admin/users/+page.svelte +++ b/frontend/src/routes/admin/users/+page.svelte @@ -5,8 +5,10 @@ import { listAdminUsers, setUserActive, + setUserAdmin, type AdminUser, } from '$lib/api/admin-users'; + import { auth } from '$lib/auth.svelte'; // Data state let users = $state([]); @@ -22,6 +24,11 @@ // Toggle state let togglingId = $state(null); + // Admin dialog state + let adminTarget = $state(null); + let togglingAdmin = $state(false); + let adminError = $state(null); + async function fetchUsers(page: number = 1) { const wasEmpty = users.length === 0; if (wasEmpty) loading = true; @@ -56,6 +63,23 @@ togglingId = null; } + async function handleConfirmAdminToggle() { + if (!adminTarget) return; + togglingAdmin = true; + adminError = null; + const target = adminTarget; + const newAdmin = !target.is_admin; + const result = await setUserAdmin(target.id, newAdmin); + if (result.ok) { + adminTarget = null; + target.is_admin = newAdmin; + toast.success(`${target.email} ${newAdmin ? 'granted' : 'revoked'} admin`); + } else { + adminError = result.error; + } + togglingAdmin = false; + } + function goToPage(page: number) { if (page < 1 || page > totalPages) return; fetchUsers(page); @@ -222,8 +246,18 @@ -
- {user.is_admin ? 'Admin' : 'User'} +
+
@@ -292,3 +326,72 @@
+ + +{#if adminTarget} +
+ +
{ if (!togglingAdmin) adminTarget = null; }} + onkeydown={(e) => { if (e.key === 'Escape' && !togglingAdmin) adminTarget = null; }} + >
+
+
+

+ {adminTarget.is_admin ? 'Revoke Admin' : 'Grant Admin'} +

+

+ {adminTarget.is_admin ? 'Remove admin access from' : 'Grant admin access to'} + {adminTarget.email}. + {adminTarget.is_admin + ? 'They will lose access to the admin panel immediately.' + : 'They will be able to manage all platform resources.'} +

+ + {#if adminTarget.is_admin && adminTarget.id === auth.userId} +
+ + + + + You are removing your own admin access. You will lose access to this panel. + +
+ {/if} + + {#if adminError} +
+ {adminError} +
+ {/if} + +
+ + +
+
+
+
+{/if} diff --git a/internal/api/handlers_users.go b/internal/api/handlers_users.go index 1a82653..5cd6837 100644 --- a/internal/api/handlers_users.go +++ b/internal/api/handlers_users.go @@ -162,3 +162,58 @@ func (h *usersHandler) SetUserActive(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusNoContent) } + +// SetUserAdmin handles PUT /v1/admin/users/{id}/admin +// Grants or revokes platform admin status. Cannot remove the last admin. +func (h *usersHandler) SetUserAdmin(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + userIDStr := chi.URLParam(r, "id") + + userID, err := id.ParseUserID(userIDStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid user ID") + return + } + + var req struct { + Admin bool `json:"admin"` + } + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") + return + } + + user, err := h.db.GetUserByID(r.Context(), userID) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "user not found") + return + } + + if user.IsAdmin == req.Admin { + w.WriteHeader(http.StatusNoContent) + return + } + + if req.Admin { + if err := h.db.SetUserAdmin(r.Context(), db.SetUserAdminParams{ + ID: userID, + IsAdmin: true, + }); err != nil { + writeError(w, http.StatusInternalServerError, "internal", "failed to update admin status") + return + } + h.audit.LogUserGrantAdmin(r.Context(), ac, userID, user.Email) + } else { + affected, err := h.db.RevokeUserAdmin(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", "failed to update admin status") + return + } + if affected == 0 { + writeError(w, http.StatusBadRequest, "invalid_request", "cannot remove the last admin") + return + } + h.audit.LogUserRevokeAdmin(r.Context(), ac, userID, user.Email) + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml index c18c575..6501061 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -2346,6 +2346,54 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/admin/users/{id}/admin: + put: + summary: Grant or revoke platform admin + operationId: setUserAdmin + tags: [admin] + description: | + Sets the platform admin flag on a user. Cannot remove the last admin. + Requires platform admin access (JWT + is_admin). + The target user's JWT is not re-issued — their frontend will reflect the + change on next login or team switch. + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + example: "usr-a1b2c3d4" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [admin] + properties: + admin: + type: boolean + description: true to grant admin, false to revoke. + responses: + "204": + description: Admin status updated + "400": + $ref: "#/components/responses/BadRequest" + "403": + description: Caller is not a platform admin + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: User not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + components: securitySchemes: apiKeyAuth: diff --git a/internal/api/server.go b/internal/api/server.go index 11b6fbb..e59eecd 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -269,6 +269,7 @@ func New( r.Delete("/teams/{id}", teamH.AdminDeleteTeam) r.Get("/users", usersH.AdminListUsers) r.Put("/users/{id}/active", usersH.SetUserActive) + r.Put("/users/{id}/admin", usersH.SetUserAdmin) r.Get("/audit-logs", auditH.AdminList) r.Get("/templates", buildH.ListTemplates) r.Delete("/templates/{name}", buildH.DeleteTemplate) diff --git a/pkg/audit/logger.go b/pkg/audit/logger.go index eb73d70..ae26729 100644 --- a/pkg/audit/logger.go +++ b/pkg/audit/logger.go @@ -365,6 +365,14 @@ func (l *AuditLogger) LogUserDeactivate(ctx context.Context, ac auth.AuthContext l.Log(ctx, newAdminEntry(ac, "user", id.FormatUserID(userID), "deactivate", "warning", map[string]any{"email": email})) } +func (l *AuditLogger) LogUserGrantAdmin(ctx context.Context, ac auth.AuthContext, userID pgtype.UUID, email string) { + l.Log(ctx, newAdminEntry(ac, "user", id.FormatUserID(userID), "grant_admin", "success", map[string]any{"email": email})) +} + +func (l *AuditLogger) LogUserRevokeAdmin(ctx context.Context, ac auth.AuthContext, userID pgtype.UUID, email string) { + l.Log(ctx, newAdminEntry(ac, "user", id.FormatUserID(userID), "revoke_admin", "warning", map[string]any{"email": email})) +} + // --- Team admin events (scope: admin) --- func (l *AuditLogger) LogTeamSetBYOC(ctx context.Context, ac auth.AuthContext, teamID pgtype.UUID, enabled bool) { diff --git a/pkg/db/users.sql.go b/pkg/db/users.sql.go index b2d79e8..da4b436 100644 --- a/pkg/db/users.sql.go +++ b/pkg/db/users.sql.go @@ -415,6 +415,21 @@ func (q *Queries) ListUsersAdmin(ctx context.Context, arg ListUsersAdminParams) return items, nil } +const revokeUserAdmin = `-- name: RevokeUserAdmin :execrows +UPDATE users u SET is_admin = false, updated_at = NOW() +WHERE u.id = $1 + AND u.is_admin = true + AND (SELECT COUNT(*) FROM users WHERE is_admin = true AND status != 'deleted') > 1 +` + +func (q *Queries) RevokeUserAdmin(ctx context.Context, id pgtype.UUID) (int64, error) { + result, err := q.db.Exec(ctx, revokeUserAdmin, id) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const searchUsersByEmailPrefix = `-- name: SearchUsersByEmailPrefix :many SELECT id, email FROM users WHERE email LIKE $1 || '%' ORDER BY email LIMIT 10 ` From 021d709de233e5a2f0f4772f4b488324a657f8fc Mon Sep 17 00:00:00 2001 From: pptx704 Date: Sun, 3 May 2026 15:51:20 +0600 Subject: [PATCH 4/5] feat: show template owner and restrict delete in admin panel Add Owner column to admin templates table, resolving team IDs to names via admin teams API. Disable delete for non-platform templates and the minimal template, with contextual tooltips explaining why. --- .../src/routes/admin/templates/+page.svelte | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/admin/templates/+page.svelte b/frontend/src/routes/admin/templates/+page.svelte index ae678ed..414d68f 100644 --- a/frontend/src/routes/admin/templates/+page.svelte +++ b/frontend/src/routes/admin/templates/+page.svelte @@ -2,6 +2,7 @@ import CopyButton from '$lib/components/CopyButton.svelte'; import { onMount, onDestroy } from 'svelte'; import { toast } from '$lib/toast.svelte'; + import { auth } from '$lib/auth.svelte'; import { formatDate, timeAgo } from '$lib/utils/format'; import { listBuilds, @@ -13,6 +14,7 @@ type BuildLogEntry, type AdminTemplate } from '$lib/api/builds'; + import { listAdminTeams } from '$lib/api/team'; let activeTab = $state<'templates' | 'builds'>('templates'); @@ -35,6 +37,9 @@ let expandedBuildId = $state(null); let expandedSteps = $state>(new Set()); + // Team name lookup + let teamNames = $state>(new Map()); + // Delete template state let deleteTarget = $state(null); let deleting = $state(false); @@ -64,6 +69,28 @@ let baseCount = $derived(templates.filter((t) => t.type === 'base').length); let runningBuilds = $derived(builds.filter((b) => b.status === 'running').length); + async function fetchTeamNames() { + const names = new Map(); + let page = 1; + while (true) { + const result = await listAdminTeams(page); + if (!result.ok) break; + for (const team of result.data.teams) { + names.set(team.id, team.name); + } + if (page >= result.data.total_pages) break; + page++; + } + teamNames = names; + } + + const PLATFORM_TEAM_ID = 'team-0000000000000000000000000'; + + function canDeleteTemplate(tmpl: AdminTemplate): boolean { + if (tmpl.name === 'minimal') return false; + return tmpl.team_id === PLATFORM_TEAM_ID; + } + async function fetchTemplates() { templatesLoading = true; templatesError = null; @@ -238,6 +265,7 @@ } onMount(() => { + fetchTeamNames(); fetchTemplates(); fetchBuilds().then(startPolling); @@ -339,7 +367,7 @@
{#if activeTab === 'templates'} {#if templatesLoading} - {@render skeletonRows(5, ['Name', 'Type', 'Specs', 'Size', 'Created', ''])} + {@render skeletonRows(5, ['Name', 'Type', 'Owner', 'Specs', 'Size', 'Created', ''])} {:else if templatesError}
{templatesError} @@ -442,6 +470,7 @@ Name Type + Owner Specs Size Created @@ -473,6 +502,13 @@ {/if} + + {#if tmpl.team_id === PLATFORM_TEAM_ID} + Platform + {:else} + {teamNames.get(tmpl.team_id) ?? tmpl.team_id} + {/if} + {#if tmpl.vcpus && tmpl.memory_mb} @@ -495,7 +531,11 @@ From 1244c08e425ffa2094b1eb66af1db894dbe868e8 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Sun, 3 May 2026 16:43:26 +0600 Subject: [PATCH 5/5] fix: fetch sandbox metrics immediately on page load Metrics data was only fetched after Chart.js dynamic import completed, leaving graphs empty until the first poll interval fired. Now loadMetrics() runs in parallel with the Chart.js import, and initCharts() resets the dedup key so pre-fetched data populates newly created chart instances. --- frontend/src/lib/components/MetricsPanel.svelte | 2 ++ frontend/src/routes/dashboard/capsules/[id]/+page.svelte | 2 ++ 2 files changed, 4 insertions(+) diff --git a/frontend/src/lib/components/MetricsPanel.svelte b/frontend/src/lib/components/MetricsPanel.svelte index 38a424d..826bbca 100644 --- a/frontend/src/lib/components/MetricsPanel.svelte +++ b/frontend/src/lib/components/MetricsPanel.svelte @@ -213,6 +213,7 @@ }, }); + lastDataKey = ''; updateCharts(); } @@ -233,6 +234,7 @@ onMount(async () => { if (!available) return; + loadMetrics(); const mod = await import('chart.js/auto'); ChartJS = mod.Chart; diff --git a/frontend/src/routes/dashboard/capsules/[id]/+page.svelte b/frontend/src/routes/dashboard/capsules/[id]/+page.svelte index a8bfb4d..f7bc6d4 100644 --- a/frontend/src/routes/dashboard/capsules/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/capsules/[id]/+page.svelte @@ -333,6 +333,7 @@ }, }); + lastDataKey = ''; updateCharts(); } @@ -376,6 +377,7 @@ if (!metricsAvailable) return; + loadMetrics(); const mod = await import('chart.js/auto'); ChartJS = mod.Chart;