Initial commit — Server_Monitorizare_v2
This commit is contained in:
53
.gitignore
vendored
Normal file
53
.gitignore
vendored
Normal 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
326
INSTALLATION.md
Normal 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
80
README.md
Normal 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
201
app/__init__.py
Normal 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
0
app/api/__init__.py
Normal file
454
app/api/ansible.py
Normal file
454
app/api/ansible.py
Normal 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
373
app/api/logs.py
Normal 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
170
app/api/wmt.py
Normal 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
629
app/models/__init__.py
Normal 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
1
app/models/device.py
Normal file
@@ -0,0 +1 @@
|
||||
# Enhanced Server Monitoring System v2.0 - Models Package
|
||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
925
app/services/ansible_service.py
Normal file
925
app/services/ansible_service.py
Normal 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 []
|
||||
324
app/services/device_service.py
Normal file
324
app/services/device_service.py
Normal 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 {}
|
||||
256
app/services/file_service.py
Normal file
256
app/services/file_service.py
Normal 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
378
app/services/log_service.py
Normal 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
0
app/utils/__init__.py
Normal file
0
app/web/__init__.py
Normal file
0
app/web/__init__.py
Normal file
599
app/web/ansible.py
Normal file
599
app/web/ansible.py
Normal file
@@ -0,0 +1,599 @@
|
||||
"""
|
||||
Web routes for Ansible management interface
|
||||
"""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||
from app.services.ansible_service import AnsibleService
|
||||
from app.models import Device, AnsibleExecution, PlaybookExecution
|
||||
from config.database_config import get_db
|
||||
import logging
|
||||
|
||||
# Create blueprint
|
||||
ansible_web_bp = Blueprint('ansible_web', __name__, url_prefix='/ansible')
|
||||
|
||||
# Initialize service
|
||||
ansible_service = AnsibleService()
|
||||
|
||||
@ansible_web_bp.route('/')
|
||||
def index():
|
||||
"""Redirect ansible root to playbooks"""
|
||||
return redirect(url_for('ansible_web.playbooks'))
|
||||
|
||||
@ansible_web_bp.route('/devices')
|
||||
def devices():
|
||||
"""Ansible inventory management interface"""
|
||||
try:
|
||||
# Load current inventory from file
|
||||
inventory_data = ansible_service.get_inventory_data()
|
||||
|
||||
# Load all DB devices so the user can see which haven't been synced yet
|
||||
with get_db().get_session() as session:
|
||||
db_devices = session.query(Device).all()
|
||||
db_devices_list = [
|
||||
{
|
||||
'hostname': d.hostname,
|
||||
'device_ip': d.device_ip,
|
||||
'status': d.status,
|
||||
'device_type': d.device_type,
|
||||
'location': d.location
|
||||
}
|
||||
for d in db_devices
|
||||
]
|
||||
|
||||
# Collect all hostnames already present in inventory
|
||||
all_inv_hosts = set()
|
||||
for group_data in inventory_data.get('groups', {}).values():
|
||||
for h in group_data.get('hosts', []):
|
||||
all_inv_hosts.add(h['hostname'])
|
||||
|
||||
# Mark which DB devices are in inventory
|
||||
for d in db_devices_list:
|
||||
d['in_inventory'] = d['hostname'] in all_inv_hosts
|
||||
|
||||
return render_template(
|
||||
'ansible/devices.html',
|
||||
inventory=inventory_data,
|
||||
db_devices=db_devices_list,
|
||||
all_inv_hosts=all_inv_hosts
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading inventory page: {e}")
|
||||
flash(f'Error loading inventory: {e}', 'error')
|
||||
return render_template(
|
||||
'ansible/devices.html',
|
||||
inventory={'groups': {}, 'raw_yaml': ''},
|
||||
db_devices=[],
|
||||
all_inv_hosts=set()
|
||||
)
|
||||
|
||||
@ansible_web_bp.route('/playbooks')
|
||||
def playbooks():
|
||||
"""Playbook management interface"""
|
||||
try:
|
||||
# Get available playbooks
|
||||
playbook_dir = ansible_service.playbook_dir
|
||||
playbooks = []
|
||||
|
||||
if playbook_dir.exists():
|
||||
for file in playbook_dir.glob('*.yml'):
|
||||
playbooks.append({
|
||||
'name': file.stem,
|
||||
'filename': file.name,
|
||||
'path': str(file)
|
||||
})
|
||||
|
||||
# Add built-in playbooks
|
||||
builtin_playbooks = [
|
||||
{
|
||||
'name': 'update_devices',
|
||||
'description': 'Update all packages on monitoring devices',
|
||||
'builtin': True
|
||||
},
|
||||
{
|
||||
'name': 'restart_service',
|
||||
'description': 'Restart monitoring services on devices',
|
||||
'builtin': True
|
||||
}
|
||||
]
|
||||
|
||||
return render_template('ansible/playbooks.html',
|
||||
playbooks=playbooks,
|
||||
builtin_playbooks=builtin_playbooks)
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading playbooks: {e}")
|
||||
flash(f'Error loading playbooks: {e}', 'error')
|
||||
return render_template('ansible/playbooks.html', playbooks=[], builtin_playbooks=[])
|
||||
|
||||
@ansible_web_bp.route('/execute', methods=['GET', 'POST'])
|
||||
def execute():
|
||||
"""Execute playbook interface"""
|
||||
if request.method == 'GET':
|
||||
try:
|
||||
preselect = request.args.get('playbook', '')
|
||||
inventory_data = ansible_service.get_inventory_data()
|
||||
|
||||
# Flatten all unique hosts from inventory for the host picker
|
||||
seen = set()
|
||||
all_inv_hosts = []
|
||||
for group in inventory_data.get('groups', {}).values():
|
||||
for h in group.get('hosts', []):
|
||||
if h['hostname'] not in seen:
|
||||
all_inv_hosts.append({
|
||||
'hostname': h['hostname'],
|
||||
'ip': h.get('ansible_host', '')
|
||||
})
|
||||
seen.add(h['hostname'])
|
||||
|
||||
return render_template('ansible/execute.html',
|
||||
inventory=inventory_data,
|
||||
all_inv_hosts=all_inv_hosts,
|
||||
preselect_playbook=preselect)
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading execute form: {e}")
|
||||
flash(f'Error loading form: {e}', 'error')
|
||||
return render_template('ansible/execute.html',
|
||||
inventory={'groups': {}},
|
||||
all_inv_hosts=[],
|
||||
preselect_playbook='')
|
||||
|
||||
elif request.method == 'POST':
|
||||
# Execute playbook
|
||||
try:
|
||||
import json as _json
|
||||
is_ajax = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||
|
||||
playbook_name = request.form.get('playbook')
|
||||
selected_hosts = request.form.getlist('hosts')
|
||||
priority = int(request.form.get('priority', 5))
|
||||
max_retries = int(request.form.get('max_retries', 0))
|
||||
check_mode = bool(request.form.get('check_mode'))
|
||||
extra_vars = {}
|
||||
|
||||
# Parse extra variables if provided
|
||||
extra_vars_str = request.form.get('extra_vars', '').strip()
|
||||
if extra_vars_str:
|
||||
try:
|
||||
extra_vars = _json.loads(extra_vars_str)
|
||||
except _json.JSONDecodeError:
|
||||
if is_ajax:
|
||||
return jsonify({'success': False, 'error': 'Invalid JSON in extra variables'}), 400
|
||||
flash('Invalid JSON format for extra variables', 'error')
|
||||
return redirect(url_for('ansible_web.execute'))
|
||||
|
||||
# Add check mode to extra vars if enabled
|
||||
if check_mode:
|
||||
extra_vars['check_mode'] = True
|
||||
|
||||
if not playbook_name:
|
||||
if is_ajax:
|
||||
return jsonify({'success': False, 'error': 'Playbook selection is required'}), 400
|
||||
flash('Playbook selection is required', 'error')
|
||||
return redirect(url_for('ansible_web.execute'))
|
||||
|
||||
if not selected_hosts:
|
||||
if is_ajax:
|
||||
return jsonify({'success': False, 'error': 'At least one device must be selected'}), 400
|
||||
flash('At least one device must be selected', 'error')
|
||||
return redirect(url_for('ansible_web.execute'))
|
||||
|
||||
# Create builtin playbooks if needed
|
||||
if playbook_name == 'update_devices':
|
||||
ansible_service.create_update_playbook()
|
||||
elif playbook_name == 'restart_service':
|
||||
ansible_service.create_restart_service_playbook()
|
||||
elif playbook_name == 'system_health':
|
||||
ansible_service.create_system_health_playbook()
|
||||
|
||||
# Add controller IP for callbacks
|
||||
extra_vars['ansible_controller_ip'] = request.host
|
||||
|
||||
# Use async execution (returns immediately with execution_id)
|
||||
result = ansible_service.execute_playbook_async(
|
||||
playbook_name=playbook_name,
|
||||
limit_hosts=selected_hosts,
|
||||
extra_vars=extra_vars,
|
||||
priority=priority,
|
||||
max_retries=max_retries
|
||||
)
|
||||
|
||||
if result['success']:
|
||||
if is_ajax:
|
||||
return jsonify({'success': True, 'execution_id': result['execution_id']})
|
||||
flash(f'Playbook "{playbook_name}" started! Monitoring execution...', 'success')
|
||||
return redirect(url_for('ansible_web.execution_details',
|
||||
execution_id=result['execution_id']))
|
||||
else:
|
||||
error_msg = result.get('error', 'Unknown error')
|
||||
if is_ajax:
|
||||
return jsonify({'success': False, 'error': error_msg}), 500
|
||||
flash(f'Playbook execution failed: {error_msg}', 'error')
|
||||
return redirect(url_for('ansible_web.execute'))
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error executing playbook: {e}")
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
flash(f'Error executing playbook: {e}', 'error')
|
||||
return redirect(url_for('ansible_web.execute'))
|
||||
|
||||
@ansible_web_bp.route('/executions')
|
||||
def executions():
|
||||
"""Execution history interface"""
|
||||
try:
|
||||
executions = ansible_service.get_execution_history(limit=100)
|
||||
return render_template('ansible/executions.html', executions=executions)
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading executions: {e}")
|
||||
flash(f'Error loading executions: {e}', 'error')
|
||||
return render_template('ansible/executions.html', executions=[])
|
||||
|
||||
@ansible_web_bp.route('/executions/<execution_id>')
|
||||
def execution_details(execution_id):
|
||||
"""View detailed execution results"""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
execution = session.query(PlaybookExecution).filter_by(
|
||||
execution_id=execution_id
|
||||
).first()
|
||||
|
||||
if not execution:
|
||||
flash('Execution not found', 'error')
|
||||
return redirect(url_for('ansible_web.executions'))
|
||||
|
||||
# Read log file if available
|
||||
log_content = None
|
||||
if execution.ansible_log_file:
|
||||
try:
|
||||
with open(execution.ansible_log_file, 'r') as f:
|
||||
log_content = f.read()
|
||||
except FileNotFoundError:
|
||||
log_content = "Log file not found"
|
||||
|
||||
return render_template('ansible/execution_details.html',
|
||||
execution=execution,
|
||||
log_content=log_content)
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading execution details: {e}")
|
||||
flash(f'Error loading execution details: {e}', 'error')
|
||||
return redirect(url_for('ansible_web.executions'))
|
||||
|
||||
@ansible_web_bp.route('/ssh/setup')
|
||||
def ssh_setup():
|
||||
"""SSH key setup interface"""
|
||||
try:
|
||||
# Check if SSH key exists
|
||||
key_exists = ansible_service.ssh_key_path.exists()
|
||||
public_key = None
|
||||
|
||||
if key_exists:
|
||||
public_key_path = ansible_service.ssh_key_path.with_suffix('.pub')
|
||||
if public_key_path.exists():
|
||||
with open(public_key_path, 'r') as f:
|
||||
public_key = f.read().strip()
|
||||
|
||||
settings = ansible_service.load_settings()
|
||||
|
||||
return render_template('ansible/ssh_setup.html',
|
||||
key_exists=key_exists,
|
||||
public_key=public_key,
|
||||
settings=settings)
|
||||
except Exception as e:
|
||||
logging.error(f"Error in SSH setup page: {e}")
|
||||
flash(f'Error loading SSH setup: {e}', 'error')
|
||||
return render_template('ansible/ssh_setup.html', key_exists=False, public_key=None, settings={})
|
||||
|
||||
|
||||
@ansible_web_bp.route('/ssh/settings', methods=['POST'])
|
||||
def save_ssh_settings():
|
||||
"""Save SSH settings (fallback password etc.)"""
|
||||
try:
|
||||
fallback_password = request.form.get('ssh_fallback_password', '').strip()
|
||||
if not fallback_password:
|
||||
flash('Fallback password cannot be empty.', 'error')
|
||||
return redirect(url_for('ansible_web.ssh_setup'))
|
||||
|
||||
ansible_service.save_settings({'ssh_fallback_password': fallback_password})
|
||||
flash('SSH settings saved successfully.', 'success')
|
||||
except Exception as e:
|
||||
logging.error(f"Error saving SSH settings: {e}")
|
||||
flash(f'Error saving SSH settings: {e}', 'error')
|
||||
return redirect(url_for('ansible_web.ssh_setup'))
|
||||
|
||||
@ansible_web_bp.route('/ssh/generate', methods=['POST'])
|
||||
def generate_ssh_keys():
|
||||
"""Generate new SSH keys"""
|
||||
try:
|
||||
result = ansible_service.setup_ssh_keys()
|
||||
|
||||
if result['success']:
|
||||
flash('SSH keys generated successfully!', 'success')
|
||||
else:
|
||||
flash(f'Error generating SSH keys: {result.get("error")}', 'error')
|
||||
|
||||
return redirect(url_for('ansible_web.ssh_setup'))
|
||||
except Exception as e:
|
||||
logging.error(f"Error generating SSH keys: {e}")
|
||||
flash(f'Error generating SSH keys: {e}', 'error')
|
||||
return redirect(url_for('ansible_web.ssh_setup'))
|
||||
|
||||
@ansible_web_bp.route('/ssh/test', methods=['POST'])
|
||||
def test_ssh():
|
||||
"""Test SSH connectivity to selected devices"""
|
||||
try:
|
||||
selected_ips = request.form.getlist('device_ips')
|
||||
|
||||
if not selected_ips:
|
||||
flash('Please select at least one device to test', 'error')
|
||||
return redirect(url_for('ansible_web.devices'))
|
||||
|
||||
# Test connectivity
|
||||
results = ansible_service.bulk_ssh_test(selected_ips)
|
||||
|
||||
# Count results
|
||||
successful = sum(1 for r in results.values() if r.get('success'))
|
||||
total = len(results)
|
||||
|
||||
flash(f'SSH test completed: {successful}/{total} devices reachable',
|
||||
'success' if successful == total else 'warning')
|
||||
|
||||
return render_template('ansible/ssh_test_results.html', results=results)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error testing SSH: {e}")
|
||||
flash(f'Error testing SSH: {e}', 'error')
|
||||
return redirect(url_for('ansible_web.devices'))
|
||||
|
||||
# API endpoints for AJAX calls
|
||||
@ansible_web_bp.route('/api/refresh_inventory', methods=['POST'])
|
||||
def api_refresh_inventory():
|
||||
"""AJAX endpoint to refresh inventory"""
|
||||
try:
|
||||
inventory = ansible_service.generate_dynamic_inventory()
|
||||
device_count = len(inventory.get('all', {}).get('children', {}).get('monitoring_devices', {}).get('hosts', {}))
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Inventory refreshed with {device_count} devices'
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"Error refreshing inventory: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@ansible_web_bp.route('/api/execution_status/<execution_id>')
|
||||
def api_execution_status(execution_id):
|
||||
"""AJAX endpoint to get execution status"""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
execution = session.query(PlaybookExecution).filter_by(
|
||||
execution_id=execution_id
|
||||
).first()
|
||||
|
||||
if not execution:
|
||||
return jsonify({'error': 'Execution not found'}), 404
|
||||
|
||||
return jsonify({
|
||||
'id': execution.id,
|
||||
'status': execution.status,
|
||||
'start_time': execution.start_time.isoformat() if execution.start_time else None,
|
||||
'end_time': execution.end_time.isoformat() if execution.end_time else None,
|
||||
'exit_code': execution.exit_code,
|
||||
'successful_hosts': execution.successful_hosts,
|
||||
'failed_hosts': execution.failed_hosts,
|
||||
'unreachable_hosts': execution.unreachable_hosts
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"Error getting execution status: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@ansible_web_bp.route('/upload_playbook', methods=['POST'])
|
||||
def upload_playbook():
|
||||
"""Upload a custom playbook file"""
|
||||
try:
|
||||
if 'playbook_file' not in request.files:
|
||||
flash('No playbook file selected', 'error')
|
||||
return redirect(url_for('ansible_web.playbooks'))
|
||||
|
||||
file = request.files['playbook_file']
|
||||
if file.filename == '':
|
||||
flash('No playbook file selected', 'error')
|
||||
return redirect(url_for('ansible_web.playbooks'))
|
||||
|
||||
if file and file.filename.lower().endswith(('.yml', '.yaml')):
|
||||
# Get playbook name
|
||||
playbook_name = request.form.get('playbook_name', '').strip()
|
||||
if not playbook_name:
|
||||
playbook_name = file.filename.rsplit('.', 1)[0]
|
||||
|
||||
# Clean filename
|
||||
import re
|
||||
safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', playbook_name)
|
||||
filename = f"{safe_filename}.yml"
|
||||
|
||||
# Save file
|
||||
playbook_path = ansible_service.playbook_dir / filename
|
||||
file.save(str(playbook_path))
|
||||
|
||||
flash(f'Playbook "{filename}" uploaded successfully!', 'success')
|
||||
else:
|
||||
flash('Invalid file type. Please upload a .yml or .yaml file.', 'error')
|
||||
|
||||
return redirect(url_for('ansible_web.playbooks'))
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error uploading playbook: {e}")
|
||||
flash(f'Error uploading playbook: {e}', 'error')
|
||||
return redirect(url_for('ansible_web.playbooks'))
|
||||
|
||||
@ansible_web_bp.route('/playbook/content')
|
||||
def playbook_content():
|
||||
"""Get playbook content for viewing"""
|
||||
try:
|
||||
playbook_path = request.args.get('path')
|
||||
if not playbook_path:
|
||||
return "No playbook path provided", 400
|
||||
|
||||
# Security check - ensure path is within playbooks directory
|
||||
from pathlib import Path
|
||||
requested_path = Path(playbook_path)
|
||||
if not requested_path.is_absolute():
|
||||
requested_path = ansible_service.playbook_dir / requested_path
|
||||
|
||||
# Ensure path is within playbook directory
|
||||
try:
|
||||
requested_path.resolve().relative_to(ansible_service.playbook_dir.resolve())
|
||||
except ValueError:
|
||||
return "Invalid playbook path", 400
|
||||
|
||||
if not requested_path.exists():
|
||||
return "Playbook file not found", 404
|
||||
|
||||
with open(requested_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
return content, 200, {'Content-Type': 'text/plain'}
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error reading playbook content: {e}")
|
||||
return f"Error reading playbook: {e}", 500
|
||||
|
||||
@ansible_web_bp.route('/playbook/save', methods=['POST'])
|
||||
def save_playbook():
|
||||
"""Save a new or existing playbook"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
name = data.get('name', '').strip()
|
||||
content = data.get('content', '').strip()
|
||||
is_new = data.get('is_new', False)
|
||||
|
||||
if not name:
|
||||
return jsonify({'error': 'Playbook name is required'}), 400
|
||||
|
||||
if not content:
|
||||
return jsonify({'error': 'Playbook content is required'}), 400
|
||||
|
||||
# Validate YAML syntax
|
||||
try:
|
||||
import yaml
|
||||
yaml.safe_load(content)
|
||||
except yaml.YAMLError as e:
|
||||
return jsonify({'error': f'Invalid YAML syntax: {e}'}), 400
|
||||
|
||||
# Clean filename
|
||||
import re
|
||||
safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', name)
|
||||
filename = f"{safe_filename}.yml"
|
||||
|
||||
# Save file
|
||||
playbook_path = ansible_service.playbook_dir / filename
|
||||
|
||||
# Check if file exists and not updating
|
||||
if is_new and playbook_path.exists():
|
||||
return jsonify({'error': f'Playbook {filename} already exists'}), 400
|
||||
|
||||
with open(playbook_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Playbook {filename} saved successfully',
|
||||
'filename': filename
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error saving playbook: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@ansible_web_bp.route('/playbook/validate', methods=['POST'])
|
||||
def validate_playbook():
|
||||
"""Validate playbook YAML syntax and structure"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
content = data.get('content', '').strip()
|
||||
if not content:
|
||||
return jsonify({'error': 'No content to validate'}), 400
|
||||
|
||||
# Validate YAML syntax
|
||||
try:
|
||||
import yaml
|
||||
parsed = yaml.safe_load(content)
|
||||
except yaml.YAMLError as e:
|
||||
return jsonify({'valid': False, 'error': f'YAML syntax error: {e}'})
|
||||
|
||||
# Basic Ansible playbook structure validation
|
||||
if not isinstance(parsed, list):
|
||||
return jsonify({'valid': False, 'error': 'Playbook must be a list of plays'})
|
||||
|
||||
for i, play in enumerate(parsed):
|
||||
if not isinstance(play, dict):
|
||||
return jsonify({'valid': False, 'error': f'Play {i+1} must be a dictionary'})
|
||||
|
||||
if 'hosts' not in play:
|
||||
return jsonify({'valid': False, 'error': f'Play {i+1} is missing required "hosts" field'})
|
||||
|
||||
if 'tasks' not in play and 'roles' not in play:
|
||||
return jsonify({'valid': False, 'error': f'Play {i+1} must have either "tasks" or "roles"'})
|
||||
|
||||
return jsonify({'valid': True, 'message': 'Playbook is valid'})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error validating playbook: {e}")
|
||||
return jsonify({'valid': False, 'error': str(e)})
|
||||
|
||||
@ansible_web_bp.route('/playbook/delete', methods=['POST'])
|
||||
def delete_playbook():
|
||||
"""Delete a custom playbook"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
playbook_name = data.get('playbook_name', '').strip()
|
||||
if not playbook_name:
|
||||
return jsonify({'error': 'Playbook name is required'}), 400
|
||||
|
||||
# Find the playbook file
|
||||
import re
|
||||
safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', playbook_name)
|
||||
|
||||
# Try both with and without .yml extension
|
||||
possible_files = [
|
||||
ansible_service.playbook_dir / f"{safe_filename}.yml",
|
||||
ansible_service.playbook_dir / f"{safe_filename}.yaml",
|
||||
ansible_service.playbook_dir / f"{playbook_name}.yml",
|
||||
ansible_service.playbook_dir / f"{playbook_name}.yaml"
|
||||
]
|
||||
|
||||
playbook_path = None
|
||||
for path in possible_files:
|
||||
if path.exists():
|
||||
playbook_path = path
|
||||
break
|
||||
|
||||
if not playbook_path:
|
||||
return jsonify({'error': f'Playbook {playbook_name} not found'}), 404
|
||||
|
||||
# Security check - ensure path is within playbooks directory
|
||||
try:
|
||||
playbook_path.resolve().relative_to(ansible_service.playbook_dir.resolve())
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid playbook path'}), 400
|
||||
|
||||
# Delete the file
|
||||
playbook_path.unlink()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Playbook {playbook_name} deleted successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error deleting playbook: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
578
app/web/main.py
Normal file
578
app/web/main.py
Normal file
@@ -0,0 +1,578 @@
|
||||
"""
|
||||
Main web routes for dashboard and device management
|
||||
"""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||
from app.models import Device, LogEntry, MessageTemplate, AnsibleExecution, WMTUpdateRequest, InventoryGroup, device_inventory_association
|
||||
from config.database_config import get_db
|
||||
from app.services.log_service import LogCompressionService
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from sqlalchemy import text, func
|
||||
import logging
|
||||
import yaml
|
||||
|
||||
# Create blueprint
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
# Initialize services
|
||||
log_service = LogCompressionService()
|
||||
|
||||
@main_bp.route('/')
|
||||
def index():
|
||||
"""Redirect root to devices page"""
|
||||
return redirect(url_for('main.devices'))
|
||||
|
||||
@main_bp.route('/dashboard')
|
||||
def dashboard():
|
||||
"""Redirect /dashboard to devices"""
|
||||
return redirect(url_for('main.devices'))
|
||||
|
||||
@main_bp.route('/devices')
|
||||
def devices():
|
||||
"""Device management page"""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
devices = session.query(Device).order_by(Device.last_seen.desc()).all()
|
||||
|
||||
# Get log count per device
|
||||
device_log_counts = {}
|
||||
for device in devices:
|
||||
log_count = session.query(LogEntry).filter_by(device_id=device.id).count()
|
||||
device_log_counts[device.id] = log_count
|
||||
|
||||
pending_count = session.query(WMTUpdateRequest).filter_by(status='pending').count()
|
||||
|
||||
return render_template('device_management.html',
|
||||
devices=devices,
|
||||
device_log_counts=device_log_counts,
|
||||
pending_count=pending_count)
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading devices: {e}")
|
||||
flash(f'Error loading devices: {e}', 'error')
|
||||
return render_template('device_management.html', devices=[], device_log_counts={}, pending_count=0)
|
||||
|
||||
@main_bp.route('/device/<int:device_id>')
|
||||
def device_detail(device_id):
|
||||
"""Device detail page with logs and stats"""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
device = session.query(Device).get(device_id)
|
||||
|
||||
if not device:
|
||||
flash('Device not found', 'error')
|
||||
return redirect(url_for('main.devices'))
|
||||
|
||||
# Get device logs (last 100)
|
||||
logs = session.query(LogEntry).filter_by(device_id=device_id).order_by(
|
||||
LogEntry.timestamp.desc()
|
||||
).limit(100).all()
|
||||
|
||||
# Get log statistics
|
||||
log_stats = {
|
||||
'total': len(logs),
|
||||
'by_severity': {},
|
||||
'last_24h': 0
|
||||
}
|
||||
|
||||
last_24h = datetime.utcnow() - timedelta(hours=24)
|
||||
for log in logs:
|
||||
# Count by severity
|
||||
severity = log.severity
|
||||
log_stats['by_severity'][severity] = log_stats['by_severity'].get(severity, 0) + 1
|
||||
|
||||
# Count last 24h
|
||||
if log.timestamp >= last_24h:
|
||||
log_stats['last_24h'] += 1
|
||||
|
||||
return render_template('device_detail.html',
|
||||
device=device,
|
||||
logs=logs,
|
||||
log_stats=log_stats)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading device detail: {e}")
|
||||
flash(f'Error loading device detail: {e}', 'error')
|
||||
return redirect(url_for('main.devices'))
|
||||
|
||||
@main_bp.route('/logs')
|
||||
def logs():
|
||||
"""Log viewer with filtering"""
|
||||
try:
|
||||
# Get filter parameters
|
||||
device_id = request.args.get('device_id', type=int)
|
||||
severity = request.args.get('severity')
|
||||
search = request.args.get('search', '')
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = min(request.args.get('per_page', 50, type=int), 200)
|
||||
|
||||
with get_db().get_session() as session:
|
||||
# Build query
|
||||
query = session.query(LogEntry).join(Device)
|
||||
|
||||
# Apply filters
|
||||
if device_id:
|
||||
query = query.filter(LogEntry.device_id == device_id)
|
||||
|
||||
if severity:
|
||||
query = query.filter(LogEntry.severity == severity)
|
||||
|
||||
if search:
|
||||
# Search in resolved message or full message
|
||||
query = query.filter(
|
||||
# This is simplified - in production you'd want full-text search
|
||||
LogEntry.full_message.contains(search)
|
||||
)
|
||||
|
||||
# Order by timestamp desc
|
||||
query = query.order_by(LogEntry.timestamp.desc())
|
||||
|
||||
# Get total count for pagination
|
||||
total = query.count()
|
||||
|
||||
# Apply pagination
|
||||
offset = (page - 1) * per_page
|
||||
logs = query.offset(offset).limit(per_page).all()
|
||||
|
||||
# Calculate pagination info
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
has_prev = page > 1
|
||||
has_next = page < total_pages
|
||||
|
||||
# Get device list for filter dropdown
|
||||
devices = session.query(Device).order_by(Device.hostname).all()
|
||||
|
||||
pagination = {
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
'total': total,
|
||||
'total_pages': total_pages,
|
||||
'has_prev': has_prev,
|
||||
'has_next': has_next,
|
||||
'prev_num': page - 1 if has_prev else None,
|
||||
'next_num': page + 1 if has_next else None
|
||||
}
|
||||
|
||||
return render_template('logs.html',
|
||||
logs=logs,
|
||||
pagination=pagination,
|
||||
devices=devices,
|
||||
current_device_id=device_id,
|
||||
current_severity=severity,
|
||||
current_search=search)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading logs: {e}")
|
||||
flash(f'Error loading logs: {e}', 'error')
|
||||
return render_template('logs.html',
|
||||
logs=[],
|
||||
pagination={},
|
||||
devices=[],
|
||||
current_device_id=None,
|
||||
current_severity=None,
|
||||
current_search='')
|
||||
|
||||
@main_bp.route('/templates')
|
||||
def templates():
|
||||
"""Message templates management"""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
templates = session.query(MessageTemplate).order_by(
|
||||
MessageTemplate.usage_count.desc()
|
||||
).all()
|
||||
|
||||
# Get template statistics
|
||||
template_stats = {
|
||||
'total': len(templates),
|
||||
'by_category': {},
|
||||
'total_usage': sum(t.usage_count for t in templates)
|
||||
}
|
||||
|
||||
for template in templates:
|
||||
category = template.category
|
||||
template_stats['by_category'][category] = template_stats['by_category'].get(category, 0) + 1
|
||||
|
||||
return render_template('templates.html',
|
||||
templates=templates,
|
||||
template_stats=template_stats)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading templates: {e}")
|
||||
flash(f'Error loading templates: {e}', 'error')
|
||||
return render_template('templates.html', templates=[], template_stats={})
|
||||
|
||||
@main_bp.route('/stats')
|
||||
def stats():
|
||||
"""System statistics and analytics"""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
# Get compression stats
|
||||
compression_stats = log_service.get_compression_stats()
|
||||
|
||||
# Get device statistics
|
||||
device_stats = {
|
||||
'total': session.query(Device).count(),
|
||||
'active': session.query(Device).filter_by(status='active').count(),
|
||||
'inactive': session.query(Device).filter_by(status='inactive').count(),
|
||||
'maintenance': session.query(Device).filter_by(status='maintenance').count()
|
||||
}
|
||||
|
||||
# Get log statistics by time periods
|
||||
now = datetime.utcnow()
|
||||
periods = {
|
||||
'last_hour': now - timedelta(hours=1),
|
||||
'last_24h': now - timedelta(hours=24),
|
||||
'last_week': now - timedelta(days=7),
|
||||
'last_month': now - timedelta(days=30)
|
||||
}
|
||||
|
||||
log_stats = {}
|
||||
for period_name, period_start in periods.items():
|
||||
count = session.query(LogEntry).filter(
|
||||
LogEntry.timestamp >= period_start
|
||||
).count()
|
||||
log_stats[period_name] = count
|
||||
|
||||
# Get execution statistics
|
||||
exec_stats = {
|
||||
'total': session.query(AnsibleExecution).count(),
|
||||
'successful': session.query(AnsibleExecution).filter_by(status='completed').count(),
|
||||
'failed': session.query(AnsibleExecution).filter_by(status='failed').count(),
|
||||
'running': session.query(AnsibleExecution).filter_by(status='running').count()
|
||||
}
|
||||
|
||||
return render_template('stats.html',
|
||||
compression_stats=compression_stats,
|
||||
device_stats=device_stats,
|
||||
log_stats=log_stats,
|
||||
exec_stats=exec_stats)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading stats: {e}")
|
||||
flash(f'Error loading stats: {e}', 'error')
|
||||
return render_template('stats.html',
|
||||
compression_stats={},
|
||||
device_stats={},
|
||||
log_stats={},
|
||||
exec_stats={})
|
||||
|
||||
# API Endpoints for Device Management
|
||||
|
||||
@main_bp.route('/api/devices/add', methods=['POST'])
|
||||
def api_add_device():
|
||||
"""API endpoint to add a device manually"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
# Validate required fields
|
||||
required_fields = ['hostname', 'device_ip', 'nume_masa']
|
||||
for field in required_fields:
|
||||
if not data.get(field):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'Missing required field: {field}'
|
||||
}), 400
|
||||
|
||||
# Check if device already exists (MAC-first, then hostname/IP)
|
||||
with get_db().get_session() as session:
|
||||
mac_input = data.get('mac_address', '').strip().lower() or None
|
||||
|
||||
existing_device = None
|
||||
if mac_input:
|
||||
existing_device = session.query(Device).filter_by(mac_address=mac_input).first()
|
||||
|
||||
if not existing_device:
|
||||
existing_device = session.query(Device).filter(
|
||||
(Device.hostname == data['hostname']) |
|
||||
(Device.device_ip == data['device_ip'])
|
||||
).first()
|
||||
|
||||
if existing_device:
|
||||
# If found by MAC or hostname/IP, update it rather than reject
|
||||
if mac_input and not existing_device.mac_address:
|
||||
existing_device.mac_address = mac_input
|
||||
existing_device.hostname = data['hostname']
|
||||
existing_device.device_ip = data['device_ip']
|
||||
existing_device.nume_masa = data['nume_masa']
|
||||
if data.get('device_type'):
|
||||
existing_device.device_type = data['device_type']
|
||||
if data.get('os_version'):
|
||||
existing_device.os_version = data['os_version']
|
||||
if data.get('location'):
|
||||
existing_device.location = data['location']
|
||||
if data.get('status'):
|
||||
existing_device.status = data['status']
|
||||
existing_device.config_updated_at = datetime.utcnow()
|
||||
existing_device.info_reviewed_at = datetime.utcnow()
|
||||
session.flush()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Device already existed – record updated',
|
||||
'device_id': existing_device.id
|
||||
}), 200
|
||||
|
||||
# Create new device
|
||||
new_device = Device(
|
||||
hostname=data['hostname'],
|
||||
device_ip=data['device_ip'],
|
||||
nume_masa=data['nume_masa'],
|
||||
mac_address=data.get('mac_address', '').strip().lower() or None,
|
||||
device_type=data.get('device_type', 'unknown'),
|
||||
os_version=data.get('os_version'),
|
||||
status=data.get('status', 'active'),
|
||||
location=data.get('location'),
|
||||
description=data.get('description'),
|
||||
config_updated_at=datetime.utcnow(),
|
||||
info_reviewed_at=datetime.utcnow(),
|
||||
last_seen=datetime.utcnow()
|
||||
)
|
||||
|
||||
session.add(new_device)
|
||||
session.commit()
|
||||
|
||||
# Refresh ansible inventory
|
||||
try:
|
||||
from app.services.ansible_service import AnsibleService
|
||||
ansible_service = AnsibleService()
|
||||
ansible_service.generate_dynamic_inventory()
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to update ansible inventory: {e}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Device added successfully',
|
||||
'device_id': new_device.id
|
||||
}), 201
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error adding device: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'Error adding device: {str(e)}'
|
||||
}), 500
|
||||
|
||||
@main_bp.route('/api/devices/<int:device_id>/execute', methods=['POST'])
|
||||
def api_execute_device_command(device_id):
|
||||
"""API endpoint to execute commands on devices"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
command = data.get('command')
|
||||
|
||||
if not command:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Command is required'
|
||||
}), 400
|
||||
|
||||
with get_db().get_session() as session:
|
||||
device = session.query(Device).get(device_id)
|
||||
|
||||
if not device:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Device not found'
|
||||
}), 404
|
||||
|
||||
# Mock implementation - in production this would execute actual commands
|
||||
if command == 'ping':
|
||||
# Simulate ping command
|
||||
import subprocess
|
||||
try:
|
||||
result = subprocess.run(['ping', '-c', '1', device.device_ip],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
success = result.returncode == 0
|
||||
output = result.stdout if success else result.stderr
|
||||
|
||||
return jsonify({
|
||||
'success': success,
|
||||
'command': command,
|
||||
'output': output,
|
||||
'device': device.hostname
|
||||
})
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Command timed out',
|
||||
'command': command
|
||||
})
|
||||
else:
|
||||
# For other commands, return placeholder
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Command "{command}" would be executed on {device.hostname}',
|
||||
'command': command,
|
||||
'note': 'This is a placeholder implementation'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error executing device command: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'Error executing command: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device edit / delete (unified – includes WMT fields)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@main_bp.route('/devices/<int:device_id>/edit', methods=['GET', 'POST'])
|
||||
def device_edit(device_id):
|
||||
"""Edit a device record (monitoring + WMT fields)."""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
device = session.query(Device).filter_by(id=device_id).first()
|
||||
if not device:
|
||||
flash('Device not found.', 'error')
|
||||
return redirect(url_for('main.devices'))
|
||||
|
||||
if request.method == 'POST':
|
||||
device.hostname = request.form.get('hostname', '').strip() or device.hostname
|
||||
device.device_ip = request.form.get('device_ip', '').strip() or device.device_ip
|
||||
device.nume_masa = request.form.get('nume_masa', '').strip() or device.nume_masa
|
||||
mac_raw = request.form.get('mac_address', '').strip().lower() or None
|
||||
# Only assign MAC if no other device owns it
|
||||
if mac_raw and mac_raw != device.mac_address:
|
||||
conflict = session.query(Device).filter(
|
||||
Device.mac_address == mac_raw, Device.id != device_id
|
||||
).first()
|
||||
if conflict:
|
||||
flash(f'MAC {mac_raw} is already assigned to {conflict.hostname}.', 'error')
|
||||
return render_template('device_edit.html', device=device)
|
||||
device.mac_address = mac_raw
|
||||
device.status = request.form.get('status', 'active')
|
||||
device.location = request.form.get('location', '').strip() or None
|
||||
device.device_type = request.form.get('device_type', '').strip() or 'unknown'
|
||||
device.description = request.form.get('description', '').strip() or None
|
||||
device.os_version = request.form.get('os_version', '').strip() or None
|
||||
device.config_updated_at = datetime.utcnow()
|
||||
device.info_reviewed_at = datetime.utcnow()
|
||||
flash('Device updated.', 'success')
|
||||
return redirect(url_for('main.devices'))
|
||||
|
||||
return render_template(
|
||||
'device_edit.html',
|
||||
device=device,
|
||||
breadcrumbs=[
|
||||
{'url': url_for('main.dashboard'), 'title': 'Dashboard'},
|
||||
{'url': url_for('main.devices'), 'title': 'Devices'},
|
||||
{'url': '#', 'title': f'Edit {device.hostname}'},
|
||||
],
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f'Device edit error: {e}')
|
||||
flash(f'Error: {e}', 'error')
|
||||
return redirect(url_for('main.devices'))
|
||||
|
||||
|
||||
@main_bp.route('/devices/<int:device_id>/delete', methods=['POST'])
|
||||
def device_delete(device_id):
|
||||
"""Delete a device and all its logs."""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
device = session.query(Device).filter_by(id=device_id).first()
|
||||
if device:
|
||||
name = device.hostname
|
||||
session.delete(device)
|
||||
flash(f'Device {name} deleted.', 'success')
|
||||
else:
|
||||
flash('Device not found.', 'error')
|
||||
except Exception as e:
|
||||
logging.error(f'Device delete error: {e}')
|
||||
flash(f'Error deleting device: {e}', 'error')
|
||||
return redirect(url_for('main.devices'))
|
||||
|
||||
|
||||
# ── Admin page ────────────────────────────────────────────────────────
|
||||
|
||||
INVENTORY_FILE = Path('ansible/inventory/dynamic_inventory.yaml')
|
||||
|
||||
@main_bp.route('/admin')
|
||||
def admin():
|
||||
"""Admin / maintenance page with DB and inventory stats."""
|
||||
stats = {}
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
stats['devices'] = session.query(func.count(Device.id)).scalar()
|
||||
stats['logs'] = session.query(func.count(LogEntry.id)).scalar()
|
||||
stats['templates'] = session.query(func.count(MessageTemplate.id)).scalar()
|
||||
stats['inventory_groups'] = session.query(func.count(InventoryGroup.id)).scalar()
|
||||
stats['wmt_requests'] = session.query(func.count(WMTUpdateRequest.id)).scalar()
|
||||
except Exception as e:
|
||||
logging.error(f'Admin stats error: {e}')
|
||||
# Inventory host count
|
||||
try:
|
||||
if INVENTORY_FILE.exists():
|
||||
data = yaml.safe_load(INVENTORY_FILE.read_text()) or {}
|
||||
all_children = data.get('all', {}).get('children', {})
|
||||
inv_hosts = set()
|
||||
for g in all_children.values():
|
||||
inv_hosts.update((g or {}).get('hosts', {}).keys())
|
||||
stats['inventory_hosts'] = len(inv_hosts)
|
||||
stats['inventory_groups_yaml'] = len(all_children)
|
||||
else:
|
||||
stats['inventory_hosts'] = 0
|
||||
stats['inventory_groups_yaml'] = 0
|
||||
except Exception as e:
|
||||
logging.error(f'Admin inventory stats error: {e}')
|
||||
stats['inventory_hosts'] = '?'
|
||||
stats['inventory_groups_yaml'] = '?'
|
||||
return render_template('admin.html', stats=stats)
|
||||
|
||||
|
||||
@main_bp.route('/admin/clear/logs', methods=['POST'])
|
||||
def admin_clear_logs():
|
||||
"""Delete all log entries from the database."""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
count = session.query(LogEntry).delete()
|
||||
session.commit()
|
||||
return jsonify({'success': True, 'deleted': count})
|
||||
except Exception as e:
|
||||
logging.error(f'Admin clear logs error: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@main_bp.route('/admin/clear/devices', methods=['POST'])
|
||||
def admin_clear_devices():
|
||||
"""Delete all devices (and their log entries) from the database."""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
session.execute(text('DELETE FROM device_inventory_groups'))
|
||||
logs = session.query(LogEntry).delete()
|
||||
devices = session.query(Device).delete()
|
||||
session.commit()
|
||||
return jsonify({'success': True, 'deleted_devices': devices, 'deleted_logs': logs})
|
||||
except Exception as e:
|
||||
logging.error(f'Admin clear devices error: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@main_bp.route('/admin/clear/inventory', methods=['POST'])
|
||||
def admin_clear_inventory():
|
||||
"""Reset the Ansible inventory file to a completely empty state."""
|
||||
try:
|
||||
empty = {'_meta': {'hostvars': {}}, 'all': {'hosts': {}, 'children': {}}}
|
||||
INVENTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
INVENTORY_FILE.write_text(yaml.dump(empty, default_flow_style=False))
|
||||
# Also clear inventory_groups table
|
||||
with get_db().get_session() as session:
|
||||
session.execute(text('DELETE FROM device_inventory_groups'))
|
||||
groups = session.query(InventoryGroup).delete()
|
||||
session.commit()
|
||||
return jsonify({'success': True, 'groups_deleted': groups})
|
||||
except Exception as e:
|
||||
logging.error(f'Admin clear inventory error: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@main_bp.route('/admin/clear/wmt', methods=['POST'])
|
||||
def admin_clear_wmt():
|
||||
"""Delete all WMT update requests."""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
count = session.query(WMTUpdateRequest).delete()
|
||||
session.commit()
|
||||
return jsonify({'success': True, 'deleted': count})
|
||||
except Exception as e:
|
||||
logging.error(f'Admin clear WMT requests error: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
204
app/web/wmt.py
Normal file
204
app/web/wmt.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
WMT management web routes – global settings, device registry, update requests.
|
||||
"""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from datetime import datetime
|
||||
from app.models import WMTGlobalConfig, Device, WMTUpdateRequest
|
||||
from config.database_config import get_db
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
wmt_web_bp = Blueprint('wmt_web', __name__, url_prefix='/wmt')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_or_create_global_config(session):
|
||||
cfg = session.query(WMTGlobalConfig).first()
|
||||
if cfg is None:
|
||||
cfg = WMTGlobalConfig()
|
||||
session.add(cfg)
|
||||
session.flush()
|
||||
return cfg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@wmt_web_bp.route('/')
|
||||
def index():
|
||||
"""WMT management dashboard."""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
global_cfg = _get_or_create_global_config(session)
|
||||
devices = session.query(Device).filter(Device.mac_address.isnot(None)).order_by(Device.nume_masa).all()
|
||||
pending_count = session.query(WMTUpdateRequest).filter_by(status='pending').count()
|
||||
recent_requests = (
|
||||
session.query(WMTUpdateRequest)
|
||||
.order_by(WMTUpdateRequest.submitted_at.desc())
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
return render_template(
|
||||
'wmt/index.html',
|
||||
global_cfg=global_cfg,
|
||||
devices=devices,
|
||||
pending_count=pending_count,
|
||||
recent_requests=recent_requests,
|
||||
breadcrumbs=[{'url': url_for('wmt_web.index'), 'title': 'WMT Management'}],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'WMT dashboard error: {e}')
|
||||
flash(f'Error loading dashboard: {e}', 'error')
|
||||
return render_template('wmt/index.html', global_cfg=None, devices=[],
|
||||
pending_count=0, recent_requests=[])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@wmt_web_bp.route('/settings', methods=['GET', 'POST'])
|
||||
def settings():
|
||||
"""View and edit global WMT configuration."""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
cfg = _get_or_create_global_config(session)
|
||||
|
||||
if request.method == 'POST':
|
||||
cfg.chrome_url = request.form.get('chrome_url', '').strip()
|
||||
cfg.chrome_local_url = request.form.get('chrome_local_url', '').strip() or None
|
||||
cfg.chrome_insecure_origin = request.form.get('chrome_insecure_origin', '').strip()
|
||||
cfg.card_api_base_url = request.form.get('card_api_base_url', '').strip()
|
||||
cfg.server_log_url = request.form.get('server_log_url', '').strip()
|
||||
cfg.internet_check_host = request.form.get('internet_check_host', '').strip()
|
||||
cfg.update_host = request.form.get('update_host', '').strip()
|
||||
cfg.update_user = request.form.get('update_user', '').strip()
|
||||
cfg.notes = request.form.get('notes', '').strip() or None
|
||||
cfg.updated_at = datetime.utcnow()
|
||||
cfg.updated_by = 'admin'
|
||||
flash('Global settings saved.', 'success')
|
||||
return redirect(url_for('wmt_web.settings'))
|
||||
|
||||
return render_template(
|
||||
'wmt/settings.html',
|
||||
cfg=cfg,
|
||||
breadcrumbs=[
|
||||
{'url': url_for('wmt_web.index'), 'title': 'WMT Management'},
|
||||
{'url': url_for('wmt_web.settings'), 'title': 'Global Settings'},
|
||||
],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'WMT settings error: {e}')
|
||||
flash(f'Error: {e}', 'error')
|
||||
return redirect(url_for('wmt_web.index'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Update requests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@wmt_web_bp.route('/requests')
|
||||
def update_requests():
|
||||
"""List all device update requests."""
|
||||
status_filter = request.args.get('status', 'pending')
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
query = session.query(WMTUpdateRequest)
|
||||
if status_filter != 'all':
|
||||
query = query.filter_by(status=status_filter)
|
||||
req_list = query.order_by(WMTUpdateRequest.submitted_at.desc()).all()
|
||||
pending_count = session.query(WMTUpdateRequest).filter_by(status='pending').count()
|
||||
|
||||
return render_template(
|
||||
'wmt/requests.html',
|
||||
requests=req_list,
|
||||
status_filter=status_filter,
|
||||
pending_count=pending_count,
|
||||
breadcrumbs=[
|
||||
{'url': url_for('wmt_web.index'), 'title': 'WMT Management'},
|
||||
{'url': url_for('wmt_web.update_requests'), 'title': 'Update Requests'},
|
||||
],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'WMT requests list error: {e}')
|
||||
flash(f'Error: {e}', 'error')
|
||||
return redirect(url_for('wmt_web.index'))
|
||||
|
||||
|
||||
@wmt_web_bp.route('/requests/<int:req_id>/accept', methods=['POST'])
|
||||
def accept_request(req_id):
|
||||
"""Accept an update request: apply proposed values to WMTDevice."""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
req = session.query(WMTUpdateRequest).filter_by(id=req_id).first()
|
||||
if not req:
|
||||
flash('Request not found.', 'error')
|
||||
return redirect(url_for('wmt_web.update_requests'))
|
||||
|
||||
# Find or create the Device
|
||||
device = session.query(Device).filter_by(mac_address=req.mac_address).first()
|
||||
if device is None:
|
||||
device = Device(
|
||||
mac_address=req.mac_address,
|
||||
hostname=req.proposed_hostname or '',
|
||||
device_ip=req.proposed_device_ip or '',
|
||||
nume_masa=req.proposed_device_name or '',
|
||||
)
|
||||
session.add(device)
|
||||
session.flush()
|
||||
req.device_id = device.id
|
||||
|
||||
# Apply proposed values
|
||||
if req.proposed_device_name is not None:
|
||||
device.nume_masa = req.proposed_device_name
|
||||
if req.proposed_hostname is not None:
|
||||
device.hostname = req.proposed_hostname
|
||||
if req.proposed_device_ip is not None:
|
||||
device.device_ip = req.proposed_device_ip
|
||||
device.config_updated_at = datetime.utcnow()
|
||||
device.info_reviewed_at = datetime.utcnow() # admin reviewed → push timestamp to devices
|
||||
|
||||
# Mark request as accepted
|
||||
req.status = 'accepted'
|
||||
req.admin_reviewed_at = datetime.utcnow()
|
||||
req.admin_notes = request.form.get('admin_notes', '').strip() or None
|
||||
|
||||
flash('Request accepted and device record updated.', 'success')
|
||||
except Exception as e:
|
||||
logger.error(f'WMT accept request error: {e}')
|
||||
flash(f'Error accepting request: {e}', 'error')
|
||||
return redirect(url_for('wmt_web.update_requests'))
|
||||
|
||||
|
||||
@wmt_web_bp.route('/requests/<int:req_id>/reject', methods=['POST'])
|
||||
def reject_request(req_id):
|
||||
"""Reject an update request (updates reviewed_at so WMT client won't re-submit)."""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
req = session.query(WMTUpdateRequest).filter_by(id=req_id).first()
|
||||
if not req:
|
||||
flash('Request not found.', 'error')
|
||||
return redirect(url_for('wmt_web.update_requests'))
|
||||
|
||||
req.status = 'rejected'
|
||||
req.admin_reviewed_at = datetime.utcnow()
|
||||
req.admin_notes = request.form.get('admin_notes', '').strip() or None
|
||||
|
||||
# Update device info_reviewed_at even though data didn't change –
|
||||
# this signals to the WMT client that the server has reviewed the state
|
||||
# so it won't keep re-submitting the same request.
|
||||
if req.device_id:
|
||||
device = session.query(Device).filter_by(id=req.device_id).first()
|
||||
if device:
|
||||
device.info_reviewed_at = datetime.utcnow()
|
||||
|
||||
flash('Request rejected.', 'warning')
|
||||
except Exception as e:
|
||||
logger.error(f'WMT reject request error: {e}')
|
||||
flash(f'Error rejecting request: {e}', 'error')
|
||||
return redirect(url_for('wmt_web.update_requests'))
|
||||
0
config/__init__.py
Normal file
0
config/__init__.py
Normal file
106
config/config.py
Normal file
106
config/config.py
Normal 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
120
config/database_config.py
Normal 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
|
||||
144
docs/VSCODE_SESSION_LIMITS.md
Normal file
144
docs/VSCODE_SESSION_LIMITS.md
Normal 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
179
main.py
Normal 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
6
requirements.txt
Normal 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
|
||||
166
scripts/migrate_automation_models.py
Executable file
166
scripts/migrate_automation_models.py
Executable 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
118
scripts/update_database_schema.py
Executable 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)
|
||||
130
scripts/vscode_emergency_cleanup.sh
Executable file
130
scripts/vscode_emergency_cleanup.sh
Executable 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
112
scripts/vscode_session_monitor.sh
Executable 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
241
templates/admin.html
Normal file
@@ -0,0 +1,241 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Admin — Server Monitoring{% endblock %}
|
||||
{% block page_title %}Admin & 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 & 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 %}
|
||||
664
templates/ansible/dashboard.html
Normal file
664
templates/ansible/dashboard.html
Normal 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 %}
|
||||
538
templates/ansible/devices.html
Normal file
538
templates/ansible/devices.html
Normal 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 %}
|
||||
612
templates/ansible/execute.html
Normal file
612
templates/ansible/execute.html
Normal 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 %} +{{ 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 ❌ ${data.failed_hosts||0} failed ⚠️ ${data.unreachable_hosts||0} unreachable`;
|
||||
}
|
||||
const terminal = document.getElementById('liveTerminal');
|
||||
const colorised = (data.log || '')
|
||||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.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 %}
|
||||
311
templates/ansible/executions.html
Normal file
311
templates/ansible/executions.html
Normal 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 %}
|
||||
685
templates/ansible/playbooks.html
Normal file
685
templates/ansible/playbooks.html
Normal 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>
|
||||
113
templates/ansible/ssh_setup.html
Normal file
113
templates/ansible/ssh_setup.html
Normal 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
433
templates/base.html
Normal 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
334
templates/dashboard.html
Normal 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 %}
|
||||
263
templates/dashboard_old.html
Normal file
263
templates/dashboard_old.html
Normal 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';
|
||||
230
templates/device_detail.html
Normal file
230
templates/device_detail.html
Normal 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 }} • <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
109
templates/device_edit.html
Normal 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 %}
|
||||
89
templates/device_logs.html
Normal file
89
templates/device_logs.html
Normal 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">© 2023 Device Logs Dashboard. All rights reserved.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
278
templates/device_management.html
Normal file
278
templates/device_management.html
Normal 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 %}
|
||||
507
templates/device_management_old.html
Normal file
507
templates/device_management_old.html
Normal 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"> </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
0
templates/devices.html
Normal file
23
templates/errors/400.html
Normal file
23
templates/errors/400.html
Normal 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
23
templates/errors/403.html
Normal 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
56
templates/errors/404.html
Normal 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
23
templates/errors/500.html
Normal 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>
|
||||
89
templates/hostname_logs.html
Normal file
89
templates/hostname_logs.html
Normal 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">© 2023 Device Logs Dashboard. All rights reserved.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
336
templates/logs.html
Normal file
336
templates/logs.html
Normal 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"> </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
230
templates/server_logs.html
Normal 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">© 2025 Server Operations Dashboard. All rights reserved.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
211
templates/stats.html
Normal file
211
templates/stats.html
Normal 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
100
templates/templates.html
Normal 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 %}
|
||||
157
templates/unique_devices.html
Normal file
157
templates/unique_devices.html
Normal 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">© 2023 Unique Devices Dashboard. All rights reserved.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
90
templates/wmt/device_form.html
Normal file
90
templates/wmt/device_form.html
Normal 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 %}
|
||||
70
templates/wmt/devices.html
Normal file
70
templates/wmt/devices.html
Normal 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
181
templates/wmt/index.html
Normal 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
124
templates/wmt/requests.html
Normal 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
109
templates/wmt/settings.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user