We now have working api and simple webpages to view the data.

This commit is contained in:
Kalzu Rekku
2025-04-03 22:58:53 +03:00
parent 362d0e7b65
commit 0bee8c7fd3
9 changed files with 943 additions and 0 deletions

39
Dockerfile Normal file
View 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"]

View File

@ -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
View 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
View 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
View 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>

View 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
View 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]
)

View File

@ -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)

View File

@ -0,0 +1,4 @@
Flask
requests
gunicorn
jinja2