diff options
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | README.md | 92 | ||||
-rw-r--r-- | index.html | 550 | ||||
-rwxr-xr-x | server.py | 834 | ||||
-rw-r--r-- | static/css/style.css | 394 | ||||
-rw-r--r-- | static/images/ashita-no-joe-joe-yabuki.gif | bin | 0 -> 976398 bytes | |||
-rw-r--r-- | static/js/main.js | 750 | ||||
-rw-r--r-- | templates/index.html | 140 |
8 files changed, 2762 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ee8ead --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +venv/ +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..ba64fa1 --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# Server Monitoring Dashboard + +A lightweight monitoring dashboard for servers with JWT authentication and real-time metrics. + +## Features + +- Real-time system metrics display +- Application performance monitoring +- Service status monitoring +- Secure JWT authentication +- Responsive web interface + +## Requirements + +- Python 3.6+ +- Dependencies: + - PyJWT + - (Optional) python-dotenv for .env file support + +## Installation + +1. Clone this repository +2. Install dependencies: + +```bash +pip install PyJWT +pip install python-dotenv # Optional but recommended +``` + +## Configuration + +You can configure the application using environment variables or by creating a `.env` file in the project root directory. + +### Using .env file (recommended) + +Create a `.env` file in the project root with the following variables: + +``` +# Security +JWT_SECRET=your_secret_jwt_key +AUTH_PASSWORD=your_secure_password + +# Server Configuration +SERVER_PORT=8000 +JWT_EXPIRATION_HOURS=8 + +# SNMP Configuration +SNMP_HOST=127.0.0.1 +SNMP_COMMUNITY=your_snmp_community_string +SNMP_VERSION=2c +SNMP_COLLECTION_INTERVAL=10 +``` + +### Using Environment Variables + +Alternatively, you can set the environment variables directly: + +```bash +export JWT_SECRET=your_secret_jwt_key +export AUTH_PASSWORD=your_secure_password +export SERVER_PORT=8000 +# ... and so on +``` + +### Security Notes + +- **JWT_SECRET**: Should be a random, secure string. You can generate one using: + ```python + python -c "import secrets; print(secrets.token_hex(32))" + ``` +- **AUTH_PASSWORD**: Choose a strong password to protect your metrics dashboard +- If no JWT_SECRET is provided, a random one will be generated at startup (not recommended for production) +- If no AUTH_PASSWORD is provided, a random one will be generated and displayed at startup + +## Running the Server + +```bash +python server.py +``` + +The server will start and display the URL where you can access the dashboard. + +## Accessing the Dashboard + +1. Navigate to the server URL in your browser (default: http://localhost:8000) +2. Click "View Server Metrics" +3. Enter the password configured in AUTH_PASSWORD +4. View your real-time metrics + +## License + +[MIT License](LICENSE)
\ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..9da7941 --- /dev/null +++ b/index.html @@ -0,0 +1,550 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="UTF-8"/> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <title>My Hardware Setup</title> + <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans&family=Roboto&family=Shrikhand&display=swap" rel="stylesheet"> + <link href="style.css" rel="stylesheet"/> + <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> + <script src="https://cdn.jsdelivr.net/npm/[email protected]"></script> + <script src="https://cdn.jsdelivr.net/npm/[email protected]"></script> + <script src="https://cdn.jsdelivr.net/npm/[email protected]/crypto-js.min.js"></script> + <script src="server-stats.js" defer></script> + <script src="snmp-charts.js" defer></script> + <script src="metrics-access.js" defer></script> +</head> +<body> + <header> + <h1>The Heart of the Operation: My Rig, Server & Laptops</h1> + <h3>Dive deep into the silicon and circuits that power my digital world!</h3> + </header> + + <section class="main-content"> + <div class="container"> + <h2>What Makes it Tick?</h2> + <p>Ever wondered what's inside the box? I have build my setup that help's me do all of my activities smoothly and easily. Below, I'll break down the key components and what they do.</p> + + <img src="ashita-no-joe-joe-yabuki.gif" alt="Animated CPU GIF" class="component-image"> + <div class="component-section"> + <h3><span class="icon">🖧</span> The Server</h3> + <p>This is where all of my websites, applications, files, and projects are hosted. These are accessible 24/7.</p> + <div class="component-item"> + <h4>CPU: Intel Xeon Gold</h4> + <p>The processor that handles all server requests.</p> + </div> + + <div class="component-item"> + <h4>RAM: 8GB</h4> + <p>Memory for the server to run applications.</p> + </div> + + <div class="component-item"> + <h4>Storage: 50GB SSD</h4> + <p>Fast storage for the server's operating system and data.</p> + </div> + + <div class="component-item"> + <h4>Server Status:</h4> + <div id="server-stats"> + <div class="status-container"> + <div id="server-status" class="status-light"> </div> + <span id="uptime">Checking status...</span> + </div> + </div> + </div> + + <div class="component-item"> + <button id="view-metrics-btn" class="button">View Server Metrics</button> + </div> + + <!-- Add SNMP Metrics Section (hidden by default) --> + <div class="component-item" id="metrics-section" style="display: none;"> + <h4>Real-time Metrics:</h4> + <div id="metrics-container"> + <div class="metric-grid"> + <!-- Charts will be dynamically added here based on available metrics --> + </div> + </div> + </div> + </div> + <div class="component-section"> + <h3><span class="icon">💻</span> ThinkPad E14</h3> + <p>My daily driver laptop for work and portability.</p> + <div class="component-item"> + <h4>RAM: 16GB</h4> + <p>Plenty of memory for multitasking.</p> + </div> + <div class="component-item"> + <h4>Storage: 256GB SSD</h4> + <p>Fast storage for quick boot and application loading.</p> + </div> + </div> + + <div class="component-section"> + <h3><span class="icon">💻</span> ThinkPad T480 (Librebooted)</h3> + <p>My privacy-focused laptop, running Libreboot for enhanced security and control.</p> + <div class="component-item"> + <h4>RAM: 16GB</h4> + <p>Memory for smooth operation, even with security-focused software.</p> + </div> + <div class="component-item"> + <h4>Storage: 512GB SSD</h4> + <p>Fast and reliable storage.</p> + </div> + </div> + </div> + </section> + + <section class="cta-section"> + <div class="container"> + <h2>Explore More!</h2> + <p>Want to see these components in action? Check out these links:</p> + <a href="https://surgot.in" class="button">Visit My Website</a> + <a href="https://git.surgot.in" class="button">See My Code</a> + </div> + </section> + + <!-- + <section class="testimonials"> + <div class="container"> + <h2>What People Are Saying (About My Projects, Powered by This Hardware):</h2> + <div class="testimonial"> + <p>"[QUOTE ABOUT YOUR WEBSITE/PROJECT] - It loads so fast!" - [Name/Username]</p> + </div> + <div class="testimonial"> + <p>"[QUOTE ABOUT YOUR YOUTUBE VIDEOS/PROJECT] - The editing is incredible!" - [Name/Username]</p> + </div> + </div> + </section> + --> + + <!-- Password Modal --> + <div id="password-modal" class="modal"> + <div class="modal-content"> + <span class="close-button">×</span> + <h3>Authentication Required</h3> + <p>Please enter the password to view server metrics:</p> + <div class="password-input-container"> + <input type="password" id="metrics-password" placeholder="Enter password"> + <button id="submit-password" class="button">Submit</button> + </div> + <p id="password-error" class="error-message"></p> + </div> + </div> + + <footer> + <p>© Surgot/2025 - Built with passion (and a lot of processing power!)</p> + </footer> + + <!-- Consolidated JavaScript --> + <script> + // --- Server Stats Logic (from server-stats.js) --- + async function getServerStats() { + const statusElement = document.getElementById('server-status'); + const uptimeElement = document.getElementById('uptime'); + + if (!statusElement || !uptimeElement) { + console.error("Server status elements not found in the DOM"); + return; + } + + try { + // Fetch stats directly from the server endpoint + const response = await fetch('/stat/.server-stats.json'); + + if (!response || !response.ok) { + throw new Error(`Could not find server stats file`); + } + + const data = await response.json(); + + let status = "green"; + const load1 = parseFloat(data.load_1min); + const cores = parseInt(data.cores, 10) || 1; + + if (!isNaN(load1)) { + if (load1 > (cores * 0.7)) status = "yellow"; + if (load1 > (cores * 0.9)) status = "red"; + } else { + status = "yellow"; + console.warn("Invalid load value:", data.load_1min); + } + + statusElement.classList.remove('green', 'yellow', 'red'); + statusElement.classList.add(status); + + let tooltipText = "Unknown status"; + if (status === "green") tooltipText = "Server is running normally"; + else if (status === "yellow") tooltipText = "Server is under medium load"; + else if (status === "red") tooltipText = "Server is under heavy load"; + statusElement.title = tooltipText; + + uptimeElement.textContent = `Uptime: ${data.uptime || 'Unknown'} (Load: ${data.load_1min || '?'}, ${data.load_5min || '?'}, ${data.load_15min || '?'})`; + + } catch (error) { + console.error("Error fetching server stats:", error); + statusElement.classList.remove('green', 'yellow', 'red'); + statusElement.classList.add('yellow'); + statusElement.title = "Status check failed - server may still be operational"; + uptimeElement.textContent = 'Status: Available (Stats unavailable)'; + } + } + + // --- Metrics Access Logic (from metrics-access.js) --- + const viewMetricsBtn = document.getElementById('view-metrics-btn'); + const passwordModal = document.getElementById('password-modal'); + const passwordInput = document.getElementById('metrics-password'); + const submitPasswordBtn = document.getElementById('submit-password'); + const closeModalBtn = document.querySelector('.close-button'); + const passwordError = document.getElementById('password-error'); + const metricsSection = document.getElementById('metrics-section'); + const AUTH_SESSION_KEY = 'metrics_authenticated'; + const METRICS_PASSWORD_HASH = "3a7526cc5662c9d46d9458a07ed6608689006a64f095838cefd244fb9da20780"; // Default: "metrics2023" + + function checkAuthentication() { + return sessionStorage.getItem(AUTH_SESSION_KEY) === 'true'; + } + + function setAuthenticated(state) { + sessionStorage.setItem(AUTH_SESSION_KEY, state ? 'true' : 'false'); + } + + function showMetrics() { + if (!metricsSection) return; + metricsSection.style.display = 'block'; + if (typeof updateCharts === 'function') { + setTimeout(updateCharts, 100); + } + if (!document.getElementById('hide-metrics-btn')) { + createHideMetricsButton(); + } + } + + function hideMetrics() { + if (!metricsSection) return; + metricsSection.style.display = 'none'; + if (viewMetricsBtn) { + viewMetricsBtn.textContent = 'View Server Metrics'; + } + const hideBtn = document.getElementById('hide-metrics-btn'); + if (hideBtn) hideBtn.remove(); // Remove hide button when hiding + } + + function createHideMetricsButton() { + const hideBtn = document.createElement('button'); + hideBtn.id = 'hide-metrics-btn'; + hideBtn.className = 'button'; + hideBtn.textContent = 'Hide Server Metrics'; + hideBtn.style.marginLeft = '10px'; + hideBtn.addEventListener('click', hideMetrics); + + if (viewMetricsBtn && viewMetricsBtn.parentNode) { + viewMetricsBtn.parentNode.appendChild(hideBtn); + } + } + + function openPasswordModal() { + if (!passwordModal || !passwordInput) return; + passwordInput.value = ''; + passwordError.textContent = ''; + passwordError.style.opacity = 0; + passwordModal.style.display = 'block'; + setTimeout(() => { + passwordModal.classList.add('show'); + passwordInput.focus(); + }, 10); + } + + function closePasswordModal() { + if (!passwordModal) return; + passwordModal.classList.remove('show'); + setTimeout(() => { + passwordModal.style.display = 'none'; + }, 300); + } + + function validatePassword(password) { + if (typeof CryptoJS === 'undefined') { + console.error("CryptoJS library not loaded!"); + showError("Authentication library error."); + return false; + } + const hash = CryptoJS.SHA256(password).toString(); + return hash === METRICS_PASSWORD_HASH; + } + + function showError(message) { + if (!passwordError || !passwordInput) return; + passwordError.textContent = message; + passwordError.style.opacity = 0; + setTimeout(() => { + passwordError.style.opacity = 1; + }, 10); + passwordInput.classList.add('shake'); + setTimeout(() => { + passwordInput.classList.remove('shake'); + }, 500); + } + + if (viewMetricsBtn) { + viewMetricsBtn.addEventListener('click', function() { + if (checkAuthentication()) { + showMetrics(); + } else { + openPasswordModal(); + } + }); + } + + if (submitPasswordBtn) { + submitPasswordBtn.addEventListener('click', function() { + if (!passwordInput) return; + const password = passwordInput.value; + if (!password) { + showError('Please enter a password'); + return; + } + if (validatePassword(password)) { + setAuthenticated(true); + closePasswordModal(); + showMetrics(); + } else { + passwordInput.value = ''; + showError('Incorrect password. Please try again.'); + } + }); + } + + if (closeModalBtn) { + closeModalBtn.addEventListener('click', closePasswordModal); + } + + window.addEventListener('click', function(event) { + if (event.target === passwordModal) { + closePasswordModal(); + } + }); + + if (passwordInput) { + passwordInput.addEventListener('keyup', function(event) { + if (event.key === 'Enter') { + submitPasswordBtn.click(); + } + }); + } + + // --- SNMP Charts Logic (from snmp-charts.js) --- + const API_ENDPOINT = '/api/metrics'; + const UPDATE_INTERVAL = 10000; + const CHART_HISTORY = 60; + let charts = {}; + let chartContainers = {}; + let chartsInitialized = false; + let updateIntervalId; + let metricsDefinitions = {}; + + const chartColors = { + networkIn: '#88B7B5', networkOut: '#FDCFF3', cpu: '#88B7B5', + memory: '#FDCFF3', system: '#88B7B5', generic: '#88B7B5' + }; + + function formatBytes(bytes, decimals = 2) { + if (bytes === 0) return '0 Bytes'; + const k = 1024, dm = decimals < 0 ? 0 : decimals, sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; + } + + function formatTime(centiseconds) { + const totalSeconds = Math.floor(centiseconds / 100); + const days = Math.floor(totalSeconds / 86400), hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60), seconds = totalSeconds % 60; + if (days > 0) return `${days}d ${hours}h ${minutes}m`; + if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`; + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; + } + + function calculateRates(data) { + if (data.length < 2) return []; + const rates = []; + for (let i = 1; i < data.length; i++) { + const timeDiff = (data[i].timestamp - data[i-1].timestamp) / 1000; + const valueDiff = data[i].value - data[i-1].value; + if (timeDiff < 0.001) continue; + const rate = valueDiff >= 0 ? valueDiff / timeDiff : (4294967295 + valueDiff) / timeDiff; + rates.push({ timestamp: data[i].timestamp, value: rate }); + } + return rates; + } + + function createChartContainers(metrics) { + const metricGrid = document.querySelector('.metric-grid'); + if (!metricGrid) return; + metricGrid.innerHTML = ''; + for (const [metricName, definition] of Object.entries(metrics)) { + const displayName = definition.label || metricName; + const metricCard = document.createElement('div'); + metricCard.className = 'metric-card'; + metricCard.innerHTML = `<h5>${displayName}</h5><div class="chart-container"><canvas id="${metricName}Chart"></canvas></div>`; + metricGrid.appendChild(metricCard); + chartContainers[metricName] = metricCard; + } + } + + function initCharts(metrics) { + if (chartsInitialized) return; + const commonOptions = { + responsive: true, maintainAspectRatio: false, animation: { duration: 300 }, + scales: { + x: { type: 'time', time: { unit: 'minute', tooltipFormat: 'HH:mm:ss' }, grid: { color: 'rgba(255, 255, 255, 0.1)' }, ticks: { color: '#E8F1F2', maxTicksLimit: 5 } }, + y: { beginAtZero: true, grid: { color: 'rgba(255, 255, 255, 0.1)' }, ticks: { color: '#E8F1F2' } } + }, + plugins: { + legend: { labels: { color: '#E8F1F2', font: { family: "'IBM Plex Sans', sans-serif" } } }, + tooltip: { mode: 'index', intersect: false, backgroundColor: 'rgba(17, 17, 17, 0.8)', titleColor: '#FDCFF3', bodyColor: '#E8F1F2', borderColor: '#333', borderWidth: 1 } + } + }; + + for (const [metricName, definition] of Object.entries(metrics)) { + const canvas = document.getElementById(`${metricName}Chart`); + if (!canvas) continue; + const ctx = canvas.getContext('2d'); + let chartOptions = { ...commonOptions }; + let datasets = []; + let label = definition.label || metricName; + let color = chartColors.generic; + + if (metricName === 'network_in' || metricName === 'network_out') { + chartOptions.scales.y.title = { display: true, text: 'Bytes/sec', color: '#E8F1F2' }; + chartOptions.scales.y.ticks.callback = value => formatBytes(value, 0); + label = metricName === 'network_in' ? 'In' : 'Out'; + color = metricName === 'network_in' ? chartColors.networkIn : chartColors.networkOut; + } else if (metricName === 'cpu_load') { + chartOptions.scales.y.title = { display: true, text: 'Load/Usage', color: '#E8F1F2' }; + label = 'CPU'; color = chartColors.cpu; + } else if (metricName.includes('memory')) { + chartOptions.scales.y.title = { display: true, text: 'Memory (KB)', color: '#E8F1F2' }; + chartOptions.scales.y.ticks.callback = value => formatBytes(value * 1024, 0); + label = metricName.includes('total') ? 'Total' : metricName.includes('size') ? 'Size' : 'Used'; + color = chartColors.memory; + } else if (metricName === 'system_uptime') { + chartOptions.scales.y.title = { display: true, text: 'Uptime', color: '#E8F1F2' }; + chartOptions.scales.y.ticks.callback = value => formatTime(value); + label = 'Uptime'; color = chartColors.system; + } else if (metricName === 'system_processes') { + chartOptions.scales.y.title = { display: true, text: 'Count', color: '#E8F1F2' }; + label = 'Processes'; color = chartColors.system; + } else { + chartOptions.scales.y.title = { display: true, text: 'Value', color: '#E8F1F2' }; + } + + datasets = [{ + label: label, + borderColor: color, backgroundColor: `${color}33`, borderWidth: 2, + data: [], pointRadius: 0, fill: true + }]; + + charts[metricName] = new Chart(ctx, { type: 'line', data: { datasets }, options: chartOptions }); + } + chartsInitialized = true; + startDataUpdates(); + } + + function startDataUpdates() { + updateCharts(); + if (updateIntervalId) clearInterval(updateIntervalId); + updateIntervalId = setInterval(updateCharts, UPDATE_INTERVAL); + } + + function stopDataUpdates() { + if (updateIntervalId) { + clearInterval(updateIntervalId); + updateIntervalId = null; + } + } + + async function updateCharts() { + if (!chartsInitialized || !document.getElementById('metrics-section').offsetParent) return; + try { + const response = await fetch(API_ENDPOINT); + if (!response.ok) { + console.error('Failed to fetch metrics:', response.statusText); + return; + } + const data = await response.json(); + metricsDefinitions = data.definitions || {}; + for (const [metricName, metricData] of Object.entries(data.metrics)) { + if (!charts[metricName] || !metricData || metricData.length === 0) continue; + let chartData = (metricName === 'network_in' || metricName === 'network_out') ? calculateRates(metricData) : metricData; + if (!chartData || chartData.length === 0) continue; + + chartData.forEach(point => { + if (typeof point.value === 'string') { + const match = point.value.match(/\d+/); + point.value = match ? parseFloat(match[0]) : 0; + } + }); + + charts[metricName].data.datasets[0].data = chartData.map(point => ({ x: point.timestamp, y: point.value })); + charts[metricName].update('none'); + } + } catch (error) { + console.error('Error updating charts:', error); + } + } + + async function initializeMetricsUI() { + try { + const response = await fetch(API_ENDPOINT); + if (!response.ok) { + console.error('Failed to fetch metrics definitions:', response.statusText); + return; + } + const data = await response.json(); + metricsDefinitions = data.definitions || {}; + createChartContainers(metricsDefinitions); + initCharts(metricsDefinitions); + } catch (error) { + console.error('Error initializing metrics UI:', error); + } + } + + function handleMetricsVisibilityChange() { + if (!metricsSection) return; + if (metricsSection.offsetParent !== null) { + if (!chartsInitialized) { + initializeMetricsUI(); + } else { + startDataUpdates(); + } + } else { + stopDataUpdates(); + } + } + + // Initial Load + document.addEventListener('DOMContentLoaded', function() { + // Start server stats check + getServerStats(); + setInterval(getServerStats, 30000); + + // Apply styles to submit button + if (submitPasswordBtn) submitPasswordBtn.classList.add('submit-button'); + + // Check if already authenticated and show metrics if so + if (checkAuthentication()) showMetrics(); + + // Observer for metrics section visibility + if (metricsSection) { + const observer = new MutationObserver(handleMetricsVisibilityChange); + observer.observe(metricsSection, { attributes: true, attributeFilter: ['style'] }); + } + }); + + </script> + +</body> +</html> diff --git a/server.py b/server.py new file mode 100755 index 0000000..65960a3 --- /dev/null +++ b/server.py @@ -0,0 +1,834 @@ +#!/usr/bin/env python3 +import subprocess +import json +import time +import os +import base64 +import hashlib +import mimetypes +import platform +import re +import jwt +import secrets +from datetime import datetime, timedelta, timezone +import threading +import socket +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import parse_qs, urlparse, unquote + +# Optional: Load environment variables from .env file if python-dotenv is available +try: + from dotenv import load_dotenv + load_dotenv() # Load environment variables from .env file if it exists + print("Successfully loaded .env file") +except ImportError: + print("python-dotenv not installed. Using environment variables directly.") + +# Initialize mime types +mimetypes.init() + +# --- Configuration --- +# Load JWT Secret from environment variable or use a secure fallback +# Generate one using: python -c "import secrets; print(secrets.token_hex(32))" +JWT_SECRET_KEY = os.environ.get("JWT_SECRET", secrets.token_hex(32)) +JWT_ALGORITHM = "HS256" +JWT_EXPIRATION_HOURS = int(os.environ.get("JWT_EXPIRATION_HOURS", "8")) +SERVER_PORT = int(os.environ.get("SERVER_PORT", "8000")) +SNMP_COLLECTION_INTERVAL = int(os.environ.get("SNMP_COLLECTION_INTERVAL", "10")) + +# Authentication Configuration +default_password = secrets.token_urlsafe(16) # Generate a secure random password as fallback +AUTH_CONFIG = { + 'password': os.environ.get('AUTH_PASSWORD', default_password), + 'password_hash': None # Will be set on startup +} + +# SNMP Configuration +SNMP_HOST = os.environ.get("SNMP_HOST", "127.0.0.1") +SNMP_COMMUNITY = os.environ.get("SNMP_COMMUNITY", "public") +SNMP_VERSION = os.environ.get("SNMP_VERSION", "2c") + +# Data storage - in memory for simplicity +metrics_data = {} +DATA_POINTS_TO_KEEP = 60 +data_lock = threading.Lock() + +# --- New Data Structures for App Performance --- +request_log = [] # Stores tuples of (timestamp, duration_ms, status_code) +request_log_lock = threading.Lock() +app_perf_data = {} # Stores calculated app performance metrics +APP_PERF_POINTS_TO_KEEP = 60 +# --- End New Data Structures --- + +# Define metrics to collect +METRICS = [ + {"name": "network_in", "oid": "ifInOctets.1", "label": "Network In (bytes)"}, + {"name": "network_out", "oid": "ifOutOctets.1", "label": "Network Out (bytes)"}, + # {"name": "system_uptime", "oid": "sysUpTime.0", "label": "System Uptime"}, + {"name": "system_processes", "oid": "hrSystemProcesses.0", "label": "System Processes"}, + + # --- Placeholders for New Metrics --- + # App Performance - calculated, not SNMP + {"name": "app_response_time_avg", "label": "Avg Response Time (ms)", "type": "calculated"}, + {"name": "app_error_rate", "label": "Error Rate (%)", "type": "calculated"}, + {"name": "app_request_count", "label": "Request Count", "type": "calculated"}, + # Service Availability - calculated, not SNMP + {"name": "service_http_status", "label": "HTTP Service Status", "type": "calculated"}, + {"name": "service_ssh_status", "label": "SSH Service Status", "type": "calculated"}, + # Add other services here if needed + # --- End Placeholders --- +] + +# Map of alternate OIDs +ALTERNATE_OIDS = { + "cpu_load": [ + "laLoad.1", "ssCpuRawUser.0", "hrProcessorLoad.0", "systemStats.ssCpuUser.0" + ], + "memory_total": [ + "hrMemorySize.0", "memTotalReal.0", "hrStorageSize.1" + ], + "memory_used": [ + "memAvailReal.0", "hrStorageUsed.1", "hrSWRunPerfMem.1" + ] +} +# --- End Configuration --- + +# --- Utility Functions --- + +def run_snmp_command(oid): + """Run SNMP command and return the value""" + try: + # Format command according to OpenBSD snmp manual + # snmp get [options] agent oid ... + cmd = [ + "snmp", "get", + "-v", SNMP_VERSION, + "-c", SNMP_COMMUNITY, + SNMP_HOST, oid + ] + + # Print the command being executed for debugging + print(f"Executing: {' '.join(cmd)}") + + # Don't use check=True so we can capture error output + result = subprocess.run(cmd, capture_output=True, text=True) + + # Check for errors + if result.returncode != 0: + print(f"SNMP Error: {result.stderr.strip()}") + # Try with doas if permissions might be an issue + try: + cmd_with_doas = ["doas"] + cmd + print(f"Retrying with doas: {' '.join(cmd_with_doas)}") + result = subprocess.run(cmd_with_doas, capture_output=True, text=True) + if result.returncode != 0: + print(f"SNMP Error with doas: {result.stderr.strip()}") + return None + except Exception as e: + print(f"Failed to run with doas: {e}") + return None + + # Parse the output to extract just the value + output = result.stdout.strip() + + # Show output for debugging + print(f"SNMP Output: {output}") + + # Check for "No Such Instance" or similar errors + if "No Such Instance" in output or "No Such Object" in output: + print(f"OID not available: {output}") + return None + + # Proper parsing based on OpenBSD snmp output format + if "=" in output: + value_part = output.split("=", 1)[1].strip() + + # Special handling for Timeticks + if "Timeticks:" in value_part: + # Extract the numeric part from something like "Timeticks: (12345) 1:2:3.45" + ticks_part = value_part.split("(", 1)[1].split(")", 1)[0] + return float(ticks_part) + elif ":" in value_part: + value_part = value_part.split(":", 1)[1].strip() + + # Convert to a number if possible + try: + return float(value_part) + except ValueError: + # Only return string values that don't look like errors + if "error" not in value_part.lower() and "no such" not in value_part.lower(): + return value_part + print(f"Error in SNMP value: {value_part}") + return None + else: + print(f"Unexpected SNMP output format: {output}") + return None + except subprocess.SubprocessError as e: + print(f"Error running SNMP command: {e}") + if hasattr(e, 'stderr') and e.stderr: + print(f"STDERR: {e.stderr}") + return None + except Exception as e: + print(f"Unexpected error in run_snmp_command: {e}") + return None + +def discover_supported_oids(): + """Attempt to discover which OIDs are supported by the system""" + print("\n----- Discovering supported OIDs (excluding memory total) -----") + discovered_metrics = [] + + # Try to find a working CPU metric + print("Looking for CPU metrics...") + for oid in ALTERNATE_OIDS["cpu_load"]: + value = run_snmp_command(oid) + if value is not None and isinstance(value, (int, float)) and value >= 0: + print(f"Found working CPU OID: {oid}") + discovered_metrics.append({ + "name": "cpu_load", + "oid": oid, + "label": "CPU Load/Usage" + }) + break + + # Try to find working memory metrics (excluding total memory) + print("Looking for memory metrics (excluding total)...") + + # First try to find total memory (but don't add it to the list) + total_memory_oid_found = None + for oid in ALTERNATE_OIDS["memory_total"]: + value = run_snmp_command(oid) + if value is not None and isinstance(value, (int, float)) and value > 0: + print(f" (Found working memory total OID: {oid} - skipping addition)") + # discovered_metrics.append({ + # "name": "memory_total", + # "oid": oid, + # "label": "Total Memory" + # }) + total_memory_oid_found = oid # Still note if found for potential future use + break + + # Then try to find used memory + for oid in ALTERNATE_OIDS["memory_used"]: + value = run_snmp_command(oid) + if value is not None and isinstance(value, (int, float)) and value > 0: + print(f"Found working memory used OID: {oid}") + discovered_metrics.append({ + "name": "memory_used", + "oid": oid, + "label": "Memory Usage" + }) + break + + # If we found total memory but not used memory, we can use the total as a placeholder + # We keep discovery, but won't add memory_size to METRICS by default anymore + # Also removing the fallback addition here as total memory is not desired + # if total_memory_oid_found and not any(m["name"] == "memory_used" for m in discovered_metrics): + # print(f" (Skipping memory total as placeholder for memory usage)") + # # discovered_metrics.append({ + # # "name": "memory_size", + # # "oid": total_memory_oid_found, + # # "label": "Memory Size" + # # }) + + return discovered_metrics + +def collect_metrics(): + """Collect all configured metrics""" + with data_lock: + current_time = int(time.time() * 1000) # JavaScript timestamp (milliseconds) + + for metric in METRICS: + metric_name = metric["name"] + oid = metric.get("oid") # Use .get() as calculated metrics won't have OID + + # Initialize the metric data if it doesn't exist + if metric_name not in metrics_data: + metrics_data[metric_name] = [] + + # Skip SNMP collection for calculated metrics + if metric.get("type") == "calculated": + continue + + value = run_snmp_command(oid) + if value is not None and (isinstance(value, (int, float)) or isinstance(value, str) and value.strip() != ""): + print(f"Successfully collected {metric_name}: {value}") + # Add the new data point + metrics_data[metric_name].append({ + "timestamp": current_time, + "value": value + }) + + # Keep only the most recent data points + if len(metrics_data[metric_name]) > DATA_POINTS_TO_KEEP: + metrics_data[metric_name] = metrics_data[metric_name][-DATA_POINTS_TO_KEEP:] + else: + print(f"Failed to collect {metric_name}") + +# --- New Collection Functions (moved before metrics_collector) --- + +def collect_application_performance(): + """Calculates app performance metrics from the request log.""" + current_time = int(time.time() * 1000) + with request_log_lock: + # Make a copy to avoid holding the lock during calculations + log_copy = list(request_log) + # Clear the original log for the next interval + request_log.clear() + + if not log_copy: + # No requests logged in this interval + avg_response_time = 0 + error_rate = 0 + request_count = 0 + else: + total_duration = sum(log[1] for log in log_copy) + total_requests = len(log_copy) + error_count = sum(1 for log in log_copy if log[2] >= 400) + + avg_response_time = total_duration / total_requests if total_requests > 0 else 0 + error_rate = (error_count / total_requests) * 100 if total_requests > 0 else 0 + request_count = total_requests + + print(f"Collected App Perf: Avg Response={avg_response_time:.2f}ms, Error Rate={error_rate:.2f}%, Count={request_count}") + + # Store the calculated metrics + with data_lock: + for name, value in [ + ("app_response_time_avg", avg_response_time), + ("app_error_rate", error_rate), + ("app_request_count", request_count) + ]: + if name not in app_perf_data: # Use app_perf_data for calculated app metrics + app_perf_data[name] = [] + app_perf_data[name].append({"timestamp": current_time, "value": value}) + if len(app_perf_data[name]) > APP_PERF_POINTS_TO_KEEP: + app_perf_data[name] = app_perf_data[name][-APP_PERF_POINTS_TO_KEEP:] + +def check_service_status(host, port, timeout=1): + """Checks if a TCP service is available on a host and port.""" + try: + # Use socket.create_connection for simplicity and IPv6 compatibility + with socket.create_connection((host, port), timeout=timeout): + return 1 # Service is up + except socket.timeout: + print(f"Service check timeout for {host}:{port}") + return 0 # Timeout means service is not readily available + except ConnectionRefusedError: + # Explicitly handle connection refused + return 0 # Service is actively refusing connection + except OSError as e: + # Catch other potential OS errors like network unreachable + print(f"Service check OS error for {host}:{port}: {e}") + return 0 + except Exception as e: + # Catch any other unexpected errors + print(f"Unexpected error checking service {host}:{port}: {e}") + return 0 # Treat other errors as down + +def collect_service_availability(): + """Collects availability status for configured services.""" + current_time = int(time.time() * 1000) + services_to_check = [ + # Check the web server itself + {"name": "service_http_status", "host": "127.0.0.1", "port": SERVER_PORT}, + # Check SSH + {"name": "service_ssh_status", "host": "127.0.0.1", "port": 22}, + # Add other services like database here: + # {"name": "service_db_status", "host": "db_host", "port": db_port}, + ] + + print("Collecting Service Availability...") + with data_lock: # Assuming lock is sufficient for metrics_data writes + for service in services_to_check: + metric_name = service["name"] + host = service["host"] + port = service["port"] + + status = check_service_status(host, port) + print(f" {metric_name} ({host}:{port}): {'Up' if status == 1 else 'Down'}") + + if metric_name not in metrics_data: # Store directly in metrics_data + metrics_data[metric_name] = [] + + # Ensure data structure matches other metrics + metrics_data[metric_name].append({"timestamp": current_time, "value": status}) + # Keep only the most recent data points + if len(metrics_data[metric_name]) > DATA_POINTS_TO_KEEP: + metrics_data[metric_name] = metrics_data[metric_name][-DATA_POINTS_TO_KEEP:] + +# --- End New Collection Functions --- + +def metrics_collector(): + """Background thread to collect metrics periodically""" + while True: + try: + print("\n--- Collecting metrics ---") + # Collect SNMP metrics + collect_metrics() + # Collect Service Availability + collect_service_availability() + # Collect Application Performance + collect_application_performance() # Process logs collected by web server + + except Exception as e: + print(f"Error in metrics collector loop: {e}") + + time.sleep(SNMP_COLLECTION_INTERVAL) + +def verify_password(password): + """Verify the password against stored hash (no username needed).""" + hashed_password = hashlib.sha256(password.encode()).hexdigest() + # Compare the provided password hash with the configured hash + return secrets.compare_digest(hashed_password, AUTH_CONFIG['password_hash']) + +def get_server_stats(): + """Generate server stats, including basic service checks.""" + stats = {} + + try: + # --- Get Uptime --- + if platform.system() == "Windows": + # Windows uptime via PowerShell (in seconds) + uptime_cmd = ["powershell", "-Command", "Get-CimInstance Win32_OperatingSystem | Select-Object LastBootUpTime,LocalDateTime | ForEach-Object {(New-TimeSpan -Start $_.LastBootUpTime -End $_.LocalDateTime).TotalSeconds}"] + uptime_sec = int(float(subprocess.check_output(uptime_cmd).decode().strip())) + days = uptime_sec // 86400 + hours = (uptime_sec % 86400) // 3600 + minutes = (uptime_sec % 3600) // 60 + uptime_text = f"{days} days, {hours} hours, {minutes} minutes" + else: + # Linux/Unix uptime + uptime_output = subprocess.check_output(["uptime"]).decode() + # Extract the uptime text + uptime_match = re.search(r'up\s+(.*?),\s+\d+\s+user', uptime_output) + uptime_text = uptime_match.group(1) if uptime_match else "Unknown" + + # Extract load averages + load_match = re.search(r'load average[s]?:\s+([0-9.]+),\s+([0-9.]+),\s+([0-9.]+)', uptime_output) + if load_match: + stats['load_1min'] = load_match.group(1) + stats['load_5min'] = load_match.group(2) + stats['load_15min'] = load_match.group(3) + else: + stats['load_1min'] = "0" + stats['load_5min'] = "0" + stats['load_15min'] = "0" + + # Set uptime + stats['uptime'] = uptime_text + + # Get CPU cores + if platform.system() == "Windows": + # Windows core count + cores_cmd = ["powershell", "-Command", "Get-CimInstance Win32_ComputerSystem | Select-Object NumberOfLogicalProcessors"] + cores = int(subprocess.check_output(cores_cmd).decode().strip().split("\r\n")[-1]) + else: + # Linux/Unix core count + try: + cores = int(subprocess.check_output(["grep", "-c", "processor", "/proc/cpuinfo"]).decode().strip()) + except: + # Fallback for systems without /proc/cpuinfo + try: + cores = int(subprocess.check_output(["sysctl", "-n", "hw.ncpu"]).decode().strip()) + except: + cores = 1 # Default if we can't determine + + stats['cores'] = str(cores) + + # --- Check Service Availability --- + print("Checking essential services for server stats...") + # Check HTTP service (self) + stats['service_http_status'] = check_service_status("127.0.0.1", SERVER_PORT, timeout=0.5) + print(f" HTTP check (port {SERVER_PORT}): {'Up' if stats['service_http_status'] == 1 else 'Down'}") + # Check SSH service + stats['service_ssh_status'] = check_service_status("127.0.0.1", 22, timeout=0.5) + print(f" SSH check (port 22): {'Up' if stats['service_ssh_status'] == 1 else 'Down'}") + # --- End Service Availability Check --- + + stats['timestamp'] = int(time.time()) + return stats + except Exception as e: + print(f"Error generating server stats: {e}") + # Return default values if error, including default service status + return { + 'uptime': 'Unknown', + 'load_1min': '0', + 'load_5min': '0', + 'load_15min': '0', + 'cores': '1', + 'service_http_status': 0, # Default to down on error + 'service_ssh_status': 0, # Default to down on error + 'timestamp': int(time.time()) + } + +# --- HTTP Request Handler with JWT --- + +class WebServer(BaseHTTPRequestHandler): + """HTTP request handler for the combined web server with JWT Auth""" + + # Base directory for serving files + base_path = os.path.dirname(os.path.abspath(__file__)) + static_path = os.path.join(base_path, "static") + templates_path = os.path.join(base_path, "templates") + + def log_message(self, format, *args): + client_ip = self.client_address[0] + print(f"[{client_ip}] {format % args}") + + def send_response_with_headers(self, status_code, content_type, extra_headers=None): + self.send_response(status_code) + self.send_header("Content-Type", content_type) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + if extra_headers: + for header, value in extra_headers.items(): + self.send_header(header, value) + self.end_headers() + + def send_error_json(self, status_code, message): + self.log_message(f"Sending error {status_code}: {message}") + self.send_response(status_code) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + self.end_headers() + self.wfile.write(json.dumps({"error": message}).encode()) + + def serve_static_file(self, requested_path): + """Serve a static file from the static directory""" + try: + # Prevent directory traversal, ensure path starts with /static/ + if not requested_path.startswith('/static/') or '..' in requested_path: + self.send_error_json(403, "Access denied to static resource") + return + + # Construct absolute path relative to the static directory + relative_path = requested_path.lstrip('/') # Remove leading slash + file_path = os.path.abspath(os.path.join(self.base_path, relative_path)) + + # Security check: ensure the final path is within the static directory + if not file_path.startswith(self.static_path): + self.log_message(f"Attempted access outside static dir: {file_path}") + self.send_error_json(403, "Access denied") + return + + if not os.path.exists(file_path) or not os.path.isfile(file_path): + self.send_error_json(404, f"Static file not found: {requested_path}") + return + + # Guess MIME type + content_type, _ = mimetypes.guess_type(file_path) + if not content_type: + content_type = 'application/octet-stream' # Default binary type + + self.log_message(f"Serving static file: {file_path} ({content_type})") + with open(file_path, 'rb') as file: + fs = os.fstat(file.fileno()) + self.send_response_with_headers(200, content_type, {"Content-Length": str(fs.st_size)}) + self.wfile.write(file.read()) + + except Exception as e: + self.log_message(f"Error serving static file {requested_path}: {e}") + self.send_error_json(500, "Internal server error serving file") + + def serve_template(self, template_name): + """Serve an HTML template from the templates directory""" + try: + file_path = os.path.abspath(os.path.join(self.templates_path, template_name)) + + # Security check + if not file_path.startswith(self.templates_path) or '..' in template_name: + self.send_error_json(403, "Access denied to template") + return + + if not os.path.exists(file_path) or not os.path.isfile(file_path): + self.send_error_json(404, f"Template not found: {template_name}") + return + + self.log_message(f"Serving template: {file_path}") + with open(file_path, 'rb') as file: + fs = os.fstat(file.fileno()) + self.send_response_with_headers(200, "text/html", {"Content-Length": str(fs.st_size)}) + self.wfile.write(file.read()) + + except Exception as e: + self.log_message(f"Error serving template {template_name}: {e}") + self.send_error_json(500, "Internal server error serving template") + + def verify_jwt(self): + """Verify JWT from Authorization header. Returns payload or None.""" + auth_header = self.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return None + + token = auth_header.split(' ')[1] + try: + payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM]) + # Optional: check 'sub' or other claims if needed + self.log_message(f"JWT verified for sub: {payload.get('sub')}") + return payload + except jwt.ExpiredSignatureError: + self.log_message("JWT verification failed: ExpiredSignatureError") + return None # Indicate expired but valid structure + except jwt.InvalidTokenError as e: + self.log_message(f"JWT verification failed: InvalidTokenError ({e})") + return None # Indicate invalid token + except Exception as e: + self.log_message(f"JWT verification failed: Unexpected error ({e})") + return None + + def require_auth(self): + """Decorator-like method to check auth before proceeding. Returns True if authorized.""" + payload = self.verify_jwt() + if payload: + return True + else: + self.send_error_json(401, "Authentication required or token expired") + return False + + def do_OPTIONS(self): + self.send_response(204) # No Content for OPTIONS + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization") + self.end_headers() + + def do_POST(self): + path = urlparse(self.path).path + + if path == "/api/login": + content_length = int(self.headers.get('Content-Length', 0)) + post_data = self.rfile.read(content_length).decode('utf-8') + try: + login_data = json.loads(post_data) + password = login_data.get('password', '') # Only get password + + if verify_password(password): # Only pass password + # Create JWT payload (no 'sub' needed or set to generic) + expiration = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRATION_HOURS) + payload = { + # 'sub': 'authenticated_user', # Optional generic subject + 'iat': datetime.now(timezone.utc), + 'exp': expiration + } + token = jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM) + self.log_message(f"Login successful, token issued.") # Removed username + self.send_response_with_headers(200, "application/json") + self.wfile.write(json.dumps({"token": token}).encode()) + else: + self.log_message(f"Login failed: Invalid password") # Removed username + self.send_error_json(401, "Invalid credentials") + except json.JSONDecodeError: + self.send_error_json(400, "Invalid JSON") + except Exception as e: + self.log_message(f"Error during login: {e}") + self.send_error_json(500, "Server error during login") + else: + self.send_error_json(404, "Not found") + + def do_GET(self): + url_parts = urlparse(self.path) + path = unquote(url_parts.path) + + # --- API Endpoints --- + if path == "/api/metrics": + if self.require_auth(): + self.send_response_with_headers(200, "application/json") + with data_lock: + # Combine SNMP metrics and calculated App Perf metrics + combined_metrics = metrics_data.copy() + combined_metrics.update(app_perf_data) # Merge app perf data + + response = { + "metrics": combined_metrics, + # Update definitions to include calculated metrics' labels + "definitions": { + m["name"]: {"label": m["label"]} + for m in METRICS # Use the main METRICS list for definitions + } + } + self.wfile.write(json.dumps(response).encode()) + elif path.startswith("/api/metric/"): + if self.require_auth(): + metric_name = path.split("/")[-1] + # Check both metrics_data and app_perf_data + data_to_send = None + with data_lock: # Use data_lock for both, assuming app_perf_data updates are covered by it in collector + if metric_name in metrics_data: + data_to_send = metrics_data[metric_name] + elif metric_name in app_perf_data: + data_to_send = app_perf_data[metric_name] + + if data_to_send: + self.send_response_with_headers(200, "application/json") + self.wfile.write(json.dumps(data_to_send).encode()) + else: + self.send_error_json(404, f"Metric '{metric_name}' not found") # Use send_error_json for consistency + elif path == "/stat/.server-stats.json": + # Server stats endpoint does not require authentication + stats = get_server_stats() + self.send_response_with_headers(200, "application/json") + self.wfile.write(json.dumps(stats, indent=4).encode()) + + # --- Static Files & Templates --- + elif path == "/": + self.serve_template("index.html") + elif path.startswith("/static/"): + self.serve_static_file(path) + elif path == "/favicon.ico": + # Favicon served from static dir + self.serve_static_file("/static/favicon.ico") # Assuming favicon is in static + else: + # Serve index.html for any other path (for single-page app behavior) + # OR send 404 if you prefer strict path matching + self.serve_template("index.html") + # Alternatively: self.send_error_json(404, "Not found") + +# --- Server Startup --- + +def get_ip_address(): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(('10.255.255.255', 1)) + ip = s.getsockname()[0] + except Exception: + ip = '127.0.0.1' + finally: + s.close() + return ip + +def start_server(port=SERVER_PORT): + try: + # Update password hash at startup based on the password + AUTH_CONFIG['password_hash'] = hashlib.sha256(AUTH_CONFIG['password'].encode()).hexdigest() + + # Print startup information (mask sensitive info) + print(f"\nStarting server with the following configuration:") + print(f"Server Port: {port}") + if AUTH_CONFIG['password'] == default_password: + print(f"WARNING: Using auto-generated password: {AUTH_CONFIG['password']}") + print(f"You should set AUTH_PASSWORD in the environment or .env file") + else: + print(f"Using configured password (from environment)") + + # Print a hint of the JWT secret but never the full value + jwt_hint = JWT_SECRET_KEY[:4] + "..." + JWT_SECRET_KEY[-4:] if len(JWT_SECRET_KEY) > 8 else "****" + print(f"JWT Secret configured: {jwt_hint}") + + server = HTTPServer(("0.0.0.0", port), WebServer) + ip = get_ip_address() + print(f"\nServer is running at: http://{ip}:{port}/") + print(f"Press Ctrl+C to stop the server") + server.serve_forever() + except KeyboardInterrupt: + print("\nShutting down server...") + server.socket.close() + except ImportError: + print("\nERROR: PyJWT library not found.") + print("Please install it using: pip install PyJWT cryptography") + except OSError as e: + if e.errno == 98: # Address already in use + print(f"\nERROR: Port {port} is already in use.") + print("Please stop the other process or choose a different port.") + else: + print(f"\nError starting server: {e}") + except Exception as e: + print(f"\nError starting server: {e}") + +if __name__ == "__main__": + print("\n=== Comprehensive Web and Metrics Server with JWT ===") + print("Starting with the following configuration:") + print(f"SNMP Host: {SNMP_HOST}") + print(f"SNMP Community: {SNMP_COMMUNITY}") + print(f"SNMP Version: {SNMP_VERSION}") + # Display only the metrics configured for collection/calculation initially + print(f"Configured Metrics: {[m['name'] + (' (SNMP)' if 'oid' in m and m.get('type') != 'calculated' else ' (calculated)') for m in METRICS]}") + + print("\nAttempting to discover additional SNMP metrics (won't be added to periodic collection)...") + # Store discovered metrics separately, don't add them back to METRICS + discovered_metrics_info = discover_supported_oids() + if discovered_metrics_info: + print(f"Discovered {len(discovered_metrics_info)} additional potentially available SNMP metrics:") + for m in discovered_metrics_info: + print(f" - {m['name']} ({m['oid']})") + # METRICS.extend(discovered) # DO NOT add discovered metrics back to the main polling list + else: + print("No additional SNMP metrics discovered") + + collector_thread = threading.Thread(target=metrics_collector, daemon=True) + collector_thread.start() + + start_server() + +# --- End Add request logging --- + def handle_one_request(self): + """Handle a single HTTP request and log performance.""" + start_time = time.monotonic() + # Use a variable to store the status code, as super().handle_one_request() doesn't return it easily + self._current_status_code = 200 # Default to 200 in case no response is sent + try: + # Call the original handler + super().handle_one_request() + except Exception as e: + # Log exceptions and set status to 500 + self.log_message(f"Error handling request: {e}") + self._current_status_code = 500 + # Don't re-raise so we can still capture metrics + finally: + # Calculate duration *after* the request is handled + end_time = time.monotonic() + duration_ms = (end_time - start_time) * 1000 + # Use the captured status code or default to 500 if something went very wrong before send_response was called + status_code = getattr(self, '_current_status_code', 500) + + # Log the request details (timestamp, duration, status) + with request_log_lock: + request_log.append((int(time.time() * 1000), duration_ms, status_code)) + # Optional: Limit request_log size if it grows too large between collections + # Consider if this is needed based on expected request volume and collection interval + MAX_LOG_ENTRIES = 1000 + if len(request_log) > MAX_LOG_ENTRIES: + request_log = request_log[-MAX_LOG_ENTRIES:] + + # Log the request completion for debugging + # Ensure requestline is available; default if not + requestline = getattr(self, 'requestline', 'Unknown Request') + self.log_message(f'"{requestline}" {status_code} - {duration_ms:.2f}ms') + + def send_response(self, code, message=None): + """Override send_response to capture the status code.""" + self.log_message(f"Setting status code: {code}") # Debug log + self._current_status_code = code # Store the status code + super().send_response(code, message) + # --- End request logging methods --- + + def send_response_with_headers(self, status_code, content_type, extra_headers=None): + # Call the overridden send_response to ensure status code is captured + self.send_response(status_code) + self.send_header("Content-Type", content_type) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + if extra_headers: + for header, value in extra_headers.items(): + self.send_header(header, value) + self.end_headers() + + def send_error_json(self, status_code, message): + self.log_message(f"Sending error {status_code}: {message}") + # Call the overridden send_response directly here to ensure the status code is set before headers + self.send_response(status_code) + # Set headers manually after send_response + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + self.end_headers() + self.wfile.write(json.dumps({"error": message}).encode()) + +# --- End Add request logging ---
\ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..a32b5d9 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,394 @@ +body { + max-width: 1100px; + margin: auto; + line-height: 1.6em; + font-family: 'IBM Plex Sans', sans-serif; + background: #070707; + color: #E8F1F2; +} + +footer { + text-align: center; + margin-bottom: 100px; +} + +a { color: #88B7B5} +a:hover { color: #FDCFF3 } + +header { + color: #FDCFF3; + padding: 10px; + margin: 10px; + text-align: center; + border-bottom: 2px solid white; +} + +h1, h2, h3, h4, h5, h6 { + color: #FDCFF3; +} + +header span { + color: #C8B8DB; + text-decoration: underline; +} + +.container { + /* Add some padding and potentially a max-width for larger screens */ + padding: 0 20px; + max-width: 1000px; /* Example max-width */ + margin: 0 auto; /* Center the container */ +} + +.component-section { + margin-bottom: 40px; /* Spacing between component groups */ + border-bottom: 1px solid #555; /* Subtle divider */ + padding-bottom: 20px; +} + +.component-section:last-child { + border-bottom: none; /* Remove border from the last section */ +} + + +.component-item { + margin-bottom: 20px; /* Spacing between individual components */ + display: flex; /* Use flexbox for layout */ + align-items: center; /* Vertically center content */ + flex-wrap: wrap; /* Allow items to wrap on smaller screens*/ +} + +.component-item h4 { + margin: 0 0 10px 0; /* Spacing below the heading */ + flex: 1 1 100%; /* Make the heading take full width */ +} +.component-item p { + flex: 1 1 60%; /* Text takes up most of the space*/ + padding-right: 20px; /* Add some spacing between text and image */ + margin: 10px; +} + +.component-image { + flex: 0 0 30%; /* Image takes up less space, doesn't grow */ + width: auto; + margin-right: auto; + margin-left: auto; /* Push image to the right */ + display: block; +} +.component-image.half-width { + max-width: 45%; + margin: 10px; +} +.icon { + margin-right: 8px; + font-size: 1.2em; /* Slightly larger icons */ +} + +/* CTA Section Styles */ +.cta-section { + text-align: center; + padding: 40px 0; + background-color: #111; /* Darker background */ +} + +.button { + display: inline-block; + padding: 10px 20px; + background-color: #88B7B5; + color: #070707; + text-decoration: none; + border-radius: 5px; + margin: 10px; + transition: background-color 0.3s ease; + border: none; + cursor: pointer; + font-family: 'IBM Plex Sans', sans-serif; + font-size: 1em; +} + +.button:hover { + background-color: #FDCFF3; + color: #000000; +} + +/* Testimonials Section */ +.testimonials { + padding: 40px 0; + text-align: center; +} +.testimonial { + margin-bottom: 20px; + font-style: italic; +} +/* Responsive adjustments */ +@media (max-width: 768px) { + .component-item { + flex-direction: column; /* Stack items vertically on small screens */ + text-align: center; /* Center-align text */ + } + .component-image { + margin-left: 0; /* Remove margin */ + max-width: 100%; /* Full-width images */ + } + .component-image.half-width { + max-width: 100%; /* Full-width images */ + } + .component-item p { + padding-right: 0; /* Remove padding */ + } +} + +/* Status Indicator */ +.status-container { + display: flex; + align-items: center; + gap: 10px; +} + +.status-light { + width: 20px; + height: 20px; + border-radius: 50%; + animation: pulse 2s infinite; +} + +.status-light.green { + background: #88B7B5; + animation: pulse-green 2s infinite; +} + +.status-light.yellow { + background: #FDCFF3; + animation: pulse-yellow 1s infinite; +} + +.status-light.red { + background: #ff4d4d; + animation: pulse-red 0.75s infinite; +} + +@keyframes pulse-green { + 0% { box-shadow: 0 0 0 0 rgba(136, 183, 181, 0.4); } + 100% { box-shadow: 0 0 0 10px rgba(136, 183, 181, 0); } +} + +@keyframes pulse-yellow { + 0% { box-shadow: 0 0 0 0 rgba(253, 207, 243, 0.4); } + 100% { box-shadow: 0 0 0 10px rgba(253, 207, 243, 0); } +} + +@keyframes pulse-red { + 0% { box-shadow: 0 0 0 0 rgba(255, 77, 77, 0.4); } + 100% { box-shadow: 0 0 0 10px rgba(255, 77, 77, 0); } +} + +#uptime { + color: #88B7B5; + font-size: 0.9em; +} + +/* Service Status Styles */ +.service-status { + margin-top: 15px; + margin-bottom: 15px; + padding: 10px 15px; + font-size: 0.9em; + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 15px; + background-color: rgba(17, 17, 17, 0.5); + border-radius: 5px; + border-left: 3px solid #333; +} + +.service-item { + display: flex; + align-items: center; + gap: 10px; +} + +.service-item span { + color: #88B7B5; +} + +/* Remove unused pill styles */ +.service-up, .service-down { + display: none; +} + +/* Chart and Metrics Styles */ +#metrics-container { + width: 100%; + margin-top: 15px; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; + width: 100%; +} + +.metric-card { + background-color: #111; + border-radius: 8px; + padding: 15px; + border: 1px solid #333; + transition: box-shadow 0.3s ease; +} + +.metric-card:hover { + box-shadow: 0 0 15px rgba(136, 183, 181, 0.3); + border-color: #88B7B5; +} + +.metric-card h5 { + margin-top: 0; + margin-bottom: 10px; + color: #FDCFF3; + text-align: center; + font-size: 1em; +} + +.chart-container { + position: relative; + width: 100%; + height: 200px; + margin-top: 10px; + margin-bottom: 10px; +} + +.metric-section-header { + grid-column: 1 / -1; + margin-top: 15px; + margin-bottom: 10px; + color: #FDCFF3; + border-bottom: 1px solid #333; + padding-bottom: 5px; +} + +@media (max-width: 768px) { + .metric-grid { + grid-template-columns: 1fr; /* Single column on mobile */ + } + .chart-container { + height: 180px; /* Slightly smaller on mobile */ + } +} + +/* Modal Styles */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(4px); + opacity: 0; + transition: opacity 0.3s ease; +} + +.modal.show { + opacity: 1; +} + +.modal-content { + position: relative; + background-color: #111; + margin: 15% auto; + padding: 25px; + border: 1px solid #88B7B5; + border-radius: 8px; + width: 80%; + max-width: 500px; + box-shadow: 0 4px 20px rgba(136, 183, 181, 0.5); + transform: translateY(-20px) scale(0.95); + opacity: 0; + transition: transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275), + opacity 0.3s ease; +} + +.modal.show .modal-content { + transform: translateY(0) scale(1); + opacity: 1; +} + +@keyframes modalFadeIn { + from {opacity: 0; transform: translateY(-20px);} + to {opacity: 1; transform: translateY(0);} +} + +.close-button { + position: absolute; + top: 10px; + right: 20px; + color: #FDCFF3; + font-size: 28px; + font-weight: bold; + cursor: pointer; + transition: color 0.2s, transform 0.2s; +} + +.close-button:hover { + color: #ff4d4d; + transform: scale(1.1); +} + +.password-input-container { + display: flex; + gap: 10px; + margin: 20px 0; +} + +.password-input-container input { + flex: 1; + padding: 10px; + border-radius: 5px; + border: 1px solid #333; + background-color: #070707; + color: #E8F1F2; + font-family: 'IBM Plex Sans', sans-serif; + transition: border-color 0.3s, box-shadow 0.3s; +} + +.password-input-container input:focus { + outline: none; + border-color: #88B7B5; + box-shadow: 0 0 8px rgba(136, 183, 181, 0.5); +} + +.button.submit-button { + background-color: #88B7B5; + transition: background-color 0.3s, transform 0.2s; +} + +.button.submit-button:hover { + background-color: #FDCFF3; + transform: translateY(-2px); +} + +.button.submit-button:active { + transform: translateY(1px); +} + +.error-message { + color: #ff4d4d; + font-size: 0.9em; + margin-top: 10px; + min-height: 20px; + transition: opacity 0.3s; +} + +/* Enhanced shake animation */ +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); } + 20%, 40%, 60%, 80% { transform: translateX(5px); } +} + +.shake { + animation: shake 0.5s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; +} diff --git a/static/images/ashita-no-joe-joe-yabuki.gif b/static/images/ashita-no-joe-joe-yabuki.gif Binary files differnew file mode 100644 index 0000000..73777e0 --- /dev/null +++ b/static/images/ashita-no-joe-joe-yabuki.gif diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..74cd9c7 --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,750 @@ +// --- JWT Constants --- +const JWT_TOKEN_KEY = 'metrics_jwt_token'; + +// --- Server Stats Logic --- +async function getServerStats() { + const statusElement = document.getElementById('server-status'); + const uptimeElement = document.getElementById('uptime'); + + if (!statusElement || !uptimeElement) { + return; + } + + try { + const response = await fetch('/stat/.server-stats.json'); + if (!response || !response.ok) { + throw new Error(`Could not fetch server stats (status: ${response?.status})`); + } + const data = await response.json(); + + let status = "green"; + const load1 = parseFloat(data.load_1min); + const cores = parseInt(data.cores, 10) || 1; + + if (!isNaN(load1)) { + if (load1 > (cores * 0.7)) status = "yellow"; + if (load1 > (cores * 0.9)) status = "red"; + } else { + status = "yellow"; + } + + statusElement.className = `status-light ${status}`; // More direct class setting + statusElement.title = status === "green" ? "Server running normally" : + status === "yellow" ? "Server under medium load" : + status === "red" ? "Server under heavy load" : "Unknown status"; + + uptimeElement.textContent = `Uptime: ${data.uptime || 'Unknown'} (Load: ${data.load_1min || '?'}, ${data.load_5min || '?'}, ${data.load_15min || '?'})`; + + // Store service status in a global variable for later use when metrics are displayed + window.serviceStatusData = { + http_status: data.service_http_status, + ssh_status: data.service_ssh_status + }; + + // Update the service status if it's currently visible + updateServiceStatusDisplay(); + + } catch (error) { + statusElement.className = 'status-light yellow'; + statusElement.title = "Status check failed - server may still be operational"; + uptimeElement.textContent = 'Status: Available (Stats unavailable)'; + + // Clear service status data on error + window.serviceStatusData = null; + } +} + +// Function to create/update service status display +function updateServiceStatusDisplay() { + // Only update if metrics section is visible + if (metricsSection && metricsSection.style.display === 'block' && window.serviceStatusData) { + let serviceStatusElement = document.getElementById('service-status'); + + // Create the element if it doesn't exist + if (!serviceStatusElement) { + serviceStatusElement = document.createElement('div'); + serviceStatusElement.id = 'service-status'; + serviceStatusElement.className = 'service-status'; + + // Insert at the beginning of metrics section for better visibility + if (metricsSection.firstChild) { + metricsSection.insertBefore(serviceStatusElement, metricsSection.firstChild); + } else { + metricsSection.appendChild(serviceStatusElement); + } + } + + // Update the content + const httpStatus = window.serviceStatusData.http_status === 1 ? "Up" : "Down"; + const sshStatus = window.serviceStatusData.ssh_status === 1 ? "Up" : "Down"; + + const httpClass = window.serviceStatusData.http_status === 1 ? "green" : "red"; + const sshClass = window.serviceStatusData.ssh_status === 1 ? "green" : "red"; + + serviceStatusElement.innerHTML = ` + <div class="service-item"> + <div class="status-light ${httpClass}"></div> + <span>HTTP: ${httpStatus}</span> + </div> + <div class="service-item"> + <div class="status-light ${sshClass}"></div> + <span>SSH: ${sshStatus}</span> + </div> + `; + } +} + +// --- Metrics Authentication & Display Logic --- +const viewMetricsBtn = document.getElementById('view-metrics-btn'); +const passwordModal = document.getElementById('password-modal'); +const passwordInput = document.getElementById('metrics-password'); +const submitPasswordBtn = document.getElementById('submit-password'); +const closeModalBtn = document.querySelector('.close-button'); +const passwordError = document.getElementById('password-error'); +const metricsSection = document.getElementById('metrics-section'); +const metricsControls = document.getElementById('metrics-controls'); // Container for buttons + +function getToken() { + return localStorage.getItem(JWT_TOKEN_KEY); +} + +function setToken(token) { + localStorage.setItem(JWT_TOKEN_KEY, token); +} + +function removeToken() { + localStorage.removeItem(JWT_TOKEN_KEY); +} + +function showMetrics() { + if (!metricsSection) { + return; + } + + metricsSection.style.display = 'block'; + createOrShowHideButton(); + + // Display the service status when metrics are shown + updateServiceStatusDisplay(); + + // Initialize metrics UI if not already initialized + if (!chartsInitialized) { + initializeMetricsUI(); + } else { + startDataUpdates(); + } +} + +function hideMetrics() { + if (!metricsSection) return; + metricsSection.style.display = 'none'; + removeHideButton(); + + // Remove the service status element when metrics are hidden + const serviceStatusElement = document.getElementById('service-status'); + if (serviceStatusElement) { + serviceStatusElement.remove(); + } + + if (typeof stopDataUpdates === 'function') { + stopDataUpdates(); + } +} + +function createOrShowHideButton() { + if (!metricsControls) return; + let hideBtn = document.getElementById('hide-metrics-btn'); + if (!hideBtn) { + hideBtn = document.createElement('button'); + hideBtn.id = 'hide-metrics-btn'; + hideBtn.className = 'button'; + hideBtn.textContent = 'Hide Server Metrics'; + hideBtn.style.marginLeft = '10px'; + hideBtn.addEventListener('click', () => { + removeToken(); // Optionally clear token on hide + hideMetrics(); + }); + metricsControls.appendChild(hideBtn); + } + hideBtn.style.display = 'inline-block'; // Ensure it's visible +} + +function removeHideButton() { + const hideBtn = document.getElementById('hide-metrics-btn'); + if (hideBtn) { + hideBtn.remove(); + } +} + +function openPasswordModal() { + if (!passwordModal || !passwordInput || !passwordError) return; + passwordInput.value = ''; + passwordError.textContent = ''; + passwordError.style.opacity = 0; + passwordModal.style.display = 'block'; + setTimeout(() => { + passwordModal.classList.add('show'); + passwordInput.focus(); + }, 10); +} + +function closePasswordModal() { + if (!passwordModal) return; + passwordModal.classList.remove('show'); + setTimeout(() => { + passwordModal.style.display = 'none'; + }, 300); +} + +async function login() { + if (!passwordInput || !passwordError) return; + const password = passwordInput.value; + if (!password) { + showPasswordError('Please enter a password'); + return; + } + + try { + const response = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: password }) + }); + + if (response.ok) { + const data = await response.json(); + if (data.token) { + setToken(data.token); // Store JWT + closePasswordModal(); + showMetrics(); + } else { + showPasswordError('Login failed: No token received.'); + } + } else { + const errorData = await response.json().catch(() => ({ error: 'Unknown login error' })); + showPasswordError(`Login failed: ${errorData.error || response.statusText}`); + passwordInput.value = ''; + } + } catch (error) { + showPasswordError('Login request error. Check connection.'); + } +} + +function showPasswordError(message) { + if (!passwordError || !passwordInput) return; + passwordError.textContent = message; + passwordError.style.opacity = 1; + passwordInput.classList.add('shake'); + setTimeout(() => { passwordInput.classList.remove('shake'); }, 500); +} + +// --- SNMP Charts Logic --- +const API_ENDPOINT = '/api/metrics'; +const UPDATE_INTERVAL = 10000; +const CHART_HISTORY = 60; +let charts = {}; +let chartsInitialized = false; +let updateIntervalId; +let metricsDefinitions = {}; + +const chartColors = { + networkIn: '#88B7B5', + networkOut: '#FDCFF3', + cpu: '#88B7B5', + memory: '#FDCFF3', + system: '#88B7B5', + generic: '#88B7B5', + // New colors for application performance metrics + appResponse: '#4CAF50', // Green for response time + appError: '#F44336', // Red for error rate + appRequests: '#2196F3', // Blue for request count + serviceStatus: '#FF9800' // Orange for service status +}; + +// formatBytes, formatTime, calculateRates - Keep these utility functions as they were +function formatBytes(bytes, decimals = 2) { + if (bytes === undefined || bytes === null || bytes === 0) return '0 Bytes'; + const k = 1024, dm = decimals < 0 ? 0 : decimals, sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +} + +function formatTime(centiseconds) { + if (!centiseconds || centiseconds < 0) return '0s'; + const totalSeconds = Math.floor(centiseconds / 100); + const days = Math.floor(totalSeconds / 86400), hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60), seconds = totalSeconds % 60; + if (days > 0) return `${days}d ${hours}h ${minutes}m`; + if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`; + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +} + +function calculateRates(data) { + if (!data || data.length < 2) { + return []; + } + + const rates = []; + for (let i = 1; i < data.length; i++) { + const timeDiff = (data[i].timestamp - data[i-1].timestamp) / 1000; + const valueDiff = data[i].value - data[i-1].value; + if (timeDiff < 0.001) continue; + + // Detect counter wrap/reset (when a counter resets to 0) + const rate = valueDiff >= 0 ? + valueDiff / timeDiff : + (4294967295 + valueDiff) / timeDiff; + + rates.push({ timestamp: data[i].timestamp, value: rate }); + } + + return rates; +} + +function createChartContainers(metrics) { + const metricGrid = document.querySelector('.metric-grid'); + if (!metricGrid) { + return; + } + + metricGrid.innerHTML = ''; // Clear previous + charts = {}; // Clear chart objects + + // Group the metrics into two sections + const networkAndSystemMetrics = {}; + const appPerformanceMetrics = {}; + const serviceStatusMetrics = {}; + + // First categorize the metrics + for (const [metricName, definition] of Object.entries(metrics)) { + // Skip system_uptime and memory metrics (as requested) + if (metricName === 'system_uptime' || + metricName.includes('memory_total') || + metricName === 'memory_size') { + continue; + } + + // Categorize based on metric name + if (metricName.startsWith('app_')) { + appPerformanceMetrics[metricName] = definition; + } else if (metricName.startsWith('service_')) { + serviceStatusMetrics[metricName] = definition; + } else { + networkAndSystemMetrics[metricName] = definition; + } + } + + // Create section headers and containers + if (Object.keys(networkAndSystemMetrics).length > 0) { + const sectionHeader = document.createElement('h4'); + sectionHeader.textContent = 'Network & System Metrics'; + sectionHeader.className = 'metric-section-header'; + metricGrid.appendChild(sectionHeader); + + // Create containers for network & system metrics + for (const [metricName, definition] of Object.entries(networkAndSystemMetrics)) { + createMetricCard(metricGrid, metricName, definition); + } + } + + // Application Performance section + if (Object.keys(appPerformanceMetrics).length > 0) { + const sectionHeader = document.createElement('h4'); + sectionHeader.textContent = 'Application Performance'; + sectionHeader.className = 'metric-section-header'; + metricGrid.appendChild(sectionHeader); + + // Create containers for app performance metrics + for (const [metricName, definition] of Object.entries(appPerformanceMetrics)) { + createMetricCard(metricGrid, metricName, definition); + } + } + + // Service Status section (not displayed as a chart - we already added it to server stats) + // We skip adding charts for service_* metrics +} + +function createMetricCard(container, metricName, definition) { + const displayName = definition.label || metricName; + const metricCard = document.createElement('div'); + metricCard.className = 'metric-card'; + metricCard.innerHTML = `<h5>${displayName}</h5><div class="chart-container"><canvas id="${metricName}Chart"></canvas></div>`; + container.appendChild(metricCard); +} + +function startDataUpdates() { + if (updateIntervalId) { + return; // Don't start if already running + } + + updateCharts(); + updateIntervalId = setInterval(updateCharts, UPDATE_INTERVAL); +} + +function stopDataUpdates() { + if (updateIntervalId) { + clearInterval(updateIntervalId); + updateIntervalId = null; + } +} + +async function fetchWithAuth(url, options = {}) { + const token = getToken(); + const headers = { ...options.headers }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + try { + const response = await fetch(url, { ...options, headers }); + + if (response.status === 401) { + // Unauthorized - Token is invalid or expired + removeToken(); + hideMetrics(); // Hide metrics section + openPasswordModal(); // Prompt for login + throw new Error('Unauthorized'); // Prevent further processing + } + + return response; + } catch (error) { + throw error; + } +} + +function initCharts(metrics) { + // Common options are reused from previous logic + const commonOptions = { + responsive: true, + maintainAspectRatio: false, + animation: { duration: 300 }, + scales: { + x: { + type: 'time', + time: { unit: 'minute', tooltipFormat: 'HH:mm:ss' }, + grid: { color: 'rgba(255, 255, 255, 0.1)' }, + ticks: { color: '#E8F1F2', maxTicksLimit: 5 } + }, + y: { + beginAtZero: true, + grid: { color: 'rgba(255, 255, 255, 0.1)' }, + ticks: { color: '#E8F1F2' } + } + }, + plugins: { + legend: { + display: true, + labels: { color: '#E8F1F2', font: { family: "'IBM Plex Sans', sans-serif" } } + }, + tooltip: { + enabled: true, + mode: 'index', + intersect: false, + backgroundColor: 'rgba(17, 17, 17, 0.8)', + titleColor: '#FDCFF3', + bodyColor: '#E8F1F2', + borderColor: '#333', + borderWidth: 1 + } + } + }; + + for (const [metricName, definition] of Object.entries(metrics)) { + // Skip system_uptime and memory metrics (as requested) + if (metricName === 'system_uptime' || + metricName.includes('memory_total') || + metricName === 'memory_size') { + continue; + } + + // Skip service status metrics - they're shown in the server stats + if (metricName.startsWith('service_')) { + continue; + } + + const canvas = document.getElementById(`${metricName}Chart`); + if (!canvas) { + continue; + } + + const ctx = canvas.getContext('2d'); + if (!ctx) { + continue; + } + + let chartOptions = JSON.parse(JSON.stringify(commonOptions)); // Deep clone options + let datasets = []; + let label = definition.label || metricName; + let color = chartColors.generic; + + // Configure chart based on metric type + if (metricName === 'network_in' || metricName === 'network_out') { + chartOptions.scales.y.title = { display: true, text: 'Bytes/sec', color: '#E8F1F2' }; + chartOptions.scales.y.ticks.callback = function(value) { return formatBytes(value, 0); }; + label = metricName === 'network_in' ? 'In' : 'Out'; + color = metricName === 'network_in' ? chartColors.networkIn : chartColors.networkOut; + } else if (metricName === 'cpu_load') { + chartOptions.scales.y.title = { display: true, text: 'Load/Usage', color: '#E8F1F2' }; + label = 'CPU'; color = chartColors.cpu; + } else if (metricName === 'system_processes') { + chartOptions.scales.y.title = { display: true, text: 'Count', color: '#E8F1F2' }; + label = 'Processes'; color = chartColors.system; + } + // New application performance metrics + else if (metricName === 'app_response_time_avg') { + chartOptions.scales.y.title = { display: true, text: 'Time (ms)', color: '#E8F1F2' }; + label = 'Response Time'; + color = chartColors.appResponse; + } else if (metricName === 'app_error_rate') { + chartOptions.scales.y.title = { display: true, text: 'Error Rate (%)', color: '#E8F1F2' }; + label = 'Error Rate'; + color = chartColors.appError; + } else if (metricName === 'app_request_count') { + chartOptions.scales.y.title = { display: true, text: 'Requests', color: '#E8F1F2' }; + label = 'Request Count'; + color = chartColors.appRequests; + } else { + chartOptions.scales.y.title = { display: true, text: 'Value', color: '#E8F1F2' }; + } + + datasets = [{ + label: label, + borderColor: color, + backgroundColor: `${color}33`, + borderWidth: 2, + data: [], + pointRadius: 0, + fill: true + }]; + + // Destroy existing chart if it exists to prevent memory leaks + if (charts[metricName]) { + try { + charts[metricName].destroy(); + } catch (e) { + // Silent error handling + } + } + + try { + charts[metricName] = new Chart(ctx, { + type: 'line', + data: { datasets }, + options: chartOptions + }); + } catch (error) { + // Silent error handling + } + } + + chartsInitialized = true; +} + +// Modified to handle sparse data better +async function updateCharts() { + // Only run if metrics section is visible + if (!metricsSection || metricsSection.style.display === 'none') return; + + try { + const response = await fetchWithAuth(API_ENDPOINT); + if (!response.ok) { + return; + } + const data = await response.json(); + + // Combine metrics from both regular metrics and app performance metrics + const allMetrics = { ...data.metrics }; + + // Update chart data for each metric + for (const [metricName, metricData] of Object.entries(allMetrics)) { + // Skip metrics we're not displaying + if (!charts[metricName]) { + continue; + } + + if (!metricData || !Array.isArray(metricData) || metricData.length === 0) { + continue; + } + + // Process the data + let chartData; + + // For network metrics, try to calculate rates, but fall back to actual values if needed + if (metricName === 'network_in' || metricName === 'network_out') { + chartData = calculateRates(metricData); + + // If rate calculation failed due to insufficient data, use the raw values directly + if (!chartData || chartData.length === 0) { + chartData = [...metricData]; + } + } else { + // For other metrics, use the data directly + chartData = [...metricData]; + } + + if (!chartData || !Array.isArray(chartData) || chartData.length === 0) { + continue; + } + + // Map the data to chart format + const formattedData = chartData.map(point => { + if (!point || typeof point !== 'object' || point.timestamp === undefined || point.value === undefined) { + return null; + } + + let value = point.value; + if (typeof value === 'string') { + const match = value.match(/[\d.]+/); // Allow decimals + value = match ? parseFloat(match[0]) : 0; + } else if (typeof value !== 'number') { + value = 0; + } + + return { x: point.timestamp, y: value }; + }).filter(point => point !== null); + + if (formattedData.length === 0) { + continue; + } + + // Update the chart - wrap in try/catch to prevent errors from breaking all charts + try { + charts[metricName].data.datasets[0].data = formattedData; + charts[metricName].update('none'); // Use 'none' mode for better performance + } catch (e) { + // Silent error handling + } + } + } catch (error) { + if (error.message !== 'Unauthorized') { + // Silent error handling + } + } +} + +// Modified initializeMetricsUI to better handle edge cases +function initializeMetricsUI() { + if (chartsInitialized) { + startDataUpdates(); + return; + } + + // Ensure we are authenticated before trying to fetch + if (!getToken()) { + openPasswordModal(); + return; + } + + // Fetch metrics data + fetchWithAuth(API_ENDPOINT) + .then(response => { + if (!response.ok) { + return Promise.reject(new Error(`HTTP error: ${response.status}`)); + } + return response.json(); + }) + .then(data => { + if (!data || typeof data !== 'object') { + return Promise.reject(new Error("Invalid metrics data format")); + } + + if (!data.metrics || !data.definitions) { + // Create empty objects if missing rather than failing + data.metrics = data.metrics || {}; + data.definitions = data.definitions || {}; + } + + // Store definitions + metricsDefinitions = data.definitions || {}; + + // Check specifically for app performance metrics + const appMetrics = Object.keys(metricsDefinitions) + .filter(key => key.startsWith('app_')); + + if (appMetrics.length === 0) { + // Add placeholder definitions if they don't exist + if (!metricsDefinitions['app_response_time_avg']) { + metricsDefinitions['app_response_time_avg'] = { + label: 'Avg Response Time (ms)', + type: 'calculated' + }; + } + + if (!metricsDefinitions['app_error_rate']) { + metricsDefinitions['app_error_rate'] = { + label: 'Error Rate (%)', + type: 'calculated' + }; + } + + if (!metricsDefinitions['app_request_count']) { + metricsDefinitions['app_request_count'] = { + label: 'Request Count', + type: 'calculated' + }; + } + } + + // Create UI + createChartContainers(metricsDefinitions); + initCharts(metricsDefinitions); + + // Start updates + startDataUpdates(); + }) + .catch(error => { + if (error.message === 'Unauthorized') { + openPasswordModal(); + } else { + alert('Error loading metrics. Please try again.'); + } + }); +} + +// --- Initial Load Logic --- +document.addEventListener('DOMContentLoaded', function() { + // Start server stats check immediately and then interval + getServerStats(); + setInterval(getServerStats, 30000); + + // Style the submit button + if (submitPasswordBtn) submitPasswordBtn.classList.add('submit-button'); + + // Event listeners for authentication + if (viewMetricsBtn) { + viewMetricsBtn.addEventListener('click', () => { + // Authenticate and show metrics + const token = getToken(); + if (token) { + // Try to initialize/show metrics, fetchWithAuth will handle invalid tokens + showMetrics(); + } else { + openPasswordModal(); + } + }); + } + if (submitPasswordBtn) { + submitPasswordBtn.addEventListener('click', login); + } + if (passwordInput) { + passwordInput.addEventListener('keyup', (event) => { + if (event.key === 'Enter') login(); + }); + } + if (closeModalBtn) { + closeModalBtn.addEventListener('click', closePasswordModal); + } + window.addEventListener('click', (event) => { + if (event.target === passwordModal) closePasswordModal(); + }); + + // Check token on load - attempt to show metrics if token exists + // initializeMetricsUI() will handle token validation via fetchWithAuth + if (getToken()) { + showMetrics(); + } else { + // Ensure metrics are hidden if no token + hideMetrics(); + } +});
\ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..d2f7310 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,140 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="UTF-8"/> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <title>My Hardware Setup</title> + <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans&family=Roboto&family=Shrikhand&display=swap" rel="stylesheet"> + <link href="/static/css/style.css" rel="stylesheet"/> + <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> + <script src="https://cdn.jsdelivr.net/npm/[email protected]"></script> + <script src="https://cdn.jsdelivr.net/npm/[email protected]"></script> + <script src="https://cdn.jsdelivr.net/npm/[email protected]/crypto-js.min.js"></script> + <script src="/static/js/main.js" defer></script> +</head> +<body> + <header> + <h1>The Heart of the Operation: My Rig, Server & Laptops</h1> + <h3>Dive deep into the silicon and circuits that power my digital world!</h3> + </header> + + <section class="main-content"> + <div class="container"> + <h2>What Makes it Tick?</h2> + <p>Ever wondered what's inside the box? I have build my setup that help's me do all of my activities smoothly and easily. Below, I'll break down the key components and what they do.</p> + + <img src="/static/images/ashita-no-joe-joe-yabuki.gif" alt="Animated CPU GIF" class="component-image"> + <div class="component-section"> + <h3><span class="icon">🖧</span> The Server</h3> + <p>This is where all of my websites, applications, files, and projects are hosted. These are accessible 24/7.</p> + <div class="component-item"> + <h4>CPU: Intel Xeon Gold</h4> + <p>The processor that handles all server requests.</p> + </div> + + <div class="component-item"> + <h4>RAM: 8GB</h4> + <p>Memory for the server to run applications.</p> + </div> + + <div class="component-item"> + <h4>Storage: 50GB SSD</h4> + <p>Fast storage for the server's operating system and data.</p> + </div> + + <div class="component-item"> + <h4>Server Status:</h4> + <div id="server-stats"> + <div class="status-container"> + <div id="server-status" class="status-light"> </div> + <span id="uptime">Checking status...</span> + </div> + </div> + </div> + + <div class="component-item" id="metrics-controls"> + <button id="view-metrics-btn" class="button">View Server Metrics</button> + </div> + + <!-- Add SNMP Metrics Section (hidden by default) --> + <div class="component-item" id="metrics-section" style="display: none;"> + <h4>Real-time Metrics:</h4> + <div id="metrics-container"> + <div class="metric-grid"> + <!-- Charts will be dynamically added here based on available metrics --> + </div> + </div> + </div> + </div> + <div class="component-section"> + <h3><span class="icon">💻</span> ThinkPad E14</h3> + <p>My daily driver laptop for work and portability.</p> + <div class="component-item"> + <h4>RAM: 16GB</h4> + <p>Plenty of memory for multitasking.</p> + </div> + <div class="component-item"> + <h4>Storage: 256GB SSD</h4> + <p>Fast storage for quick boot and application loading.</p> + </div> + </div> + + <div class="component-section"> + <h3><span class="icon">💻</span> ThinkPad T480 (Librebooted)</h3> + <p>My privacy-focused laptop, running Libreboot for enhanced security and control.</p> + <div class="component-item"> + <h4>RAM: 16GB</h4> + <p>Memory for smooth operation, even with security-focused software.</p> + </div> + <div class="component-item"> + <h4>Storage: 512GB SSD</h4> + <p>Fast and reliable storage.</p> + </div> + </div> + </div> + </section> + + <section class="cta-section"> + <div class="container"> + <h2>Explore More!</h2> + <p>Want to see these components in action? Check out these links:</p> + <a href="https://surgot.in" class="button">Visit My Website</a> + <a href="https://git.surgot.in" class="button">See My Code</a> + </div> + </section> + + <!-- + <section class="testimonials"> + <div class="container"> + <h2>What People Are Saying (About My Projects, Powered by This Hardware):</h2> + <div class="testimonial"> + <p>"[QUOTE ABOUT YOUR WEBSITE/PROJECT] - It loads so fast!" - [Name/Username]</p> + </div> + <div class="testimonial"> + <p>"[QUOTE ABOUT YOUR YOUTUBE VIDEOS/PROJECT] - The editing is incredible!" - [Name/Username]</p> + </div> + </div> + </section> + --> + + <!-- Password Modal --> + <div id="password-modal" class="modal"> + <div class="modal-content"> + <span class="close-button">×</span> + <h3>Authentication Required</h3> + <p>Please enter the password to view server metrics:</p> + <div class="password-input-container"> + <input type="password" id="metrics-password" placeholder="Enter password"> + <button id="submit-password" class="button submit-button">Submit</button> + </div> + <p id="password-error" class="error-message"></p> + </div> + </div> + + <footer> + <p>© Surgot/2025 - Built with passion (and a lot of processing power!)</p> + </footer> + +</body> +</html> |