From 375acd42ac8a311aeeeb6bd9be7185df2cfc8789 Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Sun, 22 Feb 2026 08:49:24 -0800 Subject: [PATCH 1/6] feat: distinguish hard vs soft failures for incident creation Hard failures (500, DNS, SSL, etc.) create incidents immediately. Soft failures (502, 503, 504, timeout) only create incidents after 3 occurrences within an hour. Site status now shows three states: Up (green), Transient Failures (amber), Failures (red). Closes #39 Co-Authored-By: Claude Opus 4.6 --- src/__tests__/check-runner.test.ts | 219 +++++++++++++++++++++++++++-- src/__tests__/checker.test.ts | 90 +++++++++++- src/app/api/checks/run/route.ts | 70 +++++---- src/app/page.tsx | 32 +++-- src/app/sites/[id]/page.tsx | 25 +++- src/lib/checker.ts | 22 +++ 6 files changed, 408 insertions(+), 50 deletions(-) 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..1339735 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, computeSiteStatus } from '@/lib/checker' describe('checkSite', () => { beforeEach(() => { @@ -63,3 +63,91 @@ 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) + }) +}) + +describe('computeSiteStatus', () => { + test('returns "up" when all checks are successful', () => { + const checks = [ + { status: 'success', status_code: 200, error: null }, + { status: 'success', status_code: 200, error: null }, + ] + expect(computeSiteStatus(checks)).toBe('up') + }) + + test('returns "up" when there are no checks', () => { + expect(computeSiteStatus([])).toBe('up') + }) + + test('returns "failures" when there is a hard failure', () => { + const checks = [ + { status: 'success', status_code: 200, error: null }, + { status: 'failure', status_code: 500, error: 'HTTP 500' }, + ] + expect(computeSiteStatus(checks)).toBe('failures') + }) + + test('returns "transient_failures" when there are only soft failures', () => { + const checks = [ + { status: 'success', status_code: 200, error: null }, + { status: 'failure', status_code: 503, error: 'HTTP 503' }, + ] + expect(computeSiteStatus(checks)).toBe('transient_failures') + }) + + test('returns "failures" when there are both hard and soft failures', () => { + const checks = [ + { status: 'failure', status_code: 503, error: 'HTTP 503' }, + { status: 'failure', status_code: 500, error: 'HTTP 500' }, + ] + expect(computeSiteStatus(checks)).toBe('failures') + }) + + test('returns "transient_failures" for connection timeout', () => { + const checks = [ + { status: 'success', status_code: 200, error: null }, + { status: 'failure', status_code: null, error: 'Connection timeout' }, + ] + expect(computeSiteStatus(checks)).toBe('transient_failures') + }) + + test('returns "failures" for DNS failure', () => { + const checks = [ + { status: 'success', status_code: 200, error: null }, + { status: 'failure', status_code: null, error: 'getaddrinfo ENOTFOUND example.com' }, + ] + expect(computeSiteStatus(checks)).toBe('failures') + }) +}) 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/page.tsx b/src/app/page.tsx index 3873630..086d9de 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link" import { createClient } from "@/lib/supabase/server" import type { Site, Check, Incident } from "@/lib/supabase/types" +import { computeSiteStatus, type SiteStatus } from "@/lib/checker" import { formatTimeAgo, formatDuration } from "@/lib/format" import AddSiteCard from "@/components/AddSiteCard" import SiteFormDialog from "@/components/SiteFormDialog" @@ -8,9 +9,15 @@ import RealtimeStatusPage from "@/components/RealtimeStatusPage" export const revalidate = 0 -type SiteWithLastCheck = Site & { lastCheck: Check | null } +type SiteWithStatus = Site & { lastCheck: Check | null; siteStatus: SiteStatus } type IncidentWithSite = Incident & { site: Site; check: Check } +const STATUS_DOT_COLORS: Record = { + up: "#2DA44E", + failures: "#C4453C", + transient_failures: "#D4A017", +} + async function getStatusData() { const supabase = await createClient() @@ -25,18 +32,26 @@ async function getStatusData() { .eq("status", "open") .order("opened_at", { ascending: false }) - const sitesWithChecks: SiteWithLastCheck[] = [] + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString() + const sitesWithStatus: SiteWithStatus[] = [] for (const site of sites ?? []) { - const { data: checks } = await supabase + const { data: lastChecks } = await supabase .from("checks") .select("*") .eq("site_id", site.id) .order("checked_at", { ascending: false }) .limit(1) - sitesWithChecks.push({ + const { data: recentChecks } = await supabase + .from("checks") + .select("status, status_code, error") + .eq("site_id", site.id) + .gte("checked_at", oneHourAgo) + + sitesWithStatus.push({ ...site, - lastCheck: checks?.[0] ?? null, + lastCheck: lastChecks?.[0] ?? null, + siteStatus: computeSiteStatus(recentChecks ?? []), }) } @@ -45,7 +60,7 @@ async function getStatusData() { } = await supabase.auth.getUser() return { - sites: sitesWithChecks, + sites: sitesWithStatus, incidents: (openIncidents ?? []) as IncidentWithSite[], isAdmin: !!user, } @@ -154,10 +169,7 @@ export default async function StatusPage() { = { + up: { color: "#2DA44E", label: "Up" }, + failures: { color: "#C4453C", label: "Failures" }, + transient_failures: { color: "#D4A017", label: "Transient Failures" }, +} + export const revalidate = 0 async function getSiteData(id: string) { @@ -31,14 +38,17 @@ async function getSiteData(id: string) { .order("checked_at", { ascending: false }) .limit(50) - const latestCheck = checks?.[0] ?? null - const isDown = latestCheck?.status === "failure" + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000) + const recentChecks = (checks ?? []).filter( + (c) => new Date(c.checked_at) > oneHourAgo + ) + const siteStatus = computeSiteStatus(recentChecks) return { site: site as Site, incidents: (incidents ?? []) as (Incident & { check: Check })[], checks: (checks ?? []) as Check[], - isDown, + siteStatus, } } @@ -51,7 +61,8 @@ export default async function SiteDetailPage({ const data = await getSiteData(id) if (!data) notFound() - const { site, incidents, checks, isDown } = data + const { site, incidents, checks, siteStatus } = data + const statusConfig = STATUS_CONFIG[siteStatus] const supabase = await createClient() const { data: { user }, @@ -83,10 +94,10 @@ export default async function SiteDetailPage({
- - {isDown ? "Down" : "Up"} + + {statusConfig.label}
diff --git a/src/lib/checker.ts b/src/lib/checker.ts index 7d2bdfb..5f1f736 100644 --- a/src/lib/checker.ts +++ b/src/lib/checker.ts @@ -4,6 +4,28 @@ export type CheckResult = { error: string | null } +export type SiteStatus = 'up' | 'failures' | 'transient_failures' + +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 function computeSiteStatus( + recentChecks: { status: string; status_code: number | null; error: string | null }[] +): SiteStatus { + const failures = recentChecks.filter(c => c.status === 'failure') + if (failures.length === 0) return 'up' + + const hasHardFailure = failures.some(c => !isSoftFailure(c.status_code, c.error)) + if (hasHardFailure) return 'failures' + + return 'transient_failures' +} + export async function checkSite(url: string): Promise { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 30000) From e70997bbe65077d1f343d2d55c80026f358197c0 Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Sun, 22 Feb 2026 10:46:30 -0800 Subject: [PATCH 2/6] chore: add synthetic check data to seed for UAT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 4 sites with check histories demonstrating each status: - Healthy Site: all passing → Up (green) - Flaky Site: one 503 among passing checks → Transient Failures (amber) - Broken Site: one HTTP 500 → Failures (red) - Down Site: 6 consecutive 503s with open incident Co-Authored-By: Claude Opus 4.6 --- supabase/seed.sql | 70 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 4 deletions(-) diff --git a/supabase/seed.sql b/supabase/seed.sql index 4c4d843..a187c5c 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -62,12 +62,74 @@ INSERT INTO auth.identities ( ); -- ============================================================================ --- Sample sites +-- Sample sites (4 sites to demonstrate each status) -- ============================================================================ -INSERT INTO sites (id, name, url) VALUES - ('11111111-1111-1111-1111-111111111111', 'Example Site', 'https://example.com'), - ('22222222-2222-2222-2222-222222222222', 'Anthropic', 'https://www.anthropic.com'); +INSERT INTO sites (id, name, url, position) VALUES + ('11111111-1111-1111-1111-111111111111', 'Healthy Site', 'https://example.com', 0), + ('22222222-2222-2222-2222-222222222222', 'Flaky Site', 'https://flaky.example.com', 1), + ('33333333-3333-3333-3333-333333333333', 'Broken Site', 'https://broken.example.com', 2), + ('44444444-4444-4444-4444-444444444444', 'Down Site (Incident)', 'https://down.example.com', 3); + +-- ============================================================================ +-- Synthetic checks (last hour) to demonstrate all three site statuses +-- ============================================================================ + +-- Site 1: "Healthy Site" — all passing → status: Up (green) +INSERT INTO checks (id, site_id, status, status_code, error, checked_at) VALUES + ('aaaa0001-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '50 minutes'), + ('aaaa0001-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '45 minutes'), + ('aaaa0001-0000-0000-0000-000000000003', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '40 minutes'), + ('aaaa0001-0000-0000-0000-000000000004', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '35 minutes'), + ('aaaa0001-0000-0000-0000-000000000005', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '30 minutes'), + ('aaaa0001-0000-0000-0000-000000000006', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '25 minutes'), + ('aaaa0001-0000-0000-0000-000000000007', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '20 minutes'), + ('aaaa0001-0000-0000-0000-000000000008', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '15 minutes'), + ('aaaa0001-0000-0000-0000-000000000009', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '10 minutes'), + ('aaaa0001-0000-0000-0000-000000000010', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '5 minutes'); + +-- Site 2: "Flaky Site" — mostly passing with one 503 → status: Transient Failures (amber) +INSERT INTO checks (id, site_id, status, status_code, error, checked_at) VALUES + ('aaaa0002-0000-0000-0000-000000000001', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '50 minutes'), + ('aaaa0002-0000-0000-0000-000000000002', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '45 minutes'), + ('aaaa0002-0000-0000-0000-000000000003', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '40 minutes'), + ('aaaa0002-0000-0000-0000-000000000004', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '35 minutes'), + ('aaaa0002-0000-0000-0000-000000000005', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '30 minutes'), + ('aaaa0002-0000-0000-0000-000000000006', '22222222-2222-2222-2222-222222222222', 'failure', 503, 'HTTP 503', now() - interval '25 minutes'), + ('aaaa0002-0000-0000-0000-000000000007', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '20 minutes'), + ('aaaa0002-0000-0000-0000-000000000008', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '15 minutes'), + ('aaaa0002-0000-0000-0000-000000000009', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '10 minutes'), + ('aaaa0002-0000-0000-0000-000000000010', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '5 minutes'); + +-- Site 3: "Broken Site" — has a hard failure (HTTP 500) → status: Failures (red) +INSERT INTO checks (id, site_id, status, status_code, error, checked_at) VALUES + ('aaaa0003-0000-0000-0000-000000000001', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '50 minutes'), + ('aaaa0003-0000-0000-0000-000000000002', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '45 minutes'), + ('aaaa0003-0000-0000-0000-000000000003', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '40 minutes'), + ('aaaa0003-0000-0000-0000-000000000004', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '35 minutes'), + ('aaaa0003-0000-0000-0000-000000000005', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '30 minutes'), + ('aaaa0003-0000-0000-0000-000000000006', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '25 minutes'), + ('aaaa0003-0000-0000-0000-000000000007', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '20 minutes'), + ('aaaa0003-0000-0000-0000-000000000008', '33333333-3333-3333-3333-333333333333', 'failure', 500, 'HTTP 500', now() - interval '15 minutes'), + ('aaaa0003-0000-0000-0000-000000000009', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '10 minutes'), + ('aaaa0003-0000-0000-0000-000000000010', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '5 minutes'); + +-- Site 4: "Down Site (Incident)" — 3 soft failures triggered an incident, still failing +INSERT INTO checks (id, site_id, status, status_code, error, checked_at) VALUES + ('aaaa0004-0000-0000-0000-000000000001', '44444444-4444-4444-4444-444444444444', 'success', 200, NULL, now() - interval '50 minutes'), + ('aaaa0004-0000-0000-0000-000000000002', '44444444-4444-4444-4444-444444444444', 'success', 200, NULL, now() - interval '45 minutes'), + ('aaaa0004-0000-0000-0000-000000000003', '44444444-4444-4444-4444-444444444444', 'success', 200, NULL, now() - interval '40 minutes'), + ('aaaa0004-0000-0000-0000-000000000004', '44444444-4444-4444-4444-444444444444', 'success', 200, NULL, now() - interval '35 minutes'), + ('aaaa0004-0000-0000-0000-000000000005', '44444444-4444-4444-4444-444444444444', 'failure', 503, 'HTTP 503', now() - interval '30 minutes'), + ('aaaa0004-0000-0000-0000-000000000006', '44444444-4444-4444-4444-444444444444', 'failure', 502, 'HTTP 502', now() - interval '25 minutes'), + ('aaaa0004-0000-0000-0000-000000000007', '44444444-4444-4444-4444-444444444444', 'failure', 503, 'HTTP 503', now() - interval '20 minutes'), + ('aaaa0004-0000-0000-0000-000000000008', '44444444-4444-4444-4444-444444444444', 'failure', 503, 'HTTP 503', now() - interval '15 minutes'), + ('aaaa0004-0000-0000-0000-000000000009', '44444444-4444-4444-4444-444444444444', 'failure', 503, 'HTTP 503', now() - interval '10 minutes'), + ('aaaa0004-0000-0000-0000-000000000010', '44444444-4444-4444-4444-444444444444', 'failure', 503, 'HTTP 503', now() - interval '5 minutes'); + +-- Open incident for Site 4 (triggered by the 3rd soft failure) +INSERT INTO incidents (id, site_id, check_id, status, opened_at) VALUES + ('bbbb0001-0000-0000-0000-000000000001', '44444444-4444-4444-4444-444444444444', 'aaaa0004-0000-0000-0000-000000000007', 'open', now() - interval '20 minutes'); -- ============================================================================ -- Test contact From 3a65108bf605d6a42e6a2259b04acc25bb4c9769 Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Sun, 22 Feb 2026 10:51:42 -0800 Subject: [PATCH 3/6] fix: remove position column from seed (added by migration) The seed runs before migrations, so the position column doesn't exist yet. The migration backfills positions alphabetically. Co-Authored-By: Claude Opus 4.6 --- supabase/seed.sql | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/supabase/seed.sql b/supabase/seed.sql index a187c5c..b6d23ba 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -65,11 +65,11 @@ INSERT INTO auth.identities ( -- Sample sites (4 sites to demonstrate each status) -- ============================================================================ -INSERT INTO sites (id, name, url, position) VALUES - ('11111111-1111-1111-1111-111111111111', 'Healthy Site', 'https://example.com', 0), - ('22222222-2222-2222-2222-222222222222', 'Flaky Site', 'https://flaky.example.com', 1), - ('33333333-3333-3333-3333-333333333333', 'Broken Site', 'https://broken.example.com', 2), - ('44444444-4444-4444-4444-444444444444', 'Down Site (Incident)', 'https://down.example.com', 3); +INSERT INTO sites (id, name, url) VALUES + ('11111111-1111-1111-1111-111111111111', 'Healthy Site', 'https://example.com'), + ('22222222-2222-2222-2222-222222222222', 'Flaky Site', 'https://flaky.example.com'), + ('33333333-3333-3333-3333-333333333333', 'Broken Site', 'https://broken.example.com'), + ('44444444-4444-4444-4444-444444444444', 'Down Site (Incident)', 'https://down.example.com'); -- ============================================================================ -- Synthetic checks (last hour) to demonstrate all three site statuses From eab2e110f0377bf1bbc5b068095f7388ec31dc2b Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Sun, 22 Feb 2026 13:41:16 -0800 Subject: [PATCH 4/6] refactor: drop site status concept, show per-check colors instead Instead of computing an aggregate site status from the last hour of checks, show red/amber/green based on the individual latest check (hard failure / soft failure / success). Removes computeSiteStatus and SiteStatus type. Renames main page header to "Latest checks". Removes the Up/Down/Transient status indicator from site detail page. Co-Authored-By: Claude Opus 4.6 --- src/__tests__/checker.test.ts | 55 +---------------------------------- src/app/page.tsx | 29 +++++++----------- src/app/sites/[id]/page.tsx | 38 +++++------------------- src/lib/checker.ts | 14 --------- 4 files changed, 19 insertions(+), 117 deletions(-) diff --git a/src/__tests__/checker.test.ts b/src/__tests__/checker.test.ts index 1339735..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, isSoftFailure, computeSiteStatus } from '@/lib/checker' +import { checkSite, isSoftFailure } from '@/lib/checker' describe('checkSite', () => { beforeEach(() => { @@ -98,56 +98,3 @@ describe('isSoftFailure', () => { }) }) -describe('computeSiteStatus', () => { - test('returns "up" when all checks are successful', () => { - const checks = [ - { status: 'success', status_code: 200, error: null }, - { status: 'success', status_code: 200, error: null }, - ] - expect(computeSiteStatus(checks)).toBe('up') - }) - - test('returns "up" when there are no checks', () => { - expect(computeSiteStatus([])).toBe('up') - }) - - test('returns "failures" when there is a hard failure', () => { - const checks = [ - { status: 'success', status_code: 200, error: null }, - { status: 'failure', status_code: 500, error: 'HTTP 500' }, - ] - expect(computeSiteStatus(checks)).toBe('failures') - }) - - test('returns "transient_failures" when there are only soft failures', () => { - const checks = [ - { status: 'success', status_code: 200, error: null }, - { status: 'failure', status_code: 503, error: 'HTTP 503' }, - ] - expect(computeSiteStatus(checks)).toBe('transient_failures') - }) - - test('returns "failures" when there are both hard and soft failures', () => { - const checks = [ - { status: 'failure', status_code: 503, error: 'HTTP 503' }, - { status: 'failure', status_code: 500, error: 'HTTP 500' }, - ] - expect(computeSiteStatus(checks)).toBe('failures') - }) - - test('returns "transient_failures" for connection timeout', () => { - const checks = [ - { status: 'success', status_code: 200, error: null }, - { status: 'failure', status_code: null, error: 'Connection timeout' }, - ] - expect(computeSiteStatus(checks)).toBe('transient_failures') - }) - - test('returns "failures" for DNS failure', () => { - const checks = [ - { status: 'success', status_code: 200, error: null }, - { status: 'failure', status_code: null, error: 'getaddrinfo ENOTFOUND example.com' }, - ] - expect(computeSiteStatus(checks)).toBe('failures') - }) -}) diff --git a/src/app/page.tsx b/src/app/page.tsx index 086d9de..03195b4 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,7 +1,7 @@ import Link from "next/link" import { createClient } from "@/lib/supabase/server" import type { Site, Check, Incident } from "@/lib/supabase/types" -import { computeSiteStatus, type SiteStatus } from "@/lib/checker" +import { isSoftFailure } from "@/lib/checker" import { formatTimeAgo, formatDuration } from "@/lib/format" import AddSiteCard from "@/components/AddSiteCard" import SiteFormDialog from "@/components/SiteFormDialog" @@ -9,13 +9,12 @@ import RealtimeStatusPage from "@/components/RealtimeStatusPage" export const revalidate = 0 -type SiteWithStatus = Site & { lastCheck: Check | null; siteStatus: SiteStatus } +type SiteWithLastCheck = Site & { lastCheck: Check | null } type IncidentWithSite = Incident & { site: Site; check: Check } -const STATUS_DOT_COLORS: Record = { - up: "#2DA44E", - failures: "#C4453C", - transient_failures: "#D4A017", +function checkDotColor(check: Check | null): string { + if (!check || check.status !== "failure") return "#2DA44E" + return isSoftFailure(check.status_code, check.error) ? "#D4A017" : "#C4453C" } async function getStatusData() { @@ -32,8 +31,7 @@ async function getStatusData() { .eq("status", "open") .order("opened_at", { ascending: false }) - const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString() - const sitesWithStatus: SiteWithStatus[] = [] + const sitesWithChecks: SiteWithLastCheck[] = [] for (const site of sites ?? []) { const { data: lastChecks } = await supabase .from("checks") @@ -42,16 +40,9 @@ async function getStatusData() { .order("checked_at", { ascending: false }) .limit(1) - const { data: recentChecks } = await supabase - .from("checks") - .select("status, status_code, error") - .eq("site_id", site.id) - .gte("checked_at", oneHourAgo) - - sitesWithStatus.push({ + sitesWithChecks.push({ ...site, lastCheck: lastChecks?.[0] ?? null, - siteStatus: computeSiteStatus(recentChecks ?? []), }) } @@ -60,7 +51,7 @@ async function getStatusData() { } = await supabase.auth.getUser() return { - sites: sitesWithStatus, + sites: sitesWithChecks, incidents: (openIncidents ?? []) as IncidentWithSite[], isAdmin: !!user, } @@ -139,7 +130,7 @@ export default async function StatusPage() { className="text-xl font-bold mb-4" style={{ color: "#1A1A1A" }} > - Sites + Latest checks {sites.length === 0 && !isAdmin ? ( @@ -169,7 +160,7 @@ export default async function StatusPage() { = { - up: { color: "#2DA44E", label: "Up" }, - failures: { color: "#C4453C", label: "Failures" }, - transient_failures: { color: "#D4A017", label: "Transient Failures" }, -} - export const revalidate = 0 async function getSiteData(id: string) { @@ -38,17 +32,10 @@ async function getSiteData(id: string) { .order("checked_at", { ascending: false }) .limit(50) - const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000) - const recentChecks = (checks ?? []).filter( - (c) => new Date(c.checked_at) > oneHourAgo - ) - const siteStatus = computeSiteStatus(recentChecks) - return { site: site as Site, incidents: (incidents ?? []) as (Incident & { check: Check })[], checks: (checks ?? []) as Check[], - siteStatus, } } @@ -61,8 +48,7 @@ export default async function SiteDetailPage({ const data = await getSiteData(id) if (!data) notFound() - const { site, incidents, checks, siteStatus } = data - const statusConfig = STATUS_CONFIG[siteStatus] + const { site, incidents, checks } = data const supabase = await createClient() const { data: { user }, @@ -88,18 +74,9 @@ export default async function SiteDetailPage({ > {site.name} -
+
{site.url}
-
- - - {statusConfig.label} - -
{user && ( @@ -238,6 +215,9 @@ export default async function SiteDetailPage({ ) : ( checks.map((check, i) => { const isFailure = check.status === "failure" + const dotColor = isFailure + ? isSoftFailure(check.status_code, check.error) ? "#D4A017" : "#C4453C" + : "#2DA44E" return ( {isFailure ? check.error ?? "Failure" diff --git a/src/lib/checker.ts b/src/lib/checker.ts index 5f1f736..29c2d13 100644 --- a/src/lib/checker.ts +++ b/src/lib/checker.ts @@ -4,8 +4,6 @@ export type CheckResult = { error: string | null } -export type SiteStatus = 'up' | 'failures' | 'transient_failures' - const SOFT_FAILURE_STATUS_CODES = [502, 503, 504] export function isSoftFailure(statusCode: number | null, error: string | null): boolean { @@ -14,18 +12,6 @@ export function isSoftFailure(statusCode: number | null, error: string | null): return false } -export function computeSiteStatus( - recentChecks: { status: string; status_code: number | null; error: string | null }[] -): SiteStatus { - const failures = recentChecks.filter(c => c.status === 'failure') - if (failures.length === 0) return 'up' - - const hasHardFailure = failures.some(c => !isSoftFailure(c.status_code, c.error)) - if (hasHardFailure) return 'failures' - - return 'transient_failures' -} - export async function checkSite(url: string): Promise { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 30000) From b3561eedb9e2d2ce36fe6db4f7445888aac4dad4 Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Sun, 22 Feb 2026 14:05:57 -0800 Subject: [PATCH 5/6] refactor: extract shared CheckLogTable component Unifies the check log display between site detail and incident detail pages. Both now use the same table with red/amber/green dot colors. The incident page passes triggeringCheckId to highlight the check that opened the incident. Co-Authored-By: Claude Opus 4.6 --- src/app/incidents/[id]/page.tsx | 92 ++---------------------- src/app/sites/[id]/page.tsx | 92 +----------------------- src/components/CheckLogTable.tsx | 118 +++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 176 deletions(-) create mode 100644 src/components/CheckLogTable.tsx 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/sites/[id]/page.tsx b/src/app/sites/[id]/page.tsx index 19a0d86..a0d42d8 100644 --- a/src/app/sites/[id]/page.tsx +++ b/src/app/sites/[id]/page.tsx @@ -2,9 +2,9 @@ import Link from "next/link" import { notFound } from "next/navigation" import { createClient } from "@/lib/supabase/server" import type { Site, Check, Incident } from "@/lib/supabase/types" -import { isSoftFailure } from "@/lib/checker" import { formatTimeAgo } from "@/lib/format" import SiteFormDialog from "@/components/SiteFormDialog" +import CheckLogTable from "@/components/CheckLogTable" export const revalidate = 0 @@ -174,95 +174,7 @@ export default async function SiteDetailPage({ > Check Log -
- - - - - - - - - {checks.length === 0 ? ( - - - - ) : ( - checks.map((check, i) => { - const isFailure = check.status === "failure" - const dotColor = isFailure - ? isSoftFailure(check.status_code, check.error) ? "#D4A017" : "#C4453C" - : "#2DA44E" - 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 + + )} +
+
+
+ ) +} From dd67f9bdfa27088577bc740e468f5c121d8c206b Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Sun, 22 Feb 2026 14:38:58 -0800 Subject: [PATCH 6/6] fix: update e2e test for renamed "Latest checks" heading Co-Authored-By: Claude Opus 4.6 --- e2e/status-page.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 }) => {