g1t/packages/contracts/src/status.ts

359 lines13,073 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 * 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];