feat: version who-knows-what of incident banner (#43726)

Feature

## What is the current behavior?

Incident banner logic depends on StatusPage and Supabase project for
metadata.

## What is the new behavior?

New incident banner logic that depends only on incident.io. Displays in
non-production environments for now because I haven't wired up the rest
of the workflow. This is just to allow a total end-to-end
testing/playground for test incidents <-> Slack <-> preview dashboard
for people to try out the UX.

## Additional context

You can test using my [test
incident](https://app.incident.io/supabase/incidents/405). This has
severity minor, so the preview site should have a banner. Toggle to
informative, hard refresh dashboard with cache off, and banner should
disappear. Toggle back to minor, hard refresh without cache again, and
banner should reappear. Same thing if you edit the "Banner shown" field
from 1 to -1 and back.
This commit is contained in:
Charis authored and GitHub committed 2026-03-12 16:55:53 -04:00
1 parent f53b8f4dd7
commit befc817f94
12 files changed
+275 -20

No files matched your search

@@ -19,6 +19,10 @@ const usEast1AndCreation = {
id: 'us-east-1-and-creation',
cache: { affected_regions: ['us-east-1'], affects_project_creation: true },
}
const forced = {
id: 'forced',
cache: { affected_regions: ['us-east-1'], affects_project_creation: false, force: true },
}
describe('shouldShowBanner', () => {
describe('no incidents', () => {
@@ -213,6 +217,40 @@ describe('shouldShowBanner', () => {
})
})
describe('force', () => {
it('shows for a user with no projects regardless of affects_project_creation', () => {
expect(
shouldShowBanner({ incidents: [forced], hasProjects: false, userRegions: new Set() })
).toBe(true)
})
it('shows for a user whose regions do not overlap with affected_regions', () => {
expect(
shouldShowBanner({
incidents: [forced],
hasProjects: true,
userRegions: new Set(['eu-west-1']),
})
).toBe(true)
})
it('shows for a user with projects and no regions at all', () => {
expect(
shouldShowBanner({ incidents: [forced], hasProjects: true, userRegions: new Set() })
).toBe(true)
})
it('shows even when mixed with non-matching non-forced incidents', () => {
expect(
shouldShowBanner({
incidents: [usEast1Only, forced],
hasProjects: false,
userRegions: new Set(),
})
).toBe(true)
})
})
describe('hasUnknownRegions', () => {
it('shows when regions are unknown and incident has a region restriction', () => {
expect(
@@ -26,6 +26,9 @@ export function shouldShowBanner({
hasUnknownRegions?: boolean
}): boolean {
return incidents.some((incident) => {
// Forced incidents are shown unconditionally, regardless of regions or project state
if (incident.cache?.force) return true
const affectedRegions = incident.cache?.affected_regions ?? []
const affectsProjectCreation = incident.cache?.affects_project_creation ?? false
@@ -1,9 +1,10 @@
import { useQueries } from '@tanstack/react-query'
import { LOCAL_STORAGE_KEYS, useFlag } from 'common'
import { useQueries, useQuery } from '@tanstack/react-query'
import { IS_PROD, LOCAL_STORAGE_KEYS, useFlag } from 'common'
import { useCallback, useMemo } from 'react'
import { getRelevantIncidentIds, shouldShowBanner } from './StatusPageBanner.utils'
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
import { incidentBannerQueryOptions } from '@/data/platform/incident-banner-query'
import { useIncidentStatusQuery } from '@/data/platform/incident-status-query'
import { projectKeys } from '@/data/projects/keys'
import {
@@ -14,13 +15,24 @@ import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
export type StatusPageBannerData = { title: string; dismiss?: () => void }
// In non-production environments the incident-banner endpoint is the source of truth.
const IS_NON_PROD = !IS_PROD
export function useStatusPageBannerVisibility(): StatusPageBannerData | null {
const showIncidentBannerOverride =
useFlag('ongoingIncident') || process.env.NEXT_PUBLIC_ONGOING_INCIDENT === 'true'
// Both queries run in parallel on all environments.
const { data: allStatusPageEvents } = useIncidentStatusQuery()
const { incidents: allIncidents = [] } = allStatusPageEvents ?? {}
const incidents = allIncidents.filter((i) => i.impact !== 'none')
const { data: incidentBannerData } = useQuery(incidentBannerQueryOptions())
// In non-production: derive incidents from the incident-banner endpoint.
// In production: derive incidents from the statuspage endpoint (existing behaviour).
const bannerItems = incidentBannerData?.incidents ?? []
const incidents = IS_NON_PROD
? bannerItems.map((i) => ({ id: i.id, cache: i.metadata }))
: (allStatusPageEvents?.incidents ?? []).filter((i) => i.impact !== 'none')
const hasActiveIncidents = incidents.length > 0
@@ -75,14 +87,9 @@ export function useStatusPageBannerVisibility(): StatusPageBannerData | null {
if (!hasActiveIncidents || !isProjectsFetched) return null
// Filter out individually dismissed incidents. An incident stays dismissed as
// long as its ID remains in the stored set, regardless of whether other
// incidents are added or removed.
const dismissedIdSet = new Set(dismissedIds)
const undismissedIncidents = incidents.filter((i) => !dismissedIdSet.has(i.id))
// If dismissed state hasn't loaded yet, hide to prevent a flash of the banner.
// If all relevant incidents have been dismissed, hide the banner.
if (
!isDismissedLoaded ||
!shouldShowBanner({
@@ -98,8 +105,5 @@ export function useStatusPageBannerVisibility(): StatusPageBannerData | null {
? 'We are investigating a technical issue'
: 'Project creation may be impacted in some regions'
return {
title,
dismiss,
}
return { title, dismiss }
}
@@ -0,0 +1,37 @@
import { queryOptions } from '@tanstack/react-query'
import { getAccessToken, IS_PLATFORM } from 'common'
import { platformKeys } from './keys'
import { BASE_PATH } from '@/lib/constants'
export interface IncidentBannerItem {
id: string
show_banner: true | 'force'
metadata: {
affected_regions: Array<string> | null
affects_project_creation: boolean
force: boolean
}
}
export type IncidentBannerData = { incidents: Array<IncidentBannerItem> }
export type IncidentBannerError = unknown
async function getIncidentBanner(signal?: AbortSignal): Promise<IncidentBannerData> {
const accessToken = await getAccessToken()
const response = await fetch(`${BASE_PATH}/api/incident-banner`, {
signal,
method: 'GET',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` },
})
if (!response.ok) throw new Error(`Failed to fetch incident banner: ${response.statusText}`)
return response.json()
}
export const incidentBannerQueryOptions = () =>
queryOptions({
queryKey: platformKeys.incidentBanner(),
queryFn: ({ signal }) => getIncidentBanner(signal),
staleTime: 1000 * 60 * 5,
enabled: IS_PLATFORM,
})
@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { getAccessToken } from 'common'
import type { IncidentInfo } from 'lib/api/incident-status'
import { BASE_PATH, IS_PLATFORM, IS_TEST_ENV } from 'lib/constants'
import { partition } from 'lodash'
@@ -9,11 +10,13 @@ import { platformKeys } from './keys'
export async function getIncidentStatus(
signal?: AbortSignal
): Promise<{ maintenanceEvents: IncidentInfo[]; incidents: IncidentInfo[] }> {
const accessToken = await getAccessToken()
const response = await fetch(`${BASE_PATH}/api/incident-status`, {
signal,
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
+1
View File
@@ -1,3 +1,4 @@
export const platformKeys = {
incidentStatus: () => ['platform', 'incident-status'] as const,
incidentBanner: () => ['platform', 'incident-banner'] as const,
}
+2 -2
View File
@@ -31,9 +31,9 @@ export default async function apiWrapper(
req: NextApiRequest,
res: NextApiResponse,
claims?: JwtPayload
) => Promise<Response | void>,
) => Promise<NextApiResponse | Response | void>,
options?: { withAuth: boolean }
): Promise<Response | void> {
): Promise<NextApiResponse | Response | void> {
try {
const { withAuth } = options || {}
let claims: JwtPayload | undefined
+2
View File
@@ -6,6 +6,8 @@ import { InternalServerError } from 'lib/api/apiHelpers'
export type IncidentCache = {
affected_regions: Array<string> | null
affects_project_creation: boolean
/** When true, the banner is shown unconditionally regardless of regions or project state. */
force?: boolean
}
export type IncidentMetadata = {
+163
View File
@@ -0,0 +1,163 @@
import { createHash } from 'crypto'
import { IS_PLATFORM, IS_PROD } from 'common'
import { NextApiRequest, NextApiResponse } from 'next'
import { z } from 'zod'
import apiWrapper from 'lib/api/apiWrapper'
const INCIDENT_IO_BASE_URL = 'https://api.incident.io/v2'
const BANNER_FIELD_ID = '01KKCFNW31EGRMD3JQ58E2TJ2M'
const METADATA_FIELD_ID = '01KKCD4KNWQ7HYSXT72CHB7WR4'
const MINOR_SEVERITY_ID = '01J7BTA8DEF371JQSXGBZYZY7D'
const SENTINEL_VALUE_SHOW_BANNER = '1'
const SENTINEL_VALUE_FORCE_BANNER = '100'
const FALLBACK_METADATA = { affected_regions: null, affects_project_creation: false }
/**
* Cache on browser for 5 minutes
* Cache on CDN for 5 minutes
* Allow serving stale content for 1 minute while revalidating
*/
const CACHE_CONTROL_SETTINGS = 'public, max-age=300, s-maxage=300, stale-while-revalidate=60'
const MetadataSchema = z.object({
affected_regions: z.union([z.array(z.string()), z.null()]),
affects_project_creation: z.boolean(),
})
interface CustomFieldValue {
value_option?: { id: string; value: string }
value_text?: string
value_numeric?: string
}
interface CustomFieldEntry {
custom_field: { id: string }
values: Array<CustomFieldValue>
}
interface Incident {
id: string
name: string
mode: string
created_at: string
custom_field_entries: Array<CustomFieldEntry>
}
interface IncidentIoListResponse {
incidents: Array<Incident>
pagination_meta?: { after?: string }
}
type ShowBannerValue = true | 'force'
interface BannerIncident {
id: string
show_banner: ShowBannerValue
metadata: z.infer<typeof MetadataSchema> & { force: boolean }
}
function getFieldValue(entries: Array<CustomFieldEntry>, fieldId: string): string | undefined {
const entry = entries.find((e) => e.custom_field.id === fieldId)
if (!entry || entry.values.length === 0) return undefined
const val = entry.values[0]
return val.value_option?.value ?? val.value_text ?? val.value_numeric
}
async function fetchAllIncidents(apiKey: string, mode: string): Promise<Array<Incident>> {
const incidents: Array<Incident> = []
let after: string | undefined
do {
const params = new URLSearchParams()
params.append('status_category[one_of]', 'live')
params.append('severity[gte]', MINOR_SEVERITY_ID)
params.append('mode[one_of]', mode)
params.set('page_size', '25')
if (after) params.set('after', after)
const response = await fetch(`${INCIDENT_IO_BASE_URL}/incidents?${params}`, {
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
const cause = await response.text()
throw new Error(`incident.io API responded with status ${response.status}`, { cause })
}
const data: IncidentIoListResponse = await response.json()
incidents.push(...data.incidents)
after = data.pagination_meta?.after
} while (after)
return incidents
}
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!IS_PLATFORM) {
return res.status(404).end()
}
if (req.method !== 'GET') {
res.setHeader('Allow', ['GET'])
return res.status(405).json({ error: { message: `Method ${req.method} Not Allowed` } })
}
const apiKey = process.env.INCIDENT_IO_API_KEY
if (!apiKey) {
console.error('INCIDENT_IO_API_KEY is not set')
return res.status(500).json({ error: { message: 'Internal server error' } })
}
const incidentMode = IS_PROD ? 'standard' : 'test'
let allIncidents: Array<Incident>
try {
allIncidents = await fetchAllIncidents(apiKey, incidentMode)
} catch (error) {
console.error('Error fetching incidents from incident.io: %O', error)
return res.status(502).json({ error: { message: 'Internal server error' } })
}
const bannerIncidents: Array<BannerIncident> = []
for (const incident of allIncidents) {
const bannerValue = getFieldValue(incident.custom_field_entries, BANNER_FIELD_ID)
if (bannerValue !== SENTINEL_VALUE_SHOW_BANNER && bannerValue !== SENTINEL_VALUE_FORCE_BANNER) {
continue
}
const metadataRaw = getFieldValue(incident.custom_field_entries, METADATA_FIELD_ID)
let parsedJson: unknown = null
try {
parsedJson = JSON.parse(metadataRaw ?? 'null')
} catch {
// malformed JSON — fall through to default metadata
}
const parsed = MetadataSchema.safeParse(parsedJson)
const metadata: z.infer<typeof MetadataSchema> = parsed.success
? parsed.data
: FALLBACK_METADATA
bannerIncidents.push({
id: createHash('sha256').update(incident.created_at).digest('hex'),
show_banner: bannerValue === SENTINEL_VALUE_FORCE_BANNER ? 'force' : true,
metadata: { ...metadata, force: bannerValue === SENTINEL_VALUE_FORCE_BANNER },
})
}
res.setHeader('Cache-Control', CACHE_CONTROL_SETTINGS)
return res.status(200).json({ incidents: bannerIncidents })
}
const wrapper = (req: NextApiRequest, res: NextApiResponse) =>
apiWrapper(req, res, handler, { withAuth: true })
export default wrapper
+7 -5
View File
@@ -8,6 +8,7 @@ import {
type IncidentInfo,
} from '@/lib/api/incident-status'
import { createAdminClient } from '@/lib/api/supabase-admin'
import apiWrapper from 'lib/api/apiWrapper'
/**
* Cache on browser for 5 minutes
@@ -47,11 +48,7 @@ async function fetchIncidentCache(incidentIds: Array<string>): Promise<Map<strin
return cacheMap
}
// Default export needed by Next.js convention
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<Array<IncidentInfo> | { error: string }>
) {
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!IS_PLATFORM) {
return res.status(404).end()
}
@@ -100,3 +97,8 @@ export default async function handler(
return res.status(500).json({ error: 'Unable to fetch incidents at this time' })
}
}
const wrapper = (req: NextApiRequest, res: NextApiResponse) =>
apiWrapper(req, res, handler, { withAuth: true })
export default wrapper
+1
View File
@@ -25,6 +25,7 @@ const HOSTED_SUPPORTED_API_URLS = [
'/edge-functions/body',
'/generate-attachment-url',
'/incident-status',
'/incident-banner',
'/status-override',
'/api/integrations/stripe-sync',
'/content/graphql',
+1
View File
@@ -116,6 +116,7 @@
"SUPPORT_SUPABASE_SECRET_KEY",
"STATUSPAGE_API_KEY",
"STATUSPAGE_PAGE_ID",
"INCIDENT_IO_API_KEY",
"LIVE_SUPABASE_SECRET_KEY"
],
"passThroughEnv": [