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

332 lines
14 KiB
TypeScript

import { Elysia } from "elysia";
import { Eta } from "eta";
import { resolve } from "path";
import { resolveKey } from "./auth";
import sql from "../db";
import { sparkline, sparklineFromPings } from "../utils/sparkline";
import { createHash } from "crypto";
// Generate a cache-buster hash from the CSS file content at startup
const cssFile = Bun.file(resolve(import.meta.dir, "../dashboard/tailwind.css"));
const cssHash = createHash("md5").update(await cssFile.bytes()).digest("hex").slice(0, 8);
const jsFile = Bun.file(resolve(import.meta.dir, "../dashboard/app.js"));
const jsHash = createHash("md5").update(await jsFile.bytes()).digest("hex").slice(0, 8);
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 = sparklineFromPings;
const REGION_COLORS: Record<string, string> = {
'eu-central': '#3b82f6', // blue
'us-west': '#f59e0b', // amber
'__none__': '#6b7280', // gray for null region
};
const REGION_LABELS: Record<string, string> = {
'eu-central': '🇩🇪 EU',
'us-west': '🇺🇸 US-W',
'__none__': '?',
};
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>';
}
// Group by region
const byRegion: Record<string, any[]> = {};
for (const p of data) {
const key = p.region || '__none__';
if (!byRegion[key]) byRegion[key] = [];
byRegion[key].push(p);
}
const regions = Object.keys(byRegion);
const allValues = data.map((c: any) => c.latency_ms);
const max = Math.max(...allValues, 1);
const min = Math.min(...allValues, 0);
const range = max - min || 1;
const w = 800;
const h = 128;
// Find the overall time range to align lines on a shared x-axis
const allTimes = data.map((c: any) => new Date(c.checked_at).getTime());
const tMin = Math.min(...allTimes);
const tMax = Math.max(...allTimes);
const tRange = tMax - tMin || 1;
let paths = '';
let dots = '';
let legend = '';
for (const region of regions) {
const color = REGION_COLORS[region] || '#6b7280';
const label = REGION_LABELS[region] || region;
const rPings = byRegion[region].slice().sort((a: any, b: any) =>
new Date(a.checked_at).getTime() - new Date(b.checked_at).getTime()
);
const points = rPings.map((p: any) => {
const x = ((new Date(p.checked_at).getTime() - tMin) / tRange) * w;
const y = h - ((p.latency_ms - min) / range) * (h - 16) - 8;
return { x, y, up: p.up };
});
if (points.length < 1) continue;
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
paths += `<path d="${pathD}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`;
dots += points.filter(p => !p.up).map(p =>
`<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="3" fill="#f87171"/>`
).join('');
legend += `<span class="flex items-center gap-1"><span style="background:${color}" class="inline-block w-2 h-2 rounded-full"></span><span>${label}</span></span>`;
}
return `<div class="relative w-full h-full">
<svg viewBox="0 0 ${w} ${h}" class="w-full h-full" preserveAspectRatio="none">
${paths}${dots}
</svg>
<span class="absolute top-0 left-1 text-gray-500 text-xs leading-none pointer-events-none">${max}ms</span>
<span class="absolute bottom-0 left-1 text-gray-500 text-xs leading-none pointer-events-none">${min}ms</span>
${regions.length > 1 ? `<div class="absolute top-0 right-1 flex gap-2 text-xs text-gray-500">${legend}</div>` : ''}
</div>`;
}
function escapeHtmlSSR(str: string): string {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
export function html(template: string, data: Record<string, unknown> = {}) {
return new Response(eta.render(template, { ...data, timeAgoSSR, sparklineSSR, latencyChartSSR, escapeHtmlSSR, cssHash, jsHash }), {
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<{ accountId: string; keyId: string | null; plan: 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;
return await resolveKey(key) ?? null;
}
const dashDir = resolve(import.meta.dir, "../dashboard");
export const dashboard = new Elysia()
.get("/", () => html("landing", {}))
.get("/dashboard/app.js", () => new Response(Bun.file(`${dashDir}/app.js`), { headers: { "cache-control": "public, max-age=31536000, immutable" } }))
.get("/dashboard/app.css", () => new Response(Bun.file(`${dashDir}/app.css`), { headers: { "cache-control": "public, max-age=31536000, immutable" } }))
.get("/dashboard/tailwind.css", () => new Response(Bun.file(`${dashDir}/tailwind.css`), { headers: { "cache-control": "public, max-age=31536000, immutable" } }))
.get("/dashboard/query-builder.js", () => new Response(Bun.file(`${dashDir}/query-builder.js`), { headers: { "cache-control": "public, max-age=31536000, immutable" } }))
// Login page
.get("/dashboard", async ({ cookie }) => {
const key = cookie?.pingql_key?.value;
if (key) {
const resolved = await resolveKey(key);
if (resolved) return redirect("/dashboard/home");
// Invalid/stale key — clear it and show login
cookie.pingql_key?.remove();
}
return html("login", {});
})
// Logout
.get("/dashboard/logout", ({ cookie }) => {
// Explicitly expire with same domain/path so browser actually clears it
cookie.pingql_key?.set({ value: "", maxAge: 0, path: "/", domain: process.env.COOKIE_DOMAIN ?? ".pingql.com", secure: process.env.NODE_ENV !== "development", sameSite: "lax" });
return redirect("/dashboard");
})
// Home — SSR monitor list
.get("/dashboard/home", async ({ cookie, headers }) => {
const resolved = await getAccountId(cookie, headers);
const accountId = resolved?.accountId ?? null;
const keyId = resolved?.keyId ?? null;
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, COALESCE(p.region,'__none__') 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 resolved = await getAccountId(cookie, headers);
const accountId = resolved?.accountId ?? null;
const keyId = resolved?.keyId ?? null;
if (!accountId) return redirect("/dashboard");
const [acc] = await sql`SELECT id, email_hash, plan, plan_expires_at, created_at FROM accounts WHERE id = ${accountId}`;
const isSubKey = !!keyId;
const apiKeys = isSubKey ? [] : await sql`SELECT id, key, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`;
const loginKey = isSubKey ? null : (cookie?.pingql_key?.value ?? null);
const [{ count: monitorCount }] = await sql`SELECT COUNT(*)::int as count FROM monitors WHERE account_id = ${accountId}`;
// Fetch paid + active (non-expired) invoices
let invoices: any[] = [];
try {
invoices = await sql`
SELECT id, plan, months, amount_usd, coin, amount_crypto, status, created_at, paid_at, expires_at, txid
FROM payments
WHERE account_id = ${accountId}
AND (status = 'paid' OR (status IN ('pending', 'confirming') AND expires_at >= now()))
ORDER BY created_at DESC
LIMIT 20
`;
} catch {}
return html("settings", { nav: "settings", account: acc, apiKeys, accountId, loginKey, isSubKey, monitorCount, invoices });
})
// Checkout — upgrade plan
.get("/dashboard/checkout", async ({ cookie, headers }) => {
const resolved = await getAccountId(cookie, headers);
if (!resolved?.accountId) return redirect("/dashboard");
const [acc] = await sql`SELECT plan, plan_expires_at FROM accounts WHERE id = ${resolved.accountId}`;
if (acc.plan === "lifetime") return redirect("/dashboard/settings");
return html("checkout", { nav: "settings", account: acc, payApi: process.env.PAY_API || "https://pay.pingql.com", invoiceId: null });
})
// Existing invoice by ID — survives refreshes
.get("/dashboard/checkout/:id", async ({ cookie, headers, params }) => {
const resolved = await getAccountId(cookie, headers);
if (!resolved?.accountId) return redirect("/dashboard");
const [acc] = await sql`SELECT plan, plan_expires_at FROM accounts WHERE id = ${resolved.accountId}`;
return html("checkout", { nav: "settings", account: acc, payApi: process.env.PAY_API || "https://pay.pingql.com", invoiceId: params.id });
})
// New monitor
.get("/dashboard/monitors/new", async ({ cookie, headers }) => {
const resolved = await getAccountId(cookie, headers);
const accountId = resolved?.accountId ?? null;
const keyId = resolved?.keyId ?? null;
if (!accountId) return redirect("/dashboard");
return html("new", { nav: "monitors", plan: resolved?.plan || "free" });
})
// Home data endpoint for polling (monitor list change detection)
.get("/dashboard/home/data", async ({ cookie, headers }) => {
const resolved = await getAccountId(cookie, headers);
const accountId = resolved?.accountId ?? null;
const keyId = resolved?.keyId ?? null;
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 resolved = await getAccountId(cookie, headers);
const accountId = resolved?.accountId ?? null;
const keyId = resolved?.keyId ?? null;
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, plan: resolved?.plan || "free" });
})
// Chart partial endpoint — returns just the latency chart SVG
.get("/dashboard/monitors/:id/chart", async ({ cookie, headers, params }) => {
const resolved = await getAccountId(cookie, headers);
const accountId = resolved?.accountId ?? null;
const keyId = resolved?.keyId ?? null;
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 resolved = await getAccountId(cookie, headers);
const accountId = resolved?.accountId ?? null;
const keyId = resolved?.keyId ?? null;
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, region FROM pings
WHERE monitor_id = ${params.id} AND latency_ms IS NOT NULL
ORDER BY checked_at DESC LIMIT 40
`;
return new Response(sparklineSSR(pings.slice().reverse()), {
headers: { "content-type": "text/html; charset=utf-8" },
});
})
// Docs
.get("/docs", () => html("docs", {}))
.get("/privacy", () => html("privacy", {}))
.get("/terms", () => html("tos", {}));