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
+155 -1
View File
@@ -1,14 +1,16 @@
""" """
Main web routes for dashboard and device management 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 app.models import Device, LogEntry, MessageTemplate, AnsibleExecution, PlaybookExecution, WMTUpdateRequest, InventoryGroup, device_inventory_association
from config.database_config import get_db from config.database_config import get_db
from app.services.log_service import LogCompressionService from app.services.log_service import LogCompressionService
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from sqlalchemy import text, func from sqlalchemy import text, func
from werkzeug.utils import secure_filename
import logging import logging
import subprocess
import yaml import yaml
# Create blueprint # Create blueprint
@@ -17,6 +19,21 @@ main_bp = Blueprint('main', __name__)
# Initialize services # Initialize services
log_service = LogCompressionService() 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('/') @main_bp.route('/')
def index(): def index():
"""Redirect root to devices page""" """Redirect root to devices page"""
@@ -635,3 +652,140 @@ def admin_clear_wmt():
except Exception as e: except Exception as e:
logging.error(f'Admin clear WMT requests error: {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})
+296
View File
@@ -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 <file> # 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 <file>]"
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 <file> 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" <<EOF
Server Monitorizare v2 - Backup Manifest
=========================================
Backup name : $BACKUP_NAME
Created at : $(date '+%Y-%m-%d %H:%M:%S %Z')
Hostname : $(hostname)
DB size : $(du -sh "$STAGE_DIR/enhanced_monitoring.db" | cut -f1)
DB devices : $(sqlite3 "$STAGE_DIR/enhanced_monitoring.db" "SELECT COUNT(*) FROM devices;")
DB inv groups : $(sqlite3 "$STAGE_DIR/enhanced_monitoring.db" "SELECT COUNT(*) FROM inventory_groups;")
WMT configs : $(sqlite3 "$STAGE_DIR/enhanced_monitoring.db" "SELECT COUNT(*) FROM wmt_global_config;")
Ansible hosts : $(grep -c 'ansible_host:' "$STAGE_DIR/ansible/inventory/dynamic_inventory.yaml" 2>/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
+257
View File
@@ -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 <backup.tar.gz> [--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 <path/to/backup.tar.gz> [--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://<new-server-ip>: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://<NEW_IP>: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."
+289 -25
View File
@@ -70,8 +70,91 @@
<div class="row g-4"> <div class="row g-4">
<!-- ── Backup Manager ──────────────────────────────────────────── -->
<div class="col-md-8">
<div class="card h-100" style="border:2px solid #198754">
<div class="card-header" style="background:#198754;color:#fff">
<h5 class="mb-0"><i class="fas fa-archive me-2"></i>Backup Manager</h5>
</div>
<div class="card-body">
<div class="d-flex align-items-center mb-3 gap-3 flex-wrap">
<button class="btn btn-success" id="btn-run-backup" onclick="runBackup()">
<i class="fas fa-play me-2"></i>Run Backup Now
</button>
<span class="small" id="backup-status-msg"></span>
</div>
<hr class="my-2">
<h6 class="text-muted mb-2"><i class="fas fa-list me-1"></i>Existing Backups</h6>
<div id="backup-list-container">
<div class="text-center text-muted py-3">
<span class="spinner-border spinner-border-sm me-2"></span>Loading…
</div>
</div>
</div>
</div>
</div>
<!-- ── Restore / Migrate ───────────────────────────────────────── -->
<div class="col-md-4">
<div class="card h-100" style="border:2px solid #0d6efd">
<div class="card-header" style="background:#0d6efd;color:#fff">
<h5 class="mb-0"><i class="fas fa-upload me-2"></i>Restore / Migrate</h5>
</div>
<div class="card-body d-flex flex-column">
<p class="text-muted small mb-3">
Restore devices, Ansible inventory &amp; config from a backup archive.
Use <strong>Upload</strong> to migrate to a new server.
</p>
<div class="mb-3">
<label class="form-label fw-semibold small mb-1">Source</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="restore-source"
id="src-existing" value="existing" checked onchange="toggleRestoreSource()">
<label class="form-check-label small" for="src-existing">Select existing backup</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="restore-source"
id="src-upload" value="upload" onchange="toggleRestoreSource()">
<label class="form-check-label small" for="src-upload">Upload backup file (.tar.gz)</label>
</div>
</div>
<div id="restore-existing-picker" class="mb-3">
<select class="form-select form-select-sm" id="restore-existing-select">
<option value="">— loading… —</option>
</select>
</div>
<div id="restore-upload-picker" class="mb-3" style="display:none">
<input type="file" class="form-control form-control-sm"
id="restore-file-input" accept=".tar.gz">
<div class="form-text">Only .tar.gz archives produced by this system.</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold small mb-1">Restore Scope</label>
<select class="form-select form-select-sm" id="restore-mode">
<option value="all">Full restore (DB + Ansible + Config)</option>
<option value="db-only">Database only (devices, groups, WMT config)</option>
<option value="ansible-only">Ansible only (inventory, playbooks, SSH keys)</option>
</select>
</div>
<div class="alert alert-warning py-2 small mb-3">
<i class="fas fa-exclamation-triangle me-1"></i>
A safety copy of the current DB is created automatically before restore.
</div>
<button class="btn btn-primary mt-auto w-100" id="btn-restore" onclick="runRestore()">
<i class="fas fa-undo me-2"></i>Restore Backup
</button>
</div>
</div>
</div>
<!-- Clear Log Entries --> <!-- Clear Log Entries -->
<div class="col-md-3"> <div class="col-md-4">
<div class="card warning-card h-100"> <div class="card warning-card h-100">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0"><i class="fas fa-stream me-2"></i>Clear Log Entries</h5> <h5 class="mb-0"><i class="fas fa-stream me-2"></i>Clear Log Entries</h5>
@@ -93,31 +176,8 @@
</div> </div>
</div> </div>
<!-- Clear Device Logs -->
<div class="col-md-3">
<div class="card warning-card h-100">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-file-alt me-2"></i>Clear Device Logs</h5>
</div>
<div class="card-body d-flex flex-column">
<p class="text-muted flex-grow-1">
Deletes <strong>all log entries</strong> from the database.
Registered devices are <strong>not affected</strong> and will continue logging automatically.
</p>
<div class="alert alert-warning py-2 mb-3">
<i class="fas fa-exclamation-triangle me-1"></i>
Currently <strong id="badge-logs2">{{ stats.get('logs', '?') }}</strong> log entries.
</div>
<button class="btn btn-warning w-100"
onclick="runAction('clear-device-logs', 'Delete ALL device log entries? Devices stay registered. This cannot be undone.')">
<i class="fas fa-trash me-2"></i>Clear Device Logs
</button>
</div>
</div>
</div>
<!-- Registered Device Registry --> <!-- Registered Device Registry -->
<div class="col-md-6"> <div class="col-md-8">
<div class="card danger-card h-100"> <div class="card danger-card h-100">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-server me-2"></i>Registered Device Registry</h5> <h5 class="mb-0"><i class="fas fa-server me-2"></i>Registered Device Registry</h5>
@@ -215,6 +275,23 @@
</div><!-- /container --> </div><!-- /container -->
<!-- Output Modal (backup / restore log) -->
<div class="modal fade" id="outputModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="outputModalTitle">Output</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-2">
<pre id="outputModalBody"
class="bg-dark text-light p-3 rounded mb-0"
style="max-height:420px;overflow-y:auto;font-size:.78rem;white-space:pre-wrap;word-break:break-all;"></pre>
</div>
</div>
</div>
</div>
<!-- Result toast --> <!-- Result toast -->
<div class="position-fixed bottom-0 end-0 p-3" style="z-index:9999"> <div class="position-fixed bottom-0 end-0 p-3" style="z-index:9999">
<div id="resultToast" class="toast align-items-center text-white border-0" role="alert" aria-live="assertive"> <div id="resultToast" class="toast align-items-center text-white border-0" role="alert" aria-live="assertive">
@@ -310,5 +387,192 @@ function refreshStats() {
// Reload the page stats after a short delay to let DB settle // Reload the page stats after a short delay to let DB settle
setTimeout(() => location.reload(), 800); setTimeout(() => location.reload(), 800);
} }
// ── Backup Manager ──────────────────────────────────────────────────────────
function escHtml(s) {
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function showOutputModal(title, content) {
document.getElementById('outputModalTitle').textContent = title;
document.getElementById('outputModalBody').textContent = content || '(no output)';
bootstrap.Modal.getOrCreateInstance(document.getElementById('outputModal')).show();
}
function loadBackupList() {
fetch('/admin/backup/list')
.then(r => r.json())
.then(data => {
renderBackupList(data.success ? data.backups : []);
const sel = document.getElementById('restore-existing-select');
if (data.success && data.backups.length) {
sel.innerHTML = data.backups
.map(b => `<option value="${escHtml(b.name)}">${escHtml(b.name)} (${escHtml(b.size_human)})</option>`)
.join('');
} else {
sel.innerHTML = '<option value="">— no backups found —</option>';
}
})
.catch(() => renderBackupList(null));
}
function renderBackupList(backups) {
const el = document.getElementById('backup-list-container');
if (!backups) {
el.innerHTML = '<div class="text-danger small">Failed to load backup list.</div>';
return;
}
if (backups.length === 0) {
el.innerHTML = `<div class="text-muted small text-center py-3">
<i class="fas fa-inbox fa-2x d-block mb-2"></i>
No backups yet. Click "Run Backup Now" to create one.</div>`;
return;
}
const rows = backups.map(b => `
<tr>
<td class="small font-monospace text-break">${escHtml(b.name)}</td>
<td class="small text-nowrap">${escHtml(b.mtime)}</td>
<td class="small text-end text-nowrap">${escHtml(b.size_human)}</td>
<td class="text-end text-nowrap">
<a href="/admin/backup/download/${encodeURIComponent(b.name)}"
class="btn btn-sm btn-outline-secondary me-1" title="Download">
<i class="fas fa-download"></i>
</a>
<button class="btn btn-sm btn-outline-danger" title="Delete"
onclick="deleteBackup('${escHtml(b.name)}')">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>`).join('');
el.innerHTML = `
<div class="table-responsive" style="max-height:220px;overflow-y:auto;">
<table class="table table-sm table-hover mb-0">
<thead class="table-light sticky-top">
<tr>
<th>Archive</th>
<th class="text-nowrap">Created</th>
<th class="text-end">Size</th>
<th></th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
function runBackup() {
if (!confirm('Run a full backup now?')) return;
const btn = document.getElementById('btn-run-backup');
const msg = document.getElementById('backup-status-msg');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Running…';
msg.textContent = '';
fetch('/admin/backup/run', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(r => r.json())
.then(data => {
if (data.success) {
msg.className = 'text-success small';
msg.textContent = data.archive ? '✓ Created: ' + data.archive : '✓ Backup completed.';
loadBackupList();
showOutputModal('Backup Output', data.output);
} else {
msg.className = 'text-danger small';
msg.textContent = 'Backup failed — see details';
showOutputModal('Backup Error', data.error);
}
})
.catch(err => {
msg.className = 'text-danger small';
msg.textContent = 'Network error: ' + err;
})
.finally(() => {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-play me-2"></i>Run Backup Now';
});
}
function deleteBackup(name) {
if (!confirm('Delete backup "' + name + '"?\nThis cannot be undone.')) return;
fetch('/admin/backup/delete/' + encodeURIComponent(name), {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(r => r.json())
.then(data => {
if (data.success) {
loadBackupList();
showToast('success', 'Deleted: ' + data.deleted);
} else {
showToast('danger', 'Error: ' + (data.error || 'Unknown'));
}
})
.catch(err => showToast('danger', 'Network error: ' + err));
}
function toggleRestoreSource() {
const val = document.querySelector('input[name="restore-source"]:checked').value;
document.getElementById('restore-existing-picker').style.display = val === 'existing' ? '' : 'none';
document.getElementById('restore-upload-picker').style.display = val === 'upload' ? '' : 'none';
}
function runRestore() {
const source = document.querySelector('input[name="restore-source"]:checked').value;
const mode = document.getElementById('restore-mode').value;
if (!confirm('Restore from backup (scope: ' + mode + ')?\n\n' +
'This will overwrite the current database / Ansible files.\n' +
'A safety copy of the current DB is created automatically.')) return;
const btn = document.getElementById('btn-restore');
btn.disabled = true;
const origLabel = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Restoring…';
const fd = new FormData();
fd.append('mode', mode);
if (source === 'existing') {
const name = document.getElementById('restore-existing-select').value;
if (!name) {
showToast('danger', 'Select a backup first.');
btn.disabled = false; btn.innerHTML = origLabel; return;
}
fd.append('existing_backup', name);
} else {
const file = document.getElementById('restore-file-input').files[0];
if (!file) {
showToast('danger', 'Select a .tar.gz file to upload.');
btn.disabled = false; btn.innerHTML = origLabel; return;
}
fd.append('backup_file', file);
}
fetch('/admin/backup/restore', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: fd
})
.then(r => r.json())
.then(data => {
if (data.success) {
showToast('success', 'Restore completed successfully.');
showOutputModal('Restore Output', data.output);
} else {
showOutputModal('Restore Error', data.error);
showToast('danger', 'Restore failed — see output');
}
})
.catch(err => showToast('danger', 'Network error: ' + err))
.finally(() => { btn.disabled = false; btn.innerHTML = origLabel; });
}
document.addEventListener('DOMContentLoaded', loadBackupList);
</script> </script>
{% endblock %} {% endblock %}