From 07648672ada95d4f47f4f583aca639a3841799ae Mon Sep 17 00:00:00 2001 From: M1 Date: Wed, 18 Mar 2026 16:25:47 +0400 Subject: [PATCH] feat: per-region chart lines and lowest-avg sparkline --- apps/web/src/routes/dashboard.ts | 97 +++++++++++++++++++++++--------- apps/web/src/utils/sparkline.ts | 37 +++++++++++- apps/web/src/views/home.ejs | 2 +- 3 files changed, 107 insertions(+), 29 deletions(-) diff --git a/apps/web/src/routes/dashboard.ts b/apps/web/src/routes/dashboard.ts index 157cd5a..71844bb 100644 --- a/apps/web/src/routes/dashboard.ts +++ b/apps/web/src/routes/dashboard.ts @@ -3,7 +3,7 @@ import { Eta } from "eta"; import { resolve } from "path"; import { resolveKey } from "./auth"; import sql from "../db"; -import { sparkline } from "../utils/sparkline"; +import { sparkline, sparklineFromPings } from "../utils/sparkline"; import { createHash } from "crypto"; // Generate a cache-buster hash from the CSS file content at startup @@ -21,40 +21,86 @@ function timeAgoSSR(date: string | Date): string { return `${text}`; } -const sparklineSSR = sparkline; +const sparklineSSR = sparklineFromPings; + +const REGION_COLORS: Record = { + 'eu-central': '#3b82f6', // blue + 'us-east': '#10b981', // green + 'us-west': '#f59e0b', // amber + 'ap-southeast': '#a78bfa', // purple + '__none__': '#6b7280', // gray for null region +}; + +const REGION_LABELS: Record = { + 'eu-central': 'πŸ‡©πŸ‡ͺ EU', + 'us-east': 'πŸ‡ΊπŸ‡Έ US-E', + 'us-west': 'πŸ‡ΊπŸ‡Έ US-W', + 'ap-southeast': 'πŸ‡ΈπŸ‡¬ AP', + '__none__': '?', +}; function latencyChartSSR(pings: any[]): string { const data = pings.filter((c: any) => c.latency_ms != null); if (data.length < 2) { return '
Not enough data
'; } - const values = data.map((c: any) => c.latency_ms); - const ups = data.map((c: any) => c.up); - const max = Math.max(...values, 1); - const min = Math.min(...values, 0); + + // Group by region + const byRegion: Record = {}; + for (const p of data) { + const key = p.region || '__none__'; + if (!byRegion[key]) byRegion[key] = []; + byRegion[key].push(p); + } + + const regions = Object.keys(byRegion); + const allValues = data.map((c: any) => c.latency_ms); + const max = Math.max(...allValues, 1); + const min = Math.min(...allValues, 0); const range = max - min || 1; const w = 800; const h = 128; - const step = w / Math.max(values.length - 1, 1); - const points = values.map((v: number, i: number) => { - const x = i * step; - const y = h - ((v - min) / range) * (h - 16) - 8; - return [x, y]; - }); - const pathD = points.map((p: number[], i: number) => `${i === 0 ? 'M' : 'L'}${p[0]},${p[1]}`).join(' '); - const areaD = pathD + ` L${points[points.length - 1][0]},${h} L${points[0][0]},${h} Z`; - const dots = points.map((p: number[], i: number) => - !ups[i] ? `` : '' - ).join(''); + + // Find the overall time range to align lines on a shared x-axis + const allTimes = data.map((c: any) => new Date(c.checked_at).getTime()); + const tMin = Math.min(...allTimes); + const tMax = Math.max(...allTimes); + const tRange = tMax - tMin || 1; + + let paths = ''; + let dots = ''; + let legend = ''; + + for (const region of regions) { + const color = REGION_COLORS[region] || '#6b7280'; + const label = REGION_LABELS[region] || region; + const rPings = byRegion[region].slice().sort((a: any, b: any) => + new Date(a.checked_at).getTime() - new Date(b.checked_at).getTime() + ); + + const points = rPings.map((p: any) => { + const x = ((new Date(p.checked_at).getTime() - tMin) / tRange) * w; + const y = h - ((p.latency_ms - min) / range) * (h - 16) - 8; + return { x, y, up: p.up }; + }); + + if (points.length < 1) continue; + + const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' '); + paths += ``; + dots += points.filter(p => !p.up).map(p => + `` + ).join(''); + legend += `${label}`; + } + return `
- - - - ${dots} + ${paths}${dots} ${max}ms ${min}ms + ${regions.length > 1 ? `
${legend}
` : ''}
`; } @@ -131,7 +177,7 @@ export const dashboard = new Elysia() if (monitorIds.length > 0) { const allPings = await sql` SELECT * FROM ( - SELECT p.*, ROW_NUMBER() OVER (PARTITION BY p.monitor_id ORDER BY p.checked_at DESC) as rn + SELECT p.*, ROW_NUMBER() OVER (PARTITION BY p.monitor_id, COALESCE(p.region,'__none__') ORDER BY p.checked_at DESC) as rn FROM pings p WHERE p.monitor_id = ANY(${monitorIds}) ) sub WHERE rn <= 20 ORDER BY monitor_id, checked_at ASC `; @@ -243,12 +289,11 @@ export const dashboard = new Elysia() if (!monitor) return new Response("Not found", { status: 404 }); const pings = await sql` - SELECT latency_ms FROM pings + SELECT latency_ms, region FROM pings WHERE monitor_id = ${params.id} AND latency_ms IS NOT NULL - ORDER BY checked_at DESC LIMIT 20 + ORDER BY checked_at DESC LIMIT 40 `; - const latencies = pings.map((p: any) => p.latency_ms).reverse(); - return new Response(sparklineSSR(latencies), { + return new Response(sparklineSSR(pings.slice().reverse()), { headers: { "content-type": "text/html; charset=utf-8" }, }); }) diff --git a/apps/web/src/utils/sparkline.ts b/apps/web/src/utils/sparkline.ts index 6aa076c..bec8386 100644 --- a/apps/web/src/utils/sparkline.ts +++ b/apps/web/src/utils/sparkline.ts @@ -1,4 +1,4 @@ -export function sparkline(values: number[], width = 120, height = 32): string { +export function sparkline(values: number[], width = 120, height = 32, color = '#60a5fa'): string { if (!values.length) return ''; const max = Math.max(...values, 1); const min = Math.min(...values, 0); @@ -9,5 +9,38 @@ export function sparkline(values: number[], width = 120, height = 32): string { const y = height - ((v - min) / range) * (height - 4) - 2; return `${x},${y}`; }).join(' '); - return ``; + return ``; +} + +// Given pings with region+latency, pick the region with the lowest avg latency +// and return its sparkline in that region's color. +export function sparklineFromPings(pings: Array<{latency_ms?: number|null, region?: string|null}>, width = 120, height = 32): string { + const COLORS: Record = { + 'eu-central': '#3b82f6', + 'us-east': '#10b981', + 'us-west': '#f59e0b', + 'ap-southeast': '#a78bfa', + }; + + // Group by region + const byRegion: Record = {}; + for (const p of pings) { + if (p.latency_ms == null) continue; + const key = p.region || '__none__'; + if (!byRegion[key]) byRegion[key] = []; + byRegion[key].push(p.latency_ms); + } + + if (!Object.keys(byRegion).length) return ''; + + // Pick region with lowest average latency + let bestRegion = '__none__'; + let bestAvg = Infinity; + for (const [region, vals] of Object.entries(byRegion)) { + const avg = vals.reduce((a, b) => a + b, 0) / vals.length; + if (avg < bestAvg) { bestAvg = avg; bestRegion = region; } + } + + const color = COLORS[bestRegion] || '#60a5fa'; + return sparkline(byRegion[bestRegion], width, height, color); } diff --git a/apps/web/src/views/home.ejs b/apps/web/src/views/home.ejs index 59dbd12..2266ec9 100644 --- a/apps/web/src/views/home.ejs +++ b/apps/web/src/views/home.ejs @@ -24,7 +24,7 @@ <% } else { %> <% it.monitors.forEach(function(m) { - const latencies = (m.pings || []).filter(p => p.latency_ms != null).map(p => p.latency_ms); + const latencies = (m.pings || []).filter(p => p.latency_ms != null); const avgLatency = latencies.length ? Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length) : null; %>