791 lines
33 KiB
Python
791 lines
33 KiB
Python
"""
|
||
Main web routes for dashboard and device management
|
||
"""
|
||
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
|
||
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"""
|
||
return redirect(url_for('main.devices'))
|
||
|
||
@main_bp.route('/dashboard')
|
||
def dashboard():
|
||
"""Redirect /dashboard to devices"""
|
||
return redirect(url_for('main.devices'))
|
||
|
||
@main_bp.route('/devices')
|
||
def devices():
|
||
"""Device management page"""
|
||
try:
|
||
with get_db().get_session() as session:
|
||
devices = session.query(Device).order_by(Device.last_seen.desc()).all()
|
||
|
||
# Log count per device
|
||
device_log_counts = {}
|
||
for device in devices:
|
||
log_count = session.query(LogEntry).filter_by(device_id=device.id).count()
|
||
device_log_counts[device.id] = log_count
|
||
|
||
pending_count = session.query(WMTUpdateRequest).filter_by(status='pending').count()
|
||
|
||
# Pending approval requests mapped by mac_address for per-device badge
|
||
pending_requests = session.query(WMTUpdateRequest).filter_by(status='pending').all()
|
||
pending_by_mac = {}
|
||
for req in pending_requests:
|
||
pending_by_mac[req.mac_address] = pending_by_mac.get(req.mac_address, 0) + 1
|
||
|
||
return render_template('device_management.html',
|
||
devices=devices,
|
||
device_log_counts=device_log_counts,
|
||
pending_count=pending_count,
|
||
pending_by_mac=pending_by_mac)
|
||
except Exception as e:
|
||
logging.error(f"Error loading devices: {e}")
|
||
flash(f'Error loading devices: {e}', 'error')
|
||
return render_template('device_management.html', devices=[], device_log_counts={},
|
||
pending_count=0, pending_by_mac={})
|
||
|
||
@main_bp.route('/device/<int:device_id>')
|
||
def device_detail(device_id):
|
||
"""Device detail page with logs and stats"""
|
||
try:
|
||
with get_db().get_session() as session:
|
||
device = session.query(Device).get(device_id)
|
||
|
||
if not device:
|
||
flash('Device not found', 'error')
|
||
return redirect(url_for('main.devices'))
|
||
|
||
# Get device logs (last 100)
|
||
logs = session.query(LogEntry).filter_by(device_id=device_id).order_by(
|
||
LogEntry.timestamp.desc()
|
||
).limit(100).all()
|
||
|
||
# Get log statistics
|
||
log_stats = {
|
||
'total': len(logs),
|
||
'by_severity': {},
|
||
'last_24h': 0
|
||
}
|
||
|
||
last_24h = datetime.utcnow() - timedelta(hours=24)
|
||
for log in logs:
|
||
# Count by severity
|
||
severity = log.severity
|
||
log_stats['by_severity'][severity] = log_stats['by_severity'].get(severity, 0) + 1
|
||
|
||
# Count last 24h
|
||
if log.timestamp >= last_24h:
|
||
log_stats['last_24h'] += 1
|
||
|
||
return render_template('device_detail.html',
|
||
device=device,
|
||
logs=logs,
|
||
log_stats=log_stats)
|
||
|
||
except Exception as e:
|
||
logging.error(f"Error loading device detail: {e}")
|
||
flash(f'Error loading device detail: {e}', 'error')
|
||
return redirect(url_for('main.devices'))
|
||
|
||
@main_bp.route('/logs')
|
||
def logs():
|
||
"""Log viewer with filtering"""
|
||
try:
|
||
# Get filter parameters
|
||
device_id = request.args.get('device_id', type=int)
|
||
severity = request.args.get('severity')
|
||
search = request.args.get('search', '')
|
||
page = request.args.get('page', 1, type=int)
|
||
per_page = min(request.args.get('per_page', 50, type=int), 200)
|
||
|
||
with get_db().get_session() as session:
|
||
# Build query
|
||
query = session.query(LogEntry).join(Device)
|
||
|
||
# Apply filters
|
||
if device_id:
|
||
query = query.filter(LogEntry.device_id == device_id)
|
||
|
||
if severity:
|
||
query = query.filter(LogEntry.severity == severity)
|
||
|
||
if search:
|
||
# Search in resolved message or full message
|
||
query = query.filter(
|
||
# This is simplified - in production you'd want full-text search
|
||
LogEntry.full_message.contains(search)
|
||
)
|
||
|
||
# Order by timestamp desc
|
||
query = query.order_by(LogEntry.timestamp.desc())
|
||
|
||
# Get total count for pagination
|
||
total = query.count()
|
||
|
||
# Apply pagination
|
||
offset = (page - 1) * per_page
|
||
logs = query.offset(offset).limit(per_page).all()
|
||
|
||
# Calculate pagination info
|
||
total_pages = (total + per_page - 1) // per_page
|
||
has_prev = page > 1
|
||
has_next = page < total_pages
|
||
|
||
# Get device list for filter dropdown
|
||
devices = session.query(Device).order_by(Device.hostname).all()
|
||
|
||
pagination = {
|
||
'page': page,
|
||
'per_page': per_page,
|
||
'total': total,
|
||
'total_pages': total_pages,
|
||
'has_prev': has_prev,
|
||
'has_next': has_next,
|
||
'prev_num': page - 1 if has_prev else None,
|
||
'next_num': page + 1 if has_next else None
|
||
}
|
||
|
||
return render_template('logs.html',
|
||
logs=logs,
|
||
pagination=pagination,
|
||
devices=devices,
|
||
current_device_id=device_id,
|
||
current_severity=severity,
|
||
current_search=search)
|
||
|
||
except Exception as e:
|
||
logging.error(f"Error loading logs: {e}")
|
||
flash(f'Error loading logs: {e}', 'error')
|
||
return render_template('logs.html',
|
||
logs=[],
|
||
pagination={},
|
||
devices=[],
|
||
current_device_id=None,
|
||
current_severity=None,
|
||
current_search='')
|
||
|
||
@main_bp.route('/templates')
|
||
def templates():
|
||
"""Message templates management"""
|
||
try:
|
||
with get_db().get_session() as session:
|
||
templates = session.query(MessageTemplate).order_by(
|
||
MessageTemplate.usage_count.desc()
|
||
).all()
|
||
|
||
# Get template statistics
|
||
template_stats = {
|
||
'total': len(templates),
|
||
'by_category': {},
|
||
'total_usage': sum(t.usage_count for t in templates)
|
||
}
|
||
|
||
for template in templates:
|
||
category = template.category
|
||
template_stats['by_category'][category] = template_stats['by_category'].get(category, 0) + 1
|
||
|
||
return render_template('templates.html',
|
||
templates=templates,
|
||
template_stats=template_stats)
|
||
|
||
except Exception as e:
|
||
logging.error(f"Error loading templates: {e}")
|
||
flash(f'Error loading templates: {e}', 'error')
|
||
return render_template('templates.html', templates=[], template_stats={})
|
||
|
||
@main_bp.route('/stats')
|
||
def stats():
|
||
"""System statistics and analytics"""
|
||
try:
|
||
with get_db().get_session() as session:
|
||
# Get compression stats
|
||
compression_stats = log_service.get_compression_stats()
|
||
|
||
# Get device statistics
|
||
device_stats = {
|
||
'total': session.query(Device).count(),
|
||
'active': session.query(Device).filter_by(status='active').count(),
|
||
'inactive': session.query(Device).filter_by(status='inactive').count(),
|
||
'maintenance': session.query(Device).filter_by(status='maintenance').count()
|
||
}
|
||
|
||
# Get log statistics by time periods
|
||
now = datetime.utcnow()
|
||
periods = {
|
||
'last_hour': now - timedelta(hours=1),
|
||
'last_24h': now - timedelta(hours=24),
|
||
'last_week': now - timedelta(days=7),
|
||
'last_month': now - timedelta(days=30)
|
||
}
|
||
|
||
log_stats = {}
|
||
for period_name, period_start in periods.items():
|
||
count = session.query(LogEntry).filter(
|
||
LogEntry.timestamp >= period_start
|
||
).count()
|
||
log_stats[period_name] = count
|
||
|
||
# Get execution statistics
|
||
exec_stats = {
|
||
'total': session.query(PlaybookExecution).count(),
|
||
'successful': session.query(PlaybookExecution).filter_by(status='completed').count(),
|
||
'failed': session.query(PlaybookExecution).filter_by(status='failed').count(),
|
||
'running': session.query(PlaybookExecution).filter(
|
||
PlaybookExecution.status.in_(['queued', 'running'])
|
||
).count()
|
||
}
|
||
|
||
return render_template('stats.html',
|
||
compression_stats=compression_stats,
|
||
device_stats=device_stats,
|
||
log_stats=log_stats,
|
||
exec_stats=exec_stats)
|
||
|
||
except Exception as e:
|
||
logging.error(f"Error loading stats: {e}")
|
||
flash(f'Error loading stats: {e}', 'error')
|
||
return render_template('stats.html',
|
||
compression_stats={},
|
||
device_stats={},
|
||
log_stats={},
|
||
exec_stats={})
|
||
|
||
# API Endpoints for Device Management
|
||
|
||
@main_bp.route('/api/devices/add', methods=['POST'])
|
||
def api_add_device():
|
||
"""API endpoint to add a device manually"""
|
||
try:
|
||
data = request.get_json()
|
||
|
||
# Validate required fields
|
||
required_fields = ['hostname', 'device_ip', 'nume_masa']
|
||
for field in required_fields:
|
||
if not data.get(field):
|
||
return jsonify({
|
||
'success': False,
|
||
'message': f'Missing required field: {field}'
|
||
}), 400
|
||
|
||
# Check if device already exists (MAC-first, then hostname/IP)
|
||
with get_db().get_session() as session:
|
||
mac_input = data.get('mac_address', '').strip().lower() or None
|
||
|
||
existing_device = None
|
||
if mac_input:
|
||
existing_device = session.query(Device).filter_by(mac_address=mac_input).first()
|
||
|
||
if not existing_device:
|
||
existing_device = session.query(Device).filter(
|
||
(Device.hostname == data['hostname']) |
|
||
(Device.device_ip == data['device_ip'])
|
||
).first()
|
||
|
||
if existing_device:
|
||
# If found by MAC or hostname/IP, update it rather than reject
|
||
if mac_input and not existing_device.mac_address:
|
||
existing_device.mac_address = mac_input
|
||
existing_device.hostname = data['hostname']
|
||
existing_device.device_ip = data['device_ip']
|
||
existing_device.nume_masa = data['nume_masa']
|
||
if data.get('device_type'):
|
||
existing_device.device_type = data['device_type']
|
||
if data.get('os_version'):
|
||
existing_device.os_version = data['os_version']
|
||
if data.get('location'):
|
||
existing_device.location = data['location']
|
||
if data.get('status'):
|
||
existing_device.status = data['status']
|
||
existing_device.config_updated_at = datetime.utcnow()
|
||
existing_device.info_reviewed_at = datetime.utcnow()
|
||
session.flush()
|
||
return jsonify({
|
||
'success': True,
|
||
'message': 'Device already existed – record updated',
|
||
'device_id': existing_device.id
|
||
}), 200
|
||
|
||
# Create new device
|
||
new_device = Device(
|
||
hostname=data['hostname'],
|
||
device_ip=data['device_ip'],
|
||
nume_masa=data['nume_masa'],
|
||
mac_address=data.get('mac_address', '').strip().lower() or None,
|
||
device_type=data.get('device_type', 'unknown'),
|
||
os_version=data.get('os_version'),
|
||
status=data.get('status', 'active'),
|
||
location=data.get('location'),
|
||
description=data.get('description'),
|
||
config_updated_at=datetime.utcnow(),
|
||
info_reviewed_at=datetime.utcnow(),
|
||
last_seen=datetime.utcnow()
|
||
)
|
||
|
||
session.add(new_device)
|
||
session.commit()
|
||
|
||
# Refresh ansible inventory
|
||
try:
|
||
from app.services.ansible_service import AnsibleService
|
||
ansible_service = AnsibleService()
|
||
ansible_service.generate_dynamic_inventory()
|
||
except Exception as e:
|
||
logging.warning(f"Failed to update ansible inventory: {e}")
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'message': 'Device added successfully',
|
||
'device_id': new_device.id
|
||
}), 201
|
||
|
||
except Exception as e:
|
||
logging.error(f"Error adding device: {e}")
|
||
return jsonify({
|
||
'success': False,
|
||
'message': f'Error adding device: {str(e)}'
|
||
}), 500
|
||
|
||
@main_bp.route('/api/devices/<int:device_id>/execute', methods=['POST'])
|
||
def api_execute_device_command(device_id):
|
||
"""API endpoint to execute commands on devices"""
|
||
try:
|
||
data = request.get_json()
|
||
command = data.get('command')
|
||
|
||
if not command:
|
||
return jsonify({
|
||
'success': False,
|
||
'message': 'Command is required'
|
||
}), 400
|
||
|
||
with get_db().get_session() as session:
|
||
device = session.query(Device).get(device_id)
|
||
|
||
if not device:
|
||
return jsonify({
|
||
'success': False,
|
||
'message': 'Device not found'
|
||
}), 404
|
||
|
||
# Mock implementation - in production this would execute actual commands
|
||
if command == 'ping':
|
||
# Simulate ping command
|
||
import subprocess
|
||
try:
|
||
result = subprocess.run(['ping', '-c', '1', device.device_ip],
|
||
capture_output=True, text=True, timeout=5)
|
||
success = result.returncode == 0
|
||
output = result.stdout if success else result.stderr
|
||
|
||
return jsonify({
|
||
'success': success,
|
||
'command': command,
|
||
'output': output,
|
||
'device': device.hostname
|
||
})
|
||
except subprocess.TimeoutExpired:
|
||
return jsonify({
|
||
'success': False,
|
||
'message': 'Command timed out',
|
||
'command': command
|
||
})
|
||
else:
|
||
# For other commands, return placeholder
|
||
return jsonify({
|
||
'success': True,
|
||
'message': f'Command "{command}" would be executed on {device.hostname}',
|
||
'command': command,
|
||
'note': 'This is a placeholder implementation'
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"Error executing device command: {e}")
|
||
return jsonify({
|
||
'success': False,
|
||
'message': f'Error executing command: {str(e)}'
|
||
}), 500
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Device edit / delete (unified – includes WMT fields)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@main_bp.route('/devices/<int:device_id>/edit', methods=['GET', 'POST'])
|
||
def device_edit(device_id):
|
||
"""Edit a device record (monitoring + WMT fields)."""
|
||
try:
|
||
with get_db().get_session() as session:
|
||
device = session.query(Device).filter_by(id=device_id).first()
|
||
if not device:
|
||
flash('Device not found.', 'error')
|
||
return redirect(url_for('main.devices'))
|
||
|
||
if request.method == 'POST':
|
||
device.hostname = request.form.get('hostname', '').strip() or device.hostname
|
||
device.device_ip = request.form.get('device_ip', '').strip() or device.device_ip
|
||
device.nume_masa = request.form.get('nume_masa', '').strip() or device.nume_masa
|
||
mac_raw = request.form.get('mac_address', '').strip().lower() or None
|
||
# Only assign MAC if no other device owns it
|
||
if mac_raw and mac_raw != device.mac_address:
|
||
conflict = session.query(Device).filter(
|
||
Device.mac_address == mac_raw, Device.id != device_id
|
||
).first()
|
||
if conflict:
|
||
flash(f'MAC {mac_raw} is already assigned to {conflict.hostname}.', 'error')
|
||
return render_template('device_edit.html', device=device)
|
||
device.mac_address = mac_raw
|
||
device.status = request.form.get('status', 'active')
|
||
device.location = request.form.get('location', '').strip() or None
|
||
device.device_type = request.form.get('device_type', '').strip() or 'unknown'
|
||
device.description = request.form.get('description', '').strip() or None
|
||
device.os_version = request.form.get('os_version', '').strip() or None
|
||
custom_url = request.form.get('custom_chrome_url', '').strip()
|
||
device.custom_chrome_url = custom_url if custom_url else None
|
||
device.config_updated_at = datetime.utcnow()
|
||
device.info_reviewed_at = datetime.utcnow()
|
||
flash('Device updated.', 'success')
|
||
return redirect(url_for('main.devices'))
|
||
|
||
return render_template(
|
||
'device_edit.html',
|
||
device=device,
|
||
breadcrumbs=[
|
||
{'url': url_for('main.dashboard'), 'title': 'Dashboard'},
|
||
{'url': url_for('main.devices'), 'title': 'Devices'},
|
||
{'url': '#', 'title': f'Edit {device.hostname}'},
|
||
],
|
||
)
|
||
except Exception as e:
|
||
logging.error(f'Device edit error: {e}')
|
||
flash(f'Error: {e}', 'error')
|
||
return redirect(url_for('main.devices'))
|
||
|
||
|
||
@main_bp.route('/devices/<int:device_id>/delete', methods=['POST'])
|
||
def device_delete(device_id):
|
||
"""Delete a device and all its logs."""
|
||
try:
|
||
with get_db().get_session() as session:
|
||
device = session.query(Device).filter_by(id=device_id).first()
|
||
if device:
|
||
name = device.hostname
|
||
session.delete(device)
|
||
flash(f'Device {name} deleted.', 'success')
|
||
else:
|
||
flash('Device not found.', 'error')
|
||
except Exception as e:
|
||
logging.error(f'Device delete error: {e}')
|
||
flash(f'Error deleting device: {e}', 'error')
|
||
return redirect(url_for('main.devices'))
|
||
|
||
|
||
# ── Admin page ────────────────────────────────────────────────────────
|
||
|
||
INVENTORY_FILE = Path('ansible/inventory/dynamic_inventory.yaml')
|
||
|
||
@main_bp.route('/admin')
|
||
def admin():
|
||
"""Admin / maintenance page with DB and inventory stats."""
|
||
stats = {}
|
||
try:
|
||
with get_db().get_session() as session:
|
||
stats['devices'] = session.query(func.count(Device.id)).scalar()
|
||
stats['logs'] = session.query(func.count(LogEntry.id)).scalar()
|
||
stats['templates'] = session.query(func.count(MessageTemplate.id)).scalar()
|
||
stats['inventory_groups'] = session.query(func.count(InventoryGroup.id)).scalar()
|
||
stats['wmt_requests'] = session.query(func.count(WMTUpdateRequest.id)).scalar()
|
||
except Exception as e:
|
||
logging.error(f'Admin stats error: {e}')
|
||
# Inventory host count
|
||
try:
|
||
if INVENTORY_FILE.exists():
|
||
data = yaml.safe_load(INVENTORY_FILE.read_text()) or {}
|
||
all_children = data.get('all', {}).get('children', {})
|
||
inv_hosts = set()
|
||
for g in all_children.values():
|
||
inv_hosts.update((g or {}).get('hosts', {}).keys())
|
||
stats['inventory_hosts'] = len(inv_hosts)
|
||
stats['inventory_groups_yaml'] = len(all_children)
|
||
else:
|
||
stats['inventory_hosts'] = 0
|
||
stats['inventory_groups_yaml'] = 0
|
||
except Exception as e:
|
||
logging.error(f'Admin inventory stats error: {e}')
|
||
stats['inventory_hosts'] = '?'
|
||
stats['inventory_groups_yaml'] = '?'
|
||
# Pass registered devices for per-device delete UI
|
||
devices = []
|
||
try:
|
||
with get_db().get_session() as session:
|
||
devices = [
|
||
{'id': d.id, 'hostname': d.hostname, 'nume_masa': d.nume_masa,
|
||
'mac_address': d.mac_address, 'device_ip': d.device_ip}
|
||
for d in session.query(Device).order_by(Device.nume_masa).all()
|
||
]
|
||
except Exception as e:
|
||
logging.error(f'Admin device list error: {e}')
|
||
return render_template('admin.html', stats=stats, devices=devices)
|
||
|
||
|
||
@main_bp.route('/admin/clear/logs', methods=['POST'])
|
||
def admin_clear_logs():
|
||
"""Delete all log entries from the database."""
|
||
return _clear_all_log_entries()
|
||
|
||
|
||
@main_bp.route('/admin/clear/device-logs', methods=['POST'])
|
||
def admin_clear_device_logs():
|
||
"""Delete all log entries from the database (devices stay intact).
|
||
|
||
Functionally identical to admin_clear_logs – both endpoints are kept because
|
||
the admin UI exposes two separate buttons, but they share one implementation.
|
||
"""
|
||
return _clear_all_log_entries()
|
||
|
||
|
||
def _clear_all_log_entries():
|
||
"""Shared implementation: delete every LogEntry row."""
|
||
try:
|
||
with get_db().get_session() as session:
|
||
count = session.query(LogEntry).delete()
|
||
session.commit()
|
||
return jsonify({'success': True, 'deleted': count})
|
||
except Exception as e:
|
||
logging.error(f'Admin clear logs error: {e}')
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@main_bp.route('/admin/delete/device/<int:device_id>', methods=['POST'])
|
||
def admin_delete_device(device_id):
|
||
"""Delete a single registered device and its log entries."""
|
||
try:
|
||
with get_db().get_session() as session:
|
||
device = session.query(Device).filter_by(id=device_id).first()
|
||
if not device:
|
||
return jsonify({'success': False, 'error': 'Device not found'}), 404
|
||
name = device.nume_masa or device.hostname
|
||
session.execute(text('DELETE FROM device_inventory_groups WHERE device_id = :id'), {'id': device_id})
|
||
session.query(LogEntry).filter_by(device_id=device_id).delete()
|
||
session.query(WMTUpdateRequest).filter_by(device_id=device_id).delete()
|
||
session.delete(device)
|
||
session.commit()
|
||
logging.info(f'Admin deleted device {device_id} ({name})')
|
||
return jsonify({'success': True, 'name': name})
|
||
except Exception as e:
|
||
logging.error(f'Admin delete device {device_id} error: {e}')
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@main_bp.route('/admin/clear/devices', methods=['POST'])
|
||
def admin_clear_devices():
|
||
"""Delete ALL devices (and their log entries) from the database."""
|
||
try:
|
||
with get_db().get_session() as session:
|
||
session.execute(text('DELETE FROM device_inventory_groups'))
|
||
logs = session.query(LogEntry).delete()
|
||
devices = session.query(Device).delete()
|
||
session.commit()
|
||
return jsonify({'success': True, 'deleted_devices': devices, 'deleted_logs': logs})
|
||
except Exception as e:
|
||
logging.error(f'Admin clear devices error: {e}')
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@main_bp.route('/admin/clear/inventory', methods=['POST'])
|
||
def admin_clear_inventory():
|
||
"""Reset the Ansible inventory file to a completely empty state."""
|
||
try:
|
||
empty = {'_meta': {'hostvars': {}}, 'all': {'hosts': {}, 'children': {}}}
|
||
INVENTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||
INVENTORY_FILE.write_text(yaml.dump(empty, default_flow_style=False))
|
||
# Also clear inventory_groups table
|
||
with get_db().get_session() as session:
|
||
session.execute(text('DELETE FROM device_inventory_groups'))
|
||
groups = session.query(InventoryGroup).delete()
|
||
session.commit()
|
||
return jsonify({'success': True, 'groups_deleted': groups})
|
||
except Exception as e:
|
||
logging.error(f'Admin clear inventory error: {e}')
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@main_bp.route('/admin/clear/wmt', methods=['POST'])
|
||
def admin_clear_wmt():
|
||
"""Delete all WMT update requests."""
|
||
try:
|
||
with get_db().get_session() as session:
|
||
count = session.query(WMTUpdateRequest).delete()
|
||
session.commit()
|
||
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
|
||
|
||
|
||
# ── 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}) |