g1t/packages/contracts/src/status.ts

359 lines13,073 bytesCodeBlame
1/**
2 * The status page at status.g1t.sh (apps/status): what its JSON and feeds
3 * say, and the staff-only entrypoint sudo runs incidents, maintenance and
4 * postmortems through. Fields are snake_case, like the API.
5 */
6import type { Result } from "./result";
7
8/**
9 * One part's state. Its last check, made worse by an open incident's
10 * impact on it, or `maintenance` during a maintenance window.
11 * `unmonitored`: no check exists for it and nothing is reported.
12 */
13export type StatusComponentState = "up" | "degraded" | "partial" | "down" | "maintenance" | "unmonitored";
14
15/** The whole of g1t, in one word. `unknown` until anything has been checked. */
16export type StatusOverallState = "up" | "degraded" | "down" | "maintenance" | "unknown";
17
18/** Where an incident stands, in the words status pages use. */
19export type IncidentStatus = "investigating" | "identified" | "monitoring" | "resolved";
20
21/** How bad an incident is, for the team: SEV1 is the worst. Never shown on the status page. */
22export type IncidentSeverity = "sev1" | "sev2" | "sev3" | "sev4";
23
24/** What an incident does to one part, while it is open. */
25export type ComponentImpact = "operational" | "degraded" | "partial_outage" | "major_outage";
26
27/** An incident's impact as a whole: some parts having trouble, or a major outage. */
28export type IncidentImpact = "degraded" | "down";
29
30export type StatusComponent = {
31 key: string;
32 name: string;
33 /** Where people meet it: `api.g1t.sh`. */
34 address: string;
35 /** What the check looks at, said plainly. */
36 checks: string;
37 /** What the page shows: the check, an incident's impact (the worse wins), or maintenance. */
38 state: StatusComponentState;
39 /** What the last check alone said. */
40 check_state: StatusComponentState;
41 /** What was seen: "Answered in 84 ms", "Failed: HTTP 502". */
42 detail: string;
43 latency_ms: number | null;
44 /** Share of checks that answered over the last 90 days, 0 to 100. Null with no checks yet. */
45 uptime_90d: number | null;
46};
47
48export type StatusIncidentUpdate = {
49 id: string;
50 at: string;
51 status: IncidentStatus;
52 text: string;
53};
54
55export type StatusIncident = {
56 id: string;
57 title: string;
58 /** `down` when any part has a major outage. */
59 impact: IncidentImpact;
60 status: IncidentStatus;
61 /** Keys of the parts it affects. */
62 components: string[];
63 /** What it does to each part. */
64 component_impacts: { key: string; impact: ComponentImpact }[];
65 started_at: string;
66 /** Null while it is still going on. */
67 resolved_at: string | null;
68 /** Its page: `https://status.g1t.sh/incidents/<id>`. */
69 url: string;
70 /** When its postmortem was published on that page; null until then. */
71 postmortem_published_at: string | null;
72 /** Newest first. */
73 updates: StatusIncidentUpdate[];
74};
75
76export type MaintenanceState = "scheduled" | "in_progress" | "completed" | "cancelled";
77
78export type StatusMaintenanceUpdate = { id: string; at: string; text: string };
79
80/** Planned work, announced ahead. Its parts show "Under maintenance" during the window. */
81export type StatusMaintenance = {
82 id: string;
83 title: string;
84 message: string;
85 components: string[];
86 /** The window, RFC 3339, UTC. */
87 starts_at: string;
88 ends_at: string;
89 state: MaintenanceState;
90 url: string;
91 /** Newest first. */
92 updates: StatusMaintenanceUpdate[];
93};
94
95export type StatusOverall = {
96 state: StatusOverallState;
97 /** Two or three words: "All systems normal", "Partial outage", "Major outage". */
98 title: string;
99 /** One sentence, naming what is affected. */
100 line: string;
101};
102
103/** `GET status.g1t.sh/status.json`. */
104export type StatusReport = {
105 /** When the parts were last checked; null before the first check. */
106 checked_at: string | null;
107 overall: StatusOverall;
108 components: StatusComponent[];
109 /** Open incidents, and those resolved in the last 90 days, newest first. */
110 incidents: StatusIncident[];
111 /** Maintenance under way or still to come, soonest first. */
112 maintenance: StatusMaintenance[];
113};
114
115// --- Staff ---------------------------------------------------------------------
116
117/** `draft`: detected or saved, not on the page yet. `dismissed`: a draft closed as a false alarm. */
118export type IncidentVisibility = "draft" | "public" | "dismissed";
119
120/** What a line on an incident's timeline records. */
121export type TimelineKind =
122 | "declared"
123 | "detected"
124 | "published"
125 | "dismissed"
126 /** A public update: what the status page shows. */
127 | "update"
128 /** A note only staff see. */
129 | "note"
130 | "status"
131 | "severity"
132 | "impact"
133 | "role"
134 | "acknowledged"
135 /** A check flipped back to working while the incident was open. */
136 | "recovered"
137 /** A check kept failing while the incident was open. */
138 | "failing"
139 | "followup"
140 | "postmortem";
141
142export type TimelineEntry = {
143 id: string;
144 at: string;
145 /** A staff email, or `status` for what the checks wrote. */
146 by: string;
147 kind: TimelineKind;
148 /** Shown on the status page. */
149 public: boolean;
150 /** For public updates: the status it was posted with. */
151 status: IncidentStatus | null;
152 text: string;
153 /** How many subscribers it was emailed to; null when it was not. */
154 notified: number | null;
155};
156
157export type FollowUp = {
158 id: string;
159 title: string;
160 owner: string | null;
161 done_at: string | null;
162 created_at: string;
163 created_by: string;
164};
165
166export type PostmortemFields = {
167 summary: string;
168 impact: string;
169 timeline: string;
170 root_cause: string;
171 went_well: string;
172 went_badly: string;
173 /** What will change, one per line; filled in from the follow-ups. */
174 action_items: string;
175};
176
177export type Postmortem = PostmortemFields & {
178 updated_at: string;
179 updated_by: string;
180 published_at: string | null;
181};
182
183/** Seconds from the impact starting to each milestone, once reached. */
184export type IncidentDurations = {
185 to_acknowledge: number | null;
186 to_mitigate: number | null;
187 to_resolve: number | null;
188};
189
190export type AdminIncident = {
191 id: string;
192 title: string;
193 severity: IncidentSeverity;
194 status: IncidentStatus;
195 visibility: IncidentVisibility;
196 /** Declared by staff, or drafted when a check kept failing. */
197 source: "declared" | "detected";
198 components: { key: string; impact: ComponentImpact }[];
199 /** When the impact began (can be earlier than declared). */
200 started_at: string;
201 declared_at: string;
202 acknowledged_at: string | null;
203 mitigated_at: string | null;
204 resolved_at: string | null;
205 published_at: string | null;
206 commander: string | null;
207 communications: string | null;
208 created_by: string;
209 postmortem_published_at: string | null;
210 durations: IncidentDurations;
211 /** Follow-ups open and done. */
212 followups_open: number;
213 followups_done: number;
214 /** The last public update's time, for "updated 12 minutes ago". */
215 last_update_at: string | null;
216};
217
218export type AdminIncidentDetail = AdminIncident & {
219 /** Oldest first: public updates, notes and what changed. */
220 timeline: TimelineEntry[];
221 followups: FollowUp[];
222 postmortem: Postmortem | null;
223 /** What the postmortem editor starts from before anything is saved: the timeline filled in, follow-ups listed. */
224 postmortem_draft: PostmortemFields;
225 /** Its page on the status site. */
226 url: string;
227};
228
229export type AdminMaintenance = StatusMaintenance & { created_at: string; created_by: string };
230
231/** One line of the status worker's audit log: every staff change, with who made it. */
232export type StatusAuditEntry = { id: string; at: string; by: string; action: string; target: string; detail: string };
233
234export type ImpactInput = { key: string; impact: ComponentImpact };
235
236export type DeclareIncident = {
237 title: string;
238 severity: IncidentSeverity;
239 status?: IncidentStatus;
240 components: ImpactInput[];
241 /** The first public update. */
242 message: string;
243 /** When the impact began, if earlier than now. RFC 3339. */
244 started_at?: string | null;
245 commander?: string | null;
246 communications?: string | null;
247 /** Email subscribers about it. */
248 notify: boolean;
249 /** The staff member's email, kept with every line. */
250 by: string;
251};
252
253/** An update, a note, or a change, posted together. */
254export type IncidentChange = {
255 /** Shown on the status page (and emailed when `notify`), or a note only staff see. */
256 public: boolean;
257 message: string;
258 status?: IncidentStatus | null;
259 severity?: IncidentSeverity | null;
260 /** The parts' impact from now on; parts left out keep theirs. */
261 impacts?: ImpactInput[] | null;
262 notify?: boolean;
263 by: string;
264};
265
266export type RolesChange = { commander: string | null; communications: string | null; by: string };
267
268/** Putting a draft on the status page, with its first public update. */
269export type PublishIncident = { title?: string | null; message: string; notify: boolean; by: string };
270
271export type NewMaintenance = {
272 title: string;
273 message: string;
274 components: string[];
275 starts_at: string;
276 ends_at: string;
277 notify: boolean;
278 by: string;
279};
280
281export type MaintenanceChange = {
282 /** `update` posts a message; the others also move it along. */
283 action: "update" | "start" | "complete" | "cancel";
284 message: string;
285 notify: boolean;
286 by: string;
287};
288
289export type StatusBoard = {
290 /** Open and draft incidents, and those resolved in the last 180 days, newest first. */
291 incidents: AdminIncident[];
292 /** Upcoming, under way, and finished in the last 90 days. */
293 maintenance: AdminMaintenance[];
294 /** Confirmed email subscribers. */
295 subscribers: number;
296 /** Whether the status worker can send email (a sender and a token secret). */
297 email: boolean;
298};
299
300/**
301 * The status worker's `StatusAdmin` entrypoint. Only a service binding
302 * reaches it (sudo's `STATUS`); status.g1t.sh itself has no way to write.
303 * Every change is kept in its audit log with `by`.
304 */
305export interface StatusAdminApi {
306 /** The parts the page lists, for choosing which an incident affects. */
307 components(): Promise<{ key: string; name: string }[]>;
308 board(): Promise<StatusBoard>;
309 incident(id: string): Promise<AdminIncidentDetail | null>;
310 /** Open incidents, drafts included: the sidebar's count. */
311 openCount(): Promise<number>;
312 declare(input: DeclareIncident): Promise<Result<AdminIncident>>;
313 update(id: string, change: IncidentChange): Promise<Result<AdminIncident>>;
314 roles(id: string, change: RolesChange): Promise<Result<AdminIncident>>;
315 publish(id: string, input: PublishIncident): Promise<Result<AdminIncident>>;
316 dismiss(id: string, input: { reason: string; by: string }): Promise<Result<AdminIncident>>;
317 addFollowUp(id: string, input: { title: string; owner: string | null; by: string }): Promise<Result<FollowUp>>;
318 setFollowUp(id: string, followUp: string, input: { done: boolean; by: string }): Promise<Result<FollowUp>>;
319 savePostmortem(id: string, input: PostmortemFields & { by: string }): Promise<Result<Postmortem>>;
320 publishPostmortem(id: string, input: { publish: boolean; by: string }): Promise<Result<Postmortem>>;
321 scheduleMaintenance(input: NewMaintenance): Promise<Result<AdminMaintenance>>;
322 changeMaintenance(id: string, change: MaintenanceChange): Promise<Result<AdminMaintenance>>;
323 /** Newest first, 100 at a time, before `before`. */
324 audit(filter?: { before?: string | null }): Promise<StatusAuditEntry[]>;
325}
326
327// --- Words both sides use ------------------------------------------------------------
328
329/** What each severity means, for the team. Never shown on the status page. */
330export const INCIDENT_SEVERITIES: { value: IncidentSeverity; label: string; about: string; notify: boolean }[] = [
331 {
332 value: "sev1",
333 label: "SEV1",
334 about: "Critical: g1t is down or unusable for most people, or data is at risk. Everyone on it; a public update at least every 30 minutes.",
335 notify: true,
336 },
337 {
338 value: "sev2",
339 label: "SEV2",
340 about: "Major: a core part (sign-in, git, the API, agents) is broken or badly degraded for many people. A public update at least hourly.",
341 notify: true,
342 },
343 { value: "sev3", label: "SEV3", about: "Minor: one part is degraded or broken for some people, and there is a way around it.", notify: false },
344 { value: "sev4", label: "SEV4", about: "Low: little or no customer impact, such as a cosmetic fault or a risk caught before anyone noticed.", notify: false },
345];
346
347export const INCIDENT_STATUSES: { value: IncidentStatus; label: string; about: string }[] = [
348 { value: "investigating", label: "Investigating", about: "Something is wrong; the cause is not known yet." },
349 { value: "identified", label: "Identified", about: "The cause is known and a fix is under way." },
350 { value: "monitoring", label: "Monitoring", about: "A fix is out; watching that it holds." },
351 { value: "resolved", label: "Resolved", about: "Over. Closes the incident." },
352];
353
354export const COMPONENT_IMPACTS: { value: ComponentImpact; label: string }[] = [
355 { value: "operational", label: "Operational" },
356 { value: "degraded", label: "Degraded performance" },
357 { value: "partial_outage", label: "Partial outage" },
358 { value: "major_outage", label: "Major outage" },
359];