mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-05-18 05:15:15 +00:00
ad77d7b00d
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
900 lines
44 KiB
HTML
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 & 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 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 242 880 (5 MB). Minimum: 100 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 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 %}
|