g1t/apps/status/src/render.ts

684 lines40,534 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
Fast pages, required checks on the branch, self-hosted runners, honest incidents5 * 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:
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas8 * 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";
Fast pages, required checks on the branch, self-hosted runners, honest incidents20import { timeIn, validZone } from "./time.ts";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas21
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;
Fast pages, required checks on the branch, self-hosted runners, honest incidents34 /** The reader's time zone (time.ts `readZone`); UTC when absent. */
35 zone?: string;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas36};
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
Fast pages, required checks on the branch, self-hosted runners, honest incidents51/**
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);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas65}
66
Fast pages, required checks on the branch, self-hosted runners, honest incidents67/** A time in the reader's zone; the UTC instant in `datetime` and, in words, on hover. */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas68function time(at: string, relative = false): string {
Fast pages, required checks on the branch, self-hosted runners, honest incidents69 return `<time datetime="${escape(at)}" title="${escape(timeIn(at, "UTC"))}"${relative ? " data-relative" : ""}>${escape(timeLabel(at))}</time>`;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas70}
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 {
Fast pages, required checks on the branch, self-hosted runners, honest incidents305 zoned(options);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas306 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 {
Fast pages, required checks on the branch, self-hosted runners, honest incidents374 zoned(options);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas375 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 {
Fast pages, required checks on the branch, self-hosted runners, honest incidents402 zoned(options);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas403 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 {
Fast pages, required checks on the branch, self-hosted runners, honest incidents430 zoned(options);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas431 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" });
Fast pages, required checks on the branch, self-hosted runners, honest incidents513 const fmt = new Intl.DateTimeFormat(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit", timeZoneName: "short" });
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas514 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");
Fast pages, required checks on the branch, self-hosted runners, honest incidents525 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));
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas529 }
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`;