fix(web): run manual backups in standalone viewer (#280)

This commit is contained in:
agessaman committed 2026-09-15 20:57:58 -07:00
1 parent a861e848af
commit 3b1edea8af
3 files changed
+35 -57

No files matched your search

+8 -18
View File
@@ -276,16 +276,13 @@ log_max_bytes = 5242880 # 5 MB per file
log_backup_count = 3 # number of rotated files to keep
```
**Database Backup** — schedule automatic backups from the Config tab:
**Database Backup**: schedule automatic backups from the Config tab. The viewer stores these settings in the database, not in a `[Maintenance]` section of `config.ini`:
```ini
[Maintenance]
db_backup_enabled = true
db_backup_schedule = daily # daily | weekly | manual
db_backup_time = 02:00 # HH:MM local time
db_backup_retention_count = 7
db_backup_dir = /data/backups
```
- Enabled
- Schedule: daily, weekly, or manual
- Time: local `HH:MM`
- Retention count
- Backup directory
The **Maintenance Status** card in the Config tab shows the last backup time, next scheduled run, and log rotation status.
@@ -453,15 +450,8 @@ When `json_logging = true` each log line is a JSON object:
```
### Maintenance
```ini
[Maintenance]
db_backup_enabled = false
db_backup_schedule = daily # daily | weekly | manual
db_backup_time = 02:00 # HH:MM local time
db_backup_retention_count = 7
db_backup_dir = /data/backups
email_attach_log = false # attach current log file (≤ 5 MB) to nightly email before rotation
```
Configure database backups and nightly-email log attachments in the Web Viewer Config tab. These settings are stored in the SQLite `bot_metadata` table and do not use a `[Maintenance]` section in `config.ini`.
### Notifications
```ini
+14 -6
View File
@@ -54,6 +54,7 @@ from modules.db_retention import (
retention_delete_settings,
)
from modules.ini_writer import IniValueError, update_ini_values
from modules.maintenance import MaintenanceRunner
from modules.scheduled_message_admin import (
SECTION as SCHEDULED_MESSAGES_SECTION,
)
@@ -634,6 +635,14 @@ class BotDataViewer:
# Now set db_manager on the minimal bot for RepeaterManager
minimal_bot.db_manager = self.db_manager
# The viewer runs as a separate process, so it cannot call the bot's
# MessageScheduler directly. MaintenanceRunner only needs this small
# bot facade for manual database backups.
self._maintenance_runner = MaintenanceRunner(
minimal_bot,
get_current_time=datetime.now,
)
# The viewer only needs RepeaterManager for the manual geocode
# endpoint, so defer its setup until that endpoint is actually used.
self._repeater_manager_bot = minimal_bot
@@ -2200,12 +2209,11 @@ class BotDataViewer:
def api_maintenance_backup_now():
"""Trigger an immediate DB backup outside the normal schedule."""
try:
bot = getattr(self, 'bot', None)
scheduler = getattr(bot, 'scheduler', None) if bot else None
if scheduler is None or not hasattr(scheduler, 'run_db_backup'):
return jsonify({'success': False, 'error': 'Scheduler not available'}), 503
scheduler.run_db_backup()
# Read outcome written by _run_db_backup
runner = getattr(self, '_maintenance_runner', None)
if runner is None:
return jsonify({'success': False, 'error': 'Maintenance runner not available'}), 503
runner.run_db_backup()
# Read the outcome written by MaintenanceRunner.
path = self.db_manager.get_metadata('maint.status.db_backup_path') or ''
outcome = self.db_manager.get_metadata('maint.status.db_backup_outcome') or ''
if outcome.startswith('error'):
+13 -33
View File
@@ -2773,59 +2773,39 @@ class TestDbBackupDirValidation:
class TestBackupNowRoute:
"""Tests for POST /api/maintenance/backup_now endpoint."""
def test_returns_503_when_no_scheduler(self, viewer):
"""Returns 503 when bot/scheduler is not attached."""
# viewer fixture has no bot attached
with viewer.app.test_client() as c:
resp = c.post("/api/maintenance/backup_now")
def test_returns_503_when_no_maintenance_runner(self, viewer):
"""Returns 503 when the viewer's maintenance runner is unavailable."""
with patch.object(viewer, '_maintenance_runner', None):
with viewer.app.test_client() as c:
resp = c.post("/api/maintenance/backup_now")
assert resp.status_code == 503
data = resp.get_json()
assert data["success"] is False
assert "error" in data # Error details sanitized in 5xx responses
def test_returns_200_on_successful_backup(self, viewer, tmp_path):
"""Returns 200 with success=True and path when backup succeeds."""
from unittest.mock import MagicMock, patch
"""The standalone viewer performs a real SQLite backup."""
backup_dir = tmp_path / "backups"
viewer.db_manager.set_metadata('maint.db_backup_dir', str(backup_dir))
mock_scheduler = MagicMock()
def fake_run_db_backup():
# Simulate what run_db_backup writes to metadata
viewer.db_manager.set_metadata(
'maint.status.db_backup_path', str(tmp_path / "test.db")
)
viewer.db_manager.set_metadata('maint.status.db_backup_outcome', 'ok')
mock_scheduler.run_db_backup = fake_run_db_backup
mock_bot = MagicMock()
mock_bot.scheduler = mock_scheduler
with patch.object(viewer, 'bot', mock_bot, create=True):
with viewer.app.test_client() as c:
resp = c.post("/api/maintenance/backup_now")
with viewer.app.test_client() as c:
resp = c.post("/api/maintenance/backup_now")
assert resp.status_code == 200
data = resp.get_json()
assert data["success"] is True
assert "test.db" in data["path"]
assert Path(data["path"]).is_file()
assert Path(data["path"]).parent == backup_dir
def test_returns_500_on_backup_error(self, viewer):
"""Returns 500 with success=False when backup writes an error outcome."""
from unittest.mock import MagicMock, patch
mock_scheduler = MagicMock()
def fake_run_db_backup():
viewer.db_manager.set_metadata('maint.status.db_backup_path', '')
viewer.db_manager.set_metadata(
'maint.status.db_backup_outcome', 'error: cannot create dir'
)
mock_scheduler.run_db_backup = fake_run_db_backup
mock_bot = MagicMock()
mock_bot.scheduler = mock_scheduler
with patch.object(viewer, 'bot', mock_bot, create=True):
with patch.object(viewer._maintenance_runner, 'run_db_backup', fake_run_db_backup):
with viewer.app.test_client() as c:
resp = c.post("/api/maintenance/backup_now")