Something went wrong. Try again.
Extremely basic alerting on health endpoints using Cloudflare durable objects
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339import type { Alarm } from "./alerts";import { prettyJson } from "./email";import { REGIONS, REGION_NAMES, type Check, type Region, type RegionSnapshot } from "./monitor";
const ESCAPES: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
export function esc(value: unknown): string { return String(value).replace(/[&<>"']/g, (c) => ESCAPES[c]);}
/** A region's result is stale when its newest check is older than this. */export function staleAfterMs(env: Env): number { return 2 * Number(env.CHECK_INTERVAL_SECONDS) * 1000 + Number(env.TIMEOUT_MS);}
function shortUrl(url: string): string { return url.replace(/^https?:\/\//, "");}
function historyHref(endpoint: string, params: Record<string, string> = {}): string { return "/endpoint?" + new URLSearchParams({ url: endpoint, ...params }).toString();}
function statusLabel(c: Check): string { return c.status === 0 ? "ERR" : String(c.status);}
function time(ts: number): string { const iso = new Date(ts).toISOString(); return `<time datetime="${iso}">${iso.slice(11, 19)} UTC</time>`;}
function body(c: Check): string { const parts: string[] = []; if (c.error) parts.push(`<p class="error">${esc(c.error)}</p>`); const pretty = prettyJson(c.body); if (pretty !== null) parts.push(`<pre>${esc(pretty)}</pre>`); else if (!c.error) parts.push(`<p class="muted">Empty response body</p>`); return parts.join("");}
// --- Homepage ---------------------------------------------------------------
type Cell = { region: Region; check: Check | null; state: "ok" | "fail" | "stale" | "unknown" };
export function homePage( env: Env, endpoints: string[], snapshots: (RegionSnapshot | null)[], alarms: Alarm[] | null, now: number,): string { const staleMs = staleAfterMs(env); const rows = endpoints.map((endpoint) => { const cells: Cell[] = REGIONS.map((region, i) => { const snap = snapshots[i]; const check = snap?.checks.find((c) => c.endpoint === endpoint) ?? null; const state = !check ? "unknown" : now - check.ts > staleMs ? "stale" : check.status === 200 ? "ok" : "fail"; return { region, check, state }; }); return { endpoint, cells, failing: cells.some((c) => c.state === "fail") }; }); // Failing endpoints first, otherwise keep configured order. rows.sort((a, b) => Number(b.failing) - Number(a.failing));
const unreachable = REGIONS.filter((_, i) => snapshots[i] === null); const banner = unreachable.length ? `<p class="banner">Could not reach monitor in: ${unreachable.map(esc).join(", ")}</p>` : "";
return layout( "Monitor", `<header><h1>Monitor</h1><a href="/" class="button">Refresh</a></header> ${banner} ${alarmsSection(alarms, new Set(endpoints))} <h2>Endpoints</h2> <div class="head row cols-endpoint" aria-hidden="true"> <span>Endpoint</span><span>Status</span><span>Checked</span><span>Latency</span><span>Regions</span><span></span> </div> ${rows.map(endpointRow).join("") || `<p class="muted">No endpoints configured.</p>`} <p class="muted legend">Status and latency show the worst result across regions. Expand a row for per-region details.</p>`, );}
function alarmsSection(alarms: Alarm[] | null, configured: Set<string>): string { if (alarms === null) { return `<section id="alarms"><h2>Active alarms</h2><p class="banner">Could not reach the alert coordinator.</p></section>`; } const items = alarms.map((a) => { const silenced = a.silencedAt !== null; const isConfigured = configured.has(a.endpoint); const state = !isConfigured ? "Endpoint no longer configured" : silenced ? `Silenced ${time(a.silencedAt!)} · reminders resume if anything else fails; recovery is still sent` : a.lastAlertTs !== null ? `Last alert ${time(a.lastAlertTs)}` : "Alert not sent yet"; const name = isConfigured ? `<a class="name" href="${esc(historyHref(a.endpoint, { failures: "1" }))}">${esc(shortUrl(a.endpoint))}</a>` : `<span class="name">${esc(shortUrl(a.endpoint))}</span>`; const control = isConfigured ? `<button name="action" value="${silenced ? "unsilence" : "silence"}">${silenced ? "Unsilence" : "Silence"}</button>` : `<button name="action" value="dismiss">Dismiss</button>`; return `<div class="alarm${silenced ? " silenced" : ""}"> <div class="alarm-main"> ${name} <span class="chips">${a.failures.map((f) => `<span class="chip">${esc(f)}</span>`).join("")}</span> <span class="muted">Failing since ${time(a.failingSince)}${a.failingRegions.length ? ` from ${a.failingRegions.map(esc).join(", ")}` : ""}</span> <span class="muted">${state}</span> </div> <form method="post" action="/silence"> <input type="hidden" name="endpoint" value="${esc(a.endpoint)}"> ${control} </form> </div>`; }); return `<section id="alarms"><h2>Active alarms${alarms.length ? ` <span class="count">${alarms.length}</span>` : ""}</h2> ${items.join("") || `<p class="muted">No active alarms.</p>`}</section>`;}
function endpointRow({ endpoint, cells, failing }: { endpoint: string; cells: Cell[]; failing: boolean }): string { const checks = cells.map((c) => c.check).filter((c): c is Check => c !== null); const worst = checks.find((c) => c.status !== 200) ?? checks[0]; const newest = checks.length ? Math.max(...checks.map((c) => c.ts)) : null; const maxLatency = checks.length ? Math.max(...checks.map((c) => c.duration_ms)) : null; const badges = cells .map((c) => `<span class="badge ${c.state}" title="${esc(REGION_NAMES[c.region])}: ${c.state}">${c.region}</span>`) .join("");
const regionRows = cells .map(({ region, check, state }) => check ? `<details class="region"><summary class="row cols-region"> <span class="name toggle">${region}</span> <span class="status ${state}">${statusLabel(check)}</span> <span>${time(check.ts)}</span> <span>${check.duration_ms} ms</span> </summary>${body(check)}</details>` : `<div class="region row cols-region"><span class="name">${region}</span><span class="muted">no data</span></div>`, ) .join("");
return `<details class="endpoint${failing ? " failing" : ""}"> <summary class="row cols-endpoint"> <span class="name toggle">${esc(shortUrl(endpoint))}</span> <span class="status ${failing ? "fail" : worst ? "ok" : "unknown"}">${worst ? statusLabel(worst) : "—"}</span> <span>${newest ? time(newest) : "—"}</span> <span>${maxLatency !== null ? `${maxLatency} ms` : "—"}</span> <span class="badges">${badges}</span> <a href="${esc(historyHref(endpoint))}">History →</a> </summary> <div class="regions">${regionRows}</div> </details>`;}
// --- History page -------------------------------------------------------------
export interface HistoryView { endpoint: string; region: Region | "all"; failuresOnly: boolean; before: number | null; checks: Check[]; nextBefore: number | null; unreachable: Region[];}
export function historyPage(v: HistoryView): string { const filters: Record<string, string> = {}; if (v.region !== "all") filters.region = v.region; if (v.failuresOnly) filters.failures = "1";
const options = ["all", ...REGIONS] .map((r) => `<option value="${r}"${r === v.region ? " selected" : ""}>${r === "all" ? "All regions" : `${r} — ${REGION_NAMES[r as Region]}`}</option>`) .join("");
const rows = v.checks .map((c) => `<details class="check"><summary class="row cols-check"> <span class="toggle">${time(c.ts)}</span> <span class="name">${c.region}</span> <span class="status ${c.status === 200 ? "ok" : "fail"}">${statusLabel(c)}</span> <span>${c.duration_ms} ms</span> </summary>${body(c)}</details>`) .join("");
const banner = v.unreachable.length ? `<p class="banner">Could not reach monitor in: ${v.unreachable.map(esc).join(", ")} — results may be incomplete.</p>` : "";
const pager = [ v.before !== null ? `<a class="button" href="${esc(historyHref(v.endpoint, filters))}">← Newest</a>` : "", v.nextBefore !== null ? `<a class="button" href="${esc(historyHref(v.endpoint, { ...filters, before: String(v.nextBefore) }))}">Older →</a>` : "", ].join("");
return layout( `${shortUrl(v.endpoint)} — Monitor`, `<header><a href="/">← All endpoints</a></header> <h1 class="endpoint-title"><a href="${esc(v.endpoint)}" rel="noreferrer">${esc(shortUrl(v.endpoint))}</a></h1> <form method="get" action="/endpoint" class="filters"> <input type="hidden" name="url" value="${esc(v.endpoint)}"> <select name="region" aria-label="Region" onchange="this.form.submit()">${options}</select> <label><input type="checkbox" name="failures" value="1"${v.failuresOnly ? " checked" : ""} onchange="this.form.submit()"> Non-200 only</label> <noscript><button type="submit">Apply</button></noscript> </form> ${banner} <div class="head row cols-check" aria-hidden="true"><span>Time</span><span>Region</span><span>Status</span><span>Latency</span></div> ${rows || `<p class="muted">No checks match.</p>`} <nav class="pager">${pager}</nav>`, );}
// --- Shared -------------------------------------------------------------------
export function errorPage(status: number, message: string): Response { return html(layout("Monitor", `<header><a href="/">← All endpoints</a></header><p>${esc(message)}</p>`), status);}
export function html(content: string, status = 200): Response { return new Response(content, { status, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }, });}
/** Favicon: a white exclamation mark on a red rounded square, inlined as an SVG data URI. */const FAVICON = "data:image/svg+xml," + encodeURIComponent( `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">` + `<rect width="64" height="64" rx="14" fill="#c62828"/>` + `<rect x="26" y="8" width="12" height="30" rx="6" fill="#fff"/>` + `<circle cx="32" cy="48" r="7" fill="#fff"/>` + `</svg>`, );
function layout(title: string, content: string): string { return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="icon" href="${FAVICON}"><title>${esc(title)}</title><style>${CSS}</style></head><body><main>${content}</main><script>${SCRIPT}</script></body></html>`;}
// Rewrites UTC timestamps into the viewer's local time.const SCRIPT = `for (const t of document.querySelectorAll("time[datetime]")) { const d = new Date(t.dateTime); const sameDay = d.toDateString() === new Date().toDateString(); t.textContent = sameDay ? d.toLocaleTimeString() : d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit" }); t.title = d.toString();}`;
const CSS = `:root { --fg:#1a1a1a; --muted:#666; --bg:#fff; --line:#e3e3e3; --hover:#f5f5f5; --pre:#f6f6f6; --ok:#17803d; --fail:#c62828; --stale:#b26a00; --unknown:#888; color-scheme: light dark; }@media (prefers-color-scheme: dark) { :root { --fg:#e6e6e6; --muted:#9a9a9a; --bg:#151515; --line:#2e2e2e; --hover:#1f1f1f; --pre:#1d1d1d; --ok:#4caf50; --fail:#ef5350; --stale:#ffb74d; --unknown:#777; }}* { box-sizing: border-box; }body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }main { max-width: 1000px; margin: 0 auto; padding: 16px; }header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }h1 { font-size: 1.3rem; margin: 0; }h2 { font-size: 1rem; margin: 20px 0 8px; }.count { display: inline-block; min-width: 1.5em; padding: 0 6px; border-radius: 10px; background: var(--fail); color: #fff; font-size: .8rem; text-align: center; }.alarm { display: flex; gap: 12px; align-items: center; justify-content: space-between; padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--line); border-left: 4px solid var(--fail); border-radius: 6px; }.alarm.silenced { border-left-color: var(--unknown); }.alarm.silenced .name { color: var(--muted); }.alarm form { flex-shrink: 0; }.alarm-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }.alarm-main .muted { font-size: .85rem; }.chips { display: flex; gap: 4px; flex-wrap: wrap; }.chip { font-size: .75rem; padding: 1px 6px; border-radius: 10px; border: 1px solid var(--line); background: var(--hover); }.endpoint-title { margin: 0 0 12px; word-break: break-all; }a { color: inherit; }.button, button { display: inline-block; padding: 6px 12px; border: 1px solid var(--line); border-radius: 6px; text-decoration: none; background: var(--bg); color: var(--fg); font: inherit; cursor: pointer; }.muted { color: var(--muted); }.legend { font-size: .85rem; margin-top: 16px; }.banner { padding: 8px 12px; border-left: 4px solid var(--stale); background: var(--hover); }.error { color: var(--fail); margin: 8px 0; }pre { background: var(--pre); padding: 10px; border-radius: 6px; overflow-x: auto; font-size: .85rem; margin: 8px 0; }
/* Rows: grid on wide screens, wrapped lines on phones. */.row { display: grid; gap: 4px 12px; align-items: center; padding: 10px 8px; }details > summary { cursor: pointer; list-style: none; }details > summary::-webkit-details-marker { display: none; }details > summary:hover { background: var(--hover); }details[open] > summary { background: var(--hover); }.endpoint, .check { border-bottom: 1px solid var(--line); }.head { font-size: .8rem; color: var(--muted); text-transform: uppercase; letter-spacing: .03em; border-bottom: 1px solid var(--line); padding-top: 0; }.name { font-weight: 600; word-break: break-all; }.status { font-weight: 600; font-variant-numeric: tabular-nums; }.status.ok { color: var(--ok); } .status.fail { color: var(--fail); }.status.stale { color: var(--stale); } .status.unknown { color: var(--unknown); }.badges { display: flex; gap: 4px; flex-wrap: wrap; }.badge { font-size: .75rem; padding: 1px 6px; border-radius: 10px; color: #fff; background: var(--unknown); }.badge.ok { background: var(--ok); } .badge.fail { background: var(--fail); } .badge.stale { background: var(--stale); }.toggle::before { content: "▸"; display: inline-block; width: 1.1em; color: var(--muted); font-weight: normal; }details[open] > summary .toggle::before { content: "▾"; }time, .status, .cols-region span, .cols-check span { white-space: nowrap; }.regions { padding: 0 8px 12px 24px; }.region { border-top: 1px solid var(--line); }.region > pre, .region > p, .check > pre, .check > p { margin-left: 8px; margin-right: 8px; }
/* Column templates */.cols-endpoint { grid-template-columns: minmax(0,1fr) 3.5rem 7.5rem 5.5rem 15rem 5.5rem; }.cols-region { grid-template-columns: 5rem 3.5rem 7.5rem 5.5rem; }.cols-check { grid-template-columns: 12rem 4rem 3.5rem 5.5rem; }.filters { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 12px; }.filters select { padding: 6px; font: inherit; max-width: 100%; }.pager { display: flex; gap: 8px; justify-content: space-between; margin-top: 16px; }
@media (max-width: 640px) { .head.cols-endpoint { display: none; } .cols-endpoint { grid-template-columns: auto auto 1fr auto; } .cols-endpoint .name { grid-column: 1 / -2; } .cols-endpoint a { grid-column: -2 / -1; grid-row: 1; justify-self: end; } .cols-endpoint .badges { grid-column: 1 / -1; } .cols-region { grid-template-columns: 4.5rem 3rem 1fr auto; } .cols-check { grid-template-columns: 1fr 3rem 3rem 4.5rem; } .cols-check .name { font-weight: normal; } .regions { padding-left: 8px; }}`;