From 59861651bd7a458a1e76f93a13fe6e95b1a3967d Mon Sep 17 00:00:00 2001 From: nate Date: Wed, 18 Mar 2026 19:49:44 +0400 Subject: [PATCH] feat: interactive canvas latency chart with hover tooltips and smooth curves --- apps/web/src/views/detail.ejs | 252 ++++++++++++++++++++++++++++++++-- 1 file changed, 240 insertions(+), 12 deletions(-) diff --git a/apps/web/src/views/detail.ejs b/apps/web/src/views/detail.ejs index 7940870..9427cb9 100644 --- a/apps/web/src/views/detail.ejs +++ b/apps/web/src/views/detail.ejs @@ -56,8 +56,17 @@
-

Response Time

-
<%~ it.latencyChartSSR(chartPings) %>
+
+

Response Time

+
+
+
+ + + + + +
@@ -160,13 +169,232 @@ } }); + // ── Interactive latency chart ────────────────────────────────────── + const REGION_COLORS = { + 'eu-central': '#3b82f6', 'us-east': '#10b981', + 'us-west': '#f59e0b', 'ap-southeast': '#a78bfa', '__none__': '#6b7280' + }; + const REGION_FLAGS = { + 'eu-central': '🇩🇪 EU Central', 'us-east': '🇺🇸 US East', + 'us-west': '🇺🇸 US West', 'ap-southeast': '🇸🇬 AP Southeast' + }; + + let chartPings = <%~ JSON.stringify(chartPings.map(p => ({ + latency_ms: p.latency_ms, region: p.region || '__none__', + checked_at: p.checked_at, up: p.up, run_id: p.run_id || null, + status_code: p.status_code + }))) %>; + + function renderChart() { + const canvas = document.getElementById('chart-canvas'); + const container = canvas.parentElement; + const dpr = window.devicePixelRatio || 1; + const rect = container.getBoundingClientRect(); + const W = rect.width, H = rect.height; + canvas.width = W * dpr; canvas.height = H * dpr; + canvas.style.width = W + 'px'; canvas.style.height = H + 'px'; + const ctx = canvas.getContext('2d'); + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, W, H); + + const pad = { top: 8, bottom: 8, left: 0, right: 0 }; + const cW = W - pad.left - pad.right; + const cH = H - pad.top - pad.bottom; + + const data = chartPings.filter(p => p.latency_ms != null); + if (data.length < 2) { + ctx.fillStyle = '#4b5563'; ctx.font = '13px sans-serif'; ctx.textAlign = 'center'; + ctx.fillText('Not enough data', W / 2, H / 2); + return; + } + + // Group by region + const byRegion = {}; + for (const p of data) { + const r = p.region || '__none__'; + if (!byRegion[r]) byRegion[r] = []; + byRegion[r].push(p); + } + + const allLat = data.map(p => p.latency_ms); + const yMin = Math.min(...allLat), yMax = Math.max(...allLat); + const yRange = yMax - yMin || 1; + const allT = data.map(p => new Date(p.checked_at).getTime()); + const tMin = Math.min(...allT), tMax = Math.max(...allT); + const tRange = tMax - tMin || 1; + + function toX(t) { return pad.left + ((t - tMin) / tRange) * cW; } + function toY(v) { return pad.top + cH - ((v - yMin) / yRange) * cH; } + + // Grid lines + ctx.strokeStyle = 'rgba(75,85,99,0.3)'; ctx.lineWidth = 0.5; + for (let i = 0; i <= 4; i++) { + const y = pad.top + (cH / 4) * i; + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); + } + + // Draw smooth lines per region using cardinal spline + const regions = Object.keys(byRegion); + const regionPoints = {}; + for (const region of regions) { + const color = REGION_COLORS[region] || '#6b7280'; + const rPings = byRegion[region].slice().sort((a, b) => + new Date(a.checked_at).getTime() - new Date(b.checked_at).getTime() + ); + const pts = rPings.map(p => ({ + x: toX(new Date(p.checked_at).getTime()), + y: toY(p.latency_ms), + ping: p + })); + regionPoints[region] = pts; + + if (pts.length < 2) continue; + + ctx.beginPath(); + ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; + + // Catmull-Rom spline for smooth curves + ctx.moveTo(pts[0].x, pts[0].y); + for (let i = 0; i < pts.length - 1; i++) { + const p0 = pts[Math.max(i - 1, 0)]; + const p1 = pts[i]; + const p2 = pts[i + 1]; + const p3 = pts[Math.min(i + 2, pts.length - 1)]; + const tension = 0.3; + const cp1x = p1.x + (p2.x - p0.x) * tension; + const cp1y = p1.y + (p2.y - p0.y) * tension; + const cp2x = p2.x - (p3.x - p1.x) * tension; + const cp2y = p2.y - (p3.y - p1.y) * tension; + ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y); + } + ctx.stroke(); + + // Draw dots for failed pings + for (const pt of pts) { + if (!pt.ping.up) { + ctx.beginPath(); ctx.arc(pt.x, pt.y, 3, 0, Math.PI * 2); + ctx.fillStyle = '#f87171'; ctx.fill(); + } + } + } + + // Y-axis labels + document.getElementById('chart-ymax').textContent = yMax + 'ms'; + document.getElementById('chart-ymin').textContent = yMin + 'ms'; + + // Legend + const legendEl = document.getElementById('chart-legend'); + if (regions.length > 1) { + legendEl.innerHTML = regions.map(r => { + const c = REGION_COLORS[r] || '#6b7280'; + const l = REGION_FLAGS[r] || r; + return `${l}`; + }).join(''); + } else { + legendEl.innerHTML = ''; + } + + // Store for hover + canvas._regionPoints = regionPoints; + canvas._toX = toX; canvas._toY = toY; + canvas._tMin = tMin; canvas._tRange = tRange; + canvas._W = W; canvas._H = H; canvas._pad = pad; canvas._cW = cW; + } + + // Group pings by run_id for tooltip + function getRunGroup(t, regionPoints, cW, pad) { + // Find the closest time point across all regions + let closestDist = Infinity, closestT = null; + for (const pts of Object.values(regionPoints)) { + for (const pt of pts) { + const d = Math.abs(pt.x - t); + if (d < closestDist) { closestDist = d; closestT = pt.ping; } + } + } + if (!closestT || closestDist > 30) return null; + + // If we have a run_id, group all pings with same run_id + const runId = closestT.run_id; + const group = []; + if (runId) { + for (const [region, pts] of Object.entries(regionPoints)) { + const match = pts.find(pt => pt.ping.run_id === runId); + if (match) group.push({ region, ...match }); + } + } + if (!group.length) { + // Fallback: just show the closest point + const region = closestT.region || '__none__'; + const pts = regionPoints[region] || []; + const pt = pts.find(p => p.ping === closestT); + if (pt) group.push({ region, ...pt }); + } + return group.length ? { group, x: group[0].x, time: closestT.checked_at, runId } : null; + } + + // Hover handler + const chartContainer = document.getElementById('latency-chart'); + const tooltip = document.getElementById('chart-tooltip'); + const crosshair = document.getElementById('chart-crosshair'); + + chartContainer.addEventListener('mousemove', (e) => { + const canvas = document.getElementById('chart-canvas'); + if (!canvas._regionPoints) return; + const rect = canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + + const result = getRunGroup(mx, canvas._regionPoints, canvas._cW, canvas._pad); + if (!result) { + tooltip.classList.add('hidden'); + crosshair.classList.add('hidden'); + return; + } + + crosshair.classList.remove('hidden'); + crosshair.style.left = result.x + 'px'; + + // Build tooltip + const time = new Date(result.time); + const timeStr = time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + let html = `
${timeStr}
`; + result.group.sort((a, b) => (a.ping.latency_ms || 0) - (b.ping.latency_ms || 0)); + for (const pt of result.group) { + const c = REGION_COLORS[pt.region] || '#6b7280'; + const label = REGION_FLAGS[pt.region] || pt.region; + const status = pt.ping.up ? '' : ' DOWN'; + html += `
+ ${label} + ${pt.ping.latency_ms}ms${status} +
`; + } + if (result.runId) { + html += `
${result.runId.slice(0, 8)}…
`; + } + tooltip.innerHTML = html; + tooltip.classList.remove('hidden'); + + // Position tooltip + const tw = tooltip.offsetWidth; + const containerW = chartContainer.offsetWidth; + const left = result.x + 12; + tooltip.style.left = (left + tw > containerW ? result.x - tw - 12 : left) + 'px'; + tooltip.style.top = '4px'; + }); + + chartContainer.addEventListener('mouseleave', () => { + tooltip.classList.add('hidden'); + crosshair.classList.add('hidden'); + }); + + renderChart(); + window.addEventListener('resize', renderChart); + // Running totals for incremental stat updates let _total = <%= pings.length %>, _up = <%= upPings.length %>; let _latSum = <%= latencies.reduce((a,b)=>a+b,0) %>, _latCount = <%= latencies.length %>; // SSE: update everything on ping - let _fetchingChart = false; - watchAccount(async (ping) => { + watchAccount((ping) => { if (ping.monitor_id !== monitorId) return; // Accumulate @@ -223,14 +451,14 @@ while (tbody.children.length > 100) tbody.removeChild(tbody.lastChild); } - // Chart - if (_fetchingChart) return; - _fetchingChart = true; - try { - const res = await fetch(`/dashboard/monitors/${monitorId}/chart`, { credentials: 'same-origin' }); - if (res.ok) document.getElementById('latency-chart').innerHTML = await res.text(); - } catch {} - _fetchingChart = false; + // Chart — push new ping and re-render locally + chartPings.push({ + latency_ms: ping.latency_ms, region: ping.region || '__none__', + checked_at: ping.checked_at, up: ping.up, run_id: ping.run_id || null, + status_code: ping.status_code + }); + if (chartPings.length > 200) chartPings.shift(); + renderChart(); });