diff --git a/e2e/status-page.spec.ts b/e2e/status-page.spec.ts index 3e2fa29..5a6913e 100644 --- a/e2e/status-page.spec.ts +++ b/e2e/status-page.spec.ts @@ -2,9 +2,9 @@ import { test, expect } from "@playwright/test"; test.use({ storageState: { cookies: [], origins: [] } }); -test("shows Sites heading", async ({ page }) => { +test("shows Latest checks heading", async ({ page }) => { await page.goto("/"); - await expect(page.getByRole("heading", { name: "Sites" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Latest checks" })).toBeVisible(); }); test("shows seeded site card with name and URL", async ({ page }) => { diff --git a/src/__tests__/check-runner.test.ts b/src/__tests__/check-runner.test.ts index 98bc7cd..88187b2 100644 --- a/src/__tests__/check-runner.test.ts +++ b/src/__tests__/check-runner.test.ts @@ -1,10 +1,14 @@ import { describe, test, expect, vi, beforeEach } from 'vitest' import { NextRequest } from 'next/server' -// Mock the checker module -vi.mock('@/lib/checker', () => ({ - checkSite: vi.fn(), -})) +// Mock the checker module — keep real isSoftFailure, mock checkSite +vi.mock('@/lib/checker', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + checkSite: vi.fn(), + } +}) // Mock the notifications module const mockSendIncidentAlert = vi.fn() @@ -86,7 +90,7 @@ describe('POST /api/checks/run', () => { expect(body).toEqual({ message: 'No sites to check', checks: 0, incidents: 0 }) }) - test('records checks and opens incidents for failures, sends email', async () => { + test('hard failure (HTTP 500) creates incident immediately, sends email', async () => { const site = { id: 'site-1', name: 'Test', url: 'https://example.com' } mockFrom.mockImplementation((table: string) => { @@ -134,8 +138,8 @@ describe('POST /api/checks/run', () => { vi.mocked(checkSite).mockResolvedValue({ status: 'failure', - statusCode: 503, - error: 'HTTP 503', + statusCode: 500, + error: 'HTTP 500', }) const request = new NextRequest('http://localhost/api/checks/run', { @@ -150,12 +154,211 @@ describe('POST /api/checks/run', () => { expect(mockSendIncidentAlert).toHaveBeenCalledWith({ siteName: 'Test', siteUrl: 'https://example.com', - error: 'HTTP 503', + error: 'HTTP 500', incidentId: 'inc-1', contactEmails: ['alice@example.com'], }) }) + test('single soft failure (HTTP 503) does not create incident', async () => { + const site = { id: 'site-1', name: 'Test', url: 'https://example.com' } + + mockFrom.mockImplementation((table: string) => { + if (table === 'sites') { + return { select: () => ({ data: [site], error: null }) } + } + if (table === 'checks') { + return { + insert: () => ({ + select: () => ({ + single: () => ({ + data: { id: 'check-1', site_id: 'site-1', status: 'failure' }, + }), + }), + }), + select: () => ({ + eq: () => ({ + eq: () => ({ + gte: () => ({ + data: [{ status_code: 503, error: 'HTTP 503' }], + }), + }), + }), + }), + } + } + if (table === 'incidents') { + return { + select: () => ({ + eq: () => ({ + eq: () => ({ + limit: () => ({ + single: () => ({ data: null }), + }), + }), + }), + }), + } + } + return {} + }) + + vi.mocked(checkSite).mockResolvedValue({ + status: 'failure', + statusCode: 503, + error: 'HTTP 503', + }) + + const request = new NextRequest('http://localhost/api/checks/run', { + method: 'POST', + headers: { authorization: 'Bearer test-secret' }, + }) + + const response = await POST(request) + const body = await response.json() + expect(body.incidents).toBe(0) + expect(mockSendIncidentAlert).not.toHaveBeenCalled() + }) + + test('three soft failures within an hour creates incident', async () => { + const site = { id: 'site-1', name: 'Test', url: 'https://example.com' } + + mockFrom.mockImplementation((table: string) => { + if (table === 'sites') { + return { select: () => ({ data: [site], error: null }) } + } + if (table === 'checks') { + return { + insert: () => ({ + select: () => ({ + single: () => ({ + data: { id: 'check-3', site_id: 'site-1', status: 'failure' }, + }), + }), + }), + select: () => ({ + eq: () => ({ + eq: () => ({ + gte: () => ({ + data: [ + { status_code: 503, error: 'HTTP 503' }, + { status_code: 502, error: 'HTTP 502' }, + { status_code: 503, error: 'HTTP 503' }, + ], + }), + }), + }), + }), + } + } + if (table === 'incidents') { + return { + select: () => ({ + eq: () => ({ + eq: () => ({ + limit: () => ({ + single: () => ({ data: null }), + }), + }), + }), + }), + insert: () => ({ + select: () => ({ + single: () => ({ data: { id: 'inc-1' } }), + }), + }), + } + } + if (table === 'contacts') { + return { + select: () => ({ + data: [{ email: 'alice@example.com' }], + }), + } + } + return {} + }) + + vi.mocked(checkSite).mockResolvedValue({ + status: 'failure', + statusCode: 503, + error: 'HTTP 503', + }) + + const request = new NextRequest('http://localhost/api/checks/run', { + method: 'POST', + headers: { authorization: 'Bearer test-secret' }, + }) + + const response = await POST(request) + const body = await response.json() + expect(body.incidents).toBe(1) + expect(mockSendIncidentAlert).toHaveBeenCalled() + }) + + test('two soft failures within an hour does not create incident', async () => { + const site = { id: 'site-1', name: 'Test', url: 'https://example.com' } + + mockFrom.mockImplementation((table: string) => { + if (table === 'sites') { + return { select: () => ({ data: [site], error: null }) } + } + if (table === 'checks') { + return { + insert: () => ({ + select: () => ({ + single: () => ({ + data: { id: 'check-2', site_id: 'site-1', status: 'failure' }, + }), + }), + }), + select: () => ({ + eq: () => ({ + eq: () => ({ + gte: () => ({ + data: [ + { status_code: 503, error: 'HTTP 503' }, + { status_code: 504, error: 'HTTP 504' }, + ], + }), + }), + }), + }), + } + } + if (table === 'incidents') { + return { + select: () => ({ + eq: () => ({ + eq: () => ({ + limit: () => ({ + single: () => ({ data: null }), + }), + }), + }), + }), + } + } + return {} + }) + + vi.mocked(checkSite).mockResolvedValue({ + status: 'failure', + statusCode: 503, + error: 'HTTP 503', + }) + + const request = new NextRequest('http://localhost/api/checks/run', { + method: 'POST', + headers: { authorization: 'Bearer test-secret' }, + }) + + const response = await POST(request) + const body = await response.json() + expect(body.incidents).toBe(0) + expect(mockSendIncidentAlert).not.toHaveBeenCalled() + }) + test('records checks without creating incidents for successful checks', async () => { const site = { id: 'site-1', name: 'Test', url: 'https://example.com' } const mockInsert = vi.fn().mockReturnValue({ diff --git a/src/__tests__/checker.test.ts b/src/__tests__/checker.test.ts index 28a3445..c0a998c 100644 --- a/src/__tests__/checker.test.ts +++ b/src/__tests__/checker.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, vi, beforeEach } from 'vitest' -import { checkSite } from '@/lib/checker' +import { checkSite, isSoftFailure } from '@/lib/checker' describe('checkSite', () => { beforeEach(() => { @@ -63,3 +63,38 @@ describe('checkSite', () => { }) }) }) + +describe('isSoftFailure', () => { + test('HTTP 502 is a soft failure', () => { + expect(isSoftFailure(502, 'HTTP 502')).toBe(true) + }) + + test('HTTP 503 is a soft failure', () => { + expect(isSoftFailure(503, 'HTTP 503')).toBe(true) + }) + + test('HTTP 504 is a soft failure', () => { + expect(isSoftFailure(504, 'HTTP 504')).toBe(true) + }) + + test('Connection timeout is a soft failure', () => { + expect(isSoftFailure(null, 'Connection timeout')).toBe(true) + }) + + test('HTTP 500 is a hard failure', () => { + expect(isSoftFailure(500, 'HTTP 500')).toBe(false) + }) + + test('HTTP 404 is a hard failure', () => { + expect(isSoftFailure(404, 'HTTP 404')).toBe(false) + }) + + test('DNS resolution failure is a hard failure', () => { + expect(isSoftFailure(null, 'getaddrinfo ENOTFOUND example.com')).toBe(false) + }) + + test('SSL error is a hard failure', () => { + expect(isSoftFailure(null, 'unable to verify the first certificate')).toBe(false) + }) +}) + diff --git a/src/app/api/checks/run/route.ts b/src/app/api/checks/run/route.ts index c01e774..d70f136 100644 --- a/src/app/api/checks/run/route.ts +++ b/src/app/api/checks/run/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { createAdminClient } from '@/lib/supabase/admin' -import { checkSite } from '@/lib/checker' +import { checkSite, isSoftFailure } from '@/lib/checker' import { sendIncidentAlert } from '@/lib/notifications' export const maxDuration = 60 @@ -60,29 +60,51 @@ async function handleCheckRun(request: NextRequest) { .single() if (!openIncident) { - const { data: incident } = await supabase - .from('incidents') - .insert({ - site_id: site.id, - check_id: check.id, - status: 'open', - }) - .select('id') - .single() - newIncidents++ - - if (incident) { - const { data: contacts } = await supabase - .from('contacts') - .select('email') - - await sendIncidentAlert({ - siteName: site.name, - siteUrl: site.url, - error, - incidentId: incident.id, - contactEmails: (contacts ?? []).map((c: { email: string }) => c.email), - }) + const soft = isSoftFailure(statusCode, error) + let shouldCreateIncident = !soft // Hard failures always create incidents + + if (soft) { + // Soft failures only create incidents when there are 3+ within the last hour + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString() + const { data: recentFailures } = await supabase + .from('checks') + .select('status_code, error') + .eq('site_id', site.id) + .eq('status', 'failure') + .gte('checked_at', oneHourAgo) + + const softFailureCount = (recentFailures ?? []).filter( + (c: { status_code: number | null; error: string | null }) => + isSoftFailure(c.status_code, c.error) + ).length + shouldCreateIncident = softFailureCount >= 3 + } + + if (shouldCreateIncident) { + const { data: incident } = await supabase + .from('incidents') + .insert({ + site_id: site.id, + check_id: check.id, + status: 'open', + }) + .select('id') + .single() + newIncidents++ + + if (incident) { + const { data: contacts } = await supabase + .from('contacts') + .select('email') + + await sendIncidentAlert({ + siteName: site.name, + siteUrl: site.url, + error, + incidentId: incident.id, + contactEmails: (contacts ?? []).map((c: { email: string }) => c.email), + }) + } } } } diff --git a/src/app/incidents/[id]/page.tsx b/src/app/incidents/[id]/page.tsx index 6e5dd96..56edd9c 100644 --- a/src/app/incidents/[id]/page.tsx +++ b/src/app/incidents/[id]/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link" import { notFound } from "next/navigation" import { createClient } from "@/lib/supabase/server" import type { Incident, Site, Check } from "@/lib/supabase/types" +import CheckLogTable from "@/components/CheckLogTable" import { resolveIncident } from "./actions" export const revalidate = 0 @@ -172,96 +173,15 @@ export default async function IncidentDetailPage({ )} -
+

Checks Since Incident Opened

-
    - {checks.length === 0 ? ( -
  • - No checks recorded since incident opened. -
  • - ) : ( - checks.map((check, i) => { - const isTriggering = check.id === incident.check_id - return ( -
  • - - - {check.status === "failure" ? "Failing" : "Passing"} - - {check.status_code && ( - - {check.status_code} - - )} - - {isTriggering && ( - - Triggered incident - - )} - {new Date(check.checked_at).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - })},{" "} - {new Date(check.checked_at).toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - })} - -
  • - ) - }) - )} -
-
+ + ) } diff --git a/src/app/page.tsx b/src/app/page.tsx index 4077834..1f15e2d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -26,7 +26,7 @@ async function getStatusData() { const sitesWithChecks: SiteWithLastCheck[] = [] for (const site of sites ?? []) { - const { data: checks } = await supabase + const { data: lastChecks } = await supabase .from("checks") .select("*") .eq("site_id", site.id) @@ -35,7 +35,7 @@ async function getStatusData() { sitesWithChecks.push({ ...site, - lastCheck: checks?.[0] ?? null, + lastCheck: lastChecks?.[0] ?? null, }) } @@ -123,7 +123,7 @@ export default async function StatusPage() { className="text-xl font-bold mb-4" style={{ color: "#1A1A1A" }} > - Sites + Latest checks diff --git a/src/app/sites/[id]/page.tsx b/src/app/sites/[id]/page.tsx index 2b7bef6..a0d42d8 100644 --- a/src/app/sites/[id]/page.tsx +++ b/src/app/sites/[id]/page.tsx @@ -4,6 +4,7 @@ import { createClient } from "@/lib/supabase/server" import type { Site, Check, Incident } from "@/lib/supabase/types" import { formatTimeAgo } from "@/lib/format" import SiteFormDialog from "@/components/SiteFormDialog" +import CheckLogTable from "@/components/CheckLogTable" export const revalidate = 0 @@ -31,14 +32,10 @@ async function getSiteData(id: string) { .order("checked_at", { ascending: false }) .limit(50) - const latestCheck = checks?.[0] ?? null - const isDown = latestCheck?.status === "failure" - return { site: site as Site, incidents: (incidents ?? []) as (Incident & { check: Check })[], checks: (checks ?? []) as Check[], - isDown, } } @@ -51,7 +48,7 @@ export default async function SiteDetailPage({ const data = await getSiteData(id) if (!data) notFound() - const { site, incidents, checks, isDown } = data + const { site, incidents, checks } = data const supabase = await createClient() const { data: { user }, @@ -77,18 +74,9 @@ export default async function SiteDetailPage({ > {site.name} -
+
{site.url}
-
- - - {isDown ? "Down" : "Up"} - -
{user && ( @@ -186,94 +174,7 @@ export default async function SiteDetailPage({ > Check Log -
- - - - - - - - - {checks.length === 0 ? ( - - - - ) : ( - checks.map((check, i) => { - const isFailure = check.status === "failure" - return ( - - - - - ) - }) - )} - -
- Timestamp - - Result -
- No checks recorded yet. -
- {new Date(check.checked_at).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - })}{" "} - {new Date(check.checked_at).toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - })} - -
- - - {isFailure - ? check.error ?? "Failure" - : `HTTP ${check.status_code ?? 200} OK`} - -
-
-
+ ) diff --git a/src/components/CheckLogTable.tsx b/src/components/CheckLogTable.tsx new file mode 100644 index 0000000..a111c3d --- /dev/null +++ b/src/components/CheckLogTable.tsx @@ -0,0 +1,118 @@ +import type { Check } from "@/lib/supabase/types" +import { isSoftFailure } from "@/lib/checker" + +function checkDotColor(check: Check): string { + if (check.status !== "failure") return "#2DA44E" + return isSoftFailure(check.status_code, check.error) ? "#D4A017" : "#C4453C" +} + +export default function CheckLogTable({ + checks, + triggeringCheckId, +}: { + checks: Check[] + triggeringCheckId?: string +}) { + return ( +
+ + + + + + + + + {checks.length === 0 ? ( + + + + ) : ( + checks.map((check, i) => { + const dotColor = checkDotColor(check) + const isFailure = check.status === "failure" + const isTriggering = triggeringCheckId === check.id + return ( + + + + + ) + }) + )} + +
+ Timestamp + + Result +
+ No checks recorded yet. +
+ {new Date(check.checked_at).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })}{" "} + {new Date(check.checked_at).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + })} + +
+ + + {isFailure + ? check.error ?? "Failure" + : `HTTP ${check.status_code ?? 200} OK`} + + {isTriggering && ( + + Triggered incident + + )} +
+
+
+ ) +} diff --git a/src/components/SiteList.tsx b/src/components/SiteList.tsx index 3445906..ed5a6bf 100644 --- a/src/components/SiteList.tsx +++ b/src/components/SiteList.tsx @@ -21,6 +21,7 @@ import { import { CSS } from "@dnd-kit/utilities" import { restrictToVerticalAxis } from "@dnd-kit/modifiers" import type { Site, Check } from "@/lib/supabase/types" +import { isSoftFailure } from "@/lib/checker" import { formatTimeAgo } from "@/lib/format" import { reorderSites } from "@/app/sites/actions" import SiteFormDialog from "@/components/SiteFormDialog" @@ -124,7 +125,9 @@ function SiteCard({ style={{ backgroundColor: site.lastCheck?.status === "failure" - ? "#C4453C" + ? isSoftFailure(site.lastCheck.status_code, site.lastCheck.error) + ? "#D4A017" + : "#C4453C" : "#2DA44E", }} /> diff --git a/src/lib/checker.ts b/src/lib/checker.ts index 7d2bdfb..29c2d13 100644 --- a/src/lib/checker.ts +++ b/src/lib/checker.ts @@ -4,6 +4,14 @@ export type CheckResult = { error: string | null } +const SOFT_FAILURE_STATUS_CODES = [502, 503, 504] + +export function isSoftFailure(statusCode: number | null, error: string | null): boolean { + if (statusCode !== null && SOFT_FAILURE_STATUS_CODES.includes(statusCode)) return true + if (statusCode === null && error === 'Connection timeout') return true + return false +} + export async function checkSite(url: string): Promise { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 30000)