Files
meshcore-bot/modules/web_viewer/templates/config.html
T
Stacy Olivas ad77d7b00d fix: BUG-025/026/027/028/029 implementations and ruff/mypy refinements
BUG-025: send_channel_message retry logic on no_event_received
BUG-026: split_text_into_chunks and chunked dispatch in message_handler
BUG-027: test_weekly_on_wrong_day_does_not_run patch uses fake_now
BUG-028: byte_data = b"" initialised before try in decode_meshcore_packet
BUG-029: app.py db_path via self._config_base; realtime.html socket race
  fixed; base.html forceNew removed; ping_timeout 5 to 20s

Additional: ruff and mypy refinements across all modules; discord bridge,
telegram bridge, rate limiter, and service plugin updates
2026-03-17 18:07:19 -07:00

900 lines
44 KiB
HTML

{% extends "base.html" %}
{% block title %}Configuration - MeshCore Bot{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="fas fa-cog"></i> Configuration
</h1>
<!-- Email / Notifications -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-envelope me-2"></i>Email &amp; Notifications</h5>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="nightly-enabled-toggle" role="switch">
<label class="form-check-label" for="nightly-enabled-toggle">
Nightly maintenance email
</label>
</div>
</div>
<div class="card-body">
<p class="text-muted small mb-4">
When enabled, a nightly digest is sent summarising maintenance activity
(log rotation, database backup, data retention, error counts).
All fields are stored in the bot database — no config.ini edit required.
</p>
<form id="notifications-form" novalidate>
<!-- SMTP server -->
<h6 class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
SMTP Server
</h6>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label" for="smtp-host">Server hostname</label>
<input type="text" class="form-control" id="smtp-host"
placeholder="smtp.example.com" autocomplete="off">
</div>
<div class="col-md-3">
<label class="form-label" for="smtp-port">Port</label>
<input type="number" class="form-control" id="smtp-port"
placeholder="587" min="1" max="65535">
</div>
<div class="col-md-3">
<label class="form-label" for="smtp-security">Security</label>
<select class="form-select" id="smtp-security">
<option value="starttls">STARTTLS (port 587)</option>
<option value="ssl">SSL / TLS (port 465)</option>
<option value="none">None / plain (port 25)</option>
</select>
</div>
</div>
<!-- Credentials -->
<h6 class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
Credentials
</h6>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label" for="smtp-user">Username / email</label>
<input type="text" class="form-control" id="smtp-user"
placeholder="user@example.com" autocomplete="username">
</div>
<div class="col-md-6">
<label class="form-label" for="smtp-password">Password</label>
<div class="input-group">
<input type="password" class="form-control" id="smtp-password"
placeholder="••••••••" autocomplete="current-password">
<button class="btn btn-outline-secondary" type="button"
id="toggle-password" title="Show / hide password">
<i class="fas fa-eye" id="toggle-password-icon"></i>
</button>
</div>
<div class="form-text">
Stored in the bot database. Use an app-specific password where supported.
</div>
</div>
</div>
<!-- Sender -->
<h6 class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
Sender
</h6>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label" for="from-name">Sender display name</label>
<input type="text" class="form-control" id="from-name"
placeholder="MeshCore Bot">
</div>
<div class="col-md-6">
<label class="form-label" for="from-email">Sender email address</label>
<input type="email" class="form-control" id="from-email"
placeholder="bot@example.com">
</div>
</div>
<!-- Recipients -->
<h6 class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
Recipients
</h6>
<div class="row g-3 mb-4">
<div class="col-12">
<label class="form-label" for="recipients">
Recipient email address(es)
</label>
<input type="text" class="form-control" id="recipients"
placeholder="admin@example.com, ops@example.com">
<div class="form-text">Separate multiple addresses with commas.</div>
</div>
</div>
<!-- Actions -->
<div class="d-flex gap-2 align-items-center">
<button type="button" class="btn btn-primary" id="save-notifications-btn">
<i class="fas fa-save me-1"></i>Save settings
</button>
<button type="button" class="btn btn-outline-secondary" id="test-email-btn">
<i class="fas fa-paper-plane me-1"></i>Send test email
</button>
<span id="save-status" class="ms-2 small" style="display:none;"></span>
</div>
</form>
</div>
</div><!-- /.card -->
<!-- Log Rotation -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-sync-alt me-2"></i>Log Rotation</h5>
</div>
<div class="card-body">
<p class="text-muted small mb-4">
Controls when the log file is rotated and how many backup files are kept.
Changes apply within 60&nbsp;seconds without a restart.
</p>
<form id="log-rotation-form" novalidate>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label" for="log-max-bytes">Max file size (bytes)</label>
<input type="number" class="form-control" id="log-max-bytes"
placeholder="5242880" min="102400">
<div class="form-text">Default: 5&nbsp;242&nbsp;880 (5&nbsp;MB). Minimum: 100&nbsp;KB.</div>
</div>
<div class="col-md-6">
<label class="form-label" for="log-backup-count">Backup file count</label>
<input type="number" class="form-control" id="log-backup-count"
placeholder="3" min="1" max="20">
<div class="form-text">Number of rotated backups to keep (e.g. .log.1, .log.2…).</div>
</div>
</div>
<div class="d-flex gap-2 align-items-center">
<button type="button" class="btn btn-primary" id="save-log-rotation-btn">
<i class="fas fa-save me-1"></i>Save
</button>
<span id="log-rotation-status" class="ms-2 small" style="display:none;"></span>
</div>
</form>
</div>
</div><!-- /.card -->
<!-- Database Backup -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-database me-2"></i>Database Backup</h5>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="db-backup-enabled-toggle" role="switch">
<label class="form-check-label" for="db-backup-enabled-toggle">
Automatic backup enabled
</label>
</div>
</div>
<div class="card-body">
<p class="text-muted small mb-4">
Creates a consistent SQLite backup using the native backup API.
Old backups beyond the retention count are automatically pruned.
</p>
<form id="db-backup-form" novalidate>
<div class="row g-3 mb-4">
<div class="col-md-4">
<label class="form-label" for="db-backup-schedule">Schedule</label>
<select class="form-select" id="db-backup-schedule">
<option value="daily">Daily</option>
<option value="weekly">Weekly (Monday)</option>
<option value="manual">Manual only</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="db-backup-time">Backup time (HH:MM)</label>
<input type="time" class="form-control" id="db-backup-time" value="02:00">
</div>
<div class="col-md-4">
<label class="form-label" for="db-backup-retention-count">Backups to keep</label>
<input type="number" class="form-control" id="db-backup-retention-count"
placeholder="7" min="1" max="90">
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-12">
<label class="form-label" for="db-backup-dir">Backup directory</label>
<input type="text" class="form-control" id="db-backup-dir"
placeholder="/data/backups">
<div class="form-text">Absolute path. The directory must already exist.</div>
<div id="db-backup-dir-error" class="text-danger small mt-1" style="display:none"></div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="email-attach-log">
<label class="form-check-label" for="email-attach-log">
Attach current log file to nightly email
<span class="text-muted small">(only if file is ≤ 5&nbsp;MB)</span>
</label>
</div>
</div>
</div>
<div class="d-flex gap-2 align-items-center">
<button type="button" class="btn btn-primary" id="save-db-backup-btn">
<i class="fas fa-save me-1"></i>Save
</button>
<button type="button" class="btn btn-secondary" id="backup-now-btn">
<i class="fas fa-database me-1"></i>Backup Now
</button>
<button type="button" class="btn btn-outline-warning" id="restore-btn"
data-bs-toggle="modal" data-bs-target="#restoreModal"
onclick="window.restoreManager && window.restoreManager.load()">
<i class="fas fa-undo me-1"></i>Restore
</button>
<span id="db-backup-status" class="ms-2 small" style="display:none;"></span>
</div>
</form>
</div>
</div><!-- /.card -->
<!-- Maintenance Status -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-tasks me-2"></i>Maintenance Status</h5>
<button type="button" class="btn btn-sm btn-outline-secondary" id="refresh-status-btn">
<i class="fas fa-redo me-1"></i>Refresh
</button>
</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0" id="maintenance-status-table">
<thead class="table-light">
<tr>
<th>Job</th>
<th>Last ran (UTC)</th>
<th>Outcome</th>
</tr>
</thead>
<tbody id="maintenance-status-body">
<tr><td colspan="3" class="text-center text-muted py-3">Loading…</td></tr>
</tbody>
</table>
</div>
</div><!-- /.card -->
</div>
</div>
</div>
<!-- Database Operations Card -->
<div class="container-fluid mt-4">
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-trash-alt me-2"></i>Database Operations</h5>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Permanently delete old records from time-series tables
(<code>packet_stream</code>, <code>message_stats</code>,
<code>complete_contact_tracking</code>, <code>purging_log</code>,
<code>mesh_connections</code>, <code>daily_stats</code>).
This cannot be undone — back up first if needed.
</p>
<div class="row g-3 align-items-end">
<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">
<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"
data-bs-toggle="modal" data-bs-target="#purgeModal"
onclick="window.purgeManager && window.purgeManager.prepareConfirm()">
<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>
<table class="table table-sm table-bordered" style="max-width:400px">
<thead><tr><th>Table</th><th>Deleted</th></tr></thead>
<tbody id="purge-results-body"></tbody>
</table>
</div>
</div>
</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> from all time-series tables.</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>Restore Database
</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>Warning:</strong> Restoring will overwrite the active database.
The bot must be restarted after restore for changes to take effect.
</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>Restore
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
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');
} else {
pwdField.type = 'password';
this.togglePwdIcon.classList.replace('fa-eye-slash', 'fa-eye');
}
});
}
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' },
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' });
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';
}
}
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' },
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' });
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' },
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.innerHTML = jobs.map(j => {
const badge = !j.ran ? '<span class="text-muted">never</span>'
: `<span class="text-muted">${j.ran.replace('T', ' ').split('.')[0]}</span>`;
const outBadge = !j.outcome ? ''
: j.outcome === 'ok'
? '<span class="badge bg-success">ok</span>'
: `<span class="badge bg-danger" title="${j.outcome}">error</span>`;
return `<tr><td>${j.label}</td><td>${badge}</td><td>${outBadge}</td></tr>`;
}).join('');
} catch (err) {
this.tbody.innerHTML = `<tr><td colspan="3" class="text-danger text-center">Failed to load: ${err.message}</td></tr>`;
} 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.innerHTML = '';
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');
tr.innerHTML = `
<td><small>${b.name}</small></td>
<td><small>${b.size_mb} MB</small></td>
<td><small>${dt}</small></td>
<td><button class="btn btn-xs btn-outline-secondary btn-sm py-0"
onclick="document.getElementById('restore-db-path').value='${b.path}'">
Use</button></td>`;
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(`Restore database from:\n${path}\n\nThis will overwrite the active database. Continue?`)) return;
this.confirmBtn.disabled = true;
this.confirmBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Restoring…';
try {
const resp = await fetch('/api/maintenance/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ db_file: path }),
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Restore failed');
this.showStatus(`Restored successfully. ${data.warning || ''}`, '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>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.confirmBtn = document.getElementById('purge-confirm-btn');
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());
}
window.purgeManager = this;
}
prepareConfirm() {
const days = this.keepDaysEl ? this.keepDaysEl.value : '30';
if (this.confirmDaysEl) {
this.confirmDaysEl.textContent = `${days} day${days === '1' ? '' : 's'}`;
}
}
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 resp = await fetch('/api/maintenance/purge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keep_days: days }),
});
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.innerHTML = Object.entries(deleted)
.map(([t, c]) => `<tr><td><code>${t}</code></td><td>${c}</td></tr>`)
.join('');
if (this.resultsEl) this.resultsEl.style.display = 'block';
}
} catch (e) {
this.showStatus(`Error: ${e.message}`, 'danger');
} finally {
if (this.confirmBtn) this.confirmBtn.disabled = false;
}
}
}
document.addEventListener('DOMContentLoaded', () => {
new LogRotationManager();
new DbBackupManager();
new MaintenanceStatusManager();
new RestoreManager();
new PurgeManager();
});
</script>
{% endblock %}