my own status page
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: merge similar status updates

+72 -13
+72 -13
src/routes/index.ts
··· 1 - import type { Env } from "../types"; 1 + import type { Env, IncidentWithUpdates } from "../types"; 2 2 import { getManifest } from "../manifest"; 3 3 import { getAllLatestPings, getAllUptime7d, getOverallUptimeDays, getOverallUptimePct, getLastCheckTime, getActiveIncidentsWithUpdates, getActiveIncidents, getRecentResolvedIncidentsWithUpdates } from "../db"; 4 4 import { getDeviceStatus } from "../tailscale"; 5 5 import { getOverallStatus } from "../overall"; 6 6 import { COMMIT_SHA } from "../version"; 7 7 8 + interface IncidentGroup { 9 + status: string; 10 + title: string; 11 + services: string[]; 12 + started_at: number; 13 + resolved_at: number | null; 14 + updated_at: number; 15 + updates: { status: string; message: string; created_at: number }[]; 16 + } 17 + 18 + function groupIncidents(incidents: IncidentWithUpdates[]): IncidentGroup[] { 19 + const groups: IncidentGroup[] = []; 20 + const assigned = new Set<number>(); 21 + 22 + for (let i = 0; i < incidents.length; i++) { 23 + if (assigned.has(i)) continue; 24 + const a = incidents[i]; 25 + const group: IncidentGroup = { 26 + status: a.status, 27 + title: a.title, 28 + services: [a.service_id], 29 + started_at: a.started_at, 30 + resolved_at: a.resolved_at, 31 + updated_at: a.updated_at, 32 + updates: a.updates.map((u) => ({ status: u.status, message: u.message, created_at: u.created_at })), 33 + }; 34 + 35 + for (let j = i + 1; j < incidents.length; j++) { 36 + if (assigned.has(j)) continue; 37 + const b = incidents[j]; 38 + if (Math.abs(a.started_at - b.started_at) <= 60 && Math.abs((a.resolved_at ?? 0) - (b.resolved_at ?? 0)) <= 60 && a.status === b.status && a.updates.length === b.updates.length && a.updates.every((u, idx) => u.status === b.updates[idx].status && u.message === b.updates[idx].message)) { 39 + group.services.push(b.service_id); 40 + assigned.add(j); 41 + } 42 + } 43 + 44 + if (group.services.length > 1) { 45 + const statusWord = group.title.includes("timeout") ? "timeout" : group.title.includes("down") ? "down" : "degraded"; 46 + group.title = `${group.services.length} services are ${statusWord}`; 47 + } 48 + 49 + groups.push(group); 50 + } 51 + 52 + return groups; 53 + } 54 + 8 55 export async function handleIndex(env: Env): Promise<Response> { 9 56 const manifest = await getManifest(env); 10 57 const serverServiceIds = Object.values(manifest) ··· 52 99 }); 53 100 const activeIncidents = activeIncidentsWithUpdates; 54 101 102 + const serviceUrlMap = new Map<string, string>(); 103 + for (const machine of Object.values(manifest)) { 104 + for (const svc of machine.services) { 105 + serviceUrlMap.set(svc.name, `https://${svc.domain}`); 106 + } 107 + } 108 + 109 + const groupedActive = groupIncidents(activeIncidents); 110 + const groupedResolved = groupIncidents(resolvedIncidents); 111 + 55 112 const html = `<!DOCTYPE html> 56 113 <html lang="en"> 57 114 <head> ··· 118 175 .incident-status.identified { color: #f39c12; } 119 176 .incident-title { font-weight: 500; } 120 177 .incident-time { color: #8b949e; } 178 + .incident-services a { color: #484f58; text-decoration: none; } 179 + .incident-services a:hover { text-decoration: underline; } 121 180 .incident-timeline { margin-top: 0.25rem; padding-left: 0.75rem; } 122 181 .timeline-entry { padding: 0.25rem 0 0.25rem 0.5rem; font-size: 0.7rem; position: relative; } 123 182 .timeline-entry::before { content: ''; position: absolute; left: -0.75rem; top: 50%; width: 6px; height: 6px; border-radius: 50%; background: #30363d; transform: translate(-2px, -50%); z-index: 1; } ··· 152 211 <div class="uptime-bar">${uptimeDays.map((d) => `<div class="day ${d.status}" title="${d.date}: ${d.status}"></div>`).join("")}</div> 153 212 <h1>infra.dunkirk.sh</h1> 154 213 <p class="overall"><span class="dot ${overallClass}" id="overall-dot" title="${overallClass}"></span><span id="overall-text">${overallText}</span><span class="uptime-pct" id="overall-uptime"> at ${uptime90d}%</span></p> 155 - ${activeIncidents.length > 0 ? `<div class="incidents"> 156 - ${activeIncidents.map((i) => `<div class="incident-banner"> 214 + ${groupedActive.length > 0 ? `<div class="incidents"> 215 + ${groupedActive.map((g) => `<div class="incident-banner"> 157 216 <div class="incident-header"> 158 - <span class="incident-status ${i.status}">${i.status}</span> 159 - <span class="incident-title">${esc(i.title)}</span> 160 - <span class="incident-time">started <relative-time datetime="${new Date(i.started_at * 1000).toISOString()}">loading</relative-time></span> 217 + <span class="incident-status ${g.status}">${g.status}</span> 218 + <span class="incident-title">${esc(g.title)}</span>${g.services.length > 1 ? `<span class="incident-services">${g.services.map((s) => serviceUrlMap.has(s) ? `<a href="${esc(serviceUrlMap.get(s)!)}">${esc(s)}</a>` : esc(s)).join(", ")}</span>` : ""} 219 + <span class="incident-time">started <relative-time datetime="${new Date(g.started_at * 1000).toISOString()}">loading</relative-time></span> 161 220 </div> 162 - ${i.updates.length > 0 ? `<div class="incident-timeline"> 163 - ${i.updates.map((u) => `<div class="timeline-entry ${u.status}"> 221 + ${g.updates.length > 0 ? `<div class="incident-timeline"> 222 + ${g.updates.map((u) => `<div class="timeline-entry ${u.status}"> 164 223 <span class="timeline-time">${new Date(u.created_at * 1000).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "America/New_York" })}</span> 165 224 <span class="timeline-status ${u.status}">${esc(u.status)}</span> 166 225 <span class="timeline-msg">${esc(u.message)}</span> ··· 194 253 ${clients.map((m) => `<span class="client"><span class="dot ${m.online ? "online" : "unknown"}" data-machine="${esc(m.name)}" title="${m.online ? "online" : "offline"}"></span>${esc(m.name)}</span>`).join("\n")} 195 254 </div> 196 255 </div>` : ""} 197 - ${resolvedIncidents.length > 0 ? `<div class="resolved-incidents"> 256 + ${groupedResolved.length > 0 ? `<div class="resolved-incidents"> 198 257 <div class="resolved-header">recent incidents</div> 199 - ${resolvedIncidents.map((i) => `<div class="resolved-item"> 200 - <div class="resolved-item-header">${esc(i.title)} — resolved <relative-time datetime="${new Date((i.resolved_at ?? i.updated_at) * 1000).toISOString()}">loading</relative-time></div> 201 - ${i.updates.length > 0 ? `<div class="incident-timeline"> 202 - ${i.updates.map((u) => `<div class="timeline-entry ${u.status}"> 258 + ${groupedResolved.map((g) => `<div class="resolved-item"> 259 + <div class="resolved-item-header">${esc(g.title)} — resolved <relative-time datetime="${new Date((g.resolved_at ?? g.updated_at) * 1000).toISOString()}">loading</relative-time>${g.services.length > 1 ? `<div class="incident-services">${g.services.map((s) => serviceUrlMap.has(s) ? `<a href="${esc(serviceUrlMap.get(s)!)}">${esc(s)}</a>` : esc(s)).join(", ")}</div>` : ""}</div> 260 + ${g.updates.length > 0 ? `<div class="incident-timeline"> 261 + ${g.updates.map((u) => `<div class="timeline-entry ${u.status}"> 203 262 <span class="timeline-time">${new Date(u.created_at * 1000).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "America/New_York" })}</span> 204 263 <span class="timeline-status ${u.status}">${esc(u.status)}</span> 205 264 <span class="timeline-msg">${esc(u.message)}</span>