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:
+475
-1
@@ -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'))
|
||||
|
||||
Reference in New Issue
Block a user