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

193 lines8,850 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
Fast pages, required checks on the branch, self-hosted runners, honest incidents20import { fromLocalInput, toLocalInput } from "./time.ts";
21
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas22export { 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
Fast pages, required checks on the branch, self-hosted runners, honest incidents155/** 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);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas158}
159
Fast pages, required checks on the branch, self-hosted runners, honest incidents160/** 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);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas163}
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}