// --- 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 = `
`;
}
}
// --- 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 = `${displayName}
`;
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();
}
});