g1t/apps/sudo/app/routes/incidents.tsx

281 lines12,226 bytesCodeBlame
1import { CalendarClock, ChevronRight, ExternalLink, Mail, Siren } from "lucide-react";
2import { Link } from "react-router";
3
4import type { AdminIncident, AdminMaintenance } from "@g1t/contracts";
5
6import type { Route } from "./+types/incidents";
7import { ImpactBadge, MaintenanceBadge, PhaseBadge, SeverityBadge } from "~/components/incidents";
8import { Button, ButtonLink, EmptyState, Input, Notice, PageHeader, Select, Stat, When } from "~/components/ui";
9import {
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";
21import { statusAdmin } from "~/lib/services.server";
22import { settle } from "~/lib/settle";
23import { requireStaff } from "~/lib/staff";
24
25export const meta: Route.MetaFunction = () => [{ title: "Incidents · sudo" }, { name: "robots", content: "noindex, nofollow" }];
26
27const STATUS_PAGE = "https://status.g1t.sh/";
28
29export 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
42function 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
93function 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
117export 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}