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:
+66
-2
@@ -106,6 +106,19 @@ def _get_data_dir() -> Path:
|
|||||||
|
|
||||||
app = FastAPI(title="BincioActivity Serve", docs_url=None, redoc_url=None)
|
app = FastAPI(title="BincioActivity Serve", docs_url=None, redoc_url=None)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def _cleanup_orphaned_tmp_zips() -> None:
|
||||||
|
"""Remove tmp*.zip files left in user data dirs by the pre-fix upload handler."""
|
||||||
|
import glob as _glob
|
||||||
|
data_dir = _get_data_dir()
|
||||||
|
for p in _glob.glob(str(data_dir / "*" / "tmp*.zip")):
|
||||||
|
try:
|
||||||
|
Path(p).unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -437,6 +450,56 @@ async def admin_jobs(bincio_session: Optional[str] = Cookie(default=None)) -> JS
|
|||||||
return JSONResponse(jobs)
|
return JSONResponse(jobs)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/admin/disk")
|
||||||
|
async def admin_disk(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
||||||
|
"""Per-user disk usage breakdown. Admin only."""
|
||||||
|
_require_admin(bincio_session)
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
data_dir = _get_data_dir()
|
||||||
|
|
||||||
|
def _mb(path: Path) -> float:
|
||||||
|
if not path.exists():
|
||||||
|
return 0.0
|
||||||
|
total = sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
|
||||||
|
return round(total / 1_048_576, 1)
|
||||||
|
|
||||||
|
def _count(path: Path, pattern: str = "*") -> int:
|
||||||
|
if not path.exists():
|
||||||
|
return 0
|
||||||
|
return sum(1 for f in path.glob(pattern) if f.is_file())
|
||||||
|
|
||||||
|
users = []
|
||||||
|
for user_dir in sorted(data_dir.iterdir()):
|
||||||
|
if not user_dir.is_dir() or user_dir.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
# leaked tmp zips
|
||||||
|
leaked = [f for f in user_dir.glob("tmp*.zip") if f.is_file()]
|
||||||
|
users.append({
|
||||||
|
"handle": user_dir.name,
|
||||||
|
"total_mb": _mb(user_dir),
|
||||||
|
"activities_mb": _mb(user_dir / "activities"),
|
||||||
|
"activities_count": _count(user_dir / "activities", "*.json"),
|
||||||
|
"merged_mb": _mb(user_dir / "_merged"),
|
||||||
|
"originals_mb": _mb(user_dir / "originals"),
|
||||||
|
"originals_strava_mb": _mb(user_dir / "originals" / "strava"),
|
||||||
|
"images_mb": _mb(user_dir / "edits" / "images"),
|
||||||
|
"leaked_zips_mb": round(sum(f.stat().st_size for f in leaked) / 1_048_576, 1),
|
||||||
|
"leaked_zips_count": len(leaked),
|
||||||
|
})
|
||||||
|
|
||||||
|
disk = shutil.disk_usage("/")
|
||||||
|
return JSONResponse({
|
||||||
|
"disk": {
|
||||||
|
"total_gb": round(disk.total / 1_073_741_824, 1),
|
||||||
|
"used_gb": round(disk.used / 1_073_741_824, 1),
|
||||||
|
"free_gb": round(disk.free / 1_073_741_824, 1),
|
||||||
|
"percent": round(disk.used / disk.total * 100, 1),
|
||||||
|
},
|
||||||
|
"users": users,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/admin/users/{handle}/activities")
|
@app.delete("/api/admin/users/{handle}/activities")
|
||||||
async def admin_delete_activities(
|
async def admin_delete_activities(
|
||||||
handle: str,
|
handle: str,
|
||||||
@@ -803,7 +866,7 @@ async def upload_strava_zip(
|
|||||||
|
|
||||||
dd = _get_data_dir() / user.handle
|
dd = _get_data_dir() / user.handle
|
||||||
import tempfile
|
import tempfile
|
||||||
tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False, dir=dd)
|
tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
|
||||||
zip_path = Path(tmp.name)
|
zip_path = Path(tmp.name)
|
||||||
try:
|
try:
|
||||||
while chunk := await file.read(1024 * 1024): # 1 MB chunks
|
while chunk := await file.read(1024 * 1024): # 1 MB chunks
|
||||||
@@ -826,8 +889,9 @@ async def upload_strava_zip(
|
|||||||
merge_all(dd)
|
merge_all(dd)
|
||||||
_trigger_rebuild(user.handle)
|
_trigger_rebuild(user.handle)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
zip_path.unlink(missing_ok=True)
|
|
||||||
yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n"
|
yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n"
|
||||||
|
finally:
|
||||||
|
zip_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
event_stream(),
|
event_stream(),
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Bincio VPS disk usage report
|
||||||
|
# Run on the VPS: bash scripts/disk_report.sh
|
||||||
|
# Or remotely: ssh root@<vps> 'bash -s' < scripts/disk_report.sh
|
||||||
|
|
||||||
|
DATA=/var/bincio/data
|
||||||
|
SITE=/var/bincio/site # adjust if your site build lives elsewhere
|
||||||
|
|
||||||
|
hr() { echo; echo "── $* ──────────────────────────────────────"; }
|
||||||
|
|
||||||
|
hr "DISK OVERVIEW"
|
||||||
|
df -h / | tail -1 | awk '{printf "Used: %s / %s (%s full)\n", $3, $2, $5}'
|
||||||
|
|
||||||
|
hr "BINCIO ROOT"
|
||||||
|
du -sh /var/bincio/ 2>/dev/null
|
||||||
|
|
||||||
|
hr "DATA ROOT: $DATA"
|
||||||
|
du -sh "$DATA" 2>/dev/null
|
||||||
|
|
||||||
|
hr "PER-USER BREAKDOWN"
|
||||||
|
for user_dir in "$DATA"/*/; do
|
||||||
|
handle=$(basename "$user_dir")
|
||||||
|
[[ "$handle" == _* ]] && continue # skip _feedback etc.
|
||||||
|
|
||||||
|
total=$(du -sh "$user_dir" 2>/dev/null | cut -f1)
|
||||||
|
|
||||||
|
act=$(du -sh "$user_dir/activities" 2>/dev/null | cut -f1 || echo "—")
|
||||||
|
merged=$(du -sh "$user_dir/_merged" 2>/dev/null | cut -f1 || echo "—")
|
||||||
|
edits=$(du -sh "$user_dir/edits" 2>/dev/null | cut -f1 || echo "—")
|
||||||
|
images=$(du -sh "$user_dir/edits/images" 2>/dev/null | cut -f1 || echo "—")
|
||||||
|
orig=$(du -sh "$user_dir/originals" 2>/dev/null | cut -f1 || echo "—")
|
||||||
|
orig_strava=$(du -sh "$user_dir/originals/strava" 2>/dev/null | cut -f1 || echo "—")
|
||||||
|
orig_fit=$(du -sh "$user_dir/originals" 2>/dev/null) # will count below by extension
|
||||||
|
|
||||||
|
n_act=$(find "$user_dir/activities" -name "*.json" 2>/dev/null | wc -l | tr -d ' ')
|
||||||
|
n_orig=$(find "$user_dir/originals" -type f 2>/dev/null | wc -l | tr -d ' ')
|
||||||
|
n_strava=$(find "$user_dir/originals/strava" -name "*.json" 2>/dev/null | wc -l | tr -d ' ')
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " @$handle (total: $total)"
|
||||||
|
echo " activities/ $act ($n_act JSON files)"
|
||||||
|
echo " _merged/ $merged"
|
||||||
|
echo " edits/ $edits (images: $images)"
|
||||||
|
echo " originals/ $orig ($n_orig files)"
|
||||||
|
echo " strava/ $orig_strava ($n_strava JSON)"
|
||||||
|
done
|
||||||
|
|
||||||
|
hr "FEEDBACK"
|
||||||
|
du -sh "$DATA/_feedback" 2>/dev/null || echo " (none)"
|
||||||
|
|
||||||
|
hr "SITE BUILD"
|
||||||
|
du -sh "$SITE" 2>/dev/null || echo " (not found at $SITE)"
|
||||||
|
|
||||||
|
hr "LOGS"
|
||||||
|
journalctl --disk-usage 2>/dev/null || echo " (journalctl unavailable)"
|
||||||
|
|
||||||
|
hr "LARGEST FILES IN DATA (top 20)"
|
||||||
|
find "$DATA" -type f -printf '%s\t%p\n' 2>/dev/null \
|
||||||
|
| sort -rn | head -20 \
|
||||||
|
| awk '{
|
||||||
|
size=$1; path=$2;
|
||||||
|
if (size >= 1048576) printf "%6.1f MB %s\n", size/1048576, path;
|
||||||
|
else if (size >= 1024) printf "%6.1f KB %s\n", size/1024, path;
|
||||||
|
else printf "%6d B %s\n", size, path;
|
||||||
|
}'
|
||||||
|
|
||||||
|
hr "EXTENSION BREAKDOWN IN originals/"
|
||||||
|
find "$DATA" -path "*/originals/*" -type f 2>/dev/null \
|
||||||
|
| sed 's/.*\.//' | sort | uniq -c | sort -rn \
|
||||||
|
| awk '{printf " %6d .%s\n", $1, $2}'
|
||||||
|
|
||||||
|
echo
|
||||||
@@ -2,12 +2,33 @@
|
|||||||
import Base from '../../layouts/Base.astro';
|
import Base from '../../layouts/Base.astro';
|
||||||
---
|
---
|
||||||
<Base title="Admin — BincioActivity">
|
<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>
|
<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>
|
<h2 class="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-3">Users</h2>
|
||||||
<div id="user-list" class="space-y-2">
|
<div class="overflow-x-auto rounded-lg border border-zinc-800">
|
||||||
<p class="text-zinc-500 text-sm">Loading…</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Confirmation dialog -->
|
<!-- Confirmation dialog -->
|
||||||
@@ -22,40 +43,88 @@ import Base from '../../layouts/Base.astro';
|
|||||||
</Base>
|
</Base>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const listEl = document.getElementById('user-list')!;
|
const overviewEl = document.getElementById('disk-overview')!;
|
||||||
const dialog = document.getElementById('confirm-dialog') as HTMLDialogElement;
|
const tbodyEl = document.getElementById('user-list')!;
|
||||||
const confirmH = document.getElementById('confirm-handle')!;
|
const dialog = document.getElementById('confirm-dialog') as HTMLDialogElement;
|
||||||
|
const confirmH = document.getElementById('confirm-handle')!;
|
||||||
const confirmOk = document.getElementById('confirm-ok')!;
|
const confirmOk = document.getElementById('confirm-ok')!;
|
||||||
const confirmCancel = document.getElementById('confirm-cancel')!;
|
const confirmCancel = document.getElementById('confirm-cancel')!;
|
||||||
|
|
||||||
let pendingHandle = '';
|
let pendingHandle = '';
|
||||||
|
|
||||||
async function load() {
|
function fmt(mb: number): string {
|
||||||
const r = await fetch('/api/admin/users', { credentials: 'include' });
|
if (mb >= 1024) return (mb / 1024).toFixed(1) + ' GB';
|
||||||
if (!r.ok) {
|
if (mb >= 1) return mb.toFixed(0) + ' MB';
|
||||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Not authorised or server unavailable.</p>';
|
return (mb * 1024).toFixed(0) + ' KB';
|
||||||
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('');
|
|
||||||
|
|
||||||
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', () => {
|
btn.addEventListener('click', () => {
|
||||||
pendingHandle = btn.dataset.handle!;
|
pendingHandle = btn.dataset.handle!;
|
||||||
confirmH.textContent = pendingHandle;
|
confirmH.textContent = pendingHandle;
|
||||||
@@ -69,7 +138,7 @@ import Base from '../../layouts/Base.astro';
|
|||||||
|
|
||||||
confirmOk.addEventListener('click', async () => {
|
confirmOk.addEventListener('click', async () => {
|
||||||
dialog.close();
|
dialog.close();
|
||||||
const row = listEl.querySelector(`[data-handle="${pendingHandle}"]`);
|
const row = tbodyEl.querySelector(`[data-handle="${pendingHandle}"]`);
|
||||||
const btn = row?.querySelector<HTMLButtonElement>('.delete-btn');
|
const btn = row?.querySelector<HTMLButtonElement>('.delete-btn');
|
||||||
if (btn) { btn.disabled = true; btn.textContent = 'Deleting…'; }
|
if (btn) { btn.disabled = true; btn.textContent = 'Deleting…'; }
|
||||||
|
|
||||||
@@ -81,10 +150,12 @@ import Base from '../../layouts/Base.astro';
|
|||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
if (btn) { btn.textContent = `Deleted (${d.deleted})`; btn.classList.add('text-green-500'); }
|
if (btn) { btn.textContent = `Deleted (${d.deleted})`; btn.classList.add('text-green-500'); }
|
||||||
|
// Reload to refresh sizes
|
||||||
|
setTimeout(() => load(), 1500);
|
||||||
} else {
|
} else {
|
||||||
if (btn) { btn.disabled = false; btn.textContent = 'Error: ' + (d.detail ?? 'failed'); btn.classList.add('text-red-400'); }
|
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'); }
|
if (btn) { btn.disabled = false; btn.textContent = 'Error'; btn.classList.add('text-red-400'); }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user