feat: per-region chart lines and lowest-avg sparkline

This commit is contained in:
M1 2026-03-18 16:25:47 +04:00
parent e1bb39431d
commit 07648672ad
3 changed files with 107 additions and 29 deletions

View File

@ -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 `<span class="timestamp" data-ts="${ts}">${text}</span>`;
}
const sparklineSSR = sparkline;
const sparklineSSR = sparklineFromPings;
const REGION_COLORS: Record<string, string> = {
'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<string, string> = {
'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 '<div class="h-full flex items-center justify-center text-gray-600 text-sm">Not enough data</div>';
}
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<string, any[]> = {};
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] ? `<circle cx="${p[0]}" cy="${p[1]}" r="3" fill="#f87171"/>` : ''
).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 += `<path d="${pathD}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`;
dots += points.filter(p => !p.up).map(p =>
`<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="3" fill="#f87171"/>`
).join('');
legend += `<span class="flex items-center gap-1"><span style="background:${color}" class="inline-block w-2 h-2 rounded-full"></span><span>${label}</span></span>`;
}
return `<div class="relative w-full h-full">
<svg viewBox="0 0 ${w} ${h}" class="w-full h-full" preserveAspectRatio="none">
<defs><linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#3b82f6" stop-opacity="0.15"/><stop offset="100%" stop-color="#3b82f6" stop-opacity="0"/></linearGradient></defs>
<path d="${areaD}" fill="url(#areaGrad)"/>
<path d="${pathD}" fill="none" stroke="#3b82f6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
${dots}
${paths}${dots}
</svg>
<span class="absolute top-0 left-1 text-gray-500 text-xs leading-none pointer-events-none">${max}ms</span>
<span class="absolute bottom-0 left-1 text-gray-500 text-xs leading-none pointer-events-none">${min}ms</span>
${regions.length > 1 ? `<div class="absolute top-0 right-1 flex gap-2 text-xs text-gray-500">${legend}</div>` : ''}
</div>`;
}
@ -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" },
});
})

View File

@ -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 `<svg width="${width}" height="${height}" class="inline-block" data-vals="${values.join(',')}"><polyline points="${points}" fill="none" stroke="#60a5fa" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
return `<svg width="${width}" height="${height}" class="inline-block" data-vals="${values.join(',')}"><polyline points="${points}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
}
// 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<string, string> = {
'eu-central': '#3b82f6',
'us-east': '#10b981',
'us-west': '#f59e0b',
'ap-southeast': '#a78bfa',
};
// Group by region
const byRegion: Record<string, number[]> = {};
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);
}

View File

@ -24,7 +24,7 @@
</div>
<% } 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;
%>
<a href="/dashboard/monitors/<%= m.id %>" data-monitor-id="<%= m.id %>" class="block bg-gray-900 hover:bg-gray-800/80 border border-gray-800 rounded-xl p-4 transition-colors group">