aboutsummaryrefslogtreecommitdiffstats
path: root/static
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 /static
downloadchronos-2b85ffa5d9997b59223fab4dd527d0b3c0406be4.tar.gz
chronos-2b85ffa5d9997b59223fab4dd527d0b3c0406be4.tar.bz2
chronos-2b85ffa5d9997b59223fab4dd527d0b3c0406be4.zip
feat: site which monitors the system graph using openbsd snmpd
Diffstat (limited to 'static')
-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
3 files changed, 1144 insertions, 0 deletions
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