my own status page
0
fork

Configure Feed

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

feat: add api and badge docs

+356 -96
+48 -1
README.md
··· 1 1 # status 2 2 3 - my own status page 3 + super simple cf worker based uptime / status dashboard running [infra.dunkirk.sh](https://infra.dunkirk.sh). 4 4 5 5 The canonical repo for this is hosted on tangled over at [`dunkirk.sh/status`](https://tangled.org/dunkirk.sh/status) 6 + 7 + ## API 8 + 9 + ``` 10 + /api/status # overall summary (ok, status, uptime, counts) 11 + /api/status/overall # same as above 12 + /api/status/service/:id # single service status + latency + uptime 13 + /api/status/machine/:name # machine online status + all its services 14 + /api/uptime/:service_id # hourly uptime buckets for a service 15 + ``` 16 + 17 + ## Badges 18 + 19 + ``` 20 + /badge # overall infra status 21 + /badge/overall # same as above 22 + /badge/service/:id # single service 23 + /badge/machine/:name # machine status 24 + ``` 25 + 26 + **Query params:** 27 + 28 + | Param | Description | Example | 29 + | -------- | ----------------------------------- | ---------------------- | 30 + | `style` | `flat` (default) or `for-the-badge` | `?style=for-the-badge` | 31 + | `colorA` | Label background (hex) | `?colorA=363a4f` | 32 + | `colorB` | Value background (hex) | `?colorB=b7bdf8` | 33 + | `label` | Override label text | `?label=my+service` | 34 + 35 + ## Setup 36 + 37 + ```bash 38 + bun install 39 + wrangler d1 create status-db 40 + wrangler kv namespace create KV 41 + # update wrangler.toml with the IDs 42 + bun run db:migrate:local 43 + wrangler secret put TAILSCALE_API_KEY 44 + bun run dev 45 + ``` 46 + 47 + ## Deploy 48 + 49 + ```bash 50 + bun run deploy 51 + bun run db:migrate 52 + ``` 6 53 7 54 <p align="center"> 8 55 <img src="https://raw.githubusercontent.com/taciturnaxolotl/carriage/main/.github/images/line-break.svg" />
+8 -11
src/index.ts
··· 3 3 import { checkHealth } from "./health"; 4 4 import { insertPing, pruneOldPings } from "./db"; 5 5 import { refreshDevices } from "./tailscale"; 6 - import { handleStatus } from "./routes/status"; 6 + import { handleStatusRoute } from "./routes/status"; 7 7 import { handleUptime } from "./routes/uptime"; 8 - import { handleBadge, handleOverallBadge } from "./routes/badge"; 8 + import { handleBadgeRoute } from "./routes/badge"; 9 9 import { handleIndex } from "./routes/index"; 10 10 11 11 export default { ··· 17 17 return handleIndex(env); 18 18 } 19 19 20 - if (path === "/api/status") { 21 - return handleStatus(env); 20 + if (path.startsWith("/api/status")) { 21 + const res = await handleStatusRoute(env, path); 22 + if (res) return res; 22 23 } 23 24 24 25 const uptimeMatch = path.match(/^\/api\/uptime\/(.+)$/); ··· 26 27 return handleUptime(env, uptimeMatch[1], url); 27 28 } 28 29 29 - if (path === "/badge") { 30 - return handleOverallBadge(env); 31 - } 32 - 33 - const badgeMatch = path.match(/^\/badge\/(.+)$/); 34 - if (badgeMatch) { 35 - return handleBadge(env, badgeMatch[1]); 30 + if (path.startsWith("/badge")) { 31 + const badge = await handleBadgeRoute(env, path, url); 32 + if (badge) return badge; 36 33 } 37 34 38 35 return new Response("Not Found", { status: 404 });
+185 -30
src/routes/badge.ts
··· 1 1 import type { Env } from "../types"; 2 2 import { getManifest } from "../manifest"; 3 3 import { getLatestPing, getUptime7d } from "../db"; 4 + import { getDeviceStatus } from "../tailscale"; 4 5 5 6 const COLORS: Record<string, string> = { 6 7 up: "#3cc068", ··· 38 39 return w; 39 40 } 40 41 41 - function makeBadge(label: string, status: string, uptime: number): string { 42 - const color = COLORS[status] ?? COLORS.unknown; 43 - const statusLabel = STATUS_LABELS[status] ?? "unknown"; 44 - const value = `${statusLabel} ${uptime}%`; 42 + function textColorForBg(hex: string): string { 43 + const c = hex.replace("#", ""); 44 + const r = parseInt(c.slice(0, 2), 16); 45 + const g = parseInt(c.slice(2, 4), 16); 46 + const b = parseInt(c.slice(4, 6), 16); 47 + const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255; 48 + return lum > 0.5 ? "#333" : "#fff"; 49 + } 45 50 51 + interface BadgeData { 52 + label: string; 53 + status: string; 54 + value: string; 55 + } 56 + 57 + interface StyleOpts { 58 + style: "flat" | "for-the-badge"; 59 + colorA: string; 60 + colorB: string; 61 + label?: string; 62 + } 63 + 64 + function parseStyleOpts(url: URL, status: string): StyleOpts { 65 + const colorA = parseColor(url.searchParams.get("colorA")) ?? "#555"; 66 + const colorB = 67 + parseColor(url.searchParams.get("colorB")) ?? 68 + COLORS[status] ?? 69 + COLORS.unknown; 70 + return { 71 + style: 72 + url.searchParams.get("style") === "for-the-badge" 73 + ? "for-the-badge" 74 + : "flat", 75 + colorA, 76 + colorB, 77 + label: url.searchParams.get("label") ?? undefined, 78 + }; 79 + } 80 + 81 + function parseColor(s: string | null): string | undefined { 82 + if (!s) return undefined; 83 + return s.startsWith("#") ? s : `#${s}`; 84 + } 85 + 86 + function renderBadge(data: BadgeData, opts: StyleOpts): string { 87 + const label = opts.label ?? data.label; 88 + return opts.style === "for-the-badge" 89 + ? renderForTheBadge(label, data.value, opts.colorA, opts.colorB) 90 + : renderFlat(label, data.value, opts.colorA, opts.colorB); 91 + } 92 + 93 + function renderFlat( 94 + label: string, 95 + value: string, 96 + colorA: string, 97 + colorB: string, 98 + ): string { 46 99 const pad = 20; 47 100 const labelW = Math.round(textWidth(label) + pad); 48 101 const valueW = Math.round(textWidth(value) + pad); ··· 50 103 const labelX = labelW / 2; 51 104 const valueX = labelW + valueW / 2; 52 105 53 - return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${total}" height="20" role="img"> 54 - <title>${label}: ${value}</title> 106 + return `<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="20" role="img"> 107 + <title>${esc(label)}: ${esc(value)}</title> 55 108 <linearGradient id="s" x2="0" y2="100%"> 56 109 <stop offset="0" stop-color="#bbb" stop-opacity=".1"/> 57 110 <stop offset="1" stop-opacity=".1"/> 58 111 </linearGradient> 59 112 <clipPath id="r"><rect width="${total}" height="20" rx="3" fill="#fff"/></clipPath> 60 113 <g clip-path="url(#r)"> 61 - <rect width="${labelW}" height="20" fill="#555"/> 62 - <rect x="${labelW}" width="${valueW}" height="20" fill="${color}"/> 114 + <rect width="${labelW}" height="20" fill="${colorA}"/> 115 + <rect x="${labelW}" width="${valueW}" height="20" fill="${colorB}"/> 63 116 <rect width="${total}" height="20" fill="url(#s)"/> 64 117 </g> 65 - <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11"> 66 - <text aria-hidden="true" x="${labelX}" y="15" fill="#010101" fill-opacity=".3">${esc(label)}</text> 67 - <text x="${labelX}" y="14">${esc(label)}</text> 68 - <text aria-hidden="true" x="${valueX}" y="15" fill="#010101" fill-opacity=".3">${esc(value)}</text> 69 - <text x="${valueX}" y="14">${esc(value)}</text> 118 + <g text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11"> 119 + <text x="${labelX}" y="14" fill="${textColorForBg(colorA)}">${esc(label)}</text> 120 + <text x="${valueX}" y="14" fill="${textColorForBg(colorB)}">${esc(value)}</text> 70 121 </g> 71 122 </svg>`; 72 123 } 73 124 125 + function renderForTheBadge( 126 + label: string, 127 + value: string, 128 + colorA: string, 129 + colorB: string, 130 + ): string { 131 + const labelUp = label.toUpperCase(); 132 + const valueUp = value.toUpperCase(); 133 + const charW = 75; 134 + const pad = 240; 135 + const labelTL = labelUp.length * charW; 136 + const valueTL = valueUp.length * charW; 137 + const labelW = (labelTL + pad) / 10; 138 + const valueW = (valueTL + pad) / 10; 139 + const total = labelW + valueW; 140 + const labelX = labelW * 5; 141 + const valueX = (labelW + valueW / 2) * 10; 142 + const h = 28; 143 + 144 + 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>`; 145 + } 146 + 74 147 function esc(s: string): string { 75 148 return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); 76 149 } 77 150 151 + function worstStatus(statuses: string[]): string { 152 + if (statuses.includes("down")) return "down"; 153 + if (statuses.includes("degraded")) return "degraded"; 154 + if (statuses.includes("unknown")) return "unknown"; 155 + return "up"; 156 + } 157 + 78 158 const BADGE_HEADERS = { 79 159 "Content-Type": "image/svg+xml", 80 160 "Cache-Control": "no-cache, no-store, must-revalidate", 81 161 "Access-Control-Allow-Origin": "*", 82 162 }; 83 163 84 - export async function handleBadge( 164 + function badgeResponse(data: BadgeData, url: URL): Response { 165 + const opts = parseStyleOpts(url, data.status); 166 + return new Response(renderBadge(data, opts), { headers: BADGE_HEADERS }); 167 + } 168 + 169 + // GET /badge/service/:id 170 + async function serviceBadge(env: Env, id: string, url: URL): Promise<Response> { 171 + const ping = await getLatestPing(env.DB, id); 172 + const uptime = await getUptime7d(env.DB, id); 173 + const status = (ping?.status as string) ?? "unknown"; 174 + const statusLabel = STATUS_LABELS[status] ?? "unknown"; 175 + return badgeResponse( 176 + { label: id, status, value: `${statusLabel} ${uptime}%` }, 177 + url, 178 + ); 179 + } 180 + 181 + // GET /badge/machine/:name 182 + async function machineBadge( 85 183 env: Env, 86 - serviceId: string, 184 + name: string, 185 + url: URL, 87 186 ): Promise<Response> { 88 - const ping = await getLatestPing(env.DB, serviceId); 89 - const uptime = await getUptime7d(env.DB, serviceId); 90 - const status = (ping?.status as string) ?? "unknown"; 187 + const manifest = await getManifest(env); 188 + const machine = manifest[name]; 189 + if (!machine) { 190 + return new Response("Machine not found", { status: 404 }); 191 + } 91 192 92 - return new Response(makeBadge(serviceId, status, uptime), { 93 - headers: BADGE_HEADERS, 94 - }); 193 + const online = await getDeviceStatus(env, machine.tailscale_host); 194 + if (!online) { 195 + return badgeResponse({ label: name, status: "down", value: "offline" }, url); 196 + } 197 + 198 + const monitored = machine.services.filter((s) => s.health_url); 199 + if (monitored.length === 0) { 200 + return badgeResponse( 201 + { label: name, status: online ? "up" : "down", value: online ? "online" : "offline" }, 202 + url, 203 + ); 204 + } 205 + 206 + const statuses: string[] = []; 207 + let totalUptime = 0; 208 + for (const svc of monitored) { 209 + const ping = await getLatestPing(env.DB, svc.name); 210 + const uptime = await getUptime7d(env.DB, svc.name); 211 + statuses.push((ping?.status as string) ?? "unknown"); 212 + totalUptime += uptime; 213 + } 214 + 215 + const status = worstStatus(statuses); 216 + const avgUptime = Math.round((totalUptime / monitored.length) * 100) / 100; 217 + const statusLabel = STATUS_LABELS[status] ?? "unknown"; 218 + return badgeResponse( 219 + { label: name, status, value: `${statusLabel} ${avgUptime}%` }, 220 + url, 221 + ); 95 222 } 96 223 97 - export async function handleOverallBadge(env: Env): Promise<Response> { 224 + // GET /badge/overall 225 + async function overallBadge(env: Env, url: URL): Promise<Response> { 98 226 const manifest = await getManifest(env); 99 227 const allServices = Object.values(manifest).flatMap((m) => m.services); 100 228 const monitored = allServices.filter((s) => s.health_url !== null); 101 229 102 - let worst: string = "up"; 230 + const statuses: string[] = []; 103 231 let totalUptime = 0; 104 232 105 233 for (const svc of monitored) { 106 234 const ping = await getLatestPing(env.DB, svc.name); 107 235 const uptime = await getUptime7d(env.DB, svc.name); 236 + statuses.push((ping?.status as string) ?? "unknown"); 108 237 totalUptime += uptime; 109 - const s = (ping?.status as string) ?? "unknown"; 110 - if (s === "down") worst = "down"; 111 - else if (s === "degraded" && worst !== "down") worst = "degraded"; 112 - else if (s === "unknown" && worst === "up") worst = "unknown"; 113 238 } 114 239 240 + const status = worstStatus(statuses); 115 241 const avgUptime = 116 242 monitored.length > 0 117 243 ? Math.round((totalUptime / monitored.length) * 100) / 100 118 244 : 100; 245 + const statusLabel = STATUS_LABELS[status] ?? "unknown"; 246 + return badgeResponse( 247 + { label: "infra", status, value: `${statusLabel} ${avgUptime}%` }, 248 + url, 249 + ); 250 + } 119 251 120 - return new Response(makeBadge("infra", worst, avgUptime), { 121 - headers: BADGE_HEADERS, 122 - }); 252 + export async function handleBadgeRoute( 253 + env: Env, 254 + path: string, 255 + url: URL, 256 + ): Promise<Response | null> { 257 + if (path === "/badge" || path === "/badge/overall") { 258 + return overallBadge(env, url); 259 + } 260 + 261 + const serviceMatch = path.match(/^\/badge\/service\/(.+)$/); 262 + if (serviceMatch) { 263 + return serviceBadge(env, serviceMatch[1], url); 264 + } 265 + 266 + const machineMatch = path.match(/^\/badge\/machine\/(.+)$/); 267 + if (machineMatch) { 268 + return machineBadge(env, machineMatch[1], url); 269 + } 270 + 271 + // Legacy: /badge/:id → treat as service 272 + const legacyMatch = path.match(/^\/badge\/(.+)$/); 273 + if (legacyMatch) { 274 + return serviceBadge(env, legacyMatch[1], url); 275 + } 276 + 277 + return null; 123 278 }
+115 -37
src/routes/status.ts
··· 1 - import type { Env, StatusResponse } from "../types"; 1 + import type { Env } from "../types"; 2 2 import { getManifest } from "../manifest"; 3 3 import { getLatestPing, getUptime7d } from "../db"; 4 4 import { getDeviceStatus } from "../tailscale"; 5 5 6 - export async function handleStatus(env: Env): Promise<Response> { 6 + const JSON_HEADERS = { "Access-Control-Allow-Origin": "*" }; 7 + 8 + function worstStatus(statuses: string[]): string { 9 + if (statuses.includes("down")) return "down"; 10 + if (statuses.includes("degraded")) return "degraded"; 11 + if (statuses.includes("unknown")) return "unknown"; 12 + return "up"; 13 + } 14 + 15 + // GET /api/status or /api/status/overall 16 + async function overallStatus(env: Env): Promise<Response> { 7 17 const manifest = await getManifest(env); 18 + const allServices = Object.values(manifest).flatMap((m) => m.services); 19 + const monitored = allServices.filter((s) => s.health_url !== null); 8 20 9 - const machines = await Promise.all( 10 - Object.entries(manifest).map(async ([name, machine]) => { 11 - const online = await getDeviceStatus(env, machine.tailscale_host); 12 - const services = await Promise.all( 13 - machine.services.map(async (svc) => { 14 - const ping = await getLatestPing(env.DB, svc.name); 15 - const uptime = await getUptime7d(env.DB, svc.name); 16 - return { 17 - id: svc.name, 18 - name: svc.name, 19 - status: (ping?.status ?? "unknown") as 20 - | "up" 21 - | "degraded" 22 - | "down" 23 - | "unknown", 24 - latency_ms: ping?.latency_ms ?? null, 25 - uptime_7d: uptime, 26 - }; 27 - }), 28 - ); 21 + const statuses: string[] = []; 22 + let totalUptime = 0; 23 + 24 + for (const svc of monitored) { 25 + const ping = await getLatestPing(env.DB, svc.name); 26 + const uptime = await getUptime7d(env.DB, svc.name); 27 + statuses.push((ping?.status as string) ?? "unknown"); 28 + totalUptime += uptime; 29 + } 30 + 31 + const status = worstStatus(statuses); 32 + const avgUptime = 33 + monitored.length > 0 34 + ? Math.round((totalUptime / monitored.length) * 100) / 100 35 + : 100; 36 + 37 + return Response.json( 38 + { 39 + ok: status === "up", 40 + status, 41 + uptime_7d: avgUptime, 42 + services_total: allServices.length, 43 + services_monitored: monitored.length, 44 + machines_total: Object.keys(manifest).length, 45 + }, 46 + { headers: JSON_HEADERS }, 47 + ); 48 + } 49 + 50 + // GET /api/status/service/:id 51 + async function serviceStatus(env: Env, id: string): Promise<Response> { 52 + const ping = await getLatestPing(env.DB, id); 53 + const uptime = await getUptime7d(env.DB, id); 54 + 55 + if (!ping) { 56 + return Response.json({ error: "service not found" }, { status: 404, headers: JSON_HEADERS }); 57 + } 58 + 59 + return Response.json( 60 + { 61 + id, 62 + status: ping.status, 63 + latency_ms: ping.latency_ms, 64 + uptime_7d: uptime, 65 + }, 66 + { headers: JSON_HEADERS }, 67 + ); 68 + } 69 + 70 + // GET /api/status/machine/:name 71 + async function machineStatus(env: Env, name: string): Promise<Response> { 72 + const manifest = await getManifest(env); 73 + const machine = manifest[name]; 74 + 75 + if (!machine) { 76 + return Response.json({ error: "machine not found" }, { status: 404, headers: JSON_HEADERS }); 77 + } 78 + 79 + const online = await getDeviceStatus(env, machine.tailscale_host); 80 + const services = await Promise.all( 81 + machine.services.map(async (svc) => { 82 + const ping = await getLatestPing(env.DB, svc.name); 83 + const uptime = await getUptime7d(env.DB, svc.name); 29 84 return { 30 - name, 31 - hostname: machine.hostname, 32 - type: machine.type, 33 - online, 34 - services, 85 + id: svc.name, 86 + status: (ping?.status ?? "unknown") as string, 87 + latency_ms: ping?.latency_ms ?? null, 88 + uptime_7d: uptime, 35 89 }; 36 90 }), 37 91 ); 38 92 39 - const ok = machines 40 - .filter((m) => m.type === "server") 41 - .every( 42 - (m) => 43 - m.online && 44 - m.services.every((s) => s.status === "up" || s.status === "unknown"), 45 - ); 93 + const statuses = services.map((s) => s.status); 94 + const status = online ? worstStatus(statuses) : "down"; 95 + 96 + return Response.json( 97 + { 98 + name, 99 + hostname: machine.hostname, 100 + type: machine.type, 101 + online, 102 + status, 103 + services, 104 + }, 105 + { headers: JSON_HEADERS }, 106 + ); 107 + } 46 108 47 - return Response.json({ ok, machines } satisfies StatusResponse, { 48 - headers: { "Access-Control-Allow-Origin": "*" }, 49 - }); 109 + export async function handleStatusRoute( 110 + env: Env, 111 + path: string, 112 + ): Promise<Response | null> { 113 + if (path === "/api/status" || path === "/api/status/overall") { 114 + return overallStatus(env); 115 + } 116 + 117 + const serviceMatch = path.match(/^\/api\/status\/service\/(.+)$/); 118 + if (serviceMatch) { 119 + return serviceStatus(env, serviceMatch[1]); 120 + } 121 + 122 + const machineMatch = path.match(/^\/api\/status\/machine\/(.+)$/); 123 + if (machineMatch) { 124 + return machineStatus(env, machineMatch[1]); 125 + } 126 + 127 + return null; 50 128 }
-17
src/types.ts
··· 22 22 23 23 export type ServicesManifest = Record<string, Machine>; 24 24 25 - export interface StatusResponse { 26 - ok: boolean; 27 - machines: { 28 - name: string; 29 - hostname: string; 30 - type: string; 31 - online: boolean; 32 - services: { 33 - id: string; 34 - name: string; 35 - status: "up" | "degraded" | "down" | "unknown"; 36 - latency_ms: number | null; 37 - uptime_7d: number; 38 - }[]; 39 - }[]; 40 - } 41 - 42 25 export interface UptimeResponse { 43 26 service_id: string; 44 27 window_hours: number;