g1t/apps/status/src/model.ts

370 lines14,826 bytesCodeBlame

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 quotas1/**
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"}` };
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily34 if (result.degraded) return { state: "degraded", detail: result.degraded };
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas35 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}