my own status page
0
fork

Configure Feed

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

feat: add tailscale clients

+165 -45
+1
.env
··· 1 + TAILSCALE_API_KEY=tskey-api-kd7rfk8Po221CNTRL-pkN2FbZ24bYhbVYx3JuGaYW2cFeTUZjUA
+13 -6
src/index.ts
··· 2 2 import { getManifest } from "./manifest"; 3 3 import { checkHealth } from "./health"; 4 4 import { insertPing, pruneOldPings } from "./db"; 5 + import { refreshDevices } from "./tailscale"; 5 6 import { handleStatus } from "./routes/status"; 6 7 import { handleUptime } from "./routes/uptime"; 7 8 import { handleBadge, handleOverallBadge } from "./routes/badge"; ··· 38 39 }, 39 40 40 41 async scheduled(_controller: ScheduledController, env: Env): Promise<void> { 41 - const manifest = await getManifest(env); 42 + const [manifest] = await Promise.all([ 43 + getManifest(env), 44 + refreshDevices(env), 45 + ]); 42 46 43 - const checks = manifest.map(async (svc) => { 44 - if (!svc.health_url) return; 45 - const result = await checkHealth(svc); 46 - await insertPing(env.DB, svc.name, result.status, result.latency_ms); 47 - }); 47 + const checks = Object.values(manifest).flatMap((machine) => 48 + machine.services 49 + .filter((svc) => svc.health_url) 50 + .map(async (svc) => { 51 + const result = await checkHealth(svc); 52 + await insertPing(env.DB, svc.name, result.status, result.latency_ms); 53 + }), 54 + ); 48 55 49 56 await Promise.all(checks); 50 57 await pruneOldPings(env.DB, 90);
+2 -1
src/routes/badge.ts
··· 160 160 161 161 export async function handleOverallBadge(env: Env, url: URL): Promise<Response> { 162 162 const manifest = await getManifest(env); 163 - const monitored = manifest.filter((s) => s.health_url !== null); 163 + const allServices = Object.values(manifest).flatMap((m) => m.services); 164 + const monitored = allServices.filter((s) => s.health_url !== null); 164 165 165 166 let worst: string = "up"; 166 167 let totalUptime = 0;
+54 -17
src/routes/index.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 import { COMMIT_SHA } from "../version"; 5 6 6 7 export async function handleIndex(env: Env): Promise<Response> { 7 8 const manifest = await getManifest(env); 8 9 9 - const services = await Promise.all( 10 - manifest.map(async (svc) => { 11 - const ping = await getLatestPing(env.DB, svc.name); 12 - const uptime = await getUptime7d(env.DB, svc.name); 13 - return { 14 - name: svc.name, 15 - description: svc.description, 16 - url: `https://${svc.domain}`, 17 - status: ping?.status ?? "unknown", 18 - latency_ms: ping?.latency_ms ?? null, 19 - uptime_7d: uptime, 20 - has_health: svc.health_url !== null, 21 - }; 10 + const machines = await Promise.all( 11 + Object.entries(manifest).map(async ([name, machine]) => { 12 + const online = await getDeviceStatus(env, machine.tailscale_host); 13 + const services = await Promise.all( 14 + machine.services.map(async (svc) => { 15 + const ping = await getLatestPing(env.DB, svc.name); 16 + const uptime = await getUptime7d(env.DB, svc.name); 17 + return { 18 + name: svc.name, 19 + description: svc.description, 20 + url: `https://${svc.domain}`, 21 + status: ping?.status ?? "unknown", 22 + latency_ms: ping?.latency_ms ?? null, 23 + uptime_7d: uptime, 24 + has_health: svc.health_url !== null, 25 + }; 26 + }), 27 + ); 28 + return { name, type: machine.type, online, services }; 22 29 }), 23 30 ); 24 31 25 - const allUp = services.every( 26 - (s) => s.status === "up" || s.status === "unknown", 27 - ); 32 + const servers = machines.filter((m) => m.type === "server"); 33 + const clients = machines.filter((m) => m.type === "client"); 34 + 35 + const allUp = machines 36 + .filter((m) => m.type === "server") 37 + .every( 38 + (m) => 39 + m.online && 40 + m.services.every((s) => s.status === "up" || s.status === "unknown"), 41 + ); 28 42 29 43 const html = `<!DOCTYPE html> 30 44 <html lang="en"> ··· 43 57 .dot.degraded { background: #f39c12; } 44 58 .dot.down { background: #e74c3c; } 45 59 .dot.unknown { background: #8b949e; } 60 + .dot.online { background: #2ecc71; } 61 + .dot.offline { background: #e74c3c; } 62 + .machine { margin-bottom: 1.5rem; } 63 + .machine-header { display: flex; align-items: center; gap: 0.25rem; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; color: #8b949e; margin-bottom: 0.5rem; } 64 + .machine-type { font-size: 0.6rem; background: #21262d; padding: 0.1rem 0.4rem; border-radius: 3px; margin-left: 0.4rem; } 46 65 .service { display: flex; align-items: center; justify-content: space-between; padding: 0.5rem 0; border-bottom: 1px solid #21262d; } 47 66 .service:last-child { border-bottom: none; } 48 67 .svc-left { display: flex; align-items: center; gap: 0.25rem; } ··· 52 71 .svc-right { font-size: 0.75rem; color: #8b949e; display: flex; gap: 0; flex-shrink: 0; } 53 72 .uptime { width: 3.5rem; text-align: right; } 54 73 .latency { width: 3rem; text-align: right; } 74 + .no-services { font-size: 0.8rem; color: #8b949e; padding: 0.25rem 0; } 75 + .clients { margin-bottom: 1.5rem; } 76 + .clients-header { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; color: #8b949e; margin-bottom: 0.5rem; } 77 + .clients-list { display: flex; flex-wrap: wrap; gap: 0.5rem; } 78 + .client { display: flex; align-items: center; gap: 0.25rem; font-size: 0.8rem; color: #8b949e; } 55 79 footer { margin-top: auto; padding-top: 1rem; border-top: 1px solid #21262d; font-size: 0.7rem; color: #8b949e; display: flex; justify-content: space-between; } 56 80 footer a { color: #8b949e; text-decoration: none; } 57 81 footer a:hover { text-decoration: underline; } ··· 60 84 <body> 61 85 <h1>infra.dunkirk.sh</h1> 62 86 <p class="overall"><span class="dot ${allUp ? "up" : "degraded"}"></span>${allUp ? "All systems operational" : "Some systems degraded"}</p> 63 - ${services 87 + ${servers 88 + .map( 89 + (m) => `<div class="machine"> 90 + <div class="machine-header"><span class="dot ${m.online ? "online" : "offline"}"></span>${esc(m.name)}<span class="machine-type">${esc(m.type)}</span></div> 91 + ${m.services.length === 0 ? `<div class="no-services">no services</div>` : m.services 64 92 .map( 65 93 (s) => `<div class="service"> 66 94 <div class="svc-left"> ··· 73 101 </div>`, 74 102 ) 75 103 .join("\n")} 104 + </div>`, 105 + ) 106 + .join("\n")} 107 + ${clients.length > 0 ? `<div class="clients"> 108 + <div class="clients-header">devices</div> 109 + <div class="clients-list"> 110 + ${clients.map((m) => `<span class="client"><span class="dot ${m.online ? "online" : "offline"}"></span>${esc(m.name)}</span>`).join("\n")} 111 + </div> 112 + </div>` : ""} 76 113 <footer><span>checked every 5 min</span><a href="https://github.com/taciturnaxolotl/status/commit/${COMMIT_SHA}">${COMMIT_SHA}</a></footer> 77 114 </body> 78 115 </html>`;
+34 -15
src/routes/status.ts
··· 1 1 import type { Env, StatusResponse } from "../types"; 2 2 import { getManifest } from "../manifest"; 3 3 import { getLatestPing, getUptime7d } from "../db"; 4 + import { getDeviceStatus } from "../tailscale"; 4 5 5 6 export async function handleStatus(env: Env): Promise<Response> { 6 7 const manifest = await getManifest(env); 7 8 8 - const services = await Promise.all( 9 - manifest.map(async (svc) => { 10 - const ping = await getLatestPing(env.DB, svc.name); 11 - const uptime = await getUptime7d(env.DB, svc.name); 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 + ); 12 29 return { 13 - id: svc.name, 14 - name: svc.name, 15 - status: (ping?.status ?? "unknown") as 16 - | "up" 17 - | "degraded" 18 - | "down" 19 - | "unknown", 20 - latency_ms: ping?.latency_ms ?? null, 21 - uptime_7d: uptime, 30 + name, 31 + hostname: machine.hostname, 32 + type: machine.type, 33 + online, 34 + services, 22 35 }; 23 36 }), 24 37 ); 25 38 26 - const ok = services.every((s) => s.status === "up" || s.status === "unknown"); 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 + ); 27 46 28 - return Response.json({ ok, services } satisfies StatusResponse, { 47 + return Response.json({ ok, machines } satisfies StatusResponse, { 29 48 headers: { "Access-Control-Allow-Origin": "*" }, 30 49 }); 31 50 }
+41
src/tailscale.ts
··· 1 + import type { Env } from "./types"; 2 + 3 + interface TailscaleDevice { 4 + hostname: string; 5 + connectedToControl: boolean; 6 + lastSeen: string; 7 + os: string; 8 + } 9 + 10 + const KV_KEY = "tailscale_devices"; 11 + 12 + export async function refreshDevices(env: Env): Promise<void> { 13 + if (!env.TAILSCALE_API_KEY) return; 14 + 15 + const res = await fetch( 16 + "https://api.tailscale.com/api/v2/tailnet/-/devices?fields=default", 17 + { 18 + headers: { 19 + Authorization: `Bearer ${env.TAILSCALE_API_KEY}`, 20 + }, 21 + }, 22 + ); 23 + 24 + if (!res.ok) return; 25 + 26 + const data: { devices: TailscaleDevice[] } = await res.json(); 27 + await env.KV.put(KV_KEY, JSON.stringify(data.devices)); 28 + } 29 + 30 + export async function getDeviceStatus( 31 + env: Env, 32 + hostname: string, 33 + ): Promise<boolean> { 34 + const cached = await env.KV.get(KV_KEY, "json"); 35 + if (!cached) return false; 36 + const devices = cached as TailscaleDevice[]; 37 + const device = devices.find( 38 + (d) => d.hostname.toLowerCase() === hostname.toLowerCase(), 39 + ); 40 + return device?.connectedToControl ?? false; 41 + }
+20 -6
src/types.ts
··· 13 13 }; 14 14 } 15 15 16 - export type ServicesManifest = Service[]; 16 + export interface Machine { 17 + hostname: string; 18 + tailscale_host: string; 19 + type: "server" | "client"; 20 + services: Service[]; 21 + } 22 + 23 + export type ServicesManifest = Record<string, Machine>; 17 24 18 25 export interface StatusResponse { 19 26 ok: boolean; 20 - services: { 21 - id: string; 27 + machines: { 22 28 name: string; 23 - status: "up" | "degraded" | "down" | "unknown"; 24 - latency_ms: number | null; 25 - uptime_7d: number; 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 + }[]; 26 39 }[]; 27 40 } 28 41 ··· 38 51 export interface Env { 39 52 DB: D1Database; 40 53 KV: KVNamespace; 54 + TAILSCALE_API_KEY?: string; 41 55 }