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

194 lines8,587 bytesCodeBlame
1import { ExternalLink } from "lucide-react";
2import { redirect } from "react-router";
3
4import type { PostmortemFields } from "@g1t/contracts";
5
6import type { Route } from "./+types/incident-postmortem";
7import { BackLink, SeverityBadge } from "~/components/incidents";
8import { Badge, Button, Field, Notice, PageHeader, Section, Textarea, When } from "~/components/ui";
9import { duration } from "~/lib/incidents";
10import { POSTMORTEM_SECTIONS, proseBlocks } from "~/lib/postmortem";
11import { statusAdmin } from "~/lib/services.server";
12import { settle } from "~/lib/settle";
13import { requireStaff } from "~/lib/staff";
14
15export const meta: Route.MetaFunction = ({ loaderData }) => [
16 { title: `Postmortem: ${loaderData?.incident?.title ?? "incident"} · sudo` },
17 { name: "robots", content: "noindex, nofollow" },
18];
19
20export async function loader({ params, request, context }: Route.LoaderArgs) {
21 requireStaff(context);
22 const incident = await settle(statusAdmin.incident(params.id));
23 if (incident.ok && !incident.value) throw new Response("No such incident.", { status: 404 });
24 const done = new URL(request.url).searchParams.get("done");
25 return {
26 incident: incident.ok ? incident.value : null,
27 error: incident.ok ? null : incident.error,
28 done: done === "saved" ? "Draft saved. The preview shows it." : done === "published" ? "Published on the incident's page." : done === "unpublished" ? "Taken down from the status page." : null,
29 };
30}
31
32type ActionData = { error: string; values?: PostmortemFields };
33
34export async function action({ params, request, context }: Route.ActionArgs) {
35 const staff = requireStaff(context);
36 const form = await request.formData();
37 const intent = String(form.get("intent") ?? "");
38 const values = Object.fromEntries(POSTMORTEM_SECTIONS.map((s) => [s.key, String(form.get(s.key) ?? "")])) as PostmortemFields;
39 if (intent === "save" || intent === "save_publish") {
40 const saved = await settle(statusAdmin.savePostmortem(params.id, { ...values, by: staff.email }));
41 if (!saved.ok) return { error: saved.error, values } satisfies ActionData;
42 if (!saved.value.ok) return { error: saved.value.error.message, values } satisfies ActionData;
43 if (intent === "save") return redirect(`/incidents/${params.id}/postmortem?done=saved#preview`);
44 }
45 if (intent === "save_publish" || intent === "publish" || intent === "unpublish") {
46 const publish = intent !== "unpublish";
47 const result = await settle(statusAdmin.publishPostmortem(params.id, { publish, by: staff.email }));
48 if (!result.ok) return { error: result.error } satisfies ActionData;
49 if (!result.value.ok) return { error: result.value.error.message } satisfies ActionData;
50 return redirect(`/incidents/${params.id}/postmortem?done=${publish ? "published" : "unpublished"}`);
51 }
52 return { error: "Unknown action." } satisfies ActionData;
53}
54
55function Preview({ fields }: { fields: PostmortemFields }) {
56 const shown = POSTMORTEM_SECTIONS.filter((s) => fields[s.key].trim());
57 if (!shown.length) return <p className="text-sm text-muted">Nothing written yet.</p>;
58 return (
59 <div className="space-y-5">
60 {shown.map((s) => (
61 <section key={s.key}>
62 <h3 className="text-sm font-semibold">{s.title}</h3>
63 <div className="mt-1 space-y-2 text-sm text-fg-soft">
64 {proseBlocks(fields[s.key]).map((block, i) =>
65 block.kind === "list" ? (
66 <ul key={i} className="list-disc space-y-1 pl-5">
67 {block.items.map((item, j) => (
68 <li key={j}>{item}</li>
69 ))}
70 </ul>
71 ) : (
72 <p key={i} className="whitespace-pre-line">
73 {block.text}
74 </p>
75 ),
76 )}
77 </div>
78 </section>
79 ))}
80 </div>
81 );
82}
83
84export default function PostmortemEditor({ loaderData, actionData }: Route.ComponentProps) {
85 const { incident, error, done } = loaderData;
86 const failed = actionData as ActionData | undefined;
87 if (!incident) {
88 return (
89 <main className="mx-auto max-w-6xl px-4 py-8">
90 <Notice tone="warn">The status worker did not answer: {error}</Notice>
91 </main>
92 );
93 }
94 const saved = incident.postmortem;
95 const fields: PostmortemFields = failed?.values ?? saved ?? incident.postmortem_draft;
96 const published = saved?.published_at != null;
97 const resolved = incident.resolved_at != null;
98
99 return (
100 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
101 <BackLink to={`/incidents/${incident.id}`}>{incident.title}</BackLink>
102 <div className="mt-3">
103 <PageHeader
104 title="Postmortem"
105 description={
106 <>
107 <SeverityBadge severity={incident.severity} /> <span className="ml-1">{incident.title}</span> · lasted {duration(incident.durations.to_resolve)}
108 </>
109 }
110 actions={
111 published ? (
112 <a href={`${incident.url}#postmortem`} className="inline-flex items-center gap-1.5 text-sm text-merged hover:underline">
113 On the status page <ExternalLink size={13} aria-hidden="true" />
114 </a>
115 ) : undefined
116 }
117 />
118 </div>
119 <div className="mt-4 space-y-3">
120 {done && <Notice tone="ok">{done}</Notice>}
121 {failed && <Notice tone="error">{failed.error}</Notice>}
122 {!resolved && <Notice tone="info">The incident is still open. Draft away; it can be published once it is resolved.</Notice>}
123 {!saved && (
124 <Notice tone="info">
125 Started for you: the impact from its parts and times, the timeline from the incident's own, and the follow-ups as action items. The timeline includes internal notes: edit out anything that should stay inside.
126 </Notice>
127 )}
128 </div>
129
130 <div className="mt-6 grid gap-6 lg:grid-cols-2">
131 <form method="post" className="min-w-0 space-y-4">
132 <Section title="Write" description="Plain text. A blank line starts a paragraph; lines starting with “- ” become a list.">
133 <div className="space-y-4">
134 {POSTMORTEM_SECTIONS.map((s) => (
135 <Field key={s.key} label={s.title} hint={s.hint}>
136 <Textarea name={s.key} rows={s.rows} maxLength={20000} defaultValue={fields[s.key]} className="font-[inherit]" />
137 </Field>
138 ))}
139 </div>
140 </Section>
141 <div className="flex flex-wrap items-center gap-2">
142 <Button type="submit" name="intent" value="save" variant="quiet">
143 Save draft
144 </Button>
145 {resolved && !published && (
146 <Button type="submit" name="intent" value="save_publish" variant="lavender">
147 Save and publish
148 </Button>
149 )}
150 {saved && (
151 <span className="text-xs text-faint">
152 Saved <When at={saved.updated_at} time /> by <span className="font-mono">{saved.updated_by}</span>
153 </span>
154 )}
155 </div>
156 </form>
157
158 <div className="min-w-0 space-y-4 lg:sticky lg:top-6 lg:self-start">
159 <Section
160 id="preview"
161 title="Preview"
162 description={saved ? "As the status page shows it, from the last save." : "As the status page would show it."}
163 actions={published ? <Badge tone="mint">Published</Badge> : <Badge>Not published</Badge>}
164 >
165 <Preview fields={saved ?? fields} />
166 </Section>
167 {saved && (
168 <form method="post" className="flex flex-wrap items-center gap-3">
169 {published ? (
170 <>
171 <input type="hidden" name="intent" value="unpublish" />
172 <Button type="submit" variant="danger">
173 Take down
174 </Button>
175 <span className="text-xs text-faint">
176 Published <When at={saved.published_at} time />
177 </span>
178 </>
179 ) : (
180 <>
181 <input type="hidden" name="intent" value="publish" />
182 <Button type="submit" variant="lavender" disabled={!resolved}>
183 Publish the saved draft
184 </Button>
185 <span className="text-xs text-faint">Needs a summary and a root cause.</span>
186 </>
187 )}
188 </form>
189 )}
190 </div>
191 </div>
192 </main>
193 );
194}