Skip to content

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

194 lines8,977 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1/**
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}