mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
## 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 -->
339 lines
11 KiB
TypeScript
339 lines
11 KiB
TypeScript
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')
|
|
})
|
|
})
|