464 lines
19 KiB
TypeScript
464 lines
19 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
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(
|
|
`<!DOCTYPE html><html lang="en" class="dark"><head><meta charset="UTF-8"><meta http-equiv="refresh" content="0;url=${to}"><meta name="robots" content="noindex"><style>html,body{background:#0a0a0a;margin:0;height:100%}</style></head><body></body></html>`,
|
|
{ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } },
|
|
);
|
|
}
|
|
|
|
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");
|
|
})
|
|
|
|
// Welcome page — shows new account key after registration (no-JS flow)
|
|
.get("/dashboard/welcome", async ({ cookie, headers, query }) => {
|
|
const resolved = await getAccountId(cookie, headers);
|
|
if (!resolved?.accountId) return redirect("/dashboard");
|
|
return html("welcome", { key: query.key || cookie?.pingql_key?.value || "" });
|
|
})
|
|
|
|
// 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', 'underpaid', '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");
|
|
|
|
// Fetch coins server-side for no-JS rendering
|
|
const payApi = process.env.PAY_API || "https://pay.pingql.com";
|
|
let coins: any[] = [];
|
|
try {
|
|
const res = await fetch(`${payApi}/coins`);
|
|
const data = await res.json();
|
|
coins = data.coins || [];
|
|
} catch {}
|
|
|
|
return html("checkout", { nav: "settings", account: acc, payApi, invoiceId: null, coins, invoice: null });
|
|
})
|
|
|
|
// Existing invoice by ID — SSR the payment status
|
|
.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}`;
|
|
|
|
const payApi = process.env.PAY_API || "https://pay.pingql.com";
|
|
const key = cookie?.pingql_key?.value;
|
|
let invoice: any = null;
|
|
let coins: any[] = [];
|
|
try {
|
|
const [invoiceRes, coinsRes] = await Promise.all([
|
|
fetch(`${payApi}/checkout/${params.id}`, { headers: { "Authorization": `Bearer ${key}` } }),
|
|
fetch(`${payApi}/coins`),
|
|
]);
|
|
if (invoiceRes.ok) invoice = await invoiceRes.json();
|
|
const coinsData = await coinsRes.json();
|
|
coins = coinsData.coins || [];
|
|
} catch {}
|
|
|
|
return html("checkout", { nav: "settings", account: acc, payApi, invoiceId: params.id, coins, invoice });
|
|
})
|
|
|
|
// Create checkout via form POST (no-JS)
|
|
.post("/dashboard/checkout", async ({ cookie, headers, body }) => {
|
|
const resolved = await getAccountId(cookie, headers);
|
|
if (!resolved?.accountId) return redirect("/dashboard");
|
|
|
|
const payApi = process.env.PAY_API || "https://pay.pingql.com";
|
|
const key = cookie?.pingql_key?.value;
|
|
const b = body as any;
|
|
|
|
try {
|
|
const res = await fetch(`${payApi}/checkout`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
|
|
body: JSON.stringify({
|
|
plan: b.plan,
|
|
coin: b.coin,
|
|
months: b.months ? Number(b.months) : undefined,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok && data.id) return redirect(`/dashboard/checkout/${data.id}`);
|
|
} catch {}
|
|
|
|
return redirect("/dashboard/checkout");
|
|
})
|
|
|
|
// 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" },
|
|
});
|
|
})
|
|
|
|
// ── Form-based monitor actions (no-JS support) ─────────────────────
|
|
|
|
// Create monitor via form POST
|
|
.post("/dashboard/monitors/new", async ({ cookie, headers, body, set }) => {
|
|
const resolved = await getAccountId(cookie, headers);
|
|
if (!resolved?.accountId) return redirect("/dashboard");
|
|
|
|
const b = body as any;
|
|
const regions = Array.isArray(b.regions) ? b.regions : (b.regions ? [b.regions] : []);
|
|
const query = b.query ? (typeof b.query === "string" ? JSON.parse(b.query) : b.query) : undefined;
|
|
const requestHeaders: Record<string, string> = {};
|
|
// Collect header_key[]/header_value[] pairs
|
|
const hKeys = Array.isArray(b.header_key) ? b.header_key : (b.header_key ? [b.header_key] : []);
|
|
const hVals = Array.isArray(b.header_value) ? b.header_value : (b.header_value ? [b.header_value] : []);
|
|
for (let i = 0; i < hKeys.length; i++) {
|
|
if (hKeys[i]?.trim()) requestHeaders[hKeys[i].trim()] = hVals[i] || "";
|
|
}
|
|
|
|
try {
|
|
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
|
const key = cookie?.pingql_key?.value;
|
|
await fetch(`${apiUrl}/monitors/`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
|
|
body: JSON.stringify({
|
|
name: b.name,
|
|
url: b.url,
|
|
method: b.method || "GET",
|
|
interval_s: Number(b.interval_s) || 30,
|
|
timeout_ms: Number(b.timeout_ms) || 10000,
|
|
regions,
|
|
request_headers: Object.keys(requestHeaders).length ? requestHeaders : null,
|
|
request_body: b.request_body || null,
|
|
query,
|
|
}),
|
|
});
|
|
} catch {}
|
|
|
|
return redirect("/dashboard/home");
|
|
})
|
|
|
|
// Delete monitor via form POST
|
|
.post("/dashboard/monitors/:id/delete", async ({ cookie, headers, params }) => {
|
|
const resolved = await getAccountId(cookie, headers);
|
|
if (!resolved?.accountId) return redirect("/dashboard");
|
|
|
|
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
|
const key = cookie?.pingql_key?.value;
|
|
await fetch(`${apiUrl}/monitors/${params.id}`, {
|
|
method: "DELETE",
|
|
headers: { "Authorization": `Bearer ${key}` },
|
|
});
|
|
|
|
return redirect("/dashboard/home");
|
|
})
|
|
|
|
// Toggle monitor via form POST
|
|
.post("/dashboard/monitors/:id/toggle", async ({ cookie, headers, params }) => {
|
|
const resolved = await getAccountId(cookie, headers);
|
|
if (!resolved?.accountId) return redirect("/dashboard");
|
|
|
|
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
|
const key = cookie?.pingql_key?.value;
|
|
await fetch(`${apiUrl}/monitors/${params.id}/toggle`, {
|
|
method: "POST",
|
|
headers: { "Authorization": `Bearer ${key}` },
|
|
});
|
|
|
|
return redirect(`/dashboard/monitors/${params.id}`);
|
|
})
|
|
|
|
// Docs
|
|
.get("/docs", () => html("docs", {}))
|
|
.get("/privacy", () => html("privacy", {}))
|
|
.get("/terms", () => html("tos", {}));
|