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(); diff --git a/src/app/canary/route.ts b/src/app/canary/route.ts new file mode 100644 index 0000000..a361c43 --- /dev/null +++ b/src/app/canary/route.ts @@ -0,0 +1,28 @@ +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 + + const html = ` +Canary +

Canary response: ${statusCode}

+` + + return new Response(html, { + status: statusCode, + headers: { "Content-Type": "text/html" }, + }) +} diff --git a/src/app/settings/actions.ts b/src/app/settings/actions.ts index 754458c..4834e89 100644 --- a/src/app/settings/actions.ts +++ b/src/app/settings/actions.ts @@ -45,3 +45,22 @@ export async function deleteContact(contactId: string) { if (error) return { error: `Failed to remove contact: ${error.message}` } 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 82fd070..284dc33 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 ActionForm from "@/components/ActionForm" import Link from "next/link" @@ -32,6 +33,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 (
@@ -174,6 +183,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');