mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(studio): /api/status-page endpoint backed by incident.io Widget API (#50931)
## Summary * Adds `/api/status-page` (Next route + TanStack wrapper), backed by the [incident.io](<http://incident.io>) Widget API, annotating each item with `visible`, `show_banner`, and (for scheduled maintenances) `banner_lead_days`. * Deployment-mode visibility is driven by a new `status_page:visibility_field_ids` custom-content key. * Widget array parsing is fault-tolerant: a malformed item in one array is dropped and logged rather than failing the whole response, so one bad item can't hide a real ongoing incident. * 429s from [incident.io](<http://incident.io>) are retried with equal-jitter exponential backoff, respecting `Retry-After`, up to 2 retries. * Nothing consumes this endpoint yet — it replaces no existing behavior and changes nothing user-visible. Later PRs (this is PR 1 of a stack) wire up consumers behind the `incidentIoStatusPage` ConfigCat flag. Part of [FE-4057](https://linear.app/supabase/issue/FE-4057/frontend-bannerbot-reconfigured) — see Linear for full design context. ## Test plan - [X] `pnpm --filter studio run typecheck` - [X] `pnpm --filter studio run lint:ratchet` - [X] `pnpm knip --workspace apps/studio` - [X] `pnpm test:prettier` - [X] `pnpm --filter studio exec vitest run status-page` — 44 tests passing, including a regression test built from a real production [incident.io](<http://incident.io>) payload that initially failed to parse, and a compile-time type-safety regression test for the array-parsing helper Co-authored-by: Claude Code [charis@supabase.io](<mailto:charis@supabase.io>) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a status page that displays ongoing incidents and maintenance, with visibility and banner settings based on linked incident details. * Status page data is available through a new API endpoint, with caching for successful responses and degraded results. * **Bug Fixes** * Status page data can still display when some linked incident details are unavailable; affected results are marked as degraded. * Improved handling of invalid widget entries so they don’t prevent valid items from being processed. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Code <charis@supabase.io>
This commit is contained in:
1 parent
1b5a806963
commit
b2cf3693dd
16 files changed
+1294
-4
No files matched your search
@@ -442,8 +442,9 @@ per-file items only when a subtree has special cases.
|
||||
- [x] `routes/api/v1/**` — except `body.ts` (streaming rewrite)
|
||||
- [x] `routes/api/v1/projects/$ref/functions/$slug/body.ts` — Web-streams rewrite. Returns a `Response` whose body is a `ReadableStream`; each artifact file is converted via `Readable.toWeb(createReadStream(...))` and pulled chunk-by-chunk into the multipart stream. Skips the `apiWrapper` since `getFunctionsArtifactStore` already asserts self-hosted mode (the pages-router `withAuth` was a no-op outside `IS_PLATFORM`).
|
||||
- [x] `routes/api/mcp/index.ts` — uses `WebStandardStreamableHTTPServerTransport` from `@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js`. Takes a Web `Request`, returns a `Response` directly — no shim needed. Query parsing pulled from `request.url`'s search params; headers passed through unchanged.
|
||||
- [x] `routes/api/incident-banner.ts`, `routes/api/incident-status.ts` — App
|
||||
Router routes under `app/api/**` (already Web-native, direct re-export)
|
||||
- [x] `routes/api/incident-banner.ts`, `routes/api/incident-status.ts`,
|
||||
`routes/api/status-page.ts` — App Router routes under `app/api/**`
|
||||
(already Web-native, direct re-export)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { IS_PLATFORM } from 'common'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
import { InternalServerError } from '@/lib/api/apiHelpers'
|
||||
import { getStatusPage } from '@/lib/api/status-page'
|
||||
|
||||
const CACHE_CONTROL_SUCCESS = 'public, s-maxage=300, stale-while-revalidate=300'
|
||||
const CACHE_CONTROL_DEGRADED = 'public, s-maxage=60, stale-while-revalidate=60'
|
||||
const CACHE_CONTROL_ERROR = 'no-store'
|
||||
|
||||
export async function OPTIONS() {
|
||||
if (!IS_PLATFORM) return new Response(null, { status: 404 })
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: { Allow: 'GET, HEAD, OPTIONS' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function HEAD() {
|
||||
if (!IS_PLATFORM) return new Response(null, { status: 404 })
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'Cache-Control': CACHE_CONTROL_SUCCESS },
|
||||
})
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
if (!IS_PLATFORM) return new Response(null, { status: 404 })
|
||||
|
||||
try {
|
||||
const { data, isDegraded } = await getStatusPage()
|
||||
return NextResponse.json(data, {
|
||||
headers: { 'Cache-Control': isDegraded ? CACHE_CONTROL_DEGRADED : CACHE_CONTROL_SUCCESS },
|
||||
})
|
||||
} catch (error) {
|
||||
let errorCode = 500
|
||||
const headers = new Headers({ 'Cache-Control': CACHE_CONTROL_ERROR })
|
||||
|
||||
if (error instanceof InternalServerError) {
|
||||
if (typeof error.details?.status === 'number') errorCode = error.details.status
|
||||
if (errorCode === 420) errorCode = 429
|
||||
if (errorCode === 429 && typeof error.details?.retryAfter === 'string') {
|
||||
headers.set('Retry-After', error.details.retryAfter)
|
||||
}
|
||||
console.error(
|
||||
'Failed to fetch incident.io status page: %s',
|
||||
JSON.stringify(
|
||||
{
|
||||
message: error.message,
|
||||
details: error.details,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
} else {
|
||||
console.error('Unexpected error fetching incident.io status page:', error)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: { message: 'Internal server error' } },
|
||||
{ status: errorCode, headers }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -39,4 +39,6 @@ export type CustomContentTypes = {
|
||||
infraAwsNimbusLabel: string
|
||||
|
||||
sslCertificateUrl: string
|
||||
|
||||
statusPageVisibilityFieldIds: Array<string>
|
||||
}
|
||||
@@ -60,5 +60,7 @@
|
||||
"infra:cloud_providers": ["AWS", "AWS_K8S", "FLY"],
|
||||
"infra:aws_nimbus_label": "AWS Nimbus",
|
||||
|
||||
"ssl:certificate_url": "https://supabase-downloads.s3-ap-southeast-1.amazonaws.com/${env}/ssl/${env}-ca-2021.crt"
|
||||
"ssl:certificate_url": "https://supabase-downloads.s3-ap-southeast-1.amazonaws.com/${env}/ssl/${env}-ca-2021.crt",
|
||||
|
||||
"status_page:visibility_field_ids": ["", "01M3D1YZJ1FV5495GP53VMZKCY"]
|
||||
}
|
||||
@@ -60,5 +60,7 @@
|
||||
"infra:cloud_providers": ["AWS_NIMBUS"],
|
||||
"infra:aws_nimbus_label": "AWS Nimbus",
|
||||
|
||||
"ssl:certificate_url": "https://supabase-downloads.s3-ap-southeast-1.amazonaws.com/${env}/ssl/${env}-ca-2021.crt"
|
||||
"ssl:certificate_url": "https://supabase-downloads.s3-ap-southeast-1.amazonaws.com/${env}/ssl/${env}-ca-2021.crt",
|
||||
|
||||
"status_page:visibility_field_ids": ["01TESTOWNFIELDID000000000", "02TESTOTHERFIELDID00000000"]
|
||||
}
|
||||
@@ -105,6 +105,14 @@
|
||||
"ssl:certificate_url": {
|
||||
"type": "string",
|
||||
"description": "The URL of the SSL certificate"
|
||||
},
|
||||
|
||||
"status_page:visibility_field_ids": {
|
||||
"type": "array",
|
||||
"description": "Deployment-mode visibility field IDs on incident.io response incidents. Element 0 is this build's own custom-field ID (empty string = default Supabase build); the rest are other modes' field IDs.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { mswServer } from '@/tests/lib/msw'
|
||||
|
||||
vi.mock('common', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('common')>()
|
||||
return { ...actual, IS_PLATFORM: true }
|
||||
})
|
||||
|
||||
const WIDGET_URL = 'https://api.incident.io/widget'
|
||||
const API_KEY = 'test-api-key'
|
||||
const STATUS_PAGE_ID = 'test-status-page-id'
|
||||
|
||||
function stubHappyEnv() {
|
||||
vi.stubEnv('INCIDENT_IO_WIDGET_URL', WIDGET_URL)
|
||||
vi.stubEnv('INCIDENT_IO_API_KEY', API_KEY)
|
||||
vi.stubEnv('INCIDENT_IO_STATUS_PAGE_ID', STATUS_PAGE_ID)
|
||||
}
|
||||
|
||||
function widgetFixture() {
|
||||
return {
|
||||
page_title: 'Supabase Status',
|
||||
page_url: 'https://status.supabase.com',
|
||||
ongoing_incidents: [
|
||||
{
|
||||
id: 'incident-1',
|
||||
name: 'Elevated error rates',
|
||||
url: 'https://status.supabase.com/incidents/incident-1',
|
||||
last_update_at: '2026-01-01T00:00:00Z',
|
||||
last_update_message: 'Investigating',
|
||||
status: 'investigating',
|
||||
current_worst_impact: 'partial_outage',
|
||||
affected_components: [
|
||||
{
|
||||
id: 'component-1',
|
||||
name: 'API',
|
||||
group_name: null,
|
||||
current_status: 'partial_outage',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
in_progress_maintenances: [],
|
||||
scheduled_maintenances: [],
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('getStatusPage', () => {
|
||||
test('happy path: annotates the item from its linked response incident', async () => {
|
||||
stubHappyEnv()
|
||||
mswServer.use(
|
||||
http.get(WIDGET_URL, () => HttpResponse.json(widgetFixture())),
|
||||
http.get('*/v1/status-pages/:statusPageId/incidents/:itemId/response-incidents', () =>
|
||||
HttpResponse.json({
|
||||
incidents: [{ id: 'response-incident-1', linked_at: '2026-01-01T00:00:00Z' }],
|
||||
})
|
||||
),
|
||||
http.get('*/v2/incidents/:id', () =>
|
||||
HttpResponse.json({
|
||||
incident: {
|
||||
id: 'response-incident-1',
|
||||
custom_field_entries: [],
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const { getStatusPage } = await import('./status-page')
|
||||
const result = await getStatusPage()
|
||||
|
||||
expect(result.isDegraded).toBe(false)
|
||||
expect(result.data.ongoing_incidents[0]).toMatchObject({
|
||||
id: 'incident-1',
|
||||
visible: true,
|
||||
show_banner: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('link endpoint 404 → item gets default annotations, isDegraded stays false', async () => {
|
||||
stubHappyEnv()
|
||||
mswServer.use(
|
||||
http.get(WIDGET_URL, () => HttpResponse.json(widgetFixture())),
|
||||
http.get(
|
||||
'*/v1/status-pages/:statusPageId/incidents/:itemId/response-incidents',
|
||||
() => new HttpResponse(null, { status: 404 })
|
||||
)
|
||||
)
|
||||
|
||||
const { getStatusPage } = await import('./status-page')
|
||||
const result = await getStatusPage()
|
||||
|
||||
expect(result.data.ongoing_incidents[0]).toMatchObject({
|
||||
visible: true,
|
||||
show_banner: true,
|
||||
})
|
||||
expect(result.isDegraded).toBe(false)
|
||||
})
|
||||
|
||||
test('/v2/incidents/:id failing → item gets default annotations AND isDegraded true', async () => {
|
||||
stubHappyEnv()
|
||||
mswServer.use(
|
||||
http.get(WIDGET_URL, () => HttpResponse.json(widgetFixture())),
|
||||
http.get('*/v1/status-pages/:statusPageId/incidents/:itemId/response-incidents', () =>
|
||||
HttpResponse.json({
|
||||
incidents: [{ id: 'response-incident-1', linked_at: '2026-01-01T00:00:00Z' }],
|
||||
})
|
||||
),
|
||||
http.get('*/v2/incidents/:id', () => new HttpResponse(null, { status: 500 }))
|
||||
)
|
||||
|
||||
const { getStatusPage } = await import('./status-page')
|
||||
const result = await getStatusPage()
|
||||
|
||||
expect(result.data.ongoing_incidents[0]).toMatchObject({
|
||||
visible: true,
|
||||
show_banner: true,
|
||||
})
|
||||
expect(result.isDegraded).toBe(true)
|
||||
})
|
||||
|
||||
test('widget returns 503 → getStatusPage rejects', async () => {
|
||||
stubHappyEnv()
|
||||
mswServer.use(http.get(WIDGET_URL, () => new HttpResponse(null, { status: 503 })))
|
||||
|
||||
const { getStatusPage } = await import('./status-page')
|
||||
await expect(getStatusPage()).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('missing API key / status page ID → succeeds with default annotations everywhere, isDegraded true', async () => {
|
||||
vi.stubEnv('INCIDENT_IO_WIDGET_URL', WIDGET_URL)
|
||||
vi.stubEnv('INCIDENT_IO_API_KEY', '')
|
||||
vi.stubEnv('INCIDENT_IO_STATUS_PAGE_ID', '')
|
||||
mswServer.use(http.get(WIDGET_URL, () => HttpResponse.json(widgetFixture())))
|
||||
|
||||
const { getStatusPage } = await import('./status-page')
|
||||
const result = await getStatusPage()
|
||||
|
||||
expect(result.isDegraded).toBe(true)
|
||||
expect(result.data.ongoing_incidents[0]).toMatchObject({
|
||||
visible: true,
|
||||
show_banner: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('real payload: scheduled maintenance without starts_at/ends_at still parses', async () => {
|
||||
stubHappyEnv()
|
||||
|
||||
const realPayload = {
|
||||
page_title: 'Supabase',
|
||||
page_url: 'https://statuspage.incident.io/supabase',
|
||||
ongoing_incidents: [
|
||||
{
|
||||
name: '401 errors due to JWT rejections',
|
||||
status: 'identified',
|
||||
url: 'https://statuspage.incident.io/supabase/incidents/01KZZ19EQMSC9DNDA15ZGVY19V',
|
||||
id: '01KZZ19EQMSC9DNDA15ZGVY19V',
|
||||
current_worst_impact: 'degraded_performance',
|
||||
affected_components: [
|
||||
{
|
||||
id: '01KJZVWMNKQ0HV8DXHRSSERV5T',
|
||||
name: 'API Gateway',
|
||||
current_status: 'degraded_performance',
|
||||
},
|
||||
],
|
||||
last_update_at: '2026-09-25T16:23:02.23Z',
|
||||
last_update_message:
|
||||
'The deployment process changes have been rolled out across all regions. We will monitor throughout the weekend.',
|
||||
},
|
||||
],
|
||||
in_progress_maintenances: [],
|
||||
scheduled_maintenances: [
|
||||
{
|
||||
id: '01KWA5M6EAYR6F74N18REZNYQ1',
|
||||
name: 'Scheduled Platform Maintenance 07/02',
|
||||
status: 'maintenance_scheduled',
|
||||
url: 'https://statuspage.incident.io/supabase/incidents/01KWA5M6EAYR6F74N18REZNYQ1',
|
||||
affected_components: [],
|
||||
last_update_at: '2026-06-29T16:56:35.249Z',
|
||||
last_update_message: 'We will be carrying out a scheduled maintenance window...',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
mswServer.use(
|
||||
http.get(WIDGET_URL, () => HttpResponse.json(realPayload)),
|
||||
http.get(
|
||||
'*/v1/status-pages/:statusPageId/incidents/:itemId/response-incidents',
|
||||
() => new HttpResponse(null, { status: 404 })
|
||||
)
|
||||
)
|
||||
|
||||
const { getStatusPage } = await import('./status-page')
|
||||
const result = await getStatusPage()
|
||||
|
||||
expect(result.isDegraded).toBe(false)
|
||||
expect(result.data.ongoing_incidents).toHaveLength(1)
|
||||
expect(result.data.scheduled_maintenances).toHaveLength(1)
|
||||
expect(result.data.scheduled_maintenances[0]).toMatchObject({
|
||||
id: '01KWA5M6EAYR6F74N18REZNYQ1',
|
||||
visible: true,
|
||||
show_banner: true,
|
||||
banner_lead_days: null,
|
||||
})
|
||||
expect(result.data.scheduled_maintenances[0].starts_at).toBeUndefined()
|
||||
expect(result.data.scheduled_maintenances[0].ends_at).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchWithRetry backoff (via getStatusPage widget fetch)', () => {
|
||||
test('a Retry-After that parses to 0 does not skip the equal-jitter backoff floor', async () => {
|
||||
stubHappyEnv()
|
||||
vi.useFakeTimers()
|
||||
// Equal jitter is `base / 2 + random() * (base / 2)`; pinning random() to 0 collapses it to
|
||||
// the deterministic floor (base / 2) so the wait boundary below is exact, not probabilistic.
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0)
|
||||
|
||||
let widgetCallCount = 0
|
||||
mswServer.use(
|
||||
http.get(WIDGET_URL, () => {
|
||||
widgetCallCount++
|
||||
if (widgetCallCount === 1) {
|
||||
return new HttpResponse(null, {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': new Date(Date.now() - 10_000).toUTCString() },
|
||||
})
|
||||
}
|
||||
return HttpResponse.json(widgetFixture())
|
||||
}),
|
||||
http.get(
|
||||
'*/v1/status-pages/:statusPageId/incidents/:itemId/response-incidents',
|
||||
() => new HttpResponse(null, { status: 404 })
|
||||
)
|
||||
)
|
||||
|
||||
const { getStatusPage } = await import('./status-page')
|
||||
const resultPromise = getStatusPage()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(widgetCallCount).toBe(1)
|
||||
|
||||
// Backoff floor for the first retry (attempt 0) is (1000 * 2 ** 0) / 2 = 500ms with
|
||||
// Math.random() pinned to 0.
|
||||
await vi.advanceTimersByTimeAsync(499)
|
||||
expect(widgetCallCount).toBe(1)
|
||||
|
||||
// Crossing the floor triggers the retry.
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(widgetCallCount).toBe(2)
|
||||
|
||||
const result = await resultPromise
|
||||
expect(result.data.ongoing_incidents[0].id).toBe('incident-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/status-page route Cache-Control header', () => {
|
||||
test('success → 300s cache with 300s stale-while-revalidate', async () => {
|
||||
stubHappyEnv()
|
||||
mswServer.use(
|
||||
http.get(WIDGET_URL, () => HttpResponse.json(widgetFixture())),
|
||||
http.get(
|
||||
'*/v1/status-pages/:statusPageId/incidents/:itemId/response-incidents',
|
||||
() => new HttpResponse(null, { status: 404 })
|
||||
)
|
||||
)
|
||||
|
||||
const { GET } = await import('../../app/api/status-page/route')
|
||||
const response = await GET()
|
||||
|
||||
expect(response.headers.get('Cache-Control')).toBe(
|
||||
'public, s-maxage=300, stale-while-revalidate=300'
|
||||
)
|
||||
})
|
||||
|
||||
test('degraded (missing config) → 60s cache with 60s stale-while-revalidate', async () => {
|
||||
vi.stubEnv('INCIDENT_IO_WIDGET_URL', WIDGET_URL)
|
||||
vi.stubEnv('INCIDENT_IO_API_KEY', '')
|
||||
vi.stubEnv('INCIDENT_IO_STATUS_PAGE_ID', '')
|
||||
mswServer.use(http.get(WIDGET_URL, () => HttpResponse.json(widgetFixture())))
|
||||
|
||||
const { GET } = await import('../../app/api/status-page/route')
|
||||
const response = await GET()
|
||||
|
||||
expect(response.headers.get('Cache-Control')).toBe(
|
||||
'public, s-maxage=60, stale-while-revalidate=60'
|
||||
)
|
||||
})
|
||||
|
||||
test('error (widget 503) → no-store', async () => {
|
||||
stubHappyEnv()
|
||||
mswServer.use(http.get(WIDGET_URL, () => new HttpResponse(null, { status: 503 })))
|
||||
|
||||
const { GET } = await import('../../app/api/status-page/route')
|
||||
const response = await GET()
|
||||
|
||||
expect(response.headers.get('Cache-Control')).toBe('no-store')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import customContentRaw from '@/hooks/custom-content/custom-content.json'
|
||||
import { InternalServerError } from '@/lib/api/apiHelpers'
|
||||
import {
|
||||
annotateWidget,
|
||||
mergeLinkedFlags,
|
||||
type ResponseIncident,
|
||||
type ResponseIncidentFlags,
|
||||
} from '@/lib/api/status-page.utils'
|
||||
import {
|
||||
WidgetResponseSchema,
|
||||
type StatusPageResponse,
|
||||
type WidgetResponse,
|
||||
} from '@/lib/status-page/status-page.schema'
|
||||
|
||||
const HIDE_BANNER_FIELD_ID = '01M3D30MY0GQ1284ERHHV4DFAT'
|
||||
const BANNER_LEAD_DAYS_FIELD_ID = '01M3D332BQVSDE4ZGK86X6X69V'
|
||||
|
||||
const WIDGET_TIMEOUT_MS = 10_000
|
||||
const LINK_TIMEOUT_MS = 5_000
|
||||
const INCIDENT_TIMEOUT_MS = 5_000
|
||||
const MAX_RETRIES = 2
|
||||
|
||||
export type StatusPageResult = { data: StatusPageResponse; isDegraded: boolean }
|
||||
|
||||
const LinkedResponseIncidentsSchema = z.object({
|
||||
incidents: z.array(z.object({ id: z.string(), linked_at: z.string() })),
|
||||
})
|
||||
|
||||
const CustomFieldValueSchema = z.object({
|
||||
value_option: z.object({ value: z.string() }).nullish(),
|
||||
value_text: z.string().nullish(),
|
||||
value_numeric: z.string().nullish(),
|
||||
})
|
||||
|
||||
const ResponseIncidentSchema = z.object({
|
||||
incident: z.object({
|
||||
id: z.string(),
|
||||
custom_field_entries: z.array(
|
||||
z.object({
|
||||
custom_field: z.object({ id: z.string() }),
|
||||
values: z.array(CustomFieldValueSchema),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
function parseRetryAfterMs(header: string | null): number | undefined {
|
||||
if (!header) return undefined
|
||||
|
||||
const seconds = Number(header)
|
||||
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000
|
||||
|
||||
const dateMs = Date.parse(header)
|
||||
if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now())
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function backoffWithJitter(attempt: number): number {
|
||||
const base = 1000 * 2 ** attempt
|
||||
return base / 2 + Math.random() * (base / 2)
|
||||
}
|
||||
|
||||
// Shared by all three incident.io calls: on a 429, wait for the Retry-After hint (or an
|
||||
// exponential fallback) and retry, up to MAX_RETRIES times, then return the still-429 response
|
||||
// to the caller.
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeoutMs: number
|
||||
): Promise<Response> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const response = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) })
|
||||
|
||||
if (response.status !== 429 || attempt >= MAX_RETRIES) {
|
||||
return response
|
||||
}
|
||||
|
||||
const retryAfterMs = parseRetryAfterMs(response.headers.get('Retry-After'))
|
||||
await sleep(Math.max(retryAfterMs ?? 0, backoffWithJitter(attempt)))
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWidget(url: string): Promise<WidgetResponse> {
|
||||
const response = await fetchWithRetry(url, { cache: 'no-store' }, WIDGET_TIMEOUT_MS)
|
||||
|
||||
if (!response.ok) {
|
||||
const retryAfter = response.headers.get('Retry-After') ?? undefined
|
||||
const body = await response.text()
|
||||
throw new InternalServerError(`incident.io Widget API responded with ${response.status}`, {
|
||||
status: response.status,
|
||||
body,
|
||||
...(retryAfter !== undefined && { retryAfter }),
|
||||
})
|
||||
}
|
||||
|
||||
const json = await response.json()
|
||||
const result = WidgetResponseSchema.safeParse(json)
|
||||
if (!result.success) {
|
||||
throw new InternalServerError('incident.io Widget API response did not match expected schema', {
|
||||
issues: result.error.issues,
|
||||
})
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
async function fetchLinkedResponseIncidentIds(
|
||||
apiKey: string,
|
||||
statusPageId: string,
|
||||
itemId: string
|
||||
): Promise<Array<string>> {
|
||||
const response = await fetchWithRetry(
|
||||
`https://api.incident.io/v1/status-pages/${statusPageId}/incidents/${itemId}/response-incidents`,
|
||||
{ headers: { Authorization: `Bearer ${apiKey}` } },
|
||||
LINK_TIMEOUT_MS
|
||||
)
|
||||
|
||||
if (response.status === 404) return []
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`incident.io response-incidents lookup responded with ${response.status}`)
|
||||
}
|
||||
|
||||
const json = await response.json()
|
||||
const result = LinkedResponseIncidentsSchema.parse(json)
|
||||
return result.incidents.map((incident) => incident.id)
|
||||
}
|
||||
|
||||
async function fetchResponseIncident(apiKey: string, id: string): Promise<ResponseIncident> {
|
||||
const response = await fetchWithRetry(
|
||||
`https://api.incident.io/v2/incidents/${id}`,
|
||||
{ headers: { Authorization: `Bearer ${apiKey}` } },
|
||||
INCIDENT_TIMEOUT_MS
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`incident.io incident lookup responded with ${response.status}`)
|
||||
}
|
||||
|
||||
const json = await response.json()
|
||||
return ResponseIncidentSchema.parse(json).incident
|
||||
}
|
||||
|
||||
export async function getStatusPage(): Promise<StatusPageResult> {
|
||||
const widgetUrl = process.env.INCIDENT_IO_WIDGET_URL
|
||||
if (!widgetUrl) {
|
||||
throw new InternalServerError('INCIDENT_IO_WIDGET_URL is not set')
|
||||
}
|
||||
|
||||
const widget = await fetchWidget(widgetUrl)
|
||||
|
||||
const modeFieldIds = customContentRaw['status_page:visibility_field_ids'] ?? ['']
|
||||
|
||||
const apiKey = process.env.INCIDENT_IO_API_KEY
|
||||
const statusPageId = process.env.INCIDENT_IO_STATUS_PAGE_ID
|
||||
|
||||
if (!apiKey || !statusPageId) {
|
||||
console.error('INCIDENT_IO_API_KEY or INCIDENT_IO_STATUS_PAGE_ID is not set')
|
||||
return { data: annotateWidget(widget, new Map(), modeFieldIds), isDegraded: true }
|
||||
}
|
||||
|
||||
let isDegraded = false
|
||||
|
||||
const allItems = [
|
||||
...widget.ongoing_incidents,
|
||||
...widget.in_progress_maintenances,
|
||||
...widget.scheduled_maintenances,
|
||||
]
|
||||
|
||||
const linkResults = await Promise.allSettled(
|
||||
allItems.map((item) => fetchLinkedResponseIncidentIds(apiKey, statusPageId, item.id))
|
||||
)
|
||||
|
||||
const linkedIdsByItemId = new Map<string, Array<string>>()
|
||||
linkResults.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
linkedIdsByItemId.set(allItems[index].id, result.value)
|
||||
} else {
|
||||
isDegraded = true
|
||||
}
|
||||
})
|
||||
|
||||
const uniqueLinkedIds = Array.from(new Set(Array.from(linkedIdsByItemId.values()).flat()))
|
||||
|
||||
const incidentResults = await Promise.allSettled(
|
||||
uniqueLinkedIds.map((id) => fetchResponseIncident(apiKey, id))
|
||||
)
|
||||
|
||||
const incidentsById = new Map<string, ResponseIncident>()
|
||||
incidentResults.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
incidentsById.set(uniqueLinkedIds[index], result.value)
|
||||
} else {
|
||||
isDegraded = true
|
||||
}
|
||||
})
|
||||
|
||||
const flagsById = new Map<string, ResponseIncidentFlags>()
|
||||
for (const item of allItems) {
|
||||
const linkedIncidents = (linkedIdsByItemId.get(item.id) ?? [])
|
||||
.map((id) => incidentsById.get(id))
|
||||
.filter((incident): incident is ResponseIncident => incident !== undefined)
|
||||
|
||||
if (linkedIncidents.length > 0) {
|
||||
flagsById.set(
|
||||
item.id,
|
||||
mergeLinkedFlags(linkedIncidents, modeFieldIds, {
|
||||
hideBannerFieldId: HIDE_BANNER_FIELD_ID,
|
||||
leadDaysFieldId: BANNER_LEAD_DAYS_FIELD_ID,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return { data: annotateWidget(widget, flagsById, modeFieldIds), isDegraded }
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
annotateWidget,
|
||||
computeAnnotations,
|
||||
mergeLinkedFlags,
|
||||
readCustomFieldFlag,
|
||||
readCustomFieldNumber,
|
||||
type CustomFieldEntry,
|
||||
type ResponseIncident,
|
||||
type ResponseIncidentFlags,
|
||||
} from './status-page.utils'
|
||||
import type { WidgetResponse } from '@/lib/status-page/status-page.schema'
|
||||
|
||||
const FIELD_ID = 'field-1'
|
||||
const OTHER_FIELD_ID = 'field-2'
|
||||
|
||||
function entriesWith(fieldId: string, values: CustomFieldEntry['values']): Array<CustomFieldEntry> {
|
||||
return [{ custom_field: { id: fieldId }, values }]
|
||||
}
|
||||
|
||||
describe('readCustomFieldFlag', () => {
|
||||
test('matches via value_option', () => {
|
||||
const entries = entriesWith(FIELD_ID, [{ value_option: { value: 'true' } }])
|
||||
expect(readCustomFieldFlag(entries, FIELD_ID)).toBe(true)
|
||||
})
|
||||
|
||||
test('matches via value_text', () => {
|
||||
const entries = entriesWith(FIELD_ID, [{ value_text: 'true' }])
|
||||
expect(readCustomFieldFlag(entries, FIELD_ID)).toBe(true)
|
||||
})
|
||||
|
||||
test('matches via value_numeric (non-zero → true)', () => {
|
||||
const entries = entriesWith(FIELD_ID, [{ value_numeric: '5' }])
|
||||
expect(readCustomFieldFlag(entries, FIELD_ID)).toBe(true)
|
||||
})
|
||||
|
||||
test('value_numeric of 0 → false', () => {
|
||||
const entries = entriesWith(FIELD_ID, [{ value_numeric: '0' }])
|
||||
expect(readCustomFieldFlag(entries, FIELD_ID)).toBe(false)
|
||||
})
|
||||
|
||||
test('value_numeric negative → false', () => {
|
||||
const entries = entriesWith(FIELD_ID, [{ value_numeric: '-5' }])
|
||||
expect(readCustomFieldFlag(entries, FIELD_ID)).toBe(false)
|
||||
})
|
||||
|
||||
test('missing entry → false', () => {
|
||||
const entries = entriesWith(OTHER_FIELD_ID, [{ value_text: 'true' }])
|
||||
expect(readCustomFieldFlag(entries, FIELD_ID)).toBe(false)
|
||||
})
|
||||
|
||||
test('empty fieldId → false (no-op)', () => {
|
||||
const entries = entriesWith(FIELD_ID, [{ value_text: 'true' }])
|
||||
expect(readCustomFieldFlag(entries, '')).toBe(false)
|
||||
})
|
||||
|
||||
test('is case-insensitive', () => {
|
||||
const entries = entriesWith(FIELD_ID, [{ value_option: { value: 'TRUE' } }])
|
||||
expect(readCustomFieldFlag(entries, FIELD_ID)).toBe(true)
|
||||
|
||||
const entries2 = entriesWith(FIELD_ID, [{ value_text: 'Yes' }])
|
||||
expect(readCustomFieldFlag(entries2, FIELD_ID)).toBe(true)
|
||||
})
|
||||
|
||||
test('trims whitespace', () => {
|
||||
const entries = entriesWith(FIELD_ID, [{ value_text: ' true ' }])
|
||||
expect(readCustomFieldFlag(entries, FIELD_ID)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readCustomFieldNumber', () => {
|
||||
test('reads a valid positive value via value_numeric', () => {
|
||||
const entries = entriesWith(FIELD_ID, [{ value_numeric: '7' }])
|
||||
expect(readCustomFieldNumber(entries, FIELD_ID)).toBe(7)
|
||||
})
|
||||
|
||||
test('reads a valid positive value via value_option', () => {
|
||||
const entries = entriesWith(FIELD_ID, [{ value_option: { value: '3' } }])
|
||||
expect(readCustomFieldNumber(entries, FIELD_ID)).toBe(3)
|
||||
})
|
||||
|
||||
test('reads a valid positive value via value_text', () => {
|
||||
const entries = entriesWith(FIELD_ID, [{ value_text: '10' }])
|
||||
expect(readCustomFieldNumber(entries, FIELD_ID)).toBe(10)
|
||||
})
|
||||
|
||||
test('0 → null', () => {
|
||||
const entries = entriesWith(FIELD_ID, [{ value_numeric: '0' }])
|
||||
expect(readCustomFieldNumber(entries, FIELD_ID)).toBe(null)
|
||||
})
|
||||
|
||||
test('negative number → null', () => {
|
||||
const entries = entriesWith(FIELD_ID, [{ value_numeric: '-5' }])
|
||||
expect(readCustomFieldNumber(entries, FIELD_ID)).toBe(null)
|
||||
})
|
||||
|
||||
test('non-numeric string (NaN) → null', () => {
|
||||
const entries = entriesWith(FIELD_ID, [{ value_text: 'not-a-number' }])
|
||||
expect(readCustomFieldNumber(entries, FIELD_ID)).toBe(null)
|
||||
})
|
||||
|
||||
test('empty fieldId → null', () => {
|
||||
const entries = entriesWith(FIELD_ID, [{ value_numeric: '7' }])
|
||||
expect(readCustomFieldNumber(entries, '')).toBe(null)
|
||||
})
|
||||
|
||||
test('missing entry → null', () => {
|
||||
const entries = entriesWith(OTHER_FIELD_ID, [{ value_numeric: '7' }])
|
||||
expect(readCustomFieldNumber(entries, FIELD_ID)).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeLinkedFlags', () => {
|
||||
const fieldIds = { hideBannerFieldId: 'hide-banner', leadDaysFieldId: 'lead-days' }
|
||||
|
||||
test('OR-merges modeFlags across multiple linked incidents', () => {
|
||||
const incidentA: ResponseIncident = {
|
||||
id: 'a',
|
||||
custom_field_entries: entriesWith('mode-1', [{ value_text: 'true' }]),
|
||||
}
|
||||
const incidentB: ResponseIncident = {
|
||||
id: 'b',
|
||||
custom_field_entries: entriesWith('mode-1', [{ value_text: 'false' }]),
|
||||
}
|
||||
|
||||
const result = mergeLinkedFlags([incidentA, incidentB], ['mode-1'], fieldIds)
|
||||
expect(result.modeFlags['mode-1']).toBe(true)
|
||||
})
|
||||
|
||||
test('AND-merges hideBanner: all must have it set for it to end up true', () => {
|
||||
const incidentA: ResponseIncident = {
|
||||
id: 'a',
|
||||
custom_field_entries: entriesWith('hide-banner', [{ value_text: 'true' }]),
|
||||
}
|
||||
const incidentB: ResponseIncident = {
|
||||
id: 'b',
|
||||
custom_field_entries: entriesWith('hide-banner', [{ value_text: 'true' }]),
|
||||
}
|
||||
|
||||
const result = mergeLinkedFlags([incidentA, incidentB], [], fieldIds)
|
||||
expect(result.hideBanner).toBe(true)
|
||||
})
|
||||
|
||||
test('one incident missing hideBanner brings the merge to false', () => {
|
||||
const incidentA: ResponseIncident = {
|
||||
id: 'a',
|
||||
custom_field_entries: entriesWith('hide-banner', [{ value_text: 'true' }]),
|
||||
}
|
||||
const incidentB: ResponseIncident = {
|
||||
id: 'b',
|
||||
custom_field_entries: entriesWith('hide-banner', [{ value_text: 'false' }]),
|
||||
}
|
||||
|
||||
const result = mergeLinkedFlags([incidentA, incidentB], [], fieldIds)
|
||||
expect(result.hideBanner).toBe(false)
|
||||
})
|
||||
|
||||
test('leadDays picks the max of the non-null values across incidents', () => {
|
||||
const incidentA: ResponseIncident = {
|
||||
id: 'a',
|
||||
custom_field_entries: entriesWith('lead-days', [{ value_numeric: '3' }]),
|
||||
}
|
||||
const incidentB: ResponseIncident = {
|
||||
id: 'b',
|
||||
custom_field_entries: entriesWith('lead-days', [{ value_numeric: '10' }]),
|
||||
}
|
||||
|
||||
const result = mergeLinkedFlags([incidentA, incidentB], [], fieldIds)
|
||||
expect(result.leadDays).toBe(10)
|
||||
})
|
||||
|
||||
test('leadDays is null when none set it', () => {
|
||||
const incidentA: ResponseIncident = { id: 'a', custom_field_entries: [] }
|
||||
|
||||
const result = mergeLinkedFlags([incidentA], [], fieldIds)
|
||||
expect(result.leadDays).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeAnnotations', () => {
|
||||
test('flags undefined → all-permissive defaults', () => {
|
||||
expect(computeAnnotations(undefined, [''])).toEqual({
|
||||
visible: true,
|
||||
show_banner: true,
|
||||
banner_lead_days: null,
|
||||
})
|
||||
})
|
||||
|
||||
test('hideBanner true → show_banner false', () => {
|
||||
const flags: ResponseIncidentFlags = { hideBanner: true, modeFlags: {}, leadDays: null }
|
||||
expect(computeAnnotations(flags, ['']).show_banner).toBe(false)
|
||||
})
|
||||
|
||||
test('hideBanner false → show_banner true', () => {
|
||||
const flags: ResponseIncidentFlags = { hideBanner: false, modeFlags: {}, leadDays: null }
|
||||
expect(computeAnnotations(flags, ['']).show_banner).toBe(true)
|
||||
})
|
||||
|
||||
test('own mode ID empty + another mode flag set → hidden', () => {
|
||||
const flags: ResponseIncidentFlags = {
|
||||
hideBanner: false,
|
||||
modeFlags: { 'other-mode': true },
|
||||
leadDays: null,
|
||||
}
|
||||
expect(computeAnnotations(flags, ['', 'other-mode']).visible).toBe(false)
|
||||
})
|
||||
|
||||
test('own mode ID set + own flag true, even if another is also true → visible', () => {
|
||||
const flags: ResponseIncidentFlags = {
|
||||
hideBanner: false,
|
||||
modeFlags: { 'own-mode': true, 'other-mode': true },
|
||||
leadDays: null,
|
||||
}
|
||||
expect(computeAnnotations(flags, ['own-mode', 'other-mode']).visible).toBe(true)
|
||||
})
|
||||
|
||||
test('own mode ID set + own flag false + another flag true → hidden', () => {
|
||||
const flags: ResponseIncidentFlags = {
|
||||
hideBanner: false,
|
||||
modeFlags: { 'own-mode': false, 'other-mode': true },
|
||||
leadDays: null,
|
||||
}
|
||||
expect(computeAnnotations(flags, ['own-mode', 'other-mode']).visible).toBe(false)
|
||||
})
|
||||
|
||||
test('nothing set anywhere → visible', () => {
|
||||
const flags: ResponseIncidentFlags = {
|
||||
hideBanner: false,
|
||||
modeFlags: { 'own-mode': false, 'other-mode': false },
|
||||
leadDays: null,
|
||||
}
|
||||
expect(computeAnnotations(flags, ['own-mode', 'other-mode']).visible).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('annotateWidget', () => {
|
||||
const baseIncident = {
|
||||
id: 'incident-1',
|
||||
name: 'Incident 1',
|
||||
url: 'https://status.example.com/incidents/incident-1',
|
||||
last_update_at: '2026-01-01T00:00:00Z',
|
||||
last_update_message: 'Investigating',
|
||||
affected_components: [],
|
||||
status: 'investigating' as const,
|
||||
current_worst_impact: 'partial_outage' as const,
|
||||
}
|
||||
|
||||
const baseInProgressMaintenance = {
|
||||
id: 'maintenance-1',
|
||||
name: 'Maintenance 1',
|
||||
url: 'https://status.example.com/maintenances/maintenance-1',
|
||||
last_update_at: '2026-01-01T00:00:00Z',
|
||||
last_update_message: null,
|
||||
affected_components: [],
|
||||
status: 'maintenance_in_progress' as const,
|
||||
started_at: '2026-01-01T00:00:00Z',
|
||||
scheduled_end_at: null,
|
||||
}
|
||||
|
||||
const baseScheduledMaintenance = {
|
||||
id: 'scheduled-1',
|
||||
name: 'Scheduled 1',
|
||||
url: 'https://status.example.com/maintenances/scheduled-1',
|
||||
last_update_at: '2026-01-01T00:00:00Z',
|
||||
last_update_message: null,
|
||||
affected_components: [],
|
||||
status: 'maintenance_scheduled' as const,
|
||||
starts_at: '2026-02-01T00:00:00Z',
|
||||
ends_at: '2026-02-02T00:00:00Z',
|
||||
}
|
||||
|
||||
const widget: WidgetResponse = {
|
||||
page_title: 'Supabase Status',
|
||||
page_url: 'https://status.example.com',
|
||||
ongoing_incidents: [baseIncident],
|
||||
in_progress_maintenances: [baseInProgressMaintenance],
|
||||
scheduled_maintenances: [baseScheduledMaintenance],
|
||||
}
|
||||
|
||||
test('preserves non-annotation fields, and gates visibility/annotations per item', () => {
|
||||
const flagsById = new Map<string, ResponseIncidentFlags>([
|
||||
['incident-1', { hideBanner: true, modeFlags: {}, leadDays: 5 }],
|
||||
])
|
||||
|
||||
const result = annotateWidget(widget, flagsById, [''])
|
||||
|
||||
expect(result.page_title).toBe('Supabase Status')
|
||||
expect(result.page_url).toBe('https://status.example.com')
|
||||
|
||||
expect(result.ongoing_incidents[0]).toMatchObject({
|
||||
...baseIncident,
|
||||
visible: true,
|
||||
show_banner: false,
|
||||
})
|
||||
|
||||
expect(result.ongoing_incidents[0]).not.toHaveProperty('banner_lead_days')
|
||||
expect(result.in_progress_maintenances[0]).not.toHaveProperty('banner_lead_days')
|
||||
expect(result.scheduled_maintenances[0]).toHaveProperty('banner_lead_days')
|
||||
})
|
||||
|
||||
test('an item absent from flagsById gets the full default annotations', () => {
|
||||
const result = annotateWidget(widget, new Map(), [''])
|
||||
|
||||
expect(result.ongoing_incidents[0]).toMatchObject({
|
||||
visible: true,
|
||||
show_banner: true,
|
||||
})
|
||||
expect(result.in_progress_maintenances[0]).toMatchObject({
|
||||
visible: true,
|
||||
show_banner: true,
|
||||
})
|
||||
expect(result.scheduled_maintenances[0]).toMatchObject({
|
||||
visible: true,
|
||||
show_banner: true,
|
||||
banner_lead_days: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { StatusPageResponse, WidgetResponse } from '@/lib/status-page/status-page.schema'
|
||||
|
||||
export type CustomFieldValue = {
|
||||
value_option?: { value: string } | null
|
||||
value_text?: string | null
|
||||
value_numeric?: string | null
|
||||
}
|
||||
|
||||
export type CustomFieldEntry = {
|
||||
custom_field: { id: string }
|
||||
values: Array<CustomFieldValue>
|
||||
}
|
||||
|
||||
export type ResponseIncident = {
|
||||
id: string
|
||||
custom_field_entries: Array<CustomFieldEntry>
|
||||
}
|
||||
|
||||
const TRUE_VALUES = new Set(['yes', 'true', '1'])
|
||||
|
||||
export function readCustomFieldFlag(entries: Array<CustomFieldEntry>, fieldId: string): boolean {
|
||||
if (!fieldId) return false
|
||||
|
||||
const entry = entries.find((e) => e.custom_field.id === fieldId)
|
||||
if (!entry) return false
|
||||
|
||||
return entry.values.some((value) => {
|
||||
const text = (value.value_option?.value ?? value.value_text ?? '').trim().toLowerCase()
|
||||
if (TRUE_VALUES.has(text)) return true
|
||||
|
||||
if (value.value_numeric != null) {
|
||||
const numeric = Number(value.value_numeric)
|
||||
return Number.isFinite(numeric) && numeric > 0
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
export function readCustomFieldNumber(
|
||||
entries: Array<CustomFieldEntry>,
|
||||
fieldId: string
|
||||
): number | null {
|
||||
if (!fieldId) return null
|
||||
|
||||
const entry = entries.find((e) => e.custom_field.id === fieldId)
|
||||
if (!entry) return null
|
||||
|
||||
for (const value of entry.values) {
|
||||
const raw = value.value_numeric ?? value.value_option?.value ?? value.value_text
|
||||
if (raw == null) continue
|
||||
|
||||
const numeric = Number(raw)
|
||||
if (Number.isFinite(numeric) && numeric > 0) return numeric
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export type ResponseIncidentFlags = {
|
||||
hideBanner: boolean
|
||||
modeFlags: Record<string, boolean>
|
||||
leadDays: number | null
|
||||
}
|
||||
|
||||
export type ResponseIncidentFieldIds = {
|
||||
hideBannerFieldId: string
|
||||
leadDaysFieldId: string
|
||||
}
|
||||
|
||||
export function mergeLinkedFlags(
|
||||
incidents: Array<ResponseIncident>,
|
||||
modeFieldIds: Array<string>,
|
||||
fieldIds: ResponseIncidentFieldIds
|
||||
): ResponseIncidentFlags {
|
||||
const modeFlags: Record<string, boolean> = {}
|
||||
for (const fieldId of modeFieldIds.filter(Boolean)) {
|
||||
modeFlags[fieldId] = incidents.some((incident) =>
|
||||
readCustomFieldFlag(incident.custom_field_entries, fieldId)
|
||||
)
|
||||
}
|
||||
|
||||
const hideBanner = incidents.every((incident) =>
|
||||
readCustomFieldFlag(incident.custom_field_entries, fieldIds.hideBannerFieldId)
|
||||
)
|
||||
|
||||
const leadDaysValues = incidents
|
||||
.map((incident) =>
|
||||
readCustomFieldNumber(incident.custom_field_entries, fieldIds.leadDaysFieldId)
|
||||
)
|
||||
.filter((value): value is number => value !== null)
|
||||
|
||||
return {
|
||||
hideBanner,
|
||||
modeFlags,
|
||||
leadDays: leadDaysValues.length > 0 ? Math.max(...leadDaysValues) : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function computeAnnotations(
|
||||
flags: ResponseIncidentFlags | undefined,
|
||||
modeFieldIds: Array<string>
|
||||
): { visible: boolean; show_banner: boolean; banner_lead_days: number | null } {
|
||||
if (!flags) {
|
||||
return { visible: true, show_banner: true, banner_lead_days: null }
|
||||
}
|
||||
|
||||
const [own = ''] = modeFieldIds
|
||||
const anySet = modeFieldIds.filter(Boolean).some((id) => flags.modeFlags[id])
|
||||
const visible = !anySet || (own !== '' && flags.modeFlags[own] === true)
|
||||
|
||||
return {
|
||||
visible,
|
||||
show_banner: !flags.hideBanner,
|
||||
banner_lead_days: flags.leadDays,
|
||||
}
|
||||
}
|
||||
|
||||
export function annotateWidget(
|
||||
widget: WidgetResponse,
|
||||
flagsById: Map<string, ResponseIncidentFlags>,
|
||||
modeFieldIds: Array<string>
|
||||
): StatusPageResponse {
|
||||
const withBaseAnnotations = <T extends { id: string }>(item: T) => {
|
||||
const { visible, show_banner } = computeAnnotations(flagsById.get(item.id), modeFieldIds)
|
||||
return { ...item, visible, show_banner }
|
||||
}
|
||||
|
||||
return {
|
||||
...widget,
|
||||
ongoing_incidents: widget.ongoing_incidents.map(withBaseAnnotations),
|
||||
in_progress_maintenances: widget.in_progress_maintenances.map(withBaseAnnotations),
|
||||
scheduled_maintenances: widget.scheduled_maintenances.map((item) => {
|
||||
const annotations = computeAnnotations(flagsById.get(item.id), modeFieldIds)
|
||||
return { ...item, ...annotations }
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export const HOSTED_SUPPORTED_API_URLS = [
|
||||
'/generate-attachment-url',
|
||||
'/incident-status',
|
||||
'/incident-banner',
|
||||
'/status-page',
|
||||
'/status-override',
|
||||
'/api/integrations/stripe-sync',
|
||||
'/content/graphql',
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, expectTypeOf, test, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
WidgetIncidentSchema,
|
||||
WidgetInProgressMaintenanceSchema,
|
||||
WidgetResponseSchema,
|
||||
WidgetScheduledMaintenanceSchema,
|
||||
} from './status-page.schema'
|
||||
|
||||
describe('WidgetResponseSchema: tolerant arrays (fail-open)', () => {
|
||||
test('drops a malformed item from one array without failing the whole parse, leaving the valid item in that array and the other arrays intact', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const payload = {
|
||||
page_title: 'Supabase',
|
||||
page_url: 'https://statuspage.incident.io/supabase',
|
||||
ongoing_incidents: [
|
||||
{
|
||||
id: 'incident-valid',
|
||||
name: 'Valid incident',
|
||||
url: 'https://status.example.com/incidents/incident-valid',
|
||||
last_update_at: '2026-01-01T00:00:00Z',
|
||||
status: 'identified',
|
||||
current_worst_impact: 'partial_outage',
|
||||
},
|
||||
// Malformed: missing required `id`/`name`/`url`/`last_update_at`.
|
||||
{ status: 'identified' },
|
||||
],
|
||||
in_progress_maintenances: [
|
||||
{
|
||||
id: 'maintenance-valid',
|
||||
name: 'Valid maintenance',
|
||||
url: 'https://status.example.com/maintenances/maintenance-valid',
|
||||
last_update_at: '2026-01-01T00:00:00Z',
|
||||
status: 'maintenance_in_progress',
|
||||
started_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
scheduled_maintenances: [],
|
||||
}
|
||||
|
||||
const result = WidgetResponseSchema.safeParse(payload)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success) return
|
||||
|
||||
// The other valid item in the same array survives...
|
||||
expect(result.data.ongoing_incidents).toHaveLength(1)
|
||||
expect(result.data.ongoing_incidents[0].id).toBe('incident-valid')
|
||||
// ...and the other arrays are unaffected by the malformed item elsewhere.
|
||||
expect(result.data.in_progress_maintenances).toHaveLength(1)
|
||||
expect(result.data.in_progress_maintenances[0].id).toBe('maintenance-valid')
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ongoing_incidents'),
|
||||
expect.any(Object)
|
||||
)
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
test('defaults a missing array key to an empty array', () => {
|
||||
const result = WidgetResponseSchema.safeParse({
|
||||
page_title: 'Supabase',
|
||||
page_url: 'https://statuspage.incident.io/supabase',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success) return
|
||||
|
||||
expect(result.data.ongoing_incidents).toEqual([])
|
||||
expect(result.data.in_progress_maintenances).toEqual([])
|
||||
expect(result.data.scheduled_maintenances).toEqual([])
|
||||
})
|
||||
|
||||
test('tolerantArray keeps parsed item types concrete, not any', () => {
|
||||
const result = WidgetResponseSchema.parse({
|
||||
page_title: 'Supabase',
|
||||
page_url: 'https://statuspage.incident.io/supabase',
|
||||
})
|
||||
|
||||
expectTypeOf(result.ongoing_incidents).toEqualTypeOf<
|
||||
Array<z.infer<typeof WidgetIncidentSchema>>
|
||||
>()
|
||||
expectTypeOf(result.in_progress_maintenances).toEqualTypeOf<
|
||||
Array<z.infer<typeof WidgetInProgressMaintenanceSchema>>
|
||||
>()
|
||||
expectTypeOf(result.scheduled_maintenances).toEqualTypeOf<
|
||||
Array<z.infer<typeof WidgetScheduledMaintenanceSchema>>
|
||||
>()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const ComponentStatus = z.enum([
|
||||
'operational',
|
||||
'degraded_performance',
|
||||
'partial_outage',
|
||||
'full_outage',
|
||||
])
|
||||
const WorstImpact = z.enum(['degraded_performance', 'partial_outage', 'full_outage'])
|
||||
|
||||
export const AffectedComponentSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
group_name: z.string().nullish(),
|
||||
current_status: ComponentStatus.catch('degraded_performance'),
|
||||
})
|
||||
|
||||
const Base = {
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
last_update_at: z.string(),
|
||||
last_update_message: z.string().nullish(),
|
||||
affected_components: z.array(AffectedComponentSchema).default([]),
|
||||
}
|
||||
|
||||
export const WidgetIncidentSchema = z.object({
|
||||
...Base,
|
||||
status: z.enum(['investigating', 'identified', 'monitoring']).catch('investigating'),
|
||||
current_worst_impact: WorstImpact.catch('full_outage'),
|
||||
})
|
||||
|
||||
export const WidgetInProgressMaintenanceSchema = z.object({
|
||||
...Base,
|
||||
status: z.literal('maintenance_in_progress').catch('maintenance_in_progress'),
|
||||
started_at: z.string(),
|
||||
scheduled_end_at: z.string().nullish(),
|
||||
})
|
||||
|
||||
export const WidgetScheduledMaintenanceSchema = z.object({
|
||||
...Base,
|
||||
status: z.literal('maintenance_scheduled').catch('maintenance_scheduled'),
|
||||
starts_at: z.string().nullish(),
|
||||
ends_at: z.string().nullish(),
|
||||
})
|
||||
|
||||
function tolerantArray<T extends z.ZodTypeAny>(itemSchema: T, arrayName: string) {
|
||||
return z
|
||||
.array(z.unknown())
|
||||
.default([])
|
||||
.transform(
|
||||
(items): Array<z.output<T>> =>
|
||||
items.flatMap((item) => {
|
||||
const result = itemSchema.safeParse(item)
|
||||
if (result.success) return [result.data]
|
||||
|
||||
const rawId =
|
||||
typeof item === 'object' && item !== null && 'id' in item
|
||||
? (item as { id?: unknown }).id
|
||||
: undefined
|
||||
console.warn(`incident.io Widget API: dropping malformed item in ${arrayName}`, {
|
||||
id: rawId,
|
||||
issues: result.error.issues,
|
||||
})
|
||||
return []
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export const WidgetResponseSchema = z.object({
|
||||
page_title: z.string(),
|
||||
page_url: z.string(),
|
||||
ongoing_incidents: tolerantArray(WidgetIncidentSchema, 'ongoing_incidents'),
|
||||
in_progress_maintenances: tolerantArray(
|
||||
WidgetInProgressMaintenanceSchema,
|
||||
'in_progress_maintenances'
|
||||
),
|
||||
scheduled_maintenances: tolerantArray(WidgetScheduledMaintenanceSchema, 'scheduled_maintenances'),
|
||||
})
|
||||
|
||||
const Annotations = { visible: z.boolean(), show_banner: z.boolean() }
|
||||
|
||||
export const StatusPageResponseSchema = WidgetResponseSchema.extend({
|
||||
ongoing_incidents: z.array(WidgetIncidentSchema.extend(Annotations)),
|
||||
in_progress_maintenances: z.array(WidgetInProgressMaintenanceSchema.extend(Annotations)),
|
||||
scheduled_maintenances: z.array(
|
||||
WidgetScheduledMaintenanceSchema.extend({
|
||||
...Annotations,
|
||||
banner_lead_days: z.number().positive().nullable(),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
export type WidgetResponse = z.infer<typeof WidgetResponseSchema>
|
||||
export type StatusPageResponse = z.infer<typeof StatusPageResponseSchema>
|
||||
export type AffectedComponent = z.infer<typeof AffectedComponentSchema>
|
||||
@@ -27,6 +27,7 @@ import { Route as OrgChar91_Char93RouteImport } from './routes/org.[_]'
|
||||
import { Route as NewSlugRouteImport } from './routes/new/$slug'
|
||||
import { Route as McpSecretsRouteImport } from './routes/mcp/secrets'
|
||||
import { Route as IntegrationsVercelRouteImport } from './routes/integrations/vercel'
|
||||
import { Route as ApiStatusPageRouteImport } from './routes/api/status-page'
|
||||
import { Route as ApiStatusOverrideRouteImport } from './routes/api/status-override'
|
||||
import { Route as ApiScopedAccessTokenPermissionsRouteImport } from './routes/api/scoped-access-token-permissions'
|
||||
import { Route as ApiParseQueryRouteImport } from './routes/api/parse-query'
|
||||
@@ -422,6 +423,11 @@ const IntegrationsVercelRoute = IntegrationsVercelRouteImport.update({
|
||||
path: '/integrations/vercel',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiStatusPageRoute = ApiStatusPageRouteImport.update({
|
||||
id: '/api/status-page',
|
||||
path: '/api/status-page',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiStatusOverrideRoute = ApiStatusOverrideRouteImport.update({
|
||||
id: '/api/status-override',
|
||||
path: '/api/status-override',
|
||||
@@ -2175,6 +2181,7 @@ export interface FileRoutesByFullPath {
|
||||
'/api/parse-query': typeof ApiParseQueryRoute
|
||||
'/api/scoped-access-token-permissions': typeof ApiScopedAccessTokenPermissionsRoute
|
||||
'/api/status-override': typeof ApiStatusOverrideRoute
|
||||
'/api/status-page': typeof ApiStatusPageRoute
|
||||
'/integrations/vercel': typeof IntegrationsVercelRouteWithChildren
|
||||
'/mcp/secrets': typeof McpSecretsRoute
|
||||
'/new/$slug': typeof NewSlugRoute
|
||||
@@ -2497,6 +2504,7 @@ export interface FileRoutesByTo {
|
||||
'/api/parse-query': typeof ApiParseQueryRoute
|
||||
'/api/scoped-access-token-permissions': typeof ApiScopedAccessTokenPermissionsRoute
|
||||
'/api/status-override': typeof ApiStatusOverrideRoute
|
||||
'/api/status-page': typeof ApiStatusPageRoute
|
||||
'/integrations/vercel': typeof IntegrationsVercelRouteWithChildren
|
||||
'/mcp/secrets': typeof McpSecretsRoute
|
||||
'/new/$slug': typeof NewSlugRoute
|
||||
@@ -2809,6 +2817,7 @@ export interface FileRoutesById {
|
||||
'/api/parse-query': typeof ApiParseQueryRoute
|
||||
'/api/scoped-access-token-permissions': typeof ApiScopedAccessTokenPermissionsRoute
|
||||
'/api/status-override': typeof ApiStatusOverrideRoute
|
||||
'/api/status-page': typeof ApiStatusPageRoute
|
||||
'/integrations/vercel': typeof IntegrationsVercelRouteWithChildren
|
||||
'/mcp/secrets': typeof McpSecretsRoute
|
||||
'/new/$slug': typeof NewSlugRoute
|
||||
@@ -3134,6 +3143,7 @@ export interface FileRouteTypes {
|
||||
| '/api/parse-query'
|
||||
| '/api/scoped-access-token-permissions'
|
||||
| '/api/status-override'
|
||||
| '/api/status-page'
|
||||
| '/integrations/vercel'
|
||||
| '/mcp/secrets'
|
||||
| '/new/$slug'
|
||||
@@ -3456,6 +3466,7 @@ export interface FileRouteTypes {
|
||||
| '/api/parse-query'
|
||||
| '/api/scoped-access-token-permissions'
|
||||
| '/api/status-override'
|
||||
| '/api/status-page'
|
||||
| '/integrations/vercel'
|
||||
| '/mcp/secrets'
|
||||
| '/new/$slug'
|
||||
@@ -3767,6 +3778,7 @@ export interface FileRouteTypes {
|
||||
| '/api/parse-query'
|
||||
| '/api/scoped-access-token-permissions'
|
||||
| '/api/status-override'
|
||||
| '/api/status-page'
|
||||
| '/integrations/vercel'
|
||||
| '/mcp/secrets'
|
||||
| '/new/$slug'
|
||||
@@ -4081,6 +4093,7 @@ export interface RootRouteChildren {
|
||||
ApiParseQueryRoute: typeof ApiParseQueryRoute
|
||||
ApiScopedAccessTokenPermissionsRoute: typeof ApiScopedAccessTokenPermissionsRoute
|
||||
ApiStatusOverrideRoute: typeof ApiStatusOverrideRoute
|
||||
ApiStatusPageRoute: typeof ApiStatusPageRoute
|
||||
IntegrationsVercelRoute: typeof IntegrationsVercelRouteWithChildren
|
||||
McpSecretsRoute: typeof McpSecretsRoute
|
||||
NewSlugRoute: typeof NewSlugRoute
|
||||
@@ -4309,6 +4322,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IntegrationsVercelRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/status-page': {
|
||||
id: '/api/status-page'
|
||||
path: '/api/status-page'
|
||||
fullPath: '/api/status-page'
|
||||
preLoaderRoute: typeof ApiStatusPageRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/status-override': {
|
||||
id: '/api/status-override'
|
||||
path: '/api/status-override'
|
||||
@@ -7207,6 +7227,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ApiParseQueryRoute: ApiParseQueryRoute,
|
||||
ApiScopedAccessTokenPermissionsRoute: ApiScopedAccessTokenPermissionsRoute,
|
||||
ApiStatusOverrideRoute: ApiStatusOverrideRoute,
|
||||
ApiStatusPageRoute: ApiStatusPageRoute,
|
||||
IntegrationsVercelRoute: IntegrationsVercelRouteWithChildren,
|
||||
McpSecretsRoute: McpSecretsRoute,
|
||||
NewSlugRoute: NewSlugRoute,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import { GET, HEAD, OPTIONS } from '@/app/api/status-page/route'
|
||||
|
||||
export const Route = createFileRoute('/api/status-page')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: () => GET(),
|
||||
HEAD: () => HEAD(),
|
||||
OPTIONS: () => OPTIONS(),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -105,6 +105,8 @@
|
||||
"STATUSPAGE_API_KEY",
|
||||
"STATUSPAGE_PAGE_ID",
|
||||
"INCIDENT_IO_API_KEY",
|
||||
"INCIDENT_IO_WIDGET_URL",
|
||||
"INCIDENT_IO_STATUS_PAGE_ID",
|
||||
"LIVE_SUPABASE_SECRET_KEY",
|
||||
// Selects the build mode for the studio's `build`/`start` scripts
|
||||
// (e.g. e2e sets `MODE=test`). Listed so turbo invalidates the
|
||||
|
||||
Reference in new issue
Block a user