diff --git a/app/archiver/manager.py b/app/archiver/manager.py index ebf6535..7b7b3e9 100644 --- a/app/archiver/manager.py +++ b/app/archiver/manager.py @@ -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. diff --git a/app/routes/api.py b/app/routes/api.py index 5690191..3356c60 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -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 + ) diff --git a/app/static/js/app.js b/app/static/js/app.js index ff06504..41bb464 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -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 = '
Loading...
'; + + try { + const response = await fetch('/api/backup/list'); + const data = await response.json(); + + if (!data.success) { + container.innerHTML = `
${escapeHtml(data.error)}
`; + 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 = '

No backups yet

'; + 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 = ` +
+ + ${escapeHtml(b.filename)} + ${b.size_display} +
+ + + + `; + list.appendChild(item); + }); + + container.innerHTML = ''; + container.appendChild(list); + + } catch (error) { + console.error('Error loading backups:', error); + container.innerHTML = '
Failed to load backups
'; + } +} + +async function createBackup() { + const btn = document.getElementById('createBackupBtn'); + if (!btn) return; + + btn.disabled = true; + btn.innerHTML = '
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 = ' Create Backup'; + } +} + +document.addEventListener('DOMContentLoaded', initializeBackup); + diff --git a/app/templates/base.html b/app/templates/base.html index ba096ea..199e714 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -148,6 +148,13 @@ Device Info + @@ -355,6 +362,31 @@ + + +