Initial commit — Server_Monitorizare_v2
This commit is contained in:
0
app/web/__init__.py
Normal file
0
app/web/__init__.py
Normal file
599
app/web/ansible.py
Normal file
599
app/web/ansible.py
Normal file
@@ -0,0 +1,599 @@
|
||||
"""
|
||||
Web routes for Ansible management interface
|
||||
"""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||
from app.services.ansible_service import AnsibleService
|
||||
from app.models import Device, AnsibleExecution, PlaybookExecution
|
||||
from config.database_config import get_db
|
||||
import logging
|
||||
|
||||
# Create blueprint
|
||||
ansible_web_bp = Blueprint('ansible_web', __name__, url_prefix='/ansible')
|
||||
|
||||
# Initialize service
|
||||
ansible_service = AnsibleService()
|
||||
|
||||
@ansible_web_bp.route('/')
|
||||
def index():
|
||||
"""Redirect ansible root to playbooks"""
|
||||
return redirect(url_for('ansible_web.playbooks'))
|
||||
|
||||
@ansible_web_bp.route('/devices')
|
||||
def devices():
|
||||
"""Ansible inventory management interface"""
|
||||
try:
|
||||
# Load current inventory from file
|
||||
inventory_data = ansible_service.get_inventory_data()
|
||||
|
||||
# Load all DB devices so the user can see which haven't been synced yet
|
||||
with get_db().get_session() as session:
|
||||
db_devices = session.query(Device).all()
|
||||
db_devices_list = [
|
||||
{
|
||||
'hostname': d.hostname,
|
||||
'device_ip': d.device_ip,
|
||||
'status': d.status,
|
||||
'device_type': d.device_type,
|
||||
'location': d.location
|
||||
}
|
||||
for d in db_devices
|
||||
]
|
||||
|
||||
# Collect all hostnames already present in inventory
|
||||
all_inv_hosts = set()
|
||||
for group_data in inventory_data.get('groups', {}).values():
|
||||
for h in group_data.get('hosts', []):
|
||||
all_inv_hosts.add(h['hostname'])
|
||||
|
||||
# Mark which DB devices are in inventory
|
||||
for d in db_devices_list:
|
||||
d['in_inventory'] = d['hostname'] in all_inv_hosts
|
||||
|
||||
return render_template(
|
||||
'ansible/devices.html',
|
||||
inventory=inventory_data,
|
||||
db_devices=db_devices_list,
|
||||
all_inv_hosts=all_inv_hosts
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading inventory page: {e}")
|
||||
flash(f'Error loading inventory: {e}', 'error')
|
||||
return render_template(
|
||||
'ansible/devices.html',
|
||||
inventory={'groups': {}, 'raw_yaml': ''},
|
||||
db_devices=[],
|
||||
all_inv_hosts=set()
|
||||
)
|
||||
|
||||
@ansible_web_bp.route('/playbooks')
|
||||
def playbooks():
|
||||
"""Playbook management interface"""
|
||||
try:
|
||||
# Get available playbooks
|
||||
playbook_dir = ansible_service.playbook_dir
|
||||
playbooks = []
|
||||
|
||||
if playbook_dir.exists():
|
||||
for file in playbook_dir.glob('*.yml'):
|
||||
playbooks.append({
|
||||
'name': file.stem,
|
||||
'filename': file.name,
|
||||
'path': str(file)
|
||||
})
|
||||
|
||||
# Add built-in playbooks
|
||||
builtin_playbooks = [
|
||||
{
|
||||
'name': 'update_devices',
|
||||
'description': 'Update all packages on monitoring devices',
|
||||
'builtin': True
|
||||
},
|
||||
{
|
||||
'name': 'restart_service',
|
||||
'description': 'Restart monitoring services on devices',
|
||||
'builtin': True
|
||||
}
|
||||
]
|
||||
|
||||
return render_template('ansible/playbooks.html',
|
||||
playbooks=playbooks,
|
||||
builtin_playbooks=builtin_playbooks)
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading playbooks: {e}")
|
||||
flash(f'Error loading playbooks: {e}', 'error')
|
||||
return render_template('ansible/playbooks.html', playbooks=[], builtin_playbooks=[])
|
||||
|
||||
@ansible_web_bp.route('/execute', methods=['GET', 'POST'])
|
||||
def execute():
|
||||
"""Execute playbook interface"""
|
||||
if request.method == 'GET':
|
||||
try:
|
||||
preselect = request.args.get('playbook', '')
|
||||
inventory_data = ansible_service.get_inventory_data()
|
||||
|
||||
# Flatten all unique hosts from inventory for the host picker
|
||||
seen = set()
|
||||
all_inv_hosts = []
|
||||
for group in inventory_data.get('groups', {}).values():
|
||||
for h in group.get('hosts', []):
|
||||
if h['hostname'] not in seen:
|
||||
all_inv_hosts.append({
|
||||
'hostname': h['hostname'],
|
||||
'ip': h.get('ansible_host', '')
|
||||
})
|
||||
seen.add(h['hostname'])
|
||||
|
||||
return render_template('ansible/execute.html',
|
||||
inventory=inventory_data,
|
||||
all_inv_hosts=all_inv_hosts,
|
||||
preselect_playbook=preselect)
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading execute form: {e}")
|
||||
flash(f'Error loading form: {e}', 'error')
|
||||
return render_template('ansible/execute.html',
|
||||
inventory={'groups': {}},
|
||||
all_inv_hosts=[],
|
||||
preselect_playbook='')
|
||||
|
||||
elif request.method == 'POST':
|
||||
# Execute playbook
|
||||
try:
|
||||
import json as _json
|
||||
is_ajax = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||
|
||||
playbook_name = request.form.get('playbook')
|
||||
selected_hosts = request.form.getlist('hosts')
|
||||
priority = int(request.form.get('priority', 5))
|
||||
max_retries = int(request.form.get('max_retries', 0))
|
||||
check_mode = bool(request.form.get('check_mode'))
|
||||
extra_vars = {}
|
||||
|
||||
# Parse extra variables if provided
|
||||
extra_vars_str = request.form.get('extra_vars', '').strip()
|
||||
if extra_vars_str:
|
||||
try:
|
||||
extra_vars = _json.loads(extra_vars_str)
|
||||
except _json.JSONDecodeError:
|
||||
if is_ajax:
|
||||
return jsonify({'success': False, 'error': 'Invalid JSON in extra variables'}), 400
|
||||
flash('Invalid JSON format for extra variables', 'error')
|
||||
return redirect(url_for('ansible_web.execute'))
|
||||
|
||||
# Add check mode to extra vars if enabled
|
||||
if check_mode:
|
||||
extra_vars['check_mode'] = True
|
||||
|
||||
if not playbook_name:
|
||||
if is_ajax:
|
||||
return jsonify({'success': False, 'error': 'Playbook selection is required'}), 400
|
||||
flash('Playbook selection is required', 'error')
|
||||
return redirect(url_for('ansible_web.execute'))
|
||||
|
||||
if not selected_hosts:
|
||||
if is_ajax:
|
||||
return jsonify({'success': False, 'error': 'At least one device must be selected'}), 400
|
||||
flash('At least one device must be selected', 'error')
|
||||
return redirect(url_for('ansible_web.execute'))
|
||||
|
||||
# Create builtin playbooks if needed
|
||||
if playbook_name == 'update_devices':
|
||||
ansible_service.create_update_playbook()
|
||||
elif playbook_name == 'restart_service':
|
||||
ansible_service.create_restart_service_playbook()
|
||||
elif playbook_name == 'system_health':
|
||||
ansible_service.create_system_health_playbook()
|
||||
|
||||
# Add controller IP for callbacks
|
||||
extra_vars['ansible_controller_ip'] = request.host
|
||||
|
||||
# Use async execution (returns immediately with execution_id)
|
||||
result = ansible_service.execute_playbook_async(
|
||||
playbook_name=playbook_name,
|
||||
limit_hosts=selected_hosts,
|
||||
extra_vars=extra_vars,
|
||||
priority=priority,
|
||||
max_retries=max_retries
|
||||
)
|
||||
|
||||
if result['success']:
|
||||
if is_ajax:
|
||||
return jsonify({'success': True, 'execution_id': result['execution_id']})
|
||||
flash(f'Playbook "{playbook_name}" started! Monitoring execution...', 'success')
|
||||
return redirect(url_for('ansible_web.execution_details',
|
||||
execution_id=result['execution_id']))
|
||||
else:
|
||||
error_msg = result.get('error', 'Unknown error')
|
||||
if is_ajax:
|
||||
return jsonify({'success': False, 'error': error_msg}), 500
|
||||
flash(f'Playbook execution failed: {error_msg}', 'error')
|
||||
return redirect(url_for('ansible_web.execute'))
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error executing playbook: {e}")
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
flash(f'Error executing playbook: {e}', 'error')
|
||||
return redirect(url_for('ansible_web.execute'))
|
||||
|
||||
@ansible_web_bp.route('/executions')
|
||||
def executions():
|
||||
"""Execution history interface"""
|
||||
try:
|
||||
executions = ansible_service.get_execution_history(limit=100)
|
||||
return render_template('ansible/executions.html', executions=executions)
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading executions: {e}")
|
||||
flash(f'Error loading executions: {e}', 'error')
|
||||
return render_template('ansible/executions.html', executions=[])
|
||||
|
||||
@ansible_web_bp.route('/executions/<execution_id>')
|
||||
def execution_details(execution_id):
|
||||
"""View detailed execution results"""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
execution = session.query(PlaybookExecution).filter_by(
|
||||
execution_id=execution_id
|
||||
).first()
|
||||
|
||||
if not execution:
|
||||
flash('Execution not found', 'error')
|
||||
return redirect(url_for('ansible_web.executions'))
|
||||
|
||||
# Read log file if available
|
||||
log_content = None
|
||||
if execution.ansible_log_file:
|
||||
try:
|
||||
with open(execution.ansible_log_file, 'r') as f:
|
||||
log_content = f.read()
|
||||
except FileNotFoundError:
|
||||
log_content = "Log file not found"
|
||||
|
||||
return render_template('ansible/execution_details.html',
|
||||
execution=execution,
|
||||
log_content=log_content)
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading execution details: {e}")
|
||||
flash(f'Error loading execution details: {e}', 'error')
|
||||
return redirect(url_for('ansible_web.executions'))
|
||||
|
||||
@ansible_web_bp.route('/ssh/setup')
|
||||
def ssh_setup():
|
||||
"""SSH key setup interface"""
|
||||
try:
|
||||
# Check if SSH key exists
|
||||
key_exists = ansible_service.ssh_key_path.exists()
|
||||
public_key = None
|
||||
|
||||
if key_exists:
|
||||
public_key_path = ansible_service.ssh_key_path.with_suffix('.pub')
|
||||
if public_key_path.exists():
|
||||
with open(public_key_path, 'r') as f:
|
||||
public_key = f.read().strip()
|
||||
|
||||
settings = ansible_service.load_settings()
|
||||
|
||||
return render_template('ansible/ssh_setup.html',
|
||||
key_exists=key_exists,
|
||||
public_key=public_key,
|
||||
settings=settings)
|
||||
except Exception as e:
|
||||
logging.error(f"Error in SSH setup page: {e}")
|
||||
flash(f'Error loading SSH setup: {e}', 'error')
|
||||
return render_template('ansible/ssh_setup.html', key_exists=False, public_key=None, settings={})
|
||||
|
||||
|
||||
@ansible_web_bp.route('/ssh/settings', methods=['POST'])
|
||||
def save_ssh_settings():
|
||||
"""Save SSH settings (fallback password etc.)"""
|
||||
try:
|
||||
fallback_password = request.form.get('ssh_fallback_password', '').strip()
|
||||
if not fallback_password:
|
||||
flash('Fallback password cannot be empty.', 'error')
|
||||
return redirect(url_for('ansible_web.ssh_setup'))
|
||||
|
||||
ansible_service.save_settings({'ssh_fallback_password': fallback_password})
|
||||
flash('SSH settings saved successfully.', 'success')
|
||||
except Exception as e:
|
||||
logging.error(f"Error saving SSH settings: {e}")
|
||||
flash(f'Error saving SSH settings: {e}', 'error')
|
||||
return redirect(url_for('ansible_web.ssh_setup'))
|
||||
|
||||
@ansible_web_bp.route('/ssh/generate', methods=['POST'])
|
||||
def generate_ssh_keys():
|
||||
"""Generate new SSH keys"""
|
||||
try:
|
||||
result = ansible_service.setup_ssh_keys()
|
||||
|
||||
if result['success']:
|
||||
flash('SSH keys generated successfully!', 'success')
|
||||
else:
|
||||
flash(f'Error generating SSH keys: {result.get("error")}', 'error')
|
||||
|
||||
return redirect(url_for('ansible_web.ssh_setup'))
|
||||
except Exception as e:
|
||||
logging.error(f"Error generating SSH keys: {e}")
|
||||
flash(f'Error generating SSH keys: {e}', 'error')
|
||||
return redirect(url_for('ansible_web.ssh_setup'))
|
||||
|
||||
@ansible_web_bp.route('/ssh/test', methods=['POST'])
|
||||
def test_ssh():
|
||||
"""Test SSH connectivity to selected devices"""
|
||||
try:
|
||||
selected_ips = request.form.getlist('device_ips')
|
||||
|
||||
if not selected_ips:
|
||||
flash('Please select at least one device to test', 'error')
|
||||
return redirect(url_for('ansible_web.devices'))
|
||||
|
||||
# Test connectivity
|
||||
results = ansible_service.bulk_ssh_test(selected_ips)
|
||||
|
||||
# Count results
|
||||
successful = sum(1 for r in results.values() if r.get('success'))
|
||||
total = len(results)
|
||||
|
||||
flash(f'SSH test completed: {successful}/{total} devices reachable',
|
||||
'success' if successful == total else 'warning')
|
||||
|
||||
return render_template('ansible/ssh_test_results.html', results=results)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error testing SSH: {e}")
|
||||
flash(f'Error testing SSH: {e}', 'error')
|
||||
return redirect(url_for('ansible_web.devices'))
|
||||
|
||||
# API endpoints for AJAX calls
|
||||
@ansible_web_bp.route('/api/refresh_inventory', methods=['POST'])
|
||||
def api_refresh_inventory():
|
||||
"""AJAX endpoint to refresh inventory"""
|
||||
try:
|
||||
inventory = ansible_service.generate_dynamic_inventory()
|
||||
device_count = len(inventory.get('all', {}).get('children', {}).get('monitoring_devices', {}).get('hosts', {}))
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Inventory refreshed with {device_count} devices'
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"Error refreshing inventory: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@ansible_web_bp.route('/api/execution_status/<execution_id>')
|
||||
def api_execution_status(execution_id):
|
||||
"""AJAX endpoint to get execution status"""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
execution = session.query(PlaybookExecution).filter_by(
|
||||
execution_id=execution_id
|
||||
).first()
|
||||
|
||||
if not execution:
|
||||
return jsonify({'error': 'Execution not found'}), 404
|
||||
|
||||
return jsonify({
|
||||
'id': execution.id,
|
||||
'status': execution.status,
|
||||
'start_time': execution.start_time.isoformat() if execution.start_time else None,
|
||||
'end_time': execution.end_time.isoformat() if execution.end_time else None,
|
||||
'exit_code': execution.exit_code,
|
||||
'successful_hosts': execution.successful_hosts,
|
||||
'failed_hosts': execution.failed_hosts,
|
||||
'unreachable_hosts': execution.unreachable_hosts
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"Error getting execution status: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@ansible_web_bp.route('/upload_playbook', methods=['POST'])
|
||||
def upload_playbook():
|
||||
"""Upload a custom playbook file"""
|
||||
try:
|
||||
if 'playbook_file' not in request.files:
|
||||
flash('No playbook file selected', 'error')
|
||||
return redirect(url_for('ansible_web.playbooks'))
|
||||
|
||||
file = request.files['playbook_file']
|
||||
if file.filename == '':
|
||||
flash('No playbook file selected', 'error')
|
||||
return redirect(url_for('ansible_web.playbooks'))
|
||||
|
||||
if file and file.filename.lower().endswith(('.yml', '.yaml')):
|
||||
# Get playbook name
|
||||
playbook_name = request.form.get('playbook_name', '').strip()
|
||||
if not playbook_name:
|
||||
playbook_name = file.filename.rsplit('.', 1)[0]
|
||||
|
||||
# Clean filename
|
||||
import re
|
||||
safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', playbook_name)
|
||||
filename = f"{safe_filename}.yml"
|
||||
|
||||
# Save file
|
||||
playbook_path = ansible_service.playbook_dir / filename
|
||||
file.save(str(playbook_path))
|
||||
|
||||
flash(f'Playbook "{filename}" uploaded successfully!', 'success')
|
||||
else:
|
||||
flash('Invalid file type. Please upload a .yml or .yaml file.', 'error')
|
||||
|
||||
return redirect(url_for('ansible_web.playbooks'))
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error uploading playbook: {e}")
|
||||
flash(f'Error uploading playbook: {e}', 'error')
|
||||
return redirect(url_for('ansible_web.playbooks'))
|
||||
|
||||
@ansible_web_bp.route('/playbook/content')
|
||||
def playbook_content():
|
||||
"""Get playbook content for viewing"""
|
||||
try:
|
||||
playbook_path = request.args.get('path')
|
||||
if not playbook_path:
|
||||
return "No playbook path provided", 400
|
||||
|
||||
# Security check - ensure path is within playbooks directory
|
||||
from pathlib import Path
|
||||
requested_path = Path(playbook_path)
|
||||
if not requested_path.is_absolute():
|
||||
requested_path = ansible_service.playbook_dir / requested_path
|
||||
|
||||
# Ensure path is within playbook directory
|
||||
try:
|
||||
requested_path.resolve().relative_to(ansible_service.playbook_dir.resolve())
|
||||
except ValueError:
|
||||
return "Invalid playbook path", 400
|
||||
|
||||
if not requested_path.exists():
|
||||
return "Playbook file not found", 404
|
||||
|
||||
with open(requested_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
return content, 200, {'Content-Type': 'text/plain'}
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error reading playbook content: {e}")
|
||||
return f"Error reading playbook: {e}", 500
|
||||
|
||||
@ansible_web_bp.route('/playbook/save', methods=['POST'])
|
||||
def save_playbook():
|
||||
"""Save a new or existing playbook"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
name = data.get('name', '').strip()
|
||||
content = data.get('content', '').strip()
|
||||
is_new = data.get('is_new', False)
|
||||
|
||||
if not name:
|
||||
return jsonify({'error': 'Playbook name is required'}), 400
|
||||
|
||||
if not content:
|
||||
return jsonify({'error': 'Playbook content is required'}), 400
|
||||
|
||||
# Validate YAML syntax
|
||||
try:
|
||||
import yaml
|
||||
yaml.safe_load(content)
|
||||
except yaml.YAMLError as e:
|
||||
return jsonify({'error': f'Invalid YAML syntax: {e}'}), 400
|
||||
|
||||
# Clean filename
|
||||
import re
|
||||
safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', name)
|
||||
filename = f"{safe_filename}.yml"
|
||||
|
||||
# Save file
|
||||
playbook_path = ansible_service.playbook_dir / filename
|
||||
|
||||
# Check if file exists and not updating
|
||||
if is_new and playbook_path.exists():
|
||||
return jsonify({'error': f'Playbook {filename} already exists'}), 400
|
||||
|
||||
with open(playbook_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Playbook {filename} saved successfully',
|
||||
'filename': filename
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error saving playbook: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@ansible_web_bp.route('/playbook/validate', methods=['POST'])
|
||||
def validate_playbook():
|
||||
"""Validate playbook YAML syntax and structure"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
content = data.get('content', '').strip()
|
||||
if not content:
|
||||
return jsonify({'error': 'No content to validate'}), 400
|
||||
|
||||
# Validate YAML syntax
|
||||
try:
|
||||
import yaml
|
||||
parsed = yaml.safe_load(content)
|
||||
except yaml.YAMLError as e:
|
||||
return jsonify({'valid': False, 'error': f'YAML syntax error: {e}'})
|
||||
|
||||
# Basic Ansible playbook structure validation
|
||||
if not isinstance(parsed, list):
|
||||
return jsonify({'valid': False, 'error': 'Playbook must be a list of plays'})
|
||||
|
||||
for i, play in enumerate(parsed):
|
||||
if not isinstance(play, dict):
|
||||
return jsonify({'valid': False, 'error': f'Play {i+1} must be a dictionary'})
|
||||
|
||||
if 'hosts' not in play:
|
||||
return jsonify({'valid': False, 'error': f'Play {i+1} is missing required "hosts" field'})
|
||||
|
||||
if 'tasks' not in play and 'roles' not in play:
|
||||
return jsonify({'valid': False, 'error': f'Play {i+1} must have either "tasks" or "roles"'})
|
||||
|
||||
return jsonify({'valid': True, 'message': 'Playbook is valid'})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error validating playbook: {e}")
|
||||
return jsonify({'valid': False, 'error': str(e)})
|
||||
|
||||
@ansible_web_bp.route('/playbook/delete', methods=['POST'])
|
||||
def delete_playbook():
|
||||
"""Delete a custom playbook"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
playbook_name = data.get('playbook_name', '').strip()
|
||||
if not playbook_name:
|
||||
return jsonify({'error': 'Playbook name is required'}), 400
|
||||
|
||||
# Find the playbook file
|
||||
import re
|
||||
safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', playbook_name)
|
||||
|
||||
# Try both with and without .yml extension
|
||||
possible_files = [
|
||||
ansible_service.playbook_dir / f"{safe_filename}.yml",
|
||||
ansible_service.playbook_dir / f"{safe_filename}.yaml",
|
||||
ansible_service.playbook_dir / f"{playbook_name}.yml",
|
||||
ansible_service.playbook_dir / f"{playbook_name}.yaml"
|
||||
]
|
||||
|
||||
playbook_path = None
|
||||
for path in possible_files:
|
||||
if path.exists():
|
||||
playbook_path = path
|
||||
break
|
||||
|
||||
if not playbook_path:
|
||||
return jsonify({'error': f'Playbook {playbook_name} not found'}), 404
|
||||
|
||||
# Security check - ensure path is within playbooks directory
|
||||
try:
|
||||
playbook_path.resolve().relative_to(ansible_service.playbook_dir.resolve())
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid playbook path'}), 400
|
||||
|
||||
# Delete the file
|
||||
playbook_path.unlink()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Playbook {playbook_name} deleted successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error deleting playbook: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
578
app/web/main.py
Normal file
578
app/web/main.py
Normal file
@@ -0,0 +1,578 @@
|
||||
"""
|
||||
Main web routes for dashboard and device management
|
||||
"""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||
from app.models import Device, LogEntry, MessageTemplate, AnsibleExecution, 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
|
||||
import logging
|
||||
import yaml
|
||||
|
||||
# Create blueprint
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
# Initialize services
|
||||
log_service = LogCompressionService()
|
||||
|
||||
@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()
|
||||
|
||||
# Get 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()
|
||||
|
||||
return render_template('device_management.html',
|
||||
devices=devices,
|
||||
device_log_counts=device_log_counts,
|
||||
pending_count=pending_count)
|
||||
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)
|
||||
|
||||
@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(AnsibleExecution).count(),
|
||||
'successful': session.query(AnsibleExecution).filter_by(status='completed').count(),
|
||||
'failed': session.query(AnsibleExecution).filter_by(status='failed').count(),
|
||||
'running': session.query(AnsibleExecution).filter_by(status='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
|
||||
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'] = '?'
|
||||
return render_template('admin.html', stats=stats)
|
||||
|
||||
|
||||
@main_bp.route('/admin/clear/logs', methods=['POST'])
|
||||
def admin_clear_logs():
|
||||
"""Delete all log entries from the database."""
|
||||
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/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
|
||||
204
app/web/wmt.py
Normal file
204
app/web/wmt.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
WMT management web routes – global settings, device registry, update requests.
|
||||
"""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from datetime import datetime
|
||||
from app.models import WMTGlobalConfig, Device, WMTUpdateRequest
|
||||
from config.database_config import get_db
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
wmt_web_bp = Blueprint('wmt_web', __name__, url_prefix='/wmt')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_or_create_global_config(session):
|
||||
cfg = session.query(WMTGlobalConfig).first()
|
||||
if cfg is None:
|
||||
cfg = WMTGlobalConfig()
|
||||
session.add(cfg)
|
||||
session.flush()
|
||||
return cfg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@wmt_web_bp.route('/')
|
||||
def index():
|
||||
"""WMT management dashboard."""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
global_cfg = _get_or_create_global_config(session)
|
||||
devices = session.query(Device).filter(Device.mac_address.isnot(None)).order_by(Device.nume_masa).all()
|
||||
pending_count = session.query(WMTUpdateRequest).filter_by(status='pending').count()
|
||||
recent_requests = (
|
||||
session.query(WMTUpdateRequest)
|
||||
.order_by(WMTUpdateRequest.submitted_at.desc())
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
return render_template(
|
||||
'wmt/index.html',
|
||||
global_cfg=global_cfg,
|
||||
devices=devices,
|
||||
pending_count=pending_count,
|
||||
recent_requests=recent_requests,
|
||||
breadcrumbs=[{'url': url_for('wmt_web.index'), 'title': 'WMT Management'}],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'WMT dashboard error: {e}')
|
||||
flash(f'Error loading dashboard: {e}', 'error')
|
||||
return render_template('wmt/index.html', global_cfg=None, devices=[],
|
||||
pending_count=0, recent_requests=[])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@wmt_web_bp.route('/settings', methods=['GET', 'POST'])
|
||||
def settings():
|
||||
"""View and edit global WMT configuration."""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
cfg = _get_or_create_global_config(session)
|
||||
|
||||
if request.method == 'POST':
|
||||
cfg.chrome_url = request.form.get('chrome_url', '').strip()
|
||||
cfg.chrome_local_url = request.form.get('chrome_local_url', '').strip() or None
|
||||
cfg.chrome_insecure_origin = request.form.get('chrome_insecure_origin', '').strip()
|
||||
cfg.card_api_base_url = request.form.get('card_api_base_url', '').strip()
|
||||
cfg.server_log_url = request.form.get('server_log_url', '').strip()
|
||||
cfg.internet_check_host = request.form.get('internet_check_host', '').strip()
|
||||
cfg.update_host = request.form.get('update_host', '').strip()
|
||||
cfg.update_user = request.form.get('update_user', '').strip()
|
||||
cfg.notes = request.form.get('notes', '').strip() or None
|
||||
cfg.updated_at = datetime.utcnow()
|
||||
cfg.updated_by = 'admin'
|
||||
flash('Global settings saved.', 'success')
|
||||
return redirect(url_for('wmt_web.settings'))
|
||||
|
||||
return render_template(
|
||||
'wmt/settings.html',
|
||||
cfg=cfg,
|
||||
breadcrumbs=[
|
||||
{'url': url_for('wmt_web.index'), 'title': 'WMT Management'},
|
||||
{'url': url_for('wmt_web.settings'), 'title': 'Global Settings'},
|
||||
],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'WMT settings error: {e}')
|
||||
flash(f'Error: {e}', 'error')
|
||||
return redirect(url_for('wmt_web.index'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Update requests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@wmt_web_bp.route('/requests')
|
||||
def update_requests():
|
||||
"""List all device update requests."""
|
||||
status_filter = request.args.get('status', 'pending')
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
query = session.query(WMTUpdateRequest)
|
||||
if status_filter != 'all':
|
||||
query = query.filter_by(status=status_filter)
|
||||
req_list = query.order_by(WMTUpdateRequest.submitted_at.desc()).all()
|
||||
pending_count = session.query(WMTUpdateRequest).filter_by(status='pending').count()
|
||||
|
||||
return render_template(
|
||||
'wmt/requests.html',
|
||||
requests=req_list,
|
||||
status_filter=status_filter,
|
||||
pending_count=pending_count,
|
||||
breadcrumbs=[
|
||||
{'url': url_for('wmt_web.index'), 'title': 'WMT Management'},
|
||||
{'url': url_for('wmt_web.update_requests'), 'title': 'Update Requests'},
|
||||
],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'WMT requests list error: {e}')
|
||||
flash(f'Error: {e}', 'error')
|
||||
return redirect(url_for('wmt_web.index'))
|
||||
|
||||
|
||||
@wmt_web_bp.route('/requests/<int:req_id>/accept', methods=['POST'])
|
||||
def accept_request(req_id):
|
||||
"""Accept an update request: apply proposed values to WMTDevice."""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
req = session.query(WMTUpdateRequest).filter_by(id=req_id).first()
|
||||
if not req:
|
||||
flash('Request not found.', 'error')
|
||||
return redirect(url_for('wmt_web.update_requests'))
|
||||
|
||||
# Find or create the Device
|
||||
device = session.query(Device).filter_by(mac_address=req.mac_address).first()
|
||||
if device is None:
|
||||
device = Device(
|
||||
mac_address=req.mac_address,
|
||||
hostname=req.proposed_hostname or '',
|
||||
device_ip=req.proposed_device_ip or '',
|
||||
nume_masa=req.proposed_device_name or '',
|
||||
)
|
||||
session.add(device)
|
||||
session.flush()
|
||||
req.device_id = device.id
|
||||
|
||||
# Apply proposed values
|
||||
if req.proposed_device_name is not None:
|
||||
device.nume_masa = req.proposed_device_name
|
||||
if req.proposed_hostname is not None:
|
||||
device.hostname = req.proposed_hostname
|
||||
if req.proposed_device_ip is not None:
|
||||
device.device_ip = req.proposed_device_ip
|
||||
device.config_updated_at = datetime.utcnow()
|
||||
device.info_reviewed_at = datetime.utcnow() # admin reviewed → push timestamp to devices
|
||||
|
||||
# Mark request as accepted
|
||||
req.status = 'accepted'
|
||||
req.admin_reviewed_at = datetime.utcnow()
|
||||
req.admin_notes = request.form.get('admin_notes', '').strip() or None
|
||||
|
||||
flash('Request accepted and device record updated.', 'success')
|
||||
except Exception as e:
|
||||
logger.error(f'WMT accept request error: {e}')
|
||||
flash(f'Error accepting request: {e}', 'error')
|
||||
return redirect(url_for('wmt_web.update_requests'))
|
||||
|
||||
|
||||
@wmt_web_bp.route('/requests/<int:req_id>/reject', methods=['POST'])
|
||||
def reject_request(req_id):
|
||||
"""Reject an update request (updates reviewed_at so WMT client won't re-submit)."""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
req = session.query(WMTUpdateRequest).filter_by(id=req_id).first()
|
||||
if not req:
|
||||
flash('Request not found.', 'error')
|
||||
return redirect(url_for('wmt_web.update_requests'))
|
||||
|
||||
req.status = 'rejected'
|
||||
req.admin_reviewed_at = datetime.utcnow()
|
||||
req.admin_notes = request.form.get('admin_notes', '').strip() or None
|
||||
|
||||
# Update device info_reviewed_at even though data didn't change –
|
||||
# this signals to the WMT client that the server has reviewed the state
|
||||
# so it won't keep re-submitting the same request.
|
||||
if req.device_id:
|
||||
device = session.query(Device).filter_by(id=req.device_id).first()
|
||||
if device:
|
||||
device.info_reviewed_at = datetime.utcnow()
|
||||
|
||||
flash('Request rejected.', 'warning')
|
||||
except Exception as e:
|
||||
logger.error(f'WMT reject request error: {e}')
|
||||
flash(f'Error rejecting request: {e}', 'error')
|
||||
return redirect(url_for('wmt_web.update_requests'))
|
||||
Reference in New Issue
Block a user