feat(backup): add backup API endpoints and UI

- POST /api/backup/create — trigger immediate backup
- GET /api/backup/list — list backups with sizes
- GET /api/backup/download — download backup file
- Backup modal accessible from menu with create/download buttons
- Daily automatic backup via APScheduler (configurable hour/retention)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-03-12 07:22:50 +01:00
parent d6e2a3472a
commit 4ecab9b307
4 changed files with 263 additions and 0 deletions

View File

@@ -21,6 +21,7 @@ _scheduler: Optional[BackgroundScheduler] = None
# Job IDs
CLEANUP_JOB_ID = 'daily_cleanup'
RETENTION_JOB_ID = 'daily_retention'
BACKUP_JOB_ID = 'daily_backup'
# Module-level db reference (set by init_retention_schedule)
_db = None
@@ -603,10 +604,60 @@ def schedule_daily_archiving():
# Initialize cleanup schedule from saved settings
init_cleanup_schedule()
# Initialize backup schedule
init_backup_schedule()
except Exception as e:
logger.error(f"Failed to start archive scheduler: {e}", exc_info=True)
def init_backup_schedule():
"""Initialize daily backup job from config."""
global _scheduler, _db
if _scheduler is None or _db is None:
return
if not config.MC_BACKUP_ENABLED:
logger.info("Backup is disabled in configuration")
return
try:
backup_hour = config.MC_BACKUP_HOUR
trigger = CronTrigger(hour=backup_hour, minute=0)
backup_dir = Path(config.MC_CONFIG_DIR) / 'backups'
_scheduler.add_job(
func=_backup_job,
trigger=trigger,
id=BACKUP_JOB_ID,
name='Daily Database Backup',
replace_existing=True,
args=[backup_dir]
)
logger.info(f"Backup schedule initialized: daily at {backup_hour:02d}:00")
except Exception as e:
logger.error(f"Error scheduling backup: {e}", exc_info=True)
def _backup_job(backup_dir):
"""Execute daily backup and cleanup old backups."""
global _db
if _db is None:
logger.warning("No database reference for backup")
return
try:
backup_path = _db.create_backup(backup_dir)
logger.info(f"Daily backup completed: {backup_path}")
removed = _db.cleanup_old_backups(backup_dir, config.MC_BACKUP_RETENTION_DAYS)
if removed > 0:
logger.info(f"Cleaned up {removed} old backup(s)")
except Exception as e:
logger.error(f"Backup job failed: {e}", exc_info=True)
def stop_scheduler():
"""
Stop the background scheduler.

View File

@@ -3783,3 +3783,91 @@ def clear_console_history():
'success': False,
'error': str(e)
}), 500
# =============================================================================
# Backup Endpoints
# =============================================================================
@api_bp.route('/backup/list', methods=['GET'])
def list_backups():
"""List available database backups."""
try:
dm = current_app.config.get('DEVICE_MANAGER')
db = dm.db if dm else None
if not db:
return jsonify({'success': False, 'error': 'Database not available'}), 503
backup_dir = Path(config.MC_CONFIG_DIR) / 'backups'
backups = db.list_backups(backup_dir)
# Format sizes for display
for b in backups:
size = b.get('size_bytes', 0)
if size >= 1024 * 1024:
b['size_display'] = f"{size / (1024*1024):.1f} MB"
elif size >= 1024:
b['size_display'] = f"{size / 1024:.1f} KB"
else:
b['size_display'] = f"{size} B"
return jsonify({
'success': True,
'backups': backups,
'auto_backup_enabled': config.MC_BACKUP_ENABLED,
'backup_hour': config.MC_BACKUP_HOUR,
'retention_days': config.MC_BACKUP_RETENTION_DAYS,
}), 200
except Exception as e:
logger.error(f"Error listing backups: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/backup/create', methods=['POST'])
def create_backup():
"""Create an immediate database backup."""
try:
dm = current_app.config.get('DEVICE_MANAGER')
db = dm.db if dm else None
if not db:
return jsonify({'success': False, 'error': 'Database not available'}), 503
backup_dir = Path(config.MC_CONFIG_DIR) / 'backups'
backup_path = db.create_backup(backup_dir)
return jsonify({
'success': True,
'message': 'Backup created',
'filename': backup_path.name,
'size_bytes': backup_path.stat().st_size,
}), 200
except Exception as e:
logger.error(f"Error creating backup: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/backup/download', methods=['GET'])
def download_backup():
"""Download a backup file."""
filename = request.args.get('file', '')
if not filename:
return jsonify({'success': False, 'error': 'Missing file parameter'}), 400
# Security: prevent path traversal
if '/' in filename or '\\' in filename or '..' in filename:
return jsonify({'success': False, 'error': 'Invalid filename'}), 400
backup_dir = Path(config.MC_CONFIG_DIR) / 'backups'
backup_path = backup_dir / filename
if not backup_path.exists():
return jsonify({'success': False, 'error': 'Backup not found'}), 404
return send_file(
str(backup_path),
mimetype='application/x-sqlite3',
as_attachment=True,
download_name=filename
)

View File

@@ -3756,3 +3756,95 @@ function highlightSearchTerm(html, query) {
// Initialize search when DOM is ready
document.addEventListener('DOMContentLoaded', initializeSearch);
// =============================================================================
// Backup Management
// =============================================================================
function initializeBackup() {
document.getElementById('backupModal')?.addEventListener('shown.bs.modal', loadBackupList);
}
async function loadBackupList() {
const container = document.getElementById('backupList');
const statusEl = document.getElementById('backupAutoStatus');
if (!container) return;
container.innerHTML = '<div class="text-center text-muted py-3"><div class="spinner-border spinner-border-sm"></div> Loading...</div>';
try {
const response = await fetch('/api/backup/list');
const data = await response.json();
if (!data.success) {
container.innerHTML = `<div class="alert alert-danger">${escapeHtml(data.error)}</div>`;
return;
}
// Show auto-backup status
if (statusEl) {
statusEl.textContent = data.auto_backup_enabled
? `Auto: daily at ${String(data.backup_hour).padStart(2, '0')}:00, keep ${data.retention_days}d`
: 'Auto-backup disabled';
}
if (data.backups.length === 0) {
container.innerHTML = '<div class="text-center text-muted py-3"><i class="bi bi-inbox"></i><p class="mt-2 mb-0">No backups yet</p></div>';
return;
}
const list = document.createElement('div');
list.className = 'list-group';
data.backups.forEach(b => {
const item = document.createElement('div');
item.className = 'list-group-item d-flex justify-content-between align-items-center';
item.innerHTML = `
<div>
<i class="bi bi-file-earmark-zip"></i>
<span class="ms-1">${escapeHtml(b.filename)}</span>
<small class="text-muted ms-2">${b.size_display}</small>
</div>
<a href="/api/backup/download?file=${encodeURIComponent(b.filename)}" class="btn btn-sm btn-outline-primary" title="Download">
<i class="bi bi-download"></i>
</a>
`;
list.appendChild(item);
});
container.innerHTML = '';
container.appendChild(list);
} catch (error) {
console.error('Error loading backups:', error);
container.innerHTML = '<div class="alert alert-danger">Failed to load backups</div>';
}
}
async function createBackup() {
const btn = document.getElementById('createBackupBtn');
if (!btn) return;
btn.disabled = true;
btn.innerHTML = '<div class="spinner-border spinner-border-sm"></div> Creating...';
try {
const response = await fetch('/api/backup/create', { method: 'POST' });
const data = await response.json();
if (data.success) {
showNotification(`Backup created: ${data.filename}`, 'success');
loadBackupList();
} else {
showNotification('Backup failed: ' + data.error, 'danger');
}
} catch (error) {
console.error('Error creating backup:', error);
showNotification('Backup failed', 'danger');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-plus-circle"></i> Create Backup';
}
}
document.addEventListener('DOMContentLoaded', initializeBackup);

View File

@@ -148,6 +148,13 @@
<i class="bi bi-cpu" style="font-size: 1.5rem;"></i>
<span>Device Info</span>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#backupModal" data-bs-dismiss="offcanvas">
<i class="bi bi-database-down" style="font-size: 1.5rem;"></i>
<div>
<span>Backup</span>
<small class="d-block text-muted">Database backup & restore</small>
</div>
</button>
</div>
</div>
</div>
@@ -355,6 +362,31 @@
</div>
</div>
<!-- Backup Modal -->
<div class="modal fade" id="backupModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-database-down"></i> Database Backup</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<button class="btn btn-primary" id="createBackupBtn" onclick="createBackup()">
<i class="bi bi-plus-circle"></i> Create Backup
</button>
<span id="backupAutoStatus" class="text-muted small"></span>
</div>
<div id="backupList">
<div class="text-center text-muted py-3">
<div class="spinner-border spinner-border-sm"></div> Loading...
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Search Modal -->
<div class="modal fade" id="searchModal" tabindex="-1">
<div class="modal-dialog modal-lg">