diff --git a/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx b/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx
index f43caee3d6..162e2bf22d 100644
--- a/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx
+++ b/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx
@@ -16,5 +16,12 @@ export const StatusPageBanner = () => {
if (!banner) return null
- return
+ return (
+
+ )
}
diff --git a/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts
index 5b2618d773..acb5f11c60 100644
--- a/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts
+++ b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts
@@ -1,18 +1,22 @@
import { describe, expect, it } from 'vitest'
-import { shouldShowBanner } from './StatusPageBanner.utils'
+import { getRelevantIncidentIds, shouldShowBanner } from './StatusPageBanner.utils'
-const noCache = { cache: null } as const
+const noCache = { id: 'no-cache', cache: null } as const
const noRestrictions = {
+ id: 'no-restrictions',
cache: { affected_regions: null, affects_project_creation: false },
}
const affectsCreation = {
+ id: 'affects-creation',
cache: { affected_regions: null, affects_project_creation: true },
}
const usEast1Only = {
+ id: 'us-east-1-only',
cache: { affected_regions: ['us-east-1'], affects_project_creation: false },
}
const usEast1AndCreation = {
+ id: 'us-east-1-and-creation',
cache: { affected_regions: ['us-east-1'], affects_project_creation: true },
}
@@ -87,7 +91,9 @@ describe('shouldShowBanner', () => {
it('shows when affected_regions is an empty array', () => {
expect(
shouldShowBanner({
- incidents: [{ cache: { affected_regions: [], affects_project_creation: false } }],
+ incidents: [
+ { id: 'test', cache: { affected_regions: [], affects_project_creation: false } },
+ ],
hasProjects: true,
userRegions: new Set(['us-east-1']),
})
@@ -110,7 +116,10 @@ describe('shouldShowBanner', () => {
expect(
shouldShowBanner({
incidents: [
- { cache: { affected_regions: ['eu-west-1'], affects_project_creation: false } },
+ {
+ id: 'test',
+ cache: { affected_regions: ['eu-west-1'], affects_project_creation: false },
+ },
],
hasProjects: true,
userRegions: new Set(['us-east-1', 'eu-west-1']),
@@ -123,6 +132,7 @@ describe('shouldShowBanner', () => {
shouldShowBanner({
incidents: [
{
+ id: 'test',
cache: {
affected_regions: ['us-east-1', 'ap-southeast-1'],
affects_project_creation: false,
@@ -223,3 +233,107 @@ describe('shouldShowBanner', () => {
})
})
})
+
+describe('getRelevantIncidentIds', () => {
+ it('returns empty array when there are no incidents', () => {
+ expect(
+ getRelevantIncidentIds({ incidents: [], hasProjects: true, userRegions: new Set() })
+ ).toEqual([])
+ })
+
+ it('returns empty array when no incidents are relevant to the user', () => {
+ expect(
+ getRelevantIncidentIds({
+ incidents: [usEast1Only],
+ hasProjects: true,
+ userRegions: new Set(['eu-west-1']),
+ })
+ ).toEqual([])
+ })
+
+ it('returns the ID of a single relevant incident', () => {
+ expect(
+ getRelevantIncidentIds({
+ incidents: [noRestrictions],
+ hasProjects: true,
+ userRegions: new Set(['us-east-1']),
+ })
+ ).toEqual(['no-restrictions'])
+ })
+
+ it('returns IDs of all relevant incidents', () => {
+ const euWest1Only = {
+ id: 'eu-west-1-only',
+ cache: { affected_regions: ['eu-west-1'], affects_project_creation: false },
+ }
+ const apSoutheast1Only = {
+ id: 'ap-southeast-1-only',
+ cache: { affected_regions: ['ap-southeast-1'], affects_project_creation: false },
+ }
+
+ expect(
+ getRelevantIncidentIds({
+ incidents: [euWest1Only, apSoutheast1Only],
+ hasProjects: true,
+ userRegions: new Set(['eu-west-1', 'ap-southeast-1']),
+ })
+ ).toEqual(expect.arrayContaining(['ap-southeast-1-only', 'eu-west-1-only']))
+ })
+
+ it('excludes incidents irrelevant to the user from the result', () => {
+ // User is in eu-west-1; us-east-1-only incident should not be included
+ expect(
+ getRelevantIncidentIds({
+ incidents: [usEast1Only, noRestrictions],
+ hasProjects: true,
+ userRegions: new Set(['eu-west-1']),
+ })
+ ).toEqual(['no-restrictions'])
+ })
+
+ describe('user has no projects', () => {
+ it('includes incidents with affects_project_creation', () => {
+ expect(
+ getRelevantIncidentIds({
+ incidents: [affectsCreation],
+ hasProjects: false,
+ userRegions: new Set(),
+ })
+ ).toEqual(['affects-creation'])
+ })
+
+ it('excludes incidents without affects_project_creation', () => {
+ expect(
+ getRelevantIncidentIds({
+ incidents: [noRestrictions, usEast1Only],
+ hasProjects: false,
+ userRegions: new Set(),
+ })
+ ).toEqual([])
+ })
+ })
+
+ describe('hasUnknownRegions', () => {
+ it('includes region-restricted incidents when regions are unknown', () => {
+ expect(
+ getRelevantIncidentIds({
+ incidents: [usEast1Only],
+ hasProjects: true,
+ userRegions: new Set(),
+ hasUnknownRegions: true,
+ })
+ ).toEqual(['us-east-1-only'])
+ })
+
+ it('still excludes incidents for no-project users even when regions are unknown', () => {
+ expect(
+ getRelevantIncidentIds({
+ incidents: [usEast1Only],
+ hasProjects: false,
+ userRegions: new Set(),
+ hasUnknownRegions: true,
+ })
+ ).toEqual([])
+ })
+ })
+})
diff --git a/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts
index d8fcf5f8f7..ccd3fa9827 100644
--- a/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts
+++ b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts
@@ -1,6 +1,6 @@
import type { IncidentCache } from 'lib/api/incident-status'
-type BannerIncident = { cache?: IncidentCache | null }
+type BannerIncident = { id: string; cache?: IncidentCache | null }
/**
* Determines whether the incident status banner should be shown to a given user,
@@ -42,3 +42,27 @@ export function shouldShowBanner({
return affectedRegions.some((region) => userRegions.has(region))
})
}
+
+/**
+ * Returns the IDs of incidents that are relevant to the given user.
+ *
+ * An incident is considered relevant if it would trigger banner visibility for
+ * the user, per the same logic as shouldShowBanner.
+ */
+export function getRelevantIncidentIds({
+ incidents,
+ hasProjects,
+ userRegions,
+ hasUnknownRegions = false,
+}: {
+ incidents: Array
+ hasProjects: boolean
+ userRegions: Set
+ hasUnknownRegions?: boolean
+}): Array {
+ return incidents
+ .filter((incident) =>
+ shouldShowBanner({ incidents: [incident], hasProjects, userRegions, hasUnknownRegions })
+ )
+ .map((incident) => incident.id)
+}
diff --git a/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts b/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts
index 8975cba3b9..4bce064808 100644
--- a/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts
+++ b/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts
@@ -1,7 +1,8 @@
import { useQueries } from '@tanstack/react-query'
-import { useFlag } from 'common'
+import { LOCAL_STORAGE_KEYS, useFlag } from 'common'
+import { useCallback, useMemo } from 'react'
-import { shouldShowBanner } from './StatusPageBanner.utils'
+import { getRelevantIncidentIds, shouldShowBanner } from './StatusPageBanner.utils'
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
import { useIncidentStatusQuery } from '@/data/platform/incident-status-query'
import { projectKeys } from '@/data/projects/keys'
@@ -9,8 +10,9 @@ import {
getOrganizationProjects,
type OrgProject,
} from '@/data/projects/org-projects-infinite-query'
+import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
-export type StatusPageBannerData = { title: string }
+export type StatusPageBannerData = { title: string; dismiss?: () => void }
export function useStatusPageBannerVisibility(): StatusPageBannerData | null {
const showIncidentBannerOverride =
@@ -40,22 +42,63 @@ export function useStatusPageBannerVisibility(): StatusPageBannerData | null {
const allProjects = orgProjectsQueries.flatMap((q) => q.data?.projects ?? [])
const hasProjects = allProjects.length > 0
- const userRegions = new Set(
- allProjects.flatMap((project: OrgProject) => project.databases.map((db) => db.region))
+ const userRegions = useMemo(
+ () =>
+ new Set(
+ allProjects.flatMap((project: OrgProject) => project.databases.map((db) => db.region))
+ ),
+ [allProjects]
)
const hasUnknownRegions = orgProjectsQueries.some(
(q) => q.isError || (q.data !== undefined && q.data.pagination.count > q.data.projects.length)
)
+ const [dismissedIds, setDismissedIds, { isSuccess: isDismissedLoaded }] = useLocalStorageQuery<
+ Array
+ >(LOCAL_STORAGE_KEYS.INCIDENT_BANNER_DISMISSED_IDS, [])
+
+ const dismiss = useCallback(() => {
+ const activeIncidentIds = new Set(incidents.map((i) => i.id))
+ const relevantIds = getRelevantIncidentIds({
+ incidents,
+ hasProjects,
+ userRegions,
+ hasUnknownRegions,
+ })
+ setDismissedIds((prev) => [
+ ...new Set([...prev.filter((id) => activeIncidentIds.has(id)), ...relevantIds]),
+ ])
+ }, [incidents, hasProjects, userRegions, hasUnknownRegions, setDismissedIds])
+
if (showIncidentBannerOverride) return { title: 'We are investigating a technical issue' }
if (!hasActiveIncidents || !isProjectsFetched) return null
- if (!shouldShowBanner({ incidents, hasProjects, userRegions, hasUnknownRegions })) return null
+ // Filter out individually dismissed incidents. An incident stays dismissed as
+ // long as its ID remains in the stored set, regardless of whether other
+ // incidents are added or removed.
+ const dismissedIdSet = new Set(dismissedIds)
+ const undismissedIncidents = incidents.filter((i) => !dismissedIdSet.has(i.id))
+
+ // If dismissed state hasn't loaded yet, hide to prevent a flash of the banner.
+ // If all relevant incidents have been dismissed, hide the banner.
+ if (
+ !isDismissedLoaded ||
+ !shouldShowBanner({
+ incidents: undismissedIncidents,
+ hasProjects,
+ userRegions,
+ hasUnknownRegions,
+ })
+ )
+ return null
+
+ const title = hasProjects
+ ? 'We are investigating a technical issue'
+ : 'Project creation may be impacted in some regions'
return {
- title: hasProjects
- ? 'We are investigating a technical issue'
- : 'Project creation may be impacted in some regions',
+ title,
+ dismiss,
}
}
diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts
index 266def9198..b0ffc6549e 100644
--- a/packages/common/constants/local-storage.ts
+++ b/packages/common/constants/local-storage.ts
@@ -11,7 +11,7 @@ export const LOCAL_STORAGE_KEYS = {
PROJECTS_SORT: 'projects-sort',
FEEDBACK_WIDGET_CONTENT: 'feedback-widget-content',
FEEDBACK_WIDGET_SCREENSHOT: 'feedback-widget-screenshot',
- INCIDENT_BANNER_DISMISSED: (id: string) => `incident-banner-dismissed-${id}`,
+ INCIDENT_BANNER_DISMISSED_IDS: 'incident-banner-dismissed-ids',
MAINTENANCE_BANNER_DISMISSED: (id: string) => `maintenance-banner-dismissed-${id}`,
UI_PREVIEW_API_SIDE_PANEL: 'supabase-ui-api-side-panel',