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 = '
No backups yet