Payload preview
{`{
"event": "down",
diff --git a/src/client/pages/StatusPage.tsx b/src/client/pages/StatusPage.tsx
new file mode 100644
index 0000000..399bca0
--- /dev/null
+++ b/src/client/pages/StatusPage.tsx
@@ -0,0 +1,159 @@
+import { useEffect, useState } from "react";
+import { Activity, CircleCheck, Database, RefreshCw, TriangleAlert, Zap } from "lucide-react";
+import type { PublicOverallStatus, PublicServiceStatus } from "../api/status";
+import { SiteIcon } from "../components/SiteIcon";
+import { StatusHistoryBar } from "../components/StatusHistoryBar";
+import { navigate } from "../lib/router";
+import { useSessionQuery } from "../queries/auth";
+import { useStatusQuery } from "../queries/status";
+
+const OVERALL_COPY: Record = {
+ operational: {
+ title: "All systems operational",
+ detail: "Every monitored service is responding normally.",
+ },
+ degraded: {
+ title: "Some systems are degraded",
+ detail: "One or more services are currently experiencing disruption.",
+ },
+ down: {
+ title: "Major service disruption",
+ detail: "All reporting services are currently unavailable.",
+ },
+};
+
+const SERVICE_STATUS: Record = {
+ up: { label: "Operational", className: "online" },
+ down: { label: "Down", className: "offline" },
+ unknown: { label: "Awaiting data", className: "checking" },
+};
+
+function errorMessage(error: unknown) {
+ return error instanceof Error ? error.message : "Unknown request error";
+}
+
+function relativeUpdate(updatedAt: number, now: number) {
+ const seconds = Math.max(0, Math.floor((now - updatedAt) / 1_000));
+ if (seconds < 5) return "Updated just now";
+ if (seconds < 60) return `Updated ${seconds}s ago`;
+ const minutes = Math.floor(seconds / 60);
+ return `Updated ${minutes}m ago`;
+}
+
+function OverallIcon({ status }: { status: PublicOverallStatus }) {
+ if (status === "operational") return ;
+ if (status === "degraded") return ;
+ return ;
+}
+
+export function StatusPage() {
+ const statusQuery = useStatusQuery();
+ const sessionQuery = useSessionQuery();
+ const [now, setNow] = useState(Date.now);
+ const status = statusQuery.data;
+
+ useEffect(() => {
+ const timer = window.setInterval(() => setNow(Date.now()), 5_000);
+ return () => window.clearInterval(timer);
+ }, []);
+
+ return (
+
+
+
+
+
+ upwatch
+
+
+
+
+
+
+
+ System status
+ Service availability
+ Live operational health and 90-day availability for every public service.
+
+
+ {statusQuery.isPending ? (
+
+
+
{[0, 1, 2].map((item) =>
)}
+
+ ) : statusQuery.isError || !status ? (
+
+
+ Status could not be loaded
+ {errorMessage(statusQuery.error)}
+
+
+ ) : (
+ <>
+
+
+
+
{OVERALL_COPY[status.overall].title}
+
{OVERALL_COPY[status.overall].detail}
+
+
+
+
+
+
+
+
Services
+
Availability is calculated from checks collected over the last 90 days.
+
+
+
+
+ {status.services.length === 0 ? (
+
+
+
No public services yet
+
Service health will appear here after monitoring is enabled.
+
+ ) : (
+
+ {status.services.map((service) => {
+ const serviceStatus = SERVICE_STATUS[service.status];
+ return (
+
+
+
+
+ {service.name}
+ {serviceStatus.label}
+
+
+
+ 90-day uptime
+ {service.uptime90d === null ? "—" : `${service.uptime90d.toFixed(1)}%`}
+
+
+
+ );
+ })}
+
+ )}
+
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/src/client/queries/status.ts b/src/client/queries/status.ts
new file mode 100644
index 0000000..8bc96c6
--- /dev/null
+++ b/src/client/queries/status.ts
@@ -0,0 +1,15 @@
+import { useQuery } from "@tanstack/react-query";
+import { getStatus } from "../api/status";
+
+export const statusKeys = {
+ all: ["public-status"] as const,
+};
+
+export function useStatusQuery() {
+ return useQuery({
+ queryKey: statusKeys.all,
+ queryFn: ({ signal }) => getStatus(signal),
+ refetchInterval: 60_000,
+ refetchIntervalInBackground: false,
+ });
+}
diff --git a/src/client/styles.css b/src/client/styles.css
index 7259d69..652185f 100644
--- a/src/client/styles.css
+++ b/src/client/styles.css
@@ -271,6 +271,83 @@ button { color: inherit; }
.payload-preview .overline { color: #83dcb4; }
.payload-preview pre { margin: 16px 0 0; overflow-x: auto; font: 400 12px/1.7 "IBM Plex Mono", monospace; color: #d7d7d7; }
+.status-page-shell {
+ min-height: 100dvh;
+ background:
+ radial-gradient(circle at 12% 22%, rgb(62 207 142 / 0.07), transparent 28rem),
+ linear-gradient(180deg, #fff 0, #fff 36%, #fafcfb 100%);
+}
+.status-header { position: relative; }
+.status-header-inner { width: min(960px, calc(100% - 48px)); }
+.status-header-action {
+ min-height: 34px; padding: 6px 13px; border: 1px solid #d5d5d5; border-radius: 6px;
+ font-size: 12px; font-weight: 500; color: #444; background: rgb(255 255 255 / 0.86); cursor: pointer;
+ transition: border-color 160ms ease, color 160ms ease, transform 160ms ease;
+}
+.status-header-action:hover { border-color: #9bcdb7; color: #16885b; transform: translateY(-1px); }
+.status-main { width: min(960px, calc(100% - 48px)); margin: 0 auto; padding: 72px 0 56px; }
+.status-intro { max-width: 620px; }
+.status-intro h1 { margin: 0; font-size: clamp(38px, 6vw, 56px); font-weight: 500; line-height: 1.02; letter-spacing: -2.5px; }
+.status-intro > p:last-child { max-width: 560px; margin: 18px 0 0; font-size: 16px; line-height: 1.6; color: var(--muted); }
+.status-banner {
+ display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 18px; min-height: 116px;
+ margin-top: 42px; padding: 24px 26px; border: 1px solid #b9e6d2; border-radius: 10px;
+ color: #125c41; background: linear-gradient(110deg, #edfaf4, #f8fdfb); box-shadow: 0 12px 32px rgb(24 110 76 / 0.06);
+ animation: enter 450ms cubic-bezier(0.16, 1, 0.3, 1) both;
+}
+.status-banner.degraded { border-color: #e7d796; color: #705d0f; background: linear-gradient(110deg, #fff9e8, #fffdf6); box-shadow: 0 12px 32px rgb(130 105 14 / 0.06); }
+.status-banner.down { border-color: #ebc1c1; color: #8e3030; background: linear-gradient(110deg, #fff2f2, #fffafa); box-shadow: 0 12px 32px rgb(130 34 34 / 0.06); }
+.status-banner-icon { display: grid; width: 46px; height: 46px; place-items: center; border: 1px solid rgb(36 180 126 / 0.22); border-radius: 50%; background: rgb(255 255 255 / 0.72); }
+.status-banner-icon svg { width: 21px; height: 21px; }
+.status-banner.degraded .status-banner-icon { border-color: rgb(178 145 24 / 0.25); }
+.status-banner.down .status-banner-icon { border-color: rgb(174 61 61 / 0.22); }
+.status-banner h2 { margin: 0; font-size: 18px; font-weight: 600; letter-spacing: -0.3px; }
+.status-banner p { margin: 6px 0 0; font-size: 13px; color: currentColor; opacity: 0.75; }
+.status-banner time { justify-self: end; font: 400 10px/1.4 "IBM Plex Mono", monospace; opacity: 0.66; white-space: nowrap; }
+.public-services-panel { margin-top: 18px; overflow: hidden; border: 1px solid #dedede; border-radius: 10px; background: #fff; box-shadow: 0 14px 40px rgb(28 65 49 / 0.04); animation: enter 500ms 60ms cubic-bezier(0.16, 1, 0.3, 1) both; }
+.public-services-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; min-height: 88px; padding: 18px 22px; border-bottom: 1px solid #e8e8e8; }
+.public-services-heading h2 { margin: 0; font-size: 18px; font-weight: 500; }
+.public-services-heading p { margin: 5px 0 0; font-size: 12px; color: var(--muted); }
+.public-service-row { display: grid; grid-template-columns: minmax(190px, 0.7fr) 120px minmax(320px, 1.2fr); align-items: center; gap: 24px; min-height: 128px; padding: 22px; }
+.public-service-row + .public-service-row { border-top: 1px solid #ededed; }
+.public-service-row:hover { background: #fdfefd; }
+.public-service-summary { display: flex; align-items: center; gap: 14px; min-width: 0; }
+.public-service-summary > div { min-width: 0; }
+.public-service-summary strong { display: block; overflow: hidden; font-size: 15px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
+.public-service-summary .row-status { margin-top: 7px; }
+.public-service-uptime { text-align: right; }
+.public-service-uptime span { display: block; font-size: 10px; color: var(--faint); }
+.public-service-uptime strong { display: block; margin-top: 5px; font: 500 17px/1.2 "IBM Plex Mono", monospace; letter-spacing: -0.5px; }
+.status-history { min-width: 0; }
+.uptime-days { display: flex; align-items: stretch; width: 100%; height: 32px; gap: 2px; }
+.uptime-day { flex: 1 1 0; min-width: 1px; border-radius: 2px; background: #e6e8e7; transition: filter 140ms ease, transform 140ms ease; }
+.uptime-day:hover { z-index: 1; filter: saturate(1.15) brightness(0.92); transform: scaleY(1.12); }
+.uptime-day.is-up { background: #3ecf8e; }
+.uptime-day.is-partial { background: #e1bc45; }
+.uptime-day.is-down { background: #dc6262; }
+.uptime-day.is-empty { background: #e8ebe9; }
+.uptime-days-caption { display: flex; align-items: center; gap: 9px; margin-top: 8px; font: 400 9px/1.3 "IBM Plex Mono", monospace; color: #a0a0a0; }
+.uptime-days-caption i { flex: 1; height: 1px; background: #ededed; }
+.status-panel-state { display: grid; justify-items: center; min-height: 280px; place-content: center; padding: 48px 24px; text-align: center; }
+.status-panel-state > span { display: grid; width: 44px; height: 44px; margin-bottom: 16px; place-items: center; border: 1px solid #dcdcdc; border-radius: 50%; color: #666; background: #fafafa; }
+.status-panel-state svg { width: 19px; height: 19px; }
+.status-panel-state strong { font-size: 15px; font-weight: 500; }
+.status-panel-state p { max-width: 400px; margin: 8px 0 18px; font-size: 13px; line-height: 1.5; color: var(--muted); }
+.status-error-state { margin-top: 42px; border: 1px solid #ebd1d1; border-radius: 10px; background: #fffafa; }
+.status-error-state > span { border-color: #ebcaca; color: #a34242; background: #fff3f3; }
+.status-loading { margin-top: 42px; }
+.status-banner-skeleton { height: 116px; border-radius: 10px; background: linear-gradient(90deg, #eef2f0 20%, #f8faf9 50%, #eef2f0 80%); background-size: 220% 100%; animation: skeleton 1.4s ease-in-out infinite; }
+.status-service-skeleton { margin-top: 18px; overflow: hidden; border: 1px solid #e4e4e4; border-radius: 10px; background: #fff; }
+.status-service-skeleton > div { display: grid; grid-template-columns: 42px 1fr minmax(240px, 0.8fr); align-items: center; gap: 16px; min-height: 116px; padding: 22px; }
+.status-service-skeleton > div + div { border-top: 1px solid #ededed; }
+.status-service-skeleton i, .status-service-skeleton span, .status-service-skeleton b { display: block; border-radius: 5px; background: linear-gradient(90deg, #f0f2f1 20%, #fafafa 50%, #f0f2f1 80%); background-size: 220% 100%; animation: skeleton 1.4s ease-in-out infinite; }
+.status-service-skeleton i { width: 42px; height: 42px; }
+.status-service-skeleton span { width: min(220px, 75%); height: 34px; }
+.status-service-skeleton b { height: 32px; }
+.status-footer { display: flex; justify-content: space-between; width: min(960px, calc(100% - 48px)); margin: 0 auto; padding: 28px 0 36px; border-top: 1px solid #e7ebe9; font-size: 11px; color: #939393; }
+.status-footer span:last-child { display: inline-flex; align-items: center; gap: 7px; }
+.status-footer i { width: 6px; height: 6px; border-radius: 50%; background: var(--primary-deep); box-shadow: 0 0 0 3px rgb(62 207 142 / 0.12); }
+
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes blink { 50% { opacity: 0.35; } }
@keyframes loading-pulse { 50% { opacity: 0.4; transform: scale(0.94); } }
@@ -291,6 +368,7 @@ button { color: inherit; }
@media (max-width: 760px) {
.dashboard-header-inner, .dashboard-main, .dashboard-page-footer { width: min(100% - 32px, 1280px); }
+ .status-header-inner, .status-main, .status-footer { width: min(100% - 32px, 960px); }
.header-context { display: none; }
.dashboard-main { padding: 40px 0 56px; }
.dashboard-intro { align-items: flex-start; flex-direction: column; }
@@ -314,6 +392,11 @@ button { color: inherit; }
.sla-grid { grid-template-columns: repeat(2, 1fr); }
.sla-card.incident-summary { grid-column: 1 / -1; }
.settings-main { width: min(100% - 32px, 760px); }
+ .status-main { padding: 52px 0 44px; }
+ .status-banner { grid-template-columns: auto 1fr; }
+ .status-banner time { grid-column: 2; justify-self: start; }
+ .public-service-row { grid-template-columns: 1fr auto; gap: 20px; }
+ .status-history { grid-column: 1 / -1; }
}
@media (max-width: 520px) {
@@ -336,6 +419,17 @@ button { color: inherit; }
.settings-heading h1 { font-size: 34px; }
.settings-card-intro, .settings-form { padding: 20px; }
.settings-actions { align-items: stretch; flex-direction: column-reverse; }
+ .status-intro h1 { letter-spacing: -1.8px; }
+ .status-intro > p:last-child { font-size: 14px; }
+ .status-banner { grid-template-columns: 1fr; gap: 12px; padding: 22px 20px; }
+ .status-banner-icon { width: 40px; height: 40px; }
+ .status-banner time { grid-column: auto; }
+ .public-services-heading { padding: 17px 16px; }
+ .public-services-heading p { max-width: 250px; }
+ .public-service-row { padding: 20px 16px; }
+ .public-service-uptime strong { font-size: 15px; }
+ .uptime-days { height: 28px; gap: 1px; }
+ .status-footer { align-items: flex-start; flex-direction: column; gap: 8px; }
}
@media (prefers-reduced-motion: reduce) {
diff --git a/src/worker/index.ts b/src/worker/index.ts
index b2789c4..15307d1 100644
--- a/src/worker/index.ts
+++ b/src/worker/index.ts
@@ -4,6 +4,7 @@ import { runDueChecks } from "./checks/run-due-checks";
import authRoutes from "./routes/auth";
import monitorRoutes from "./routes/monitors";
import settingsRoutes from "./routes/settings";
+import statusRoutes from "./routes/status";
import { cleanupExpiredAuthRecords } from "./scheduled/cleanup";
import { runDailyRollup } from "./scheduled/rollup";
@@ -26,6 +27,7 @@ app.get("/api/health", async (context) => {
app.route("/", authRoutes);
app.route("/api/monitors", monitorRoutes);
app.route("/api/settings", settingsRoutes);
+app.route("/api/status", statusRoutes);
export default {
fetch: app.fetch,
diff --git a/src/worker/routes/status.ts b/src/worker/routes/status.ts
new file mode 100644
index 0000000..7cb79da
--- /dev/null
+++ b/src/worker/routes/status.ts
@@ -0,0 +1,185 @@
+import { and, eq, gte, inArray, lt, sql } from "drizzle-orm";
+import { Hono } from "hono";
+import { getDb } from "../db/client";
+import { checks, monitorDailyStats, monitors } from "../db/schema";
+import { resolveFavicon } from "./monitors";
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+const FAVICON_CACHE_SECONDS = 86_400;
+
+type ServiceStatus = "up" | "down" | "unknown";
+type OverallStatus = "operational" | "degraded" | "down";
+
+type HistoryEntry = {
+ day: number;
+ uptimePct: number | null;
+};
+
+type DailyAggregate = {
+ monitorId: number;
+ day: Date;
+ totalChecks: number;
+ upChecks: number;
+};
+
+type EdgeCache = {
+ match(request: RequestInfo | URL): Promise;
+ put(request: RequestInfo | URL, response: Response): Promise;
+};
+
+function parseId(rawId: string) {
+ const id = Number(rawId);
+ return Number.isSafeInteger(id) && id > 0 ? id : null;
+}
+
+function roundUptime(upChecks: number, totalChecks: number) {
+ return totalChecks > 0 ? Math.round((upChecks / totalChecks) * 1_000) / 10 : null;
+}
+
+function serviceStatus(lastOk: boolean | null): ServiceStatus {
+ if (lastOk === true) return "up";
+ if (lastOk === false) return "down";
+ return "unknown";
+}
+
+function overallStatus(statuses: ServiceStatus[]): OverallStatus {
+ let checked = 0;
+ let down = 0;
+ for (const status of statuses) {
+ if (status === "unknown") continue;
+ checked += 1;
+ if (status === "down") down += 1;
+ }
+ if (down === 0) return "operational";
+ if (down === checked) return "down";
+ return "degraded";
+}
+
+const statusRoutes = new Hono<{ Bindings: Env }>();
+
+statusRoutes.get("/", async (context) => {
+ const db = getDb(context.env);
+ const monitorRows = await db
+ .select({
+ id: monitors.id,
+ name: monitors.name,
+ lastOk: monitors.lastOk,
+ lastCheckedAt: monitors.lastCheckedAt,
+ })
+ .from(monitors)
+ .where(eq(monitors.enabled, true))
+ .orderBy(monitors.createdAt);
+
+ const now = new Date();
+ const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
+ const cutoff = today - 89 * DAY_MS;
+ const monitorIds = monitorRows.map((monitor) => monitor.id);
+ let historicalRows: DailyAggregate[] = [];
+ let todayRows: DailyAggregate[] = [];
+
+ if (monitorIds.length > 0) {
+ [historicalRows, todayRows] = await Promise.all([
+ db.select({
+ monitorId: monitorDailyStats.monitorId,
+ day: monitorDailyStats.day,
+ totalChecks: monitorDailyStats.totalChecks,
+ upChecks: monitorDailyStats.upChecks,
+ })
+ .from(monitorDailyStats)
+ .where(and(
+ inArray(monitorDailyStats.monitorId, monitorIds),
+ gte(monitorDailyStats.day, new Date(cutoff)),
+ lt(monitorDailyStats.day, new Date(today)),
+ ))
+ .orderBy(monitorDailyStats.day),
+ db.select({
+ monitorId: checks.monitorId,
+ day: sql`cast(${today} as integer)`,
+ totalChecks: sql`count(*)`,
+ upChecks: sql`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`,
+ })
+ .from(checks)
+ .where(and(
+ inArray(checks.monitorId, monitorIds),
+ gte(checks.checkedAt, new Date(today)),
+ ))
+ .groupBy(checks.monitorId),
+ ]);
+ }
+
+ const bucketsByMonitor = new Map();
+ for (const row of [...historicalRows, ...todayRows]) {
+ const buckets = bucketsByMonitor.get(row.monitorId);
+ if (buckets) buckets.push(row);
+ else bucketsByMonitor.set(row.monitorId, [row]);
+ }
+
+ const services = monitorRows.map((monitor) => {
+ const buckets = bucketsByMonitor.get(monitor.id) ?? [];
+ let totalChecks = 0;
+ let upChecks = 0;
+ const history: HistoryEntry[] = buckets.map((bucket) => {
+ totalChecks += bucket.totalChecks;
+ upChecks += bucket.upChecks;
+ return {
+ day: bucket.day instanceof Date ? bucket.day.getTime() : Number(bucket.day),
+ uptimePct: roundUptime(bucket.upChecks, bucket.totalChecks),
+ };
+ });
+
+ return {
+ id: monitor.id,
+ name: monitor.name,
+ status: serviceStatus(monitor.lastOk),
+ lastCheckedAt: monitor.lastCheckedAt?.toISOString() ?? null,
+ uptime90d: roundUptime(upChecks, totalChecks),
+ history,
+ };
+ });
+
+ return context.json({
+ overall: overallStatus(services.map((service) => service.status)),
+ updatedAt: Date.now(),
+ services,
+ });
+});
+
+statusRoutes.get("/:id/favicon", async (context) => {
+ const id = parseId(context.req.param("id"));
+ if (id === null) return context.json({ message: "Service not found" }, 404);
+ const [monitor] = await getDb(context.env)
+ .select({ url: monitors.url })
+ .from(monitors)
+ .where(and(eq(monitors.id, id), eq(monitors.enabled, true)))
+ .limit(1);
+ if (!monitor) return context.json({ message: "Service not found" }, 404);
+
+ const cacheKey = new Request(`${new URL(context.req.url).origin}/api/status/${id}/favicon`);
+ let cache: EdgeCache | null = null;
+ try {
+ const defaultCache = (caches as CacheStorage & { readonly default: EdgeCache }).default;
+ const cached = await defaultCache.match(cacheKey);
+ if (cached) return cached;
+ cache = defaultCache;
+ } catch {
+ // Cache API availability is best-effort, particularly in local and preview environments.
+ }
+
+ const favicon = await resolveFavicon(monitor.url);
+ if (!favicon) return context.json({ message: "No favicon" }, 404);
+
+ const response = new Response(favicon.body, {
+ headers: {
+ "Cache-Control": `public, max-age=${FAVICON_CACHE_SECONDS}`,
+ "Content-Length": String(favicon.body.byteLength),
+ "Content-Type": favicon.contentType,
+ "X-Content-Type-Options": "nosniff",
+ },
+ });
+ if (cache) {
+ context.executionCtx.waitUntil(cache.put(cacheKey, response.clone()).catch(() => undefined));
+ }
+ return response;
+});
+
+export default statusRoutes;
diff --git a/test/status.spec.ts b/test/status.spec.ts
new file mode 100644
index 0000000..9c6f6a3
--- /dev/null
+++ b/test/status.spec.ts
@@ -0,0 +1,159 @@
+import { applyD1Migrations, env, SELF, type D1Migration } from "cloudflare:test";
+import { beforeAll, beforeEach, describe, expect, it } from "vitest";
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+type PublicStatusResponse = {
+ overall: "operational" | "degraded" | "down";
+ updatedAt: number;
+ services: Array<{
+ id: number;
+ name: string;
+ status: "up" | "down" | "unknown";
+ lastCheckedAt: string | null;
+ uptime90d: number | null;
+ history: Array<{ day: number; uptimePct: number | null }>;
+ }>;
+};
+
+async function resetDatabase() {
+ await env.DB.batch([
+ env.DB.prepare("DELETE FROM checks"),
+ env.DB.prepare("DELETE FROM incidents"),
+ env.DB.prepare("DELETE FROM monitor_daily_stats"),
+ env.DB.prepare("DELETE FROM notification_settings"),
+ env.DB.prepare("DELETE FROM monitors"),
+ env.DB.prepare("DELETE FROM login_attempts"),
+ env.DB.prepare("DELETE FROM sessions"),
+ env.DB.prepare("DELETE FROM admin_credentials"),
+ ]);
+}
+
+async function insertMonitor(input: {
+ name: string;
+ url?: string;
+ enabled?: boolean;
+ lastOk?: boolean | null;
+ lastStatusCode?: number | null;
+ lastLatencyMs?: number | null;
+ lastError?: string | null;
+}) {
+ const now = Date.now();
+ const result = await env.DB.prepare(`
+ INSERT INTO monitors (
+ name, url, method, expected_status, interval_seconds, timeout_ms,
+ enabled, alerts_enabled, last_ok, last_status_code, last_latency_ms,
+ last_error, last_checked_at, created_at, updated_at
+ ) VALUES (?, ?, 'GET', 200, 300, 10000, ?, 1, ?, ?, ?, ?, ?, ?, ?)
+ `)
+ .bind(
+ input.name,
+ input.url ?? `https://${input.name.toLowerCase()}.example.com/health`,
+ input.enabled === false ? 0 : 1,
+ input.lastOk === null || input.lastOk === undefined ? null : input.lastOk ? 1 : 0,
+ input.lastStatusCode ?? null,
+ input.lastLatencyMs ?? null,
+ input.lastError ?? null,
+ now,
+ now,
+ now,
+ )
+ .run();
+ return Number(result.meta.last_row_id);
+}
+
+function statusFetch(path = "/api/status") {
+ return SELF.fetch(`https://example.com${path}`);
+}
+
+describe("public status API", () => {
+ beforeAll(async () => {
+ const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] };
+ await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
+ });
+ beforeEach(resetDatabase);
+
+ it("returns public status without authentication and omits sensitive monitor fields", async () => {
+ await insertMonitor({
+ name: "Public API",
+ lastOk: true,
+ lastStatusCode: 200,
+ lastLatencyMs: 42,
+ lastError: "sensitive diagnostic",
+ });
+
+ const response = await statusFetch();
+ const body = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(body.overall).toBe("operational");
+ expect(body.updatedAt).toEqual(expect.any(Number));
+ expect(body.services).toHaveLength(1);
+ expect(body.services[0]).toMatchObject({
+ name: "Public API",
+ status: "up",
+ uptime90d: null,
+ history: [],
+ });
+ for (const privateField of ["url", "lastError", "lastStatusCode", "lastLatencyMs", "method", "timeoutMs"]) {
+ expect(body.services[0]).not.toHaveProperty(privateField);
+ }
+ });
+
+ it("excludes disabled monitors and reports degraded health for a partial outage", async () => {
+ await insertMonitor({ name: "Healthy", lastOk: true });
+ await insertMonitor({ name: "Unavailable", lastOk: false });
+ await insertMonitor({ name: "Disabled secret", enabled: false, lastOk: false });
+ await insertMonitor({ name: "New service", lastOk: null });
+
+ const response = await statusFetch();
+ const body = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(body.overall).toBe("degraded");
+ expect(body.services.map((service) => service.name)).toEqual(["Healthy", "Unavailable", "New service"]);
+ expect(body.services.map((service) => service.status)).toEqual(["up", "down", "unknown"]);
+ });
+
+ it("reports a total outage when every checked service is down", async () => {
+ await insertMonitor({ name: "Website", lastOk: false });
+ await insertMonitor({ name: "API", lastOk: false });
+ await insertMonitor({ name: "Not checked", lastOk: null });
+
+ const body = await (await statusFetch()).json();
+ expect(body.overall).toBe("down");
+ });
+
+ it("combines historical daily rollups and today's checks into 90-day uptime", async () => {
+ const id = await insertMonitor({ name: "Aggregated", lastOk: true });
+ const now = new Date();
+ const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
+ await env.DB.batch([
+ env.DB.prepare("INSERT INTO monitor_daily_stats (monitor_id, day, total_checks, up_checks, avg_latency_ms, min_latency_ms, max_latency_ms) VALUES (?, ?, 8, 6, 100, 50, 150)").bind(id, today - DAY_MS),
+ env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 90, ?)").bind(id, today + 1_000),
+ env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 110, ?)").bind(id, today + 2_000),
+ ]);
+
+ const body = await (await statusFetch()).json();
+ const service = body.services[0];
+
+ expect(service.uptime90d).toBe(80);
+ expect(service.history).toEqual([
+ { day: today - DAY_MS, uptimePct: 75 },
+ { day: today, uptimePct: 100 },
+ ]);
+ });
+
+ it("keeps the administrative monitor collection protected", async () => {
+ const response = await SELF.fetch("https://example.com/api/monitors");
+ expect(response.status).toBe(401);
+ expect(await response.json()).toEqual({ message: "Authentication required" });
+ });
+
+ it("does not expose a favicon for a disabled service", async () => {
+ const id = await insertMonitor({ name: "Private", enabled: false });
+ const response = await statusFetch(`/api/status/${id}/favicon`);
+ expect(response.status).toBe(404);
+ expect(await response.json()).toEqual({ message: "Service not found" });
+ });
+});