diff --git a/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx b/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx
index 3b9cd6c2c1d..6b6c61a501a 100644
--- a/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx
+++ b/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx
@@ -1,18 +1,18 @@
-import { useRouter } from 'next/router'
-
-import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { LOCAL_STORAGE_KEYS } from 'common'
-import { HeaderBanner } from 'components/interfaces/Organization/HeaderBanner'
-import { InlineLink } from 'components/ui/InlineLink'
+import { useRouter } from 'next/router'
import { TimestampInfo } from 'ui-patterns'
+import { HeaderBanner } from '@/components/interfaces/Organization/HeaderBanner'
+import { InlineLink } from '@/components/ui/InlineLink'
+import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
+
/**
* Used to display urgent notices that apply for all users, such as maintenance windows.
*/
export const NoticeBanner = () => {
const router = useRouter()
- const [bannerAcknowledged, setBannerAcknowledge, { isSuccess }] = useLocalStorageQuery(
+ const [bannerAcknowledged, setBannerAcknowledged, { isSuccess }] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.MAINTENANCE_WINDOW_BANNER,
false
)
@@ -38,7 +38,7 @@ export const NoticeBanner = () => {
>
}
- onDismiss={() => setBannerAcknowledge(true)}
+ onDismiss={() => setBannerAcknowledged(true)}
/>
)
}
diff --git a/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx b/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx
index 1254546603c..f43caee3d6c 100644
--- a/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx
+++ b/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx
@@ -1,7 +1,6 @@
-import { useFlag } from 'common'
-
import { HeaderBanner } from '@/components/interfaces/Organization/HeaderBanner'
import { InlineLink } from '@/components/ui/InlineLink'
+import { useStatusPageBannerVisibility } from './useStatusPageBannerVisibility'
const BANNER_DESCRIPTION = (
<>
@@ -13,18 +12,9 @@ const BANNER_DESCRIPTION = (
* Used to display ongoing incidents
*/
export const StatusPageBanner = () => {
- const showIncidentBanner =
- useFlag('ongoingIncident') || process.env.NEXT_PUBLIC_ONGOING_INCIDENT === 'true'
+ const banner = useStatusPageBannerVisibility()
- if (showIncidentBanner) {
- return (
-
- )
- }
+ if (!banner) return null
- return null
+ return
}
diff --git a/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts
new file mode 100644
index 00000000000..5b2618d7739
--- /dev/null
+++ b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts
@@ -0,0 +1,225 @@
+import { describe, expect, it } from 'vitest'
+
+import { shouldShowBanner } from './StatusPageBanner.utils'
+
+const noCache = { cache: null } as const
+const noRestrictions = {
+ cache: { affected_regions: null, affects_project_creation: false },
+}
+const affectsCreation = {
+ cache: { affected_regions: null, affects_project_creation: true },
+}
+const usEast1Only = {
+ cache: { affected_regions: ['us-east-1'], affects_project_creation: false },
+}
+const usEast1AndCreation = {
+ cache: { affected_regions: ['us-east-1'], affects_project_creation: true },
+}
+
+describe('shouldShowBanner', () => {
+ describe('no incidents', () => {
+ it('does not show when there are no incidents', () => {
+ expect(
+ shouldShowBanner({ incidents: [], hasProjects: true, userRegions: new Set(['us-east-1']) })
+ ).toBe(false)
+ })
+ })
+
+ describe('user has no projects', () => {
+ it('does not show when cache is absent', () => {
+ expect(
+ shouldShowBanner({ incidents: [noCache], hasProjects: false, userRegions: new Set() })
+ ).toBe(false)
+ })
+
+ it('does not show when affects_project_creation is false', () => {
+ expect(
+ shouldShowBanner({
+ incidents: [noRestrictions],
+ hasProjects: false,
+ userRegions: new Set(),
+ })
+ ).toBe(false)
+ })
+
+ it('shows when affects_project_creation is true and no region restriction', () => {
+ expect(
+ shouldShowBanner({
+ incidents: [affectsCreation],
+ hasProjects: false,
+ userRegions: new Set(),
+ })
+ ).toBe(true)
+ })
+
+ it('shows when affects_project_creation is true even with a region restriction', () => {
+ expect(
+ shouldShowBanner({
+ incidents: [usEast1AndCreation],
+ hasProjects: false,
+ userRegions: new Set(),
+ })
+ ).toBe(true)
+ })
+ })
+
+ describe('user has projects, no region restriction', () => {
+ it('shows when cache is absent', () => {
+ expect(
+ shouldShowBanner({
+ incidents: [noCache],
+ hasProjects: true,
+ userRegions: new Set(['us-east-1']),
+ })
+ ).toBe(true)
+ })
+
+ it('shows when affected_regions is null', () => {
+ expect(
+ shouldShowBanner({
+ incidents: [noRestrictions],
+ hasProjects: true,
+ userRegions: new Set(['us-east-1']),
+ })
+ ).toBe(true)
+ })
+
+ it('shows when affected_regions is an empty array', () => {
+ expect(
+ shouldShowBanner({
+ incidents: [{ cache: { affected_regions: [], affects_project_creation: false } }],
+ hasProjects: true,
+ userRegions: new Set(['us-east-1']),
+ })
+ ).toBe(true)
+ })
+ })
+
+ describe('user has projects, with region restriction', () => {
+ it('shows when user has a primary database in an affected region', () => {
+ expect(
+ shouldShowBanner({
+ incidents: [usEast1Only],
+ hasProjects: true,
+ userRegions: new Set(['us-east-1']),
+ })
+ ).toBe(true)
+ })
+
+ it('shows when user has a read replica in an affected region', () => {
+ expect(
+ shouldShowBanner({
+ incidents: [
+ { cache: { affected_regions: ['eu-west-1'], affects_project_creation: false } },
+ ],
+ hasProjects: true,
+ userRegions: new Set(['us-east-1', 'eu-west-1']),
+ })
+ ).toBe(true)
+ })
+
+ it('shows when one of multiple affected regions matches', () => {
+ expect(
+ shouldShowBanner({
+ incidents: [
+ {
+ cache: {
+ affected_regions: ['us-east-1', 'ap-southeast-1'],
+ affects_project_creation: false,
+ },
+ },
+ ],
+ hasProjects: true,
+ userRegions: new Set(['ap-southeast-1']),
+ })
+ ).toBe(true)
+ })
+
+ it('does not show when user has no databases in any affected region', () => {
+ expect(
+ shouldShowBanner({
+ incidents: [usEast1Only],
+ hasProjects: true,
+ userRegions: new Set(['eu-west-1', 'ap-southeast-1']),
+ })
+ ).toBe(false)
+ })
+
+ it('does not show when user has projects but no databases in affected region, even with affects_project_creation', () => {
+ expect(
+ shouldShowBanner({
+ incidents: [usEast1AndCreation],
+ hasProjects: true,
+ userRegions: new Set(['eu-west-1']),
+ })
+ ).toBe(false)
+ })
+ })
+
+ describe('multiple incidents', () => {
+ it('shows when at least one incident matches even if others do not', () => {
+ expect(
+ shouldShowBanner({
+ incidents: [usEast1Only, noRestrictions],
+ hasProjects: true,
+ userRegions: new Set(['eu-west-1']),
+ })
+ ).toBe(true)
+ })
+
+ it('does not show when no incident matches', () => {
+ expect(
+ shouldShowBanner({
+ incidents: [usEast1Only, usEast1AndCreation],
+ hasProjects: true,
+ userRegions: new Set(['eu-west-1']),
+ })
+ ).toBe(false)
+ })
+
+ it('shows when any incident matches for a no-project user via affects_project_creation', () => {
+ expect(
+ shouldShowBanner({
+ incidents: [usEast1Only, affectsCreation],
+ hasProjects: false,
+ userRegions: new Set(),
+ })
+ ).toBe(true)
+ })
+ })
+
+ describe('hasUnknownRegions', () => {
+ it('shows when regions are unknown and incident has a region restriction', () => {
+ expect(
+ shouldShowBanner({
+ incidents: [usEast1Only],
+ hasProjects: true,
+ userRegions: new Set(),
+ hasUnknownRegions: true,
+ })
+ ).toBe(true)
+ })
+
+ it('still applies no-projects check even when regions are unknown', () => {
+ expect(
+ shouldShowBanner({
+ incidents: [usEast1Only],
+ hasProjects: false,
+ userRegions: new Set(),
+ hasUnknownRegions: true,
+ })
+ ).toBe(false)
+ })
+
+ it('still shows for affects_project_creation with no projects even when regions are unknown', () => {
+ expect(
+ shouldShowBanner({
+ incidents: [usEast1AndCreation],
+ hasProjects: false,
+ userRegions: new Set(),
+ hasUnknownRegions: true,
+ })
+ ).toBe(true)
+ })
+ })
+})
diff --git a/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts
new file mode 100644
index 00000000000..d8fcf5f8f7b
--- /dev/null
+++ b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts
@@ -0,0 +1,44 @@
+import type { IncidentCache } from 'lib/api/incident-status'
+
+type BannerIncident = { cache?: IncidentCache | null }
+
+/**
+ * Determines whether the incident status banner should be shown to a given user,
+ * given all active incidents and the user's project state.
+ *
+ * Returns true if any incident matches the user's context.
+ *
+ * @param incidents - Active incidents from the incident-status endpoint
+ * @param hasProjects - Whether the user has any projects at all
+ * @param userRegions - Deduplicated set of regions of all databases (primary and read replicas) owned by the user
+ * @param hasUnknownRegions - True when region data is incomplete (org has >100 projects).
+ * When true, the region check is skipped and a match is assumed.
+ */
+export function shouldShowBanner({
+ incidents,
+ hasProjects,
+ userRegions,
+ hasUnknownRegions = false,
+}: {
+ incidents: Array
+ hasProjects: boolean
+ userRegions: Set
+ hasUnknownRegions?: boolean
+}): boolean {
+ return incidents.some((incident) => {
+ const affectedRegions = incident.cache?.affected_regions ?? []
+ const affectsProjectCreation = incident.cache?.affects_project_creation ?? false
+
+ // Users with no projects only see the banner if the incident affects project creation
+ if (!hasProjects) return affectsProjectCreation
+
+ // User has projects: if no region restriction, always show
+ if (affectedRegions.length === 0) return true
+
+ // Region data is incomplete — assume the user has a database in an affected region
+ if (hasUnknownRegions) return true
+
+ // Region restriction: only show if the user has a database in an affected region
+ return affectedRegions.some((region) => userRegions.has(region))
+ })
+}
diff --git a/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts b/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts
new file mode 100644
index 00000000000..8975cba3b9a
--- /dev/null
+++ b/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts
@@ -0,0 +1,61 @@
+import { useQueries } from '@tanstack/react-query'
+import { useFlag } from 'common'
+
+import { 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'
+import {
+ getOrganizationProjects,
+ type OrgProject,
+} from '@/data/projects/org-projects-infinite-query'
+
+export type StatusPageBannerData = { title: string }
+
+export function useStatusPageBannerVisibility(): StatusPageBannerData | null {
+ const showIncidentBannerOverride =
+ useFlag('ongoingIncident') || process.env.NEXT_PUBLIC_ONGOING_INCIDENT === 'true'
+
+ const { data: allStatusPageEvents } = useIncidentStatusQuery()
+ const { incidents = [] } = allStatusPageEvents ?? {}
+
+ const hasActiveIncidents = incidents.length > 0
+
+ const { data: organizations } = useOrganizationsQuery({
+ enabled: !showIncidentBannerOverride && hasActiveIncidents,
+ })
+
+ const orgProjectsQueries = useQueries({
+ queries: (organizations ?? []).map((org) => ({
+ queryKey: projectKeys.bannerProjectsByOrg(org.slug),
+ queryFn: () => getOrganizationProjects({ slug: org.slug, limit: 100 }),
+ staleTime: 5 * 60 * 1000,
+ enabled: !showIncidentBannerOverride && hasActiveIncidents,
+ })),
+ })
+
+ const isProjectsFetched =
+ organizations !== undefined &&
+ (organizations.length === 0 || orgProjectsQueries.every((q) => q.isFetched))
+
+ 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 hasUnknownRegions = orgProjectsQueries.some(
+ (q) => q.isError || (q.data !== undefined && q.data.pagination.count > q.data.projects.length)
+ )
+
+ if (showIncidentBannerOverride) return { title: 'We are investigating a technical issue' }
+
+ if (!hasActiveIncidents || !isProjectsFetched) return null
+
+ if (!shouldShowBanner({ incidents, hasProjects, userRegions, hasUnknownRegions })) return null
+
+ return {
+ title: hasProjects
+ ? 'We are investigating a technical issue'
+ : 'Project creation may be impacted in some regions',
+ }
+}
diff --git a/apps/studio/data/projects/keys.ts b/apps/studio/data/projects/keys.ts
index 0684b180478..810e41baf17 100644
--- a/apps/studio/data/projects/keys.ts
+++ b/apps/studio/data/projects/keys.ts
@@ -36,4 +36,7 @@ export const projectKeys = {
['projects', projectRef, 'clone-backups'] as const,
listCloneStatus: (projectRef: string | undefined) =>
['projects', projectRef, 'clone-status'] as const,
+
+ // Banner-specific: first-page snapshot used by the status page banner hook
+ bannerProjectsByOrg: (slug: string) => ['banner', 'org-projects', slug] as const,
}
diff --git a/apps/studio/data/projects/org-projects-infinite-query.ts b/apps/studio/data/projects/org-projects-infinite-query.ts
index d13b328d006..02825fb2738 100644
--- a/apps/studio/data/projects/org-projects-infinite-query.ts
+++ b/apps/studio/data/projects/org-projects-infinite-query.ts
@@ -24,7 +24,7 @@ interface GetOrgProjectsInfiniteVariables {
export type OrgProjectsResponse = components['schemas']['OrganizationProjectsResponse']
export type OrgProject = OrgProjectsResponse['projects'][number]
-async function getOrganizationProjects(
+export async function getOrganizationProjects(
{
slug,
limit = DEFAULT_LIMIT,
diff --git a/apps/studio/lib/api/incident-status.ts b/apps/studio/lib/api/incident-status.ts
index b552e27ead4..7b19ee53c40 100644
--- a/apps/studio/lib/api/incident-status.ts
+++ b/apps/studio/lib/api/incident-status.ts
@@ -3,12 +3,25 @@ import z from 'zod'
import { IS_PLATFORM } from 'common'
import { InternalServerError } from 'lib/api/apiHelpers'
+export type IncidentCache = {
+ affected_regions: Array | null
+ affects_project_creation: boolean
+}
+
+export type IncidentMetadata = {
+ dashboard_metadata?: {
+ show_banner?: boolean
+ }
+}
+
export type IncidentInfo = {
id: string
name: string
status: string
impact: string
active_since: string
+ metadata: IncidentMetadata
+ cache?: IncidentCache | null
}
const STATUSPAGE_API_URL = 'https://api.statuspage.io/v1'
@@ -27,6 +40,16 @@ const StatusPageIncidentsSchema = z.array(
created_at: z.string(),
scheduled_for: z.string().nullable(),
impact: z.string(),
+ metadata: z
+ .object({
+ dashboard_metadata: z
+ .object({
+ show_banner: z.boolean().optional(),
+ })
+ .optional(),
+ })
+ .optional()
+ .default({}),
})
)
@@ -114,5 +137,6 @@ export async function getActiveIncidents(): Promise {
status: incident.status,
impact: incident.impact,
active_since: incident.scheduled_for ?? incident.created_at,
+ metadata: incident.metadata,
}))
}
diff --git a/apps/studio/lib/api/supabase-admin.ts b/apps/studio/lib/api/supabase-admin.ts
new file mode 100644
index 00000000000..e6a19f7008b
--- /dev/null
+++ b/apps/studio/lib/api/supabase-admin.ts
@@ -0,0 +1,9 @@
+import { createClient } from '@supabase/supabase-js'
+
+/**
+ * Creates a Supabase client using the secret key.
+ * For use in server-side API routes only.
+ */
+export function createAdminClient() {
+ return createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.LIVE_SUPABASE_SECRET_KEY!)
+}
diff --git a/apps/studio/pages/api/incident-status.ts b/apps/studio/pages/api/incident-status.ts
index e925a92e6a3..c075bbdd38a 100644
--- a/apps/studio/pages/api/incident-status.ts
+++ b/apps/studio/pages/api/incident-status.ts
@@ -1,8 +1,13 @@
+import { IS_PLATFORM } from 'common'
import { NextApiRequest, NextApiResponse } from 'next'
-import { IS_PLATFORM } from 'common'
-import { InternalServerError } from 'lib/api/apiHelpers'
-import { getActiveIncidents, type IncidentInfo } from 'lib/api/incident-status'
+import { InternalServerError } from '@/lib/api/apiHelpers'
+import {
+ getActiveIncidents,
+ type IncidentCache,
+ type IncidentInfo,
+} from '@/lib/api/incident-status'
+import { createAdminClient } from '@/lib/api/supabase-admin'
/**
* Cache on browser for 5 minutes
@@ -11,11 +16,41 @@ import { getActiveIncidents, type IncidentInfo } from 'lib/api/incident-status'
*/
const CACHE_CONTROL_SETTINGS = 'public, max-age=300, s-maxage=300, stale-while-revalidate=60'
+async function fetchIncidentCache(incidentIds: Array): Promise