stats page first implementation
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
+261
-2
@@ -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):
|
||||
@@ -69,3 +90,241 @@ def home(request):
|
||||
},
|
||||
}
|
||||
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,
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 & 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
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 %}
|
||||
@@ -3,5 +3,6 @@ Django==6.0.3
|
||||
gunicorn==25.1.0
|
||||
markdown-it-py==4.0.0
|
||||
pillow==12.2.0
|
||||
psutil==7.2.2
|
||||
PyMySQL==1.1.2
|
||||
whitenoise==6.12.0
|
||||
|
||||
Reference in New Issue
Block a user