Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion e2e/contacts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
28 changes: 28 additions & 0 deletions src/app/canary/route.ts
Original file line number Diff line number Diff line change
@@ -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 = `<!DOCTYPE html>
<html><head><title>Canary</title></head>
<body><p>Canary response: ${statusCode}</p></body>
</html>`

return new Response(html, {
status: statusCode,
headers: { "Content-Type": "text/html" },
})
}
19 changes: 19 additions & 0 deletions src/app/settings/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
28 changes: 28 additions & 0 deletions src/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 (
<main className="max-w-[720px] mx-auto" style={{ padding: "32px 24px 64px" }}>
<div className="mb-7">
Expand Down Expand Up @@ -174,6 +183,25 @@ export default async function SettingsPage() {
</button>
</ActionForm>
</div>

<div
className="rounded mb-6"
style={{
backgroundColor: "#FFFFFF",
border: "1px solid #E8E4DF",
padding: "28px",
}}
>
<h2 className="text-xl font-bold mb-1.5" style={{ color: "#1A1A1A" }}>
Canary
</h2>
<p className="text-sm mb-5" style={{ color: "#5C5C5C" }}>
The <code style={{ fontSize: "13px", backgroundColor: "#F5F3F0", padding: "1px 5px", borderRadius: "3px" }}>/canary</code> endpoint
responds with the status code below. Add it as a monitored site to test alerting end-to-end.
</p>

<CanarySettings currentCode={canaryCode} />
</div>
</main>
)
}
64 changes: 64 additions & 0 deletions src/components/CanarySettings.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLSelectElement>) {
const newCode = parseInt(e.target.value, 10)
startTransition(async () => {
setOptimisticCode(newCode)
await updateCanaryStatusCode(newCode)
})
}

const current = STATUS_CODES.find((s) => s.code === optimisticCode)

return (
<div className="flex items-center gap-3">
<select
value={optimisticCode}
onChange={handleChange}
disabled={isPending}
className="text-sm px-2 py-2 rounded"
style={{
border: "1px solid #E8E4DF",
backgroundColor: "#FFFFFF",
color: "#1A1A1A",
opacity: isPending ? 0.6 : 1,
}}
>
{STATUS_CODES.map(({ code, name, color }) => (
<option key={code} value={code}>
{code === 200 ? "\u{1F7E2}" : color === "#D4880F" ? "\u{1F7E1}" : "\u{1F534}"}{" "}
{code} {name}
</option>
))}
</select>
{current && (
<span
className="inline-block w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: current.color }}
/>
)}
</div>
)
}
16 changes: 16 additions & 0 deletions supabase/migrations/20250224000001_create_settings_table.sql
Original file line number Diff line number Diff line change
@@ -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');