diff --git a/migrations/0002_add_statuses.sql b/migrations/0002_add_statuses.sql index a440223..7889c2e 100644 --- a/migrations/0002_add_statuses.sql +++ b/migrations/0002_add_statuses.sql @@ -4,7 +4,7 @@ CREATE TABLE pings_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, service_id TEXT NOT NULL, timestamp INTEGER NOT NULL, - status TEXT NOT NULL CHECK (status IN ('up', 'degraded', 'misconfigured', 'down', 'unknown')), + status TEXT NOT NULL CHECK (status IN ('up', 'degraded', 'misconfigured', 'timeout', 'down', 'unknown')), latency_ms INTEGER ); diff --git a/src/health.ts b/src/health.ts index 520ebc7..e8db65c 100644 --- a/src/health.ts +++ b/src/health.ts @@ -2,7 +2,7 @@ import type { Service } from "./types"; const SLOW_THRESHOLD_MS = 3000; -export type Status = "up" | "degraded" | "misconfigured" | "down" | "unknown"; +export type Status = "up" | "degraded" | "misconfigured" | "timeout" | "down" | "unknown"; interface HealthResult { status: Status; @@ -36,7 +36,9 @@ export async function checkHealth(service: Service): Promise { return { status: "up", latency_ms }; } return { status: "down", latency_ms }; - } catch { - return { status: "down", latency_ms: Date.now() - start }; + } catch (err) { + const latency_ms = Date.now() - start; + const isTimeout = err instanceof DOMException && err.name === "TimeoutError"; + return { status: isTimeout ? "timeout" : "down", latency_ms }; } } diff --git a/src/index.ts b/src/index.ts index 574dc8c..42d8d4c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { checkHealth } from "./health"; import { insertPing, pruneOldPings } from "./db"; import { refreshDevices } from "./tailscale"; import { handleStatusRoute } from "./routes/status"; +import { handleFavicon } from "./routes/favicon"; import { handleUptime } from "./routes/uptime"; import { handleBadgeRoute } from "./routes/badge"; import { handleIndex } from "./routes/index"; @@ -17,6 +18,10 @@ export default { return handleIndex(env); } + if (path === "/favicon.svg") { + return handleFavicon(env); + } + if (path === "/health") { return Response.json({ ok: true, timestamp: new Date().toISOString() }); } diff --git a/src/routes/badge.ts b/src/routes/badge.ts index 926f6ec..c88a9d8 100644 --- a/src/routes/badge.ts +++ b/src/routes/badge.ts @@ -7,6 +7,7 @@ const COLORS: Record = { up: "#3cc068", degraded: "#f0ad4e", misconfigured: "#9b59b6", + timeout: "#e05d44", partial: "#f0ad4e", down: "#e05d44", unknown: "#9f9f9f", @@ -16,6 +17,7 @@ const STATUS_LABELS: Record = { up: "operational", degraded: "degraded", misconfigured: "misconfigured", + timeout: "timeout", partial: "partial", down: "down", unknown: "unknown", @@ -154,8 +156,8 @@ function esc(s: string): string { function worstStatus(statuses: string[]): string { if (statuses.length === 0) return "unknown"; - if (statuses.every((s) => s === "down")) return "down"; - if (statuses.includes("down")) return "partial"; + if (statuses.every((s) => s === "down" || s === "timeout")) return "down"; + if (statuses.includes("down") || statuses.includes("timeout")) return "partial"; if (statuses.includes("misconfigured")) return "misconfigured"; if (statuses.includes("degraded")) return "degraded"; if (statuses.includes("unknown")) return "unknown"; diff --git a/src/routes/favicon.ts b/src/routes/favicon.ts new file mode 100644 index 0000000..389f19f --- /dev/null +++ b/src/routes/favicon.ts @@ -0,0 +1,44 @@ +import type { Env } from "../types"; +import { getManifest } from "../manifest"; +import { getLatestPing } from "../db"; + +const COLORS: Record = { + up: "#2ecc71", + degraded: "#f39c12", + down: "#e74c3c", +}; + +export async function handleFavicon(env: Env): Promise { + const manifest = await getManifest(env); + const activeServers = Object.values(manifest).filter( + (m) => m.type === "server" && m.services.length > 0, + ); + const statuses: string[] = []; + + for (const machine of activeServers) { + for (const svc of machine.services.filter((s) => s.health_url)) { + const ping = await getLatestPing(env.DB, svc.name); + statuses.push((ping?.status as string) ?? "unknown"); + } + } + + const downCount = statuses.filter((s) => s === "down" || s === "timeout").length; + const downRatio = statuses.length > 0 ? downCount / statuses.length : 0; + const hasIssues = statuses.some( + (s) => s === "down" || s === "timeout" || s === "degraded" || s === "misconfigured", + ); + + let color: string; + if (downRatio >= 0.4) color = COLORS.down; + else if (hasIssues) color = COLORS.degraded; + else color = COLORS.up; + + const svg = ``; + + return new Response(svg, { + headers: { + "Content-Type": "image/svg+xml", + "Cache-Control": "no-cache, no-store, must-revalidate", + }, + }); +} diff --git a/src/routes/index.ts b/src/routes/index.ts index eb7823d..a53e8f3 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -40,11 +40,12 @@ export async function handleIndex(env: Env): Promise { const activeServers = servers.filter((m) => m.services.length > 0); const anyServerOffline = activeServers.some((m) => !m.online); const svcStatuses = activeServers.flatMap((m) => m.services.map((s) => s.status)); - const downCount = svcStatuses.filter((s) => s === "down").length; + const downCount = svcStatuses.filter((s) => s === "down" || s === "timeout").length; const downRatio = svcStatuses.length > 0 ? downCount / svcStatuses.length : 0; const onFire = anyServerOffline || downRatio >= 0.4; const hasDegraded = svcStatuses.includes("down") || + svcStatuses.includes("timeout") || svcStatuses.includes("degraded") || svcStatuses.includes("misconfigured") || svcStatuses.includes("partial"); @@ -61,6 +62,7 @@ export async function handleIndex(env: Env): Promise { infra.dunkirk.sh +