html.ts
1import { prettyJson } from "./email";
2import { REGIONS, REGION_NAMES, type Check, type Region, type RegionSnapshot } from "./monitor";
3
4const ESCAPES: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
5
6export function esc(value: unknown): string {
7 return String(value).replace(/[&<>"']/g, (c) => ESCAPES[c]);
8}
9
10/** A region's result is stale when its newest check is older than this. */
11export function staleAfterMs(env: Env): number {
12 return 2 * Number(env.CHECK_INTERVAL_SECONDS) * 1000 + Number(env.TIMEOUT_MS);
13}
14
15function shortUrl(url: string): string {
16 return url.replace(/^https?:\/\//, "");
17}
18
19function historyHref(endpoint: string, params: Record<string, string> = {}): string {
20 return "/endpoint?" + new URLSearchParams({ url: endpoint, ...params }).toString();
21}
22
23function statusLabel(c: Check): string {
24 return c.status === 0 ? "ERR" : String(c.status);
25}
26
27function time(ts: number): string {
28 const iso = new Date(ts).toISOString();
29 return `<time datetime="${iso}">${iso.slice(11, 19)} UTC</time>`;
30}
31
32function body(c: Check): string {
33 const parts: string[] = [];
34 if (c.error) parts.push(`<p class="error">${esc(c.error)}</p>`);
35 const pretty = prettyJson(c.body);
36 if (pretty !== null) parts.push(`<pre>${esc(pretty)}</pre>`);
37 else if (!c.error) parts.push(`<p class="muted">Empty response body</p>`);
38 return parts.join("");
39}
40
41// --- Homepage ---------------------------------------------------------------
42
43type Cell = { region: Region; check: Check | null; state: "ok" | "fail" | "stale" | "unknown" };
44
45export function homePage(env: Env, endpoints: string[], snapshots: (RegionSnapshot | null)[], now: number): string {
46 const staleMs = staleAfterMs(env);
47 const rows = endpoints.map((endpoint) => {
48 const cells: Cell[] = REGIONS.map((region, i) => {
49 const snap = snapshots[i];
50 const check = snap?.checks.find((c) => c.endpoint === endpoint) ?? null;
51 const state = !check ? "unknown" : now - check.ts > staleMs ? "stale" : check.status === 200 ? "ok" : "fail";
52 return { region, check, state };
53 });
54 return { endpoint, cells, failing: cells.some((c) => c.state === "fail") };
55 });
56 // Failing endpoints first, otherwise keep configured order.
57 rows.sort((a, b) => Number(b.failing) - Number(a.failing));
58
59 const unreachable = REGIONS.filter((_, i) => snapshots[i] === null);
60 const banner = unreachable.length
61 ? `<p class="banner">Could not reach monitor in: ${unreachable.map(esc).join(", ")}</p>`
62 : "";
63
64 return layout(
65 "Monitor",
66 `<header><h1>Monitor</h1><a href="/" class="button">Refresh</a></header>
67 ${banner}
68 <div class="head row cols-endpoint" aria-hidden="true">
69 <span>Endpoint</span><span>Status</span><span>Checked</span><span>Latency</span><span>Regions</span><span></span>
70 </div>
71 ${rows.map(endpointRow).join("") || `<p class="muted">No endpoints configured.</p>`}
72 <p class="muted legend">Status and latency show the worst result across regions. Expand a row for per-region details.</p>`,
73 );
74}
75
76function endpointRow({ endpoint, cells, failing }: { endpoint: string; cells: Cell[]; failing: boolean }): string {
77 const checks = cells.map((c) => c.check).filter((c): c is Check => c !== null);
78 const worst = checks.find((c) => c.status !== 200) ?? checks[0];
79 const newest = checks.length ? Math.max(...checks.map((c) => c.ts)) : null;
80 const maxLatency = checks.length ? Math.max(...checks.map((c) => c.duration_ms)) : null;
81 const badges = cells
82 .map((c) => `<span class="badge ${c.state}" title="${esc(REGION_NAMES[c.region])}: ${c.state}">${c.region}</span>`)
83 .join("");
84
85 const regionRows = cells
86 .map(({ region, check, state }) =>
87 check
88 ? `<details class="region"><summary class="row cols-region">
89 <span class="name toggle">${region}</span>
90 <span class="status ${state}">${statusLabel(check)}</span>
91 <span>${time(check.ts)}</span>
92 <span>${check.duration_ms} ms</span>
93 </summary>${body(check)}</details>`
94 : `<div class="region row cols-region"><span class="name">${region}</span><span class="muted">no data</span></div>`,
95 )
96 .join("");
97
98 return `<details class="endpoint${failing ? " failing" : ""}">
99 <summary class="row cols-endpoint">
100 <span class="name toggle">${esc(shortUrl(endpoint))}</span>
101 <span class="status ${failing ? "fail" : worst ? "ok" : "unknown"}">${worst ? statusLabel(worst) : "—"}</span>
102 <span>${newest ? time(newest) : "—"}</span>
103 <span>${maxLatency !== null ? `${maxLatency} ms` : "—"}</span>
104 <span class="badges">${badges}</span>
105 <a href="${esc(historyHref(endpoint))}">History →</a>
106 </summary>
107 <div class="regions">${regionRows}</div>
108 </details>`;
109}
110
111// --- History page -------------------------------------------------------------
112
113export interface HistoryView {
114 endpoint: string;
115 region: Region | "all";
116 failuresOnly: boolean;
117 before: number | null;
118 checks: Check[];
119 nextBefore: number | null;
120 unreachable: Region[];
121}
122
123export function historyPage(v: HistoryView): string {
124 const filters: Record<string, string> = {};
125 if (v.region !== "all") filters.region = v.region;
126 if (v.failuresOnly) filters.failures = "1";
127
128 const options = ["all", ...REGIONS]
129 .map((r) => `<option value="${r}"${r === v.region ? " selected" : ""}>${r === "all" ? "All regions" : `${r} — ${REGION_NAMES[r as Region]}`}</option>`)
130 .join("");
131
132 const rows = v.checks
133 .map((c) => `<details class="check"><summary class="row cols-check">
134 <span class="toggle">${time(c.ts)}</span>
135 <span class="name">${c.region}</span>
136 <span class="status ${c.status === 200 ? "ok" : "fail"}">${statusLabel(c)}</span>
137 <span>${c.duration_ms} ms</span>
138 </summary>${body(c)}</details>`)
139 .join("");
140
141 const banner = v.unreachable.length
142 ? `<p class="banner">Could not reach monitor in: ${v.unreachable.map(esc).join(", ")} — results may be incomplete.</p>`
143 : "";
144
145 const pager = [
146 v.before !== null ? `<a class="button" href="${esc(historyHref(v.endpoint, filters))}">← Newest</a>` : "",
147 v.nextBefore !== null
148 ? `<a class="button" href="${esc(historyHref(v.endpoint, { ...filters, before: String(v.nextBefore) }))}">Older →</a>`
149 : "",
150 ].join("");
151
152 return layout(
153 `${shortUrl(v.endpoint)} — Monitor`,
154 `<header><a href="/">← All endpoints</a></header>
155 <h1 class="endpoint-title"><a href="${esc(v.endpoint)}" rel="noreferrer">${esc(shortUrl(v.endpoint))}</a></h1>
156 <form method="get" action="/endpoint" class="filters">
157 <input type="hidden" name="url" value="${esc(v.endpoint)}">
158 <select name="region" aria-label="Region" onchange="this.form.submit()">${options}</select>
159 <label><input type="checkbox" name="failures" value="1"${v.failuresOnly ? " checked" : ""} onchange="this.form.submit()"> Non-200 only</label>
160 <noscript><button type="submit">Apply</button></noscript>
161 </form>
162 ${banner}
163 <div class="head row cols-check" aria-hidden="true"><span>Time</span><span>Region</span><span>Status</span><span>Latency</span></div>
164 ${rows || `<p class="muted">No checks match.</p>`}
165 <nav class="pager">${pager}</nav>`,
166 );
167}
168
169// --- Shared -------------------------------------------------------------------
170
171export function errorPage(status: number, message: string): Response {
172 return html(layout("Monitor", `<header><a href="/">← All endpoints</a></header><p>${esc(message)}</p>`), status);
173}
174
175export function html(content: string, status = 200): Response {
176 return new Response(content, {
177 status,
178 headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
179 });
180}
181
182/** Favicon: a white exclamation mark on a red rounded square, inlined as an SVG data URI. */
183const FAVICON =
184 "data:image/svg+xml," +
185 encodeURIComponent(
186 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">` +
187 `<rect width="64" height="64" rx="14" fill="#c62828"/>` +
188 `<rect x="26" y="8" width="12" height="30" rx="6" fill="#fff"/>` +
189 `<circle cx="32" cy="48" r="7" fill="#fff"/>` +
190 `</svg>`,
191 );
192
193function layout(title: string, content: string): string {
194 return `<!doctype html>
195<html lang="en">
196<head>
197<meta charset="utf-8">
198<meta name="viewport" content="width=device-width, initial-scale=1">
199<link rel="icon" href="${FAVICON}">
200<title>${esc(title)}</title>
201<style>${CSS}</style>
202</head>
203<body>
204<main>${content}</main>
205<script>${SCRIPT}</script>
206</body>
207</html>`;
208}
209
210// Rewrites UTC timestamps into the viewer's local time.
211const SCRIPT = `
212for (const t of document.querySelectorAll("time[datetime]")) {
213 const d = new Date(t.dateTime);
214 const sameDay = d.toDateString() === new Date().toDateString();
215 t.textContent = sameDay
216 ? d.toLocaleTimeString()
217 : d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit" });
218 t.title = d.toString();
219}`;
220
221const CSS = `
222:root { --fg:#1a1a1a; --muted:#666; --bg:#fff; --line:#e3e3e3; --hover:#f5f5f5; --pre:#f6f6f6;
223 --ok:#17803d; --fail:#c62828; --stale:#b26a00; --unknown:#888; color-scheme: light dark; }
224@media (prefers-color-scheme: dark) {
225 :root { --fg:#e6e6e6; --muted:#9a9a9a; --bg:#151515; --line:#2e2e2e; --hover:#1f1f1f; --pre:#1d1d1d;
226 --ok:#4caf50; --fail:#ef5350; --stale:#ffb74d; --unknown:#777; }
227}
228* { box-sizing: border-box; }
229body { margin: 0; background: var(--bg); color: var(--fg);
230 font: 15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
231main { max-width: 1000px; margin: 0 auto; padding: 16px; }
232header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
233h1 { font-size: 1.3rem; margin: 0; }
234.endpoint-title { margin: 0 0 12px; word-break: break-all; }
235a { color: inherit; }
236.button, button { display: inline-block; padding: 6px 12px; border: 1px solid var(--line); border-radius: 6px;
237 text-decoration: none; background: var(--bg); color: var(--fg); font: inherit; cursor: pointer; }
238.muted { color: var(--muted); }
239.legend { font-size: .85rem; margin-top: 16px; }
240.banner { padding: 8px 12px; border-left: 4px solid var(--stale); background: var(--hover); }
241.error { color: var(--fail); margin: 8px 0; }
242pre { background: var(--pre); padding: 10px; border-radius: 6px; overflow-x: auto; font-size: .85rem; margin: 8px 0; }
243
244/* Rows: grid on wide screens, wrapped lines on phones. */
245.row { display: grid; gap: 4px 12px; align-items: center; padding: 10px 8px; }
246details > summary { cursor: pointer; list-style: none; }
247details > summary::-webkit-details-marker { display: none; }
248details > summary:hover { background: var(--hover); }
249details[open] > summary { background: var(--hover); }
250.endpoint, .check { border-bottom: 1px solid var(--line); }
251.head { font-size: .8rem; color: var(--muted); text-transform: uppercase; letter-spacing: .03em;
252 border-bottom: 1px solid var(--line); padding-top: 0; }
253.name { font-weight: 600; word-break: break-all; }
254.status { font-weight: 600; font-variant-numeric: tabular-nums; }
255.status.ok { color: var(--ok); } .status.fail { color: var(--fail); }
256.status.stale { color: var(--stale); } .status.unknown { color: var(--unknown); }
257.badges { display: flex; gap: 4px; flex-wrap: wrap; }
258.badge { font-size: .75rem; padding: 1px 6px; border-radius: 10px; color: #fff; background: var(--unknown); }
259.badge.ok { background: var(--ok); } .badge.fail { background: var(--fail); } .badge.stale { background: var(--stale); }
260.toggle::before { content: "▸"; display: inline-block; width: 1.1em; color: var(--muted); font-weight: normal; }
261details[open] > summary .toggle::before { content: "▾"; }
262time, .status, .cols-region span, .cols-check span { white-space: nowrap; }
263.regions { padding: 0 8px 12px 24px; }
264.region { border-top: 1px solid var(--line); }
265.region > pre, .region > p, .check > pre, .check > p { margin-left: 8px; margin-right: 8px; }
266
267/* Column templates */
268.cols-endpoint { grid-template-columns: minmax(0,1fr) 3.5rem 7.5rem 5.5rem 15rem 5.5rem; }
269.cols-region { grid-template-columns: 5rem 3.5rem 7.5rem 5.5rem; }
270.cols-check { grid-template-columns: 12rem 4rem 3.5rem 5.5rem; }
271.filters { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 12px; }
272.filters select { padding: 6px; font: inherit; max-width: 100%; }
273.pager { display: flex; gap: 8px; justify-content: space-between; margin-top: 16px; }
274
275@media (max-width: 640px) {
276 .head.cols-endpoint { display: none; }
277 .cols-endpoint { grid-template-columns: auto auto 1fr auto; }
278 .cols-endpoint .name { grid-column: 1 / -2; }
279 .cols-endpoint a { grid-column: -2 / -1; grid-row: 1; justify-self: end; }
280 .cols-endpoint .badges { grid-column: 1 / -1; }
281 .cols-region { grid-template-columns: 4.5rem 3rem 1fr auto; }
282 .cols-check { grid-template-columns: 1fr 3rem 3rem 4.5rem; }
283 .cols-check .name { font-weight: normal; }
284 .regions { padding-left: 8px; }
285}
286`;