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

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