| 1 | /** |
| 2 | * What /status shows and the footer's dot is coloured by: each public part |
| 3 | * of g1t, whether it answered a quick check, and one line for the whole. |
| 4 | * |
| 5 | * The checks themselves run on the server (status.server.ts). This module |
| 6 | * only says what a check's result means, so it can be tested on its own. |
| 7 | * Fields are snake_case: /status.json is public, like the API. |
| 8 | */ |
| 9 | |
| 10 | export type ComponentState = "up" | "degraded" | "down" | "unmonitored"; |
| 11 | export type OverallState = "up" | "degraded" | "down" | "unknown"; |
| 12 | |
| 13 | export type ComponentKey = |
| 14 | | "site" |
| 15 | | "api" |
| 16 | | "git" |
| 17 | | "mcp" |
| 18 | | "docs" |
| 19 | | "deployments" |
| 20 | | "agents" |
| 21 | | "sandboxes" |
| 22 | | "billing"; |
| 23 | |
| 24 | export type ComponentInfo = { |
| 25 | key: ComponentKey; |
| 26 | name: string; |
| 27 | /** Where people meet it. */ |
| 28 | address: string; |
| 29 | /** What the check actually looks at, said plainly on the page. */ |
| 30 | checks: string; |
| 31 | /** Whether g1t as a whole is down when this is. */ |
| 32 | core: boolean; |
| 33 | }; |
| 34 | |
| 35 | /** The public parts of g1t, in the order the page lists them. */ |
| 36 | export const COMPONENTS: ComponentInfo[] = [ |
| 37 | { |
| 38 | key: "site", |
| 39 | name: "Website and sign-in", |
| 40 | address: "g1t.sh", |
| 41 | checks: "The account service every page asks who is signed in.", |
| 42 | core: true, |
| 43 | }, |
| 44 | { key: "api", name: "API", address: "api.g1t.sh", checks: "The API's front page, over the public internet.", core: true }, |
| 45 | { |
| 46 | key: "git", |
| 47 | name: "Git and repositories", |
| 48 | address: "g1t.sh/<owner>/<repo>.git", |
| 49 | checks: "The repository service looking up a public repository. Clones and pushes themselves are not exercised.", |
| 50 | core: true, |
| 51 | }, |
| 52 | { key: "mcp", name: "MCP server", address: "mcp.g1t.sh", checks: "The MCP server's description, over the public internet.", core: false }, |
| 53 | { key: "docs", name: "Documentation", address: "docs.g1t.sh", checks: "The documentation's front page.", core: false }, |
| 54 | { |
| 55 | key: "deployments", |
| 56 | name: "Deployments", |
| 57 | address: "*.g1t.page", |
| 58 | checks: "g1t.page answering. Each deployed app is not checked one by one.", |
| 59 | core: false, |
| 60 | }, |
| 61 | { |
| 62 | key: "agents", |
| 63 | name: "Agents' model proxy", |
| 64 | address: "models.g1t.sh", |
| 65 | checks: "The proxy g1t's agents reach their model through. The model providers behind it are not checked.", |
| 66 | core: false, |
| 67 | }, |
| 68 | { |
| 69 | key: "sandboxes", |
| 70 | name: "Sandboxes", |
| 71 | address: "Agents, checks, workflows and builds", |
| 72 | checks: "Starting a sandbox costs money and takes seconds, so it is not checked from here yet.", |
| 73 | core: false, |
| 74 | }, |
| 75 | { |
| 76 | key: "billing", |
| 77 | name: "Billing", |
| 78 | address: "g1t.sh/<workspace>/-/billing", |
| 79 | checks: "The billing service reading its price book. Stripe itself is not checked.", |
| 80 | core: false, |
| 81 | }, |
| 82 | ]; |
| 83 | |
| 84 | /** A check's raw outcome. */ |
| 85 | export type ProbeResult = { |
| 86 | ok: boolean; |
| 87 | /** How long it took, in milliseconds. */ |
| 88 | ms: number; |
| 89 | /** Why it failed, in a few words: "timed out", "HTTP 502". */ |
| 90 | error?: string; |
| 91 | }; |
| 92 | |
| 93 | /** Slower than this, a part that answered counts as degraded. */ |
| 94 | export const SLOW_MS = 1500; |
| 95 | |
| 96 | export type ComponentStatus = { |
| 97 | key: ComponentKey; |
| 98 | name: string; |
| 99 | address: string; |
| 100 | checks: string; |
| 101 | state: ComponentState; |
| 102 | /** What was seen, for the page: "Answered in 84 ms", "Timed out". */ |
| 103 | detail: string; |
| 104 | latency_ms: number | null; |
| 105 | }; |
| 106 | |
| 107 | /** What a check's result means for its part. `null` is a part with no check. */ |
| 108 | export function classify(result: ProbeResult | null, slowMs = SLOW_MS): { state: ComponentState; detail: string } { |
| 109 | if (!result) return { state: "unmonitored", detail: "Not monitored yet" }; |
| 110 | if (!result.ok) { |
| 111 | const why = result.error ?? "no answer"; |
| 112 | return { state: "down", detail: `Failed: ${why}` }; |
| 113 | } |
| 114 | const ms = Math.round(result.ms); |
| 115 | if (ms > slowMs) return { state: "degraded", detail: `Slow: answered in ${ms} ms` }; |
| 116 | return { state: "up", detail: `Answered in ${ms} ms` }; |
| 117 | } |
| 118 | |
| 119 | /** One component's row, from its check. */ |
| 120 | export function componentStatus(info: ComponentInfo, result: ProbeResult | null, slowMs = SLOW_MS): ComponentStatus { |
| 121 | const { state, detail } = classify(result, slowMs); |
| 122 | return { |
| 123 | key: info.key, |
| 124 | name: info.name, |
| 125 | address: info.address, |
| 126 | checks: info.checks, |
| 127 | state, |
| 128 | detail, |
| 129 | latency_ms: result ? Math.round(result.ms) : null, |
| 130 | }; |
| 131 | } |
| 132 | |
| 133 | export type IncidentUpdate = { at: string; text: string }; |
| 134 | |
| 135 | export type Incident = { |
| 136 | id: string; |
| 137 | title: string; |
| 138 | /** How bad it is while open. */ |
| 139 | impact: "degraded" | "down"; |
| 140 | components: ComponentKey[]; |
| 141 | started_at: string; |
| 142 | /** Null while it is still going on. */ |
| 143 | resolved_at: string | null; |
| 144 | /** Newest first. */ |
| 145 | updates: IncidentUpdate[]; |
| 146 | }; |
| 147 | |
| 148 | /** |
| 149 | * Incidents, newest first. Written by hand, in code, for now: add one when |
| 150 | * something breaks, with updates as it is fixed, and set `resolved_at` once |
| 151 | * it is. The page shows the last 90 days. |
| 152 | */ |
| 153 | export const INCIDENTS: Incident[] = []; |
| 154 | |
| 155 | /** How far back the page lists incidents. */ |
| 156 | export const INCIDENT_DAYS = 90; |
| 157 | |
| 158 | export type Overall = { state: OverallState; line: string }; |
| 159 | |
| 160 | const RANK: Record<OverallState, number> = { unknown: 0, up: 1, degraded: 2, down: 3 }; |
| 161 | |
| 162 | function worse(a: OverallState, b: OverallState): OverallState { |
| 163 | return RANK[b] > RANK[a] ? b : a; |
| 164 | } |
| 165 | |
| 166 | /** |
| 167 | * One line for the whole: down when a core part is down, degraded when any |
| 168 | * part is down or slow or an incident is open, up when every checked part |
| 169 | * answered. Parts that are not checked never make it look better or worse. |
| 170 | */ |
| 171 | export function summarize(components: ComponentStatus[], incidents: Incident[] = []): Overall { |
| 172 | const core = new Set(COMPONENTS.filter((c) => c.core).map((c) => c.key)); |
| 173 | const monitored = components.filter((c) => c.state !== "unmonitored"); |
| 174 | const open = incidents.filter((incident) => incident.resolved_at == null); |
| 175 | if (monitored.length === 0 && open.length === 0) { |
| 176 | return { state: "unknown", line: "Status is not available right now" }; |
| 177 | } |
| 178 | let state: OverallState = monitored.length > 0 ? "up" : "unknown"; |
| 179 | for (const c of monitored) { |
| 180 | if (c.state === "down") state = worse(state, core.has(c.key) ? "down" : "degraded"); |
| 181 | else if (c.state === "degraded") state = worse(state, "degraded"); |
| 182 | } |
| 183 | for (const incident of open) state = worse(state, incident.impact); |
| 184 | |
| 185 | const failing = monitored.filter((c) => c.state === "down" || c.state === "degraded").map((c) => c.name); |
| 186 | if (state === "up") return { state, line: "All monitored systems are working" }; |
| 187 | if (state === "down") { |
| 188 | return { state, line: failing.length ? `Major outage: ${failing.join(", ")}` : "Major outage" }; |
| 189 | } |
| 190 | if (state === "degraded") { |
| 191 | if (failing.length) return { state, line: `Some systems are having trouble: ${failing.join(", ")}` }; |
| 192 | return { state, line: open[0] ? `Investigating: ${open[0].title}` : "Some systems are having trouble" }; |
| 193 | } |
| 194 | return { state, line: "Status is not available right now" }; |
| 195 | } |
| 196 | |
| 197 | export type StatusReport = { |
| 198 | checked_at: string; |
| 199 | overall: Overall; |
| 200 | components: ComponentStatus[]; |
| 201 | incidents: Incident[]; |
| 202 | }; |
| 203 | |
| 204 | /** Incidents still open, or resolved within `days` of `now`. */ |
| 205 | export function recentIncidents(incidents: Incident[], now: Date, days = INCIDENT_DAYS): Incident[] { |
| 206 | const since = now.getTime() - days * 24 * 60 * 60 * 1000; |
| 207 | return incidents.filter((incident) => incident.resolved_at == null || Date.parse(incident.resolved_at) >= since); |
| 208 | } |
| 209 | |
| 210 | /** The whole report, from each part's check. */ |
| 211 | export function report(results: Partial<Record<ComponentKey, ProbeResult | null>>, now: Date, incidents = INCIDENTS): StatusReport { |
| 212 | const components = COMPONENTS.map((info) => componentStatus(info, results[info.key] ?? null)); |
| 213 | const recent = recentIncidents(incidents, now); |
| 214 | return { checked_at: now.toISOString(), overall: summarize(components, recent), components, incidents: recent }; |
| 215 | } |
| 216 | |
| 217 | /** Words for a part's state. */ |
| 218 | export const STATE_LABEL: Record<ComponentState, string> = { |
| 219 | up: "Operational", |
| 220 | degraded: "Degraded", |
| 221 | down: "Down", |
| 222 | unmonitored: "Not monitored yet", |
| 223 | }; |
| 224 | |
| 225 | /** The dot's colour for a state, as a Tailwind background class. */ |
| 226 | export function dotClass(state: ComponentState | OverallState | null | undefined): string { |
| 227 | switch (state) { |
| 228 | case "up": |
| 229 | return "bg-accent"; |
| 230 | case "degraded": |
| 231 | return "bg-warn"; |
| 232 | case "down": |
| 233 | return "bg-danger"; |
| 234 | default: |
| 235 | return "bg-faint"; |
| 236 | } |
| 237 | } |