| 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 three checks in a row, a draft appears here and the alert address gets an email." |
| 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 | } |