my own status page
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, "&").replace(/</g, "<").replace(/>/g, ">");
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}