g1t/apps/sudo/app/routes/incident.tsx

493 lines24,872 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 quotas1import { CheckCircle2, Circle, CircleDot, ExternalLink, FileText, Globe, Lock, Radar } from "lucide-react";
2import { Link, redirect } from "react-router";
3
4import type { AdminIncidentDetail, IncidentSeverity, IncidentStatus, TimelineEntry } from "@g1t/contracts";
5
6import type { Route } from "./+types/incident";
7import { BackLink, ImpactBadge, ImpactPicker, PhaseBadge, SeverityBadge, Timer } from "~/components/incidents";
8import { Badge, Button, Field, Input, Notice, Section, Select, Textarea, When } from "~/components/ui";
9import { text } from "~/lib/forms";
10import {
11 INCIDENT_SEVERITIES,
12 INCIDENT_STATUSES,
13 duration,
14 nextStatus,
15 notifyByDefault,
16 readImpacts,
17 secondsBetween,
18 severityLabel,
19 staffSuggestions,
20 statusLabel,
21} from "~/lib/incidents";
22import { staffEmails, statusAdmin } from "~/lib/services.server";
23import { settle } from "~/lib/settle";
24import { requireStaff } from "~/lib/staff";
25
26export const meta: Route.MetaFunction = ({ loaderData }) => [
27 { title: `${loaderData?.incident?.title ?? "Incident"} · sudo` },
28 { name: "robots", content: "noindex, nofollow" },
29];
30
31/** What the page says after a change, by the code in `?done=`. */
32const DONE: Record<string, string> = {
33 declared: "Declared and published. status.g1t.sh shows it within 30 seconds.",
34 update: "Update posted. The status page shows it within 30 seconds.",
35 note: "Note added. Only staff see it.",
36 resolved: "Resolved. Write the postmortem while it is fresh.",
37 roles: "Roles saved.",
38 published: "Published to the status page.",
39 dismissed: "Dismissed. It never appeared on the status page.",
40 followup: "Follow-up saved.",
41};
42
43export async function loader({ params, request, context }: Route.LoaderArgs) {
44 const staff = requireStaff(context);
45 const [incident, components] = await Promise.all([settle(statusAdmin.incident(params.id)), settle(statusAdmin.components())]);
46 if (incident.ok && !incident.value) throw new Response("No such incident.", { status: 404 });
47 const url = new URL(request.url);
48 const done = url.searchParams.get("declared") ? "declared" : (url.searchParams.get("done") ?? "");
49 const detail = incident.ok ? incident.value : null;
50 return {
51 me: staff.email,
52 incident: detail,
53 components: components.ok ? components.value : [],
54 error: incident.ok ? null : incident.error,
55 done: DONE[done] ?? null,
56 staff: staffSuggestions(staffEmails(), [staff.email, detail?.commander, detail?.communications, ...(detail?.followups.map((f) => f.owner) ?? [])]),
57 now: new Date().toISOString(),
58 };
59}
60
61type ActionData = { error: string; intent: string };
62
63export async function action({ params, request, context }: Route.ActionArgs) {
64 const staff = requireStaff(context);
65 const form = await request.formData();
66 const intent = text(form, "intent");
67 const id = params.id ?? "";
68 const back = (done: string, anchor = "") => redirect(`/incidents/${id}?done=${done}${anchor}`);
69 const failed = (error: string) => ({ error, intent }) satisfies ActionData;
70 const unwrap = async <T,>(call: Promise<{ ok: true; value: T } | { ok: false; error: { message: string } }>) => {
71 const result = await settle(call);
72 if (!result.ok) return { ok: false as const, error: result.error };
73 return result.value.ok ? { ok: true as const, value: result.value.value } : { ok: false as const, error: result.value.error.message };
74 };
75
76 if (intent === "update" || intent === "resolve") {
77 const components = await settle(statusAdmin.components());
78 const keys = components.ok ? components.value.map((c) => c.key) : [];
79 const isPublic = intent === "resolve" || text(form, "visibility") === "public";
80 const status = intent === "resolve" ? "resolved" : (text(form, "status") as IncidentStatus) || null;
81 const r = await unwrap(
82 statusAdmin.update(id, {
83 public: isPublic,
84 message: String(form.get("message") ?? ""),
85 status,
86 severity: (text(form, "severity") as IncidentSeverity) || null,
87 impacts: intent === "resolve" ? [] : readImpacts(form, keys),
88 notify: isPublic && form.get("notify") === "on",
89 by: staff.email,
90 }),
91 );
92 if (!r.ok) return failed(r.error);
93 return back(status === "resolved" ? "resolved" : isPublic ? "update" : "note");
94 }
95 if (intent === "roles") {
96 const r = await unwrap(statusAdmin.roles(id, { commander: text(form, "commander") || null, communications: text(form, "communications") || null, by: staff.email }));
97 return !r.ok ? failed(r.error) : back("roles", "#roles");
98 }
99 if (intent === "publish") {
100 const r = await unwrap(
101 statusAdmin.publish(id, { title: text(form, "title") || null, message: String(form.get("message") ?? ""), notify: form.get("notify") === "on", by: staff.email }),
102 );
103 return !r.ok ? failed(r.error) : back("published");
104 }
105 if (intent === "dismiss") {
106 const r = await unwrap(statusAdmin.dismiss(id, { reason: text(form, "reason"), by: staff.email }));
107 return !r.ok ? failed(r.error) : back("dismissed");
108 }
109 if (intent === "followup_add") {
110 const r = await unwrap(statusAdmin.addFollowUp(id, { title: text(form, "title"), owner: text(form, "owner") || null, by: staff.email }));
111 return !r.ok ? failed(r.error) : back("followup", "#followups");
112 }
113 if (intent === "followup_set") {
114 const r = await unwrap(statusAdmin.setFollowUp(id, text(form, "followup"), { done: text(form, "done") === "1", by: staff.email }));
115 return !r.ok ? failed(r.error) : back("followup", "#followups");
116 }
117 return failed("Unknown action.");
118}
119
120const KIND_ICON = { update: Globe, note: Lock, detected: Radar, followup: CheckCircle2, postmortem: FileText } as const;
121
122function EntryLine({ entry }: { entry: TimelineEntry }) {
123 const who = entry.by === "status" ? "the checks" : entry.by;
124 const meta = (
125 <span className="text-xs text-faint">
126 <When at={entry.at} time /> · <span className="font-mono">{who}</span>
127 </span>
128 );
129 if (entry.kind === "update") {
130 return (
131 <li className="rounded-md border border-merged/30 bg-merged/6 px-3.5 py-3">
132 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
133 <Globe size={14} aria-hidden="true" className="text-merged" />
134 <span className="text-xs font-semibold text-merged">Public update</span>
135 {entry.status && <Badge tone="lavender">{statusLabel(entry.status)}</Badge>}
136 {meta}
137 {entry.notified != null && <span className="text-xs text-faint">· emailed {entry.notified}</span>}
138 </div>
139 <p className="mt-1.5 text-sm whitespace-pre-line text-fg">{entry.text}</p>
140 </li>
141 );
142 }
143 if (entry.kind === "note") {
144 return (
145 <li className="rounded-md border border-line bg-raised/40 px-3.5 py-3">
146 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
147 <Lock size={13} aria-hidden="true" className="text-faint" />
148 <span className="text-xs font-medium text-muted">Internal note</span>
149 {meta}
150 </div>
151 <p className="mt-1.5 text-sm whitespace-pre-line text-fg-soft">{entry.text}</p>
152 </li>
153 );
154 }
155 const Icon = KIND_ICON[entry.kind as keyof typeof KIND_ICON] ?? CircleDot;
156 const loud = entry.kind === "detected" || entry.kind === "failing";
157 return (
158 <li className="flex gap-2.5 px-1 py-1">
159 <Icon size={13} aria-hidden="true" className={`mt-1 shrink-0 ${loud ? "text-warn" : "text-faint"}`} />
160 <p className="min-w-0 text-sm text-muted">
161 <span className={loud ? "text-fg-soft" : ""}>{entry.text}</span> {meta}
162 </p>
163 </li>
164 );
165}
166
167function Header({ incident, now }: { incident: AdminIncidentDetail; now: string }) {
168 const open = incident.resolved_at == null;
169 const d = incident.durations;
170 return (
171 <>
172 <div className="mt-3 flex flex-wrap items-center gap-2">
173 <SeverityBadge severity={incident.severity} />
174 <PhaseBadge incident={incident} />
175 {incident.source === "detected" && <Badge tone="warn">Detected by the checks</Badge>}
176 {incident.visibility !== "public" && <Badge>Not on the status page</Badge>}
177 </div>
178 <h1 className="mt-2 text-2xl font-semibold tracking-tight break-words">{incident.title}</h1>
179 <p className="mt-1 text-sm text-muted">
180 Impact began <When at={incident.started_at} time /> · declared by <span className="font-mono text-xs">{incident.created_by === "status" ? "the checks" : incident.created_by}</span>
181 {incident.commander && (
182 <>
183 {" "}· IC <span className="font-mono text-xs">{incident.commander}</span>
184 </>
185 )}
186 {incident.communications && (
187 <>
188 {" "}· comms <span className="font-mono text-xs">{incident.communications}</span>
189 </>
190 )}
191 </p>
192 <div className="mt-4 grid grid-cols-2 divide-line rounded-lg border border-line bg-surface sm:grid-cols-4 sm:divide-x [&>*:nth-child(-n+2)]:border-b [&>*:nth-child(-n+2)]:border-line sm:[&>*:nth-child(-n+2)]:border-b-0">
193 <Timer
194 label={open ? "Open for" : "Lasted"}
195 value={duration(open ? secondsBetween(incident.started_at, now) : d.to_resolve)}
196 tone={open ? "warn" : "mint"}
197 hint={open ? `${severityLabel(incident.severity)} · ${statusLabel(incident.status)}` : <When at={incident.resolved_at} time />}
198 />
199 <Timer label="To acknowledge" value={duration(d.to_acknowledge)} hint={incident.acknowledged_at ? <When at={incident.acknowledged_at} time /> : "Not yet"} />
200 <Timer label="To mitigate" value={duration(d.to_mitigate)} hint={incident.mitigated_at ? <When at={incident.mitigated_at} time /> : "At monitoring"} />
201 <Timer label="To resolve" value={duration(d.to_resolve)} hint={incident.resolved_at ? <When at={incident.resolved_at} time /> : "Still open"} />
202 </div>
203 </>
204 );
205}
206
207function UpdateForm({ incident, components, error }: { incident: AdminIncidentDetail; components: { key: string; name: string }[]; error: string | null }) {
208 const draft = incident.visibility === "draft";
209 const current = new Map(incident.components.map((c) => [c.key, c.impact]));
210 return (
211 <Section id="update" title={draft ? "Add a note" : "Post an update"} description={draft ? "Drafts take notes and changes; publish it to post publicly." : "A public update goes on the status page; a note stays here."}>
212 <form method="post" action="#update" className="space-y-4">
213 <input type="hidden" name="intent" value="update" />
214 {error && <Notice tone="error">{error}</Notice>}
215 {!draft && (
216 <fieldset className="flex flex-wrap gap-2" aria-label="Who sees it">
217 <label className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-line px-3 py-1.5 text-sm has-checked:border-merged/60 has-checked:bg-merged/10">
218 <input type="radio" name="visibility" value="public" defaultChecked className="accent-[#b6a8ff]" />
219 <Globe size={14} aria-hidden="true" /> Public update
220 </label>
221 <label className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-line px-3 py-1.5 text-sm has-checked:border-line-strong has-checked:bg-raised">
222 <input type="radio" name="visibility" value="internal" className="accent-[#b6a8ff]" />
223 <Lock size={14} aria-hidden="true" /> Internal note
224 </label>
225 </fieldset>
226 )}
227 <div className="grid gap-3 sm:grid-cols-2">
228 <Field label="Status" hint={draft ? undefined : "Changing it needs a public update."}>
229 <Select name="status" defaultValue="">
230 <option value="">Keep: {statusLabel(incident.status)}</option>
231 {INCIDENT_STATUSES.filter((s) => s.value !== incident.status).map((s) => (
232 <option key={s.value} value={s.value}>
233 {s.label}
234 {s.value === nextStatus(incident.status) ? " (next)" : ""}
235 </option>
236 ))}
237 </Select>
238 </Field>
239 <Field label="Severity">
240 <Select name="severity" defaultValue="">
241 <option value="">Keep: {severityLabel(incident.severity)}</option>
242 {INCIDENT_SEVERITIES.filter((s) => s.value !== incident.severity).map((s) => (
243 <option key={s.value} value={s.value}>
244 {s.label}
245 </option>
246 ))}
247 </Select>
248 </Field>
249 </div>
250 <details className="group rounded-md">
251 <summary className="cursor-pointer text-sm text-muted select-none hover:text-fg">Change the parts' impact</summary>
252 <div className="mt-2">
253 <ImpactPicker components={components} current={current} blank />
254 </div>
255 </details>
256 <Field label="Message">
257 <Textarea name="message" rows={4} maxLength={4000} placeholder={draft ? "What you found, for the team." : "What changed, in a sentence or two, for the people affected."} />
258 </Field>
259 <div className="flex flex-wrap items-center justify-between gap-3">
260 {!draft ? (
261 <label className="flex items-center gap-2 text-sm">
262 <input type="checkbox" name="notify" defaultChecked={notifyByDefault(incident.severity)} className="accent-[#b6a8ff]" />
263 Email subscribers (public updates only)
264 </label>
265 ) : (
266 <span />
267 )}
268 <Button type="submit" variant="lavender">
269 Post
270 </Button>
271 </div>
272 </form>
273 </Section>
274 );
275}
276
277export default function Incident({ loaderData, actionData }: Route.ComponentProps) {
278 const { incident, components, error, done, staff, now } = loaderData;
279 const failed = actionData as ActionData | undefined;
280 const errorFor = (intent: string) => (failed?.intent === intent ? failed.error : null);
281 if (!incident) {
282 return (
283 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
284 <BackLink to="/incidents">Incidents</BackLink>
285 <div className="mt-4">
286 <Notice tone="warn">The status worker did not answer: {error}</Notice>
287 </div>
288 </main>
289 );
290 }
291 const names = new Map(components.map((c) => [c.key, c.name]));
292 const open = incident.resolved_at == null;
293 const isPublic = incident.visibility === "public";
294 const affected = incident.components.filter((c) => c.impact !== "operational");
295 const timeline = [...incident.timeline].reverse();
296
297 return (
298 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
299 <BackLink to={incident.visibility === "draft" ? "/incidents?tab=drafts" : open ? "/incidents" : "/incidents?tab=resolved"}>Incidents</BackLink>
300 <Header incident={incident} now={now} />
301
302 <div className="mt-4 space-y-3">
303 {done && <Notice tone="ok">{done}</Notice>}
304 {failed && !["update", "roles", "followup_add", "followup_set", "resolve", "publish", "dismiss"].includes(failed.intent) && <Notice tone="error">{failed.error}</Notice>}
305 </div>
306
307 {incident.visibility === "draft" && (
308 <section className="mt-5 rounded-lg border border-warn/35 bg-warn/6 p-4 sm:p-5">
309 <h2 className="font-semibold">This is a draft</h2>
310 <p className="mt-1 text-sm text-muted">
311 {incident.source === "detected"
312 ? "The checks failed several times in a row and made this. Nobody outside sees it until you publish it. If it was a blip, dismiss it."
313 : "Nobody outside sees it until you publish it."}
314 </p>
315 <div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1fr)_18rem]">
316 <form method="post" className="space-y-3">
317 <input type="hidden" name="intent" value="publish" />
318 {errorFor("publish") && <Notice tone="error">{errorFor("publish")}</Notice>}
319 <Field label="Public title">
320 <Input name="title" defaultValue={incident.title.replace(/^Detected: /, "")} maxLength={120} required />
321 </Field>
322 <Field label="First public update">
323 <Textarea name="message" rows={3} required maxLength={4000} placeholder="We are looking into reports that…" />
324 </Field>
325 <div className="flex flex-wrap items-center justify-between gap-3">
326 <label className="flex items-center gap-2 text-sm">
327 <input type="checkbox" name="notify" defaultChecked={notifyByDefault(incident.severity)} className="accent-[#b6a8ff]" />
328 Email subscribers
329 </label>
330 <Button type="submit" variant="lavender">
331 Publish to the status page
332 </Button>
333 </div>
334 </form>
335 <form method="post" className="space-y-3 lg:border-l lg:border-warn/20 lg:pl-4">
336 <input type="hidden" name="intent" value="dismiss" />
337 {errorFor("dismiss") && <Notice tone="error">{errorFor("dismiss")}</Notice>}
338 <Field label="Not an incident?">
339 <Input name="reason" maxLength={500} placeholder="A blip: answered again within minutes" />
340 </Field>
341 <Button type="submit" variant="quiet">
342 Dismiss draft
343 </Button>
344 </form>
345 </div>
346 </section>
347 )}
348
349 <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
350 <div className="min-w-0 space-y-6">
351 {incident.visibility !== "dismissed" && <UpdateForm incident={incident} components={components} error={errorFor("update")} />}
352 <Section title="Timeline" description="Newest first. Public updates are what the status page shows; everything else is for staff.">
353 <ol className="space-y-2">
354 {timeline.map((entry) => (
355 <EntryLine key={entry.id} entry={entry} />
356 ))}
357 </ol>
358 </Section>
359 </div>
360
361 <aside className="min-w-0 space-y-6">
362 {open && isPublic && (
363 <Section id="resolve" title="Resolve" description="Closes it, posts a last public update, and stops colouring the parts.">
364 <form method="post" action="#resolve" className="space-y-3">
365 <input type="hidden" name="intent" value="resolve" />
366 {errorFor("resolve") && <Notice tone="error">{errorFor("resolve")}</Notice>}
367 <Textarea name="message" rows={3} required maxLength={4000} aria-label="Resolution update" placeholder="Pushes work again. A bad deploy was rolled back; nothing was lost." />
368 <label className="flex items-center gap-2 text-sm">
369 <input type="checkbox" name="notify" defaultChecked={notifyByDefault(incident.severity)} className="accent-[#b6a8ff]" />
370 Email subscribers
371 </label>
372 <Button type="submit" variant="lavender" className="w-full">
373 <CheckCircle2 size={15} aria-hidden="true" /> Resolve incident
374 </Button>
375 </form>
376 </Section>
377 )}
378
379 {!open && isPublic && (
380 <Section title="Postmortem" description={incident.postmortem_published_at ? "Published on the incident's page." : incident.postmortem ? "Drafted, not published." : "Not started."}>
381 <div className="flex flex-wrap items-center gap-2">
382 <Link to={`/incidents/${incident.id}/postmortem`} className="inline-flex items-center gap-1.5 text-sm font-medium text-merged hover:underline">
383 <FileText size={14} aria-hidden="true" />
384 {incident.postmortem ? "Edit the postmortem" : "Write the postmortem"}
385 </Link>
386 {incident.postmortem_published_at && <Badge tone="mint">Published</Badge>}
387 </div>
388 </Section>
389 )}
390
391 <Section title="Parts" description={open ? "What the status page shows for each while it is open." : "What it did to each."}>
392 {affected.length === 0 ? (
393 <p className="text-sm text-muted">None marked.</p>
394 ) : (
395 <ul className="space-y-2">
396 {affected.map((c) => (
397 <li key={c.key} className="flex items-center justify-between gap-2 text-sm">
398 <span className="min-w-0 truncate">{names.get(c.key) ?? c.key}</span>
399 <ImpactBadge impact={c.impact} />
400 </li>
401 ))}
402 </ul>
403 )}
404 </Section>
405
406 <Section id="roles" title="Roles">
407 <form method="post" action="#roles" className="space-y-3">
408 <input type="hidden" name="intent" value="roles" />
409 {errorFor("roles") && <Notice tone="error">{errorFor("roles")}</Notice>}
410 <Field label="Incident commander">
411 <Input name="commander" type="email" list="staff" defaultValue={incident.commander ?? ""} placeholder="Nobody" />
412 </Field>
413 <Field label="Communications">
414 <Input name="communications" type="email" list="staff" defaultValue={incident.communications ?? ""} placeholder="Nobody" />
415 </Field>
416 <Button type="submit" variant="quiet">
417 Save roles
418 </Button>
419 </form>
420 </Section>
421
422 <Section id="followups" title="Follow-ups" description="What to change so it does not happen again.">
423 {errorFor("followup_set") && (
424 <div className="mb-3">
425 <Notice tone="error">{errorFor("followup_set")}</Notice>
426 </div>
427 )}
428 {incident.followups.length > 0 && (
429 <ul className="mb-4 space-y-1.5">
430 {incident.followups.map((f) => (
431 <li key={f.id}>
432 <form method="post" action="#followups" className="flex items-start gap-2">
433 <input type="hidden" name="intent" value="followup_set" />
434 <input type="hidden" name="followup" value={f.id} />
435 <input type="hidden" name="done" value={f.done_at ? "0" : "1"} />
436 <button
437 type="submit"
438 aria-label={f.done_at ? `Mark “${f.title}” not done` : `Mark “${f.title}” done`}
439 className="mt-0.5 shrink-0 rounded text-faint hover:text-merged"
440 >
441 {f.done_at ? <CheckCircle2 size={16} className="text-accent" /> : <Circle size={16} />}
442 </button>
443 <span className="min-w-0 text-sm">
444 <span className={f.done_at ? "text-faint line-through" : ""}>{f.title}</span>
445 {f.owner && <span className="block font-mono text-xs text-faint">{f.owner}</span>}
446 </span>
447 </form>
448 </li>
449 ))}
450 </ul>
451 )}
452 <form method="post" action="#followups" className="space-y-2">
453 <input type="hidden" name="intent" value="followup_add" />
454 {errorFor("followup_add") && <Notice tone="error">{errorFor("followup_add")}</Notice>}
455 <Input name="title" required maxLength={200} placeholder="Alert on push errors above 1%" aria-label="Follow-up" />
456 <div className="flex gap-2">
457 <Input name="owner" type="email" list="staff" placeholder="Owner (optional)" aria-label="Owner" />
458 <Button type="submit" variant="quiet">
459 Add
460 </Button>
461 </div>
462 </form>
463 </Section>
464
465 <Section title="Links">
466 <ul className="space-y-1.5 text-sm">
467 {isPublic ? (
468 <li>
469 <a href={incident.url} className="inline-flex items-center gap-1.5 text-merged hover:underline">
470 Status page permalink <ExternalLink size={13} aria-hidden="true" />
471 </a>
472 <span className="block truncate font-mono text-xs text-faint">{incident.url}</span>
473 </li>
474 ) : (
475 <li className="text-muted">No public page until it is published.</li>
476 )}
477 <li>
478 <Link to="/audit" className="text-muted hover:text-fg hover:underline">
479 Audit log
480 </Link>
481 </li>
482 </ul>
483 </Section>
484 <datalist id="staff">
485 {staff.map((email) => (
486 <option key={email} value={email} />
487 ))}
488 </datalist>
489 </aside>
490 </div>
491 </main>
492 );
493}