From b2cf3693dd74216ecc27506390931d35dc929c9c Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:36:29 -0400 Subject: [PATCH] feat(studio): /api/status-page endpoint backed by incident.io Widget API (#50931) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary * Adds `/api/status-page` (Next route + TanStack wrapper), backed by the [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]() 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]() 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]() ## 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. --------- Co-authored-by: Claude Code --- apps/studio/TANSTACK_MIGRATION.md | 5 +- apps/studio/app/api/status-page/route.ts | 65 ++++ .../custom-content/CustomContent.types.ts | 2 + .../hooks/custom-content/custom-content.json | 4 +- .../custom-content/custom-content.sample.json | 4 +- .../custom-content/custom-content.schema.json | 8 + apps/studio/lib/api/status-page.test.ts | 304 +++++++++++++++++ apps/studio/lib/api/status-page.ts | 223 ++++++++++++ apps/studio/lib/api/status-page.utils.test.ts | 319 ++++++++++++++++++ apps/studio/lib/api/status-page.utils.ts | 138 ++++++++ apps/studio/lib/hosted-api-allowlist.ts | 1 + .../status-page/status-page.schema.test.ts | 93 +++++ .../lib/status-page/status-page.schema.ts | 96 ++++++ apps/studio/routeTree.gen.ts | 21 ++ apps/studio/routes/api/status-page.ts | 13 + apps/studio/turbo.jsonc | 2 + 16 files changed, 1294 insertions(+), 4 deletions(-) create mode 100644 apps/studio/app/api/status-page/route.ts create mode 100644 apps/studio/lib/api/status-page.test.ts create mode 100644 apps/studio/lib/api/status-page.ts create mode 100644 apps/studio/lib/api/status-page.utils.test.ts create mode 100644 apps/studio/lib/api/status-page.utils.ts create mode 100644 apps/studio/lib/status-page/status-page.schema.test.ts create mode 100644 apps/studio/lib/status-page/status-page.schema.ts create mode 100644 apps/studio/routes/api/status-page.ts diff --git a/apps/studio/TANSTACK_MIGRATION.md b/apps/studio/TANSTACK_MIGRATION.md index 1ef32b7e503..b07f589a098 100644 --- a/apps/studio/TANSTACK_MIGRATION.md +++ b/apps/studio/TANSTACK_MIGRATION.md @@ -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) --- diff --git a/apps/studio/app/api/status-page/route.ts b/apps/studio/app/api/status-page/route.ts new file mode 100644 index 00000000000..6607ffaddea --- /dev/null +++ b/apps/studio/app/api/status-page/route.ts @@ -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 } + ) + } +} diff --git a/apps/studio/hooks/custom-content/CustomContent.types.ts b/apps/studio/hooks/custom-content/CustomContent.types.ts index f8ac5f665f6..6c9f15cd334 100644 --- a/apps/studio/hooks/custom-content/CustomContent.types.ts +++ b/apps/studio/hooks/custom-content/CustomContent.types.ts @@ -39,4 +39,6 @@ export type CustomContentTypes = { infraAwsNimbusLabel: string sslCertificateUrl: string + + statusPageVisibilityFieldIds: Array } diff --git a/apps/studio/hooks/custom-content/custom-content.json b/apps/studio/hooks/custom-content/custom-content.json index 0f094e3788c..b7e1439b241 100644 --- a/apps/studio/hooks/custom-content/custom-content.json +++ b/apps/studio/hooks/custom-content/custom-content.json @@ -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"] } diff --git a/apps/studio/hooks/custom-content/custom-content.sample.json b/apps/studio/hooks/custom-content/custom-content.sample.json index 1fcc2e6a810..ca47096b02a 100644 --- a/apps/studio/hooks/custom-content/custom-content.sample.json +++ b/apps/studio/hooks/custom-content/custom-content.sample.json @@ -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"] } diff --git a/apps/studio/hooks/custom-content/custom-content.schema.json b/apps/studio/hooks/custom-content/custom-content.schema.json index 508f26527b4..86a2a495a71 100644 --- a/apps/studio/hooks/custom-content/custom-content.schema.json +++ b/apps/studio/hooks/custom-content/custom-content.schema.json @@ -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": [ diff --git a/apps/studio/lib/api/status-page.test.ts b/apps/studio/lib/api/status-page.test.ts new file mode 100644 index 00000000000..41dec153f52 --- /dev/null +++ b/apps/studio/lib/api/status-page.test.ts @@ -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() + 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') + }) +}) diff --git a/apps/studio/lib/api/status-page.ts b/apps/studio/lib/api/status-page.ts new file mode 100644 index 00000000000..5ecd394d0c6 --- /dev/null +++ b/apps/studio/lib/api/status-page.ts @@ -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 { + 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 { + 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 { + 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> { + 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 { + 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 { + 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>() + 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() + incidentResults.forEach((result, index) => { + if (result.status === 'fulfilled') { + incidentsById.set(uniqueLinkedIds[index], result.value) + } else { + isDegraded = true + } + }) + + const flagsById = new Map() + 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 } +} diff --git a/apps/studio/lib/api/status-page.utils.test.ts b/apps/studio/lib/api/status-page.utils.test.ts new file mode 100644 index 00000000000..cc93bf75a6b --- /dev/null +++ b/apps/studio/lib/api/status-page.utils.test.ts @@ -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 { + 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([ + ['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, + }) + }) +}) diff --git a/apps/studio/lib/api/status-page.utils.ts b/apps/studio/lib/api/status-page.utils.ts new file mode 100644 index 00000000000..da51fca414e --- /dev/null +++ b/apps/studio/lib/api/status-page.utils.ts @@ -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 +} + +export type ResponseIncident = { + id: string + custom_field_entries: Array +} + +const TRUE_VALUES = new Set(['yes', 'true', '1']) + +export function readCustomFieldFlag(entries: Array, 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, + 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 + leadDays: number | null +} + +export type ResponseIncidentFieldIds = { + hideBannerFieldId: string + leadDaysFieldId: string +} + +export function mergeLinkedFlags( + incidents: Array, + modeFieldIds: Array, + fieldIds: ResponseIncidentFieldIds +): ResponseIncidentFlags { + const modeFlags: Record = {} + 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 +): { 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, + modeFieldIds: Array +): StatusPageResponse { + const withBaseAnnotations = (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 } + }), + } +} diff --git a/apps/studio/lib/hosted-api-allowlist.ts b/apps/studio/lib/hosted-api-allowlist.ts index 8d70221994d..59c7b0c80bf 100644 --- a/apps/studio/lib/hosted-api-allowlist.ts +++ b/apps/studio/lib/hosted-api-allowlist.ts @@ -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', diff --git a/apps/studio/lib/status-page/status-page.schema.test.ts b/apps/studio/lib/status-page/status-page.schema.test.ts new file mode 100644 index 00000000000..f3bee36f631 --- /dev/null +++ b/apps/studio/lib/status-page/status-page.schema.test.ts @@ -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> + >() + expectTypeOf(result.in_progress_maintenances).toEqualTypeOf< + Array> + >() + expectTypeOf(result.scheduled_maintenances).toEqualTypeOf< + Array> + >() + }) +}) diff --git a/apps/studio/lib/status-page/status-page.schema.ts b/apps/studio/lib/status-page/status-page.schema.ts new file mode 100644 index 00000000000..f94deb55c42 --- /dev/null +++ b/apps/studio/lib/status-page/status-page.schema.ts @@ -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(itemSchema: T, arrayName: string) { + return z + .array(z.unknown()) + .default([]) + .transform( + (items): Array> => + 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 +export type StatusPageResponse = z.infer +export type AffectedComponent = z.infer diff --git a/apps/studio/routeTree.gen.ts b/apps/studio/routeTree.gen.ts index 7b18ae86134..f51bca5bf40 100644 --- a/apps/studio/routeTree.gen.ts +++ b/apps/studio/routeTree.gen.ts @@ -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, diff --git a/apps/studio/routes/api/status-page.ts b/apps/studio/routes/api/status-page.ts new file mode 100644 index 00000000000..655d9f23e5e --- /dev/null +++ b/apps/studio/routes/api/status-page.ts @@ -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(), + }, + }, +}) diff --git a/apps/studio/turbo.jsonc b/apps/studio/turbo.jsonc index 678cd30ed30..bb57b7c8670 100644 --- a/apps/studio/turbo.jsonc +++ b/apps/studio/turbo.jsonc @@ -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