feat: Enhance admin dashboard with user, team, and event statistics, and system health monitoring

This commit is contained in:
Christian Krakau-Louis
2025-04-16 14:23:40 +02:00
parent 61a0a0a6a6
commit d48c16119a
6 changed files with 821 additions and 2 deletions
+289
View File
@@ -0,0 +1,289 @@
{% extends "base.html" %}
{% block title %}Admin Dashboard - LeagueLedger{% endblock %}
{% block extra_head %}
<!-- Chart.js for statistics visualization -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
{% endblock %}
{% block content %}
<div class="container mx-auto px-4">
<h1 class="text-3xl font-garamond text-irish-green font-bold mb-6">Admin Dashboard</h1>
<!-- Dashboard Overview -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<!-- User Stats Card -->
<div class="bg-white rounded-lg shadow-md p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-irish-green">Users</h3>
<span class="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded-full">Total: {{ user_stats.total_users }}</span>
</div>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-gray-500">Active:</span>
<span class="font-medium">{{ user_stats.active_users }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Verified:</span>
<span class="font-medium">{{ user_stats.verified_users }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Admins:</span>
<span class="font-medium">{{ user_stats.admin_users }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">New (30d):</span>
<span class="font-medium">{{ user_stats.new_registrations_30d }}</span>
</div>
<div class="mt-4">
<a href="/admin/user" class="text-irish-green hover:underline text-sm flex items-center">
<i class="fas fa-users mr-1"></i> Manage Users
</a>
</div>
</div>
</div>
<!-- Team Stats Card -->
<div class="bg-white rounded-lg shadow-md p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-irish-green">Teams</h3>
<span class="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded-full">Total: {{ team_stats.total_teams }}</span>
</div>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-gray-500">Active:</span>
<span class="font-medium">{{ team_stats.active_teams }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Public:</span>
<span class="font-medium">{{ team_stats.public_teams }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Private:</span>
<span class="font-medium">{{ team_stats.total_teams - team_stats.public_teams }}</span>
</div>
<div class="mt-4">
<a href="/admin/team" class="text-irish-green hover:underline text-sm flex items-center">
<i class="fas fa-arrow-right mr-1"></i> Manage Teams
</a>
</div>
</div>
</div>
<!-- Event Stats Card -->
<div class="bg-white rounded-lg shadow-md p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-irish-green">Events</h3>
<span class="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded-full">Total: {{ event_stats.total_events }}</span>
</div>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-gray-500">Past:</span>
<span class="font-medium">{{ event_stats.past_events }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Upcoming:</span>
<span class="font-medium">{{ event_stats.upcoming_events_count }}</span>
</div>
{% if event_stats.upcoming_events %}
<div class="text-gray-500 text-sm mt-2">Next event:</div>
<div class="text-sm font-medium">{{ event_stats.upcoming_events[0].name }}</div>
<div class="text-xs text-gray-500">{{ event_stats.upcoming_events[0].event_date.strftime('%Y-%m-%d') }}</div>
{% endif %}
<div class="mt-2">
<a href="/admin/event" class="text-irish-green hover:underline text-sm flex items-center">
<i class="fas fa-arrow-right mr-1"></i> Manage Events
</a>
</div>
</div>
</div>
<!-- System Health Card -->
<div class="bg-white rounded-lg shadow-md p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-irish-green">System</h3>
<span class="bg-{{ 'green' if system_health.database_status == 'online' else 'red' }}-100 text-{{ 'green' if system_health.database_status == 'online' else 'red' }}-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
{{ system_health.database_status }}
</span>
</div>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-gray-500">Uptime:</span>
<span class="font-medium text-sm">{{ system_health.uptime }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Errors:</span>
<span class="font-medium">{{ system_health.recent_errors|length }}</span>
</div>
<div class="mt-4">
<a href="#" class="text-irish-green hover:underline text-sm flex items-center">
<i class="fas fa-cog mr-1"></i> System Settings
</a>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- User Growth Chart -->
<div class="bg-white rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold text-irish-green mb-4">User Growth</h3>
<div class="h-64">
<canvas id="userGrowthChart"></canvas>
</div>
</div>
<!-- Team Distribution Chart -->
<div class="bg-white rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold text-irish-green mb-4">Team Sizes</h3>
<div class="h-64">
<canvas id="teamSizeChart"></canvas>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Upcoming Events Table -->
<div class="bg-white rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold text-irish-green mb-4">Upcoming Events</h3>
{% if event_stats.upcoming_events %}
<div class="overflow-x-auto">
<table class="min-w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Location</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{% for event in event_stats.upcoming_events %}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ event.name }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ event.event_date.strftime('%Y-%m-%d') }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ event.location }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4 text-gray-500">No upcoming events</div>
{% endif %}
<div class="mt-4 flex justify-end">
<a href="/admin/event/new" class="bg-irish-green hover:bg-opacity-90 text-white px-4 py-2 rounded-md text-sm">
<i class="fas fa-plus mr-2"></i>Add Event
</a>
</div>
</div>
<!-- Top Events Attendance -->
<div class="bg-white rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold text-irish-green mb-4">Top Events by Attendance</h3>
{% if event_stats.attendance_rates %}
<div class="overflow-x-auto">
<table class="min-w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Attendees</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{% for event in event_stats.attendance_rates %}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ event.event_name }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ event.attendee_count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4 text-gray-500">No attendance data available</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// User growth chart with real data from backend
const userGrowthCtx = document.getElementById('userGrowthChart').getContext('2d');
const userGrowthChart = new Chart(userGrowthCtx, {
type: 'line',
data: {
labels: {{ user_stats.month_labels | tojson }},
datasets: [{
label: 'New Users',
data: {{ user_stats.monthly_registrations | tojson }},
backgroundColor: 'rgba(0, 104, 55, 0.1)',
borderColor: 'rgba(0, 104, 55, 1)',
borderWidth: 2,
tension: 0.3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0
}
}
}
}
});
// Team size distribution chart
const teamData = [0, 0, 0, 0, 0, 0, 0]; // 0 to 6+ members
{% if team_stats.team_distribution %}
{% for item in team_stats.team_distribution %}
{% if item.member_count < 6 %}
teamData[{{ item.member_count }}] = {{ item.count }};
{% else %}
teamData[6] += {{ item.count }};
{% endif %}
{% endfor %}
{% endif %}
const teamSizeCtx = document.getElementById('teamSizeChart').getContext('2d');
const teamSizeChart = new Chart(teamSizeCtx, {
type: 'bar',
data: {
labels: ['0', '1', '2', '3', '4', '5', '6+'],
datasets: [{
label: 'Teams by Member Count',
data: teamData,
backgroundColor: 'rgba(255, 180, 0, 0.7)',
borderColor: 'rgba(255, 180, 0, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0
}
},
x: {
title: {
display: true,
text: 'Number of Members'
}
}
}
}
});
});
</script>
{% endblock %}
+9 -1
View File
@@ -2,7 +2,12 @@
{% block content %}
<div class="max-w-5xl mx-auto">
<div class="bg-white rounded-lg shadow-md p-6">
<h1 class="text-2xl font-bold text-irish-green mb-6">Admin Dashboard</h1>
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-irish-green">Admin Panel</h1>
<a href="/admin/dashboard" class="bg-golden-ale text-black-stout px-4 py-2 rounded-md hover:bg-opacity-90 transition flex items-center">
<i class="fas fa-chart-line mr-2"></i> Statistics Dashboard
</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for model_key, display_name in models %}
@@ -23,6 +28,9 @@
<div class="mt-10 pt-6 border-t border-gray-200">
<h2 class="text-xl font-bold text-irish-green mb-4">Quick Actions</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<a href="/admin/dashboard" class="bg-golden-ale text-black-stout px-4 py-3 rounded-md hover:bg-opacity-90 transition flex items-center justify-center">
<i class="fas fa-chart-line mr-2"></i> Statistics Dashboard
</a>
<a href="/qr" class="bg-white border border-irish-green text-irish-green hover:bg-irish-green hover:text-white px-4 py-3 rounded-md transition flex items-center">
<i class="fas fa-qrcode mr-2"></i> QR Code Dashboard
</a>
+190 -1
View File
@@ -5,10 +5,15 @@ Admin interface for managing database records.
from fastapi import APIRouter, Depends, Request, Form, HTTPException, Query
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from sqlalchemy import inspect
from sqlalchemy import inspect, func, desc, text
import json
from typing import Dict, Any, List, Type, Optional
import inspect as py_inspect
from datetime import datetime, timedelta
import time
import os
import psutil
from dateutil.relativedelta import relativedelta
from ..db import SessionLocal, Base
from ..models import (
@@ -42,6 +47,168 @@ def get_db():
finally:
db.close()
# Get user statistics for the dashboard
def get_user_statistics(db: Session) -> Dict[str, Any]:
"""Get user statistics for the admin dashboard."""
stats = {}
# Total users
stats["total_users"] = db.query(User).count()
# Active users (not disabled)
stats["active_users"] = db.query(User).filter(User.is_active == True).count()
# Verified users
stats["verified_users"] = db.query(User).filter(User.is_verified == True).count()
# Admin users
stats["admin_users"] = db.query(User).filter(User.is_admin == True).count()
# New registrations in the last 30 days
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
stats["new_registrations_30d"] = db.query(User).filter(
User.created_at >= thirty_days_ago
).count()
# Monthly user registration data for the chart (last 6 months)
monthly_data = []
month_labels = []
# Get the current month and year
current_date = datetime.now()
# Loop through the last 6 months
for i in range(5, -1, -1):
# Calculate month and year for this data point
month_date = current_date - relativedelta(months=i)
start_of_month = datetime(month_date.year, month_date.month, 1)
# For the current month, only count until today
if i == 0:
end_of_month = current_date
else:
# Calculate the end of the month
if month_date.month == 12:
end_of_month = datetime(month_date.year + 1, 1, 1) - timedelta(days=1)
else:
end_of_month = datetime(month_date.year, month_date.month + 1, 1) - timedelta(days=1)
# Format month as abbreviated month name
month_name = month_date.strftime('%b')
month_labels.append(month_name)
# Count users registered in this month
monthly_count = db.query(User).filter(
User.created_at >= start_of_month,
User.created_at <= end_of_month
).count()
monthly_data.append(monthly_count)
# Add the data to the stats
stats["monthly_registrations"] = monthly_data
stats["month_labels"] = month_labels
return stats
# Get team statistics for the dashboard
def get_team_statistics(db: Session) -> Dict[str, Any]:
"""Get team statistics for the admin dashboard."""
stats = {}
# Total teams
stats["total_teams"] = db.query(Team).count()
# Active teams
stats["active_teams"] = db.query(Team).filter(Team.is_active == True).count()
# Public teams
stats["public_teams"] = db.query(Team).filter(Team.is_public == True).count()
# Team size distribution - teams grouped by member count
# Modified query to correctly count team members and group by team
team_sizes = db.query(
TeamMembership.team_id,
func.count(TeamMembership.user_id).label('member_count')
).group_by(TeamMembership.team_id).subquery()
# Now we can query the distribution from the subquery
team_distribution = db.query(
team_sizes.c.member_count,
func.count().label('count')
).group_by(team_sizes.c.member_count).all()
# Convert to a list of dictionaries for easier handling in the template
team_dist_list = [{"member_count": size[0], "count": size[1]} for size in team_distribution]
stats["team_distribution"] = team_dist_list
return stats
# Get event statistics for the dashboard
def get_event_statistics(db: Session) -> Dict[str, Any]:
"""Get event statistics for the admin dashboard."""
stats = {}
# Total events
stats["total_events"] = db.query(Event).count()
# Past events
today = datetime.now().date()
stats["past_events"] = db.query(Event).filter(Event.event_date < today).count()
# Upcoming events
stats["upcoming_events_count"] = db.query(Event).filter(Event.event_date >= today).count()
# List of upcoming events
upcoming_events = db.query(Event).filter(
Event.event_date >= today
).order_by(Event.event_date).limit(5).all()
stats["upcoming_events"] = upcoming_events
# Events with highest attendance
attendance_rates = db.query(
Event.id.label('event_id'),
Event.name.label('event_name'),
func.count(EventAttendee.id).label('attendee_count')
).join(EventAttendee).group_by(Event.id, Event.name).order_by(
desc('attendee_count')
).limit(5).all()
stats["attendance_rates"] = attendance_rates
return stats
# Get system health information
def get_system_health(db: Session) -> Dict[str, Any]:
"""Get system health information for the admin dashboard."""
health_info = {}
# Database status
try:
result = db.execute(text("SELECT 'online' as status")).fetchall()
health_info["database_status"] = "online" if result else "offline"
except Exception as e:
health_info["database_status"] = "error"
health_info["database_error"] = str(e)
# System uptime
try:
uptime_seconds = time.time() - psutil.boot_time()
days, remainder = divmod(uptime_seconds, 86400)
hours, remainder = divmod(remainder, 3600)
minutes, seconds = divmod(remainder, 60)
health_info["uptime"] = f"{int(days)} days, {int(hours)} hours, {int(minutes)} minutes"
except Exception:
health_info["uptime"] = "Unknown"
# Recent errors (would be fetched from a logging system in production)
# For this example, we'll return a placeholder
health_info["recent_errors"] = []
return health_info
def get_model_info(model_class: Type[Base]) -> Dict[str, Dict[str, Any]]:
"""Get column information for a model."""
mapper = inspect(model_class)
@@ -78,6 +245,28 @@ def get_relationships(model_class: Type[Base]) -> Dict[str, str]:
@require_admin(redirect_url="/auth/login?next=/admin/")
async def admin_home(request: Request, db: Session = Depends(get_db)):
"""Admin dashboard home."""
# Get statistics
user_stats = get_user_statistics(db)
team_stats = get_team_statistics(db)
event_stats = get_event_statistics(db)
system_health = get_system_health(db)
return templates.TemplateResponse(
"admin/dashboard.html",
{
"request": request,
"user": request.user,
"user_stats": user_stats,
"team_stats": team_stats,
"event_stats": event_stats,
"system_health": system_health
}
)
@router.get("/models", response_class=HTMLResponse)
@require_admin(redirect_url="/auth/login?next=/admin/models")
async def admin_models(request: Request, db: Session = Depends(get_db)):
"""Admin models overview."""
# Since we're using Starlette's authentication, the user is now available in request.user
model_list = [(key, name) for key, (_, name) in MODELS.items()]
return templates.TemplateResponse(