b4c3f65636
Moves the Recent Activity card into the main column, directly beneath
Playlist Overview, so the two cards that describe "what the server and
its players are doing" sit together. System Status stays full-width below
the grid.
While moving it, the card was showing almost nothing useful: player
feedback ("Feedback received from ...") is a ~15s heartbeat that makes up
84% of all log rows (423/506) and 18 of the newest 20, so the list was a
wall of repeated one-line status reports that pushed real events off the
screen.
The card is now tabbed:
- "Activity" (default) - uploads, logins, HTTPS changes, deployments...
- "Player heartbeats" - the raw status stream, on demand
get_recent_logs() gains exclude_prefix / include_prefix so the split
happens in SQL. Filtering a single wide window in Python instead gave only
7 useful entries, because the number returned depends on heartbeat volume
and would fall further as more players are added. Both queries now return
25. Prefixes are passed with autoescape=True so a literal '%' or '_' in a
prefix matches itself rather than acting as a LIKE wildcard.
UI: the list is capped at 320px and scrolls, so a long log cannot make
the card dominate the page. Long messages wrap instead of pushing the
timestamp off-screen (previously float:right), and the debug level now
has its own colour instead of rendering as "info" green.
Verified: 25/25 entries per tab, no cross-contamination, no horizontal
overflow at 500-1400px, tabs wrap below the title under ~600px, and dark
mode uses the theme variables.
125 lines
4.5 KiB
Python
125 lines
4.5 KiB
Python
"""
|
|
Main Blueprint - Dashboard and Home Routes
|
|
"""
|
|
from flask import Blueprint, render_template, redirect, url_for
|
|
from flask_login import login_required, current_user
|
|
from app.extensions import db, cache
|
|
from app.models.player import Player
|
|
from app.models.playlist import Playlist
|
|
from app.models.content import Content
|
|
from app.utils.logger import get_recent_logs
|
|
import os
|
|
|
|
main_bp = Blueprint('main', __name__)
|
|
|
|
|
|
@main_bp.route('/')
|
|
@login_required
|
|
@cache.cached(timeout=60, unless=lambda: current_user.role != 'viewer')
|
|
def dashboard():
|
|
"""Main dashboard page"""
|
|
# Get statistics
|
|
total_players = Player.query.count()
|
|
total_playlists = Playlist.query.count()
|
|
total_content = Content.query.count()
|
|
|
|
# Calculate storage usage
|
|
upload_folder = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'uploads')
|
|
storage_mb = 0
|
|
if os.path.exists(upload_folder):
|
|
for filename in os.listdir(upload_folder):
|
|
filepath = os.path.join(upload_folder, filename)
|
|
if os.path.isfile(filepath):
|
|
storage_mb += os.path.getsize(filepath)
|
|
storage_mb = round(storage_mb / (1024 * 1024), 2) # Convert to MB
|
|
|
|
# Recent Activity.
|
|
#
|
|
# Player feedback ("Feedback received from ...") is a ~15s heartbeat: it is
|
|
# ~84% of all log rows, and 18 of the newest 20. Showing it unfiltered
|
|
# buries the events an operator actually cares about (uploads, logins,
|
|
# HTTPS changes, deployments), so the two kinds are queried separately and
|
|
# the card defaults to real activity, with heartbeats available on demand.
|
|
#
|
|
# The split is done in SQL rather than by filtering one wide window in
|
|
# Python: with a fleet of players the heartbeat rate is high enough that a
|
|
# fixed window would return mostly noise and only a handful of real events.
|
|
HEARTBEAT_PREFIX = 'Feedback received from'
|
|
activity_logs = get_recent_logs(25, exclude_prefix=HEARTBEAT_PREFIX)
|
|
heartbeat_logs = get_recent_logs(25, include_prefix=HEARTBEAT_PREFIX)
|
|
|
|
# Per-playlist overview for the dashboard: which playlist holds what, and
|
|
# which players consume it. This answers "where should I add media / which
|
|
# playlist needs editing?" without opening each playlist.
|
|
#
|
|
# Players are fetched in a single query and grouped in Python rather than
|
|
# relying on Playlist.players per row, to avoid one query per playlist.
|
|
playlists = Playlist.query.order_by(Playlist.name).all()
|
|
players_by_playlist = {}
|
|
for player in Player.query.order_by(Player.name).all():
|
|
if player.playlist_id is not None:
|
|
players_by_playlist.setdefault(player.playlist_id, []).append(player)
|
|
|
|
playlist_overview = []
|
|
for playlist in playlists:
|
|
assigned = players_by_playlist.get(playlist.id, [])
|
|
playlist_overview.append({
|
|
'playlist': playlist,
|
|
'content_count': playlist.contents.count(),
|
|
'total_duration': playlist.total_duration,
|
|
'players': assigned,
|
|
'player_count': len(assigned),
|
|
'online_count': sum(1 for p in assigned if p.is_online),
|
|
})
|
|
|
|
# Players with no playlist produce no content on screen, so surface them.
|
|
unassigned_players = Player.query.filter(Player.playlist_id.is_(None))\
|
|
.order_by(Player.name).all()
|
|
|
|
return render_template(
|
|
'dashboard.html',
|
|
total_players=total_players,
|
|
total_playlists=total_playlists,
|
|
total_content=total_content,
|
|
storage_mb=storage_mb,
|
|
recent_logs=activity_logs,
|
|
heartbeat_logs=heartbeat_logs,
|
|
playlist_overview=playlist_overview,
|
|
unassigned_players=unassigned_players
|
|
)
|
|
|
|
|
|
@main_bp.route('/health')
|
|
def health():
|
|
"""Health check endpoint"""
|
|
from flask import jsonify
|
|
import os
|
|
|
|
try:
|
|
# Check database
|
|
db.session.execute(db.text('SELECT 1'))
|
|
|
|
# Check disk space
|
|
upload_folder = os.path.join(
|
|
main_bp.root_path or '.',
|
|
'static/uploads'
|
|
)
|
|
|
|
if os.path.exists(upload_folder):
|
|
stat = os.statvfs(upload_folder)
|
|
free_space_gb = (stat.f_bavail * stat.f_frsize) / (1024**3)
|
|
else:
|
|
free_space_gb = 0
|
|
|
|
return jsonify({
|
|
'status': 'healthy',
|
|
'database': 'ok',
|
|
'disk_space_gb': round(free_space_gb, 2)
|
|
}), 200
|
|
|
|
except Exception as e:
|
|
return jsonify({
|
|
'status': 'unhealthy',
|
|
'error': str(e)
|
|
}), 500
|