Files
supabase/apps/studio/lib/api/status-page.utils.ts
CharisandClaude Code b2cf3693dd 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>
2026-09-28 11:36:29 -04:00

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 }
}),
}
}