mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-03-28 17:42:45 +01:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user