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