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(label)}
- ${esc(value)}
- ${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;