refactor: full SSR dashboard, minimal SSE DOM patches, poll-based refresh
This commit is contained in:
@@ -3,11 +3,58 @@ import { Eta } from "eta";
|
||||
import { resolve } from "path";
|
||||
import { resolveKey } from "./auth";
|
||||
import sql from "../db";
|
||||
import { sparkline } from "../utils/sparkline";
|
||||
|
||||
const eta = new Eta({ views: resolve(import.meta.dir, "../views"), cache: true, defaultExtension: ".ejs" });
|
||||
|
||||
function timeAgoSSR(date: string | Date): string {
|
||||
const ts = new Date(date).getTime();
|
||||
const s = Math.floor((Date.now() - ts) / 1000);
|
||||
const text = s < 60 ? `${s}s ago` : s < 3600 ? `${Math.floor(s/60)}m ago` : s < 86400 ? `${Math.floor(s/3600)}h ago` : `${Math.floor(s/86400)}d ago`;
|
||||
return `<span class="timestamp" data-ts="${ts}">${text}</span>`;
|
||||
}
|
||||
|
||||
const sparklineSSR = sparkline;
|
||||
|
||||
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);
|
||||
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('');
|
||||
return `<svg viewBox="0 0 ${w} ${h}" class="w-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}
|
||||
<text x="4" y="12" fill="#6b7280" font-size="10">${max}ms</text>
|
||||
<text x="4" y="${h - 2}" fill="#6b7280" font-size="10">${min}ms</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function escapeHtmlSSR(str: string): string {
|
||||
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function html(template: string, data: Record<string, unknown> = {}) {
|
||||
return new Response(eta.render(template, data), {
|
||||
return new Response(eta.render(template, { ...data, timeAgoSSR, sparklineSSR, latencyChartSSR, escapeHtmlSSR }), {
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
@@ -56,7 +103,28 @@ export const dashboard = new Elysia()
|
||||
ORDER BY m.created_at DESC
|
||||
`;
|
||||
|
||||
return html("home", { nav: "monitors", monitors, accountId });
|
||||
// Fetch last 20 pings per monitor for sparklines
|
||||
const monitorIds = monitors.map((m: any) => m.id);
|
||||
let pingsMap: Record<string, any[]> = {};
|
||||
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
|
||||
FROM pings p WHERE p.monitor_id = ANY(${monitorIds})
|
||||
) sub WHERE rn <= 20 ORDER BY monitor_id, checked_at ASC
|
||||
`;
|
||||
for (const p of allPings) {
|
||||
if (!pingsMap[p.monitor_id]) pingsMap[p.monitor_id] = [];
|
||||
pingsMap[p.monitor_id].push(p);
|
||||
}
|
||||
}
|
||||
|
||||
const monitorsWithPings = monitors.map((m: any) => ({
|
||||
...m,
|
||||
pings: pingsMap[m.id] || [],
|
||||
}));
|
||||
|
||||
return html("home", { nav: "monitors", monitors: monitorsWithPings, accountId });
|
||||
})
|
||||
|
||||
// Settings — SSR account info
|
||||
@@ -77,6 +145,19 @@ export const dashboard = new Elysia()
|
||||
return html("new", { nav: "monitors", scripts: ["/dashboard/query-builder.js"] });
|
||||
})
|
||||
|
||||
// Home data endpoint for polling (monitor list change detection)
|
||||
.get("/dashboard/home/data", async ({ cookie, headers }) => {
|
||||
const accountId = await getAccountId(cookie, headers);
|
||||
if (!accountId) return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
||||
|
||||
const monitors = await sql`
|
||||
SELECT id FROM monitors WHERE account_id = ${accountId} ORDER BY created_at DESC
|
||||
`;
|
||||
return new Response(JSON.stringify({ monitorIds: monitors.map((m: any) => m.id) }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
})
|
||||
|
||||
// Monitor detail — SSR with initial data
|
||||
.get("/dashboard/monitors/:id", async ({ cookie, headers, params }) => {
|
||||
const accountId = await getAccountId(cookie, headers);
|
||||
@@ -95,5 +176,25 @@ export const dashboard = new Elysia()
|
||||
return html("detail", { nav: "monitors", monitor, pings, scripts: ["/dashboard/query-builder.js"] });
|
||||
})
|
||||
|
||||
// Chart partial endpoint — returns just the latency chart SVG
|
||||
.get("/dashboard/monitors/:id/chart", async ({ cookie, headers, params }) => {
|
||||
const accountId = await getAccountId(cookie, headers);
|
||||
if (!accountId) return new Response("Unauthorized", { status: 401 });
|
||||
|
||||
const [monitor] = await sql`
|
||||
SELECT id FROM monitors WHERE id = ${params.id} AND account_id = ${accountId}
|
||||
`;
|
||||
if (!monitor) return new Response("Not found", { status: 404 });
|
||||
|
||||
const pings = await sql`
|
||||
SELECT * FROM pings WHERE monitor_id = ${params.id}
|
||||
ORDER BY checked_at DESC LIMIT 100
|
||||
`;
|
||||
const chartPings = pings.slice().reverse();
|
||||
return new Response(latencyChartSSR(chartPings), {
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
})
|
||||
|
||||
// Docs
|
||||
.get("/docs", () => Bun.file(`${dashDir}/docs.html`));
|
||||
|
||||
Reference in New Issue
Block a user