Add backup/restore scripts and update web/admin UI
This commit is contained in:
+156
-2
@@ -1,14 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
Main web routes for dashboard and device management
|
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"""
|
||||||
@@ -634,4 +651,141 @@ def admin_clear_wmt():
|
|||||||
return jsonify({'success': True, 'deleted': count})
|
return jsonify({'success': True, 'deleted': count})
|
||||||
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})
|
||||||
Executable
+296
@@ -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
|
||||||
Executable
+257
@@ -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
@@ -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 & 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, '&').replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user