diff --git a/src/main.js b/src/main.js index 24209fb..2940d34 100644 --- a/src/main.js +++ b/src/main.js @@ -53,6 +53,10 @@ async function measureLatency(url) { const latency = Math.round(performance.now() - start) clearTimeout(timeoutId) + // Clamp >1000ms to unreachable + if (latency > 1000) { + return { latency: null, error: 'timeout' } + } return { latency, error: null } } catch (err) { clearTimeout(timeoutId) @@ -65,16 +69,14 @@ async function measureLatency(url) { } } -// Get latency color based on value -function getLatencyColor(latency, status) { - if (status === 'offline' || status === 'error' || latency === null) { - return 'var(--color-latency-offline)' - } - if (latency < 50) return 'var(--color-latency-excellent)' - if (latency < 100) return 'var(--color-latency-good)' - if (latency < 200) return 'var(--color-latency-moderate)' - if (latency < 500) return 'var(--color-latency-poor)' - return 'var(--color-latency-bad)' +// Get latency color (raw hex for canvas) +function getLatencyColor(latency) { + if (latency === null) return '#6b7280' // gray + if (latency < 50) return '#22c55e' // green + if (latency < 100) return '#84cc16' // lime + if (latency < 200) return '#eab308' // yellow + if (latency < 500) return '#f97316' // orange + return '#ef4444' // red } // Get Tailwind class for latency @@ -89,95 +91,73 @@ function getLatencyClass(latency, status) { return 'text-red-500' } +// Fixed axis constants +const Y_AXIS_MAX = 1000 // ms +const Y_AXIS_TICKS = [0, 250, 500, 750, 1000] +const X_AXIS_TICKS = [0, 60, 120, 180, 240, 300] // seconds ago + // Draw sparkline on canvas - latest point at right edge, growing left -function drawSparkline(canvas, history, hostColor) { +function drawSparkline(canvas, history) { const ctx = canvas.getContext('2d') - const width = canvas.width - const height = canvas.height - const padding = 4 + const dpr = window.devicePixelRatio || 1 + const width = canvas.width / dpr + const height = canvas.height / dpr + + // Margins for axis labels + const marginLeft = 35 + const marginRight = 10 + const marginTop = 5 + const marginBottom = 18 + + const chartWidth = width - marginLeft - marginRight + const chartHeight = height - marginTop - marginBottom // Clear canvas ctx.clearRect(0, 0, width, height) - // Filter valid latency points - const validPoints = history.filter(p => p.latency !== null) + // Draw Y-axis labels and grid lines + ctx.font = '9px monospace' + ctx.textAlign = 'right' + ctx.textBaseline = 'middle' - if (validPoints.length < 2) { - // Draw placeholder line + Y_AXIS_TICKS.forEach(tick => { + const y = marginTop + chartHeight - (tick / Y_AXIS_MAX) * chartHeight + + // Grid line ctx.strokeStyle = 'rgba(255,255,255,0.1)' ctx.lineWidth = 1 ctx.beginPath() - ctx.moveTo(padding, height / 2) - ctx.lineTo(width - padding, height / 2) + ctx.moveTo(marginLeft, y) + ctx.lineTo(width - marginRight, y) ctx.stroke() - return - } - // Calculate min/max for scaling - const latencies = validPoints.map(p => p.latency) - const minLatency = Math.min(...latencies) - const maxLatency = Math.max(...latencies) - const range = maxLatency - minLatency || 1 - - // Draw background grid lines - ctx.strokeStyle = 'rgba(255,255,255,0.05)' - ctx.lineWidth = 1 - for (let i = 0; i <= 4; i++) { - const y = padding + (i / 4) * (height - 2 * padding) - ctx.beginPath() - ctx.moveTo(0, y) - ctx.lineTo(width, y) - ctx.stroke() - } - - // Draw the sparkline - right-aligned (latest at right edge) - ctx.strokeStyle = hostColor - ctx.lineWidth = 1.5 - ctx.lineCap = 'round' - ctx.lineJoin = 'round' - ctx.beginPath() - - const pointWidth = (width - 2 * padding) / (MAX_HISTORY_POINTS - 1) - const historyLen = history.length - - // Calculate x position: latest point at right edge - const getX = (i) => width - padding - (historyLen - 1 - i) * pointWidth - - // Map all history points to x positions - let firstPoint = true - history.forEach((point, i) => { - if (point.latency === null) return - - const x = getX(i) - const normalizedY = (point.latency - minLatency) / range - const y = height - padding - normalizedY * (height - 2 * padding) - - if (firstPoint) { - ctx.moveTo(x, y) - firstPoint = false - } else { - ctx.lineTo(x, y) - } + // Label + ctx.fillStyle = 'rgba(255,255,255,0.5)' + ctx.fillText(`${tick}`, marginLeft - 4, y) }) - ctx.stroke() + // Draw X-axis labels + ctx.textAlign = 'center' + ctx.textBaseline = 'top' - // Draw latest point indicator (always at right edge) - const lastValidPoint = [...history].reverse().find(p => p.latency !== null) - if (lastValidPoint) { - const lastIndex = history.lastIndexOf(lastValidPoint) - const x = getX(lastIndex) - const normalizedY = (lastValidPoint.latency - minLatency) / range - const y = height - padding - normalizedY * (height - 2 * padding) + X_AXIS_TICKS.forEach(tick => { + // tick is seconds ago, 0 = now (right edge), 300 = 5min ago (left edge) + const x = marginLeft + chartWidth - (tick / HISTORY_DURATION) * chartWidth - ctx.fillStyle = hostColor - ctx.beginPath() - ctx.arc(x, y, 3, 0, Math.PI * 2) - ctx.fill() - } + // Label + ctx.fillStyle = 'rgba(255,255,255,0.5)' + ctx.fillText(`-${tick}s`, x, height - marginBottom + 4) + }) - // Draw error regions (red zones where connectivity was lost) - ctx.fillStyle = 'rgba(239, 68, 68, 0.3)' + // Helper functions for chart coordinates + const historyLen = history.length + const pointWidth = chartWidth / (MAX_HISTORY_POINTS - 1) + + const getX = (i) => marginLeft + chartWidth - (historyLen - 1 - i) * pointWidth + const getY = (latency) => marginTop + chartHeight - (latency / Y_AXIS_MAX) * chartHeight + + // Draw error regions first (behind the line) + ctx.fillStyle = 'rgba(239, 68, 68, 0.2)' let inErrorRegion = false let errorStart = 0 @@ -189,13 +169,57 @@ function drawSparkline(canvas, history, hostColor) { errorStart = x } else if (point.latency !== null && inErrorRegion) { inErrorRegion = false - ctx.fillRect(errorStart, 0, x - errorStart, height) + ctx.fillRect(errorStart, marginTop, x - errorStart, chartHeight) } }) if (inErrorRegion) { const lastX = getX(historyLen - 1) - ctx.fillRect(errorStart, 0, lastX - errorStart + padding, height) + ctx.fillRect(errorStart, marginTop, lastX - errorStart + 5, chartHeight) + } + + // Draw the sparkline segments - color coded by latency + ctx.lineWidth = 2 + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + + let prevPoint = null + let prevX = 0 + let prevY = 0 + + history.forEach((point, i) => { + if (point.latency === null) { + prevPoint = null + return + } + + const x = getX(i) + const y = getY(point.latency) + + if (prevPoint !== null) { + ctx.strokeStyle = getLatencyColor(point.latency) + ctx.beginPath() + ctx.moveTo(prevX, prevY) + ctx.lineTo(x, y) + ctx.stroke() + } + + prevPoint = point + prevX = x + prevY = y + }) + + // Draw latest point indicator + const lastValidPoint = [...history].reverse().find(p => p.latency !== null) + if (lastValidPoint) { + const lastIndex = history.lastIndexOf(lastValidPoint) + const x = getX(lastIndex) + const y = getY(lastValidPoint.latency) + + ctx.fillStyle = getLatencyColor(lastValidPoint.latency) + ctx.beginPath() + ctx.arc(x, y, 3, 0, Math.PI * 2) + ctx.fill() } } @@ -228,6 +252,15 @@ function createUI() { History: ${HISTORY_DURATION}s | Running

+
+ Reachable: --/-- + | + Min: --ms + | + Max: --ms + | + Avg: --ms +
@@ -258,7 +291,7 @@ function createUI() {
@@ -285,7 +318,7 @@ function createUI() { document.querySelectorAll('.sparkline-canvas').forEach(canvas => { const rect = canvas.getBoundingClientRect() canvas.width = rect.width * window.devicePixelRatio - canvas.height = 60 * window.devicePixelRatio + canvas.height = 80 * window.devicePixelRatio canvas.getContext('2d').scale(window.devicePixelRatio, window.devicePixelRatio) }) }) @@ -326,7 +359,7 @@ function updateHostDisplay(index) { // Update sparkline const canvasRect = canvas.getBoundingClientRect() - drawSparkline(canvas, host.history, host.color) + drawSparkline(canvas, host.history) } // Calculate average latency from history @@ -338,6 +371,45 @@ function calculateAverageLatency(history) { return Math.round(sum / validPoints.length) } +// Update summary line with current host stats +function updateSummary() { + const reachableHosts = hostState.filter(h => h.lastLatency !== null) + const reachableCount = reachableHosts.length + const totalCount = hostState.length + + const reachableEl = document.getElementById('summary-reachable') + const minEl = document.getElementById('summary-min') + const maxEl = document.getElementById('summary-max') + const avgEl = document.getElementById('summary-avg') + + if (!reachableEl) return + + reachableEl.textContent = `${reachableCount}/${totalCount}` + reachableEl.className = reachableCount === totalCount ? 'text-green-400' : + reachableCount === 0 ? 'text-red-400' : 'text-yellow-400' + + if (reachableCount > 0) { + const latencies = reachableHosts.map(h => h.lastLatency) + const min = Math.min(...latencies) + const max = Math.max(...latencies) + const avg = Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length) + + minEl.textContent = `${min}ms` + minEl.className = getLatencyClass(min, 'online') + maxEl.textContent = `${max}ms` + maxEl.className = getLatencyClass(max, 'online') + avgEl.textContent = `${avg}ms` + avgEl.className = getLatencyClass(avg, 'online') + } else { + minEl.textContent = '--ms' + minEl.className = 'text-gray-500' + maxEl.textContent = '--ms' + maxEl.className = 'text-gray-500' + avgEl.textContent = '--ms' + avgEl.className = 'text-gray-500' + } +} + // Main measurement loop async function measureAll() { const timestamp = Date.now() @@ -376,6 +448,9 @@ async function measureAll() { // Update display updateHostDisplay(index) }) + + // Update summary + updateSummary() } // Handle window resize @@ -383,14 +458,14 @@ function handleResize() { document.querySelectorAll('.sparkline-canvas').forEach((canvas, index) => { const rect = canvas.getBoundingClientRect() canvas.width = rect.width * window.devicePixelRatio - canvas.height = 60 * window.devicePixelRatio + canvas.height = 80 * window.devicePixelRatio const ctx = canvas.getContext('2d') ctx.setTransform(1, 0, 0, 1, 0, 0) ctx.scale(window.devicePixelRatio, window.devicePixelRatio) // Redraw if (hostState[index]) { - drawSparkline(canvas, hostState[index].history, hostState[index].color) + drawSparkline(canvas, hostState[index].history) } }) }