6b0e51898c
The maintenance-page reset used to rotate auth_code immediately. Because the token also guards the playlist endpoints, that stopped a player from syncing content the instant the button was pressed -- before the player ever had a chance to react -- and clients that do not understand the heartbeat's 401 auth_required would have stayed broken until redeployed. Split the model behaviour: * request_reauth() -- sets the flag only (used by the admin reset endpoints and the automatic interval). The current token stays valid, so there is no outage while the player is being asked to re-authenticate. * issue_new_token() -- replaces the token now (used by the existing "regenerate auth code" action, where the admin is shown the new value). * mark_authenticated() -- if a reset was pending, this is where the old token is retired: a new token is issued and returned to the player in the /api/auth/player response. Net effect: the reset is a request the player acts on, never a forced logout. A player that ignores it keeps working and shows as "awaiting re-auth" in the UI; one that handles it gets a fresh token and the old one stops working. Maintenance page wording updated to match (request vs reset; "awaiting re-auth" badge), plus a themed info note explaining it is not a forced logout. Adds docs/player-auth-reset.md describing the protocol and exactly what the player client (Kiwy-Signage) still needs to implement: detect 401 / auth_required on the heartbeat (and on the token-guarded endpoints), re-run POST /api/auth/player with the stored quick-connect code, save the new auth_code and retry. No password or SSH redeploy required. Verified end-to-end: token unchanged and still accepted after the request (200), heartbeat answers 401 auth_required=token_reset, re-auth issues a different token, the old token is then rejected (403) and the new one accepted (200), flag cleared; the interval path only flags without rotating.
423 lines
20 KiB
HTML
423 lines
20 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}Maintenance - Admin - DigiServer v2{% endblock %}
|
||
|
||
{% block content %}
|
||
<style>
|
||
/* Theme-aware building blocks for the maintenance console. Using the
|
||
variables defined in base.html keeps every panel readable in both the
|
||
light and dark themes (inline colours would override the theme). */
|
||
.maint-panel {
|
||
background: var(--bg-color);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.maint-thead {
|
||
background: var(--card-bg);
|
||
}
|
||
|
||
.maint-badge-pending {
|
||
display: inline-block;
|
||
padding: 3px 8px;
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
background: #fef3c7;
|
||
color: #92400e;
|
||
}
|
||
|
||
/* Informational note (e.g. the "this is a request, not a forced logout"
|
||
explanation), themed so it stays readable in both modes. */
|
||
.maint-note {
|
||
padding: 10px 14px;
|
||
border-radius: 8px;
|
||
font-size: 0.875rem;
|
||
background: #e7f3ff;
|
||
border-left: 4px solid #0066cc;
|
||
color: #0c5460;
|
||
}
|
||
|
||
body.dark-mode .maint-note {
|
||
background: #1e3a5f;
|
||
border-left-color: #90cdf4;
|
||
color: #cbd5e1;
|
||
}
|
||
|
||
body.dark-mode .maint-badge-pending {
|
||
background: #4a3800;
|
||
color: #fbbf24;
|
||
}
|
||
|
||
/* Muted description text should stay readable on the dark card. */
|
||
body.dark-mode .card > p[style*="color: #6c757d"] {
|
||
color: var(--text-secondary) !important;
|
||
}
|
||
</style>
|
||
|
||
<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: 12px;">
|
||
Requests that a player authenticate again. The player is told on its next
|
||
heartbeat and receives a fresh token when it does — its current token stays
|
||
valid until then, so playlist syncing is not interrupted.
|
||
</p>
|
||
<p class="maint-note" style="margin-bottom: 20px;">
|
||
ℹ️ This asks the player to re-authenticate; it is not a forced logout. A player
|
||
whose software does not yet handle the request keeps working on its existing
|
||
token and simply stays <em>awaiting re-auth</em> below. Players do not need
|
||
a password — the quick-connect code they already hold is enough.
|
||
</p>
|
||
|
||
<form method="POST" action="{{ url_for('admin.maintenance_set_interval') }}"
|
||
class="maint-panel"
|
||
style="display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap; margin-bottom: 25px; padding: 15px;">
|
||
<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 var(--border-color); border-radius: 6px; width: 140px;">
|
||
</div>
|
||
<div style="flex: 1; min-width: 220px; color: var(--text-secondary); 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('Ask ALL players to re-authenticate? Each one will be told on its next heartbeat and then receive a new token. Their current tokens keep working until then.');"
|
||
style="margin-bottom: 20px;">
|
||
<button type="submit" class="btn btn-danger">🔁 Request Re-auth for All Players</button>
|
||
</form>
|
||
|
||
<div style="max-height: 460px; overflow-y: auto;">
|
||
<table class="table" style="width: 100%; border-collapse: collapse;">
|
||
<thead class="maint-thead" style="position: sticky; top: 0; z-index: 1;">
|
||
<tr>
|
||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid var(--border-color);">Player</th>
|
||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid var(--border-color);">Status</th>
|
||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid var(--border-color);">Token Age</th>
|
||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid var(--border-color);">Re-auth</th>
|
||
<th style="padding: 10px; text-align: right; border-bottom: 2px solid var(--border-color);">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for player in players %}
|
||
{% set age = player.auth_token_age_days() %}
|
||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||
<td style="padding: 10px;">
|
||
<strong>{{ player.name }}</strong>
|
||
<div style="font-size: 12px; color: var(--text-secondary);">{{ 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: var(--text-secondary);">○ {{ 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: var(--text-secondary);">
|
||
issued {{ player.auth_token_issued_at | localtime('%Y-%m-%d %H:%M') }}
|
||
</div>
|
||
{% endif %}
|
||
{% else %}
|
||
<span style="color: var(--text-secondary);">—</span>
|
||
{% endif %}
|
||
</td>
|
||
<td style="padding: 10px;">
|
||
{% if player.auth_reset_required %}
|
||
<span class="maint-badge-pending" title="The player has been asked to re-authenticate; the token is replaced once it does.">awaiting re-auth</span>
|
||
{% else %}
|
||
<span style="color: var(--text-secondary); 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('Ask {{ player.name }} to re-authenticate?');">
|
||
<button type="submit" class="btn btn-warning btn-sm"
|
||
style="padding: 4px 10px; font-size: 12px;">🔁 Re-auth</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: var(--text-secondary);">
|
||
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 var(--border-color);">' + c + '</td>';
|
||
}).join('') + '</tr>';
|
||
}
|
||
|
||
function esc(s) {
|
||
return String(s === null || s === undefined ? '' : s)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
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: var(--text-secondary);">' +
|
||
'<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>' +
|
||
' · 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 var(--border-color);">' + 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 var(--border-color);">' + 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 var(--border-color);">' + 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 %}
|