| 1 | import { CalendarClock, ChevronRight, ExternalLink, Mail, Siren } from "lucide-react"; |
| 2 | import { Link } from "react-router"; |
| 3 | |
| 4 | import type { AdminIncident, AdminMaintenance } from "@g1t/contracts"; |
| 5 | |
| 6 | import type { Route } from "./+types/incidents"; |
| 7 | import { ImpactBadge, MaintenanceBadge, PhaseBadge, SeverityBadge } from "~/components/incidents"; |
| 8 | import { Button, ButtonLink, EmptyState, Input, Notice, PageHeader, Select, Stat, When } from "~/components/ui"; |
| 9 | import { |
| 10 | INCIDENT_SEVERITIES, |
| 11 | TABS, |
| 12 | duration, |
| 13 | filterIncidents, |
| 14 | incidentsHref, |
| 15 | maintenanceList, |
| 16 | median, |
| 17 | parseFilters, |
| 18 | secondsBetween, |
| 19 | tabCounts, |
| 20 | } from "~/lib/incidents"; |
| 21 | import { statusAdmin } from "~/lib/services.server"; |
| 22 | import { settle } from "~/lib/settle"; |
| 23 | import { requireStaff } from "~/lib/staff"; |
| 24 | |
| 25 | export const meta: Route.MetaFunction = () => [{ title: "Incidents · sudo" }, { name: "robots", content: "noindex, nofollow" }]; |
| 26 | |
| 27 | const STATUS_PAGE = "https://status.g1t.sh/"; |
| 28 | |
| 29 | export async function loader({ request, context }: Route.LoaderArgs) { |
| 30 | requireStaff(context); |
| 31 | const filters = parseFilters(new URL(request.url).searchParams); |
| 32 | const [board, components] = await Promise.all([settle(statusAdmin.board()), settle(statusAdmin.components())]); |
| 33 | return { |
| 34 | filters, |
| 35 | board: board.ok ? board.value : null, |
| 36 | components: components.ok ? components.value : [], |
| 37 | error: board.ok ? null : board.error, |
| 38 | now: new Date().toISOString(), |
| 39 | }; |
| 40 | } |
| 41 | |
| 42 | function IncidentRow({ incident, names, now }: { incident: AdminIncident; names: Map<string, string>; now: string }) { |
| 43 | const affected = incident.components.filter((c) => c.impact !== "operational"); |
| 44 | const open = incident.resolved_at == null; |
| 45 | return ( |
| 46 | <li> |
| 47 | <Link |
| 48 | to={`/incidents/${incident.id}`} |
| 49 | className="group flex flex-col gap-2 px-4 py-3.5 hover:bg-raised/40 sm:flex-row sm:items-start sm:gap-4 sm:px-5" |
| 50 | > |
| 51 | <div className="flex shrink-0 items-center gap-2 sm:w-20 sm:pt-0.5"> |
| 52 | <SeverityBadge severity={incident.severity} /> |
| 53 | </div> |
| 54 | <div className="min-w-0 grow"> |
| 55 | <div className="flex flex-wrap items-center gap-2"> |
| 56 | <p className="font-medium group-hover:text-fg">{incident.title}</p> |
| 57 | <PhaseBadge incident={incident} /> |
| 58 | {incident.source === "detected" && <span className="text-xs text-faint">Detected by the checks</span>} |
| 59 | </div> |
| 60 | <div className="mt-1.5 flex flex-wrap gap-1.5"> |
| 61 | {affected.map((c) => ( |
| 62 | <span key={c.key} className="inline-flex items-center gap-1.5 text-xs text-muted"> |
| 63 | {names.get(c.key) ?? c.key} <ImpactBadge impact={c.impact} /> |
| 64 | </span> |
| 65 | ))} |
| 66 | </div> |
| 67 | <p className="mt-1.5 text-xs text-faint"> |
| 68 | Started <When at={incident.started_at} time /> |
| 69 | {incident.commander && ( |
| 70 | <> |
| 71 | {" "}· IC <span className="font-mono">{incident.commander}</span> |
| 72 | </> |
| 73 | )} |
| 74 | {incident.followups_open + incident.followups_done > 0 && ( |
| 75 | <> |
| 76 | {" "}· follow-ups {incident.followups_done}/{incident.followups_open + incident.followups_done} |
| 77 | </> |
| 78 | )} |
| 79 | </p> |
| 80 | </div> |
| 81 | <div className="flex shrink-0 items-center gap-3 text-right sm:block"> |
| 82 | <p className={`tabular text-sm font-medium ${open ? "text-warn" : "text-fg-soft"}`}> |
| 83 | {open ? duration(secondsBetween(incident.started_at, now)) : duration(incident.durations.to_resolve)} |
| 84 | </p> |
| 85 | <p className="text-xs text-faint">{open ? "open" : incident.visibility === "dismissed" ? "dismissed" : "to resolve"}</p> |
| 86 | </div> |
| 87 | <ChevronRight size={16} aria-hidden="true" className="hidden shrink-0 self-center text-faint group-hover:text-muted sm:block" /> |
| 88 | </Link> |
| 89 | </li> |
| 90 | ); |
| 91 | } |
| 92 | |
| 93 | function MaintenanceRow({ m, names }: { m: AdminMaintenance; names: Map<string, string> }) { |
| 94 | return ( |
| 95 | <li> |
| 96 | <Link |
| 97 | to={`/incidents/maintenance/${m.id}`} |
| 98 | className="group flex flex-col gap-2 px-4 py-3.5 hover:bg-raised/40 sm:flex-row sm:items-start sm:gap-4 sm:px-5" |
| 99 | > |
| 100 | <CalendarClock size={16} aria-hidden="true" className="hidden shrink-0 text-faint sm:mt-0.5 sm:block" /> |
| 101 | <div className="min-w-0 grow"> |
| 102 | <div className="flex flex-wrap items-center gap-2"> |
| 103 | <p className="font-medium">{m.title}</p> |
| 104 | <MaintenanceBadge state={m.state} /> |
| 105 | </div> |
| 106 | <p className="mt-1 text-xs text-muted">{m.components.map((k) => names.get(k) ?? k).join(", ")}</p> |
| 107 | <p className="mt-1 text-xs text-faint"> |
| 108 | <When at={m.starts_at} time /> to <When at={m.ends_at} time /> · {duration(secondsBetween(m.starts_at, m.ends_at))} |
| 109 | </p> |
| 110 | </div> |
| 111 | <ChevronRight size={16} aria-hidden="true" className="hidden shrink-0 self-center text-faint group-hover:text-muted sm:block" /> |
| 112 | </Link> |
| 113 | </li> |
| 114 | ); |
| 115 | } |
| 116 | |
| 117 | export default function Incidents({ loaderData }: Route.ComponentProps) { |
| 118 | const { filters, board, components, error, now } = loaderData; |
| 119 | const names = new Map(components.map((c) => [c.key, c.name])); |
| 120 | const incidents = board?.incidents ?? []; |
| 121 | const maintenance = board?.maintenance ?? []; |
| 122 | const counts = tabCounts(incidents, maintenance); |
| 123 | const shown = filterIncidents(incidents, filters); |
| 124 | const shownMaintenance = maintenanceList(maintenance, filters.component); |
| 125 | const recent = incidents.filter((i) => i.visibility === "public" && i.resolved_at && secondsBetween(i.resolved_at, now) < 90 * 86400); |
| 126 | const filtered = filters.severity != null || filters.component != null || filters.q !== ""; |
| 127 | |
| 128 | return ( |
| 129 | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> |
| 130 | <PageHeader |
| 131 | title="Incidents" |
| 132 | description="Declare, run and close incidents, schedule maintenance, and publish postmortems. What is public appears on status.g1t.sh within 30 seconds." |
| 133 | actions={ |
| 134 | <div className="flex flex-wrap items-center gap-2"> |
| 135 | <ButtonLink to="/incidents/new" variant="lavender"> |
| 136 | <Siren size={15} aria-hidden="true" /> |
| 137 | Declare incident |
| 138 | </ButtonLink> |
| 139 | <ButtonLink to="/incidents/maintenance/new" variant="quiet"> |
| 140 | <CalendarClock size={15} aria-hidden="true" /> |
| 141 | Schedule maintenance |
| 142 | </ButtonLink> |
| 143 | <a |
| 144 | href={STATUS_PAGE} |
| 145 | className="inline-flex items-center gap-1.5 rounded-md px-2 py-2 text-sm text-muted hover:text-fg" |
| 146 | > |
| 147 | status.g1t.sh <ExternalLink size={13} aria-hidden="true" /> |
| 148 | </a> |
| 149 | </div> |
| 150 | } |
| 151 | /> |
| 152 | |
| 153 | {error && ( |
| 154 | <div className="mt-5"> |
| 155 | <Notice tone="warn">The status worker did not answer: {error}</Notice> |
| 156 | </div> |
| 157 | )} |
| 158 | |
| 159 | <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4"> |
| 160 | <Stat label="Open" value={counts.open} tone={counts.open ? "danger" : "mint"} hint={counts.drafts ? `${counts.drafts} draft${counts.drafts === 1 ? "" : "s"} waiting` : "No drafts waiting"} /> |
| 161 | <Stat label="Maintenance ahead" value={counts.maintenance} hint="Scheduled or under way" /> |
| 162 | <Stat |
| 163 | label="Median time to resolve" |
| 164 | value={duration(median(recent.map((i) => i.durations.to_resolve)))} |
| 165 | hint={`${recent.length} public incident${recent.length === 1 ? "" : "s"}, 90 days`} |
| 166 | /> |
| 167 | <Stat |
| 168 | label="Email subscribers" |
| 169 | value={board?.subscribers ?? "—"} |
| 170 | hint={board && !board.email ? "Email is off: feeds only" : "Confirmed addresses"} |
| 171 | /> |
| 172 | </div> |
| 173 | |
| 174 | <nav aria-label="Incident lists" className="mt-6 flex gap-1 overflow-x-auto border-b border-line"> |
| 175 | {TABS.map((tab) => { |
| 176 | const current = filters.tab === tab.value; |
| 177 | return ( |
| 178 | <Link |
| 179 | key={tab.value} |
| 180 | to={incidentsHref({ ...filters, tab: tab.value })} |
| 181 | aria-current={current ? "page" : undefined} |
| 182 | className={`-mb-px flex shrink-0 items-center gap-2 border-b-2 px-3 py-2 text-sm whitespace-nowrap ${ |
| 183 | current ? "border-merged font-medium text-fg" : "border-transparent text-muted hover:text-fg" |
| 184 | }`} |
| 185 | > |
| 186 | {tab.label} |
| 187 | <span className={`tabular rounded-full px-1.5 text-xs ${counts[tab.value] && tab.value !== "resolved" ? "bg-merged/15 text-merged" : "text-faint"}`}> |
| 188 | {counts[tab.value]} |
| 189 | </span> |
| 190 | </Link> |
| 191 | ); |
| 192 | })} |
| 193 | </nav> |
| 194 | |
| 195 | <form method="get" action="/incidents" className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-center"> |
| 196 | {filters.tab !== "open" && <input type="hidden" name="tab" value={filters.tab} />} |
| 197 | <Input name="q" type="search" defaultValue={filters.q} placeholder="Search titles" aria-label="Search titles" className="sm:w-64" /> |
| 198 | {filters.tab !== "maintenance" && ( |
| 199 | <Select name="severity" defaultValue={filters.severity ?? ""} aria-label="Severity" className="sm:w-40"> |
| 200 | <option value="">Any severity</option> |
| 201 | {INCIDENT_SEVERITIES.map((s) => ( |
| 202 | <option key={s.value} value={s.value}> |
| 203 | {s.label} |
| 204 | </option> |
| 205 | ))} |
| 206 | </Select> |
| 207 | )} |
| 208 | <Select name="component" defaultValue={filters.component ?? ""} aria-label="Part" className="sm:w-56"> |
| 209 | <option value="">Any part</option> |
| 210 | {components.map((c) => ( |
| 211 | <option key={c.key} value={c.key}> |
| 212 | {c.name} |
| 213 | </option> |
| 214 | ))} |
| 215 | </Select> |
| 216 | <div className="flex items-center gap-2"> |
| 217 | <Button type="submit" variant="quiet" className="py-2"> |
| 218 | Apply |
| 219 | </Button> |
| 220 | {filtered && ( |
| 221 | <Link to={incidentsHref({ tab: filters.tab })} className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline"> |
| 222 | Clear |
| 223 | </Link> |
| 224 | )} |
| 225 | </div> |
| 226 | </form> |
| 227 | |
| 228 | <div className="mt-4"> |
| 229 | {filters.tab === "maintenance" ? ( |
| 230 | shownMaintenance.length === 0 ? ( |
| 231 | <EmptyState title="No maintenance scheduled"> |
| 232 | <Link to="/incidents/maintenance/new" className="text-merged hover:underline"> |
| 233 | Schedule maintenance |
| 234 | </Link>{" "} |
| 235 | to tell people ahead of planned work. |
| 236 | </EmptyState> |
| 237 | ) : ( |
| 238 | <ul className="divide-y divide-line rounded-lg border border-line bg-surface"> |
| 239 | {shownMaintenance.map((m) => ( |
| 240 | <MaintenanceRow key={m.id} m={m} names={names} /> |
| 241 | ))} |
| 242 | </ul> |
| 243 | ) |
| 244 | ) : shown.length === 0 ? ( |
| 245 | <EmptyState |
| 246 | title={ |
| 247 | filtered |
| 248 | ? "Nothing matches" |
| 249 | : filters.tab === "open" |
| 250 | ? "Nothing is open" |
| 251 | : filters.tab === "drafts" |
| 252 | ? "No drafts" |
| 253 | : "No resolved incidents in the last 180 days" |
| 254 | } |
| 255 | > |
| 256 | {filtered |
| 257 | ? "Try another severity or part, or clear the filters." |
| 258 | : filters.tab === "open" |
| 259 | ? "The status page shows only its own checks." |
| 260 | : filters.tab === "drafts" |
| 261 | ? "When a part fails or is slow on three checks in a row, a draft appears here and the alert address gets an email. If no one picks it up and the part stays healthy for 10 minutes, it is dismissed on its own. Restarts during a deploy are not drafted unless they outlast it." |
| 262 | : null} |
| 263 | </EmptyState> |
| 264 | ) : ( |
| 265 | <ul className="divide-y divide-line rounded-lg border border-line bg-surface"> |
| 266 | {shown.map((incident) => ( |
| 267 | <IncidentRow key={incident.id} incident={incident} names={names} now={now} /> |
| 268 | ))} |
| 269 | </ul> |
| 270 | )} |
| 271 | </div> |
| 272 | |
| 273 | {board && !board.email && ( |
| 274 | <p className="mt-4 flex items-center gap-2 text-xs text-faint"> |
| 275 | <Mail size={13} aria-hidden="true" /> |
| 276 | The status worker has no email sender or no STATUS_SECRET, so subscribers cannot sign up; the feeds still carry every update. |
| 277 | </p> |
| 278 | )} |
| 279 | </main> |
| 280 | ); |
| 281 | } |