refactor: full SSR dashboard, minimal SSE DOM patches, poll-based refresh

This commit is contained in:
M1
2026-03-16 21:14:45 +04:00
parent 878829111f
commit 2f7273604b
6 changed files with 299 additions and 376 deletions
+103 -2
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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`));