g1t/apps/status/src/render.ts

664 lines39,774 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1/**
2 * The pages at status.g1t.sh, drawn on the server as plain HTML: no
3 * framework, a small stylesheet inline, and a few lines of script (served
4 * at /status.js) that only add hover and keyboard detail to the bars and
5 * say times in the reader's own words. Every page works without it:
6 * subscribing, confirming and leaving are plain forms. No Workers
7 * imports, so it is tested under Node.
8 *
9 * / the status now, maintenance, recent incidents, subscribing
10 * /incidents/<id> one incident's every public update, and its postmortem
11 * /maintenance/<id> one maintenance window and its updates
12 * /history past incidents and maintenance, by month
13 * /subscribe subscribing by email, to every part or some
14 */
15import type { PostmortemFields, StatusComponent, StatusIncident, StatusMaintenance, StatusOverallState } from "@g1t/contracts";
16
17import { type DayBar, HISTORY_DAYS, IMPACT_WORD, INCIDENT_STATUS, type PageModel, maintenanceState, percent } from "./model.ts";
18
19export type PageOptions = {
20 /** The site the page is about: `https://g1t.sh`. */
21 siteUrl: string;
22 /** Where help is: `https://g1t.sh/support`. */
23 supportUrl: string;
24 /** The share card, or empty for none. */
25 ogImage: string;
26 /** This page's own address, for its canonical link. */
27 selfUrl: string;
28 now: Date;
29 /** Whether email subscriptions can be offered (a sender and a secret). */
30 email?: boolean;
31};
32
33export function escape(text: string): string {
34 return text.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
35}
36
37const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
38const MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
39
40/** "5 Oct 2026". */
41export function dayLabel(day: string): string {
42 const [y, m, d] = day.split("-").map(Number);
43 return `${d} ${MONTHS[(m ?? 1) - 1]} ${y}`;
44}
45
46/** "5 Oct 2026, 14:03 UTC": the script says it in the reader's time. */
47export function timeLabel(at: string): string {
48 const date = new Date(at);
49 const hh = String(date.getUTCHours()).padStart(2, "0");
50 const mm = String(date.getUTCMinutes()).padStart(2, "0");
51 return `${dayLabel(date.toISOString().slice(0, 10))}, ${hh}:${mm} UTC`;
52}
53
54function time(at: string, relative = false): string {
55 return `<time datetime="${escape(at)}"${relative ? " data-relative" : ""}>${escape(timeLabel(at))}</time>`;
56}
57
58/** "1h 05m", "3d 4h". */
59export function lasted(fromIso: string, toIso: string): string {
60 const minutes = Math.max(0, Math.round((Date.parse(toIso) - Date.parse(fromIso)) / 60000));
61 if (minutes < 60) return `${minutes} min`;
62 const hours = Math.floor(minutes / 60);
63 if (hours < 48) return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
64 return `${Math.floor(hours / 24)}d ${hours % 24}h`;
65}
66
67/** What a day's square says on hover, and to a screen reader. */
68export function dayDetail(bar: DayBar): string {
69 const parts = [dayLabel(bar.day)];
70 if (bar.checks === 0) parts.push("No data");
71 else {
72 parts.push(`${percent(bar.uptime)} answered`);
73 if (bar.failed > 0) parts.push(`${bar.failed} failed check${bar.failed === 1 ? "" : "s"} of ${bar.checks}`);
74 if (bar.slow > 0) parts.push(`${bar.slow} slow`);
75 if (bar.avg_ms != null) parts.push(`average ${bar.avg_ms} ms`);
76 }
77 for (const title of bar.incidents) parts.push(`Incident: ${title}`);
78 return parts.join(" · ");
79}
80
81export const STATE_WORD: Record<StatusComponent["state"], string> = {
82 up: "Operational",
83 degraded: "Degraded",
84 partial: "Partial outage",
85 down: "Outage",
86 maintenance: "Under maintenance",
87 unmonitored: "Not monitored",
88};
89
90/** The pixel G1T: 5×7 capitals, the 1 in lavender, as in the site's logo. */
91const G = [".###.", "#...#", "#....", "#.###", "#...#", "#...#", ".###."];
92const ONE = ["..#..", ".##..", "#.#..", "..#..", "..#..", "..#..", "#####"];
93const T = ["#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."];
94
95function pixels(rows: string[], dx: number, cls: string): string {
96 return rows
97 .flatMap((row, y) => [...row].flatMap((c, x) => (c === "#" ? [`<rect x="${(dx + x) * 10 + 1}" y="${y * 10 + 1}" width="8" height="8"${cls}/>`] : [])))
98 .join("");
99}
100
101export const LOGO = `<svg class="logo" viewBox="1 1 148 68" role="img" aria-label="g1t">${pixels(G, 0, "")}${pixels(ONE, 6, ' class="one"')}${pixels(T, 10, "")}</svg>`;
102
103/** The favicon: the pixel 1. */
104export const FAVICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" shape-rendering="crispEdges"><style>rect{fill:#6b56e8}@media (prefers-color-scheme: dark){rect{fill:#b6a8ff}}</style>${ONE.flatMap(
105 (row, y) => [...row].flatMap((c, x) => (c === "#" ? [`<rect x="${x * 2 + 3}" y="${y * 2 + 1}" width="2" height="2"/>`] : [])),
106).join("")}</svg>`;
107
108function bar(component: StatusComponent, days: DayBar[]): string {
109 if (component.check_state === "unmonitored" && component.uptime_90d == null) {
110 return `<p class="unchecked">${escape(component.checks)}</p>`;
111 }
112 const cells = days
113 .map((d) => {
114 const detail = dayDetail(d);
115 return `<li class="${d.state}" title="${escape(detail)}" data-detail="${escape(detail)}"></li>`;
116 })
117 .join("");
118 const label = `${component.name}: ${percent(component.uptime_90d)} of checks answered over ${HISTORY_DAYS} days. Use the arrow keys for each day.`;
119 return `<div class="history">
120<ol class="bar" tabindex="0" aria-label="${escape(label)}">${cells}</ol>
121<div class="legend"><span><span class="long">${HISTORY_DAYS}</span><span class="short">30</span> days ago</span><span class="uptime">${escape(percent(component.uptime_90d))} uptime</span><span>Today</span></div>
122<p class="detail" aria-live="polite"></p>
123</div>`;
124}
125
126function componentRow(component: StatusComponent, days: DayBar[]): string {
127 const seen = component.check_state !== "unmonitored" ? `<p class="seen">${escape(component.detail)}</p>` : "";
128 return `<li class="component">
129<div class="row">
130<div class="name"><h3>${escape(component.name)}</h3><span class="address">${escape(component.address)}</span></div>
131<div class="state s-${component.state}"><span class="dot ${component.state}" aria-hidden="true"></span>${STATE_WORD[component.state]}</div>
132</div>
133${seen}
134${bar(component, days)}
135</li>`;
136}
137
138/**
139 * Text as HTML: blank lines between paragraphs, and a block whose lines
140 * all start with "- " as a list. Everything is escaped.
141 */
142export function prose(text: string): string {
143 return text
144 .split(/\n{2,}/)
145 .map((block) => {
146 const lines = block.split("\n").filter((l) => l.trim() !== "");
147 if (lines.length && lines.every((l) => /^\s*[-*] /.test(l))) {
148 return `<ul>${lines.map((l) => `<li>${escape(l.replace(/^\s*[-*] /, ""))}</li>`).join("")}</ul>`;
149 }
150 return `<p>${escape(block).replace(/\n/g, "<br>")}</p>`;
151 })
152 .join("");
153}
154
155function impactLine(incident: StatusIncident, names: Map<string, string>): string {
156 return incident.component_impacts
157 .map((c) => `<span class="affects">${escape(names.get(c.key) ?? c.key)} <span class="impact i-${c.impact}">${escape(IMPACT_WORD[c.impact])}</span></span>`)
158 .join("");
159}
160
161function pill(incident: StatusIncident): string {
162 const open = incident.resolved_at == null;
163 const impact = incident.impact === "down" ? "Major outage" : "Partial outage";
164 return `<span class="pill ${open ? incident.impact : "done"}">${open ? `${escape(INCIDENT_STATUS[incident.status])} · ${impact}` : "Resolved"}</span>`;
165}
166
167function updatesList(incident: StatusIncident): string {
168 return `<ol class="updates">${incident.updates
169 .map(
170 (u) =>
171 `<li id="update-${escape(u.id)}"><p class="when"><strong>${escape(INCIDENT_STATUS[u.status])}</strong> ${time(u.at)}</p><div class="text">${prose(u.text)}</div></li>`,
172 )
173 .join("")}</ol>`;
174}
175
176/** An incident on the front page: its title links to its own page. */
177function incidentBlock(incident: StatusIncident, names: Map<string, string>): string {
178 const open = incident.resolved_at == null;
179 return `<article class="incident ${open ? `open ${incident.impact}` : "resolved"}">
180<header>
181<h3><a href="/incidents/${escape(incident.id)}">${escape(incident.title)}</a></h3>
182${pill(incident)}
183</header>
184<p class="meta">Started ${time(incident.started_at)}${incident.resolved_at ? ` · resolved ${time(incident.resolved_at)}` : ""}</p>
185<div class="impacts">${impactLine(incident, names)}</div>
186${updatesList(incident)}
187</article>`;
188}
189
190/** A line for a past incident: title, when, how long, and its postmortem. */
191function incidentLine(incident: StatusIncident): string {
192 const how = incident.resolved_at ? `lasted ${lasted(incident.started_at, incident.resolved_at)}` : escape(INCIDENT_STATUS[incident.status]);
193 const latest = incident.updates[0];
194 return `<li class="line">
195<div class="line-head"><a href="/incidents/${escape(incident.id)}">${escape(incident.title)}</a>${incident.postmortem_published_at ? `<a class="tag" href="/incidents/${escape(incident.id)}#postmortem">Postmortem</a>` : ""}</div>
196<p class="meta">${time(incident.started_at)} · ${how}</p>
197${latest ? `<p class="last">${escape(latest.text.split("\n")[0]!.slice(0, 220))}</p>` : ""}
198</li>`;
199}
200
201function maintenanceWindow(m: StatusMaintenance): string {
202 return `${time(m.starts_at)} to ${time(m.ends_at)} · ${lasted(m.starts_at, m.ends_at)}`;
203}
204
205const MAINTENANCE_WORD: Record<StatusMaintenance["state"], string> = {
206 scheduled: "Scheduled",
207 in_progress: "In progress",
208 completed: "Completed",
209 cancelled: "Cancelled",
210};
211
212function maintenanceBlock(m: StatusMaintenance, names: Map<string, string>, now: Date): string {
213 const state = maintenanceState(m, now);
214 const latest = m.updates[0];
215 return `<article class="incident maint ${state}">
216<header>
217<h3><a href="/maintenance/${escape(m.id)}">${escape(m.title)}</a></h3>
218<span class="pill maint">${MAINTENANCE_WORD[state]}</span>
219</header>
220<p class="meta">${maintenanceWindow(m)}</p>
221<p class="meta">Affects ${escape(m.components.map((k) => names.get(k) ?? k).join(", "))}</p>
222<div class="text">${prose(m.message)}</div>
223${latest && latest.text !== m.message ? `<p class="when latest"><strong>Latest</strong> ${time(latest.at)} · ${escape(latest.text.split("\n")[0]!)}</p>` : ""}
224</article>`;
225}
226
227const BANNER_CLASS: Record<StatusOverallState, string> = { up: "up", degraded: "degraded", down: "down", maintenance: "maintenance", unknown: "unknown" };
228
229type Layout = { title: string; description: string; body: string; live?: boolean; feeds?: boolean };
230
231function layout(options: PageOptions, page: Layout): string {
232 const site = options.siteUrl.replace(/\/+$/, "");
233 const siteHost = site.replace(/^https?:\/\//, "");
234 return `<!doctype html>
235<html lang="en">
236<head>
237<meta charset="utf-8">
238<meta name="viewport" content="width=device-width, initial-scale=1">
239<title>${escape(page.title)}</title>
240<meta name="description" content="${escape(page.description)}">
241<meta name="theme-color" content="#0f0f11">
242<meta name="color-scheme" content="dark">
243<link rel="icon" href="/favicon.svg" type="image/svg+xml">
244<link rel="canonical" href="${escape(options.selfUrl)}">
245<link rel="alternate" type="application/json" href="/status.json">
246<link rel="alternate" type="application/atom+xml" title="g1t status" href="/feed.xml">
247<link rel="alternate" type="application/feed+json" title="g1t status" href="/feed.json">
248<link rel="preload" href="/fonts/hanken-grotesk.woff2" as="font" type="font/woff2" crossorigin>
249<link rel="preload" href="/fonts/bricolage-grotesque.woff2" as="font" type="font/woff2" crossorigin>
250<meta property="og:site_name" content="g1t">
251<meta property="og:type" content="website">
252<meta property="og:url" content="${escape(options.selfUrl)}">
253<meta property="og:title" content="${escape(page.title)}">
254<meta property="og:description" content="${escape(page.description)}">
255${options.ogImage ? `<meta property="og:image" content="${escape(options.ogImage)}"><meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="${escape(options.ogImage)}">` : ""}
256<style>${STYLE}</style>
257<script src="/status.js" defer></script>
258</head>
259<body${page.live ? " data-live" : ""}>
260<div class="page">
261<header class="top">
262<a class="brand" href="/" aria-label="g1t status">${LOGO}<span>Status</span></a>
263<a class="out" href="${escape(site)}/">${escape(siteHost)} <span aria-hidden="true">→</span></a>
264</header>
265<main>
266${page.body}
267</main>
268<footer class="foot">
269<a href="/history">History</a><span aria-hidden="true">·</span><a href="/subscribe">Subscribe</a><span aria-hidden="true">·</span><a href="/feed.xml">Atom feed</a><span aria-hidden="true">·</span><a href="/feed.json">JSON feed</a><span aria-hidden="true">·</span><a href="/status.json">status.json</a><span aria-hidden="true">·</span><a href="${escape(options.supportUrl)}">Support</a><span aria-hidden="true">·</span><a href="${escape(site)}/">${escape(siteHost)}</a>
270</footer>
271</div>
272</body>
273</html>`;
274}
275
276const FEEDS = `the <a href="/feed.xml">Atom feed</a> or the <a href="/feed.json">JSON feed</a>`;
277
278function subscribeForm(options: PageOptions, compact: boolean): string {
279 if (!options.email) return `<p class="muted">Email updates are not available here. Follow ${FEEDS} instead.</p>`;
280 return `<form class="subscribe" method="post" action="/subscribe">
281<label class="sr" for="sub-email">Email address</label>
282<input id="sub-email" type="email" name="email" required maxlength="254" autocomplete="email" placeholder="you@example.com">
283<input class="trap" type="text" name="website" tabindex="-1" autocomplete="off" aria-hidden="true">
284<button type="submit">Subscribe</button>
285</form>
286<p class="feeds">Get an email when an incident or planned maintenance is posted${compact ? `, or <a href="/subscribe">choose which parts</a>` : ""}. Or follow ${FEEDS}.</p>`;
287}
288
289/** The front page. */
290export function renderPage(model: PageModel, options: PageOptions): string {
291 const { report, bars, stale } = model;
292 const { overall, components, incidents, maintenance, checked_at } = report;
293 const names = new Map(components.map((c) => [c.key, c.name]));
294 const open = incidents.filter((i) => i.resolved_at == null);
295 const recentSince = options.now.getTime() - 14 * 24 * 60 * 60 * 1000;
296 const past = incidents.filter((i) => i.resolved_at != null && Date.parse(i.resolved_at) >= recentSince);
297 const inProgress = maintenance.filter((m) => maintenanceState(m, options.now) === "in_progress");
298 const upcoming = maintenance.filter((m) => maintenanceState(m, options.now) === "scheduled");
299 const description = `${overall.title}. Live status of g1t: the website, API, git, MCP, docs, deployments, agents and billing, with 90 days of history.`;
300 const checkedLine = checked_at
301 ? stale
302 ? `The checks have not run since ${time(checked_at, true)}, so what follows may be out of date.`
303 : `Checked ${time(checked_at, true)}, and every minute.`
304 : "Waiting for the first checks.";
305 const body = `<section class="banner ${BANNER_CLASS[overall.state]}" aria-live="polite">
306<span class="dot big ${overall.state}" aria-hidden="true"></span>
307<div>
308<h1>${escape(overall.title)}</h1>
309<p>${overall.state === "up" ? "" : `${escape(overall.line)} `}${checkedLine}</p>
310</div>
311</section>
312${open.length ? `<section class="block"><h2>Happening now</h2>${open.map((i) => incidentBlock(i, names)).join("")}</section>` : ""}
313${inProgress.length ? `<section class="block"><h2>Maintenance in progress</h2>${inProgress.map((m) => maintenanceBlock(m, names, options.now)).join("")}</section>` : ""}
314${upcoming.length ? `<section class="block"><h2>Upcoming maintenance</h2>${upcoming.map((m) => maintenanceBlock(m, names, options.now)).join("")}</section>` : ""}
315<section class="block">
316<h2>Components</h2>
317<ul class="components">${components.map((c) => componentRow(c, bars[c.key] ?? [])).join("")}</ul>
318</section>
319<section class="block">
320<div class="block-head"><h2>Past incidents</h2><a href="/history">Full history <span aria-hidden="true">→</span></a></div>
321${past.length ? `<ul class="lines">${past.map(incidentLine).join("")}</ul>` : `<p class="empty">No incidents in the last 14 days.</p>`}
322</section>
323<section class="block" id="subscribe">
324<h2>Subscribe to updates</h2>
325<div class="card">${subscribeForm(options, true)}</div>
326</section>
327<section class="block about">
328<h2>About these checks</h2>
329<p>Each part is checked every minute with one quick request, the same a visitor's would make, from outside g1t. A part that answers in over 1.5 seconds counts as slow; one that fails, or takes over 5 seconds, as down. A day shows green when at least 99.9% of its checks answered; checks during planned maintenance are not counted. This page runs apart from g1t itself, so it stays up when g1t does not.</p>
330<dl>${components.map((c) => `<dt>${escape(c.name)}</dt><dd>${escape(c.checks)}</dd>`).join("")}</dl>
331</section>`;
332 return layout(options, { title: `${overall.title} · g1t status`, description, body, live: true });
333}
334
335const POSTMORTEM_SECTIONS: { key: keyof PostmortemFields; title: string }[] = [
336 { key: "summary", title: "Summary" },
337 { key: "impact", title: "Impact" },
338 { key: "timeline", title: "Timeline" },
339 { key: "root_cause", title: "Root cause" },
340 { key: "went_well", title: "What went well" },
341 { key: "went_badly", title: "What went badly" },
342 { key: "action_items", title: "Action items" },
343];
344
345/** A postmortem's sections, those with something in them. */
346export function postmortemHtml(p: PostmortemFields): string {
347 return POSTMORTEM_SECTIONS.filter((s) => p[s.key].trim())
348 .map((s) => `<section class="pm-section"><h3>${s.title}</h3><div class="text">${prose(p[s.key])}</div></section>`)
349 .join("");
350}
351
352/** One incident's page: every public update, oldest last, and its postmortem once published. */
353export function renderIncident(
354 incident: StatusIncident,
355 postmortem: (PostmortemFields & { published_at: string | null }) | null,
356 names: Map<string, string>,
357 options: PageOptions,
358): string {
359 const open = incident.resolved_at == null;
360 const body = `<p class="crumbs"><a href="/">Status</a> <span aria-hidden="true">/</span> <a href="/history">History</a></p>
361<article class="incident-page">
362<header class="page-head">
363<h1>${escape(incident.title)}</h1>
364${pill(incident)}
365</header>
366<p class="meta">Started ${time(incident.started_at)}${incident.resolved_at ? ` · resolved ${time(incident.resolved_at)} · lasted ${lasted(incident.started_at, incident.resolved_at)}` : ""}</p>
367<div class="impacts">${impactLine(incident, names)}</div>
368${postmortem?.published_at ? `<p class="note">A postmortem was published ${time(postmortem.published_at)}. <a href="#postmortem">Read it</a>.</p>` : ""}
369<section class="block"><h2>Updates</h2><div class="card">${updatesList(incident)}</div></section>
370${
371 postmortem?.published_at
372 ? `<section class="block" id="postmortem"><h2>Postmortem</h2><div class="card postmortem">${postmortemHtml(postmortem)}</div></section>`
373 : !open
374 ? `<p class="muted">If a postmortem is written for this incident, it will appear on this page.</p>`
375 : ""
376}
377</article>`;
378 return layout(options, {
379 title: `${incident.title} · g1t status`,
380 description: `${open ? INCIDENT_STATUS[incident.status] : "Resolved"}: ${incident.title}. ${incident.updates[0]?.text.slice(0, 140) ?? ""}`,
381 body,
382 });
383}
384
385export function renderMaintenance(m: StatusMaintenance, names: Map<string, string>, options: PageOptions): string {
386 const state = maintenanceState(m, options.now);
387 const body = `<p class="crumbs"><a href="/">Status</a> <span aria-hidden="true">/</span> <a href="/history">History</a></p>
388<header class="page-head"><h1>${escape(m.title)}</h1><span class="pill maint">${MAINTENANCE_WORD[state]}</span></header>
389<p class="meta">${maintenanceWindow(m)}</p>
390<p class="meta">Affects ${escape(m.components.map((k) => names.get(k) ?? k).join(", "))}</p>
391<section class="block"><h2>Updates</h2><div class="card"><ol class="updates">${m.updates
392 .map((u) => `<li id="update-${escape(u.id)}"><p class="when">${time(u.at)}</p><div class="text">${prose(u.text)}</div></li>`)
393 .join("")}</ol></div></section>`;
394 return layout(options, { title: `${m.title} · g1t status`, description: `Planned maintenance: ${m.title}.`, body });
395}
396
397/** Months, newest first, each with its incidents and maintenance. */
398export function byMonth(incidents: StatusIncident[], maintenance: StatusMaintenance[]): { month: string; incidents: StatusIncident[]; maintenance: StatusMaintenance[] }[] {
399 const months = new Map<string, { incidents: StatusIncident[]; maintenance: StatusMaintenance[] }>();
400 const slot = (month: string) => months.get(month) ?? months.set(month, { incidents: [], maintenance: [] }).get(month)!;
401 for (const i of incidents) slot(i.started_at.slice(0, 7)).incidents.push(i);
402 for (const m of maintenance) slot(m.starts_at.slice(0, 7)).maintenance.push(m);
403 return [...months]
404 .sort((a, b) => b[0].localeCompare(a[0]))
405 .map(([month, v]) => ({
406 month,
407 incidents: v.incidents.sort((a, b) => b.started_at.localeCompare(a.started_at)),
408 maintenance: v.maintenance.sort((a, b) => b.starts_at.localeCompare(a.starts_at)),
409 }));
410}
411
412export function renderHistory(incidents: StatusIncident[], maintenance: StatusMaintenance[], options: PageOptions, months = 12): string {
413 const groups = byMonth(incidents, maintenance);
414 const now = options.now;
415 const list: string[] = [];
416 for (let i = 0; i < months; i++) {
417 const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - i, 1));
418 const key = d.toISOString().slice(0, 7);
419 const group = groups.find((g) => g.month === key);
420 const items = [
421 ...(group?.incidents.map(incidentLine) ?? []),
422 ...(group?.maintenance.map(
423 (m) =>
424 `<li class="line"><div class="line-head"><a href="/maintenance/${escape(m.id)}">${escape(m.title)}</a><span class="tag">Maintenance · ${MAINTENANCE_WORD[maintenanceState(m, now)]}</span></div><p class="meta">${maintenanceWindow(m)}</p></li>`,
425 ) ?? []),
426 ];
427 list.push(`<section class="month"><h2>${MONTH_NAMES[d.getUTCMonth()]} ${d.getUTCFullYear()}</h2>${items.length ? `<ul class="lines">${items.join("")}</ul>` : `<p class="none">No incidents or maintenance.</p>`}</section>`);
428 }
429 const body = `<p class="crumbs"><a href="/">Status</a></p>
430<header class="page-head"><h1>History</h1></header>
431<p class="muted">Every incident and planned maintenance of the last ${months} months, newest first.</p>
432${list.join("")}`;
433 return layout(options, { title: "History · g1t status", description: "Past incidents and maintenance on g1t, by month.", body });
434}
435
436export function renderSubscribe(options: PageOptions, parts: { key: string; name: string }[]): string {
437 const form = options.email
438 ? `<form class="stack" method="post" action="/subscribe">
439<label for="email">Email address</label>
440<input id="email" type="email" name="email" required maxlength="254" autocomplete="email" placeholder="you@example.com">
441<input class="trap" type="text" name="website" tabindex="-1" autocomplete="off" aria-hidden="true">
442<fieldset>
443<legend>Which parts</legend>
444<p class="muted">Leave every box clear to hear about everything.</p>
445<div class="checks">${parts.map((p) => `<label class="check"><input type="checkbox" name="components" value="${escape(p.key)}"> ${escape(p.name)}</label>`).join("")}</div>
446</fieldset>
447<button type="submit">Subscribe</button>
448<p class="muted">We email you a link to confirm first. Every email has a link to unsubscribe. Your address is used for nothing else.</p>
449</form>`
450 : `<p class="muted">Email updates are not available on this installation.</p>`;
451 const body = `<p class="crumbs"><a href="/">Status</a></p>
452<header class="page-head"><h1>Subscribe to updates</h1></header>
453<div class="card">${form}</div>
454<section class="block"><h2>Feeds</h2><div class="card"><p class="muted">Every public update and maintenance notice, newest first: the <a href="/feed.xml">Atom feed</a> for feed readers and chat apps, the <a href="/feed.json">JSON feed</a>, and <a href="/status.json">status.json</a> for the state right now.</p></div></section>`;
455 return layout(options, { title: "Subscribe · g1t status", description: "Get an email when g1t posts an incident or planned maintenance.", body });
456}
457
458/** A short page: a heading, a line, and perhaps a form with one button. */
459export function renderMessage(
460 options: PageOptions,
461 input: { title: string; text: string; form?: { action: string; fields: Record<string, string>; button: string } },
462): string {
463 const form = input.form
464 ? `<form method="post" action="${escape(input.form.action)}">${Object.entries(input.form.fields)
465 .map(([k, v]) => `<input type="hidden" name="${escape(k)}" value="${escape(v)}">`)
466 .join("")}<button type="submit">${escape(input.form.button)}</button></form>`
467 : `<p><a href="/">Back to the status page</a></p>`;
468 const body = `<p class="crumbs"><a href="/">Status</a></p>
469<section class="card message"><h1>${escape(input.title)}</h1><p>${escape(input.text)}</p>${form}</section>`;
470 return layout(options, { title: `${input.title} · g1t status`, description: input.text, body });
471}
472
473/** A small badge: "g1t | all systems normal", coloured by state. */
474export function renderBadge(state: StatusOverallState, title: string): string {
475 const color = { up: "#86efc4", degraded: "#ffbd8c", down: "#ff8394", maintenance: "#8ec5ff", unknown: "#86868e" }[state];
476 const label = "g1t";
477 const value = title.toLowerCase();
478 // Widths from an average glyph of 6.5px at 11px: close enough to centre.
479 const left = Math.round(label.length * 6.5 + 14);
480 const right = Math.round(value.length * 6.2 + 14);
481 const width = left + right;
482 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="20" role="img" aria-label="${escape(`${label}: ${value}`)}">
483<title>${escape(`${label}: ${value}`)}</title>
484<clipPath id="r"><rect width="${width}" height="20" rx="4"/></clipPath>
485<g clip-path="url(#r)"><rect width="${left}" height="20" fill="#1e1e21"/><rect x="${left}" width="${right}" height="20" fill="${color}"/></g>
486<g font-family="Verdana,DejaVu Sans,sans-serif" font-size="11" text-anchor="middle">
487<text x="${left / 2}" y="14" fill="#ededef">${escape(label)}</text>
488<text x="${left + right / 2}" y="14" fill="#0f0f11">${escape(value)}</text>
489</g></svg>`;
490}
491
492/** The page's script: day detail on hover, tap and arrow keys; times in the reader's words. */
493export const SCRIPT = `(() => {
494 const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
495 const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" });
496 function ago(at) {
497 const s = (Date.parse(at) - Date.now()) / 1000;
498 const a = Math.abs(s);
499 if (a < 45) return rtf.format(Math.round(s), "second");
500 if (a < 2700) return rtf.format(Math.round(s / 60), "minute");
501 if (a < 79200) return rtf.format(Math.round(s / 3600), "hour");
502 return rtf.format(Math.round(s / 86400), "day");
503 }
504 function times() {
505 for (const t of document.querySelectorAll("time[datetime]")) {
506 const at = t.getAttribute("datetime");
507 t.title = fmt.format(new Date(at));
508 t.textContent = t.hasAttribute("data-relative") ? ago(at) : fmt.format(new Date(at));
509 }
510 }
511 times();
512 setInterval(times, 30000);
513 for (const bar of document.querySelectorAll(".bar")) {
514 const detail = bar.parentElement.querySelector(".detail");
515 const days = [...bar.children];
516 let at = -1;
517 function show(i) {
518 days.forEach((d, j) => d.classList.toggle("on", j === i));
519 at = i;
520 detail.textContent = i >= 0 ? days[i].dataset.detail : "";
521 }
522 bar.addEventListener("pointerover", (e) => { const i = days.indexOf(e.target); if (i >= 0) show(i); });
523 bar.addEventListener("click", (e) => { const i = days.indexOf(e.target); if (i >= 0) show(i); });
524 bar.addEventListener("pointerleave", (e) => { if (e.pointerType === "mouse" && document.activeElement !== bar) show(-1); });
525 bar.addEventListener("blur", () => show(-1));
526 bar.addEventListener("keydown", (e) => {
527 const visible = days.filter((d) => d.offsetParent !== null);
528 const first = days.indexOf(visible[0]);
529 if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
530 e.preventDefault();
531 const next = at < 0 ? days.length - 1 : at + (e.key === "ArrowLeft" ? -1 : 1);
532 show(Math.min(days.length - 1, Math.max(first, next)));
533 } else if (e.key === "Home") { e.preventDefault(); show(first); }
534 else if (e.key === "End") { e.preventDefault(); show(days.length - 1); }
535 });
536 bar.addEventListener("focus", () => { if (at < 0) show(days.length - 1); });
537 }
538 // Fresh numbers without a reload, while the front page is in view and no one is typing.
539 if (document.body.hasAttribute("data-live")) {
540 setInterval(() => {
541 const busy = document.querySelector(".bar:focus") || (document.activeElement && document.activeElement.tagName === "INPUT");
542 if (document.visibilityState === "visible" && !busy) location.reload();
543 }, 120000);
544 }
545})();
546`;
547
548const STYLE = `
549@font-face{font-family:"Hanken Grotesk";font-weight:400 700;font-display:swap;src:url(/fonts/hanken-grotesk.woff2) format("woff2")}
550@font-face{font-family:"Bricolage Grotesque";font-weight:500 700;font-display:swap;src:url(/fonts/bricolage-grotesque.woff2) format("woff2")}
551@font-face{font-family:"IBM Plex Mono";font-weight:400;font-display:swap;src:url(/fonts/ibm-plex-mono.woff2) format("woff2")}
552:root{--bg:#0f0f11;--surface:#161618;--raised:#1e1e21;--line:#28282c;--line-strong:#38383e;--fg:#ededef;--soft:#dcdce0;--muted:#a0a0a8;--faint:#86868e;
553--up:#86efc4;--warn:#ffbd8c;--down:#ff8394;--lav:#b6a8ff;--info:#8ec5ff;
554--sans:"Hanken Grotesk",ui-sans-serif,system-ui,sans-serif;--display:"Bricolage Grotesque",ui-sans-serif,system-ui,sans-serif;--mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color-scheme:dark}
555*{box-sizing:border-box}
556html{-webkit-text-size-adjust:100%}
557body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 var(--sans);-webkit-font-smoothing:antialiased}
558a{color:inherit}
559a:focus-visible,.bar:focus-visible,button:focus-visible,input:focus-visible{outline:2px solid var(--lav);outline-offset:3px;border-radius:4px}
560.page{max-width:760px;margin:0 auto;padding:0 16px 48px}
561.top{display:flex;align-items:center;justify-content:space-between;gap:16px;height:72px;border-bottom:1px solid var(--line)}
562.brand{display:flex;align-items:center;gap:12px;text-decoration:none}
563.brand span{font:600 20px/1 var(--display);letter-spacing:-.01em;color:var(--muted);padding-left:12px;border-left:1px solid var(--line-strong)}
564.logo{height:22px;width:auto;display:block}.logo rect{fill:var(--fg)}.logo rect.one{fill:var(--lav)}
565.out{font-size:13px;color:var(--muted);text-decoration:none}.out:hover{color:var(--fg)}
566.banner{display:flex;gap:14px;align-items:flex-start;margin-top:32px;padding:20px 22px;border:1px solid var(--line);border-radius:14px;background:var(--surface)}
567.banner.up{border-color:rgba(134,239,196,.28);background:linear-gradient(0deg,rgba(134,239,196,.04),rgba(134,239,196,.04)),var(--surface)}
568.banner.degraded{border-color:rgba(255,189,140,.4);background:linear-gradient(0deg,rgba(255,189,140,.06),rgba(255,189,140,.06)),var(--surface)}
569.banner.down{border-color:rgba(255,131,148,.45);background:linear-gradient(0deg,rgba(255,131,148,.07),rgba(255,131,148,.07)),var(--surface)}
570.banner.maintenance{border-color:rgba(142,197,255,.4);background:linear-gradient(0deg,rgba(142,197,255,.06),rgba(142,197,255,.06)),var(--surface)}
571.banner h1{margin:0;font:600 26px/1.2 var(--display);letter-spacing:-.02em}
572.banner p{margin:6px 0 0;color:var(--muted);font-size:14px}
573.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--faint);flex:none}
574.dot.big{width:12px;height:12px;margin-top:9px;box-shadow:0 0 0 4px rgba(134,239,196,.12)}
575.dot.up{background:var(--up)}.dot.degraded,.dot.partial{background:var(--warn)}.dot.down{background:var(--down)}.dot.maintenance{background:var(--info)}.dot.unmonitored,.dot.unknown{background:var(--faint)}
576.dot.big.degraded{box-shadow:0 0 0 4px rgba(255,189,140,.14)}.dot.big.down{box-shadow:0 0 0 4px rgba(255,131,148,.16)}.dot.big.maintenance{box-shadow:0 0 0 4px rgba(142,197,255,.14)}.dot.big.unknown{box-shadow:none}
577.block{margin-top:40px}
578.block h2,.month h2{margin:0 0 12px;font:600 13px/1.4 var(--sans);color:var(--muted);letter-spacing:.02em}
579.block-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
580.block-head a{font-size:13px;color:var(--muted);text-decoration:none}.block-head a:hover{color:var(--fg)}
581.components{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:14px;background:var(--surface)}
582.component{padding:16px 18px 14px}
583.component+.component{border-top:1px solid var(--line)}
584.row{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
585.name{display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 10px;min-width:0}
586.name h3{margin:0;font-size:15px;font-weight:600}
587.address{font:12px/1.4 var(--mono);color:var(--faint);overflow-wrap:anywhere}
588.state{display:flex;align-items:center;gap:7px;font-size:13px;font-weight:500;white-space:nowrap}
589.s-up{color:var(--up)}.s-degraded,.s-partial{color:var(--warn)}.s-down{color:var(--down)}.s-maintenance{color:var(--info)}.s-unmonitored{color:var(--faint)}
590.seen{margin:2px 0 0;font-size:12px;color:var(--faint)}
591.unchecked{margin:8px 0 2px;font-size:13px;color:var(--faint)}
592.history{margin-top:12px}
593.bar{list-style:none;margin:0;padding:0;display:flex;gap:2px;height:30px}
594.bar li{flex:1 1 0;min-width:0;border-radius:2px;background:var(--line);transition:opacity .12s}
595.bar li.up{background:var(--up);opacity:.85}.bar li.degraded{background:var(--warn)}.bar li.down{background:var(--down)}
596.bar:hover li,.bar:focus li{opacity:.55}.bar li.on,.bar:hover li:hover{opacity:1;transform:scaleY(1.08)}
597.legend{display:flex;justify-content:space-between;margin-top:6px;font-size:12px;color:var(--faint)}
598.legend .uptime{color:var(--muted)}
599.legend .short{display:none}
600.detail{margin:4px 0 0;min-height:18px;font-size:12px;color:var(--soft)}
601.incident,.card{border:1px solid var(--line);border-radius:14px;background:var(--surface);padding:16px 18px;margin-bottom:12px}
602.incident.open.degraded{border-color:rgba(255,189,140,.4)}.incident.open.down{border-color:rgba(255,131,148,.45)}.incident.maint{border-color:rgba(142,197,255,.35)}
603.incident header,.page-head{display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;gap:6px 12px}
604.incident h3{margin:0;font-size:15px;font-weight:600}
605.incident h3 a,.line-head a{text-decoration:none}.incident h3 a:hover,.line-head a:hover{text-decoration:underline;text-underline-offset:3px}
606.page-head{margin-top:20px}
607.page-head h1{margin:0;font:600 24px/1.25 var(--display);letter-spacing:-.02em;overflow-wrap:anywhere}
608.crumbs{margin:24px 0 0;font-size:13px;color:var(--faint)}.crumbs a{color:var(--muted);text-decoration:none}.crumbs a:hover{color:var(--fg)}
609.pill{font-size:12px;font-weight:500;padding:1px 8px;border-radius:999px;border:1px solid var(--line-strong);color:var(--muted);white-space:nowrap}
610.pill.degraded{color:var(--warn);border-color:rgba(255,189,140,.4)}.pill.down{color:var(--down);border-color:rgba(255,131,148,.45)}.pill.maint{color:var(--info);border-color:rgba(142,197,255,.4)}
611.meta{margin:4px 0 0;font-size:12px;color:var(--faint)}
612.impacts{display:flex;flex-wrap:wrap;gap:4px 14px;margin-top:6px}
613.affects{font-size:12px;color:var(--muted)}
614.impact{margin-left:4px;font-weight:500}.i-degraded,.i-partial_outage{color:var(--warn)}.i-major_outage{color:var(--down)}.i-operational{color:var(--up)}
615.updates{list-style:none;margin:12px 0 0;padding:0 0 0 14px;border-left:1px solid var(--line-strong)}
616.card>.updates{margin-top:0}
617.updates li+li{margin-top:12px}
618.when{margin:0;font-size:12px;color:var(--faint)}.when.latest{margin-top:8px}.when strong{color:var(--soft);font-weight:600;margin-right:4px}
619.text p,.text li{margin:4px 0 0;font-size:14px;color:var(--soft)}
620.text ul{margin:4px 0 0;padding-left:20px}
621.empty{margin:0;color:var(--muted);font-size:14px;padding:16px 18px;border:1px dashed var(--line-strong);border-radius:14px}
622.muted{color:var(--muted);font-size:14px}
623.note{margin:14px 0 0;font-size:14px;color:var(--soft);padding:10px 14px;border:1px solid rgba(182,168,255,.35);border-radius:10px;background:rgba(182,168,255,.06)}
624.lines{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:14px;background:var(--surface)}
625.line{padding:12px 18px}.line+.line{border-top:1px solid var(--line)}
626.line-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;font-weight:600;font-size:15px}
627.tag{font-size:12px;font-weight:500;color:var(--lav);text-decoration:none;white-space:nowrap}
628.last{margin:4px 0 0;font-size:13px;color:var(--muted)}
629.month{margin-top:28px}.month:has(.none){margin-top:18px}.month:has(.none) h2{margin-bottom:2px}
630.postmortem .pm-section+.pm-section{margin-top:18px}
631.pm-section h3{margin:0;font-size:15px;font-weight:600}
632form.subscribe{display:flex;gap:8px;flex-wrap:wrap}
633input[type=email]{flex:1 1 220px;min-width:0;font:inherit;font-size:14px;color:var(--fg);background:var(--bg);border:1px solid var(--line-strong);border-radius:8px;padding:9px 12px}
634input[type=email]::placeholder{color:var(--faint)}
635button{font:inherit;font-size:14px;font-weight:600;color:var(--bg);background:var(--lav);border:0;border-radius:8px;padding:9px 16px;cursor:pointer}
636button:hover{background:#c8bdff}
637.trap{position:absolute;left:-9999px;width:1px;height:1px;opacity:0}
638.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
639.feeds{margin:10px 0 0;font-size:13px;color:var(--faint)}.feeds a,.muted a,.note a{color:var(--soft)}
640.stack{display:flex;flex-direction:column;gap:10px}.stack label{font-size:13px;color:var(--muted)}
641.stack button{align-self:flex-start}.stack input[type=email]{flex:none}
642.page-head+.card,.page-head+.muted+.card{margin-top:16px}
643.month .none{margin:0;font-size:13px;color:var(--faint)}
644fieldset{border:0;padding:0;margin:6px 0 0}legend{font-size:13px;color:var(--muted);padding:0}
645fieldset .muted{margin:2px 0 8px;font-size:13px}
646.checks{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:6px}
647.check{display:flex;align-items:center;gap:8px;font-size:14px;color:var(--soft)!important;padding:6px 10px;border:1px solid var(--line);border-radius:8px}
648.check input{accent-color:var(--lav)}
649.message{margin-top:24px}.message h1{margin:0;font:600 22px/1.25 var(--display)}.message p{color:var(--muted);font-size:14px}
650.about p{margin:0;font-size:13px;color:var(--muted);max-width:68ch}
651.about dl{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;margin:16px 0 0;font-size:13px}
652.about dt{color:var(--soft)}.about dd{margin:0;color:var(--faint)}
653.foot{display:flex;flex-wrap:wrap;gap:8px;margin-top:48px;padding-top:20px;border-top:1px solid var(--line);font-size:13px;color:var(--faint)}
654.foot a{text-decoration:none;color:var(--muted)}.foot a:hover{color:var(--fg)}
655@media (max-width:640px){
656.bar li:nth-child(-n+${HISTORY_DAYS - 30}){display:none}
657.bar{gap:3px}
658.legend .long{display:none}.legend .short{display:inline}
659.banner{padding:16px}.banner h1{font-size:22px}
660.component,.incident,.card,.line{padding:14px}
661.about dl{grid-template-columns:1fr;gap:2px}.about dd{margin-bottom:8px}
662}
663@media (prefers-reduced-motion:reduce){.bar li{transition:none}}
664`;