diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx
index de62e8abea1..215efbfa17d 100644
--- a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx
+++ b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx
@@ -1,32 +1,19 @@
import { useFlag } from 'common'
import { ClockSkewBanner } from 'components/layouts/AppLayout/ClockSkewBanner'
-import { IncidentBanner } from 'components/layouts/AppLayout/IncidentBanner'
import { NoticeBanner } from 'components/layouts/AppLayout/NoticeBanner'
+import { StatusPageBanner } from 'components/layouts/AppLayout/StatusPageBanner'
import { PropsWithChildren } from 'react'
import { OrganizationResourceBanner } from '../Organization/HeaderBanner'
-import { MaintenanceBanner } from '@/components/layouts/AppLayout/MaintenanceBanner'
-import { useIncidentStatusQuery } from '@/data/platform/incident-status-query'
export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => {
- const { data: allStatusPageEvents } = useIncidentStatusQuery()
- const { maintenanceEvents = [], incidents = [] } = allStatusPageEvents ?? {}
-
- // Only show incident banner for incidents with real impact (not "none")
- const hasBannerWorthyIncidents = incidents.some((incident) => incident.impact !== 'none')
- const ongoingIncident =
- useFlag('ongoingIncident') ||
- process.env.NEXT_PUBLIC_ONGOING_INCIDENT === 'true' ||
- hasBannerWorthyIncidents
- const ongoingMaintenance = maintenanceEvents.length > 0
-
const showNoticeBanner = useFlag('showNoticeBanner')
const clockSkewBanner = useFlag('clockSkewBanner')
return (
- {ongoingIncident ? : ongoingMaintenance ? : null}
+
{showNoticeBanner && }
{clockSkewBanner && }
diff --git a/apps/studio/components/layouts/AppLayout/IncidentBanner.tsx b/apps/studio/components/layouts/AppLayout/IncidentBanner.tsx
deleted file mode 100644
index 5f5830ef2e1..00000000000
--- a/apps/studio/components/layouts/AppLayout/IncidentBanner.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { HeaderBanner } from 'components/interfaces/Organization/HeaderBanner'
-import { InlineLink } from 'components/ui/InlineLink'
-
-/**
- * Used to display ongoing incidents
- */
-export const IncidentBanner = () => {
- return (
-
- Follow the status page for
- updates
- >
- }
- />
- )
-}
diff --git a/apps/studio/components/layouts/AppLayout/MaintenanceBanner.tsx b/apps/studio/components/layouts/AppLayout/MaintenanceBanner.tsx
deleted file mode 100644
index 4c5610b36b6..00000000000
--- a/apps/studio/components/layouts/AppLayout/MaintenanceBanner.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { LOCAL_STORAGE_KEYS } from 'common'
-import { HeaderBanner } from 'components/interfaces/Organization/HeaderBanner'
-import { InlineLink } from 'components/ui/InlineLink'
-
-import { useIncidentStatusQuery } from '@/data/platform/incident-status-query'
-import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
-
-/**
- * Used to display ongoing maintenance
- */
-export function MaintenanceBanner() {
- const { data: allStatusPageEvents } = useIncidentStatusQuery()
- const { maintenanceEvents = [] } = allStatusPageEvents ?? {}
- const currentEventId = maintenanceEvents[0]?.id ?? ''
-
- const [dismissed, setDismissed] = useLocalStorageQuery(
- LOCAL_STORAGE_KEYS.MAINTENANCE_BANNER_DISMISSED(currentEventId),
- false
- )
-
- if (dismissed) return null
-
- return (
-
- Follow the status page for
- updates
- >
- }
- onDismiss={() => setDismissed(true)}
- />
- )
-}
diff --git a/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx b/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx
new file mode 100644
index 00000000000..b5ffe723f8c
--- /dev/null
+++ b/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx
@@ -0,0 +1,68 @@
+import { LOCAL_STORAGE_KEYS, useFlag } from 'common'
+import { HeaderBanner } from 'components/interfaces/Organization/HeaderBanner'
+import { InlineLink } from 'components/ui/InlineLink'
+
+import { useIncidentStatusQuery } from '@/data/platform/incident-status-query'
+import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
+
+const BANNER_DESCRIPTION = (
+ <>
+ Follow the status page for updates
+ >
+)
+
+/**
+ * Used to display ongoing incidents or maintenances
+ */
+export const StatusPageBanner = () => {
+ const { data: allStatusPageEvents } = useIncidentStatusQuery()
+ const { incidents = [], maintenanceEvents = [] } = allStatusPageEvents ?? {}
+
+ // Only show incident banner for incidents with real impact (not "none")
+ const highImpactIncident = incidents.find((incident) => incident.impact !== 'none')
+ const incidentEventId = highImpactIncident?.id ?? ''
+
+ const showIncidentBannerOverride =
+ useFlag('ongoingIncident') || process.env.NEXT_PUBLIC_ONGOING_INCIDENT === 'true'
+
+ const ongoingMaintenance = maintenanceEvents.length > 0
+ const maintenanceEventId = maintenanceEvents[0]?.id ?? ''
+
+ const [dismissedIncident, setDismissedIncident] = useLocalStorageQuery(
+ LOCAL_STORAGE_KEYS.INCIDENT_BANNER_DISMISSED(incidentEventId),
+ false
+ )
+
+ const [dismissedMaintenance, setDismissedMaintenance] = useLocalStorageQuery(
+ LOCAL_STORAGE_KEYS.MAINTENANCE_BANNER_DISMISSED(maintenanceEventId),
+ false
+ )
+
+ if (showIncidentBannerOverride || (highImpactIncident && !dismissedIncident)) {
+ return (
+ setDismissedIncident(true)
+ }
+ />
+ )
+ }
+
+ if (ongoingMaintenance && !dismissedMaintenance) {
+ return (
+ setDismissedMaintenance(true)}
+ />
+ )
+ }
+
+ return null
+}
diff --git a/apps/studio/data/platform/incident-status-query.ts b/apps/studio/data/platform/incident-status-query.ts
index a6db924e096..7c767fc1cb5 100644
--- a/apps/studio/data/platform/incident-status-query.ts
+++ b/apps/studio/data/platform/incident-status-query.ts
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import type { IncidentInfo } from 'lib/api/incident-status'
-import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
+import { BASE_PATH, IS_PLATFORM, IS_TEST_ENV } from 'lib/constants'
import { partition } from 'lodash'
import { UseCustomQueryOptions } from 'types'
@@ -42,5 +42,6 @@ export const useIncidentStatusQuery = (
queryFn: ({ signal }) => getIncidentStatus(signal),
staleTime: 1000 * 60 * 5, // 5 minutes to match API cache
...options,
- enabled: IS_PLATFORM && (options.enabled ?? true),
+ // Enable in platform mode, or in test environment for E2E testing
+ enabled: (IS_PLATFORM || IS_TEST_ENV) && (options.enabled ?? true),
})
diff --git a/e2e/studio/features/status-page-banner.spec.ts b/e2e/studio/features/status-page-banner.spec.ts
new file mode 100644
index 00000000000..a1c504e1dfd
--- /dev/null
+++ b/e2e/studio/features/status-page-banner.spec.ts
@@ -0,0 +1,174 @@
+import { expect } from '@playwright/test'
+
+import { test } from '../utils/test.js'
+import { toUrl } from '../utils/to-url.js'
+
+// Mock incident data - impact is "major" (not "none" or "maintenance")
+const mockIncident = {
+ id: 'incident-123',
+ name: 'Test Incident',
+ status: 'investigating',
+ impact: 'major',
+ active_since: new Date().toISOString(),
+}
+
+// Mock incident with "none" impact - should NOT trigger the incident banner
+const mockIncidentNoneImpact = {
+ id: 'incident-none-456',
+ name: 'No Impact Incident',
+ status: 'investigating',
+ impact: 'none',
+ active_since: new Date().toISOString(),
+}
+
+// Mock maintenance event - impact is "maintenance"
+const mockMaintenance = {
+ id: 'maintenance-789',
+ name: 'Scheduled Maintenance',
+ status: 'in_progress',
+ impact: 'maintenance',
+ active_since: new Date().toISOString(),
+}
+
+test.describe('StatusPageBanner', () => {
+ test.describe.configure({ mode: 'serial' })
+
+ test.beforeEach(async ({ page }) => {
+ // Clear any dismissed banner state from localStorage before each test
+ await page.addInitScript(() => {
+ // Clear all incident/maintenance banner dismissals and test overrides
+ for (const key of Object.keys(localStorage)) {
+ if (
+ key.startsWith('incident-banner-dismissed-') ||
+ key.startsWith('maintenance-banner-dismissed-')
+ ) {
+ localStorage.removeItem(key)
+ }
+ }
+ })
+ })
+
+ test('incident banner shows for incidents with impact not equal to "none"', async ({
+ page,
+ ref,
+ }) => {
+ // Mock the incident status API to return an incident with major impact
+ await page.route('**/api/incident-status', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([mockIncident]),
+ })
+ })
+
+ await page.goto(toUrl(`/project/${ref}`))
+
+ // Wait for the incident banner to be visible
+ const incidentBanner = page.getByText('We are investigating a technical issue')
+ await expect(incidentBanner).toBeVisible({ timeout: 15000 })
+
+ // Verify the dismiss button IS present for real incidents
+ const dismissButton = page.getByRole('button', { name: 'Dismiss banner' })
+ await expect(dismissButton).toBeVisible()
+ })
+
+ test('incident banner does NOT show for incidents with impact "none"', async ({ page, ref }) => {
+ // Mock the incident status API to return an incident with "none" impact
+ await page.route('**/api/incident-status', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([mockIncidentNoneImpact]),
+ })
+ })
+
+ await page.goto(toUrl(`/project/${ref}`))
+
+ // Wait for page to load
+ await expect(page.getByRole('heading', { level: 1 })).toBeVisible({ timeout: 15000 })
+
+ // Verify the incident banner is NOT visible
+ const incidentBanner = page.getByText('We are investigating a technical issue')
+ await expect(incidentBanner).not.toBeVisible()
+ })
+
+ test('incident banner takes precedence over maintenance banner', async ({ page, ref }) => {
+ // Mock the incident status API to return both an incident and a maintenance event
+ await page.route('**/api/incident-status', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([mockIncident, mockMaintenance]),
+ })
+ })
+
+ await page.goto(toUrl(`/project/${ref}`))
+
+ // Wait for the incident banner to be visible
+ const incidentBanner = page.getByText('We are investigating a technical issue')
+ await expect(incidentBanner).toBeVisible({ timeout: 15000 })
+
+ // Verify the maintenance banner is NOT visible (incident takes precedence)
+ const maintenanceBanner = page.getByText('Scheduled maintenance is in progress')
+ await expect(maintenanceBanner).not.toBeVisible()
+ })
+
+ test('maintenance banner shows after dismissing incident banner when both exist', async ({
+ page,
+ ref,
+ }) => {
+ // Mock the incident status API to return both an incident and a maintenance event
+ await page.route('**/api/incident-status', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([mockIncident, mockMaintenance]),
+ })
+ })
+
+ await page.goto(toUrl(`/project/${ref}`))
+
+ // Wait for the incident banner to be visible
+ const incidentBanner = page.getByText('We are investigating a technical issue')
+ await expect(incidentBanner).toBeVisible({ timeout: 15000 })
+
+ // Click the dismiss button to dismiss the incident banner
+ const dismissButton = page.getByRole('button', { name: 'Dismiss banner' })
+ await expect(dismissButton).toBeVisible()
+ await dismissButton.click()
+
+ // Wait for the incident banner to disappear
+ await expect(incidentBanner).not.toBeVisible({ timeout: 5000 })
+
+ // Verify the maintenance banner is now visible
+ const maintenanceBanner = page.getByText('Scheduled maintenance is in progress')
+ await expect(maintenanceBanner).toBeVisible({ timeout: 5000 })
+ })
+
+ test('maintenance banner is dismissible', async ({ page, ref }) => {
+ // Mock the incident status API to return only a maintenance event
+ await page.route('**/api/incident-status', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([mockMaintenance]),
+ })
+ })
+
+ await page.goto(toUrl(`/project/${ref}`))
+
+ // Wait for the maintenance banner to be visible
+ const maintenanceBanner = page.getByText('Scheduled maintenance is in progress')
+ await expect(maintenanceBanner).toBeVisible({ timeout: 15000 })
+
+ // Verify the dismiss button is present
+ const dismissButton = page.getByRole('button', { name: 'Dismiss banner' })
+ await expect(dismissButton).toBeVisible()
+
+ // Dismiss the maintenance banner
+ await dismissButton.click()
+
+ // Verify the maintenance banner is no longer visible
+ await expect(maintenanceBanner).not.toBeVisible({ timeout: 5000 })
+ })
+})
diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts
index 44e56284601..6a6aeb6518b 100644
--- a/packages/common/constants/local-storage.ts
+++ b/packages/common/constants/local-storage.ts
@@ -9,6 +9,7 @@ export const LOCAL_STORAGE_KEYS = {
PROJECTS_VIEW: 'projects-view',
FEEDBACK_WIDGET_CONTENT: 'feedback-widget-content',
FEEDBACK_WIDGET_SCREENSHOT: 'feedback-widget-screenshot',
+ INCIDENT_BANNER_DISMISSED: (id: string) => `incident-banner-dismissed-${id}`,
MAINTENANCE_BANNER_DISMISSED: (id: string) => `maintenance-banner-dismissed-${id}`,
UI_PREVIEW_API_SIDE_PANEL: 'supabase-ui-api-side-panel',