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/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 { 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/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/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 @@ 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/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; 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; 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 `