| 1 | import { ExternalLink } from "lucide-react"; |
| 2 | import { redirect } from "react-router"; |
| 3 | |
| 4 | import type { PostmortemFields } from "@g1t/contracts"; |
| 5 | |
| 6 | import type { Route } from "./+types/incident-postmortem"; |
| 7 | import { BackLink, SeverityBadge } from "~/components/incidents"; |
| 8 | import { Badge, Button, Field, Notice, PageHeader, Section, Textarea, When } from "~/components/ui"; |
| 9 | import { duration } from "~/lib/incidents"; |
| 10 | import { POSTMORTEM_SECTIONS, proseBlocks } from "~/lib/postmortem"; |
| 11 | import { statusAdmin } from "~/lib/services.server"; |
| 12 | import { settle } from "~/lib/settle"; |
| 13 | import { requireStaff } from "~/lib/staff"; |
| 14 | |
| 15 | export const meta: Route.MetaFunction = ({ loaderData }) => [ |
| 16 | { title: `Postmortem: ${loaderData?.incident?.title ?? "incident"} · sudo` }, |
| 17 | { name: "robots", content: "noindex, nofollow" }, |
| 18 | ]; |
| 19 | |
| 20 | export 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 | |
| 32 | type ActionData = { error: string; values?: PostmortemFields }; |
| 33 | |
| 34 | export 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 | |
| 55 | function 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 | |
| 84 | export 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 | } |