flagon-io/g1t

public

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

g1t/apps/status/src/model.ts

370 lines14,826 bytesCodeBlame
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
167
168const RANK: Record<StatusOverallState, number> = { unknown: 0, up: 1, maintenance: 1.5, degraded: 2, down: 3 };
169
170function worse(a: StatusOverallState, b: StatusOverallState): StatusOverallState {
171 return RANK[b] > RANK[a] ? b : a;
172}
173
174/** Words for an incident's status. */
175export 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. */
183export 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. */
191export 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. */
199const 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. */
202export 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 */
212export 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 */
229export 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`. */
238export 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 */
255export 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. */
305export 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. */
317export 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. */
325export 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}