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

159 lines7,723 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 { 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}