diff --git a/nonpacks/landing/tests.py b/nonpacks/landing/tests.py index 7ce503c..6b40777 100644 --- a/nonpacks/landing/tests.py +++ b/nonpacks/landing/tests.py @@ -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) diff --git a/nonpacks/landing/urls.py b/nonpacks/landing/urls.py index 43e6a8a..13c5c35 100644 --- a/nonpacks/landing/urls.py +++ b/nonpacks/landing/urls.py @@ -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'), ] \ No newline at end of file diff --git a/nonpacks/landing/views.py b/nonpacks/landing/views.py index d70f598..9a0c3f4 100644 --- a/nonpacks/landing/views.py +++ b/nonpacks/landing/views.py @@ -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) \ No newline at end of file + 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, + } \ No newline at end of file diff --git a/nonpacks/static/css/style.css b/nonpacks/static/css/style.css index 442e297..b9dc05f 100644 --- a/nonpacks/static/css/style.css +++ b/nonpacks/static/css/style.css @@ -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; } diff --git a/nonpacks/templates/base.html b/nonpacks/templates/base.html index c1ad02d..f0e89bc 100644 --- a/nonpacks/templates/base.html +++ b/nonpacks/templates/base.html @@ -94,6 +94,9 @@ Admin panel + + Server Stats + {% endif %}