g1t/apps/sudo/app/lib/incidents.ts

194 lines8,977 bytesCodeBlame
1/**
2 * The Incidents pages' wording and arithmetic: tabs and filters read from
3 * the address, chips and their tones, durations, and reading the forms.
4 * No Workers or React imports, so it is tested under Node. The status
5 * worker checks everything again.
6 */
7import {
8 type AdminIncident,
9 type AdminMaintenance,
10 COMPONENT_IMPACTS,
11 type ComponentImpact,
12 INCIDENT_SEVERITIES,
13 INCIDENT_STATUSES,
14 type ImpactInput,
15 type IncidentSeverity,
16 type IncidentStatus,
17 type MaintenanceState,
18} from "@g1t/contracts/status";
19
20export { COMPONENT_IMPACTS, INCIDENT_SEVERITIES, INCIDENT_STATUSES };
21
22export type Tone = "plain" | "lavender" | "mint" | "warn" | "danger" | "info";
23
24export const SEVERITY_TONE: Record<IncidentSeverity, Tone> = { sev1: "danger", sev2: "warn", sev3: "info", sev4: "plain" };
25export const STATUS_TONE: Record<IncidentStatus, Tone> = { investigating: "danger", identified: "warn", monitoring: "info", resolved: "mint" };
26export const IMPACT_TONE: Record<ComponentImpact, Tone> = { operational: "mint", degraded: "warn", partial_outage: "warn", major_outage: "danger" };
27export const MAINTENANCE_TONE: Record<MaintenanceState, Tone> = { scheduled: "info", in_progress: "lavender", completed: "mint", cancelled: "plain" };
28export const MAINTENANCE_WORD: Record<MaintenanceState, string> = {
29 scheduled: "Scheduled",
30 in_progress: "In progress",
31 completed: "Completed",
32 cancelled: "Cancelled",
33};
34
35export function severityLabel(severity: IncidentSeverity): string {
36 return INCIDENT_SEVERITIES.find((s) => s.value === severity)?.label ?? severity.toUpperCase();
37}
38
39export function statusLabel(status: IncidentStatus): string {
40 return INCIDENT_STATUSES.find((s) => s.value === status)?.label ?? status;
41}
42
43export function impactLabel(impact: ComponentImpact): string {
44 return COMPONENT_IMPACTS.find((i) => i.value === impact)?.label ?? impact;
45}
46
47export function notifyByDefault(severity: IncidentSeverity): boolean {
48 return INCIDENT_SEVERITIES.find((s) => s.value === severity)?.notify ?? false;
49}
50
51/** A notify choice: `yes`, `no`, or `auto` (the severity's default). */
52export function wantsNotify(choice: string, severity: IncidentSeverity): boolean {
53 return choice === "yes" || choice === "on" || (choice === "auto" && notifyByDefault(severity));
54}
55
56/** The status a next update most likely moves to. */
57export function nextStatus(status: IncidentStatus): IncidentStatus {
58 return status === "investigating" ? "identified" : status === "identified" ? "monitoring" : "resolved";
59}
60
61// --- Tabs and filters ------------------------------------------------------------------
62
63export const TABS = [
64 { value: "open", label: "Open" },
65 { value: "maintenance", label: "Scheduled maintenance" },
66 { value: "resolved", label: "Resolved" },
67 { value: "drafts", label: "Drafts" },
68] as const;
69export type Tab = (typeof TABS)[number]["value"];
70
71export type Filters = { tab: Tab; severity: IncidentSeverity | null; component: string | null; q: string };
72
73export function parseFilters(params: URLSearchParams): Filters {
74 const tab = TABS.find((t) => t.value === params.get("tab"))?.value ?? "open";
75 const severity = INCIDENT_SEVERITIES.find((s) => s.value === params.get("severity"))?.value ?? null;
76 const component = /^[a-z][a-z0-9-]{0,40}$/.test(params.get("component") ?? "") ? params.get("component") : null;
77 const q = (params.get("q") ?? "").trim().slice(0, 100);
78 return { tab, severity, component, q };
79}
80
81export function incidentsHref(filters: Partial<Filters>): string {
82 const params = new URLSearchParams();
83 if (filters.tab && filters.tab !== "open") params.set("tab", filters.tab);
84 if (filters.severity) params.set("severity", filters.severity);
85 if (filters.component) params.set("component", filters.component);
86 if (filters.q) params.set("q", filters.q);
87 const query = params.toString();
88 return query ? `/incidents?${query}` : "/incidents";
89}
90
91/** Which tab an incident is under. Maintenance has its own. */
92export function tabOf(incident: Pick<AdminIncident, "visibility" | "resolved_at">): Exclude<Tab, "maintenance"> {
93 if (incident.visibility === "draft") return "drafts";
94 if (incident.visibility === "dismissed" || incident.resolved_at) return "resolved";
95 return "open";
96}
97
98export function filterIncidents<T extends Pick<AdminIncident, "visibility" | "resolved_at" | "severity" | "components" | "title">>(
99 list: T[],
100 filters: Filters,
101): T[] {
102 const q = filters.q.toLowerCase();
103 return list.filter(
104 (i) =>
105 tabOf(i) === filters.tab &&
106 (!filters.severity || i.severity === filters.severity) &&
107 (!filters.component || i.components.some((c) => c.key === filters.component && c.impact !== "operational")) &&
108 (!q || i.title.toLowerCase().includes(q)),
109 );
110}
111
112/** Maintenance under the tab: upcoming and under way first, soonest first; then finished, newest first. */
113export function maintenanceList(list: AdminMaintenance[], component: string | null): AdminMaintenance[] {
114 const live = (m: AdminMaintenance) => m.state === "scheduled" || m.state === "in_progress";
115 return list
116 .filter((m) => !component || m.components.includes(component))
117 .sort((a, b) => (live(a) !== live(b) ? (live(a) ? -1 : 1) : live(a) ? a.starts_at.localeCompare(b.starts_at) : b.starts_at.localeCompare(a.starts_at)));
118}
119
120export function tabCounts(incidents: Pick<AdminIncident, "visibility" | "resolved_at">[], maintenance: Pick<AdminMaintenance, "state">[]): Record<Tab, number> {
121 const counts: Record<Tab, number> = { open: 0, maintenance: 0, resolved: 0, drafts: 0 };
122 for (const i of incidents) counts[tabOf(i)] += 1;
123 counts.maintenance = maintenance.filter((m) => m.state === "scheduled" || m.state === "in_progress").length;
124 return counts;
125}
126
127// --- Time ----------------------------------------------------------------------------
128
129/** "45s", "12m", "2h 05m", "3d 4h"; a dash for none. */
130export function duration(seconds: number | null | undefined): string {
131 if (seconds == null || Number.isNaN(seconds)) return "—";
132 const s = Math.max(0, Math.round(seconds));
133 if (s < 60) return `${s}s`;
134 const minutes = Math.floor(s / 60);
135 if (minutes < 60) return `${minutes}m`;
136 const hours = Math.floor(minutes / 60);
137 if (hours < 48) return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
138 return `${Math.floor(hours / 24)}d ${hours % 24}h`;
139}
140
141export function secondsBetween(from: string, to: Date | string): number {
142 return (new Date(to).getTime() - Date.parse(from)) / 1000;
143}
144
145/** The median of the known values; null with none. */
146export function median(values: (number | null)[]): number | null {
147 const known = values.filter((v): v is number => v != null).sort((a, b) => a - b);
148 if (!known.length) return null;
149 const mid = Math.floor(known.length / 2);
150 return known.length % 2 ? known[mid]! : (known[mid - 1]! + known[mid]!) / 2;
151}
152
153/** A `datetime-local` value, read as UTC: sudo ships no script to know the browser's zone. */
154export function utc(raw: string): string | null {
155 const value = raw.trim();
156 if (!value) return null;
157 const iso = /Z$|[+-]\d\d:\d\d$/.test(value) ? value : `${value.length === 16 ? `${value}:00` : value}Z`;
158 return Number.isNaN(Date.parse(iso)) ? null : new Date(iso).toISOString();
159}
160
161/** A time as a `datetime-local` value in UTC: `2026-10-05T14:00`. */
162export function localValue(at: Date | string): string {
163 return new Date(at).toISOString().slice(0, 16);
164}
165
166// --- Forms ------------------------------------------------------------------------------
167
168/** Each part's impact from `impact.<key>` selects; an empty choice leaves the part as it is. */
169export function readImpacts(form: FormData, keys: string[]): ImpactInput[] {
170 const out: ImpactInput[] = [];
171 for (const key of keys) {
172 const value = form.get(`impact.${key}`);
173 const impact = COMPONENT_IMPACTS.find((i) => i.value === value)?.value;
174 if (impact) out.push({ key, impact });
175 }
176 return out;
177}
178
179/** Staff addresses for the roles' suggestions: exact addresses in STAFF_EMAILS, and any seen. */
180export function staffSuggestions(staffEmails: string, seen: (string | null | undefined)[]): string[] {
181 const listed = staffEmails
182 .split(",")
183 .map((e) => e.trim().toLowerCase())
184 .filter((e) => /^[^@\s]+@[^@\s]+$/.test(e));
185 return [...new Set([...listed, ...seen.filter((e): e is string => !!e && e.includes("@")).map((e) => e.toLowerCase())])].sort();
186}
187
188/** The chip an incident's phase shows: draft, dismissed, postmortem published, or its status. */
189export function phase(i: Pick<AdminIncident, "visibility" | "status" | "postmortem_published_at">): { label: string; tone: Tone } {
190 if (i.visibility === "draft") return { label: "Draft", tone: "lavender" };
191 if (i.visibility === "dismissed") return { label: "Dismissed", tone: "plain" };
192 if (i.postmortem_published_at) return { label: "Postmortem published", tone: "mint" };
193 return { label: statusLabel(i.status), tone: STATUS_TONE[i.status] };
194}