diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ae63555 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +FROM python:3.10-alpine + +WORKDIR /app + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app \ + ADMIN_API_KEY=super-secret-admin-key-123 + +# Install runtime dependencies and create runtime user +RUN apk add --no-cache sqlite-libs \ + && addgroup -S appgroup \ + && adduser -S -G appgroup appuser + +# Copy requirements first (optimization for caching) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create data directory with proper permissions +RUN mkdir -p /data/db \ + && chown -R appuser:appgroup /data/db \ + && chmod -R 755 /data/db + +# Set proper permissions for application directory +RUN chown -R appuser:appgroup /app \ + && chmod -R 755 /app + +# Switch to non-root user +USER appuser + +# Expose port +EXPOSE 5000 + +# Run the application +CMD ["python", "main.py"] diff --git a/app/__init__.py b/app/__init__.py index e69de29..e853a3e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -0,0 +1,81 @@ +from flask import Flask +from .database.SlidingSqlite.SlidingSqlite import SlidingSQLite +import os +import configparser +import json + +def load_admin_api_key(app): + """Load admin API key from config file, env var, or database.""" + admin_key = None + + # 1. Try environment variable + admin_key = os.getenv('ADMIN_API_KEY') + if admin_key: + app.config['ADMIN_API_KEY'] = admin_key + return + + # 2. Try config file (e.g., /app/config.ini or /app/config.json) + config_file = '/app/config.ini' + if os.path.exists(config_file): + config = configparser.ConfigParser() + config.read(config_file) + admin_key = config.get('auth', 'admin_api_key', fallback=None) + elif os.path.exists('/app/config.json'): + config_file = '/app/config.json' + with open(config_file, 'r') as f: + config = json.load(f) + admin_key = config.get('auth', {}).get('admin_api_key') + + if admin_key: + app.config['ADMIN_API_KEY'] = admin_key + return + + # Check database + query = "SELECT api_key FROM admin_keys LIMIT 1" + result = app.db.execute_read_sync(query) + if result.success and result.data: + app.config['ADMIN_API_KEY'] = result.data[0][0] + return + + # Generate and store a new key + import secrets + default_key = secrets.token_hex(16) + app.config['ADMIN_API_KEY'] = default_key + app.db.execute_write( + "INSERT INTO admin_keys (api_key, description) VALUES (?, ?)", + (default_key, "Default admin key created on bootstrap") + ) + # Log the default key (in a real app, you'd log this securely) + print(f"Generated and stored default admin API key: {default_key}") + +def create_app(): + app = Flask(__name__) + + # Configuration +# app.config['DB_DIR'] = '/home/kalzu/src/ai-coding/node-monitor/temp_data/data/db' # Change this for container! + app.config['DB_DIR'] = '/data/db' # This is container version + app.config['RETENTION_PERIOD'] = 604800 # 7 days + app.config['ROTATION_INTERVAL'] = 3600 # 1 hour + + # Load schema + with open(os.path.join(os.path.dirname(__file__), 'database', 'schema.sql'), 'r') as f: + schema = f.read() + + # Initialize SlidingSQLite + app.db = SlidingSQLite( + db_dir=app.config['DB_DIR'], + schema=schema, + retention_period=app.config['RETENTION_PERIOD'], + rotation_interval=app.config['ROTATION_INTERVAL'] + ) + + # Load admin API key + load_admin_api_key(app) + + # Register blueprints + from .api import routes as api_routes + from .web import routes as web_routes + app.register_blueprint(api_routes.bp) + app.register_blueprint(web_routes.bp) + + return app diff --git a/app/api/routes.py b/app/api/routes.py new file mode 100644 index 0000000..ec9ac90 --- /dev/null +++ b/app/api/routes.py @@ -0,0 +1,221 @@ +from flask import Blueprint, request, jsonify, current_app +from datetime import datetime + +bp = Blueprint('api', __name__, url_prefix='/api') + +def authenticate_node(node_name, api_key): + """Authenticate a node using its name and API key.""" + auth_query = """ + SELECT node_id + FROM node_auth + WHERE node_name = ? AND api_key = ? AND status = 'active' + """ + result = current_app.db.execute_read_sync(auth_query, (node_name, api_key)) + return result.data[0][0] if result.success and result.data else None + +def authenticate_admin(api_key): + """Authenticate an admin using the API key.""" + return api_key == current_app.config['ADMIN_API_KEY'] + +@bp.route('/node-stats', methods=['POST']) +def node_stats(): + # Use current_app instead of bp.app + # app = bp.app # Remove this line + + # Expected JSON payload example: + # { + # "node_name": "node1", + # "api_key": "xxxx", + # "hostname": "node1.local", + # "cpu_percent": 45.5, + # "memory_used_mb": 2048, + # "memory_free_mb": 4096, + # "disk_used_gb": 100, + # "disk_free_gb": 400, + # "bytes_sent": 102400, + # "bytes_received": 51200 + # } + + try: + data = request.get_json() + if not data or not isinstance(data, dict): + return jsonify({'error': 'Invalid JSON payload'}), 400 + + # Required fields + required = ['node_name', 'api_key'] + if not all(field in data for field in required): + return jsonify({'error': 'Missing required fields'}), 400 + + # Authenticate node + node_id = authenticate_node(data['node_name'], data['api_key']) + if not node_id: + return jsonify({'error': 'Authentication failed'}), 401 + + # Update or insert node_info + node_info_query = """ + INSERT OR REPLACE INTO node_info ( + node_id, hostname, node_nickname, ip_address, os_type, + cpu_cores, total_memory_mb, total_disk_gb, manufacturer, + model, location, last_updated + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + current_app.db.execute_write(node_info_query, ( + node_id, + data.get('hostname', ''), + data.get('node_nickname'), + data.get('ip_address'), + data.get('os_type'), + data.get('cpu_cores'), + data.get('total_memory_mb'), + data.get('total_disk_gb'), + data.get('manufacturer'), + data.get('model'), + data.get('location'), + datetime.utcnow().isoformat() + )) + + # Insert CPU usage + if 'cpu_percent' in data or 'cpu_temp_celsius' in data: + cpu_query = """ + INSERT INTO cpu_usage (node_id, cpu_percent, cpu_temp_celsius, timestamp) + VALUES (?, ?, ?, ?) + """ + current_app.db.execute_write(cpu_query, ( + node_id, + data.get('cpu_percent'), + data.get('cpu_temp_celsius'), + datetime.utcnow().isoformat() + )) + + # Insert memory usage + if any(k in data for k in ['memory_used_mb', 'memory_free_mb', 'memory_percent']): + memory_query = """ + INSERT INTO memory_usage (node_id, memory_used_mb, memory_free_mb, memory_percent, timestamp) + VALUES (?, ?, ?, ?, ?) + """ + current_app.db.execute_write(memory_query, ( + node_id, + data.get('memory_used_mb', 0), + data.get('memory_free_mb', 0), + data.get('memory_percent', 0), + datetime.utcnow().isoformat() + )) + + # Insert disk usage + if any(k in data for k in ['disk_used_gb', 'disk_free_gb', 'disk_percent', 'partition_name']): + disk_query = """ + INSERT INTO disk_usage (node_id, disk_used_gb, disk_free_gb, disk_percent, partition_name, timestamp) + VALUES (?, ?, ?, ?, ?, ?) + """ + current_app.db.execute_write(disk_query, ( + node_id, + data.get('disk_used_gb', 0), + data.get('disk_free_gb', 0), + data.get('disk_percent', 0), + data.get('partition_name'), + datetime.utcnow().isoformat() + )) + + # Insert network usage + if any(k in data for k in ['bytes_sent', 'bytes_received', 'packets_sent', 'packets_received']): + network_query = """ + INSERT INTO network_usage (node_id, bytes_sent, bytes_received, packets_sent, packets_received, timestamp) + VALUES (?, ?, ?, ?, ?, ?) + """ + current_app.db.execute_write(network_query, ( + node_id, + data.get('bytes_sent', 0), + data.get('bytes_received', 0), + data.get('packets_sent', 0), + data.get('packets_received', 0), + datetime.utcnow().isoformat() + )) + + # Insert process stats + if 'process_count' in data or 'zombie_process_count' in data: + process_query = """ + INSERT INTO process_stats (node_id, process_count, zombie_process_count, timestamp) + VALUES (?, ?, ?, ?) + """ + current_app.db.execute_write(process_query, ( + node_id, + data.get('process_count', 0), + data.get('zombie_process_count', 0), + datetime.utcnow().isoformat() + )) + + # Insert node connections (if provided) + if 'target_node_name' in data and 'ping_latency_ms' in data: + target_node_query = "SELECT node_id FROM node_auth WHERE node_name = ? AND status = 'active'" + target_result = current_app.db.execute_read_sync(target_node_query, (data['target_node_name'],)) + if target_result.success and target_result.data: + target_node_id = target_result.data[0][0] + conn_query = """ + INSERT INTO node_connections (source_node_id, target_node_id, ping_latency_ms, packet_loss_percent, timestamp) + VALUES (?, ?, ?, ?, ?) + """ + current_app.db.execute_write(conn_query, ( + node_id, + target_node_id, + data.get('ping_latency_ms'), + data.get('packet_loss_percent', 0), + datetime.utcnow().isoformat() + )) + + return jsonify({'status': 'success', 'message': 'Stats recorded'}), 201 + + except Exception as e: + return jsonify({'error': f'Internal server error: {str(e)}'}), 500 + +@bp.route('/admin/nodes', methods=['POST']) +def manage_nodes(): + try: + data = request.get_json() + if not data or 'api_key' not in data: + return jsonify({'error': 'Admin API key required'}), 400 + + if not authenticate_admin(data['api_key']): + return jsonify({'error': 'Admin authentication failed'}), 401 + + # Expected payload: + # { + # "api_key": "admin-key", + # "action": "add|modify|delete", + # "node_name": "node1", + # "node_api_key": "xxxx" (for add/modify) + # } + action = data.get('action') + node_name = data.get('node_name') + + if action == 'add': + node_api_key = data.get('node_api_key') + if not node_name or not node_api_key: + return jsonify({'error': 'node_name and node_api_key required'}), 400 + query = """ + INSERT INTO node_auth (node_name, api_key, status) + VALUES (?, ?, 'active') + ON CONFLICT(node_name) DO UPDATE SET api_key = ?, status = 'active' + """ + current_app.db.execute_write(query, (node_name, node_api_key, node_api_key)) + return jsonify({'status': 'success', 'message': f'Node {node_name} added/updated'}), 201 + + elif action == 'modify': + node_api_key = data.get('node_api_key') + if not node_name or not node_api_key: + return jsonify({'error': 'node_name and node_api_key required'}), 400 + query = "UPDATE node_auth SET api_key = ? WHERE node_name = ?" + current_app.db.execute_write(query, (node_api_key, node_name)) + return jsonify({'status': 'success', 'message': f'Node {node_name} modified'}), 200 + + elif action == 'delete': + if not node_name: + return jsonify({'error': 'node_name required'}), 400 + query = "UPDATE node_auth SET status = 'inactive' WHERE node_name = ?" + current_app.db.execute_write(query, (node_name,)) + return jsonify({'status': 'success', 'message': f'Node {node_name} deleted'}), 200 + + else: + return jsonify({'error': 'Invalid action'}), 400 + + except Exception as e: + return jsonify({'error': f'Internal server error: {str(e)}'}), 500 diff --git a/app/database/schema.sql b/app/database/schema.sql new file mode 100644 index 0000000..b39b26b --- /dev/null +++ b/app/database/schema.sql @@ -0,0 +1,150 @@ +-- Table for node authentication information (unchanged) +CREATE TABLE node_auth ( + node_id INTEGER PRIMARY KEY AUTOINCREMENT, + node_name TEXT NOT NULL UNIQUE, + api_key TEXT NOT NULL, + auth_token TEXT, + last_auth_timestamp DATETIME, + status TEXT CHECK(status IN ('active', 'inactive', 'pending')) DEFAULT 'pending' +); + +CREATE TABLE IF NOT EXISTS admin_keys ( + key_id INTEGER PRIMARY KEY AUTOINCREMENT, + api_key TEXT NOT NULL UNIQUE, + description TEXT, + created_at TEXT DEFAULT (datetime('now')) +); + +-- Updated table for basic node information with new fields +CREATE TABLE node_info ( + node_id INTEGER PRIMARY KEY, + hostname TEXT NOT NULL, + node_nickname TEXT, + ip_address TEXT, + os_type TEXT, + cpu_cores INTEGER, + total_memory_mb INTEGER, + total_disk_gb INTEGER, + manufacturer TEXT, + model TEXT, + location TEXT, + first_seen DATETIME DEFAULT CURRENT_TIMESTAMP, + last_updated DATETIME, + FOREIGN KEY (node_id) REFERENCES node_auth(node_id) ON DELETE CASCADE +); + +-- Table for node-to-node connection metrics +CREATE TABLE node_connections ( + connection_id INTEGER PRIMARY KEY AUTOINCREMENT, + source_node_id INTEGER, + target_node_id INTEGER, + ping_latency_ms REAL, + packet_loss_percent REAL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (source_node_id) REFERENCES node_auth(node_id), + FOREIGN KEY (target_node_id) REFERENCES node_auth(node_id) +); + +-- Timeseries table for CPU usage +CREATE TABLE cpu_usage ( + measurement_id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER, + cpu_percent REAL, + cpu_temp_celsius REAL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (node_id) REFERENCES node_auth(node_id) +); + +-- Timeseries table for memory usage +CREATE TABLE memory_usage ( + measurement_id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER, + memory_used_mb INTEGER, + memory_free_mb INTEGER, + memory_percent REAL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (node_id) REFERENCES node_auth(node_id) +); + +-- Timeseries table for disk usage +CREATE TABLE disk_usage ( + measurement_id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER, + disk_used_gb INTEGER, + disk_free_gb INTEGER, + disk_percent REAL, + partition_name TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (node_id) REFERENCES node_auth(node_id) +); + +-- Table for network usage +CREATE TABLE network_usage ( + measurement_id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER, + bytes_sent INTEGER, + bytes_received INTEGER, + packets_sent INTEGER, + packets_received INTEGER, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (node_id) REFERENCES node_auth(node_id) +); + +-- Table process monitoring +CREATE TABLE process_stats ( + measurement_id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER, + process_count INTEGER, + zombie_process_count INTEGER, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (node_id) REFERENCES node_auth(node_id) +); + +-- Table for monitoring configuration and alarm thresholds +CREATE TABLE monitoring_config ( + config_id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER, + cpu_threshold_percent REAL, + memory_threshold_percent REAL, + disk_threshold_percent REAL, + temp_threshold_celsius REAL, + network_bytes_threshold INTEGER, + process_count_threshold INTEGER, + ping_latency_threshold_ms REAL, + check_interval_seconds INTEGER DEFAULT 60, + FOREIGN KEY (node_id) REFERENCES node_auth(node_id) +); + +-- Table for alarm history +CREATE TABLE alarm_history ( + alarm_id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER, + alarm_type TEXT CHECK(alarm_type IN ('cpu', 'memory', 'disk', 'temperature', + 'network', 'process', 'latency')), + threshold_value REAL, + actual_value REAL, + alarm_message TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + acknowledged BOOLEAN DEFAULT FALSE, + FOREIGN KEY (node_id) REFERENCES node_auth(node_id) +); + +-- Table for node status events +CREATE TABLE node_events ( + event_id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER, + event_type TEXT CHECK(event_type IN ('online', 'offline', 'error', 'warning')), + event_message TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (node_id) REFERENCES node_auth(node_id) +); + +-- Updated indexes for better query performance +CREATE INDEX idx_cpu_usage_node_timestamp ON cpu_usage(node_id, timestamp); +CREATE INDEX idx_memory_usage_node_timestamp ON memory_usage(node_id, timestamp); +CREATE INDEX idx_disk_usage_node_timestamp ON disk_usage(node_id, timestamp); +CREATE INDEX idx_network_usage_node_timestamp ON network_usage(node_id, timestamp); +CREATE INDEX idx_process_stats_node_timestamp ON process_stats(node_id, timestamp); +CREATE INDEX idx_node_connections_nodes ON node_connections(source_node_id, target_node_id); +CREATE INDEX idx_node_events_node_timestamp ON node_events(node_id, timestamp); +CREATE INDEX idx_alarm_history_node_timestamp ON alarm_history(node_id, timestamp); diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..f05e7f9 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,120 @@ + + +
+ + +Hostname: {{ node.hostname or 'N/A' }}
+Last Updated: {{ node.last_updated or 'N/A' }}
+{{ node.cpu_percent|round(2) if node.cpu_percent else 'N/A' }}
+ +{{ node.memory_percent|round(1) if node.memory_percent else 'N/A' }}
+ +Latency: {{ conn.ping_latency_ms|round(1) if conn.ping_latency_ms else 'N/A' }} ms
+No active connections
+ {% endfor %} +No nodes found.
+ {% endfor %} + + diff --git a/app/templates/node_detail.html b/app/templates/node_detail.html new file mode 100644 index 0000000..53987ed --- /dev/null +++ b/app/templates/node_detail.html @@ -0,0 +1,192 @@ + + + + + +