feat: interactive canvas latency chart with hover tooltips and smooth curves
This commit is contained in:
parent
df22029755
commit
59861651bd
|
|
@ -56,8 +56,17 @@
|
||||||
|
|
||||||
<!-- Latency chart -->
|
<!-- Latency chart -->
|
||||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4 mb-8">
|
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4 mb-8">
|
||||||
<h3 class="text-sm text-gray-400 mb-3">Response Time</h3>
|
<div class="flex items-center justify-between mb-3">
|
||||||
<div id="latency-chart" class="h-32 w-full"><%~ it.latencyChartSSR(chartPings) %></div>
|
<h3 class="text-sm text-gray-400">Response Time</h3>
|
||||||
|
<div id="chart-legend" class="flex gap-3 text-xs text-gray-500"></div>
|
||||||
|
</div>
|
||||||
|
<div id="latency-chart" class="relative h-48 w-full" style="cursor:crosshair">
|
||||||
|
<canvas id="chart-canvas" class="w-full h-full"></canvas>
|
||||||
|
<div id="chart-tooltip" class="absolute hidden bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-xs pointer-events-none z-10 shadow-lg" style="min-width:140px"></div>
|
||||||
|
<div id="chart-crosshair" class="absolute top-0 bottom-0 w-px bg-gray-600/50 pointer-events-none hidden"></div>
|
||||||
|
<span id="chart-ymax" class="absolute top-1 left-2 text-gray-600 text-xs pointer-events-none"></span>
|
||||||
|
<span id="chart-ymin" class="absolute bottom-1 left-2 text-gray-600 text-xs pointer-events-none"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status bar -->
|
<!-- Status bar -->
|
||||||
|
|
@ -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 `<span class="flex items-center gap-1"><span style="background:${c}" class="inline-block w-2 h-2 rounded-full"></span>${l}</span>`;
|
||||||
|
}).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 = `<div class="text-gray-400 mb-1">${timeStr}</div>`;
|
||||||
|
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 ? '' : ' <span class="text-red-400">DOWN</span>';
|
||||||
|
html += `<div class="flex items-center justify-between gap-3">
|
||||||
|
<span class="flex items-center gap-1.5"><span style="background:${c}" class="inline-block w-1.5 h-1.5 rounded-full"></span>${label}</span>
|
||||||
|
<span class="text-gray-200 font-mono">${pt.ping.latency_ms}ms${status}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
if (result.runId) {
|
||||||
|
html += `<div class="text-gray-600 mt-1 text-[10px] font-mono">${result.runId.slice(0, 8)}…</div>`;
|
||||||
|
}
|
||||||
|
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
|
// Running totals for incremental stat updates
|
||||||
let _total = <%= pings.length %>, _up = <%= upPings.length %>;
|
let _total = <%= pings.length %>, _up = <%= upPings.length %>;
|
||||||
let _latSum = <%= latencies.reduce((a,b)=>a+b,0) %>, _latCount = <%= latencies.length %>;
|
let _latSum = <%= latencies.reduce((a,b)=>a+b,0) %>, _latCount = <%= latencies.length %>;
|
||||||
|
|
||||||
// SSE: update everything on ping
|
// SSE: update everything on ping
|
||||||
let _fetchingChart = false;
|
watchAccount((ping) => {
|
||||||
watchAccount(async (ping) => {
|
|
||||||
if (ping.monitor_id !== monitorId) return;
|
if (ping.monitor_id !== monitorId) return;
|
||||||
|
|
||||||
// Accumulate
|
// Accumulate
|
||||||
|
|
@ -223,14 +451,14 @@
|
||||||
while (tbody.children.length > 100) tbody.removeChild(tbody.lastChild);
|
while (tbody.children.length > 100) tbody.removeChild(tbody.lastChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chart
|
// Chart — push new ping and re-render locally
|
||||||
if (_fetchingChart) return;
|
chartPings.push({
|
||||||
_fetchingChart = true;
|
latency_ms: ping.latency_ms, region: ping.region || '__none__',
|
||||||
try {
|
checked_at: ping.checked_at, up: ping.up, run_id: ping.run_id || null,
|
||||||
const res = await fetch(`/dashboard/monitors/${monitorId}/chart`, { credentials: 'same-origin' });
|
status_code: ping.status_code
|
||||||
if (res.ok) document.getElementById('latency-chart').innerHTML = await res.text();
|
});
|
||||||
} catch {}
|
if (chartPings.length > 200) chartPings.shift();
|
||||||
_fetchingChart = false;
|
renderChart();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue