Files
digiserver-v2/app/templates/admin/maintenance.html
T
ske087 88827be7be 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.
2026-10-14 22:13:18 +03:00

365 lines
18 KiB
HTML

{% extends "base.html" %}
{% block title %}Maintenance - Admin - DigiServer v2{% endblock %}
{% block content %}
<div style="max-width: 1200px; margin: 0 auto;">
<div style="margin-bottom: 30px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px;">
<div>
<h1 style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem;">
🛠️ Maintenance
</h1>
<p style="color: #6c757d; margin: 0;">
Administrative tools for database health, leftover records and player authentication.
</p>
</div>
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-secondary">
← Back to Admin
</a>
</div>
<!-- ================= Database Integrity ================= -->
<div class="card" style="margin-bottom: 30px;">
<h2>🧩 Database Integrity</h2>
<p style="color: #6c757d; margin-bottom: 20px;">
Runs SQLite's <code>integrity_check</code>, <code>quick_check</code> and
<code>foreign_key_check</code> to confirm the database file is healthy and
no rows reference missing parents. <strong>Optimize</strong> runs
<code>VACUUM</code> and <code>ANALYZE</code> to reclaim free space.
</p>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button type="button" class="btn btn-primary" id="btn-integrity">🔍 Check Integrity</button>
<button type="button" class="btn btn-warning" id="btn-optimize"
onclick="return confirm('Compact the database now? This may take a few seconds.');">
🧹 Optimize Database
</button>
</div>
<div id="integrity-result" style="margin-top: 20px;"></div>
</div>
<!-- ================= Leftover / Orphaned Records ================= -->
<div class="card" style="margin-bottom: 30px;">
<h2>🧾 Leftover Database Records</h2>
<p style="color: #6c757d; margin-bottom: 20px;">
Rows that point at a player, playlist or content item that no longer exists —
typically left behind after deleting a player. Cleaning removes them (or
clears the dangling link); it never touches valid data.
</p>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button type="button" class="btn btn-primary" id="btn-scan">🔍 Scan for Leftovers</button>
<button type="button" class="btn btn-danger" id="btn-clean"
onclick="return confirm('Remove all leftover database records and orphaned edited-media folders? This cannot be undone.');">
🗑️ Clean Leftovers
</button>
</div>
<div id="orphan-result" style="margin-top: 20px;"></div>
</div>
<!-- ================= Player Authentication Tokens ================= -->
<div class="card" style="margin-bottom: 30px;">
<h2>🔑 Player Authentication Tokens</h2>
<p style="color: #6c757d; margin-bottom: 20px;">
Resetting a token invalidates the player's current credentials. On its next
heartbeat the player is told to authenticate again and receives a fresh token.
</p>
<form method="POST" action="{{ url_for('admin.maintenance_set_interval') }}"
style="display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap; margin-bottom: 25px; padding: 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e2e8f0;">
<div>
<label for="interval_days" style="display: block; font-weight: 600; margin-bottom: 6px;">
Automatic reset interval
</label>
<input type="number" id="interval_days" name="interval_days" min="0" step="1"
value="{{ interval_days }}"
style="padding: 10px; border: 1px solid #ccc; border-radius: 6px; width: 140px;">
</div>
<div style="flex: 1; min-width: 220px; color: #6c757d; font-size: 0.9rem;">
Days before a token expires automatically. Use <strong>0</strong> to disable.
{% if auth_config %}
<br>Last changed by <strong>{{ auth_config.updated_by or 'unknown' }}</strong>
{% if auth_config.updated_at %}
on {{ auth_config.updated_at | localtime('%Y-%m-%d %H:%M') }}
{% endif %}
{% else %}
<br>Not configured yet.
{% endif %}
</div>
<button type="submit" class="btn btn-primary">💾 Save Interval</button>
</form>
<form method="POST" action="{{ url_for('admin.maintenance_reset_all_tokens') }}"
onsubmit="return confirm('Reset the authentication token for ALL players? Each player will re-authenticate on its next heartbeat.');"
style="margin-bottom: 20px;">
<button type="submit" class="btn btn-danger">🔁 Reset All Player Tokens</button>
</form>
<div style="max-height: 460px; overflow-y: auto;">
<table class="table" style="width: 100%; border-collapse: collapse;">
<thead style="position: sticky; top: 0; background: #f8f9fa; z-index: 1;">
<tr>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Player</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Status</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Token Age</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Re-auth</th>
<th style="padding: 10px; text-align: right; border-bottom: 2px solid #dee2e6;">Actions</th>
</tr>
</thead>
<tbody>
{% for player in players %}
{% set age = player.auth_token_age_days() %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px;">
<strong>{{ player.name }}</strong>
<div style="font-size: 12px; color: #6c757d;">{{ player.hostname }}</div>
</td>
<td style="padding: 10px;">
{% if player.is_online %}
<span style="color: #16a34a; font-weight: 600;">● Online</span>
{% else %}
<span style="color: #94a3b8;">○ {{ player.status or 'offline' }}</span>
{% endif %}
</td>
<td style="padding: 10px;">
{% if age is not none %}
{{ '%.1f'|format(age) }} day(s)
{% if player.auth_token_issued_at %}
<div style="font-size: 12px; color: #6c757d;">
issued {{ player.auth_token_issued_at | localtime('%Y-%m-%d %H:%M') }}
</div>
{% endif %}
{% else %}
<span style="color: #94a3b8;">—</span>
{% endif %}
</td>
<td style="padding: 10px;">
{% if player.auth_reset_required %}
<span style="background: #fef3c7; color: #92400e; padding: 3px 8px; border-radius: 999px; font-size: 12px; font-weight: 600;">
pending
</span>
{% else %}
<span style="color: #94a3b8; font-size: 12px;">—</span>
{% endif %}
</td>
<td style="padding: 10px; text-align: right; white-space: nowrap;">
<form method="POST"
action="{{ url_for('admin.maintenance_reset_token', player_id=player.id) }}"
style="display: inline;"
onsubmit="return confirm('Reset the token for {{ player.name }}?');">
<button type="submit" class="btn btn-warning btn-sm"
style="padding: 4px 10px; font-size: 12px;">🔁 Reset</button>
</form>
{% if player.auth_reset_required %}
<form method="POST"
action="{{ url_for('admin.maintenance_cancel_reset', player_id=player.id) }}"
style="display: inline;">
<button type="submit" class="btn btn-secondary btn-sm"
style="padding: 4px 10px; font-size: 12px;">Cancel</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="5" style="padding: 30px; text-align: center; color: #999;">
No players registered yet.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<script>
(function () {
const URLS = {
integrity: "{{ url_for('admin.maintenance_db_integrity') }}",
optimize: "{{ url_for('admin.maintenance_optimize_db') }}",
scan: "{{ url_for('admin.maintenance_scan_orphans') }}",
clean: "{{ url_for('admin.maintenance_clean_orphans') }}"
};
const integrityBox = document.getElementById('integrity-result');
const orphanBox = document.getElementById('orphan-result');
function post(url) {
return fetch(url, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function (r) { return r.json(); });
}
function banner(ok, text) {
const bg = ok ? '#dcfce7' : '#fee2e2';
const fg = ok ? '#166534' : '#991b1b';
const icon = ok ? '✓' : '✕';
return '<div style="padding: 12px 15px; border-radius: 8px; background: ' + bg +
'; color: ' + fg + '; font-weight: 600;">' + icon + ' ' + text + '</div>';
}
function tableRow(cells) {
return '<tr>' + cells.map(function (c) {
return '<td style="padding: 8px 10px; border-bottom: 1px solid #e2e8f0;">' + c + '</td>';
}).join('') + '</tr>';
}
function esc(s) {
return String(s === null || s === undefined ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function setBusy(button, busy, label) {
if (!button) { return; }
if (busy) {
button.dataset.original = button.innerHTML;
button.disabled = true;
button.innerHTML = label || '⏳ Working…';
} else {
button.disabled = false;
if (button.dataset.original) { button.innerHTML = button.dataset.original; }
}
}
// ---- Integrity ----
const btnIntegrity = document.getElementById('btn-integrity');
btnIntegrity.addEventListener('click', function () {
setBusy(btnIntegrity, true, '⏳ Checking…');
integrityBox.innerHTML = '';
post(URLS.integrity).then(function (data) {
if (!data.success) {
integrityBox.innerHTML = banner(false, 'Check failed: ' + esc(data.error));
return;
}
let html = '';
html += banner(data.ok,
data.ok ? 'Database integrity OK — no problems found.'
: 'Issues detected — review the details below.');
html += '<div style="margin-top: 12px; font-size: 0.9rem; color: #475569;">' +
'<div>Integrity check: <strong>' + esc(data.integrity.join(', ')) + '</strong></div>' +
'<div>Quick check: <strong>' + esc(data.quick_check.join(', ')) + '</strong></div>' +
'<div>Database size: <strong>' + esc(data.db_size_mb) + ' MB</strong>' +
' &nbsp;·&nbsp; Reclaimable free space: <strong>' + esc(data.freelist_mb) + ' MB</strong></div>' +
'</div>';
if (data.foreign_key_violations.length) {
html += '<h3 style="margin: 18px 0 8px;">Foreign-key violations (' +
data.foreign_key_violations.length + ')</h3>';
html += '<table class="table" style="width: 100%; border-collapse: collapse;">';
html += '<thead><tr>' +
['Table', 'Row ID', 'Missing parent', 'Constraint'].map(function (h) {
return '<th style="padding: 8px; text-align: left; border-bottom: 2px solid #dee2e6;">' + h + '</th>';
}).join('') + '</tr></thead><tbody>';
data.foreign_key_violations.slice(0, 100).forEach(function (v) {
html += tableRow([esc(v.table), esc(v.rowid), esc(v.parent), esc(v.constraint)]);
});
html += '</tbody></table>';
}
integrityBox.innerHTML = html;
}).catch(function (e) {
integrityBox.innerHTML = banner(false, 'Request failed: ' + e);
}).finally(function () {
setBusy(btnIntegrity, false);
});
});
// ---- Optimize ----
const btnOptimize = document.getElementById('btn-optimize');
btnOptimize.addEventListener('click', function () {
setBusy(btnOptimize, true, '⏳ Optimizing…');
post(URLS.optimize).then(function (data) {
if (data.success) {
integrityBox.innerHTML = banner(true,
'Database optimized. Current size: ' + esc(data.db_size_mb) + ' MB.');
} else {
integrityBox.innerHTML = banner(false, 'Optimize failed: ' + esc(data.error));
}
}).catch(function (e) {
integrityBox.innerHTML = banner(false, 'Request failed: ' + e);
}).finally(function () {
setBusy(btnOptimize, false);
});
});
// ---- Scan orphans ----
const btnScan = document.getElementById('btn-scan');
btnScan.addEventListener('click', function () {
setBusy(btnScan, true, '⏳ Scanning…');
orphanBox.innerHTML = '';
post(URLS.scan).then(function (data) {
if (!data.success) {
orphanBox.innerHTML = banner(false, 'Scan failed.');
return;
}
const em = data.edited_media || {};
const total = data.total + (em.count || 0);
let html = banner(total === 0,
total === 0 ? 'No leftover records or files found.'
: total + ' leftover item(s) found.');
html += '<table class="table" style="width: 100%; border-collapse: collapse; margin-top: 15px;">';
html += '<thead><tr>' +
['Type', 'Table', 'Action', 'Count'].map(function (h) {
return '<th style="padding: 8px; text-align: left; border-bottom: 2px solid #dee2e6;">' + h + '</th>';
}).join('') + '</tr></thead><tbody>';
data.checks.forEach(function (c) {
if (c.error) {
html += tableRow([esc(c.label), esc(c.table), '—',
'<span style="color: #b91c1c;">error: ' + esc(c.error) + '</span>']);
} else {
const colour = c.count > 0 ? '#b91c1c' : '#16a34a';
html += tableRow([esc(c.label), esc(c.table), esc(c.action),
'<strong style="color: ' + colour + ';">' + c.count + '</strong>']);
}
});
if (em.count) {
html += tableRow(['Orphaned edited-media folders on disk', 'edited_media',
'delete', '<strong style="color: #b91c1c;">' + em.count +
'</strong> (' + esc(em.size_mb) + ' MB)']);
}
html += '</tbody></table>';
orphanBox.innerHTML = html;
}).catch(function (e) {
orphanBox.innerHTML = banner(false, 'Request failed: ' + e);
}).finally(function () {
setBusy(btnScan, false);
});
});
// ---- Clean orphans ----
const btnClean = document.getElementById('btn-clean');
btnClean.addEventListener('click', function () {
setBusy(btnClean, true, '⏳ Cleaning…');
post(URLS.clean).then(function (data) {
let html = banner(data.success && !data.errors.length,
'Removed ' + data.total_rows + ' database row(s) and ' +
data.removed_dirs + ' media folder(s); freed ' + data.freed_mb + ' MB.');
if (data.cleaned && data.cleaned.length) {
html += '<table class="table" style="width: 100%; border-collapse: collapse; margin-top: 15px;">';
html += '<thead><tr>' +
['Record type', 'Action', 'Rows removed'].map(function (h) {
return '<th style="padding: 8px; text-align: left; border-bottom: 2px solid #dee2e6;">' + h + '</th>';
}).join('') + '</tr></thead><tbody>';
data.cleaned.forEach(function (c) {
html += tableRow([esc(c.label), esc(c.action), c.rows]);
});
html += '</tbody></table>';
}
if (data.errors && data.errors.length) {
html += '<h3 style="margin: 18px 0 8px; color: #b91c1c;">Errors</h3>';
html += '<table class="table" style="width: 100%; border-collapse: collapse;">';
data.errors.forEach(function (e) {
html += tableRow([esc(e.label), esc(e.error)]);
});
html += '</table>';
}
orphanBox.innerHTML = html;
}).catch(function (e) {
orphanBox.innerHTML = banner(false, 'Request failed: ' + e);
}).finally(function () {
setBusy(btnClean, false);
});
});
})();
</script>
{% endblock %}