224 lines
8.8 KiB
TypeScript
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
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`));
|