Initial implementation of NetWatch network latency monitor
SPA for real-time latency monitoring to 10 internet hosts: - 250ms update interval with 300s history sparkline graphs - Color-coded latency display (green <50ms to red >500ms) - HEAD request timing with no-cors mode for cross-origin support - Built with Vite + Tailwind CSS v4, all dependencies bundled - Designed for static bucket deployment
This commit is contained in:
397
src/main.js
Normal file
397
src/main.js
Normal file
@@ -0,0 +1,397 @@
|
||||
import './styles.css'
|
||||
|
||||
// Configuration
|
||||
const UPDATE_INTERVAL = 250 // ms
|
||||
const HISTORY_DURATION = 300 // seconds
|
||||
const MAX_HISTORY_POINTS = Math.ceil((HISTORY_DURATION * 1000) / UPDATE_INTERVAL) // 1200 points
|
||||
const REQUEST_TIMEOUT = 5000 // ms
|
||||
|
||||
// Hosts to monitor (IPv4 preferred endpoints)
|
||||
const HOSTS = [
|
||||
{ name: 'Google Cloud Console', url: 'https://console.cloud.google.com', color: '#4285f4' },
|
||||
{ name: 'AWS Console', url: 'https://console.aws.amazon.com', color: '#ff9900' },
|
||||
{ name: 'GitHub', url: 'https://github.com', color: '#f0f6fc' },
|
||||
{ name: 'Cloudflare', url: 'https://www.cloudflare.com', color: '#f38020' },
|
||||
{ name: 'Microsoft Azure', url: 'https://portal.azure.com', color: '#0078d4' },
|
||||
{ name: 'DigitalOcean', url: 'https://www.digitalocean.com', color: '#0080ff' },
|
||||
{ name: 'Fastly CDN', url: 'https://www.fastly.com', color: '#ff282d' },
|
||||
{ name: 'Akamai', url: 'https://www.akamai.com', color: '#0096d6' },
|
||||
{ name: 'Local Gateway', url: 'http://192.168.100.1', color: '#a855f7' },
|
||||
{ name: 'datavi.be', url: 'https://datavi.be', color: '#10b981' },
|
||||
]
|
||||
|
||||
// State: history for each host
|
||||
const hostState = HOSTS.map(host => ({
|
||||
...host,
|
||||
history: [], // Array of { timestamp, latency } or { timestamp, error }
|
||||
lastLatency: null,
|
||||
status: 'pending', // 'online', 'offline', 'pending', 'error'
|
||||
}))
|
||||
|
||||
// Measure latency using HEAD request
|
||||
async function measureLatency(url) {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT)
|
||||
|
||||
const start = performance.now()
|
||||
|
||||
try {
|
||||
// Add cache-busting parameter
|
||||
const targetUrl = new URL(url)
|
||||
targetUrl.searchParams.set('_cb', Date.now().toString())
|
||||
|
||||
await fetch(targetUrl.toString(), {
|
||||
method: 'HEAD',
|
||||
mode: 'no-cors', // Allow cross-origin without CORS headers
|
||||
cache: 'no-store',
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
const end = performance.now()
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
return { latency: Math.round(end - start), error: null }
|
||||
} catch (err) {
|
||||
const end = performance.now()
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (err.name === 'AbortError') {
|
||||
return { latency: null, error: 'timeout' }
|
||||
}
|
||||
|
||||
// For no-cors mode, network errors indicate unreachable
|
||||
// But successful completion means we got through
|
||||
return { latency: null, error: 'unreachable' }
|
||||
}
|
||||
}
|
||||
|
||||
// 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 Tailwind class for latency
|
||||
function getLatencyClass(latency, status) {
|
||||
if (status === 'offline' || status === 'error' || latency === null) {
|
||||
return 'text-gray-500'
|
||||
}
|
||||
if (latency < 50) return 'text-green-500'
|
||||
if (latency < 100) return 'text-lime-500'
|
||||
if (latency < 200) return 'text-yellow-500'
|
||||
if (latency < 500) return 'text-orange-500'
|
||||
return 'text-red-500'
|
||||
}
|
||||
|
||||
// Draw sparkline on canvas
|
||||
function drawSparkline(canvas, history, hostColor) {
|
||||
const ctx = canvas.getContext('2d')
|
||||
const width = canvas.width
|
||||
const height = canvas.height
|
||||
const padding = 4
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
// Filter valid latency points
|
||||
const validPoints = history.filter(p => p.latency !== null)
|
||||
|
||||
if (validPoints.length < 2) {
|
||||
// Draw placeholder 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.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
|
||||
ctx.strokeStyle = hostColor
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.beginPath()
|
||||
|
||||
const pointWidth = (width - 2 * padding) / (MAX_HISTORY_POINTS - 1)
|
||||
|
||||
// Map all history points to x positions
|
||||
let firstPoint = true
|
||||
history.forEach((point, i) => {
|
||||
if (point.latency === null) return
|
||||
|
||||
const x = padding + i * pointWidth
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
ctx.stroke()
|
||||
|
||||
// Draw latest point indicator
|
||||
const lastValidPoint = [...history].reverse().find(p => p.latency !== null)
|
||||
if (lastValidPoint) {
|
||||
const lastIndex = history.lastIndexOf(lastValidPoint)
|
||||
const x = padding + lastIndex * pointWidth
|
||||
const normalizedY = (lastValidPoint.latency - minLatency) / range
|
||||
const y = height - padding - normalizedY * (height - 2 * padding)
|
||||
|
||||
ctx.fillStyle = hostColor
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 3, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// Draw error regions (red zones where connectivity was lost)
|
||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.3)'
|
||||
let inErrorRegion = false
|
||||
let errorStart = 0
|
||||
|
||||
history.forEach((point, i) => {
|
||||
const x = padding + i * pointWidth
|
||||
|
||||
if (point.latency === null && !inErrorRegion) {
|
||||
inErrorRegion = true
|
||||
errorStart = x
|
||||
} else if (point.latency !== null && inErrorRegion) {
|
||||
inErrorRegion = false
|
||||
ctx.fillRect(errorStart, 0, x - errorStart, height)
|
||||
}
|
||||
})
|
||||
|
||||
if (inErrorRegion) {
|
||||
ctx.fillRect(errorStart, 0, width - errorStart, height)
|
||||
}
|
||||
}
|
||||
|
||||
// Create the UI
|
||||
function createUI() {
|
||||
const app = document.getElementById('app')
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">NetWatch</h1>
|
||||
<p class="text-gray-400 text-sm">
|
||||
Real-time network latency monitor |
|
||||
<span class="text-gray-500">Updates every ${UPDATE_INTERVAL}ms</span> |
|
||||
<span class="text-gray-500">History: ${HISTORY_DURATION}s</span>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div id="hosts-container" class="space-y-4">
|
||||
${hostState.map((host, index) => `
|
||||
<div class="host-row bg-gray-800/50 rounded-lg p-4 border border-gray-700/50" data-index="${index}">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Host Info -->
|
||||
<div class="w-48 flex-shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-3 h-3 rounded-full" style="background-color: ${host.color}"></div>
|
||||
<span class="font-medium text-white truncate">${host.name}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-1 truncate">${host.url}</div>
|
||||
</div>
|
||||
|
||||
<!-- Latency Display -->
|
||||
<div class="w-32 flex-shrink-0 text-right">
|
||||
<div class="latency-value text-4xl font-bold tabular-nums" data-host="${index}">
|
||||
<span class="text-gray-500">---</span>
|
||||
</div>
|
||||
<div class="status-text text-xs text-gray-500 mt-1" data-host="${index}">
|
||||
waiting...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sparkline Graph -->
|
||||
<div class="flex-grow sparkline-container rounded overflow-hidden border border-gray-700/30">
|
||||
<canvas
|
||||
class="sparkline-canvas w-full"
|
||||
data-host="${index}"
|
||||
height="60"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<footer class="mt-8 text-center text-gray-600 text-xs">
|
||||
<p>Latency measured via HEAD requests | IPv4 only | CORS restrictions may affect some measurements</p>
|
||||
<p class="mt-2">
|
||||
<span class="inline-block w-3 h-3 rounded-full bg-green-500 mr-1 align-middle"></span><50ms
|
||||
<span class="inline-block w-3 h-3 rounded-full bg-lime-500 mr-1 ml-3 align-middle"></span><100ms
|
||||
<span class="inline-block w-3 h-3 rounded-full bg-yellow-500 mr-1 ml-3 align-middle"></span><200ms
|
||||
<span class="inline-block w-3 h-3 rounded-full bg-orange-500 mr-1 ml-3 align-middle"></span><500ms
|
||||
<span class="inline-block w-3 h-3 rounded-full bg-red-500 mr-1 ml-3 align-middle"></span>>500ms
|
||||
<span class="inline-block w-3 h-3 rounded-full bg-gray-500 mr-1 ml-3 align-middle"></span>offline
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
`
|
||||
|
||||
// Set up canvas sizes after DOM is ready
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelectorAll('.sparkline-canvas').forEach(canvas => {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
canvas.width = rect.width * window.devicePixelRatio
|
||||
canvas.height = 60 * window.devicePixelRatio
|
||||
canvas.getContext('2d').scale(window.devicePixelRatio, window.devicePixelRatio)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Update single host display
|
||||
function updateHostDisplay(index) {
|
||||
const host = hostState[index]
|
||||
const latencyEl = document.querySelector(`.latency-value[data-host="${index}"]`)
|
||||
const statusEl = document.querySelector(`.status-text[data-host="${index}"]`)
|
||||
const canvas = document.querySelector(`.sparkline-canvas[data-host="${index}"]`)
|
||||
|
||||
if (!latencyEl || !statusEl || !canvas) return
|
||||
|
||||
// Update latency value
|
||||
if (host.lastLatency !== null) {
|
||||
const colorClass = getLatencyClass(host.lastLatency, host.status)
|
||||
latencyEl.innerHTML = `<span class="${colorClass}">${host.lastLatency}<span class="text-lg">ms</span></span>`
|
||||
} else if (host.status === 'offline' || host.status === 'error') {
|
||||
latencyEl.innerHTML = `<span class="text-gray-500">---</span>`
|
||||
}
|
||||
|
||||
// Update status text
|
||||
const avgLatency = calculateAverageLatency(host.history)
|
||||
if (host.status === 'online' && avgLatency !== null) {
|
||||
statusEl.textContent = `avg: ${avgLatency}ms`
|
||||
statusEl.className = 'status-text text-xs text-gray-400 mt-1'
|
||||
} else if (host.status === 'offline') {
|
||||
statusEl.textContent = 'unreachable'
|
||||
statusEl.className = 'status-text text-xs text-red-400 mt-1'
|
||||
} else if (host.status === 'error') {
|
||||
statusEl.textContent = 'timeout'
|
||||
statusEl.className = 'status-text text-xs text-orange-400 mt-1'
|
||||
} else {
|
||||
statusEl.textContent = 'connecting...'
|
||||
statusEl.className = 'status-text text-xs text-gray-500 mt-1'
|
||||
}
|
||||
|
||||
// Update sparkline
|
||||
const canvasRect = canvas.getBoundingClientRect()
|
||||
drawSparkline(canvas, host.history, host.color)
|
||||
}
|
||||
|
||||
// Calculate average latency from history
|
||||
function calculateAverageLatency(history) {
|
||||
const validPoints = history.filter(p => p.latency !== null)
|
||||
if (validPoints.length === 0) return null
|
||||
|
||||
const sum = validPoints.reduce((acc, p) => acc + p.latency, 0)
|
||||
return Math.round(sum / validPoints.length)
|
||||
}
|
||||
|
||||
// Main measurement loop
|
||||
async function measureAll() {
|
||||
const timestamp = Date.now()
|
||||
|
||||
// Measure all hosts in parallel
|
||||
const results = await Promise.all(
|
||||
hostState.map(host => measureLatency(host.url))
|
||||
)
|
||||
|
||||
// Update state
|
||||
results.forEach((result, index) => {
|
||||
const host = hostState[index]
|
||||
|
||||
// Add to history
|
||||
host.history.push({
|
||||
timestamp,
|
||||
latency: result.latency,
|
||||
error: result.error,
|
||||
})
|
||||
|
||||
// Trim history to max size
|
||||
while (host.history.length > MAX_HISTORY_POINTS) {
|
||||
host.history.shift()
|
||||
}
|
||||
|
||||
// Update current state
|
||||
host.lastLatency = result.latency
|
||||
if (result.error === 'timeout') {
|
||||
host.status = 'error'
|
||||
} else if (result.error) {
|
||||
host.status = 'offline'
|
||||
} else {
|
||||
host.status = 'online'
|
||||
}
|
||||
|
||||
// Update display
|
||||
updateHostDisplay(index)
|
||||
})
|
||||
}
|
||||
|
||||
// Handle window resize
|
||||
function handleResize() {
|
||||
document.querySelectorAll('.sparkline-canvas').forEach((canvas, index) => {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
canvas.width = rect.width * window.devicePixelRatio
|
||||
canvas.height = 60 * 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize
|
||||
function init() {
|
||||
createUI()
|
||||
|
||||
// Start measurement loop
|
||||
measureAll()
|
||||
setInterval(measureAll, UPDATE_INTERVAL)
|
||||
|
||||
// Handle resize
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
// Initial resize setup after a short delay
|
||||
setTimeout(handleResize, 100)
|
||||
}
|
||||
|
||||
// Run when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init)
|
||||
} else {
|
||||
init()
|
||||
}
|
||||
18
src/styles.css
Normal file
18
src/styles.css
Normal file
@@ -0,0 +1,18 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-latency-excellent: #22c55e;
|
||||
--color-latency-good: #84cc16;
|
||||
--color-latency-moderate: #eab308;
|
||||
--color-latency-poor: #f97316;
|
||||
--color-latency-bad: #ef4444;
|
||||
--color-latency-offline: #6b7280;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.sparkline-container {
|
||||
background: linear-gradient(to bottom, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0) 100%);
|
||||
}
|
||||
Reference in New Issue
Block a user