feat(studio): smart incident banner targeting (#43112)

Feature enhancement — smarter incident banner targeting logic

## What is the current behavior?

Displaying the incident banner requires toggling a flag or environment
variable. Banners are shown to all users regardless of whether their
projects are in affected regions or whether the incident affects project
creation.

## What is the new behavior?

Banner visibility is now driven by `show_banner` metadata from the
StatusPage API — no manual flag or env var toggle needed. Per-user
targeting is then applied:
- Users with projects only see the banner when they have a database in
an affected region
- Users without projects only see the banner when the incident affects
project creation

Incident responses are enriched with cache data (`affected_regions`,
`affects_project_creation`) fetched from a Supabase table. Visibility
logic is extracted into a dedicated hook and pure utility function,
backed by unit tests.

## Additional context

Resolves FE-2562
This commit is contained in:
Charis authored and GitHub committed 2026-02-25 17:18:33 +00:00
1 parent ec332eb387
commit 5a01291c23
11 files changed
+435 -30

No files matched your search

@@ -1,18 +1,18 @@
import { useRouter } from 'next/router'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { LOCAL_STORAGE_KEYS } from 'common'
import { HeaderBanner } from 'components/interfaces/Organization/HeaderBanner'
import { InlineLink } from 'components/ui/InlineLink'
import { useRouter } from 'next/router'
import { TimestampInfo } from 'ui-patterns'
import { HeaderBanner } from '@/components/interfaces/Organization/HeaderBanner'
import { InlineLink } from '@/components/ui/InlineLink'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
/**
* Used to display urgent notices that apply for all users, such as maintenance windows.
*/
export const NoticeBanner = () => {
const router = useRouter()
const [bannerAcknowledged, setBannerAcknowledge, { isSuccess }] = useLocalStorageQuery(
const [bannerAcknowledged, setBannerAcknowledged, { isSuccess }] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.MAINTENANCE_WINDOW_BANNER,
false
)
@@ -38,7 +38,7 @@ export const NoticeBanner = () => {
</InlineLink>
</>
}
onDismiss={() => setBannerAcknowledge(true)}
onDismiss={() => setBannerAcknowledged(true)}
/>
)
}
@@ -1,7 +1,6 @@
import { useFlag } from 'common'
import { HeaderBanner } from '@/components/interfaces/Organization/HeaderBanner'
import { InlineLink } from '@/components/ui/InlineLink'
import { useStatusPageBannerVisibility } from './useStatusPageBannerVisibility'
const BANNER_DESCRIPTION = (
<>
@@ -13,18 +12,9 @@ const BANNER_DESCRIPTION = (
* Used to display ongoing incidents
*/
export const StatusPageBanner = () => {
const showIncidentBanner =
useFlag('ongoingIncident') || process.env.NEXT_PUBLIC_ONGOING_INCIDENT === 'true'
const banner = useStatusPageBannerVisibility()
if (showIncidentBanner) {
return (
<HeaderBanner
variant="warning"
title="We are investigating a technical issue"
description={BANNER_DESCRIPTION}
/>
)
}
if (!banner) return null
return null
return <HeaderBanner variant="warning" title={banner.title} description={BANNER_DESCRIPTION} />
}
@@ -0,0 +1,225 @@
import { describe, expect, it } from 'vitest'
import { shouldShowBanner } from './StatusPageBanner.utils'
const noCache = { cache: null } as const
const noRestrictions = {
cache: { affected_regions: null, affects_project_creation: false },
}
const affectsCreation = {
cache: { affected_regions: null, affects_project_creation: true },
}
const usEast1Only = {
cache: { affected_regions: ['us-east-1'], affects_project_creation: false },
}
const usEast1AndCreation = {
cache: { affected_regions: ['us-east-1'], affects_project_creation: true },
}
describe('shouldShowBanner', () => {
describe('no incidents', () => {
it('does not show when there are no incidents', () => {
expect(
shouldShowBanner({ incidents: [], hasProjects: true, userRegions: new Set(['us-east-1']) })
).toBe(false)
})
})
describe('user has no projects', () => {
it('does not show when cache is absent', () => {
expect(
shouldShowBanner({ incidents: [noCache], hasProjects: false, userRegions: new Set() })
).toBe(false)
})
it('does not show when affects_project_creation is false', () => {
expect(
shouldShowBanner({
incidents: [noRestrictions],
hasProjects: false,
userRegions: new Set(),
})
).toBe(false)
})
it('shows when affects_project_creation is true and no region restriction', () => {
expect(
shouldShowBanner({
incidents: [affectsCreation],
hasProjects: false,
userRegions: new Set(),
})
).toBe(true)
})
it('shows when affects_project_creation is true even with a region restriction', () => {
expect(
shouldShowBanner({
incidents: [usEast1AndCreation],
hasProjects: false,
userRegions: new Set(),
})
).toBe(true)
})
})
describe('user has projects, no region restriction', () => {
it('shows when cache is absent', () => {
expect(
shouldShowBanner({
incidents: [noCache],
hasProjects: true,
userRegions: new Set(['us-east-1']),
})
).toBe(true)
})
it('shows when affected_regions is null', () => {
expect(
shouldShowBanner({
incidents: [noRestrictions],
hasProjects: true,
userRegions: new Set(['us-east-1']),
})
).toBe(true)
})
it('shows when affected_regions is an empty array', () => {
expect(
shouldShowBanner({
incidents: [{ cache: { affected_regions: [], affects_project_creation: false } }],
hasProjects: true,
userRegions: new Set(['us-east-1']),
})
).toBe(true)
})
})
describe('user has projects, with region restriction', () => {
it('shows when user has a primary database in an affected region', () => {
expect(
shouldShowBanner({
incidents: [usEast1Only],
hasProjects: true,
userRegions: new Set(['us-east-1']),
})
).toBe(true)
})
it('shows when user has a read replica in an affected region', () => {
expect(
shouldShowBanner({
incidents: [
{ cache: { affected_regions: ['eu-west-1'], affects_project_creation: false } },
],
hasProjects: true,
userRegions: new Set(['us-east-1', 'eu-west-1']),
})
).toBe(true)
})
it('shows when one of multiple affected regions matches', () => {
expect(
shouldShowBanner({
incidents: [
{
cache: {
affected_regions: ['us-east-1', 'ap-southeast-1'],
affects_project_creation: false,
},
},
],
hasProjects: true,
userRegions: new Set(['ap-southeast-1']),
})
).toBe(true)
})
it('does not show when user has no databases in any affected region', () => {
expect(
shouldShowBanner({
incidents: [usEast1Only],
hasProjects: true,
userRegions: new Set(['eu-west-1', 'ap-southeast-1']),
})
).toBe(false)
})
it('does not show when user has projects but no databases in affected region, even with affects_project_creation', () => {
expect(
shouldShowBanner({
incidents: [usEast1AndCreation],
hasProjects: true,
userRegions: new Set(['eu-west-1']),
})
).toBe(false)
})
})
describe('multiple incidents', () => {
it('shows when at least one incident matches even if others do not', () => {
expect(
shouldShowBanner({
incidents: [usEast1Only, noRestrictions],
hasProjects: true,
userRegions: new Set(['eu-west-1']),
})
).toBe(true)
})
it('does not show when no incident matches', () => {
expect(
shouldShowBanner({
incidents: [usEast1Only, usEast1AndCreation],
hasProjects: true,
userRegions: new Set(['eu-west-1']),
})
).toBe(false)
})
it('shows when any incident matches for a no-project user via affects_project_creation', () => {
expect(
shouldShowBanner({
incidents: [usEast1Only, affectsCreation],
hasProjects: false,
userRegions: new Set(),
})
).toBe(true)
})
})
describe('hasUnknownRegions', () => {
it('shows when regions are unknown and incident has a region restriction', () => {
expect(
shouldShowBanner({
incidents: [usEast1Only],
hasProjects: true,
userRegions: new Set(),
hasUnknownRegions: true,
})
).toBe(true)
})
it('still applies no-projects check even when regions are unknown', () => {
expect(
shouldShowBanner({
incidents: [usEast1Only],
hasProjects: false,
userRegions: new Set(),
hasUnknownRegions: true,
})
).toBe(false)
})
it('still shows for affects_project_creation with no projects even when regions are unknown', () => {
expect(
shouldShowBanner({
incidents: [usEast1AndCreation],
hasProjects: false,
userRegions: new Set(),
hasUnknownRegions: true,
})
).toBe(true)
})
})
})
@@ -0,0 +1,44 @@
import type { IncidentCache } from 'lib/api/incident-status'
type BannerIncident = { cache?: IncidentCache | null }
/**
* Determines whether the incident status banner should be shown to a given user,
* given all active incidents and the user's project state.
*
* Returns true if any incident matches the user's context.
*
* @param incidents - Active incidents from the incident-status endpoint
* @param hasProjects - Whether the user has any projects at all
* @param userRegions - Deduplicated set of regions of all databases (primary and read replicas) owned by the user
* @param hasUnknownRegions - True when region data is incomplete (org has >100 projects).
* When true, the region check is skipped and a match is assumed.
*/
export function shouldShowBanner({
incidents,
hasProjects,
userRegions,
hasUnknownRegions = false,
}: {
incidents: Array<BannerIncident>
hasProjects: boolean
userRegions: Set<string>
hasUnknownRegions?: boolean
}): boolean {
return incidents.some((incident) => {
const affectedRegions = incident.cache?.affected_regions ?? []
const affectsProjectCreation = incident.cache?.affects_project_creation ?? false
// Users with no projects only see the banner if the incident affects project creation
if (!hasProjects) return affectsProjectCreation
// User has projects: if no region restriction, always show
if (affectedRegions.length === 0) return true
// Region data is incomplete — assume the user has a database in an affected region
if (hasUnknownRegions) return true
// Region restriction: only show if the user has a database in an affected region
return affectedRegions.some((region) => userRegions.has(region))
})
}
@@ -0,0 +1,61 @@
import { useQueries } from '@tanstack/react-query'
import { useFlag } from 'common'
import { shouldShowBanner } from './StatusPageBanner.utils'
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
import { useIncidentStatusQuery } from '@/data/platform/incident-status-query'
import { projectKeys } from '@/data/projects/keys'
import {
getOrganizationProjects,
type OrgProject,
} from '@/data/projects/org-projects-infinite-query'
export type StatusPageBannerData = { title: string }
export function useStatusPageBannerVisibility(): StatusPageBannerData | null {
const showIncidentBannerOverride =
useFlag('ongoingIncident') || process.env.NEXT_PUBLIC_ONGOING_INCIDENT === 'true'
const { data: allStatusPageEvents } = useIncidentStatusQuery()
const { incidents = [] } = allStatusPageEvents ?? {}
const hasActiveIncidents = incidents.length > 0
const { data: organizations } = useOrganizationsQuery({
enabled: !showIncidentBannerOverride && hasActiveIncidents,
})
const orgProjectsQueries = useQueries({
queries: (organizations ?? []).map((org) => ({
queryKey: projectKeys.bannerProjectsByOrg(org.slug),
queryFn: () => getOrganizationProjects({ slug: org.slug, limit: 100 }),
staleTime: 5 * 60 * 1000,
enabled: !showIncidentBannerOverride && hasActiveIncidents,
})),
})
const isProjectsFetched =
organizations !== undefined &&
(organizations.length === 0 || orgProjectsQueries.every((q) => q.isFetched))
const allProjects = orgProjectsQueries.flatMap((q) => q.data?.projects ?? [])
const hasProjects = allProjects.length > 0
const userRegions = new Set(
allProjects.flatMap((project: OrgProject) => project.databases.map((db) => db.region))
)
const hasUnknownRegions = orgProjectsQueries.some(
(q) => q.isError || (q.data !== undefined && q.data.pagination.count > q.data.projects.length)
)
if (showIncidentBannerOverride) return { title: 'We are investigating a technical issue' }
if (!hasActiveIncidents || !isProjectsFetched) return null
if (!shouldShowBanner({ incidents, hasProjects, userRegions, hasUnknownRegions })) return null
return {
title: hasProjects
? 'We are investigating a technical issue'
: 'Project creation may be impacted in some regions',
}
}
+3
View File
@@ -36,4 +36,7 @@ export const projectKeys = {
['projects', projectRef, 'clone-backups'] as const,
listCloneStatus: (projectRef: string | undefined) =>
['projects', projectRef, 'clone-status'] as const,
// Banner-specific: first-page snapshot used by the status page banner hook
bannerProjectsByOrg: (slug: string) => ['banner', 'org-projects', slug] as const,
}
@@ -24,7 +24,7 @@ interface GetOrgProjectsInfiniteVariables {
export type OrgProjectsResponse = components['schemas']['OrganizationProjectsResponse']
export type OrgProject = OrgProjectsResponse['projects'][number]
async function getOrganizationProjects(
export async function getOrganizationProjects(
{
slug,
limit = DEFAULT_LIMIT,
+24
View File
@@ -3,12 +3,25 @@ import z from 'zod'
import { IS_PLATFORM } from 'common'
import { InternalServerError } from 'lib/api/apiHelpers'
export type IncidentCache = {
affected_regions: Array<string> | null
affects_project_creation: boolean
}
export type IncidentMetadata = {
dashboard_metadata?: {
show_banner?: boolean
}
}
export type IncidentInfo = {
id: string
name: string
status: string
impact: string
active_since: string
metadata: IncidentMetadata
cache?: IncidentCache | null
}
const STATUSPAGE_API_URL = 'https://api.statuspage.io/v1'
@@ -27,6 +40,16 @@ const StatusPageIncidentsSchema = z.array(
created_at: z.string(),
scheduled_for: z.string().nullable(),
impact: z.string(),
metadata: z
.object({
dashboard_metadata: z
.object({
show_banner: z.boolean().optional(),
})
.optional(),
})
.optional()
.default({}),
})
)
@@ -114,5 +137,6 @@ export async function getActiveIncidents(): Promise<IncidentInfo[]> {
status: incident.status,
impact: incident.impact,
active_since: incident.scheduled_for ?? incident.created_at,
metadata: incident.metadata,
}))
}
+9
View File
@@ -0,0 +1,9 @@
import { createClient } from '@supabase/supabase-js'
/**
* Creates a Supabase client using the secret key.
* For use in server-side API routes only.
*/
export function createAdminClient() {
return createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.LIVE_SUPABASE_SECRET_KEY!)
}
+55 -7
View File
@@ -1,8 +1,13 @@
import { IS_PLATFORM } from 'common'
import { NextApiRequest, NextApiResponse } from 'next'
import { IS_PLATFORM } from 'common'
import { InternalServerError } from 'lib/api/apiHelpers'
import { getActiveIncidents, type IncidentInfo } from 'lib/api/incident-status'
import { InternalServerError } from '@/lib/api/apiHelpers'
import {
getActiveIncidents,
type IncidentCache,
type IncidentInfo,
} from '@/lib/api/incident-status'
import { createAdminClient } from '@/lib/api/supabase-admin'
/**
* Cache on browser for 5 minutes
@@ -11,11 +16,41 @@ import { getActiveIncidents, type IncidentInfo } from 'lib/api/incident-status'
*/
const CACHE_CONTROL_SETTINGS = 'public, max-age=300, s-maxage=300, stale-while-revalidate=60'
async function fetchIncidentCache(incidentIds: Array<string>): Promise<Map<string, IncidentCache>> {
const cacheMap = new Map<string, IncidentCache>()
if (incidentIds.length === 0) return cacheMap
const supabase = createAdminClient()
try {
const { data, error } = await supabase
.from('incident_status_cache')
.select('incident_id, affected_regions, affects_project_creation')
.in('incident_id', incidentIds)
if (error) {
console.error('Failed to fetch incident_status_cache: %O', error)
return cacheMap
}
for (const row of data ?? []) {
cacheMap.set(row.incident_id, {
affected_regions: row.affected_regions ?? null,
affects_project_creation: row.affects_project_creation,
})
}
} catch (error) {
console.error('Unexpected error fetching incident_status_cache: %O', error)
}
return cacheMap
}
// Default export needed by Next.js convention
// eslint-disable-next-line no-restricted-exports
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<IncidentInfo[] | { error: string }>
res: NextApiResponse<Array<IncidentInfo> | { error: string }>
) {
if (!IS_PLATFORM) {
return res.status(404).end()
@@ -34,11 +69,24 @@ export default async function handler(
}
try {
const incidents = await getActiveIncidents()
const allIncidents = await getActiveIncidents()
const bannerIncidents = allIncidents.filter(
(incident) =>
incident.impact !== 'maintenance' &&
incident.metadata?.dashboard_metadata?.show_banner === true
)
const cacheMap = await fetchIncidentCache(bannerIncidents.map((i) => i.id))
const enrichedIncidents = bannerIncidents.map((incident) => ({
...incident,
cache: cacheMap.get(incident.id) ?? null,
}))
res.setHeader('Cache-Control', CACHE_CONTROL_SETTINGS)
return res.status(200).json(incidents)
return res.status(200).json(enrichedIncidents)
} catch (error) {
if (error instanceof InternalServerError) {
console.error('Failed to fetch active StatusPage incidents: %O', {
+2 -1
View File
@@ -108,7 +108,8 @@
"AI_NORMAL_MODEL",
"SUPPORT_SUPABASE_SECRET_KEY",
"STATUSPAGE_API_KEY",
"STATUSPAGE_PAGE_ID"
"STATUSPAGE_PAGE_ID",
"LIVE_SUPABASE_SECRET_KEY"
],
"passThroughEnv": [
"CURRENT_CLI_VERSION",