my own status page
0
fork

Configure Feed

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

feat: grade states

+116 -18
+17
migrations/0002_add_statuses.sql
··· 1 + -- Drop the old CHECK constraint and add new one with additional statuses 2 + -- SQLite doesn't support ALTER CONSTRAINT, so we recreate the table 3 + CREATE TABLE pings_new ( 4 + id INTEGER PRIMARY KEY AUTOINCREMENT, 5 + service_id TEXT NOT NULL, 6 + timestamp INTEGER NOT NULL, 7 + status TEXT NOT NULL CHECK (status IN ('up', 'degraded', 'misconfigured', 'down', 'unknown')), 8 + latency_ms INTEGER 9 + ); 10 + 11 + INSERT INTO pings_new SELECT * FROM pings; 12 + 13 + DROP TABLE pings; 14 + 15 + ALTER TABLE pings_new RENAME TO pings; 16 + 17 + CREATE INDEX idx_pings_service_ts ON pings (service_id, timestamp DESC);
+14 -4
src/health.ts
··· 1 1 import type { Service } from "./types"; 2 2 3 + const SLOW_THRESHOLD_MS = 3000; 4 + 5 + export type Status = "up" | "degraded" | "misconfigured" | "down" | "unknown"; 6 + 3 7 interface HealthResult { 4 - status: "up" | "degraded" | "down"; 8 + status: Status; 5 9 latency_ms: number; 6 10 } 7 11 8 12 export async function checkHealth(service: Service): Promise<HealthResult> { 9 13 if (!service.health_url) { 10 - return { status: "unknown" as "down", latency_ms: 0 }; 14 + return { status: "unknown", latency_ms: 0 }; 11 15 } 12 16 13 17 const start = Date.now(); ··· 19 23 }); 20 24 const latency_ms = Date.now() - start; 21 25 22 - if (res.status >= 200 && res.status < 300) { 23 - return { status: "up", latency_ms }; 26 + if (res.status >= 400 && res.status < 500) { 27 + return { status: "misconfigured", latency_ms }; 24 28 } 25 29 if (res.status >= 500) { 26 30 return { status: "degraded", latency_ms }; 31 + } 32 + if (res.status >= 200 && res.status < 300) { 33 + if (latency_ms > SLOW_THRESHOLD_MS) { 34 + return { status: "degraded", latency_ms }; 35 + } 36 + return { status: "up", latency_ms }; 27 37 } 28 38 return { status: "down", latency_ms }; 29 39 } catch {
+8 -1
src/routes/badge.ts
··· 6 6 const COLORS: Record<string, string> = { 7 7 up: "#3cc068", 8 8 degraded: "#f0ad4e", 9 + misconfigured: "#9b59b6", 10 + partial: "#f0ad4e", 9 11 down: "#e05d44", 10 12 unknown: "#9f9f9f", 11 13 }; ··· 13 15 const STATUS_LABELS: Record<string, string> = { 14 16 up: "operational", 15 17 degraded: "degraded", 18 + misconfigured: "misconfigured", 19 + partial: "partial", 16 20 down: "down", 17 21 unknown: "unknown", 18 22 }; ··· 149 153 } 150 154 151 155 function worstStatus(statuses: string[]): string { 152 - if (statuses.includes("down")) return "down"; 156 + if (statuses.length === 0) return "unknown"; 157 + if (statuses.every((s) => s === "down")) return "down"; 158 + if (statuses.includes("down")) return "partial"; 159 + if (statuses.includes("misconfigured")) return "misconfigured"; 153 160 if (statuses.includes("degraded")) return "degraded"; 154 161 if (statuses.includes("unknown")) return "unknown"; 155 162 return "up";
+21 -10
src/routes/index.ts
··· 37 37 const servers = machines.filter((m) => m.type === "server"); 38 38 const clients = machines.filter((m) => m.type === "client"); 39 39 40 - const allUp = machines 41 - .filter((m) => m.type === "server") 42 - .every( 43 - (m) => 44 - m.online && 45 - m.services.every((s) => s.status === "up" || s.status === "unknown"), 46 - ); 40 + const activeServers = servers.filter((m) => m.services.length > 0); 41 + const anyServerOffline = activeServers.some((m) => !m.online); 42 + const svcStatuses = activeServers.flatMap((m) => m.services.map((s) => s.status)); 43 + const downCount = svcStatuses.filter((s) => s === "down").length; 44 + const downRatio = svcStatuses.length > 0 ? downCount / svcStatuses.length : 0; 45 + const onFire = anyServerOffline || downRatio >= 0.4; 46 + const hasDegraded = 47 + svcStatuses.includes("down") || 48 + svcStatuses.includes("degraded") || 49 + svcStatuses.includes("misconfigured") || 50 + svcStatuses.includes("partial"); 51 + const overallClass = onFire ? "down" : hasDegraded ? "degraded" : "up"; 52 + const overallText = onFire 53 + ? "On fire" 54 + : hasDegraded 55 + ? "Some systems degraded" 56 + : "All systems operational"; 47 57 48 58 const html = `<!DOCTYPE html> 49 59 <html lang="en"> ··· 61 71 .dot.up { background: #2ecc71; } 62 72 .dot.degraded { background: #f39c12; } 63 73 .dot.down { background: #e74c3c; } 74 + .dot.misconfigured { background: #9b59b6; } 64 75 .dot.unknown { background: #8b949e; } 65 76 .dot.online { background: #2ecc71; } 66 77 .dot.offline { background: #e74c3c; } ··· 88 99 </head> 89 100 <body> 90 101 <h1>infra.dunkirk.sh</h1> 91 - <p class="overall"><span class="dot ${allUp ? "up" : "degraded"}"></span>${allUp ? "All systems operational" : "Some systems degraded"}</p> 102 + <p class="overall"><span class="dot ${overallClass}"></span>${overallText}</p> 92 103 ${servers 93 104 .map( 94 105 (m) => `<div class="machine"> 95 - <div class="machine-header"><span class="dot ${m.online ? "online" : "offline"}"></span>${esc(m.name)}<span class="machine-type">${esc(m.type)}</span></div> 106 + <div class="machine-header"><span class="dot ${m.online ? "online" : m.services.length === 0 ? "unknown" : "offline"}"></span>${esc(m.name)}<span class="machine-type">${esc(m.type)}</span></div> 96 107 ${m.services.length === 0 ? `<div class="no-services">no services</div>` : m.services 97 108 .map( 98 109 (s) => `<div class="service"> ··· 112 123 ${clients.length > 0 ? `<div class="clients"> 113 124 <div class="clients-header">devices</div> 114 125 <div class="clients-list"> 115 - ${clients.map((m) => `<span class="client"><span class="dot ${m.online ? "online" : "offline"}"></span>${esc(m.name)}</span>`).join("\n")} 126 + ${clients.map((m) => `<span class="client"><span class="dot ${m.online ? "online" : "unknown"}"></span>${esc(m.name)}</span>`).join("\n")} 116 127 </div> 117 128 </div>` : ""} 118 129 <footer><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></footer>
+56 -3
src/routes/status.ts
··· 6 6 const JSON_HEADERS = { "Access-Control-Allow-Origin": "*" }; 7 7 8 8 function worstStatus(statuses: string[]): string { 9 - if (statuses.includes("down")) return "down"; 9 + if (statuses.length === 0) return "unknown"; 10 + if (statuses.every((s) => s === "down")) return "down"; 11 + if (statuses.includes("down")) return "partial"; 12 + if (statuses.includes("misconfigured")) return "misconfigured"; 10 13 if (statuses.includes("degraded")) return "degraded"; 11 14 if (statuses.includes("unknown")) return "unknown"; 12 15 return "up"; 13 16 } 14 17 15 - // GET /api/status or /api/status/overall 18 + // GET /api/status/overall 16 19 async function overallStatus(env: Env): Promise<Response> { 17 20 const manifest = await getManifest(env); 18 21 const allServices = Object.values(manifest).flatMap((m) => m.services); ··· 47 50 ); 48 51 } 49 52 53 + // GET /api/status 54 + async function fullStatus(env: Env): Promise<Response> { 55 + const manifest = await getManifest(env); 56 + 57 + const machines = await Promise.all( 58 + Object.entries(manifest).map(async ([name, machine]) => { 59 + const online = await getDeviceStatus(env, machine.tailscale_host); 60 + const services = await Promise.all( 61 + machine.services.map(async (svc) => { 62 + const ping = await getLatestPing(env.DB, svc.name); 63 + const uptime = await getUptime7d(env.DB, svc.name); 64 + return { 65 + id: svc.name, 66 + status: (ping?.status ?? "unknown") as string, 67 + latency_ms: ping?.latency_ms ?? null, 68 + uptime_7d: uptime, 69 + }; 70 + }), 71 + ); 72 + const svcStatuses = services.map((s) => s.status); 73 + return { 74 + name, 75 + hostname: machine.hostname, 76 + type: machine.type, 77 + online, 78 + status: online ? worstStatus(svcStatuses) : "down", 79 + services, 80 + }; 81 + }), 82 + ); 83 + 84 + const allStatuses = machines 85 + .filter((m) => m.type === "server") 86 + .flatMap((m) => [m.online ? "up" : "down", ...m.services.map((s) => s.status)]); 87 + const status = worstStatus(allStatuses); 88 + 89 + return Response.json( 90 + { 91 + ok: status === "up", 92 + status, 93 + machines, 94 + }, 95 + { headers: JSON_HEADERS }, 96 + ); 97 + } 98 + 50 99 // GET /api/status/service/:id 51 100 async function serviceStatus(env: Env, id: string): Promise<Response> { 52 101 const ping = await getLatestPing(env.DB, id); ··· 110 159 env: Env, 111 160 path: string, 112 161 ): Promise<Response | null> { 113 - if (path === "/api/status" || path === "/api/status/overall") { 162 + if (path === "/api/status") { 163 + return fullStatus(env); 164 + } 165 + 166 + if (path === "/api/status/overall") { 114 167 return overallStatus(env); 115 168 } 116 169