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 { resolve } from "path";
|
||||||
import { resolveKey } from "./auth";
|
import { resolveKey } from "./auth";
|
||||||
import sql from "../db";
|
import sql from "../db";
|
||||||
import { sparkline } from "../utils/sparkline";
|
import { sparkline, sparklineFromPings } from "../utils/sparkline";
|
||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
|
|
||||||
// Generate a cache-buster hash from the CSS file content at startup
|
// 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>`;
|
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 {
|
function latencyChartSSR(pings: any[]): string {
|
||||||
const data = pings.filter((c: any) => c.latency_ms != null);
|
const data = pings.filter((c: any) => c.latency_ms != null);
|
||||||
if (data.length < 2) {
|
if (data.length < 2) {
|
||||||
return '<div class="h-full flex items-center justify-center text-gray-600 text-sm">Not enough data</div>';
|
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);
|
// Group by region
|
||||||
const max = Math.max(...values, 1);
|
const byRegion: Record<string, any[]> = {};
|
||||||
const min = Math.min(...values, 0);
|
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 range = max - min || 1;
|
||||||
const w = 800;
|
const w = 800;
|
||||||
const h = 128;
|
const h = 128;
|
||||||
const step = w / Math.max(values.length - 1, 1);
|
|
||||||
const points = values.map((v: number, i: number) => {
|
// Find the overall time range to align lines on a shared x-axis
|
||||||
const x = i * step;
|
const allTimes = data.map((c: any) => new Date(c.checked_at).getTime());
|
||||||
const y = h - ((v - min) / range) * (h - 16) - 8;
|
const tMin = Math.min(...allTimes);
|
||||||
return [x, y];
|
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`;
|
if (points.length < 1) continue;
|
||||||
const dots = points.map((p: number[], i: number) =>
|
|
||||||
!ups[i] ? `<circle cx="${p[0]}" cy="${p[1]}" r="3" fill="#f87171"/>` : ''
|
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('');
|
).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">
|
return `<div class="relative w-full h-full">
|
||||||
<svg viewBox="0 0 ${w} ${h}" class="w-full h-full" preserveAspectRatio="none">
|
<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>
|
${paths}${dots}
|
||||||
<path d="${areaD}" fill="url(#areaGrad)"/>
|
|
||||||
<path d="${pathD}" fill="none" stroke="#3b82f6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
${dots}
|
|
||||||
</svg>
|
</svg>
|
||||||
<span class="absolute top-0 left-1 text-gray-500 text-xs leading-none pointer-events-none">${max}ms</span>
|
<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>
|
<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>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,7 +177,7 @@ export const dashboard = new Elysia()
|
||||||
if (monitorIds.length > 0) {
|
if (monitorIds.length > 0) {
|
||||||
const allPings = await sql`
|
const allPings = await sql`
|
||||||
SELECT * FROM (
|
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})
|
FROM pings p WHERE p.monitor_id = ANY(${monitorIds})
|
||||||
) sub WHERE rn <= 20 ORDER BY monitor_id, checked_at ASC
|
) 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 });
|
if (!monitor) return new Response("Not found", { status: 404 });
|
||||||
|
|
||||||
const pings = await sql`
|
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
|
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(pings.slice().reverse()), {
|
||||||
return new Response(sparklineSSR(latencies), {
|
|
||||||
headers: { "content-type": "text/html; charset=utf-8" },
|
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 '';
|
if (!values.length) return '';
|
||||||
const max = Math.max(...values, 1);
|
const max = Math.max(...values, 1);
|
||||||
const min = Math.min(...values, 0);
|
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;
|
const y = height - ((v - min) / range) * (height - 4) - 2;
|
||||||
return `${x},${y}`;
|
return `${x},${y}`;
|
||||||
}).join(' ');
|
}).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>
|
</div>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<% it.monitors.forEach(function(m) {
|
<% 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;
|
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">
|
<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