diff --git a/README.md b/README.md index 760f726..5fcfa8b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,56 @@ # status -my own status page +super simple cf worker based uptime / status dashboard running [infra.dunkirk.sh](https://infra.dunkirk.sh). The canonical repo for this is hosted on tangled over at [`dunkirk.sh/status`](https://tangled.org/dunkirk.sh/status) +## API + +``` +/api/status # overall summary (ok, status, uptime, counts) +/api/status/overall # same as above +/api/status/service/:id # single service status + latency + uptime +/api/status/machine/:name # machine online status + all its services +/api/uptime/:service_id # hourly uptime buckets for a service +``` + +## Badges + +``` +/badge # overall infra status +/badge/overall # same as above +/badge/service/:id # single service +/badge/machine/:name # machine status +``` + +**Query params:** + +| Param | Description | Example | +| -------- | ----------------------------------- | ---------------------- | +| `style` | `flat` (default) or `for-the-badge` | `?style=for-the-badge` | +| `colorA` | Label background (hex) | `?colorA=363a4f` | +| `colorB` | Value background (hex) | `?colorB=b7bdf8` | +| `label` | Override label text | `?label=my+service` | + +## Setup + +```bash +bun install +wrangler d1 create status-db +wrangler kv namespace create KV +# update wrangler.toml with the IDs +bun run db:migrate:local +wrangler secret put TAILSCALE_API_KEY +bun run dev +``` + +## Deploy + +```bash +bun run deploy +bun run db:migrate +``` +

diff --git a/src/index.ts b/src/index.ts index 83adc6f..82af808 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,9 +3,9 @@ import { getManifest } from "./manifest"; import { checkHealth } from "./health"; import { insertPing, pruneOldPings } from "./db"; import { refreshDevices } from "./tailscale"; -import { handleStatus } from "./routes/status"; +import { handleStatusRoute } from "./routes/status"; import { handleUptime } from "./routes/uptime"; -import { handleBadge, handleOverallBadge } from "./routes/badge"; +import { handleBadgeRoute } from "./routes/badge"; import { handleIndex } from "./routes/index"; export default { @@ -17,8 +17,9 @@ export default { return handleIndex(env); } - if (path === "/api/status") { - return handleStatus(env); + if (path.startsWith("/api/status")) { + const res = await handleStatusRoute(env, path); + if (res) return res; } const uptimeMatch = path.match(/^\/api\/uptime\/(.+)$/); @@ -26,13 +27,9 @@ export default { return handleUptime(env, uptimeMatch[1], url); } - if (path === "/badge") { - return handleOverallBadge(env); - } - - const badgeMatch = path.match(/^\/badge\/(.+)$/); - if (badgeMatch) { - return handleBadge(env, badgeMatch[1]); + if (path.startsWith("/badge")) { + const badge = await handleBadgeRoute(env, path, url); + if (badge) return badge; } return new Response("Not Found", { status: 404 }); diff --git a/src/routes/badge.ts b/src/routes/badge.ts index 6240837..4d0202f 100644 --- a/src/routes/badge.ts +++ b/src/routes/badge.ts @@ -1,6 +1,7 @@ import type { Env } from "../types"; import { getManifest } from "../manifest"; import { getLatestPing, getUptime7d } from "../db"; +import { getDeviceStatus } from "../tailscale"; const COLORS: Record = { up: "#3cc068", @@ -38,11 +39,63 @@ function textWidth(s: string): number { return w; } -function makeBadge(label: string, status: string, uptime: number): string { - const color = COLORS[status] ?? COLORS.unknown; - const statusLabel = STATUS_LABELS[status] ?? "unknown"; - const value = `${statusLabel} ${uptime}%`; +function textColorForBg(hex: string): string { + const c = hex.replace("#", ""); + const r = parseInt(c.slice(0, 2), 16); + const g = parseInt(c.slice(2, 4), 16); + const b = parseInt(c.slice(4, 6), 16); + const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return lum > 0.5 ? "#333" : "#fff"; +} + +interface BadgeData { + label: string; + status: string; + value: string; +} +interface StyleOpts { + style: "flat" | "for-the-badge"; + colorA: string; + colorB: string; + label?: string; +} + +function parseStyleOpts(url: URL, status: string): StyleOpts { + const colorA = parseColor(url.searchParams.get("colorA")) ?? "#555"; + const colorB = + parseColor(url.searchParams.get("colorB")) ?? + COLORS[status] ?? + COLORS.unknown; + return { + style: + url.searchParams.get("style") === "for-the-badge" + ? "for-the-badge" + : "flat", + colorA, + colorB, + label: url.searchParams.get("label") ?? undefined, + }; +} + +function parseColor(s: string | null): string | undefined { + if (!s) return undefined; + return s.startsWith("#") ? s : `#${s}`; +} + +function renderBadge(data: BadgeData, opts: StyleOpts): string { + const label = opts.label ?? data.label; + return opts.style === "for-the-badge" + ? renderForTheBadge(label, data.value, opts.colorA, opts.colorB) + : renderFlat(label, data.value, opts.colorA, opts.colorB); +} + +function renderFlat( + label: string, + value: string, + colorA: string, + colorB: string, +): string { const pad = 20; const labelW = Math.round(textWidth(label) + pad); const valueW = Math.round(textWidth(value) + pad); @@ -50,74 +103,176 @@ function makeBadge(label: string, status: string, uptime: number): string { const labelX = labelW / 2; const valueX = labelW + valueW / 2; - return ` - ${label}: ${value} + return ` + ${esc(label)}: ${esc(value)} - - + + - - - ${esc(label)} - - ${esc(value)} + + ${esc(label)} + ${esc(value)} `; } +function renderForTheBadge( + label: string, + value: string, + colorA: string, + colorB: string, +): string { + const labelUp = label.toUpperCase(); + const valueUp = value.toUpperCase(); + const charW = 75; + const pad = 240; + const labelTL = labelUp.length * charW; + const valueTL = valueUp.length * charW; + const labelW = (labelTL + pad) / 10; + const valueW = (valueTL + pad) / 10; + const total = labelW + valueW; + const labelX = labelW * 5; + const valueX = (labelW + valueW / 2) * 10; + const h = 28; + + return `${esc(label)}: ${esc(value)}${esc(labelUp)}${esc(valueUp)}`; +} + function esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } +function worstStatus(statuses: string[]): string { + if (statuses.includes("down")) return "down"; + if (statuses.includes("degraded")) return "degraded"; + if (statuses.includes("unknown")) return "unknown"; + return "up"; +} + const BADGE_HEADERS = { "Content-Type": "image/svg+xml", "Cache-Control": "no-cache, no-store, must-revalidate", "Access-Control-Allow-Origin": "*", }; -export async function handleBadge( +function badgeResponse(data: BadgeData, url: URL): Response { + const opts = parseStyleOpts(url, data.status); + return new Response(renderBadge(data, opts), { headers: BADGE_HEADERS }); +} + +// GET /badge/service/:id +async function serviceBadge(env: Env, id: string, url: URL): Promise { + const ping = await getLatestPing(env.DB, id); + const uptime = await getUptime7d(env.DB, id); + const status = (ping?.status as string) ?? "unknown"; + const statusLabel = STATUS_LABELS[status] ?? "unknown"; + return badgeResponse( + { label: id, status, value: `${statusLabel} ${uptime}%` }, + url, + ); +} + +// GET /badge/machine/:name +async function machineBadge( env: Env, - serviceId: string, + name: string, + url: URL, ): Promise { - const ping = await getLatestPing(env.DB, serviceId); - const uptime = await getUptime7d(env.DB, serviceId); - const status = (ping?.status as string) ?? "unknown"; + const manifest = await getManifest(env); + const machine = manifest[name]; + if (!machine) { + return new Response("Machine not found", { status: 404 }); + } + + const online = await getDeviceStatus(env, machine.tailscale_host); + if (!online) { + return badgeResponse({ label: name, status: "down", value: "offline" }, url); + } + + const monitored = machine.services.filter((s) => s.health_url); + if (monitored.length === 0) { + return badgeResponse( + { label: name, status: online ? "up" : "down", value: online ? "online" : "offline" }, + url, + ); + } + + const statuses: string[] = []; + let totalUptime = 0; + for (const svc of monitored) { + const ping = await getLatestPing(env.DB, svc.name); + const uptime = await getUptime7d(env.DB, svc.name); + statuses.push((ping?.status as string) ?? "unknown"); + totalUptime += uptime; + } - return new Response(makeBadge(serviceId, status, uptime), { - headers: BADGE_HEADERS, - }); + const status = worstStatus(statuses); + const avgUptime = Math.round((totalUptime / monitored.length) * 100) / 100; + const statusLabel = STATUS_LABELS[status] ?? "unknown"; + return badgeResponse( + { label: name, status, value: `${statusLabel} ${avgUptime}%` }, + url, + ); } -export async function handleOverallBadge(env: Env): Promise { +// GET /badge/overall +async function overallBadge(env: Env, url: URL): Promise { const manifest = await getManifest(env); const allServices = Object.values(manifest).flatMap((m) => m.services); const monitored = allServices.filter((s) => s.health_url !== null); - let worst: string = "up"; + const statuses: string[] = []; let totalUptime = 0; for (const svc of monitored) { const ping = await getLatestPing(env.DB, svc.name); const uptime = await getUptime7d(env.DB, svc.name); + statuses.push((ping?.status as string) ?? "unknown"); totalUptime += uptime; - const s = (ping?.status as string) ?? "unknown"; - if (s === "down") worst = "down"; - else if (s === "degraded" && worst !== "down") worst = "degraded"; - else if (s === "unknown" && worst === "up") worst = "unknown"; } + const status = worstStatus(statuses); const avgUptime = monitored.length > 0 ? Math.round((totalUptime / monitored.length) * 100) / 100 : 100; + const statusLabel = STATUS_LABELS[status] ?? "unknown"; + return badgeResponse( + { label: "infra", status, value: `${statusLabel} ${avgUptime}%` }, + url, + ); +} + +export async function handleBadgeRoute( + env: Env, + path: string, + url: URL, +): Promise { + if (path === "/badge" || path === "/badge/overall") { + return overallBadge(env, url); + } + + const serviceMatch = path.match(/^\/badge\/service\/(.+)$/); + if (serviceMatch) { + return serviceBadge(env, serviceMatch[1], url); + } + + const machineMatch = path.match(/^\/badge\/machine\/(.+)$/); + if (machineMatch) { + return machineBadge(env, machineMatch[1], url); + } + + // Legacy: /badge/:id → treat as service + const legacyMatch = path.match(/^\/badge\/(.+)$/); + if (legacyMatch) { + return serviceBadge(env, legacyMatch[1], url); + } - return new Response(makeBadge("infra", worst, avgUptime), { - headers: BADGE_HEADERS, - }); + return null; } diff --git a/src/routes/status.ts b/src/routes/status.ts index e24eee3..ec724d3 100644 --- a/src/routes/status.ts +++ b/src/routes/status.ts @@ -1,50 +1,128 @@ -import type { Env, StatusResponse } from "../types"; +import type { Env } from "../types"; import { getManifest } from "../manifest"; import { getLatestPing, getUptime7d } from "../db"; import { getDeviceStatus } from "../tailscale"; -export async function handleStatus(env: Env): Promise { +const JSON_HEADERS = { "Access-Control-Allow-Origin": "*" }; + +function worstStatus(statuses: string[]): string { + if (statuses.includes("down")) return "down"; + if (statuses.includes("degraded")) return "degraded"; + if (statuses.includes("unknown")) return "unknown"; + return "up"; +} + +// GET /api/status or /api/status/overall +async function overallStatus(env: Env): Promise { + const manifest = await getManifest(env); + const allServices = Object.values(manifest).flatMap((m) => m.services); + const monitored = allServices.filter((s) => s.health_url !== null); + + const statuses: string[] = []; + let totalUptime = 0; + + for (const svc of monitored) { + const ping = await getLatestPing(env.DB, svc.name); + const uptime = await getUptime7d(env.DB, svc.name); + statuses.push((ping?.status as string) ?? "unknown"); + totalUptime += uptime; + } + + const status = worstStatus(statuses); + const avgUptime = + monitored.length > 0 + ? Math.round((totalUptime / monitored.length) * 100) / 100 + : 100; + + return Response.json( + { + ok: status === "up", + status, + uptime_7d: avgUptime, + services_total: allServices.length, + services_monitored: monitored.length, + machines_total: Object.keys(manifest).length, + }, + { headers: JSON_HEADERS }, + ); +} + +// GET /api/status/service/:id +async function serviceStatus(env: Env, id: string): Promise { + const ping = await getLatestPing(env.DB, id); + const uptime = await getUptime7d(env.DB, id); + + if (!ping) { + return Response.json({ error: "service not found" }, { status: 404, headers: JSON_HEADERS }); + } + + return Response.json( + { + id, + status: ping.status, + latency_ms: ping.latency_ms, + uptime_7d: uptime, + }, + { headers: JSON_HEADERS }, + ); +} + +// GET /api/status/machine/:name +async function machineStatus(env: Env, name: string): Promise { const manifest = await getManifest(env); + const machine = manifest[name]; + + if (!machine) { + return Response.json({ error: "machine not found" }, { status: 404, headers: JSON_HEADERS }); + } - const machines = await Promise.all( - Object.entries(manifest).map(async ([name, machine]) => { - const online = await getDeviceStatus(env, machine.tailscale_host); - const services = await Promise.all( - machine.services.map(async (svc) => { - const ping = await getLatestPing(env.DB, svc.name); - const uptime = await getUptime7d(env.DB, svc.name); - return { - id: svc.name, - name: svc.name, - status: (ping?.status ?? "unknown") as - | "up" - | "degraded" - | "down" - | "unknown", - latency_ms: ping?.latency_ms ?? null, - uptime_7d: uptime, - }; - }), - ); + const online = await getDeviceStatus(env, machine.tailscale_host); + const services = await Promise.all( + machine.services.map(async (svc) => { + const ping = await getLatestPing(env.DB, svc.name); + const uptime = await getUptime7d(env.DB, svc.name); return { - name, - hostname: machine.hostname, - type: machine.type, - online, - services, + id: svc.name, + status: (ping?.status ?? "unknown") as string, + latency_ms: ping?.latency_ms ?? null, + uptime_7d: uptime, }; }), ); - const ok = machines - .filter((m) => m.type === "server") - .every( - (m) => - m.online && - m.services.every((s) => s.status === "up" || s.status === "unknown"), - ); - - return Response.json({ ok, machines } satisfies StatusResponse, { - headers: { "Access-Control-Allow-Origin": "*" }, - }); + const statuses = services.map((s) => s.status); + const status = online ? worstStatus(statuses) : "down"; + + return Response.json( + { + name, + hostname: machine.hostname, + type: machine.type, + online, + status, + services, + }, + { headers: JSON_HEADERS }, + ); +} + +export async function handleStatusRoute( + env: Env, + path: string, +): Promise { + if (path === "/api/status" || path === "/api/status/overall") { + return overallStatus(env); + } + + const serviceMatch = path.match(/^\/api\/status\/service\/(.+)$/); + if (serviceMatch) { + return serviceStatus(env, serviceMatch[1]); + } + + const machineMatch = path.match(/^\/api\/status\/machine\/(.+)$/); + if (machineMatch) { + return machineStatus(env, machineMatch[1]); + } + + return null; } diff --git a/src/types.ts b/src/types.ts index 59d2b9c..6eacdc6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,23 +22,6 @@ export interface Machine { export type ServicesManifest = Record; -export interface StatusResponse { - ok: boolean; - machines: { - name: string; - hostname: string; - type: string; - online: boolean; - services: { - id: string; - name: string; - status: "up" | "degraded" | "down" | "unknown"; - latency_ms: number | null; - uptime_7d: number; - }[]; - }[]; -} - export interface UptimeResponse { service_id: string; window_hours: number;