mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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',
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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!)
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user