mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
chore(studio): Consolidate incident banner + maintenance banner, make incident banner dismissible (#42551)
## Context PR mainly adds a change to allow users to dismiss an incident banner Am also taking this time to consolidate `IncidentBanner` and `MaintenanceBanner` into a single component `StatusPageBanner` so that the logic for rendering either component is more clearer. ## Intended behaviour (should be covered by e2e tests) - Incident banner shows if there are incidents with impact !== 'none' - Incident banner still takes precedence over Maintanence banner (if there's both incident + maintenence events) - Dismissing the incident banner will subsequently show the maintenance banner if the maintenance hasn't been dismissed before (just to make sure that the user doesn't miss any comms) - Incident banner will not be dismissible if there's an override (via feature flag or env var) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Unified status page banner showing incident or maintenance notifications, with dismissible banners that persist across sessions; incident notices take precedence over maintenance. * **Refactor** * Replaced separate incident and maintenance banners with a single consolidated StatusPageBanner component. * **Tests** * Added end-to-end tests covering banner visibility, precedence, and dismissal behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
a057c951ea
commit
839b367375
7 files changed
+248
-73
No files matched your search
@@ -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 (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex-shrink-0">
|
||||
{ongoingIncident ? <IncidentBanner /> : ongoingMaintenance ? <MaintenanceBanner /> : null}
|
||||
<StatusPageBanner />
|
||||
{showNoticeBanner && <NoticeBanner />}
|
||||
<OrganizationResourceBanner />
|
||||
{clockSkewBanner && <ClockSkewBanner />}
|
||||
|
||||
@@ -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 (
|
||||
<HeaderBanner
|
||||
variant="warning"
|
||||
title="We are investigating a technical issue"
|
||||
description={
|
||||
<>
|
||||
Follow the <InlineLink href="https://status.supabase.com">status page</InlineLink> for
|
||||
updates
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<HeaderBanner
|
||||
variant="note"
|
||||
title="Scheduled maintenance is in progress"
|
||||
description={
|
||||
<>
|
||||
Follow the <InlineLink href="https://status.supabase.com">status page</InlineLink> for
|
||||
updates
|
||||
</>
|
||||
}
|
||||
onDismiss={() => setDismissed(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 <InlineLink href="https://status.supabase.com">status page</InlineLink> 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 (
|
||||
<HeaderBanner
|
||||
variant="warning"
|
||||
title="We are investigating a technical issue"
|
||||
description={BANNER_DESCRIPTION}
|
||||
onDismiss={
|
||||
showIncidentBannerOverride || !highImpactIncident
|
||||
? undefined
|
||||
: () => setDismissedIncident(true)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (ongoingMaintenance && !dismissedMaintenance) {
|
||||
return (
|
||||
<HeaderBanner
|
||||
variant="note"
|
||||
title="Scheduled maintenance is in progress"
|
||||
description={BANNER_DESCRIPTION}
|
||||
onDismiss={() => setDismissedMaintenance(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -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 = <TData = IncidentStatusData>(
|
||||
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),
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user