Skip to content

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

362 linesCodeBlameRaw
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 */
6import 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
20import type { ComponentInfo } from "./components.ts";
21import type { ProbeResult } from "./probe.ts";
22
23/** Slower than this, a part that answered counts as degraded. */
24export const SLOW_MS = 1500;
25/** How many days of history the page shows and keeps. */
26export const HISTORY_DAYS = 90;
27/** Checks older than this mean the checker has stopped: the page says so. */
28export const STALE_MS = 10 * 60 * 1000;
29
30/** What a check's result means for its part. `null` is a part with no check. */
31export 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. */
41export type Current = { state: StatusComponentState; detail: string; latency_ms: number | null };
42
43/** One part's checks on one UTC day, as stored. */
44export 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. */
57export 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
70const DAY_MS = 24 * 60 * 60 * 1000;
71
72/** `YYYY-MM-DD` of a time, in UTC. */
73export 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. */
78export 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 */
87export 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%". */
93export function percent(uptime: number | null): string {
94 if (uptime == null) return "No data";
95 return uptime >= 100 ? "100%" : `${uptime.toFixed(2)}%`;
96}
97
98const 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 */
106export 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`. */
119export 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. */
128export 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. */
156export 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
167const RANK: Record<StatusOverallState, number> = { unknown: 0, up: 1, maintenance: 1.5, degraded: 2, down: 3 };
168
169function worse(a: StatusOverallState, b: StatusOverallState): StatusOverallState {
170 return RANK[b] > RANK[a] ? b : a;
171}
172
173/** Words for an incident's status. */
174export const INCIDENT_STATUS: Record<IncidentStatus, string> = {
175 investigating: "Investigating",
176 identified: "Identified",
177 monitoring: "Monitoring",
178 resolved: "Resolved",
179};
180
181/** Words for what an incident does to a part. */
182export const IMPACT_WORD: Record<ComponentImpact, string> = {
183 operational: "Operational",
184 degraded: "Degraded performance",
185 partial_outage: "Partial outage",
186 major_outage: "Major outage",
187};
188
189/** A part's state for an impact on it. */
190export const IMPACT_STATE: Record<ComponentImpact, StatusComponentState> = {
191 operational: "up",
192 degraded: "degraded",
193 partial_outage: "partial",
194 major_outage: "down",
195};
196
197/** How bad each state is: the worse of a check and an incident wins. */
198const STATE_RANK: Record<StatusComponentState, number> = { unmonitored: 0, up: 1, maintenance: 1, degraded: 2, partial: 3, down: 4 };
199
200/** An incident's impact as a whole: `down` when any part has a major outage. */
201export function overallImpact(impacts: { impact: ComponentImpact }[]): IncidentImpact {
202 return impacts.some((i) => i.impact === "major_outage") ? "down" : "degraded";
203}
204
205/**
206 * What the page shows for a part: the worse of its last check and what
207 * open incidents say about it. During maintenance it is "Under
208 * maintenance", unless an incident says worse, since checks failing in a
209 * window are expected.
210 */
211export function effectiveState(check: StatusComponentState, impacts: ComponentImpact[], underMaintenance: boolean): StatusComponentState {
212 let reported: StatusComponentState | null = null;
213 for (const impact of impacts) {
214 const state = IMPACT_STATE[impact];
215 if (reported == null || STATE_RANK[state] > STATE_RANK[reported]) reported = state;
216 }
217 if (underMaintenance && (reported == null || reported === "up")) return "maintenance";
218 if (reported == null) return check;
219 if (check === "unmonitored" || check === "maintenance") return reported;
220 return STATE_RANK[reported] > STATE_RANK[check] ? reported : check;
221}
222
223/**
224 * Where maintenance stands at `now`. Cancelled and completed are final;
225 * otherwise the window decides, so the page is right even if the
226 * minutely job that moves it along is late.
227 */
228export function maintenanceState(m: Pick<StatusMaintenance, "state" | "starts_at" | "ends_at">, now: Date): MaintenanceState {
229 if (m.state === "cancelled" || m.state === "completed") return m.state;
230 const t = now.getTime();
231 if (t >= Date.parse(m.ends_at)) return "completed";
232 if (t >= Date.parse(m.starts_at) || m.state === "in_progress") return "in_progress";
233 return "scheduled";
234}
235
236/** The parts under maintenance at `now`. */
237export function underMaintenance(list: Pick<StatusMaintenance, "state" | "starts_at" | "ends_at" | "components">[], now: Date): Set<string> {
238 const keys = new Set<string>();
239 for (const m of list) if (maintenanceState(m, now) === "in_progress") for (const k of m.components) keys.add(k);
240 return keys;
241}
242
243/**
244 * The whole, from its parts and its open incidents: a major outage when a
245 * core part is down or an incident says so; a partial outage when any part
246 * is down or partly down, or an incident is open; degraded performance
247 * when parts are only slow; under maintenance when work is under way and
248 * nothing else is wrong. Parts with no check never make it look better or
249 * worse.
250 */
251export function summarize(
252 parts: Pick<StatusComponent, "key" | "name" | "state">[],
253 core: Set<string>,
254 open: Pick<StatusIncident, "title" | "impact" | "status">[] = [],
255 maintenance: Pick<StatusMaintenance, "title">[] = [],
256): StatusOverall {
257 const monitored = parts.filter((c) => c.state !== "unmonitored");
258 if (monitored.length === 0 && open.length === 0) {
259 return { state: "unknown", title: "Status unavailable", line: "No checks have run yet." };
260 }
261 let state: StatusOverallState = monitored.length > 0 ? "up" : "unknown";
262 for (const c of monitored) {
263 if (c.state === "down") state = worse(state, core.has(c.key) ? "down" : "degraded");
264 else if (c.state === "partial" || c.state === "degraded") state = worse(state, "degraded");
265 else if (c.state === "maintenance") state = worse(state, "maintenance");
266 }
267 for (const incident of open) state = worse(state, incident.impact);
268
269 const down = monitored.filter((c) => c.state === "down" || c.state === "partial").map((c) => c.name);
270 const slow = monitored.filter((c) => c.state === "degraded").map((c) => c.name);
271 const lead = open[0];
272 const incidentLine = lead ? `${INCIDENT_STATUS[lead.status]}: ${lead.title}.` : null;
273 const named = (names: string[]) => names.join(", ");
274
275 if (state === "up") return { state, title: "All systems normal", line: "Every part of g1t answered its last check." };
276 if (state === "maintenance") {
277 const work = maintenance[0];
278 return { state, title: "Under maintenance", line: work ? `Planned work: ${work.title}.` : "Planned work is under way." };
279 }
280 if (state === "down") {
281 return {
282 state,
283 title: "Major outage",
284 line: incidentLine ?? (down.length ? `Not answering: ${named(down)}.` : "g1t is not working right now."),
285 };
286 }
287 if (state === "degraded") {
288 if (down.length || open.length) {
289 return {
290 state,
291 title: "Partial outage",
292 line: incidentLine ?? `Not answering: ${named(down)}${slow.length ? `. Slow: ${named(slow)}` : ""}.`,
293 };
294 }
295 return { state, title: "Degraded performance", line: `Slow to answer: ${named(slow)}.` };
296 }
297 return { state, title: "Status unavailable", line: "No checks have run yet." };
298}
299
300/** Open incidents, and those resolved within `days` of `now`, newest first. */
301export function recentIncidents<T extends Pick<StatusIncident, "started_at" | "resolved_at">>(incidents: T[], now: Date, days = HISTORY_DAYS): T[] {
302 const since = now.getTime() - days * DAY_MS;
303 return incidents
304 .filter((i) => i.resolved_at == null || Date.parse(i.resolved_at) >= since)
305 .sort((a, b) => Date.parse(b.started_at) - Date.parse(a.started_at));
306}
307
308/** Everything the page and the JSON show. */
309export type PageModel = {
310 report: StatusReport;
311 bars: Record<string, DayBar[]>;
312 /** The checks have not run for a while: the checker itself is in trouble. */
313 stale: boolean;
314};
315
316/** The report, from what is stored. */
317export function buildPage(input: {
318 parts: ComponentInfo[];
319 current: Map<string, Current>;
320 checkedAt: string | null;
321 days: DayRow[];
322 incidents: StatusIncident[];
323 maintenance?: StatusMaintenance[];
324 now: Date;
325}): PageModel {
326 const { parts, current, checkedAt, days, now } = input;
327 const window = lastDays(now);
328 const since = window[0]!;
329 const rows = days.filter((r) => r.day >= since);
330 const incidents = recentIncidents(input.incidents, now);
331 const open = incidents.filter((i) => i.resolved_at == null);
332 // Upcoming and under way, soonest first, each with its state as of now.
333 const maintenance = (input.maintenance ?? [])
334 .map((m) => ({ ...m, state: maintenanceState(m, now) }))
335 .filter((m) => m.state === "scheduled" || m.state === "in_progress")
336 .sort((a, b) => a.starts_at.localeCompare(b.starts_at));
337 const working = underMaintenance(maintenance, now);
338 const components: StatusComponent[] = parts.map((info) => {
339 const seen = info.check.kind === "none" ? null : current.get(info.key);
340 const check: StatusComponentState = seen?.state ?? "unmonitored";
341 const impacts = open.flatMap((i) => i.component_impacts.filter((c) => c.key === info.key).map((c) => c.impact));
342 return {
343 key: info.key,
344 name: info.name,
345 address: info.address,
346 checks: info.checks,
347 state: effectiveState(check, impacts, working.has(info.key)),
348 check_state: check,
349 detail: seen?.detail ?? (info.check.kind === "none" ? "Not monitored yet" : "Not checked yet"),
350 latency_ms: seen?.latency_ms ?? null,
351 uptime_90d: info.check.kind === "none" ? null : uptimeOver(info.key, rows),
352 };
353 });
354 const core = new Set(parts.filter((p) => p.core).map((p) => p.key));
355 const bars = Object.fromEntries(parts.map((p) => [p.key, barFor(p.key, rows, incidents, window)]));
356 const inProgress = maintenance.filter((m) => m.state === "in_progress");
357 return {
358 report: { checked_at: checkedAt, overall: summarize(components, core, open, inProgress), components, incidents, maintenance },
359 bars,
360 stale: checkedAt == null || now.getTime() - Date.parse(checkedAt) > STALE_MS,
361 };
362}