936 lines
41 KiB
TypeScript
936 lines
41 KiB
TypeScript
import { Elysia } from "elysia";
|
|
import { Eta } from "eta";
|
|
import { resolve } from "path";
|
|
import { resolveKey } from "./auth";
|
|
import sql from "../db";
|
|
import { sparklineFromPings, pickBestRegion } from "../utils/sparkline";
|
|
import { PLAN_LABELS, REGION_COLORS, REGION_LABELS, REGIONS } from "../../../shared/plans";
|
|
|
|
async function hashFile(path: string): Promise<string> {
|
|
const bytes = await Bun.file(path).bytes();
|
|
return Bun.hash(bytes).toString(36);
|
|
}
|
|
|
|
const dashDir = resolve(import.meta.dir, "../dashboard");
|
|
const assetHash = {
|
|
css: await hashFile(`${dashDir}/tailwind.css`),
|
|
appCss: await hashFile(`${dashDir}/app.css`),
|
|
js: await hashFile(`${dashDir}/app.js`),
|
|
qb: await hashFile(`${dashDir}/query-builder.js`),
|
|
};
|
|
|
|
const eta = new Eta({ views: resolve(import.meta.dir, "../views"), cache: true, defaultExtension: ".ejs", rmWhitespace: true });
|
|
|
|
function timeAgoSSR(date: string | Date): string {
|
|
const ts = new Date(date).getTime();
|
|
const s = Math.max(0, 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;
|
|
|
|
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 w = 800, h = 128;
|
|
const pad = { top: 8, bottom: 8 };
|
|
const cH = h - pad.top - pad.bottom;
|
|
|
|
const runTimes: Record<string, number[]> = {};
|
|
for (const p of data) {
|
|
const rid = p.run_id || p.checked_at;
|
|
if (!runTimes[rid]) runTimes[rid] = [];
|
|
runTimes[rid].push(new Date(p.checked_at).getTime());
|
|
}
|
|
const runs = Object.keys(runTimes).sort((a, b) => {
|
|
const avgA = runTimes[a].reduce((x: number, y: number) => x + y, 0) / runTimes[a].length;
|
|
const avgB = runTimes[b].reduce((x: number, y: number) => x + y, 0) / runTimes[b].length;
|
|
return avgA - avgB;
|
|
});
|
|
const runIndex: Record<string, number> = {};
|
|
runs.forEach((rid, i) => { runIndex[rid] = i; });
|
|
const maxIdx = Math.max(runs.length - 1, 1);
|
|
|
|
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 yMax = Math.max(...allValues, 1);
|
|
const yMin = Math.min(...allValues, 0);
|
|
const yRange = yMax - yMin || 1;
|
|
|
|
function toX(p: any): number {
|
|
const idx = runIndex[p.run_id || p.checked_at] || 0;
|
|
return (idx / maxIdx) * w;
|
|
}
|
|
function toY(v: number): number {
|
|
return pad.top + cH - ((v - yMin) / yRange) * cH;
|
|
}
|
|
|
|
let grid = '';
|
|
for (let i = 0; i <= 4; i++) {
|
|
const y = (pad.top + (cH / 4) * i).toFixed(1);
|
|
grid += `<line x1="0" y1="${y}" x2="${w}" y2="${y}" stroke="rgba(75,85,99,0.3)" stroke-width="0.5"/>`;
|
|
}
|
|
|
|
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) => toX(a) - toX(b));
|
|
|
|
const pts = rPings.map((p: any) => ({ x: toX(p), y: toY(p.latency_ms), up: p.up }));
|
|
if (pts.length < 2) continue;
|
|
|
|
// Catmull-Rom spline (tension 0.15, matches canvas)
|
|
const t = 0.15;
|
|
let d = `M${pts[0].x.toFixed(1)},${pts[0].y.toFixed(1)}`;
|
|
for (let i = 0; i < pts.length - 1; i++) {
|
|
const p0 = pts[Math.max(i - 1, 0)];
|
|
const p1 = pts[i];
|
|
const p2 = pts[i + 1];
|
|
const p3 = pts[Math.min(i + 2, pts.length - 1)];
|
|
const cp1x = p1.x + (p2.x - p0.x) * t;
|
|
const cp1y = p1.y + (p2.y - p0.y) * t;
|
|
const cp2x = p2.x - (p3.x - p1.x) * t;
|
|
const cp2y = p2.y - (p3.y - p1.y) * t;
|
|
d += ` C${cp1x.toFixed(1)},${cp1y.toFixed(1)} ${cp2x.toFixed(1)},${cp2y.toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`;
|
|
}
|
|
paths += `<path d="${d}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`;
|
|
dots += pts.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">
|
|
${grid}${paths}${dots}
|
|
</svg>
|
|
<span class="absolute top-0 left-1 text-gray-500 text-xs leading-none pointer-events-none">${yMax}ms</span>
|
|
<span class="absolute bottom-0 left-1 text-gray-500 text-xs leading-none pointer-events-none">${yMin}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,'"');
|
|
}
|
|
|
|
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" } },
|
|
);
|
|
}
|
|
|
|
export function html(template: string, data: Record<string, unknown> = {}) {
|
|
return new Response(eta.render(template, {
|
|
...data, timeAgoSSR, sparklineSSR, pickBestRegion, latencyChartSSR, escapeHtmlSSR, h: assetHash,
|
|
regionColors: REGION_COLORS, regionLabels: REGION_LABELS, regions: REGIONS, planLabels: PLAN_LABELS,
|
|
}), {
|
|
headers: { "content-type": "text/html; charset=utf-8" },
|
|
});
|
|
}
|
|
|
|
async function getAccountId(cookie: any, headers: any): Promise<{ accountId: string; keyId: string | null; plan: string } | null> {
|
|
const key = cookie?.pingql_key?.value || headers["authorization"]?.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
|
|
if (!key) return null;
|
|
return await resolveKey(key) ?? null;
|
|
}
|
|
|
|
// Parse the status page edit form's monitor list. The form posts:
|
|
// monitor_order - full list of monitor IDs in DOM order (every row, not just checked)
|
|
// monitor_ids - only the *checked* IDs, also in DOM order
|
|
// display_name[<id>] - optional per-page name override
|
|
// Bun's body parser surfaces bracket-keyed fields either as nested objects
|
|
// (`b.display_name = { id: value }`) or as flat string keys
|
|
// (`b['display_name[id]'] = value`) depending on parser version, so handle both.
|
|
function pickMap(b: any, prefix: string): Record<string, string> {
|
|
const out: Record<string, string> = {};
|
|
if (b[prefix] && typeof b[prefix] === "object" && !Array.isArray(b[prefix])) {
|
|
for (const [k, v] of Object.entries(b[prefix])) {
|
|
if (typeof v === "string" && v.trim()) out[k] = v.trim();
|
|
}
|
|
} else {
|
|
const re = new RegExp(`^${prefix}\\[(.+)\\]$`);
|
|
for (const k of Object.keys(b)) {
|
|
const m = k.match(re);
|
|
if (m && typeof b[k] === "string" && b[k].trim()) out[m[1]!] = b[k].trim();
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function parseStatusPageForm(b: any): {
|
|
groupsForApi: Array<{ name: string; position: number }>;
|
|
monitorsForApi: Array<{ monitor_id: string; position: number; group_index: number | null; display_name: string | null }>;
|
|
} {
|
|
// Parse groups from form (ordered array of names)
|
|
const groupNames: string[] = Array.isArray(b.group_names) ? b.group_names : (b.group_names ? [b.group_names] : []);
|
|
const groupsForApi = groupNames
|
|
.filter((n: string) => n && n.trim())
|
|
.map((n: string, i: number) => ({ name: n.trim(), position: i }));
|
|
|
|
const order: string[] = Array.isArray(b.monitor_order) ? b.monitor_order : (b.monitor_order ? [b.monitor_order] : []);
|
|
const checked: string[] = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []);
|
|
const checkedSet = new Set(checked);
|
|
const displayNames = pickMap(b, "display_name");
|
|
const monitorGroupMap = pickMap(b, "monitor_group");
|
|
|
|
const seen = new Set<string>();
|
|
const monitorsForApi: Array<{ monitor_id: string; position: number; group_index: number | null; display_name: string | null }> = [];
|
|
|
|
function addMonitor(id: string) {
|
|
if (seen.has(id)) return;
|
|
seen.add(id);
|
|
const gi = monitorGroupMap[id];
|
|
const groupIndex = gi !== undefined && gi !== '' ? Number(gi) : null;
|
|
monitorsForApi.push({
|
|
monitor_id: id,
|
|
position: monitorsForApi.length,
|
|
group_index: (groupIndex !== null && groupIndex >= 0 && groupIndex < groupsForApi.length) ? groupIndex : null,
|
|
display_name: displayNames[id] ?? null,
|
|
});
|
|
}
|
|
|
|
for (const id of order) { if (checkedSet.has(id)) addMonitor(id); }
|
|
for (const id of checked) { addMonitor(id); }
|
|
|
|
return { groupsForApi, monitorsForApi };
|
|
}
|
|
|
|
export const dashboard = new Elysia()
|
|
.get("/", () => html("landing", {}))
|
|
|
|
.get("/favicon.svg", () => new Response(Bun.file(`${dashDir}/favicon.svg`), { headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" } }))
|
|
.get("/assets/tailwind.css", () => new Response(Bun.file(`${dashDir}/tailwind.css`), { headers: { "cache-control": "public, max-age=31536000, immutable" } }))
|
|
.get("/assets/app.css", () => new Response(Bun.file(`${dashDir}/app.css`), { headers: { "cache-control": "public, max-age=31536000, immutable" } }))
|
|
.get("/assets/app.js", () => new Response(Bun.file(`${dashDir}/app.js`), { 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" } }))
|
|
|
|
.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", {});
|
|
})
|
|
|
|
.get("/dashboard/logout", ({ cookie }) => {
|
|
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");
|
|
})
|
|
|
|
.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 || "" });
|
|
})
|
|
|
|
.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 * FROM monitors WHERE account_id = ${accountId} ORDER BY created_at DESC
|
|
`;
|
|
|
|
// One simple indexed query per monitor, run in parallel. Each is an index
|
|
// seek on (monitor_id, checked_at DESC) - microseconds. Trivially scalable
|
|
// and easy to reason about.
|
|
const pingResults = await Promise.all(
|
|
monitors.map((m: any) => sql`
|
|
SELECT id, checked_at, latency_ms, up, region
|
|
FROM pings
|
|
WHERE monitor_id = ${m.id}
|
|
ORDER BY checked_at DESC
|
|
LIMIT 60
|
|
`)
|
|
);
|
|
|
|
const monitorsWithPings = monitors.map((m: any, i: number) => {
|
|
const recent = pingResults[i] as any[]; // DESC order
|
|
return {
|
|
...m,
|
|
last_ping: recent[0] ?? null,
|
|
pings: recent.slice().reverse(), // sparkline expects chronological ASC
|
|
};
|
|
});
|
|
|
|
return html("home", { nav: "monitors", monitors: monitorsWithPings, accountId });
|
|
})
|
|
|
|
.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 isSubKey = !!keyId;
|
|
const loginKey = isSubKey ? null : (cookie?.pingql_key?.value ?? null);
|
|
|
|
// All four reads are independent - fan them out in parallel instead of
|
|
// serializing four round-trips. Each individual query is fast (PK seek or
|
|
// small indexed scan); the win is just halving the wall-clock by not
|
|
// waiting on each one in turn.
|
|
const accountQ = sql`SELECT id, email_hash, plan, plan_expires_at, plan_stack, created_at FROM accounts WHERE id = ${accountId}`;
|
|
const apiKeysQ = isSubKey
|
|
? Promise.resolve([] as any[])
|
|
: sql`SELECT id, key, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`;
|
|
const monitorCountQ = sql`SELECT COUNT(*)::int as count FROM monitors WHERE account_id = ${accountId}`;
|
|
const invoicesQ = 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(() => [] as any[]);
|
|
|
|
const [accountRows, apiKeys, monitorCountRows, invoices] = await Promise.all([
|
|
accountQ, apiKeysQ, monitorCountQ, invoicesQ,
|
|
]);
|
|
const acc = accountRows[0];
|
|
const monitorCount = monitorCountRows[0].count;
|
|
|
|
return html("settings", { nav: "settings", account: acc, apiKeys, accountId, loginKey, isSubKey, monitorCount, invoices });
|
|
})
|
|
|
|
.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, plan_stack FROM accounts WHERE id = ${resolved.accountId}`;
|
|
const stack = typeof acc.plan_stack === "string" ? JSON.parse(acc.plan_stack) : (acc.plan_stack || []);
|
|
const hasLifetime = acc.plan === "lifetime" || stack.some((s: any) => s.plan === "lifetime");
|
|
if (acc.plan === "lifetime" && stack.length === 0) return redirect("/dashboard/settings");
|
|
|
|
const [{ total_spent }] = await sql`SELECT COALESCE(SUM(amount_usd), 0)::numeric as total_spent FROM payments WHERE account_id = ${resolved.accountId} AND status = 'paid'`;
|
|
|
|
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, totalSpent: Number(total_spent), hasLifetime });
|
|
})
|
|
|
|
.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 });
|
|
})
|
|
|
|
.get("/dashboard/checkout/:id/receipt", async ({ cookie, headers, params, set }) => {
|
|
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;
|
|
try {
|
|
const res = await fetch(`${payApi}/checkout/${params.id}/receipt`, {
|
|
headers: { "Authorization": `Bearer ${key}` },
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}));
|
|
set.status = res.status;
|
|
return data.error || "Receipt not available";
|
|
}
|
|
set.headers["content-type"] = "text/html; charset=utf-8";
|
|
return await res.text();
|
|
} catch {
|
|
set.status = 500;
|
|
return "Could not load receipt";
|
|
}
|
|
})
|
|
|
|
.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");
|
|
})
|
|
|
|
.get("/dashboard/monitors/new", async ({ cookie, headers }) => {
|
|
const resolved = await getAccountId(cookie, headers);
|
|
const accountId = resolved?.accountId ?? null;
|
|
if (!accountId) return redirect("/dashboard");
|
|
const channels = await sql`
|
|
SELECT id, name, kind FROM notification_channels
|
|
WHERE account_id = ${accountId} AND enabled = true
|
|
ORDER BY created_at DESC
|
|
`;
|
|
return html("new", { nav: "monitors", plan: resolved?.plan || "free", channels });
|
|
})
|
|
|
|
.get("/dashboard/notifications", async ({ cookie, headers, query }) => {
|
|
const resolved = await getAccountId(cookie, headers);
|
|
if (!resolved?.accountId) return redirect("/dashboard");
|
|
const channels = await sql`
|
|
SELECT id, name, kind, config, enabled, created_at
|
|
FROM notification_channels
|
|
WHERE account_id = ${resolved.accountId}
|
|
ORDER BY created_at DESC
|
|
`;
|
|
const testResult = query.test === "ok" ? { ok: true } : query.test_error ? { ok: false, error: String(query.test_error) } : null;
|
|
return html("notifications", { nav: "notifications", channels, testResult });
|
|
})
|
|
|
|
.post("/dashboard/notifications/new", async ({ cookie, headers, body }) => {
|
|
const resolved = await getAccountId(cookie, headers);
|
|
if (!resolved?.accountId) return redirect("/dashboard");
|
|
const b = body as any;
|
|
const kind = b.kind || "webhook";
|
|
const config: any = {};
|
|
if (kind === "webhook") {
|
|
config.url = (b.url || "").trim();
|
|
if (b.secret) config.secret = b.secret;
|
|
}
|
|
try {
|
|
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
|
const key = cookie?.pingql_key?.value;
|
|
await fetch(`${apiUrl}/notifications/channels/`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
|
|
body: JSON.stringify({ name: (b.name || "").trim(), kind, config }),
|
|
});
|
|
} catch {}
|
|
return redirect("/dashboard/notifications");
|
|
})
|
|
|
|
.post("/dashboard/notifications/:id/delete", async ({ cookie, headers, params }) => {
|
|
const resolved = await getAccountId(cookie, headers);
|
|
if (!resolved?.accountId) return redirect("/dashboard");
|
|
try {
|
|
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
|
const key = cookie?.pingql_key?.value;
|
|
await fetch(`${apiUrl}/notifications/channels/${params.id}`, {
|
|
method: "DELETE",
|
|
headers: { "Authorization": `Bearer ${key}` },
|
|
});
|
|
} catch {}
|
|
return redirect("/dashboard/notifications");
|
|
})
|
|
|
|
.post("/dashboard/notifications/:id/test", async ({ cookie, headers, params }) => {
|
|
const resolved = await getAccountId(cookie, headers);
|
|
if (!resolved?.accountId) return redirect("/dashboard");
|
|
try {
|
|
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
|
const key = cookie?.pingql_key?.value;
|
|
const res = await fetch(`${apiUrl}/notifications/channels/${params.id}/test`, {
|
|
method: "POST",
|
|
headers: { "Authorization": `Bearer ${key}` },
|
|
});
|
|
if (res.ok) return redirect("/dashboard/notifications?test=ok");
|
|
const data: any = await res.json().catch(() => ({}));
|
|
return redirect(`/dashboard/notifications?test_error=${encodeURIComponent(data.error || res.statusText)}`);
|
|
} catch (e: any) {
|
|
return redirect(`/dashboard/notifications?test_error=${encodeURIComponent(e?.message || "request failed")}`);
|
|
}
|
|
})
|
|
|
|
.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" },
|
|
});
|
|
})
|
|
|
|
.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 200
|
|
`;
|
|
const channels = await sql`
|
|
SELECT id, name, kind FROM notification_channels
|
|
WHERE account_id = ${accountId} AND enabled = true
|
|
ORDER BY created_at DESC
|
|
`;
|
|
// channel_ids is already on the monitor row as a UUID[] column
|
|
|
|
return html("detail", { nav: "monitors", monitor, pings, plan: resolved?.plan || "free", channels });
|
|
})
|
|
|
|
.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 200
|
|
`;
|
|
const chartPings = pings.slice().reverse();
|
|
return new Response(latencyChartSSR(chartPings), {
|
|
headers: { "content-type": "text/html; charset=utf-8" },
|
|
});
|
|
})
|
|
|
|
.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" },
|
|
});
|
|
})
|
|
|
|
.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> = {};
|
|
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,
|
|
max_retries: Number(b.max_retries) || 0,
|
|
retry_interval_s: Number(b.retry_interval_s) || undefined,
|
|
resend_interval: Number(b.resend_interval) || 0,
|
|
cert_alert_days: b.cert_alert_days != null ? Number(b.cert_alert_days) : 0,
|
|
max_redirects: b.max_redirects != null ? Number(b.max_redirects) : 1,
|
|
channel_ids: Array.isArray(b.channel_ids) ? b.channel_ids : (b.channel_ids ? [b.channel_ids] : []),
|
|
regions,
|
|
request_headers: Object.keys(requestHeaders).length ? requestHeaders : null,
|
|
request_body: b.request_body || null,
|
|
query,
|
|
}),
|
|
});
|
|
} catch {}
|
|
|
|
return redirect("/dashboard/home");
|
|
})
|
|
|
|
.post("/dashboard/monitors/:id/edit", async ({ cookie, headers, params, body }) => {
|
|
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> = {};
|
|
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/${params.id}`, {
|
|
method: "PATCH",
|
|
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,
|
|
max_retries: Number(b.max_retries) || 0,
|
|
retry_interval_s: Number(b.retry_interval_s) || undefined,
|
|
resend_interval: Number(b.resend_interval) || 0,
|
|
cert_alert_days: b.cert_alert_days != null ? Number(b.cert_alert_days) : 0,
|
|
max_redirects: b.max_redirects != null ? Number(b.max_redirects) : 1,
|
|
channel_ids: Array.isArray(b.channel_ids) ? b.channel_ids : (b.channel_ids ? [b.channel_ids] : []),
|
|
regions,
|
|
request_headers: Object.keys(requestHeaders).length ? requestHeaders : null,
|
|
request_body: b.request_body || null,
|
|
query,
|
|
}),
|
|
});
|
|
} catch {}
|
|
|
|
return redirect(`/dashboard/monitors/${params.id}`);
|
|
})
|
|
|
|
.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");
|
|
})
|
|
|
|
.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}`);
|
|
})
|
|
|
|
// ── Status pages ──────────────────────────────────────────────────
|
|
.get("/dashboard/pages", async ({ cookie, headers }) => {
|
|
const resolved = await getAccountId(cookie, headers);
|
|
if (!resolved?.accountId) return redirect("/dashboard");
|
|
const pages = await sql`
|
|
SELECT id, slug, title, description, theme, custom_domain
|
|
FROM status_pages WHERE account_id = ${resolved.accountId}
|
|
ORDER BY created_at DESC
|
|
`;
|
|
return html("status-pages", { nav: "pages", pages });
|
|
})
|
|
|
|
.get("/dashboard/pages/new", async ({ cookie, headers }) => {
|
|
const resolved = await getAccountId(cookie, headers);
|
|
if (!resolved?.accountId) return redirect("/dashboard");
|
|
const allMonitors = await sql`
|
|
SELECT id, name FROM monitors WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC
|
|
`;
|
|
return html("status-page-edit", { nav: "pages", isNew: true, page: null, allMonitors });
|
|
})
|
|
|
|
.get("/dashboard/pages/:id", async ({ cookie, headers, params }) => {
|
|
const resolved = await getAccountId(cookie, headers);
|
|
if (!resolved?.accountId) return redirect("/dashboard");
|
|
const [page] = await sql`
|
|
SELECT * FROM status_pages WHERE id = ${params.id} AND account_id = ${resolved.accountId}
|
|
`;
|
|
if (!page) return redirect("/dashboard/pages");
|
|
const [monitors, groups, allMonitors] = await Promise.all([
|
|
sql`SELECT monitor_id, display_name, group_id FROM status_page_monitors WHERE status_page_id = ${params.id} ORDER BY position ASC`,
|
|
sql`SELECT id, name, position FROM status_page_groups WHERE status_page_id = ${params.id} ORDER BY position ASC`,
|
|
sql`SELECT id, name FROM monitors WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC`,
|
|
]);
|
|
page.monitors = monitors;
|
|
page.groups = groups;
|
|
return html("status-page-edit", { nav: "pages", isNew: false, page, allMonitors });
|
|
})
|
|
|
|
.post("/dashboard/pages/new", async ({ cookie, headers, body }) => {
|
|
const resolved = await getAccountId(cookie, headers);
|
|
if (!resolved?.accountId) return redirect("/dashboard");
|
|
const b = body as any;
|
|
const { groupsForApi, monitorsForApi } = parseStatusPageForm(b);
|
|
try {
|
|
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
|
const key = cookie?.pingql_key?.value;
|
|
await fetch(`${apiUrl}/pages/`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
|
|
body: JSON.stringify({
|
|
slug: (b.slug || "").trim(),
|
|
title: b.title,
|
|
description: b.description || null,
|
|
theme: b.theme || "auto",
|
|
|
|
|
|
bar_frequency: b.bar_frequency || "daily",
|
|
bar_count: Number(b.bar_count) || 90,
|
|
show_response_time: !!b.show_response_time,
|
|
|
|
show_powered_by: !!b.show_powered_by,
|
|
index_search: !!b.index_search,
|
|
auto_refresh_s: Number(b.auto_refresh_s) || 60,
|
|
|
|
password: b.password || undefined,
|
|
custom_domain: b.custom_domain || null,
|
|
custom_css: b.custom_css || null,
|
|
footer_text: b.footer_text || null,
|
|
groups: groupsForApi,
|
|
monitors: monitorsForApi,
|
|
}),
|
|
});
|
|
} catch {}
|
|
return redirect("/dashboard/pages");
|
|
})
|
|
|
|
.post("/dashboard/pages/:id/edit", async ({ cookie, headers, params, body }) => {
|
|
const resolved = await getAccountId(cookie, headers);
|
|
if (!resolved?.accountId) return redirect("/dashboard");
|
|
const b = body as any;
|
|
const { groupsForApi, monitorsForApi } = parseStatusPageForm(b);
|
|
try {
|
|
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
|
const key = cookie?.pingql_key?.value;
|
|
const payload: any = {
|
|
slug: (b.slug || "").trim(),
|
|
title: b.title,
|
|
description: b.description || null,
|
|
theme: b.theme || "auto",
|
|
bar_frequency: b.bar_frequency || "daily",
|
|
bar_count: Number(b.bar_count) || 90,
|
|
show_response_time: !!b.show_response_time,
|
|
show_powered_by: !!b.show_powered_by,
|
|
index_search: !!b.index_search,
|
|
auto_refresh_s: Number(b.auto_refresh_s) || 60,
|
|
custom_domain: b.custom_domain || null,
|
|
custom_css: b.custom_css || null,
|
|
footer_text: b.footer_text || null,
|
|
groups: groupsForApi,
|
|
monitors: monitorsForApi,
|
|
};
|
|
// Three cases for the password field:
|
|
// 1. user clicked Remove → hidden `remove_password=1` is set, send null
|
|
// (the API treats `password === null` as "clear the password_hash")
|
|
// 2. user typed a new password → send the string (API hashes + replaces)
|
|
// 3. neither → omit the field entirely so the API leaves password_hash
|
|
// alone. Empty string falls through to this branch on purpose.
|
|
if (b.remove_password) payload.password = null;
|
|
else if (b.password) payload.password = b.password;
|
|
await fetch(`${apiUrl}/pages/${params.id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
} catch {}
|
|
return redirect("/dashboard/pages");
|
|
})
|
|
|
|
.post("/dashboard/pages/:id/delete", async ({ cookie, headers, params }) => {
|
|
const resolved = await getAccountId(cookie, headers);
|
|
if (!resolved?.accountId) return redirect("/dashboard");
|
|
try {
|
|
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
|
const key = cookie?.pingql_key?.value;
|
|
await fetch(`${apiUrl}/pages/${params.id}`, {
|
|
method: "DELETE",
|
|
headers: { "Authorization": `Bearer ${key}` },
|
|
});
|
|
} catch {}
|
|
return redirect("/dashboard/pages");
|
|
})
|
|
|
|
// ── Incidents ─────────────────────────────────────────────────────
|
|
.get("/dashboard/incidents", async ({ cookie, headers }) => {
|
|
const resolved = await getAccountId(cookie, headers);
|
|
if (!resolved?.accountId) return redirect("/dashboard");
|
|
const incidents = await sql`
|
|
SELECT id, title, status, severity, pinned, started_at, resolved_at
|
|
FROM incidents WHERE account_id = ${resolved.accountId}
|
|
ORDER BY started_at DESC LIMIT 200
|
|
`;
|
|
return html("incidents", { nav: "incidents", incidents });
|
|
})
|
|
|
|
.get("/dashboard/incidents/new", async ({ cookie, headers }) => {
|
|
const resolved = await getAccountId(cookie, headers);
|
|
if (!resolved?.accountId) return redirect("/dashboard");
|
|
const allMonitors = await sql`SELECT id, name FROM monitors WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC`;
|
|
const allPages = await sql`SELECT id, title FROM status_pages WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC`;
|
|
return html("incident-edit", { nav: "incidents", isNew: true, incident: null, allMonitors, allPages });
|
|
})
|
|
|
|
.get("/dashboard/incidents/:id", async ({ cookie, headers, params }) => {
|
|
const resolved = await getAccountId(cookie, headers);
|
|
if (!resolved?.accountId) return redirect("/dashboard");
|
|
const [incident] = await sql`
|
|
SELECT * FROM incidents WHERE id = ${params.id} AND account_id = ${resolved.accountId}
|
|
`;
|
|
if (!incident) return redirect("/dashboard/incidents");
|
|
const updates = await sql`SELECT * FROM incident_updates WHERE incident_id = ${params.id} ORDER BY created_at ASC`;
|
|
const monitors = await sql`SELECT monitor_id FROM incident_monitors WHERE incident_id = ${params.id}`;
|
|
const pages = await sql`SELECT status_page_id FROM incident_status_pages WHERE incident_id = ${params.id}`;
|
|
incident.updates = updates;
|
|
incident.monitor_ids = monitors.map((m: any) => m.monitor_id);
|
|
incident.status_page_ids = pages.map((p: any) => p.status_page_id);
|
|
const allMonitors = await sql`SELECT id, name FROM monitors WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC`;
|
|
const allPages = await sql`SELECT id, title FROM status_pages WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC`;
|
|
return html("incident-edit", { nav: "incidents", isNew: false, incident, allMonitors, allPages });
|
|
})
|
|
|
|
.post("/dashboard/incidents/new", async ({ cookie, headers, body }) => {
|
|
const resolved = await getAccountId(cookie, headers);
|
|
if (!resolved?.accountId) return redirect("/dashboard");
|
|
const b = body as any;
|
|
const monitorIds = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []);
|
|
const pageIds = Array.isArray(b.status_page_ids) ? b.status_page_ids : (b.status_page_ids ? [b.status_page_ids] : []);
|
|
try {
|
|
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
|
const key = cookie?.pingql_key?.value;
|
|
await fetch(`${apiUrl}/incidents/`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
|
|
body: JSON.stringify({
|
|
title: b.title,
|
|
status: b.status || "investigating",
|
|
severity: b.severity || "minor",
|
|
pinned: !!b.pinned,
|
|
monitor_ids: monitorIds,
|
|
status_page_ids: pageIds,
|
|
initial_update: { body: b.initial_update_body || "Investigating." },
|
|
}),
|
|
});
|
|
} catch {}
|
|
return redirect("/dashboard/incidents");
|
|
})
|
|
|
|
.post("/dashboard/incidents/:id/edit", async ({ cookie, headers, params, body }) => {
|
|
const resolved = await getAccountId(cookie, headers);
|
|
if (!resolved?.accountId) return redirect("/dashboard");
|
|
const b = body as any;
|
|
const monitorIds = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []);
|
|
const pageIds = Array.isArray(b.status_page_ids) ? b.status_page_ids : (b.status_page_ids ? [b.status_page_ids] : []);
|
|
try {
|
|
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
|
const key = cookie?.pingql_key?.value;
|
|
await fetch(`${apiUrl}/incidents/${params.id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
|
|
body: JSON.stringify({
|
|
title: b.title,
|
|
status: b.status,
|
|
severity: b.severity,
|
|
pinned: !!b.pinned,
|
|
monitor_ids: monitorIds,
|
|
status_page_ids: pageIds,
|
|
}),
|
|
});
|
|
} catch {}
|
|
return redirect(`/dashboard/incidents/${params.id}`);
|
|
})
|
|
|
|
.post("/dashboard/incidents/:id/update", async ({ cookie, headers, params, body }) => {
|
|
const resolved = await getAccountId(cookie, headers);
|
|
if (!resolved?.accountId) return redirect("/dashboard");
|
|
const b = body as any;
|
|
try {
|
|
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
|
const key = cookie?.pingql_key?.value;
|
|
await fetch(`${apiUrl}/incidents/${params.id}/updates`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
|
|
body: JSON.stringify({ status: b.status, body: b.body }),
|
|
});
|
|
} catch {}
|
|
return redirect(`/dashboard/incidents/${params.id}`);
|
|
})
|
|
|
|
.post("/dashboard/incidents/:id/delete", async ({ cookie, headers, params }) => {
|
|
const resolved = await getAccountId(cookie, headers);
|
|
if (!resolved?.accountId) return redirect("/dashboard");
|
|
try {
|
|
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
|
const key = cookie?.pingql_key?.value;
|
|
await fetch(`${apiUrl}/incidents/${params.id}`, {
|
|
method: "DELETE",
|
|
headers: { "Authorization": `Bearer ${key}` },
|
|
});
|
|
} catch {}
|
|
return redirect("/dashboard/incidents");
|
|
})
|
|
|
|
.get("/docs", () => html("docs", {}))
|
|
.get("/privacy", () => html("privacy", {}))
|
|
.get("/terms", () => html("tos", {}));
|