Bug fixed — temp ZIPs now go to /tmp/ (system temp) and are always deleted in a finally block, so they can't leak. A startup hook also auto-cleans any leftovers on

next server restart.

  Admin page now shows:
  - Overall disk bar (used/free/%)
  - Per-user table: total, activities (with file count), originals (with Strava breakdown), merged, images
  - A mini bar per user showing relative size
  - Red ⚠ warning if orphaned temp ZIPs are still present for a user
  - Delete activities button (reloads sizes after)
This commit is contained in:
Davide Scaini
2026-04-13 12:24:59 +02:00
parent 7e526c14e1
commit 7b37f45180
3 changed files with 242 additions and 35 deletions
+104 -33
View File
@@ -2,12 +2,33 @@
import Base from '../../layouts/Base.astro';
---
<Base title="Admin — BincioActivity">
<div class="max-w-2xl mx-auto px-4 py-10">
<div class="max-w-3xl mx-auto px-4 py-10">
<h1 class="text-2xl font-bold text-white mb-8">Admin</h1>
<!-- Disk overview -->
<div id="disk-overview" class="mb-8 p-4 rounded-lg bg-zinc-900 border border-zinc-800 text-sm">
<p class="text-zinc-500">Loading disk info…</p>
</div>
<!-- User table -->
<h2 class="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-3">Users</h2>
<div id="user-list" class="space-y-2">
<p class="text-zinc-500 text-sm">Loading…</p>
<div class="overflow-x-auto rounded-lg border border-zinc-800">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-zinc-500 text-xs border-b border-zinc-800">
<th class="px-4 py-2 font-medium">Handle</th>
<th class="px-4 py-2 font-medium text-right">Total</th>
<th class="px-4 py-2 font-medium text-right">Activities</th>
<th class="px-4 py-2 font-medium text-right">Originals</th>
<th class="px-4 py-2 font-medium text-right">Merged</th>
<th class="px-4 py-2 font-medium text-right">Images</th>
<th class="px-4 py-2 font-medium"></th>
</tr>
</thead>
<tbody id="user-list">
<tr><td colspan="7" class="px-4 py-6 text-zinc-500 text-center">Loading…</td></tr>
</tbody>
</table>
</div>
<!-- Confirmation dialog -->
@@ -22,40 +43,88 @@ import Base from '../../layouts/Base.astro';
</Base>
<script>
const listEl = document.getElementById('user-list')!;
const dialog = document.getElementById('confirm-dialog') as HTMLDialogElement;
const confirmH = document.getElementById('confirm-handle')!;
const overviewEl = document.getElementById('disk-overview')!;
const tbodyEl = document.getElementById('user-list')!;
const dialog = document.getElementById('confirm-dialog') as HTMLDialogElement;
const confirmH = document.getElementById('confirm-handle')!;
const confirmOk = document.getElementById('confirm-ok')!;
const confirmCancel = document.getElementById('confirm-cancel')!;
let pendingHandle = '';
async function load() {
const r = await fetch('/api/admin/users', { credentials: 'include' });
if (!r.ok) {
listEl.innerHTML = '<p class="text-red-400 text-sm">Not authorised or server unavailable.</p>';
return;
}
const users = await r.json();
if (!users.length) {
listEl.innerHTML = '<p class="text-zinc-500 text-sm">No users.</p>';
return;
}
listEl.innerHTML = users.map((u: { handle: string; display_name: string; is_admin: boolean }) => `
<div class="flex items-center justify-between px-4 py-3 rounded-lg bg-zinc-900 border border-zinc-800" data-handle="${u.handle}">
<div>
<span class="text-sm font-medium text-white">@${u.handle}</span>
${u.display_name ? `<span class="text-zinc-500 text-sm ml-2">${u.display_name}</span>` : ''}
${u.is_admin ? '<span class="ml-2 text-xs text-amber-400">admin</span>' : ''}
</div>
<button
class="delete-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-red-900 hover:text-red-300 text-zinc-400 transition-colors"
data-handle="${u.handle}"
>Delete activities</button>
</div>
`).join('');
function fmt(mb: number): string {
if (mb >= 1024) return (mb / 1024).toFixed(1) + ' GB';
if (mb >= 1) return mb.toFixed(0) + ' MB';
return (mb * 1024).toFixed(0) + ' KB';
}
listEl.querySelectorAll<HTMLButtonElement>('.delete-btn').forEach(btn => {
function bar(pct: number): string {
const color = pct >= 90 ? 'bg-red-500' : pct >= 70 ? 'bg-amber-500' : 'bg-blue-500';
return `<div class="w-full bg-zinc-800 rounded-full h-1.5 mt-1"><div class="${color} h-1.5 rounded-full" style="width:${Math.min(pct,100)}%"></div></div>`;
}
async function load() {
const r = await fetch('/api/admin/disk', { credentials: 'include' });
if (!r.ok) {
overviewEl.innerHTML = '<p class="text-red-400">Not authorised or server unavailable.</p>';
return;
}
const { disk, users } = await r.json();
// Disk overview
const pct = disk.percent;
const color = pct >= 90 ? 'text-red-400' : pct >= 70 ? 'text-amber-400' : 'text-green-400';
overviewEl.innerHTML = `
<div class="flex items-center justify-between mb-2">
<span class="text-zinc-300 font-medium">Disk usage</span>
<span class="${color} font-semibold">${pct}%</span>
</div>
${bar(pct)}
<p class="text-zinc-500 mt-2">${disk.used_gb} GB used of ${disk.total_gb} GB — ${disk.free_gb} GB free</p>
`;
// User rows
const maxMb = Math.max(...users.map((u: any) => u.total_mb), 1);
tbodyEl.innerHTML = users.map((u: any) => {
const rowPct = Math.round(u.total_mb / maxMb * 100);
const leaked = u.leaked_zips_count > 0
? `<span class="text-red-400 font-medium ml-2" title="${u.leaked_zips_count} orphaned temp ZIP(s)">⚠ ${fmt(u.leaked_zips_mb)} leaked</span>`
: '';
const stravaNote = u.originals_strava_mb > 0
? `<span class="text-zinc-600 text-xs ml-1">(${fmt(u.originals_strava_mb)} Strava)</span>`
: '';
return `
<tr class="border-b border-zinc-800/50 hover:bg-zinc-900/40" data-handle="${u.handle}">
<td class="px-4 py-3">
<div class="flex items-center gap-1">
<a href="/u/${u.handle}/" class="text-white hover:text-zinc-300">@${u.handle}</a>
${leaked}
</div>
${bar(rowPct)}
</td>
<td class="px-4 py-3 text-right text-zinc-300 font-medium tabular-nums">${fmt(u.total_mb)}</td>
<td class="px-4 py-3 text-right text-zinc-400 tabular-nums">
${fmt(u.activities_mb)}
<span class="text-zinc-600 text-xs block">${u.activities_count} files</span>
</td>
<td class="px-4 py-3 text-right text-zinc-400 tabular-nums">
${u.originals_mb > 0 ? fmt(u.originals_mb) : '—'}
${stravaNote}
</td>
<td class="px-4 py-3 text-right text-zinc-400 tabular-nums">${u.merged_mb > 0 ? fmt(u.merged_mb) : '—'}</td>
<td class="px-4 py-3 text-right text-zinc-400 tabular-nums">${u.images_mb > 0 ? fmt(u.images_mb) : '—'}</td>
<td class="px-4 py-3 text-right">
<button
class="delete-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-red-900 hover:text-red-300 text-zinc-400 transition-colors"
data-handle="${u.handle}"
>Delete activities</button>
</td>
</tr>
`;
}).join('');
tbodyEl.querySelectorAll<HTMLButtonElement>('.delete-btn').forEach(btn => {
btn.addEventListener('click', () => {
pendingHandle = btn.dataset.handle!;
confirmH.textContent = pendingHandle;
@@ -69,7 +138,7 @@ import Base from '../../layouts/Base.astro';
confirmOk.addEventListener('click', async () => {
dialog.close();
const row = listEl.querySelector(`[data-handle="${pendingHandle}"]`);
const row = tbodyEl.querySelector(`[data-handle="${pendingHandle}"]`);
const btn = row?.querySelector<HTMLButtonElement>('.delete-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Deleting…'; }
@@ -81,10 +150,12 @@ import Base from '../../layouts/Base.astro';
const d = await r.json();
if (r.ok) {
if (btn) { btn.textContent = `Deleted (${d.deleted})`; btn.classList.add('text-green-500'); }
// Reload to refresh sizes
setTimeout(() => load(), 1500);
} else {
if (btn) { btn.disabled = false; btn.textContent = 'Error: ' + (d.detail ?? 'failed'); btn.classList.add('text-red-400'); }
}
} catch (e: any) {
} catch {
if (btn) { btn.disabled = false; btn.textContent = 'Error'; btn.classList.add('text-red-400'); }
}
});