pingql/apps/web/src/routes/dashboard.ts

224 lines
8.8 KiB
TypeScript

import { Elysia } from "elysia";
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, timeAgoSSR, sparklineSSR, latencyChartSSR, escapeHtmlSSR }), {
headers: { "content-type": "text/html; charset=utf-8" },
});
}
function redirect(to: string) {
return new Response(null, { status: 302, headers: { Location: to } });
}
async function getAccountId(cookie: any, headers: any): Promise<string | null> {
const authHeader = headers["authorization"] ?? "";
const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
const key = cookie?.pingql_key?.value || bearer;
if (!key) return null;
const resolved = await resolveKey(key);
return resolved?.accountId ?? null;
}
const dashDir = resolve(import.meta.dir, "../dashboard");
export const dashboard = new Elysia()
.get("/dashboard/app.js", () => Bun.file(`${dashDir}/app.js`))
.get("/dashboard/app.css", () => Bun.file(`${dashDir}/app.css`))
.get("/dashboard/query-builder.js", () => Bun.file(`${dashDir}/query-builder.js`))
// Login page
.get("/dashboard", ({ cookie }) => {
if (cookie?.pingql_key?.value) return redirect("/dashboard/home");
return Bun.file(`${dashDir}/index.html`);
})
// Logout
.get("/dashboard/logout", ({ cookie }) => {
cookie.pingql_key?.remove();
return redirect("/dashboard");
})
// Home — SSR monitor list
.get("/dashboard/home", async ({ cookie, headers }) => {
const accountId = await getAccountId(cookie, headers);
if (!accountId) return redirect("/dashboard");
const monitors = await sql`
SELECT m.*, (
SELECT row_to_json(p) FROM pings p
WHERE p.monitor_id = m.id ORDER BY p.checked_at DESC LIMIT 1
) as last_ping
FROM monitors m WHERE m.account_id = ${accountId}
ORDER BY m.created_at DESC
`;
// 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
.get("/dashboard/settings", async ({ cookie, headers }) => {
const accountId = await getAccountId(cookie, headers);
if (!accountId) return redirect("/dashboard");
const [acc] = await sql`SELECT id, email_hash, created_at FROM accounts WHERE id = ${accountId}`;
const apiKeys = await sql`SELECT id, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`;
return html("settings", { nav: "settings", account: acc, apiKeys, accountId });
})
// New monitor
.get("/dashboard/monitors/new", async ({ cookie, headers }) => {
const accountId = await getAccountId(cookie, headers);
if (!accountId) return redirect("/dashboard");
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);
if (!accountId) return redirect("/dashboard");
const [monitor] = await sql`
SELECT * FROM monitors WHERE id = ${params.id} AND account_id = ${accountId}
`;
if (!monitor) return redirect("/dashboard/home");
const pings = await sql`
SELECT * FROM pings WHERE monitor_id = ${params.id}
ORDER BY checked_at DESC LIMIT 100
`;
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" },
});
})
// Sparkline partial — returns just the SVG for one monitor
.get("/dashboard/monitors/:id/sparkline", 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 latency_ms FROM pings
WHERE monitor_id = ${params.id} AND latency_ms IS NOT NULL
ORDER BY checked_at DESC LIMIT 20
`;
const latencies = pings.map((p: any) => p.latency_ms).reverse();
return new Response(sparklineSSR(latencies), {
headers: { "content-type": "text/html; charset=utf-8" },
});
})
// Docs
.get("/docs", () => Bun.file(`${dashDir}/docs.html`));