From 2f7273604b32c078a2b683cfd70092a852e34697 Mon Sep 17 00:00:00 2001 From: M1 Date: Mon, 16 Mar 2026 21:14:45 +0400 Subject: [PATCH] refactor: full SSR dashboard, minimal SSE DOM patches, poll-based refresh --- apps/web/src/dashboard/app.js | 22 --- apps/web/src/routes/dashboard.ts | 105 ++++++++++- apps/web/src/utils/sparkline.ts | 13 ++ apps/web/src/views/detail.ejs | 299 +++++++++++-------------------- apps/web/src/views/home.ejs | 139 ++++---------- apps/web/src/views/settings.ejs | 97 ++++------ 6 files changed, 299 insertions(+), 376 deletions(-) create mode 100644 apps/web/src/utils/sparkline.ts diff --git a/apps/web/src/dashboard/app.js b/apps/web/src/dashboard/app.js index a986452..28e50b9 100644 --- a/apps/web/src/dashboard/app.js +++ b/apps/web/src/dashboard/app.js @@ -52,28 +52,6 @@ setInterval(() => { }); }, 1000); -// Render a tiny sparkline SVG from latency values -function sparkline(values, width = 120, height = 32) { - if (!values.length) return ''; - const max = Math.max(...values, 1); - const min = Math.min(...values, 0); - const range = max - min || 1; - const step = width / Math.max(values.length - 1, 1); - const points = values.map((v, i) => { - const x = i * step; - const y = height - ((v - min) / range) * (height - 4) - 2; - return `${x},${y}`; - }).join(' '); - return ``; -} - -// Status badge -function statusBadge(up) { - if (up === true) return ''; - if (up === false) return ''; - return ''; -} - function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; diff --git a/apps/web/src/routes/dashboard.ts b/apps/web/src/routes/dashboard.ts index 6d244f1..a6e4123 100644 --- a/apps/web/src/routes/dashboard.ts +++ b/apps/web/src/routes/dashboard.ts @@ -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 `${text}`; +} + +const sparklineSSR = sparkline; + +function latencyChartSSR(pings: any[]): string { + const data = pings.filter((c: any) => c.latency_ms != null); + if (data.length < 2) { + return '
Not enough data
'; + } + 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] ? `` : '' + ).join(''); + return ` + + + + ${dots} + ${max}ms + ${min}ms + `; +} + +function escapeHtmlSSR(str: string): string { + return str.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + function html(template: string, data: Record = {}) { - 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 = {}; + 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`)); diff --git a/apps/web/src/utils/sparkline.ts b/apps/web/src/utils/sparkline.ts new file mode 100644 index 0000000..c5d4cb5 --- /dev/null +++ b/apps/web/src/utils/sparkline.ts @@ -0,0 +1,13 @@ +export function sparkline(values: number[], width = 120, height = 32): string { + if (!values.length) return ''; + const max = Math.max(...values, 1); + const min = Math.min(...values, 0); + const range = max - min || 1; + const step = width / Math.max(values.length - 1, 1); + const points = values.map((v, i) => { + const x = i * step; + const y = height - ((v - min) / range) * (height - 4) - 2; + return `${x},${y}`; + }).join(' '); + return ``; +} diff --git a/apps/web/src/views/detail.ejs b/apps/web/src/views/detail.ejs index c551c5f..1763f8a 100644 --- a/apps/web/src/views/detail.ejs +++ b/apps/web/src/views/detail.ejs @@ -1,24 +1,35 @@ <%~ include('./partials/head', { title: 'Monitor', scripts: ['/dashboard/query-builder.js'] }) %> <%~ include('./partials/nav', { nav: 'monitors' }) %> +<% + const m = it.monitor; + const pings = it.pings || []; + const lastPing = pings[0]; + const upPings = pings.filter(p => p.up); + const latencies = pings.filter(p => p.latency_ms != null).map(p => p.latency_ms); + const avgLatency = latencies.length ? Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length) : null; + const uptime = pings.length ? Math.round((upPings.length / pings.length) * 100) : null; + const barPings = pings.slice(0, 60).reverse(); + const chartPings = pings.slice().reverse(); +%> +
-
Loading...
- @@ -83,7 +112,7 @@
-
@@ -92,10 +121,11 @@
-
@@ -105,35 +135,39 @@ -
+
+ <% if (m.request_headers && typeof m.request_headers === 'object') { + Object.entries(m.request_headers).forEach(function([k, v]) { %> +
+ + + +
+ <% }) } %> +
-
<%~ include('./partials/foot') %>