diff --git a/app/web/main.py b/app/web/main.py index 8051361..2485c19 100644 --- a/app/web/main.py +++ b/app/web/main.py @@ -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 \ No newline at end of file + 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/') +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/', 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}) \ No newline at end of file diff --git a/scripts/backup_system.sh b/scripts/backup_system.sh new file mode 100755 index 0000000..cac57b9 --- /dev/null +++ b/scripts/backup_system.sh @@ -0,0 +1,296 @@ +#!/bin/bash +# ============================================================================= +# Server Monitorizare v2 - Backup System +# ============================================================================= +# Backs up: +# - SQLite database (safe online backup via sqlite3 .backup) +# - Ansible inventory, playbooks, ansible.cfg, ssh_keys +# - WMT releases and ansible_settings.json +# - JSON export of critical device/config data (human-readable, portable) +# +# Usage: +# ./scripts/backup_system.sh # run backup now +# ./scripts/backup_system.sh install-cron # install daily cron job +# ./scripts/backup_system.sh list # list existing backups +# ./scripts/backup_system.sh verify # verify a backup archive +# ============================================================================= + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_DIR="$(dirname "$SCRIPT_DIR")" +BACKUP_DIR="$APP_DIR/data/backups" +DB_PATH="$APP_DIR/data/enhanced_monitoring.db" +LOG_FILE="$APP_DIR/logs/backup.log" +KEEP_DAILY=7 # keep last 7 daily backups +KEEP_WEEKLY=4 # keep last 4 weekly backups (Sunday) +TIMESTAMP=$(date +"%Y%m%d_%H%M%S") +DAY_OF_WEEK=$(date +%u) # 1=Monday … 7=Sunday +BACKUP_NAME="smv2_backup_${TIMESTAMP}" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +log() { + local level="$1"; shift + local msg="$*" + local ts; ts=$(date '+%Y-%m-%d %H:%M:%S') + echo "[$ts] [$level] $msg" | tee -a "$LOG_FILE" +} + +require_cmd() { + command -v "$1" &>/dev/null || { log ERROR "Required command not found: $1"; exit 1; } +} + +usage() { + echo "Usage: $0 [backup|install-cron|list|verify ]" + echo " (no argument) Run a full backup now" + echo " install-cron Install daily cron job at 02:00" + echo " list List existing backups with sizes" + echo " verify Verify integrity of a backup archive" + exit 0 +} + +# --------------------------------------------------------------------------- +# Subcommand: list +# --------------------------------------------------------------------------- +do_list() { + echo "=== Existing backups in $BACKUP_DIR ===" + if ls "$BACKUP_DIR"/*.tar.gz &>/dev/null; then + ls -lh "$BACKUP_DIR"/*.tar.gz | awk '{print $5, $9}' + else + echo " (no .tar.gz backups found)" + fi + echo "" + if ls "$BACKUP_DIR"/*.bak &>/dev/null; then + echo "=== Legacy .bak files ===" + ls -lh "$BACKUP_DIR"/*.bak | awk '{print $5, $9}' + fi +} + +# --------------------------------------------------------------------------- +# Subcommand: verify +# --------------------------------------------------------------------------- +do_verify() { + local archive="$1" + [[ -f "$archive" ]] || { echo "File not found: $archive"; exit 1; } + echo "Verifying: $archive" + tar -tzf "$archive" | head -30 + echo "..." + echo "OK: archive appears intact ($(tar -tzf "$archive" | wc -l) entries)" +} + +# --------------------------------------------------------------------------- +# Subcommand: install-cron +# --------------------------------------------------------------------------- +do_install_cron() { + local cron_line="0 2 * * * cd $APP_DIR && bash $SCRIPT_DIR/backup_system.sh >> $APP_DIR/logs/backup_cron.log 2>&1" + # Check if already installed + if crontab -l 2>/dev/null | grep -qF "backup_system.sh"; then + echo "Cron job already installed:" + crontab -l | grep "backup_system.sh" + return + fi + # Append to crontab + (crontab -l 2>/dev/null; echo "$cron_line") | crontab - + echo "Cron job installed: daily at 02:00" + echo " $cron_line" + echo "" + echo "To remove it later: crontab -e (and delete the backup_system.sh line)" +} + +# --------------------------------------------------------------------------- +# Rotate old backups +# --------------------------------------------------------------------------- +rotate_backups() { + log INFO "Rotating old backups (keep daily=$KEEP_DAILY, weekly=$KEEP_WEEKLY)..." + + # Weekly backups: files tagged with "_weekly_" — rotate keeping last N + local weekly_count + weekly_count=$(find "$BACKUP_DIR" -maxdepth 1 -name 'smv2_backup_*_weekly_*.tar.gz' | wc -l) + if (( weekly_count > KEEP_WEEKLY )); then + find "$BACKUP_DIR" -maxdepth 1 -name 'smv2_backup_*_weekly_*.tar.gz' \ + | sort -r | tail -n +"$((KEEP_WEEKLY + 1))" \ + | xargs -r rm -v -- + log INFO "Removed old weekly backups (kept $KEEP_WEEKLY)" + fi + + # Daily backups: all other tar.gz files (not tagged _weekly_) + local daily_count + daily_count=$(find "$BACKUP_DIR" -maxdepth 1 -name 'smv2_backup_*.tar.gz' \ + | grep -v '_weekly_' | wc -l) + if (( daily_count > KEEP_DAILY )); then + find "$BACKUP_DIR" -maxdepth 1 -name 'smv2_backup_*.tar.gz' \ + | grep -v '_weekly_' | sort -r \ + | tail -n +"$((KEEP_DAILY + 1))" \ + | xargs -r rm -v -- + log INFO "Removed old daily backups (kept $KEEP_DAILY)" + fi +} + +# --------------------------------------------------------------------------- +# Export critical tables to JSON (portable, readable by restore script) +# --------------------------------------------------------------------------- +export_json() { + local export_dir="$1" + log INFO "Exporting critical tables to JSON..." + + python3 - "$DB_PATH" "$export_dir" <<'PYEOF' +import sys, sqlite3, json, os + +db_path = sys.argv[1] +out_dir = sys.argv[2] +os.makedirs(out_dir, exist_ok=True) + +conn = sqlite3.connect(db_path) +conn.row_factory = sqlite3.Row + +# Tables that are essential for migration (config + operational data) +# Log/stats tables are excluded — they can be huge and are not needed on new server +CRITICAL_TABLES = [ + 'devices', + 'inventory_groups', + 'device_inventory_groups', + 'wmt_global_config', + 'wmt_update_requests', + 'message_templates', +] + +for table in CRITICAL_TABLES: + try: + rows = conn.execute(f"SELECT * FROM {table}").fetchall() + data = [dict(r) for r in rows] + with open(os.path.join(out_dir, f"{table}.json"), 'w') as f: + json.dump(data, f, indent=2, default=str) + print(f" Exported {table}: {len(data)} rows") + except Exception as e: + print(f" WARNING: could not export {table}: {e}") + +conn.close() +PYEOF +} + +# --------------------------------------------------------------------------- +# Main backup routine +# --------------------------------------------------------------------------- +do_backup() { + require_cmd sqlite3 + require_cmd tar + require_cmd python3 + + mkdir -p "$BACKUP_DIR" + mkdir -p "$(dirname "$LOG_FILE")" + + # Tag Sunday backups as weekly + local tag="" + if [[ "$DAY_OF_WEEK" == "7" ]]; then + tag="_weekly" + BACKUP_NAME="smv2_backup_${TIMESTAMP}${tag}" + fi + + STAGE_DIR=$(mktemp -d) + trap 'rm -rf "$STAGE_DIR"' EXIT + + log INFO "========================================================" + log INFO "Starting backup: $BACKUP_NAME" + log INFO "Stage dir: $STAGE_DIR" + + # ------------------------------------------------------------------ + # 1. Safe SQLite backup (uses SQLite's online backup API — safe while + # the application is running, produces a consistent snapshot) + # ------------------------------------------------------------------ + log INFO "Backing up SQLite database..." + sqlite3 "$DB_PATH" ".backup '$STAGE_DIR/enhanced_monitoring.db'" + log INFO "Database backed up ($(du -sh "$STAGE_DIR/enhanced_monitoring.db" | cut -f1))" + + # ------------------------------------------------------------------ + # 2. JSON export of critical tables + # ------------------------------------------------------------------ + export_json "$STAGE_DIR/json_export" + + # ------------------------------------------------------------------ + # 3. Ansible directory (inventory, playbooks, cfg, ssh_keys) + # ssh_keys are included because ansible.cfg references them and + # are needed for the new server to operate. + # Permissions on ssh_keys (600) are preserved by tar. + # ------------------------------------------------------------------ + log INFO "Backing up Ansible configuration..." + cp -a "$APP_DIR/ansible" "$STAGE_DIR/ansible" + + # ------------------------------------------------------------------ + # 4. ansible_settings.json (fallback SSH password, auth mode) + # ------------------------------------------------------------------ + if [[ -f "$APP_DIR/data/ansible_settings.json" ]]; then + cp "$APP_DIR/data/ansible_settings.json" "$STAGE_DIR/" + log INFO "Backed up ansible_settings.json" + fi + + # ------------------------------------------------------------------ + # 5. WMT releases (zip packages used by playbooks) + # ------------------------------------------------------------------ + if [[ -d "$APP_DIR/data/wmt_releases" ]] && \ + [[ -n "$(ls "$APP_DIR/data/wmt_releases/" 2>/dev/null)" ]]; then + cp -a "$APP_DIR/data/wmt_releases" "$STAGE_DIR/wmt_releases" + log INFO "Backed up wmt_releases ($(du -sh "$STAGE_DIR/wmt_releases" | cut -f1))" + fi + + # ------------------------------------------------------------------ + # 6. App config (excludes secrets already in ansible_settings.json) + # ------------------------------------------------------------------ + cp -a "$APP_DIR/config" "$STAGE_DIR/config" + + # ------------------------------------------------------------------ + # 7. Create manifest + # ------------------------------------------------------------------ + cat > "$STAGE_DIR/BACKUP_MANIFEST.txt" </dev/null || echo 0) + +Included files: +$(find "$STAGE_DIR" -type f | sed "s|$STAGE_DIR/||" | sort) +EOF + + log INFO "Manifest written" + + # ------------------------------------------------------------------ + # 8. Compress everything into a single archive + # ------------------------------------------------------------------ + local ARCHIVE="$BACKUP_DIR/${BACKUP_NAME}.tar.gz" + log INFO "Compressing to $ARCHIVE ..." + tar -czf "$ARCHIVE" -C "$STAGE_DIR" . + local size; size=$(du -sh "$ARCHIVE" | cut -f1) + log INFO "Archive created: $ARCHIVE ($size)" + + # ------------------------------------------------------------------ + # 9. Rotate old backups + # ------------------------------------------------------------------ + rotate_backups + + log INFO "Backup complete: $ARCHIVE" + log INFO "========================================================" + echo "" + echo "Backup complete: $ARCHIVE ($size)" +} + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- +case "${1:-backup}" in + backup) do_backup ;; + install-cron) do_install_cron ;; + list) do_list ;; + verify) [[ -n "${2:-}" ]] || usage; do_verify "$2" ;; + help|--help|-h) usage ;; + *) usage ;; +esac diff --git a/scripts/restore_backup.sh b/scripts/restore_backup.sh new file mode 100755 index 0000000..aea6da2 --- /dev/null +++ b/scripts/restore_backup.sh @@ -0,0 +1,257 @@ +#!/bin/bash +# ============================================================================= +# Server Monitorizare v2 - Migration Restore Script +# ============================================================================= +# Restores a backup archive produced by backup_system.sh onto a new server. +# +# Usage: +# ./scripts/restore_backup.sh [--db-only] [--ansible-only] +# +# What it does: +# 1. Extracts the archive to a temp staging area +# 2. Restores the SQLite database (with a safety backup of any existing DB) +# 3. Restores Ansible inventory, playbooks, ssh_keys, ansible.cfg +# 4. Restores WMT releases and ansible_settings.json +# 5. Fixes SSH key permissions (must be 600 for Ansible) +# 6. Prints post-restore checklist +# +# For a fresh server install, run this AFTER: +# pip install -r requirements.txt +# ============================================================================= + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_DIR="$(dirname "$SCRIPT_DIR")" +TIMESTAMP=$(date +"%Y%m%d_%H%M%S") + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +log() { echo "[$(date '+%H:%M:%S')] $*"; } +ok() { echo " [OK] $*"; } +warn() { echo " [WARN] $*"; } +err() { echo " [ERROR] $*" >&2; } + +usage() { + echo "Usage: $0 [--db-only | --ansible-only]" + echo "" + echo "Options:" + echo " --db-only Restore only the SQLite database" + echo " --ansible-only Restore only Ansible files (inventory, playbooks, ssh_keys)" + echo "" + echo "Example:" + echo " $0 data/backups/smv2_backup_20260708_020000.tar.gz" + exit 1 +} + +# --------------------------------------------------------------------------- +# Parse args +# --------------------------------------------------------------------------- +ARCHIVE="${1:-}" +MODE="all" # all | db-only | ansible-only +YES=0 # set to 1 by --yes to skip interactive confirmation + +[[ -n "$ARCHIVE" ]] || usage +[[ -f "$ARCHIVE" ]] || { err "Archive not found: $ARCHIVE"; exit 1; } + +for arg in "${@:2}"; do + case "$arg" in + --db-only) MODE="db-only" ;; + --ansible-only) MODE="ansible-only" ;; + --yes|-y) YES=1 ;; + *) err "Unknown option: $arg"; usage ;; + esac +done + +# --------------------------------------------------------------------------- +# Extract archive +# --------------------------------------------------------------------------- +log "Extracting archive: $ARCHIVE" +STAGE_DIR=$(mktemp -d) +trap 'rm -rf "$STAGE_DIR"' EXIT + +tar -xzf "$ARCHIVE" -C "$STAGE_DIR" +log "Extracted to staging: $STAGE_DIR" + +# Show manifest if present +if [[ -f "$STAGE_DIR/BACKUP_MANIFEST.txt" ]]; then + echo "" + echo "=== Backup Manifest ===" + cat "$STAGE_DIR/BACKUP_MANIFEST.txt" + echo "========================" + echo "" +fi + +# --------------------------------------------------------------------------- +# Confirm before proceeding +# --------------------------------------------------------------------------- +if [[ "$YES" == "1" ]]; then + log "Non-interactive mode (--yes) — proceeding automatically." +else + read -r -p "Proceed with restore to $APP_DIR ? [y/N] " confirm + [[ "${confirm,,}" == "y" ]] || { log "Aborted."; exit 0; } +fi + +# --------------------------------------------------------------------------- +# Restore: SQLite database +# --------------------------------------------------------------------------- +restore_db() { + local src="$STAGE_DIR/enhanced_monitoring.db" + local dst="$APP_DIR/data/enhanced_monitoring.db" + + if [[ ! -f "$src" ]]; then + warn "No database file in archive — skipping DB restore" + return + fi + + mkdir -p "$APP_DIR/data" + + # Safety backup of any existing database + if [[ -f "$dst" ]]; then + local safety_bak="${dst}.pre_restore_${TIMESTAMP}.bak" + cp "$dst" "$safety_bak" + ok "Existing DB saved as safety backup: $(basename "$safety_bak")" + fi + + cp "$src" "$dst" + ok "Database restored: $dst ($(du -sh "$dst" | cut -f1))" + + # Quick sanity check + local dev_count; dev_count=$(sqlite3 "$dst" "SELECT COUNT(*) FROM devices;" 2>/dev/null || echo "?") + ok "Devices in restored DB: $dev_count" +} + +# --------------------------------------------------------------------------- +# Restore: Ansible files +# --------------------------------------------------------------------------- +restore_ansible() { + local src="$STAGE_DIR/ansible" + + if [[ ! -d "$src" ]]; then + warn "No ansible/ directory in archive — skipping" + return + fi + + # Backup existing ansible dir if present + if [[ -d "$APP_DIR/ansible" ]]; then + local bak="$APP_DIR/ansible.pre_restore_${TIMESTAMP}.bak" + mv "$APP_DIR/ansible" "$bak" + ok "Existing ansible/ moved to: $(basename "$bak")" + fi + + cp -a "$src" "$APP_DIR/ansible" + + # Critical: SSH private key must be 600 or Ansible rejects it + if [[ -f "$APP_DIR/ansible/ssh_keys/app_key" ]]; then + chmod 600 "$APP_DIR/ansible/ssh_keys/app_key" + ok "SSH private key permissions set to 600" + fi + + ok "Ansible directory restored" + ok "Playbooks: $(ls "$APP_DIR/ansible/playbooks/"*.yml 2>/dev/null | wc -l) files" + ok "Inventory hosts: $(grep -c 'ansible_host:' "$APP_DIR/ansible/inventory/dynamic_inventory.yaml" 2>/dev/null || echo 0)" +} + +# --------------------------------------------------------------------------- +# Restore: WMT releases +# --------------------------------------------------------------------------- +restore_wmt_releases() { + local src="$STAGE_DIR/wmt_releases" + if [[ ! -d "$src" ]]; then return; fi + + mkdir -p "$APP_DIR/data/wmt_releases" + # Only copy files not already present (don't overwrite newer releases) + for f in "$src"/*; do + local fname; fname=$(basename "$f") + if [[ ! -f "$APP_DIR/data/wmt_releases/$fname" ]]; then + cp "$f" "$APP_DIR/data/wmt_releases/" + ok "WMT release restored: $fname" + else + ok "WMT release already present (skipped): $fname" + fi + done +} + +# --------------------------------------------------------------------------- +# Restore: ansible_settings.json +# --------------------------------------------------------------------------- +restore_settings() { + local src="$STAGE_DIR/ansible_settings.json" + if [[ ! -f "$src" ]]; then return; fi + + local dst="$APP_DIR/data/ansible_settings.json" + if [[ -f "$dst" ]]; then + cp "$dst" "${dst}.pre_restore_${TIMESTAMP}.bak" + fi + cp "$src" "$dst" + ok "ansible_settings.json restored" +} + +# --------------------------------------------------------------------------- +# Restore: config/ +# --------------------------------------------------------------------------- +restore_config() { + local src="$STAGE_DIR/config" + if [[ ! -d "$src" ]]; then return; fi + + if [[ -d "$APP_DIR/config" ]]; then + ok "config/ already present — skipping (manual merge may be needed)" + warn "Backup config is in: $STAGE_DIR/config/ (not auto-applied)" + return + fi + cp -a "$src" "$APP_DIR/config" + ok "config/ restored" +} + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- +log "Restore mode: $MODE" + +case "$MODE" in + all) + restore_db + restore_ansible + restore_wmt_releases + restore_settings + restore_config + ;; + db-only) + restore_db + ;; + ansible-only) + restore_ansible + restore_settings + ;; +esac + +# --------------------------------------------------------------------------- +# Post-restore checklist +# --------------------------------------------------------------------------- +echo "" +echo "================================================================" +echo " Post-Restore Checklist" +echo "================================================================" +echo " 1. Install Python dependencies (if fresh server):" +echo " cd $APP_DIR && pip install -r requirements.txt" +echo "" +echo " 2. Start the application:" +echo " cd $APP_DIR && python main.py" +echo "" +echo " 3. Verify device list loads at: http://:5000" +echo "" +echo " 4. Test Ansible connectivity:" +echo " cd $APP_DIR/ansible && ansible -i inventory/dynamic_inventory.yaml all -m ping --limit 1" +echo "" +echo " 5. If the new server IP has changed, update any WMT devices" +echo " pointing to the old server URL (wmt_global_config.server_log_url)" +echo " via the admin UI or:" +echo " sqlite3 data/enhanced_monitoring.db \\" +echo " \"UPDATE wmt_global_config SET server_log_url='http://:5000/api/log';\"" +echo "" +echo " 6. Re-install cron backup on the new server:" +echo " bash $SCRIPT_DIR/backup_system.sh install-cron" +echo "" +echo "================================================================" +log "Restore completed." diff --git a/templates/admin.html b/templates/admin.html index db91478..e8a5169 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -70,8 +70,91 @@
+ +
+
+
+
Backup Manager
+
+
+
+ + +
+
+
Existing Backups
+
+
+ Loading… +
+
+
+
+
+ + +
+
+
+
Restore / Migrate
+
+
+

+ Restore devices, Ansible inventory & config from a backup archive. + Use Upload to migrate to a new server. +

+ +
+ +
+ + +
+
+ + +
+
+ +
+ +
+ + + +
+ + +
+ +
+ + A safety copy of the current DB is created automatically before restore. +
+ + +
+
+
+ -
+
Clear Log Entries
@@ -93,31 +176,8 @@
- -
-
-
-
Clear Device Logs
-
-
-

- Deletes all log entries from the database. - Registered devices are not affected and will continue logging automatically. -

-
- - Currently {{ stats.get('logs', '?') }} log entries. -
- -
-
-
- -
+
Registered Device Registry
@@ -215,6 +275,23 @@
+ + +