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

161 lines7,886 bytesCodeBlame
1import { redirect } from "react-router";
2
3import type { IncidentSeverity, IncidentStatus } from "@g1t/contracts";
4
5import type { Route } from "./+types/incident-new";
6import { BackLink, ImpactPicker } from "~/components/incidents";
7import { Button, Field, Input, Notice, PageHeader, Section, Select, Textarea } from "~/components/ui";
8import { text } from "~/lib/forms";
9import { INCIDENT_SEVERITIES, INCIDENT_STATUSES, readImpacts, staffSuggestions, utc, wantsNotify } from "~/lib/incidents";
10import { staffEmails, statusAdmin } from "~/lib/services.server";
11import { settle } from "~/lib/settle";
12import { requireStaff, zoneContext } from "~/lib/staff";
13import { zoneAbbr } from "~/lib/time";
14
15export const meta: Route.MetaFunction = () => [{ title: "Declare an incident · sudo" }, { name: "robots", content: "noindex, nofollow" }];
16
17export async function loader({ context }: Route.LoaderArgs) {
18 const staff = requireStaff(context);
19 const components = await settle(statusAdmin.components());
20 return {
21 me: staff.email,
22 components: components.ok ? components.value : [],
23 error: components.ok ? null : components.error,
24 staff: staffSuggestions(staffEmails(), [staff.email]),
25 zone: zoneAbbr(new Date(), context.get(zoneContext).zone),
26 };
27}
28
29type ActionData = { error: string; values: Record<string, string> };
30
31export async function action({ request, context }: Route.ActionArgs) {
32 const staff = requireStaff(context);
33 const form = await request.formData();
34 const components = await settle(statusAdmin.components());
35 const keys = components.ok ? components.value.map((c) => c.key) : [];
36 const values = Object.fromEntries(["title", "severity", "status", "message", "started_at", "commander", "communications", "notify"].map((k) => [k, text(form, k)]));
37 const result = await settle(
38 statusAdmin.declare({
39 title: text(form, "title"),
40 severity: text(form, "severity") as IncidentSeverity,
41 status: text(form, "status") as IncidentStatus,
42 components: readImpacts(form, keys),
43 message: String(form.get("message") ?? ""),
44 started_at: utc(text(form, "started_at"), context.get(zoneContext).zone),
45 commander: text(form, "commander") || null,
46 communications: text(form, "communications") || null,
47 notify: wantsNotify(text(form, "notify"), text(form, "severity") as IncidentSeverity),
48 by: staff.email,
49 }),
50 );
51 if (!result.ok) return { error: result.error, values } satisfies ActionData;
52 if (!result.value.ok) return { error: result.value.error.message, values } satisfies ActionData;
53 return redirect(`/incidents/${result.value.value.id}?declared=1`);
54}
55
56export default function DeclareIncident({ loaderData, actionData }: Route.ComponentProps) {
57 const { me, components, error, staff } = loaderData;
58 const failed = actionData as ActionData | undefined;
59 const v = failed?.values ?? {};
60
61 return (
62 <main className="mx-auto max-w-3xl px-4 py-8 sm:py-10">
63 <BackLink to="/incidents">Incidents</BackLink>
64 <div className="mt-3">
65 <PageHeader
66 title="Declare an incident"
67 description="It goes on status.g1t.sh at once with its first update, and the parts you mark show their impact there until it is resolved."
68 />
69 </div>
70 <div className="mt-5 space-y-3">
71 {error && <Notice tone="warn">The status worker did not answer: {error}</Notice>}
72 {failed && <Notice tone="error">{failed.error}</Notice>}
73 </div>
74
75 <form method="post" className="mt-6 space-y-6">
76 <Section title="What is happening">
77 <div className="grid gap-4 sm:grid-cols-2">
78 <Field label="Title" className="sm:col-span-2" hint="What people notice, not the internals. Shown publicly.">
79 <Input name="title" required maxLength={120} autoFocus defaultValue={v.title} placeholder="Pushes over HTTPS failing" />
80 </Field>
81 <fieldset className="sm:col-span-2">
82 <legend className="mb-1.5 text-sm font-medium text-muted">Severity</legend>
83 <div className="grid gap-2 sm:grid-cols-2">
84 {INCIDENT_SEVERITIES.map((s) => (
85 <label
86 key={s.value}
87 className="flex cursor-pointer gap-2.5 rounded-md border border-line px-3 py-2 has-checked:border-merged/60 has-checked:bg-merged/8"
88 >
89 <input type="radio" name="severity" value={s.value} required defaultChecked={(v.severity || "sev3") === s.value} className="mt-1 accent-[#b6a8ff]" />
90 <span>
91 <span className="block text-sm font-semibold">{s.label}</span>
92 <span className="block text-xs text-muted">{s.about}</span>
93 </span>
94 </label>
95 ))}
96 </div>
97 </fieldset>
98 <Field label="Status" hint="Investigating: the cause is not known yet. Identified: a fix is under way. Monitoring: a fix is out.">
99 <Select name="status" defaultValue={v.status || "investigating"}>
100 {INCIDENT_STATUSES.filter((s) => s.value !== "resolved").map((s) => (
101 <option key={s.value} value={s.value}>
102 {s.label}
103 </option>
104 ))}
105 </Select>
106 </Field>
107 <Field label={`Impact began (${loaderData.zone})`} hint="Leave empty for now. Up to 90 days back; durations count from here.">
108 <Input type="datetime-local" name="started_at" defaultValue={v.started_at} />
109 </Field>
110 </div>
111 </Section>
112
113 <Section title="Parts affected" description="Mark each affected part. They show this on the status page, or worse if their checks say so.">
114 <ImpactPicker components={components} current={new Map()} />
115 </Section>
116
117 <Section title="First public update" description="Shown on the status page with the status above.">
118 <Textarea
119 name="message"
120 rows={4}
121 required
122 maxLength={4000}
123 defaultValue={v.message}
124 aria-label="First public update"
125 placeholder="We are looking into reports that pushes over HTTPS fail with a 502. Pulls and the website are not affected."
126 />
127 <Field label="Email subscribers" className="mt-3 sm:w-80" hint="Subscribers who chose other parts are not emailed.">
128 <Select name="notify" defaultValue={v.notify || "auto"}>
129 <option value="auto">As the severity says: yes for SEV1 and SEV2</option>
130 <option value="yes">Yes</option>
131 <option value="no">No</option>
132 </Select>
133 </Field>
134 </Section>
135
136 <Section title="Roles" description="Staff emails. Change them any time from the incident.">
137 <div className="grid gap-4 sm:grid-cols-2">
138 <Field label="Incident commander" hint="Runs the response and decides.">
139 <Input name="commander" type="email" list="staff" defaultValue={v.commander ?? me} placeholder={me} />
140 </Field>
141 <Field label="Communications" hint="Writes the public updates.">
142 <Input name="communications" type="email" list="staff" defaultValue={v.communications} placeholder="Optional" />
143 </Field>
144 </div>
145 <datalist id="staff">
146 {staff.map((email) => (
147 <option key={email} value={email} />
148 ))}
149 </datalist>
150 </Section>
151
152 <div className="flex flex-wrap items-center gap-3">
153 <Button type="submit" variant="lavender">
154 Declare and publish
155 </Button>
156 <p className="text-xs text-faint">Recorded in the audit log with your email.</p>
157 </div>
158 </form>
159 </main>
160 );
161}