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
+71 -26
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" },
});
})