Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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