5b69ccb02b
Both bugs had the same cause: light colours were set with inline style attributes, which outrank the theme rules in base.html and so survived the dark theme. Text that inherits --text-color then became light-on-light. * manage_player.html: the delete confirmation modal used an inline background-color of #fefefe with the body's dark-mode text colour, making the message unreadable in dark mode. Rebuilt it with .modal-* classes driven by the base.html CSS variables (and dark-mode variants for the title, warning and player name). * admin/maintenance.html: the "Automatic reset interval" panel, table headers and JS-rendered rows/tables hardcoded #f8f9fa/#e2e8f0/#dee2e6. Switched them to --bg-color / --card-bg / --border-color / --text-secondary and added a themed .maint-badge-pending. Verified with computed styles in both themes; light mode is unchanged.
399 lines
19 KiB
HTML
399 lines
19 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;
|
|
}
|
|
|
|
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: 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') }}"
|
|
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('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 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">pending</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('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: 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 %}
|