Files
agessaman 02868841dd fix(web_viewer): give pages a real heading outline and dialogs their names
Screen-reader users skim a page by its headings, and several outlines were broken:
- The dashboard had no h1; it now has a visually hidden "Dashboard" heading ahead of the health strip.
- Feeds and Radio stat cards made the number the heading ("heading level 2, 4") under an h5 label. The label is now the h2 and the number a styled paragraph, and their card sections are h2s instead of h5s straight after the h1. Radio's uppercase group labels become h3s. Feeds' error count toggles its color classes instead of replacing them, which had dropped the readout size.
- Logs went from h1 to h6 for its stream card; it is an h2.
- Config's panel titles are h2s, their group labels h3s, and the database table names (built in JS) h4s.
Visual sizes are unchanged: each heading keeps its old look through Bootstrap's .h5/.h6 classes.

11 of 17 modals had no aria-labelledby, so they announced as an unnamed "dialog". Each now points at its modal title.

axe-core reports no violations on any of 15 pages in either theme.
2026-10-24 13:38:15 -07:00

1515 lines
66 KiB
HTML

{% extends "base.html" %}
{% block title %}Configuration - MeshCore Bot{% endblock %}
{% block extra_css %}
<style>
/* Layout */
.config-layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 1rem;
}
.config-nav.card,
.config-admin-tools.card {
border: 1px solid var(--border-color);
background-color: var(--card-bg);
border-left: 3px solid #0d6efd;
}
[data-theme="dark"] .config-nav.card,
[data-theme="dark"] .config-admin-tools.card {
border-left-color: #6ea8fe;
}
.config-sidebar {
position: sticky;
top: 1rem;
align-self: start;
max-height: calc(100vh - 2rem);
overflow-y: auto;
}
.config-sidebar .nav-link {
color: var(--text-color);
border-radius: 0.375rem;
}
.config-sidebar .nav-link:hover,
.config-sidebar .nav-link:focus {
background-color: var(--bg-secondary);
}
.config-sidebar .nav-link.active {
background-color: var(--bg-tertiary);
font-weight: 600;
}
.config-panel-card {
scroll-margin-top: 1rem;
border: 1px solid var(--border-color);
background-color: var(--card-bg);
}
.config-panel-card .card-header {
border-bottom: 1px solid var(--border-color);
}
/* Stat tiles (database overview) */
.config-stat-tile {
background-color: var(--bg-secondary);
border-color: var(--border-color) !important;
}
/* Database table rows (JS-rendered) */
.config-db-table-row {
border-color: var(--border-color) !important;
background-color: var(--bg-secondary);
}
.config-table-name-icon {
color: var(--text-color);
opacity: 0.85;
}
.config-table-metric {
color: var(--text-color);
}
.config-tables-list .loading {
color: var(--text-muted);
}
/* Maintenance + generic config tables */
.config-table-thead th {
background-color: var(--card-header-bg);
color: var(--text-color);
border-bottom: 1px solid var(--border-color);
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
padding-top: 0.75rem;
padding-bottom: 0.75rem;
}
/* Maintenance Status panel */
.config-maintenance-panel-body {
padding: 1rem 1.25rem 1.25rem;
}
.config-maintenance-table {
--maint-row-border: color-mix(in srgb, var(--border-color) 55%, transparent);
}
.config-maintenance-table tbody td {
padding-top: 0.65rem;
padding-bottom: 0.65rem;
padding-left: 0.75rem;
padding-right: 0.75rem;
border-color: var(--maint-row-border);
vertical-align: middle;
}
.config-maintenance-table thead th {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.config-maint-col-job {
min-width: 11rem;
}
.config-maint-col-ran {
font-variant-numeric: tabular-nums;
}
.config-maint-time {
font-family: var(--font-mono);
font-size: 0.9em;
}
.config-outcome-cell {
white-space: nowrap;
}
.config-outcome-dash {
opacity: 0.55;
}
[data-theme="dark"] .config-maintenance-table.table {
color: var(--text-color);
}
[data-theme="dark"] .config-maintenance-table td,
[data-theme="dark"] .config-maintenance-table th {
border-color: var(--border-color);
}
/* btn-info / btn-warning: improve contrast on dark card backgrounds */
[data-theme="dark"] .config-btn-info.btn-info {
background-color: #0dcaf0;
border-color: #0dcaf0;
color: #052c33;
}
[data-theme="dark"] .config-btn-info.btn-info:hover {
background-color: #31d2f2;
border-color: #25cff2;
color: #052c33;
}
[data-theme="dark"] .config-btn-warning.btn-warning {
background-color: #ffc107;
border-color: #ffc107;
color: #332701;
}
[data-theme="dark"] .config-btn-warning.btn-warning:hover {
background-color: #ffca2c;
border-color: #ffc720;
color: #332701;
}
@media (max-width: 992px) {
.config-layout {
/* minmax(0, ...) so the column can shrink below its widest child
on a phone instead of widening the page. */
grid-template-columns: minmax(0, 1fr);
}
.config-sidebar {
position: static;
max-height: none;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<h1 class="mb-4">
<i class="fas fa-cog"></i> Configuration
</h1>
<div class="config-layout">
<aside class="config-sidebar d-flex flex-column gap-3">
<div class="config-nav card">
<div class="card-body p-3">
{% for category_key, category_label in panel_categories %}
{% set category_panels = config_panels | selectattr('category', 'equalto', category_key) | list %}
{% if category_panels %}
<div class="mb-3">
<p class="text-uppercase text-muted fw-semibold mb-2" id="config-nav-{{ category_key }}" style="font-size:.75rem;letter-spacing:.05em;">
{{ category_label }}
</p>
<nav class="nav flex-column" aria-labelledby="config-nav-{{ category_key }}">
{% for panel in category_panels %}
<a class="nav-link px-2 py-1" href="#{{ panel.id }}" data-config-target="{{ panel.id }}">
<i class="{{ panel.icon }} me-2"></i>{{ panel.title }}
</a>
{% endfor %}
</nav>
</div>
{% endif %}
{% endfor %}
</div>
</div>
<div class="config-admin-tools card">
<div class="card-body p-3">
<p class="text-uppercase text-muted fw-semibold mb-2" id="config-nav-admin" style="font-size:.75rem;letter-spacing:.05em;">
Admin Tools
</p>
<nav class="nav flex-column" aria-labelledby="config-nav-admin">
<a class="nav-link px-2 py-1" href="/api-explorer">
<i class="fas fa-plug me-2"></i>API
</a>
<a class="nav-link px-2 py-1" href="/admin/config">
<i class="fas fa-file-code me-2"></i>Raw Config
</a>
</nav>
</div>
</div>
</aside>
<div class="config-panels">
{% for panel in config_panels %}
{% include panel.template %}
{% endfor %}
</div>
</div>
</div>
<!-- Purge Records (database operations) -->
<div class="modal fade" id="purgeRecordsModal" tabindex="-1" aria-labelledby="purgeRecordsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="purgeRecordsModalLabel">
<i class="fas fa-trash-alt me-2"></i>Purge Records
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="text-muted small mb-3">
Permanently delete <strong>old</strong> records from the SQLite tables you select below.
This cannot be undone — back up first if needed.
</p>
<fieldset class="mb-3">
<legend class="form-label fw-semibold mb-2">Tables to purge</legend>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="purge-all-tables" checked>
<label class="form-check-label" for="purge-all-tables">All listed tables</label>
</div>
<div id="purge-tables-list" class="border rounded p-3 config-purge-tables-list" style="background-color: var(--bg-secondary); border-color: var(--border-color) !important;">
<div class="row row-cols-1 row-cols-md-2 g-2 small">
<div class="col">
<div class="form-check">
<input class="form-check-input purge-table-cb" type="checkbox" value="packet_stream" id="purge-t-packet_stream" checked disabled>
<label class="form-check-label" for="purge-t-packet_stream"><code>packet_stream</code> — raw packet history</label>
</div>
</div>
<div class="col">
<div class="form-check">
<input class="form-check-input purge-table-cb" type="checkbox" value="message_stats" id="purge-t-message_stats" checked disabled>
<label class="form-check-label" for="purge-t-message_stats"><code>message_stats</code> — message statistics</label>
</div>
</div>
<div class="col">
<div class="form-check">
<input class="form-check-input purge-table-cb" type="checkbox" value="complete_contact_tracking" id="purge-t-complete_contact_tracking" checked disabled>
<label class="form-check-label" for="purge-t-complete_contact_tracking"><code>complete_contact_tracking</code> — contact rows</label>
</div>
</div>
<div class="col">
<div class="form-check">
<input class="form-check-input purge-table-cb" type="checkbox" value="purging_log" id="purge-t-purging_log" checked disabled>
<label class="form-check-label" for="purge-t-purging_log"><code>purging_log</code> — purge audit log</label>
</div>
</div>
<div class="col">
<div class="form-check">
<input class="form-check-input purge-table-cb" type="checkbox" value="mesh_connections" id="purge-t-mesh_connections" checked disabled>
<label class="form-check-label" for="purge-t-mesh_connections"><code>mesh_connections</code> — mesh graph edges</label>
</div>
</div>
<div class="col">
<div class="form-check">
<input class="form-check-input purge-table-cb" type="checkbox" value="daily_stats" id="purge-t-daily_stats" checked disabled>
<label class="form-check-label" for="purge-t-daily_stats"><code>daily_stats</code> — daily aggregates</label>
</div>
</div>
</div>
</div>
</fieldset>
<div class="row g-3 align-items-end flex-wrap">
<div class="col-auto">
<label class="form-label" for="purge-keep-days">Keep data for</label>
<select class="form-select" id="purge-keep-days" style="width:auto;min-width:10rem">
<option value="1">1 day</option>
<option value="7">7 days</option>
<option value="14">14 days</option>
<option value="30" selected>30 days</option>
<option value="60">60 days</option>
<option value="90">90 days</option>
</select>
</div>
<div class="col-auto">
<button type="button" class="btn btn-danger" id="purge-open-confirm-btn">
<i class="fas fa-trash-alt me-1"></i>Purge Now
</button>
</div>
<div class="col-auto">
<span id="purge-status" class="small" style="display:none"></span>
</div>
</div>
<div id="purge-results" class="mt-3" style="display:none">
<h6 class="text-muted">Rows deleted</h6>
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0" style="max-width:100%">
<thead><tr><th>Table</th><th>Deleted</th></tr></thead>
<tbody id="purge-results-body"></tbody>
</table>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Purge Confirmation Modal -->
<div class="modal fade" id="purgeModal" tabindex="-1" aria-labelledby="purgeModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="purgeModalLabel">
<i class="fas fa-exclamation-triangle me-2"></i>Confirm Purge
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>You are about to permanently delete records older than
<strong id="purge-confirm-days"></strong>
<span id="purge-confirm-tables-desc">from all listed time-series tables.</span></p>
<p class="text-danger"><strong>This cannot be undone.</strong>
Make sure you have a backup if needed.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="purge-confirm-btn">
<i class="fas fa-trash-alt me-1"></i>Delete Records
</button>
</div>
</div>
</div>
</div>
<!-- Restore Modal -->
<div class="modal fade" id="restoreModal" tabindex="-1" aria-labelledby="restoreModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="restoreModalLabel">
<i class="fas fa-undo me-2"></i>Stage Database Restore
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>Restart required:</strong> This verifies and stages the selected backup.
The active database remains unchanged until the complete MeshCore Bot service is restarted.
</div>
<div class="mb-3">
<label class="form-label" for="restore-db-path">Backup file path</label>
<input type="text" class="form-control" id="restore-db-path"
placeholder="/path/to/backup.db">
</div>
<div id="restore-backup-list-section">
<p class="text-muted small mb-1">Available backups from configured backup directory:</p>
<div id="restore-backup-loading" class="text-center py-2" style="display:none">
<span class="spinner-border spinner-border-sm"></span>
</div>
<div id="restore-backup-empty" class="text-muted small" style="display:none">
No backups found in backup directory.
</div>
<table class="table table-sm table-hover mb-0" id="restore-backup-table" style="display:none">
<thead><tr><th>File</th><th>Size</th><th>Date</th><th></th></tr></thead>
<tbody id="restore-backup-tbody"></tbody>
</table>
</div>
<div id="restore-status" class="mt-3 small" style="display:none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-warning" id="restore-confirm-btn">
<i class="fas fa-undo me-1"></i>Stage Restore
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script nonce="{{ g.csp_nonce }}">
class ConfigManager {
constructor() {
this.fields = {
smtp_host: document.getElementById('smtp-host'),
smtp_port: document.getElementById('smtp-port'),
smtp_security: document.getElementById('smtp-security'),
smtp_user: document.getElementById('smtp-user'),
smtp_password: document.getElementById('smtp-password'),
from_name: document.getElementById('from-name'),
from_email: document.getElementById('from-email'),
recipients: document.getElementById('recipients'),
};
this.nightlyToggle = document.getElementById('nightly-enabled-toggle');
this.saveBtn = document.getElementById('save-notifications-btn');
this.testBtn = document.getElementById('test-email-btn');
this.saveStatus = document.getElementById('save-status');
this.togglePwdBtn = document.getElementById('toggle-password');
this.togglePwdIcon = document.getElementById('toggle-password-icon');
this.initialize();
}
async initialize() {
await this.loadSettings();
this.setupEventHandlers();
}
async loadSettings() {
try {
const resp = await fetch('/api/config/notifications');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (data.smtp_host) this.fields.smtp_host.value = data.smtp_host;
if (data.smtp_port) this.fields.smtp_port.value = data.smtp_port;
if (data.smtp_security) this.fields.smtp_security.value = data.smtp_security;
if (data.smtp_user) this.fields.smtp_user.value = data.smtp_user;
// Never pre-fill password from server; server returns it but we show placeholder
if (data.smtp_password && data.smtp_password !== '') {
this.fields.smtp_password.placeholder = '(saved — enter new value to change)';
}
if (data.from_name) this.fields.from_name.value = data.from_name;
if (data.from_email) this.fields.from_email.value = data.from_email;
if (data.recipients) this.fields.recipients.value = data.recipients;
this.nightlyToggle.checked = data.nightly_enabled === 'true';
} catch (err) {
this.showStatus('Failed to load settings: ' + err.message, 'danger');
}
}
setupEventHandlers() {
this.saveBtn.addEventListener('click', () => this.saveSettings());
this.testBtn.addEventListener('click', () => this.sendTestEmail());
// Security preset — update port suggestion when security changes
this.fields.smtp_security.addEventListener('change', () => {
const portField = this.fields.smtp_port;
const sec = this.fields.smtp_security.value;
if (!portField.value || ['25','465','587'].includes(portField.value)) {
if (sec === 'ssl') portField.value = '465';
else if (sec === 'none') portField.value = '25';
else portField.value = '587';
}
});
// Password reveal toggle
this.togglePwdBtn.addEventListener('click', () => {
const pwdField = this.fields.smtp_password;
if (pwdField.type === 'password') {
pwdField.type = 'text';
this.togglePwdIcon.classList.replace('fa-eye', 'fa-eye-slash');
this.togglePwdBtn.setAttribute('aria-pressed', 'true');
} else {
pwdField.type = 'password';
this.togglePwdIcon.classList.replace('fa-eye-slash', 'fa-eye');
this.togglePwdBtn.setAttribute('aria-pressed', 'false');
}
});
}
buildPayload() {
const payload = {
nightly_enabled: this.nightlyToggle.checked ? 'true' : 'false',
};
for (const [key, el] of Object.entries(this.fields)) {
// Only send password if the user actually typed something
if (key === 'smtp_password' && !el.value) continue;
payload[key] = el.value.trim();
}
return payload;
}
async saveSettings() {
this.saveBtn.disabled = true;
this.saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status"></span>Saving...';
this.hideStatus();
try {
const resp = await fetch('/api/config/notifications', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(this.buildPayload()),
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Save failed');
this.showStatus('Settings saved.', 'success');
// Clear password field after save so it shows placeholder again
if (this.fields.smtp_password.value) {
this.fields.smtp_password.value = '';
this.fields.smtp_password.placeholder = '(saved — enter new value to change)';
}
} catch (err) {
this.showStatus('Save failed: ' + err.message, 'danger');
} finally {
this.saveBtn.disabled = false;
this.saveBtn.innerHTML = '<i class="fas fa-save me-1"></i>Save settings';
}
}
async sendTestEmail() {
this.testBtn.disabled = true;
this.testBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status"></span>Sending...';
this.hideStatus();
try {
const resp = await fetch('/api/config/notifications/test', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Send failed');
this.showStatus('Test email sent successfully.', 'success');
} catch (err) {
this.showStatus('Test failed: ' + err.message, 'danger');
} finally {
this.testBtn.disabled = false;
this.testBtn.innerHTML = '<i class="fas fa-paper-plane me-1"></i>Send test email';
}
}
showStatus(msg, type) {
this.saveStatus.textContent = msg;
this.saveStatus.className = `ms-2 small text-${type}`;
this.saveStatus.style.display = 'inline';
if (type === 'success') {
setTimeout(() => this.hideStatus(), 4000);
}
}
hideStatus() {
this.saveStatus.style.display = 'none';
}
}
// ── Radio Reliability ────────────────────────────────────────────────────────
class RadioReliabilityManager {
constructor() {
this.zombieEnabledEl = document.getElementById('zombie-alert-enabled-toggle');
this.zombieEmailEl = document.getElementById('zombie-alert-email');
this.zombieIniEl = document.getElementById('zombie-alert-ini-summary');
this.zombieSaveBtn = document.getElementById('save-zombie-alert-btn');
this.zombieSaveConfigBtn = document.getElementById('save-zombie-alert-config-btn');
this.zombieStatusEl = document.getElementById('zombie-alert-status');
this.debugEnabledEl = document.getElementById('radio-debug-enabled-toggle');
this.debugIniEl = document.getElementById('radio-debug-ini-summary');
this.debugSaveBtn = document.getElementById('save-radio-debug-btn');
this.debugSaveConfigBtn = document.getElementById('save-radio-debug-config-btn');
this.debugApplyBtn = document.getElementById('apply-radio-debug-btn');
this.debugStatusEl = document.getElementById('radio-debug-status');
this.probeIntervalEl = document.getElementById('radio-probe-interval');
this.probeFailThresholdEl = document.getElementById('radio-probe-fail-threshold');
this.probeIniEl = document.getElementById('radio-probe-ini-summary');
this.probeSaveBtn = document.getElementById('save-radio-probe-btn');
this.probeSaveConfigBtn = document.getElementById('save-radio-probe-config-btn');
this.probeStatusEl = document.getElementById('radio-probe-status');
this.offlineThresholdEl = document.getElementById('radio-offline-threshold');
this.offlineAlertEnabledEl = document.getElementById('radio-offline-alert-enabled-toggle');
this.offlineAlertEmailEl = document.getElementById('radio-offline-alert-email');
this.offlineIniEl = document.getElementById('radio-offline-ini-summary');
this.offlineSaveBtn = document.getElementById('save-radio-offline-btn');
this.offlineSaveConfigBtn = document.getElementById('save-radio-offline-config-btn');
this.offlineStatusEl = document.getElementById('radio-offline-status');
if (!this.zombieEnabledEl || !this.debugEnabledEl || !this.probeIntervalEl || !this.offlineThresholdEl) return;
this.initialize();
}
async initialize() {
await this.loadZombieAlertConfig();
await this.loadRadioDebugConfig();
await this.loadRadioProbeConfig();
await this.loadRadioOfflineAlertConfig();
this.setupEventHandlers();
}
setupEventHandlers() {
this.zombieSaveBtn.addEventListener('click', () => this.saveZombieAlert(false));
this.zombieSaveConfigBtn.addEventListener('click', () => this.saveZombieAlert(true));
this.debugSaveBtn.addEventListener('click', () => this.saveRadioDebug(false, false));
this.debugSaveConfigBtn.addEventListener('click', () => this.saveRadioDebug(true, false));
this.debugApplyBtn.addEventListener('click', () => this.saveRadioDebug(true, true));
this.probeSaveBtn.addEventListener('click', () => this.saveRadioProbe(false));
this.probeSaveConfigBtn.addEventListener('click', () => this.saveRadioProbe(true));
this.offlineSaveBtn.addEventListener('click', () => this.saveRadioOfflineAlert(false));
this.offlineSaveConfigBtn.addEventListener('click', () => this.saveRadioOfflineAlert(true));
}
async loadZombieAlertConfig() {
try {
const resp = await fetch('/api/config/zombie-alert');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const metaEnabled = String(data?.meta?.alert_enabled || '').toLowerCase();
const iniEnabled = String(data?.config_ini?.alert_enabled || 'false').toLowerCase();
const effectiveEnabled = metaEnabled ? metaEnabled === 'true' : iniEnabled === 'true';
const effectiveEmail = data?.meta?.alert_email || data?.config_ini?.alert_email || '';
this.zombieEnabledEl.checked = effectiveEnabled;
this.zombieEmailEl.value = effectiveEmail;
const iniEmail = data?.config_ini?.alert_email || '(blank)';
this.zombieIniEl.textContent = `enabled=${iniEnabled}, email=${iniEmail}`;
} catch (err) {
this.showStatus(this.zombieStatusEl, `Failed to load zombie alert config: ${err.message}`, 'danger');
}
}
async loadRadioDebugConfig() {
try {
const resp = await fetch('/api/config/radio-debug');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const metaEnabled = String(data?.meta?.enabled || '').toLowerCase();
const iniEnabled = String(data?.config_ini?.enabled || 'false').toLowerCase();
this.debugEnabledEl.checked = metaEnabled ? metaEnabled === 'true' : iniEnabled === 'true';
this.debugIniEl.textContent = `enabled=${iniEnabled}`;
} catch (err) {
this.showStatus(this.debugStatusEl, `Failed to load radio debug config: ${err.message}`, 'danger');
}
}
async saveZombieAlert(writeToConfig) {
this.setBusy([this.zombieSaveBtn, this.zombieSaveConfigBtn], true);
this.hideStatus(this.zombieStatusEl);
try {
const resp = await fetch('/api/config/zombie-alert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({
alert_enabled: this.zombieEnabledEl.checked ? 'true' : 'false',
alert_email: this.zombieEmailEl.value.trim(),
write_to_config: writeToConfig ? 'true' : 'false',
}),
});
const data = await resp.json();
if (!resp.ok || !data.success) throw new Error(data.error || 'Save failed');
this.showStatus(
this.zombieStatusEl,
writeToConfig ? 'Saved to metadata and config.ini.' : 'Saved to metadata.',
'success',
);
await this.loadZombieAlertConfig();
} catch (err) {
this.showStatus(this.zombieStatusEl, `Save failed: ${err.message}`, 'danger');
} finally {
this.setBusy([this.zombieSaveBtn, this.zombieSaveConfigBtn], false);
}
}
async saveRadioDebug(writeToConfig, reconnect) {
this.setBusy([this.debugSaveBtn, this.debugSaveConfigBtn, this.debugApplyBtn], true);
this.hideStatus(this.debugStatusEl);
try {
const resp = await fetch('/api/config/radio-debug', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({
enabled: this.debugEnabledEl.checked ? 'true' : 'false',
write_to_config: writeToConfig ? 'true' : 'false',
reconnect: reconnect ? 'true' : 'false',
}),
});
const data = await resp.json();
if (!resp.ok || !data.success) throw new Error(data.error || 'Save failed');
const msg = reconnect
? 'Saved and queued reconnect to apply now.'
: writeToConfig
? 'Saved to metadata and config.ini.'
: 'Saved to metadata.';
this.showStatus(this.debugStatusEl, msg, 'success');
await this.loadRadioDebugConfig();
} catch (err) {
this.showStatus(this.debugStatusEl, `Save failed: ${err.message}`, 'danger');
} finally {
this.setBusy([this.debugSaveBtn, this.debugSaveConfigBtn, this.debugApplyBtn], false);
}
}
async loadRadioProbeConfig() {
try {
const resp = await fetch('/api/config/radio-probe');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
this.probeIntervalEl.value = data.probe_interval_seconds || 300;
this.probeFailThresholdEl.value = data.probe_fail_threshold || 3;
this.probeIniEl.textContent = `interval=${data.probe_interval_seconds || 300}s, threshold=${data.probe_fail_threshold || 3}`;
} catch (err) {
this.showStatus(this.probeStatusEl, `Failed to load radio probe config: ${err.message}`, 'danger');
}
}
async saveRadioProbe(writeToConfig) {
this.setBusy([this.probeSaveBtn, this.probeSaveConfigBtn], true);
this.hideStatus(this.probeStatusEl);
try {
const resp = await fetch('/api/config/radio-probe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({
probe_interval_seconds: parseInt(this.probeIntervalEl.value),
probe_fail_threshold: parseInt(this.probeFailThresholdEl.value),
save_to_config: writeToConfig,
}),
});
const data = await resp.json();
if (!resp.ok || !data.success) throw new Error(data.error || 'Save failed');
this.showStatus(
this.probeStatusEl,
writeToConfig ? 'Saved to metadata and config.ini.' : 'Saved to metadata.',
'success',
);
await this.loadRadioProbeConfig();
} catch (err) {
this.showStatus(this.probeStatusEl, `Save failed: ${err.message}`, 'danger');
} finally {
this.setBusy([this.probeSaveBtn, this.probeSaveConfigBtn], false);
}
}
async loadRadioOfflineAlertConfig() {
try {
const resp = await fetch('/api/config/radio-offline-alert');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
this.offlineThresholdEl.value = data.offline_threshold || 3;
this.offlineAlertEnabledEl.checked = data.alert_enabled || false;
this.offlineAlertEmailEl.value = data.alert_email || '';
this.offlineIniEl.textContent = `threshold=${data.offline_threshold || 3}, enabled=${data.alert_enabled || false}, email=${data.alert_email || '(blank)'}`;
} catch (err) {
this.showStatus(this.offlineStatusEl, `Failed to load radio offline alert config: ${err.message}`, 'danger');
}
}
async saveRadioOfflineAlert(writeToConfig) {
this.setBusy([this.offlineSaveBtn, this.offlineSaveConfigBtn], true);
this.hideStatus(this.offlineStatusEl);
try {
const resp = await fetch('/api/config/radio-offline-alert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({
offline_threshold: parseInt(this.offlineThresholdEl.value),
alert_enabled: this.offlineAlertEnabledEl.checked,
alert_email: this.offlineAlertEmailEl.value.trim(),
save_to_config: writeToConfig,
}),
});
const data = await resp.json();
if (!resp.ok || !data.success) throw new Error(data.error || 'Save failed');
this.showStatus(
this.offlineStatusEl,
writeToConfig ? 'Saved to metadata and config.ini.' : 'Saved to metadata.',
'success',
);
await this.loadRadioOfflineAlertConfig();
} catch (err) {
this.showStatus(this.offlineStatusEl, `Save failed: ${err.message}`, 'danger');
} finally {
this.setBusy([this.offlineSaveBtn, this.offlineSaveConfigBtn], false);
}
}
setBusy(buttons, busy) {
buttons.forEach(btn => {
if (btn) btn.disabled = busy;
});
}
showStatus(el, msg, type) {
if (!el) return;
el.textContent = msg;
el.className = `small text-${type}`;
el.style.display = 'inline';
if (type === 'success') setTimeout(() => this.hideStatus(el), 4500);
}
hideStatus(el) {
if (!el) return;
el.style.display = 'none';
}
}
document.addEventListener('DOMContentLoaded', () => {
new ConfigManager();
});
// ── Log Rotation ─────────────────────────────────────────────────────────────
class LogRotationManager {
constructor() {
this.maxBytesEl = document.getElementById('log-max-bytes');
this.backupCountEl = document.getElementById('log-backup-count');
this.saveBtn = document.getElementById('save-log-rotation-btn');
this.statusEl = document.getElementById('log-rotation-status');
this.initialize();
}
async initialize() {
try {
const resp = await fetch('/api/config/logging');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (data.log_max_bytes) this.maxBytesEl.value = data.log_max_bytes;
if (data.log_backup_count) this.backupCountEl.value = data.log_backup_count;
} catch (err) {
this.show('Failed to load: ' + err.message, 'danger');
}
this.saveBtn.addEventListener('click', () => this.save());
}
async save() {
this.saveBtn.disabled = true;
this.saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status"></span>Saving…';
this.hide();
try {
const resp = await fetch('/api/config/logging', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({
log_max_bytes: this.maxBytesEl.value,
log_backup_count: this.backupCountEl.value,
}),
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Save failed');
this.show('Saved. Changes apply within 60 seconds.', 'success');
} catch (err) {
this.show('Save failed: ' + err.message, 'danger');
} finally {
this.saveBtn.disabled = false;
this.saveBtn.innerHTML = '<i class="fas fa-save me-1"></i>Save';
}
}
show(msg, type) {
this.statusEl.textContent = msg;
this.statusEl.className = `ms-2 small text-${type}`;
this.statusEl.style.display = 'inline';
if (type === 'success') setTimeout(() => this.hide(), 4000);
}
hide() { this.statusEl.style.display = 'none'; }
}
// ── DB Backup ─────────────────────────────────────────────────────────────────
class DbBackupManager {
constructor() {
this.enabledToggle = document.getElementById('db-backup-enabled-toggle');
this.scheduleEl = document.getElementById('db-backup-schedule');
this.timeEl = document.getElementById('db-backup-time');
this.retentionEl = document.getElementById('db-backup-retention-count');
this.dirEl = document.getElementById('db-backup-dir');
this.dirErrorEl = document.getElementById('db-backup-dir-error');
this.attachLogEl = document.getElementById('email-attach-log');
this.saveBtn = document.getElementById('save-db-backup-btn');
this.statusEl = document.getElementById('db-backup-status');
this.initialize();
}
async initialize() {
try {
const resp = await fetch('/api/config/maintenance');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
this.enabledToggle.checked = data.db_backup_enabled === 'true';
if (data.db_backup_schedule) this.scheduleEl.value = data.db_backup_schedule;
if (data.db_backup_time) this.timeEl.value = data.db_backup_time;
if (data.db_backup_retention_count) this.retentionEl.value = data.db_backup_retention_count;
if (data.db_backup_dir) this.dirEl.value = data.db_backup_dir;
this.attachLogEl.checked = data.email_attach_log === 'true';
} catch (err) {
this.show('Failed to load: ' + err.message, 'danger');
}
this.saveBtn.addEventListener('click', () => this.save());
const backupNowBtn = document.getElementById('backup-now-btn');
if (backupNowBtn) backupNowBtn.addEventListener('click', () => this.backupNow());
}
async backupNow() {
const btn = document.getElementById('backup-now-btn');
if (btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status"></span>Running…';
}
this.hide();
try {
const resp = await fetch('/api/maintenance/backup_now', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
const data = await resp.json();
if (!resp.ok || !data.success) throw new Error(data.error || 'Backup failed');
const pathInfo = data.path ? ` → ${data.path}` : '';
this.show(`Backup complete${pathInfo}`, 'success');
} catch (err) {
this.show('Backup failed: ' + err.message, 'danger');
} finally {
if (btn) {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-database me-1"></i>Backup Now';
}
}
}
async save() {
this.saveBtn.disabled = true;
this.saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status"></span>Saving…';
this.hide();
if (this.dirErrorEl) this.dirErrorEl.style.display = 'none';
if (this.dirEl) this.dirEl.classList.remove('is-invalid');
try {
const resp = await fetch('/api/config/maintenance', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({
db_backup_enabled: this.enabledToggle.checked ? 'true' : 'false',
db_backup_schedule: this.scheduleEl.value,
db_backup_time: this.timeEl.value,
db_backup_retention_count: this.retentionEl.value,
db_backup_dir: this.dirEl.value.trim(),
email_attach_log: this.attachLogEl.checked ? 'true' : 'false',
}),
});
const data = await resp.json();
if (!resp.ok) {
if (data.error && data.error.includes('Backup directory')) {
if (this.dirEl) this.dirEl.classList.add('is-invalid');
if (this.dirErrorEl) {
this.dirErrorEl.textContent = data.error;
this.dirErrorEl.style.display = '';
}
}
throw new Error(data.error || 'Save failed');
}
this.show('Settings saved.', 'success');
} catch (err) {
this.show('Save failed: ' + err.message, 'danger');
} finally {
this.saveBtn.disabled = false;
this.saveBtn.innerHTML = '<i class="fas fa-save me-1"></i>Save';
}
}
show(msg, type) {
this.statusEl.textContent = msg;
this.statusEl.className = `ms-2 small text-${type}`;
this.statusEl.style.display = 'inline';
if (type === 'success') setTimeout(() => this.hide(), 4000);
}
hide() { this.statusEl.style.display = 'none'; }
}
// ── Maintenance Status ────────────────────────────────────────────────────────
class MaintenanceStatusManager {
constructor() {
this.tbody = document.getElementById('maintenance-status-body');
this.refreshBtn = document.getElementById('refresh-status-btn');
this.refreshBtn.addEventListener('click', () => this.load());
this.load();
}
async load() {
this.refreshBtn.disabled = true;
try {
const resp = await fetch('/api/maintenance/status');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const d = await resp.json();
const jobs = [
{ label: 'Data retention', ran: d.data_retention_ran_at, outcome: d.data_retention_outcome },
{ label: 'Nightly email', ran: d.nightly_email_ran_at, outcome: d.nightly_email_outcome },
{ label: 'Database backup', ran: d.db_backup_ran_at, outcome: d.db_backup_outcome },
{ label: 'Log rotation (applied)', ran: d.log_rotation_applied_at, outcome: '' },
];
this.tbody.replaceChildren();
jobs.forEach(j => {
const row = document.createElement('tr');
const labelCell = document.createElement('td');
labelCell.className = 'fw-medium';
labelCell.textContent = j.label;
const ranCell = document.createElement('td');
ranCell.className = 'text-center config-maint-col-ran';
const ranDisplay = document.createElement('span');
ranDisplay.className = j.ran
? 'text-muted config-maint-time'
: 'text-muted';
ranDisplay.textContent = j.ran
? String(j.ran).replace('T', ' ').split('.')[0]
: 'never';
ranCell.appendChild(ranDisplay);
const outcomeCell = document.createElement('td');
outcomeCell.className = 'text-end config-outcome-cell';
const outcome = document.createElement('span');
if (j.outcome === 'ok') {
outcome.className = 'badge bg-success';
outcome.textContent = 'ok';
} else if (j.outcome) {
outcome.className = 'badge bg-danger';
outcome.title = String(j.outcome);
outcome.textContent = 'error';
} else {
outcome.className = 'text-muted config-outcome-dash';
outcome.setAttribute('aria-hidden', 'true');
outcome.textContent = '—';
}
outcomeCell.appendChild(outcome);
row.append(labelCell, ranCell, outcomeCell);
this.tbody.appendChild(row);
});
} catch (err) {
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = 3;
cell.className = 'text-danger text-center';
cell.textContent = `Failed to load: ${err.message}`;
row.appendChild(cell);
this.tbody.replaceChildren(row);
} finally {
this.refreshBtn.disabled = false;
}
}
}
class RestoreManager {
constructor() {
this.pathEl = document.getElementById('restore-db-path');
this.confirmBtn = document.getElementById('restore-confirm-btn');
this.statusEl = document.getElementById('restore-status');
if (this.confirmBtn) this.confirmBtn.addEventListener('click', () => this.restore());
window.restoreManager = this;
}
async load() {
const loading = document.getElementById('restore-backup-loading');
const empty = document.getElementById('restore-backup-empty');
const table = document.getElementById('restore-backup-table');
const tbody = document.getElementById('restore-backup-tbody');
if (!tbody) return;
loading.style.display = '';
empty.style.display = 'none';
table.style.display = 'none';
tbody.replaceChildren();
try {
const resp = await fetch('/api/maintenance/list_backups');
const data = await resp.json();
loading.style.display = 'none';
if (!data.backups || data.backups.length === 0) {
empty.style.display = '';
return;
}
table.style.display = '';
for (const b of data.backups) {
const dt = new Date(b.mtime * 1000).toLocaleString();
const tr = document.createElement('tr');
const addCell = (value) => {
const td = document.createElement('td');
const small = document.createElement('small');
small.textContent = String(value ?? '');
td.appendChild(small);
tr.appendChild(td);
return td;
};
addCell(b.name);
addCell(`${b.size_mb} MB`);
addCell(dt);
const actionCell = document.createElement('td');
const useButton = document.createElement('button');
useButton.type = 'button';
useButton.className = 'btn btn-xs btn-outline-secondary btn-sm py-0';
useButton.textContent = 'Use';
useButton.addEventListener('click', () => {
this.pathEl.value = String(b.path ?? '');
});
actionCell.appendChild(useButton);
tr.appendChild(actionCell);
tbody.appendChild(tr);
}
} catch (e) {
loading.style.display = 'none';
empty.style.display = '';
empty.textContent = 'Failed to load backup list.';
}
}
async restore() {
const path = this.pathEl ? this.pathEl.value.trim() : '';
if (!path) { this.showStatus('Please enter a backup file path.', 'danger'); return; }
if (!confirm(
`Stage database restore from:\n${path}\n\n` +
'The active database will remain unchanged until the complete MeshCore Bot service is restarted. Continue?'
)) return;
this.confirmBtn.disabled = true;
this.confirmBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Staging…';
try {
const resp = await fetch('/api/maintenance/restore', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({ db_file: path }),
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Restore failed');
this.showStatus(
data.warning || 'Restore verified and staged. Restart the complete MeshCore Bot service to apply it.',
'success',
);
} catch (err) {
this.showStatus('Restore failed: ' + err.message, 'danger');
} finally {
this.confirmBtn.disabled = false;
this.confirmBtn.innerHTML = '<i class="fas fa-undo me-1"></i>Stage Restore';
}
}
showStatus(msg, type) {
if (!this.statusEl) return;
this.statusEl.textContent = msg;
this.statusEl.className = `mt-3 small text-${type}`;
this.statusEl.style.display = '';
}
}
class PurgeManager {
constructor() {
this.keepDaysEl = document.getElementById('purge-keep-days');
this.confirmDaysEl= document.getElementById('purge-confirm-days');
this.confirmTablesEl = document.getElementById('purge-confirm-tables-desc');
this.confirmBtn = document.getElementById('purge-confirm-btn');
this.openConfirmBtn = document.getElementById('purge-open-confirm-btn');
this.allTablesEl = document.getElementById('purge-all-tables');
this.statusEl = document.getElementById('purge-status');
this.resultsEl = document.getElementById('purge-results');
this.resultsBody = document.getElementById('purge-results-body');
if (this.confirmBtn) {
this.confirmBtn.addEventListener('click', () => this.purge());
}
if (this.openConfirmBtn) {
this.openConfirmBtn.addEventListener('click', () => this.openConfirmModal());
}
if (this.allTablesEl) {
this.allTablesEl.addEventListener('change', () => this.syncAllTablesCheckbox());
}
document.querySelectorAll('.purge-table-cb').forEach(cb => {
cb.addEventListener('change', () => this.onIndividualTableChange());
});
window.purgeManager = this;
}
syncAllTablesCheckbox() {
const all = this.allTablesEl && this.allTablesEl.checked;
document.querySelectorAll('.purge-table-cb').forEach(cb => {
cb.disabled = !!all;
if (all) cb.checked = true;
});
}
onIndividualTableChange() {
if (!this.allTablesEl) return;
const boxes = Array.from(document.querySelectorAll('.purge-table-cb'));
if (!boxes.every(b => b.checked)) {
this.allTablesEl.checked = false;
}
}
/** Returns undefined when all tables (omit `tables` in API); else list of names. */
getTablesPayload() {
if (this.allTablesEl && this.allTablesEl.checked) {
return undefined;
}
const sel = Array.from(document.querySelectorAll('.purge-table-cb:checked')).map(cb => cb.value);
return sel;
}
openConfirmModal() {
const tables = this.getTablesPayload();
if (Array.isArray(tables) && tables.length === 0) {
alert('Select at least one table, or choose "All listed tables".');
return;
}
this.prepareConfirm();
const el = document.getElementById('purgeModal');
if (el) bootstrap.Modal.getOrCreateInstance(el).show();
}
prepareConfirm() {
const days = this.keepDaysEl ? this.keepDaysEl.value : '30';
if (this.confirmDaysEl) {
this.confirmDaysEl.textContent = `${days} day${days === '1' ? '' : 's'}`;
}
if (this.confirmTablesEl) {
const payload = this.getTablesPayload();
if (payload === undefined) {
this.confirmTablesEl.textContent = 'from all listed time-series tables.';
} else {
this.confirmTablesEl.textContent = `from these tables: ${payload.map(t => t).join(', ')}.`;
}
}
}
showStatus(msg, type) {
if (!this.statusEl) return;
this.statusEl.textContent = msg;
this.statusEl.className = `small text-${type}`;
this.statusEl.style.display = 'inline';
}
async purge() {
if (this.confirmBtn) this.confirmBtn.disabled = true;
const days = this.keepDaysEl ? parseInt(this.keepDaysEl.value, 10) : 30;
const modal = bootstrap.Modal.getInstance(document.getElementById('purgeModal'));
if (modal) modal.hide();
this.showStatus('Purging…', 'secondary');
if (this.resultsEl) this.resultsEl.style.display = 'none';
try {
const tablesPayload = this.getTablesPayload();
const body = { keep_days: days };
if (tablesPayload !== undefined) {
body.tables = tablesPayload;
}
const resp = await fetch('/api/maintenance/purge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(body),
});
const data = await resp.json();
if (!resp.ok) {
this.showStatus(`Error: ${data.error || resp.statusText}`, 'danger');
return;
}
const deleted = data.deleted || {};
const total = Object.values(deleted).reduce((s, v) => s + v, 0);
this.showStatus(`Done — ${total} row${total === 1 ? '' : 's'} deleted`, 'success');
if (this.resultsBody && Object.keys(deleted).length > 0) {
this.resultsBody.replaceChildren();
Object.entries(deleted).forEach(([tableName, count]) => {
const row = document.createElement('tr');
const tableCell = document.createElement('td');
const code = document.createElement('code');
code.textContent = String(tableName);
tableCell.appendChild(code);
const countCell = document.createElement('td');
countCell.textContent = String(count);
row.append(tableCell, countCell);
this.resultsBody.appendChild(row);
});
if (this.resultsEl) this.resultsEl.style.display = 'block';
}
} catch (e) {
this.showStatus(`Error: ${e.message}`, 'danger');
} finally {
if (this.confirmBtn) this.confirmBtn.disabled = false;
}
}
}
class DatabaseInfoManager {
constructor() {
this.dbData = {};
this.initializeDatabase();
}
async initializeDatabase() {
await this.loadDatabaseData();
this.setupEventHandlers();
this.updateStatistics();
this.renderTablesInfo();
}
async loadDatabaseData() {
try {
const response = await fetch('/api/database');
const data = await response.json();
if (data.error) {
this.showError('Failed to load database data: ' + data.error);
return;
}
this.dbData = data;
} catch (error) {
this.showError('Failed to load database data: ' + error.message);
}
}
setupEventHandlers() {
const refreshBtn = document.getElementById('refresh-db');
const exportBtn = document.getElementById('export-db');
const optimizeBtn = document.getElementById('optimize-db');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
this.loadDatabaseData().then(() => {
this.updateStatistics();
this.renderTablesInfo();
});
});
}
if (exportBtn) exportBtn.addEventListener('click', () => this.exportDatabase());
if (optimizeBtn) optimizeBtn.addEventListener('click', () => this.optimizeDatabase());
}
updateStatistics() {
const setText = (id, value) => {
const el = document.getElementById(id);
if (el) el.textContent = value;
};
setText('total-tables', this.dbData.total_tables || 0);
setText('total-records', this.dbData.total_records || 0);
setText('last-updated', this.dbData.last_updated || '-');
setText('db-size', this.dbData.db_size || '-');
}
renderTablesInfo() {
const infoElement = document.getElementById('tables-info');
if (!infoElement) return;
infoElement.replaceChildren();
if (!this.dbData.tables || this.dbData.tables.length === 0) {
const empty = document.createElement('div');
empty.className = 'text-muted';
empty.textContent = 'No tables found';
infoElement.appendChild(empty);
return;
}
const makeTextElement = (tag, text, className = '') => {
const element = document.createElement(tag);
element.className = className;
element.textContent = String(text ?? '');
return element;
};
this.dbData.tables.forEach(table => {
const row = document.createElement('div');
row.className = 'row mb-3 p-3 border rounded config-db-table-row g-2 align-items-center';
const identity = document.createElement('div');
identity.className = 'col-md-4';
// h4: each table sits under the panel's "Tables" h3
const heading = document.createElement('h4');
heading.className = 'h6 mb-1';
const icon = document.createElement('i');
icon.className = 'fas fa-table config-table-name-icon me-1';
heading.append(icon, document.createTextNode(String(table.name ?? '')));
identity.append(
heading,
makeTextElement('small', table.description || 'Database table', 'text-muted'),
);
const records = document.createElement('div');
records.className = 'col-md-2 text-center';
records.append(
makeTextElement('div', table.record_count || 0, 'h5 mb-0 config-table-metric'),
makeTextElement('small', 'Records', 'text-muted'),
);
const size = document.createElement('div');
size.className = 'col-md-3 text-center';
size.append(
makeTextElement('div', table.size || 'Unknown', 'h6 mb-0'),
makeTextElement('small', 'Size', 'text-muted'),
);
const status = document.createElement('div');
status.className = 'col-md-3 text-center';
const badge = makeTextElement(
'span',
this.getTableStatus(table),
`badge bg-${this.getTableStatusClass(table)}`,
);
status.append(
badge,
document.createElement('br'),
makeTextElement('small', 'Status', 'text-muted'),
);
row.append(identity, records, size, status);
infoElement.appendChild(row);
});
}
getTableStatus(table) {
const count = table.record_count || 0;
if (count === 0) return 'Empty';
if (count < 100) return 'Small';
if (count < 1000) return 'Medium';
if (count < 10000) return 'Large';
return 'Very Large';
}
getTableStatusClass(table) {
const count = table.record_count || 0;
if (count === 0) return 'secondary';
if (count < 100) return 'success';
if (count < 1000) return 'info';
if (count < 10000) return 'warning';
return 'danger';
}
exportDatabase() {
const headers = ['Table Name', 'Record Count', 'Size', 'Status', 'Timestamp'];
const rows = (this.dbData.tables || []).map(table => [
table.name, table.record_count || 0, table.size || 'Unknown', this.getTableStatus(table), new Date().toISOString(),
]);
const csv = [headers, ...rows].map(row => row.map(field => `"${field}"`).join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `database_export_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
window.URL.revokeObjectURL(url);
}
async optimizeDatabase() {
if (!confirm('Are you sure you want to optimize the database? This may take a few moments.')) return;
const optimizeBtn = document.getElementById('optimize-db');
const originalText = optimizeBtn ? optimizeBtn.innerHTML : '';
try {
if (optimizeBtn) {
optimizeBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Optimizing...';
optimizeBtn.disabled = true;
}
const response = await fetch('/api/optimize-database', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
});
const result = await response.json();
if (result.success) {
alert(`Database optimization completed successfully!\n\nResults:\n- ${result.vacuum_result}\n- ${result.analyze_result}\n- ${result.reindex_result}`);
await this.loadDatabaseData();
this.updateStatistics();
this.renderTablesInfo();
} else {
alert('Database optimization failed: ' + (result.error || 'Unknown error'));
}
} catch (error) {
alert('Database optimization failed: ' + error.message);
} finally {
if (optimizeBtn) {
optimizeBtn.innerHTML = originalText || '<i class="fas fa-tools"></i> Optimize';
optimizeBtn.disabled = false;
}
}
}
showError(message) {
const tablesInfo = document.getElementById('tables-info');
if (tablesInfo) {
const alert = document.createElement('div');
alert.className = 'alert alert-danger';
alert.textContent = String(message ?? '');
tablesInfo.replaceChildren(alert);
}
}
}
document.addEventListener('DOMContentLoaded', () => {
const navLinks = Array.from(document.querySelectorAll('[data-config-target]'));
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
navLinks.forEach(link => link.classList.toggle('active', link.dataset.configTarget === entry.target.id));
});
}, { rootMargin: '-25% 0px -65% 0px', threshold: [0.1, 0.5] });
document.querySelectorAll('.config-panel-card[id]').forEach(section => observer.observe(section));
new LogRotationManager();
new DbBackupManager();
new RadioReliabilityManager();
new MaintenanceStatusManager();
new RestoreManager();
new PurgeManager();
window.databaseManager = new DatabaseInfoManager();
});
</script>
{% endblock %}