| 1 | import { CheckCircle2, Circle, CircleDot, ExternalLink, FileText, Globe, Lock, Radar } from "lucide-react"; |
| 2 | import { Link, redirect } from "react-router"; |
| 3 | |
| 4 | import type { AdminIncidentDetail, IncidentSeverity, IncidentStatus, TimelineEntry } from "@g1t/contracts"; |
| 5 | |
| 6 | import type { Route } from "./+types/incident"; |
| 7 | import { BackLink, ImpactBadge, ImpactPicker, PhaseBadge, SeverityBadge, Timer } from "~/components/incidents"; |
| 8 | import { Badge, Button, Field, Input, Notice, Section, Select, Textarea, When } from "~/components/ui"; |
| 9 | import { text } from "~/lib/forms"; |
| 10 | import { |
| 11 | INCIDENT_SEVERITIES, |
| 12 | INCIDENT_STATUSES, |
| 13 | duration, |
| 14 | nextStatus, |
| 15 | notifyByDefault, |
| 16 | readImpacts, |
| 17 | secondsBetween, |
| 18 | severityLabel, |
| 19 | staffSuggestions, |
| 20 | statusLabel, |
| 21 | } from "~/lib/incidents"; |
| 22 | import { staffEmails, statusAdmin } from "~/lib/services.server"; |
| 23 | import { settle } from "~/lib/settle"; |
| 24 | import { requireStaff } from "~/lib/staff"; |
| 25 | |
| 26 | export const meta: Route.MetaFunction = ({ loaderData }) => [ |
| 27 | { title: `${loaderData?.incident?.title ?? "Incident"} · sudo` }, |
| 28 | { name: "robots", content: "noindex, nofollow" }, |
| 29 | ]; |
| 30 | |
| 31 | /** What the page says after a change, by the code in `?done=`. */ |
| 32 | const DONE: Record<string, string> = { |
| 33 | declared: "Declared and published. status.g1t.sh shows it within 30 seconds.", |
| 34 | update: "Update posted. The status page shows it within 30 seconds.", |
| 35 | note: "Note added. Only staff see it.", |
| 36 | resolved: "Resolved. Write the postmortem while it is fresh.", |
| 37 | roles: "Roles saved.", |
| 38 | published: "Published to the status page.", |
| 39 | dismissed: "Dismissed. It never appeared on the status page.", |
| 40 | followup: "Follow-up saved.", |
| 41 | }; |
| 42 | |
| 43 | export async function loader({ params, request, context }: Route.LoaderArgs) { |
| 44 | const staff = requireStaff(context); |
| 45 | const [incident, components] = await Promise.all([settle(statusAdmin.incident(params.id)), settle(statusAdmin.components())]); |
| 46 | if (incident.ok && !incident.value) throw new Response("No such incident.", { status: 404 }); |
| 47 | const url = new URL(request.url); |
| 48 | const done = url.searchParams.get("declared") ? "declared" : (url.searchParams.get("done") ?? ""); |
| 49 | const detail = incident.ok ? incident.value : null; |
| 50 | return { |
| 51 | me: staff.email, |
| 52 | incident: detail, |
| 53 | components: components.ok ? components.value : [], |
| 54 | error: incident.ok ? null : incident.error, |
| 55 | done: DONE[done] ?? null, |
| 56 | staff: staffSuggestions(staffEmails(), [staff.email, detail?.commander, detail?.communications, ...(detail?.followups.map((f) => f.owner) ?? [])]), |
| 57 | now: new Date().toISOString(), |
| 58 | }; |
| 59 | } |
| 60 | |
| 61 | type ActionData = { error: string; intent: string }; |
| 62 | |
| 63 | export async function action({ params, request, context }: Route.ActionArgs) { |
| 64 | const staff = requireStaff(context); |
| 65 | const form = await request.formData(); |
| 66 | const intent = text(form, "intent"); |
| 67 | const id = params.id ?? ""; |
| 68 | const back = (done: string, anchor = "") => redirect(`/incidents/${id}?done=${done}${anchor}`); |
| 69 | const failed = (error: string) => ({ error, intent }) satisfies ActionData; |
| 70 | const unwrap = async <T,>(call: Promise<{ ok: true; value: T } | { ok: false; error: { message: string } }>) => { |
| 71 | const result = await settle(call); |
| 72 | if (!result.ok) return { ok: false as const, error: result.error }; |
| 73 | return result.value.ok ? { ok: true as const, value: result.value.value } : { ok: false as const, error: result.value.error.message }; |
| 74 | }; |
| 75 | |
| 76 | if (intent === "update" || intent === "resolve") { |
| 77 | const components = await settle(statusAdmin.components()); |
| 78 | const keys = components.ok ? components.value.map((c) => c.key) : []; |
| 79 | const isPublic = intent === "resolve" || text(form, "visibility") === "public"; |
| 80 | const status = intent === "resolve" ? "resolved" : (text(form, "status") as IncidentStatus) || null; |
| 81 | const r = await unwrap( |
| 82 | statusAdmin.update(id, { |
| 83 | public: isPublic, |
| 84 | message: String(form.get("message") ?? ""), |
| 85 | status, |
| 86 | severity: (text(form, "severity") as IncidentSeverity) || null, |
| 87 | impacts: intent === "resolve" ? [] : readImpacts(form, keys), |
| 88 | notify: isPublic && form.get("notify") === "on", |
| 89 | by: staff.email, |
| 90 | }), |
| 91 | ); |
| 92 | if (!r.ok) return failed(r.error); |
| 93 | return back(status === "resolved" ? "resolved" : isPublic ? "update" : "note"); |
| 94 | } |
| 95 | if (intent === "roles") { |
| 96 | const r = await unwrap(statusAdmin.roles(id, { commander: text(form, "commander") || null, communications: text(form, "communications") || null, by: staff.email })); |
| 97 | return !r.ok ? failed(r.error) : back("roles", "#roles"); |
| 98 | } |
| 99 | if (intent === "publish") { |
| 100 | const r = await unwrap( |
| 101 | statusAdmin.publish(id, { title: text(form, "title") || null, message: String(form.get("message") ?? ""), notify: form.get("notify") === "on", by: staff.email }), |
| 102 | ); |
| 103 | return !r.ok ? failed(r.error) : back("published"); |
| 104 | } |
| 105 | if (intent === "dismiss") { |
| 106 | const r = await unwrap(statusAdmin.dismiss(id, { reason: text(form, "reason"), by: staff.email })); |
| 107 | return !r.ok ? failed(r.error) : back("dismissed"); |
| 108 | } |
| 109 | if (intent === "followup_add") { |
| 110 | const r = await unwrap(statusAdmin.addFollowUp(id, { title: text(form, "title"), owner: text(form, "owner") || null, by: staff.email })); |
| 111 | return !r.ok ? failed(r.error) : back("followup", "#followups"); |
| 112 | } |
| 113 | if (intent === "followup_set") { |
| 114 | const r = await unwrap(statusAdmin.setFollowUp(id, text(form, "followup"), { done: text(form, "done") === "1", by: staff.email })); |
| 115 | return !r.ok ? failed(r.error) : back("followup", "#followups"); |
| 116 | } |
| 117 | return failed("Unknown action."); |
| 118 | } |
| 119 | |
| 120 | const KIND_ICON = { update: Globe, note: Lock, detected: Radar, followup: CheckCircle2, postmortem: FileText } as const; |
| 121 | |
| 122 | function EntryLine({ entry }: { entry: TimelineEntry }) { |
| 123 | const who = entry.by === "status" ? "the checks" : entry.by; |
| 124 | const meta = ( |
| 125 | <span className="text-xs text-faint"> |
| 126 | <When at={entry.at} time /> · <span className="font-mono">{who}</span> |
| 127 | </span> |
| 128 | ); |
| 129 | if (entry.kind === "update") { |
| 130 | return ( |
| 131 | <li className="rounded-md border border-merged/30 bg-merged/6 px-3.5 py-3"> |
| 132 | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> |
| 133 | <Globe size={14} aria-hidden="true" className="text-merged" /> |
| 134 | <span className="text-xs font-semibold text-merged">Public update</span> |
| 135 | {entry.status && <Badge tone="lavender">{statusLabel(entry.status)}</Badge>} |
| 136 | {meta} |
| 137 | {entry.notified != null && <span className="text-xs text-faint">· emailed {entry.notified}</span>} |
| 138 | </div> |
| 139 | <p className="mt-1.5 text-sm whitespace-pre-line text-fg">{entry.text}</p> |
| 140 | </li> |
| 141 | ); |
| 142 | } |
| 143 | if (entry.kind === "note") { |
| 144 | return ( |
| 145 | <li className="rounded-md border border-line bg-raised/40 px-3.5 py-3"> |
| 146 | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> |
| 147 | <Lock size={13} aria-hidden="true" className="text-faint" /> |
| 148 | <span className="text-xs font-medium text-muted">Internal note</span> |
| 149 | {meta} |
| 150 | </div> |
| 151 | <p className="mt-1.5 text-sm whitespace-pre-line text-fg-soft">{entry.text}</p> |
| 152 | </li> |
| 153 | ); |
| 154 | } |
| 155 | const Icon = KIND_ICON[entry.kind as keyof typeof KIND_ICON] ?? CircleDot; |
| 156 | const loud = entry.kind === "detected" || entry.kind === "failing"; |
| 157 | return ( |
| 158 | <li className="flex gap-2.5 px-1 py-1"> |
| 159 | <Icon size={13} aria-hidden="true" className={`mt-1 shrink-0 ${loud ? "text-warn" : "text-faint"}`} /> |
| 160 | <p className="min-w-0 text-sm text-muted"> |
| 161 | <span className={loud ? "text-fg-soft" : ""}>{entry.text}</span> {meta} |
| 162 | </p> |
| 163 | </li> |
| 164 | ); |
| 165 | } |
| 166 | |
| 167 | function Header({ incident, now }: { incident: AdminIncidentDetail; now: string }) { |
| 168 | const open = incident.resolved_at == null; |
| 169 | const d = incident.durations; |
| 170 | return ( |
| 171 | <> |
| 172 | <div className="mt-3 flex flex-wrap items-center gap-2"> |
| 173 | <SeverityBadge severity={incident.severity} /> |
| 174 | <PhaseBadge incident={incident} /> |
| 175 | {incident.source === "detected" && <Badge tone="warn">Detected by the checks</Badge>} |
| 176 | {incident.visibility !== "public" && <Badge>Not on the status page</Badge>} |
| 177 | </div> |
| 178 | <h1 className="mt-2 text-2xl font-semibold tracking-tight break-words">{incident.title}</h1> |
| 179 | <p className="mt-1 text-sm text-muted"> |
| 180 | Impact began <When at={incident.started_at} time /> · declared by <span className="font-mono text-xs">{incident.created_by === "status" ? "the checks" : incident.created_by}</span> |
| 181 | {incident.commander && ( |
| 182 | <> |
| 183 | {" "}· IC <span className="font-mono text-xs">{incident.commander}</span> |
| 184 | </> |
| 185 | )} |
| 186 | {incident.communications && ( |
| 187 | <> |
| 188 | {" "}· comms <span className="font-mono text-xs">{incident.communications}</span> |
| 189 | </> |
| 190 | )} |
| 191 | </p> |
| 192 | <div className="mt-4 grid grid-cols-2 divide-line rounded-lg border border-line bg-surface sm:grid-cols-4 sm:divide-x [&>*:nth-child(-n+2)]:border-b [&>*:nth-child(-n+2)]:border-line sm:[&>*:nth-child(-n+2)]:border-b-0"> |
| 193 | <Timer |
| 194 | label={open ? "Open for" : "Lasted"} |
| 195 | value={duration(open ? secondsBetween(incident.started_at, now) : d.to_resolve)} |
| 196 | tone={open ? "warn" : "mint"} |
| 197 | hint={open ? `${severityLabel(incident.severity)} · ${statusLabel(incident.status)}` : <When at={incident.resolved_at} time />} |
| 198 | /> |
| 199 | <Timer label="To acknowledge" value={duration(d.to_acknowledge)} hint={incident.acknowledged_at ? <When at={incident.acknowledged_at} time /> : "Not yet"} /> |
| 200 | <Timer label="To mitigate" value={duration(d.to_mitigate)} hint={incident.mitigated_at ? <When at={incident.mitigated_at} time /> : "At monitoring"} /> |
| 201 | <Timer label="To resolve" value={duration(d.to_resolve)} hint={incident.resolved_at ? <When at={incident.resolved_at} time /> : "Still open"} /> |
| 202 | </div> |
| 203 | </> |
| 204 | ); |
| 205 | } |
| 206 | |
| 207 | function UpdateForm({ incident, components, error }: { incident: AdminIncidentDetail; components: { key: string; name: string }[]; error: string | null }) { |
| 208 | const draft = incident.visibility === "draft"; |
| 209 | const current = new Map(incident.components.map((c) => [c.key, c.impact])); |
| 210 | return ( |
| 211 | <Section id="update" title={draft ? "Add a note" : "Post an update"} description={draft ? "Drafts take notes and changes; publish it to post publicly." : "A public update goes on the status page; a note stays here."}> |
| 212 | <form method="post" action="#update" className="space-y-4"> |
| 213 | <input type="hidden" name="intent" value="update" /> |
| 214 | {error && <Notice tone="error">{error}</Notice>} |
| 215 | {!draft && ( |
| 216 | <fieldset className="flex flex-wrap gap-2" aria-label="Who sees it"> |
| 217 | <label className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-line px-3 py-1.5 text-sm has-checked:border-merged/60 has-checked:bg-merged/10"> |
| 218 | <input type="radio" name="visibility" value="public" defaultChecked className="accent-[#b6a8ff]" /> |
| 219 | <Globe size={14} aria-hidden="true" /> Public update |
| 220 | </label> |
| 221 | <label className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-line px-3 py-1.5 text-sm has-checked:border-line-strong has-checked:bg-raised"> |
| 222 | <input type="radio" name="visibility" value="internal" className="accent-[#b6a8ff]" /> |
| 223 | <Lock size={14} aria-hidden="true" /> Internal note |
| 224 | </label> |
| 225 | </fieldset> |
| 226 | )} |
| 227 | <div className="grid gap-3 sm:grid-cols-2"> |
| 228 | <Field label="Status" hint={draft ? undefined : "Changing it needs a public update."}> |
| 229 | <Select name="status" defaultValue=""> |
| 230 | <option value="">Keep: {statusLabel(incident.status)}</option> |
| 231 | {INCIDENT_STATUSES.filter((s) => s.value !== incident.status).map((s) => ( |
| 232 | <option key={s.value} value={s.value}> |
| 233 | {s.label} |
| 234 | {s.value === nextStatus(incident.status) ? " (next)" : ""} |
| 235 | </option> |
| 236 | ))} |
| 237 | </Select> |
| 238 | </Field> |
| 239 | <Field label="Severity"> |
| 240 | <Select name="severity" defaultValue=""> |
| 241 | <option value="">Keep: {severityLabel(incident.severity)}</option> |
| 242 | {INCIDENT_SEVERITIES.filter((s) => s.value !== incident.severity).map((s) => ( |
| 243 | <option key={s.value} value={s.value}> |
| 244 | {s.label} |
| 245 | </option> |
| 246 | ))} |
| 247 | </Select> |
| 248 | </Field> |
| 249 | </div> |
| 250 | <details className="group rounded-md"> |
| 251 | <summary className="cursor-pointer text-sm text-muted select-none hover:text-fg">Change the parts' impact</summary> |
| 252 | <div className="mt-2"> |
| 253 | <ImpactPicker components={components} current={current} blank /> |
| 254 | </div> |
| 255 | </details> |
| 256 | <Field label="Message"> |
| 257 | <Textarea name="message" rows={4} maxLength={4000} placeholder={draft ? "What you found, for the team." : "What changed, in a sentence or two, for the people affected."} /> |
| 258 | </Field> |
| 259 | <div className="flex flex-wrap items-center justify-between gap-3"> |
| 260 | {!draft ? ( |
| 261 | <label className="flex items-center gap-2 text-sm"> |
| 262 | <input type="checkbox" name="notify" defaultChecked={notifyByDefault(incident.severity)} className="accent-[#b6a8ff]" /> |
| 263 | Email subscribers (public updates only) |
| 264 | </label> |
| 265 | ) : ( |
| 266 | <span /> |
| 267 | )} |
| 268 | <Button type="submit" variant="lavender"> |
| 269 | Post |
| 270 | </Button> |
| 271 | </div> |
| 272 | </form> |
| 273 | </Section> |
| 274 | ); |
| 275 | } |
| 276 | |
| 277 | export default function Incident({ loaderData, actionData }: Route.ComponentProps) { |
| 278 | const { incident, components, error, done, staff, now } = loaderData; |
| 279 | const failed = actionData as ActionData | undefined; |
| 280 | const errorFor = (intent: string) => (failed?.intent === intent ? failed.error : null); |
| 281 | if (!incident) { |
| 282 | return ( |
| 283 | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> |
| 284 | <BackLink to="/incidents">Incidents</BackLink> |
| 285 | <div className="mt-4"> |
| 286 | <Notice tone="warn">The status worker did not answer: {error}</Notice> |
| 287 | </div> |
| 288 | </main> |
| 289 | ); |
| 290 | } |
| 291 | const names = new Map(components.map((c) => [c.key, c.name])); |
| 292 | const open = incident.resolved_at == null; |
| 293 | const isPublic = incident.visibility === "public"; |
| 294 | const affected = incident.components.filter((c) => c.impact !== "operational"); |
| 295 | const timeline = [...incident.timeline].reverse(); |
| 296 | |
| 297 | return ( |
| 298 | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> |
| 299 | <BackLink to={incident.visibility === "draft" ? "/incidents?tab=drafts" : open ? "/incidents" : "/incidents?tab=resolved"}>Incidents</BackLink> |
| 300 | <Header incident={incident} now={now} /> |
| 301 | |
| 302 | <div className="mt-4 space-y-3"> |
| 303 | {done && <Notice tone="ok">{done}</Notice>} |
| 304 | {failed && !["update", "roles", "followup_add", "followup_set", "resolve", "publish", "dismiss"].includes(failed.intent) && <Notice tone="error">{failed.error}</Notice>} |
| 305 | </div> |
| 306 | |
| 307 | {incident.visibility === "draft" && ( |
| 308 | <section className="mt-5 rounded-lg border border-warn/35 bg-warn/6 p-4 sm:p-5"> |
| 309 | <h2 className="font-semibold">This is a draft</h2> |
| 310 | <p className="mt-1 text-sm text-muted"> |
| 311 | {incident.source === "detected" |
| 312 | ? "The checks failed or were slow three times in a row and made this. Nobody outside sees it until you publish it. If it was a blip, dismiss it; if no one picks it up and its parts stay healthy for 10 minutes, it is dismissed on its own." |
| 313 | : "Nobody outside sees it until you publish it."} |
| 314 | </p> |
| 315 | <div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1fr)_18rem]"> |
| 316 | <form method="post" className="space-y-3"> |
| 317 | <input type="hidden" name="intent" value="publish" /> |
| 318 | {errorFor("publish") && <Notice tone="error">{errorFor("publish")}</Notice>} |
| 319 | <Field label="Public title"> |
| 320 | <Input name="title" defaultValue={incident.title.replace(/^Detected: /, "")} maxLength={120} required /> |
| 321 | </Field> |
| 322 | <Field label="First public update"> |
| 323 | <Textarea name="message" rows={3} required maxLength={4000} placeholder="We are looking into reports that…" /> |
| 324 | </Field> |
| 325 | <div className="flex flex-wrap items-center justify-between gap-3"> |
| 326 | <label className="flex items-center gap-2 text-sm"> |
| 327 | <input type="checkbox" name="notify" defaultChecked={notifyByDefault(incident.severity)} className="accent-[#b6a8ff]" /> |
| 328 | Email subscribers |
| 329 | </label> |
| 330 | <Button type="submit" variant="lavender"> |
| 331 | Publish to the status page |
| 332 | </Button> |
| 333 | </div> |
| 334 | </form> |
| 335 | <form method="post" className="space-y-3 lg:border-l lg:border-warn/20 lg:pl-4"> |
| 336 | <input type="hidden" name="intent" value="dismiss" /> |
| 337 | {errorFor("dismiss") && <Notice tone="error">{errorFor("dismiss")}</Notice>} |
| 338 | <Field label="Not an incident?"> |
| 339 | <Input name="reason" maxLength={500} placeholder="A blip: answered again within minutes" /> |
| 340 | </Field> |
| 341 | <Button type="submit" variant="quiet"> |
| 342 | Dismiss draft |
| 343 | </Button> |
| 344 | </form> |
| 345 | </div> |
| 346 | </section> |
| 347 | )} |
| 348 | |
| 349 | <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]"> |
| 350 | <div className="min-w-0 space-y-6"> |
| 351 | {incident.visibility !== "dismissed" && <UpdateForm incident={incident} components={components} error={errorFor("update")} />} |
| 352 | <Section title="Timeline" description="Newest first. Public updates are what the status page shows; everything else is for staff."> |
| 353 | <ol className="space-y-2"> |
| 354 | {timeline.map((entry) => ( |
| 355 | <EntryLine key={entry.id} entry={entry} /> |
| 356 | ))} |
| 357 | </ol> |
| 358 | </Section> |
| 359 | </div> |
| 360 | |
| 361 | <aside className="min-w-0 space-y-6"> |
| 362 | {open && isPublic && ( |
| 363 | <Section id="resolve" title="Resolve" description="Closes it, posts a last public update, and stops colouring the parts."> |
| 364 | <form method="post" action="#resolve" className="space-y-3"> |
| 365 | <input type="hidden" name="intent" value="resolve" /> |
| 366 | {errorFor("resolve") && <Notice tone="error">{errorFor("resolve")}</Notice>} |
| 367 | <Textarea name="message" rows={3} required maxLength={4000} aria-label="Resolution update" placeholder="Pushes work again. A bad deploy was rolled back; nothing was lost." /> |
| 368 | <label className="flex items-center gap-2 text-sm"> |
| 369 | <input type="checkbox" name="notify" defaultChecked={notifyByDefault(incident.severity)} className="accent-[#b6a8ff]" /> |
| 370 | Email subscribers |
| 371 | </label> |
| 372 | <Button type="submit" variant="lavender" className="w-full"> |
| 373 | <CheckCircle2 size={15} aria-hidden="true" /> Resolve incident |
| 374 | </Button> |
| 375 | </form> |
| 376 | </Section> |
| 377 | )} |
| 378 | |
| 379 | {!open && isPublic && ( |
| 380 | <Section title="Postmortem" description={incident.postmortem_published_at ? "Published on the incident's page." : incident.postmortem ? "Drafted, not published." : "Not started."}> |
| 381 | <div className="flex flex-wrap items-center gap-2"> |
| 382 | <Link to={`/incidents/${incident.id}/postmortem`} className="inline-flex items-center gap-1.5 text-sm font-medium text-merged hover:underline"> |
| 383 | <FileText size={14} aria-hidden="true" /> |
| 384 | {incident.postmortem ? "Edit the postmortem" : "Write the postmortem"} |
| 385 | </Link> |
| 386 | {incident.postmortem_published_at && <Badge tone="mint">Published</Badge>} |
| 387 | </div> |
| 388 | </Section> |
| 389 | )} |
| 390 | |
| 391 | <Section title="Parts" description={open ? "What the status page shows for each while it is open." : "What it did to each."}> |
| 392 | {affected.length === 0 ? ( |
| 393 | <p className="text-sm text-muted">None marked.</p> |
| 394 | ) : ( |
| 395 | <ul className="space-y-2"> |
| 396 | {affected.map((c) => ( |
| 397 | <li key={c.key} className="flex items-center justify-between gap-2 text-sm"> |
| 398 | <span className="min-w-0 truncate">{names.get(c.key) ?? c.key}</span> |
| 399 | <ImpactBadge impact={c.impact} /> |
| 400 | </li> |
| 401 | ))} |
| 402 | </ul> |
| 403 | )} |
| 404 | </Section> |
| 405 | |
| 406 | <Section id="roles" title="Roles"> |
| 407 | <form method="post" action="#roles" className="space-y-3"> |
| 408 | <input type="hidden" name="intent" value="roles" /> |
| 409 | {errorFor("roles") && <Notice tone="error">{errorFor("roles")}</Notice>} |
| 410 | <Field label="Incident commander"> |
| 411 | <Input name="commander" type="email" list="staff" defaultValue={incident.commander ?? ""} placeholder="Nobody" /> |
| 412 | </Field> |
| 413 | <Field label="Communications"> |
| 414 | <Input name="communications" type="email" list="staff" defaultValue={incident.communications ?? ""} placeholder="Nobody" /> |
| 415 | </Field> |
| 416 | <Button type="submit" variant="quiet"> |
| 417 | Save roles |
| 418 | </Button> |
| 419 | </form> |
| 420 | </Section> |
| 421 | |
| 422 | <Section id="followups" title="Follow-ups" description="What to change so it does not happen again."> |
| 423 | {errorFor("followup_set") && ( |
| 424 | <div className="mb-3"> |
| 425 | <Notice tone="error">{errorFor("followup_set")}</Notice> |
| 426 | </div> |
| 427 | )} |
| 428 | {incident.followups.length > 0 && ( |
| 429 | <ul className="mb-4 space-y-1.5"> |
| 430 | {incident.followups.map((f) => ( |
| 431 | <li key={f.id}> |
| 432 | <form method="post" action="#followups" className="flex items-start gap-2"> |
| 433 | <input type="hidden" name="intent" value="followup_set" /> |
| 434 | <input type="hidden" name="followup" value={f.id} /> |
| 435 | <input type="hidden" name="done" value={f.done_at ? "0" : "1"} /> |
| 436 | <button |
| 437 | type="submit" |
| 438 | aria-label={f.done_at ? `Mark “${f.title}” not done` : `Mark “${f.title}” done`} |
| 439 | className="mt-0.5 shrink-0 rounded text-faint hover:text-merged" |
| 440 | > |
| 441 | {f.done_at ? <CheckCircle2 size={16} className="text-accent" /> : <Circle size={16} />} |
| 442 | </button> |
| 443 | <span className="min-w-0 text-sm"> |
| 444 | <span className={f.done_at ? "text-faint line-through" : ""}>{f.title}</span> |
| 445 | {f.owner && <span className="block font-mono text-xs text-faint">{f.owner}</span>} |
| 446 | </span> |
| 447 | </form> |
| 448 | </li> |
| 449 | ))} |
| 450 | </ul> |
| 451 | )} |
| 452 | <form method="post" action="#followups" className="space-y-2"> |
| 453 | <input type="hidden" name="intent" value="followup_add" /> |
| 454 | {errorFor("followup_add") && <Notice tone="error">{errorFor("followup_add")}</Notice>} |
| 455 | <Input name="title" required maxLength={200} placeholder="Alert on push errors above 1%" aria-label="Follow-up" /> |
| 456 | <div className="flex gap-2"> |
| 457 | <Input name="owner" type="email" list="staff" placeholder="Owner (optional)" aria-label="Owner" /> |
| 458 | <Button type="submit" variant="quiet"> |
| 459 | Add |
| 460 | </Button> |
| 461 | </div> |
| 462 | </form> |
| 463 | </Section> |
| 464 | |
| 465 | <Section title="Links"> |
| 466 | <ul className="space-y-1.5 text-sm"> |
| 467 | {isPublic ? ( |
| 468 | <li> |
| 469 | <a href={incident.url} className="inline-flex items-center gap-1.5 text-merged hover:underline"> |
| 470 | Status page permalink <ExternalLink size={13} aria-hidden="true" /> |
| 471 | </a> |
| 472 | <span className="block truncate font-mono text-xs text-faint">{incident.url}</span> |
| 473 | </li> |
| 474 | ) : ( |
| 475 | <li className="text-muted">No public page until it is published.</li> |
| 476 | )} |
| 477 | <li> |
| 478 | <Link to="/audit" className="text-muted hover:text-fg hover:underline"> |
| 479 | Audit log |
| 480 | </Link> |
| 481 | </li> |
| 482 | </ul> |
| 483 | </Section> |
| 484 | <datalist id="staff"> |
| 485 | {staff.map((email) => ( |
| 486 | <option key={email} value={email} /> |
| 487 | ))} |
| 488 | </datalist> |
| 489 | </aside> |
| 490 | </div> |
| 491 | </main> |
| 492 | ); |
| 493 | } |