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

493 lines24,976 bytesCodeBlame
1import { 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 or were slow three times in a row and made this. Nobody outside sees it until you publish it. If it was a blip, dismiss it; if no one picks it up and its parts stay healthy for 10 minutes, it is dismissed on its own."
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}