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> { + const cacheMap = new Map() + + if (incidentIds.length === 0) return cacheMap + + const supabase = createAdminClient() + + try { + const { data, error } = await supabase + .from('incident_status_cache') + .select('incident_id, affected_regions, affects_project_creation') + .in('incident_id', incidentIds) + + if (error) { + console.error('Failed to fetch incident_status_cache: %O', error) + return cacheMap + } + + for (const row of data ?? []) { + cacheMap.set(row.incident_id, { + affected_regions: row.affected_regions ?? null, + affects_project_creation: row.affects_project_creation, + }) + } + } catch (error) { + console.error('Unexpected error fetching incident_status_cache: %O', error) + } + + return cacheMap +} + // Default export needed by Next.js convention -// eslint-disable-next-line no-restricted-exports export default async function handler( req: NextApiRequest, - res: NextApiResponse + res: NextApiResponse | { error: string }> ) { if (!IS_PLATFORM) { return res.status(404).end() @@ -34,11 +69,24 @@ export default async function handler( } try { - const incidents = await getActiveIncidents() + const allIncidents = await getActiveIncidents() + + const bannerIncidents = allIncidents.filter( + (incident) => + incident.impact !== 'maintenance' && + incident.metadata?.dashboard_metadata?.show_banner === true + ) + + const cacheMap = await fetchIncidentCache(bannerIncidents.map((i) => i.id)) + + const enrichedIncidents = bannerIncidents.map((incident) => ({ + ...incident, + cache: cacheMap.get(incident.id) ?? null, + })) res.setHeader('Cache-Control', CACHE_CONTROL_SETTINGS) - return res.status(200).json(incidents) + return res.status(200).json(enrichedIncidents) } catch (error) { if (error instanceof InternalServerError) { console.error('Failed to fetch active StatusPage incidents: %O', { diff --git a/turbo.json b/turbo.json index c8026da1181..789b14f1c59 100644 --- a/turbo.json +++ b/turbo.json @@ -108,7 +108,8 @@ "AI_NORMAL_MODEL", "SUPPORT_SUPABASE_SECRET_KEY", "STATUSPAGE_API_KEY", - "STATUSPAGE_PAGE_ID" + "STATUSPAGE_PAGE_ID", + "LIVE_SUPABASE_SECRET_KEY" ], "passThroughEnv": [ "CURRENT_CLI_VERSION",