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

194 lines8,587 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 { 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}