From 91f50bdc2dc97b86f3b01461ad95f5eb6086406f Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Tue, 24 Feb 2026 06:17:26 -0800 Subject: [PATCH 1/3] feat: add canary endpoint for end-to-end alert testing Adds a /canary route that returns a configurable HTTP status code, and a settings UI to control it. This lets you add /canary as a monitored site and trigger real incidents to verify email and Slack alerting works. - New `settings` table (key-value) with canary_status_code default 200 - /canary route reads the setting and returns that status code - Dropdown on settings page with color-coded status codes (green/amber/red matching success/soft-fail/hard-fail classification) - Server action with auth check and validation Co-Authored-By: Claude Opus 4.6 --- src/app/canary/route.ts | 20 ++++++ src/app/settings/actions.ts | 19 ++++++ src/app/settings/page.tsx | 28 ++++++++ src/components/CanarySettings.tsx | 64 +++++++++++++++++++ .../20250224000001_create_settings_table.sql | 16 +++++ 5 files changed, 147 insertions(+) create mode 100644 src/app/canary/route.ts create mode 100644 src/components/CanarySettings.tsx create mode 100644 supabase/migrations/20250224000001_create_settings_table.sql diff --git a/src/app/canary/route.ts b/src/app/canary/route.ts new file mode 100644 index 0000000..a391fb5 --- /dev/null +++ b/src/app/canary/route.ts @@ -0,0 +1,20 @@ +import { createClient } from "@supabase/supabase-js" + +export const dynamic = "force-dynamic" + +export async function GET() { + const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ) + + const { data } = await supabase + .from("settings") + .select("value") + .eq("key", "canary_status_code") + .single() + + const statusCode = data ? parseInt(data.value, 10) : 200 + + return new Response(null, { status: statusCode }) +} diff --git a/src/app/settings/actions.ts b/src/app/settings/actions.ts index 8953a38..125fb34 100644 --- a/src/app/settings/actions.ts +++ b/src/app/settings/actions.ts @@ -44,3 +44,22 @@ export async function deleteContact(contactId: string) { await supabase.from("contacts").delete().eq("id", contactId) revalidatePath("/settings") } + +const VALID_CANARY_CODES = [200, 301, 302, 400, 401, 403, 404, 408, 500, 502, 503, 504] + +export async function updateCanaryStatusCode(code: number) { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) redirect("/login") + + if (!VALID_CANARY_CODES.includes(code)) return + + await supabase + .from("settings") + .upsert({ key: "canary_status_code", value: String(code), updated_at: new Date().toISOString() }) + + revalidatePath("/settings") +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index e87ab8c..61e9da8 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -3,6 +3,7 @@ import { createClient } from "@/lib/supabase/server" import type { Contact, Invitation } from "@/lib/supabase/types" import { deleteContact } from "./actions" import AddContactForm from "@/components/AddContactForm" +import CanarySettings from "@/components/CanarySettings" import { sendInvitation, revokeInvitation } from "./invite-actions" import Link from "next/link" @@ -31,6 +32,14 @@ export default async function SettingsPage() { const typedInvitations = (invitations ?? []) as Invitation[] + const { data: canarySetting } = await supabase + .from("settings") + .select("value") + .eq("key", "canary_status_code") + .single() + + const canaryCode = canarySetting ? parseInt(canarySetting.value, 10) : 200 + return (
@@ -173,6 +182,25 @@ export default async function SettingsPage() {
+ +
+

+ Canary +

+

+ The /canary endpoint + responds with the status code below. Add it as a monitored site to test alerting end-to-end. +

+ + +
) } diff --git a/src/components/CanarySettings.tsx b/src/components/CanarySettings.tsx new file mode 100644 index 0000000..714c84a --- /dev/null +++ b/src/components/CanarySettings.tsx @@ -0,0 +1,64 @@ +"use client" + +import { useOptimistic, useTransition } from "react" +import { updateCanaryStatusCode } from "@/app/settings/actions" + +const STATUS_CODES = [ + { code: 200, name: "OK", color: "#2D6A2D" }, + { code: 301, name: "Moved Permanently", color: "#C4453C" }, + { code: 302, name: "Found", color: "#C4453C" }, + { code: 400, name: "Bad Request", color: "#C4453C" }, + { code: 401, name: "Unauthorized", color: "#C4453C" }, + { code: 403, name: "Forbidden", color: "#C4453C" }, + { code: 404, name: "Not Found", color: "#C4453C" }, + { code: 408, name: "Request Timeout", color: "#C4453C" }, + { code: 500, name: "Internal Server Error", color: "#C4453C" }, + { code: 502, name: "Bad Gateway", color: "#D4880F" }, + { code: 503, name: "Service Unavailable", color: "#D4880F" }, + { code: 504, name: "Gateway Timeout", color: "#D4880F" }, +] as const + +export default function CanarySettings({ currentCode }: { currentCode: number }) { + const [isPending, startTransition] = useTransition() + const [optimisticCode, setOptimisticCode] = useOptimistic(currentCode) + + function handleChange(e: React.ChangeEvent) { + const newCode = parseInt(e.target.value, 10) + startTransition(async () => { + setOptimisticCode(newCode) + await updateCanaryStatusCode(newCode) + }) + } + + const current = STATUS_CODES.find((s) => s.code === optimisticCode) + + return ( +
+ + {current && ( + + )} +
+ ) +} diff --git a/supabase/migrations/20250224000001_create_settings_table.sql b/supabase/migrations/20250224000001_create_settings_table.sql new file mode 100644 index 0000000..e2b3635 --- /dev/null +++ b/supabase/migrations/20250224000001_create_settings_table.sql @@ -0,0 +1,16 @@ +-- settings: simple key-value store for app configuration +CREATE TABLE settings ( + key text PRIMARY KEY, + value text NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- RLS: public read, authenticated write +ALTER TABLE settings ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Public read settings" ON settings FOR SELECT USING (true); +CREATE POLICY "Authenticated insert settings" ON settings FOR INSERT WITH CHECK (auth.role() = 'authenticated'); +CREATE POLICY "Authenticated update settings" ON settings FOR UPDATE USING (auth.role() = 'authenticated'); + +-- Seed the canary default +INSERT INTO settings (key, value) VALUES ('canary_status_code', '200'); From 3eade260d94e8047a86dd8b8f31c015e89cae978 Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Tue, 24 Feb 2026 06:33:46 -0800 Subject: [PATCH 2/3] fix: return stub HTML page from canary endpoint Co-Authored-By: Claude Opus 4.6 --- src/app/canary/route.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/app/canary/route.ts b/src/app/canary/route.ts index a391fb5..a361c43 100644 --- a/src/app/canary/route.ts +++ b/src/app/canary/route.ts @@ -16,5 +16,13 @@ export async function GET() { const statusCode = data ? parseInt(data.value, 10) : 200 - return new Response(null, { status: statusCode }) + const html = ` +Canary +

Canary response: ${statusCode}

+` + + return new Response(html, { + status: statusCode, + headers: { "Content-Type": "text/html" }, + }) } From 2bdc90b9deff5c0381e679adb1076dcd73173617 Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Tue, 24 Feb 2026 06:50:17 -0800 Subject: [PATCH 3/3] fix: scope combobox selector in contacts e2e test The canary dropdown added a second combobox to the settings page, causing a strict mode violation in the Slack contact test. Co-Authored-By: Claude Opus 4.6 --- e2e/contacts.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/contacts.spec.ts b/e2e/contacts.spec.ts index 0a76574..6d2f100 100644 --- a/e2e/contacts.spec.ts +++ b/e2e/contacts.spec.ts @@ -27,7 +27,7 @@ test("adds an email contact and displays it in the list", async ({ page }) => { test("adds a Slack contact and displays it in the list", async ({ page }) => { await page.goto("/settings"); - await page.getByRole("combobox").selectOption("slack"); + await page.locator("form", { hasText: "Add" }).getByRole("combobox").selectOption("slack"); await page.getByPlaceholder("#channel name").fill("#alerts"); await page.getByPlaceholder("https://hooks.slack.com/services/...").fill("https://hooks.slack.com/services/T00/B00/test"); await page.getByRole("button", { name: "Add" }).click();