feat: per-region chart lines and lowest-avg sparkline
This commit is contained in:
parent
e1bb39431d
commit
07648672ad
|
|
@ -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];
|
||||
|
||||
// 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 };
|
||||
});
|
||||
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"/>` : ''
|
||||
|
||||
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" },
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue