mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
## 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>
320 lines
11 KiB
TypeScript
320 lines
11 KiB
TypeScript
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,
|
|
})
|
|
})
|
|
})
|