1
0
forked from wrenn/wrenn
## What's New?
Performance updates for large capsules, admin panel enhancement and bug fixes

### Envd
- Fixed bug with sandbox metrics calculation
- Page cache drop and balloon inflation to reduce memfile snapshot
- Updated rpc timeout logic for better control
- Added tests

### Admin Panel
- Add/Remove platform admin
- Updated template deletion logic for fine grained permission

### Others
- Minor frontend visual improvement
- Minor bugfixes
- Version bump

Co-authored-by: Tasnim Kabir Sadik <tksadik92@gmail.com>
Reviewed-on: wrenn/wrenn#45
Co-authored-by: pptx704 <rafeed@omukk.dev>
Co-committed-by: pptx704 <rafeed@omukk.dev>
This commit is contained in:
2026-05-13 05:05:35 +00:00
committed by Tasnim Kabir Sadik
parent f5a23c1fa0
commit 78f2ea603f
55 changed files with 2042 additions and 238 deletions

View File

@ -26,3 +26,7 @@ export async function listAdminUsers(page: number = 1): Promise<ApiResult<AdminU
export async function setUserActive(id: string, active: boolean): Promise<ApiResult<void>> {
return apiFetch('PUT', `/api/v1/admin/users/${id}/active`, { active });
}
export async function setUserAdmin(id: string, admin: boolean): Promise<ApiResult<void>> {
return apiFetch('PUT', `/api/v1/admin/users/${id}/admin`, { admin });
}

View File

@ -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;

View File

@ -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<string | null>(null);
let expandedSteps = $state<Set<number>>(new Set());
// Team name lookup
let teamNames = $state<Map<string, string>>(new Map());
// Delete template state
let deleteTarget = $state<AdminTemplate | null>(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<string, string>();
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 @@
<div class="flex-1 overflow-y-auto px-8 py-6">
{#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}
<div class="rounded-[var(--radius-card)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-4 py-3 text-ui text-[var(--color-red)]">
{templatesError}
@ -442,6 +470,7 @@
<tr class="border-b border-[var(--color-border)] bg-[var(--color-bg-0)]/40">
<th class="table-header">Name</th>
<th class="table-header">Type</th>
<th class="table-header hidden md:table-cell">Owner</th>
<th class="table-header hidden md:table-cell">Specs</th>
<th class="table-header hidden lg:table-cell">Size</th>
<th class="table-header hidden lg:table-cell">Created</th>
@ -473,6 +502,13 @@
</span>
{/if}
</td>
<td class="hidden px-5 py-3.5 md:table-cell">
{#if tmpl.team_id === PLATFORM_TEAM_ID}
<span class="text-meta text-[var(--color-text-muted)]">Platform</span>
{:else}
<span class="text-meta text-[var(--color-text-secondary)]">{teamNames.get(tmpl.team_id) ?? tmpl.team_id}</span>
{/if}
</td>
<td class="hidden px-5 py-3.5 md:table-cell">
{#if tmpl.vcpus && tmpl.memory_mb}
<span class="font-mono text-meta tabular-nums text-[var(--color-text-secondary)]">
@ -495,7 +531,11 @@
<td class="px-5 py-3.5 text-right">
<button
onclick={() => { deleteTarget = tmpl; deleteError = null; }}
class="rounded-[var(--radius-button)] px-3 py-1.5 text-meta text-[var(--color-text-tertiary)] transition-all duration-150 hover:bg-[var(--color-red)]/10 hover:text-[var(--color-red)]"
disabled={!canDeleteTemplate(tmpl)}
title={tmpl.name === 'minimal' ? 'The minimal template cannot be deleted' : !canDeleteTemplate(tmpl) ? 'Cannot delete templates owned by other teams' : undefined}
class="rounded-[var(--radius-button)] px-3 py-1.5 text-meta transition-all duration-150 {canDeleteTemplate(tmpl)
? 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-red)]/10 hover:text-[var(--color-red)]'
: 'text-[var(--color-text-muted)] cursor-not-allowed opacity-40'}"
>
Delete
</button>

View File

@ -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<AdminUser[]>([]);
@ -22,6 +24,11 @@
// Toggle state
let togglingId = $state<string | null>(null);
// Admin dialog state
let adminTarget = $state<AdminUser | null>(null);
let togglingAdmin = $state(false);
let adminError = $state<string | null>(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 @@
</div>
<!-- Role -->
<div class="px-5 py-4">
<span class="text-ui text-[var(--color-text-secondary)]">{user.is_admin ? 'Admin' : 'User'}</span>
<div class="flex items-center px-5 py-4">
<button
onclick={() => { adminError = null; adminTarget = user; }}
disabled={user.status !== 'active'}
aria-label="{user.is_admin ? 'Revoke admin for' : 'Grant admin to'} {user.name || user.email}"
class="rounded-[var(--radius-button)] border px-3 py-1.5 text-meta font-medium transition-all duration-150 disabled:opacity-50
{user.is_admin
? 'border-[var(--color-amber)]/30 bg-[var(--color-amber)]/10 text-[var(--color-amber)] hover:bg-[var(--color-amber)]/20 hover:border-[var(--color-amber)]/50'
: 'border-[var(--color-border)] text-[var(--color-text-tertiary)] hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-secondary)]'}"
>
{user.is_admin ? 'Admin' : 'User'}
</button>
</div>
<!-- Joined -->
@ -292,3 +326,72 @@
</div>
</footer>
</main>
<!-- Admin confirmation dialog -->
{#if adminTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute inset-0 bg-black/60"
onclick={() => { if (!togglingAdmin) adminTarget = null; }}
onkeydown={(e) => { if (e.key === 'Escape' && !togglingAdmin) adminTarget = null; }}
></div>
<div
class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]"
style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)"
>
<div class="p-6">
<h2 class="font-serif text-heading leading-tight text-[var(--color-text-bright)]">
{adminTarget.is_admin ? 'Revoke Admin' : 'Grant Admin'}
</h2>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
{adminTarget.is_admin ? 'Remove admin access from' : 'Grant admin access to'}
<code class="rounded bg-[var(--color-bg-4)] px-1.5 py-0.5 font-mono text-[0.8rem] text-[var(--color-text-primary)]">{adminTarget.email}</code>.
{adminTarget.is_admin
? 'They will lose access to the admin panel immediately.'
: 'They will be able to manage all platform resources.'}
</p>
{#if adminTarget.is_admin && adminTarget.id === auth.userId}
<div class="mt-3 flex items-start gap-2.5 rounded-[var(--radius-input)] border border-[var(--color-amber)]/30 bg-[var(--color-amber)]/5 px-3 py-2.5">
<svg class="mt-0.5 shrink-0 text-[var(--color-amber)]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<span class="text-meta text-[var(--color-amber)]">
You are removing your own admin access. You will lose access to this panel.
</span>
</div>
{/if}
{#if adminError}
<div class="mt-3 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
{adminError}
</div>
{/if}
<div class="mt-6 flex justify-end gap-3">
<button
onclick={() => (adminTarget = null)}
disabled={togglingAdmin}
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
Cancel
</button>
<button
onclick={handleConfirmAdminToggle}
disabled={togglingAdmin}
class="flex items-center gap-2 rounded-[var(--radius-button)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-110 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0
{adminTarget.is_admin ? 'bg-[var(--color-red)]' : 'bg-[var(--color-accent)]'}"
>
{#if togglingAdmin}
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
{adminTarget.is_admin ? 'Revoking...' : 'Granting...'}
{:else}
{adminTarget.is_admin ? 'Revoke admin' : 'Grant admin'}
{/if}
</button>
</div>
</div>
</div>
</div>
{/if}

View File

@ -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;

View File

@ -333,6 +333,7 @@
},
});
lastDataKey = '';
updateCharts();
}
@ -376,6 +377,7 @@
if (!metricsAvailable) return;
loadMetrics();
const mod = await import('chart.js/auto');
ChartJS = mod.Chart;