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})
+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">
<!-- ── 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 -->
<div class="col-md-3">
<div class="col-md-4">
<div class="card warning-card h-100">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-stream me-2"></i>Clear Log Entries</h5>
@@ -93,31 +176,8 @@
</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 -->
<div class="col-md-6">
<div class="col-md-8">
<div class="card danger-card h-100">
<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>
@@ -215,6 +275,23 @@
</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 -->
<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">
@@ -310,5 +387,192 @@ function refreshStats() {
// Reload the page stats after a short delay to let DB settle
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>
{% endblock %}