Add backup/restore scripts and update web/admin UI
This commit is contained in:
+156
-2
@@ -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})
|
||||
Reference in New Issue
Block a user