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>
139 lines
3.9 KiB
TypeScript
139 lines
3.9 KiB
TypeScript
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 }
|
|
}),
|
|
}
|
|
}
|