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