diff --git a/src/main.js b/src/main.js index 2940d34..4eeb18b 100644 --- a/src/main.js +++ b/src/main.js @@ -1,43 +1,109 @@ import './styles.css' -// Configuration -const UPDATE_INTERVAL = 2000 // ms -const HISTORY_DURATION = 300 // seconds -const MAX_HISTORY_POINTS = Math.ceil((HISTORY_DURATION * 1000) / UPDATE_INTERVAL) // 150 points -const REQUEST_TIMEOUT = 1000 // ms +// --- Configuration ----------------------------------------------------------- -// Pause state -let isPaused = false -let intervalId = null +const CONFIG = Object.freeze({ + updateInterval: 2000, + historyDuration: 300, + requestTimeout: 1000, + maxLatency: 1000, + yAxisTicks: [0, 250, 500, 750, 1000], + xAxisTicks: [0, 60, 120, 180, 240, 300], + canvasHeight: 80, + get maxHistoryPoints() { + return Math.ceil((this.historyDuration * 1000) / this.updateInterval) + }, +}) -// 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' }, +const WAN_HOSTS = [ + { name: 'Google Cloud Console', url: 'https://console.cloud.google.com' }, + { name: 'AWS Console', url: 'https://console.aws.amazon.com' }, + { name: 'GitHub', url: 'https://github.com' }, + { name: 'Cloudflare', url: 'https://www.cloudflare.com' }, + { name: 'Microsoft Azure', url: 'https://portal.azure.com' }, + { name: 'DigitalOcean', url: 'https://www.digitalocean.com' }, + { name: 'Fastly CDN', url: 'https://www.fastly.com' }, + { name: 'Akamai', url: 'https://www.akamai.com' }, + { name: 'datavi.be', url: 'https://datavi.be' }, ] -// 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' -})) +const LOCAL_HOSTS = [ + { name: 'Local Gateway', url: 'http://192.168.100.1' }, +] + +// --- App State --------------------------------------------------------------- + +class HostState { + constructor(host) { + this.name = host.name + this.url = host.url + this.history = [] // { timestamp, latency, paused } + this.lastLatency = null + this.status = 'pending' // 'online' | 'offline' | 'error' | 'pending' + } + + pushSample(timestamp, result) { + this.history.push({ timestamp, latency: result.latency, error: result.error }) + this._trim() + this.lastLatency = result.latency + if (result.error === 'timeout') this.status = 'error' + else if (result.error) this.status = 'offline' + else this.status = 'online' + } + + pushPaused(timestamp) { + this.history.push({ timestamp, latency: null, paused: true }) + this._trim() + } + + averageLatency() { + const valid = this.history.filter(p => p.latency !== null) + if (valid.length === 0) return null + return Math.round(valid.reduce((s, p) => s + p.latency, 0) / valid.length) + } + + _trim() { + while (this.history.length > CONFIG.maxHistoryPoints) this.history.shift() + } +} + +class AppState { + constructor() { + this.wan = WAN_HOSTS.map(h => new HostState(h)) + this.local = LOCAL_HOSTS.map(h => new HostState(h)) + this.paused = false + } + + get allHosts() { return [...this.wan, ...this.local] } + + /** WAN-only stats from latest sample (excludes local) */ + wanStats() { + const reachable = this.wan.filter(h => h.lastLatency !== null) + const latencies = reachable.map(h => h.lastLatency) + const total = this.wan.length + if (latencies.length === 0) return { reachable: 0, total, min: null, max: null, avg: null } + return { + reachable: latencies.length, + total, + min: Math.min(...latencies), + max: Math.max(...latencies), + avg: Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length), + } + } + + /** Overall health: true = healthy (more than half WAN reachable) */ + isHealthy() { + const reachable = this.wan.filter(h => h.lastLatency !== null).length + return reachable > this.wan.length / 2 + } +} + +// --- Latency Measurement ----------------------------------------------------- -// Measure latency using HEAD request async function measureLatency(url) { const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT) + const timeoutId = setTimeout(() => controller.abort(), CONFIG.requestTimeout) - // Add cache-busting parameter const targetUrl = new URL(url) targetUrl.searchParams.set('_cb', Date.now().toString()) @@ -50,209 +116,196 @@ async function measureLatency(url) { cache: 'no-store', signal: controller.signal, }) - const latency = Math.round(performance.now() - start) clearTimeout(timeoutId) - // Clamp >1000ms to unreachable - if (latency > 1000) { - return { latency: null, error: 'timeout' } - } + if (latency > CONFIG.maxLatency) return { latency: null, error: 'timeout' } return { latency, error: null } } catch (err) { clearTimeout(timeoutId) - - if (err.name === 'AbortError') { - return { latency: null, error: 'timeout' } - } - + if (err.name === 'AbortError') return { latency: null, error: 'timeout' } return { latency: null, error: 'unreachable' } } } -// 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 +// --- Color Helpers ----------------------------------------------------------- + +function latencyHex(latency) { + if (latency === null) return '#6b7280' + if (latency < 50) return '#22c55e' + if (latency < 100) return '#84cc16' + if (latency < 200) return '#eab308' + if (latency < 500) return '#f97316' + return '#ef4444' } -// 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' +function latencyClass(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' } -// 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 +// --- Sparkline Renderer ------------------------------------------------------ -// Draw sparkline on canvas - latest point at right edge, growing left -function drawSparkline(canvas, history) { - const ctx = canvas.getContext('2d') - const dpr = window.devicePixelRatio || 1 - const width = canvas.width / dpr - const height = canvas.height / dpr +class SparklineRenderer { + static MARGIN = { left: 35, right: 10, top: 5, bottom: 18 } - // Margins for axis labels - const marginLeft = 35 - const marginRight = 10 - const marginTop = 5 - const marginBottom = 18 + static draw(canvas, history) { + const ctx = canvas.getContext('2d') + const dpr = window.devicePixelRatio || 1 + const w = canvas.width / dpr + const h = canvas.height / dpr + const m = SparklineRenderer.MARGIN + const cw = w - m.left - m.right + const ch = h - m.top - m.bottom - const chartWidth = width - marginLeft - marginRight - const chartHeight = height - marginTop - marginBottom + ctx.clearRect(0, 0, w, h) + SparklineRenderer._drawYAxis(ctx, w, h, m, ch) + SparklineRenderer._drawXAxis(ctx, w, h, m, cw) - // Clear canvas - ctx.clearRect(0, 0, width, height) + const len = history.length + const pw = cw / (CONFIG.maxHistoryPoints - 1) + const getX = i => m.left + cw - (len - 1 - i) * pw + const getY = lat => m.top + ch - (lat / CONFIG.maxLatency) * ch - // Draw Y-axis labels and grid lines - ctx.font = '9px monospace' - ctx.textAlign = 'right' - ctx.textBaseline = 'middle' - - 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(marginLeft, y) - ctx.lineTo(width - marginRight, y) - ctx.stroke() - - // Label - ctx.fillStyle = 'rgba(255,255,255,0.5)' - ctx.fillText(`${tick}`, marginLeft - 4, y) - }) - - // Draw X-axis labels - ctx.textAlign = 'center' - ctx.textBaseline = 'top' - - 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 - - // Label - ctx.fillStyle = 'rgba(255,255,255,0.5)' - ctx.fillText(`-${tick}s`, x, height - marginBottom + 4) - }) - - // 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 - - history.forEach((point, i) => { - const x = getX(i) - - if (point.latency === null && !inErrorRegion) { - inErrorRegion = true - errorStart = x - } else if (point.latency !== null && inErrorRegion) { - inErrorRegion = false - ctx.fillRect(errorStart, marginTop, x - errorStart, chartHeight) - } - }) - - if (inErrorRegion) { - const lastX = getX(historyLen - 1) - ctx.fillRect(errorStart, marginTop, lastX - errorStart + 5, chartHeight) + SparklineRenderer._drawErrors(ctx, history, getX, m.top, ch) + SparklineRenderer._drawLine(ctx, history, getX, getY) + SparklineRenderer._drawTip(ctx, history, getX, getY) } - // 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 + static _drawYAxis(ctx, w, h, m, ch) { + ctx.font = '9px monospace' + ctx.textAlign = 'right' + ctx.textBaseline = 'middle' + for (const tick of CONFIG.yAxisTicks) { + const y = m.top + ch - (tick / CONFIG.maxLatency) * ch + ctx.strokeStyle = 'rgba(255,255,255,0.1)' + ctx.lineWidth = 1 + ctx.beginPath(); ctx.moveTo(m.left, y); ctx.lineTo(w - m.right, y); ctx.stroke() + ctx.fillStyle = 'rgba(255,255,255,0.5)' + ctx.fillText(`${tick}`, m.left - 4, y) } + } - 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() + static _drawXAxis(ctx, w, h, m, cw) { + ctx.textAlign = 'center' + ctx.textBaseline = 'top' + for (const tick of CONFIG.xAxisTicks) { + const x = m.left + cw - (tick / CONFIG.historyDuration) * cw + ctx.fillStyle = 'rgba(255,255,255,0.5)' + ctx.fillText(`-${tick}s`, x, h - m.bottom + 4) } + } - prevPoint = point - prevX = x - prevY = y - }) + static _drawErrors(ctx, history, getX, top, ch) { + ctx.fillStyle = 'rgba(239, 68, 68, 0.2)' + let inErr = false, start = 0 + for (let i = 0; i < history.length; i++) { + const p = history[i] + const x = getX(i) + // Only real errors, not paused gaps + const isError = p.latency === null && !p.paused + if (isError && !inErr) { inErr = true; start = x } + else if (!isError && inErr) { inErr = false; ctx.fillRect(start, top, x - start, ch) } + } + if (inErr) ctx.fillRect(start, top, getX(history.length - 1) - start + 5, ch) + } - // 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) + static _drawLine(ctx, history, getX, getY) { + ctx.lineWidth = 2 + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + let prev = null + for (let i = 0; i < history.length; i++) { + const p = history[i] + if (p.latency === null) { prev = null; continue } + const x = getX(i), y = getY(p.latency) + if (prev) { + ctx.strokeStyle = latencyHex(p.latency) + ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(x, y); ctx.stroke() + } + prev = { x, y } + } + } - ctx.fillStyle = getLatencyColor(lastValidPoint.latency) - ctx.beginPath() - ctx.arc(x, y, 3, 0, Math.PI * 2) - ctx.fill() + static _drawTip(ctx, history, getX, getY) { + for (let i = history.length - 1; i >= 0; i--) { + if (history[i].latency !== null) { + const x = getX(i), y = getY(history[i].latency) + ctx.fillStyle = latencyHex(history[i].latency) + ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fill() + return + } + } + } + + static sizeCanvas(canvas) { + const dpr = window.devicePixelRatio || 1 + const rect = canvas.getBoundingClientRect() + canvas.width = rect.width * dpr + canvas.height = CONFIG.canvasHeight * dpr + const ctx = canvas.getContext('2d') + ctx.setTransform(1, 0, 0, 1, 0, 0) + ctx.scale(dpr, dpr) } } -// Create the UI -function createUI() { - const app = document.getElementById('app') +// --- UI Renderer ------------------------------------------------------------- +function hostRowHTML(host, index) { + return ` +
Real-time network latency monitor | - Updates every ${UPDATE_INTERVAL / 1000}s | - History: ${HISTORY_DURATION}s | + Updates every ${CONFIG.updateInterval / 1000}s | + History: ${CONFIG.historyDuration}s | Running
-