diff --git a/src/routes/index.ts b/src/routes/index.ts index 2ed47f6..9b3ffcf 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,10 +1,57 @@ -import type { Env } from "../types"; +import type { Env, IncidentWithUpdates } from "../types"; import { getManifest } from "../manifest"; import { getAllLatestPings, getAllUptime7d, getOverallUptimeDays, getOverallUptimePct, getLastCheckTime, getActiveIncidentsWithUpdates, getActiveIncidents, getRecentResolvedIncidentsWithUpdates } from "../db"; import { getDeviceStatus } from "../tailscale"; import { getOverallStatus } from "../overall"; import { COMMIT_SHA } from "../version"; +interface IncidentGroup { + status: string; + title: string; + services: string[]; + started_at: number; + resolved_at: number | null; + updated_at: number; + updates: { status: string; message: string; created_at: number }[]; +} + +function groupIncidents(incidents: IncidentWithUpdates[]): IncidentGroup[] { + const groups: IncidentGroup[] = []; + const assigned = new Set(); + + for (let i = 0; i < incidents.length; i++) { + if (assigned.has(i)) continue; + const a = incidents[i]; + const group: IncidentGroup = { + status: a.status, + title: a.title, + services: [a.service_id], + started_at: a.started_at, + resolved_at: a.resolved_at, + updated_at: a.updated_at, + updates: a.updates.map((u) => ({ status: u.status, message: u.message, created_at: u.created_at })), + }; + + for (let j = i + 1; j < incidents.length; j++) { + if (assigned.has(j)) continue; + const b = incidents[j]; + 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)) { + group.services.push(b.service_id); + assigned.add(j); + } + } + + if (group.services.length > 1) { + const statusWord = group.title.includes("timeout") ? "timeout" : group.title.includes("down") ? "down" : "degraded"; + group.title = `${group.services.length} services are ${statusWord}`; + } + + groups.push(group); + } + + return groups; +} + export async function handleIndex(env: Env): Promise { const manifest = await getManifest(env); const serverServiceIds = Object.values(manifest) @@ -52,6 +99,16 @@ export async function handleIndex(env: Env): Promise { }); const activeIncidents = activeIncidentsWithUpdates; + const serviceUrlMap = new Map(); + for (const machine of Object.values(manifest)) { + for (const svc of machine.services) { + serviceUrlMap.set(svc.name, `https://${svc.domain}`); + } + } + + const groupedActive = groupIncidents(activeIncidents); + const groupedResolved = groupIncidents(resolvedIncidents); + const html = ` @@ -118,6 +175,8 @@ export async function handleIndex(env: Env): Promise { .incident-status.identified { color: #f39c12; } .incident-title { font-weight: 500; } .incident-time { color: #8b949e; } + .incident-services a { color: #484f58; text-decoration: none; } + .incident-services a:hover { text-decoration: underline; } .incident-timeline { margin-top: 0.25rem; padding-left: 0.75rem; } .timeline-entry { padding: 0.25rem 0 0.25rem 0.5rem; font-size: 0.7rem; position: relative; } .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,15 +211,15 @@ export async function handleIndex(env: Env): Promise {
${uptimeDays.map((d) => `
`).join("")}

infra.dunkirk.sh

${overallText} at ${uptime90d}%

-${activeIncidents.length > 0 ? `
-${activeIncidents.map((i) => `
+${groupedActive.length > 0 ? `
+${groupedActive.map((g) => `
- ${i.status} - ${esc(i.title)} - started loading + ${g.status} + ${esc(g.title)}${g.services.length > 1 ? `${g.services.map((s) => serviceUrlMap.has(s) ? `${esc(s)}` : esc(s)).join(", ")}` : ""} + started loading
- ${i.updates.length > 0 ? `
-${i.updates.map((u) => `
+ ${g.updates.length > 0 ? `
+${g.updates.map((u) => `
${new Date(u.created_at * 1000).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "America/New_York" })} ${esc(u.status)} ${esc(u.message)} @@ -194,12 +253,12 @@ ${clients.length > 0 ? `
${clients.map((m) => `${esc(m.name)}`).join("\n")}
` : ""} -${resolvedIncidents.length > 0 ? `
+${groupedResolved.length > 0 ? `
recent incidents
-${resolvedIncidents.map((i) => `
-
${esc(i.title)} — resolved loading
- ${i.updates.length > 0 ? `
-${i.updates.map((u) => `
+${groupedResolved.map((g) => `
+
${esc(g.title)} — resolved loading${g.services.length > 1 ? `
${g.services.map((s) => serviceUrlMap.has(s) ? `${esc(s)}` : esc(s)).join(", ")}
` : ""}
+ ${g.updates.length > 0 ? `
+${g.updates.map((u) => `
${new Date(u.created_at * 1000).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "America/New_York" })} ${esc(u.status)} ${esc(u.message)}