my own status page
at main 507 lines 16 kB view raw
1export async function insertPing( 2 db: D1Database, 3 service_id: string, 4 status: string, 5 latency_ms: number, 6): Promise<void> { 7 await db 8 .prepare( 9 "INSERT INTO pings (service_id, timestamp, status, latency_ms) VALUES (?, ?, ?, ?)", 10 ) 11 .bind(service_id, Math.floor(Date.now() / 1000), status, latency_ms) 12 .run(); 13} 14 15export async function getLatestPing( 16 db: D1Database, 17 service_id: string, 18): Promise<{ status: string; latency_ms: number | null } | null> { 19 const row = await db 20 .prepare( 21 "SELECT status, latency_ms FROM pings WHERE service_id = ? ORDER BY timestamp DESC LIMIT 1", 22 ) 23 .bind(service_id) 24 .first(); 25 if (!row) return null; 26 return { status: row.status as string, latency_ms: row.latency_ms as number | null }; 27} 28 29export async function getUptime7d( 30 db: D1Database, 31 service_id: string, 32 days = 90, 33): Promise<number> { 34 const since = Math.floor(Date.now() / 1000) - days * 24 * 60 * 60; 35 const row = await db 36 .prepare( 37 "SELECT COUNT(*) as total, SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) as up_count FROM pings WHERE service_id = ? AND timestamp >= ?", 38 ) 39 .bind(service_id, since) 40 .first<{ total: number; up_count: number }>(); 41 42 if (!row || row.total === 0) return 100; 43 return Math.round((row.up_count / row.total) * 10000) / 100; 44} 45 46export async function getAllLatestPings( 47 db: D1Database, 48): Promise<Map<string, { status: string; latency_ms: number | null }>> { 49 const rows = await db 50 .prepare( 51 `SELECT p.service_id, p.status, p.latency_ms 52 FROM pings p 53 INNER JOIN (SELECT service_id, MAX(timestamp) as max_ts FROM pings GROUP BY service_id) latest 54 ON p.service_id = latest.service_id AND p.timestamp = latest.max_ts`, 55 ) 56 .all(); 57 58 const map = new Map<string, { status: string; latency_ms: number | null }>(); 59 for (const row of rows.results) { 60 map.set(row.service_id as string, { 61 status: row.status as string, 62 latency_ms: row.latency_ms as number | null, 63 }); 64 } 65 return map; 66} 67 68export async function getAllUptime7d( 69 db: D1Database, 70 days = 90, 71): Promise<Map<string, number>> { 72 const since = Math.floor(Date.now() / 1000) - days * 24 * 60 * 60; 73 const rows = await db 74 .prepare( 75 `SELECT service_id, COUNT(*) as total, SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) as up_count 76 FROM pings WHERE timestamp >= ? 77 GROUP BY service_id`, 78 ) 79 .bind(since) 80 .all(); 81 82 const map = new Map<string, number>(); 83 for (const row of rows.results) { 84 const total = row.total as number; 85 const up = row.up_count as number; 86 map.set( 87 row.service_id as string, 88 total === 0 ? 100 : Math.round((up / total) * 10000) / 100, 89 ); 90 } 91 return map; 92} 93 94export async function getUptimeBuckets( 95 db: D1Database, 96 service_id: string, 97 window_hours: number, 98): Promise<{ timestamp: number; status: "up" | "degraded" | "down" }[]> { 99 const since = Math.floor(Date.now() / 1000) - window_hours * 60 * 60; 100 const rows = await db 101 .prepare( 102 `SELECT 103 (timestamp / 3600) * 3600 AS bucket, 104 status, 105 COUNT(*) AS cnt 106 FROM pings 107 WHERE service_id = ? AND timestamp >= ? 108 GROUP BY bucket, status 109 ORDER BY bucket ASC`, 110 ) 111 .bind(service_id, since) 112 .all(); 113 114 const bucketMap = new Map<number, Map<string, number>>(); 115 for (const row of rows.results) { 116 const b = row.bucket as number; 117 if (!bucketMap.has(b)) bucketMap.set(b, new Map()); 118 bucketMap.get(b)!.set(row.status as string, row.cnt as number); 119 } 120 121 const result: { timestamp: number; status: "up" | "degraded" | "down" }[] = []; 122 for (const [bucket, counts] of bucketMap) { 123 let status: "up" | "degraded" | "down" = "up"; 124 if (counts.has("down")) status = "down"; 125 else if (counts.has("degraded")) status = "degraded"; 126 result.push({ timestamp: bucket, status }); 127 } 128 129 return result; 130} 131 132export async function getOverallUptimeDays( 133 db: D1Database, 134 days: number, 135 serviceIds?: string[], 136): Promise<{ date: string; status: "up" | "degraded" | "down" | "none" }[]> { 137 const since = Math.floor(Date.now() / 1000) - days * 24 * 60 * 60; 138 let rows; 139 if (serviceIds && serviceIds.length > 0) { 140 const placeholders = serviceIds.map(() => "?").join(", "); 141 rows = await db 142 .prepare( 143 `SELECT 144 (timestamp / 86400) AS day_bucket, 145 service_id, 146 SUM(CASE WHEN status IN ('down','timeout') THEN 1 ELSE 0 END) AS bad_count, 147 SUM(CASE WHEN status IN ('degraded','misconfigured') THEN 1 ELSE 0 END) AS degraded_count, 148 COUNT(*) AS total 149 FROM pings 150 WHERE timestamp >= ? AND service_id IN (${placeholders}) 151 GROUP BY day_bucket, service_id 152 ORDER BY day_bucket ASC`, 153 ) 154 .bind(since, ...serviceIds) 155 .all(); 156 } else { 157 rows = await db 158 .prepare( 159 `SELECT 160 (timestamp / 86400) AS day_bucket, 161 service_id, 162 SUM(CASE WHEN status IN ('down','timeout') THEN 1 ELSE 0 END) AS bad_count, 163 SUM(CASE WHEN status IN ('degraded','misconfigured') THEN 1 ELSE 0 END) AS degraded_count, 164 COUNT(*) AS total 165 FROM pings 166 WHERE timestamp >= ? 167 GROUP BY day_bucket, service_id 168 ORDER BY day_bucket ASC`, 169 ) 170 .bind(since) 171 .all(); 172 } 173 174 // bucketMap: day_bucket -> { totalPings, totalBad, anyServiceReallyBad, anyServiceReallyDegraded } 175 const bucketMap = new Map<number, { total: number; bad: number; reallyBad: boolean; reallyDegraded: boolean }>(); 176 for (const row of rows.results) { 177 const b = row.day_bucket as number; 178 if (!bucketMap.has(b)) bucketMap.set(b, { total: 0, bad: 0, reallyBad: false, reallyDegraded: false }); 179 const bucket = bucketMap.get(b)!; 180 const badCount = row.bad_count as number; 181 const degradedCount = row.degraded_count as number; 182 bucket.total += row.total as number; 183 bucket.bad += badCount; 184 // A service counts as "really" bad/degraded only if it failed ≥2 checks, 185 // matching the 2-consecutive-failures threshold used by the incident system. 186 if (badCount >= 2) bucket.reallyBad = true; 187 if (degradedCount >= 2) bucket.reallyDegraded = true; 188 } 189 190 const now = Math.floor(Date.now() / 1000); 191 const todayBucket = Math.floor(now / 86400); 192 const result: { date: string; status: "up" | "degraded" | "down" | "none" }[] = []; 193 194 for (let i = days - 1; i >= 0; i--) { 195 const bucket = todayBucket - i; 196 const d = new Date(bucket * 86400 * 1000); 197 const date = d.toISOString().slice(0, 10); 198 const counts = bucketMap.get(bucket); 199 200 if (!counts) { 201 result.push({ date, status: "none" }); 202 continue; 203 } 204 205 const badRatio = counts.total > 0 ? counts.bad / counts.total : 0; 206 207 let status: "up" | "degraded" | "down" = "up"; 208 if (badRatio > 0.05) status = "down"; 209 else if (counts.reallyBad || counts.reallyDegraded) status = "degraded"; 210 211 result.push({ date, status }); 212 } 213 214 return result; 215} 216 217export async function getOverallUptimePct( 218 db: D1Database, 219 days: number, 220 serviceIds?: string[], 221): Promise<number> { 222 const since = Math.floor(Date.now() / 1000) - days * 24 * 60 * 60; 223 let row: { up_count: number; total: number } | null; 224 if (serviceIds && serviceIds.length > 0) { 225 const placeholders = serviceIds.map(() => "?").join(", "); 226 row = await db 227 .prepare( 228 `SELECT 229 SUM(CASE WHEN status NOT IN ('down','timeout') THEN 1 ELSE 0 END) AS up_count, 230 COUNT(*) AS total 231 FROM pings 232 WHERE timestamp >= ? AND service_id IN (${placeholders})`, 233 ) 234 .bind(since, ...serviceIds) 235 .first<{ up_count: number; total: number }>(); 236 } else { 237 row = await db 238 .prepare( 239 `SELECT 240 SUM(CASE WHEN status NOT IN ('down','timeout') THEN 1 ELSE 0 END) AS up_count, 241 COUNT(*) AS total 242 FROM pings 243 WHERE timestamp >= ?`, 244 ) 245 .bind(since) 246 .first<{ up_count: number; total: number }>(); 247 } 248 if (!row || row.total === 0) return 100; 249 return Math.round((row.up_count / row.total) * 10000) / 100; 250} 251 252export async function getLastCheckTime( 253 db: D1Database, 254): Promise<number | null> { 255 const row = await db 256 .prepare("SELECT MAX(timestamp) as ts FROM pings") 257 .first<{ ts: number | null }>(); 258 return row?.ts ?? null; 259} 260 261export async function pruneOldPings( 262 db: D1Database, 263 days: number, 264): Promise<void> { 265 const cutoff = Math.floor(Date.now() / 1000) - days * 24 * 60 * 60; 266 await db 267 .prepare("DELETE FROM pings WHERE timestamp < ?") 268 .bind(cutoff) 269 .run(); 270} 271 272import type { Incident, IncidentUpdate, IncidentWithUpdates } from "./types"; 273 274export async function createIncident( 275 db: D1Database, 276 data: { service_id: string; title: string; severity: "critical" | "major" | "minor"; github_repo?: string; github_issue_number?: number }, 277): Promise<number> { 278 const now = Math.floor(Date.now() / 1000); 279 const result = await db 280 .prepare( 281 "INSERT INTO incidents (service_id, title, status, severity, github_repo, github_issue_number, started_at, created_at, updated_at) VALUES (?, ?, 'investigating', ?, ?, ?, ?, ?, ?)", 282 ) 283 .bind(data.service_id, data.title, data.severity, data.github_repo ?? null, data.github_issue_number ?? null, now, now, now) 284 .run(); 285 const id = result.meta.last_row_id; 286 await db 287 .prepare( 288 "INSERT INTO incident_updates (incident_id, status, message, created_at) VALUES (?, 'investigating', 'Incident detected automatically', ?)", 289 ) 290 .bind(id, now) 291 .run(); 292 return id as number; 293} 294 295export async function updateIncident( 296 db: D1Database, 297 id: number, 298 data: { status?: string; triage_report?: string; resolved_at?: number }, 299): Promise<void> { 300 const sets: string[] = []; 301 const values: unknown[] = []; 302 if (data.status) { sets.push("status = ?"); values.push(data.status); } 303 if (data.triage_report !== undefined) { sets.push("triage_report = ?"); values.push(data.triage_report); } 304 if (data.resolved_at) { sets.push("resolved_at = ?"); values.push(data.resolved_at); } 305 sets.push("updated_at = ?"); 306 values.push(Math.floor(Date.now() / 1000)); 307 values.push(id); 308 await db 309 .prepare(`UPDATE incidents SET ${sets.join(", ")} WHERE id = ?`) 310 .bind(...values) 311 .run(); 312} 313 314export async function addIncidentUpdate( 315 db: D1Database, 316 incident_id: number, 317 status: string, 318 message: string, 319): Promise<void> { 320 const now = Math.floor(Date.now() / 1000); 321 await db 322 .prepare("INSERT INTO incident_updates (incident_id, status, message, created_at) VALUES (?, ?, ?, ?)") 323 .bind(incident_id, status, message, now) 324 .run(); 325} 326 327export async function getActiveIncidents(db: D1Database): Promise<Incident[]> { 328 const rows = await db 329 .prepare("SELECT * FROM incidents WHERE status != 'resolved' ORDER BY created_at DESC") 330 .all(); 331 return rows.results as unknown as Incident[]; 332} 333 334export async function getActiveIncidentsWithUpdates(db: D1Database): Promise<IncidentWithUpdates[]> { 335 const rows = await db 336 .prepare( 337 `SELECT i.*, u.id as update_id, u.status as update_status, u.message as update_message, u.created_at as update_created_at 338 FROM incidents i 339 LEFT JOIN incident_updates u ON u.incident_id = i.id 340 WHERE i.status != 'resolved' 341 ORDER BY i.created_at DESC, u.created_at ASC`, 342 ) 343 .all(); 344 345 const incidentMap = new Map<number, IncidentWithUpdates>(); 346 for (const row of rows.results) { 347 const id = row.id as number; 348 if (!incidentMap.has(id)) { 349 incidentMap.set(id, { 350 id, 351 service_id: row.service_id as string, 352 title: row.title as string, 353 status: row.status as string, 354 severity: row.severity as string, 355 triage_report: row.triage_report as string | null, 356 github_repo: row.github_repo as string | null, 357 github_issue_number: row.github_issue_number as number | null, 358 started_at: row.started_at as number, 359 resolved_at: row.resolved_at as number | null, 360 created_at: row.created_at as number, 361 updated_at: row.updated_at as number, 362 updates: [], 363 }); 364 } 365 if (row.update_id) { 366 incidentMap.get(id)!.updates.push({ 367 id: row.update_id as number, 368 incident_id: id, 369 status: row.update_status as string, 370 message: row.update_message as string, 371 created_at: row.update_created_at as number, 372 }); 373 } 374 } 375 return Array.from(incidentMap.values()); 376} 377 378export async function getActiveIncidentForService( 379 db: D1Database, 380 service_id: string, 381): Promise<Incident | null> { 382 const row = await db 383 .prepare("SELECT * FROM incidents WHERE service_id = ? AND status != 'resolved' ORDER BY created_at DESC LIMIT 1") 384 .bind(service_id) 385 .first(); 386 return (row as unknown as Incident) ?? null; 387} 388 389export async function getRecentIncidents(db: D1Database, days: number): Promise<Incident[]> { 390 const since = Math.floor(Date.now() / 1000) - days * 24 * 60 * 60; 391 const rows = await db 392 .prepare("SELECT * FROM incidents WHERE resolved_at >= ? OR status != 'resolved' ORDER BY created_at DESC") 393 .bind(since) 394 .all(); 395 return rows.results as unknown as Incident[]; 396} 397 398export async function getRecentResolvedIncidentsWithUpdates(db: D1Database, days: number): Promise<IncidentWithUpdates[]> { 399 const since = Math.floor(Date.now() / 1000) - days * 24 * 60 * 60; 400 const rows = await db 401 .prepare( 402 `SELECT i.*, u.id as update_id, u.status as update_status, u.message as update_message, u.created_at as update_created_at 403 FROM incidents i 404 LEFT JOIN incident_updates u ON u.incident_id = i.id 405 WHERE i.status = 'resolved' AND i.resolved_at >= ? 406 ORDER BY i.resolved_at DESC, u.created_at ASC`, 407 ) 408 .bind(since) 409 .all(); 410 411 const incidentMap = new Map<number, IncidentWithUpdates>(); 412 for (const row of rows.results) { 413 const id = row.id as number; 414 if (!incidentMap.has(id)) { 415 incidentMap.set(id, { 416 id, 417 service_id: row.service_id as string, 418 title: row.title as string, 419 status: row.status as string, 420 severity: row.severity as string, 421 triage_report: row.triage_report as string | null, 422 github_repo: row.github_repo as string | null, 423 github_issue_number: row.github_issue_number as number | null, 424 started_at: row.started_at as number, 425 resolved_at: row.resolved_at as number | null, 426 created_at: row.created_at as number, 427 updated_at: row.updated_at as number, 428 updates: [], 429 }); 430 } 431 if (row.update_id) { 432 incidentMap.get(id)!.updates.push({ 433 id: row.update_id as number, 434 incident_id: id, 435 status: row.update_status as string, 436 message: row.update_message as string, 437 created_at: row.update_created_at as number, 438 }); 439 } 440 } 441 return Array.from(incidentMap.values()); 442} 443 444export async function getIncident(db: D1Database, id: number): Promise<IncidentWithUpdates | null> { 445 const incident = await db 446 .prepare("SELECT * FROM incidents WHERE id = ?") 447 .bind(id) 448 .first(); 449 if (!incident) return null; 450 const updates = await db 451 .prepare("SELECT * FROM incident_updates WHERE incident_id = ? ORDER BY created_at ASC") 452 .bind(id) 453 .all(); 454 return { 455 ...(incident as unknown as Incident), 456 updates: updates.results as unknown as IncidentUpdate[], 457 }; 458} 459 460export async function getIncidentByGitHubIssue( 461 db: D1Database, 462 repo: string, 463 issueNumber: number, 464): Promise<Incident | null> { 465 const row = await db 466 .prepare("SELECT * FROM incidents WHERE github_repo = ? AND github_issue_number = ? LIMIT 1") 467 .bind(repo, issueNumber) 468 .first(); 469 return (row as unknown as Incident) ?? null; 470} 471 472export async function setIncidentGitHub( 473 db: D1Database, 474 id: number, 475 repo: string, 476 issueNumber: number, 477): Promise<void> { 478 await db 479 .prepare("UPDATE incidents SET github_repo = ?, github_issue_number = ?, updated_at = ? WHERE id = ?") 480 .bind(repo, issueNumber, Math.floor(Date.now() / 1000), id) 481 .run(); 482} 483 484export async function getRecentlyResolvedIncident( 485 db: D1Database, 486 service_id: string, 487 withinSeconds: number, 488): Promise<Incident | null> { 489 const since = Math.floor(Date.now() / 1000) - withinSeconds; 490 const row = await db 491 .prepare("SELECT * FROM incidents WHERE service_id = ? AND status = 'resolved' AND resolved_at >= ? ORDER BY resolved_at DESC LIMIT 1") 492 .bind(service_id, since) 493 .first(); 494 return (row as unknown as Incident) ?? null; 495} 496 497export async function getRecentlyResolvedIncidents( 498 db: D1Database, 499 withinSeconds: number, 500): Promise<Incident[]> { 501 const since = Math.floor(Date.now() / 1000) - withinSeconds; 502 const rows = await db 503 .prepare("SELECT * FROM incidents WHERE status = 'resolved' AND resolved_at >= ? ORDER BY resolved_at DESC") 504 .bind(since) 505 .all(); 506 return rows.results as unknown as Incident[]; 507}