-
+ {useStatusPageWidget ?
:
}
{showNoticeBanner &&
}
{clockSkewBanner &&
}
diff --git a/apps/studio/components/layouts/AppLayout/StatusBanner.test.tsx b/apps/studio/components/layouts/AppLayout/StatusBanner.test.tsx
new file mode 100644
index 00000000000..38976d1a86a
--- /dev/null
+++ b/apps/studio/components/layouts/AppLayout/StatusBanner.test.tsx
@@ -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 {
+ 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] {
+ 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([
+ createMockOrganizationResponse({ slug: 'org-a' }),
+ ]),
+ })
+ addAPIMock({
+ method: 'get',
+ path: '/platform/organizations/:slug/projects',
+ response: () =>
+ HttpResponse.json({
+ 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(
+ buildStatusPage({ ongoing_incidents: [buildIncident()] })
+ )
+ )
+ )
+ const { promise: orgsRequested, resolve: releaseOrgsResponse } = Promise.withResolvers()
+ addAPIMock({
+ method: 'get',
+ path: '/platform/organizations',
+ response: async () => {
+ await orgsRequested
+ return HttpResponse.json([
+ createMockOrganizationResponse({ slug: 'org-a' }),
+ ])
+ },
+ })
+ addAPIMock({
+ method: 'get',
+ path: '/platform/organizations/:slug/projects',
+ response: () =>
+ HttpResponse.json({
+ pagination: { count: 1, limit: 100, offset: 0 },
+ projects: [mockProject('project-a', [{ region: 'us-east-1', type: 'PRIMARY' }])],
+ }),
+ })
+
+ customRender(, { 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(
+ buildStatusPage({ ongoing_incidents: [buildIncident({ id: 'inc-1' })] })
+ )
+ )
+ )
+ mockOrgProjectEndpoints()
+
+ customRender(, { 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(
+ buildStatusPage({ ongoing_incidents: [buildIncident({ id: 'inc-1' })] })
+ )
+ )
+ )
+ mockOrgProjectEndpoints()
+
+ customRender(, { 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(
+ 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(, { profileContext: createMockProfileContext() })
+
+ expect(await screen.findByText(INCIDENT_TITLE)).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Dismiss banner' })).not.toBeInTheDocument()
+ })
+})
diff --git a/apps/studio/components/layouts/AppLayout/StatusBanner.tsx b/apps/studio/components/layouts/AppLayout/StatusBanner.tsx
new file mode 100644
index 00000000000..fae92f7b77b
--- /dev/null
+++ b/apps/studio/components/layouts/AppLayout/StatusBanner.tsx
@@ -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} Status page
+ >
+ )
+ }
+
+ if (selection.kind === 'maintenance') {
+ const { scheduledEndAt } = selection.item
+ const hasFutureEnd = scheduledEndAt !== null && new Date(scheduledEndAt).getTime() > Date.now()
+ return (
+ <>
+ {description}
+ {hasFutureEnd && (
+ <>
+ {' '}
+ (
+
+ )
+ >
+ )}
+ >
+ )
+ }
+
+ const { startsAt, endsAt } = selection.item
+ return (
+ <>
+ {description}
+ {startsAt !== null && endsAt !== null && (
+ <>
+ {' '}
+ (
+ {' – '}
+ )
+ >
+ )}
+ >
+ )
+}
+
+/**
+ * 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 (
+
+ Follow the status page for
+ updates
+ >
+ }
+ />
+ )
+ }
+
+ const { selection, pageUrl, onDismiss } = state
+ const { title, description } = getBannerCopy(selection)
+
+ return (
+
+ )
+}
diff --git a/apps/studio/components/layouts/AppLayout/StatusBanner.utils.test.ts b/apps/studio/components/layouts/AppLayout/StatusBanner.utils.test.ts
new file mode 100644
index 00000000000..7cd16219350
--- /dev/null
+++ b/apps/studio/components/layouts/AppLayout/StatusBanner.utils.test.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 = [
+ 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 = [
+ 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 = [
+ 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 = [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 = [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 = [
+ 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 = [
+ 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 = [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 = [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')
+ })
+})
diff --git a/apps/studio/components/layouts/AppLayout/StatusBanner.utils.ts b/apps/studio/components/layouts/AppLayout/StatusBanner.utils.ts
new file mode 100644
index 00000000000..a8e055b8523
--- /dev/null
+++ b/apps/studio/components/layouts/AppLayout/StatusBanner.utils.ts
@@ -0,0 +1,137 @@
+import {
+ getDismissalKey,
+ isRelevantToUser,
+ type StatusItem,
+ type UserRegionContext,
+} from '@/lib/status-page/status-page.utils'
+
+export type IncidentItem = Extract
+export type MaintenanceItem = Extract
+export type UpcomingMaintenanceItem = Extract
+
+export type BannerSelection =
+ | { kind: 'incident'; items: Array }
+ | { 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(items: ReadonlyArray, 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
+ user: UserRegionContext
+ dismissedKeys: ReadonlySet
+ 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 {
+ 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
+ allItems: Array
+ add: Array
+}): Array {
+ 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',
+ }
+ }
+ }
+}
diff --git a/apps/studio/components/layouts/AppLayout/useStatusBanner.ts b/apps/studio/components/layouts/AppLayout/useStatusBanner.ts
new file mode 100644
index 00000000000..85797ea0bf0
--- /dev/null
+++ b/apps/studio/components/layouts/AppLayout/useStatusBanner.ts
@@ -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>(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 }
+}
diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts
index 08c2acb6dd6..262980f4cd7 100644
--- a/packages/common/constants/local-storage.ts
+++ b/packages/common/constants/local-storage.ts
@@ -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',