UNPKG

network-performance-monitor

Version:

A comprehensive network performance monitoring tool that continuously tests and tracks your network's performance over time

1,027 lines (922 loc) 35.1 kB
// Global variables let latencyChart, speedChart, websiteChart; let dataRange = { start: null, end: null }; let currentPeriod = '1h'; let autoRefreshInterval = null; let autoRefreshEnabled = true; let countdownInterval = null; let lastRefreshTime = Date.now(); let selectedNetwork = null; let currentNetwork = null; // Custom plugin to draw average line labels const averageLinePlugin = { id: 'averageLinePlugin', afterDraw: (chart) => { const ctx = chart.ctx; const annotation = chart.options.plugins.annotation?.annotations?.avgLine; if (annotation && annotation.label && annotation.label.content && annotation.yMin !== undefined) { const yScale = chart.scales.y; const xScale = chart.scales.x; const y = yScale.getPixelForValue(annotation.yMin); // Get chart area bounds const chartArea = chart.chartArea; const x = chartArea.right - 10; ctx.save(); // Set font first to measure text ctx.font = `${annotation.label.font?.weight || 'bold'} ${annotation.label.font?.size || 12}px Arial`; const text = annotation.label.content; const textWidth = ctx.measureText(text).width; const padding = annotation.label.padding || 4; // Draw background ctx.fillStyle = annotation.label.backgroundColor || 'rgba(0,0,0,0.8)'; ctx.fillRect(x - textWidth - padding * 2, y - 10 - padding, textWidth + padding * 2, 20 + padding * 2); // Draw text ctx.fillStyle = annotation.label.color || 'white'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; ctx.fillText(text, x - padding, y); ctx.restore(); } } }; // Initialize charts function initCharts() { // Register plugins globally but disable by default Chart.register(ChartDataLabels); Chart.register(averageLinePlugin); if (typeof chartjsPluginAnnotation !== 'undefined') { Chart.register(chartjsPluginAnnotation); } Chart.defaults.plugins.datalabels = { display: false }; const commonOptions = { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false, }, plugins: { zoom: { zoom: { wheel: { enabled: true, }, pinch: { enabled: true }, mode: 'x', }, pan: { enabled: true, mode: 'x', }, limits: { x: { min: 'original', max: 'original' } } }, tooltip: { callbacks: { afterLabel: function(context) { const dataPoint = context.raw; const details = []; if (context.dataset.label === 'Ping Latency' && dataPoint.host) { details.push(`Host: ${dataPoint.host}`); if (dataPoint.packetLoss !== null && dataPoint.packetLoss !== undefined) { details.push(`Packet Loss: ${dataPoint.packetLoss}%`); } } if (context.dataset.label === 'Website Response Time' && dataPoint.url) { details.push(`URL: ${dataPoint.url}`); if (dataPoint.statusCode) { details.push(`Status: ${dataPoint.statusCode}`); } } if ((context.dataset.label === 'Download Speed' || context.dataset.label === 'Upload Speed') && dataPoint.speedData) { details.push(`Server: ${dataPoint.speedData.server || 'Unknown'}`); details.push(`Ping: ${dataPoint.speedData.ping ? dataPoint.speedData.ping.toFixed(2) + ' ms' : 'N/A'}`); const triggerReason = dataPoint.speedData.triggerReason || 'scheduled'; const formattedTrigger = triggerReason.charAt(0).toUpperCase() + triggerReason.slice(1); details.push(`Trigger: ${formattedTrigger}`); if (context.dataset.label === 'Download Speed' && dataPoint.speedData.uploadSpeed) { details.push(`Upload: ${dataPoint.speedData.uploadSpeed.toFixed(2)} Mbps`); } if (context.dataset.label === 'Upload Speed' && dataPoint.speedData.downloadSpeed) { details.push(`Download: ${dataPoint.speedData.downloadSpeed.toFixed(2)} Mbps`); } } return details; } } } }, scales: { x: { type: 'time', time: { displayFormats: { minute: 'HH:mm', hour: 'HH:mm', day: 'MMM dd', week: 'MMM dd', month: 'MMM yyyy' } }, ticks: { source: 'auto', autoSkip: true, maxRotation: 0 } } } }; // Latency chart const latencyCtx = document.getElementById('latencyChart').getContext('2d'); latencyChart = new Chart(latencyCtx, { type: 'line', data: { datasets: [{ label: 'Ping Latency', data: [], borderColor: 'rgb(75, 192, 192)', backgroundColor: 'rgba(75, 192, 192, 0.1)', tension: 0.1, pointRadius: 2, pointHoverRadius: 5 }] }, options: { ...commonOptions, scales: { ...commonOptions.scales, y: { type: 'logarithmic', beginAtZero: false, min: 1, title: { display: true, text: 'Latency (ms) - Log Scale' }, ticks: { callback: function(value) { if (value === 1 || value === 10 || value === 100 || value === 1000) { return value + ' ms'; } return null; } } } }, plugins: { ...commonOptions.plugins, annotation: { annotations: {} }, averageLinePlugin: { enabled: true } } } }); // Speed chart const speedCtx = document.getElementById('speedChart').getContext('2d'); speedChart = new Chart(speedCtx, { type: 'line', data: { datasets: [{ label: 'Download Speed', data: [], borderColor: 'rgb(54, 162, 235)', backgroundColor: 'rgba(54, 162, 235, 0.1)', tension: 0.1, pointRadius: 3, pointHoverRadius: 6 }, { label: 'Upload Speed', data: [], borderColor: 'rgb(255, 99, 132)', backgroundColor: 'rgba(255, 99, 132, 0.1)', tension: 0.1, pointRadius: 3, pointHoverRadius: 6 }] }, options: { ...commonOptions, scales: { ...commonOptions.scales, y: { beginAtZero: true, title: { display: true, text: 'Speed (Mbps)' } } }, plugins: { ...commonOptions.plugins, datalabels: { display: false } } } }); // Website chart - same format as latency chart const websiteCtx = document.getElementById('websiteChart').getContext('2d'); websiteChart = new Chart(websiteCtx, { type: 'line', data: { datasets: [{ label: 'Website Response Time', data: [], borderColor: 'rgb(153, 102, 255)', backgroundColor: 'rgba(153, 102, 255, 0.1)', tension: 0.1, pointRadius: 2, pointHoverRadius: 5 }] }, options: { ...commonOptions, scales: { ...commonOptions.scales, y: { type: 'logarithmic', beginAtZero: false, min: 1, title: { display: true, text: 'Response Time (ms) - Log Scale' }, ticks: { callback: function(value) { if (value === 1 || value === 10 || value === 100 || value === 1000) { return value + ' ms'; } return null; } } } }, plugins: { ...commonOptions.plugins, annotation: { annotations: {} }, averageLinePlugin: { enabled: true } } } }); } // Fetch and display data async function fetchData(period = 'all') { let start, end; if (period === 'all') { start = dataRange.start; end = dataRange.end; } else { end = new Date(); start = new Date(); switch(period) { case '1h': start.setHours(start.getHours() - 1); break; case '1d': start.setDate(start.getDate() - 1); break; case '1w': start.setDate(start.getDate() - 7); break; case '1m': start.setMonth(start.getMonth() - 1); break; case '1y': start.setFullYear(start.getFullYear() - 1); break; } } const params = new URLSearchParams(); if (start) params.append('start', start.toISOString()); if (end) params.append('end', end.toISOString()); if (selectedNetwork) params.append('network', selectedNetwork); try { // Fetch ping data const pingResponse = await fetch(`/api/ping-data?${params}`); const pingData = await pingResponse.json(); // Fetch speed data const speedResponse = await fetch(`/api/speed-data?${params}`); const speedData = await speedResponse.json(); // Fetch website data const websiteResponse = await fetch(`/api/website-data?${params}`); const websiteData = await websiteResponse.json(); updateCharts(pingData, speedData, websiteData, start, end); } catch (error) { console.error('Error fetching data:', error); } } // Get appropriate time format based on time range function getTimeFormat(period) { switch(period) { case '1h': return { displayFormats: { minute: 'HH:mm', hour: 'HH:mm' }, unit: 'minute' }; case '1d': return { displayFormats: { hour: 'HH:00', day: 'HH:00' }, unit: 'hour' }; case '1w': return { displayFormats: { day: 'MMM dd', week: 'MMM dd' }, unit: 'day' }; case '1m': return { displayFormats: { day: 'MMM dd', week: 'MMM dd', month: 'MMM dd' }, unit: 'day' }; case '1y': return { displayFormats: { month: 'MMM', year: 'MMM' }, unit: 'month' }; default: // For 'all' data, auto-detect best format if (dataRange.start && dataRange.end) { const diff = dataRange.end - dataRange.start; const days = diff / (1000 * 60 * 60 * 24); if (days <= 1) { return getTimeFormat('1d'); } else if (days <= 7) { return getTimeFormat('1w'); } else if (days <= 31) { return getTimeFormat('1m'); } else { return getTimeFormat('1y'); } } return { displayFormats: { hour: 'MMM dd HH:mm', day: 'MMM dd', week: 'MMM dd', month: 'MMM yyyy' } }; } } // Update charts with new data function updateCharts(pingData, speedData, websiteData, start, end) { // Calculate average latency and find anomalies let totalLatency = 0; let maxLatencies = []; const latencyData = pingData.map(d => { // Handle timestamp that might be in "YYYY-MM-DD HH:mm:ss" format // Assume the timestamp from DB is in UTC const timestamp = d.timestamp.replace(' ', 'T') + 'Z'; const date = new Date(timestamp); const latency = d.latency; totalLatency += latency; return { x: date, y: latency, host: d.host, packetLoss: d.packet_loss }; }); const avgLatency = pingData.length > 0 ? totalLatency / pingData.length : 0; // Find top anomalies (latencies > 2x average) if (avgLatency > 0) { const anomalyThreshold = avgLatency * 2; const anomalies = latencyData .filter(d => d.y > anomalyThreshold) .sort((a, b) => b.y - a.y) .slice(0, 5); // Top 5 anomalies maxLatencies = anomalies; } // Update latency chart latencyChart.data.datasets[0].data = latencyData; // Add or update average line if (avgLatency > 0) { // Update annotation for average line latencyChart.options.plugins.annotation.annotations = { avgLine: { type: 'line', yMin: avgLatency, yMax: avgLatency, borderColor: 'rgba(255, 99, 132, 0.5)', borderWidth: 2, borderDash: [5, 5], label: { content: `Avg: ${avgLatency.toFixed(1)} ms`, enabled: true, position: 'end', backgroundColor: 'rgba(255, 99, 132, 0.8)', color: 'white', font: { size: 12, weight: 'bold' }, padding: 4, xAdjust: -60, yAdjust: -10 } } }; // Configure data labels for anomalies latencyChart.options.plugins.datalabels = { display: function(context) { const value = context.dataset.data[context.dataIndex].y; // Only show labels for anomalies return maxLatencies.some(anomaly => anomaly.x.getTime() === context.dataset.data[context.dataIndex].x.getTime() && anomaly.y === value ); }, align: 'top', anchor: 'bottom', backgroundColor: 'rgba(255, 99, 132, 0.8)', borderRadius: 4, color: 'white', font: { weight: 'bold', size: 11 }, formatter: function(value) { return value.y.toFixed(1) + ' ms'; }, padding: 4 }; } else { latencyChart.options.plugins.annotation.annotations = {}; latencyChart.options.plugins.datalabels = { display: false }; } // Update speed chart speedChart.data.datasets[0].data = speedData.map(d => { const timestamp = d.timestamp.replace(' ', 'T') + 'Z'; const date = new Date(timestamp); return { x: date, y: d.download_speed, speedData: { downloadSpeed: d.download_speed, uploadSpeed: d.upload_speed, ping: d.ping, server: d.server, triggerReason: d.trigger_reason } }; }); speedChart.data.datasets[1].data = speedData.map(d => { const timestamp = d.timestamp.replace(' ', 'T') + 'Z'; const date = new Date(timestamp); return { x: date, y: d.upload_speed, speedData: { downloadSpeed: d.download_speed, uploadSpeed: d.upload_speed, ping: d.ping, server: d.server, triggerReason: d.trigger_reason } }; }); // Process website data let totalWebsiteLatency = 0; let maxWebsiteLatencies = []; const websiteLatencyData = websiteData.map(d => { const timestamp = d.timestamp.replace(' ', 'T') + 'Z'; const date = new Date(timestamp); const latency = d.latency; totalWebsiteLatency += latency; return { x: date, y: latency, url: d.url, statusCode: d.status_code }; }); const avgWebsiteLatency = websiteData.length > 0 ? totalWebsiteLatency / websiteData.length : 0; // Find top website anomalies if (avgWebsiteLatency > 0) { const anomalyThreshold = avgWebsiteLatency * 2; const anomalies = websiteLatencyData .filter(d => d.y > anomalyThreshold) .sort((a, b) => b.y - a.y) .slice(0, 5); maxWebsiteLatencies = anomalies; } // Update website chart websiteChart.data.datasets[0].data = websiteLatencyData; // Add or update average line for website chart if (avgWebsiteLatency > 0) { websiteChart.options.plugins.annotation.annotations = { avgLine: { type: 'line', yMin: avgWebsiteLatency, yMax: avgWebsiteLatency, borderColor: 'rgba(153, 102, 255, 0.5)', borderWidth: 2, borderDash: [5, 5], label: { content: `Avg: ${avgWebsiteLatency.toFixed(1)} ms`, enabled: true, position: 'end', backgroundColor: 'rgba(153, 102, 255, 0.8)', color: 'white', font: { size: 12, weight: 'bold' }, padding: 4, xAdjust: -60, yAdjust: -10 } } }; // Configure data labels for website anomalies websiteChart.options.plugins.datalabels = { display: function(context) { const value = context.dataset.data[context.dataIndex].y; return maxWebsiteLatencies.some(anomaly => anomaly.x.getTime() === context.dataset.data[context.dataIndex].x.getTime() && anomaly.y === value ); }, align: 'top', anchor: 'bottom', backgroundColor: 'rgba(153, 102, 255, 0.8)', borderRadius: 4, color: 'white', font: { weight: 'bold', size: 11 }, formatter: function(value) { return value.y.toFixed(1) + ' ms'; }, padding: 4 }; } else { websiteChart.options.plugins.annotation.annotations = {}; websiteChart.options.plugins.datalabels = { display: false }; } // Set scale limits const scaleOptions = { min: start || dataRange.start, max: end || dataRange.end }; // Update time format based on current period const timeFormat = getTimeFormat(currentPeriod); latencyChart.options.scales.x.time = timeFormat; speedChart.options.scales.x.time = timeFormat; websiteChart.options.scales.x.time = timeFormat; latencyChart.options.scales.x.min = scaleOptions.min; latencyChart.options.scales.x.max = scaleOptions.max; speedChart.options.scales.x.min = scaleOptions.min; speedChart.options.scales.x.max = scaleOptions.max; websiteChart.options.scales.x.min = scaleOptions.min; websiteChart.options.scales.x.max = scaleOptions.max; // Update zoom limits latencyChart.options.plugins.zoom.limits.x.min = dataRange.start; latencyChart.options.plugins.zoom.limits.x.max = dataRange.end; speedChart.options.plugins.zoom.limits.x.min = dataRange.start; speedChart.options.plugins.zoom.limits.x.max = dataRange.end; websiteChart.options.plugins.zoom.limits.x.min = dataRange.start; websiteChart.options.plugins.zoom.limits.x.max = dataRange.end; latencyChart.update(); speedChart.update(); websiteChart.update(); } // Fetch and display averages async function fetchAverages() { try { // Calculate time range based on currentPeriod let start, end; if (currentPeriod === 'all') { start = dataRange.start; end = dataRange.end; } else { end = new Date(); start = new Date(); switch(currentPeriod) { case '1h': start.setHours(start.getHours() - 1); break; case '1d': start.setDate(start.getDate() - 1); break; case '1w': start.setDate(start.getDate() - 7); break; case '1m': start.setMonth(start.getMonth() - 1); break; case '1y': start.setFullYear(start.getFullYear() - 1); break; } } // Build URL with parameters const params = new URLSearchParams(); if (selectedNetwork) { params.append('network', selectedNetwork); } if (start) { params.append('start', start.toISOString()); } if (end) { params.append('end', end.toISOString()); } const url = '/api/averages' + (params.toString() ? '?' + params.toString() : ''); const response = await fetch(url); const averages = await response.json(); document.getElementById('avgPing').textContent = averages.avgPingLatency ? `${averages.avgPingLatency.toFixed(2)} ms` : '--'; document.getElementById('avgWebsite').textContent = averages.avgWebsiteLatency ? `${averages.avgWebsiteLatency.toFixed(2)} ms` : '--'; document.getElementById('avgDownload').textContent = averages.avgDownloadSpeed ? `${averages.avgDownloadSpeed.toFixed(2)} Mbps` : '--'; document.getElementById('avgUpload').textContent = averages.avgUploadSpeed ? `${averages.avgUploadSpeed.toFixed(2)} Mbps` : '--'; } catch (error) { console.error('Error fetching averages:', error); } } // Fetch data range async function fetchDataRange() { try { const response = await fetch('/api/data-range'); const range = await response.json(); if (range.start && range.end) { // Handle timestamp format - assume UTC from database const startTimestamp = range.start.replace(' ', 'T') + 'Z'; const endTimestamp = range.end.replace(' ', 'T') + 'Z'; dataRange.start = new Date(startTimestamp); dataRange.end = new Date(endTimestamp); const formatDate = (date) => { return date.toLocaleDateString() + ' ' + date.toLocaleTimeString(); }; document.getElementById('dataRange').textContent = `Data available from ${formatDate(dataRange.start)} to ${formatDate(dataRange.end)}`; } } catch (error) { console.error('Error fetching data range:', error); } } // Auto-refresh management function startAutoRefresh() { if (autoRefreshInterval) { clearInterval(autoRefreshInterval); } if (countdownInterval) { clearInterval(countdownInterval); } if (autoRefreshEnabled) { lastRefreshTime = Date.now(); // Main refresh interval autoRefreshInterval = setInterval(async () => { lastRefreshTime = Date.now(); // Update network list to detect network changes await loadNetworks(); fetchData(currentPeriod); fetchAverages(); }, 30000); // Countdown update interval countdownInterval = setInterval(() => { updateRefreshStatus(); }, 1000); updateRefreshStatus(); } } function stopAutoRefresh() { if (autoRefreshInterval) { clearInterval(autoRefreshInterval); autoRefreshInterval = null; } if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } updateRefreshStatus(); } function updateRefreshStatus() { const statusElement = document.getElementById('refreshStatus'); if (autoRefreshEnabled) { const elapsed = Math.floor((Date.now() - lastRefreshTime) / 1000); const remaining = Math.max(0, 30 - elapsed); statusElement.textContent = `(${remaining}s)`; statusElement.style.color = remaining <= 5 ? '#007bff' : '#666'; } else { statusElement.textContent = '(paused)'; statusElement.style.color = '#999'; } } // Trigger manual speed test async function triggerManualSpeedTest() { const btn = document.getElementById('manualSpeedTestBtn'); const originalText = btn.textContent; try { // Update button state btn.disabled = true; btn.classList.add('running'); btn.textContent = 'Running Speed Test...'; // Make API call const response = await fetch('/api/trigger-speed-test', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Speed test failed'); } const result = await response.json(); // Show notification const notification = document.createElement('div'); notification.className = 'network-change-notification'; if (result.success) { notification.style.backgroundColor = '#28a745'; notification.textContent = `Speed Test Complete: ↓ ${result.downloadSpeed?.toFixed(2) || 'N/A'} Mbps | ↑ ${result.uploadSpeed?.toFixed(2) || 'N/A'} Mbps | Ping: ${result.ping?.toFixed(2) || 'N/A'} ms`; } else { notification.style.backgroundColor = '#dc3545'; notification.textContent = `Speed Test Failed: ${result.error || 'Unknown error'}`; } document.body.appendChild(notification); setTimeout(() => notification.remove(), 10000); // Refresh data to show new speed test result setTimeout(() => { fetchData(currentPeriod); }, 1000); } catch (error) { console.error('Error triggering speed test:', error); // Show error notification const notification = document.createElement('div'); notification.className = 'network-change-notification'; notification.style.backgroundColor = '#dc3545'; notification.textContent = `Error: ${error.message}`; document.body.appendChild(notification); setTimeout(() => notification.remove(), 5000); } finally { // Reset button state btn.disabled = false; btn.classList.remove('running'); btn.textContent = originalText; } } // Setup event listeners function setupEventListeners() { // Auto-refresh toggle const autoRefreshToggle = document.getElementById('autoRefreshToggle'); autoRefreshToggle.addEventListener('change', (e) => { autoRefreshEnabled = e.target.checked; if (autoRefreshEnabled) { startAutoRefresh(); } else { stopAutoRefresh(); } }); // Manual speed test button const speedTestBtn = document.getElementById('manualSpeedTestBtn'); speedTestBtn.addEventListener('click', async () => { await triggerManualSpeedTest(); }); // Network selector const networkSelect = document.getElementById('networkSelect'); networkSelect.addEventListener('change', (e) => { selectedNetwork = e.target.value || null; fetchData(currentPeriod); fetchAverages(); // Averages may differ by network }); // Time period buttons document.querySelectorAll('.time-btn').forEach(btn => { btn.addEventListener('click', (e) => { document.querySelectorAll('.time-btn').forEach(b => b.classList.remove('active')); e.target.classList.add('active'); currentPeriod = e.target.dataset.period; fetchData(currentPeriod); fetchAverages(); // Update averages for new time period }); }); // Sync zoom between charts let syncing = false; const syncZoom = (sourceChart, targetCharts) => { if (!syncing) { syncing = true; targetCharts.forEach(targetChart => { targetChart.options.scales.x.min = sourceChart.options.scales.x.min; targetChart.options.scales.x.max = sourceChart.options.scales.x.max; targetChart.update('none'); }); syncing = false; } }; // Add zoom event listeners const latencyCanvas = document.getElementById('latencyChart'); const speedCanvas = document.getElementById('speedChart'); const websiteCanvas = document.getElementById('websiteChart'); latencyCanvas.addEventListener('wheel', () => { setTimeout(() => syncZoom(latencyChart, [speedChart, websiteChart]), 10); }); speedCanvas.addEventListener('wheel', () => { setTimeout(() => syncZoom(speedChart, [latencyChart, websiteChart]), 10); }); websiteCanvas.addEventListener('wheel', () => { setTimeout(() => syncZoom(websiteChart, [latencyChart, speedChart]), 10); }); } // Load available networks async function loadNetworks() { try { // Get current network info const currentNetworkResponse = await fetch('/api/current-network'); const networkInfo = await currentNetworkResponse.json(); const previousNetwork = currentNetwork; currentNetwork = networkInfo.name; // Log if network changed if (previousNetwork && previousNetwork !== currentNetwork) { console.log(`Network changed from "${previousNetwork}" to "${currentNetwork}"`); // Show notification if network changed const notification = document.createElement('div'); notification.className = 'network-change-notification'; notification.textContent = `Network changed to: ${currentNetwork}`; document.body.appendChild(notification); setTimeout(() => notification.remove(), 5000); } // Get all networks const networksResponse = await fetch('/api/networks'); const networks = await networksResponse.json(); const select = document.getElementById('networkSelect'); const previousSelection = select.value; select.innerHTML = ''; // Add "All Networks" option const allOption = document.createElement('option'); allOption.value = ''; allOption.textContent = 'All Networks'; select.appendChild(allOption); // Add individual networks networks.forEach(network => { const option = document.createElement('option'); option.value = network; option.textContent = network; if (network === currentNetwork) { option.textContent += ' (current)'; } select.appendChild(option); }); // Preserve selection if network still exists, otherwise default to current if (previousSelection && networks.includes(previousSelection)) { select.value = previousSelection; } else if (!selectedNetwork || !networks.includes(selectedNetwork)) { selectedNetwork = currentNetwork; select.value = currentNetwork; } else { select.value = selectedNetwork; } } catch (error) { console.error('Error loading networks:', error); } } // Initialize everything async function init() { initCharts(); setupEventListeners(); await loadNetworks(); await fetchDataRange(); await fetchAverages(); await fetchData('1h'); // Start auto-refresh startAutoRefresh(); } // Start the app document.addEventListener('DOMContentLoaded', init);