html.ts

  1import { prettyJson } from "./email";
  2import { REGIONS, REGION_NAMES, type Check, type Region, type RegionSnapshot } from "./monitor";
  3
  4const ESCAPES: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
  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&nbsp;→</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`;