Add backup/restore scripts and update web/admin UI

This commit is contained in:
ske087
2026-07-08 15:46:41 +03:00
parent f29dcd2e38
commit cac83f8aef
4 changed files with 998 additions and 27 deletions
+156 -2
View File
@@ -1,14 +1,16 @@
"""
Main web routes for dashboard and device management
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file
from app.models import Device, LogEntry, MessageTemplate, AnsibleExecution, PlaybookExecution, WMTUpdateRequest, InventoryGroup, device_inventory_association
from config.database_config import get_db
from app.services.log_service import LogCompressionService
from datetime import datetime, timedelta
from pathlib import Path
from sqlalchemy import text, func
from werkzeug.utils import secure_filename
import logging
import subprocess
import yaml
# Create blueprint
@@ -17,6 +19,21 @@ main_bp = Blueprint('main', __name__)
# Initialize services
log_service = LogCompressionService()
# ── Backup / Restore paths ────────────────────────────────────────────────────
_APP_ROOT = Path(__file__).resolve().parents[2]
_BACKUP_DIR = _APP_ROOT / 'data' / 'backups'
_BACKUP_SCR = _APP_ROOT / 'scripts' / 'backup_system.sh'
_RESTORE_SCR = _APP_ROOT / 'scripts' / 'restore_backup.sh'
_UPLOAD_DIR = _APP_ROOT / 'data' / 'uploads'
def _human_size(n: int) -> str:
for unit in ('B', 'KB', 'MB', 'GB'):
if n < 1024:
return f'{n:.1f} {unit}'
n /= 1024
return f'{n:.1f} TB'
@main_bp.route('/')
def index():
"""Redirect root to devices page"""
@@ -634,4 +651,141 @@ def admin_clear_wmt():
return jsonify({'success': True, 'deleted': count})
except Exception as e:
logging.error(f'Admin clear WMT requests error: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
return jsonify({'success': False, 'error': str(e)}), 500
# ── Backup Manager routes ─────────────────────────────────────────────────────
@main_bp.route('/admin/backup/run', methods=['POST'])
def admin_backup_run():
"""Run the backup script and return JSON with result + output."""
try:
result = subprocess.run(
['bash', str(_BACKUP_SCR), 'backup'],
capture_output=True, text=True, timeout=120,
cwd=str(_APP_ROOT)
)
output = result.stdout + (('\n' + result.stderr) if result.stderr else '')
if result.returncode != 0:
return jsonify({'success': False, 'error': output[-3000:]}), 500
# Extract archive filename from the last "Backup complete:" line
archive = None
for line in result.stdout.splitlines():
if 'Backup complete:' in line and '.tar.gz' in line:
parts = line.split('Backup complete:')[-1].strip().split()
if parts:
archive = Path(parts[0]).name
break
return jsonify({'success': True, 'archive': archive, 'output': output[-3000:]})
except subprocess.TimeoutExpired:
return jsonify({'success': False, 'error': 'Backup timed out (>120 s)'}), 500
except Exception as e:
logging.error(f'admin_backup_run error: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
@main_bp.route('/admin/backup/list')
def admin_backup_list():
"""Return JSON list of existing backup archives sorted newest-first."""
try:
_BACKUP_DIR.mkdir(parents=True, exist_ok=True)
backups = []
for p in sorted(_BACKUP_DIR.glob('smv2_backup_*.tar.gz'),
key=lambda x: x.stat().st_mtime, reverse=True):
st = p.stat()
backups.append({
'name': p.name,
'size': st.st_size,
'size_human': _human_size(st.st_size),
'mtime': datetime.fromtimestamp(st.st_mtime).strftime('%Y-%m-%d %H:%M'),
})
return jsonify({'success': True, 'backups': backups})
except Exception as e:
logging.error(f'admin_backup_list error: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
@main_bp.route('/admin/backup/download/<filename>')
def admin_backup_download(filename):
"""Download a backup archive (path-traversal safe)."""
safe = secure_filename(filename)
if not safe.endswith('.tar.gz') or safe != filename:
return jsonify({'success': False, 'error': 'Invalid filename'}), 400
target = (_BACKUP_DIR / safe).resolve()
# Ensure the resolved path is still inside the backup directory
if not str(target).startswith(str(_BACKUP_DIR.resolve())):
return jsonify({'success': False, 'error': 'Access denied'}), 403
if not target.exists():
return jsonify({'success': False, 'error': 'File not found'}), 404
return send_file(str(target), as_attachment=True, download_name=safe)
@main_bp.route('/admin/backup/restore', methods=['POST'])
def admin_backup_restore():
"""Restore from an existing server-side backup or an uploaded archive."""
mode = request.form.get('mode', 'all')
if mode not in ('all', 'db-only', 'ansible-only'):
return jsonify({'success': False, 'error': 'Invalid restore mode'}), 400
archive_path = None
# Option A: select an existing backup by name
existing = request.form.get('existing_backup', '').strip()
if existing:
safe = secure_filename(existing)
if not safe.endswith('.tar.gz'):
return jsonify({'success': False, 'error': 'Invalid backup filename'}), 400
candidate = (_BACKUP_DIR / safe).resolve()
if not str(candidate).startswith(str(_BACKUP_DIR.resolve())) or not candidate.exists():
return jsonify({'success': False, 'error': 'Backup not found on server'}), 404
archive_path = candidate
# Option B: uploaded file
if archive_path is None and 'backup_file' in request.files:
f = request.files['backup_file']
if f and f.filename:
safe_name = secure_filename(f.filename)
if not safe_name.endswith('.tar.gz'):
return jsonify({'success': False, 'error': 'Only .tar.gz archives accepted'}), 400
_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
upload_path = _UPLOAD_DIR / safe_name
f.save(str(upload_path))
archive_path = upload_path
if archive_path is None:
return jsonify({'success': False, 'error': 'No backup file provided'}), 400
try:
cmd = ['bash', str(_RESTORE_SCR), str(archive_path), '--yes']
if mode != 'all':
cmd.append(f'--{mode}')
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=180,
cwd=str(_APP_ROOT)
)
output = result.stdout + (('\n' + result.stderr) if result.stderr else '')
if result.returncode != 0:
return jsonify({'success': False, 'error': output[-3000:]}), 500
logging.info(f'Admin restore completed: mode={mode} archive={archive_path.name}')
return jsonify({'success': True, 'output': output[-3000:]})
except subprocess.TimeoutExpired:
return jsonify({'success': False, 'error': 'Restore timed out (>180 s)'}), 500
except Exception as e:
logging.error(f'admin_backup_restore error: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
@main_bp.route('/admin/backup/delete/<filename>', methods=['POST'])
def admin_backup_delete(filename):
"""Delete a backup archive by name (path-traversal safe)."""
safe = secure_filename(filename)
if not safe.endswith('.tar.gz'):
return jsonify({'success': False, 'error': 'Invalid filename'}), 400
target = (_BACKUP_DIR / safe).resolve()
if not str(target).startswith(str(_BACKUP_DIR.resolve())):
return jsonify({'success': False, 'error': 'Access denied'}), 403
if not target.exists():
return jsonify({'success': False, 'error': 'File not found'}), 404
target.unlink()
logging.info(f'Admin deleted backup: {safe}')
return jsonify({'success': True, 'deleted': safe})