forked from wrenn/wrenn
v0.1.6 (#45)
## 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:
@ -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 });
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -333,6 +333,7 @@
|
||||
},
|
||||
});
|
||||
|
||||
lastDataKey = '';
|
||||
updateCharts();
|
||||
}
|
||||
|
||||
@ -376,6 +377,7 @@
|
||||
|
||||
if (!metricsAvailable) return;
|
||||
|
||||
loadMetrics();
|
||||
const mod = await import('chart.js/auto');
|
||||
ChartJS = mod.Chart;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user