stats page first implementation

This commit is contained in:
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,
}