my own status page
0
fork

Configure Feed

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

feat: add incidents

+671 -11
+24
migrations/0003_add_incidents.sql
··· 1 + CREATE TABLE incidents ( 2 + id INTEGER PRIMARY KEY AUTOINCREMENT, 3 + service_id TEXT NOT NULL, 4 + title TEXT NOT NULL, 5 + status TEXT NOT NULL CHECK (status IN ('investigating', 'identified', 'monitoring', 'resolved')), 6 + severity TEXT NOT NULL CHECK (severity IN ('critical', 'major', 'minor')), 7 + triage_report TEXT, 8 + started_at INTEGER NOT NULL, 9 + resolved_at INTEGER, 10 + created_at INTEGER NOT NULL, 11 + updated_at INTEGER NOT NULL 12 + ); 13 + 14 + CREATE TABLE incident_updates ( 15 + id INTEGER PRIMARY KEY AUTOINCREMENT, 16 + incident_id INTEGER NOT NULL REFERENCES incidents(id), 17 + status TEXT NOT NULL, 18 + message TEXT NOT NULL, 19 + created_at INTEGER NOT NULL 20 + ); 21 + 22 + CREATE INDEX idx_incidents_service ON incidents (service_id, status); 23 + CREATE INDEX idx_incidents_active ON incidents (status) WHERE status != 'resolved'; 24 + CREATE INDEX idx_incident_updates ON incident_updates (incident_id, created_at DESC);
+2
migrations/0004_add_github_fields.sql
··· 1 + ALTER TABLE incidents ADD COLUMN github_repo TEXT; 2 + ALTER TABLE incidents ADD COLUMN github_issue_number INTEGER;
+148
src/db.ts
··· 156 156 .bind(cutoff) 157 157 .run(); 158 158 } 159 + 160 + import type { Incident, IncidentUpdate, IncidentWithUpdates } from "./types"; 161 + 162 + export async function createIncident( 163 + db: D1Database, 164 + data: { service_id: string; title: string; severity: "critical" | "major" | "minor"; github_repo?: string; github_issue_number?: number }, 165 + ): Promise<number> { 166 + const now = Math.floor(Date.now() / 1000); 167 + const result = await db 168 + .prepare( 169 + "INSERT INTO incidents (service_id, title, status, severity, github_repo, github_issue_number, started_at, created_at, updated_at) VALUES (?, ?, 'investigating', ?, ?, ?, ?, ?, ?)", 170 + ) 171 + .bind(data.service_id, data.title, data.severity, data.github_repo ?? null, data.github_issue_number ?? null, now, now, now) 172 + .run(); 173 + const id = result.meta.last_row_id; 174 + await db 175 + .prepare( 176 + "INSERT INTO incident_updates (incident_id, status, message, created_at) VALUES (?, 'investigating', 'Incident detected automatically', ?)", 177 + ) 178 + .bind(id, now) 179 + .run(); 180 + return id as number; 181 + } 182 + 183 + export async function updateIncident( 184 + db: D1Database, 185 + id: number, 186 + data: { status?: string; triage_report?: string; resolved_at?: number }, 187 + ): Promise<void> { 188 + const sets: string[] = []; 189 + const values: unknown[] = []; 190 + if (data.status) { sets.push("status = ?"); values.push(data.status); } 191 + if (data.triage_report !== undefined) { sets.push("triage_report = ?"); values.push(data.triage_report); } 192 + if (data.resolved_at) { sets.push("resolved_at = ?"); values.push(data.resolved_at); } 193 + sets.push("updated_at = ?"); 194 + values.push(Math.floor(Date.now() / 1000)); 195 + values.push(id); 196 + await db 197 + .prepare(`UPDATE incidents SET ${sets.join(", ")} WHERE id = ?`) 198 + .bind(...values) 199 + .run(); 200 + } 201 + 202 + export async function addIncidentUpdate( 203 + db: D1Database, 204 + incident_id: number, 205 + status: string, 206 + message: string, 207 + ): Promise<void> { 208 + const now = Math.floor(Date.now() / 1000); 209 + await db 210 + .prepare("INSERT INTO incident_updates (incident_id, status, message, created_at) VALUES (?, ?, ?, ?)") 211 + .bind(incident_id, status, message, now) 212 + .run(); 213 + } 214 + 215 + export async function getActiveIncidents(db: D1Database): Promise<Incident[]> { 216 + const rows = await db 217 + .prepare("SELECT * FROM incidents WHERE status != 'resolved' ORDER BY created_at DESC") 218 + .all(); 219 + return rows.results as unknown as Incident[]; 220 + } 221 + 222 + export async function getActiveIncidentsWithUpdates(db: D1Database): Promise<IncidentWithUpdates[]> { 223 + const incidents = await getActiveIncidents(db); 224 + return Promise.all( 225 + incidents.map(async (incident) => { 226 + const updates = await db 227 + .prepare("SELECT * FROM incident_updates WHERE incident_id = ? ORDER BY created_at ASC") 228 + .bind(incident.id) 229 + .all(); 230 + return { ...incident, updates: updates.results as unknown as IncidentUpdate[] }; 231 + }), 232 + ); 233 + } 234 + 235 + export async function getActiveIncidentForService( 236 + db: D1Database, 237 + service_id: string, 238 + ): Promise<Incident | null> { 239 + const row = await db 240 + .prepare("SELECT * FROM incidents WHERE service_id = ? AND status != 'resolved' ORDER BY created_at DESC LIMIT 1") 241 + .bind(service_id) 242 + .first(); 243 + return (row as unknown as Incident) ?? null; 244 + } 245 + 246 + export async function getRecentIncidents(db: D1Database, days: number): Promise<Incident[]> { 247 + const since = Math.floor(Date.now() / 1000) - days * 24 * 60 * 60; 248 + const rows = await db 249 + .prepare("SELECT * FROM incidents WHERE resolved_at >= ? OR status != 'resolved' ORDER BY created_at DESC") 250 + .bind(since) 251 + .all(); 252 + return rows.results as unknown as Incident[]; 253 + } 254 + 255 + export async function getIncident(db: D1Database, id: number): Promise<IncidentWithUpdates | null> { 256 + const incident = await db 257 + .prepare("SELECT * FROM incidents WHERE id = ?") 258 + .bind(id) 259 + .first(); 260 + if (!incident) return null; 261 + const updates = await db 262 + .prepare("SELECT * FROM incident_updates WHERE incident_id = ? ORDER BY created_at ASC") 263 + .bind(id) 264 + .all(); 265 + return { 266 + ...(incident as unknown as Incident), 267 + updates: updates.results as unknown as IncidentUpdate[], 268 + }; 269 + } 270 + 271 + export async function getIncidentByGitHubIssue( 272 + db: D1Database, 273 + repo: string, 274 + issueNumber: number, 275 + ): Promise<Incident | null> { 276 + const row = await db 277 + .prepare("SELECT * FROM incidents WHERE github_repo = ? AND github_issue_number = ? LIMIT 1") 278 + .bind(repo, issueNumber) 279 + .first(); 280 + return (row as unknown as Incident) ?? null; 281 + } 282 + 283 + export async function setIncidentGitHub( 284 + db: D1Database, 285 + id: number, 286 + repo: string, 287 + issueNumber: number, 288 + ): Promise<void> { 289 + await db 290 + .prepare("UPDATE incidents SET github_repo = ?, github_issue_number = ?, updated_at = ? WHERE id = ?") 291 + .bind(repo, issueNumber, Math.floor(Date.now() / 1000), id) 292 + .run(); 293 + } 294 + 295 + export async function getRecentlyResolvedIncident( 296 + db: D1Database, 297 + service_id: string, 298 + withinSeconds: number, 299 + ): Promise<Incident | null> { 300 + const since = Math.floor(Date.now() / 1000) - withinSeconds; 301 + const row = await db 302 + .prepare("SELECT * FROM incidents WHERE service_id = ? AND status = 'resolved' AND resolved_at >= ? ORDER BY resolved_at DESC LIMIT 1") 303 + .bind(service_id, since) 304 + .first(); 305 + return (row as unknown as Incident) ?? null; 306 + }
+193
src/github.ts
··· 1 + // GitHub Issues integration for incidents 2 + 3 + import type { Incident } from "./types"; 4 + import { updateIncident, addIncidentUpdate } from "./db"; 5 + 6 + export function parseRepo(repoUrl: string): { owner: string; repo: string } | null { 7 + const match = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/); 8 + if (!match) return null; 9 + return { owner: match[1], repo: match[2].replace(/\.git$/, "") }; 10 + } 11 + 12 + export async function createIssue( 13 + token: string, 14 + owner: string, 15 + repo: string, 16 + opts: { title: string; body: string; assignees?: string[]; labels?: string[] }, 17 + ): Promise<number> { 18 + const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues`, { 19 + method: "POST", 20 + headers: { 21 + Authorization: `Bearer ${token}`, 22 + Accept: "application/vnd.github+json", 23 + "User-Agent": "infra-status-worker", 24 + }, 25 + body: JSON.stringify(opts), 26 + }); 27 + if (!res.ok) { 28 + const text = await res.text(); 29 + throw new Error(`GitHub create issue failed: ${res.status} ${text}`); 30 + } 31 + const data = await res.json<{ number: number }>(); 32 + return data.number; 33 + } 34 + 35 + export async function commentOnIssue( 36 + token: string, 37 + owner: string, 38 + repo: string, 39 + issueNumber: number, 40 + body: string, 41 + ): Promise<void> { 42 + const res = await fetch( 43 + `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}/comments`, 44 + { 45 + method: "POST", 46 + headers: { 47 + Authorization: `Bearer ${token}`, 48 + Accept: "application/vnd.github+json", 49 + "User-Agent": "infra-status-worker", 50 + }, 51 + body: JSON.stringify({ body }), 52 + }, 53 + ); 54 + if (!res.ok) { 55 + const text = await res.text(); 56 + throw new Error(`GitHub comment failed: ${res.status} ${text}`); 57 + } 58 + } 59 + 60 + export async function closeIssue( 61 + token: string, 62 + owner: string, 63 + repo: string, 64 + issueNumber: number, 65 + ): Promise<void> { 66 + const res = await fetch( 67 + `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}`, 68 + { 69 + method: "PATCH", 70 + headers: { 71 + Authorization: `Bearer ${token}`, 72 + Accept: "application/vnd.github+json", 73 + "User-Agent": "infra-status-worker", 74 + }, 75 + body: JSON.stringify({ state: "closed" }), 76 + }, 77 + ); 78 + if (!res.ok) { 79 + const text = await res.text(); 80 + throw new Error(`GitHub close issue failed: ${res.status} ${text}`); 81 + } 82 + } 83 + 84 + export async function editIssueBody( 85 + token: string, 86 + owner: string, 87 + repo: string, 88 + issueNumber: number, 89 + body: string, 90 + ): Promise<void> { 91 + const res = await fetch( 92 + `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}`, 93 + { 94 + method: "PATCH", 95 + headers: { 96 + Authorization: `Bearer ${token}`, 97 + Accept: "application/vnd.github+json", 98 + "User-Agent": "infra-status-worker", 99 + }, 100 + body: JSON.stringify({ body }), 101 + }, 102 + ); 103 + if (!res.ok) { 104 + const text = await res.text(); 105 + throw new Error(`GitHub edit issue failed: ${res.status} ${text}`); 106 + } 107 + } 108 + 109 + interface GitHubIssue { 110 + state: string; 111 + body: string | null; 112 + } 113 + 114 + interface GitHubComment { 115 + id: number; 116 + body: string; 117 + created_at: string; 118 + user: { login: string; type: string }; 119 + } 120 + 121 + async function fetchIssue(token: string, owner: string, repo: string, issueNumber: number): Promise<GitHubIssue> { 122 + const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}`, { 123 + headers: { 124 + Authorization: `Bearer ${token}`, 125 + Accept: "application/vnd.github+json", 126 + "User-Agent": "infra-status-worker", 127 + }, 128 + }); 129 + if (!res.ok) throw new Error(`GitHub fetch issue failed: ${res.status}`); 130 + return res.json(); 131 + } 132 + 133 + async function fetchComments(token: string, owner: string, repo: string, issueNumber: number, since?: string): Promise<GitHubComment[]> { 134 + let url = `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}/comments?per_page=50`; 135 + if (since) url += `&since=${since}`; 136 + const res = await fetch(url, { 137 + headers: { 138 + Authorization: `Bearer ${token}`, 139 + Accept: "application/vnd.github+json", 140 + "User-Agent": "infra-status-worker", 141 + }, 142 + }); 143 + if (!res.ok) throw new Error(`GitHub fetch comments failed: ${res.status}`); 144 + return res.json(); 145 + } 146 + 147 + export async function syncGitHubIncidents( 148 + db: D1Database, 149 + kv: KVNamespace, 150 + token: string, 151 + incidents: Incident[], 152 + ): Promise<void> { 153 + for (const incident of incidents) { 154 + if (!incident.github_repo || !incident.github_issue_number) continue; 155 + 156 + const parsed = parseRepo(`https://github.com/${incident.github_repo}`); 157 + if (!parsed) continue; 158 + 159 + try { 160 + // Check issue state (closed = resolved) and sync body edits 161 + const issue = await fetchIssue(token, parsed.owner, parsed.repo, incident.github_issue_number); 162 + if (issue.state === "closed" && incident.status !== "resolved") { 163 + const now = Math.floor(Date.now() / 1000); 164 + await updateIncident(db, incident.id, { status: "resolved", resolved_at: now }); 165 + await addIncidentUpdate(db, incident.id, "resolved", "Issue closed on GitHub"); 166 + continue; 167 + } 168 + 169 + // Sync issue body edits back to triage_report 170 + if (issue.body && issue.body !== incident.triage_report) { 171 + await updateIncident(db, incident.id, { triage_report: issue.body }); 172 + } 173 + 174 + // Sync new comments since last check 175 + const kvKey = `gh_sync:${incident.id}:last`; 176 + const lastSeen = await kv.get(kvKey); 177 + const comments = await fetchComments(token, parsed.owner, parsed.repo, incident.github_issue_number, lastSeen ?? undefined); 178 + 179 + // Filter to human comments only (skip bots and our own posts) 180 + const human = comments.filter((c) => c.user.type !== "Bot" && !c.body.startsWith("Automated incident detected") && !c.body.startsWith("## Triage Report") && !c.body.startsWith("Service recovered automatically")); 181 + 182 + for (const comment of human) { 183 + await addIncidentUpdate(db, incident.id, incident.status, comment.body); 184 + } 185 + 186 + // Track last comment time so we don't re-import 187 + if (comments.length > 0) { 188 + const latest = comments[comments.length - 1].created_at; 189 + await kv.put(kvKey, latest, { expirationTtl: 86400 * 7 }); 190 + } 191 + } catch (_) {} // best effort, don't block other syncs 192 + } 193 + }
+105 -7
src/index.ts
··· 1 1 import type { Env } from "./types"; 2 2 import { getManifest } from "./manifest"; 3 3 import { checkHealth } from "./health"; 4 - import { insertPing, pruneOldPings } from "./db"; 4 + import { insertPing, getLatestPing, pruneOldPings, createIncident, updateIncident, addIncidentUpdate, getActiveIncidentForService, getActiveIncidents, getRecentlyResolvedIncident, setIncidentGitHub } from "./db"; 5 5 import { refreshDevices } from "./tailscale"; 6 6 import { handleStatusRoute } from "./routes/status"; 7 7 import { handleFavicon } from "./routes/favicon"; 8 8 import { handleUptime } from "./routes/uptime"; 9 9 import { handleBadgeRoute } from "./routes/badge"; 10 10 import { handleIndex } from "./routes/index"; 11 + import { handleIncidentRoute } from "./routes/incidents"; 12 + import { createIssue, commentOnIssue, closeIssue, parseRepo, syncGitHubIncidents } from "./github"; 11 13 import { schemas } from "./schemas"; 12 14 13 15 async function handleRequest(request: Request, env: Env): Promise<Response> { ··· 54 56 if (badge) return badge; 55 57 } 56 58 59 + if (path.startsWith("/api/incidents")) { 60 + const res = await handleIncidentRoute(request, env, path); 61 + if (res) return res; 62 + } 63 + 57 64 return new Response("Not Found", { status: 404 }); 58 65 } 59 66 ··· 63 70 return new Response(null, { 64 71 headers: { 65 72 "Access-Control-Allow-Origin": "*", 66 - "Access-Control-Allow-Methods": "GET, OPTIONS", 67 - "Access-Control-Allow-Headers": "Content-Type", 73 + "Access-Control-Allow-Methods": "GET, POST, PATCH, OPTIONS", 74 + "Access-Control-Allow-Headers": "Content-Type, Authorization", 68 75 }, 69 76 }); 70 77 } ··· 81 88 refreshDevices(env), 82 89 ]); 83 90 84 - const checks = Object.values(manifest).flatMap((machine) => 85 - machine.services 91 + const checks = Object.values(manifest).flatMap((machine) => { 92 + const triageUrl = machine.triage_url; 93 + return machine.services 86 94 .filter((svc) => svc.health_url) 87 95 .map(async (svc) => { 96 + const previous = await getLatestPing(env.DB, svc.name); 88 97 const result = await checkHealth(svc); 89 98 await insertPing(env.DB, svc.name, result.status, result.latency_ms); 90 - }), 91 - ); 99 + 100 + const isDown = result.status === "down" || result.status === "timeout"; 101 + const wasUp = !previous || previous.status === "up" || previous.status === "degraded"; 102 + 103 + if (isDown) { 104 + // Track consecutive failures in KV for flap prevention 105 + const failKey = `triage:${svc.name}:failures`; 106 + const current = parseInt((await env.KV.get(failKey)) ?? "0"); 107 + const failures = current + 1; 108 + await env.KV.put(failKey, String(failures), { expirationTtl: 1800 }); 109 + 110 + // Only trigger after 2 consecutive failures (10 min of downtime) 111 + if (failures >= 2) { 112 + const existing = await getActiveIncidentForService(env.DB, svc.name); 113 + if (!existing) { 114 + // Check cooldown: no incident resolved in last 15 min 115 + const recent = await getRecentlyResolvedIncident(env.DB, svc.name, 900); 116 + if (!recent) { 117 + const id = await createIncident(env.DB, { 118 + service_id: svc.name, 119 + title: `${svc.name} is ${result.status}`, 120 + severity: "major", 121 + }); 122 + 123 + // Create GitHub issue on the service's repo 124 + if (env.GITHUB_TOKEN && svc.repository) { 125 + const parsed = parseRepo(svc.repository); 126 + if (parsed) { 127 + try { 128 + const issueNumber = await createIssue(env.GITHUB_TOKEN, parsed.owner, parsed.repo, { 129 + title: `${svc.name} is ${result.status}`, 130 + body: `Automated incident detected by [infra.dunkirk.sh](https://infra.dunkirk.sh)\n\n**Service:** ${svc.name}\n**Health URL:** ${svc.health_url}\n**Status:** ${result.status}\n**Detected at:** ${new Date().toISOString()}\n\n---\n*Comments on this issue will appear on the status page. Close the issue to resolve the incident.*`, 131 + assignees: env.GITHUB_ASSIGNEE ? [env.GITHUB_ASSIGNEE] : [], 132 + labels: ["incident"], 133 + }); 134 + await setIncidentGitHub(env.DB, id, `${parsed.owner}/${parsed.repo}`, issueNumber); 135 + } catch (_) {} // best effort 136 + } 137 + } 138 + 139 + // Fire webhook to triage agent (non-blocking) 140 + if (triageUrl && env.TRIAGE_AUTH_TOKEN) { 141 + fetch(triageUrl, { 142 + method: "POST", 143 + headers: { 144 + "Content-Type": "application/json", 145 + Authorization: `Bearer ${env.TRIAGE_AUTH_TOKEN}`, 146 + }, 147 + body: JSON.stringify({ 148 + incident_id: id, 149 + service_id: svc.name, 150 + service_name: svc.name, 151 + health_url: svc.health_url, 152 + callback_url: `https://infra.dunkirk.sh/api/incidents/${id}`, 153 + }), 154 + }).catch(() => {}); // fire and forget 155 + } 156 + } 157 + } 158 + } 159 + } else { 160 + // Service is up — clear failure counter 161 + await env.KV.delete(`triage:${svc.name}:failures`); 162 + 163 + // Auto-resolve active incidents 164 + const active = await getActiveIncidentForService(env.DB, svc.name); 165 + if (active) { 166 + await updateIncident(env.DB, active.id, { 167 + status: "resolved", 168 + resolved_at: Math.floor(Date.now() / 1000), 169 + }); 170 + await addIncidentUpdate(env.DB, active.id, "resolved", "Service recovered automatically"); 171 + 172 + // Close the GitHub issue 173 + if (env.GITHUB_TOKEN && active.github_repo && active.github_issue_number) { 174 + const parsed = parseRepo(`https://github.com/${active.github_repo}`); 175 + if (parsed) { 176 + commentOnIssue(env.GITHUB_TOKEN, parsed.owner, parsed.repo, active.github_issue_number, "Service recovered automatically. Closing issue.").catch(() => {}); 177 + closeIssue(env.GITHUB_TOKEN, parsed.owner, parsed.repo, active.github_issue_number).catch(() => {}); 178 + } 179 + } 180 + } 181 + } 182 + }); 183 + }); 92 184 93 185 await Promise.all(checks); 94 186 await pruneOldPings(env.DB, 365); 187 + 188 + // Sync GitHub issue comments/state back to incidents 189 + if (env.GITHUB_TOKEN) { 190 + const active = await getActiveIncidents(env.DB); 191 + await syncGitHubIncidents(env.DB, env.KV, env.GITHUB_TOKEN, active); 192 + } 95 193 }, 96 194 } satisfies ExportedHandler<Env>;
+7 -3
src/overall.ts
··· 1 1 import type { Env } from "./types"; 2 2 import { getManifest } from "./manifest"; 3 - import { getLatestPing } from "./db"; 3 + import { getLatestPing, getActiveIncidents } from "./db"; 4 4 import { getDeviceStatus } from "./tailscale"; 5 5 6 6 export type OverallGrade = "up" | "degraded" | "down"; ··· 42 42 s === "misconfigured", 43 43 ); 44 44 45 - if (onFire) return { grade: "down", label: "On fire" }; 46 - if (hasDegraded) return { grade: "degraded", label: "Some systems degraded" }; 45 + const activeIncidents = await getActiveIncidents(env.DB); 46 + const hasCritical = activeIncidents.some((i) => i.severity === "critical"); 47 + const hasMajor = activeIncidents.some((i) => i.severity === "major"); 48 + 49 + if (hasCritical || onFire) return { grade: "down", label: "On fire" }; 50 + if (hasMajor || hasDegraded) return { grade: "degraded", label: "Some systems degraded" }; 47 51 return { grade: "up", label: "All systems operational" }; 48 52 }
+101
src/routes/incidents.ts
··· 1 + import type { Env } from "../types"; 2 + import { 3 + getActiveIncidents, 4 + getRecentIncidents, 5 + getIncident, 6 + createIncident, 7 + updateIncident, 8 + addIncidentUpdate, 9 + } from "../db"; 10 + import { commentOnIssue, editIssueBody, parseRepo } from "../github"; 11 + 12 + function authCheck(request: Request, env: Env): boolean { 13 + const auth = request.headers.get("Authorization"); 14 + if (!auth || !env.TRIAGE_AUTH_TOKEN) return false; 15 + return auth === `Bearer ${env.TRIAGE_AUTH_TOKEN}`; 16 + } 17 + 18 + export async function handleIncidentRoute( 19 + request: Request, 20 + env: Env, 21 + path: string, 22 + ): Promise<Response | null> { 23 + // GET /api/incidents 24 + if (path === "/api/incidents" && request.method === "GET") { 25 + const active = await getActiveIncidents(env.DB); 26 + const recent = await getRecentIncidents(env.DB, 7); 27 + const resolved = recent.filter((i) => i.status === "resolved"); 28 + return Response.json({ active, recent_resolved: resolved }); 29 + } 30 + 31 + // POST /api/incidents 32 + if (path === "/api/incidents" && request.method === "POST") { 33 + if (!authCheck(request, env)) { 34 + return Response.json({ error: "unauthorized" }, { status: 401 }); 35 + } 36 + const body = await request.json<{ service_id: string; title: string; severity: "critical" | "major" | "minor" }>(); 37 + if (!body.service_id || !body.title || !body.severity) { 38 + return Response.json({ error: "missing fields" }, { status: 400 }); 39 + } 40 + const id = await createIncident(env.DB, body); 41 + return Response.json({ id }, { status: 201 }); 42 + } 43 + 44 + // GET /api/incidents/:id 45 + const singleMatch = path.match(/^\/api\/incidents\/(\d+)$/); 46 + if (singleMatch && request.method === "GET") { 47 + const incident = await getIncident(env.DB, parseInt(singleMatch[1])); 48 + if (!incident) return Response.json({ error: "not found" }, { status: 404 }); 49 + return Response.json(incident); 50 + } 51 + 52 + // PATCH /api/incidents/:id 53 + if (singleMatch && request.method === "PATCH") { 54 + if (!authCheck(request, env)) { 55 + return Response.json({ error: "unauthorized" }, { status: 401 }); 56 + } 57 + const id = parseInt(singleMatch[1]); 58 + const body = await request.json<{ status?: string; triage_report?: string }>(); 59 + const updateData: { status?: string; triage_report?: string; resolved_at?: number } = {}; 60 + if (body.status) updateData.status = body.status; 61 + if (body.triage_report !== undefined) updateData.triage_report = body.triage_report; 62 + if (body.status === "resolved") updateData.resolved_at = Math.floor(Date.now() / 1000); 63 + await updateIncident(env.DB, id, updateData); 64 + if (body.status) { 65 + await addIncidentUpdate(env.DB, id, body.status, body.triage_report ?? `Status changed to ${body.status}`); 66 + } 67 + 68 + // Sync triage report to GitHub issue 69 + const incident = await getIncident(env.DB, id); 70 + if (env.GITHUB_TOKEN && incident?.github_repo && incident.github_issue_number) { 71 + const parsed = parseRepo(`https://github.com/${incident.github_repo}`); 72 + if (parsed) { 73 + if (body.triage_report) { 74 + // Post triage report as comment and update issue body 75 + commentOnIssue(env.GITHUB_TOKEN, parsed.owner, parsed.repo, incident.github_issue_number, `## Triage Report\n\n${body.triage_report}`).catch(() => {}); 76 + editIssueBody(env.GITHUB_TOKEN, parsed.owner, parsed.repo, incident.github_issue_number, body.triage_report).catch(() => {}); 77 + } else if (body.status) { 78 + commentOnIssue(env.GITHUB_TOKEN, parsed.owner, parsed.repo, incident.github_issue_number, `Status changed to **${body.status}**`).catch(() => {}); 79 + } 80 + } 81 + } 82 + 83 + return Response.json({ ok: true }); 84 + } 85 + 86 + // POST /api/incidents/:id/updates 87 + const updatesMatch = path.match(/^\/api\/incidents\/(\d+)\/updates$/); 88 + if (updatesMatch && request.method === "POST") { 89 + if (!authCheck(request, env)) { 90 + return Response.json({ error: "unauthorized" }, { status: 401 }); 91 + } 92 + const body = await request.json<{ status: string; message: string }>(); 93 + if (!body.status || !body.message) { 94 + return Response.json({ error: "missing fields" }, { status: 400 }); 95 + } 96 + await addIncidentUpdate(env.DB, parseInt(updatesMatch[1]), body.status, body.message); 97 + return Response.json({ ok: true }, { status: 201 }); 98 + } 99 + 100 + return null; 101 + }
+57 -1
src/routes/index.ts
··· 1 1 import type { Env } from "../types"; 2 2 import { getManifest } from "../manifest"; 3 - import { getLatestPing, getUptime7d, getOverallUptimeDays, getLastCheckTime } from "../db"; 3 + import { getLatestPing, getUptime7d, getOverallUptimeDays, getLastCheckTime, getActiveIncidentsWithUpdates, getRecentIncidents } from "../db"; 4 4 import { getDeviceStatus } from "../tailscale"; 5 5 import { getOverallStatus } from "../overall"; 6 6 import { COMMIT_SHA } from "../version"; ··· 40 40 41 41 const { grade: overallClass, label: overallText } = await getOverallStatus(env); 42 42 const uptimeDays = await getOverallUptimeDays(env.DB, 90); 43 + const activeIncidents = await getActiveIncidentsWithUpdates(env.DB); 44 + const recentIncidents = await getRecentIncidents(env.DB, 7); 45 + const resolvedIncidents = recentIncidents.filter((i) => i.status === "resolved"); 43 46 44 47 const html = `<!DOCTYPE html> 45 48 <html lang="en"> ··· 97 100 .uptime-bar .day.degraded { background: #f39c12; } 98 101 .uptime-bar .day.down { background: #e74c3c; } 99 102 .uptime-bar .day.none { background: #21262d; } 103 + .incidents { margin-bottom: 1.5rem; } 104 + .incident-banner { background: #2d1b1b; border: 1px solid #e74c3c; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.5rem; } 105 + .incident-banner.major { border-color: #f39c12; background: #2d2517; } 106 + .incident-banner.minor { border-color: #8b949e; background: #21262d; } 107 + .incident-title { font-size: 0.85rem; font-weight: 500; margin-bottom: 0.25rem; } 108 + .incident-meta { font-size: 0.7rem; color: #8b949e; display: flex; gap: 0.75rem; } 109 + .incident-status { text-transform: uppercase; letter-spacing: 0.05em; } 110 + .incident-status.investigating { color: #e74c3c; } 111 + .incident-status.identified { color: #f39c12; } 112 + .incident-status.monitoring { color: #3498db; } 113 + .incident-triage { margin-top: 0.5rem; } 114 + .incident-triage summary { font-size: 0.75rem; color: #8b949e; cursor: pointer; } 115 + .incident-triage pre { font-size: 0.7rem; color: #c9d1d9; background: #161b22; padding: 0.5rem; border-radius: 4px; margin-top: 0.25rem; white-space: pre-wrap; word-break: break-word; max-height: 300px; overflow-y: auto; } 116 + .incident-timeline { margin-top: 0.5rem; padding-left: 0.75rem; border-left: 2px solid #21262d; } 117 + .timeline-entry { padding: 0.25rem 0 0.25rem 0.5rem; font-size: 0.7rem; position: relative; } 118 + .timeline-entry::before { content: ''; position: absolute; left: -0.75rem; top: 0.55rem; width: 6px; height: 6px; border-radius: 50%; background: #30363d; transform: translateX(-2px); } 119 + .timeline-entry.investigating::before { background: #e74c3c; } 120 + .timeline-entry.identified::before { background: #f39c12; } 121 + .timeline-entry.monitoring::before { background: #3498db; } 122 + .timeline-entry.resolved::before { background: #2ecc71; } 123 + .timeline-status { text-transform: uppercase; letter-spacing: 0.04em; font-size: 0.6rem; font-weight: 600; } 124 + .timeline-status.investigating { color: #e74c3c; } 125 + .timeline-status.identified { color: #f39c12; } 126 + .timeline-status.monitoring { color: #3498db; } 127 + .timeline-status.resolved { color: #2ecc71; } 128 + .timeline-time { color: #484f58; margin-right: 0.5rem; } 129 + .timeline-msg { color: #8b949e; } 130 + .resolved-incidents { margin-top: 0.75rem; } 131 + .resolved-header { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; color: #8b949e; margin-bottom: 0.25rem; } 132 + .resolved-item { font-size: 0.75rem; color: #8b949e; padding: 0.25rem 0; border-bottom: 1px solid #21262d; } 133 + .resolved-item:last-child { border-bottom: none; } 100 134 footer { margin-top: auto; padding-top: 1rem; border-top: 1px solid #21262d; font-size: 0.7rem; color: #8b949e; } 101 135 .footer-meta { display: flex; justify-content: space-between; } 102 136 footer a { color: #8b949e; text-decoration: none; } ··· 107 141 <div class="uptime-bar">${uptimeDays.map((d) => `<div class="day ${d.status}" title="${d.date}: ${d.status}"></div>`).join("")}</div> 108 142 <h1>infra.dunkirk.sh</h1> 109 143 <p class="overall"><span class="dot ${overallClass}" id="overall-dot" title="${overallClass}"></span><span id="overall-text">${overallText}</span></p> 144 + ${activeIncidents.length > 0 ? `<div class="incidents"> 145 + ${activeIncidents.map((i) => `<div class="incident-banner ${i.severity}"> 146 + <div class="incident-title">${esc(i.title)}</div> 147 + <div class="incident-meta"> 148 + <span class="incident-status ${i.status}">${i.status}</span> 149 + <span>${esc(i.service_id)}</span> 150 + <span>started <relative-time datetime="${new Date(i.started_at * 1000).toISOString()}">loading</relative-time></span> 151 + </div> 152 + ${i.triage_report ? `<details class="incident-triage"><summary>triage report</summary><pre>${esc(i.triage_report)}</pre></details>` : ""} 153 + ${i.updates.length > 0 ? `<div class="incident-timeline"> 154 + ${i.updates.map((u) => `<div class="timeline-entry ${u.status}"> 155 + <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> 156 + <span class="timeline-status ${u.status}">${esc(u.status)}</span> 157 + <span class="timeline-msg">${esc(u.message)}</span> 158 + </div>`).join("\n")} 159 + </div>` : ""} 160 + </div>`).join("\n")} 161 + </div>` : ""} 110 162 ${servers 111 163 .map( 112 164 (m) => `<div class="machine"> ··· 132 184 <div class="clients-list"> 133 185 ${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")} 134 186 </div> 187 + </div>` : ""} 188 + ${resolvedIncidents.length > 0 ? `<div class="resolved-incidents"> 189 + <div class="resolved-header">recent incidents</div> 190 + ${resolvedIncidents.map((i) => `<div class="resolved-item">${esc(i.title)} — resolved <relative-time datetime="${new Date((i.resolved_at ?? i.updated_at) * 1000).toISOString()}">loading</relative-time></div>`).join("\n")} 135 191 </div>` : ""} 136 192 <footer> 137 193 <div class="footer-meta"><span>${lastCheckISO ? `updated <relative-time datetime="${lastCheckISO}" prefix="">loading</relative-time>` : "no checks yet"}</span><a href="https://github.com/taciturnaxolotl/status/commit/${COMMIT_SHA}">${COMMIT_SHA}</a></div>
+31
src/types.ts
··· 17 17 hostname: string; 18 18 tailscale_host: string; 19 19 type: "server" | "client"; 20 + triage_url: string | null; 20 21 services: Service[]; 21 22 } 22 23 ··· 31 32 }[]; 32 33 } 33 34 35 + export interface Incident { 36 + id: number; 37 + service_id: string; 38 + title: string; 39 + status: "investigating" | "identified" | "monitoring" | "resolved"; 40 + severity: "critical" | "major" | "minor"; 41 + triage_report: string | null; 42 + github_repo: string | null; 43 + github_issue_number: number | null; 44 + started_at: number; 45 + resolved_at: number | null; 46 + created_at: number; 47 + updated_at: number; 48 + } 49 + 50 + export interface IncidentUpdate { 51 + id: number; 52 + incident_id: number; 53 + status: string; 54 + message: string; 55 + created_at: number; 56 + } 57 + 58 + export interface IncidentWithUpdates extends Incident { 59 + updates: IncidentUpdate[]; 60 + } 61 + 34 62 export interface Env { 35 63 DB: D1Database; 36 64 KV: KVNamespace; 37 65 TAILSCALE_API_KEY?: string; 66 + TRIAGE_AUTH_TOKEN?: string; 67 + GITHUB_TOKEN?: string; 68 + GITHUB_ASSIGNEE?: string; 38 69 }
+3
wrangler.toml
··· 14 14 [[kv_namespaces]] 15 15 binding = "KV" 16 16 id = "c4daf2e998ea45e1aad9efb6636b66df" 17 + 18 + [vars] 19 + GITHUB_ASSIGNEE = "taciturnaxolotl"