feat(studio): status page banner (incident / maintenance / upcoming) (#51044)

## Summary

* Adds a new global status banner (`StatusBanner`) driven by the
[incident.io](<http://incident.io>) status page data, showing at most
one of: an active incident, in-progress maintenance, or upcoming
maintenance, in that priority order.
* All three types are independently dismissible (persisted to a new
localStorage key, `status-banner-dismissed-keys`); dismissal hides the
banner for items still active, and a new incident reappears even if a
related item was previously dismissed.
* Only shows to users who are actually affected (based on their
projects' regions) or when region data is incomplete (fails open), and
is bypassed entirely by the existing emergency incident override.
* Behind the existing `incidentIoStatusPage` ConfigCat flag —
`AppBannerWrapper` renders this new banner instead of the legacy
`StatusPageBanner` only when the flag is on; default behavior is
unchanged.
* This is PR 5b in a stacked series for Linear
[FE-4057](https://linear.app/supabase/issue/FE-4057) — see that issue
for full design context.

## Test plan

* New unit tests (`StatusBanner.utils.test.ts`) covering
banner-selection priority, dismissal-key handling, and copy generation
* New MSW component test (`StatusBanner.test.tsx`) covering loading
state, dismiss-and-persist, and the emergency-override path
* `pnpm --filter studio run typecheck`, `lint:ratchet`, `pnpm knip
--workspace apps/studio`, `pnpm test:prettier`, and relevant vitest
suites all pass

🤖 Generated with [Claude Code](<https://claude.com/claude-code>)

Co-Authored-By: Claude
[noreply@anthropic.com](<mailto:noreply@anthropic.com>)

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

* **New Features**
* Added status banners for relevant incidents and scheduled maintenance,
including incident details, maintenance timing, and links to the status
page.
* Banners can be dismissed, and dismissed items stay hidden while new
incidents or maintenance updates can still appear.
* Upcoming maintenance banners appear within the relevant lead time, and
maintenance timing is shown when available.
* Emergency overrides display a warning banner without a dismiss option.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Charis authored and GitHub committed 2026-09-29 18:09:47 -04:00
1 parent d22702e907
commit 0daafca2ca
7 files changed
+880 -1

No files matched your search

@@ -11,6 +11,7 @@ import { OrganizationResourceBanner } from '../Organization/HeaderBanner'
import { isLogsOrObservabilityPath, isOrganizationLandingPath } from './AppBannerWrapper.utils'
import { ClockSkewBanner } from '@/components/layouts/AppLayout/ClockSkewBanner'
import { NoticeBanner } from '@/components/layouts/AppLayout/NoticeBanner'
import { StatusBanner } from '@/components/layouts/AppLayout/StatusBanner'
import { StatusPageBanner } from '@/components/layouts/AppLayout/StatusPageBanner'
import { BannerLogsAllDeprecation } from '@/components/ui/BannerStack/Banners/BannerLogsAllDeprecation'
import { BannerPrivacyPolicyUpdate } from '@/components/ui/BannerStack/Banners/BannerPrivacyPolicyUpdate'
@@ -33,6 +34,7 @@ const MAX_TIMEOUT_MS = 2_147_483_647
export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => {
const showNoticeBanner = useFlag('showNoticeBanner')
const clockSkewBanner = useFlag('clockSkewBanner')
const useStatusPageWidget = useFlag('incidentIoStatusPage')
const { addBanner, dismissBanner } = useBannerStack()
const pathname = usePathname()
@@ -158,7 +160,7 @@ export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => {
return (
<div className="flex flex-col">
<div className="shrink-0">
<StatusPageBanner />
{useStatusPageWidget ? <StatusBanner /> : <StatusPageBanner />}
{showNoticeBanner && <NoticeBanner />}
<OrganizationResourceBanner />
{clockSkewBanner && <ClockSkewBanner />}
@@ -0,0 +1,228 @@
import { QueryClient } from '@tanstack/react-query'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { platformComponents as components } from 'api-types'
import { http, HttpResponse } from 'msw'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { StatusBanner } from './StatusBanner'
import { platformKeys } from '@/data/platform/keys'
import { BASE_PATH } from '@/lib/constants'
import type { StatusPageResponse } from '@/lib/status-page/status-page.schema'
import { createMockOrganizationResponse } from '@/tests/helpers'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock, mswServer } from '@/tests/lib/msw'
import { createMockProfileContext } from '@/tests/lib/profile-helpers'
type OrganizationResponse = components['schemas']['OrganizationResponse_Output']
type OrganizationProjectsResponse = components['schemas']['OrganizationProjectsResponse_Output']
type OrgProject = OrganizationProjectsResponse['projects'][number]
type OrgDatabase = OrgProject['databases'][number]
function mockProject(
ref: string,
databases: Array<{ region: string; type: 'PRIMARY' | 'READ_REPLICA' }>
): OrgProject {
return {
cloud_provider: 'AWS',
databases: databases.map(
({ region, type }): OrgDatabase => ({
cloud_provider: 'AWS',
identifier: ref,
region,
status: 'ACTIVE_HEALTHY',
type,
})
),
inserted_at: new Date().toISOString(),
integration_source: null,
is_branch: false,
name: ref,
ref,
region: databases[0]?.region ?? 'us-east-1',
status: 'ACTIVE_HEALTHY',
}
}
function buildStatusPage(overrides: Partial<StatusPageResponse> = {}): StatusPageResponse {
return {
page_title: 'Supabase status',
page_url: 'https://status.supabase.com/',
ongoing_incidents: [],
in_progress_maintenances: [],
scheduled_maintenances: [],
...overrides,
}
}
function buildIncident(
overrides: Partial<StatusPageResponse['ongoing_incidents'][number]> = {}
): StatusPageResponse['ongoing_incidents'][number] {
return {
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,
...overrides,
}
}
const INCIDENT_TITLE = 'We are investigating a technical issue'
const mockOrgProjectEndpoints = () => {
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: () =>
HttpResponse.json<OrganizationResponse[]>([
createMockOrganizationResponse({ slug: 'org-a' }),
]),
})
addAPIMock({
method: 'get',
path: '/platform/organizations/:slug/projects',
response: () =>
HttpResponse.json<OrganizationProjectsResponse>({
pagination: { count: 1, limit: 100, offset: 0 },
projects: [mockProject('project-a', [{ region: 'us-east-1', type: 'PRIMARY' }])],
}),
})
}
const { mockUseFlag } = vi.hoisted(() => ({
mockUseFlag: vi.fn().mockReturnValue(false),
}))
vi.mock(import('common'), async (importOriginal) => {
const actual = await importOriginal()
return { ...actual, useFlag: mockUseFlag }
})
vi.mock(import('@/lib/constants'), async (importOriginal) => {
const actual = await importOriginal()
return { ...actual, IS_PLATFORM: true }
})
describe('StatusBanner', () => {
beforeEach(() => {
window.localStorage.clear()
})
test('renders nothing while orgs are loading', async () => {
mswServer.use(
http.get(`${BASE_PATH}/api/status-page`, () =>
HttpResponse.json<StatusPageResponse>(
buildStatusPage({ ongoing_incidents: [buildIncident()] })
)
)
)
const { promise: orgsRequested, resolve: releaseOrgsResponse } = Promise.withResolvers<void>()
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: async () => {
await orgsRequested
return HttpResponse.json<OrganizationResponse[]>([
createMockOrganizationResponse({ slug: 'org-a' }),
])
},
})
addAPIMock({
method: 'get',
path: '/platform/organizations/:slug/projects',
response: () =>
HttpResponse.json<OrganizationProjectsResponse>({
pagination: { count: 1, limit: 100, offset: 0 },
projects: [mockProject('project-a', [{ region: 'us-east-1', type: 'PRIMARY' }])],
}),
})
customRender(<StatusBanner />, { profileContext: createMockProfileContext() })
expect(screen.queryByText(INCIDENT_TITLE)).not.toBeInTheDocument()
releaseOrgsResponse()
await waitFor(() => {
expect(screen.getByText(INCIDENT_TITLE)).toBeInTheDocument()
})
})
test('dismissing hides the banner and writes the dismissed key to local storage', async () => {
mswServer.use(
http.get(`${BASE_PATH}/api/status-page`, () =>
HttpResponse.json<StatusPageResponse>(
buildStatusPage({ ongoing_incidents: [buildIncident({ id: 'inc-1' })] })
)
)
)
mockOrgProjectEndpoints()
customRender(<StatusBanner />, { profileContext: createMockProfileContext() })
await screen.findByText(INCIDENT_TITLE)
fireEvent.click(screen.getByRole('button', { name: 'Dismiss banner' }))
await waitFor(() => {
expect(screen.queryByText(INCIDENT_TITLE)).not.toBeInTheDocument()
})
expect(JSON.parse(window.localStorage.getItem('status-banner-dismissed-keys') ?? '[]')).toEqual(
['incident:inc-1']
)
})
test('a new incident brings the banner back even though the previous one is dismissed', async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
mswServer.use(
http.get(`${BASE_PATH}/api/status-page`, () =>
HttpResponse.json<StatusPageResponse>(
buildStatusPage({ ongoing_incidents: [buildIncident({ id: 'inc-1' })] })
)
)
)
mockOrgProjectEndpoints()
customRender(<StatusBanner />, { profileContext: createMockProfileContext(), queryClient })
await screen.findByText(INCIDENT_TITLE)
fireEvent.click(screen.getByRole('button', { name: 'Dismiss banner' }))
await waitFor(() => {
expect(screen.queryByText(INCIDENT_TITLE)).not.toBeInTheDocument()
})
mswServer.use(
http.get(`${BASE_PATH}/api/status-page`, () =>
HttpResponse.json<StatusPageResponse>(
buildStatusPage({
ongoing_incidents: [buildIncident({ id: 'inc-2', name: 'A different incident' })],
})
)
)
)
await queryClient.invalidateQueries({ queryKey: platformKeys.statusPage() })
await waitFor(() => {
expect(screen.getByText(INCIDENT_TITLE)).toBeInTheDocument()
})
})
test('the emergency override renders without fetching anything', async () => {
mockUseFlag.mockImplementation((name: string) => name === 'ongoingIncident')
customRender(<StatusBanner />, { profileContext: createMockProfileContext() })
expect(await screen.findByText(INCIDENT_TITLE)).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Dismiss banner' })).not.toBeInTheDocument()
})
})
@@ -0,0 +1,108 @@
import dayjs from 'dayjs'
import type { ReactNode } from 'react'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { getBannerCopy, type BannerSelection } from './StatusBanner.utils'
import { useStatusBanner } from './useStatusBanner'
import { HeaderBanner } from '@/components/interfaces/Organization/HeaderBanner'
import { InlineLink } from '@/components/ui/InlineLink'
const OVERRIDE_TITLE = 'We are investigating a technical issue'
const TIMESTAMP_FORMAT = 'DD MMM, HH:mm'
function formatTimestampLabel(value: string): string {
return dayjs(value).format(TIMESTAMP_FORMAT)
}
function getBannerDescription({
selection,
description,
pageUrl,
}: {
selection: BannerSelection
description: string
pageUrl: string
}): ReactNode {
if (selection.kind === 'incident') {
return (
<>
{description} <InlineLink href={pageUrl}>Status page</InlineLink>
</>
)
}
if (selection.kind === 'maintenance') {
const { scheduledEndAt } = selection.item
const hasFutureEnd = scheduledEndAt !== null && new Date(scheduledEndAt).getTime() > Date.now()
return (
<>
{description}
{hasFutureEnd && (
<>
{' '}
(
<TimestampInfo
utcTimestamp={scheduledEndAt}
label={formatTimestampLabel(scheduledEndAt)}
/>
)
</>
)}
</>
)
}
const { startsAt, endsAt } = selection.item
return (
<>
{description}
{startsAt !== null && endsAt !== null && (
<>
{' '}
(<TimestampInfo utcTimestamp={startsAt} label={formatTimestampLabel(startsAt)} />
{' – '}
<TimestampInfo utcTimestamp={endsAt} label={formatTimestampLabel(endsAt)} />)
</>
)}
</>
)
}
/**
* Global banner for incident.io status page items — a relevant active incident,
* in-progress maintenance, or upcoming maintenance, in that priority order.
* Replaces the legacy StatusPageBanner while `incidentIoStatusPage` is on.
*/
export const StatusBanner = () => {
const state = useStatusBanner()
if (state.type === 'hidden') return null
if (state.type === 'override') {
return (
<HeaderBanner
variant="warning"
title={OVERRIDE_TITLE}
description={
<>
Follow the <InlineLink href="https://status.supabase.com">status page</InlineLink> for
updates
</>
}
/>
)
}
const { selection, pageUrl, onDismiss } = state
const { title, description } = getBannerCopy(selection)
return (
<HeaderBanner
variant={selection.kind === 'incident' ? 'warning' : 'note'}
title={title}
description={getBannerDescription({ selection, description, pageUrl })}
onDismiss={onDismiss}
/>
)
}
@@ -0,0 +1,338 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
getBannerCopy,
getKeysToDismiss,
isWithinUpcomingWindow,
pruneDismissedKeys,
selectBanner,
type IncidentItem,
type MaintenanceItem,
type UpcomingMaintenanceItem,
} from './StatusBanner.utils'
import type {
RegionScope,
StatusItem,
UserRegionContext,
} from '@/lib/status-page/status-page.utils'
const GLOBAL_SCOPE: RegionScope = { type: 'global' }
const DEFAULT_USER: UserRegionContext = {
hasProjects: true,
regions: new Set(),
isComplete: true,
}
function makeIncident(overrides: Partial<IncidentItem> = {}): IncidentItem {
return {
id: 'inc-1',
name: 'Elevated errors',
url: 'https://status.example.com/incidents/inc-1',
lastUpdateAt: '2026-01-01T00:00:00Z',
lastUpdateMessage: null,
components: [],
scope: GLOBAL_SCOPE,
projectCreationScope: null,
isProjectCreationOnly: false,
showBanner: true,
kind: 'incident',
status: 'investigating',
impact: 'full_outage',
...overrides,
}
}
function makeMaintenance(overrides: Partial<MaintenanceItem> = {}): MaintenanceItem {
return {
id: 'maint-1',
name: 'Database maintenance',
url: 'https://status.example.com/incidents/maint-1',
lastUpdateAt: '2026-01-01T00:00:00Z',
lastUpdateMessage: null,
components: [],
scope: GLOBAL_SCOPE,
projectCreationScope: null,
isProjectCreationOnly: false,
showBanner: true,
kind: 'maintenance',
startedAt: '2026-01-01T00:00:00Z',
scheduledEndAt: null,
...overrides,
}
}
function makeUpcoming(overrides: Partial<UpcomingMaintenanceItem> = {}): UpcomingMaintenanceItem {
return {
id: 'upcoming-1',
name: 'Upcoming maintenance',
url: 'https://status.example.com/incidents/upcoming-1',
lastUpdateAt: '2026-01-01T00:00:00Z',
lastUpdateMessage: null,
components: [],
scope: GLOBAL_SCOPE,
projectCreationScope: null,
isProjectCreationOnly: false,
showBanner: true,
kind: 'upcoming_maintenance',
startsAt: '2026-02-01T00:00:00Z',
endsAt: '2026-02-01T02:00:00Z',
leadDays: 7,
...overrides,
}
}
const NOW_MS = Date.UTC(2026, 0, 25, 0, 0, 0)
const DAY_MS = 24 * 60 * 60 * 1000
describe('isWithinUpcomingWindow', () => {
it('is true exactly at the default lead time boundary', () => {
const item = makeUpcoming({
startsAt: new Date(NOW_MS + 7 * DAY_MS).toISOString(),
endsAt: new Date(NOW_MS + 8 * DAY_MS).toISOString(),
leadDays: 7,
})
expect(isWithinUpcomingWindow(item, NOW_MS)).toBe(true)
})
it('is false just outside the lead time boundary', () => {
const item = makeUpcoming({
startsAt: new Date(NOW_MS + 7 * DAY_MS + 1).toISOString(),
endsAt: new Date(NOW_MS + 8 * DAY_MS).toISOString(),
leadDays: 7,
})
expect(isWithinUpcomingWindow(item, NOW_MS)).toBe(false)
})
it('respects a custom leadDays', () => {
const withinCustomLead = makeUpcoming({
startsAt: new Date(NOW_MS + 2 * DAY_MS).toISOString(),
endsAt: new Date(NOW_MS + 3 * DAY_MS).toISOString(),
leadDays: 2,
})
expect(isWithinUpcomingWindow(withinCustomLead, NOW_MS)).toBe(true)
const outsideCustomLead = makeUpcoming({
startsAt: new Date(NOW_MS + 3 * DAY_MS).toISOString(),
endsAt: new Date(NOW_MS + 4 * DAY_MS).toISOString(),
leadDays: 2,
})
expect(isWithinUpcomingWindow(outsideCustomLead, NOW_MS)).toBe(false)
})
it('is false once endsAt is already past, even if startsAt is within the lead window', () => {
const item = makeUpcoming({
startsAt: new Date(NOW_MS - 2 * DAY_MS).toISOString(),
endsAt: new Date(NOW_MS - 1 * DAY_MS).toISOString(),
leadDays: 7,
})
expect(isWithinUpcomingWindow(item, NOW_MS)).toBe(false)
})
it('is false when startsAt or endsAt is null', () => {
expect(isWithinUpcomingWindow(makeUpcoming({ startsAt: null }), NOW_MS)).toBe(false)
expect(isWithinUpcomingWindow(makeUpcoming({ endsAt: null }), NOW_MS)).toBe(false)
})
})
describe('selectBanner', () => {
it('prioritizes incidents over maintenance and upcoming maintenance', () => {
const items: Array<StatusItem> = [
makeUpcoming({ id: 'upcoming-1' }),
makeMaintenance({ id: 'maint-1' }),
makeIncident({ id: 'inc-1' }),
]
const result = selectBanner({
items,
user: DEFAULT_USER,
dismissedKeys: new Set(),
nowMs: NOW_MS,
})
expect(result).toEqual({ kind: 'incident', items: [makeIncident({ id: 'inc-1' })] })
})
it('prioritizes in-progress maintenance over upcoming maintenance', () => {
const items: Array<StatusItem> = [
makeUpcoming({ id: 'upcoming-1' }),
makeMaintenance({ id: 'maint-1' }),
]
const result = selectBanner({
items,
user: DEFAULT_USER,
dismissedKeys: new Set(),
nowMs: NOW_MS,
})
expect(result).toEqual({ kind: 'maintenance', item: makeMaintenance({ id: 'maint-1' }) })
})
it('falls through to maintenance when the only incident is dismissed', () => {
const items: Array<StatusItem> = [
makeIncident({ id: 'inc-1' }),
makeMaintenance({ id: 'maint-1' }),
]
const result = selectBanner({
items,
user: DEFAULT_USER,
dismissedKeys: new Set(['incident:inc-1']),
nowMs: NOW_MS,
})
expect(result).toEqual({ kind: 'maintenance', item: makeMaintenance({ id: 'maint-1' }) })
})
it('excludes items with showBanner: false', () => {
const items: Array<StatusItem> = [makeIncident({ showBanner: false })]
expect(
selectBanner({ items, user: DEFAULT_USER, dismissedKeys: new Set(), nowMs: NOW_MS })
).toBeNull()
})
it('excludes project-creation-only items', () => {
const items: Array<StatusItem> = [makeIncident({ isProjectCreationOnly: true })]
expect(
selectBanner({ items, user: DEFAULT_USER, dismissedKeys: new Set(), nowMs: NOW_MS })
).toBeNull()
})
it('excludes items irrelevant to the user (region-scoped, no matching project region)', () => {
const items: Array<StatusItem> = [
makeIncident({ scope: { type: 'regions', regions: ['us-east-1'] } }),
]
const user: UserRegionContext = {
hasProjects: true,
regions: new Set(['eu-west-1']),
isComplete: true,
}
expect(selectBanner({ items, user, dismissedKeys: new Set(), nowMs: NOW_MS })).toBeNull()
})
it('excludes upcoming maintenance outside its window', () => {
const items: Array<StatusItem> = [
makeUpcoming({ startsAt: new Date(NOW_MS + 30 * DAY_MS).toISOString(), leadDays: 7 }),
]
expect(
selectBanner({ items, user: DEFAULT_USER, dismissedKeys: new Set(), nowMs: NOW_MS })
).toBeNull()
})
it('keeps distinct dismissal keys for the same ID across kinds', () => {
const items: Array<StatusItem> = [makeMaintenance({ id: 'shared-id' })]
const result = selectBanner({
items,
user: DEFAULT_USER,
dismissedKeys: new Set(['upcoming_maintenance:shared-id']),
nowMs: NOW_MS,
})
expect(result).toEqual({ kind: 'maintenance', item: makeMaintenance({ id: 'shared-id' }) })
})
it('includes all undismissed relevant incidents together', () => {
const items: Array<StatusItem> = [makeIncident({ id: 'inc-1' }), makeIncident({ id: 'inc-2' })]
const result = selectBanner({
items,
user: DEFAULT_USER,
dismissedKeys: new Set(),
nowMs: NOW_MS,
})
expect(result).toEqual({
kind: 'incident',
items: [makeIncident({ id: 'inc-1' }), makeIncident({ id: 'inc-2' })],
})
})
})
describe('getKeysToDismiss', () => {
it('returns one key per incident for an incident selection', () => {
expect(
getKeysToDismiss({
kind: 'incident',
items: [makeIncident({ id: 'a' }), makeIncident({ id: 'b' })],
})
).toEqual(['incident:a', 'incident:b'])
})
it('returns a single key for a maintenance selection', () => {
expect(getKeysToDismiss({ kind: 'maintenance', item: makeMaintenance({ id: 'm' }) })).toEqual([
'maintenance:m',
])
})
it('returns a single key for an upcoming maintenance selection', () => {
expect(
getKeysToDismiss({ kind: 'upcoming_maintenance', item: makeUpcoming({ id: 'u' }) })
).toEqual(['upcoming_maintenance:u'])
})
})
describe('pruneDismissedKeys', () => {
it('keeps keys for items still in the unfiltered item list and adds the new ones', () => {
const result = pruneDismissedKeys({
prev: ['incident:still-active', 'incident:finished'],
allItems: [makeIncident({ id: 'still-active' })],
add: ['incident:new'],
})
expect(result).toEqual(expect.arrayContaining(['incident:still-active', 'incident:new']))
expect(result).not.toContain('incident:finished')
})
it('checks against the unfiltered item list, not a relevance-filtered one', () => {
const result = pruneDismissedKeys({
prev: ['incident:hidden-but-active'],
allItems: [makeIncident({ id: 'hidden-but-active', showBanner: false })],
add: [],
})
expect(result).toEqual(['incident:hidden-but-active'])
})
})
describe('getBannerCopy', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(NOW_MS)
})
afterEach(() => {
vi.useRealTimers()
})
it('returns singular incident copy for one incident', () => {
const copy = getBannerCopy({ kind: 'incident', items: [makeIncident()] })
expect(copy.title).toBe('We are investigating a technical issue')
})
it('returns plural incident copy for multiple incidents', () => {
const copy = getBannerCopy({
kind: 'incident',
items: [makeIncident({ id: 'a' }), makeIncident({ id: 'b' })],
})
expect(copy.title).toBe('We are investigating multiple technical issues')
})
it('mentions an end time when scheduledEndAt is in the future', () => {
const copy = getBannerCopy({
kind: 'maintenance',
item: makeMaintenance({ scheduledEndAt: new Date(NOW_MS + DAY_MS).toISOString() }),
})
expect(copy.description).toContain('until maintenance ends')
})
it('omits the end-time mention when scheduledEndAt is null', () => {
const copy = getBannerCopy({
kind: 'maintenance',
item: makeMaintenance({ scheduledEndAt: null }),
})
expect(copy.description).not.toContain('until maintenance ends')
})
it('omits the end-time mention when scheduledEndAt is already past', () => {
const copy = getBannerCopy({
kind: 'maintenance',
item: makeMaintenance({ scheduledEndAt: '2020-01-01T00:00:00Z' }),
})
expect(copy.description).not.toContain('until maintenance ends')
})
it('returns upcoming maintenance copy', () => {
const copy = getBannerCopy({ kind: 'upcoming_maintenance', item: makeUpcoming() })
expect(copy.title).toBe('Upcoming scheduled maintenance')
})
})
@@ -0,0 +1,137 @@
import {
getDismissalKey,
isRelevantToUser,
type StatusItem,
type UserRegionContext,
} from '@/lib/status-page/status-page.utils'
export type IncidentItem = Extract<StatusItem, { kind: 'incident' }>
export type MaintenanceItem = Extract<StatusItem, { kind: 'maintenance' }>
export type UpcomingMaintenanceItem = Extract<StatusItem, { kind: 'upcoming_maintenance' }>
export type BannerSelection =
| { kind: 'incident'; items: Array<IncidentItem> }
| { kind: 'maintenance'; item: MaintenanceItem }
| { kind: 'upcoming_maintenance'; item: UpcomingMaintenanceItem }
const DAY_MS = 24 * 60 * 60 * 1000
/**
* True once the maintenance is close enough to start (within its lead time) and
* hasn't already ended. `startsAt`/`endsAt` can be null when the upstream data is
* incomplete; treat that as outside the window rather than guessing a date.
*/
export function isWithinUpcomingWindow(item: UpcomingMaintenanceItem, nowMs: number): boolean {
if (item.startsAt === null || item.endsAt === null) return false
const startsAtMs = new Date(item.startsAt).getTime()
const endsAtMs = new Date(item.endsAt).getTime()
if (Number.isNaN(startsAtMs) || Number.isNaN(endsAtMs)) return false
const leadMs = item.leadDays * DAY_MS
return startsAtMs - nowMs <= leadMs && endsAtMs > nowMs
}
function getUpcomingStartMs(item: UpcomingMaintenanceItem): number {
return item.startsAt !== null ? new Date(item.startsAt).getTime() : Infinity
}
function earliestBy<T>(items: ReadonlyArray<T>, getTimeMs: (item: T) => number): T {
return items.reduce((earliest, item) => (getTimeMs(item) < getTimeMs(earliest) ? item : earliest))
}
/**
* Picks the single highest-priority banner to show, or null when nothing qualifies.
* Priority: incident (all relevant, undismissed incidents together) > in-progress
* maintenance (earliest start) > upcoming maintenance (soonest start).
*/
export function selectBanner(args: {
items: Array<StatusItem>
user: UserRegionContext
dismissedKeys: ReadonlySet<string>
nowMs: number
}): BannerSelection | null {
const { items, user, dismissedKeys, nowMs } = args
const eligible = items.filter(
(item) => item.showBanner && !item.isProjectCreationOnly && isRelevantToUser(item, user)
)
const withinWindow = eligible.filter(
(item) => item.kind !== 'upcoming_maintenance' || isWithinUpcomingWindow(item, nowMs)
)
const undismissed = withinWindow.filter((item) => !dismissedKeys.has(getDismissalKey(item)))
const incidents = undismissed.filter((item): item is IncidentItem => item.kind === 'incident')
if (incidents.length > 0) return { kind: 'incident', items: incidents }
const maintenances = undismissed.filter(
(item): item is MaintenanceItem => item.kind === 'maintenance'
)
if (maintenances.length > 0) {
return {
kind: 'maintenance',
item: earliestBy(maintenances, (item) => new Date(item.startedAt).getTime()),
}
}
const upcoming = undismissed.filter(
(item): item is UpcomingMaintenanceItem => item.kind === 'upcoming_maintenance'
)
if (upcoming.length > 0) {
return { kind: 'upcoming_maintenance', item: earliestBy(upcoming, getUpcomingStartMs) }
}
return null
}
export function getKeysToDismiss(selection: BannerSelection): Array<string> {
if (selection.kind === 'incident') return selection.items.map(getDismissalKey)
return [getDismissalKey(selection.item)]
}
/**
* Drops dismissed keys for items that are no longer in the (unfiltered) current
* item list — a finished incident or maintenance can't be re-dismissed, so there's
* no reason to keep growing the list forever — then adds the newly dismissed keys.
*/
export function pruneDismissedKeys(params: {
prev: Array<string>
allItems: Array<StatusItem>
add: Array<string>
}): Array<string> {
const { prev, allItems, add } = params
const activeKeys = new Set(allItems.map(getDismissalKey))
const kept = prev.filter((key) => activeKeys.has(key))
return Array.from(new Set([...kept, ...add]))
}
export function getBannerCopy(selection: BannerSelection): { title: string; description: string } {
switch (selection.kind) {
case 'incident': {
const title =
selection.items.length > 1
? 'We are investigating multiple technical issues'
: 'We are investigating a technical issue'
return { title, description: 'Follow the status page for updates' }
}
case 'maintenance': {
const { scheduledEndAt } = selection.item
const hasFutureEnd =
scheduledEndAt !== null && new Date(scheduledEndAt).getTime() > Date.now()
return {
title: 'Scheduled maintenance in progress',
description: hasFutureEnd
? 'Some services may be temporarily unavailable until maintenance ends'
: 'Some services may be temporarily unavailable',
}
}
case 'upcoming_maintenance': {
return {
title: 'Upcoming scheduled maintenance',
description: 'Some services may be temporarily unavailable during this window',
}
}
}
}
@@ -0,0 +1,65 @@
import { useQuery } from '@tanstack/react-query'
import { LOCAL_STORAGE_KEYS } from 'common'
import {
getKeysToDismiss,
pruneDismissedKeys,
selectBanner,
type BannerSelection,
} from './StatusBanner.utils'
import { DEFAULT_STATUS_PAGE_URL } from '@/components/interfaces/Support/SupportStatus.utils'
import { statusPageQueryOptions } from '@/data/platform/status-page-query'
import { useEmergencyIncidentOverride } from '@/hooks/misc/useEmergencyIncidentOverride'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { useUserProjectRegions } from '@/hooks/misc/useUserProjectRegions'
import { normalizeStatusPage } from '@/lib/status-page/status-page.utils'
export type StatusBannerState =
| { type: 'override' }
| { type: 'hidden' }
| { type: 'shown'; selection: BannerSelection; pageUrl: string; onDismiss: () => void }
const HIDDEN: StatusBannerState = { type: 'hidden' }
export function useStatusBanner(): StatusBannerState {
const isEmergencyOverride = useEmergencyIncidentOverride()
const statusPageOptions = statusPageQueryOptions()
const { data } = useQuery({
...statusPageOptions,
select: normalizeStatusPage,
enabled: statusPageOptions.enabled && !isEmergencyOverride,
})
const items = data?.items ?? []
const userProjectRegions = useUserProjectRegions({
enabled: !isEmergencyOverride && items.length > 0,
})
const [dismissedKeys, setDismissedKeys, { isSuccess: isDismissedKeysLoaded }] =
useLocalStorageQuery<Array<string>>(LOCAL_STORAGE_KEYS.STATUS_BANNER_DISMISSED_KEYS, [])
if (isEmergencyOverride) return { type: 'override' }
if (data === undefined || userProjectRegions.status === 'loading' || !isDismissedKeysLoaded) {
return HIDDEN
}
const selection = selectBanner({
items,
user: userProjectRegions.context,
dismissedKeys: new Set(dismissedKeys),
nowMs: Date.now(),
})
if (selection === null) return HIDDEN
const onDismiss = () => {
setDismissedKeys((prev) =>
pruneDismissedKeys({ prev, allItems: items, add: getKeysToDismiss(selection) })
)
}
return { type: 'shown', selection, pageUrl: data?.pageUrl ?? DEFAULT_STATUS_PAGE_URL, onDismiss }
}
@@ -15,6 +15,7 @@ export const LOCAL_STORAGE_KEYS = {
FEEDBACK_WIDGET_CONTENT: 'feedback-widget-content',
FEEDBACK_WIDGET_SCREENSHOT: 'feedback-widget-screenshot',
INCIDENT_BANNER_DISMISSED_IDS: 'incident-banner-dismissed-ids',
STATUS_BANNER_DISMISSED_KEYS: 'status-banner-dismissed-keys',
DASHBOARD_PREFERENCES: (ref: string) => `dashboard-preferences-${ref}`,
UNIFIED_LOGS_DOCK: 'unified-logs-dock',