feat(studio): status page client data layer + support form (#50984)

## Summary

* Adds the client query layer for `/api/status-page`
(`statusPageQueryOptions` in `data/platform/status-page-query.ts`) and a
shared normalization module (`lib/status-page/status-page.utils.ts`)
that maps the endpoint response into region- and project-creation-aware
`StatusItem`s — later PRs in this stack (assistant tool,
project-creation admonition, global banner) build on this same module.
* Wires the support form's status pill and incident admonition to the
new data behind the `incidentIoStatusPage` ConfigCat flag.
`useSupportStatus` branches between the new endpoint and the existing
`useIncidentStatusQuery`/`processIncidentData` path, both mapped to the
same `SupportStatus` shape.
* `IncidentAdmonition` becomes purely presentational; the status link
now points at the `pageUrl` returned by the endpoint instead of a
hardcoded URL.

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` (touched files) — 70 tests
passing, including a flag-on case for `SupportFormPage`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Support pages now display current incident and maintenance
information, including relevant status descriptions and links to the
status page.
* Status details can reflect items affecting a user’s region or
project-creation services. Upcoming maintenance is excluded from active
alerts.
* **Bug Fixes**
* Status labels and alerts now account for loading, errors, incidents,
and maintenance consistently across support pages.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Charis authored and GitHub committed 2026-09-29 07:53:28 -04:00
1 parent 9aae037dff
commit 8b8569bd1a
12 files changed
+1037 -117

No files matched your search

@@ -4,75 +4,21 @@ import Link from 'next/link'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { useIncidentStatusQuery } from '@/data/platform/incident-status-query'
import { processIncidentData } from '@/data/platform/incident-status-utils'
interface IncidentAdmonitionProps {
isActive: boolean
title: string
description: string
statusPageUrl: string
className?: string
}
const STATUS_DESCRIPTION_SIGN_OFF = 'Follow the status page for updates.'
const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1)
const getStatusDescription = (
status: string,
hasMultipleIncidents: boolean,
allSameStatus: boolean
): string => {
const isPlural = hasMultipleIncidents
const issueTerm = isPlural ? 'these issues' : 'this issue'
switch (status) {
case 'investigating':
if (hasMultipleIncidents && !allSameStatus) {
return `We are aware of multiple ongoing issues and are investigating. ${STATUS_DESCRIPTION_SIGN_OFF}`
}
return `We are investigating ${issueTerm}. ${STATUS_DESCRIPTION_SIGN_OFF}`
case 'identified':
if (hasMultipleIncidents && !allSameStatus) {
return `We have identified the cause of some of ${issueTerm} and are working on fixes. ${STATUS_DESCRIPTION_SIGN_OFF}`
}
return `We have identified the cause of ${issueTerm} and are working on a fix. ${STATUS_DESCRIPTION_SIGN_OFF}`
case 'monitoring':
if (hasMultipleIncidents && !allSameStatus) {
return `Fixes have been deployed for some of ${issueTerm} and we are monitoring the results. ${STATUS_DESCRIPTION_SIGN_OFF}`
}
return `A fix has been deployed and we are monitoring the results. ${STATUS_DESCRIPTION_SIGN_OFF}`
case 'resolved':
if (hasMultipleIncidents && !allSameStatus) {
return `Some of ${issueTerm} have been resolved, but others may still be ongoing. ${STATUS_DESCRIPTION_SIGN_OFF}`
}
return `${capitalizeFirstLetter(issueTerm)} ${isPlural ? 'have' : 'has'} been resolved but may take some time to fully recover. ${STATUS_DESCRIPTION_SIGN_OFF}`
default:
return `We are investigating ${issueTerm}. ${STATUS_DESCRIPTION_SIGN_OFF}`
}
}
export function IncidentAdmonition({ isActive, className }: IncidentAdmonitionProps) {
const { data: allStatusPageEvents, isLoading, isError } = useIncidentStatusQuery()
const { incidents = [] } = allStatusPageEvents ?? {}
// Don't render anything while loading, on error, or if no incidents
if (isLoading || isError || !incidents || incidents.length === 0) {
return null
}
const { hasMultipleIncidents, mostCriticalIncident, overallStatus, allSameStatus } =
processIncidentData(incidents)
// Show most recent incident name + count if multiple incidents
const statusTitle =
(mostCriticalIncident?.name ?? '') +
(hasMultipleIncidents
? ` and ${incidents.length - 1} other issue${incidents.length > 2 ? 's' : ''}`
: '')
export function IncidentAdmonition({
isActive,
title,
description,
statusPageUrl,
className,
}: IncidentAdmonitionProps) {
return (
<AnimatePresence>
{isActive && (
@@ -85,11 +31,11 @@ export function IncidentAdmonition({ isActive, className }: IncidentAdmonitionPr
type="warning"
layout="horizontal"
className={className}
title={statusTitle}
description={getStatusDescription(overallStatus, hasMultipleIncidents, allSameStatus)}
title={title}
description={description}
actions={
<Button asChild icon={<ExternalLink strokeWidth={1.5} />}>
<Link href="https://status.supabase.com/" target="_blank" rel="noreferrer">
<Link href={statusPageUrl} target="_blank" rel="noreferrer">
Status page
</Link>
</Button>
@@ -22,9 +22,10 @@ import {
} from './SupportForm.state'
import { NO_PROJECT_MARKER } from './SupportForm.utils'
import { SupportFormV2 } from './SupportFormV2'
import { DEFAULT_STATUS_PAGE_URL, getSupportStatusLabel } from './SupportStatus.utils'
import { useSupportForm } from './useSupportForm'
import { useSupportStatus } from './useSupportStatus'
import CopyButton from '@/components/ui/CopyButton'
import { useIncidentStatusQuery } from '@/data/platform/incident-status-query'
import { useStateTransition } from '@/hooks/misc/useStateTransition'
import { BASE_PATH, DOCS_URL } from '@/lib/constants'
import { useTrack } from '@/lib/telemetry/track'
@@ -61,14 +62,11 @@ function SupportFormPageContent() {
const [state, dispatch] = useReducer(supportFormReducer, undefined, createInitialSupportFormState)
const { form, initialError, projectRef, orgSlug } = useSupportForm(dispatch)
const {
data: allStatusPageEvents,
isPending: isIncidentsPending,
isError: isIncidentsError,
} = useIncidentStatusQuery()
const { incidents = [] } = allStatusPageEvents ?? {}
const hasActiveIncidents =
!isIncidentsPending && !isIncidentsError && incidents && incidents.length > 0
const supportStatus = useSupportStatus()
const hasActiveIncidents = supportStatus.status === 'success' && supportStatus.hasActiveIncidents
const admonition = supportStatus.status === 'success' ? supportStatus.admonition : null
const statusPageUrl =
supportStatus.status === 'success' ? supportStatus.pageUrl : DEFAULT_STATUS_PAGE_URL
const sendTelemetry = useSupportFormTelemetry()
useStateTransition(state, 'submitting', 'success', (_, curr) => {
@@ -94,7 +92,12 @@ function SupportFormPageContent() {
<SupportFormWrapper>
<SupportFormHeader />
<IncidentAdmonition isActive={hasActiveIncidents} />
<IncidentAdmonition
isActive={admonition !== null}
title={admonition?.title ?? ''}
description={admonition?.description ?? ''}
statusPageUrl={statusPageUrl}
/>
{!isSuccess && !hasActiveIncidents && (
<div className="flex flex-col gap-y-4">
@@ -126,10 +129,11 @@ function SupportFormWrapper({ children }: PropsWithChildren) {
}
function SupportFormHeader() {
const { data: allStatusPageEvents, isPending: isLoading, isError } = useIncidentStatusQuery()
const { incidents = [], maintenanceEvents = [] } = allStatusPageEvents ?? {}
const isMaintenance = maintenanceEvents.length > 0
const isIncident = incidents.length > 0
const supportStatus = useSupportStatus()
const isLoading = supportStatus.status === 'pending'
const isIncident = supportStatus.status === 'success' && supportStatus.hasActiveIncidents
const statusPageUrl =
supportStatus.status === 'success' ? supportStatus.pageUrl : DEFAULT_STATUS_PAGE_URL
return (
<div className="flex flex-col items-start justify-between gap-y-2 sm:flex-row sm:items-center">
@@ -165,16 +169,8 @@ function SupportFormHeader() {
)
}
>
<Link href="https://status.supabase.com/" target="_blank" rel="noreferrer">
{isLoading
? 'Checking status'
: isError
? 'Failed to check status'
: isIncident
? 'Active incident ongoing'
: isMaintenance
? 'Scheduled maintenance'
: 'All systems operational'}
<Link href={statusPageUrl} target="_blank" rel="noreferrer">
{getSupportStatusLabel(supportStatus)}
</Link>
</Button>
</TooltipTrigger>
@@ -13,8 +13,9 @@ import { SupportAssistantSuccessCard } from './SupportAssistantSuccessCard'
import { createInitialSupportFormState, supportFormReducer } from './SupportForm.state'
import { NO_PROJECT_MARKER, type SupportFormUrlKeys } from './SupportForm.utils'
import { SupportFormV3 } from './SupportFormV3'
import { DEFAULT_STATUS_PAGE_URL, getSupportStatusLabel } from './SupportStatus.utils'
import { useSupportForm } from './useSupportForm'
import { useIncidentStatusQuery } from '@/data/platform/incident-status-query'
import { useSupportStatus } from './useSupportStatus'
import { useStateTransition } from '@/hooks/misc/useStateTransition'
import { useTrack } from '@/lib/telemetry/track'
@@ -49,14 +50,10 @@ export function SupportForm({ initialParams }: SupportFormProps) {
const { form, initialError, projectRef } = useSupportForm(dispatch, initialParams)
const showSupportAssistantFollowUp = useFlag('supportAssistantFollowUp') === true
const {
data: allStatusPageEvents,
isPending: isIncidentsPending,
isError: isIncidentsError,
} = useIncidentStatusQuery()
const { incidents = [] } = allStatusPageEvents ?? {}
const hasActiveIncidents =
!isIncidentsPending && !isIncidentsError && incidents && incidents.length > 0
const supportStatus = useSupportStatus()
const admonition = supportStatus.status === 'success' ? supportStatus.admonition : null
const statusPageUrl =
supportStatus.status === 'success' ? supportStatus.pageUrl : DEFAULT_STATUS_PAGE_URL
const sendTelemetry = useSupportFormTelemetry()
useStateTransition(state, 'submitting', 'success', (_, curr) => {
@@ -86,7 +83,10 @@ export function SupportForm({ initialParams }: SupportFormProps) {
return (
<div className="relative h-full overflow-y-auto overflow-x-hidden">
<IncidentAdmonition
isActive={hasActiveIncidents}
isActive={admonition !== null}
title={admonition?.title ?? ''}
description={admonition?.description ?? ''}
statusPageUrl={statusPageUrl}
className="rounded-none border-x-0 shadow-none"
/>
<div className="min-h-full px-5 pt-5">
@@ -122,10 +122,11 @@ export function SupportForm({ initialParams }: SupportFormProps) {
}
export function SupportFormStatusButton() {
const { data: allStatusPageEvents, isPending: isLoading, isError } = useIncidentStatusQuery()
const { incidents = [], maintenanceEvents = [] } = allStatusPageEvents ?? {}
const isMaintenance = maintenanceEvents.length > 0
const isIncident = incidents.length > 0
const supportStatus = useSupportStatus()
const isLoading = supportStatus.status === 'pending'
const isIncident = supportStatus.status === 'success' && supportStatus.hasActiveIncidents
const statusPageUrl =
supportStatus.status === 'success' ? supportStatus.pageUrl : DEFAULT_STATUS_PAGE_URL
return (
<Tooltip>
@@ -146,16 +147,8 @@ export function SupportFormStatusButton() {
)
}
>
<Link href="https://status.supabase.com/" target="_blank" rel="noreferrer">
{isLoading
? 'Checking status'
: isError
? 'Failed to check status'
: isIncident
? 'Active incident ongoing'
: isMaintenance
? 'Scheduled maintenance'
: 'All systems operational'}
<Link href={statusPageUrl} target="_blank" rel="noreferrer">
{getSupportStatusLabel(supportStatus)}
</Link>
</Button>
</TooltipTrigger>
@@ -0,0 +1,175 @@
import { describe, expect, test } from 'vitest'
import {
getStatusPillLabel,
getSupportAdmonitionCopy,
getSupportStatusLabel,
prioritizeActiveItems,
type SupportStatus,
} from './SupportStatus.utils'
import type { StatusItem } from '@/lib/status-page/status-page.utils'
function incident(overrides: Partial<Extract<StatusItem, { kind: 'incident' }>> = {}): StatusItem {
return {
kind: 'incident',
id: 'inc-1',
name: 'Elevated errors',
url: 'https://status.supabase.com/incidents/inc-1',
lastUpdateAt: '2026-01-01T00:00:00Z',
lastUpdateMessage: null,
components: [],
scope: { type: 'global' },
projectCreationScope: null,
isProjectCreationOnly: false,
showBanner: true,
status: 'investigating',
impact: 'full_outage',
...overrides,
}
}
function maintenance(
overrides: Partial<Extract<StatusItem, { kind: 'maintenance' }>> = {}
): StatusItem {
return {
kind: 'maintenance',
id: 'maint-1',
name: 'Database maintenance',
url: 'https://status.supabase.com/incidents/maint-1',
lastUpdateAt: '2026-01-01T00:00:00Z',
lastUpdateMessage: null,
components: [],
scope: { type: 'global' },
projectCreationScope: null,
isProjectCreationOnly: false,
showBanner: true,
startedAt: '2026-01-01T00:00:00Z',
scheduledEndAt: null,
...overrides,
}
}
function upcomingMaintenance(
overrides: Partial<Extract<StatusItem, { kind: 'upcoming_maintenance' }>> = {}
): StatusItem {
return {
kind: 'upcoming_maintenance',
id: 'upcoming-1',
name: 'Upcoming maintenance',
url: 'https://status.supabase.com/incidents/upcoming-1',
lastUpdateAt: '2026-01-01T00:00:00Z',
lastUpdateMessage: null,
components: [],
scope: { type: 'global' },
projectCreationScope: null,
isProjectCreationOnly: false,
showBanner: true,
startsAt: '2026-02-01T00:00:00Z',
endsAt: '2026-02-01T02:00:00Z',
leadDays: 7,
...overrides,
}
}
describe('prioritizeActiveItems', () => {
test('ranks incidents above maintenance by impact', () => {
const items = [
maintenance({ id: 'm', lastUpdateAt: '2026-01-01T00:00:00Z' }),
incident({
id: 'degraded',
impact: 'degraded_performance',
lastUpdateAt: '2026-01-01T00:00:00Z',
}),
incident({ id: 'full', impact: 'full_outage', lastUpdateAt: '2026-01-01T00:00:00Z' }),
]
expect(prioritizeActiveItems(items).map((item) => item.id)).toEqual(['full', 'degraded', 'm'])
})
test('breaks ties by lastUpdateAt descending', () => {
const items = [
incident({ id: 'older', impact: 'full_outage', lastUpdateAt: '2026-01-01T00:00:00Z' }),
incident({ id: 'newer', impact: 'full_outage', lastUpdateAt: '2026-01-02T00:00:00Z' }),
]
expect(prioritizeActiveItems(items).map((item) => item.id)).toEqual(['newer', 'older'])
})
test('ignores upcoming maintenance entirely', () => {
const items = [upcomingMaintenance(), incident()]
expect(prioritizeActiveItems(items).map((item) => item.id)).toEqual(['inc-1'])
})
test('includes items regardless of showBanner', () => {
const items = [incident({ showBanner: false })]
expect(prioritizeActiveItems(items)).toHaveLength(1)
})
})
describe('getSupportAdmonitionCopy', () => {
test('returns null when there is nothing active', () => {
expect(getSupportAdmonitionCopy([upcomingMaintenance()])).toBeNull()
})
test('singular title for one active item', () => {
const copy = getSupportAdmonitionCopy([incident({ name: 'Elevated errors' })])
expect(copy?.title).toBe('Elevated errors')
})
test('plural title for two active items', () => {
const copy = getSupportAdmonitionCopy([
incident({ id: 'a', name: 'Elevated errors', impact: 'full_outage' }),
incident({ id: 'b', name: 'Slow queries', impact: 'degraded_performance' }),
])
expect(copy?.title).toBe('Elevated errors and 1 other issue')
})
test('plural title uses plural "issues" for three or more', () => {
const copy = getSupportAdmonitionCopy([
incident({ id: 'a', impact: 'full_outage' }),
incident({ id: 'b', impact: 'degraded_performance' }),
incident({ id: 'c', impact: 'degraded_performance' }),
])
expect(copy?.title).toBe('Elevated errors and 2 other issues')
})
test('describes an in-progress maintenance', () => {
const copy = getSupportAdmonitionCopy([maintenance()])
expect(copy?.description).toContain('Scheduled maintenance is in progress')
})
})
describe('getStatusPillLabel', () => {
test('all systems operational when nothing is active', () => {
expect(getStatusPillLabel([upcomingMaintenance()])).toBe('All systems operational')
})
test('active incident ongoing when any incident is active', () => {
expect(getStatusPillLabel([incident(), maintenance()])).toBe('Active incident ongoing')
})
test('scheduled maintenance when only maintenance is active', () => {
expect(getStatusPillLabel([maintenance()])).toBe('Scheduled maintenance')
})
})
describe('getSupportStatusLabel', () => {
test('pending', () => {
expect(getSupportStatusLabel({ status: 'pending' })).toBe('Checking status')
})
test('error', () => {
expect(getSupportStatusLabel({ status: 'error' })).toBe('Failed to check status')
})
test('success uses the computed pill label', () => {
const status: SupportStatus = {
status: 'success',
hasActiveIncidents: true,
pillLabel: 'Active incident ongoing',
admonition: null,
pageUrl: 'https://status.supabase.com/',
}
expect(getSupportStatusLabel(status)).toBe('Active incident ongoing')
})
})
@@ -0,0 +1,129 @@
import { IMPACT_RANK, type StatusItem } from '@/lib/status-page/status-page.utils'
export const DEFAULT_STATUS_PAGE_URL = 'https://status.supabase.com/'
const STATUS_DESCRIPTION_SIGN_OFF = 'Follow the status page for updates.'
function capitalizeFirstLetter(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1)
}
/**
* Describes the current state of one or more active issues. `status` covers incident statuses
* plus the synthetic `maintenance_in_progress` status used for in-progress maintenance.
*/
export function getStatusDescription({
status,
hasMultipleIncidents,
allSameStatus,
}: {
status: string
hasMultipleIncidents: boolean
allSameStatus: boolean
}): string {
const issueTerm = hasMultipleIncidents ? 'these issues' : 'this issue'
switch (status) {
case 'investigating':
if (hasMultipleIncidents && !allSameStatus) {
return `We are aware of multiple ongoing issues and are investigating. ${STATUS_DESCRIPTION_SIGN_OFF}`
}
return `We are investigating ${issueTerm}. ${STATUS_DESCRIPTION_SIGN_OFF}`
case 'identified':
if (hasMultipleIncidents && !allSameStatus) {
return `We have identified the cause of some of ${issueTerm} and are working on fixes. ${STATUS_DESCRIPTION_SIGN_OFF}`
}
return `We have identified the cause of ${issueTerm} and are working on a fix. ${STATUS_DESCRIPTION_SIGN_OFF}`
case 'monitoring':
if (hasMultipleIncidents && !allSameStatus) {
return `Fixes have been deployed for some of ${issueTerm} and we are monitoring the results. ${STATUS_DESCRIPTION_SIGN_OFF}`
}
return `A fix has been deployed and we are monitoring the results. ${STATUS_DESCRIPTION_SIGN_OFF}`
case 'resolved':
if (hasMultipleIncidents && !allSameStatus) {
return `Some of ${issueTerm} have been resolved, but others may still be ongoing. ${STATUS_DESCRIPTION_SIGN_OFF}`
}
return `${capitalizeFirstLetter(issueTerm)} ${hasMultipleIncidents ? 'have' : 'has'} been resolved but may take some time to fully recover. ${STATUS_DESCRIPTION_SIGN_OFF}`
case 'maintenance_in_progress':
if (hasMultipleIncidents && !allSameStatus) {
return `Scheduled maintenance is in progress alongside other issues. ${STATUS_DESCRIPTION_SIGN_OFF}`
}
return `Scheduled maintenance is in progress. ${STATUS_DESCRIPTION_SIGN_OFF}`
default:
return `We are investigating ${issueTerm}. ${STATUS_DESCRIPTION_SIGN_OFF}`
}
}
export type ActiveStatusItem = Extract<StatusItem, { kind: 'incident' | 'maintenance' }>
function getItemStatusLabel(item: ActiveStatusItem): string {
return item.kind === 'incident' ? item.status : 'maintenance_in_progress'
}
/**
* Incidents and in-progress maintenance only (never upcoming maintenance), ranked by impact
* (maintenance ranks below every incident impact), then by recency. Ignores `showBanner`:
* "Hide banner" only hides the global banner, not the support form's status pill/admonition.
*/
export function prioritizeActiveItems(items: Array<StatusItem>): Array<ActiveStatusItem> {
const active = items.filter(
(item): item is ActiveStatusItem => item.kind === 'incident' || item.kind === 'maintenance'
)
return active.sort((a, b) => {
const rankA = a.kind === 'incident' ? IMPACT_RANK[a.impact] : 0
const rankB = b.kind === 'incident' ? IMPACT_RANK[b.impact] : 0
if (rankB !== rankA) return rankB - rankA
return new Date(b.lastUpdateAt).getTime() - new Date(a.lastUpdateAt).getTime()
})
}
export function getSupportAdmonitionCopy(
items: Array<StatusItem>
): { title: string; description: string } | null {
const active = prioritizeActiveItems(items)
if (active.length === 0) return null
const [top, ...rest] = active
const hasMultipleIncidents = rest.length > 0
const title = hasMultipleIncidents
? `${top.name} and ${rest.length} other issue${rest.length > 1 ? 's' : ''}`
: top.name
const topStatus = getItemStatusLabel(top)
const allSameStatus = active.every((item) => getItemStatusLabel(item) === topStatus)
return {
title,
description: getStatusDescription({ status: topStatus, hasMultipleIncidents, allSameStatus }),
}
}
export function getStatusPillLabel(items: Array<StatusItem>): string {
const active = prioritizeActiveItems(items)
if (active.length === 0) return 'All systems operational'
if (active.some((item) => item.kind === 'incident')) return 'Active incident ongoing'
return 'Scheduled maintenance'
}
export type SupportStatus =
| { status: 'pending' }
| { status: 'error' }
| {
status: 'success'
hasActiveIncidents: boolean
pillLabel: string
admonition: { title: string; description: string } | null
pageUrl: string
}
export function getSupportStatusLabel(status: SupportStatus): string {
if (status.status === 'pending') return 'Checking status'
if (status.status === 'error') return 'Failed to check status'
return status.pillLabel
}
@@ -11,6 +11,7 @@ import { SupportForm, SupportFormPage, SupportFormStatusButton } from '../Suppor
// End of third-party imports
import { API_URL, BASE_PATH } from '@/lib/constants'
import type { StatusPageResponse } from '@/lib/status-page/status-page.schema'
import { createMockOrganizationResponse, createMockProject } from '@/tests/helpers'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock, mswServer, type APIErrorBody } from '@/tests/lib/msw'
@@ -142,6 +143,10 @@ const { mockCommitSha, mockCommitTime, mockUseDeploymentCommitQuery } = vi.hoist
const mockStudioVersion = `SHA ${mockCommitSha} deployed at ${dayjs(mockCommitTime).format('YYYY-MM-DD HH:mm:ss Z')}`
const { mockUseFlag } = vi.hoisted(() => ({
mockUseFlag: vi.fn().mockReturnValue(false),
}))
vi.mock('react-inlinesvg', () => ({
__esModule: true,
default: () => null,
@@ -190,6 +195,7 @@ vi.mock(import('common'), async (importOriginal) => {
...actual,
useParams: vi.fn().mockReturnValue({ ref: 'default' }),
useIsLoggedIn: vi.fn().mockReturnValue(true),
useFlag: mockUseFlag,
isFeatureEnabled: vi.fn((feature: any, disabledFeatures: any) => {
if (typeof feature === 'string') {
if (feature === 'support:show_client_libraries') {
@@ -392,6 +398,7 @@ describe('SupportFormPage', () => {
})
beforeEach(async () => {
mockUseFlag.mockReset().mockReturnValue(false)
mockUseDeploymentCommitQuery.mockReturnValue({
data: { commitSha: mockCommitSha, commitTime: mockCommitTime },
})
@@ -590,6 +597,48 @@ describe('SupportFormPage', () => {
})
})
test('flag on: shows an active incident from the new status page endpoint', async () => {
mockUseFlag.mockImplementation((name: string) => name === 'incidentIoStatusPage')
mswServer.use(
http.get(`${BASE_PATH}/api/status-page`, () =>
HttpResponse.json<StatusPageResponse>({
page_title: 'Supabase status',
page_url: 'https://status.supabase.com/',
ongoing_incidents: [
{
id: 'inc-1',
name: 'Elevated errors',
url: 'https://status.supabase.com/incidents/inc-1',
last_update_at: '2026-01-01T00:00:00Z',
last_update_message: null,
affected_components: [],
status: 'investigating',
current_worst_impact: 'full_outage',
visible: true,
show_banner: true,
},
],
in_progress_maintenances: [],
scheduled_maintenances: [],
})
)
)
renderSupportFormPage()
await waitFor(() => {
expect(getStatusLink(screen)).toHaveTextContent('Active incident ongoing')
})
expect(await screen.findByText('Elevated errors')).toBeInTheDocument()
expect(
screen.getByText('We are investigating this issue. Follow the status page for updates.')
).toBeInTheDocument()
expect(screen.queryByText('Try Supabase Assistant')).not.toBeInTheDocument()
})
test('loading with initial params prefills the organization and project', async () => {
renderSupportForm({ initialParams: { projectRef: 'project-3' } })
@@ -0,0 +1,82 @@
import { useQuery } from '@tanstack/react-query'
import { useFlag } from 'common'
import {
DEFAULT_STATUS_PAGE_URL,
getStatusDescription,
getStatusPillLabel,
getSupportAdmonitionCopy,
prioritizeActiveItems,
type SupportStatus,
} from './SupportStatus.utils'
import { useIncidentStatusQuery } from '@/data/platform/incident-status-query'
import { processIncidentData } from '@/data/platform/incident-status-utils'
import { statusPageQueryOptions } from '@/data/platform/status-page-query'
import { IS_PLATFORM } from '@/lib/constants'
import { normalizeStatusPage } from '@/lib/status-page/status-page.utils'
function getLegacyPillLabel(hasActiveIncidents: boolean, isMaintenance: boolean): string {
if (hasActiveIncidents) return 'Active incident ongoing'
if (isMaintenance) return 'Scheduled maintenance'
return 'All systems operational'
}
export function useSupportStatus(): SupportStatus {
const useNewStatusPage = useFlag('incidentIoStatusPage') === true
const newStatusQuery = useQuery({
...statusPageQueryOptions(),
select: normalizeStatusPage,
enabled: useNewStatusPage && IS_PLATFORM,
})
const legacyQuery = useIncidentStatusQuery({ enabled: !useNewStatusPage })
if (useNewStatusPage) {
if (newStatusQuery.isPending) return { status: 'pending' }
if (newStatusQuery.isError) return { status: 'error' }
const { pageUrl, items } = newStatusQuery.data
const activeItems = prioritizeActiveItems(items)
return {
status: 'success',
hasActiveIncidents: activeItems.some((item) => item.kind === 'incident'),
pillLabel: getStatusPillLabel(items),
admonition: getSupportAdmonitionCopy(items),
pageUrl,
}
}
if (legacyQuery.isPending) return { status: 'pending' }
if (legacyQuery.isError) return { status: 'error' }
const { incidents = [], maintenanceEvents = [] } = legacyQuery.data ?? {}
const hasActiveIncidents = incidents.length > 0
const isMaintenance = maintenanceEvents.length > 0
const { hasMultipleIncidents, mostCriticalIncident, overallStatus, allSameStatus } =
processIncidentData(incidents)
const admonitionTitle =
(mostCriticalIncident?.name ?? '') +
(hasMultipleIncidents
? ` and ${incidents.length - 1} other issue${incidents.length > 2 ? 's' : ''}`
: '')
return {
status: 'success',
hasActiveIncidents,
pillLabel: getLegacyPillLabel(hasActiveIncidents, isMaintenance),
admonition: hasActiveIncidents
? {
title: admonitionTitle,
description: getStatusDescription({
status: overallStatus,
hasMultipleIncidents,
allSameStatus,
}),
}
: null,
pageUrl: DEFAULT_STATUS_PAGE_URL,
}
}
+1
View File
@@ -1,4 +1,5 @@
export const platformKeys = {
incidentStatus: () => ['platform', 'incident-status'] as const,
incidentBanner: () => ['platform', 'incident-banner'] as const,
statusPage: () => ['platform', 'status-page'] as const,
}
@@ -0,0 +1,58 @@
import { queryOptions } from '@tanstack/react-query'
import { platformKeys } from './keys'
import { BASE_PATH, IS_PLATFORM } from '@/lib/constants'
import {
StatusPageResponseSchema,
type StatusPageResponse,
} from '@/lib/status-page/status-page.schema'
import { ResponseError } from '@/types'
async function getStatusPage(signal?: AbortSignal): Promise<StatusPageResponse> {
const response = await fetch(`${BASE_PATH}/api/status-page`, {
signal,
method: 'GET',
credentials: 'omit',
})
if (!response.ok) {
const errorText = await response.text()
console.error('[getStatusPage] Failed:', response.status, errorText)
let retryAfter: number | undefined
const retryAfterHeader = response.headers.get('Retry-After')
if (retryAfterHeader !== null) {
const parsed = Number(retryAfterHeader)
if (Number.isFinite(parsed) && parsed > 0) retryAfter = parsed
}
throw new ResponseError(
`Failed to fetch status page: ${response.statusText}`,
response.status,
undefined,
retryAfter
)
}
const json = await response.json()
return StatusPageResponseSchema.parse(json)
}
export type StatusPageData = Awaited<ReturnType<typeof getStatusPage>>
export type StatusPageError = ResponseError
export const statusPageQueryOptions = () =>
queryOptions({
queryKey: platformKeys.statusPage(),
queryFn: ({ signal }) => getStatusPage(signal),
staleTime: 60_000,
refetchInterval: 5 * 60_000,
refetchOnWindowFocus: false,
retryDelay: (attemptIndex: number, error: StatusPageError) => {
if (error instanceof ResponseError && error.retryAfter) {
return error.retryAfter * 1000
}
return Math.min(1000 * 4 ** attemptIndex, 1000 * 60 * 5)
},
enabled: IS_PLATFORM,
})
@@ -6,7 +6,7 @@ const ComponentStatus = z.enum([
'partial_outage',
'full_outage',
])
const WorstImpact = z.enum(['degraded_performance', 'partial_outage', 'full_outage'])
export const WorstImpactSchema = z.enum(['degraded_performance', 'partial_outage', 'full_outage'])
export const AffectedComponentSchema = z.object({
id: z.string(),
@@ -27,7 +27,7 @@ const Base = {
export const WidgetIncidentSchema = z.object({
...Base,
status: z.enum(['investigating', 'identified', 'monitoring']).catch('investigating'),
current_worst_impact: WorstImpact.catch('full_outage'),
current_worst_impact: WorstImpactSchema.catch('full_outage'),
})
export const WidgetInProgressMaintenanceSchema = z.object({
@@ -94,3 +94,4 @@ export const StatusPageResponseSchema = WidgetResponseSchema.extend({
export type WidgetResponse = z.infer<typeof WidgetResponseSchema>
export type StatusPageResponse = z.infer<typeof StatusPageResponseSchema>
export type AffectedComponent = z.infer<typeof AffectedComponentSchema>
export type WorstImpact = z.infer<typeof WorstImpactSchema>
@@ -0,0 +1,324 @@
import { describe, expect, test } from 'vitest'
import type { AffectedComponent, StatusPageResponse } from './status-page.schema'
import {
DEFAULT_UPCOMING_LEAD_DAYS,
getRegionScope,
isProjectCreationOnly,
isRegionCode,
isRelevantToUser,
normalizeStatusPage,
type UserRegionContext,
} from './status-page.utils'
function component(
name: string,
groupName?: string | null,
status: AffectedComponent['current_status'] = 'degraded_performance'
): AffectedComponent {
return { id: name, name, group_name: groupName ?? undefined, current_status: status }
}
function emptyStatusPage(): StatusPageResponse {
return {
page_title: 'Supabase status',
page_url: 'https://status.supabase.com/',
ongoing_incidents: [],
in_progress_maintenances: [],
scheduled_maintenances: [],
}
}
describe('isRegionCode', () => {
test('accepts valid region codes', () => {
expect(isRegionCode('us-east-1')).toBe(true)
expect(isRegionCode('ap-southeast-2')).toBe(true)
expect(isRegionCode('eu-central-1')).toBe(true)
})
test('is case-insensitive and trims whitespace', () => {
expect(isRegionCode('US-EAST-1')).toBe(true)
expect(isRegionCode(' us-east-1 ')).toBe(true)
})
test('rejects non-region names', () => {
expect(isRegionCode('Dashboard')).toBe(false)
expect(isRegionCode('All regions')).toBe(false)
expect(isRegionCode('')).toBe(false)
expect(isRegionCode('us1')).toBe(false)
})
})
describe('getRegionScope', () => {
test('returns regions when every component is a region code', () => {
expect(getRegionScope([component('us-east-1'), component('ap-southeast-2')])).toEqual({
type: 'regions',
regions: ['us-east-1', 'ap-southeast-2'],
})
})
test('returns global for a mixed set of components', () => {
expect(getRegionScope([component('us-east-1'), component('Dashboard')])).toEqual({
type: 'global',
})
})
test('returns global for an empty component list', () => {
expect(getRegionScope([])).toEqual({ type: 'global' })
})
})
describe('isProjectCreationOnly', () => {
test('true when every component belongs to Project Creation', () => {
expect(
isProjectCreationOnly([
component('us-east-1', 'Project Creation'),
component('ap-southeast-2', 'project creation'),
])
).toBe(true)
})
test('false when any component is outside Project Creation', () => {
expect(
isProjectCreationOnly([
component('us-east-1', 'Project Creation'),
component('us-east-1', 'Database'),
])
).toBe(false)
})
test('false for an empty component list', () => {
expect(isProjectCreationOnly([])).toBe(false)
})
})
describe('normalizeStatusPage: split scopes', () => {
test('Project Creation plus a product component in the same region split into two scopes', () => {
const page: StatusPageResponse = {
...emptyStatusPage(),
ongoing_incidents: [
{
id: 'inc-1',
name: 'Elevated errors',
url: 'https://status.supabase.com/incidents/inc-1',
last_update_at: '2026-01-01T00:00:00Z',
last_update_message: null,
affected_components: [
component('us-east-1', 'Project Creation'),
component('us-east-1', 'Database'),
],
status: 'investigating',
current_worst_impact: 'degraded_performance',
visible: true,
show_banner: true,
},
],
}
const { items } = normalizeStatusPage(page)
expect(items).toHaveLength(1)
expect(items[0].scope).toEqual({ type: 'regions', regions: ['us-east-1'] })
expect(items[0].projectCreationScope).toEqual({ type: 'regions', regions: ['us-east-1'] })
expect(items[0].isProjectCreationOnly).toBe(false)
})
test('Project Creation with a non-region component name gives a global creation scope', () => {
const page: StatusPageResponse = {
...emptyStatusPage(),
ongoing_incidents: [
{
id: 'inc-2',
name: 'Project creation delayed',
url: 'https://status.supabase.com/incidents/inc-2',
last_update_at: '2026-01-01T00:00:00Z',
last_update_message: null,
affected_components: [component('All regions', 'Project Creation')],
status: 'investigating',
current_worst_impact: 'degraded_performance',
visible: true,
show_banner: true,
},
],
}
const { items } = normalizeStatusPage(page)
expect(items[0].isProjectCreationOnly).toBe(true)
expect(items[0].projectCreationScope).toEqual({ type: 'global' })
expect(items[0].scope).toEqual({ type: 'global' })
})
})
describe('normalizeStatusPage', () => {
test('drops invisible items', () => {
const page: StatusPageResponse = {
...emptyStatusPage(),
ongoing_incidents: [
{
id: 'inc-hidden',
name: 'Hidden incident',
url: 'https://status.supabase.com/incidents/inc-hidden',
last_update_at: '2026-01-01T00:00:00Z',
last_update_message: null,
affected_components: [],
status: 'investigating',
current_worst_impact: 'full_outage',
visible: false,
show_banner: true,
},
],
}
expect(normalizeStatusPage(page).items).toHaveLength(0)
})
test('carries showBanner through unchanged', () => {
const page: StatusPageResponse = {
...emptyStatusPage(),
ongoing_incidents: [
{
id: 'inc-quiet',
name: 'Quiet incident',
url: 'https://status.supabase.com/incidents/inc-quiet',
last_update_at: '2026-01-01T00:00:00Z',
last_update_message: null,
affected_components: [],
status: 'investigating',
current_worst_impact: 'full_outage',
visible: true,
show_banner: false,
},
],
}
expect(normalizeStatusPage(page).items[0].showBanner).toBe(false)
})
test('resolves leadDays from banner_lead_days, falling back to the default', () => {
const page: StatusPageResponse = {
...emptyStatusPage(),
scheduled_maintenances: [
{
id: 'maint-1',
name: 'Custom lead time',
url: 'https://status.supabase.com/incidents/maint-1',
last_update_at: '2026-01-01T00:00:00Z',
last_update_message: null,
affected_components: [],
status: 'maintenance_scheduled',
starts_at: '2026-02-01T00:00:00Z',
ends_at: '2026-02-01T02:00:00Z',
visible: true,
show_banner: true,
banner_lead_days: 3,
},
{
id: 'maint-2',
name: 'Default lead time',
url: 'https://status.supabase.com/incidents/maint-2',
last_update_at: '2026-01-01T00:00:00Z',
last_update_message: null,
affected_components: [],
status: 'maintenance_scheduled',
starts_at: '2026-02-01T00:00:00Z',
ends_at: '2026-02-01T02:00:00Z',
visible: true,
show_banner: true,
banner_lead_days: null,
},
],
}
const { items } = normalizeStatusPage(page)
const customLead = items.find((item) => item.id === 'maint-1')
const defaultLead = items.find((item) => item.id === 'maint-2')
expect(customLead?.kind === 'upcoming_maintenance' && customLead.leadDays).toBe(3)
expect(defaultLead?.kind === 'upcoming_maintenance' && defaultLead.leadDays).toBe(
DEFAULT_UPCOMING_LEAD_DAYS
)
})
})
describe('isRelevantToUser', () => {
const regionalItem = normalizeStatusPage({
...emptyStatusPage(),
ongoing_incidents: [
{
id: 'inc-region',
name: 'Regional incident',
url: 'https://status.supabase.com/incidents/inc-region',
last_update_at: '2026-01-01T00:00:00Z',
last_update_message: null,
affected_components: [component('us-east-1', 'Database')],
status: 'investigating',
current_worst_impact: 'full_outage',
visible: true,
show_banner: true,
},
],
}).items[0]
const globalItem = normalizeStatusPage({
...emptyStatusPage(),
ongoing_incidents: [
{
id: 'inc-global',
name: 'Global incident',
url: 'https://status.supabase.com/incidents/inc-global',
last_update_at: '2026-01-01T00:00:00Z',
last_update_message: null,
affected_components: [component('Dashboard', 'Dashboard')],
status: 'investigating',
current_worst_impact: 'full_outage',
visible: true,
show_banner: true,
},
],
}).items[0]
test('false when the user has no projects', () => {
const user: UserRegionContext = {
hasProjects: false,
regions: new Set(['us-east-1']),
isComplete: true,
}
expect(isRelevantToUser(regionalItem, user)).toBe(false)
expect(isRelevantToUser(globalItem, user)).toBe(false)
})
test('global scope is always relevant when the user has projects', () => {
const user: UserRegionContext = { hasProjects: true, regions: new Set(), isComplete: true }
expect(isRelevantToUser(globalItem, user)).toBe(true)
})
test('incomplete region data fails open for regional items', () => {
const user: UserRegionContext = { hasProjects: true, regions: new Set(), isComplete: false }
expect(isRelevantToUser(regionalItem, user)).toBe(true)
})
test('intersecting regions are relevant', () => {
const user: UserRegionContext = {
hasProjects: true,
regions: new Set(['us-east-1']),
isComplete: true,
}
expect(isRelevantToUser(regionalItem, user)).toBe(true)
})
test('disjoint regions are not relevant', () => {
const user: UserRegionContext = {
hasProjects: true,
regions: new Set(['ap-southeast-2']),
isComplete: true,
}
expect(isRelevantToUser(regionalItem, user)).toBe(false)
})
test('region comparison is case-insensitive', () => {
const user: UserRegionContext = {
hasProjects: true,
regions: new Set(['US-EAST-1']),
isComplete: true,
}
expect(isRelevantToUser(regionalItem, user)).toBe(true)
})
})
@@ -0,0 +1,166 @@
import type { AffectedComponent, StatusPageResponse, WorstImpact } from './status-page.schema'
export const PROJECT_CREATION_GROUP_NAME = 'project creation'
// Pattern, not a hardcoded region list, so new or test regions (e.g. a new AWS region, or a
// staging-only region) are recognized without a code change.
const REGION_CODE_PATTERN = /^[a-z]{2}(-[a-z]+)+-\d+$/
export const IMPACT_RANK: Record<WorstImpact, number> = {
full_outage: 3,
partial_outage: 2,
degraded_performance: 1,
}
export const DEFAULT_UPCOMING_LEAD_DAYS = 7
function normalizeLabel(value: string): string {
return value.trim().toLowerCase()
}
export function normalizeRegion(region: string): string {
return normalizeLabel(region)
}
export function isRegionCode(name: string): boolean {
return REGION_CODE_PATTERN.test(normalizeRegion(name))
}
export function isProjectCreationComponent(component: AffectedComponent): boolean {
return normalizeLabel(component.group_name ?? '') === PROJECT_CREATION_GROUP_NAME
}
export type RegionScope = { type: 'global' } | { type: 'regions'; regions: ReadonlyArray<string> }
export function getRegionScope(components: ReadonlyArray<AffectedComponent>): RegionScope {
if (components.length > 0 && components.every((component) => isRegionCode(component.name))) {
return {
type: 'regions',
regions: components.map((component) => normalizeRegion(component.name)),
}
}
return { type: 'global' }
}
export function isProjectCreationOnly(components: ReadonlyArray<AffectedComponent>): boolean {
return components.length > 0 && components.every(isProjectCreationComponent)
}
type StatusItemBase = {
id: string
name: string
url: string
lastUpdateAt: string
lastUpdateMessage: string | null
components: ReadonlyArray<AffectedComponent>
/** Scope derived from the components that aren't Project Creation. */
scope: RegionScope
/** Scope derived from Project Creation components; null when none affect creation. */
projectCreationScope: RegionScope | null
isProjectCreationOnly: boolean
showBanner: boolean
}
export type StatusItem =
| (StatusItemBase & {
kind: 'incident'
status: 'investigating' | 'identified' | 'monitoring'
impact: WorstImpact
})
| (StatusItemBase & { kind: 'maintenance'; startedAt: string; scheduledEndAt: string | null })
| (StatusItemBase & {
kind: 'upcoming_maintenance'
startsAt: string | null
endsAt: string | null
leadDays: number
})
export type NormalizedStatusPage = { pageUrl: string; items: Array<StatusItem> }
type WidgetItemCommon = {
id: string
name: string
url: string
last_update_at: string
last_update_message?: string | null
affected_components: Array<AffectedComponent>
visible: boolean
show_banner: boolean
}
function buildBase(item: WidgetItemCommon): StatusItemBase {
const nonCreationComponents = item.affected_components.filter(
(component) => !isProjectCreationComponent(component)
)
const creationComponents = item.affected_components.filter(isProjectCreationComponent)
return {
id: item.id,
name: item.name,
url: item.url,
lastUpdateAt: item.last_update_at,
lastUpdateMessage: item.last_update_message ?? null,
components: item.affected_components,
scope: getRegionScope(nonCreationComponents),
projectCreationScope: creationComponents.length > 0 ? getRegionScope(creationComponents) : null,
isProjectCreationOnly: isProjectCreationOnly(item.affected_components),
showBanner: item.show_banner,
}
}
export function normalizeStatusPage(res: StatusPageResponse): NormalizedStatusPage {
const items: Array<StatusItem> = [
...res.ongoing_incidents
.filter((incident) => incident.visible)
.map(
(incident): StatusItem => ({
...buildBase(incident),
kind: 'incident',
status: incident.status,
impact: incident.current_worst_impact,
})
),
...res.in_progress_maintenances
.filter((maintenance) => maintenance.visible)
.map(
(maintenance): StatusItem => ({
...buildBase(maintenance),
kind: 'maintenance',
startedAt: maintenance.started_at,
scheduledEndAt: maintenance.scheduled_end_at ?? null,
})
),
...res.scheduled_maintenances
.filter((maintenance) => maintenance.visible)
.map(
(maintenance): StatusItem => ({
...buildBase(maintenance),
kind: 'upcoming_maintenance',
startsAt: maintenance.starts_at ?? null,
endsAt: maintenance.ends_at ?? null,
leadDays: maintenance.banner_lead_days ?? DEFAULT_UPCOMING_LEAD_DAYS,
})
),
]
return { pageUrl: res.page_url, items }
}
export function getDismissalKey(item: Pick<StatusItem, 'kind' | 'id'>): string {
return `${item.kind}:${item.id}`
}
export type UserRegionContext = {
hasProjects: boolean
regions: ReadonlySet<string>
isComplete: boolean
}
export function isRelevantToUser(item: StatusItem, user: UserRegionContext): boolean {
if (!user.hasProjects) return false
if (item.scope.type === 'global') return true
if (!user.isComplete) return true
const userRegions = new Set(Array.from(user.regions, normalizeRegion))
return item.scope.regions.some((region) => userRegions.has(normalizeRegion(region)))
}