Add admin maintenance console

Admin-only page reachable from a new card on the admin panel (replacing the
Quick Actions card) that centralises housekeeping operations:

* Database integrity: PRAGMA integrity_check / quick_check / foreign_key_check,
  plus VACUUM + ANALYZE to reclaim free space.
* Leftover records: detects and removes orphaned rows left behind by deleted
  players/content (player_feedback, player_edit, playlist_content, dangling
  player.playlist_id) and orphaned edited-media folders on disk.
* Player authentication tokens: manual reset (single or all), a configurable
  automatic reset interval, and cancellation of a pending reset.

Resetting a token rotates player.auth_code and sets auth_reset_required, so the
next heartbeat (POST /api/player-feedback) answers 401 + auth_required and the
client restarts its authentication flow. A successful authentication clears the
flag and stamps auth_token_issued_at, from which the interval is measured.

New: PlayerAuthConfig model, add_player_auth_reset migration (registered in
docker-entrypoint.sh), admin/maintenance.html.
This commit is contained in:
2026-10-14 22:13:18 +03:00
parent 2539f82868
commit 88827be7be
10 changed files with 1124 additions and 19 deletions
+475 -1
View File
@@ -4,11 +4,14 @@ from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
from functools import wraps
import os
import shutil
from datetime import datetime
from typing import Optional
from sqlalchemy import text
from app.extensions import db, bcrypt
from app.models import User, Player, Content, ServerLog, Playlist, HTTPSConfig
from app.models import (User, Player, Content, ServerLog, Playlist, HTTPSConfig,
PlayerAuthConfig)
from app.utils.logger import log_action
from app.utils.caddy_manager import CaddyConfigGenerator
@@ -1178,3 +1181,474 @@ def build_player_status():
"""JSON progress for the running/last player build (polled by the page)."""
from app.utils.player_build import get_build_state
return jsonify(get_build_state())
# ===========================================================================
# Maintenance console (admin only)
#
# Centralises the "housekeeping" operations that were previously only possible
# by hand on the server:
# * SQLite integrity / foreign-key verification (+ VACUUM/ANALYZE)
# * detection and removal of orphaned rows left behind by deleted players
# and content
# * cleanup of edited-media folders whose content row is gone
# * player authentication-token reset (manual, bulk, or on an interval)
# ===========================================================================
# Orphaned-row checks.
#
# Deleting a player or a content item does not always cascade everywhere:
# the ``player_feedback`` foreign key has no ON DELETE action, and rows created
# before the constraints existed are simply unenforced. Each entry below finds
# (``count``) or removes (``clean``) rows pointing at a parent that is gone.
# ``action`` only changes the label shown in the UI.
_ORPHAN_CHECKS = [
{
'key': 'feedback_missing_player',
'label': 'Player heartbeat feedback for deleted players',
'table': 'player_feedback',
'action': 'delete',
'count': ("SELECT COUNT(*) FROM player_feedback f "
"LEFT JOIN player p ON f.player_id = p.id WHERE p.id IS NULL"),
'clean': ("DELETE FROM player_feedback "
"WHERE player_id NOT IN (SELECT id FROM player)"),
},
{
'key': 'feedback_missing_content',
'label': 'Feedback referencing deleted content (link cleared)',
'table': 'player_feedback',
'action': 'unlink',
'count': ("SELECT COUNT(*) FROM player_feedback f "
"LEFT JOIN content c ON f.current_content_id = c.id "
"WHERE f.current_content_id IS NOT NULL AND c.id IS NULL"),
'clean': ("UPDATE player_feedback SET current_content_id = NULL "
"WHERE current_content_id IS NOT NULL "
"AND current_content_id NOT IN (SELECT id FROM content)"),
},
{
'key': 'edits_missing_player',
'label': 'Player edits recorded for deleted players',
'table': 'player_edit',
'action': 'delete',
'count': ("SELECT COUNT(*) FROM player_edit e "
"LEFT JOIN player p ON e.player_id = p.id WHERE p.id IS NULL"),
'clean': ("DELETE FROM player_edit "
"WHERE player_id NOT IN (SELECT id FROM player)"),
},
{
'key': 'edits_missing_content',
'label': 'Player edits recorded for deleted content',
'table': 'player_edit',
'action': 'delete',
'count': ("SELECT COUNT(*) FROM player_edit e "
"LEFT JOIN content c ON e.content_id = c.id WHERE c.id IS NULL"),
'clean': ("DELETE FROM player_edit "
"WHERE content_id NOT IN (SELECT id FROM content)"),
},
{
'key': 'playlist_links_missing_content',
'label': 'Playlist entries pointing at deleted content',
'table': 'playlist_content',
'action': 'delete',
'count': ("SELECT COUNT(*) FROM playlist_content pc "
"LEFT JOIN content c ON pc.content_id = c.id WHERE c.id IS NULL"),
'clean': ("DELETE FROM playlist_content "
"WHERE content_id NOT IN (SELECT id FROM content)"),
},
{
'key': 'playlist_links_missing_playlist',
'label': 'Playlist entries pointing at deleted playlists',
'table': 'playlist_content',
'action': 'delete',
'count': ("SELECT COUNT(*) FROM playlist_content pc "
"LEFT JOIN playlist pl ON pc.playlist_id = pl.id WHERE pl.id IS NULL"),
'clean': ("DELETE FROM playlist_content "
"WHERE playlist_id NOT IN (SELECT id FROM playlist)"),
},
{
'key': 'player_missing_playlist',
'label': 'Players assigned to deleted playlists (assignment cleared)',
'table': 'player',
'action': 'unlink',
'count': ("SELECT COUNT(*) FROM player p LEFT JOIN playlist pl "
"ON p.playlist_id = pl.id "
"WHERE p.playlist_id IS NOT NULL AND pl.id IS NULL"),
'clean': ("UPDATE player SET playlist_id = NULL "
"WHERE playlist_id IS NOT NULL "
"AND playlist_id NOT IN (SELECT id FROM playlist)"),
},
]
def _edited_media_root() -> str:
"""Absolute path to the per-content edited-media archive folder."""
return os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media')
def _dir_size_bytes(path: str) -> int:
"""Total size in bytes of all files under *path*."""
total = 0
for dirpath, _dirnames, filenames in os.walk(path):
for filename in filenames:
filepath = os.path.join(dirpath, filename)
if os.path.exists(filepath):
total += os.path.getsize(filepath)
return total
def _run_orphan_scan():
"""Run every orphan check and return ``(results, total_row_count)``.
Each check is isolated: a missing table or column in an older database is
reported as an error on that single check rather than aborting the scan.
"""
results = []
total = 0
for check in _ORPHAN_CHECKS:
entry = {
'key': check['key'],
'label': check['label'],
'table': check['table'],
'action': check['action'],
'count': 0,
'error': None,
}
try:
entry['count'] = int(
db.session.execute(text(check['count'])).scalar() or 0
)
total += entry['count']
except Exception as exc: # pragma: no cover - defensive
db.session.rollback()
entry['error'] = str(exc)
results.append(entry)
return results, total
def _scan_orphaned_edited_media() -> dict:
"""Find ``edited_media/<content_id>`` folders whose content row is gone."""
entry = {'count': 0, 'size_mb': 0.0, 'ids': [], 'error': None}
root = _edited_media_root()
if not os.path.isdir(root):
return entry
try:
valid_ids = {
row[0] for row in db.session.execute(text('SELECT id FROM content'))
}
size_total = 0
for name in sorted(os.listdir(root)):
path = os.path.join(root, name)
if not os.path.isdir(path) or not name.isdigit():
continue
if int(name) in valid_ids:
continue
size_total += _dir_size_bytes(path)
entry['ids'].append(int(name))
entry['count'] += 1
entry['size_mb'] = round(size_total / (1024 * 1024), 2)
except Exception as exc: # pragma: no cover - defensive
entry['error'] = str(exc)
return entry
@admin_bp.route('/maintenance')
@login_required
@admin_required
def maintenance():
"""Admin-only maintenance console."""
auth_config = PlayerAuthConfig.get_config()
players = Player.query.order_by(Player.name).all()
return render_template(
'admin/maintenance.html',
players=players,
auth_config=auth_config,
interval_days=PlayerAuthConfig.get_interval_days(),
)
@admin_bp.route('/maintenance/db-integrity', methods=['POST'])
@login_required
@admin_required
def maintenance_db_integrity():
"""Run SQLite integrity checks and return the results as JSON."""
try:
integrity = [
row[0] for row in
db.session.execute(text('PRAGMA integrity_check')).fetchall()
]
quick = [
row[0] for row in
db.session.execute(text('PRAGMA quick_check')).fetchall()
]
violations = [
{
'table': row[0],
'rowid': row[1],
'parent': row[2],
'constraint': row[3],
}
for row in db.session.execute(text('PRAGMA foreign_key_check')).fetchall()
]
page_count = db.session.execute(text('PRAGMA page_count')).scalar() or 0
page_size = db.session.execute(text('PRAGMA page_size')).scalar() or 0
freelist = db.session.execute(text('PRAGMA freelist_count')).scalar() or 0
db_path = db.engine.url.database
db_size_mb = None
if db_path and os.path.exists(db_path):
db_size_mb = round(os.path.getsize(db_path) / (1024 * 1024), 2)
ok = integrity == ['ok'] and not violations
log_action('info',
f'Database integrity check by {current_user.username}: '
f'{"OK" if ok else "ISSUES FOUND"} '
f'({len(violations)} foreign-key violation(s))')
return jsonify({
'success': True,
'ok': ok,
'integrity': integrity,
'quick_check': quick,
'foreign_key_violations': violations,
'fk_violation_count': len(violations),
'page_count': page_count,
'page_size': page_size,
'freelist_count': freelist,
'freelist_mb': round(freelist * page_size / (1024 * 1024), 2),
'db_size_mb': db_size_mb,
})
except Exception as exc:
log_action('error', f'Database integrity check failed: {exc}')
return jsonify({'success': False, 'error': str(exc)}), 500
@admin_bp.route('/maintenance/optimize-db', methods=['POST'])
@login_required
@admin_required
def maintenance_optimize_db():
"""Compact the SQLite database (VACUUM) and refresh statistics (ANALYZE).
VACUUM cannot run inside an open transaction, so it is issued on an
AUTOCOMMIT connection.
"""
try:
db.session.commit()
with db.engine.connect().execution_options(
isolation_level='AUTOCOMMIT') as conn:
conn.execute(text('VACUUM'))
conn.execute(text('ANALYZE'))
db_path = db.engine.url.database
db_size_mb = None
if db_path and os.path.exists(db_path):
db_size_mb = round(os.path.getsize(db_path) / (1024 * 1024), 2)
log_action('info',
f'Database optimized (VACUUM + ANALYZE) by '
f'{current_user.username}')
return jsonify({'success': True, 'db_size_mb': db_size_mb})
except Exception as exc:
log_action('error', f'Database optimization failed: {exc}')
return jsonify({'success': False, 'error': str(exc)}), 500
@admin_bp.route('/maintenance/scan-orphans', methods=['POST'])
@login_required
@admin_required
def maintenance_scan_orphans():
"""Scan for orphaned database rows and edited-media folders."""
checks, total = _run_orphan_scan()
edited_media = _scan_orphaned_edited_media()
if total or edited_media['count']:
log_action('info',
f'Orphan scan by {current_user.username}: {total} orphaned '
f'row(s), {edited_media["count"]} orphaned media folder(s)')
return jsonify({
'success': True,
'checks': checks,
'total': total,
'edited_media': edited_media,
})
@admin_bp.route('/maintenance/clean-orphans', methods=['POST'])
@login_required
@admin_required
def maintenance_clean_orphans():
"""Delete orphaned database rows and orphaned edited-media folders."""
cleaned = []
errors = []
total_rows = 0
for check in _ORPHAN_CHECKS:
try:
result = db.session.execute(text(check['clean']))
changed = result.rowcount or 0
if changed:
total_rows += changed
cleaned.append({
'label': check['label'],
'action': check['action'],
'rows': changed,
})
except Exception as exc: # pragma: no cover - defensive
db.session.rollback()
errors.append({'label': check['label'], 'error': str(exc)})
try:
db.session.commit()
except Exception as exc: # pragma: no cover - defensive
db.session.rollback()
errors.append({'label': 'commit', 'error': str(exc)})
# Orphaned edited-media folders (files on disk whose content row is gone).
removed_dirs = 0
freed_bytes = 0
media = _scan_orphaned_edited_media()
if media['error']:
errors.append({'label': 'edited media scan', 'error': media['error']})
elif media['ids']:
root = _edited_media_root()
for content_id in media['ids']:
path = os.path.join(root, str(content_id))
try:
freed_bytes += _dir_size_bytes(path)
shutil.rmtree(path)
removed_dirs += 1
except Exception as exc: # pragma: no cover - defensive
errors.append({
'label': f'edited_media/{content_id}',
'error': str(exc),
})
log_action('info',
f'Orphan cleanup by {current_user.username}: removed '
f'{total_rows} row(s) and {removed_dirs} media folder(s)')
return jsonify({
'success': not errors,
'total_rows': total_rows,
'cleaned': cleaned,
'removed_dirs': removed_dirs,
'freed_mb': round(freed_bytes / (1024 * 1024), 2),
'errors': errors,
})
# ---------------------------------------------------------------------------
# Player authentication tokens
# ---------------------------------------------------------------------------
@admin_bp.route('/maintenance/set-interval', methods=['POST'])
@login_required
@admin_required
def maintenance_set_interval():
"""Save the automatic authentication-token reset interval."""
raw = (request.form.get('interval_days') or '').strip()
try:
days = int(raw)
except (TypeError, ValueError):
flash('Reset interval must be a whole number of days.', 'warning')
return redirect(url_for('admin.maintenance'))
if days < 0:
flash('Reset interval cannot be negative.', 'warning')
return redirect(url_for('admin.maintenance'))
PlayerAuthConfig.create_or_update(days, updated_by=current_user.username)
log_action('info',
f'Player auth token reset interval set to {days} day(s) by '
f'{current_user.username}')
if days == 0:
flash('Automatic token reset disabled.', 'success')
else:
flash(f'Players will be asked to re-authenticate every {days} day(s).',
'success')
return redirect(url_for('admin.maintenance'))
@admin_bp.route('/maintenance/reset-token/<int:player_id>', methods=['POST'])
@login_required
@admin_required
def maintenance_reset_token(player_id: int):
"""Reset a single player's authentication token."""
try:
player = Player.query.get_or_404(player_id)
player.rotate_auth_token(force_reauth=True)
db.session.commit()
log_action('info',
f'Auth token reset for player "{player.name}" '
f'(ID: {player.id}) by {current_user.username}')
flash(f'Token reset for "{player.name}". It will re-authenticate on its '
f'next heartbeat.', 'success')
except Exception as exc:
db.session.rollback()
log_action('error', f'Error resetting auth token: {exc}')
flash('Error resetting the authentication token.', 'danger')
return redirect(url_for('admin.maintenance'))
@admin_bp.route('/maintenance/reset-all-tokens', methods=['POST'])
@login_required
@admin_required
def maintenance_reset_all_tokens():
"""Reset the authentication token of every player."""
try:
players = Player.query.all()
for player in players:
player.rotate_auth_token(force_reauth=True)
db.session.commit()
log_action('info',
f'Auth tokens reset for all {len(players)} player(s) by '
f'{current_user.username}')
flash(f'Token reset for {len(players)} player(s). They will '
f're-authenticate on their next heartbeat.', 'success')
except Exception as exc:
db.session.rollback()
log_action('error', f'Error resetting all auth tokens: {exc}')
flash('Error resetting the authentication tokens.', 'danger')
return redirect(url_for('admin.maintenance'))
@admin_bp.route('/maintenance/cancel-reset/<int:player_id>', methods=['POST'])
@login_required
@admin_required
def maintenance_cancel_reset(player_id: int):
"""Cancel a pending re-authentication request for a player."""
try:
player = Player.query.get_or_404(player_id)
player.auth_reset_required = False
db.session.commit()
log_action('info',
f'Pending re-authentication cancelled for player '
f'"{player.name}" by {current_user.username}')
flash(f'Pending re-authentication cancelled for "{player.name}".',
'success')
except Exception as exc:
db.session.rollback()
log_action('error', f'Error cancelling pending re-auth: {exc}')
flash('Error cancelling the pending re-authentication.', 'danger')
return redirect(url_for('admin.maintenance'))