We now have working api and simple webpages to view the data.
This commit is contained in:
39
Dockerfile
Normal file
39
Dockerfile
Normal file
@ -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"]
|
@ -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
|
||||
|
221
app/api/routes.py
Normal file
221
app/api/routes.py
Normal file
@ -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
|
150
app/database/schema.sql
Normal file
150
app/database/schema.sql
Normal file
@ -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);
|
120
app/templates/index.html
Normal file
120
app/templates/index.html
Normal file
@ -0,0 +1,120 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="refresh" content="30">
|
||||
<title>Node Monitor</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: #2e2e2e;
|
||||
color: #ffffff;
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
}
|
||||
h1 {
|
||||
color: #ffffff;
|
||||
}
|
||||
.node {
|
||||
margin-bottom: 20px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.node-header {
|
||||
display: flex;
|
||||
padding: 15px;
|
||||
cursor: pointer;
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
.node-header:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
.node-info {
|
||||
flex: 1;
|
||||
}
|
||||
.node-metrics {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
.metric {
|
||||
text-align: center;
|
||||
min-width: 100px;
|
||||
}
|
||||
.status-badge {
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
.connections-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 15px;
|
||||
gap: 10px;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.connection {
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
min-width: 180px;
|
||||
flex: 0 0 calc(20% - 10px);
|
||||
}
|
||||
.status-active { background-color: #1c3b1a; color: #c3e6cb; }
|
||||
.status-inactive { background-color: #441a1f; color: #f5c6cb; }
|
||||
.status-pending { background-color: #3b361a; color: #ffeeba; }
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
margin-top: 5px;
|
||||
background-color: #444;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: #007bff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Node Monitor</h1>
|
||||
{% for node in nodes %}
|
||||
<div class="node">
|
||||
<div class="node-header" onclick="location.href='/app/node/{{ node.node_name }}'">
|
||||
<div class="node-info">
|
||||
<h2>{{ node.node_name }}</h2>
|
||||
<p>Hostname: {{ node.hostname or 'N/A' }}</p>
|
||||
<p>Last Updated: {{ node.last_updated or 'N/A' }}</p>
|
||||
</div>
|
||||
<div class="node-metrics">
|
||||
<div class="metric">
|
||||
<h3>CPU (%)</h3>
|
||||
<p>{{ node.cpu_percent|round(2) if node.cpu_percent else 'N/A' }}</p>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {{ node.cpu_percent|default(0) }}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<h3>Memory (%)</h3>
|
||||
<p>{{ node.memory_percent|round(1) if node.memory_percent else 'N/A' }}</p>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {{ node.memory_percent|default(0) }}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-badge status-{{ node.status }}">{{ node.status|upper }}</div>
|
||||
</div>
|
||||
<div class="connections-container">
|
||||
{% for conn in node.connections %}
|
||||
<div class="connection status-{{ conn.target_status }}">
|
||||
<h3>{{ conn.target_node_name }}</h3>
|
||||
<p>Latency: {{ conn.ping_latency_ms|round(1) if conn.ping_latency_ms else 'N/A' }} ms</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>No active connections</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>No nodes found.</p>
|
||||
{% endfor %}
|
||||
</body>
|
||||
</html>
|
192
app/templates/node_detail.html
Normal file
192
app/templates/node_detail.html
Normal file
@ -0,0 +1,192 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="refresh" content="30">
|
||||
<title>{{ node_name }} - Node Monitor</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.7.0/chart.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
background-color: #2e2e2e;
|
||||
color: #ffffff;
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1, h2 {
|
||||
color: #ffffff;
|
||||
}
|
||||
.back-link a {
|
||||
color: #00a0fc;
|
||||
text-decoration: none;
|
||||
}
|
||||
.back-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.node-card {
|
||||
background: #3a3a3a;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.node-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.node-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.info-item {
|
||||
background: #444;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.info-label {
|
||||
font-weight: bold;
|
||||
color: #aaa;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.info-value {
|
||||
font-size: 1.2em;
|
||||
color: #ffffff;
|
||||
}
|
||||
.status-badge {
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
.status-active { background-color: #28a745; }
|
||||
.status-inactive { background-color: #dc3545; }
|
||||
.status-pending { background-color: #ffc107; }
|
||||
.chart-container {
|
||||
background: #444;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
height: 300px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background-color: #555;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.cpu-fill { background-color: #007bff; }
|
||||
.memory-fill { background-color: #28a745; }
|
||||
.disk-fill { background-color: #ffc107; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Node Details: {{ node_name }}</h1>
|
||||
<div class="back-link">
|
||||
<a href="/app/">← Back to Overview</a>
|
||||
</div>
|
||||
<div class="node-card">
|
||||
<div class="node-header">
|
||||
<h2>{{ node_name }}</h2>
|
||||
<div class="status-badge status-{{ node.status }}">{{ node.status|upper }}</div>
|
||||
</div>
|
||||
<div class="node-info">
|
||||
<div class="info-item">
|
||||
<div class="info-label">Hostname</div>
|
||||
<div class="info-value">{{ node.hostname or 'N/A' }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">IP Address</div>
|
||||
<div class="info-value">{{ node.ip_address or 'N/A' }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">OS Type</div>
|
||||
<div class="info-value">{{ node.os_type or 'N/A' }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Last Updated</div>
|
||||
<div class="info-value">{{ node.last_updated or 'N/A' }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">CPU Usage</div>
|
||||
<div class="info-value">{{ node.cpu_percent|round(2) if node.cpu_percent else 'N/A' }}%</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill cpu-fill" style="width: {{ node.cpu_percent|default(0) }}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Memory Usage</div>
|
||||
<div class="info-value">{{ node.memory_percent|round(1) if node.memory_percent else 'N/A' }}%</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill memory-fill" style="width: {{ node.memory_percent|default(0) }}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Disk Usage</div>
|
||||
<div class="info-value">{{ node.disk_percent|round(1) if node.disk_percent else 'N/A' }}%</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill disk-fill" style="width: {{ node.disk_percent|default(0) }}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="performanceChart"></canvas>
|
||||
</div>
|
||||
<div class="back-link" style="margin-top: 20px;">
|
||||
<a href="/app/">← Back to Overview</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const ctx = document.getElementById('performanceChart').getContext('2d');
|
||||
const performanceChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [{% for ts in history_timestamps %}'{{ ts }}',{% endfor %}],
|
||||
datasets: [{
|
||||
label: 'CPU Usage (%)',
|
||||
data: [{% for val in history_cpu %}{{ val if val is not none else 'null' }},{% endfor %}],
|
||||
borderColor: '#007bff',
|
||||
tension: 0.3
|
||||
}, {
|
||||
label: 'Memory Usage (%)',
|
||||
data: [{% for val in history_memory %}{{ val if val is not none else 'null' }},{% endfor %}],
|
||||
borderColor: '#28a745',
|
||||
tension: 0.3
|
||||
}, {
|
||||
label: 'Disk Usage (%)',
|
||||
data: [{% for val in history_disk %}{{ val if val is not none else 'null' }},{% endfor %}],
|
||||
borderColor: '#ffc107',
|
||||
tension: 0.3
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: { ticks: { color: '#aaa' }, grid: { color: 'rgba(255, 255, 255, 0.1)' } },
|
||||
y: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: { color: '#aaa' },
|
||||
grid: { color: 'rgba(255, 255, 255, 0.1)' }
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { labels: { color: '#fff' } }
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
130
app/web/routes.py
Normal file
130
app/web/routes.py
Normal file
@ -0,0 +1,130 @@
|
||||
# app/web/routes.py
|
||||
from flask import Blueprint, render_template, current_app
|
||||
|
||||
bp = Blueprint('web', __name__, url_prefix='/app', template_folder='../templates')
|
||||
|
||||
@bp.route('/', methods=['GET'])
|
||||
def index():
|
||||
# Fetch all nodes with latest stats
|
||||
query = """
|
||||
SELECT
|
||||
na.node_name, na.status, ni.hostname,
|
||||
cu.cpu_percent, mu.memory_percent, du.disk_percent, ni.last_updated
|
||||
FROM node_auth na
|
||||
LEFT JOIN node_info ni ON na.node_id = ni.node_id
|
||||
LEFT JOIN (
|
||||
SELECT node_id, cpu_percent
|
||||
FROM cpu_usage
|
||||
WHERE timestamp = (SELECT MAX(timestamp) FROM cpu_usage cu2 WHERE cu2.node_id = cpu_usage.node_id)
|
||||
) cu ON na.node_id = cu.node_id
|
||||
LEFT JOIN (
|
||||
SELECT node_id, memory_percent
|
||||
FROM memory_usage
|
||||
WHERE timestamp = (SELECT MAX(timestamp) FROM memory_usage mu2 WHERE mu2.node_id = memory_usage.node_id)
|
||||
) mu ON na.node_id = mu.node_id
|
||||
LEFT JOIN (
|
||||
SELECT node_id, disk_percent
|
||||
FROM disk_usage
|
||||
WHERE timestamp = (SELECT MAX(timestamp) FROM disk_usage du2 WHERE du2.node_id = disk_usage.node_id)
|
||||
) du ON na.node_id = du.node_id
|
||||
ORDER BY na.node_name
|
||||
"""
|
||||
result = current_app.db.execute_read_sync(query)
|
||||
nodes = []
|
||||
if result.success and result.data:
|
||||
for row in result.data:
|
||||
node = {
|
||||
'node_name': row[0],
|
||||
'status': row[1],
|
||||
'hostname': row[2],
|
||||
'cpu_percent': row[3],
|
||||
'memory_percent': row[4],
|
||||
'disk_percent': row[5],
|
||||
'last_updated': row[6],
|
||||
'connections': []
|
||||
}
|
||||
# Fetch connections
|
||||
conn_query = """
|
||||
SELECT na2.node_name, na2.status, nc.ping_latency_ms
|
||||
FROM node_connections nc
|
||||
JOIN node_auth na2 ON nc.target_node_id = na2.node_id
|
||||
WHERE nc.source_node_id = (SELECT node_id FROM node_auth WHERE node_name = ?)
|
||||
AND nc.timestamp = (SELECT MAX(timestamp) FROM node_connections nc2 WHERE nc2.source_node_id = nc.source_node_id AND nc2.target_node_id = nc.target_node_id)
|
||||
"""
|
||||
conn_result = current_app.db.execute_read_sync(conn_query, (node['node_name'],))
|
||||
if conn_result.success and conn_result.data:
|
||||
node['connections'] = [{'target_node_name': r[0], 'target_status': r[1], 'ping_latency_ms': r[2]} for r in conn_result.data]
|
||||
nodes.append(node)
|
||||
return render_template('index.html', nodes=nodes)
|
||||
|
||||
@bp.route('/node/<node_name>', methods=['GET'])
|
||||
def node_detail(node_name):
|
||||
# Fetch latest node info
|
||||
query = """
|
||||
SELECT
|
||||
na.node_name, na.status, ni.hostname, ni.ip_address, ni.os_type,
|
||||
cu.cpu_percent, mu.memory_percent, du.disk_percent, ni.last_updated
|
||||
FROM node_auth na
|
||||
LEFT JOIN node_info ni ON na.node_id = ni.node_id
|
||||
LEFT JOIN (
|
||||
SELECT node_id, cpu_percent
|
||||
FROM cpu_usage
|
||||
WHERE timestamp = (SELECT MAX(timestamp) FROM cpu_usage cu2 WHERE cu2.node_id = cpu_usage.node_id)
|
||||
) cu ON na.node_id = cu.node_id
|
||||
LEFT JOIN (
|
||||
SELECT node_id, memory_percent
|
||||
FROM memory_usage
|
||||
WHERE timestamp = (SELECT MAX(timestamp) FROM memory_usage mu2 WHERE mu2.node_id = memory_usage.node_id)
|
||||
) mu ON na.node_id = mu.node_id
|
||||
LEFT JOIN (
|
||||
SELECT node_id, disk_percent
|
||||
FROM disk_usage
|
||||
WHERE timestamp = (SELECT MAX(timestamp) FROM disk_usage du2 WHERE du2.node_id = disk_usage.node_id)
|
||||
) du ON na.node_id = du.node_id
|
||||
WHERE na.node_name = ?
|
||||
"""
|
||||
result = current_app.db.execute_read_sync(query, (node_name,))
|
||||
if not result.success or not result.data:
|
||||
return "Node not found", 404
|
||||
|
||||
node = {
|
||||
'node_name': result.data[0][0],
|
||||
'status': result.data[0][1],
|
||||
'hostname': result.data[0][2],
|
||||
'ip_address': result.data[0][3],
|
||||
'os_type': result.data[0][4],
|
||||
'cpu_percent': result.data[0][5],
|
||||
'memory_percent': result.data[0][6],
|
||||
'disk_percent': result.data[0][7],
|
||||
'last_updated': result.data[0][8]
|
||||
}
|
||||
|
||||
# Fetch historical data (last 50 entries)
|
||||
history_query = """
|
||||
SELECT timestamp, cpu_percent FROM cpu_usage WHERE node_id = (SELECT node_id FROM node_auth WHERE node_name = ?) ORDER BY timestamp DESC LIMIT 50
|
||||
"""
|
||||
cpu_result = current_app.db.execute_read_sync(history_query, (node_name,))
|
||||
history_timestamps = [r[0].split('T')[1][:5] for r in cpu_result.data] if cpu_result.success and cpu_result.data else []
|
||||
history_cpu = [r[1] for r in cpu_result.data] if cpu_result.success and cpu_result.data else []
|
||||
|
||||
memory_query = """
|
||||
SELECT memory_percent FROM memory_usage WHERE node_id = (SELECT node_id FROM node_auth WHERE node_name = ?) ORDER BY timestamp DESC LIMIT 50
|
||||
"""
|
||||
memory_result = current_app.db.execute_read_sync(memory_query, (node_name,))
|
||||
history_memory = [r[0] for r in memory_result.data] if memory_result.success and memory_result.data else []
|
||||
|
||||
disk_query = """
|
||||
SELECT disk_percent FROM disk_usage WHERE node_id = (SELECT node_id FROM node_auth WHERE node_name = ?) ORDER BY timestamp DESC LIMIT 50
|
||||
"""
|
||||
disk_result = current_app.db.execute_read_sync(disk_query, (node_name,))
|
||||
history_disk = [r[0] for r in disk_result.data] if disk_result.success and disk_result.data else []
|
||||
|
||||
return render_template(
|
||||
'node_detail.html',
|
||||
node_name=node_name,
|
||||
node=node,
|
||||
history_timestamps=history_timestamps[::-1], # Reverse for chronological order
|
||||
history_cpu=history_cpu[::-1],
|
||||
history_memory=history_memory[::-1],
|
||||
history_disk=history_disk[::-1]
|
||||
)
|
6
main.py
6
main.py
@ -0,0 +1,6 @@
|
||||
from app import create_app
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5000, debug=False)
|
||||
|
@ -0,0 +1,4 @@
|
||||
Flask
|
||||
requests
|
||||
gunicorn
|
||||
jinja2
|
||||
|
Reference in New Issue
Block a user