Add fixed axes, color-coded lines, summary stats, and >1000ms clamping
- Fixed Y-axis (0-1000ms) with tick labels - Fixed X-axis showing seconds ago (-0s to -300s) - Sparkline segments color-coded by latency value - Summary line showing reachable count, min/max/avg across hosts - Latencies >1000ms now clamped to unreachable/timeout - Canvas height increased to 80px for axis labels
This commit is contained in:
parent
ed602fdb5f
commit
3069b4692d
253
src/main.js
253
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() {
|
||||
<span class="text-gray-500">History: ${HISTORY_DURATION}s</span> |
|
||||
<span id="status-indicator" class="text-green-400">Running</span>
|
||||
</p>
|
||||
<div id="summary" class="mt-4 p-3 bg-gray-800/70 rounded-lg border border-gray-700/50 font-mono text-sm">
|
||||
<span class="text-gray-400">Reachable:</span> <span id="summary-reachable" class="text-white">--/--</span>
|
||||
<span class="text-gray-600 mx-3">|</span>
|
||||
<span class="text-gray-400">Min:</span> <span id="summary-min" class="text-green-400">--ms</span>
|
||||
<span class="text-gray-600 mx-3">|</span>
|
||||
<span class="text-gray-400">Max:</span> <span id="summary-max" class="text-red-400">--ms</span>
|
||||
<span class="text-gray-600 mx-3">|</span>
|
||||
<span class="text-gray-400">Avg:</span> <span id="summary-avg" class="text-yellow-400">--ms</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="hosts-container" class="space-y-4">
|
||||
@ -258,7 +291,7 @@ function createUI() {
|
||||
<canvas
|
||||
class="sparkline-canvas w-full"
|
||||
data-host="${index}"
|
||||
height="60"
|
||||
height="80"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user