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.

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