my own status page
0
fork

Configure Feed

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

at main 287 lines 9.4 kB view raw
1import type { Env } from "../types"; 2import { getManifest } from "../manifest"; 3import { getLatestPing, getUptime7d } from "../db"; 4import { getDeviceStatus } from "../tailscale"; 5 6const COLORS: Record<string, string> = { 7 up: "#3cc068", 8 degraded: "#f0ad4e", 9 misconfigured: "#9b59b6", 10 timeout: "#e05d44", 11 partial: "#f0ad4e", 12 down: "#e05d44", 13 unknown: "#9f9f9f", 14}; 15 16const STATUS_LABELS: Record<string, string> = { 17 up: "operational", 18 degraded: "degraded", 19 misconfigured: "misconfigured", 20 timeout: "timeout", 21 partial: "partial", 22 down: "down", 23 unknown: "unknown", 24}; 25 26// Verdana character width table at 11px (from shields.io) 27const WIDTHS: Record<string, number> = { 28 " ": 3.3, "!": 4.2, '"': 5.2, "#": 7.8, $: 6.3, "%": 9.5, "&": 7.6, 29 "'": 2.8, "(": 4.2, ")": 4.2, "*": 6.3, "+": 7.8, ",": 3.5, "-": 4.4, 30 ".": 3.5, "/": 4.8, "0": 6.3, "1": 6.3, "2": 6.3, "3": 6.3, "4": 6.3, 31 "5": 6.3, "6": 6.3, "7": 6.3, "8": 6.3, "9": 6.3, ":": 4.2, ";": 4.2, 32 "<": 7.8, "=": 7.8, ">": 7.8, "?": 5.6, "@": 10.3, A: 7.3, B: 7.0, 33 C: 6.7, D: 7.6, E: 6.2, F: 5.7, G: 7.6, H: 7.6, I: 4.2, J: 4.2, 34 K: 7.0, L: 6.0, M: 8.9, N: 7.6, O: 7.6, P: 6.2, Q: 7.6, R: 7.0, 35 S: 6.7, T: 6.2, U: 7.6, V: 7.0, W: 9.5, X: 6.5, Y: 6.2, Z: 6.7, 36 a: 5.8, b: 6.5, c: 5.0, d: 6.5, e: 5.8, f: 3.9, g: 6.5, h: 6.5, 37 i: 3.0, j: 3.6, k: 6.1, l: 3.0, m: 9.5, n: 6.5, o: 6.2, p: 6.5, 38 q: 6.5, r: 4.6, s: 5.2, t: 4.2, u: 6.5, v: 5.8, w: 8.4, x: 5.6, 39 y: 5.8, z: 5.0, "|": 4.2, 40}; 41 42function textWidth(s: string): number { 43 let w = 0; 44 for (const c of s) w += WIDTHS[c] ?? 6.5; 45 return w; 46} 47 48function textColorForBg(hex: string): string { 49 const c = hex.replace("#", ""); 50 const r = parseInt(c.slice(0, 2), 16); 51 const g = parseInt(c.slice(2, 4), 16); 52 const b = parseInt(c.slice(4, 6), 16); 53 const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255; 54 return lum > 0.5 ? "#333" : "#fff"; 55} 56 57interface BadgeData { 58 label: string; 59 status: string; 60 value: string; 61} 62 63interface StyleOpts { 64 style: "flat" | "for-the-badge"; 65 colorA: string; 66 colorB: string; 67 label?: string; 68} 69 70function parseStyleOpts(url: URL, status: string): StyleOpts { 71 const colorA = parseColor(url.searchParams.get("colorA")) ?? "#555"; 72 const colorB = 73 parseColor(url.searchParams.get("colorB")) ?? 74 COLORS[status] ?? 75 COLORS.unknown; 76 return { 77 style: 78 url.searchParams.get("style") === "for-the-badge" 79 ? "for-the-badge" 80 : "flat", 81 colorA, 82 colorB, 83 label: url.searchParams.get("label") ?? undefined, 84 }; 85} 86 87function parseColor(s: string | null): string | undefined { 88 if (!s) return undefined; 89 return s.startsWith("#") ? s : `#${s}`; 90} 91 92function renderBadge(data: BadgeData, opts: StyleOpts): string { 93 const label = opts.label ?? data.label; 94 return opts.style === "for-the-badge" 95 ? renderForTheBadge(label, data.value, opts.colorA, opts.colorB) 96 : renderFlat(label, data.value, opts.colorA, opts.colorB); 97} 98 99function renderFlat( 100 label: string, 101 value: string, 102 colorA: string, 103 colorB: string, 104): string { 105 const pad = 20; 106 const labelW = Math.round(textWidth(label) + pad); 107 const valueW = Math.round(textWidth(value) + pad); 108 const total = labelW + valueW; 109 const labelX = labelW / 2; 110 const valueX = labelW + valueW / 2; 111 112 return `<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="20" role="img"> 113 <title>${esc(label)}: ${esc(value)}</title> 114 <linearGradient id="s" x2="0" y2="100%"> 115 <stop offset="0" stop-color="#bbb" stop-opacity=".1"/> 116 <stop offset="1" stop-opacity=".1"/> 117 </linearGradient> 118 <clipPath id="r"><rect width="${total}" height="20" rx="3" fill="#fff"/></clipPath> 119 <g clip-path="url(#r)"> 120 <rect width="${labelW}" height="20" fill="${colorA}"/> 121 <rect x="${labelW}" width="${valueW}" height="20" fill="${colorB}"/> 122 <rect width="${total}" height="20" fill="url(#s)"/> 123 </g> 124 <g text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11"> 125 <text x="${labelX}" y="14" fill="${textColorForBg(colorA)}">${esc(label)}</text> 126 <text x="${valueX}" y="14" fill="${textColorForBg(colorB)}">${esc(value)}</text> 127 </g> 128</svg>`; 129} 130 131function renderForTheBadge( 132 label: string, 133 value: string, 134 colorA: string, 135 colorB: string, 136): string { 137 const labelUp = label.toUpperCase(); 138 const valueUp = value.toUpperCase(); 139 const charW = 75; 140 const pad = 240; 141 const labelTL = labelUp.length * charW; 142 const valueTL = valueUp.length * charW; 143 const labelW = (labelTL + pad) / 10; 144 const valueW = (valueTL + pad) / 10; 145 const total = labelW + valueW; 146 const labelX = labelW * 5; 147 const valueX = (labelW + valueW / 2) * 10; 148 const h = 28; 149 150 return `<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="${h}" role="img" aria-label="${esc(label)}: ${esc(value)}"><title>${esc(label)}: ${esc(value)}</title><g shape-rendering="crispEdges"><rect width="${labelW}" height="${h}" fill="${colorA}"/><rect x="${labelW}" width="${valueW}" height="${h}" fill="${colorB}"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="100"><text transform="scale(.1)" x="${labelX}" y="175" textLength="${labelTL}" fill="${textColorForBg(colorA)}">${esc(labelUp)}</text><text transform="scale(.1)" x="${valueX}" y="175" textLength="${valueTL}" fill="${textColorForBg(colorB)}" font-weight="bold">${esc(valueUp)}</text></g></svg>`; 151} 152 153function esc(s: string): string { 154 return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); 155} 156 157function worstStatus(statuses: string[]): string { 158 if (statuses.length === 0) return "unknown"; 159 if (statuses.every((s) => s === "down" || s === "timeout")) return "down"; 160 if (statuses.includes("down") || statuses.includes("timeout")) return "partial"; 161 if (statuses.includes("misconfigured")) return "degraded"; 162 if (statuses.includes("degraded")) return "degraded"; 163 if (statuses.includes("unknown")) return "unknown"; 164 return "up"; 165} 166 167const BADGE_HEADERS = { 168 "Content-Type": "image/svg+xml", 169 "Cache-Control": "no-cache, no-store, must-revalidate", 170 "Access-Control-Allow-Origin": "*", 171}; 172 173function badgeResponse(data: BadgeData, url: URL): Response { 174 const opts = parseStyleOpts(url, data.status); 175 return new Response(renderBadge(data, opts), { headers: BADGE_HEADERS }); 176} 177 178// GET /badge/service/:id 179async function serviceBadge(env: Env, id: string, url: URL): Promise<Response> { 180 const ping = await getLatestPing(env.DB, id); 181 const uptime = await getUptime7d(env.DB, id); 182 const status = (ping?.status as string) ?? "unknown"; 183 const statusLabel = STATUS_LABELS[status] ?? "unknown"; 184 return badgeResponse( 185 { label: id, status, value: `${statusLabel} ${uptime}%` }, 186 url, 187 ); 188} 189 190// GET /badge/machine/:name 191async function machineBadge( 192 env: Env, 193 name: string, 194 url: URL, 195): Promise<Response> { 196 const manifest = await getManifest(env); 197 const machine = manifest[name]; 198 if (!machine) { 199 return new Response("Machine not found", { status: 404 }); 200 } 201 202 const online = await getDeviceStatus(env, machine.tailscale_host); 203 if (!online) { 204 return badgeResponse({ label: name, status: "down", value: "offline" }, url); 205 } 206 207 const monitored = machine.services.filter((s) => s.health_url); 208 if (monitored.length === 0) { 209 return badgeResponse( 210 { label: name, status: online ? "up" : "down", value: online ? "online" : "offline" }, 211 url, 212 ); 213 } 214 215 const statuses: string[] = []; 216 let totalUptime = 0; 217 for (const svc of monitored) { 218 const ping = await getLatestPing(env.DB, svc.name); 219 const uptime = await getUptime7d(env.DB, svc.name); 220 statuses.push((ping?.status as string) ?? "unknown"); 221 totalUptime += uptime; 222 } 223 224 const status = worstStatus(statuses); 225 const avgUptime = Math.round((totalUptime / monitored.length) * 100) / 100; 226 const statusLabel = STATUS_LABELS[status] ?? "unknown"; 227 return badgeResponse( 228 { label: name, status, value: `${statusLabel} ${avgUptime}%` }, 229 url, 230 ); 231} 232 233// GET /badge/overall 234async function overallBadge(env: Env, url: URL): Promise<Response> { 235 const manifest = await getManifest(env); 236 const allServices = Object.values(manifest).flatMap((m) => m.services); 237 const monitored = allServices.filter((s) => s.health_url !== null); 238 239 const statuses: string[] = []; 240 let totalUptime = 0; 241 242 for (const svc of monitored) { 243 const ping = await getLatestPing(env.DB, svc.name); 244 const uptime = await getUptime7d(env.DB, svc.name); 245 statuses.push((ping?.status as string) ?? "unknown"); 246 totalUptime += uptime; 247 } 248 249 const status = worstStatus(statuses); 250 const avgUptime = 251 monitored.length > 0 252 ? Math.round((totalUptime / monitored.length) * 100) / 100 253 : 100; 254 const statusLabel = STATUS_LABELS[status] ?? "unknown"; 255 return badgeResponse( 256 { label: "infra", status, value: `${statusLabel} ${avgUptime}%` }, 257 url, 258 ); 259} 260 261export async function handleBadgeRoute( 262 env: Env, 263 path: string, 264 url: URL, 265): Promise<Response | null> { 266 if (path === "/badge" || path === "/badge/overall") { 267 return overallBadge(env, url); 268 } 269 270 const serviceMatch = path.match(/^\/badge\/service\/(.+)$/); 271 if (serviceMatch) { 272 return serviceBadge(env, serviceMatch[1], url); 273 } 274 275 const machineMatch = path.match(/^\/badge\/machine\/(.+)$/); 276 if (machineMatch) { 277 return machineBadge(env, machineMatch[1], url); 278 } 279 280 // Legacy: /badge/:id → treat as service 281 const legacyMatch = path.match(/^\/badge\/(.+)$/); 282 if (legacyMatch) { 283 return serviceBadge(env, legacyMatch[1], url); 284 } 285 286 return null; 287}