| 1 | /** |
| 2 | * What a check's result means, how the parts add up to one line for the |
| 3 | * whole, and the 90 days of history behind each uptime bar. No Workers |
| 4 | * imports, so it is tested under Node. |
| 5 | */ |
| 6 | import type { |
| 7 | ComponentImpact, |
| 8 | IncidentImpact, |
| 9 | IncidentStatus, |
| 10 | MaintenanceState, |
| 11 | StatusComponent, |
| 12 | StatusComponentState, |
| 13 | StatusIncident, |
| 14 | StatusMaintenance, |
| 15 | StatusOverall, |
| 16 | StatusOverallState, |
| 17 | StatusReport, |
| 18 | } from "@g1t/contracts"; |
| 19 | |
| 20 | import type { ComponentInfo } from "./components.ts"; |
| 21 | import type { ProbeResult } from "./probe.ts"; |
| 22 | |
| 23 | /** Slower than this, a part that answered counts as degraded. */ |
| 24 | export const SLOW_MS = 1500; |
| 25 | /** How many days of history the page shows and keeps. */ |
| 26 | export const HISTORY_DAYS = 90; |
| 27 | /** Checks older than this mean the checker has stopped: the page says so. */ |
| 28 | export const STALE_MS = 10 * 60 * 1000; |
| 29 | |
| 30 | /** What a check's result means for its part. `null` is a part with no check. */ |
| 31 | export function classify(result: ProbeResult | null, slowMs = SLOW_MS): { state: StatusComponentState; detail: string } { |
| 32 | if (!result) return { state: "unmonitored", detail: "Not monitored yet" }; |
| 33 | if (!result.ok) return { state: "down", detail: `Failed: ${result.error ?? "no answer"}` }; |
| 34 | if (result.degraded) return { state: "degraded", detail: result.degraded }; |
| 35 | const ms = Math.round(result.ms); |
| 36 | if (ms > slowMs) return { state: "degraded", detail: `Slow: answered in ${ms} ms` }; |
| 37 | return { state: "up", detail: `Answered in ${ms} ms` }; |
| 38 | } |
| 39 | |
| 40 | /** A part's state at its last check, as stored. */ |
| 41 | export type Current = { state: StatusComponentState; detail: string; latency_ms: number | null }; |
| 42 | |
| 43 | /** One part's checks on one UTC day, as stored. */ |
| 44 | export type DayRow = { |
| 45 | component: string; |
| 46 | /** `YYYY-MM-DD`, UTC. */ |
| 47 | day: string; |
| 48 | checks: number; |
| 49 | up: number; |
| 50 | degraded: number; |
| 51 | down: number; |
| 52 | latency_total: number; |
| 53 | latency_count: number; |
| 54 | }; |
| 55 | |
| 56 | /** One day's square on a bar. */ |
| 57 | export type DayBar = { |
| 58 | day: string; |
| 59 | state: "up" | "degraded" | "down" | "none"; |
| 60 | /** Share of checks that answered, 0 to 100; null with none. */ |
| 61 | uptime: number | null; |
| 62 | checks: number; |
| 63 | failed: number; |
| 64 | slow: number; |
| 65 | avg_ms: number | null; |
| 66 | /** Incidents on this part that were open some time that day. */ |
| 67 | incidents: string[]; |
| 68 | }; |
| 69 | |
| 70 | const DAY_MS = 24 * 60 * 60 * 1000; |
| 71 | |
| 72 | /** `YYYY-MM-DD` of a time, in UTC. */ |
| 73 | export function dayOf(at: Date | number): string { |
| 74 | return new Date(at).toISOString().slice(0, 10); |
| 75 | } |
| 76 | |
| 77 | /** The last `count` days, oldest first, ending today. */ |
| 78 | export function lastDays(now: Date, count = HISTORY_DAYS): string[] { |
| 79 | const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); |
| 80 | return Array.from({ length: count }, (_, i) => dayOf(today - (count - 1 - i) * DAY_MS)); |
| 81 | } |
| 82 | |
| 83 | /** |
| 84 | * Share of checks answered, rounded down to hundredths so a single failure |
| 85 | * never shows as 100%. |
| 86 | */ |
| 87 | export function uptimeOf(answered: number, checks: number): number | null { |
| 88 | if (checks <= 0) return null; |
| 89 | return Math.floor((answered / checks) * 10000) / 100; |
| 90 | } |
| 91 | |
| 92 | /** Words for an uptime: "100%", "99.93%". */ |
| 93 | export function percent(uptime: number | null): string { |
| 94 | if (uptime == null) return "No data"; |
| 95 | return uptime >= 100 ? "100%" : `${uptime.toFixed(2)}%`; |
| 96 | } |
| 97 | |
| 98 | const LEVEL = { none: 0, up: 1, degraded: 2, down: 3 } as const; |
| 99 | |
| 100 | /** |
| 101 | * A day's colour: answered at least 99.9% of the time is up (one blip in a |
| 102 | * day of minutely checks), unless over a quarter of answers were slow; at |
| 103 | * least 95% is degraded; less is down. An incident on the part that day |
| 104 | * colours it at least as badly as its impact. |
| 105 | */ |
| 106 | export function dayState(row: Pick<DayRow, "checks" | "up" | "degraded" | "down"> | null, incidentImpact: IncidentImpact | null = null): DayBar["state"] { |
| 107 | let state: DayBar["state"] = "none"; |
| 108 | if (row && row.checks > 0) { |
| 109 | const uptime = ((row.up + row.degraded) / row.checks) * 100; |
| 110 | if (uptime >= 99.9) state = row.degraded / row.checks > 0.25 ? "degraded" : "up"; |
| 111 | else if (uptime >= 95) state = "degraded"; |
| 112 | else state = "down"; |
| 113 | } |
| 114 | if (incidentImpact && LEVEL[incidentImpact] > LEVEL[state]) state = incidentImpact; |
| 115 | return state; |
| 116 | } |
| 117 | |
| 118 | /** Whether an incident was open at some time during `day`. */ |
| 119 | export function openOn(incident: Pick<StatusIncident, "started_at" | "resolved_at">, day: string): boolean { |
| 120 | const start = Date.parse(`${day}T00:00:00Z`); |
| 121 | const end = start + DAY_MS; |
| 122 | const began = Date.parse(incident.started_at); |
| 123 | const ended = incident.resolved_at ? Date.parse(incident.resolved_at) : Number.POSITIVE_INFINITY; |
| 124 | return began < end && ended >= start; |
| 125 | } |
| 126 | |
| 127 | /** A part's bar: one square per day, oldest first. */ |
| 128 | export function barFor( |
| 129 | key: string, |
| 130 | rows: DayRow[], |
| 131 | incidents: Pick<StatusIncident, "title" | "started_at" | "resolved_at" | "component_impacts">[], |
| 132 | days: string[], |
| 133 | ): DayBar[] { |
| 134 | const byDay = new Map(rows.filter((r) => r.component === key).map((r) => [r.day, r])); |
| 135 | const mine = incidents |
| 136 | .map((i) => ({ incident: i, impact: i.component_impacts.find((c) => c.key === key)?.impact ?? null })) |
| 137 | .filter((m) => m.impact != null && m.impact !== "operational"); |
| 138 | return days.map((day) => { |
| 139 | const row = byDay.get(day) ?? null; |
| 140 | const during = mine.filter((m) => openOn(m.incident, day)); |
| 141 | const impact = during.some((m) => m.impact === "major_outage") ? "down" : during.length ? "degraded" : null; |
| 142 | return { |
| 143 | day, |
| 144 | state: dayState(row, impact), |
| 145 | uptime: row ? uptimeOf(row.up + row.degraded, row.checks) : null, |
| 146 | checks: row?.checks ?? 0, |
| 147 | failed: row?.down ?? 0, |
| 148 | slow: row?.degraded ?? 0, |
| 149 | avg_ms: row && row.latency_count > 0 ? Math.round(row.latency_total / row.latency_count) : null, |
| 150 | incidents: during.map((m) => m.incident.title), |
| 151 | }; |
| 152 | }); |
| 153 | } |
| 154 | |
| 155 | /** A part's uptime over the days given. */ |
| 156 | export function uptimeOver(key: string, rows: DayRow[]): number | null { |
| 157 | let checks = 0; |
| 158 | let answered = 0; |
| 159 | for (const row of rows) { |
| 160 | if (row.component !== key) continue; |
| 161 | checks += row.checks; |
| 162 | answered += row.up + row.degraded; |
| 163 | } |
| 164 | return uptimeOf(answered, checks); |
| 165 | } |
| 166 | |
| 167 | |
| 168 | const RANK: Record<StatusOverallState, number> = { unknown: 0, up: 1, maintenance: 1.5, degraded: 2, down: 3 }; |
| 169 | |
| 170 | function worse(a: StatusOverallState, b: StatusOverallState): StatusOverallState { |
| 171 | return RANK[b] > RANK[a] ? b : a; |
| 172 | } |
| 173 | |
| 174 | /** Words for an incident's status. */ |
| 175 | export const INCIDENT_STATUS: Record<IncidentStatus, string> = { |
| 176 | investigating: "Investigating", |
| 177 | identified: "Identified", |
| 178 | monitoring: "Monitoring", |
| 179 | resolved: "Resolved", |
| 180 | }; |
| 181 | |
| 182 | /** Words for what an incident does to a part. */ |
| 183 | export const IMPACT_WORD: Record<ComponentImpact, string> = { |
| 184 | operational: "Operational", |
| 185 | degraded: "Degraded performance", |
| 186 | partial_outage: "Partial outage", |
| 187 | major_outage: "Major outage", |
| 188 | }; |
| 189 | |
| 190 | /** A part's state for an impact on it. */ |
| 191 | export const IMPACT_STATE: Record<ComponentImpact, StatusComponentState> = { |
| 192 | operational: "up", |
| 193 | degraded: "degraded", |
| 194 | partial_outage: "partial", |
| 195 | major_outage: "down", |
| 196 | }; |
| 197 | |
| 198 | /** How bad each state is: the worse of a check and an incident wins. */ |
| 199 | const STATE_RANK: Record<StatusComponentState, number> = { unmonitored: 0, up: 1, maintenance: 1, degraded: 2, partial: 3, down: 4 }; |
| 200 | |
| 201 | /** An incident's impact as a whole: `down` when any part has a major outage. */ |
| 202 | export function overallImpact(impacts: { impact: ComponentImpact }[]): IncidentImpact { |
| 203 | return impacts.some((i) => i.impact === "major_outage") ? "down" : "degraded"; |
| 204 | } |
| 205 | |
| 206 | /** |
| 207 | * What the page shows for a part: the worse of its last check and what |
| 208 | * open incidents say about it. During maintenance it is "Under |
| 209 | * maintenance", unless an incident says worse, since checks failing in a |
| 210 | * window are expected. |
| 211 | */ |
| 212 | export function effectiveState(check: StatusComponentState, impacts: ComponentImpact[], underMaintenance: boolean): StatusComponentState { |
| 213 | let reported: StatusComponentState | null = null; |
| 214 | for (const impact of impacts) { |
| 215 | const state = IMPACT_STATE[impact]; |
| 216 | if (reported == null || STATE_RANK[state] > STATE_RANK[reported]) reported = state; |
| 217 | } |
| 218 | if (underMaintenance && (reported == null || reported === "up")) return "maintenance"; |
| 219 | if (reported == null) return check; |
| 220 | if (check === "unmonitored" || check === "maintenance") return reported; |
| 221 | return STATE_RANK[reported] > STATE_RANK[check] ? reported : check; |
| 222 | } |
| 223 | |
| 224 | /** |
| 225 | * Where maintenance stands at `now`. Cancelled and completed are final; |
| 226 | * otherwise the window decides, so the page is right even if the |
| 227 | * minutely job that moves it along is late. |
| 228 | */ |
| 229 | export function maintenanceState(m: Pick<StatusMaintenance, "state" | "starts_at" | "ends_at">, now: Date): MaintenanceState { |
| 230 | if (m.state === "cancelled" || m.state === "completed") return m.state; |
| 231 | const t = now.getTime(); |
| 232 | if (t >= Date.parse(m.ends_at)) return "completed"; |
| 233 | if (t >= Date.parse(m.starts_at) || m.state === "in_progress") return "in_progress"; |
| 234 | return "scheduled"; |
| 235 | } |
| 236 | |
| 237 | /** The parts under maintenance at `now`. */ |
| 238 | export function underMaintenance( |
| 239 | list: Pick<StatusMaintenance, "state" | "starts_at" | "ends_at" | "components">[], |
| 240 | now: Date, |
| 241 | ): Set<string> { |
| 242 | const keys = new Set<string>(); |
| 243 | for (const m of list) if (maintenanceState(m, now) === "in_progress") for (const k of m.components) keys.add(k); |
| 244 | return keys; |
| 245 | } |
| 246 | |
| 247 | /** |
| 248 | * The whole, from its parts and its open incidents: a major outage when a |
| 249 | * core part is down or an incident says so; a partial outage when any part |
| 250 | * is down or partly down, or an incident is open; degraded performance |
| 251 | * when parts are only slow; under maintenance when work is under way and |
| 252 | * nothing else is wrong. Parts with no check never make it look better or |
| 253 | * worse. |
| 254 | */ |
| 255 | export function summarize( |
| 256 | parts: Pick<StatusComponent, "key" | "name" | "state">[], |
| 257 | core: Set<string>, |
| 258 | open: Pick<StatusIncident, "title" | "impact" | "status">[] = [], |
| 259 | maintenance: Pick<StatusMaintenance, "title">[] = [], |
| 260 | ): StatusOverall { |
| 261 | const monitored = parts.filter((c) => c.state !== "unmonitored"); |
| 262 | if (monitored.length === 0 && open.length === 0) { |
| 263 | return { state: "unknown", title: "Status unavailable", line: "No checks have run yet." }; |
| 264 | } |
| 265 | let state: StatusOverallState = monitored.length > 0 ? "up" : "unknown"; |
| 266 | for (const c of monitored) { |
| 267 | if (c.state === "down") state = worse(state, core.has(c.key) ? "down" : "degraded"); |
| 268 | else if (c.state === "partial" || c.state === "degraded") state = worse(state, "degraded"); |
| 269 | else if (c.state === "maintenance") state = worse(state, "maintenance"); |
| 270 | } |
| 271 | for (const incident of open) state = worse(state, incident.impact); |
| 272 | |
| 273 | const down = monitored.filter((c) => c.state === "down" || c.state === "partial").map((c) => c.name); |
| 274 | const slow = monitored.filter((c) => c.state === "degraded").map((c) => c.name); |
| 275 | const lead = open[0]; |
| 276 | const incidentLine = lead ? `${INCIDENT_STATUS[lead.status]}: ${lead.title}.` : null; |
| 277 | const named = (names: string[]) => names.join(", "); |
| 278 | |
| 279 | if (state === "up") return { state, title: "All systems normal", line: "Every part of g1t answered its last check." }; |
| 280 | if (state === "maintenance") { |
| 281 | const work = maintenance[0]; |
| 282 | return { state, title: "Under maintenance", line: work ? `Planned work: ${work.title}.` : "Planned work is under way." }; |
| 283 | } |
| 284 | if (state === "down") { |
| 285 | return { |
| 286 | state, |
| 287 | title: "Major outage", |
| 288 | line: incidentLine ?? (down.length ? `Not answering: ${named(down)}.` : "g1t is not working right now."), |
| 289 | }; |
| 290 | } |
| 291 | if (state === "degraded") { |
| 292 | if (down.length || open.length) { |
| 293 | return { |
| 294 | state, |
| 295 | title: "Partial outage", |
| 296 | line: incidentLine ?? `Not answering: ${named(down)}${slow.length ? `. Slow: ${named(slow)}` : ""}.`, |
| 297 | }; |
| 298 | } |
| 299 | return { state, title: "Degraded performance", line: `Slow to answer: ${named(slow)}.` }; |
| 300 | } |
| 301 | return { state, title: "Status unavailable", line: "No checks have run yet." }; |
| 302 | } |
| 303 | |
| 304 | /** Open incidents, and those resolved within `days` of `now`, newest first. */ |
| 305 | export function recentIncidents<T extends Pick<StatusIncident, "started_at" | "resolved_at">>( |
| 306 | incidents: T[], |
| 307 | now: Date, |
| 308 | days = HISTORY_DAYS, |
| 309 | ): T[] { |
| 310 | const since = now.getTime() - days * DAY_MS; |
| 311 | return incidents |
| 312 | .filter((i) => i.resolved_at == null || Date.parse(i.resolved_at) >= since) |
| 313 | .sort((a, b) => Date.parse(b.started_at) - Date.parse(a.started_at)); |
| 314 | } |
| 315 | |
| 316 | /** Everything the page and the JSON show. */ |
| 317 | export type PageModel = { |
| 318 | report: StatusReport; |
| 319 | bars: Record<string, DayBar[]>; |
| 320 | /** The checks have not run for a while: the checker itself is in trouble. */ |
| 321 | stale: boolean; |
| 322 | }; |
| 323 | |
| 324 | /** The report, from what is stored. */ |
| 325 | export function buildPage(input: { |
| 326 | parts: ComponentInfo[]; |
| 327 | current: Map<string, Current>; |
| 328 | checkedAt: string | null; |
| 329 | days: DayRow[]; |
| 330 | incidents: StatusIncident[]; |
| 331 | maintenance?: StatusMaintenance[]; |
| 332 | now: Date; |
| 333 | }): PageModel { |
| 334 | const { parts, current, checkedAt, days, now } = input; |
| 335 | const window = lastDays(now); |
| 336 | const since = window[0]!; |
| 337 | const rows = days.filter((r) => r.day >= since); |
| 338 | const incidents = recentIncidents(input.incidents, now); |
| 339 | const open = incidents.filter((i) => i.resolved_at == null); |
| 340 | // Upcoming and under way, soonest first, each with its state as of now. |
| 341 | const maintenance = (input.maintenance ?? []) |
| 342 | .map((m) => ({ ...m, state: maintenanceState(m, now) })) |
| 343 | .filter((m) => m.state === "scheduled" || m.state === "in_progress") |
| 344 | .sort((a, b) => a.starts_at.localeCompare(b.starts_at)); |
| 345 | const working = underMaintenance(maintenance, now); |
| 346 | const components: StatusComponent[] = parts.map((info) => { |
| 347 | const seen = info.check.kind === "none" ? null : current.get(info.key); |
| 348 | const check: StatusComponentState = seen?.state ?? "unmonitored"; |
| 349 | const impacts = open.flatMap((i) => i.component_impacts.filter((c) => c.key === info.key).map((c) => c.impact)); |
| 350 | return { |
| 351 | key: info.key, |
| 352 | name: info.name, |
| 353 | address: info.address, |
| 354 | checks: info.checks, |
| 355 | state: effectiveState(check, impacts, working.has(info.key)), |
| 356 | check_state: check, |
| 357 | detail: seen?.detail ?? (info.check.kind === "none" ? "Not monitored yet" : "Not checked yet"), |
| 358 | latency_ms: seen?.latency_ms ?? null, |
| 359 | uptime_90d: info.check.kind === "none" ? null : uptimeOver(info.key, rows), |
| 360 | }; |
| 361 | }); |
| 362 | const core = new Set(parts.filter((p) => p.core).map((p) => p.key)); |
| 363 | const bars = Object.fromEntries(parts.map((p) => [p.key, barFor(p.key, rows, incidents, window)])); |
| 364 | const inProgress = maintenance.filter((m) => m.state === "in_progress"); |
| 365 | return { |
| 366 | report: { checked_at: checkedAt, overall: summarize(components, core, open, inProgress), components, incidents, maintenance }, |
| 367 | bars, |
| 368 | stale: checkedAt == null || now.getTime() - Date.parse(checkedAt) > STALE_MS, |
| 369 | }; |
| 370 | } |