stats page first implementation

This commit is contained in:
JakeBreath
2026-08-05 13:54:26 -05:00
parent 7af1b67143
commit 6325c67790
7 changed files with 670 additions and 4 deletions
+59 -1
View File
@@ -1,3 +1,61 @@
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.test import TestCase
from django.urls import reverse
# Create your tests here.
class ServerStatsTests(TestCase):
def setUp(self):
User = get_user_model()
self.staff = User.objects.create_user(
username='Boss', password='pw', is_staff=True,
)
self.normal = User.objects.create_user(username='NormalUser', password='pw')
cache.clear()
def _authorized(self, user=None):
session = self.client.session
session['authorized'] = True
session.save()
if user is not None:
self.client.force_login(user)
return session
def test_staff_api_returns_snapshot(self):
self._authorized(self.staff)
resp = self.client.get(reverse('landing:server_stats_api'))
self.assertEqual(resp.status_code, 200)
data = resp.json()
for key in ('app', 'system', 'content', 'recent', 'generated_at'):
self.assertIn(key, data)
self.assertIn('users', data['content'])
self.assertIn('versions', data['recent'])
def test_api_payload_cached(self):
self._authorized(self.staff)
first = self.client.get(reverse('landing:server_stats_api')).json()
second = self.client.get(reverse('landing:server_stats_api')).json()
self.assertEqual(first['generated_at'], second['generated_at'])
def test_non_staff_api_forbidden(self):
self._authorized(self.normal)
resp = self.client.get(reverse('landing:server_stats_api'))
self.assertEqual(resp.status_code, 403)
self.assertEqual(resp.json()['detail'], 'Only staff can view server stats.')
def test_page_is_staff_shell(self):
self._authorized(self.staff)
resp = self.client.get(reverse('landing:server_stats'))
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, 'stats-updated')
self.assertContains(resp, '/api/stats/')
def test_page_forbidden_for_non_staff(self):
self._authorized(self.normal)
resp = self.client.get(reverse('landing:server_stats'))
self.assertEqual(resp.status_code, 403)
def test_anonymous_redirects_to_login(self):
self._authorized()
resp = self.client.get(reverse('landing:server_stats'))
self.assertEqual(resp.status_code, 302)
+2
View File
@@ -7,4 +7,6 @@ app_name = 'landing'
urlpatterns = [
path('', views.gate, name='gate'),
path('home/', views.home, name='home'),
path('stats/', views.server_stats, name='server_stats'),
path('api/stats/', views.server_stats_api, name='server_stats_api'),
]
+262 -3
View File
@@ -1,14 +1,35 @@
import hmac
import platform
import random
import socket
import time
from datetime import timedelta
from django import get_version as django_version
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db.models import Sum
from django.contrib.auth.decorators import login_required
from django.contrib.sessions.models import Session
from django.core.cache import cache
from django.db.models import Count, Sum
from django.http import HttpResponseForbidden, JsonResponse
from django.shortcuts import redirect, render
from django.utils import timezone as tz
from common.git_utils import get_git_commit_hash
from common.middleware import gate_whitelist
from library.models import Project, Version
from library.models import (
Comment,
FileIndex,
Project,
ProjectAsset,
ProjectDraft,
Rating,
Tag,
TempUpload,
Version,
VersionFile,
)
def _under_lockout(request):
@@ -68,4 +89,242 @@ def home(request):
'versions': Version.objects.count(),
},
}
return render(request, 'landing/home.html', context)
return render(request, 'landing/home.html', context)
def _fmt_bytes(n):
"""Human-readable byte count."""
if n is None:
return ''
size = float(n)
for unit in ('B', 'KB', 'MB', 'GB', 'TB'):
if size < 1024 or unit == 'TB':
return f'{size:.1f} {unit}'
size /= 1024
def _system_snapshot():
"""Collect a one-shot system/performance snapshot via psutil."""
try:
import psutil
except ImportError:
return None
snapshot = {}
try:
boot_ts = psutil.boot_time()
snapshot['boot_time'] = tz.datetime.fromtimestamp(
boot_ts, tz.get_current_timezone()
).isoformat(timespec='seconds')
snapshot['uptime'] = round(time.time() - boot_ts)
try:
snapshot['load_avg'] = [round(x, 2) for x in psutil.getloadavg()]
except Exception:
snapshot['load_avg'] = None
snapshot['cpus'] = {
'logical': psutil.cpu_count(logical=True) or 0,
'physical': psutil.cpu_count(logical=False) or 0,
'percent': psutil.cpu_percent(interval=0.1),
}
vm = psutil.virtual_memory()
snapshot['memory'] = {
'total': vm.total, 'used': vm.used,
'available': vm.available, 'percent': vm.percent,
}
sw = psutil.swap_memory()
snapshot['swap'] = {'total': sw.total, 'used': sw.used, 'percent': sw.percent}
snapshot['disks'] = {}
for label, path in (('Root /', '/'), ('Code', str(settings.BASE_DIR)), ('Media', str(settings.MEDIA_ROOT))):
try:
d = psutil.disk_usage(path)
snapshot['disks'][label] = {
'path': path, 'total': d.total, 'used': d.used,
'free': d.free, 'percent': d.percent,
}
except OSError:
snapshot['disks'][label] = None
try:
snapshot['net'] = {
'sent': psutil.net_io_counters().bytes_sent,
'recv': psutil.net_io_counters().bytes_recv,
}
except Exception:
snapshot['net'] = None
running = 0
top_cpu = []
top_mem = []
gunicorn = 0
for proc in psutil.process_iter(['pid', 'name', 'cmdline', 'cpu_percent', 'memory_percent']):
try:
running += 1
info = proc.info
cmd = ' '.join(info.get('cmdline') or [])
if 'gunicorn' in cmd:
gunicorn += 1
top_cpu.append({
'name': info.get('name') or '?', 'pid': info.get('pid'),
'value': round(info.get('cpu_percent') or 0, 1),
})
top_mem.append({
'name': info.get('name') or '?', 'pid': info.get('pid'),
'value': round(info.get('memory_percent') or 0, 1),
})
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
snapshot['processes'] = running
snapshot['gunicorn'] = gunicorn
top_cpu.sort(key=lambda x: x['value'], reverse=True)
top_mem.sort(key=lambda x: x['value'], reverse=True)
snapshot['top_cpu'] = top_cpu[:6]
snapshot['top_mem'] = top_mem[:6]
except Exception:
return None
return snapshot
@login_required
def server_stats(request):
"""Staff-only stats page. Renders a lightweight shell; the snapshot data
is fetched from the JSON endpoint so page loads stay cheap."""
if not request.user.is_staff:
return HttpResponseForbidden('Only staff can view server stats.')
return render(request, 'landing/server_stats.html', {
'disk_labels': ['Root /', 'Code', 'Media'],
})
STATS_CACHE_KEY = 'stats:payload'
STATS_CACHE_TTL = 20
@login_required
def server_stats_api(request):
"""JSON snapshot of server state / performance / recent activity.
The payload is cached server-side for STATS_CACHE_TTL seconds so polling
clients share one computation per interval instead of hammering the DB."""
if not request.user.is_staff:
return JsonResponse({'detail': 'Only staff can view server stats.'}, status=403)
payload = cache.get(STATS_CACHE_KEY)
if payload is None:
start = time.time()
payload = _collect_stats()
payload['app']['page_gen_ms'] = round((time.time() - start) * 1000, 1)
cache.set(STATS_CACHE_KEY, payload, STATS_CACHE_TTL)
return JsonResponse(payload)
def _collect_stats(page_gen_ms=None):
"""One-shot, JSON-safe snapshot of server state and recent activity."""
now = tz.now()
users = get_user_model().objects
category_breakdown = dict(
Project.objects.values_list('category').annotate(n=Count('pk')).order_by()
)
project_categories = [c for c, _ in Project.CATEGORY_CHOICES]
categories_list = [
[c, category_breakdown.get(c, 0)] for c in project_categories
]
try:
import importlib.metadata as _md
gunicorn_version = _md.version('gunicorn')
except Exception:
gunicorn_version = 'unknown'
content = {
'users': users.count(),
'staff': users.filter(is_staff=True).count(),
'superusers': users.filter(is_superuser=True).count(),
'active_24h': users.filter(last_login__gte=now - timedelta(hours=24)).count(),
'active_7d': users.filter(last_login__gte=now - timedelta(days=7)).count(),
'projects': sum(category_breakdown.get(c, 0) for c in project_categories),
'categories': category_breakdown,
'versions': Version.objects.count(),
'files': FileIndex.objects.count(),
'storage': _fmt_bytes(
FileIndex.objects.aggregate(s=Sum('size'))['s'] or 0
),
'assets': ProjectAsset.objects.count(),
'downloads': Version.objects.aggregate(t=Sum('downloads'))['t'] or 0,
'ratings': Rating.objects.count(),
'ratings_positive': Rating.objects.filter(value=True).count(),
'ratings_negative': Rating.objects.filter(value=False).count(),
'comments': Comment.objects.count(),
'tags': Tag.objects.count(),
'sessions': Session.objects.filter(expire_date__gte=now).count(),
'pending_uploads': TempUpload.objects.filter(status='pending').count(),
'drafts': ProjectDraft.objects.count(),
}
def _iso(dt):
return dt.isoformat(timespec='seconds')
recent = {
'versions': [
{
'project': v.project.title,
'slug': v.project.slug,
'version': v.version_name,
'created_at': _iso(v.created_at),
}
for v in Version.objects.select_related('project').order_by('-created_at')[:8]
],
'comments': [
{
'user': c.user.username,
'project': c.project.title,
'slug': c.project.slug,
'body': c.body,
'created_at': _iso(c.created_at),
}
for c in Comment.objects.select_related('user', 'project').order_by('-created_at')[:8]
],
'projects': [
{
'title': p.title,
'slug': p.slug,
'category': p.category,
'owner': p.owner.username,
'created_at': _iso(p.created_at),
}
for p in Project.objects.select_related('owner').order_by('-created_at')[:5]
],
'ratings': [
{
'user': r.user.username,
'project': r.project.title,
'slug': r.project.slug,
'value': r.value,
'created_at': _iso(r.created_at),
}
for r in Rating.objects.select_related('user', 'project').order_by('-created_at')[:5]
],
}
app = {
'django': django_version(),
'python': platform.python_version(),
'hostname': socket.gethostname(),
'app_env': 'dev' if settings.DEBUG else 'prod',
'debug': settings.DEBUG,
'git': get_git_commit_hash(),
'db': settings.DATABASES['default']['ENGINE'].rsplit('.', 1)[-1],
'tz': settings.TIME_ZONE,
'media_root': str(settings.MEDIA_ROOT),
'gunicorn': gunicorn_version,
'page_gen_ms': page_gen_ms,
}
return {
'generated_at': _iso(now),
'system': _system_snapshot(),
'app': app,
'content': content,
'categories_list': categories_list,
'project_categories': project_categories,
'recent': recent,
}
+46
View File
@@ -3686,3 +3686,49 @@ a.deletelink {
.link-btn { background: none; border: none; color: inherit; cursor: pointer; padding: 0; }
.inline { display: inline; }
.pack-card-rating { color: var(--md-sys-color-on-surface-variant, #a6adc8); font-size: 0.75rem; }
/* Server stats page */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.stats-grid .card { min-width: 0; }
.stats-grid h2 { margin-bottom: 0.6rem; }
.stat-table { width: 100%; border-collapse: collapse; }
.stat-table td { padding: 0.3rem 0.35rem; border-bottom: 1px solid var(--md-sys-color-outline-variant, #45475a); vertical-align: top; }
.stat-table td:first-child { color: var(--md-sys-color-on-surface-variant, #a6adc8); width: 45%; }
.stat-table tr:last-child td { border-bottom: none; }
.stat-mono { font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 0.85em; }
.stat-muted { color: var(--md-sys-color-on-surface-variant, #a6adc8); font-size: 0.8rem; }
.stat-bar-label { margin: 0.5rem 0 0.2rem; font-size: 0.85rem; }
.stat-bar-caption { margin: 0.15rem 0 0.5rem; color: var(--md-sys-color-on-surface-variant, #a6adc8); font-size: 0.75rem; }
.bar-track { height: 8px; border-radius: 999px; background: var(--md-sys-color-surface-variant, #45475a); overflow: hidden; }
.bar-fill { height: 100%; border-radius: 999px; background: var(--ctp-mocha-green); }
.bar-fill-swap { background: var(--ctp-mocha-peach); }
.stat-tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; margin-bottom: 0.75rem; }
.stat-tile {
background: var(--md-sys-color-surface-variant, #45475a);
border-radius: 10px; padding: 0.6rem 0.5rem; text-align: center;
display: flex; flex-direction: column; gap: 0.15rem;
}
.stat-tile-value { font-size: 1.15rem; font-weight: 700; }
.stat-tile-label { font-size: 0.68rem; color: var(--md-sys-color-on-surface-variant, #a6adc8); }
.stat-subhead { margin: 0.75rem 0 0.25rem; font-size: 0.9rem; }
.stat-line { color: var(--md-sys-color-on-surface-variant, #a6adc8); font-size: 0.8rem; margin: 0.25rem 0; }
.stat-chip {
display: inline-block; background: var(--md-sys-color-surface-variant, #45475a);
border-radius: 12px; padding: 2px 8px; margin: 2px 2px 2px 0; font-size: 0.75rem;
}
.stat-list { list-style: none; margin: 0; padding: 0; }
.stat-list li { padding: 0.35rem 0; border-bottom: 1px solid var(--md-sys-color-outline-variant, #45475a); font-size: 0.85rem; }
.stat-list li:last-child { border-bottom: none; }
.stat-excerpt { margin: 0.15rem 0 0; color: var(--md-sys-color-on-surface-variant, #a6adc8); font-size: 0.78rem; }
.stat-up { color: var(--ctp-mocha-green); font-weight: 700; }
.stat-down { color: var(--ctp-mocha-red); font-weight: 700; }
/* Stats toolbar (auto-refresh header) */
.stats-toolbar { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.5rem; flex-wrap: wrap; }
.stats-updated { color: var(--md-sys-color-on-surface-variant, #a6adc8); font-size: 0.8rem; }
.stats-error { color: var(--ctp-mocha-red); font-size: 0.8rem; }
+3
View File
@@ -94,6 +94,9 @@
<a href="{% url 'admin:index' %}">
<i class="fas fa-shield-alt"></i> Admin panel
</a>
<a href="{% url 'landing:server_stats' %}">
<i class="fas fa-chart-line"></i> Server Stats
</a>
{% endif %}
<form method="post" action="{% url 'profiles:logout' %}" style="display: inline;">
{% csrf_token %}
@@ -0,0 +1,297 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Server Stats - Packs Site{% endblock %}
{% block content %}
<h1><i class="fas fa-chart-line"></i> Server Stats</h1>
<p class="empty-hint">Auto-refreshes every 20s — data served from a single cached JSON endpoint.</p>
<div class="stats-toolbar">
<span class="stats-updated"><i class="fas fa-sync-alt"></i> Updated <span id="stats-updated"></span></span>
<button type="button" class="btn btn-secondary btn-sm" id="stats-refresh"><i class="fas fa-redo"></i> Refresh</button>
<span class="stats-error" id="stats-error" hidden><i class="fas fa-exclamation-triangle"></i> Could not load stats.</span>
</div>
<div class="stats-grid">
<section class="card">
<h2><i class="fas fa-cube"></i> Application</h2>
<table class="stat-table">
<tr><td>Environment</td><td id="app-env"></td></tr>
<tr><td>Git commit</td><td><span class="stat-mono" id="app-git"></span></td></tr>
<tr><td>Django</td><td id="app-django"></td></tr>
<tr><td>Python</td><td id="app-python"></td></tr>
<tr><td>Hostname</td><td id="app-hostname"></td></tr>
<tr><td>Database</td><td id="app-db"></td></tr>
<tr><td>Time zone</td><td id="app-tz"></td></tr>
<tr><td>Media root</td><td class="stat-mono" id="app-media"></td></tr>
<tr><td>Gunicorn</td><td id="app-gunicorn"></td></tr>
<tr><td>Data generated in</td><td id="app-pagegen"></td></tr>
</table>
</section>
<section class="card">
<h2><i class="fas fa-server"></i> System</h2>
<table class="stat-table">
<tr><td>Booted</td><td id="sys-boot"></td></tr>
<tr><td>Uptime</td><td id="sys-uptime"></td></tr>
<tr><td>Load average</td><td><span class="stat-mono" id="sys-load"></span></td></tr>
<tr><td>CPU</td><td id="sys-cpu"></td></tr>
<tr><td>Processes</td><td id="sys-procs"></td></tr>
<tr><td>Network (since boot)</td><td id="sys-net"></td></tr>
</table>
</section>
<section class="card">
<h2><i class="fas fa-memory"></i> Memory &amp; Swap</h2>
<p class="stat-bar-label">RAM — <span id="mem-pct"></span>% used</p>
<div class="bar-track"><div class="bar-fill" id="mem-bar"></div></div>
<p class="stat-bar-caption"><span id="mem-used"></span> of <span id="mem-total"></span></p>
<p class="stat-bar-label">Swap — <span id="swap-pct"></span>% used</p>
<div class="bar-track"><div class="bar-fill bar-fill-swap" id="swap-bar"></div></div>
<p class="stat-bar-caption"><span id="swap-used"></span> of <span id="swap-total"></span></p>
</section>
<section class="card">
<h2><i class="fas fa-hdd"></i> Disk</h2>
{% for label in disk_labels %}
<div data-disk="{{ label }}">
<p class="stat-bar-label">{{ label }} <span class="stat-muted" data-f="path"></span><span data-f="percent"></span>% used</p>
<div class="bar-track"><div class="bar-fill" data-f="bar"></div></div>
<p class="stat-bar-caption"><span data-f="used"></span> of <span data-f="total"></span> · <span data-f="free"></span> free</p>
</div>
{% endfor %}
</section>
<section class="card">
<h2><i class="fas fa-fire"></i> Top processes by CPU</h2>
<table class="stat-table">
<tbody id="top-cpu">
<tr><td colspan="3" class="empty-hint">Loading…</td></tr>
</tbody>
</table>
</section>
<section class="card">
<h2><i class="fas fa-layer-group"></i> Top processes by memory</h2>
<table class="stat-table">
<tbody id="top-mem">
<tr><td colspan="3" class="empty-hint">Loading…</td></tr>
</tbody>
</table>
</section>
<section class="card">
<h2><i class="fas fa-database"></i> Site content</h2>
<div class="stat-tiles">
<div class="stat-tile"><span class="stat-tile-value" data-tile="users"></span><span class="stat-tile-label">Users</span></div>
<div class="stat-tile"><span class="stat-tile-value" data-tile="projects"></span><span class="stat-tile-label">Packs</span></div>
<div class="stat-tile"><span class="stat-tile-value" data-tile="versions"></span><span class="stat-tile-label">Versions</span></div>
<div class="stat-tile"><span class="stat-tile-value" data-tile="files"></span><span class="stat-tile-label">Files</span></div>
<div class="stat-tile"><span class="stat-tile-value" data-tile="downloads"></span><span class="stat-tile-label">Downloads</span></div>
<div class="stat-tile"><span class="stat-tile-value" data-tile="comments"></span><span class="stat-tile-label">Comments</span></div>
<div class="stat-tile"><span class="stat-tile-value" data-tile="ratings"></span><span class="stat-tile-label">Votes</span></div>
<div class="stat-tile"><span class="stat-tile-value" data-tile="assets"></span><span class="stat-tile-label">Gallery</span></div>
<div class="stat-tile"><span class="stat-tile-value" data-tile="sessions"></span><span class="stat-tile-label">Active sessions</span></div>
<div class="stat-tile"><span class="stat-tile-value" data-tile="pending_uploads"></span><span class="stat-tile-label">Pending uploads</span></div>
<div class="stat-tile"><span class="stat-tile-value" data-tile="drafts"></span><span class="stat-tile-label">Drafts</span></div>
<div class="stat-tile"><span class="stat-tile-value" data-tile="storage"></span><span class="stat-tile-label">Stored media</span></div>
</div>
<h3 class="stat-subhead">Accounts</h3>
<p class="stat-line">Staff <span data-tile="staff"></span> · Superusers <span data-tile="superusers"></span> · Active 24h <span data-tile="active_24h"></span> · Active 7d <span data-tile="active_7d"></span></p>
<h3 class="stat-subhead">Packs by category</h3>
<p class="stat-line" id="categories"></p>
<h3 class="stat-subhead">Ratings</h3>
<p class="stat-line" id="ratings-line"></p>
</section>
<section class="card stats-recent">
<h2><i class="fas fa-history"></i> Latest versions</h2>
<ul class="stat-list" data-list="versions"><li class="empty-hint">Loading…</li></ul>
</section>
<section class="card stats-recent">
<h2><i class="fas fa-comments"></i> Latest comments</h2>
<ul class="stat-list" data-list="comments"><li class="empty-hint">Loading…</li></ul>
</section>
<section class="card stats-recent">
<h2><i class="fas fa-box-open"></i> Latest packs</h2>
<ul class="stat-list" data-list="projects"><li class="empty-hint">Loading…</li></ul>
</section>
<section class="card stats-recent">
<h2><i class="fas fa-thumbs-up"></i> Latest votes</h2>
<ul class="stat-list" data-list="ratings"><li class="empty-hint">Loading…</li></ul>
</section>
</div>
{% endblock %}
{% block extra_js %}
<script>
(function () {
const URL = "{% url 'landing:server_stats_api' %}";
const INTERVAL_MS = 20000;
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function set(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = value == null ? '—' : value;
}
function setHtml(id, value) {
const el = document.getElementById(id);
if (el) el.innerHTML = value == null ? '—' : value;
}
function fmtBytes(n) {
if (n == null) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = Number(n);
for (const u of units) {
if (size < 1024 || u === 'TB') return size.toFixed(1) + ' ' + u;
size /= 1024;
}
}
function timeAgo(iso) {
const then = new Date(iso);
const s = Math.max(1, Math.floor((Date.now() - then.getTime()) / 1000));
if (s < 60) return s + 's ago';
const m = Math.floor(s / 60);
if (m < 60) return m + 'm ago';
const h = Math.floor(m / 60);
if (h < 24) return h + 'h ago';
return Math.floor(h / 24) + 'd ago';
}
function fmtDuration(sec) {
sec = Math.floor(sec || 0);
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
return (d ? d + 'd ' : '') + (h || d ? h + 'h ' : '') + m + 'm';
}
function setTiles(d) {
const content = d.content || {};
document.querySelectorAll('[data-tile]').forEach(el => {
el.textContent = content[el.dataset.tile] == null ? '—' : content[el.dataset.tile];
});
const cats = d.categories_list || [];
document.getElementById('categories').innerHTML = cats.length
? cats.map(([name, n]) => `<span class="stat-chip">${esc(name)}: ${n}</span>`).join('')
: '—';
const line = document.getElementById('ratings-line');
if (line) line.textContent = `${content.ratings_positive} positive · ${content.ratings_negative} negative · ${content.tags} tags`;
}
function buildProcesses(items, tbodyId) {
const tbody = document.getElementById(tbodyId);
if (!items || !items.length) {
tbody.innerHTML = '<tr><td colspan="3" class="empty-hint">No process data.</td></tr>';
return;
}
tbody.innerHTML = items.map(p =>
`<tr><td>${esc(p.name)}</td><td><span class="stat-mono">#${esc(p.pid)}</span></td><td><strong>${p.value}%</strong></td></tr>`
).join('');
}
function buildDisks(system) {
document.querySelectorAll('[data-disk]').forEach(block => {
const label = block.dataset.disk;
const disk = system && system.disks ? system.disks[label] : null;
const setF = (k, v) => { const el = block.querySelector('[data-f="' + k + '"]'); if (el) el.textContent = v; };
const bar = block.querySelector('[data-f="bar"]');
if (!disk) {
setF('path', 'unavailable');
if (bar) bar.style.width = '0%';
return;
}
setF('path', disk.path);
setF('percent', disk.percent);
if (bar) bar.style.width = disk.percent + '%';
setF('used', fmtBytes(disk.used));
setF('total', fmtBytes(disk.total));
setF('free', fmtBytes(disk.free));
});
}
function buildRecent(d) {
const lists = {
versions: items => items.map(v =>
`<li><a href="/packs/${esc(v.slug)}/">${esc(v.project)}</a> ` +
`<span class="stat-muted">v${esc(v.version)} · ${timeAgo(v.created_at)}</span></li>`
).join(''),
comments: items => items.map(c =>
`<li><strong>${esc(c.user)}</strong> on <a href="/packs/${esc(c.slug)}/">${esc(c.project)}</a> ` +
`<span class="stat-muted">· ${timeAgo(c.created_at)}</span>` +
`<p class="stat-excerpt">${esc(c.body.length > 80 ? c.body.slice(0, 80) + '…' : c.body)}</p></li>`
).join(''),
projects: items => items.map(p =>
`<li><a href="/packs/${esc(p.slug)}/">${esc(p.title)}</a> ` +
`<span class="stat-muted">${esc(p.category)} · ${esc(p.owner)} · ${timeAgo(p.created_at)}</span></li>`
).join(''),
ratings: items => items.map(r =>
`<li><strong>${esc(r.user)}</strong> ` +
`<span class="${r.value ? 'stat-up' : 'stat-down'}">${r.value ? '+1' : '1'}</span> ` +
`<a href="/packs/${esc(r.slug)}/">${esc(r.project)}</a> ` +
`<span class="stat-muted">· ${timeAgo(r.created_at)}</span></li>`
).join(''),
};
document.querySelectorAll('[data-list]').forEach(ul => {
const key = ul.dataset.list;
const items = (d.recent && d.recent[key]) || [];
ul.innerHTML = items.length ? lists[key](items) : '<li class="empty-hint">None yet.</li>';
});
}
function apply(d) {
const a = d.app || {};
setHtml('app-env', a.app_env + (a.debug ? ' <span class="badge badge-staff">DEBUG</span>' : ''));
set('app-git', a.git);
set('app-django', a.django);
set('app-python', a.python);
set('app-hostname', a.hostname);
set('app-db', a.db);
set('app-tz', a.tz);
set('app-media', a.media_root);
set('app-gunicorn', a.gunicorn);
set('app-pagegen', a.page_gen_ms != null ? a.page_gen_ms + ' ms' : '—');
const s = d.system;
if (s) {
set('sys-boot', new Date(s.boot_time).toLocaleString());
set('sys-uptime', fmtDuration(s.uptime));
set('sys-load', s.load_avg ? s.load_avg.join(' / ') : '—');
setHtml('sys-cpu', `${s.cpus.physical} core / ${s.cpus.logical} thread · <strong>${s.cpus.percent}%</strong>`);
set('sys-procs', s.processes + ' running');
set('sys-net', s.net ? `${fmtBytes(s.net.sent)} up · ${fmtBytes(s.net.recv)} down` : '—');
setHtml('app-gunicorn', a.gunicorn + ` <span class="stat-muted">(${s.gunicorn} proc)</span>`);
set('mem-pct', s.memory.percent);
document.getElementById('mem-bar').style.width = s.memory.percent + '%';
set('mem-used', fmtBytes(s.memory.used));
set('mem-total', fmtBytes(s.memory.total));
set('swap-pct', s.swap.percent);
document.getElementById('swap-bar').style.width = s.swap.percent + '%';
set('swap-used', fmtBytes(s.swap.used));
set('swap-total', fmtBytes(s.swap.total));
buildDisks(s);
buildProcesses(s.top_cpu, 'top-cpu');
buildProcesses(s.top_mem, 'top-mem');
}
setTiles(d);
buildRecent(d);
set('stats-updated', new Date(d.generated_at).toLocaleTimeString());
}
async function load() {
const err = document.getElementById('stats-error');
err.hidden = true;
try {
const res = await fetch(URL, { headers: { 'Accept': 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status);
apply(await res.json());
} catch (e) {
err.hidden = false;
}
}
document.getElementById('stats-refresh').addEventListener('click', load);
load();
setInterval(load, INTERVAL_MS);
})();
</script>
{% endblock %}