Add admin Settings page with DB health check, stats, and system reset
This commit is contained in:
parent
2b960bd3d5
commit
51d1b4a4d9
3 changed files with 333 additions and 5 deletions
117
app.py
117
app.py
|
|
@ -19,6 +19,7 @@ Admin routes:
|
|||
- /admin/login -> password login
|
||||
- /admin/logout -> clears session
|
||||
- /admin -> dashboard queue
|
||||
- /admin/settings -> health check, DB stats, disk usage, system reset
|
||||
- /admin/request/<id> -> detail/edit page for a single request
|
||||
- /admin/request/<id>/delete -> deletes one request and its files
|
||||
- /admin/reset -> deletes ALL requests and ALL files
|
||||
|
|
@ -38,7 +39,8 @@ from werkzeug.utils import secure_filename
|
|||
|
||||
# Project imports
|
||||
from config import Config
|
||||
from models import init_db, close_db, create_request, get_request_by_id, get_request_by_token, list_requests, update_request, now_utc, delete_request, reset_all_requests
|
||||
from models import init_db, close_db, create_request, get_request_by_id, get_request_by_token, list_requests, update_request, now_utc, delete_request, reset_all_requests, get_db
|
||||
from models import SCHEMA
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App setup
|
||||
|
|
@ -425,6 +427,119 @@ def admin_delete_request(rid):
|
|||
return redirect(url_for('admin_dashboard'))
|
||||
|
||||
|
||||
@app.route('/admin/settings', methods=['GET', 'POST'])
|
||||
def admin_settings():
|
||||
"""
|
||||
Settings / maintenance page for operators.
|
||||
GET -> show database health, statistics, disk usage, and reset button.
|
||||
POST -> either run a health check/fix or reset the system.
|
||||
"""
|
||||
redir = require_admin()
|
||||
if redir:
|
||||
return redir
|
||||
|
||||
db_path = Path(current_app.config['DATABASE'])
|
||||
upload_root = Path(current_app.config['UPLOAD_FOLDER'])
|
||||
|
||||
# Compute database stats.
|
||||
db_size = db_path.stat().st_size if db_path.exists() else 0
|
||||
all_requests = list_requests()
|
||||
total_records = len(all_requests)
|
||||
status_counts = {}
|
||||
for req in all_requests:
|
||||
status_counts[req['status']] = status_counts.get(req['status'], 0) + 1
|
||||
|
||||
# Compute upload folder stats.
|
||||
total_upload_size = 0
|
||||
upload_file_count = 0
|
||||
request_dir_count = 0
|
||||
if upload_root.exists():
|
||||
for entry in upload_root.iterdir():
|
||||
if entry.is_dir():
|
||||
request_dir_count += 1
|
||||
for f in entry.iterdir():
|
||||
if f.is_file():
|
||||
total_upload_size += f.stat().st_size
|
||||
upload_file_count += 1
|
||||
elif entry.is_file():
|
||||
total_upload_size += entry.stat().st_size
|
||||
upload_file_count += 1
|
||||
|
||||
def format_bytes(n):
|
||||
for unit in ['B', 'KB', 'MB', 'GB']:
|
||||
if n < 1024:
|
||||
return f"{n:.2f} {unit}"
|
||||
n /= 1024
|
||||
return f"{n:.2f} TB"
|
||||
|
||||
# Health check: verify expected columns exist.
|
||||
expected_cols = {
|
||||
'id', 'created_at', 'name', 'email', 'hobbies', 'notable_facts',
|
||||
'style_genre', 'extra_requests', 'status', 'suno_title', 'suno_style',
|
||||
'suno_lyrics', 'song_a_path', 'song_b_path', 'customer_approved',
|
||||
'approval_notified_at', 'preview_sent_at', 'delivery_sent_at',
|
||||
'square_payment_ref', 'admin_alert_email', 'player_token', 'revision_note'
|
||||
}
|
||||
health = {'ok': True, 'missing_columns': [], 'message': 'Database schema looks good.'}
|
||||
try:
|
||||
db = get_db()
|
||||
cur = db.execute('PRAGMA table_info(requests)')
|
||||
existing_cols = {row['name'] for row in cur.fetchall()}
|
||||
missing = sorted(expected_cols - existing_cols)
|
||||
if missing:
|
||||
health = {'ok': False, 'missing_columns': missing, 'message': f'Missing columns: {", ".join(missing)}'}
|
||||
except Exception as e:
|
||||
health = {'ok': False, 'missing_columns': [], 'message': f'Could not inspect table: {e}'}
|
||||
|
||||
if request.method == 'POST':
|
||||
action = request.form.get('action')
|
||||
|
||||
if action == 'fix_db':
|
||||
# Attempt to add missing columns via ALTER TABLE.
|
||||
if not health['ok'] and health['missing_columns']:
|
||||
try:
|
||||
db = get_db()
|
||||
for col in health['missing_columns']:
|
||||
# Default to TEXT columns; adequate for current schema.
|
||||
db.execute(f'ALTER TABLE requests ADD COLUMN {col} TEXT')
|
||||
db.commit()
|
||||
flash(f'Added missing columns: {", ".join(health["missing_columns"])}. Please refresh the page.', 'success')
|
||||
except Exception as e:
|
||||
flash(f'Failed to fix database: {e}', 'error')
|
||||
else:
|
||||
flash('No columns need fixing.', 'success')
|
||||
return redirect(url_for('admin_settings'))
|
||||
|
||||
elif action == 'reset_system':
|
||||
# Same nuclear reset logic as the old /admin/reset endpoint.
|
||||
if upload_root.exists():
|
||||
for entry in upload_root.iterdir():
|
||||
try:
|
||||
if entry.is_file():
|
||||
entry.unlink()
|
||||
elif entry.is_dir():
|
||||
shutil.rmtree(entry)
|
||||
except OSError:
|
||||
pass
|
||||
reset_all_requests()
|
||||
flash('System reset complete. All orders and files have been cleared.', 'success')
|
||||
return redirect(url_for('admin_settings'))
|
||||
|
||||
return render_template(
|
||||
'admin/settings.html',
|
||||
health=health,
|
||||
db_size=format_bytes(db_size),
|
||||
total_records=total_records,
|
||||
status_counts=status_counts,
|
||||
statuses=STATUS_LABELS,
|
||||
upload_file_count=upload_file_count,
|
||||
upload_dir_count=request_dir_count,
|
||||
upload_size=format_bytes(total_upload_size),
|
||||
db_path=str(db_path),
|
||||
upload_path=str(upload_root)
|
||||
)
|
||||
|
||||
|
||||
@app.route('/admin/reset', methods=['POST'])
|
||||
def admin_reset_system():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -93,6 +93,13 @@
|
|||
font-size:.9rem;
|
||||
}
|
||||
.topbar button.reset-sm:hover{background:#b91c1c;}
|
||||
.topbar a{
|
||||
color:#93c5fd;
|
||||
text-decoration:none;
|
||||
}
|
||||
.topbar a.settings{
|
||||
color:#10b981;
|
||||
}
|
||||
|
||||
/* Hidden legacy reset box (kept CSS class for compatibility, not displayed) */
|
||||
.reset-box{display:none;}
|
||||
|
|
@ -100,11 +107,9 @@
|
|||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Topbar: Reset System button and Log out link -->
|
||||
<!-- Topbar: Settings link and Log out link -->
|
||||
<div class="topbar">
|
||||
<form method="POST" action="{{ url_for('admin_reset_system') }}" onsubmit="return confirm('ARE YOU SURE? This will delete ALL requests and ALL uploaded files. This cannot be undone.')">
|
||||
<button type="submit" class="reset-sm">Reset System</button>
|
||||
</form>
|
||||
<a href="{{ url_for('admin_settings') }}" class="settings">Settings</a>
|
||||
<a href="{{ url_for('admin_logout') }}" class="logout">Log out</a>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
208
templates/admin/settings.html
Normal file
208
templates/admin/settings.html
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin Settings</title>
|
||||
<style>
|
||||
/*
|
||||
Admin settings / maintenance page.
|
||||
Shows database health, statistics, upload disk usage,
|
||||
and houses the dangerous system reset button.
|
||||
*/
|
||||
body{
|
||||
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||||
background:#111827;
|
||||
color:#f3f4f6;
|
||||
margin:0;
|
||||
padding:1rem;
|
||||
line-height:1.5;
|
||||
}
|
||||
.container{max-width:900px;margin:0 auto;}
|
||||
h1,h2{color:#60a5fa;}
|
||||
a{color:#93c5fd;text-decoration:none;}
|
||||
.topbar{
|
||||
float:right;
|
||||
display:flex;
|
||||
gap:.75rem;
|
||||
align-items:center;
|
||||
}
|
||||
.topbar a{
|
||||
color:#f87171;
|
||||
}
|
||||
.section{
|
||||
background:#1f2937;
|
||||
padding:1rem;
|
||||
border-radius:.5rem;
|
||||
margin-bottom:1rem;
|
||||
}
|
||||
.flash{
|
||||
padding:.8rem;
|
||||
background:#064e3b;
|
||||
border-radius:.5rem;
|
||||
margin-bottom:1rem;
|
||||
}
|
||||
.flash.error{background:#450a0a;}
|
||||
.stat-grid{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(auto-fit,minmax(220px,1fr));
|
||||
gap:1rem;
|
||||
}
|
||||
.stat-card{
|
||||
background:#111827;
|
||||
padding:.8rem;
|
||||
border-radius:.5rem;
|
||||
}
|
||||
.stat-card .value{
|
||||
font-size:1.5rem;
|
||||
font-weight:700;
|
||||
color:#60a5fa;
|
||||
}
|
||||
.stat-card .label{
|
||||
font-size:.85rem;
|
||||
color:#9ca3af;
|
||||
}
|
||||
.health-ok{
|
||||
color:#10b981;
|
||||
font-weight:700;
|
||||
}
|
||||
.health-bad{
|
||||
color:#f87171;
|
||||
font-weight:700;
|
||||
}
|
||||
.status-list{
|
||||
margin:0;
|
||||
padding-left:1.2rem;
|
||||
}
|
||||
.status-list li{
|
||||
margin-bottom:.3rem;
|
||||
}
|
||||
button{
|
||||
padding:.7rem 1rem;
|
||||
border:none;
|
||||
border-radius:.5rem;
|
||||
background:#3b82f6;
|
||||
color:#fff;
|
||||
font-weight:700;
|
||||
cursor:pointer;
|
||||
margin-right:.5rem;
|
||||
}
|
||||
button.fix{
|
||||
background:#10b981;
|
||||
}
|
||||
button.danger{
|
||||
background:#dc2626;
|
||||
}
|
||||
.danger-zone{
|
||||
background:#450a0a;
|
||||
border:1px solid #7f1d1d;
|
||||
border-radius:.5rem;
|
||||
padding:1rem;
|
||||
}
|
||||
.danger-zone h2{
|
||||
color:#f87171;
|
||||
margin-top:0;
|
||||
}
|
||||
.path{
|
||||
font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
font-size:.85rem;
|
||||
word-break:break-all;
|
||||
color:#9ca3af;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="topbar">
|
||||
<a href="{{ url_for('admin_dashboard') }}">Dashboard →</a>
|
||||
<a href="{{ url_for('admin_logout') }}">Log out</a>
|
||||
</div>
|
||||
|
||||
<h1>Admin Settings</h1>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="flash {{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Database health -->
|
||||
<div class="section">
|
||||
<h2>Database Health</h2>
|
||||
{% if health.ok %}
|
||||
<p class="health-ok">✅ {{ health.message }}</p>
|
||||
{% else %}
|
||||
<p class="health-bad">❌ {{ health.message }}</p>
|
||||
{% if health.missing_columns %}
|
||||
<p>Missing columns:</p>
|
||||
<ul class="status-list">
|
||||
{% for col in health.missing_columns %}
|
||||
<li>{{ col }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<form method="POST" style="margin-top:.8rem">
|
||||
<input type="hidden" name="action" value="fix_db">
|
||||
<button type="submit" class="fix">Fix Missing Columns</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<p class="path">DB path: {{ db_path }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="section">
|
||||
<h2>Database Statistics</h2>
|
||||
<div class="stat-grid">
|
||||
<div class="stat-card">
|
||||
<div class="value">{{ total_records }}</div>
|
||||
<div class="label">Total requests</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value">{{ db_size }}</div>
|
||||
<div class="label">Database size</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value">{{ upload_file_count }}</div>
|
||||
<div class="label">Uploaded MP3 files</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value">{{ upload_dir_count }}</div>
|
||||
<div class="label">Request upload folders</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value">{{ upload_size }}</div>
|
||||
<div class="label">Total upload size</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if status_counts %}
|
||||
<h3 style="margin-top:1.2rem;color:#93c5fd">By Status</h3>
|
||||
<ul class="status-list">
|
||||
{% for key,count in status_counts.items() %}
|
||||
<li>{{ statuses[key] }} ({{ key }}): <strong>{{ count }}</strong></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p style="margin-top:1rem;color:#9ca3af">No requests yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Paths -->
|
||||
<div class="section">
|
||||
<h2>Paths</h2>
|
||||
<p><strong>Database:</strong><br><span class="path">{{ db_path }}</span></p>
|
||||
<p><strong>Uploads:</strong><br><span class="path">{{ upload_path }}</span></p>
|
||||
</div>
|
||||
|
||||
<!-- System reset -->
|
||||
<div class="danger-zone">
|
||||
<h2>⚠️ Reset System</h2>
|
||||
<p>Use this only at the start of a new event. It will delete every request and every uploaded MP3 file. This cannot be undone.</p>
|
||||
<form method="POST" action="{{ url_for('admin_settings') }}" onsubmit="return confirm('ARE YOU SURE? This will delete ALL requests and ALL uploaded files. This cannot be undone.')">
|
||||
<input type="hidden" name="action" value="reset_system">
|
||||
<button type="submit" class="danger">Reset System</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in a new issue