Initial commit — Server_Monitorizare_v2

This commit is contained in:
ske087
2026-04-23 15:55:46 +03:00
commit d2485e4c66
61 changed files with 13861 additions and 0 deletions

53
.gitignore vendored Normal file
View File

@@ -0,0 +1,53 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.egg
*.egg-info/
dist/
build/
.eggs/
# Virtual environment
venv/
env/
.venv/
# Database & sensitive data
data/*.db
data/*.sqlite
data/*.sqlite3
data/backups/
data/uploads/
data/ansible_settings.json
# Logs
logs/
*.log
/tmp/
# Ansible inventory (contains IPs and SSH keys info)
ansible/inventory/dynamic_inventory.yaml
ansible/inventory/dynamic_inventory.yaml.bak
# Generated playbooks (recreated at runtime)
ansible/playbooks/*.yml
# VS Code
.vscode/
# Environment / secrets
.env
*.env
# OS
.DS_Store
Thumbs.db
# Migrations (generated)
migrations/
# Pycache
**/__pycache__/
*.pyc

326
INSTALLATION.md Normal file
View File

@@ -0,0 +1,326 @@
# Installation and Migration Guide
# Enhanced Server Monitoring System v2.0
## Overview
This guide helps you migrate from the old server.py system to the new enhanced architecture with:
- ✅ Better database structure with message compression
- ✅ File upload support
- ✅ SSH/Ansible management interface
- ✅ Modular architecture
- ✅ Smaller client message sizes (60-80% reduction)
- ✅ Real-time device monitoring
## Quick Start
### 1. Install Dependencies
```bash
# Install required Python packages
pip3 install flask sqlalchemy paramiko pyyaml requests
# Install Ansible (for device management)
sudo apt update
sudo apt install ansible sshpass
# Install additional tools
sudo apt install sqlite3 python3-pip
```
### 2. Start the Enhanced Server
```bash
# Navigate to the new server directory
cd /home/pi/Desktop/Server_Monitorizare_v2
# Start the server
python3 main.py
```
### 3. Access the Web Interface
- **Main Dashboard**: http://YOUR-SERVER-IP/
- **Ansible Management**: http://YOUR-SERVER-IP/ansible/
- **API Documentation**: http://YOUR-SERVER-IP/api/logs/stats
## Migration from Old System
### Step 1: Backup Current Data
```bash
# Backup old database
cp /home/pi/Desktop/Server_Monitorizare/data/database.db /home/pi/Desktop/backup_old_db.db
# Backup old templates
cp -r /home/pi/Desktop/Server_Monitorizare/templates /home/pi/Desktop/backup_templates/
```
### Step 2: Data Migration (Optional)
If you want to migrate existing log data:
```bash
# Run migration script (to be created)
python3 scripts/migrate_old_data.py
```
### Step 3: Update Prezenta Clients
Update the prezenta apps to use the new enhanced API:
#### Old Prezenta Code (send_log_to_server function):
```python
# OLD METHOD - Large messages
def send_log_to_server(log_message, n_masa, hostname, device_ip):
log_data = {
"hostname": str(hostname),
"device_ip": str(device_ip),
"nume_masa": str(n_masa),
"log_message": str(log_message) # Full message every time
}
server_url = "http://rpi-ansible:80/logs"
response = requests.post(server_url, json=log_data, timeout=5)
```
#### New Enhanced Method - Use Template Aliases:
```python
# NEW METHOD - Compressed messages using templates
def send_log_to_server_compressed(log_message, n_masa, hostname, device_ip):
# Try to use template alias first (much smaller)
template_aliases = {
'card_detected': 'CD001',
'connection_failed': 'CE001',
'system_startup': 'SS001',
'auto_update': 'AU001'
}
# Check if message matches a known pattern
alias = None
variables = {}
if 'Card detected:' in log_message:
alias = 'CD001'
variables = {'card_id': log_message.split('Card detected: ')[1]}
elif 'System startup completed' in log_message:
alias = 'SS001'
variables = {'time': log_message.split('in ')[1].split('s')[0]}
if alias:
# Send compressed message (60-80% smaller)
payload = {
"alias": alias,
"variables": variables,
"device_info": {
"hostname": str(hostname),
"device_ip": str(device_ip),
"nume_masa": str(n_masa)
}
}
server_url = "http://rpi-ansible:80/api/logs/template/" + alias
else:
# Fallback to full message (will create new template)
payload = {
"hostname": str(hostname),
"device_ip": str(device_ip),
"nume_masa": str(n_masa),
"log_message": str(log_message),
"severity": "info"
}
server_url = "http://rpi-ansible:80/api/logs/"
try:
response = requests.post(server_url, json=payload, timeout=5)
if response.status_code == 201:
result = response.json()
# Server may suggest using template in future
if result.get('template_alias'):
print(f"Tip: Use alias {result['template_alias']} for similar messages")
return response
except Exception as e:
print(f"Error sending log: {e}")
```
### Step 4: Setup SSH Keys for Ansible
```bash
# Generate SSH keys for device management
ssh-keygen -t rsa -b 4096 -f ~/.ssh/ansible_key -N ""
# Copy public key to devices (replace IP addresses)
ssh-copy-id -i ~/.ssh/ansible_key.pub pi@192.168.1.100
ssh-copy-id -i ~/.ssh/ansible_key.pub pi@192.168.1.101
```
Or use the web interface: http://YOUR-SERVER-IP/ansible/ssh/setup
## New Features Usage
### 1. Message Compression
The system automatically compresses repetitive log messages:
- **Before**: "Card detected: ABC123" (20 bytes)
- **After**: "CD001" + {"card_id": "ABC123"} (8 bytes template + variables)
- **Savings**: ~60% reduction
### 2. File Upload API
Upload configuration or log files:
```bash
# Upload a log file from device
curl -X POST http://YOUR-SERVER-IP/api/logs/file \
-F "file=@/path/to/logfile.log" \
-F 'device_info={"hostname":"device-01","device_ip":"192.168.1.100","nume_masa":"Masa-01"}'
```
### 3. SSH/Ansible Management
Execute commands on all devices:
```bash
# Via API
curl -X POST http://YOUR-SERVER-IP/api/ansible/execute \
-H "Content-Type: application/json" \
-d '{"playbook":"update_devices","limit_hosts":["device-01","device-02"]}'
```
Or use the web interface: http://YOUR-SERVER-IP/ansible/execute
### 4. Enhanced Dashboard
- Real-time device status
- Compression statistics
- Execution history
- Log analysis and filtering
## API Endpoints Summary
### Enhanced Log API
- `POST /api/logs/` - Submit full message (creates templates automatically)
- `POST /api/logs/template/<alias>` - Submit using template alias (smaller payload)
- `POST /api/logs/file` - Upload log files
- `GET /api/logs/query` - Query logs with filters
- `GET /api/logs/stats` - Get compression statistics
### Ansible Management API
- `GET /api/ansible/inventory` - View device inventory
- `POST /api/ansible/execute` - Execute playbooks
- `POST /api/ansible/ssh/test` - Test SSH connectivity
- `GET /api/ansible/executions` - View execution history
### Web Interface
- `/` - Enhanced dashboard
- `/devices` - Device management
- `/logs` - Advanced log viewer
- `/templates` - Template management
- `/ansible/` - Ansible management
- `/ansible/execute` - Playbook execution interface
## Performance Benefits
### Message Size Reduction Examples:
1. **Card Detection**:
- Old: `{"hostname":"device-01","device_ip":"192.168.1.100","nume_masa":"Masa-01","log_message":"Card detected: ABC123"}` (120 bytes)
- New: `{"alias":"CD001","variables":{"card_id":"ABC123"},"device_info":{...}}` (45 bytes)
- **Savings: 62%**
2. **System Updates**:
- Old: `"Auto-update: Package xyz updated successfully"` (45 bytes)
- New: `"AU001" + {"package":"xyz"}` (12 bytes)
- **Savings: 73%**
### Database Efficiency:
- Template reuse reduces storage by 60-80%
- Faster queries with indexed message types
- Automatic cleanup of old data
## Troubleshooting
### Common Issues:
1. **Database Connection Error**:
```bash
# Check if data directory exists
ls -la data/
# Recreate database
rm data/enhanced_monitoring.db
python3 main.py
```
2. **SSH Connection Failed**:
```bash
# Test SSH manually
ssh -i ~/.ssh/ansible_key pi@192.168.1.100
# Check SSH service on devices
sudo systemctl status ssh
```
3. **Ansible Execution Failed**:
```bash
# Test Ansible connectivity
ansible all -i ansible/inventory/dynamic_inventory.yaml -m ping
# Check playbook syntax
ansible-playbook --syntax-check ansible/playbooks/update_devices.yml
```
### Logs Location:
- Application logs: `logs/app.log`
- Ansible execution logs: `logs/ansible_*.log`
- Database: `data/enhanced_monitoring.db`
## Production Deployment
For production use:
1. **Use PostgreSQL instead of SQLite**:
```bash
# Set environment variable
export DATABASE_URL="postgresql://user:password@localhost/monitoring_db"
```
2. **Use reverse proxy (nginx)**:
```nginx
server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
3. **Use systemd service**:
```ini
# /etc/systemd/system/enhanced-monitoring.service
[Unit]
Description=Enhanced Server Monitoring System
After=network.target
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/Desktop/Server_Monitorizare_v2
ExecStart=/usr/bin/python3 main.py
Restart=always
[Install]
WantedBy=multi-user.target
```
## Next Steps
1. **Test the new system** with a few devices
2. **Monitor compression statistics** to see savings
3. **Update all prezenta clients** gradually
4. **Setup Ansible playbooks** for automation
5. **Create backup procedures** for the new database
For support or questions about the enhanced system, check the logs or create an issue.

80
README.md Normal file
View File

@@ -0,0 +1,80 @@
# Server Monitoring System v2 - Enhanced Architecture
## New Project Structure
```
Server_Monitorizare_v2/
├── app/
│ ├── __init__.py # Flask app factory
│ ├── models/ # Database models
│ │ ├── __init__.py
│ │ ├── device.py # Device model
│ │ ├── log.py # Log model with message aliases
│ │ └── file.py # File uploads model
│ ├── api/ # REST API endpoints
│ │ ├── __init__.py
│ │ ├── logs.py # Log endpoints
│ │ ├── devices.py # Device management
│ │ ├── files.py # File upload handling
│ │ └── ansible.py # SSH/Ansible endpoints
│ ├── services/ # Business logic layer
│ │ ├── __init__.py
│ │ ├── log_service.py # Log processing & compression
│ │ ├── device_service.py # Device management
│ │ ├── ansible_service.py # SSH/Ansible operations
│ │ └── file_service.py # File processing
│ ├── web/ # Web interface
│ │ ├── __init__.py
│ │ ├── main.py # Main dashboard routes
│ │ ├── devices.py # Device management UI
│ │ └── ansible.py # Ansible web interface
│ └── utils/ # Utility functions
│ ├── __init__.py
│ ├── database.py # DB utilities
│ ├── ssh_utils.py # SSH helpers
│ └── compression.py # Message compression
├── config/
│ ├── config.py # Configuration management
│ └── database_config.py # Database settings
├── templates/
│ ├── base.html # Base template
│ ├── dashboard.html # Enhanced dashboard
│ ├── ansible/ # Ansible interface templates
│ │ ├── inventory.html
│ │ ├── playbooks.html
│ │ └── execution.html
│ └── devices/ # Device management templates
├── ansible/
│ ├── playbooks/ # Ansible playbooks
│ ├── inventory/ # Dynamic inventory
│ └── roles/ # Custom roles
├── migrations/ # Database migrations
├── data/
│ ├── uploads/ # File uploads storage
│ └── backups/ # Database backups
└── main.py # Application entry point
```
## Key Improvements
### 1. Better Database Structure
- Normalized database schema with separate tables
- Message aliases and compression
- File upload tracking
- Device metadata storage
### 2. Modular Architecture
- Separation of concerns (API, Web, Services, Models)
- Pluggable components
- Better testability
### 3. Enhanced Message Processing
- Message compression and aliases
- Efficient storage with references
- Client message size reduction
### 4. SSH/Ansible Integration
- Web-based Ansible interface
- Dynamic inventory management
- Playbook execution tracking
- SSH key management

201
app/__init__.py Normal file
View File

@@ -0,0 +1,201 @@
"""
Flask application factory and initialization
"""
from flask import Flask, request, jsonify, render_template
import os
import logging
from logging.handlers import RotatingFileHandler
from config.config import get_config
def create_app(config_name=None):
"""Application factory pattern"""
# Get the project root directory (parent of app directory)
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
template_dir = os.path.join(project_root, 'templates')
static_dir = os.path.join(project_root, 'static')
app = Flask(__name__, template_folder=template_dir, static_folder=static_dir)
# Load configuration
config_class = get_config(config_name)
app.config.from_object(config_class)
# Ensure required directories exist
_ensure_directories()
# Setup logging
_setup_logging(app)
# Register blueprints
_register_blueprints(app)
# Register error handlers
_register_error_handlers(app)
# Add context processors
_register_context_processors(app)
return app
def _ensure_directories():
"""Ensure required directories exist"""
directories = [
'data',
'data/uploads',
'data/backups',
'logs',
'ansible/inventory',
'ansible/playbooks',
'ansible/roles'
]
for directory in directories:
os.makedirs(directory, exist_ok=True)
def _setup_logging(app):
"""Setup application logging"""
if not app.debug and not app.testing:
# File logging for production
if app.config.get('LOG_FILE'):
if not os.path.exists('logs'):
os.mkdir('logs')
file_handler = RotatingFileHandler(
app.config['LOG_FILE'],
maxBytes=10240000, # 10MB
backupCount=10
)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
))
file_handler.setLevel(logging.INFO)
app.logger.addHandler(file_handler)
app.logger.setLevel(logging.INFO)
app.logger.info('Enhanced monitoring server startup')
def _register_blueprints(app):
"""Register all blueprints"""
# Import blueprints here to avoid circular imports
from app.api.logs import logs_bp
from app.api.ansible import ansible_bp
from app.api.wmt import wmt_api_bp
from app.web.main import main_bp
from app.web.ansible import ansible_web_bp
from app.web.wmt import wmt_web_bp
app.register_blueprint(logs_bp)
app.register_blueprint(ansible_bp)
app.register_blueprint(wmt_api_bp)
app.register_blueprint(main_bp)
app.register_blueprint(ansible_web_bp)
app.register_blueprint(wmt_web_bp)
# Add compatibility routes for old clients
@app.route('/logs', methods=['POST'])
def compatibility_logs():
"""Compatibility endpoint for old prezenta clients"""
from flask import request, redirect, url_for
import re
# Forward the request to the new API endpoint
# Import inside function to avoid circular imports
from app.api.logs import submit_log
return submit_log()
def _register_error_handlers(app):
"""Register error handlers"""
@app.errorhandler(400)
def bad_request(error):
if request.is_json:
return jsonify({
'error': 'Bad request',
'message': 'The request could not be understood by the server'
}), 400
return render_template('errors/400.html'), 400
@app.errorhandler(401)
def unauthorized(error):
if request.is_json:
return jsonify({
'error': 'Unauthorized',
'message': 'Authentication required'
}), 401
return render_template('errors/401.html'), 401
@app.errorhandler(403)
def forbidden(error):
if request.is_json:
return jsonify({
'error': 'Forbidden',
'message': 'Insufficient permissions'
}), 403
return render_template('errors/403.html'), 403
@app.errorhandler(404)
def not_found(error):
if request.is_json:
return jsonify({
'error': 'Not found',
'message': 'The requested resource was not found'
}), 404
try:
return render_template('errors/404.html'), 404
except Exception as e:
# Fallback if template cannot be loaded
app.logger.error(f'Error loading 404 template: {e}')
return '''
<!DOCTYPE html>
<html>
<head><title>404 - Page Not Found</title></head>
<body>
<h1>404 - Page Not Found</h1>
<p>The requested resource was not found.</p>
<a href="/">← Back to Dashboard</a>
</body>
</html>
''', 404
@app.errorhandler(500)
def internal_error(error):
app.logger.error(f'Internal server error: {error}')
if request.is_json:
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
try:
return render_template('errors/500.html'), 500
except Exception as e:
app.logger.error(f'Error loading 500 template: {e}')
return '''
<!DOCTYPE html>
<html>
<head><title>500 - Internal Server Error</title></head>
<body>
<h1>500 - Internal Server Error</h1>
<p>An unexpected error occurred.</p>
<a href="/">← Back to Dashboard</a>
</body>
</html>
''', 500
def _register_context_processors(app):
"""Register template context processors"""
@app.context_processor
def inject_config():
from app.models import WMTUpdateRequest
from config.database_config import get_db
try:
with get_db().get_session() as session:
pending_wmt_count = session.query(WMTUpdateRequest).filter_by(status='pending').count()
except Exception:
pending_wmt_count = 0
return {
'app_name': 'Enhanced Server Monitoring',
'app_version': '2.0.0',
'pending_wmt_count': pending_wmt_count,
}

0
app/api/__init__.py Normal file
View File

454
app/api/ansible.py Normal file
View File

@@ -0,0 +1,454 @@
"""
Ansible and SSH management API endpoints
"""
from flask import Blueprint, request, jsonify
import json
import os
from datetime import datetime
from app.services.ansible_service import AnsibleService
from app.models import Device, AnsibleExecution
from config.database_config import get_db
import logging
# Create blueprint
ansible_bp = Blueprint('ansible', __name__, url_prefix='/api/ansible')
# Initialize service
ansible_service = AnsibleService()
@ansible_bp.route('/inventory', methods=['GET'])
def get_inventory():
"""Get current Ansible inventory (structured)"""
try:
data = ansible_service.get_inventory_data()
return jsonify({'success': True, 'inventory': data})
except Exception as e:
logging.error(f"Error getting inventory: {e}")
return jsonify({'error': str(e), 'success': False}), 500
@ansible_bp.route('/inventory/raw', methods=['GET'])
def get_inventory_raw():
"""Get raw YAML inventory text"""
try:
data = ansible_service.get_inventory_data()
return jsonify({'success': True, 'yaml': data.get('raw_yaml', '')})
except Exception as e:
return jsonify({'error': str(e), 'success': False}), 500
@ansible_bp.route('/inventory/sync', methods=['POST'])
def sync_inventory():
"""Sync all active app devices into monitoring_devices inventory group"""
try:
result = ansible_service.sync_devices_to_inventory()
status = 200 if result.get('success') else 400
return jsonify(result), status
except Exception as e:
logging.error(f"Error syncing inventory: {e}")
return jsonify({'error': str(e), 'success': False}), 500
@ansible_bp.route('/inventory/group/add', methods=['POST'])
def add_inventory_group():
"""Add a new inventory group"""
try:
data = request.get_json()
group_name = (data or {}).get('group_name', '').strip()
if not group_name:
return jsonify({'success': False, 'error': 'group_name is required'}), 400
result = ansible_service.add_group_to_inventory(group_name)
return jsonify(result), 200 if result.get('success') else 400
except Exception as e:
return jsonify({'error': str(e), 'success': False}), 500
@ansible_bp.route('/inventory/group/remove', methods=['POST'])
def remove_inventory_group():
"""Remove an inventory group"""
try:
data = request.get_json()
group_name = (data or {}).get('group_name', '').strip()
if not group_name:
return jsonify({'success': False, 'error': 'group_name is required'}), 400
result = ansible_service.remove_group_from_inventory(group_name)
return jsonify(result), 200 if result.get('success') else 400
except Exception as e:
return jsonify({'error': str(e), 'success': False}), 500
@ansible_bp.route('/inventory/host/add', methods=['POST'])
def add_inventory_host():
"""Add a host to an inventory group"""
try:
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'JSON body required'}), 400
group = data.get('group', '').strip()
hostname = data.get('hostname', '').strip()
ip = data.get('ip', '').strip()
ssh_user = data.get('ssh_user', 'pi').strip() or 'pi'
ssh_port = int(data.get('ssh_port', 22))
use_key = bool(data.get('use_key', True))
password = data.get('password', None)
if not group or not hostname or not ip:
return jsonify({'success': False, 'error': 'group, hostname and ip are required'}), 400
result = ansible_service.add_host_to_inventory(
group=group, hostname=hostname, ip=ip,
ssh_user=ssh_user, ssh_port=ssh_port,
use_key=use_key, password=password
)
return jsonify(result), 200 if result.get('success') else 400
except Exception as e:
return jsonify({'error': str(e), 'success': False}), 500
@ansible_bp.route('/inventory/host/remove', methods=['POST'])
def remove_inventory_host():
"""Remove a host from an inventory group"""
try:
data = request.get_json()
group = (data or {}).get('group', '').strip()
hostname = (data or {}).get('hostname', '').strip()
if not group or not hostname:
return jsonify({'success': False, 'error': 'group and hostname are required'}), 400
result = ansible_service.remove_host_from_inventory(group=group, hostname=hostname)
return jsonify(result), 200 if result.get('success') else 400
except Exception as e:
return jsonify({'error': str(e), 'success': False}), 500
@ansible_bp.route('/inventory/refresh', methods=['POST'])
def refresh_inventory():
"""Refresh Ansible inventory from database (legacy alias for /sync)"""
try:
result = ansible_service.sync_devices_to_inventory()
return jsonify(result), 200 if result.get('success') else 400
except Exception as e:
logging.error(f"Error refreshing inventory: {e}")
return jsonify({'error': str(e), 'success': False}), 500
@ansible_bp.route('/playbooks', methods=['GET'])
def list_playbooks():
"""List available Ansible playbooks"""
try:
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),
'modified': datetime.fromtimestamp(file.stat().st_mtime).isoformat()
})
# 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 jsonify({
'success': True,
'playbooks': playbooks,
'builtin_playbooks': builtin_playbooks
})
except Exception as e:
logging.error(f"Error listing playbooks: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
@ansible_bp.route('/execute', methods=['POST'])
def execute_playbook():
"""
Execute Ansible playbook
Expected JSON:
{
"playbook": "update_devices",
"limit_hosts": ["device-01", "device-02"], # optional
"extra_vars": {"key": "value"}, # optional
"create_builtin": True # optional, create builtin playbook if needed
}
"""
try:
data = request.get_json()
if not data or not data.get('playbook'):
return jsonify({
'error': 'Playbook name is required',
'success': False
}), 400
playbook_name = data['playbook']
limit_hosts = data.get('limit_hosts')
extra_vars = data.get('extra_vars', {})
create_builtin = data.get('create_builtin', True)
# Create builtin playbooks if they don't exist
if create_builtin:
if playbook_name == 'update_devices':
ansible_service.create_update_playbook()
elif playbook_name == 'restart_service':
ansible_service.create_restart_service_playbook()
# Add controller IP to extra vars for callbacks
extra_vars['ansible_controller_ip'] = request.host
# Execute playbook
result = ansible_service.execute_playbook(
playbook_name=playbook_name,
limit_hosts=limit_hosts,
extra_vars=extra_vars
)
if result['success']:
return jsonify({
'success': True,
'message': 'Playbook execution started',
'execution_id': result['execution_id'],
'log_file': result['log_file']
})
else:
return jsonify(result), 500
except Exception as e:
logging.error(f"Error executing playbook: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
@ansible_bp.route('/executions', methods=['GET'])
def get_executions():
"""Get Ansible execution history"""
try:
limit = min(int(request.args.get('limit', 50)), 200)
executions = ansible_service.get_execution_history(limit=limit)
return jsonify({
'success': True,
'executions': executions
})
except Exception as e:
logging.error(f"Error getting executions: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
@ansible_bp.route('/executions/<execution_id>/live', methods=['GET'])
def get_execution_live(execution_id):
"""Poll current log + status for a running or finished execution (UUID)."""
result = ansible_service.get_live_execution(execution_id)
if not result.get('success'):
return jsonify(result), 404
return jsonify(result), 200
@ansible_bp.route('/executions/<int:execution_id>', methods=['GET'])
def get_execution_details(execution_id):
"""Get detailed execution information"""
try:
with get_db().get_session() as session:
execution = session.query(AnsibleExecution).get(execution_id)
if not execution:
return jsonify({
'error': 'Execution not found',
'success': False
}), 404
execution_data = {
'id': execution.id,
'playbook_name': execution.playbook_name,
'target_devices': json.loads(execution.target_devices) if execution.target_devices else [],
'command_line': execution.command_line,
'start_time': execution.start_time.isoformat() if execution.start_time else None,
'end_time': execution.end_time.isoformat() if execution.end_time else None,
'status': execution.status,
'exit_code': execution.exit_code,
'stdout_log': execution.stdout_log,
'stderr_log': execution.stderr_log,
'successful_hosts': execution.successful_hosts,
'failed_hosts': execution.failed_hosts,
'unreachable_hosts': execution.unreachable_hosts
}
# Read log file if it exists
if execution.ansible_log_file and os.path.exists(execution.ansible_log_file):
with open(execution.ansible_log_file, 'r') as f:
execution_data['full_log'] = f.read()
return jsonify({
'success': True,
'execution': execution_data
})
except Exception as e:
logging.error(f"Error getting execution details: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
@ansible_bp.route('/ssh/test', methods=['POST'])
def test_ssh_connectivity():
"""
Test SSH connectivity to devices
Expected JSON:
{
"device_ips": ["192.168.1.100", "192.168.1.101"],
"username": "pi" # optional, defaults to "pi"
}
"""
try:
data = request.get_json()
if not data or not data.get('device_ips'):
return jsonify({
'error': 'device_ips list is required',
'success': False
}), 400
device_ips = data['device_ips']
username = data.get('username', 'pi')
# Test connectivity
if len(device_ips) == 1:
# Single device test
result = ansible_service.test_ssh_connectivity(device_ips[0], username)
return jsonify({
'success': True,
'results': {device_ips[0]: result}
})
else:
# Bulk test
results = ansible_service.bulk_ssh_test(device_ips)
# Summary
successful = sum(1 for r in results.values() if r.get('success'))
total = len(results)
return jsonify({
'success': True,
'results': results,
'summary': {
'successful': successful,
'failed': total - successful,
'total': total
}
})
except Exception as e:
logging.error(f"Error testing SSH connectivity: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
@ansible_bp.route('/ssh/keys/setup', methods=['POST'])
def setup_ssh_keys():
"""Setup SSH keys for Ansible authentication"""
try:
result = ansible_service.setup_ssh_keys()
return jsonify(result)
except Exception as e:
logging.error(f"Error setting up SSH keys: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
@ansible_bp.route('/ssh/keys/public', methods=['GET'])
def get_public_key():
"""Get public key for distribution to devices"""
try:
public_key_path = ansible_service.ssh_key_path.with_suffix('.pub')
if not public_key_path.exists():
return jsonify({
'error': 'Public key not found. Run SSH setup first.',
'success': False
}), 404
with open(public_key_path, 'r') as f:
public_key = f.read().strip()
return jsonify({
'success': True,
'public_key': public_key,
'key_path': str(public_key_path)
})
except Exception as e:
logging.error(f"Error getting public key: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
@ansible_bp.route('/devices/status', methods=['GET'])
def get_devices_status():
"""Get status of all devices for Ansible operations"""
try:
with get_db().get_session() as session:
devices = session.query(Device).all()
device_data = []
for device in devices:
device_data.append({
'id': device.id,
'hostname': device.hostname,
'device_ip': device.device_ip,
'nume_masa': device.nume_masa,
'status': device.status,
'last_seen': device.last_seen.isoformat() if device.last_seen else None,
'device_type': device.device_type,
'os_version': device.os_version,
'location': device.location
})
return jsonify({
'success': True,
'devices': device_data,
'total_count': len(device_data)
})
except Exception as e:
logging.error(f"Error getting devices status: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
# Callback endpoints for Ansible playbooks
@ansible_bp.route('/callback/update_complete', methods=['POST'])
def update_complete_callback():
"""Callback endpoint for update completion"""
try:
data = request.get_json()
logging.info(f"Update completed for {data.get('hostname')}: {data}")
# You could update device status, send notifications, etc.
return jsonify({'success': True})
except Exception as e:
logging.error(f"Error in update callback: {e}")
return jsonify({'error': str(e)}), 500
@ansible_bp.route('/callback/service_restarted', methods=['POST'])
def service_restart_callback():
"""Callback endpoint for service restart completion"""
try:
data = request.get_json()
logging.info(f"Service restarted for {data.get('hostname')}: {data}")
return jsonify({'success': True})
except Exception as e:
logging.error(f"Error in service restart callback: {e}")
return jsonify({'error': str(e)}), 500

373
app/api/logs.py Normal file
View File

@@ -0,0 +1,373 @@
"""
Enhanced API endpoints for logs with compression and file support
"""
from flask import Blueprint, request, jsonify
from werkzeug.utils import secure_filename
import os
import hashlib
from datetime import datetime
from app.services.log_service import LogCompressionService
from app.services.file_service import FileUploadService
from app.models import Device, LogEntry, FileUpload
from config.database_config import get_db
import logging
# Create blueprint
logs_bp = Blueprint('logs', __name__, url_prefix='/api/logs')
# Initialize services
log_service = LogCompressionService()
file_service = FileUploadService()
@logs_bp.route('/', methods=['POST'])
@logs_bp.route('/submit', methods=['POST'])
def submit_log():
"""
Enhanced log submission with compression support
Expected JSON:
{
"hostname": "device-01",
"device_ip": "192.168.1.100",
"nume_masa": "Masa-01",
"log_message": "Card detected: ABC123",
"severity": "info", # optional: debug, info, warning, error, critical
"source_file": "/path/to/logfile.log", # optional
"metadata": {} # optional additional metadata
}
"""
try:
# Validate content type
if not request.is_json:
return jsonify({
'error': 'Content-Type must be application/json',
'success': False
}), 400
data = request.get_json()
# Validate required fields
required_fields = ['hostname', 'device_ip', 'nume_masa', 'log_message']
missing_fields = [field for field in required_fields if not data.get(field)]
if missing_fields:
return jsonify({
'error': f'Missing required fields: {", ".join(missing_fields)}',
'success': False
}), 400
# Prepare device info
device_info = {
'hostname': data['hostname'],
'device_ip': data['device_ip'],
'nume_masa': data['nume_masa'],
# Optional clients can send these to keep device records up to date
'device_type': data.get('device_type') or (data.get('metadata') or {}).get('device_type'),
'os_version': data.get('os_version') or (data.get('metadata') or {}).get('os_version'),
'location': data.get('location') or (data.get('metadata') or {}).get('location'),
'mac_address': data.get('mac_address') or (data.get('metadata') or {}).get('mac_address'),
}
# Process log with compression
result = log_service.process_log_message(
device_info=device_info,
message=data['log_message'],
severity=data.get('severity', 'info')
)
if result['success']:
# Prepare response with compression info
response_data = {
'success': True,
'message': 'Log processed successfully',
'log_id': result['log_id'],
'device_id': result['device_id'],
'compression_info': result['compression']
}
# Add alias info if template was used
if result['compression'].get('used_template'):
response_data['template_alias'] = result['compression']['template_alias']
# For clients: suggest using alias in future requests
if result['compression'].get('new_template'):
response_data['suggestion'] = f"For similar messages, you can use template alias: {result['compression']['template_alias']}"
return jsonify(response_data), 201
else:
return jsonify(result), 500
except Exception as e:
logging.error(f"Error in submit_log: {e}")
return jsonify({
'error': 'Internal server error',
'success': False
}), 500
@logs_bp.route('/template/<alias>', methods=['POST'])
def submit_templated_log():
"""
Submit log using template alias (smaller payload)
Expected JSON:
{
"alias": "CD001",
"variables": {"card_id": "ABC123"},
"device_info": {
"hostname": "device-01",
"device_ip": "192.168.1.100",
"nume_masa": "Masa-01"
}
}
"""
try:
data = request.get_json()
alias = request.view_args['alias']
# Validate required fields
if not data.get('device_info'):
return jsonify({
'error': 'device_info is required',
'success': False
}), 400
# Get template message
variables = data.get('variables', {})
full_message = log_service.get_message_by_alias(alias, variables)
if not full_message:
return jsonify({
'error': f'Template alias {alias} not found',
'success': False
}), 404
# Process as regular log
result = log_service.process_log_message(
device_info=data['device_info'],
message=full_message,
severity=data.get('severity', 'info')
)
return jsonify(result), 201 if result['success'] else 500
except Exception as e:
logging.error(f"Error in submit_templated_log: {e}")
return jsonify({
'error': 'Internal server error',
'success': False
}), 500
@logs_bp.route('/file', methods=['POST'])
def upload_log_file():
"""
Upload log file for processing
Expects multipart/form-data with:
- file: log file
- device_info: JSON string with device information
"""
try:
# Check if file was uploaded
if 'file' not in request.files:
return jsonify({
'error': 'No file uploaded',
'success': False
}), 400
file = request.files['file']
if file.filename == '':
return jsonify({
'error': 'No file selected',
'success': False
}), 400
# Get device info
device_info_str = request.form.get('device_info')
if not device_info_str:
return jsonify({
'error': 'device_info is required',
'success': False
}), 400
import json
device_info = json.loads(device_info_str)
# Process file upload
result = file_service.process_uploaded_file(file, device_info)
return jsonify(result), 201 if result['success'] else 500
except Exception as e:
logging.error(f"Error in upload_log_file: {e}")
return jsonify({
'error': 'Internal server error',
'success': False
}), 500
@logs_bp.route('/query', methods=['GET'])
def query_logs():
"""
Query logs with filters and pagination
Query parameters:
- device_id: Filter by device ID
- hostname: Filter by hostname
- severity: Filter by severity level
- start_time: Start time (ISO format)
- end_time: End time (ISO format)
- limit: Number of results (default 100)
- offset: Offset for pagination (default 0)
- include_template: Include resolved template messages (default true)
"""
try:
with get_db().get_session() as session:
# Build query
query = session.query(LogEntry).join(Device)
# Apply filters
if request.args.get('device_id'):
query = query.filter(LogEntry.device_id == int(request.args.get('device_id')))
if request.args.get('hostname'):
query = query.filter(Device.hostname == request.args.get('hostname'))
if request.args.get('severity'):
query = query.filter(LogEntry.severity == request.args.get('severity'))
if request.args.get('start_time'):
start_time = datetime.fromisoformat(request.args.get('start_time'))
query = query.filter(LogEntry.timestamp >= start_time)
if request.args.get('end_time'):
end_time = datetime.fromisoformat(request.args.get('end_time'))
query = query.filter(LogEntry.timestamp <= end_time)
# Pagination
limit = min(int(request.args.get('limit', 100)), 1000) # Max 1000
offset = int(request.args.get('offset', 0))
# Order by timestamp descending
query = query.order_by(LogEntry.timestamp.desc())
# Get total count
total_count = query.count()
# Apply pagination
logs = query.limit(limit).offset(offset).all()
# Format response
include_template = request.args.get('include_template', 'true').lower() == 'true'
log_data = []
for log in logs:
log_item = {
'id': log.id,
'device': {
'id': log.device.id,
'hostname': log.device.hostname,
'device_ip': log.device.device_ip,
'nume_masa': log.device.nume_masa
},
'timestamp': log.timestamp.isoformat(),
'severity': log.severity
}
if include_template and log.template:
log_item['message'] = log.resolved_message
log_item['template_alias'] = log.template.alias
log_item['template_category'] = log.template.category
else:
log_item['message'] = log.full_message or log.resolved_message
log_data.append(log_item)
return jsonify({
'success': True,
'logs': log_data,
'pagination': {
'total_count': total_count,
'limit': limit,
'offset': offset,
'has_more': offset + limit < total_count
}
})
except Exception as e:
logging.error(f"Error in query_logs: {e}")
return jsonify({
'error': 'Internal server error',
'success': False
}), 500
@logs_bp.route('/stats', methods=['GET'])
def get_log_stats():
"""Get logging and compression statistics"""
try:
# Get compression stats
compression_stats = log_service.get_compression_stats()
# Get additional stats
with get_db().get_session() as session:
# Device stats
active_devices = session.query(Device).filter_by(status='active').count()
total_devices = session.query(Device).count()
# Recent activity
from datetime import datetime, timedelta
last_hour = datetime.utcnow() - timedelta(hours=1)
recent_logs = session.query(LogEntry).filter(
LogEntry.timestamp >= last_hour
).count()
stats = {
'success': True,
'compression': compression_stats,
'devices': {
'active': active_devices,
'total': total_devices
},
'activity': {
'logs_last_hour': recent_logs
}
}
return jsonify(stats)
except Exception as e:
logging.error(f"Error in get_log_stats: {e}")
return jsonify({
'error': 'Internal server error',
'success': False
}), 500
@logs_bp.route('/templates', methods=['GET'])
def get_templates():
"""Get available message templates and aliases"""
try:
with get_db().get_session() as session:
from app.models import MessageTemplate
templates = session.query(MessageTemplate).order_by(
MessageTemplate.usage_count.desc()
).all()
template_data = [{
'alias': template.alias,
'category': template.category,
'template_text': template.template_text,
'usage_count': template.usage_count,
'created_at': template.created_at.isoformat()
} for template in templates]
return jsonify({
'success': True,
'templates': template_data,
'total_count': len(template_data)
})
except Exception as e:
logging.error(f"Error in get_templates: {e}")
return jsonify({
'error': 'Internal server error',
'success': False
}), 500

170
app/api/wmt.py Normal file
View File

@@ -0,0 +1,170 @@
"""
WMT (Workstation Management Terminal) configuration API
Handles config distribution and device update requests from WMT clients.
"""
from flask import Blueprint, request, jsonify
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_api_bp = Blueprint('wmt_api', __name__, url_prefix='/api/wmt')
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_or_create_global_config(session):
"""Return the single WMTGlobalConfig row, creating it with defaults if absent."""
cfg = session.query(WMTGlobalConfig).first()
if cfg is None:
cfg = WMTGlobalConfig()
session.add(cfg)
session.flush()
return cfg
def _latest_config_ts(session, mac_address):
"""Return timestamps for global config and this device's admin-reviewed info."""
global_cfg = session.query(WMTGlobalConfig).first()
global_ts = global_cfg.updated_at if global_cfg and global_cfg.updated_at else datetime(1970, 1, 1)
device = session.query(Device).filter_by(mac_address=mac_address).first()
# Use info_reviewed_at as the authoritative device-level timestamp
device_ts = device.info_reviewed_at if device and device.info_reviewed_at else datetime(1970, 1, 1)
latest = max(global_ts, device_ts)
return global_ts, device_ts, latest
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@wmt_api_bp.route('/config/timestamp', methods=['GET'])
def get_config_timestamp():
"""
Returns the last-modified timestamps for global config and this device's config.
Query param: mac=<mac_address>
Response:
{
"global_updated_at": "2026-04-22T10:00:00",
"device_updated_at": "2026-04-22T09:00:00", // null if device unknown
"latest_updated_at": "2026-04-22T10:00:00"
}
"""
mac = request.args.get('mac', '').strip().lower()
if not mac:
return jsonify({'error': 'mac query parameter is required'}), 400
try:
with get_db().get_session() as session:
global_ts, device_info_reviewed_ts, latest = _latest_config_ts(session, mac)
return jsonify({
'global_updated_at': global_ts.isoformat() if global_ts != datetime(1970, 1, 1) else None,
'device_info_reviewed_at': device_info_reviewed_ts.isoformat() if device_info_reviewed_ts != datetime(1970, 1, 1) else None,
'latest_updated_at': latest.isoformat(),
}), 200
except Exception as e:
logger.error(f'Error getting WMT config timestamp: {e}')
return jsonify({'error': str(e)}), 500
@wmt_api_bp.route('/config/<mac_address>', methods=['GET'])
def get_device_config(mac_address):
"""
Returns merged config (global settings + device-specific) for a given MAC.
Used by WMT client to pull updated config at startup.
Response: merged dict consumable by the WMT config.txt writer.
"""
mac = mac_address.strip().lower()
try:
with get_db().get_session() as session:
global_cfg = _get_or_create_global_config(session)
device = session.query(Device).filter_by(mac_address=mac).first()
# Update last_seen if device is known
if device:
device.last_seen = datetime.utcnow()
_, device_ts, latest_ts = _latest_config_ts(session, mac)
payload = {
# Global settings
'chrome_url': global_cfg.chrome_url,
'chrome_local_url': global_cfg.chrome_local_url or '',
'chrome_insecure_origin': global_cfg.chrome_insecure_origin,
'card_api_base_url': global_cfg.card_api_base_url,
'server_log_url': global_cfg.server_log_url,
'internet_check_host': global_cfg.internet_check_host,
'update_host': global_cfg.update_host,
'update_user': global_cfg.update_user,
# Device-specific settings (empty string if unknown)
'device_name': device.device_name if device else '',
'hostname': device.hostname if device else '',
'device_ip': device.device_ip if device else '',
'location': device.location if device else '',
# Admin-review timestamp for device info (client stores in [device] section)
'info_reviewed_at': device.info_reviewed_at.isoformat() if (device and device.info_reviewed_at) else '1970-01-01T00:00:00',
# Sync metadata
'config_updated_at': latest_ts.isoformat(),
}
return jsonify(payload), 200
except Exception as e:
logger.error(f'Error fetching WMT config for {mac}: {e}')
return jsonify({'error': str(e)}), 500
@wmt_api_bp.route('/config/update_request', methods=['POST'])
def submit_update_request():
"""
WMT client sends current device info as an update request for admin approval.
Expected JSON:
{
"mac_address": "b8:27:eb:aa:bb:cc",
"device_name": "Masa-01",
"hostname": "rpi-masa01",
"device_ip": "192.168.1.100",
"client_config_mtime": "2026-04-22T09:30:00" // optional
}
"""
if not request.is_json:
return jsonify({'error': 'Content-Type must be application/json'}), 400
data = request.get_json()
mac = (data.get('mac_address') or '').strip().lower()
if not mac:
return jsonify({'error': 'mac_address is required'}), 400
try:
with get_db().get_session() as session:
device = session.query(Device).filter_by(mac_address=mac).first()
req = WMTUpdateRequest(
mac_address=mac,
device_id=device.id if device else None,
proposed_device_name=data.get('device_name'),
proposed_hostname=data.get('hostname'),
proposed_device_ip=data.get('device_ip'),
client_config_mtime=data.get('client_config_mtime'),
submitted_at=datetime.utcnow(),
status='pending',
)
session.add(req)
# Update device last_seen
if device:
device.last_seen = datetime.utcnow()
logger.info(f'WMT update request received from {mac}')
return jsonify({'status': 'received', 'message': 'Update request queued for admin review'}), 201
except Exception as e:
logger.error(f'Error saving WMT update request from {mac}: {e}')
return jsonify({'error': str(e)}), 500

629
app/models/__init__.py Normal file
View File

@@ -0,0 +1,629 @@
"""
Database models for enhanced server monitoring system
"""
from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean, ForeignKey, LargeBinary, Float, Table
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from datetime import datetime
import json
import hashlib
from cryptography.fernet import Fernet
import base64
import os
Base = declarative_base()
# Association table for many-to-many relationship between inventory groups and devices
device_inventory_association = Table(
'device_inventory_groups',
Base.metadata,
Column('device_id', Integer, ForeignKey('devices.id'), primary_key=True),
Column('group_id', Integer, ForeignKey('inventory_groups.id'), primary_key=True)
)
# Export all models
__all__ = [
'Base', 'Device', 'MessageTemplate', 'LogEntry', 'FileUpload',
'AnsibleExecution', 'SystemStats', 'InventoryGroup', 'PlaybookExecution',
'PlaybookHostResult', 'ExecutionQueue', 'device_inventory_association',
'WMTGlobalConfig', 'WMTUpdateRequest',
]
class Device(Base):
"""Device information and metadata"""
__tablename__ = 'devices'
id = Column(Integer, primary_key=True)
hostname = Column(String(255), nullable=False)
device_ip = Column(String(45), nullable=False) # Support IPv6
nume_masa = Column(String(100), nullable=False)
# Enhanced device metadata
device_type = Column(String(50), default='unknown')
os_version = Column(String(100))
last_seen = Column(DateTime, default=datetime.utcnow)
status = Column(String(20), default='active') # active, inactive, maintenance
location = Column(String(200))
description = Column(Text)
# WMT (Workstation Management Terminal) integration fields
mac_address = Column(String(17), unique=True, nullable=True, index=True)
config_updated_at = Column(DateTime)
info_reviewed_at = Column(DateTime, default=lambda: datetime(1970, 1, 1))
# Relationships
logs = relationship("LogEntry", back_populates="device")
files = relationship("FileUpload", back_populates="device")
inventory_groups = relationship("InventoryGroup", secondary=device_inventory_association, back_populates="devices")
update_requests = relationship('WMTUpdateRequest', back_populates='device',
cascade='all, delete-orphan',
order_by='WMTUpdateRequest.submitted_at.desc()')
@property
def device_name(self):
"""Alias for nume_masa used by WMT module."""
return self.nume_masa
def __repr__(self):
return f"<Device(hostname='{self.hostname}', ip='{self.device_ip}')>"
class MessageTemplate(Base):
"""Message templates for compression and aliases"""
__tablename__ = 'message_templates'
id = Column(Integer, primary_key=True)
template_hash = Column(String(64), unique=True, nullable=False) # SHA-256 hash
template_text = Column(Text, nullable=False)
category = Column(String(50), nullable=False) # error, info, warning, system
alias = Column(String(20), unique=True) # Short alias like "SYS001"
usage_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
log_entries = relationship("LogEntry", back_populates="template")
@staticmethod
def create_hash(message):
"""Create hash for message template"""
return hashlib.sha256(message.encode('utf-8')).hexdigest()
def __repr__(self):
return f"<MessageTemplate(alias='{self.alias}', category='{self.category}')>"
class LogEntry(Base):
"""Compressed log entries with template references"""
__tablename__ = 'log_entries'
id = Column(Integer, primary_key=True)
device_id = Column(Integer, ForeignKey('devices.id'), nullable=False)
template_id = Column(Integer, ForeignKey('message_templates.id'))
# Original fields
timestamp = Column(DateTime, default=datetime.utcnow, nullable=False)
severity = Column(String(20), default='info') # debug, info, warning, error, critical
# Compressed message storage
full_message = Column(Text) # Only for unique messages not in templates
template_variables = Column(Text) # JSON for template variable substitution
# Enhanced metadata
source_file = Column(String(255)) # If log comes from file
line_number = Column(Integer)
process_id = Column(Integer)
thread_id = Column(String(50))
# Relationships
device = relationship("Device", back_populates="logs")
template = relationship("MessageTemplate", back_populates="log_entries")
@property
def resolved_message(self):
"""Get the full resolved message"""
if self.template:
if self.template_variables:
variables = json.loads(self.template_variables)
return self.template.template_text.format(**variables)
return self.template.template_text
return self.full_message
def __repr__(self):
return f"<LogEntry(device_id={self.device_id}, timestamp='{self.timestamp}')>"
class FileUpload(Base):
"""File upload tracking and metadata"""
__tablename__ = 'file_uploads'
id = Column(Integer, primary_key=True)
device_id = Column(Integer, ForeignKey('devices.id'), nullable=False)
# File metadata
filename = Column(String(255), nullable=False)
original_filename = Column(String(255), nullable=False)
file_path = Column(String(500), nullable=False) # Server storage path
file_size = Column(Integer, nullable=False)
file_hash = Column(String(64)) # SHA-256 for deduplication
mime_type = Column(String(100))
# Upload metadata
upload_date = Column(DateTime, default=datetime.utcnow)
upload_ip = Column(String(45))
upload_user_agent = Column(String(500))
# Processing status
processed = Column(Boolean, default=False)
processing_status = Column(String(50), default='pending') # pending, processing, completed, error
processing_error = Column(Text)
# File content analysis (for logs/config files)
is_log_file = Column(Boolean, default=False)
log_entries_extracted = Column(Integer, default=0)
# Relationships
device = relationship("Device", back_populates="files")
def __repr__(self):
return f"<FileUpload(filename='{self.filename}', device_id={self.device_id})>"
class AnsibleExecution(Base):
"""
DEPRECATED MODEL - DO NOT USE FOR NEW DEVELOPMENT
This model is kept only for backward compatibility and data migration.
All new automation functionality should use PlaybookExecution instead.
This table will be removed in a future version after data migration.
"""
__tablename__ = 'ansible_executions'
id = Column(Integer, primary_key=True)
playbook_name = Column(String(200), nullable=False)
target_devices = Column(Text) # JSON list of device IDs/IPs
# Execution details
command_line = Column(Text, nullable=False)
execution_user = Column(String(100))
start_time = Column(DateTime, default=datetime.utcnow)
end_time = Column(DateTime)
status = Column(String(20), default='running') # running, completed, failed, cancelled
exit_code = Column(Integer)
# Output and logs
stdout_log = Column(Text)
stderr_log = Column(Text)
ansible_log_file = Column(String(500))
# Results summary
successful_hosts = Column(Integer, default=0)
failed_hosts = Column(Integer, default=0)
unreachable_hosts = Column(Integer, default=0)
# Relationships - Note: This class is deprecated, use PlaybookExecution instead
@classmethod
def migrate_to_new_model(cls, session):
"""Migrate this execution to new PlaybookExecution model"""
# This method is used by migration scripts
pass
def __repr__(self):
return f"<AnsibleExecution(DEPRECATED)(playbook='{self.playbook_name}')>"
class SystemStats(Base):
"""System statistics and metrics"""
__tablename__ = 'system_stats'
id = Column(Integer, primary_key=True)
device_id = Column(Integer, ForeignKey('devices.id'), nullable=False)
# System metrics
timestamp = Column(DateTime, default=datetime.utcnow)
cpu_usage = Column(Float)
memory_usage = Column(Float)
disk_usage = Column(Float)
network_in = Column(Integer)
network_out = Column(Integer)
load_average = Column(Float)
uptime = Column(Integer) # seconds
# Process counts
total_processes = Column(Integer)
running_processes = Column(Integer)
# Temperature (for Raspberry Pi)
cpu_temperature = Column(Float)
# Relationships
device = relationship("Device")
def __repr__(self):
return f"<SystemStats(device_id={self.device_id}, timestamp='{self.timestamp}')>"
class InventoryGroup(Base):
"""Ansible inventory groups with encrypted SSH credentials"""
__tablename__ = 'inventory_groups'
id = Column(Integer, primary_key=True)
name = Column(String(100), unique=True, nullable=False)
description = Column(Text)
# SSH Connection details
ssh_user = Column(String(100), default='pi')
ssh_port = Column(Integer, default=22)
ssh_key_file = Column(String(500)) # Path to SSH key
ssh_password_encrypted = Column(LargeBinary) # Encrypted password
# Group settings
ansible_vars = Column(Text) # JSON for group variables
is_enabled = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
devices = relationship("Device", secondary=device_inventory_association, back_populates="inventory_groups")
executions = relationship("PlaybookExecution", back_populates="inventory_group")
def set_ssh_password(self, password: str):
"""Encrypt and store SSH password"""
if password:
# Generate or get encryption key
key = self._get_encryption_key()
f = Fernet(key)
self.ssh_password_encrypted = f.encrypt(password.encode())
def get_ssh_password(self) -> str:
"""Decrypt and return SSH password"""
if self.ssh_password_encrypted:
key = self._get_encryption_key()
f = Fernet(key)
return f.decrypt(self.ssh_password_encrypted).decode()
return None
def _get_encryption_key(self) -> bytes:
"""Get or generate encryption key"""
key_file = 'data/.ssh_encrypt_key'
if os.path.exists(key_file):
with open(key_file, 'rb') as f:
return f.read()
else:
# Generate new key
key = Fernet.generate_key()
with open(key_file, 'wb') as f:
f.write(key)
return key
def __repr__(self):
return f"<InventoryGroup(name='{self.name}', devices={len(self.devices)})>"
class PlaybookExecution(Base):
"""
Enhanced playbook execution with queue management and comprehensive tracking.
This is the primary model for all automation execution tracking.
Replaces the deprecated AnsibleExecution model.
"""
__tablename__ = 'playbook_executions'
id = Column(Integer, primary_key=True)
execution_id = Column(String(36), unique=True, nullable=False) # UUID
playbook_name = Column(String(200), nullable=False)
playbook_description = Column(Text) # User-friendly description
inventory_group_id = Column(Integer, ForeignKey('inventory_groups.id'))
target_hosts = Column(Text) # JSON list of specific hosts
# Execution details
command_line = Column(Text)
extra_vars = Column(Text) # JSON
execution_user = Column(String(100))
execution_ip = Column(String(45))
# Enhanced timing
queued_at = Column(DateTime, default=datetime.utcnow)
started_at = Column(DateTime)
completed_at = Column(DateTime)
estimated_duration = Column(Integer) # Estimated seconds
# Enhanced status tracking
status = Column(String(20), default='queued') # queued, running, completed, failed, cancelled, timeout
queue_position = Column(Integer, default=0)
priority = Column(Integer, default=5) # 1-10, higher = more priority
pid = Column(Integer) # Process ID when running
exit_code = Column(Integer)
retry_count = Column(Integer, default=0)
max_retries = Column(Integer, default=0)
# Enhanced output and logs
stdout_log = Column(Text)
stderr_log = Column(Text)
ansible_log_file = Column(String(500))
summary_message = Column(Text) # User-friendly summary
# Enhanced results summary
total_hosts = Column(Integer, default=0)
successful_hosts = Column(Integer, default=0)
failed_hosts = Column(Integer, default=0)
unreachable_hosts = Column(Integer, default=0)
skipped_hosts = Column(Integer, default=0)
changed_hosts = Column(Integer, default=0) # Hosts where changes were made
# Relationships
inventory_group = relationship("InventoryGroup", back_populates="executions")
host_results = relationship("PlaybookHostResult", back_populates="execution", cascade="all, delete-orphan")
# Properties for better UX
@property
def duration(self):
"""Calculate execution duration in seconds"""
if self.started_at and self.completed_at:
return (self.completed_at - self.started_at).total_seconds()
return None
@property
def duration_formatted(self):
"""Human-readable duration"""
duration = self.duration
if duration is None:
return "N/A"
if duration < 60:
return f"{int(duration)}s"
elif duration < 3600:
mins = int(duration // 60)
secs = int(duration % 60)
return f"{mins}m {secs}s"
else:
hours = int(duration // 3600)
mins = int((duration % 3600) // 60)
return f"{hours}h {mins}m"
@property
def success_rate(self):
"""Calculate success rate percentage"""
if self.total_hosts > 0:
return round((self.successful_hosts / self.total_hosts) * 100, 1)
return 0
@property
def status_display(self):
"""User-friendly status display"""
status_map = {
'queued': '⏳ Queued',
'running': '🔄 Running',
'completed': '✅ Completed',
'failed': '❌ Failed',
'cancelled': '🚫 Cancelled',
'timeout': '⏰ Timeout'
}
return status_map.get(self.status, self.status)
@property
def is_running(self):
"""Check if execution is currently running"""
return self.status in ['queued', 'running']
@property
def is_finished(self):
"""Check if execution has finished"""
return self.status in ['completed', 'failed', 'cancelled', 'timeout']
@property
def can_retry(self):
"""Check if execution can be retried"""
return (self.status in ['failed', 'timeout'] and
self.retry_count < self.max_retries)
def get_host_summary(self):
"""Get summary of host results"""
return {
'total': self.total_hosts,
'successful': self.successful_hosts,
'failed': self.failed_hosts,
'unreachable': self.unreachable_hosts,
'skipped': self.skipped_hosts,
'changed': self.changed_hosts
}
def get_failed_hosts(self):
"""Get list of failed hosts for debugging"""
return [result for result in self.host_results
if result.status == 'failed']
def get_status_color(self):
"""Get CSS color class for status"""
color_map = {
'queued': 'text-warning',
'running': 'text-info',
'completed': 'text-success',
'failed': 'text-danger',
'cancelled': 'text-secondary',
'timeout': 'text-warning'
}
return color_map.get(self.status, 'text-secondary')
def update_summary(self):
"""Update summary message based on execution results"""
if self.status == 'completed':
if self.failed_hosts == 0:
self.summary_message = f"✅ Successfully executed on all {self.successful_hosts} hosts"
else:
self.summary_message = f"⚠️ Completed with {self.failed_hosts} failures out of {self.total_hosts} hosts"
elif self.status == 'failed':
self.summary_message = f"❌ Execution failed: {self.failed_hosts}/{self.total_hosts} hosts failed"
elif self.status == 'running':
self.summary_message = f"🔄 Executing on {self.total_hosts} hosts..."
elif self.status == 'queued':
self.summary_message = f"⏳ Queued for execution on {self.total_hosts} hosts"
def __repr__(self):
return f"<PlaybookExecution(id='{self.execution_id}', playbook='{self.playbook_name}', status='{self.status}')>"
class PlaybookHostResult(Base):
"""Individual host results for playbook executions"""
__tablename__ = 'playbook_host_results'
id = Column(Integer, primary_key=True)
execution_id = Column(String(36), ForeignKey('playbook_executions.execution_id'), nullable=False)
device_id = Column(Integer, ForeignKey('devices.id'), nullable=False)
hostname = Column(String(255), nullable=False)
# Result details
status = Column(String(20), nullable=False) # ok, failed, unreachable, skipped
changed = Column(Boolean, default=False)
failed_tasks = Column(Integer, default=0)
total_tasks = Column(Integer, default=0)
# Timing
start_time = Column(DateTime)
end_time = Column(DateTime)
# Output specific to this host
host_output = Column(Text)
error_message = Column(Text)
# Task results summary
task_results = Column(Text) # JSON with per-task results
# Relationships
execution = relationship("PlaybookExecution", back_populates="host_results")
device = relationship("Device")
@property
def duration(self):
"""Calculate host execution duration"""
if self.start_time and self.end_time:
return (self.end_time - self.start_time).total_seconds()
return None
@property
def success_rate(self):
"""Calculate task success rate for this host"""
if self.total_tasks > 0:
successful_tasks = self.total_tasks - self.failed_tasks
return (successful_tasks / self.total_tasks) * 100
return 0
def __repr__(self):
return f"<PlaybookHostResult(hostname='{self.hostname}', status='{self.status}')>"
class ExecutionQueue(Base):
"""Queue management for background playbook executions"""
__tablename__ = 'execution_queue'
id = Column(Integer, primary_key=True)
execution_id = Column(String(36), ForeignKey('playbook_executions.execution_id'), nullable=False)
queue_position = Column(Integer, nullable=False, default=0)
priority = Column(Integer, default=5) # 1-10, higher = more priority
# Queue metadata
queued_by = Column(String(100))
queued_at = Column(DateTime, default=datetime.utcnow)
scheduled_for = Column(DateTime) # For scheduled executions
# Dependencies
depends_on = Column(String(36), ForeignKey('playbook_executions.execution_id')) # Wait for this execution
# Status
is_active = Column(Boolean, default=True)
# Relationships
execution = relationship("PlaybookExecution", foreign_keys=[execution_id])
dependency = relationship("PlaybookExecution", foreign_keys=[depends_on])
def __repr__(self):
return f"<ExecutionQueue(execution_id='{self.execution_id}', position={self.queue_position})>"
# ---------------------------------------------------------------------------
# WMT (Workstation Management Terminal) configuration models
# ---------------------------------------------------------------------------
class WMTGlobalConfig(Base):
"""Global WMT application settings one row shared by all devices."""
__tablename__ = 'wmt_global_config'
id = Column(Integer, primary_key=True)
# Chrome launch URLs
chrome_url = Column(String(500), nullable=False,
default='http://10.76.140.17/iweb_v2/index.php/traceability/production')
chrome_local_url = Column(String(500)) # optional local / fallback URL
chrome_insecure_origin = Column(String(200), default='http://10.76.140.17')
# Card API
card_api_base_url = Column(String(500), nullable=False,
default='https://dataswsibiusb01.sibiusb.harting.intra/RO_Quality_PRD/api/record')
# Server connectivity
server_log_url = Column(String(500), default='http://rpi-ansible:80/logs')
internet_check_host = Column(String(200), default='10.76.140.17')
update_host = Column(String(200), default='rpi-ansible')
update_user = Column(String(100), default='pi')
# Metadata
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
updated_by = Column(String(100), default='admin')
notes = Column(Text)
def to_dict(self):
return {
'id': self.id,
'chrome_url': self.chrome_url,
'chrome_local_url': self.chrome_local_url,
'chrome_insecure_origin': self.chrome_insecure_origin,
'card_api_base_url': self.card_api_base_url,
'server_log_url': self.server_log_url,
'internet_check_host': self.internet_check_host,
'update_host': self.update_host,
'update_user': self.update_user,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'updated_by': self.updated_by,
'notes': self.notes,
}
def __repr__(self):
return f"<WMTGlobalConfig(chrome_url='{self.chrome_url}')>"
class WMTUpdateRequest(Base):
"""Device-initiated update request awaiting admin approval."""
__tablename__ = 'wmt_update_requests'
id = Column(Integer, primary_key=True)
# Foreign key to Device (nullable device may not be registered yet)
device_id = Column(Integer, ForeignKey('devices.id'), nullable=True)
mac_address = Column(String(17), nullable=False, index=True) # always stored
# Data proposed by the device
proposed_device_name = Column(String(100))
proposed_hostname = Column(String(255))
proposed_device_ip = Column(String(45))
# Request metadata
submitted_at = Column(DateTime, default=datetime.utcnow)
client_config_mtime = Column(String(30)) # ISO timestamp from the client
# Admin decision
status = Column(String(20), default='pending') # pending | accepted | rejected
admin_reviewed_at = Column(DateTime)
admin_notes = Column(Text)
# Relationship
device = relationship('Device', back_populates='update_requests')
def to_dict(self):
return {
'id': self.id,
'device_id': self.device_id,
'mac_address': self.mac_address,
'proposed_device_name': self.proposed_device_name,
'proposed_hostname': self.proposed_hostname,
'proposed_device_ip': self.proposed_device_ip,
'submitted_at': self.submitted_at.isoformat() if self.submitted_at else None,
'client_config_mtime': self.client_config_mtime,
'status': self.status,
'admin_reviewed_at': self.admin_reviewed_at.isoformat() if self.admin_reviewed_at else None,
'admin_notes': self.admin_notes,
}
def __repr__(self):
return f"<WMTUpdateRequest(mac='{self.mac_address}', status='{self.status}')>"

1
app/models/device.py Normal file
View File

@@ -0,0 +1 @@
# Enhanced Server Monitoring System v2.0 - Models Package

0
app/services/__init__.py Normal file
View File

View File

@@ -0,0 +1,925 @@
"""
SSH and Ansible management service for remote device operations
"""
import os
import json
import subprocess
import tempfile
import threading
import paramiko
import yaml
import uuid
from typing import Dict, List, Optional, Tuple
from datetime import datetime
from pathlib import Path
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from app.models import Device, AnsibleExecution, PlaybookExecution
from config.database_config import get_db
class AnsibleService:
"""Service for managing remote devices via SSH and Ansible"""
SETTINGS_FILE = Path("data/ansible_settings.json")
DEFAULT_SETTINGS = {
"ssh_fallback_password": "raspberry",
}
def __init__(self):
self.db = get_db()
self.ansible_dir = Path("ansible")
self.inventory_file = self.ansible_dir / "inventory" / "dynamic_inventory.yaml"
self.playbook_dir = self.ansible_dir / "playbooks"
self.ssh_key_path = Path.home() / ".ssh" / "ansible_key"
# Ensure directories exist
self.ansible_dir.mkdir(exist_ok=True)
(self.ansible_dir / "inventory").mkdir(exist_ok=True)
(self.ansible_dir / "playbooks").mkdir(exist_ok=True)
(self.ansible_dir / "roles").mkdir(exist_ok=True)
# ------------------------------------------------------------------ #
# Settings helpers #
# ------------------------------------------------------------------ #
def load_settings(self) -> Dict:
"""Load ansible settings from data/ansible_settings.json."""
settings = dict(self.DEFAULT_SETTINGS)
if self.SETTINGS_FILE.exists():
try:
with open(self.SETTINGS_FILE, 'r') as f:
stored = json.load(f)
settings.update(stored)
except Exception as e:
logging.error(f"Error reading ansible settings: {e}")
return settings
def save_settings(self, settings: Dict):
"""Persist ansible settings to data/ansible_settings.json."""
self.SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
current = self.load_settings()
current.update(settings)
with open(self.SETTINGS_FILE, 'w') as f:
json.dump(current, f, indent=2)
logging.info("Ansible settings saved")
# ------------------------------------------------------------------ #
# Inventory file helpers #
# ------------------------------------------------------------------ #
def _read_inventory(self) -> Dict:
"""Read inventory YAML file and return parsed dict (safe)."""
if self.inventory_file.exists():
try:
with open(self.inventory_file, 'r') as f:
data = yaml.safe_load(f) or {}
if 'all' not in data:
data['all'] = {'children': {}}
if 'children' not in (data['all'] or {}):
data['all']['children'] = {}
return data
except Exception as e:
logging.error(f"Error reading inventory file: {e}")
return {'all': {'children': {}}}
def _write_inventory(self, data: Dict):
"""Write inventory dict to YAML file."""
self.inventory_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.inventory_file, 'w') as f:
yaml.dump(data, f, default_flow_style=False, allow_unicode=True)
def get_inventory_data(self) -> Dict:
"""Return structured inventory data for display (groups + hosts)."""
data = self._read_inventory()
groups = {}
children = data.get('all', {}).get('children', {}) or {}
for group_name, group_data in children.items():
hosts = []
group_data = group_data or {}
for hostname, host_vars in (group_data.get('hosts') or {}).items():
entry = {'hostname': hostname}
entry.update(host_vars or {})
hosts.append(entry)
groups[group_name] = {
'hosts': hosts,
'vars': group_data.get('vars', {}) or {}
}
raw = ''
if self.inventory_file.exists():
try:
with open(self.inventory_file, 'r') as f:
raw = f.read()
except Exception:
pass
return {'groups': groups, 'raw_yaml': raw}
# ------------------------------------------------------------------ #
# Inventory CRUD #
# ------------------------------------------------------------------ #
def sync_devices_to_inventory(self) -> Dict:
"""Sync all active DB devices into monitoring_devices group.
Preserves all other custom groups already in the inventory."""
try:
import re as _re
data = self._read_inventory()
children = data['all'].setdefault('children', {})
# Reset only monitoring_devices group
children['monitoring_devices'] = {'hosts': {}}
synced = 0
with self.db.get_session() as session:
devices = session.query(Device).filter_by(status='active').all()
for device in devices:
if device.device_ip == '127.0.0.1' or device.hostname == 'localhost':
hvars = {
'ansible_connection': 'local',
'ansible_host': '127.0.0.1'
}
else:
hvars = {
'ansible_host': device.device_ip,
'ansible_user': 'pi',
'ansible_ssh_private_key_file': str(self.ssh_key_path),
'ansible_ssh_common_args': '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'
}
children['monitoring_devices']['hosts'][device.hostname] = hvars
synced += 1
self._write_inventory(data)
return {'success': True, 'synced': synced,
'message': f'Synced {synced} device(s) to monitoring_devices group'}
except Exception as e:
logging.error(f"Error syncing devices to inventory: {e}")
return {'success': False, 'error': str(e)}
def add_group_to_inventory(self, group_name: str) -> Dict:
"""Add a new empty group to the inventory."""
import re as _re
if not _re.match(r'^[a-zA-Z0-9_-]+$', group_name):
return {'success': False,
'error': 'Group name may only contain letters, numbers, underscores and hyphens'}
try:
data = self._read_inventory()
children = data['all'].setdefault('children', {})
if group_name in children:
return {'success': False, 'error': f'Group "{group_name}" already exists'}
children[group_name] = {'hosts': {}}
self._write_inventory(data)
return {'success': True, 'message': f'Group "{group_name}" created'}
except Exception as e:
return {'success': False, 'error': str(e)}
def remove_group_from_inventory(self, group_name: str) -> Dict:
"""Remove a custom group from the inventory."""
if group_name == 'monitoring_devices':
return {'success': False,
'error': 'Cannot remove the default monitoring_devices group'}
try:
data = self._read_inventory()
children = data['all'].get('children', {}) or {}
if group_name not in children:
return {'success': False, 'error': f'Group "{group_name}" not found'}
del children[group_name]
self._write_inventory(data)
return {'success': True, 'message': f'Group "{group_name}" removed'}
except Exception as e:
return {'success': False, 'error': str(e)}
def add_host_to_inventory(self, group: str, hostname: str, ip: str,
ssh_user: str = 'pi', ssh_port: int = 22,
use_key: bool = True, password: str = None) -> Dict:
"""Manually add a host to a specific inventory group."""
import re as _re
if not _re.match(r'^[a-zA-Z0-9_.-]+$', hostname):
return {'success': False, 'error': 'Invalid hostname (letters, digits, dot, hyphen, underscore only)'}
try:
data = self._read_inventory()
children = data['all'].setdefault('children', {})
if group not in children:
children[group] = {'hosts': {}}
if children[group] is None:
children[group] = {'hosts': {}}
hosts = children[group].setdefault('hosts', {})
if hosts is None:
children[group]['hosts'] = {}
hosts = children[group]['hosts']
hvars = {
'ansible_host': ip,
'ansible_user': ssh_user,
'ansible_port': ssh_port,
'ansible_ssh_common_args': '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'
}
if use_key:
hvars['ansible_ssh_private_key_file'] = str(self.ssh_key_path)
elif password:
hvars['ansible_password'] = password
hosts[hostname] = hvars
self._write_inventory(data)
return {'success': True, 'message': f'Host "{hostname}" added to group "{group}"'}
except Exception as e:
return {'success': False, 'error': str(e)}
def remove_host_from_inventory(self, group: str, hostname: str) -> Dict:
"""Remove a host from an inventory group."""
try:
data = self._read_inventory()
children = data['all'].get('children', {}) or {}
group_data = children.get(group) or {}
hosts = group_data.get('hosts') or {}
if hostname not in hosts:
return {'success': False,
'error': f'Host "{hostname}" not found in group "{group}"'}
del hosts[hostname]
self._write_inventory(data)
return {'success': True, 'message': f'Host "{hostname}" removed from "{group}"'}
except Exception as e:
return {'success': False, 'error': str(e)}
# ------------------------------------------------------------------ #
# Legacy / compatibility #
# ------------------------------------------------------------------ #
def generate_dynamic_inventory(self) -> Dict:
"""Sync DB devices into inventory and return the full inventory dict."""
self.sync_devices_to_inventory()
return self._read_inventory()
def create_update_playbook(self) -> str:
"""Create Ansible playbook for device updates"""
playbook_content = {
'name': 'Update monitoring devices',
'hosts': 'all',
'become': True,
'gather_facts': True,
'tasks': [
{
'name': 'Update apt cache',
'apt': {
'update_cache': True,
'cache_valid_time': 3600
}
},
{
'name': 'Upgrade all packages',
'apt': {
'upgrade': 'dist',
'autoremove': True,
'autoclean': True
},
'register': 'upgrade_result'
},
{
'name': 'Restart device if required',
'reboot': {
'reboot_timeout': 600
},
'when': 'upgrade_result.changed'
},
{
'name': 'Check service status',
'systemd': {
'name': 'prezenta.service',
'state': 'started'
}
},
{
'name': 'Report update completion',
'uri': {
'url': 'http://{{ ansible_controller_ip }}/api/update_complete',
'method': 'POST',
'body_format': 'json',
'body': {
'hostname': '{{ inventory_hostname }}',
'device_ip': '{{ ansible_host }}',
'status': 'completed',
'packages_updated': '{{ upgrade_result.stdout_lines | length }}'
}
}
}
]
}
playbook_path = self.playbook_dir / "update_devices.yml"
with open(playbook_path, 'w') as f:
yaml.dump([playbook_content], f, default_flow_style=False)
return str(playbook_path)
def create_restart_service_playbook(self) -> str:
"""Create playbook for restarting device services"""
playbook_content = {
'name': 'Restart monitoring service',
'hosts': 'all',
'become': True,
'tasks': [
{
'name': 'Stop prezenta service',
'systemd': {
'name': 'prezenta.service',
'state': 'stopped'
}
},
{
'name': 'Wait for service to stop',
'wait_for': {
'timeout': 10
}
},
{
'name': 'Start prezenta service',
'systemd': {
'name': 'prezenta.service',
'state': 'started',
'enabled': True
}
},
{
'name': 'Verify service is running',
'systemd': {
'name': 'prezenta.service'
},
'register': 'service_status'
},
{
'name': 'Report service restart',
'uri': {
'url': 'http://{{ ansible_controller_ip }}/api/service_restarted',
'method': 'POST',
'body_format': 'json',
'body': {
'hostname': '{{ inventory_hostname }}',
'device_ip': '{{ ansible_host }}',
'service_status': '{{ service_status.status.ActiveState }}'
}
}
}
]
}
playbook_path = self.playbook_dir / "restart_service.yml"
with open(playbook_path, 'w') as f:
yaml.dump([playbook_content], f, default_flow_style=False)
return str(playbook_path)
def execute_playbook(self, playbook_name: str, limit_hosts: List[str] = None,
extra_vars: Dict = None, priority: int = 5, max_retries: int = 0) -> Dict:
"""Execute Ansible playbook with enhanced tracking and queue management"""
try:
# Generate fresh inventory
self.generate_dynamic_inventory()
# Build ansible-playbook command
playbook_path = self.playbook_dir / f"{playbook_name}.yml"
if not playbook_path.exists():
return {
'success': False,
'error': f'Playbook {playbook_name} not found'
}
cmd = [
'ansible-playbook',
str(playbook_path.resolve()),
'-i', str(self.inventory_file.resolve()),
'-v' # Verbose output
]
# Limit to specific hosts if provided
if limit_hosts:
cmd.extend(['--limit', ','.join(limit_hosts)])
# Add extra variables
if extra_vars:
cmd.extend(['--extra-vars', json.dumps(extra_vars)])
# Create enhanced execution record using new model
execution_id = str(uuid.uuid4())
with self.db.get_session() as session:
execution = PlaybookExecution(
execution_id=execution_id,
playbook_name=playbook_name,
playbook_description=self._get_playbook_description(playbook_name),
target_hosts=json.dumps(limit_hosts or []),
command_line=' '.join(cmd),
extra_vars=json.dumps(extra_vars or {}),
queued_at=datetime.utcnow(),
started_at=datetime.utcnow(),
status='running',
priority=priority,
max_retries=max_retries,
total_hosts=len(limit_hosts) if limit_hosts else 0
)
session.add(execution)
session.flush()
execution_db_id = execution.id
# Execute playbook
with tempfile.NamedTemporaryFile(mode='w+', suffix='.log', delete=False) as log_file:
log_file_path = log_file.name
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
cwd=str(self.ansible_dir)
)
stdout, stderr = process.communicate()
# Update execution record with results
with self.db.get_session() as session:
execution = session.query(PlaybookExecution).get(execution_db_id)
execution.completed_at = datetime.utcnow()
execution.exit_code = process.returncode
execution.stdout_log = stdout
execution.stderr_log = stderr
execution.ansible_log_file = log_file_path
if process.returncode == 0:
execution.status = 'completed'
execution.summary_message = 'Playbook executed successfully'
# Parse stdout for success/failure counts
self._parse_ansible_results_enhanced(execution, stdout)
else:
execution.status = 'failed'
execution.summary_message = f'Playbook failed with exit code {process.returncode}'
# Check if retry is needed
if execution.retry_count < max_retries:
execution.status = 'retry_pending'
# Write logs to file
with open(log_file_path, 'w') as f:
f.write(f"STDOUT:\n{stdout}\n\nSTDERR:\n{stderr}\n")
return {
'success': process.returncode == 0,
'execution_id': execution_id,
'stdout': stdout,
'stderr': stderr,
'exit_code': process.returncode,
'log_file': log_file_path,
'error': stderr if process.returncode != 0 else None
}
except Exception as e:
logging.error(f"Error executing playbook {playbook_name}: {e}")
return {
'success': False,
'error': str(e)
}
# ------------------------------------------------------------------ #
# Async execution (background thread + live log streaming) #
# ------------------------------------------------------------------ #
def execute_playbook_async(self, playbook_name: str, limit_hosts: List[str] = None,
extra_vars: Dict = None, priority: int = 5,
max_retries: int = 0) -> Dict:
"""
Start a playbook in a background thread.
Returns immediately with the execution_id so the caller can poll /live.
"""
try:
self.generate_dynamic_inventory()
playbook_path = self.playbook_dir / f"{playbook_name}.yml"
if not playbook_path.exists():
return {'success': False, 'error': f'Playbook {playbook_name} not found'}
cmd = [
'ansible-playbook',
str(playbook_path.resolve()),
'-i', str(self.inventory_file.resolve()),
'-v',
]
if limit_hosts:
cmd.extend(['--limit', ','.join(limit_hosts)])
if extra_vars:
# Pass all extra vars as a single JSON string to avoid value-quoting issues
cmd.extend(['--extra-vars', json.dumps(extra_vars)])
# Create a persistent log file (NOT deleted on close)
log_fd, log_file_path = tempfile.mkstemp(suffix='.log', prefix='ansible_')
os.close(log_fd)
execution_id = str(uuid.uuid4())
with self.db.get_session() as session:
execution = PlaybookExecution(
execution_id=execution_id,
playbook_name=playbook_name,
playbook_description=self._get_playbook_description(playbook_name),
target_hosts=json.dumps(limit_hosts or []),
command_line=' '.join(cmd),
extra_vars=json.dumps(extra_vars or {}),
queued_at=datetime.utcnow(),
started_at=datetime.utcnow(),
status='running',
priority=priority,
max_retries=max_retries,
total_hosts=len(limit_hosts) if limit_hosts else 0,
ansible_log_file=log_file_path,
)
session.add(execution)
session.flush()
execution_db_id = execution.id
thread = threading.Thread(
target=self._run_playbook_thread,
args=(execution_db_id, execution_id, cmd, log_file_path, max_retries),
daemon=True,
)
thread.start()
return {'success': True, 'execution_id': execution_id}
except Exception as e:
logging.error(f"Error starting async playbook {playbook_name}: {e}")
return {'success': False, 'error': str(e)}
def _run_playbook_thread(self, execution_db_id: int, execution_id: str,
cmd: List[str], log_file_path: str, max_retries: int):
"""Background worker: streams stdout/stderr to log file, updates DB on completion."""
try:
# Build subprocess env: PYTHONUNBUFFERED forces ansible (Python-based) to
# flush each line immediately instead of block-buffering through the pipe.
env = os.environ.copy()
env['PYTHONUNBUFFERED'] = '1'
env['ANSIBLE_FORCE_COLOR'] = '0'
env['ANSIBLE_NOCOLOR'] = '1'
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, # merge stderr into stdout
text=True,
bufsize=1, # line-buffered on the read side
cwd=str(self.ansible_dir),
env=env,
)
with open(log_file_path, 'w') as lf:
# Write a startup marker immediately so the UI has something to show
lf.write(f'--- ansible-playbook started (pid {process.pid}) ---\n')
lf.write(f'Command: {" ".join(cmd)}\n')
lf.write('---\n')
lf.flush()
# Explicit readline loop — avoids Python's read-ahead buffer
# that the `for line in process.stdout` iterator uses.
while True:
line = process.stdout.readline()
if line:
lf.write(line)
lf.flush() # flush after every line for live view
elif process.poll() is not None:
break
process.wait()
# Read full output for DB storage
with open(log_file_path, 'r') as lf:
full_output = lf.read()
with self.db.get_session() as session:
execution = session.query(PlaybookExecution).get(execution_db_id)
if execution:
execution.completed_at = datetime.utcnow()
execution.exit_code = process.returncode
execution.stdout_log = full_output
if process.returncode == 0:
execution.status = 'completed'
execution.summary_message = 'Playbook executed successfully'
self._parse_ansible_results_enhanced(execution, full_output)
else:
execution.status = 'failed'
execution.summary_message = f'Playbook failed (exit {process.returncode})'
if execution.retry_count < max_retries:
execution.status = 'retry_pending'
except Exception as e:
logging.error(f"Background playbook thread error [{execution_id}]: {e}")
try:
with self.db.get_session() as session:
execution = session.query(PlaybookExecution).get(execution_db_id)
if execution:
execution.status = 'failed'
execution.summary_message = str(e)
execution.completed_at = datetime.utcnow()
except Exception:
pass
def get_live_execution(self, execution_id: str) -> Dict:
"""Return current status + log content for a running or finished execution."""
try:
with self.db.get_session() as session:
execution = session.query(PlaybookExecution).filter_by(
execution_id=execution_id
).first()
if not execution:
return {'success': False, 'error': 'Execution not found'}
log_content = ''
log_file = execution.ansible_log_file
if log_file and os.path.exists(log_file):
try:
with open(log_file, 'r') as f:
log_content = f.read()
except Exception:
log_content = execution.stdout_log or ''
else:
log_content = execution.stdout_log or ''
if not log_content and execution.status == 'running':
log_content = f'Waiting for ansible-playbook to produce output...\nCommand: {execution.command_line or ""}'
return {
'success': True,
'execution_id': execution_id,
'status': execution.status,
'playbook_name': execution.playbook_name,
'target_hosts': json.loads(execution.target_hosts) if execution.target_hosts else [],
'started_at': execution.started_at.isoformat() if execution.started_at else None,
'completed_at': execution.completed_at.isoformat() if execution.completed_at else None,
'successful_hosts': execution.successful_hosts,
'failed_hosts': execution.failed_hosts,
'unreachable_hosts': execution.unreachable_hosts,
'exit_code': execution.exit_code,
'summary_message': execution.summary_message,
'log': log_content,
}
except Exception as e:
logging.error(f"Error fetching live execution {execution_id}: {e}")
return {'success': False, 'error': str(e)}
def _parse_ansible_results_enhanced(self, execution: PlaybookExecution, output: str):
"""Parse Ansible output for enhanced result statistics"""
lines = output.split('\n')
successful_hosts = 0
failed_hosts = 0
unreachable_hosts = 0
skipped_hosts = 0
changed_hosts = 0
for line in lines:
if 'ok=' in line and 'changed=' in line:
# Parse line like: "host1: ok=4 changed=2 unreachable=0 failed=0"
try:
if 'failed=0' in line:
successful_hosts += 1
else:
failed_count = int(line.split('failed=')[1].split()[0])
if failed_count > 0:
failed_hosts += 1
else:
successful_hosts += 1
if 'unreachable=' in line:
unreachable = int(line.split('unreachable=')[1].split()[0])
if unreachable > 0:
unreachable_hosts += 1
if 'skipped=' in line:
skipped = int(line.split('skipped=')[1].split()[0])
if skipped > 0:
skipped_hosts += 1
if 'changed=' in line:
changed = int(line.split('changed=')[1].split()[0])
if changed > 0:
changed_hosts += 1
except (ValueError, IndexError):
# Skip malformed lines
continue
# Update execution record
execution.successful_hosts = successful_hosts
execution.failed_hosts = failed_hosts
execution.unreachable_hosts = unreachable_hosts
execution.skipped_hosts = skipped_hosts
execution.changed_hosts = changed_hosts
def _get_playbook_description(self, playbook_name: str) -> str:
"""Get user-friendly description for playbook"""
descriptions = {
'update_devices': 'Update all packages and monitoring software on devices',
'restart_service': 'Restart monitoring services on selected devices',
'system_health': 'Check system health and monitoring status',
'maintenance_mode': 'Put devices in maintenance mode'
}
return descriptions.get(playbook_name, f'Execute {playbook_name} playbook')
def create_system_health_playbook(self) -> str:
"""Create system health check playbook"""
playbook_content = {
'name': 'System Health Check',
'hosts': 'all',
'become': True,
'gather_facts': True,
'tasks': [
{
'name': 'Check disk usage',
'shell': 'df -h',
'register': 'disk_usage'
},
{
'name': 'Check memory usage',
'shell': 'free -m',
'register': 'memory_usage'
},
{
'name': 'Check system uptime',
'shell': 'uptime',
'register': 'system_uptime'
},
{
'name': 'Check running services',
'shell': 'systemctl list-units --type=service --state=running | grep -E "(ssh|monitoring|python)"',
'register': 'running_services',
'ignore_errors': True
},
{
'name': 'Check network connectivity',
'shell': 'ping -c 3 8.8.8.8',
'register': 'network_test',
'ignore_errors': True
},
{
'name': 'Display health summary',
'debug': {
'msg': [
'=== SYSTEM HEALTH REPORT ===',
'Disk Usage: {{ disk_usage.stdout_lines[0] if disk_usage.stdout_lines else "N/A" }}',
'Memory: {{ memory_usage.stdout_lines[1] if memory_usage.stdout_lines|length > 1 else "N/A" }}',
'Uptime: {{ system_uptime.stdout if system_uptime.stdout else "N/A" }}',
'Network: {{ "OK" if network_test.rc == 0 else "FAILED" }}',
'Services: {{ running_services.stdout_lines|length if running_services.stdout_lines else 0 }} monitoring services running'
]
}
}
]
}
self.playbook_dir.mkdir(exist_ok=True)
playbook_path = self.playbook_dir / "system_health.yml"
with open(playbook_path, 'w') as f:
yaml.dump([playbook_content], f, default_flow_style=False)
return str(playbook_path)
def _parse_ansible_results(self, execution: AnsibleExecution, output: str):
"""Parse Ansible output for result statistics"""
lines = output.split('\n')
for line in lines:
if 'ok=' in line and 'changed=' in line:
# Parse line like: "host1: ok=4 changed=2 unreachable=0 failed=0"
if 'failed=0' in line or 'failed=0 ' in line:
execution.successful_hosts += 1
else:
execution.failed_hosts += 1
if 'unreachable=' in line:
unreachable = int(line.split('unreachable=')[1].split()[0])
execution.unreachable_hosts += unreachable
def test_ssh_connectivity(self, device_ip: str, username: str = 'pi') -> Dict:
"""Test SSH connectivity to a device"""
try:
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
# Try with SSH key first, then password
try:
client.connect(
device_ip,
username=username,
key_filename=str(self.ssh_key_path),
timeout=10
)
except paramiko.AuthenticationException:
# Fallback to configurable password
fallback_pw = self.load_settings().get('ssh_fallback_password', 'raspberry')
client.connect(
device_ip,
username=username,
password=fallback_pw,
timeout=10
)
# Test command execution
stdin, stdout, stderr = client.exec_command('uptime')
uptime_output = stdout.read().decode()
client.close()
return {
'success': True,
'message': 'SSH connection successful',
'uptime': uptime_output.strip()
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
def bulk_ssh_test(self, device_ips: List[str]) -> Dict:
"""Test SSH connectivity to multiple devices in parallel"""
results = {}
with ThreadPoolExecutor(max_workers=10) as executor:
future_to_ip = {
executor.submit(self.test_ssh_connectivity, ip): ip
for ip in device_ips
}
for future in as_completed(future_to_ip):
ip = future_to_ip[future]
try:
result = future.result()
results[ip] = result
except Exception as e:
results[ip] = {
'success': False,
'error': str(e)
}
return results
def setup_ssh_keys(self) -> Dict:
"""Setup SSH keys for Ansible authentication"""
try:
key_path = Path(self.ssh_key_path)
key_path.parent.mkdir(exist_ok=True)
if not key_path.exists():
# Generate new SSH key pair
subprocess.run([
'ssh-keygen',
'-t', 'rsa',
'-b', '4096',
'-f', str(key_path),
'-N', '', # No passphrase
'-C', 'ansible@monitoring-server'
], check=True)
# Set proper permissions
key_path.chmod(0o600)
return {
'success': True,
'message': 'SSH key pair generated',
'public_key_path': f"{key_path}.pub",
'private_key_path': str(key_path)
}
else:
return {
'success': True,
'message': 'SSH key already exists',
'public_key_path': f"{key_path}.pub",
'private_key_path': str(key_path)
}
except Exception as e:
logging.error(f"Error setting up SSH keys: {e}")
return {
'success': False,
'error': str(e)
}
def get_execution_history(self, limit: int = 50) -> List[Dict]:
"""Get Ansible execution history using enhanced PlaybookExecution model"""
try:
with self.db.get_session() as session:
executions = session.query(PlaybookExecution).order_by(
PlaybookExecution.queued_at.desc()
).limit(limit).all()
return [{
'id': exec.id,
'execution_id': exec.execution_id,
'playbook_name': exec.playbook_name,
'playbook_description': exec.playbook_description,
'queued_at': exec.queued_at.isoformat() if exec.queued_at else None,
'started_at': exec.started_at.isoformat() if exec.started_at else None,
'completed_at': exec.completed_at.isoformat() if exec.completed_at else None,
'status': exec.status,
'priority': exec.priority,
'retry_count': exec.retry_count,
'max_retries': exec.max_retries,
'exit_code': exec.exit_code,
'total_hosts': exec.total_hosts,
'successful_hosts': exec.successful_hosts,
'failed_hosts': exec.failed_hosts,
'unreachable_hosts': exec.unreachable_hosts,
'skipped_hosts': exec.skipped_hosts,
'changed_hosts': exec.changed_hosts,
'summary_message': exec.summary_message,
'duration': exec.duration,
'duration_formatted': exec.duration_formatted
} for exec in executions]
except Exception as e:
logging.error(f"Error getting execution history: {e}")
return []

View File

@@ -0,0 +1,324 @@
"""
Device management service with CRUD operations and device monitoring
"""
import logging
from typing import List, Optional, Dict, Any
from datetime import datetime, timedelta
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import desc, func, and_
from app.models import Device, LogEntry, FileUpload, InventoryGroup
from config.database_config import get_db
class DeviceService:
"""Service for managing devices and device-related operations"""
def __init__(self):
self.db = get_db()
self.logger = logging.getLogger(__name__)
# Basic CRUD Operations
def create_device(self, hostname: str, device_ip: str, nume_masa: str, **kwargs) -> Device:
"""Create a new device"""
try:
with self.db.get_session() as session:
# Check if device already exists
existing = session.query(Device).filter(
(Device.hostname == hostname) | (Device.device_ip == device_ip)
).first()
if existing:
raise ValueError(f"Device with hostname '{hostname}' or IP '{device_ip}' already exists")
device = Device(
hostname=hostname,
device_ip=device_ip,
nume_masa=nume_masa,
device_type=kwargs.get('device_type', 'unknown'),
os_version=kwargs.get('os_version'),
status=kwargs.get('status', 'active'),
location=kwargs.get('location'),
description=kwargs.get('description'),
last_seen=datetime.utcnow()
)
session.add(device)
session.commit()
session.refresh(device)
self.logger.info(f"Created device: {hostname} ({device_ip})")
return device
except Exception as e:
self.logger.error(f"Error creating device: {e}")
raise
def get_device_by_id(self, device_id: int) -> Optional[Device]:
"""Get device by ID with relationships loaded"""
try:
with self.db.get_session() as session:
return session.query(Device).options(
joinedload(Device.logs),
joinedload(Device.files),
joinedload(Device.inventory_groups)
).filter(Device.id == device_id).first()
except Exception as e:
self.logger.error(f"Error getting device {device_id}: {e}")
return None
def get_device_by_hostname(self, hostname: str) -> Optional[Device]:
"""Get device by hostname"""
try:
with self.db.get_session() as session:
return session.query(Device).filter(Device.hostname == hostname).first()
except Exception as e:
self.logger.error(f"Error getting device by hostname {hostname}: {e}")
return None
def get_device_by_ip(self, device_ip: str) -> Optional[Device]:
"""Get device by IP address"""
try:
with self.db.get_session() as session:
return session.query(Device).filter(Device.device_ip == device_ip).first()
except Exception as e:
self.logger.error(f"Error getting device by IP {device_ip}: {e}")
return None
def get_all_devices(self, status: Optional[str] = None, limit: Optional[int] = None) -> List[Device]:
"""Get all devices with optional filtering"""
try:
with self.db.get_session() as session:
query = session.query(Device).order_by(desc(Device.last_seen))
if status:
query = query.filter(Device.status == status)
if limit:
query = query.limit(limit)
return query.all()
except Exception as e:
self.logger.error(f"Error getting devices: {e}")
return []
def update_device(self, device_id: int, **kwargs) -> Optional[Device]:
"""Update device information"""
try:
with self.db.get_session() as session:
device = session.query(Device).filter(Device.id == device_id).first()
if not device:
return None
# Update allowed fields
allowed_fields = [
'hostname', 'device_ip', 'nume_masa', 'device_type',
'os_version', 'status', 'location', 'description'
]
for field, value in kwargs.items():
if field in allowed_fields and hasattr(device, field):
setattr(device, field, value)
session.commit()
session.refresh(device)
self.logger.info(f"Updated device {device_id}")
return device
except Exception as e:
self.logger.error(f"Error updating device {device_id}: {e}")
raise
def delete_device(self, device_id: int) -> bool:
"""Delete device (soft delete by setting status to inactive)"""
try:
with self.db.get_session() as session:
device = session.query(Device).filter(Device.id == device_id).first()
if not device:
return False
# Soft delete - set status to inactive
device.status = 'inactive'
session.commit()
self.logger.info(f"Soft deleted device {device_id}")
return True
except Exception as e:
self.logger.error(f"Error deleting device {device_id}: {e}")
return False
# Device Monitoring Functions
def update_device_last_seen(self, hostname: str = None, device_ip: str = None) -> Optional[Device]:
"""Update device last seen timestamp"""
try:
with self.db.get_session() as session:
device = None
if hostname:
device = session.query(Device).filter(Device.hostname == hostname).first()
elif device_ip:
device = session.query(Device).filter(Device.device_ip == device_ip).first()
if device:
device.last_seen = datetime.utcnow()
session.commit()
return device
except Exception as e:
self.logger.error(f"Error updating last seen: {e}")
return None
def get_device_statistics(self, device_id: int) -> Dict[str, Any]:
"""Get comprehensive statistics for a device"""
try:
with self.db.get_session() as session:
device = session.query(Device).filter(Device.id == device_id).first()
if not device:
return {}
# Log statistics
total_logs = session.query(LogEntry).filter(LogEntry.device_id == device_id).count()
# Logs by severity
severity_counts = session.query(
LogEntry.severity,
func.count(LogEntry.id)
).filter(
LogEntry.device_id == device_id
).group_by(LogEntry.severity).all()
# Recent activity (last 24 hours)
last_24h = datetime.utcnow() - timedelta(hours=24)
recent_logs = session.query(LogEntry).filter(
and_(LogEntry.device_id == device_id, LogEntry.timestamp >= last_24h)
).count()
# File uploads
total_files = session.query(FileUpload).filter(
FileUpload.device_id == device_id
).count()
# Last log
last_log = session.query(LogEntry).filter(
LogEntry.device_id == device_id
).order_by(desc(LogEntry.timestamp)).first()
return {
'device': device,
'total_logs': total_logs,
'severity_counts': dict(severity_counts),
'recent_logs_24h': recent_logs,
'total_files': total_files,
'last_log': last_log,
'uptime_days': (datetime.utcnow() - device.last_seen).days if device.last_seen else 0
}
except Exception as e:
self.logger.error(f"Error getting device statistics: {e}")
return {}
def get_inactive_devices(self, hours: int = 24) -> List[Device]:
"""Get devices that haven't been seen recently"""
try:
with self.db.get_session() as session:
cutoff_time = datetime.utcnow() - timedelta(hours=hours)
return session.query(Device).filter(
and_(
Device.last_seen < cutoff_time,
Device.status.in_(['active', 'maintenance'])
)
).order_by(desc(Device.last_seen)).all()
except Exception as e:
self.logger.error(f"Error getting inactive devices: {e}")
return []
def get_device_logs(self, device_id: int, limit: int = 100, severity: str = None) -> List[LogEntry]:
"""Get logs for a specific device"""
try:
with self.db.get_session() as session:
query = session.query(LogEntry).filter(
LogEntry.device_id == device_id
).order_by(desc(LogEntry.timestamp))
if severity:
query = query.filter(LogEntry.severity == severity)
return query.limit(limit).all()
except Exception as e:
self.logger.error(f"Error getting device logs: {e}")
return []
def search_devices(self, search_term: str) -> List[Device]:
"""Search devices by hostname, IP, or description"""
try:
with self.db.get_session() as session:
search_pattern = f"%{search_term}%"
return session.query(Device).filter(
(Device.hostname.like(search_pattern)) |
(Device.device_ip.like(search_pattern)) |
(Device.nume_masa.like(search_pattern)) |
(Device.location.like(search_pattern)) |
(Device.description.like(search_pattern))
).order_by(desc(Device.last_seen)).all()
except Exception as e:
self.logger.error(f"Error searching devices: {e}")
return []
# Bulk Operations
def bulk_update_status(self, device_ids: List[int], status: str) -> int:
"""Update status for multiple devices"""
try:
with self.db.get_session() as session:
updated = session.query(Device).filter(
Device.id.in_(device_ids)
).update({Device.status: status}, synchronize_session=False)
session.commit()
self.logger.info(f"Updated status for {updated} devices")
return updated
except Exception as e:
self.logger.error(f"Error bulk updating status: {e}")
return 0
def get_device_summary(self) -> Dict[str, Any]:
"""Get summary statistics for all devices"""
try:
with self.db.get_session() as session:
# Device status counts
status_counts = session.query(
Device.status,
func.count(Device.id)
).group_by(Device.status).all()
# Device type counts
type_counts = session.query(
Device.device_type,
func.count(Device.id)
).group_by(Device.device_type).all()
# Recent activity
last_24h = datetime.utcnow() - timedelta(hours=24)
devices_seen_24h = session.query(Device).filter(
Device.last_seen >= last_24h
).count()
return {
'total_devices': session.query(Device).count(),
'status_counts': dict(status_counts),
'type_counts': dict(type_counts),
'devices_seen_24h': devices_seen_24h
}
except Exception as e:
self.logger.error(f"Error getting device summary: {e}")
return {}

View File

@@ -0,0 +1,256 @@
"""
File upload and processing service
"""
import os
import hashlib
import mimetypes
from datetime import datetime
from pathlib import Path
from werkzeug.utils import secure_filename
from app.models import Device, FileUpload
from config.database_config import get_db
import logging
class FileUploadService:
"""Service for handling file uploads and processing"""
def __init__(self):
self.db = get_db()
self.upload_folder = Path("data/uploads")
self.upload_folder.mkdir(exist_ok=True)
# Allowed file extensions
self.allowed_extensions = {
'txt', 'log', 'conf', 'cfg', 'json', 'yml', 'yaml',
'py', 'sh', 'service', 'env', 'ini'
}
# Max file size (50MB)
self.max_file_size = 50 * 1024 * 1024
def process_uploaded_file(self, file, device_info):
"""Process uploaded file from device"""
try:
# Validate file
if not file or file.filename == '':
return {'success': False, 'error': 'No file provided'}
# Check file extension
filename = secure_filename(file.filename)
if not self._allowed_file(filename):
return {
'success': False,
'error': f'File type not allowed. Allowed: {", ".join(self.allowed_extensions)}'
}
# Check file size
file.seek(0, 2) # Seek to end
file_size = file.tell()
file.seek(0) # Reset position
if file_size > self.max_file_size:
return {
'success': False,
'error': f'File too large. Max size: {self.max_file_size // (1024*1024)}MB'
}
# Calculate file hash
file_content = file.read()
file.seek(0) # Reset for saving
file_hash = hashlib.sha256(file_content).hexdigest()
with self.db.get_session() as session:
# Get or create device
device = self._get_or_create_device(session, device_info)
# Check for duplicate file
existing_file = session.query(FileUpload).filter_by(file_hash=file_hash).first()
if existing_file:
return {
'success': True,
'message': 'File already exists (duplicate detected)',
'file_id': existing_file.id,
'duplicate': True
}
# Generate unique filename
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
new_filename = f"{device.hostname}_{timestamp}_{filename}"
file_path = self.upload_folder / new_filename
# Save file
with open(file_path, 'wb') as f:
f.write(file_content)
# Get MIME type
mime_type, _ = mimetypes.guess_type(filename)
# Create file record
file_upload = FileUpload(
device_id=device.id,
filename=new_filename,
original_filename=filename,
file_path=str(file_path),
file_size=file_size,
file_hash=file_hash,
mime_type=mime_type,
upload_date=datetime.utcnow(),
upload_ip=device_info.get('device_ip'),
processed=False,
processing_status='pending'
)
session.add(file_upload)
session.flush()
# Process file content if it's a log file
if self._is_log_file(filename, mime_type):
self._process_log_file(file_upload, file_content)
return {
'success': True,
'message': 'File uploaded successfully',
'file_id': file_upload.id,
'filename': new_filename,
'size': file_size,
'hash': file_hash,
'processed': file_upload.processed
}
except Exception as e:
logging.error(f"Error processing uploaded file: {e}")
return {
'success': False,
'error': str(e)
}
def _allowed_file(self, filename):
"""Check if file extension is allowed"""
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in self.allowed_extensions
def _get_or_create_device(self, session, device_info):
"""Get existing device or create new one"""
device = session.query(Device).filter_by(
hostname=device_info['hostname'],
device_ip=device_info['device_ip']
).first()
if not device:
device = Device(
hostname=device_info['hostname'],
device_ip=device_info['device_ip'],
nume_masa=device_info['nume_masa'],
last_seen=datetime.utcnow(),
status='active'
)
session.add(device)
session.flush()
else:
device.last_seen = datetime.utcnow()
if device.nume_masa != device_info['nume_masa']:
device.nume_masa = device_info['nume_masa']
return device
def _is_log_file(self, filename, mime_type):
"""Check if file is a log file that should be processed"""
log_extensions = {'log', 'txt'}
log_keywords = ['log', 'error', 'debug', 'trace', 'audit']
# Check extension
if '.' in filename:
ext = filename.rsplit('.', 1)[1].lower()
if ext in log_extensions:
return True
# Check filename for log keywords
filename_lower = filename.lower()
for keyword in log_keywords:
if keyword in filename_lower:
return True
# Check MIME type
if mime_type and 'text' in mime_type:
return True
return False
def _process_log_file(self, file_upload, content):
"""Process log file content to extract log entries"""
try:
# Mark as log file
file_upload.is_log_file = True
# Simple log processing - split by lines
lines = content.decode('utf-8', errors='ignore').split('\n')
entries_extracted = 0
from app.services.log_service import LogCompressionService
log_service = LogCompressionService()
device_info = {
'hostname': file_upload.device.hostname,
'device_ip': file_upload.device.device_ip,
'nume_masa': file_upload.device.nume_masa
}
for line_num, line in enumerate(lines, 1):
line = line.strip()
if not line:
continue
# Try to extract timestamp and message
# This is a simple implementation - enhance as needed
message = f"[File: {file_upload.original_filename}:{line_num}] {line}"
# Process through log compression service
result = log_service.process_log_message(
device_info=device_info,
message=message,
severity='info'
)
if result['success']:
entries_extracted += 1
# Update file record
file_upload.log_entries_extracted = entries_extracted
file_upload.processed = True
file_upload.processing_status = 'completed'
logging.info(f"Processed log file {file_upload.filename}: {entries_extracted} entries extracted")
except Exception as e:
logging.error(f"Error processing log file content: {e}")
file_upload.processing_status = 'error'
file_upload.processing_error = str(e)
def get_upload_stats(self):
"""Get file upload statistics"""
try:
with self.db.get_session() as session:
total_files = session.query(FileUpload).count()
log_files = session.query(FileUpload).filter_by(is_log_file=True).count()
# Calculate total size
total_size = session.query(
session.func.sum(FileUpload.file_size)
).scalar() or 0
# Count by processing status
processed = session.query(FileUpload).filter_by(processed=True).count()
pending = session.query(FileUpload).filter(
FileUpload.processing_status == 'pending'
).count()
return {
'total_files': total_files,
'log_files': log_files,
'total_size': total_size,
'processed': processed,
'pending': pending
}
except Exception as e:
logging.error(f"Error getting upload stats: {e}")
return {'error': str(e)}

378
app/services/log_service.py Normal file
View File

@@ -0,0 +1,378 @@
"""
Log processing service with message compression and aliasing
"""
import json
import re
import hashlib
from typing import Dict, List, Optional, Tuple
from datetime import datetime
from sqlalchemy.orm import Session
from app.models import Device, LogEntry, MessageTemplate
from config.database_config import get_db
import logging
class LogCompressionService:
"""Service for compressing log messages using templates and aliases"""
def __init__(self):
self.db = get_db()
self.template_patterns = self._load_common_patterns()
def _load_common_patterns(self) -> List[Dict]:
"""Load common log message patterns for template matching"""
return [
{
'pattern': r'Card detected: ([A-F0-9]+)',
'template': 'Card detected: {card_id}',
'category': 'card_detection',
'alias_prefix': 'CD'
},
{
'pattern': r'Connection failed: (.+)',
'template': 'Connection failed: {error}',
'category': 'connection_error',
'alias_prefix': 'CE'
},
{
'pattern': r'System startup completed in ([0-9.]+)s',
'template': 'System startup completed in {time}s',
'category': 'system_startup',
'alias_prefix': 'SS'
},
{
'pattern': r'Auto-update: (.+)',
'template': 'Auto-update: {message}',
'category': 'auto_update',
'alias_prefix': 'AU'
},
{
'pattern': r'Command \'([^\']+)\' (SUCCESS|FAILED)',
'template': 'Command \'{command}\' {status}',
'category': 'command_execution',
'alias_prefix': 'EX'
},
{
'pattern': r'Temperature: ([0-9.]+)°C',
'template': 'Temperature: {temp}°C',
'category': 'temperature',
'alias_prefix': 'TM'
}
]
def process_log_message(self, device_info: Dict, message: str, severity: str = 'info') -> Dict:
"""
Process incoming log message with compression
Args:
device_info: Dict with hostname, device_ip, nume_masa
message: Log message text
severity: Message severity level
Returns:
Dict with processing results and storage info
"""
try:
with self.db.get_session() as session:
# Get or create device
device = self._get_or_create_device(session, device_info)
# Try to match message to existing template
template, variables = self._match_message_template(session, message)
if template:
# Use existing template
log_entry = LogEntry(
device_id=device.id,
template_id=template.id,
template_variables=json.dumps(variables) if variables else None,
severity=severity,
timestamp=datetime.utcnow()
)
# Update template usage count
template.usage_count += 1
# Calculate size savings
original_size = len(message.encode('utf-8'))
compressed_size = len(template.alias.encode('utf-8')) + \
len(json.dumps(variables or {}).encode('utf-8'))
compression_info = {
'used_template': True,
'template_alias': template.alias,
'original_size': original_size,
'compressed_size': compressed_size,
'savings_percent': ((original_size - compressed_size) / original_size) * 100
}
else:
# Create new template if message matches a pattern
template = self._create_new_template(session, message)
if template:
# New template created
variables = self._extract_variables(message, template.template_text)
log_entry = LogEntry(
device_id=device.id,
template_id=template.id,
template_variables=json.dumps(variables) if variables else None,
severity=severity,
timestamp=datetime.utcnow()
)
template.usage_count = 1
compression_info = {
'used_template': True,
'template_alias': template.alias,
'new_template': True,
'original_size': len(message.encode('utf-8')),
'compressed_size': len(template.alias.encode('utf-8'))
}
else:
# Store as full message
log_entry = LogEntry(
device_id=device.id,
full_message=message,
severity=severity,
timestamp=datetime.utcnow()
)
compression_info = {
'used_template': False,
'stored_full': True,
'original_size': len(message.encode('utf-8'))
}
session.add(log_entry)
session.flush() # Get the log entry ID
return {
'success': True,
'log_id': log_entry.id,
'device_id': device.id,
'compression': compression_info,
'message': 'Log processed successfully'
}
except Exception as e:
logging.error(f"Error processing log message: {e}")
return {
'success': False,
'error': str(e),
'message': 'Log processing failed'
}
@staticmethod
def _infer_device_type(hostname: str) -> str:
"""Guess device type from hostname pattern."""
h = hostname.upper()
if any(k in h for k in ('RPI', 'PI', 'RASP')):
return 'Raspberry Pi'
if any(k in h for k in ('SRV', 'SERVER')):
return 'Server'
if any(k in h for k in ('PC', 'DESK', 'WRK')):
return 'PC'
if any(k in h for k in ('LAPTOP', 'NB')):
return 'Laptop'
return 'unknown'
def _get_or_create_device(self, session: Session, device_info: Dict) -> Device:
"""Get existing device or create new one.
Lookup priority:
1. MAC address (most reliable survives IP/hostname changes)
2. hostname + device_ip (legacy fallback)
"""
mac = device_info.get('mac_address')
device = None
# 1. Try MAC lookup first
if mac:
device = session.query(Device).filter_by(mac_address=mac).first()
# 2. Fall back to hostname+IP
if not device:
device = session.query(Device).filter_by(
hostname=device_info['hostname'],
device_ip=device_info['device_ip']
).first()
if not device:
device = Device(
hostname=device_info['hostname'],
device_ip=device_info['device_ip'],
nume_masa=device_info['nume_masa'],
device_type=device_info.get('device_type') or self._infer_device_type(device_info['hostname']),
os_version=device_info.get('os_version'),
location=device_info.get('location'),
mac_address=mac or None,
last_seen=datetime.utcnow(),
status='active'
)
session.add(device)
session.flush()
else:
# Always update last_seen and nome_masa
device.last_seen = datetime.utcnow()
if device.nume_masa != device_info['nume_masa']:
device.nume_masa = device_info['nume_masa']
# Sync MAC address if we now know it and device doesn't have one
if mac and not device.mac_address:
device.mac_address = mac
# Update type from hostname if still unknown
if not device.device_type or device.device_type == 'unknown':
device.device_type = device_info.get('device_type') or self._infer_device_type(device_info['hostname'])
# Update OS / location only when client sends them (don't overwrite manual edits with None)
if device_info.get('os_version'):
device.os_version = device_info['os_version']
if device_info.get('location'):
device.location = device_info['location']
return device
def _match_message_template(self, session: Session, message: str) -> Tuple[Optional[MessageTemplate], Optional[Dict]]:
"""Try to match message to existing template"""
# First, try exact template match
message_hash = MessageTemplate.create_hash(message)
template = session.query(MessageTemplate).filter_by(template_hash=message_hash).first()
if template:
return template, None
# Try pattern matching with variable extraction
for pattern_info in self.template_patterns:
match = re.match(pattern_info['pattern'], message)
if match:
# Look for template with this pattern
template_text = pattern_info['template']
template = session.query(MessageTemplate).filter_by(
template_text=template_text,
category=pattern_info['category']
).first()
if template:
# Extract variables
variables = {}
for i, group in enumerate(match.groups(), 1):
# Map to variable names based on template
if '{card_id}' in template_text and pattern_info['category'] == 'card_detection':
variables['card_id'] = group
elif '{error}' in template_text and pattern_info['category'] == 'connection_error':
variables['error'] = group
elif '{time}' in template_text and pattern_info['category'] == 'system_startup':
variables['time'] = group
elif '{message}' in template_text:
variables['message'] = group
elif '{command}' in template_text and i == 1:
variables['command'] = group
elif '{status}' in template_text and i == 2:
variables['status'] = group
elif '{temp}' in template_text:
variables['temp'] = group
return template, variables
return None, None
def _create_new_template(self, session: Session, message: str) -> Optional[MessageTemplate]:
"""Create new template if message matches a known pattern"""
for pattern_info in self.template_patterns:
match = re.match(pattern_info['pattern'], message)
if match:
# Check if template already exists
existing = session.query(MessageTemplate).filter_by(
template_text=pattern_info['template'],
category=pattern_info['category']
).first()
if existing:
return existing
# Create new template
alias = self._generate_alias(session, pattern_info['alias_prefix'])
template_hash = MessageTemplate.create_hash(pattern_info['template'])
template = MessageTemplate(
template_hash=template_hash,
template_text=pattern_info['template'],
category=pattern_info['category'],
alias=alias,
created_at=datetime.utcnow()
)
session.add(template)
session.flush()
return template
return None
def _generate_alias(self, session: Session, prefix: str) -> str:
"""Generate unique alias for template"""
# Find highest existing alias number for this prefix
existing_aliases = session.query(MessageTemplate.alias).filter(
MessageTemplate.alias.like(f"{prefix}%")
).all()
max_num = 0
for (alias,) in existing_aliases:
try:
num = int(alias[len(prefix):])
max_num = max(max_num, num)
except ValueError:
continue
return f"{prefix}{max_num + 1:03d}"
def _extract_variables(self, message: str, template: str) -> Dict:
"""Extract variables from message using template"""
# Simple variable extraction - could be enhanced
variables = {}
# This is a simplified implementation
# In production, you'd want more sophisticated template matching
return variables
def get_compression_stats(self) -> Dict:
"""Get compression statistics"""
try:
with self.db.get_session() as session:
# Count total logs
total_logs = session.query(LogEntry).count()
# Count templated logs
templated_logs = session.query(LogEntry).filter(
LogEntry.template_id.isnot(None)
).count()
# Count templates
total_templates = session.query(MessageTemplate).count()
# Calculate average savings (simplified)
compression_ratio = (templated_logs / total_logs * 100) if total_logs > 0 else 0
return {
'total_logs': total_logs,
'templated_logs': templated_logs,
'total_templates': total_templates,
'compression_ratio': round(compression_ratio, 2),
'estimated_savings': round(compression_ratio * 0.6, 2) # Estimated 60% savings per template
}
except Exception as e:
logging.error(f"Error getting compression stats: {e}")
return {'error': str(e)}
def get_message_by_alias(self, alias: str, variables: Dict = None) -> Optional[str]:
"""Retrieve full message using alias and variables"""
try:
with self.db.get_session() as session:
template = session.query(MessageTemplate).filter_by(alias=alias).first()
if template:
if variables:
return template.template_text.format(**variables)
return template.template_text
return None
except Exception as e:
logging.error(f"Error retrieving message by alias: {e}")
return None

0
app/utils/__init__.py Normal file
View File

0
app/web/__init__.py Normal file
View File

599
app/web/ansible.py Normal file
View 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
View 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
View 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'))

0
config/__init__.py Normal file
View File

106
config/config.py Normal file
View File

@@ -0,0 +1,106 @@
"""
Application configuration management
"""
import os
from datetime import timedelta
class Config:
"""Base configuration"""
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
# Database
DATABASE_URL = os.environ.get('DATABASE_URL') or 'sqlite:///data/enhanced_monitoring.db'
# File Upload Settings
UPLOAD_FOLDER = 'data/uploads'
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB max file size
ALLOWED_EXTENSIONS = {'txt', 'log', 'conf', 'cfg', 'json', 'yml', 'yaml'}
# Message Compression Settings
ENABLE_MESSAGE_COMPRESSION = True
COMPRESSION_THRESHOLD = 100 # Compress messages longer than 100 chars
MAX_TEMPLATE_CACHE_SIZE = 1000
# Ansible Settings
ANSIBLE_INVENTORY_PATH = 'ansible/inventory/dynamic_inventory.yaml'
ANSIBLE_PLAYBOOK_PATH = 'ansible/playbooks'
SSH_KEY_PATH = os.path.expanduser('~/.ssh/ansible_key')
# SSH Settings
SSH_USERNAME = 'pi'
SSH_PORT = 22
SSH_TIMEOUT = 10
# Performance Settings
DATABASE_POOL_SIZE = 20
DATABASE_POOL_TIMEOUT = 30
LOG_RETENTION_DAYS = 90
# Security Settings
BCRYPT_LOG_ROUNDS = 12
SESSION_COOKIE_SECURE = False # Set to True in production with HTTPS
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
PERMANENT_SESSION_LIFETIME = timedelta(days=7)
# API Settings
API_RATE_LIMIT = '1000 per hour'
API_PAGINATION_DEFAULT = 100
API_PAGINATION_MAX = 1000
class DevelopmentConfig(Config):
"""Development configuration"""
DEBUG = True
TESTING = False
# More verbose logging in development
LOG_LEVEL = 'DEBUG'
# Disable some security features for development
SESSION_COOKIE_SECURE = False
WTF_CSRF_ENABLED = False # Disable CSRF for API testing
class ProductionConfig(Config):
"""Production configuration"""
DEBUG = False
TESTING = False
# Security settings for production
SESSION_COOKIE_SECURE = True
WTF_CSRF_ENABLED = True
# Production database (if using PostgreSQL)
DATABASE_URL = os.environ.get('DATABASE_URL') or 'sqlite:///data/enhanced_monitoring.db'
# Logging
LOG_LEVEL = 'INFO'
LOG_FILE = 'logs/app.log'
# Performance
DATABASE_POOL_SIZE = 50
class TestingConfig(Config):
"""Testing configuration"""
TESTING = True
DEBUG = True
# Use in-memory database for testing
DATABASE_URL = 'sqlite:///:memory:'
# Disable CSRF for testing
WTF_CSRF_ENABLED = False
# Configuration dictionary
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'testing': TestingConfig,
'default': DevelopmentConfig
}
def get_config(config_name=None):
"""Get configuration class"""
if config_name is None:
config_name = os.environ.get('FLASK_ENV', 'default')
return config.get(config_name, config['default'])

120
config/database_config.py Normal file
View File

@@ -0,0 +1,120 @@
"""
Database configuration and connection management
"""
import os
from sqlalchemy import create_engine, MetaData
from sqlalchemy.orm import sessionmaker
from contextlib import contextmanager
from app.models import Base
import logging
class DatabaseConfig:
"""Database configuration and connection management"""
def __init__(self, database_url=None):
if database_url is None:
# Default to SQLite with improved path
self.database_url = f"sqlite:///data/enhanced_monitoring.db"
else:
self.database_url = database_url
self.engine = None
self.Session = None
self._setup_database()
def _setup_database(self):
"""Initialize database connection and session factory"""
# Create data directory if it doesn't exist
os.makedirs('data', exist_ok=True)
# Create engine with connection pooling for SQLite
self.engine = create_engine(
self.database_url,
echo=False, # Set to True for SQL debugging
pool_pre_ping=True,
connect_args={"check_same_thread": False} # For SQLite
)
# Create session factory
self.Session = sessionmaker(bind=self.engine)
def create_tables(self):
"""Create all database tables"""
try:
Base.metadata.create_all(self.engine)
logging.info("Database tables created successfully")
return True
except Exception as e:
logging.error(f"Error creating database tables: {e}")
return False
def drop_tables(self):
"""Drop all database tables (use with caution!)"""
try:
Base.metadata.drop_all(self.engine)
logging.info("Database tables dropped successfully")
return True
except Exception as e:
logging.error(f"Error dropping database tables: {e}")
return False
@contextmanager
def get_session(self):
"""Context manager for database sessions"""
session = self.Session()
try:
yield session
session.commit()
except Exception as e:
session.rollback()
logging.error(f"Database session error: {e}")
raise
finally:
session.close()
def get_session_direct(self):
"""Get session directly (remember to close it)"""
return self.Session()
def backup_database(self, backup_path=None):
"""Create database backup"""
if backup_path is None:
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = f"data/backups/backup_{timestamp}.db"
try:
# Create backups directory
os.makedirs('data/backups', exist_ok=True)
# For SQLite, simple file copy
if self.database_url.startswith('sqlite'):
import shutil
db_file = self.database_url.replace('sqlite:///', '')
shutil.copy2(db_file, backup_path)
logging.info(f"Database backup created: {backup_path}")
return backup_path
else:
# For other databases, implement proper backup
logging.warning("Backup not implemented for non-SQLite databases")
return None
except Exception as e:
logging.error(f"Database backup failed: {e}")
return None
# Global database instance
db_config = None
def init_database(database_url=None):
"""Initialize global database configuration"""
global db_config
db_config = DatabaseConfig(database_url)
return db_config.create_tables()
def get_db():
"""Get database configuration instance"""
global db_config
if db_config is None:
init_database()
return db_config

View File

@@ -0,0 +1,144 @@
# VS Code Session Limits & Crash Prevention Guide
## 🚨 Current Issues Identified
### 1. **High Process Count**: 38 VS Code processes running
### 2. **Memory Consumption**:
- Pylance server: 573MB
- Utility processes: 686MB
- Total: ~1.2GB VS Code memory usage
### 3. **Recent Crashes**:
- Renderer process crashes (code: 5)
- File watcher crashes (code: 15)
- Extension host failures
## ⚙️ Solutions Implemented
### 1. **Optimized Settings** (`.vscode/settings.json`)
- Reduced editor suggestions and hover
- Limited Python analysis depth
- Disabled auto-updates and telemetry
- Configured memory-efficient file watching
### 2. **Session Monitoring** (`scripts/vscode_session_monitor.sh`)
```bash
# Check current session status
./scripts/vscode_session_monitor.sh check
# Start continuous monitoring
./scripts/vscode_session_monitor.sh monitor
# Clean up logs and cache
./scripts/vscode_session_monitor.sh cleanup
```
### 3. **Emergency Management** (`scripts/vscode_emergency_cleanup.sh`)
```bash
# Show memory status
./scripts/vscode_emergency_cleanup.sh status
# Light cleanup (recommended during agent iterations)
./scripts/vscode_emergency_cleanup.sh gentle
# Heavy cleanup (when unresponsive)
./scripts/vscode_emergency_cleanup.sh aggressive
# Full restart
./scripts/vscode_emergency_cleanup.sh restart
```
### 4. **Environment Configuration** (`.vscode/session_config.env`)
Load before starting VS Code:
```bash
source .vscode/session_config.env
code .
```
## 📊 Agent Session Limits
### GitHub Copilot Limits:
- **Max Tokens per Request**: 4,000
- **History Length**: 10 conversations
- **Request Timeout**: 30 seconds
### System Resource Limits:
- **Memory Warning**: When VS Code uses >1.5GB
- **Process Limit**: Maximum 20 VS Code processes
- **Max Session Duration**: 2 hours
- **Auto Cleanup**: Every 30 minutes
### Agent Iteration Limits:
- **Max Iterations**: 50 per session
- **Max Edit Size**: 10,000 characters
- **Timeout per Operation**: 3 minutes
## 🔧 Prevention Strategies
### 1. **Before Long Agent Sessions**:
```bash
# Clean up first
./scripts/vscode_emergency_cleanup.sh gentle
# Load optimized environment
source .vscode/session_config.env
# Start monitoring
./scripts/vscode_session_monitor.sh monitor &
```
### 2. **During Agent Iterations**:
- Monitor memory with `./scripts/vscode_emergency_cleanup.sh status`
- Use gentle cleanup if memory >1GB
- Restart VS Code every 2 hours or 50+ operations
### 3. **Warning Signs**:
- VS Code becomes slow/unresponsive
- High CPU usage (>80%)
- Memory usage >1.5GB
- More than 30 processes
### 4. **Emergency Actions**:
```bash
# If VS Code freezes during agent work:
./scripts/vscode_emergency_cleanup.sh aggressive
# If completely unresponsive:
./scripts/vscode_emergency_cleanup.sh full
```
## 📈 Monitoring Commands
### Resource Monitoring:
```bash
# Check memory usage
free -h
# VS Code processes
ps aux | grep code | wc -l
# Memory per process
ps -o pid,ppid,pcpu,pmem,rss,args -C code
```
### Log Analysis:
```bash
# Recent crashes
grep "crashed" ~/.config/Code/logs/*/main.log | tail -5
# Copilot requests
find ~/.config/Code/logs -name "*copilot*" -exec tail -10 {} \;
# Extension host status
ps aux | grep extensionHost
```
## 🎯 Recommended Workflow
1. **Before starting**: Load session config and monitor
2. **During agent work**: Check status every 15-20 operations
3. **After 1 hour**: Perform gentle cleanup
4. **After 2 hours**: Restart VS Code completely
5. **If crashes occur**: Use emergency cleanup and restart
This configuration should prevent most crashes and provide early warning when limits are approached.

179
main.py Normal file
View File

@@ -0,0 +1,179 @@
"""
Enhanced Server Monitoring System v2.0
Main application entry point
This is the main file to start the enhanced server monitoring system.
It includes all the improvements requested:
- Better database structure with message compression
- File upload support
- SSH/Ansible management interface
- Modular architecture
- Smaller client message sizes through templates
"""
import os
import sys
from app import create_app
from config.database_config import get_db
def main():
"""Main application entry point"""
# Create application
app = create_app()
# Setup database if needed (after app creation to avoid circular imports)
with app.app_context():
from config.database_config import get_db
db = get_db()
if not os.path.exists('data/enhanced_monitoring.db'):
print("🔧 Setting up database for first time...")
success = db.create_tables()
if success:
print("✅ Database initialized successfully")
else:
print("❌ Database initialization failed")
return 1
# Create initial dummy data if database is empty (for testing)
create_sample_data_if_needed()
# Print startup information
print_startup_info()
# Run application
try:
app.run(
host='0.0.0.0',
port=int(os.environ.get('PORT', 80)),
debug=app.config.get('DEBUG', False)
)
except KeyboardInterrupt:
print("\n👋 Shutting down Enhanced Server Monitoring System")
return 0
except Exception as e:
print(f"❌ Failed to start server: {e}")
return 1
def create_sample_data_if_needed():
"""Create sample data for testing if database is empty"""
try:
from config.database_config import get_db
from app.models import Device, MessageTemplate
db = get_db()
with db.get_session() as session:
# Check if we have any devices
device_count = session.query(Device).count()
if device_count == 0:
print("📝 Creating sample data for testing...")
# Create sample devices
sample_devices = [
Device(
hostname='rpi-device-01',
device_ip='192.168.1.100',
nume_masa='Masa-01',
device_type='Raspberry Pi',
status='active',
location='Office Floor 1'
),
Device(
hostname='rpi-device-02',
device_ip='192.168.1.101',
nume_masa='Masa-02',
device_type='Raspberry Pi',
status='active',
location='Office Floor 2'
)
]
for device in sample_devices:
session.add(device)
# Create sample message templates
sample_templates = [
MessageTemplate(
template_hash='sample1',
template_text='Card detected: {card_id}',
category='card_detection',
alias='CD001'
),
MessageTemplate(
template_hash='sample2',
template_text='System startup completed in {time}s',
category='system_startup',
alias='SS001'
)
]
for template in sample_templates:
session.add(template)
session.commit()
print("✅ Sample data created successfully")
except Exception as e:
print(f"⚠️ Warning: Could not create sample data: {e}")
def print_startup_info():
"""Print startup information and available endpoints"""
print("\n" + "="*60)
print("🚀 ENHANCED SERVER MONITORING SYSTEM v2.0")
print("="*60)
print("\n📊 Enhanced Features:")
print(" • Message compression and aliasing")
print(" • File upload support")
print(" • SSH/Ansible management interface")
print(" • Improved database structure")
print(" • Modular architecture")
print(" • Real-time device monitoring")
print("\n🌐 API Endpoints:")
print(" • POST /api/logs/ - Submit compressed logs")
print(" • POST /api/logs/template/<alias> - Submit using template alias")
print(" • POST /api/logs/file - Upload log files")
print(" • GET /api/logs/query - Query logs with filters")
print(" • GET /api/logs/stats - Get compression statistics")
print(" • GET /api/logs/templates - View message templates")
print("\n🔧 Ansible Management:")
print(" • GET /api/ansible/inventory - View device inventory")
print(" • POST /api/ansible/execute - Execute playbooks")
print(" • GET /api/ansible/executions - View execution history")
print(" • POST /api/ansible/ssh/test - Test SSH connectivity")
print("\n💻 Web Interface:")
print(" • / - Enhanced dashboard")
print(" • /devices - Device management")
print(" • /logs - Log viewer with filters")
print(" • /templates - Message template manager")
print(" • /ansible/ - Ansible management dashboard")
print(" • /ansible/execute - Execute playbooks via web")
print(" • /ansible/ssh/setup - SSH key management")
print("\n🔗 Integration with Prezenta App:")
print(" • Prezenta devices can now send smaller messages using template aliases")
print(" • Example: Instead of 'Card detected: ABC123', send alias 'CD001' + variables")
print(" • 60-80% reduction in message size for common log patterns")
print("\n💾 Database Improvements:")
print(" • Normalized schema with separate device/log/template tables")
print(" • Message deduplication and compression")
print(" • File upload tracking and metadata")
print(" • Ansible execution history")
print(" • System statistics and metrics")
print(f"\n🔧 Quick Setup for Prezenta Clients:")
print(f" 1. Update server URL to: http://YOUR-SERVER-IP/api/logs/")
print(f" 2. Use new compressed message format")
print(f" 3. Optional: Upload config files via /api/logs/file")
print("\n" + "="*60)
print("✅ Server ready! Access web interface at http://YOUR-IP/")
print("="*60 + "\n")
if __name__ == '__main__':
sys.exit(main())

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
Flask==2.3.3
SQLAlchemy==2.0.23
paramiko==3.3.1
PyYAML==6.0.1
requests==2.31.0
Werkzeug==2.3.7

View File

@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""
Migration script to consolidate AnsibleExecution data into PlaybookExecution model
and deprecate the old model structure.
"""
import sys
import os
import uuid
from datetime import datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
# Add the project root to Python path
sys.path.insert(0, '/home/pi/Desktop/Server_Monitorizare_v2')
from config.database_config import get_db
from app.models import AnsibleExecution, PlaybookExecution, PlaybookHostResult
class AutomationMigration:
def __init__(self):
from config.database_config import DatabaseConfig
db_config = DatabaseConfig()
self.engine = db_config.engine
self.Session = sessionmaker(bind=self.engine)
def migrate_executions(self):
"""Migrate data from AnsibleExecution to PlaybookExecution"""
print("🔄 Starting automation model migration...")
session = self.Session()
try:
# Get all existing AnsibleExecutions
old_executions = session.query(AnsibleExecution).all()
migrated_count = 0
for old_exec in old_executions:
# Check if already migrated
existing = session.query(PlaybookExecution).filter_by(
playbook_name=old_exec.playbook_name,
start_time=old_exec.start_time
).first()
if existing:
print(f" ⏭️ Skipping {old_exec.playbook_name} (already migrated)")
continue
# Create new PlaybookExecution from old data
new_exec = PlaybookExecution(
execution_id=str(uuid.uuid4()),
playbook_name=old_exec.playbook_name,
target_hosts=old_exec.target_devices,
# Timing
queued_at=old_exec.start_time,
started_at=old_exec.start_time,
completed_at=old_exec.end_time,
# Status mapping
status=self._map_status(old_exec.status),
exit_code=old_exec.exit_code,
# Logs
stdout_log=old_exec.stdout_log,
stderr_log=old_exec.stderr_log,
ansible_log_file=old_exec.ansible_log_file,
# Results
successful_hosts=old_exec.successful_hosts,
failed_hosts=old_exec.failed_hosts,
unreachable_hosts=old_exec.unreachable_hosts,
total_hosts=old_exec.successful_hosts + old_exec.failed_hosts + old_exec.unreachable_hosts,
# Metadata
execution_user=old_exec.execution_user,
command_line=old_exec.command_line
)
session.add(new_exec)
migrated_count += 1
print(f" ✅ Migrated: {old_exec.playbook_name} ({old_exec.start_time})")
session.commit()
print(f"\n✅ Migration completed: {migrated_count} executions migrated")
except Exception as e:
session.rollback()
print(f"❌ Migration failed: {e}")
raise
finally:
session.close()
def _map_status(self, old_status):
"""Map old status values to new enum"""
status_mapping = {
'running': 'running',
'completed': 'completed',
'failed': 'failed',
'cancelled': 'cancelled'
}
return status_mapping.get(old_status, 'failed')
def validate_migration(self):
"""Validate that migration was successful"""
print("\n🔍 Validating migration...")
session = self.Session()
try:
old_count = session.query(AnsibleExecution).count()
new_count = session.query(PlaybookExecution).count()
print(f" 📊 Old executions: {old_count}")
print(f" 📊 New executions: {new_count}")
if new_count >= old_count:
print(" ✅ Migration validation passed")
return True
else:
print(" ❌ Migration validation failed: data missing")
return False
except Exception as e:
print(f" ❌ Validation error: {e}")
return False
finally:
session.close()
def cleanup_old_tables(self, confirm=False):
"""Remove old AnsibleExecution table (use with caution)"""
if not confirm:
print("\n⚠️ Run with confirm=True to actually remove old table")
print(" This will permanently delete AnsibleExecution data!")
return
print("\n🗑️ Cleaning up old AnsibleExecution table...")
session = self.Session()
try:
session.query(AnsibleExecution).delete()
session.commit()
print(" ✅ Old execution data cleaned up")
except Exception as e:
session.rollback()
print(f" ❌ Cleanup failed: {e}")
finally:
session.close()
if __name__ == "__main__":
migration = AutomationMigration()
print("🔧 Automation Model Consolidation")
print("=" * 50)
# Step 1: Migrate data
migration.migrate_executions()
# Step 2: Validate
if migration.validate_migration():
print("\n🎉 Migration successful!")
print("\nNext steps:")
print("1. Test the new PlaybookExecution model")
print("2. Update code to use only PlaybookExecution")
print("3. Run cleanup_old_tables() when confident")
else:
print("\n❌ Migration validation failed!")
sys.exit(1)

118
scripts/update_database_schema.py Executable file
View File

@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""
Update database schema for enhanced automation models
"""
import sys
import os
from sqlalchemy import create_engine, text
# Add the project root to Python path
sys.path.insert(0, '/home/pi/Desktop/Server_Monitorizare_v2')
from config.database_config import DatabaseConfig
class DatabaseSchemaUpdater:
def __init__(self):
db_config = DatabaseConfig()
self.engine = db_config.engine
def update_playbook_executions_schema(self):
"""Add new columns to playbook_executions table"""
print("📊 Updating playbook_executions table schema...")
new_columns = [
("playbook_description", "TEXT"),
("estimated_duration", "INTEGER"),
("priority", "INTEGER DEFAULT 5"),
("retry_count", "INTEGER DEFAULT 0"),
("max_retries", "INTEGER DEFAULT 0"),
("summary_message", "TEXT"),
("skipped_hosts", "INTEGER DEFAULT 0"),
("changed_hosts", "INTEGER DEFAULT 0")
]
with self.engine.connect() as conn:
# Check if table exists
result = conn.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='playbook_executions'"))
if not result.fetchone():
print(" ⚠️ playbook_executions table doesn't exist. Creating tables...")
self.create_tables()
return
for column_name, column_type in new_columns:
try:
# Check if column already exists
check_sql = f"PRAGMA table_info(playbook_executions)"
result = conn.execute(text(check_sql))
existing_columns = [row[1] for row in result.fetchall()]
if column_name not in existing_columns:
alter_sql = f"ALTER TABLE playbook_executions ADD COLUMN {column_name} {column_type}"
conn.execute(text(alter_sql))
conn.commit()
print(f" ✅ Added column: {column_name}")
else:
print(f" ⏭️ Column {column_name} already exists")
except Exception as e:
print(f" ❌ Error adding {column_name}: {e}")
def create_tables(self):
"""Create all tables using SQLAlchemy metadata"""
print("🏗️ Creating all database tables...")
try:
from app.models import Base
Base.metadata.create_all(self.engine)
print(" ✅ All tables created successfully")
except Exception as e:
print(f" ❌ Error creating tables: {e}")
def verify_schema(self):
"""Verify the schema updates were successful"""
print("\n🔍 Verifying schema updates...")
with self.engine.connect() as conn:
try:
# Test with a simple query
result = conn.execute(text("SELECT COUNT(*) FROM playbook_executions"))
count = result.fetchone()[0]
print(f" ✅ playbook_executions table accessible: {count} records")
# Check for new columns
result = conn.execute(text("PRAGMA table_info(playbook_executions)"))
columns = [row[1] for row in result.fetchall()]
expected_columns = [
'playbook_description', 'estimated_duration', 'priority',
'retry_count', 'max_retries', 'summary_message',
'skipped_hosts', 'changed_hosts'
]
missing_columns = [col for col in expected_columns if col not in columns]
if missing_columns:
print(f" ⚠️ Missing columns: {missing_columns}")
else:
print(" ✅ All expected columns present")
return len(missing_columns) == 0
except Exception as e:
print(f" ❌ Schema verification failed: {e}")
return False
if __name__ == "__main__":
updater = DatabaseSchemaUpdater()
print("🔧 Database Schema Update for Enhanced Automation")
print("=" * 60)
# Update schema
updater.update_playbook_executions_schema()
# Verify
if updater.verify_schema():
print("\n🎉 Schema update completed successfully!")
else:
print("\n❌ Schema update verification failed!")
sys.exit(1)

View File

@@ -0,0 +1,130 @@
#!/bin/bash
# VS Code Emergency Memory Management
# Use this script when VS Code becomes unresponsive during agent iterations
echo "🔧 VS Code Emergency Memory Management"
echo "======================================"
# Function to show current status
show_status() {
echo "📊 Current VS Code Status:"
echo "Processes: $(ps aux | grep -E "(code|vscode)" | grep -v grep | wc -l)"
echo "Memory: $(ps -o pid,ppid,pcpu,pmem,rss,args -C code | awk 'NR>1{sum+=$5} END{print sum/1024 " MB"}')"
echo ""
}
# Function for gentle cleanup
gentle_cleanup() {
echo "🧹 Performing gentle cleanup..."
# Kill high-memory extension processes
for pid in $(ps aux | grep 'extensions.*server' | awk '$6 > 300000 {print $2}'); do
if [ -n "$pid" ]; then
echo "Stopping high-memory extension process: $pid"
kill -15 "$pid" 2>/dev/null
fi
done
# Clean temporary files
rm -rf /tmp/mcp-* 2>/dev/null
find ~/.config/Code -name "*.log" -size +50M -delete 2>/dev/null
sleep 3
echo "✅ Gentle cleanup completed"
}
# Function for aggressive cleanup
aggressive_cleanup() {
echo "⚠️ Performing aggressive cleanup..."
# Stop all extension hosts
pkill -f "extensionHost" 2>/dev/null
# Stop language servers
pkill -f "pylance.*server" 2>/dev/null
pkill -f "language.*server" 2>/dev/null
# Kill utility processes with high memory
for pid in $(ps aux | grep 'code.*utility' | awk '$6 > 500000 {print $2}'); do
if [ -n "$pid" ]; then
echo "Force stopping utility process: $pid"
kill -9 "$pid" 2>/dev/null
fi
done
sleep 5
echo "✅ Aggressive cleanup completed"
}
# Function to restart VS Code safely
restart_vscode() {
echo "🔄 Restarting VS Code..."
# Save current workspace
WORKSPACE_PATH="$PWD"
# Gracefully close VS Code
pkill -15 code 2>/dev/null
sleep 5
# Force close if still running
pkill -9 code 2>/dev/null
sleep 2
# Clean up leftover processes
pkill -f "vscode" 2>/dev/null
echo "VS Code stopped. Starting in 3 seconds..."
sleep 3
# Restart VS Code with the current workspace
cd "$WORKSPACE_PATH"
nohup code . > /dev/null 2>&1 &
echo "✅ VS Code restarted"
}
# Interactive menu
case "${1:-menu}" in
"status")
show_status
;;
"gentle")
show_status
gentle_cleanup
show_status
;;
"aggressive")
show_status
aggressive_cleanup
show_status
;;
"restart")
restart_vscode
;;
"full")
show_status
echo "Performing full cleanup and restart..."
gentle_cleanup
sleep 2
aggressive_cleanup
sleep 2
restart_vscode
;;
"menu")
show_status
echo "Available actions:"
echo " status - Show current VS Code status"
echo " gentle - Light cleanup (kill high-memory extensions)"
echo " aggressive - Heavy cleanup (kill language servers, utilities)"
echo " restart - Full VS Code restart"
echo " full - Complete cleanup + restart"
echo ""
echo "Usage: $0 {status|gentle|aggressive|restart|full}"
;;
*)
echo "Unknown option: $1"
$0 menu
;;
esac

112
scripts/vscode_session_monitor.sh Executable file
View File

@@ -0,0 +1,112 @@
#!/bin/bash
# VS Code Session Monitor and Restart Script
# This script monitors VS Code memory usage and session health
VSCODE_MEMORY_LIMIT=2000000 # 2GB in KB
CHECK_INTERVAL=30 # Check every 30 seconds
LOG_FILE="/tmp/vscode_monitor.log"
echo "$(date): Starting VS Code session monitor..." >> "$LOG_FILE"
monitor_vscode() {
while true; do
# Get VS Code process info
VSCODE_PIDS=$(pgrep -f "code.*--type=(utility|zygote)" | head -5)
if [ -n "$VSCODE_PIDS" ]; then
TOTAL_MEMORY=0
PROCESS_COUNT=0
for pid in $VSCODE_PIDS; do
if [ -f "/proc/$pid/status" ]; then
MEMORY=$(awk '/VmRSS:/ {print $2}' "/proc/$pid/status" 2>/dev/null || echo 0)
TOTAL_MEMORY=$((TOTAL_MEMORY + MEMORY))
PROCESS_COUNT=$((PROCESS_COUNT + 1))
fi
done
echo "$(date): VS Code processes: $PROCESS_COUNT, Total memory: ${TOTAL_MEMORY}KB" >> "$LOG_FILE"
# Check if memory usage is too high
if [ "$TOTAL_MEMORY" -gt "$VSCODE_MEMORY_LIMIT" ]; then
echo "$(date): WARNING - VS Code memory usage too high: ${TOTAL_MEMORY}KB" >> "$LOG_FILE"
echo "$(date): Recommend restarting VS Code to prevent crashes" >> "$LOG_FILE"
# Optional: Force cleanup of specific high-memory processes
for pid in $VSCODE_PIDS; do
if [ -f "/proc/$pid/status" ]; then
MEMORY=$(awk '/VmRSS:/ {print $2}' "/proc/$pid/status" 2>/dev/null || echo 0)
if [ "$MEMORY" -gt 500000 ]; then # 500MB
echo "$(date): High memory process (PID $pid): ${MEMORY}KB" >> "$LOG_FILE"
fi
fi
done
fi
# Check for renderer crashes
if grep -q "renderer process gone" ~/.config/Code/logs/*/main.log 2>/dev/null; then
echo "$(date): Renderer crash detected - recommend restart" >> "$LOG_FILE"
fi
fi
sleep "$CHECK_INTERVAL"
done
}
check_session_limits() {
echo "=== VS Code Session Limits Analysis ==="
echo "Current system limits:"
ulimit -a | grep -E "(processes|memory|files)"
echo ""
echo "Current VS Code processes:"
ps aux | grep -E "(code|vscode)" | grep -v grep | wc -l
echo ""
echo "VS Code memory usage:"
ps -o pid,ppid,pcpu,pmem,rss,args -C code | head -10
echo ""
echo "GitHub Copilot Chat requests (last 50 lines):"
find ~/.config/Code/logs -name "*copilot*" -type f -exec tail -50 {} \; 2>/dev/null | grep -c "ccreq:"
echo ""
echo "Recent crashes:"
grep "crashed" ~/.config/Code/logs/*/main.log 2>/dev/null | tail -5
}
# Function to clean up VS Code cache and temporary files
cleanup_vscode() {
echo "$(date): Starting VS Code cleanup..." >> "$LOG_FILE"
# Clean extension host logs
find ~/.config/Code/logs -name "*.log" -size +10M -delete 2>/dev/null
# Clean old crash dumps
find ~/.config/Code/Crashpad -type f -mtime +7 -delete 2>/dev/null
# Clean temporary MCP sockets
find /tmp -name "mcp-*" -type d -mtime +1 -exec rm -rf {} \; 2>/dev/null
echo "$(date): Cleanup completed" >> "$LOG_FILE"
}
# Main execution
case "${1:-check}" in
"monitor")
monitor_vscode
;;
"check")
check_session_limits
;;
"cleanup")
cleanup_vscode
;;
*)
echo "Usage: $0 {monitor|check|cleanup}"
echo " monitor: Start continuous monitoring"
echo " check: Show current session limits and status"
echo " cleanup: Clean VS Code cache and logs"
;;
esac

241
templates/admin.html Normal file
View File

@@ -0,0 +1,241 @@
{% extends "base.html" %}
{% block title %}Admin — Server Monitoring{% endblock %}
{% block page_title %}Admin &amp; Maintenance{% endblock %}
{% block extra_css %}
<style>
.danger-card { border: 2px solid #dc3545; }
.danger-card .card-header { background-color: #dc3545; color: #fff; }
.warning-card { border: 2px solid #fd7e14; }
.warning-card .card-header { background-color: #fd7e14; color: #fff; }
.stat-box { background: #f8f9fa; border-radius: 8px; padding: 12px 20px; text-align: center; }
.stat-box .num { font-size: 2rem; font-weight: bold; line-height: 1; }
.stat-box .lbl { font-size: .78rem; color: #6c757d; margin-top: 2px; }
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Current stats row -->
<div class="row g-3 mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-secondary text-white">
<h5 class="mb-0"><i class="fas fa-database me-2"></i>Current Database &amp; Inventory State</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-6 col-md-2">
<div class="stat-box">
<div class="num text-primary" id="stat-devices">{{ stats.get('devices', '?') }}</div>
<div class="lbl">Devices</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="stat-box">
<div class="num text-info" id="stat-logs">{{ stats.get('logs', '?') }}</div>
<div class="lbl">Log Entries</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="stat-box">
<div class="num text-secondary" id="stat-templates">{{ stats.get('templates', '?') }}</div>
<div class="lbl">Msg Templates</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="stat-box">
<div class="num text-danger" id="stat-wmt">{{ stats.get('wmt_requests', '?') }}</div>
<div class="lbl">WMT Requests</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="stat-box">
<div class="num text-warning" id="stat-inv-hosts">{{ stats.get('inventory_hosts', '?') }}</div>
<div class="lbl">Inventory Hosts</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="stat-box">
<div class="num text-success" id="stat-inv-groups">{{ stats.get('inventory_groups_yaml', '?') }}</div>
<div class="lbl">Inventory Groups</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row g-4">
<!-- Clear Log Entries -->
<div class="col-md-3">
<div class="card warning-card h-100">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-stream me-2"></i>Clear Log Entries</h5>
</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.
Devices remain intact — they will start logging again automatically.
</p>
<div class="alert alert-warning py-2 mb-3">
<i class="fas fa-exclamation-triangle me-1"></i>
Currently <strong id="badge-logs">{{ stats.get('logs', '?') }}</strong> log entries.
</div>
<button class="btn btn-warning w-100"
onclick="runAction('clear-logs', 'Delete ALL log entries? This cannot be undone.')">
<i class="fas fa-trash me-2"></i>Clear All Logs
</button>
</div>
</div>
</div>
<!-- Clear Devices -->
<div class="col-md-3">
<div class="card danger-card h-100">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-server me-2"></i>Clear Device Database</h5>
</div>
<div class="card-body d-flex flex-column">
<p class="text-muted flex-grow-1">
Deletes <strong>all devices</strong> and their associated log entries from the database.
Devices will re-register automatically when they next check in.
</p>
<div class="alert alert-danger py-2 mb-3">
<i class="fas fa-exclamation-triangle me-1"></i>
Currently <strong id="badge-devices">{{ stats.get('devices', '?') }}</strong> devices
and <strong id="badge-logs2">{{ stats.get('logs', '?') }}</strong> log entries.
</div>
<button class="btn btn-danger w-100"
onclick="runAction('clear-devices', 'Delete ALL devices and their logs? This cannot be undone.')">
<i class="fas fa-trash me-2"></i>Clear All Devices
</button>
</div>
</div>
</div>
<!-- Clear Ansible Inventory -->
<div class="col-md-3">
<div class="card danger-card h-100">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-sitemap me-2"></i>Clear Ansible Inventory</h5>
</div>
<div class="card-body d-flex flex-column">
<p class="text-muted flex-grow-1">
Resets the Ansible inventory file and clears all inventory groups.
The file is left fully empty — ready for new hosts and groups.
</p>
<div class="alert alert-danger py-2 mb-3">
<i class="fas fa-exclamation-triangle me-1"></i>
Currently <strong id="badge-inv-hosts">{{ stats.get('inventory_hosts', '?') }}</strong> hosts
in <strong id="badge-inv-groups">{{ stats.get('inventory_groups_yaml', '?') }}</strong> group(s).
</div>
<button class="btn btn-danger w-100"
onclick="runAction('clear-inventory', 'Reset Ansible inventory and delete all groups? This cannot be undone.')">
<i class="fas fa-trash me-2"></i>Clear Inventory
</button>
</div>
</div>
</div>
<!-- Clear WMT Update Requests -->
<div class="col-md-3">
<div class="card warning-card h-100">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-clipboard-list me-2"></i>Clear WMT Requests</h5>
</div>
<div class="card-body d-flex flex-column">
<p class="text-muted flex-grow-1">
Deletes <strong>all WMT update requests</strong> (pending, accepted and rejected).
Devices are not affected and can submit new requests at any time.
</p>
<div class="alert alert-warning py-2 mb-3">
<i class="fas fa-exclamation-triangle me-1"></i>
Currently <strong id="badge-wmt">{{ stats.get('wmt_requests', '?') }}</strong> update request(s).
</div>
<button class="btn btn-warning w-100"
onclick="runAction('clear-wmt', 'Delete ALL WMT update requests? This cannot be undone.')">
<i class="fas fa-trash me-2"></i>Clear WMT Requests
</button>
</div>
</div>
</div>
</div><!-- /row -->
</div><!-- /container -->
<!-- Result toast -->
<div class="position-fixed bottom-0 end-0 p-3" style="z-index:9999">
<div id="resultToast" class="toast align-items-center text-white border-0" role="alert" aria-live="assertive">
<div class="d-flex">
<div class="toast-body" id="toastMsg">Done.</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
</div>
<script>
const ENDPOINTS = {
'clear-logs': '{{ url_for("main.admin_clear_logs") }}',
'clear-devices': '{{ url_for("main.admin_clear_devices") }}',
'clear-inventory': '{{ url_for("main.admin_clear_inventory") }}',
'clear-wmt': '{{ url_for("main.admin_clear_wmt") }}'
};
function runAction(action, confirmMsg) {
if (!confirm(confirmMsg)) return;
const btn = event.currentTarget;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Working…';
fetch(ENDPOINTS[action], {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(r => r.json())
.then(data => {
if (data.success) {
showToast('success', buildMessage(action, data));
refreshStats();
} else {
showToast('danger', 'Error: ' + (data.error || 'Unknown'));
}
})
.catch(err => showToast('danger', 'Network error: ' + err))
.finally(() => {
btn.disabled = false;
btn.innerHTML = btn.innerHTML.replace(/<span.*?><\/span>/, '<i class="fas fa-trash me-2"></i>');
// Re-render button label properly
btn.innerHTML = '<i class="fas fa-trash me-2"></i>' + btn.textContent.trim();
});
}
function buildMessage(action, data) {
if (action === 'clear-logs')
return `Deleted ${data.deleted} log entries.`;
if (action === 'clear-devices')
return `Deleted ${data.deleted_devices} devices and ${data.deleted_logs} log entries.`;
if (action === 'clear-inventory')
return `Inventory reset. ${data.groups_deleted} group(s) removed.`;
if (action === 'clear-wmt')
return `Deleted ${data.deleted} WMT update request(s).`;
return 'Done.';
}
function showToast(type, msg) {
const toast = document.getElementById('resultToast');
toast.className = `toast align-items-center text-white bg-${type} border-0`;
document.getElementById('toastMsg').textContent = msg;
bootstrap.Toast.getOrCreateInstance(toast, {delay: 4000}).show();
}
function refreshStats() {
// Reload the page stats after a short delay to let DB settle
setTimeout(() => location.reload(), 800);
}
</script>
{% endblock %}

View File

@@ -0,0 +1,664 @@
{% extends "base.html" %}
{% block title %}Ansible Management Dashboard - Server Monitoring{% endblock %}
{% block page_title %}Ansible Management Dashboard{% endblock %}
{% block extra_css %}
<style>
.card-stat {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 15px;
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
transition: transform 0.3s ease;
}
.card-stat:hover {
transform: translateY(-5px);
}
.card-stat h3 {
font-size: 2.5rem;
font-weight: bold;
}
.device-card {
border: none;
border-radius: 12px;
box-shadow: 0 2px 15px rgba(0,0,0,0.08);
transition: all 0.3s ease;
cursor: pointer;
}
.device-card:hover {
transform: translateY(-3px);
box-shadow: 0 5px 25px rgba(0,0,0,0.15);
}
.device-card.selected {
border: 2px solid #28a745;
background-color: #f8fff9;
}
.playbook-card {
border: none;
border-radius: 12px;
box-shadow: 0 2px 15px rgba(0,0,0,0.08);
transition: all 0.3s ease;
}
.playbook-card:hover {
transform: translateY(-3px);
}
.execution-card {
border: none;
border-radius: 15px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
transition: transform 0.2s;
}
.execution-card:hover {
transform: translateY(-5px);
}
.status-running { color: #0d6efd; }
.status-completed { color: #198754; }
.status-failed { color: #dc3545; }
/* Tab styles */
.nav-tabs .nav-link {
border: none;
border-radius: 25px 25px 0 0;
margin-right: 5px;
padding: 12px 20px;
font-weight: 500;
color: #6c757d;
background-color: #f8f9fa;
}
.nav-tabs .nav-link:hover {
background-color: #e9ecef;
color: #495057;
}
.nav-tabs .nav-link.active {
background-color: #3498db;
color: white;
border: none;
}
.tab-content {
border: 1px solid #dee2e6;
border-top: none;
border-radius: 0 15px 15px 15px;
background-color: white;
min-height: 400px;
}
/* Button styles */
.btn-custom {
border-radius: 25px;
padding: 10px 20px;
font-weight: 500;
transition: all 0.3s ease;
}
.btn-custom:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- Ansible Management Tabs -->
<div class="row mb-4">
<div class="col-12">
<ul class="nav nav-tabs" id="ansibleTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="automation-tab" data-bs-toggle="tab" data-bs-target="#automation" type="button" role="tab">
<i class="fas fa-robot"></i> Automation Overview
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="devices-tab" data-bs-toggle="tab" data-bs-target="#devices" type="button" role="tab">
<i class="fas fa-network-wired"></i> Remote Devices
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="playbooks-tab" data-bs-toggle="tab" data-bs-target="#playbooks" type="button" role="tab">
<i class="fas fa-play"></i> Playbook Management
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="execution-tab" data-bs-toggle="tab" data-bs-target="#execution" type="button" role="tab">
<i class="fas fa-cogs"></i> Execution
</button>
</li>
</ul>
</div>
</div>
<!-- Tab Content -->
<div class="tab-content" id="ansibleTabContent">
<!-- Automation Overview Tab -->
<div class="tab-pane fade show active" id="automation" role="tabpanel">
<div class="row mb-4">
<div class="col-md-3">
<div class="card card-stat">
<div class="card-body text-center">
<i class="fas fa-server fa-2x mb-2"></i>
<h3>{{ stats.get('total_devices', 0) }}</h3>
<p>Devices</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card card-stat" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<div class="card-body text-center">
<i class="fas fa-play-circle fa-2x mb-2"></i>
<h3>{{ ((playbooks|default([])|length) + (builtin_playbooks|default([])|length)) }}</h3>
<p>Playbooks</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card card-stat" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
<div class="card-body text-center">
<i class="fas fa-tasks fa-2x mb-2"></i>
<h3>{{ (executions|default([]))|length }}</h3>
<p>Executions</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card card-stat" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">
<div class="card-body text-center">
<i class="fas fa-check-circle fa-2x mb-2"></i>
<h3>{{ (executions|default([]) | selectattr('status', 'equalto', 'completed') | list | length) }}</h3>
<p>Success Rate</p>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5>Quick Actions</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<button class="btn btn-primary btn-custom w-100" onclick="executeQuickPlaybook()">
<i class="fas fa-play"></i> Quick Execute
</button>
</div>
<div class="col-md-3">
<button class="btn btn-success btn-custom w-100" onclick="refreshAll()">
<i class="fas fa-sync"></i> Refresh All
</button>
</div>
<div class="col-md-3">
<button class="btn btn-info btn-custom w-100" data-bs-toggle="modal" data-bs-target="#addDeviceModal">
<i class="fas fa-plus"></i> Add Device
</button>
</div>
<div class="col-md-3">
<button class="btn btn-warning btn-custom w-100" data-bs-toggle="modal" data-bs-target="#uploadPlaybookModal">
<i class="fas fa-upload"></i> Upload Playbook
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Remote Devices Tab -->
<div class="tab-pane fade" id="devices" role="tabpanel">
<div class="row mb-4">
<div class="col-md-8">
<h4>Managed Devices</h4>
<small class="text-muted">Configure and manage devices through Ansible</small>
</div>
<div class="col-md-4 text-end">
<button class="btn btn-primary btn-custom" data-bs-toggle="modal" data-bs-target="#addDeviceModal">
<i class="fas fa-plus"></i> Add Device
</button>
<button class="btn btn-success btn-custom" onclick="refreshInventory()">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
</div>
<!-- Device Statistics -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card bg-primary text-white">
<div class="card-body">
<h5 class="card-title">Total Devices</h5>
<h2>{{ (devices|default([]))|length }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body">
<h5 class="card-title">Online</h5>
<h2>{{ (devices|default([]) | selectattr('status', 'equalto', 'active') | list | length) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-warning text-white">
<div class="card-body">
<h5 class="card-title">Offline</h5>
<h2>{{ (devices|default([]) | selectattr('status', 'equalto', 'inactive') | list | length) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-info text-white">
<div class="card-body">
<h5 class="card-title">Groups</h5>
<h2>{{ (device_groups|default([]))|length }}</h2>
</div>
</div>
</div>
</div>
<!-- Devices Table -->
<div class="card">
<div class="card-header">
<h5>Device List</h5>
</div>
<div class="card-body">
{% if devices|default([]) %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Name</th>
<th>Host</th>
<th>Group</th>
<th>Status</th>
<th>Last Check</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for device in devices|default([]) %}
<tr>
<td>{{ device.name }}</td>
<td>{{ device.host }}</td>
<td><span class="badge bg-secondary">{{ device.group }}</span></td>
<td>
{% if device.status == 'active' %}
<span class="badge bg-success">Online</span>
{% else %}
<span class="badge bg-danger">Offline</span>
{% endif %}
</td>
<td>{{ device.last_check.strftime('%Y-%m-%d %H:%M') if device.last_check else 'Never' }}</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="testDevice('{{ device.name }}')">
<i class="fas fa-plug"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="removeDevice('{{ device.name }}')">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-server fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No devices configured</h5>
<p class="text-muted">Add your first device to start managing infrastructure.</p>
<button class="btn btn-primary btn-custom" data-bs-toggle="modal" data-bs-target="#addDeviceModal">
<i class="fas fa-plus"></i> Add Device
</button>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Playbook Management Tab -->
<div class="tab-pane fade" id="playbooks" role="tabpanel">
<div class="row mb-4">
<div class="col-md-8">
<h4>Playbook Management</h4>
<small class="text-muted">Manage and execute Ansible playbooks</small>
</div>
<div class="col-md-4 text-end">
<button class="btn btn-primary btn-custom" data-bs-toggle="modal" data-bs-target="#uploadPlaybookModal">
<i class="fas fa-upload"></i> Upload Playbook
</button>
<button class="btn btn-success btn-custom" onclick="refreshPlaybooks()">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
</div>
<!-- Playbook Statistics -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card bg-primary text-white">
<div class="card-body">
<h5 class="card-title">Total Playbooks</h5>
<h2>{{ ((playbooks|default([]))|length + (builtin_playbooks|default([]))|length) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-info text-white">
<div class="card-body">
<h5 class="card-title">Custom</h5>
<h2>{{ (playbooks|default([]))|length }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body">
<h5 class="card-title">Built-in</h5>
<h2>{{ (builtin_playbooks|default([]))|length }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-warning text-white">
<div class="card-body">
<h5 class="card-title">Recent Runs</h5>
<h2>{{ (executions|default([]))|length }}</h2>
</div>
</div>
</div>
</div>
<!-- Playbooks Grid -->
<div class="row">
{% if (builtin_playbooks|default([])) or (playbooks|default([])) %}
{% for playbook in (builtin_playbooks|default([])) + (playbooks|default([])) %}
<div class="col-md-6 col-lg-4 mb-3">
<div class="card playbook-card">
<div class="card-body">
<h6 class="card-title">{{ playbook.name }}</h6>
<p class="card-text text-muted small">{{ playbook.description | default('No description available') }}</p>
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted">
{% if playbook.type == 'builtin' %}
<span class="badge bg-primary">Built-in</span>
{% else %}
<span class="badge bg-info">Custom</span>
{% endif %}
</small>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" onclick="viewPlaybook('{{ playbook.name }}')">
<i class="fas fa-eye"></i>
</button>
<button class="btn btn-outline-success" onclick="executePlaybook('{{ playbook.name }}')">
<i class="fas fa-play"></i>
</button>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="col-12">
<div class="text-center py-5">
<i class="fas fa-play fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No playbooks available</h5>
<p class="text-muted">Upload a custom playbook to get started.</p>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadPlaybookModal">
<i class="fas fa-upload"></i> Upload Playbook
</button>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Execution Tab -->
<div class="tab-pane fade" id="execution" role="tabpanel">
<div class="row mb-4">
<div class="col-md-8">
<h4>Playbook Execution</h4>
<small class="text-muted">Execute playbooks on selected devices</small>
</div>
<div class="col-md-4 text-end">
<button class="btn btn-primary btn-custom" onclick="executeQuickPlaybook()">
<i class="fas fa-play"></i> Quick Execute
</button>
</div>
</div>
<!-- Recent Executions -->
<div class="card">
<div class="card-header">
<h5>Recent Executions</h5>
</div>
<div class="card-body">
{% if executions|default([]) %}
<div class="row">
{% for execution in (executions|default([]))[:6] %}
<div class="col-md-6 mb-3">
<div class="card execution-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="card-title mb-0">{{ execution.playbook_name }}</h6>
<span class="badge
{% if execution.status == 'running' %}bg-primary status-running{% endif %}
{% if execution.status == 'completed' %}bg-success status-completed{% endif %}
{% if execution.status == 'failed' %}bg-danger status-failed{% endif %}">
{{ execution.status | title }}
</span>
</div>
<p class="card-text text-muted small">
<i class="fas fa-clock"></i>
{{ execution.start_time.strftime('%Y-%m-%d %H:%M:%S') if execution.start_time else 'N/A' }}
</p>
<div class="row text-center">
<div class="col">
<small class="text-success">
<i class="fas fa-check"></i> {{ execution.successful_hosts or 0 }}
</small>
</div>
<div class="col">
<small class="text-danger">
<i class="fas fa-times"></i> {{ execution.failed_hosts or 0 }}
</small>
</div>
<div class="col">
<small class="text-warning">
<i class="fas fa-question"></i> {{ execution.unreachable_hosts or 0 }}
</small>
</div>
</div>
<div class="mt-2">
<button class="btn btn-outline-primary btn-sm" onclick="viewExecution('{{ execution.id }}')">
<i class="fas fa-eye"></i> Details
</button>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-tasks fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No executions yet</h5>
<p class="text-muted">Execute your first playbook to see results here.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Modals -->
<!-- Add Device Modal -->
<div class="modal fade" id="addDeviceModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add New Device</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="addDeviceForm">
<div class="mb-3">
<label class="form-label">Device Name</label>
<input type="text" class="form-control" name="name" required>
</div>
<div class="mb-3">
<label class="form-label">Host/IP Address</label>
<input type="text" class="form-control" name="host" required>
</div>
<div class="mb-3">
<label class="form-label">Group</label>
<select class="form-control" name="group">
<option value="servers">Servers</option>
<option value="workstations">Workstations</option>
<option value="routers">Network Devices</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">SSH User</label>
<input type="text" class="form-control" name="user" value="ansible">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="addDevice()">Add Device</button>
</div>
</div>
</div>
</div>
<!-- Upload Playbook Modal -->
<div class="modal fade" id="uploadPlaybookModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Upload Playbook</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="uploadPlaybookForm" enctype="multipart/form-data">
<div class="mb-3">
<label class="form-label">Playbook File (.yml/.yaml)</label>
<input type="file" class="form-control" name="playbook_file" accept=".yml,.yaml" required>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea class="form-control" name="description" rows="3"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="uploadPlaybook()">Upload</button>
</div>
</div>
</div>
</div>
<script>
function executeQuickPlaybook() {
// Implementation for quick playbook execution
alert('Quick execute functionality');
}
function refreshAll() {
location.reload();
}
function refreshInventory() {
// Implementation for refreshing inventory
alert('Refreshing inventory...');
}
function refreshPlaybooks() {
// Implementation for refreshing playbooks
alert('Refreshing playbooks...');
}
function testDevice(deviceName) {
// Implementation for testing device connection
alert('Testing connection to ' + deviceName);
}
function removeDevice(deviceName) {
if (confirm('Are you sure you want to remove device: ' + deviceName + '?')) {
// Implementation for removing device
alert('Removing device: ' + deviceName);
}
}
function viewPlaybook(playbookName) {
// Implementation for viewing playbook details
alert('Viewing playbook: ' + playbookName);
}
function executePlaybook(playbookName) {
// Implementation for executing playbook
alert('Executing playbook: ' + playbookName);
}
function viewExecution(executionId) {
// Implementation for viewing execution details
alert('Viewing execution: ' + executionId);
}
function addDevice() {
// Implementation for adding device
const form = document.getElementById('addDeviceForm');
const formData = new FormData(form);
// Convert to JSON and submit
alert('Adding device...');
// Close modal
bootstrap.Modal.getInstance(document.getElementById('addDeviceModal')).hide();
}
function uploadPlaybook() {
// Implementation for uploading playbook
const form = document.getElementById('uploadPlaybookForm');
const formData = new FormData(form);
alert('Uploading playbook...');
// Close modal
bootstrap.Modal.getInstance(document.getElementById('uploadPlaybookModal')).hide();
}
</script>
{% endblock %}

View File

@@ -0,0 +1,538 @@
{% extends "base.html" %}
{% block title %}Ansible Inventory — Server Monitoring{% endblock %}
{% block page_title %}Ansible Inventory{% endblock %}
{% block content %}
<div class="container-fluid">
<div id="alertArea"></div>
<!-- ══ TOP ROW: Inventory list | Group management ══════════════ -->
<div class="row g-3 mb-4">
<!-- ── Panel 1: All hosts currently in inventory ──────────────── -->
<div class="col-lg-5">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<span>
<i class="fas fa-network-wired text-primary me-2"></i>
<strong>Inventory Hosts</strong>
{% set ns = namespace(total=0) %}
{% for g in inventory.groups.values() %}{% set ns.total = ns.total + g.hosts|length %}{% endfor %}
<span class="badge bg-primary ms-2">{{ ns.total }}</span>
</span>
<div class="d-flex gap-1">
<button class="btn btn-sm btn-success" onclick="syncDevices()">
<i class="fas fa-sync-alt me-1"></i>Sync All
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="toggleRaw()" title="Raw YAML">
<i class="fas fa-code"></i>
</button>
</div>
</div>
<!-- Raw YAML panel -->
<div id="rawPanel" class="d-none border-bottom">
<pre class="p-3 mb-0"
style="max-height:260px;overflow:auto;background:#1e1e1e;color:#d4d4d4;font-size:.78rem;">{{ inventory.raw_yaml | e }}</pre>
</div>
{% if inventory.groups %}
<div class="card-body p-0" style="max-height:480px;overflow-y:auto;">
{% for group_name, group in inventory.groups.items() %}
<!-- group label row -->
<div class="px-3 py-1 bg-light border-bottom d-flex align-items-center gap-2"
style="font-size:.75rem;">
<i class="fas fa-layer-group text-muted"></i>
<strong class="text-uppercase" style="letter-spacing:.04em;">{{ group_name }}</strong>
<span class="badge bg-light text-dark border">{{ group.hosts|length }}</span>
{% if group_name == 'monitoring_devices' %}
<span class="badge bg-info" style="font-size:.65rem;">default</span>
{% endif %}
</div>
<!-- host rows -->
{% for host in group.hosts %}
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom host-inv-row"
id="host-row-{{ group_name }}-{{ host.hostname }}"
data-hostname="{{ host.hostname }}"
data-group="{{ group_name }}">
<div>
<strong style="font-size:.88rem;">{{ host.hostname }}</strong>
<code class="ms-2 text-muted" style="font-size:.77rem;">{{ host.get('ansible_host','') }}</code>
</div>
<div class="d-flex align-items-center gap-2">
{% if host.get('ansible_connection') == 'local' %}
<span class="badge bg-secondary" style="font-size:.68rem;">local</span>
{% elif host.get('ansible_ssh_private_key_file') %}
<span class="badge bg-success" style="font-size:.68rem;">SSH key</span>
{% elif host.get('ansible_password') %}
<span class="badge bg-warning text-dark" style="font-size:.68rem;">password</span>
{% else %}
<span class="badge bg-light text-dark border" style="font-size:.68rem;"></span>
{% endif %}
<button class="btn btn-sm btn-outline-danger" style="padding:1px 6px;font-size:.72rem;"
onclick="removeHost('{{ group_name }}','{{ host.hostname }}')">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{% else %}
<div class="px-3 py-2 text-muted border-bottom" style="font-size:.82rem;">
No hosts in this group.
<a href="#" onclick="openAddHostModal('{{ group_name }}'); return false;">Add one</a>
</div>
{% endfor %}
{% endfor %}
</div>
{% else %}
<div class="card-body text-center py-5 text-muted">
<i class="fas fa-box-open fa-3x mb-3"></i>
<p class="mb-0">Inventory is empty. Use <strong>Sync All</strong> or add from below.</p>
</div>
{% endif %}
</div>
</div>
<!-- ── Panel 2: Group management ──────────────────────────────── -->
<div class="col-lg-7">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<span>
<i class="fas fa-layer-group text-success me-2"></i>
<strong>Groups</strong>
<span class="badge bg-secondary ms-2">{{ inventory.groups | length }}</span>
</span>
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#createGroupModal">
<i class="fas fa-plus me-1"></i>New Group
</button>
</div>
{% if inventory.groups %}
<div class="card-body p-0">
<div class="accordion accordion-flush" id="groupAccordion">
{% for group_name, group in inventory.groups.items() %}
<div class="accordion-item" id="grp-panel-{{ group_name }}">
<h2 class="accordion-header">
<button class="accordion-button {% if not loop.first %}collapsed{% endif %} py-2"
type="button"
data-bs-toggle="collapse"
data-bs-target="#grp-body-{{ group_name }}">
<span class="me-2">
<i class="fas fa-layer-group me-2 text-primary"></i>
<strong>{{ group_name }}</strong>
</span>
<span class="badge bg-secondary me-2">{{ group.hosts|length }} host(s)</span>
{% if group_name == 'monitoring_devices' %}
<span class="badge bg-info" style="font-size:.68rem;">default</span>
{% endif %}
</button>
</h2>
<div id="grp-body-{{ group_name }}"
class="accordion-collapse collapse {% if loop.first %}show{% endif %}">
<div class="accordion-body p-2">
{% if group.hosts %}
<div class="table-responsive">
<table class="table table-sm table-hover mb-2">
<thead class="table-light">
<tr>
<th>Hostname</th>
<th>IP</th>
<th>Auth</th>
<th class="text-end">Remove</th>
</tr>
</thead>
<tbody>
{% for host in group.hosts %}
<tr id="grptbl-{{ group_name }}-{{ host.hostname }}">
<td><strong>{{ host.hostname }}</strong></td>
<td><code style="font-size:.8rem;">{{ host.get('ansible_host','—') }}</code></td>
<td>
{% if host.get('ansible_connection') == 'local' %}
<span class="badge bg-secondary" style="font-size:.68rem;">local</span>
{% elif host.get('ansible_ssh_private_key_file') %}
<span class="badge bg-success" style="font-size:.68rem;">key</span>
{% elif host.get('ansible_password') %}
<span class="badge bg-warning text-dark" style="font-size:.68rem;">pw</span>
{% else %}
<span class="badge bg-light text-dark border" style="font-size:.68rem;"></span>
{% endif %}
</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger" style="padding:1px 6px;"
onclick="removeHost('{{ group_name }}','{{ host.hostname }}')">
<i class="fas fa-times"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-2" style="font-size:.82rem;">No hosts in this group.</p>
{% endif %}
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary"
onclick="openAddHostModal('{{ group_name }}')">
<i class="fas fa-plus me-1"></i>Add Host
</button>
{% if group_name != 'monitoring_devices' %}
<button class="btn btn-sm btn-outline-danger"
onclick="removeGroup('{{ group_name }}')">
<i class="fas fa-trash me-1"></i>Delete Group
</button>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="card-body text-center py-5 text-muted">
<i class="fas fa-folder-open fa-3x mb-3"></i>
<p class="mb-0">No groups yet. Click <strong>New Group</strong> to create one.</p>
</div>
{% endif %}
</div>
</div>
</div><!-- /top row -->
<!-- ══ BOTTOM: Available hosts from monitoring DB ════════════════ -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<span>
<i class="fas fa-desktop text-secondary me-2"></i>
<strong>Discovered Devices</strong>
<small class="text-muted ms-2">from monitoring database — not yet in inventory</small>
{% set avail = db_devices | selectattr('in_inventory','equalto',false) | list %}
<span class="badge bg-secondary ms-2">{{ avail | length }}</span>
</span>
</div>
{% set avail = db_devices | selectattr('in_inventory','equalto',false) | list %}
{% if avail %}
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Hostname</th>
<th>IP</th>
<th>Type</th>
<th>Status</th>
<th class="text-end">Action</th>
</tr>
</thead>
<tbody>
{% for d in avail %}
<tr id="avail-row-{{ d.hostname }}">
<td><strong>{{ d.hostname }}</strong></td>
<td><code style="font-size:.8rem;">{{ d.device_ip }}</code></td>
<td>{{ d.device_type or '—' }}</td>
<td>
{% if d.status == 'active' %}
<span class="badge bg-success">Active</span>
{% elif d.status == 'inactive' %}
<span class="badge bg-warning text-dark">Inactive</span>
{% else %}
<span class="badge bg-secondary">{{ d.status }}</span>
{% endif %}
</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary"
onclick="quickAdd('{{ d.hostname }}','{{ d.device_ip }}')">
<i class="fas fa-arrow-up me-1"></i>Add to Inventory
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% elif db_devices %}
<div class="card-body text-center py-4 text-muted">
<i class="fas fa-check-circle fa-2x mb-2 text-success"></i>
<p class="mb-0">All discovered devices are already in the inventory.</p>
</div>
{% else %}
<div class="card-body text-center py-4 text-muted">
<p class="mb-0">No devices discovered yet.</p>
</div>
{% endif %}
</div>
</div><!-- /container-fluid -->
<!-- ═══ Create Group Modal (with device picker) ══════════════════ -->
<div class="modal fade" id="createGroupModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-layer-group me-2"></i>Create New Group</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Group Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="newGroupName"
placeholder="e.g. webservers, rpi_devices"
pattern="[a-zA-Z0-9_\-]+" title="Letters, numbers, underscores and hyphens only">
</div>
<label class="form-label">Select hosts to add to this group</label>
<div class="border rounded p-2" style="max-height:280px;overflow-y:auto;" id="groupDevicePicker">
{% set all_inv_hosts = [] %}
{% for g in inventory.groups.values() %}
{% for h in g.hosts %}{% if all_inv_hosts.append(h.hostname) %}{% endif %}{% endfor %}
{% endfor %}
{% if all_inv_hosts %}
<div class="mb-2">
<small class="text-muted fw-bold">Already in inventory</small>
{% for group_name, group in inventory.groups.items() %}
{% for host in group.hosts %}
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="pick-inv-{{ host.hostname }}"
value="{{ host.hostname }}"
data-ip="{{ host.get('ansible_host','') }}"
name="groupHostPick">
<label class="form-check-label" for="pick-inv-{{ host.hostname }}">
{{ host.hostname }}
<code class="text-muted ms-1" style="font-size:.8rem;">{{ host.get('ansible_host','') }}</code>
<span class="badge bg-light text-dark border ms-1" style="font-size:.68rem;">{{ group_name }}</span>
</label>
</div>
{% endfor %}
{% endfor %}
</div>
{% endif %}
{% set avail2 = db_devices | selectattr('in_inventory','equalto',false) | list %}
{% if avail2 %}
<div>
<small class="text-muted fw-bold">Available (not yet in inventory)</small>
{% for d in avail2 %}
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="pick-avail-{{ d.hostname }}"
value="{{ d.hostname }}"
data-ip="{{ d.device_ip }}"
name="groupHostPick">
<label class="form-check-label" for="pick-avail-{{ d.hostname }}">
{{ d.hostname }}
<code class="text-muted ms-1" style="font-size:.8rem;">{{ d.device_ip }}</code>
{% if d.status == 'active' %}<span class="badge bg-success ms-1" style="font-size:.65rem;">active</span>{% endif %}
</label>
</div>
{% endfor %}
</div>
{% endif %}
{% if not all_inv_hosts and not avail2 %}
<p class="text-muted mb-0 text-center py-3">No devices available.</p>
{% endif %}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="createGroup()">Create Group</button>
</div>
</div>
</div>
</div>
<!-- ═══ Add Host to Group Modal ══════════════════════════════════ -->
<div class="modal fade" id="addHostModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-server me-2"></i>Add Host to:
<span id="addHostGroupLabel" class="text-primary ms-1"></span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="addHostGroup">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Hostname <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="addHostname" placeholder="e.g. rpi-desk-01">
</div>
<div class="col-md-6">
<label class="form-label">IP Address <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="addHostIP" placeholder="e.g. 192.168.1.50">
</div>
<div class="col-md-4">
<label class="form-label">SSH User</label>
<input type="text" class="form-control" id="addHostUser" value="pi">
</div>
<div class="col-md-4">
<label class="form-label">SSH Port</label>
<input type="number" class="form-control" id="addHostPort" value="22" min="1" max="65535">
</div>
<div class="col-md-4">
<label class="form-label">Authentication</label>
<select class="form-select" id="addHostAuth" onchange="togglePwField(this.value)">
<option value="key">SSH Key (recommended)</option>
<option value="password">Password</option>
</select>
</div>
<div class="col-12 d-none" id="pwField">
<label class="form-label">Password</label>
<input type="password" class="form-control" id="addHostPassword" autocomplete="new-password">
<div class="form-text text-warning">
<i class="fas fa-exclamation-triangle me-1"></i>Stored in plain text in inventory file.
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="addHost()">Add Host</button>
</div>
</div>
</div>
</div>
<script>
const API = '/api/ansible';
function showAlert(msg, type='success') {
const d = document.createElement('div');
d.className = `alert alert-${type} alert-dismissible fade show`;
d.innerHTML = `${msg} <button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
document.getElementById('alertArea').appendChild(d);
setTimeout(() => { try { d.remove(); } catch {} }, 6000);
}
function toggleRaw() {
document.getElementById('rawPanel').classList.toggle('d-none');
}
function togglePwField(val) {
document.getElementById('pwField').classList.toggle('d-none', val !== 'password');
}
/* ── Sync all DB devices into monitoring_devices ── */
async function syncDevices() {
const btn = event.currentTarget;
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Syncing…';
try {
const r = await fetch(`${API}/inventory/sync`, {method:'POST'});
const d = await r.json();
if (d.success) {
showAlert(`<i class="fas fa-check-circle me-1"></i> ${d.message}`, 'success');
setTimeout(() => location.reload(), 1200);
} else { showAlert(`Sync failed: ${d.error}`, 'danger'); }
} catch { showAlert('Network error', 'danger'); }
finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-sync-alt me-1"></i>Sync All';
}
}
/* ── Create group (with pre-selected hosts) ── */
async function createGroup() {
const name = document.getElementById('newGroupName').value.trim();
if (!name) { showAlert('Group name is required', 'warning'); return; }
// First create the group
let r = await fetch(`${API}/inventory/group/add`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({group_name: name})
});
let d = await r.json();
if (!d.success) { showAlert(d.error || 'Failed to create group', 'danger'); return; }
// Then add each checked host
const checks = document.querySelectorAll('input[name="groupHostPick"]:checked');
for (const cb of checks) {
const hostname = cb.value;
const ip = cb.dataset.ip;
await fetch(`${API}/inventory/host/add`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({group: name, hostname, ip, ssh_user:'pi', ssh_port:22, use_key:true})
});
}
bootstrap.Modal.getInstance(document.getElementById('createGroupModal')).hide();
showAlert(`<i class="fas fa-check-circle me-1"></i> Group "${name}" created with ${checks.length} host(s).`, 'success');
setTimeout(() => location.reload(), 1200);
}
/* ── Remove group ── */
async function removeGroup(groupName) {
if (!confirm(`Delete group "${groupName}" and remove all its hosts from the group?`)) return;
const r = await fetch(`${API}/inventory/group/remove`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({group_name: groupName})
});
const d = await r.json();
if (d.success) {
document.getElementById(`grp-panel-${groupName}`)?.remove();
showAlert(`<i class="fas fa-check-circle me-1"></i> ${d.message}`, 'success');
} else { showAlert(d.error || 'Failed', 'danger'); }
}
/* ── Open Add Host modal ── */
function openAddHostModal(groupName, hostname='', ip='') {
document.getElementById('addHostGroup').value = groupName;
document.getElementById('addHostGroupLabel').textContent = groupName;
document.getElementById('addHostname').value = hostname;
document.getElementById('addHostIP').value = ip;
document.getElementById('addHostUser').value = 'pi';
document.getElementById('addHostPort').value = 22;
document.getElementById('addHostAuth').value = 'key';
togglePwField('key');
new bootstrap.Modal(document.getElementById('addHostModal')).show();
}
/* ── Quick-add from discovered panel ── */
function quickAdd(hostname, ip) {
openAddHostModal('monitoring_devices', hostname, ip);
}
/* ── Add host ── */
async function addHost() {
const group = document.getElementById('addHostGroup').value;
const hostname = document.getElementById('addHostname').value.trim();
const ip = document.getElementById('addHostIP').value.trim();
const user = document.getElementById('addHostUser').value.trim() || 'pi';
const port = parseInt(document.getElementById('addHostPort').value) || 22;
const authType = document.getElementById('addHostAuth').value;
const password = document.getElementById('addHostPassword').value;
if (!hostname || !ip) { showAlert('Hostname and IP are required', 'warning'); return; }
const r = await fetch(`${API}/inventory/host/add`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({group, hostname, ip, ssh_user:user, ssh_port:port,
use_key: authType==='key',
password: authType==='password' ? password : null})
});
const d = await r.json();
if (d.success) {
bootstrap.Modal.getInstance(document.getElementById('addHostModal')).hide();
showAlert(`<i class="fas fa-check-circle me-1"></i> ${d.message}`, 'success');
setTimeout(() => location.reload(), 1000);
} else { showAlert(d.error || 'Failed to add host', 'danger'); }
}
/* ── Remove host ── */
async function removeHost(group, hostname) {
if (!confirm(`Remove "${hostname}" from group "${group}"?`)) return;
const r = await fetch(`${API}/inventory/host/remove`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({group, hostname})
});
const d = await r.json();
if (d.success) {
document.getElementById(`host-row-${group}-${hostname}`)?.remove();
document.getElementById(`grptbl-${group}-${hostname}`)?.remove();
showAlert(`<i class="fas fa-check-circle me-1"></i> ${d.message}`, 'success');
} else { showAlert(d.error || 'Failed', 'danger'); }
}
</script>
{% endblock %}

View File

@@ -0,0 +1,612 @@
{% extends "base.html" %}
{% block title %}Execute Ansible Playbook - Server Monitoring{% endblock %}
{% block page_title %}Execute Ansible Playbook{% endblock %}
{% block extra_css %}
<style>
.playbook-card {
border: 2px solid #e9ecef; border-radius: 8px;
transition: all 0.2s; cursor: pointer;
}
.playbook-card:hover { border-color: #0d6efd; box-shadow: 0 3px 10px rgba(13,110,253,.15); }
.playbook-card.selected { border-color: #198754; background-color: #f8fff9; }
.json-editor { font-family: 'Courier New', monospace; background-color: #f8f9fa; }
#liveCard { display: none; }
#liveTerminal {
background: #1e1e1e; color: #d4d4d4;
font-family: 'Courier New', monospace; font-size: .78rem;
line-height: 1.5; height: 380px; overflow-y: auto;
white-space: pre-wrap; word-break: break-all;
border-radius: 0 0 8px 8px; padding: 12px 16px;
}
#liveTerminal .ansi-ok { color: #4ec9b0; }
#liveTerminal .ansi-changed { color: #dcdcaa; }
#liveTerminal .ansi-fail { color: #f44747; }
#liveTerminal .ansi-unreachable { color: #ce9178; }
#liveTerminal .ansi-task { color: #9cdcfe; font-weight: bold; }
#liveTerminal .ansi-play { color: #c586c0; font-weight: bold; }
.live-header {
background: #252526; color: #ccc; border-radius: 8px 8px 0 0;
padding: 8px 16px; font-size: .8rem; display: flex; align-items: center; gap: 10px;
}
.pulse-dot {
width: 10px; height: 10px; border-radius: 50%;
background: #4ec9b0; animation: pulse 1.2s infinite; display: inline-block;
}
.pulse-dot.done { background: #6a9955; animation: none; }
.pulse-dot.error { background: #f44747; animation: none; }
@keyframes pulse { 0%,100%{opacity:1;} 50%{opacity:.3;} }
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category=='error' else category }} alert-dismissible fade show">
{{ message }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}{% endif %}
{% endwith %}
<form method="POST" id="executeForm">
<div class="row g-3 mb-3">
<!-- ── Playbook selection ──────────────────────────────────── -->
<div class="col-lg-5">
<div class="card h-100">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="fas fa-book me-2"></i>Select Playbook</h5>
</div>
<div class="card-body" style="overflow-y:auto; max-height:480px;">
<h6 class="text-muted fw-bold mb-2 small text-uppercase">Built-in</h6>
<div class="card playbook-card mb-2" data-name="update_devices" onclick="selectPlaybook('update_devices')">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1 text-primary"><i class="fas fa-download me-1"></i>Update Devices</h6>
<p class="small text-muted mb-0">Update all packages on monitoring devices</p>
</div>
<span class="badge bg-success ms-2 flex-shrink-0">Built-in</span>
</div>
</div>
</div>
<div class="card playbook-card mb-2" data-name="restart_service" onclick="selectPlaybook('restart_service')">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1 text-success"><i class="fas fa-redo me-1"></i>Restart Service</h6>
<p class="small text-muted mb-0">Restart monitoring services on devices</p>
</div>
<span class="badge bg-success ms-2 flex-shrink-0">Built-in</span>
</div>
</div>
</div>
<div class="card playbook-card mb-3" data-name="system_health" onclick="selectPlaybook('system_health')">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1 text-info"><i class="fas fa-heartbeat me-1"></i>System Health</h6>
<p class="small text-muted mb-0">Check system health and monitoring status</p>
</div>
<span class="badge bg-success ms-2 flex-shrink-0">Built-in</span>
</div>
</div>
</div>
<h6 class="text-muted fw-bold mb-2 small text-uppercase">Custom Playbooks</h6>
<select class="form-select" id="customPlaybook">
<option value="">— select custom playbook —</option>
</select>
<input type="hidden" name="playbook" id="selectedPlaybook">
</div>
</div>
</div>
<!-- ── Target selection ────────────────────────────────────── -->
<div class="col-lg-7">
<div class="card h-100">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-crosshairs me-2"></i>Select Target</h5>
<span id="targetCountBadge" class="badge bg-light text-dark"></span>
</div>
<div class="card-body p-3">
<!-- Mode pills -->
<ul class="nav nav-pills nav-fill mb-3">
<li class="nav-item">
<button type="button" class="nav-link active" id="pill-all" onclick="setTargetMode('all')">
<i class="fas fa-globe me-1"></i>All Hosts
</button>
</li>
<li class="nav-item">
<button type="button" class="nav-link" id="pill-group" onclick="setTargetMode('group')">
<i class="fas fa-layer-group me-1"></i>By Group
</button>
</li>
<li class="nav-item">
<button type="button" class="nav-link" id="pill-host" onclick="setTargetMode('host')">
<i class="fas fa-server me-1"></i>By Host
</button>
</li>
</ul>
<!-- All Hosts panel -->
<div id="panel-all">
{% set ns = namespace(total=0) %}
{% for g in inventory.groups.values() %}{% set ns.total = ns.total + g.hosts|length %}{% endfor %}
{% if ns.total > 0 %}
<div class="alert alert-info mb-0">
<i class="fas fa-info-circle me-2"></i>
Will run on <strong>all {{ ns.total }} host(s)</strong>
across <strong>{{ inventory.groups|length }} group(s)</strong>.
</div>
{% else %}
<div class="alert alert-warning mb-0">
<i class="fas fa-exclamation-triangle me-2"></i>
No hosts in inventory yet.
<a href="{{ url_for('ansible_web.devices') }}">Add devices to inventory</a> first.
</div>
{% endif %}
</div>
<!-- By Group panel -->
<div id="panel-group" class="d-none" style="max-height:300px;overflow-y:auto;">
{% if inventory.groups %}
{% for group_name, group in inventory.groups.items() %}
<div class="form-check border rounded p-2 mb-2">
<input class="form-check-input group-cb" type="checkbox"
id="grp-{{ group_name }}" value="{{ group_name }}"
data-count="{{ group.hosts|length }}"
onchange="updateTargetCount()">
<label class="form-check-label w-100" for="grp-{{ group_name }}">
<div class="d-flex justify-content-between align-items-center">
<strong>{{ group_name }}</strong>
<span class="badge bg-secondary">{{ group.hosts|length }} host(s)</span>
</div>
<small class="text-muted">
{% for h in group.hosts[:3] %}{{ h.hostname }}{% if not loop.last %}, {% endif %}{% endfor %}{% if group.hosts|length > 3 %}&nbsp;+{{ group.hosts|length - 3 }} more{% endif %}
</small>
</label>
</div>
{% endfor %}
{% else %}
<p class="text-muted text-center py-3">No groups in inventory.</p>
{% endif %}
</div>
<!-- By Host panel -->
<div id="panel-host" class="d-none" style="max-height:300px;overflow-y:auto;">
{% if all_inv_hosts %}
<div class="mb-2 d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="checkAllHosts(true)">
<i class="fas fa-check-double me-1"></i>Select All
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="checkAllHosts(false)">Clear</button>
</div>
{% for h in all_inv_hosts %}
<div class="form-check border-bottom py-2">
<input class="form-check-input host-cb" type="checkbox"
id="invhost-{{ h.hostname }}" value="{{ h.hostname }}"
onchange="updateTargetCount()">
<label class="form-check-label d-flex justify-content-between w-100" for="invhost-{{ h.hostname }}">
<strong>{{ h.hostname }}</strong>
<code class="text-muted" style="font-size:.8rem;">{{ h.ip }}</code>
</label>
</div>
{% endfor %}
{% else %}
<p class="text-muted text-center py-3">No hosts in inventory yet.</p>
{% endif %}
</div>
</div>
</div>
</div>
</div><!-- /top row -->
<!-- ── Advanced Options ──────────────────────────────────────── -->
<div class="row mb-3">
<div class="col-12">
<div class="card">
<div class="card-header bg-info text-white">
<h6 class="mb-0">
<i class="fas fa-cogs me-1"></i>Advanced Options
<button type="button" class="btn btn-outline-light btn-sm ms-2"
data-bs-toggle="collapse" data-bs-target="#advancedOptions">
<i class="fas fa-chevron-down"></i>
</button>
</h6>
</div>
<div class="collapse" id="advancedOptions">
<div class="card-body">
<div class="row">
<div class="col-md-4">
<h6>Execution Settings</h6>
<div class="mb-3">
<label class="form-label">Priority</label>
<select class="form-select" name="priority">
<option value="1">1 — Low</option>
<option value="3">3 — Below Normal</option>
<option value="5" selected>5 — Normal</option>
<option value="7">7 — High</option>
<option value="10">10 — Critical</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Max Retries</label>
<select class="form-select" name="max_retries">
<option value="0" selected>0 — No retries</option>
<option value="1">1 retry</option>
<option value="2">2 retries</option>
<option value="3">3 retries</option>
</select>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="checkMode" name="check_mode">
<label class="form-check-label" for="checkMode">Dry run (check mode)</label>
</div>
</div>
<div class="col-md-4">
<label class="form-label">Extra Variables (JSON)</label>
<textarea class="form-control json-editor" name="extra_vars" id="extraVars" rows="6"
placeholder='{"variable": "value"}'></textarea>
<div class="form-text">Optional variables passed to playbook</div>
</div>
<div class="col-md-4">
<h6>Common Variables</h6>
<ul class="list-unstyled small">
<li><code>timeout: 600</code></li>
<li><code>reboot_timeout: 900</code></li>
<li><code>become_user: "root"</code></li>
<li><code>force: true</code></li>
</ul>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="addCommonVars()">
<i class="fas fa-plus me-1"></i>Add Common Vars
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ── Summary bar + Execute button ─────────────────────────── -->
<div class="card mb-3">
<div class="card-body py-3">
<div class="row align-items-center">
<div class="col-md-3">
<small class="text-muted d-block">Playbook</small>
<strong id="summaryPlaybook">None selected</strong>
</div>
<div class="col-md-4">
<small class="text-muted d-block">Target</small>
<strong id="summaryDevices">None selected</strong>
</div>
<div class="col-md-3">
<small class="text-muted d-block">Est. Duration</small>
<strong id="estimatedDuration">Unknown</strong>
</div>
<div class="col-md-2 text-end">
<button type="submit" class="btn btn-primary btn-lg w-100" id="executeButton">
<i class="fas fa-play me-1"></i>Execute
</button>
</div>
</div>
</div>
</div>
<!-- Hidden host inputs populated by JS before submit -->
<div id="hostsContainer"></div>
</form>
<!-- ── Live Execution Output ──────────────────────────────────── -->
<div class="row mt-3" id="liveCard">
<div class="col-12">
<div class="card shadow">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<span class="fw-semibold">
<i class="fas fa-terminal me-2"></i>Live Execution Output
</span>
<div class="d-flex align-items-center gap-3">
<span id="liveStatusBadge" class="badge bg-secondary">Waiting…</span>
<a id="liveDetailsLink" href="#" class="btn btn-sm btn-outline-primary" style="display:none">
<i class="fas fa-external-link-alt me-1"></i>Full Details
</a>
</div>
</div>
<div class="live-header">
<span class="pulse-dot" id="livePulseDot"></span>
<span id="liveStatusText">Initializing…</span>
<span class="ms-auto text-muted" id="liveElapsed"></span>
</div>
<div id="liveTerminal"></div>
<div class="card-footer d-flex justify-content-between align-items-center py-2">
<small class="text-muted" id="liveHostSummary"></small>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-secondary" onclick="toggleAutoScroll()">
<i class="fas fa-arrow-down" id="autoScrollIcon"></i> Auto-scroll
</button>
<button class="btn btn-sm btn-outline-danger" id="liveStopBtn" onclick="stopPolling()">
<i class="fas fa-stop"></i> Stop polling
</button>
</div>
</div>
</div>
</div>
</div>
</div><!-- /container-fluid -->
<script>
// ── State ────────────────────────────────────────────────────────────
let selectedPlaybook = null;
let targetMode = 'all';
// Live output state
let pollTimer = null;
let executionId = null;
let autoScroll = true;
let pollStartTime = null;
// ── Playbook selection ───────────────────────────────────────────────
function selectPlaybook(name) {
document.querySelectorAll('.playbook-card').forEach(c => c.classList.remove('selected'));
document.getElementById('customPlaybook').value = '';
document.querySelector(`.playbook-card[data-name="${name}"]`)?.classList.add('selected');
selectedPlaybook = name;
document.getElementById('selectedPlaybook').value = name;
updateSummary();
}
document.getElementById('customPlaybook').addEventListener('change', function() {
if (this.value) {
document.querySelectorAll('.playbook-card').forEach(c => c.classList.remove('selected'));
selectedPlaybook = this.value;
document.getElementById('selectedPlaybook').value = this.value;
updateSummary();
}
});
// ── Target mode ──────────────────────────────────────────────────────
function setTargetMode(mode) {
targetMode = mode;
['all','group','host'].forEach(m => {
document.getElementById(`panel-${m}`).classList.toggle('d-none', m !== mode);
document.getElementById(`pill-${m}`).classList.toggle('active', m === mode);
});
updateTargetCount();
}
function updateTargetCount() {
let count = 0, label = '';
if (targetMode === 'all') {
count = document.querySelectorAll('.host-cb').length;
label = count > 0 ? `All — ${count} host(s)` : 'No hosts in inventory';
} else if (targetMode === 'group') {
const grps = document.querySelectorAll('.group-cb:checked');
grps.forEach(cb => count += parseInt(cb.dataset.count || '0'));
label = grps.length > 0 ? `${grps.length} group(s) / ${count} host(s)` : 'None selected';
} else {
count = document.querySelectorAll('.host-cb:checked').length;
label = count > 0 ? `${count} host(s) selected` : 'None selected';
}
document.getElementById('targetCountBadge').textContent = label;
updateSummary();
}
function checkAllHosts(check) {
document.querySelectorAll('.host-cb').forEach(cb => cb.checked = check);
updateTargetCount();
}
function getTargetCount() {
if (targetMode === 'all') return document.querySelectorAll('.host-cb').length;
if (targetMode === 'group') {
let c = 0;
document.querySelectorAll('.group-cb:checked').forEach(cb => c += parseInt(cb.dataset.count || '0'));
return c;
}
return document.querySelectorAll('.host-cb:checked').length;
}
// Populate hidden <input name="hosts"> elements before submit
function buildHiddenInputs() {
const container = document.getElementById('hostsContainer');
container.innerHTML = '';
const add = v => {
const inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'hosts'; inp.value = v;
container.appendChild(inp);
};
if (targetMode === 'all') {
document.querySelectorAll('.host-cb').forEach(cb => add(cb.value));
if (container.children.length === 0) add('all'); // fallback if inventory empty
} else if (targetMode === 'group') {
document.querySelectorAll('.group-cb:checked').forEach(cb => add(cb.value));
} else {
document.querySelectorAll('.host-cb:checked').forEach(cb => add(cb.value));
}
}
// ── Summary bar ──────────────────────────────────────────────────────
function updateSummary() {
document.getElementById('summaryPlaybook').textContent = selectedPlaybook || 'None selected';
const count = getTargetCount();
const sd = document.getElementById('summaryDevices');
if (targetMode === 'all') {
sd.textContent = count > 0 ? `All hosts (${count})` : 'No hosts in inventory';
} else if (targetMode === 'group') {
const g = document.querySelectorAll('.group-cb:checked').length;
sd.textContent = g > 0 ? `${g} group(s), ~${count} host(s)` : 'None selected';
} else {
sd.textContent = count > 0 ? `${count} host(s) selected` : 'None selected';
}
const dur = document.getElementById('estimatedDuration');
if (selectedPlaybook && count > 0) {
if (selectedPlaybook === 'update_devices')
dur.textContent = `${Math.max(5, count * 2)}${count * 10} min`;
else if (selectedPlaybook === 'restart_service')
dur.textContent = `${count}${count * 2} min`;
else dur.textContent = 'Varies';
} else { dur.textContent = 'Unknown'; }
}
function addCommonVars() {
const el = document.getElementById('extraVars');
const common = {"timeout": 600, "reboot_timeout": 900, "become_user": "root"};
try {
let cur = el.value.trim() ? JSON.parse(el.value) : {};
Object.assign(cur, common);
el.value = JSON.stringify(cur, null, 2);
} catch { el.value = JSON.stringify(common, null, 2); }
}
// ── Form submission ──────────────────────────────────────────────────
document.getElementById('executeForm').addEventListener('submit', function(e) {
e.preventDefault();
if (!selectedPlaybook) { alert('Please select a playbook'); return; }
const count = getTargetCount();
if (count === 0) { alert('Please select at least one host or group'); return; }
const extraRaw = document.getElementById('extraVars').value.trim();
if (extraRaw) {
try { JSON.parse(extraRaw); }
catch { alert('Invalid JSON in Extra Variables'); return; }
}
const grpCount = document.querySelectorAll('.group-cb:checked').length;
const targetDesc = targetMode === 'all' ? `all hosts (${count})` :
targetMode === 'group' ? `${grpCount} group(s)` :
`${count} host(s)`;
if (!confirm(`Execute "${selectedPlaybook}" on ${targetDesc}?\nThis cannot be undone.`)) return;
buildHiddenInputs();
const btn = document.getElementById('executeButton');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Starting…';
resetLiveCard();
document.getElementById('liveCard').style.display = '';
fetch(this.action, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: new FormData(this),
})
.then(r => r.json())
.then(data => {
if (data.success) {
executionId = data.execution_id;
pollStartTime = Date.now();
const link = document.getElementById('liveDetailsLink');
link.href = `/ansible/executions/${executionId}`;
link.style.display = '';
startPolling();
} else {
setLiveError(data.error || 'Unknown error');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-play me-1"></i>Execute';
}
})
.catch(err => {
setLiveError('Network error: ' + err);
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-play me-1"></i>Execute';
});
});
// ── Live terminal ────────────────────────────────────────────────────
function resetLiveCard() {
document.getElementById('liveTerminal').textContent = '';
document.getElementById('liveStatusBadge').className = 'badge bg-secondary';
document.getElementById('liveStatusBadge').textContent = 'Starting…';
document.getElementById('liveStatusText').textContent = 'Initializing…';
document.getElementById('livePulseDot').className = 'pulse-dot';
document.getElementById('liveHostSummary').textContent = '';
document.getElementById('liveElapsed').textContent = '';
document.getElementById('liveDetailsLink').style.display = 'none';
document.getElementById('liveStopBtn').style.display = '';
}
function startPolling() {
pollTimer = setInterval(pollLive, 2000);
pollLive();
}
function stopPolling() {
clearInterval(pollTimer);
pollTimer = null;
document.getElementById('liveStopBtn').style.display = 'none';
document.getElementById('liveStatusText').textContent += ' (polling stopped)';
}
function pollLive() {
if (!executionId) return;
fetch(`/api/ansible/executions/${executionId}/live`)
.then(r => r.json())
.then(data => {
if (!data.success) { setLiveError(data.error); return; }
renderLiveData(data);
if (['completed','failed','cancelled','timeout'].includes(data.status)) {
stopPolling();
document.getElementById('liveStopBtn').style.display = 'none';
document.getElementById('executeButton').disabled = false;
document.getElementById('executeButton').innerHTML = '<i class="fas fa-play me-1"></i>Execute';
}
})
.catch(err => console.warn('Poll error:', err));
}
function renderLiveData(data) {
const badge = document.getElementById('liveStatusBadge');
const dot = document.getElementById('livePulseDot');
const colors = { running:'bg-primary', completed:'bg-success', failed:'bg-danger',
cancelled:'bg-warning', timeout:'bg-warning' };
badge.className = 'badge ' + (colors[data.status] || 'bg-secondary');
badge.textContent = data.status.charAt(0).toUpperCase() + data.status.slice(1);
dot.className = data.status === 'running' ? 'pulse-dot' :
data.status === 'completed' ? 'pulse-dot done' : 'pulse-dot error';
document.getElementById('liveStatusText').textContent =
data.summary_message || `Running ${data.playbook_name} on ${(data.target_hosts||[]).join(', ')}`;
if (pollStartTime) {
const sec = Math.round((Date.now() - pollStartTime) / 1000);
document.getElementById('liveElapsed').textContent = `${sec}s elapsed`;
}
if (data.status !== 'running') {
document.getElementById('liveHostSummary').innerHTML =
`${data.successful_hosts||0} ok &nbsp; ❌ ${data.failed_hosts||0} failed &nbsp; ⚠️ ${data.unreachable_hosts||0} unreachable`;
}
const terminal = document.getElementById('liveTerminal');
const colorised = (data.log || '')
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/(PLAY\s+\[.*?\])/g, '<span class="ansi-play">$1</span>')
.replace(/(TASK\s+\[.*?\])/g, '<span class="ansi-task">$1</span>')
.replace(/(ok:.*)/g, '<span class="ansi-ok">$1</span>')
.replace(/(changed:.*)/g, '<span class="ansi-changed">$1</span>')
.replace(/(fatal:.*)/g, '<span class="ansi-fail">$1</span>')
.replace(/(FAILED!.*)/g, '<span class="ansi-fail">$1</span>')
.replace(/(UNREACHABLE!.*)/g, '<span class="ansi-unreachable">$1</span>');
terminal.innerHTML = colorised;
if (autoScroll) terminal.scrollTop = terminal.scrollHeight;
}
function setLiveError(msg) {
document.getElementById('liveStatusBadge').className = 'badge bg-danger';
document.getElementById('liveStatusBadge').textContent = 'Error';
document.getElementById('livePulseDot').className = 'pulse-dot error';
document.getElementById('liveTerminal').textContent = 'Error: ' + msg;
}
function toggleAutoScroll() {
autoScroll = !autoScroll;
document.getElementById('autoScrollIcon').style.opacity = autoScroll ? '1' : '0.3';
}
// ── Initialize ───────────────────────────────────────────────────────
updateTargetCount();
updateSummary();
{% if preselect_playbook %}
selectPlaybook('{{ preselect_playbook }}');
{% endif %}
</script>
{% endblock %}

View File

@@ -0,0 +1,311 @@
{% extends "base.html" %}
{% block title %}Execution History - Server Monitoring{% endblock %}
{% block page_title %}Ansible Execution History{% endblock %}
{% block extra_css %}
<style>
.execution-card {
border: none;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-bottom: 1rem;
transition: all 0.3s ease;
}
.execution-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}
.status-running {
background: linear-gradient(45deg, #007bff, #0056b3);
}
.status-completed {
background: linear-gradient(45deg, #28a745, #1e7e34);
}
.status-failed {
background: linear-gradient(45deg, #dc3545, #bd2130);
}
.status-queued {
background: linear-gradient(45deg, #6c757d, #545b62);
}
.execution-details {
background-color: #f8f9fa;
border-radius: 8px;
padding: 1rem;
}
.host-result {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 15px;
font-size: 0.8rem;
margin: 0.1rem;
}
.host-success { background-color: #d4edda; color: #155724; }
.host-failed { background-color: #f8d7da; color: #721c24; }
.host-unreachable { background-color: #f8d7da; color: #721c24; }
.host-skipped { background-color: #fff3cd; color: #856404; }
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Page Header -->
<div class="row mb-4">
<div class="col-md-8">
<h4><i class="fas fa-history"></i> Ansible Execution History</h4>
<small class="text-muted">Track and monitor all playbook executions</small>
</div>
<div class="col-md-4 text-end">
<a href="{{ url_for('ansible_web.execute') }}" class="btn btn-primary">
<i class="fas fa-plus"></i> New Execution
</a>
<button class="btn btn-outline-secondary ms-2" onclick="refreshPage()">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
</div>
<!-- Execution Statistics -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h3 class="text-primary">{{ executions|length }}</h3>
<p class="mb-0">Total Executions</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h3 class="text-success">{{ executions|selectattr('status', 'equalto', 'completed')|list|length }}</h3>
<p class="mb-0">Completed</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h3 class="text-info">{{ executions|selectattr('status', 'equalto', 'running')|list|length }}</h3>
<p class="mb-0">Running</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h3 class="text-danger">{{ executions|selectattr('status', 'equalto', 'failed')|list|length }}</h3>
<p class="mb-0">Failed</p>
</div>
</div>
</div>
</div>
<!-- Execution List -->
<div class="row">
<div class="col-12">
{% if executions %}
{% for execution in executions %}
<div class="execution-card card">
<div class="card-header status-{{ execution.status }} text-white d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-0">
<i class="fas fa-play"></i> {{ execution.playbook_name }}
{% if execution.status == 'running' %}
<span class="spinner-border spinner-border-sm ms-2"></span>
{% endif %}
</h6>
<small>Execution ID: {{ execution.execution_id }}</small>
</div>
<div class="text-end">
<span class="badge bg-light text-dark">{{ execution.status|title }}</span>
{% if execution.priority and execution.priority != 5 %}
<span class="badge bg-warning text-dark">Priority: {{ execution.priority }}</span>
{% endif %}
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="execution-details">
<h6><i class="fas fa-info-circle"></i> Execution Details</h6>
<div class="row">
<div class="col-6">
<small class="text-muted">Started:</small><br>
{{ execution.started_at.strftime('%Y-%m-%d %H:%M') if execution.started_at else 'Queued' }}
</div>
<div class="col-6">
<small class="text-muted">Duration:</small><br>
{% if execution.duration %}
{{ execution.duration_formatted }}
{% elif execution.status == 'running' and execution.started_at %}
<span id="duration-{{ execution.id }}">Calculating...</span>
{% else %}
-
{% endif %}
</div>
</div>
<div class="row mt-2">
<div class="col-6">
<small class="text-muted">User:</small><br>
{{ execution.execution_user or 'System' }}
</div>
<div class="col-6">
<small class="text-muted">Target Hosts:</small><br>
{{ execution.total_hosts or 0 }}
</div>
</div>
</div>
</div>
<div class="col-md-6">
{% if execution.status in ['completed', 'failed'] %}
<div class="execution-details">
<h6><i class="fas fa-chart-bar"></i> Results Summary</h6>
<div class="mb-2">
{% if execution.successful_hosts > 0 %}
<span class="host-result host-success">✓ {{ execution.successful_hosts }} successful</span>
{% endif %}
{% if execution.failed_hosts > 0 %}
<span class="host-result host-failed">✗ {{ execution.failed_hosts }} failed</span>
{% endif %}
{% if execution.unreachable_hosts > 0 %}
<span class="host-result host-unreachable">⚠ {{ execution.unreachable_hosts }} unreachable</span>
{% endif %}
{% if execution.skipped_hosts > 0 %}
<span class="host-result host-skipped">⊝ {{ execution.skipped_hosts }} skipped</span>
{% endif %}
</div>
{% if execution.summary_message %}
<small class="text-muted">{{ execution.summary_message }}</small>
{% endif %}
</div>
{% elif execution.status == 'running' %}
<div class="execution-details">
<h6><i class="fas fa-spinner fa-spin"></i> In Progress</h6>
<div class="progress mb-2">
<div class="progress-bar progress-bar-striped progress-bar-animated"
style="width: 100%"></div>
</div>
<small class="text-muted">Execution is currently running...</small>
</div>
{% else %}
<div class="execution-details">
<h6><i class="fas fa-clock"></i> Queued</h6>
{% if execution.queue_position > 0 %}
<p class="mb-0">Position in queue: {{ execution.queue_position }}</p>
{% endif %}
<small class="text-muted">Waiting for execution...</small>
</div>
{% endif %}
</div>
</div>
<!-- Action Buttons -->
<div class="mt-3 text-end">
<a href="{{ url_for('ansible_web.execution_details', execution_id=execution.execution_id) }}"
class="btn btn-outline-primary btn-sm">
<i class="fas fa-eye"></i> View Details
</a>
{% if execution.status == 'running' %}
<button class="btn btn-outline-warning btn-sm ms-1"
onclick="cancelExecution('{{ execution.execution_id }}')">
<i class="fas fa-stop"></i> Cancel
</button>
{% elif execution.status == 'failed' and execution.retry_count < execution.max_retries %}
<button class="btn btn-outline-success btn-sm ms-1"
onclick="retryExecution('{{ execution.execution_id }}')">
<i class="fas fa-redo"></i> Retry
</button>
{% endif %}
{% if execution.ansible_log_file %}
<a href="{{ url_for('ansible_web.download_log', execution_id=execution.execution_id) }}"
class="btn btn-outline-secondary btn-sm ms-1">
<i class="fas fa-download"></i> Download Log
</a>
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="card">
<div class="card-body text-center">
<i class="fas fa-info-circle fa-3x text-muted mb-3"></i>
<h5>No Executions Found</h5>
<p class="text-muted">No playbook executions have been run yet.</p>
<a href="{{ url_for('ansible_web.execute') }}" class="btn btn-primary">
<i class="fas fa-play"></i> Run Your First Playbook
</a>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<script>
function refreshPage() {
location.reload();
}
function cancelExecution(executionId) {
if (confirm('Are you sure you want to cancel this execution?')) {
fetch(`/api/ansible/executions/${executionId}/cancel`, { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Failed to cancel execution: ' + data.error);
}
});
}
}
function retryExecution(executionId) {
if (confirm('Are you sure you want to retry this execution?')) {
fetch(`/api/ansible/executions/${executionId}/retry`, { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Execution queued for retry');
location.reload();
} else {
alert('Failed to retry execution: ' + data.error);
}
});
}
}
// Auto-refresh running executions every 30 seconds
setInterval(() => {
const runningExecutions = document.querySelectorAll('.status-running');
if (runningExecutions.length > 0) {
location.reload();
}
}, 30000);
// Update running durations
function updateRunningDurations() {
document.querySelectorAll('[id^="duration-"]').forEach(element => {
const executionId = element.id.replace('duration-', '');
// This would need to be implemented to calculate current duration
// For now, show a simple indicator
element.textContent = 'Running...';
});
}
setInterval(updateRunningDurations, 1000);
</script>
{% endblock %}

View File

@@ -0,0 +1,685 @@
{% extends "base.html" %}
{% block title %}Ansible Playbook Management - Server Monitoring{% endblock %}
{% block page_title %}Ansible Playbook Management{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.15/codemirror.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.15/theme/monokai.min.css">
<style>
.playbook-item {
cursor: pointer;
transition: all 0.2s;
}
.playbook-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.playbook-item.selected {
border-color: #007bff;
background-color: #f8f9ff;
}
.code-editor-area {
min-height: 400px;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
}
.CodeMirror {
height: 400px;
border-radius: 0.375rem;
}
.playbook-actions {
position: sticky;
top: 0;
z-index: 10;
background: white;
border-bottom: 1px solid #dee2e6;
padding: 1rem;
margin: -1rem -1rem 1rem -1rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Header Actions -->
<div class="row mb-4">
<div class="col-md-8">
<h4><i class="fas fa-book"></i> Ansible Playbook Management</h4>
<small class="text-muted">Create, edit, and manage your Ansible automation playbooks</small>
</div>
<div class="col-md-4 text-end">
<button class="btn btn-success me-2" onclick="createNewPlaybook()">
<i class="fas fa-plus"></i> New Playbook
</button>
<button class="btn btn-primary me-2" data-bs-toggle="modal" data-bs-target="#uploadPlaybookModal">
<i class="fas fa-upload"></i> Import File
</button>
<button class="btn btn-outline-secondary" onclick="refreshPlaybooks()">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
</div>
<!-- Main Layout -->
<div class="row">
<!-- Playbook List Sidebar -->
<div class="col-lg-4">
<!-- Playbook Statistics -->
<div class="row mb-3">
<div class="col-6">
<div class="card bg-primary text-white text-center">
<div class="card-body py-2">
<h6 class="mb-0">Custom</h6>
<h4 class="mb-0">{{ playbooks | length }}</h4>
</div>
</div>
</div>
<div class="col-6">
<div class="card bg-success text-white text-center">
<div class="card-body py-2">
<h6 class="mb-0">Built-in</h6>
<h4 class="mb-0">{{ builtin_playbooks | length }}</h4>
</div>
</div>
</div>
</div>
<!-- Built-in Playbooks Section -->
{% if builtin_playbooks %}
<div class="card mb-3">
<div class="card-header py-2">
<h6 class="mb-0"><i class="fas fa-star text-warning"></i> Built-in Playbooks</h6>
</div>
<div class="card-body p-2">
{% for playbook in builtin_playbooks %}
<div class="playbook-item card mb-2 border-success" onclick="loadBuiltinPlaybook('{{ playbook.name }}')">
<div class="card-body py-2">
<h6 class="card-title mb-1">{{ playbook.name }}</h6>
<p class="card-text small text-muted mb-1">{{ playbook.description }}</p>
<div class="d-flex justify-content-between align-items-center">
<span class="badge bg-success">Built-in</span>
<a href="{{ url_for('ansible_web.execute') }}?playbook={{ playbook.name }}"
class="btn btn-sm btn-outline-primary"
onclick="event.stopPropagation()">
<i class="fas fa-play me-1"></i>Execute
</a>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Custom Playbooks Section -->
<div class="card">
<div class="card-header py-2">
<h6 class="mb-0"><i class="fas fa-file-code"></i> Custom Playbooks</h6>
</div>
<div class="card-body p-2">
{% if playbooks %}
{% for playbook in playbooks %}
<div class="playbook-item card mb-2" onclick="loadPlaybook('{{ playbook.name }}', '{{ playbook.path }}')">
<div class="card-body py-2">
<h6 class="card-title mb-1">{{ playbook.name }}</h6>
<p class="card-text small text-muted mb-1">{{ playbook.filename }}</p>
<div class="d-flex justify-content-between align-items-center">
<span class="badge bg-info">Custom</span>
<div class="btn-group" role="group">
<a href="{{ url_for('ansible_web.execute') }}?playbook={{ playbook.name }}"
class="btn btn-sm btn-outline-primary"
onclick="event.stopPropagation()">
<i class="fas fa-play"></i>
</a>
<button class="btn btn-sm btn-outline-danger" onclick="event.stopPropagation(); deletePlaybook('{{ playbook.name }}')">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center py-3">
<i class="fas fa-folder-open fa-2x text-muted mb-2"></i>
<p class="text-muted mb-0">No custom playbooks</p>
<small class="text-muted">Create or import one to get started</small>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Code Editor Area -->
<div class="col-lg-8">
<div class="card h-100">
<div class="playbook-actions">
<div class="row align-items-center">
<div class="col">
<h6 class="mb-0" id="currentPlaybookTitle">Select a playbook to view/edit</h6>
<small class="text-muted" id="currentPlaybookInfo">Choose from the list on the left</small>
</div>
<div class="col-auto">
<div id="editorActions" style="display: none;">
<button class="btn btn-success btn-sm me-2" onclick="savePlaybook()">
<i class="fas fa-save"></i> Save
</button>
<button class="btn btn-outline-secondary btn-sm me-2" onclick="toggleEditMode()">
<i class="fas fa-edit"></i> <span id="editToggleText">Edit</span>
</button>
<button class="btn btn-primary btn-sm me-2" onclick="executeCurrentPlaybook()">
<i class="fas fa-play"></i> Execute
</button>
<button class="btn btn-outline-warning btn-sm" onclick="validatePlaybook()">
<i class="fas fa-check"></i> Validate
</button>
</div>
</div>
</div>
</div>
<div class="card-body p-0">
<!-- Welcome Message (shown when no playbook selected) -->
<div id="welcomeMessage" class="text-center p-5">
<i class="fas fa-book-open fa-4x text-muted mb-3"></i>
<h4 class="text-muted">Ansible Playbook Editor</h4>
<p class="text-muted mb-4">Select a playbook from the left panel to view or edit its content</p>
<div class="d-flex justify-content-center gap-2">
<button class="btn btn-primary" onclick="createNewPlaybook()">
<i class="fas fa-plus"></i> Create New Playbook
</button>
<button class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#uploadPlaybookModal">
<i class="fas fa-upload"></i> Import Playbook File
</button>
</div>
</div>
<!-- Code Editor -->
<div id="codeEditorContainer" style="display: none;">
<textarea id="playbookEditor"></textarea>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Upload Playbook Modal -->
<div class="modal fade" id="uploadPlaybookModal" tabindex="-1" aria-labelledby="uploadPlaybookModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="uploadPlaybookModalLabel">Upload Playbook</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{{ url_for('ansible_web.upload_playbook') }}" method="post" enctype="multipart/form-data">
<div class="modal-body">
<div class="mb-3">
<label for="playbookFile" class="form-label">Select Playbook File (.yml or .yaml)</label>
<input type="file" class="form-control" id="playbookFile" name="playbook_file"
accept=".yml,.yaml" required>
</div>
<div class="mb-3">
<label for="playbookName" class="form-label">Playbook Name (optional)</label>
<input type="text" class="form-control" id="playbookName" name="playbook_name"
placeholder="Leave empty to use filename">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Upload</button>
</div>
</form>
</div>
</div>
</div>
<!-- View Playbook Modal -->
<div class="modal fade" id="viewPlaybookModal" tabindex="-1" aria-labelledby="viewPlaybookModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="viewPlaybookModalLabel">Playbook Content</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<pre id="playbookContent" style="max-height: 400px; overflow-y: auto; background-color: #f8f9fa; padding: 15px; border-radius: 5px;"></pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Create/Edit Playbook Modal -->
<div class="modal fade" id="createPlaybookModal" tabindex="-1" aria-labelledby="createPlaybookModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="createPlaybookModalLabel">Create New Playbook</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="newPlaybookName" class="form-label">Playbook Name</label>
<input type="text" class="form-control" id="newPlaybookName" placeholder="my_playbook" required>
<div class="form-text">Name should be lowercase, use underscores instead of spaces</div>
</div>
<div class="mb-3">
<label for="newPlaybookDescription" class="form-label">Description (optional)</label>
<input type="text" class="form-control" id="newPlaybookDescription" placeholder="Brief description of what this playbook does">
</div>
<div class="mb-3">
<label class="form-label">Template</label>
<div>
<div class="form-check">
<input class="form-check-input" type="radio" name="playbookTemplate" id="templateBlank" value="blank" checked>
<label class="form-check-label" for="templateBlank">
Blank Playbook
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="playbookTemplate" id="templateBasic" value="basic">
<label class="form-check-label" for="templateBasic">
Basic System Update Template
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="playbookTemplate" id="templateService" value="service">
<label class="form-check-label" for="templateService">
Service Management Template
</label>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="createPlaybookFromModal()">Create Playbook</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.15/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.15/mode/yaml/yaml.min.js"></script>
<script>
let codeEditor = null;
let isEditMode = false;
let currentPlaybook = null;
let isNewPlaybook = false;
// Initialize CodeMirror when document is ready
document.addEventListener('DOMContentLoaded', function() {
initializeCodeEditor();
});
function initializeCodeEditor() {
codeEditor = CodeMirror.fromTextArea(document.getElementById('playbookEditor'), {
lineNumbers: true,
mode: 'yaml',
theme: 'monokai',
indentUnit: 2,
tabSize: 2,
indentWithTabs: false,
readOnly: true, // Start in read-only mode
lineWrapping: true
});
}
function createNewPlaybook() {
const modal = new bootstrap.Modal(document.getElementById('createPlaybookModal'));
modal.show();
}
function createPlaybookFromModal() {
const name = document.getElementById('newPlaybookName').value.trim();
const description = document.getElementById('newPlaybookDescription').value.trim();
const template = document.querySelector('input[name="playbookTemplate"]:checked').value;
if (!name) {
alert('Please enter a playbook name');
return;
}
// Generate playbook content based on template
let content = '';
switch(template) {
case 'basic':
content = '---\n' +
'- name: ' + (description || 'Basic System Update') + '\n' +
' hosts: monitoring_devices\n' +
' become: yes\n' +
' vars:\n' +
' update_cache: yes\n' +
' upgrade_packages: yes\n' +
' \n' +
' tasks:\n' +
' - name: Update package cache\n' +
' apt:\n' +
' update_cache: "\\{\\{ update_cache \\}\\}"\n' +
' when: ansible_os_family == "Debian"\n' +
' \n' +
' - name: Upgrade all packages\n' +
' apt:\n' +
' upgrade: dist\n' +
' when: upgrade_packages and ansible_os_family == "Debian"\n' +
' \n' +
' - name: Remove unnecessary packages\n' +
' apt:\n' +
' autoremove: yes\n' +
' when: ansible_os_family == "Debian"\n' +
' \n' +
' - name: Display completion message\n' +
' debug:\n' +
' msg: "System update completed successfully"';
break;
case 'service':
content = '---\n' +
'- name: ' + (description || 'Service Management') + '\n' +
' hosts: monitoring_devices\n' +
' become: yes\n' +
' vars:\n' +
' service_name: "your_service_here"\n' +
' service_action: "restarted" # started, stopped, restarted, reloaded\n' +
' \n' +
' tasks:\n' +
' - name: Manage service\n' +
' systemd:\n' +
' name: "\\{\\{ service_name \\}\\}"\n' +
' state: "\\{\\{ service_action \\}\\}"\n' +
' enabled: yes\n' +
' register: service_result\n' +
' \n' +
' - name: Display service status\n' +
' debug:\n' +
' msg: "Service \\{\\{ service_name \\}\\} is \\{\\{ service_result.status.ActiveState \\}\\}"';
break;
default:
content = '---\n' +
'- name: ' + (description || name) + '\n' +
' hosts: monitoring_devices\n' +
' become: yes\n' +
' \n' +
' tasks:\n' +
' - name: Your task here\n' +
' debug:\n' +
' msg: "Hello from ' + name + ' playbook!"';
}
// Close modal
bootstrap.Modal.getInstance(document.getElementById('createPlaybookModal')).hide();
// Load into editor as new playbook
currentPlaybook = {name: name, isNew: true};
isNewPlaybook = true;
loadPlaybookIntoEditor(name, content, true);
}
function loadPlaybook(name, path) {
// Clear selection
clearPlaybookSelection();
// Mark as selected
event.currentTarget.classList.add('selected');
fetch(`/ansible/playbook/content?path=${encodeURIComponent(path)}`)
.then(response => response.text())
.then(content => {
currentPlaybook = {name: name, path: path};
isNewPlaybook = false;
loadPlaybookIntoEditor(name, content, false);
})
.catch(error => {
alert('Error loading playbook: ' + error);
});
}
function loadBuiltinPlaybook(name) {
// Clear selection
clearPlaybookSelection();
// Mark as selected
event.currentTarget.classList.add('selected');
// Generate built-in playbook content
let content = generateBuiltinPlaybookContent(name);
currentPlaybook = {name: name, builtin: true};
isNewPlaybook = false;
loadPlaybookIntoEditor(name, content, false);
}
function loadPlaybookIntoEditor(name, content, editMode = false) {
// Update UI
document.getElementById('welcomeMessage').style.display = 'none';
document.getElementById('codeEditorContainer').style.display = 'block';
document.getElementById('editorActions').style.display = 'block';
// Update title
document.getElementById('currentPlaybookTitle').textContent = name;
document.getElementById('currentPlaybookInfo').textContent =
isNewPlaybook ? 'New playbook - Remember to save' :
(currentPlaybook.builtin ? 'Built-in playbook (read-only)' : 'Custom playbook');
// Set content
codeEditor.setValue(content);
// Set edit mode
isEditMode = editMode || isNewPlaybook;
updateEditMode();
// Refresh editor
setTimeout(() => codeEditor.refresh(), 100);
}
function generateBuiltinPlaybookContent(name) {
switch(name) {
case 'update_devices':
return `---
- name: Update all monitoring devices
hosts: monitoring_devices
become: yes
tasks:
- name: Update package cache
apt:
update_cache: yes
when: ansible_os_family == "Debian"
- name: Upgrade packages
apt:
upgrade: dist
when: ansible_os_family == "Debian"
- name: Remove unnecessary packages
apt:
autoremove: yes
when: ansible_os_family == "Debian"`;
case 'restart_service':
return `---
- name: Restart monitoring services
hosts: monitoring_devices
become: yes
tasks:
- name: Restart monitoring service
systemd:
name: prezenta_monitor
state: restarted
ignore_errors: yes`;
case 'system_health':
return `---
- name: Check system health
hosts: monitoring_devices
become: yes
tasks:
- name: Check disk usage
command: df -h
register: disk_usage
- name: Check memory usage
command: free -m
register: memory_usage
- name: Display disk usage
debug:
var: disk_usage.stdout_lines
- name: Display memory usage
debug:
var: memory_usage.stdout_lines`;
default:
return `---
- name: ${name}
hosts: monitoring_devices
become: yes
tasks:
- name: Default task
debug:
msg: "Built-in playbook: ${name}"`;
}
}
function clearPlaybookSelection() {
document.querySelectorAll('.playbook-item').forEach(item => {
item.classList.remove('selected');
});
}
function toggleEditMode() {
if (currentPlaybook && currentPlaybook.builtin) {
alert('Built-in playbooks cannot be edited. Create a copy if you need to modify it.');
return;
}
isEditMode = !isEditMode;
updateEditMode();
}
function updateEditMode() {
codeEditor.setOption('readOnly', !isEditMode);
document.getElementById('editToggleText').textContent = isEditMode ? 'View' : 'Edit';
// Update editor theme
codeEditor.setOption('theme', isEditMode ? 'default' : 'monokai');
}
function savePlaybook() {
if (!currentPlaybook) {
alert('No playbook loaded');
return;
}
if (currentPlaybook.builtin) {
alert('Built-in playbooks cannot be modified');
return;
}
const content = codeEditor.getValue();
const data = {
name: currentPlaybook.name,
content: content,
is_new: isNewPlaybook
};
fetch('/ansible/playbook/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.success) {
alert('Playbook saved successfully!');
isNewPlaybook = false;
document.getElementById('currentPlaybookInfo').textContent = 'Custom playbook';
// Optionally refresh the page to update the playbook list
setTimeout(() => location.reload(), 1000);
} else {
alert('Error saving playbook: ' + result.error);
}
})
.catch(error => {
alert('Error saving playbook: ' + error);
});
}
function executeCurrentPlaybook() {
if (!currentPlaybook) {
alert('No playbook selected');
return;
}
window.location.href = `{{ url_for('ansible_web.execute') }}?playbook=${encodeURIComponent(currentPlaybook.name)}`;
}
function validatePlaybook() {
if (!codeEditor) {
alert('No playbook loaded');
return;
}
const content = codeEditor.getValue();
fetch('/ansible/playbook/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({content: content})
})
.then(response => response.json())
.then(result => {
if (result.valid) {
alert('Playbook is valid! ✅');
} else {
alert('Playbook validation failed: ' + result.error);
}
})
.catch(error => {
alert('Error validating playbook: ' + error);
});
}
function refreshPlaybooks() {
location.reload();
}
function deletePlaybook(playbookName) {
if (confirm(`Are you sure you want to delete the playbook "${playbookName}"?`)) {
fetch(`/ansible/playbook/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({playbook_name: playbookName})
})
.then(response => response.json())
.then(result => {
if (result.success) {
alert('Playbook deleted successfully!');
location.reload();
} else {
alert('Error deleting playbook: ' + result.error);
}
})
.catch(error => {
alert('Error deleting playbook: ' + error);
});
}
}
</script>
{% endblock %}
</script>

View File

@@ -0,0 +1,113 @@
{% extends "base.html" %}
{% block title %}SSH Setup - Server Monitoring{% endblock %}
{% block page_title %}SSH Setup{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="row g-4">
<!-- SSH Key Management -->
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="fas fa-key me-2"></i>SSH Key Pair</h5>
</div>
<div class="card-body">
{% if key_exists %}
<div class="alert alert-success">
<i class="fas fa-check-circle me-2"></i>SSH key exists at
<code>~/.ssh/ansible_key</code>
</div>
{% if public_key %}
<div class="mb-3">
<label class="form-label fw-semibold">Public Key</label>
<textarea class="form-control font-monospace" rows="4" readonly>{{ public_key }}</textarea>
<small class="text-muted">Copy this key to <code>~/.ssh/authorized_keys</code> on each device.</small>
</div>
{% endif %}
{% else %}
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>No SSH key found. Generate one below.
</div>
{% endif %}
<form method="post" action="{{ url_for('ansible_web.generate_ssh_keys') }}">
<button type="submit" class="btn btn-primary">
<i class="fas fa-sync me-1"></i>
{{ 'Regenerate' if key_exists else 'Generate' }} SSH Keys
</button>
</form>
</div>
</div>
</div>
<!-- SSH Settings -->
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header bg-secondary text-white">
<h5 class="mb-0"><i class="fas fa-lock me-2"></i>SSH Authentication Settings</h5>
</div>
<div class="card-body">
<p class="text-muted small">
When key-based authentication fails, the server falls back to password auth.
Set the default password for devices on this network below.
</p>
<form method="post" action="{{ url_for('ansible_web.save_ssh_settings') }}">
<div class="mb-3">
<label class="form-label fw-semibold">SSH Fallback Password</label>
<div class="input-group">
<input type="password" name="ssh_fallback_password" id="sshFallbackPassword"
class="form-control"
value="{{ settings.get('ssh_fallback_password', '') }}"
placeholder="Enter fallback password"
required>
<button class="btn btn-outline-secondary" type="button"
onclick="togglePassword()">
<i class="fas fa-eye" id="toggleIcon"></i>
</button>
</div>
<small class="text-muted">
Used when SSH key auth is not available on the target device.
</small>
</div>
<button type="submit" class="btn btn-success">
<i class="fas fa-save me-1"></i>Save Settings
</button>
</form>
</div>
</div>
</div>
</div><!-- /row -->
</div><!-- /container -->
<script>
function togglePassword() {
const input = document.getElementById('sshFallbackPassword');
const icon = document.getElementById('toggleIcon');
if (input.type === 'password') {
input.type = 'text';
icon.classList.replace('fa-eye', 'fa-eye-slash');
} else {
input.type = 'password';
icon.classList.replace('fa-eye-slash', 'fa-eye');
}
}
</script>
{% endblock %}

433
templates/base.html Normal file
View File

@@ -0,0 +1,433 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Server Monitoring Dashboard{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
body {
background-color: #f8f9fa;
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
}
/* Sidebar Styles */
.sidebar {
position: fixed;
top: 0;
left: 0;
width: 250px;
height: 100vh;
background: linear-gradient(135deg, #2c3e50, #3498db);
color: white;
padding: 20px 0;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
overflow-y: auto;
z-index: 1000;
}
.sidebar .logo {
text-align: center;
padding: 20px 15px 30px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
margin-bottom: 20px;
}
.sidebar .logo h3 {
margin: 0;
font-weight: bold;
color: #fff;
}
.sidebar .logo small {
color: #bdc3c7;
}
.sidebar .nav-menu {
list-style: none;
padding: 0 10px;
margin: 0;
}
.sidebar .nav-item {
margin-bottom: 5px;
}
.sidebar .nav-link {
display: flex;
align-items: center;
padding: 12px 15px;
color: #ecf0f1;
text-decoration: none;
border-radius: 8px;
transition: all 0.3s ease;
font-size: 14px;
}
.sidebar .nav-link:hover {
background-color: rgba(255, 255, 255, 0.1);
color: #fff;
transform: translateX(5px);
}
.sidebar .nav-link.active {
background-color: rgba(255, 255, 255, 0.2);
color: #fff;
font-weight: 500;
}
.sidebar .nav-link i {
width: 20px;
margin-right: 10px;
font-size: 16px;
}
.sidebar .nav-section {
margin: 30px 15px 10px;
font-size: 12px;
text-transform: uppercase;
color: #bdc3c7;
font-weight: 600;
letter-spacing: 0.5px;
}
.sidebar .nav-link.admin-link {
color: #ff6b6b;
margin-top: 8px;
border-top: 1px solid rgba(255,255,255,0.1);
padding-top: 14px;
}
.sidebar .nav-link.admin-link:hover {
background-color: rgba(220,53,69,0.25);
color: #ff6b6b;
}
.sidebar .nav-link.admin-link.active {
background-color: rgba(220,53,69,0.35);
color: #ff6b6b;
}
/* Main Content Styles */
.main-content {
margin-left: 250px;
padding: 20px;
min-height: 100vh;
}
.content-header {
background-color: #fff;
padding: 20px 25px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
}
.content-header h1 {
margin: 0;
color: #2c3e50;
font-size: 28px;
font-weight: 600;
}
.content-header .breadcrumb {
background: none;
padding: 0;
margin: 8px 0 0 0;
}
.content-header .breadcrumb-item a {
color: #3498db;
text-decoration: none;
}
.content-body {
background-color: #fff;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 25px;
}
/* Responsive Design */
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
transition: transform 0.3s ease;
}
.sidebar.mobile-open {
transform: translateX(0);
}
.main-content {
margin-left: 0;
}
.mobile-menu-toggle {
display: block !important;
position: fixed;
top: 15px;
left: 15px;
z-index: 1001;
background: #3498db;
color: white;
border: none;
border-radius: 5px;
padding: 8px 12px;
cursor: pointer;
}
}
.mobile-menu-toggle {
display: none;
}
/* Additional Styles */
.table-container {
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
padding: 20px;
margin-bottom: 20px;
}
.card {
border: none;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
}
.card:hover {
transform: translateY(-2px);
}
.btn-primary {
background-color: #3498db;
border-color: #3498db;
}
.btn-primary:hover {
background-color: #2980b9;
border-color: #2980b9;
}
/* Flash Messages */
.alert {
border-radius: 8px;
border: none;
margin-bottom: 20px;
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
<!-- Mobile Menu Toggle -->
<button class="mobile-menu-toggle" onclick="toggleSidebar()">
<i class="fas fa-bars"></i>
</button>
<!-- Sidebar Navigation -->
<nav class="sidebar" id="sidebar">
<div class="logo">
<h3><i class="fas fa-server"></i> Monitor</h3>
<small>Server Monitoring v2.0</small>
</div>
<ul class="nav-menu">
<div class="nav-section">Device Management</div>
<li class="nav-item">
<a href="{{ url_for('main.devices') }}" class="nav-link {% if request.endpoint in ['main.devices','main.device_edit','main.device_detail'] %}active{% endif %}">
<i class="fas fa-desktop"></i>
Devices
</a>
</li>
<div class="nav-section">WMT</div>
<li class="nav-item">
<a href="{{ url_for('wmt_web.index') }}" class="nav-link {% if request.endpoint == 'wmt_web.index' %}active{% endif %}">
<i class="fas fa-sliders-h"></i>
WMT Dashboard
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('wmt_web.settings') }}" class="nav-link {% if request.endpoint == 'wmt_web.settings' %}active{% endif %}">
<i class="fas fa-cog"></i>
WMT Settings
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('wmt_web.update_requests') }}" class="nav-link {% if request.endpoint == 'wmt_web.update_requests' %}active{% endif %}">
<i class="fas fa-inbox"></i>
Update Requests
{% if pending_wmt_count > 0 %}<span class="badge bg-danger ms-1">{{ pending_wmt_count }}</span>{% endif %}
</a>
</li>
<div class="nav-section">Monitoring</div>
<li class="nav-item">
<a href="{{ url_for('main.logs') }}" class="nav-link {% if request.endpoint == 'main.logs' %}active{% endif %}">
<i class="fas fa-list-alt"></i>
Logs
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('main.templates') }}" class="nav-link {% if request.endpoint == 'main.templates' %}active{% endif %}">
<i class="fas fa-file-alt"></i>
Templates
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('main.stats') }}" class="nav-link {% if request.endpoint == 'main.stats' %}active{% endif %}">
<i class="fas fa-chart-bar"></i>
Statistics
</a>
</li>
<div class="nav-section">Automation</div>
<li class="nav-item">
<a href="{{ url_for('ansible_web.devices') }}" class="nav-link {% if request.endpoint == 'ansible_web.devices' %}active{% endif %}">
<i class="fas fa-network-wired"></i>
Remote Devices
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('ansible_web.playbooks') }}" class="nav-link {% if request.endpoint == 'ansible_web.playbooks' %}active{% endif %}">
<i class="fas fa-play"></i>
Playbooks
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('ansible_web.execute') }}" class="nav-link {% if request.endpoint == 'ansible_web.execute' %}active{% endif %}">
<i class="fas fa-terminal"></i>
Execute
</a>
</li>
<div class="nav-section">Server</div>
<li class="nav-item">
<a href="{{ url_for('main.logs') }}" class="nav-link {% if request.endpoint == 'main.logs' %}active{% endif %}">
<i class="fas fa-stream"></i>
Live Logs
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" onclick="confirmAction('Refresh Data', 'Are you sure you want to refresh all data?')">
<i class="fas fa-sync-alt"></i>
Refresh Data
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" onclick="showInfo()">
<i class="fas fa-info-circle"></i>
System Info
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('main.admin') }}" class="nav-link admin-link {% if request.endpoint == 'main.admin' %}active{% endif %}">
<i class="fas fa-shield-alt"></i>
Admin
</a>
</li>
</ul>
</nav>
<!-- Main Content Area -->
<div class="main-content">
<!-- Content Header -->
<div class="content-header">
<h1>{% block page_title %}Dashboard{% endblock %}</h1>
{% if breadcrumbs %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
{% for crumb in breadcrumbs %}
<li class="breadcrumb-item {% if loop.last %}active{% endif %}">
{% if not loop.last %}
<a href="{{ crumb.url }}">{{ crumb.title }}</a>
{% else %}
{{ crumb.title }}
{% endif %}
</li>
{% endfor %}
</ol>
</nav>
{% endif %}
</div>
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="alert alert-{% if category == 'error' %}danger{% else %}{{ category }}{% endif %} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- Main Content Body -->
<div class="content-body">
{% block content %}{% endblock %}
</div>
</div>
<!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Mobile sidebar toggle
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
sidebar.classList.toggle('mobile-open');
}
// Close sidebar when clicking outside on mobile
document.addEventListener('click', function(event) {
const sidebar = document.getElementById('sidebar');
const toggle = document.querySelector('.mobile-menu-toggle');
if (window.innerWidth <= 768 &&
!sidebar.contains(event.target) &&
!toggle.contains(event.target)) {
sidebar.classList.remove('mobile-open');
}
});
// Utility functions
function confirmAction(title, message) {
if (confirm(message)) {
// Add refresh logic here
window.location.reload();
}
}
function showInfo() {
alert('Server Monitoring System v2.0\nDeveloped for enhanced device monitoring\nFeatures: Device management, Log monitoring, Ansible automation');
}
// Auto-refresh functionality (optional)
let autoRefreshInterval;
function startAutoRefresh(seconds = 30) {
autoRefreshInterval = setInterval(() => {
window.location.reload();
}, seconds * 1000);
}
function stopAutoRefresh() {
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
}
}
// Initialize based on page
document.addEventListener('DOMContentLoaded', function() {
// Add any page-specific initialization here
console.log('Server Monitoring Dashboard loaded');
});
</script>
{% block extra_js %}{% endblock %}
</body>
</html>

334
templates/dashboard.html Normal file
View File

@@ -0,0 +1,334 @@
{% extends "base.html" %}
{% block title %}Dashboard - Server Monitoring{% endblock %}
{% block page_title %}Dashboard{% endblock %}
{% block extra_css %}
<style>
.stats-row {
margin-bottom: 30px;
}
.stat-card {
height: 120px;
display: flex;
align-items: center;
padding: 20px;
border-left: 4px solid #3498db;
transition: transform 0.2s ease;
}
.stat-card:hover {
transform: translateY(-2px);
}
.stat-card.devices {
border-left-color: #2ecc71;
}
.stat-card.logs {
border-left-color: #e74c3c;
}
.stat-card.templates {
border-left-color: #f39c12;
}
.stat-card.active {
border-left-color: #9b59b6;
}
.stat-icon {
font-size: 2.5rem;
margin-right: 20px;
}
.stat-details h3 {
margin: 0;
font-size: 2rem;
font-weight: bold;
}
.stat-details p {
margin: 0;
color: #7f8c8d;
font-size: 0.9rem;
}
.refresh-timer {
text-align: center;
margin-bottom: 20px;
font-size: 1.1rem;
color: #2c3e50;
font-weight: 500;
}
.action-buttons {
text-align: center;
margin-bottom: 30px;
}
.action-buttons .btn {
margin: 5px;
}
.recent-logs-section {
margin-top: 30px;
}
.activity-section {
margin-top: 30px;
}
</style>
{% endblock %}
{% block content %}
<!-- Auto-refresh Timer -->
<div class="refresh-timer">
<i class="fas fa-clock"></i>
Time until refresh: <span id="refresh-timer">30</span> seconds
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<a href="{{ url_for('main.devices') }}" class="btn btn-primary">
<i class="fas fa-desktop"></i> Manage Devices
</a>
<a href="{{ url_for('main.logs') }}" class="btn btn-secondary">
<i class="fas fa-list-alt"></i> View All Logs
</a>
<a href="{{ url_for('main.stats') }}" class="btn btn-info">
<i class="fas fa-chart-bar"></i> System Stats
</a>
<a href="{{ url_for('ansible_web.index') }}" class="btn btn-success">
<i class="fas fa-cogs"></i> Automation
</a>
<button class="btn btn-danger" onclick="resetDatabase(event)" title="Clear all logs and reset database">
<i class="fas fa-trash-alt"></i> Clear Database
</button>
</div>
<!-- Activity in Last 24 Hours -->
{% if activity_stats %}
<div class="activity-section">
<h4><i class="fas fa-clock"></i> Last 24 Hours Activity</h4>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body text-center">
<h5 class="card-title">{{ activity_stats.logs_24h|default(0) }}</h5>
<p class="card-text text-muted">New Log Entries</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body text-center">
<h5 class="card-title">{{ activity_stats.devices_seen_24h|default(0) }}</h5>
<p class="card-text text-muted">Devices Active</p>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Recent Logs Section -->
<div class="recent-logs-section">
<h4><i class="fas fa-list-alt"></i> Recent Activity</h4>
<div class="table-container">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th width="20%">Device</th>
<th width="15%">IP Address</th>
<th width="15%">Location</th>
<th width="20%">Timestamp</th>
<th width="30%">Event Description</th>
</tr>
</thead>
<tbody>
{% if recent_logs %}
{% for log in recent_logs %}
<tr>
<td>
<a href="{{ url_for('main.device_detail', device_id=log.device_id) }}" class="text-decoration-none">
<strong>{{ log.device.hostname }}</strong>
</a>
</td>
<td>{{ log.device.device_ip }}</td>
<td>
{% if log.device.nume_masa %}
<span class="badge bg-info">{{ log.device.nume_masa }}</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<small>{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') if log.timestamp else 'N/A' }}</small>
</td>
<td>
<span class="badge bg-{% if log.severity == 'error' %}danger{% elif log.severity == 'warning' %}warning{% elif log.severity == 'info' %}primary{% else %}secondary{% endif %}">
{{ log.severity|default('info') }}
</span>
{{ log.resolved_message[:100] }}{% if log.resolved_message|length > 100 %}...{% endif %}
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="5" class="text-center text-muted py-4">
<i class="fas fa-inbox fa-2x"></i><br>
No recent logs available
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{% if recent_logs|length >= 50 %}
<div class="text-center mt-3">
<a href="{{ url_for('main.logs') }}" class="btn btn-outline-primary">
<i class="fas fa-arrow-right"></i> View All Logs
</a>
</div>
{% endif %}
</div>
<!-- Compression Statistics -->
{% if compression_stats %}
<div class="activity-section">
<h4><i class="fas fa-compress-arrows-alt"></i> Compression Statistics</h4>
<div class="row">
<div class="col-md-4">
<div class="card">
<div class="card-body text-center">
<h5 class="card-title">{{ compression_stats.get('average_ratio', 0) | round(1) }}%</h5>
<p class="card-text text-muted">Compression Ratio</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body text-center">
<h5 class="card-title">{{ compression_stats.get('total_saved_bytes', 0) | filesizeformat }}</h5>
<p class="card-text text-muted">Space Saved</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body text-center">
<h5 class="card-title">{{ compression_stats.get('template_count', 0) }}</h5>
<p class="card-text text-muted">Active Templates</p>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
// Countdown timer for refresh
let countdown = 30; // 30 seconds
function updateTimer() {
const timerElement = document.getElementById('refresh-timer');
if (timerElement) {
timerElement.innerText = countdown;
}
countdown--;
if (countdown < 0) {
location.reload(); // Refresh the page
}
}
// Start the timer immediately
setInterval(updateTimer, 1000); // Update every second
// Database reset functionality
async function resetDatabase(event) {
try {
// First confirmation
const confirmed = confirm(
'⚠️ WARNING: Database Reset Operation ⚠️\n\n' +
'This will permanently delete ALL logs and device history!\n\n' +
'This action cannot be undone!\n\n' +
'Are you sure you want to proceed?'
);
if (!confirmed) {
return;
}
// Second confirmation for safety
const doubleConfirmed = confirm(
'🚨 FINAL CONFIRMATION 🚨\n\n' +
'You are about to permanently DELETE all data!\n\n' +
'This is your LAST CHANCE to cancel!\n\n' +
'Click OK to proceed with deletion.'
);
if (!doubleConfirmed) {
return;
}
// Show loading indicator
const button = event.target;
const originalText = button.innerHTML;
button.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span> Clearing Database...';
button.disabled = true;
// Send reset request (this would need to be implemented on the backend)
try {
const response = await fetch('/api/reset-database', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
alert('✅ Database cleared successfully!');
location.reload(); // Refresh to show empty database
} else {
throw new Error('Server error: ' + response.statusText);
}
} catch (fetchError) {
alert('❌ Error clearing database: ' + fetchError.message);
}
} catch (error) {
alert('❌ Network Error: ' + error.message);
} finally {
// Restore button
if (event.target) {
event.target.innerHTML = '<i class="fas fa-trash-alt"></i> Clear Database';
event.target.disabled = false;
}
}
}
// Initialize page
document.addEventListener('DOMContentLoaded', function() {
console.log('Dashboard loaded successfully');
// Add hover effects to stat cards
const statCards = document.querySelectorAll('.stat-card');
statCards.forEach(card => {
card.addEventListener('mouseenter', function() {
this.style.boxShadow = '0 4px 20px rgba(0, 0, 0, 0.15)';
});
card.addEventListener('mouseleave', function() {
this.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.1)';
});
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,263 @@
{% extends "base.html" %}
{% block title %}Dashboard - Server Monitoring{% endblock %}
{% block page_title %}Dashboard{% endblock %}
{% block extra_css %}
<style>
.table-container {
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
padding: 20px;
}
.table {
margin-bottom: 0;
table-layout: fixed; /* Ensures consistent column widths */
width: 100%; /* Makes the table take up the full container width */
}
.table th, .table td {
text-align: center;
word-wrap: break-word; /* Ensures long text wraps within the cell */
}
.table th:nth-child(1), .table td:nth-child(1) {
width: 20%; /* Hostname column */
}
.table th:nth-child(2), .table td:nth-child(2) {
width: 20%; /* Device IP column */
}
.table th:nth-child(3), .table td:nth-child(3) {
width: 20%; /* Nume Masa column */
}
.table th:nth-child(4), .table td:nth-child(4) {
width: 20%; /* Timestamp column */
}
.table th:nth-child(5), .table td:nth-child(5) {
width: 20%; /* Event Description column */
}
.refresh-timer {
text-align: center;
margin-bottom: 10px;
font-size: 1.2rem;
color: #343a40;
}
</style>
<script>
// Countdown timer for refresh
let countdown = 30; // 30 seconds
function updateTimer() {
document.getElementById('refresh-timer').innerText = countdown;
countdown--;
if (countdown < 0) {
location.reload(); // Refresh the page
}
}
setInterval(updateTimer, 1000); // Update every second
// Database reset functionality
async function resetDatabase(event) {
try {
// First, get database statistics
const statsResponse = await fetch('/database_stats');
const stats = await statsResponse.json();
if (!stats.success) {
alert('❌ Error getting database statistics:\n' + stats.error);
return;
}
const totalLogs = stats.total_logs;
const uniqueDevices = stats.unique_devices;
if (totalLogs <= 1) { // Only reset log exists
alert(' Database is already empty!\nNo user logs to delete.');
return;
}
// Show confirmation dialog with detailed statistics
const confirmed = confirm(
`⚠️ WARNING: Database Reset Operation ⚠️\n\n` +
`This will permanently delete:\n` +
`${totalLogs} log entries\n` +
`• Data from ${uniqueDevices} unique devices\n` +
`• Date range: ${stats.earliest_log || 'N/A'} to ${stats.latest_log || 'N/A'}\n\n` +
`⚠️ ALL DEVICE HISTORY WILL BE LOST ⚠️\n\n` +
`This action cannot be undone!\n\n` +
`Are you absolutely sure you want to proceed?`
);
if (!confirmed) {
return;
}
// Second confirmation for safety
const doubleConfirmed = confirm(
`🚨 FINAL CONFIRMATION 🚨\n\n` +
`You are about to permanently DELETE:\n` +
`${totalLogs} log entries\n` +
`${uniqueDevices} device histories\n\n` +
`This is your LAST CHANCE to cancel!\n\n` +
`Click OK to proceed with deletion.`
);
if (!doubleConfirmed) {
return;
}
// Show loading indicator
const button = event ? event.target : document.querySelector('button[onclick*="resetDatabase"]');
const originalText = button ? button.innerHTML : '';
if (button) {
button.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span> Clearing Database...';
button.disabled = true;
}
// Send reset request
const resetResponse = await fetch('/reset_database', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const result = await resetResponse.json();
if (result.success) {
alert(
`✅ Database Reset Completed Successfully!\n\n` +
`Operation Summary:\n` +
`${result.deleted_count} log entries deleted\n` +
`• Database schema reinitialized\n` +
`• Reset timestamp: ${result.timestamp}\n\n` +
`The dashboard will refresh to show the clean database.`
);
location.reload(); // Refresh to show empty database
} else {
alert('❌ Database Reset Failed:\n' + result.error);
if (button) {
button.innerHTML = originalText;
button.disabled = false;
}
}
} catch (error) {
alert('❌ Network Error:\n' + error.message);
// Restore button if it was changed
try {
const button = event ? event.target : document.querySelector('button[onclick*="resetDatabase"]');
if (button) {
button.innerHTML = '<i class="fas fa-trash-alt"></i> Clear Database';
</script>
</style>
{% endblock %}
{% block extra_js %}
<script>
// Countdown timer for refresh
let countdown = 30; // 30 seconds
function updateTimer() {
const timerElement = document.getElementById('refresh-timer');
if (timerElement) {
timerElement.innerText = countdown;
}
countdown--;
if (countdown < 0) {
location.reload(); // Refresh the page
}
}
setInterval(updateTimer, 1000); // Update every second
// Database reset functionality
async function resetDatabase(event) {
try {
// First, get database statistics
const statsResponse = await fetch('/database_stats');
const stats = await statsResponse.json();
if (!stats.success) {
alert(' Error getting database statistics:\n' + stats.error);
return;
}
const totalLogs = stats.total_logs;
const uniqueDevices = stats.unique_devices;
if (totalLogs <= 1) { // Only reset log exists
alert(' Database is already empty!\nNo user logs to delete.');
return;
}
// Show confirmation dialog with detailed statistics
const confirmed = confirm(
` WARNING: Database Reset Operation \n\n` +
`This will permanently delete:\n` +
` ${totalLogs} log entries\n` +
` Data from ${uniqueDevices} unique devices\n` +
` Date range: ${stats.earliest_log || 'N/A'} to ${stats.latest_log || 'N/A'}\n\n` +
` ALL DEVICE HISTORY WILL BE LOST \n\n` +
`This action cannot be undone!\n\n` +
`Are you absolutely sure you want to proceed?`
);
if (!confirmed) {
return;
}
// Second confirmation for safety
const doubleConfirmed = confirm(
`🚨 FINAL CONFIRMATION 🚨\n\n` +
`You are about to permanently DELETE:\n` +
` ${totalLogs} log entries\n` +
` ${uniqueDevices} device histories\n\n` +
`This is your LAST CHANCE to cancel!\n\n` +
`Click OK to proceed with deletion.`
);
if (!doubleConfirmed) {
return;
}
// Show loading indicator
const button = event ? event.target : document.querySelector('button[onclick*="resetDatabase"]');
const originalText = button ? button.innerHTML : '';
if (button) {
button.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span> Clearing Database...';
button.disabled = true;
}
// Send reset request
const resetResponse = await fetch('/reset_database', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const result = await resetResponse.json();
if (result.success) {
alert(
` Database Reset Completed Successfully!\n\n` +
`Operation Summary:\n` +
` ${result.deleted_count} log entries deleted\n` +
` Database schema reinitialized\n` +
` Reset timestamp: ${result.timestamp}\n\n` +
`The dashboard will refresh to show the clean database.`
);
location.reload(); // Refresh to show empty database
} else {
alert(' Database Reset Failed:\n' + result.error);
if (button) {
button.innerHTML = originalText;
button.disabled = false;
}
}
} catch (error) {
alert(' Network Error:\n' + error.message);
// Restore button if it was changed
try {
const button = event ? event.target : document.querySelector('button[onclick*="resetDatabase"]');
if (button) {
button.innerHTML = '<i class="fas fa-trash-alt"></i> Clear Database';

View File

@@ -0,0 +1,230 @@
{% extends "base.html" %}
{% block title %}{{ device.hostname }} {{ app_name }}{% endblock %}
{% block page_title %}Device: {{ device.hostname }}{% endblock %}
{% block extra_css %}
<style>
.severity-debug { color: #6c757d; }
.severity-info { color: #0dcaf0; }
.severity-warning { color: #ffc107; }
.severity-error { color: #dc3545; }
.severity-critical { color: #b02a37; font-weight: 600; }
.log-row td { font-size: 0.83rem; padding: 0.35rem 0.6rem; vertical-align: middle; }
.info-label { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; color: #6c757d; }
</style>
{% endblock %}
{% block content %}
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('main.devices') }}">Devices</a></li>
<li class="breadcrumb-item active">{{ device.hostname }}</li>
</ol>
</nav>
<!-- Header card -->
<div class="card mb-4">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
<div>
<h4 class="mb-1">
{{ device.nume_masa or device.hostname }}
{% if device.status == 'active' %}
<span class="badge bg-success ms-2">Active</span>
{% elif device.status == 'maintenance' %}
<span class="badge bg-warning text-dark ms-2">Maintenance</span>
{% else %}
<span class="badge bg-danger ms-2">Offline</span>
{% endif %}
{% if device.mac_address %}
<span class="badge bg-info ms-1" title="WMT Client">WMT</span>
{% endif %}
</h4>
<div class="text-muted">{{ device.hostname }} &bull; <code>{{ device.device_ip }}</code></div>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('main.device_edit', device_id=device.id) }}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-edit me-1"></i>Edit
</a>
<a href="{{ url_for('main.logs') }}?device_id={{ device.id }}" class="btn btn-outline-info btn-sm">
<i class="fas fa-list me-1"></i>All Logs
</a>
<a href="{{ url_for('main.devices') }}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-arrow-left me-1"></i>Back
</a>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-4">
<!-- Device info -->
<div class="col-lg-5">
<div class="card h-100">
<div class="card-header"><i class="fas fa-info-circle me-2"></i>Device Info</div>
<div class="card-body">
<table class="table table-sm table-borderless mb-0">
<tr>
<td class="info-label">Hostname</td>
<td>{{ device.hostname }}</td>
</tr>
<tr>
<td class="info-label">IP Address</td>
<td><code>{{ device.device_ip }}</code></td>
</tr>
<tr>
<td class="info-label">Device Name</td>
<td>{{ device.nume_masa or '—' }}</td>
</tr>
<tr>
<td class="info-label">Type</td>
<td>{{ device.device_type or '—' }}</td>
</tr>
<tr>
<td class="info-label">OS</td>
<td>{{ device.os_version or '—' }}</td>
</tr>
<tr>
<td class="info-label">Location</td>
<td>{{ device.location or '—' }}</td>
</tr>
{% if device.description %}
<tr>
<td class="info-label">Description</td>
<td>{{ device.description }}</td>
</tr>
{% endif %}
<tr>
<td class="info-label">Last Seen</td>
<td>{{ device.last_seen.strftime('%Y-%m-%d %H:%M:%S') if device.last_seen else '—' }}</td>
</tr>
</table>
</div>
</div>
</div>
<!-- Stats + WMT -->
<div class="col-lg-7">
<div class="row g-3 h-100">
<!-- Log stats -->
<div class="col-sm-4">
<div class="card text-center h-100">
<div class="card-body py-3">
<h3 class="mb-0">{{ log_stats.total }}</h3>
<small class="text-muted">Total Logs</small>
</div>
</div>
</div>
<div class="col-sm-4">
<div class="card text-center h-100">
<div class="card-body py-3">
<h3 class="mb-0 text-info">{{ log_stats.last_24h }}</h3>
<small class="text-muted">Last 24 h</small>
</div>
</div>
</div>
<div class="col-sm-4">
<div class="card text-center h-100">
<div class="card-body py-3">
<h3 class="mb-0 text-danger">{{ log_stats.by_severity.get('error', 0) + log_stats.by_severity.get('critical', 0) }}</h3>
<small class="text-muted">Errors</small>
</div>
</div>
</div>
{% if device.mac_address %}
<!-- WMT info -->
<div class="col-12">
<div class="card border-info">
<div class="card-header text-info"><i class="fas fa-sliders-h me-2"></i>WMT Client Info</div>
<div class="card-body">
<table class="table table-sm table-borderless mb-0">
<tr>
<td class="info-label">MAC Address</td>
<td><code>{{ device.mac_address }}</code></td>
</tr>
<tr>
<td class="info-label">Config Updated</td>
<td>{{ device.config_updated_at.strftime('%Y-%m-%d %H:%M:%S') if device.config_updated_at else 'Never' }}</td>
</tr>
<tr>
<td class="info-label">Info Reviewed</td>
<td>
{% if device.info_reviewed_at and device.info_reviewed_at.year > 1970 %}
<span class="text-success">{{ device.info_reviewed_at.strftime('%Y-%m-%d %H:%M:%S') }}</span>
{% else %}
<span class="text-muted">Never reviewed</span>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
</div>
{% endif %}
<!-- Severity breakdown -->
{% if log_stats.by_severity %}
<div class="col-12">
<div class="card">
<div class="card-header"><i class="fas fa-chart-bar me-2"></i>Severity Breakdown</div>
<div class="card-body py-2">
<div class="d-flex flex-wrap gap-2">
{% for sev, cnt in log_stats.by_severity.items() %}
<span class="badge bg-secondary">{{ sev }}: {{ cnt }}</span>
{% endfor %}
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Recent logs -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-list-alt me-2"></i>Recent Logs <small class="text-muted">(last 100)</small></span>
<a href="{{ url_for('main.logs') }}?device_id={{ device.id }}" class="btn btn-sm btn-outline-secondary">
View All
</a>
</div>
<div class="card-body p-0">
{% if logs %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr class="log-row">
<th>Timestamp</th>
<th>Severity</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr class="log-row">
<td class="text-nowrap text-muted">{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') if log.timestamp else '—' }}</td>
<td>
<span class="severity-{{ log.severity }}">
<i class="fas fa-circle fa-xs me-1"></i>{{ log.severity }}
</span>
</td>
<td>{{ log.resolved_message or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted text-center py-5 mb-0">
<i class="fas fa-inbox fa-2x d-block mb-2"></i>No logs for this device yet.
</p>
{% endif %}
</div>
</div>
{% endblock %}

109
templates/device_edit.html Normal file
View File

@@ -0,0 +1,109 @@
{% extends "base.html" %}
{% block title %}Edit {{ device.hostname }} {{ app_name }}{% endblock %}
{% block page_title %}Edit Device{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<i class="fas fa-edit me-2"></i>Edit: <strong>{{ device.hostname }}</strong>
</div>
<div class="card-body">
<form method="post">
<h6 class="text-muted mb-3">Monitoring Fields</h6>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label fw-semibold">Hostname <span class="text-danger">*</span></label>
<input type="text" name="hostname" class="form-control" required
value="{{ device.hostname }}">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">IP Address <span class="text-danger">*</span></label>
<input type="text" name="device_ip" class="form-control" required
value="{{ device.device_ip }}">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Device Name / Masa <span class="text-danger">*</span></label>
<input type="text" name="nume_masa" class="form-control" required
value="{{ device.nume_masa or '' }}">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Status</label>
<select name="status" class="form-select">
<option value="active" {% if device.status == 'active' %}selected{% endif %}>Active</option>
<option value="inactive" {% if device.status == 'inactive' %}selected{% endif %}>Inactive</option>
<option value="maintenance" {% if device.status == 'maintenance' %}selected{% endif %}>Maintenance</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Device Type</label>
<input type="text" name="device_type" class="form-control"
value="{{ device.device_type or '' }}" placeholder="Raspberry Pi, PC, Server…">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Physical Location</label>
<input type="text" name="location" class="form-control"
value="{{ device.location or '' }}" placeholder="Floor 2, Room 201">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">OS Version</label>
<input type="text" name="os_version" class="form-control"
value="{{ device.os_version or '' }}" placeholder="Raspberry Pi OS 11">
</div>
<div class="col-12">
<label class="form-label fw-semibold">Description</label>
<textarea name="description" class="form-control" rows="2">{{ device.description or '' }}</textarea>
</div>
</div>
<hr>
<h6 class="text-muted mb-3">WMT Client Fields</h6>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label fw-semibold">MAC Address
<small class="text-muted fw-normal">(WMT client identifier)</small>
</label>
<input type="text" name="mac_address" class="form-control"
value="{{ device.mac_address or '' }}"
placeholder="b8:27:eb:aa:bb:cc"
pattern="^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$">
<div class="form-text">Leave empty if this is not a WMT client device.</div>
</div>
</div>
{% if device.mac_address %}
<div class="alert alert-light border small mb-4">
<strong>Config last updated:</strong>
{{ device.config_updated_at.strftime('%Y-%m-%d %H:%M:%S') if device.config_updated_at else '—' }}<br>
<strong>Info reviewed at:</strong>
<span class="{% if device.info_reviewed_at and device.info_reviewed_at.year > 1970 %}text-success{% else %}text-muted{% endif %}">
{{ device.info_reviewed_at.strftime('%Y-%m-%d %H:%M:%S') if (device.info_reviewed_at and device.info_reviewed_at.year > 1970) else 'Never reviewed' }}
</span><br>
<strong>Last seen:</strong>
{{ device.last_seen.strftime('%Y-%m-%d %H:%M:%S') if device.last_seen else 'Never' }}
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-1"></i>Save Changes
</button>
<a href="{{ url_for('main.devices') }}" class="btn btn-outline-secondary">Cancel</a>
<form method="post" action="{{ url_for('main.device_delete', device_id=device.id) }}"
class="ms-auto"
onsubmit="return confirm('Delete {{ device.hostname }}? All logs will also be deleted.')">
<button type="submit" class="btn btn-outline-danger">
<i class="fas fa-trash me-1"></i>Delete Device
</button>
</form>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Logs for Device: {{ nume_masa }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<style>
body {
background-color: #f8f9fa;
font-family: Arial, sans-serif;
}
h1 {
text-align: center;
color: #343a40;
}
.table-container {
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
padding: 20px;
}
.table {
margin-bottom: 0;
table-layout: fixed; /* Ensures consistent column widths */
width: 100%; /* Makes the table take up the full container width */
}
.table th, .table td {
text-align: center;
word-wrap: break-word; /* Ensures long text wraps within the cell */
}
.table th:nth-child(1), .table td:nth-child(1) {
width: 20%; /* Device ID column */
}
.table th:nth-child(2), .table td:nth-child(2) {
width: 20%; /* Nume Masa column */
}
.table th:nth-child(3), .table td:nth-child(3) {
width: 30%; /* Timestamp column */
}
.table th:nth-child(4), .table td:nth-child(4) {
width: 30%; /* Event Description column */
}
.back-button {
margin-bottom: 20px;
text-align: center;
}
</style>
</head>
<body>
<div class="container mt-5">
<h1 class="mb-4">Logs for Device: {{ nume_masa }}</h1>
<div class="back-button">
<a href="/dashboard" class="btn btn-primary">Back to Dashboard</a>
</div>
<div class="table-container">
<table class="table table-striped table-bordered">
<thead class="table-dark">
<tr>
<th>Device ID</th>
<th>Nume Masa</th>
<th>Timestamp</th>
<th>Event Description</th>
</tr>
</thead>
<tbody>
{% if logs %}
{% for log in logs %}
<tr>
<td>{{ log[0] }}</td>
<td>{{ log[1] }}</td>
<td>{{ log[2] }}</td>
<td>{{ log[3] }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4">No logs found for this device.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<footer>
<p class="text-center mt-4">&copy; 2023 Device Logs Dashboard. All rights reserved.</p>
</footer>
</body>
</html>

View File

@@ -0,0 +1,278 @@
{% extends "base.html" %}
{% block title %}Devices {{ app_name }}{% endblock %}
{% block page_title %}Devices{% endblock %}
{% block extra_css %}
<style>
.mac-badge { font-size: 0.75rem; letter-spacing: 0.03em; }
.sync-ok { color: #2ecc71; }
.sync-old { color: #e74c3c; }
.tbl-sm td, .tbl-sm th { padding: 0.45rem 0.6rem; font-size: 0.88rem; vertical-align: middle; }
</style>
{% endblock %}
{% block content %}
<!-- Stats row -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-2">
<div class="card text-center h-100">
<div class="card-body py-3">
<h4 class="mb-0 text-success">{{ devices|selectattr('status','equalto','active')|list|length }}</h4>
<small class="text-muted">Active</small>
</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="card text-center h-100">
<div class="card-body py-3">
<h4 class="mb-0 text-danger">{{ devices|selectattr('status','equalto','inactive')|list|length }}</h4>
<small class="text-muted">Offline</small>
</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="card text-center h-100">
<div class="card-body py-3">
<h4 class="mb-0 text-warning">{{ devices|selectattr('status','equalto','maintenance')|list|length }}</h4>
<small class="text-muted">Maintenance</small>
</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="card text-center h-100">
<div class="card-body py-3">
<h4 class="mb-0 text-primary">{{ devices|length }}</h4>
<small class="text-muted">Total</small>
</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="card text-center h-100">
<div class="card-body py-3">
<h4 class="mb-0 text-info">{{ devices|selectattr('mac_address')|list|length }}</h4>
<small class="text-muted">WMT Clients</small>
</div>
</div>
</div>
<div class="col-6 col-md-2">
<a href="{{ url_for('wmt_web.update_requests', status='pending') }}" class="card text-center h-100 text-decoration-none">
<div class="card-body py-3">
<h4 class="mb-0 {% if pending_count > 0 %}text-danger{% else %}text-secondary{% endif %}">{{ pending_count }}</h4>
<small class="text-muted">Pending Requests</small>
</div>
</a>
</div>
</div>
<!-- Toolbar -->
<div class="d-flex flex-wrap gap-2 mb-3 align-items-center">
<input type="text" id="deviceSearch" class="form-control" style="max-width:340px"
placeholder="Search hostname, IP, name, MAC…" oninput="filterTable()">
<button class="btn btn-primary ms-auto" data-bs-toggle="modal" data-bs-target="#addDeviceModal">
<i class="fas fa-plus me-1"></i>Add Device
</button>
<button class="btn btn-outline-secondary" onclick="location.reload()">
<i class="fas fa-sync-alt me-1"></i>Refresh
</button>
</div>
<!-- Device table -->
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover tbl-sm mb-0" id="deviceTable">
<thead class="table-light">
<tr>
<th>Device Name</th>
<th>Hostname</th>
<th>IP</th>
<th>MAC Address</th>
<th>Status</th>
<th>Logs</th>
<th>Last Seen</th>
<th>Config Sync</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for device in devices %}
<tr class="device-row"
data-search="{{ device.hostname|lower }} {{ device.device_ip }} {{ (device.nume_masa or '')|lower }} {{ (device.mac_address or '')|lower }}">
<td>
<strong>{{ device.nume_masa or '—' }}</strong>
</td>
<td>{{ device.hostname }}</td>
<td><code>{{ device.device_ip }}</code></td>
<td>
{% if device.mac_address %}
<code class="mac-badge">{{ device.mac_address }}</code>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
{% if device.status == 'active' %}
<span class="badge bg-success">Active</span>
{% elif device.status == 'maintenance' %}
<span class="badge bg-warning text-dark">Maintenance</span>
{% else %}
<span class="badge bg-danger">Offline</span>
{% endif %}
</td>
<td>{{ device_log_counts.get(device.id, 0) }}</td>
<td class="text-muted">
{% if device.last_seen %}
{{ device.last_seen.strftime('%Y-%m-%d %H:%M') }}
{% else %}—{% endif %}
</td>
<td>
{% if device.mac_address and device.config_updated_at %}
<span class="sync-ok" title="{{ device.config_updated_at.strftime('%Y-%m-%d %H:%M:%S') }}">
<i class="fas fa-check-circle"></i>
{{ device.config_updated_at.strftime('%m-%d %H:%M') }}
</span>
{% elif device.mac_address %}
<span class="sync-old"><i class="fas fa-clock"></i> Never</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="text-end text-nowrap">
<a href="{{ url_for('main.device_detail', device_id=device.id) }}"
class="btn btn-sm btn-outline-primary py-0" title="View Details">
<i class="fas fa-eye"></i>
</a>
<a href="{{ url_for('main.logs', device_id=device.id) }}"
class="btn btn-sm btn-outline-info py-0" title="View Logs">
<i class="fas fa-list"></i>
</a>
<a href="{{ url_for('main.device_edit', device_id=device.id) }}"
class="btn btn-sm btn-outline-secondary py-0" title="Edit">
<i class="fas fa-edit"></i>
</a>
<form method="post" action="{{ url_for('main.device_delete', device_id=device.id) }}"
class="d-inline"
onsubmit="return confirm('Delete device {{ device.hostname }}? This also removes all its logs.')">
<button type="submit" class="btn btn-sm btn-outline-danger py-0" title="Delete">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="9" class="text-center text-muted py-5">
<i class="fas fa-desktop fa-2x mb-2 d-block"></i>
No devices registered yet.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Add Device Modal -->
<div class="modal fade" id="addDeviceModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-plus-circle me-2"></i>Add Device</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="addDeviceForm" onsubmit="submitAddDevice(event)">
<div class="modal-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Hostname <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="hostname" required placeholder="RPI-Masa-01">
</div>
<div class="col-md-6">
<label class="form-label">IP Address <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="device_ip" required placeholder="192.168.1.100">
</div>
<div class="col-md-6">
<label class="form-label">Device Name / Masa <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="nume_masa" required placeholder="Masa-01">
</div>
<div class="col-md-6">
<label class="form-label">MAC Address
<small class="text-muted">(WMT clients only)</small>
</label>
<input type="text" class="form-control" name="mac_address"
placeholder="b8:27:eb:aa:bb:cc"
pattern="^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$">
</div>
<div class="col-md-6">
<label class="form-label">Device Type</label>
<select class="form-select" name="device_type">
<option value="Raspberry Pi">Raspberry Pi</option>
<option value="PC">PC/Workstation</option>
<option value="Server">Server</option>
<option value="unknown" selected>Unknown</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Status</label>
<select class="form-select" name="status">
<option value="active" selected>Active</option>
<option value="inactive">Inactive</option>
<option value="maintenance">Maintenance</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Physical Location</label>
<input type="text" class="form-control" name="location" placeholder="Floor 2, Room 201">
</div>
<div class="col-md-6">
<label class="form-label">OS Version</label>
<input type="text" class="form-control" name="os_version" placeholder="Raspberry Pi OS 11">
</div>
<div class="col-12">
<label class="form-label">Description</label>
<textarea class="form-control" name="description" rows="2"></textarea>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary"><i class="fas fa-save me-1"></i>Add Device</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function filterTable() {
const q = document.getElementById('deviceSearch').value.toLowerCase();
document.querySelectorAll('.device-row').forEach(row => {
row.style.display = row.dataset.search.includes(q) ? '' : 'none';
});
}
async function submitAddDevice(event) {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.target).entries());
const btn = event.target.querySelector('[type=submit]');
btn.disabled = true;
try {
const resp = await fetch('/api/devices/add', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
const result = await resp.json();
if (resp.ok) { location.reload(); }
else { alert('Error: ' + (result.message || 'Unknown error')); }
} catch(e) { alert('Error: ' + e.message); }
finally { btn.disabled = false; }
}
</script>
{% endblock %}

View File

@@ -0,0 +1,507 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Device Management</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
body {
background-color: #f8f9fa;
font-family: Arial, sans-serif;
}
h1 {
text-align: center;
color: #343a40;
}
.card {
margin-bottom: 20px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.device-card {
border-left: 4px solid #007bff;
}
.status-online {
color: #28a745;
}
.status-offline {
color: #dc3545;
}
.command-buttons {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 15px;
}
.back-button {
margin-bottom: 20px;
text-align: center;
}
.search-container {
margin-bottom: 20px;
}
.search-input {
max-width: 400px;
margin: 0 auto;
}
.loading {
display: none;
}
.result-container {
margin-top: 15px;
padding: 10px;
border-radius: 5px;
display: none;
}
.result-success {
background-color: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}
.result-error {
background-color: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}
</style>
</head>
<body>
<div class="container mt-5">
<h1 class="mb-4">Device Management</h1>
<div class="back-button">
<a href="/dashboard" class="btn btn-primary">Back to Dashboard</a>
<a href="/unique_devices" class="btn btn-secondary">View Unique Devices</a>
<a href="/server_logs" class="btn btn-info" title="View server operations and system logs">
<i class="fas fa-server"></i> Server Logs
</a>
</div>
<!-- Search Filter -->
<div class="search-container">
<div class="search-input">
<input type="text" id="searchInput" class="form-control" placeholder="Search devices by hostname or IP...">
</div>
</div>
<!-- Bulk Operations -->
<div class="card">
<div class="card-header">
<h5>Bulk Operations</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<label for="bulkCommand" class="form-label">Select Command:</label>
<select class="form-select" id="bulkCommand">
<option value="">Select a command...</option>
<option value="sudo apt update">Update Package Lists</option>
<option value="sudo apt upgrade -y">Upgrade Packages</option>
<option value="sudo apt update && sudo apt upgrade -y">Update and Upgrade Device</option>
<option value="sudo apt autoremove -y">Remove Unused Packages</option>
<option value="df -h">Check Disk Space</option>
<option value="free -m">Check Memory Usage</option>
<option value="uptime">Check Uptime</option>
<option value="sudo systemctl restart networking">Restart Networking</option>
<option value="sudo reboot">Reboot Device</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">&nbsp;</label>
<div>
<button class="btn btn-warning" onclick="executeOnAllDevices()">Execute on All Devices</button>
<button class="btn btn-info" onclick="executeOnSelectedDevices()">Execute on Selected</button>
<button class="btn btn-danger" onclick="autoUpdateAllDevices()" title="Auto-update all devices to latest app.py version">
Auto Update All
</button>
<button class="btn btn-dark" onclick="autoUpdateSelectedDevices()" title="Auto-update selected devices">
Auto Update Selected
</button>
</div>
</div>
</div>
<div class="result-container" id="bulkResult"></div>
</div>
</div>
<!-- Device List -->
<div id="deviceContainer">
{% for device in devices %}
<div class="card device-card" data-hostname="{{ device[0] }}" data-ip="{{ device[1] }}">
<div class="card-header">
<div class="row align-items-center">
<div class="col-md-6">
<h6 class="mb-0">
<input type="checkbox" class="device-checkbox me-2" value="{{ device[1] }}">
<strong>{{ device[0] }}</strong> ({{ device[1] }})
</h6>
</div>
<div class="col-md-3">
<small class="text-muted">Last seen: {{ device[2] }}</small>
</div>
<div class="col-md-3 text-end">
<span class="badge bg-secondary status" id="status-{{ device[1] }}">Checking...</span>
<button class="btn btn-sm btn-outline-info" onclick="checkDeviceStatus('{{ device[1] }}')">
Refresh Status
</button>
</div>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-8">
<select class="form-select command-select" id="command-{{ device[1] }}">
<option value="">Select a command...</option>
<option value="sudo apt update">Update Package Lists</option>
<option value="sudo apt upgrade -y">Upgrade Packages</option>
<option value="sudo apt update && sudo apt upgrade -y">Update and Upgrade Device</option>
<option value="sudo apt autoremove -y">Remove Unused Packages</option>
<option value="df -h">Check Disk Space</option>
<option value="free -m">Check Memory Usage</option>
<option value="uptime">Check Uptime</option>
<option value="sudo systemctl restart networking">Restart Networking</option>
<option value="sudo reboot">Reboot Device</option>
</select>
</div>
<div class="col-md-4">
<button class="btn btn-success" onclick="executeCommand('{{ device[1] }}')">
Execute Command
</button>
<button class="btn btn-warning" onclick="autoUpdateDevice('{{ device[1] }}')" title="Auto-update app.py to latest version">
Auto Update
</button>
</div>
</div>
<div class="result-container" id="result-{{ device[1] }}"></div>
<div class="loading" id="loading-{{ device[1] }}">
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
Executing command...
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Search functionality
document.getElementById('searchInput').addEventListener('keyup', function() {
const filter = this.value.toLowerCase();
const devices = document.querySelectorAll('.device-card');
devices.forEach(device => {
const hostname = device.dataset.hostname.toLowerCase();
const ip = device.dataset.ip.toLowerCase();
if (hostname.includes(filter) || ip.includes(filter)) {
device.style.display = '';
} else {
device.style.display = 'none';
}
});
});
// Check device status
async function checkDeviceStatus(deviceIp) {
const statusElement = document.getElementById(`status-${deviceIp}`);
statusElement.textContent = 'Checking...';
statusElement.className = 'badge bg-secondary';
try {
const response = await fetch(`/device_status/${deviceIp}`);
const result = await response.json();
if (result.success) {
statusElement.textContent = 'Online';
statusElement.className = 'badge bg-success';
} else {
statusElement.textContent = 'Offline';
statusElement.className = 'badge bg-danger';
}
} catch (error) {
statusElement.textContent = 'Error';
statusElement.className = 'badge bg-danger';
}
}
// Execute command on single device
async function executeCommand(deviceIp) {
const commandSelect = document.getElementById(`command-${deviceIp}`);
const command = commandSelect.value;
if (!command) {
alert('Please select a command first');
return;
}
const loadingElement = document.getElementById(`loading-${deviceIp}`);
const resultElement = document.getElementById(`result-${deviceIp}`);
loadingElement.style.display = 'block';
resultElement.style.display = 'none';
try {
const response = await fetch('/execute_command', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
device_ip: deviceIp,
command: command
})
});
const result = await response.json();
loadingElement.style.display = 'none';
resultElement.style.display = 'block';
if (result.success) {
resultElement.className = 'result-container result-success';
resultElement.innerHTML = `
<strong>Success:</strong> ${result.result.message}<br>
<small><strong>Output:</strong><br><pre>${result.result.output}</pre></small>
`;
} else {
resultElement.className = 'result-container result-error';
resultElement.innerHTML = `<strong>Error:</strong> ${result.error}`;
}
} catch (error) {
loadingElement.style.display = 'none';
resultElement.style.display = 'block';
resultElement.className = 'result-container result-error';
resultElement.innerHTML = `<strong>Network Error:</strong> ${error.message}`;
}
}
// Execute command on all devices
async function executeOnAllDevices() {
const command = document.getElementById('bulkCommand').value;
if (!command) {
alert('Please select a command first');
return;
}
const deviceIps = Array.from(document.querySelectorAll('.device-card')).map(card => card.dataset.ip);
await executeBulkCommand(deviceIps, command);
}
// Execute command on selected devices
async function executeOnSelectedDevices() {
const command = document.getElementById('bulkCommand').value;
if (!command) {
alert('Please select a command first');
return;
}
const selectedIps = Array.from(document.querySelectorAll('.device-checkbox:checked')).map(cb => cb.value);
if (selectedIps.length === 0) {
alert('Please select at least one device');
return;
}
await executeBulkCommand(selectedIps, command);
}
// Execute bulk command
async function executeBulkCommand(deviceIps, command) {
const resultElement = document.getElementById('bulkResult');
resultElement.style.display = 'block';
resultElement.className = 'result-container';
resultElement.innerHTML = '<div class="spinner-border spinner-border-sm" role="status"></div> Executing commands...';
try {
const response = await fetch('/execute_command_bulk', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
device_ips: deviceIps,
command: command
})
});
const result = await response.json();
let html = '<h6>Bulk Execution Results:</h6>';
let successCount = 0;
for (const [ip, deviceResult] of Object.entries(result.results)) {
if (deviceResult.success) {
successCount++;
html += `<div class="alert alert-success alert-sm">✓ ${ip}: ${deviceResult.result.message}</div>`;
} else {
html += `<div class="alert alert-danger alert-sm">✗ ${ip}: ${deviceResult.error}</div>`;
}
}
html += `<div class="mt-2"><strong>Summary:</strong> ${successCount}/${deviceIps.length} devices succeeded</div>`;
resultElement.className = 'result-container result-success';
resultElement.innerHTML = html;
} catch (error) {
resultElement.className = 'result-container result-error';
resultElement.innerHTML = `<strong>Network Error:</strong> ${error.message}`;
}
}
// Auto-update functionality
async function autoUpdateDevice(deviceIp) {
const resultElement = document.getElementById(`result-${deviceIp}`);
const loadingElement = document.getElementById(`loading-${deviceIp}`);
try {
// Show loading
loadingElement.style.display = 'block';
resultElement.className = 'result-container';
resultElement.innerHTML = '';
const response = await fetch('/auto_update_devices', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
device_ips: [deviceIp]
})
});
const result = await response.json();
loadingElement.style.display = 'none';
if (result.results && result.results.length > 0) {
const deviceResult = result.results[0];
if (deviceResult.success) {
if (deviceResult.status === 'no_update_needed') {
resultElement.className = 'result-container result-success';
resultElement.innerHTML = `<strong>No Update Needed:</strong> Device is already running version ${deviceResult.new_version || 'latest'}`;
} else {
resultElement.className = 'result-container result-success';
resultElement.innerHTML = `<strong>Update Success:</strong> ${deviceResult.message}<br>
<small>Updated from v${deviceResult.old_version} to v${deviceResult.new_version}</small><br>
<small class="text-warning">Device is restarting...</small>`;
}
} else {
resultElement.className = 'result-container result-error';
resultElement.innerHTML = `<strong>Update Failed:</strong> ${deviceResult.error}`;
}
} else {
resultElement.className = 'result-container result-error';
resultElement.innerHTML = '<strong>Error:</strong> No response from server';
}
} catch (error) {
loadingElement.style.display = 'none';
resultElement.className = 'result-container result-error';
resultElement.innerHTML = `<strong>Network Error:</strong> ${error.message}`;
}
}
async function autoUpdateAllDevices() {
if (!confirm('Are you sure you want to auto-update ALL devices? This will restart all devices.')) {
return;
}
await performBulkAutoUpdate('all');
}
async function autoUpdateSelectedDevices() {
const selectedDevices = Array.from(document.querySelectorAll('.device-checkbox:checked'))
.map(cb => cb.value);
if (selectedDevices.length === 0) {
alert('Please select at least one device');
return;
}
if (!confirm(`Are you sure you want to auto-update ${selectedDevices.length} selected device(s)? This will restart the selected devices.`)) {
return;
}
await performBulkAutoUpdate('selected');
}
async function performBulkAutoUpdate(mode) {
const resultElement = document.getElementById('bulkResult');
try {
// Determine which devices to update
let deviceIps;
if (mode === 'all') {
deviceIps = Array.from(document.querySelectorAll('.device-card'))
.map(card => card.dataset.ip);
} else {
deviceIps = Array.from(document.querySelectorAll('.device-checkbox:checked'))
.map(cb => cb.value);
}
// Show loading state
resultElement.className = 'result-container';
resultElement.innerHTML = `<div class="alert alert-info">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
Auto-updating ${deviceIps.length} device(s)... This may take several minutes.
</div>`;
const response = await fetch('/auto_update_devices', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
device_ips: deviceIps
})
});
const result = await response.json();
let html = '<h6>Auto-Update Results:</h6>';
let successCount = 0;
for (const deviceResult of result.results) {
if (deviceResult.success) {
successCount++;
if (deviceResult.status === 'no_update_needed') {
html += `<div class="alert alert-info alert-sm"> ${deviceResult.device_ip}: Already up to date</div>`;
} else {
html += `<div class="alert alert-success alert-sm">✓ ${deviceResult.device_ip}: ${deviceResult.message}</div>`;
}
} else {
html += `<div class="alert alert-danger alert-sm">✗ ${deviceResult.device_ip}: ${deviceResult.error}</div>`;
}
}
html += `<div class="mt-2"><strong>Summary:</strong> ${successCount}/${deviceIps.length} devices updated successfully</div>`;
if (successCount > 0) {
html += `<div class="alert alert-warning mt-2"><small>Note: Updated devices are restarting and may be temporarily unavailable.</small></div>`;
}
resultElement.className = 'result-container result-success';
resultElement.innerHTML = html;
} catch (error) {
resultElement.className = 'result-container result-error';
resultElement.innerHTML = `<strong>Network Error:</strong> ${error.message}`;
}
}
// Check status of all devices on page load
document.addEventListener('DOMContentLoaded', function() {
const devices = document.querySelectorAll('.device-card');
devices.forEach(device => {
const ip = device.dataset.ip;
checkDeviceStatus(ip);
});
});
</script>
</body>
</html>

0
templates/devices.html Normal file
View File

23
templates/errors/400.html Normal file
View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<title>400 - Bad Request</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background-color: #f5f5f5; }
.error-container { max-width: 500px; margin: 0 auto; background: white; padding: 40px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #f39c12; font-size: 72px; margin: 0; }
h2 { color: #333; margin: 20px 0; }
p { color: #666; line-height: 1.6; }
.back-link { display: inline-block; margin-top: 20px; padding: 10px 20px; background: #3498db; color: white; text-decoration: none; border-radius: 5px; }
.back-link:hover { background: #2980b9; }
</style>
</head>
<body>
<div class="error-container">
<h1>400</h1>
<h2>Bad Request</h2>
<p>The server could not understand your request.</p>
<a href="/" class="back-link">← Back to Dashboard</a>
</div>
</body>
</html>

23
templates/errors/403.html Normal file
View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<title>403 - Forbidden</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background-color: #f5f5f5; }
.error-container { max-width: 500px; margin: 0 auto; background: white; padding: 40px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #e67e22; font-size: 72px; margin: 0; }
h2 { color: #333; margin: 20px 0; }
p { color: #666; line-height: 1.6; }
.back-link { display: inline-block; margin-top: 20px; padding: 10px 20px; background: #3498db; color: white; text-decoration: none; border-radius: 5px; }
.back-link:hover { background: #2980b9; }
</style>
</head>
<body>
<div class="error-container">
<h1>403</h1>
<h2>Forbidden</h2>
<p>You don't have permission to access this resource.</p>
<a href="/" class="back-link">← Back to Dashboard</a>
</div>
</body>
</html>

56
templates/errors/404.html Normal file
View File

@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html>
<head>
<title>404 - Page Not Found</title>
<style>
body {
font-family: Arial, sans-serif;
text-align: center;
padding: 50px;
background-color: #f5f5f5;
}
.error-container {
max-width: 500px;
margin: 0 auto;
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #e74c3c;
font-size: 72px;
margin: 0;
}
h2 {
color: #333;
margin: 20px 0;
}
p {
color: #666;
line-height: 1.6;
}
.back-link {
display: inline-block;
margin-top: 20px;
padding: 10px 20px;
background: #3498db;
color: white;
text-decoration: none;
border-radius: 5px;
}
.back-link:hover {
background: #2980b9;
}
</style>
</head>
<body>
<div class="error-container">
<h1>404</h1>
<h2>Page Not Found</h2>
<p>The page you are looking for doesn't exist or has been moved.</p>
<p>Try checking the URL or use the navigation menu.</p>
<a href="/" class="back-link">← Back to Dashboard</a>
</div>
</body>
</html>

23
templates/errors/500.html Normal file
View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<title>500 - Internal Server Error</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background-color: #f5f5f5; }
.error-container { max-width: 500px; margin: 0 auto; background: white; padding: 40px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #e74c3c; font-size: 72px; margin: 0; }
h2 { color: #333; margin: 20px 0; }
p { color: #666; line-height: 1.6; }
.back-link { display: inline-block; margin-top: 20px; padding: 10px 20px; background: #3498db; color: white; text-decoration: none; border-radius: 5px; }
.back-link:hover { background: #2980b9; }
</style>
</head>
<body>
<div class="error-container">
<h1>500</h1>
<h2>Internal Server Error</h2>
<p>Something went wrong on our end. Please try again later.</p>
<a href="/" class="back-link">← Back to Dashboard</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Logs for Hostname: {{ hostname }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<style>
body {
background-color: #f8f9fa;
font-family: Arial, sans-serif;
}
h1 {
text-align: center;
color: #343a40;
}
.table-container {
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
padding: 20px;
}
.table {
margin-bottom: 0;
table-layout: fixed; /* Ensures consistent column widths */
width: 100%; /* Makes the table take up the full container width */
}
.table th, .table td {
text-align: center;
word-wrap: break-word; /* Ensures long text wraps within the cell */
}
.table th:nth-child(1), .table td:nth-child(1) {
width: 25%; /* Device ID column */
}
.table th:nth-child(2), .table td:nth-child(2) {
width: 25%; /* Nume Masa column */
}
.table th:nth-child(3), .table td:nth-child(3) {
width: 25%; /* Timestamp column */
}
.table th:nth-child(4), .table td:nth-child(4) {
width: 25%; /* Event Description column */
}
.back-button {
margin-bottom: 20px;
text-align: center;
}
</style>
</head>
<body>
<div class="container mt-5">
<h1 class="mb-4">Logs for Hostname: {{ hostname }}</h1>
<div class="back-button">
<a href="/dashboard" class="btn btn-primary">Back to Dashboard</a>
</div>
<div class="table-container">
<table class="table table-striped table-bordered">
<thead class="table-dark">
<tr>
<th>Device ID</th>
<th>Nume Masa</th>
<th>Timestamp</th>
<th>Event Description</th>
</tr>
</thead>
<tbody>
{% if logs %}
{% for log in logs %}
<tr>
<td>{{ log[0] }}</td>
<td>{{ log[1] }}</td>
<td>{{ log[2] }}</td>
<td>{{ log[3] }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4">No logs found for this hostname.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<footer>
<p class="text-center mt-4">&copy; 2023 Device Logs Dashboard. All rights reserved.</p>
</footer>
</body>
</html>

336
templates/logs.html Normal file
View File

@@ -0,0 +1,336 @@
{% extends "base.html" %}
{% block title %}Logs - Server Monitoring{% endblock %}
{% block page_title %}Log Viewer{% endblock %}
{% block extra_css %}
<style>
.filter-container {
background-color: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.log-entry {
border-left: 4px solid #dee2e6;
margin-bottom: 10px;
transition: all 0.2s ease;
}
.log-entry.severity-error {
border-left-color: #dc3545;
}
.log-entry.severity-warning {
border-left-color: #ffc107;
}
.log-entry.severity-info {
border-left-color: #17a2b8;
}
.log-entry.severity-debug {
border-left-color: #6c757d;
}
.log-entry:hover {
background-color: #f8f9fa;
cursor: pointer;
}
.log-meta {
font-size: 0.9rem;
color: #6c757d;
}
.severity-badge {
font-size: 0.8rem;
padding: 0.3rem 0.6rem;
border-radius: 50px;
}
.pagination-container {
margin-top: 30px;
}
.log-message {
font-family: 'Courier New', monospace;
font-size: 0.9rem;
margin: 10px 0;
word-break: break-word;
}
</style>
{% endblock %}
{% block content %}
<!-- Filter Section -->
<div class="filter-container">
<form method="GET" class="row g-3">
<div class="col-md-3">
<label for="device_id" class="form-label">Device</label>
<select class="form-select" name="device_id" id="device_id">
<option value="">All Devices</option>
{% for device in devices %}
<option value="{{ device.id }}" {% if current_device_id == device.id %}selected{% endif %}>
{{ device.hostname }} ({{ device.device_ip }})
</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label for="severity" class="form-label">Severity</label>
<select class="form-select" name="severity" id="severity">
<option value="">All Levels</option>
<option value="error" {% if current_severity == 'error' %}selected{% endif %}>Error</option>
<option value="warning" {% if current_severity == 'warning' %}selected{% endif %}>Warning</option>
<option value="info" {% if current_severity == 'info' %}selected{% endif %}>Info</option>
<option value="debug" {% if current_severity == 'debug' %}selected{% endif %}>Debug</option>
</select>
</div>
<div class="col-md-4">
<label for="search" class="form-label">Search Message</label>
<input type="text" class="form-control" name="search" id="search"
placeholder="Search in log messages..." value="{{ current_search }}">
</div>
<div class="col-md-2">
<label for="per_page" class="form-label">Per Page</label>
<select class="form-select" name="per_page" id="per_page">
<option value="25" {% if pagination.per_page == 25 %}selected{% endif %}>25</option>
<option value="50" {% if pagination.per_page == 50 %}selected{% endif %}>50</option>
<option value="100" {% if pagination.per_page == 100 %}selected{% endif %}>100</option>
</select>
</div>
<div class="col-md-1">
<label class="form-label">&nbsp;</label>
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-search"></i>
</button>
</div>
</form>
</div>
<!-- Results Summary -->
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<strong>Showing {{ logs|length }} of {{ pagination.total|default(0) }} log entries</strong>
{% if current_device_id or current_severity or current_search %}
<a href="{{ url_for('main.logs') }}" class="btn btn-sm btn-outline-secondary ms-2">
<i class="fas fa-times"></i> Clear Filters
</a>
{% endif %}
</div>
<div>
<button class="btn btn-success btn-sm" onclick="refreshLogs()">
<i class="fas fa-sync-alt"></i> Refresh
</button>
<button class="btn btn-info btn-sm" onclick="exportLogs()">
<i class="fas fa-download"></i> Export
</button>
</div>
</div>
<!-- Log Entries -->
<div class="log-entries">
{% if logs %}
{% for log in logs %}
<div class="card log-entry severity-{{ log.severity|default('info') }}"
onclick="toggleLogDetail('{{ loop.index }}')">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<div class="d-flex align-items-center mb-2">
<span class="severity-badge bg-{% if log.severity == 'error' %}danger{% elif log.severity == 'warning' %}warning{% elif log.severity == 'info' %}primary{% else %}secondary{% endif %} text-white">
{{ log.severity|default('info')|upper }}
</span>
<strong class="ms-2">{{ log.device.hostname }}</strong>
<span class="text-muted ms-2">({{ log.device.device_ip }})</span>
{% if log.device.nume_masa %}
<span class="badge bg-info ms-2">{{ log.device.nume_masa }}</span>
{% endif %}
</div>
<div class="log-message">
{{ log.resolved_message|default(log.full_message)|truncate(200) }}
</div>
<div class="log-meta mt-2">
<i class="fas fa-clock"></i> {{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') if log.timestamp else 'N/A' }}
{% if log.template_hash %}
<span class="ms-3"><i class="fas fa-tag"></i> Template: {{ log.template_hash[:8] }}</span>
{% endif %}
</div>
</div>
<div class="text-end">
<button class="btn btn-sm btn-outline-primary"
onclick="event.stopPropagation(); viewDevice('{{ log.device_id }}')">
<i class="fas fa-desktop"></i>
</button>
</div>
</div>
<!-- Detailed log view (initially hidden) -->
<div id="detail-{{ loop.index }}" class="log-detail mt-3" style="display: none;">
<hr>
<h6>Full Message:</h6>
<pre class="bg-light p-3 rounded">{{ log.full_message|default('No detailed message available') }}</pre>
{% if log.resolved_message != log.full_message %}
<h6>Resolved Message:</h6>
<div class="bg-info bg-opacity-10 p-3 rounded">
{{ log.resolved_message }}
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center py-5">
<i class="fas fa-inbox fa-3x text-muted mb-3"></i>
<h4 class="text-muted">No Logs Found</h4>
<p class="text-muted">No log entries match the current filters.</p>
{% if current_device_id or current_severity or current_search %}
<a href="{{ url_for('main.logs') }}" class="btn btn-primary">
<i class="fas fa-arrow-left"></i> View All Logs
</a>
{% endif %}
</div>
{% endif %}
</div>
<!-- Pagination -->
{% if pagination and pagination.total_pages > 1 %}
<div class="pagination-container">
<nav aria-label="Log pagination">
<ul class="pagination justify-content-center">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('main.logs', page=pagination.prev_num, device_id=current_device_id, severity=current_severity, search=current_search, per_page=pagination.per_page) }}">
<i class="fas fa-chevron-left"></i> Previous
</a>
</li>
{% endif %}
{% for page_num in range(1, pagination.total_pages + 1) %}
{% if page_num == pagination.page %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% elif page_num <= pagination.page + 2 and page_num >= pagination.page - 2 %}
<li class="page-item">
<a class="page-link" href="{{ url_for('main.logs', page=page_num, device_id=current_device_id, severity=current_severity, search=current_search, per_page=pagination.per_page) }}">
{{ page_num }}
</a>
</li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('main.logs', page=pagination.next_num, device_id=current_device_id, severity=current_severity, search=current_search, per_page=pagination.per_page) }}">
Next <i class="fas fa-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
<div class="text-center text-muted">
Page {{ pagination.page }} of {{ pagination.total_pages }}
({{ pagination.total }} total entries)
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
function toggleLogDetail(index) {
const detail = document.getElementById(`detail-${index}`);
if (detail.style.display === 'none') {
detail.style.display = 'block';
} else {
detail.style.display = 'none';
}
}
function viewDevice(deviceId) {
window.location.href = `{{ url_for('main.device_detail', device_id=0) }}`.replace('0', deviceId);
}
function refreshLogs() {
window.location.reload();
}
function exportLogs() {
// Create export parameters from current filters
const params = new URLSearchParams(window.location.search);
params.set('export', 'csv');
// Create download link
const downloadUrl = `{{ url_for('main.logs') }}?${params.toString()}`;
const link = document.createElement('a');
link.href = downloadUrl;
link.download = 'logs-export.csv';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
// Auto-refresh option
let autoRefreshInterval;
function startAutoRefresh(seconds = 30) {
autoRefreshInterval = setInterval(() => {
refreshLogs();
}, seconds * 1000);
}
function stopAutoRefresh() {
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
}
}
// Initialize page
document.addEventListener('DOMContentLoaded', function() {
console.log('Logs page loaded');
// Add keyboard shortcuts
document.addEventListener('keydown', function(event) {
if (event.ctrlKey || event.metaKey) {
if (event.key === 'f') {
event.preventDefault();
document.getElementById('search').focus();
}
if (event.key === 'r') {
event.preventDefault();
refreshLogs();
}
}
});
// Auto-submit form when filters change
const filterForm = document.querySelector('.filter-container form');
const autoSubmitElements = ['device_id', 'severity', 'per_page'];
autoSubmitElements.forEach(id => {
const element = document.getElementById(id);
if (element) {
element.addEventListener('change', () => filterForm.submit());
}
});
});
</script>
{% endblock %}

230
templates/server_logs.html Normal file
View File

@@ -0,0 +1,230 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Server Logs - System Operations</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
body {
background-color: #f8f9fa;
font-family: Arial, sans-serif;
}
h1 {
text-align: center;
color: #343a40;
}
.table-container {
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
padding: 20px;
}
.table {
margin-bottom: 0;
table-layout: fixed;
width: 100%;
}
.table th, .table td {
text-align: center;
word-wrap: break-word;
}
.table th:nth-child(1), .table td:nth-child(1) {
width: 15%; /* Hostname column */
}
.table th:nth-child(2), .table td:nth-child(2) {
width: 15%; /* Device IP column */
}
.table th:nth-child(3), .table td:nth-child(3) {
width: 15%; /* Operation Type column */
}
.table th:nth-child(4), .table td:nth-child(4) {
width: 20%; /* Timestamp column */
}
.table th:nth-child(5), .table td:nth-child(5) {
width: 35%; /* Event Description column */
}
.refresh-timer {
text-align: center;
margin-bottom: 10px;
font-size: 1.2rem;
color: #343a40;
}
.server-log-header {
background: linear-gradient(135deg, #dc3545, #6c757d);
color: white;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
}
.server-badge {
background-color: #dc3545;
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.8em;
}
.operation-badge {
padding: 2px 8px;
border-radius: 12px;
font-size: 0.8em;
font-weight: bold;
}
.operation-system { background-color: #6c757d; color: white; }
.operation-auto_update { background-color: #ffc107; color: black; }
.operation-command { background-color: #0d6efd; color: white; }
.operation-reset { background-color: #dc3545; color: white; }
.stats-row {
background-color: #f8f9fa;
border: 2px solid #dee2e6;
}
</style>
<script>
// Countdown timer for refresh
let countdown = 30;
function updateTimer() {
document.getElementById('refresh-timer').innerText = countdown;
countdown--;
if (countdown < 0) {
location.reload();
}
}
setInterval(updateTimer, 1000);
// Function to get operation type from description
function getOperationType(description) {
if (description.toLowerCase().includes('auto-update') || description.toLowerCase().includes('auto_update')) {
return 'auto_update';
} else if (description.toLowerCase().includes('command')) {
return 'command';
} else if (description.toLowerCase().includes('reset') || description.toLowerCase().includes('clear')) {
return 'reset';
} else {
return 'system';
}
}
// Apply operation badges after page load
document.addEventListener('DOMContentLoaded', function() {
const rows = document.querySelectorAll('tbody tr:not(.stats-row)');
rows.forEach(row => {
const descCell = row.cells[4];
const operationCell = row.cells[2];
const description = descCell.textContent;
const operationType = getOperationType(description);
operationCell.innerHTML = `<span class="operation-badge operation-${operationType}">${operationType.toUpperCase().replace('_', '-')}</span>`;
});
});
</script>
</head>
<body>
<div class="container mt-5">
<div class="server-log-header text-center">
<h1 class="mb-2">
<i class="fas fa-server"></i> Server Operations Log
</h1>
<p class="mb-0">System operations, auto-updates, database resets, and server commands</p>
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="refresh-timer">
<i class="fas fa-clock"></i> Auto-refresh: <span id="refresh-timer">30</span> seconds
</div>
<div>
<a href="/dashboard" class="btn btn-primary">
<i class="fas fa-tachometer-alt"></i> Dashboard
</a>
<a href="/device_management" class="btn btn-success">
<i class="fas fa-cogs"></i> Device Management
</a>
<a href="/unique_devices" class="btn btn-secondary">
<i class="fas fa-list"></i> Unique Devices
</a>
</div>
</div>
<div class="table-container">
{% if logs %}
<!-- Statistics Row -->
<div class="mb-3 p-3 stats-row rounded">
<div class="row text-center">
<div class="col-md-3">
<h5 class="text-primary">{{ logs|length }}</h5>
<small>Total Operations</small>
</div>
<div class="col-md-3">
<h5 class="text-success">
{% set system_ops = logs|selectattr('2', 'equalto', 'SYSTEM')|list|length %}
{{ system_ops }}
</h5>
<small>System Operations</small>
</div>
<div class="col-md-3">
<h5 class="text-warning">
{% set auto_updates = 0 %}
{% for log in logs %}
{% if 'auto' in log[4]|lower or 'update' in log[4]|lower %}
{% set auto_updates = auto_updates + 1 %}
{% endif %}
{% endfor %}
{{ auto_updates }}
</h5>
<small>Auto-Updates</small>
</div>
<div class="col-md-3">
<h5 class="text-danger">
{% set resets = 0 %}
{% for log in logs %}
{% if 'reset' in log[4]|lower or 'clear' in log[4]|lower %}
{% set resets = resets + 1 %}
{% endif %}
{% endfor %}
{{ resets }}
</h5>
<small>Database Resets</small>
</div>
</div>
</div>
<table class="table table-striped table-bordered">
<thead class="table-dark">
<tr>
<th><i class="fas fa-server"></i> Source</th>
<th><i class="fas fa-network-wired"></i> IP Address</th>
<th><i class="fas fa-cog"></i> Operation</th>
<th><i class="fas fa-clock"></i> Timestamp</th>
<th><i class="fas fa-info-circle"></i> Description</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td><span class="server-badge">{{ log[0] }}</span></td>
<td>{{ log[1] }}</td>
<td>{{ log[2] }}</td>
<td>{{ log[3] }}</td>
<td class="text-start">{{ log[4] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="text-center py-5">
<i class="fas fa-inbox fa-3x text-muted mb-3"></i>
<h4 class="text-muted">No Server Operations Found</h4>
<p class="text-muted">No server operations have been logged yet.</p>
<a href="/dashboard" class="btn btn-primary">
<i class="fas fa-arrow-left"></i> Back to Dashboard
</a>
</div>
{% endif %}
</div>
</div>
<footer>
<p class="text-center mt-4">&copy; 2025 Server Operations Dashboard. All rights reserved.</p>
</footer>
</body>
</html>

211
templates/stats.html Normal file
View File

@@ -0,0 +1,211 @@
{% extends "base.html" %}
{% block title %}System Statistics - Server Monitoring{% endblock %}
{% block page_title %}System Statistics{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Device Statistics -->
<div class="row mb-4">
<div class="col-12">
<h4>Device Statistics</h4>
</div>
<div class="col-md-3">
<div class="card bg-primary text-white">
<div class="card-body">
<h5 class="card-title">Total Devices</h5>
<h2>{{ device_stats.get('total', 0) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body">
<h5 class="card-title">Active</h5>
<h2>{{ device_stats.get('active', 0) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-warning text-white">
<div class="card-body">
<h5 class="card-title">Inactive</h5>
<h2>{{ device_stats.get('inactive', 0) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-info text-white">
<div class="card-body">
<h5 class="card-title">Maintenance</h5>
<h2>{{ device_stats.get('maintenance', 0) }}</h2>
</div>
</div>
</div>
</div>
<!-- Log Statistics -->
<div class="row mb-4">
<div class="col-12">
<h4>Log Activity</h4>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Last Hour</h5>
<h2 class="text-primary">{{ log_stats.get('last_hour', 0) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Last 24 Hours</h5>
<h2 class="text-success">{{ log_stats.get('last_24h', 0) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Last Week</h5>
<h2 class="text-info">{{ log_stats.get('last_week', 0) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Last Month</h5>
<h2 class="text-warning">{{ log_stats.get('last_month', 0) }}</h2>
</div>
</div>
</div>
</div>
<!-- Compression Statistics -->
<div class="row mb-4">
<div class="col-12">
<h4>Message Compression</h4>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Messages Compressed</h5>
<h2 class="text-primary">{{ compression_stats.get('total_compressed', 0) }}</h2>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Space Saved</h5>
<h2 class="text-success">{{ compression_stats.get('total_saved_bytes', 0) | filesizeformat }}</h2>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Compression Ratio</h5>
<h2 class="text-info">{{ compression_stats.get('average_ratio', 0) | round(1) }}%</h2>
</div>
</div>
</div>
</div>
<!-- Ansible Execution Statistics -->
<div class="row mb-4">
<div class="col-12">
<h4>Ansible Executions</h4>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Total Executions</h5>
<h2 class="text-primary">{{ exec_stats.get('total', 0) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body">
<h5 class="card-title">Successful</h5>
<h2>{{ exec_stats.get('successful', 0) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-danger text-white">
<div class="card-body">
<h5 class="card-title">Failed</h5>
<h2>{{ exec_stats.get('failed', 0) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-warning text-white">
<div class="card-body">
<h5 class="card-title">Running</h5>
<h2>{{ exec_stats.get('running', 0) }}</h2>
</div>
</div>
</div>
</div>
<!-- System Health Chart -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5>System Health Overview</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6>Device Status Distribution</h6>
<div class="progress mb-3">
{% set total_devices = device_stats.get('total', 0) %}
{% if total_devices > 0 %}
<div class="progress-bar bg-success" style="width: {{ (device_stats.get('active', 0) * 100 / total_devices) }}%">
Active ({{ device_stats.get('active', 0) }})
</div>
<div class="progress-bar bg-warning" style="width: {{ (device_stats.get('inactive', 0) * 100 / total_devices) }}%">
Inactive ({{ device_stats.get('inactive', 0) }})
</div>
<div class="progress-bar bg-info" style="width: {{ (device_stats.get('maintenance', 0) * 100 / total_devices) }}%">
Maintenance ({{ device_stats.get('maintenance', 0) }})
</div>
{% else %}
<div class="progress-bar bg-secondary" style="width: 100%">
No devices configured
</div>
{% endif %}
</div>
</div>
<div class="col-md-6">
<h6>Ansible Success Rate</h6>
<div class="progress mb-3">
{% set total_exec = exec_stats.get('total', 0) %}
{% if total_exec > 0 %}
<div class="progress-bar bg-success" style="width: {{ (exec_stats.get('successful', 0) * 100 / total_exec) }}%">
Success ({{ exec_stats.get('successful', 0) }})
</div>
<div class="progress-bar bg-danger" style="width: {{ (exec_stats.get('failed', 0) * 100 / total_exec) }}%">
Failed ({{ exec_stats.get('failed', 0) }})
</div>
{% else %}
<div class="progress-bar bg-secondary" style="width: 100%">
No executions yet
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

100
templates/templates.html Normal file
View File

@@ -0,0 +1,100 @@
{% extends "base.html" %}
{% block title %}Message Templates - Server Monitoring{% endblock %}
{% block page_title %}Message Templates{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Template Statistics -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card bg-primary text-white">
<div class="card-body">
<h5 class="card-title">Total Templates</h5>
<h2>{{ template_stats.get('total', 0) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body">
<h5 class="card-title">Total Usage</h5>
<h2>{{ template_stats.get('total_usage', 0) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-info text-white">
<div class="card-body">
<h5 class="card-title">Categories</h5>
<h2>{{ template_stats.get('by_category', {}) | length }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-warning text-white">
<div class="card-body">
<h5 class="card-title">Avg Usage</h5>
<h2>{{ (template_stats.get('total_usage', 0) / template_stats.get('total', 1)) | round(1) }}</h2>
</div>
</div>
</div>
</div>
<!-- Templates Table -->
<div class="card">
<div class="card-header">
<h5>Message Templates</h5>
<small class="text-muted">Manage and view compressed message templates</small>
</div>
<div class="card-body">
{% if templates %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Hash</th>
<th>Template Text</th>
<th>Category</th>
<th>Usage Count</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for template in templates %}
<tr>
<td><code>{{ template.template_hash[:8] }}...</code></td>
<td>{{ template.template_text[:80] }}{% if template.template_text | length > 80 %}...{% endif %}</td>
<td><span class="badge bg-secondary">{{ template.category or 'uncategorized' }}</span></td>
<td>{{ template.usage_count }}</td>
<td>{{ template.created_at.strftime('%Y-%m-%d %H:%M') if template.created_at else 'N/A' }}</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="viewTemplate('{{ template.template_hash }}')">
<i class="fas fa-eye"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-file-alt fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No templates found</h5>
<p class="text-muted">Message templates will appear here when devices start sending compressed logs.</p>
</div>
{% endif %}
</div>
</div>
</div>
<script>
function viewTemplate(hash) {
// Implementation for viewing template details
alert('Template details for: ' + hash);
}
</script>
{% endblock %}

View File

@@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Unique Devices</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<style>
body {
background-color: #f8f9fa;
font-family: Arial, sans-serif;
}
h1 {
text-align: center;
color: #343a40;
}
.table-container {
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
padding: 20px;
}
.table {
margin-bottom: 0;
table-layout: fixed; /* Ensures consistent column widths */
width: 100%; /* Makes the table take up the full container width */
}
.table th, .table td {
text-align: center;
word-wrap: break-word; /* Ensures long text wraps within the cell */
}
.table th:nth-child(1), .table td:nth-child(1) {
width: 25%; /* Hostname column */
}
.table th:nth-child(2), .table td:nth-child(2) {
width: 25%; /* Device IP column */
}
.table th:nth-child(3), .table td:nth-child(3) {
width: 25%; /* Last Log column */
}
.table th:nth-child(4), .table td:nth-child(4) {
width: 25%; /* Event Description column */
}
.back-button {
margin-bottom: 20px;
text-align: center;
}
.search-container {
margin-bottom: 20px;
}
.search-input {
max-width: 400px;
margin: 0 auto;
}
.no-results {
display: none;
text-align: center;
color: #6c757d;
font-style: italic;
padding: 20px;
}
</style>
</head>
<body>
<div class="container mt-5">
<h1 class="mb-4">Unique Devices</h1>
<div class="back-button">
<a href="/dashboard" class="btn btn-primary">Back to Dashboard</a>
</div>
<!-- Search Filter -->
<div class="search-container">
<div class="search-input">
<input type="text" id="searchInput" class="form-control" placeholder="Search devices by hostname, IP, or event description...">
</div>
</div>
<div class="table-container">
<table class="table table-striped table-bordered" id="devicesTable">
<thead class="table-dark">
<tr>
<th>Hostname</th>
<th>Device IP</th>
<th>Last Log</th>
<th>Event Description</th>
</tr>
</thead>
<tbody>
{% for device in devices %}
<tr>
<!-- Make the Hostname column clickable -->
<td>
<a href="/hostname_logs/{{ device[0] }}">{{ device[0] }}</a>
</td>
<td>{{ device[1] }}</td> <!-- Device IP -->
<td>{{ device[2] }}</td> <!-- Last Log -->
<td>{{ device[3] }}</td> <!-- Event Description -->
</tr>
{% endfor %}
</tbody>
</table>
<div id="noResults" class="no-results">
No devices found matching your search criteria.
</div>
</div>
</div>
<!-- JavaScript for filtering -->
<script>
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('searchInput');
const table = document.getElementById('devicesTable');
const tableBody = table.getElementsByTagName('tbody')[0];
const noResultsDiv = document.getElementById('noResults');
searchInput.addEventListener('keyup', function() {
const filter = this.value.toLowerCase();
const rows = tableBody.getElementsByTagName('tr');
let visibleRowCount = 0;
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const cells = row.getElementsByTagName('td');
let found = false;
// Search through all cells in the row
for (let j = 0; j < cells.length; j++) {
const cellText = cells[j].textContent || cells[j].innerText;
if (cellText.toLowerCase().indexOf(filter) > -1) {
found = true;
break;
}
}
if (found) {
row.style.display = '';
visibleRowCount++;
} else {
row.style.display = 'none';
}
}
// Show/hide "no results" message
if (visibleRowCount === 0 && filter !== '') {
noResultsDiv.style.display = 'block';
} else {
noResultsDiv.style.display = 'none';
}
});
});
</script>
<footer>
<p class="text-center mt-4">&copy; 2023 Unique Devices Dashboard. All rights reserved.</p>
</footer>
</body>
</html>

View File

@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% block title %}{% if device %}Edit Device{% else %}New Device{% endif %} WMT {{ app_name }}{% endblock %}
{% block page_title %}{% if device %}Edit Device{% else %}New Device{% endif %}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="card">
<div class="card-header">
<i class="fas fa-desktop me-2"></i>
{% if device %}
Edit: <code>{{ device.mac_address }}</code>
{% else %}
Register New WMT Device
{% endif %}
</div>
<div class="card-body">
<form method="post">
<div class="mb-3">
<label class="form-label fw-semibold">MAC Address <span class="text-danger">*</span>
<small class="text-muted fw-normal">(unique identifier, e.g. b8:27:eb:aa:bb:cc)</small>
</label>
{% if device %}
<input type="text" class="form-control" value="{{ device.mac_address }}" readonly disabled>
<small class="text-muted">MAC cannot be changed after registration.</small>
{% else %}
<input type="text" name="mac_address" class="form-control"
placeholder="b8:27:eb:aa:bb:cc" required
pattern="^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$">
{% endif %}
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Work Place
<small class="text-muted fw-normal">(table / workstation identifier)</small>
</label>
<input type="text" name="device_name" class="form-control"
value="{{ device.device_name or '' if device else '' }}"
placeholder="Masa-01">
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label fw-semibold">Hostname</label>
<input type="text" name="hostname" class="form-control"
value="{{ device.hostname or '' if device else '' }}"
placeholder="rpi-masa01">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">IP Address</label>
<input type="text" name="device_ip" class="form-control"
value="{{ device.device_ip or '' if device else '' }}"
placeholder="192.168.1.100">
</div>
</div>
<div class="mb-4">
<label class="form-label fw-semibold">Notes</label>
<textarea name="notes" class="form-control" rows="2">{{ device.notes or '' if device else '' }}</textarea>
</div>
{% if device %}
<div class="alert alert-light border small mb-4">
<strong>Last seen:</strong>
{{ device.last_seen.strftime('%Y-%m-%d %H:%M:%S') if device.last_seen else 'Never' }}<br>
<strong>Config updated:</strong>
{{ device.config_updated_at.strftime('%Y-%m-%d %H:%M:%S') if device.config_updated_at else '—' }}<br>
<strong>Info reviewed at:</strong>
<span class="{% if device.info_reviewed_at and device.info_reviewed_at.year > 1970 %}text-success{% else %}text-muted{% endif %}">
{{ device.info_reviewed_at.strftime('%Y-%m-%d %H:%M:%S') if (device.info_reviewed_at and device.info_reviewed_at.year > 1970) else 'Never reviewed' }}
</span>
<br><small class="text-muted">Updated automatically when you save this form, accept or reject a device request.</small>
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-1"></i> {% if device %}Save Changes{% else %}Register Device{% endif %}
</button>
<a href="{{ url_for('wmt_web.devices') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,70 @@
{% extends "base.html" %}
{% block title %}WMT Devices {{ app_name }}{% endblock %}
{% block page_title %}WMT Devices{% endblock %}
{% block content %}
<div class="mb-3 d-flex justify-content-between align-items-center">
<p class="text-muted mb-0">{{ devices | length }} device(s) registered.</p>
<a href="{{ url_for('wmt_web.device_new') }}" class="btn btn-success">
<i class="fas fa-plus me-1"></i> New Device
</a>
</div>
<div class="card">
<div class="card-body p-0">
{% if devices %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Work Place</th>
<th>MAC Address</th>
<th>Hostname</th>
<th>IP Address</th>
<th>Last Seen</th>
<th>Config Updated</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for d in devices %}
<tr>
<td><strong>{{ d.device_name or '—' }}</strong></td>
<td><code>{{ d.mac_address }}</code></td>
<td>{{ d.hostname or '—' }}</td>
<td>{{ d.device_ip or '—' }}</td>
<td class="text-muted small">
{{ d.last_seen.strftime('%Y-%m-%d %H:%M') if d.last_seen else 'Never' }}
</td>
<td class="text-muted small">
{{ d.config_updated_at.strftime('%Y-%m-%d %H:%M') if d.config_updated_at else '—' }}
</td>
<td class="text-end">
<a href="{{ url_for('wmt_web.device_edit', device_id=d.id) }}"
class="btn btn-sm btn-outline-primary">
<i class="fas fa-edit"></i> Edit
</a>
<form method="post" action="{{ url_for('wmt_web.device_delete', device_id=d.id) }}"
class="d-inline"
onsubmit="return confirm('Delete device {{ d.mac_address }}?')">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="fas fa-desktop fa-3x mb-3 opacity-25"></i>
<p>No devices registered yet. <a href="{{ url_for('wmt_web.device_new') }}">Add the first one</a>.</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}

181
templates/wmt/index.html Normal file
View File

@@ -0,0 +1,181 @@
{% extends "base.html" %}
{% block title %}WMT Management {{ app_name }}{% endblock %}
{% block extra_css %}
<style>
.stat-card { border-left: 4px solid; }
.stat-card.blue { border-color: #3498db; }
.stat-card.green { border-color: #2ecc71; }
.stat-card.orange{ border-color: #f39c12; }
.stat-card.red { border-color: #e74c3c; }
.badge-pending { background-color: #f39c12; }
.badge-accepted { background-color: #2ecc71; }
.badge-rejected { background-color: #e74c3c; }
</style>
{% endblock %}
{% block page_title %}WMT Management{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col">
<a href="{{ url_for('wmt_web.settings') }}" class="btn btn-primary me-2">
<i class="fas fa-cog"></i> Global Settings
</a>
<a href="{{ url_for('main.devices') }}" class="btn btn-outline-primary me-2">
<i class="fas fa-desktop"></i> Devices
</a>
<a href="{{ url_for('wmt_web.update_requests') }}" class="btn btn-outline-warning">
<i class="fas fa-inbox"></i> Update Requests
{% if pending_count > 0 %}
<span class="badge bg-danger ms-1">{{ pending_count }}</span>
{% endif %}
</a>
</div>
</div>
<!-- Stats row -->
<div class="row g-3 mb-4">
<div class="col-sm-6 col-lg-3">
<div class="card stat-card blue h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1 small">Registered Devices</p>
<h4 class="mb-0">{{ devices | length }}</h4>
</div>
<i class="fas fa-desktop fa-2x text-primary opacity-50"></i>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card stat-card orange h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1 small">Pending Requests</p>
<h4 class="mb-0">{{ pending_count }}</h4>
</div>
<i class="fas fa-clock fa-2x text-warning opacity-50"></i>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card stat-card green h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1 small">Config Last Updated</p>
<h6 class="mb-0">
{% if global_cfg and global_cfg.updated_at %}
{{ global_cfg.updated_at.strftime('%Y-%m-%d %H:%M') }}
{% else %}
Never
{% endif %}
</h6>
</div>
<i class="fas fa-sync fa-2x text-success opacity-50"></i>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card stat-card red h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1 small">Chrome URL</p>
<small class="text-truncate d-block" style="max-width:160px">
{{ global_cfg.chrome_url if global_cfg else '—' }}
</small>
</div>
<i class="fas fa-globe fa-2x text-danger opacity-50"></i>
</div>
</div>
</div>
</div>
</div>
<div class="row g-3">
<!-- Device list -->
<div class="col-lg-7">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><i class="fas fa-desktop me-2"></i>WMT Client Devices</strong>
<a href="{{ url_for('main.devices') }}" class="btn btn-sm btn-success">
<i class="fas fa-plus"></i> Add
</a>
</div>
<div class="card-body p-0">
{% if devices %}
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Device Name</th>
<th>MAC</th>
<th>IP</th>
<th>Last Seen</th>
<th></th>
</tr>
</thead>
<tbody>
{% for d in devices %}
<tr>
<td><strong>{{ d.device_name or '—' }}</strong></td>
<td><code>{{ d.mac_address }}</code></td>
<td>{{ d.device_ip or '—' }}</td>
<td class="text-muted small">
{{ d.last_seen.strftime('%Y-%m-%d %H:%M') if d.last_seen else 'Never' }}
</td>
<td>
<a href="{{ url_for('main.device_edit', device_id=d.id) }}"
class="btn btn-xs btn-outline-primary btn-sm py-0">Edit</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted p-3 mb-0">No WMT client devices registered yet.
<a href="{{ url_for('main.devices') }}">Manage devices</a>.
</p>
{% endif %}
</div>
</div>
</div>
<!-- Recent update requests -->
<div class="col-lg-5">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><i class="fas fa-inbox me-2"></i>Recent Requests</strong>
<a href="{{ url_for('wmt_web.update_requests') }}" class="btn btn-sm btn-outline-secondary">
View All
</a>
</div>
<div class="card-body p-0">
{% if recent_requests %}
<ul class="list-group list-group-flush">
{% for r in recent_requests %}
<li class="list-group-item d-flex justify-content-between align-items-start py-2">
<div>
<code class="small">{{ r.mac_address }}</code><br>
<small class="text-muted">{{ r.submitted_at.strftime('%Y-%m-%d %H:%M') }}</small>
</div>
<span class="badge badge-{{ r.status }} rounded-pill">{{ r.status }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted p-3 mb-0">No recent requests.</p>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

124
templates/wmt/requests.html Normal file
View File

@@ -0,0 +1,124 @@
{% extends "base.html" %}
{% block title %}Update Requests WMT {{ app_name }}{% endblock %}
{% block extra_css %}
<style>
.badge-pending { background-color: #f39c12; color: #fff; }
.badge-accepted { background-color: #2ecc71; color: #fff; }
.badge-rejected { background-color: #e74c3c; color: #fff; }
</style>
{% endblock %}
{% block page_title %}
WMT Update Requests
{% if pending_count > 0 %}
<span class="badge bg-danger ms-2">{{ pending_count }} pending</span>
{% endif %}
{% endblock %}
{% block content %}
<!-- Filter tabs -->
<ul class="nav nav-tabs mb-4">
<li class="nav-item">
<a class="nav-link {% if status_filter == 'pending' %}active{% endif %}"
href="{{ url_for('wmt_web.update_requests', status='pending') }}">
Pending
{% if pending_count > 0 %}<span class="badge bg-danger ms-1">{{ pending_count }}</span>{% endif %}
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if status_filter == 'accepted' %}active{% endif %}"
href="{{ url_for('wmt_web.update_requests', status='accepted') }}">Accepted</a>
</li>
<li class="nav-item">
<a class="nav-link {% if status_filter == 'rejected' %}active{% endif %}"
href="{{ url_for('wmt_web.update_requests', status='rejected') }}">Rejected</a>
</li>
<li class="nav-item">
<a class="nav-link {% if status_filter == 'all' %}active{% endif %}"
href="{{ url_for('wmt_web.update_requests', status='all') }}">All</a>
</li>
</ul>
{% if requests %}
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>#</th>
<th>MAC Address</th>
<th>Proposed Device Name</th>
<th>Proposed Hostname</th>
<th>Proposed IP</th>
<th>Submitted</th>
<th>Client Config Time</th>
<th>Status</th>
{% if status_filter == 'pending' or status_filter == 'all' %}
<th class="text-end">Actions</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for r in requests %}
<tr>
<td class="text-muted small">{{ r.id }}</td>
<td><code>{{ r.mac_address }}</code></td>
<td>{{ r.proposed_device_name or '—' }}</td>
<td>{{ r.proposed_hostname or '—' }}</td>
<td>{{ r.proposed_device_ip or '—' }}</td>
<td class="text-muted small">{{ r.submitted_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td class="text-muted small">{{ r.client_config_mtime or '—' }}</td>
<td>
<span class="badge badge-{{ r.status }} rounded-pill">{{ r.status }}</span>
{% if r.admin_reviewed_at %}
<br><small class="text-muted">{{ r.admin_reviewed_at.strftime('%Y-%m-%d %H:%M') }}</small>
{% endif %}
</td>
{% if status_filter == 'pending' or status_filter == 'all' %}
<td class="text-end">
{% if r.status == 'pending' %}
<!-- Accept -->
<form method="post" action="{{ url_for('wmt_web.accept_request', req_id=r.id) }}"
class="d-inline"
onsubmit="return confirm('Accept this request and update the device record?')">
<button type="submit" class="btn btn-sm btn-success">
<i class="fas fa-check"></i> Accept
</button>
</form>
<!-- Reject -->
<form method="post" action="{{ url_for('wmt_web.reject_request', req_id=r.id) }}"
class="d-inline ms-1"
onsubmit="return confirm('Reject this request?')">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="fas fa-times"></i> Reject
</button>
</form>
{% else %}
<span class="text-muted small"></span>
{% endif %}
</td>
{% endif %}
</tr>
{% if r.admin_notes %}
<tr class="table-light">
<td colspan="9" class="small text-muted ps-4">
<i class="fas fa-comment me-1"></i> Admin note: {{ r.admin_notes }}
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="fas fa-inbox fa-3x mb-3 opacity-25"></i>
<p>No {{ status_filter }} requests found.</p>
</div>
{% endif %}
{% endblock %}

109
templates/wmt/settings.html Normal file
View File

@@ -0,0 +1,109 @@
{% extends "base.html" %}
{% block title %}Global Settings WMT {{ app_name }}{% endblock %}
{% block page_title %}WMT Global Settings{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-9">
<div class="card">
<div class="card-header">
<i class="fas fa-cog me-2"></i>
Global Configuration
<small class="text-muted ms-3">
Applied to all WMT devices on next sync.
{% if cfg and cfg.updated_at %}
Last saved: {{ cfg.updated_at.strftime('%Y-%m-%d %H:%M:%S') }} by {{ cfg.updated_by or 'admin' }}
{% endif %}
</small>
</div>
<div class="card-body">
<form method="post">
<h6 class="text-uppercase text-muted mb-3 mt-2">
<i class="fas fa-globe me-1"></i> Chrome Launch
</h6>
<div class="mb-3">
<label class="form-label fw-semibold">Production URL
<small class="text-muted fw-normal">(kiosk mode at startup)</small>
</label>
<input type="url" name="chrome_url" class="form-control"
value="{{ cfg.chrome_url if cfg else '' }}" required>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Local / Fallback URL
<small class="text-muted fw-normal">(optional)</small>
</label>
<input type="url" name="chrome_local_url" class="form-control"
value="{{ cfg.chrome_local_url or '' }}">
</div>
<div class="mb-4">
<label class="form-label fw-semibold">Insecure Origin to Trust
<small class="text-muted fw-normal">(--unsafely-treat-insecure-origin-as-secure)</small>
</label>
<input type="text" name="chrome_insecure_origin" class="form-control"
value="{{ cfg.chrome_insecure_origin if cfg else '' }}">
</div>
<hr>
<h6 class="text-uppercase text-muted mb-3">
<i class="fas fa-id-card me-1"></i> Card API
</h6>
<div class="mb-4">
<label class="form-label fw-semibold">Base URL
<small class="text-muted fw-normal">Format: {base_url}/{device_name}/{card_id}/{0or1}/{timestamp}</small>
</label>
<input type="url" name="card_api_base_url" class="form-control"
value="{{ cfg.card_api_base_url if cfg else '' }}" required>
</div>
<hr>
<h6 class="text-uppercase text-muted mb-3">
<i class="fas fa-server me-1"></i> Server / Network
</h6>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label fw-semibold">Log Server URL</label>
<input type="url" name="server_log_url" class="form-control"
value="{{ cfg.server_log_url if cfg else '' }}">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Internet Check Host
<small class="text-muted fw-normal">(ping target)</small>
</label>
<input type="text" name="internet_check_host" class="form-control"
value="{{ cfg.internet_check_host if cfg else '' }}">
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label fw-semibold">Auto-Update Host</label>
<input type="text" name="update_host" class="form-control"
value="{{ cfg.update_host if cfg else '' }}">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Auto-Update SSH User</label>
<input type="text" name="update_user" class="form-control"
value="{{ cfg.update_user if cfg else '' }}">
</div>
</div>
<hr>
<div class="mb-3">
<label class="form-label fw-semibold">Admin Notes</label>
<textarea name="notes" class="form-control" rows="2">{{ cfg.notes or '' }}</textarea>
</div>
<div class="d-flex gap-2 mt-3">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-1"></i> Save Settings
</button>
<a href="{{ url_for('wmt_web.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}