aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatarLibravatar Biswa Kalyan Bhuyan <[email protected]> 2025-05-03 13:21:56 +0530
committerLibravatarLibravatar Biswa Kalyan Bhuyan <[email protected]> 2025-05-03 13:21:56 +0530
commit2b85ffa5d9997b59223fab4dd527d0b3c0406be4 (patch)
treebd14fca930e1704f962d402be626cebfcd8e995d
downloadchronos-2b85ffa5d9997b59223fab4dd527d0b3c0406be4.tar.gz
chronos-2b85ffa5d9997b59223fab4dd527d0b3c0406be4.tar.bz2
chronos-2b85ffa5d9997b59223fab4dd527d0b3c0406be4.zip
feat: site which monitors the system graph using openbsd snmpd
-rw-r--r--.gitignore2
-rw-r--r--README.md92
-rw-r--r--index.html550
-rwxr-xr-xserver.py834
-rw-r--r--static/css/style.css394
-rw-r--r--static/images/ashita-no-joe-joe-yabuki.gifbin0 -> 976398 bytes
-rw-r--r--static/js/main.js750
-rw-r--r--templates/index.html140
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">&times;</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>&copy; 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
new file mode 100644
index 0000000..73777e0
--- /dev/null
+++ b/static/images/ashita-no-joe-joe-yabuki.gif
Binary files differ
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">&times;</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>&copy; Surgot/2025 - Built with passion (and a lot of processing power!)</p>
+ </footer>
+
+</body>
+</html>