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

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