flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/status/src/render.ts

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