diff --git a/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts index 2c18ae97b51..d6c1eea2f95 100644 --- a/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts +++ b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts @@ -19,6 +19,10 @@ const usEast1AndCreation = { id: 'us-east-1-and-creation', cache: { affected_regions: ['us-east-1'], affects_project_creation: true }, } +const forced = { + id: 'forced', + cache: { affected_regions: ['us-east-1'], affects_project_creation: false, force: true }, +} describe('shouldShowBanner', () => { describe('no incidents', () => { @@ -213,6 +217,40 @@ describe('shouldShowBanner', () => { }) }) + describe('force', () => { + it('shows for a user with no projects regardless of affects_project_creation', () => { + expect( + shouldShowBanner({ incidents: [forced], hasProjects: false, userRegions: new Set() }) + ).toBe(true) + }) + + it('shows for a user whose regions do not overlap with affected_regions', () => { + expect( + shouldShowBanner({ + incidents: [forced], + hasProjects: true, + userRegions: new Set(['eu-west-1']), + }) + ).toBe(true) + }) + + it('shows for a user with projects and no regions at all', () => { + expect( + shouldShowBanner({ incidents: [forced], hasProjects: true, userRegions: new Set() }) + ).toBe(true) + }) + + it('shows even when mixed with non-matching non-forced incidents', () => { + expect( + shouldShowBanner({ + incidents: [usEast1Only, forced], + hasProjects: false, + userRegions: new Set(), + }) + ).toBe(true) + }) + }) + describe('hasUnknownRegions', () => { it('shows when regions are unknown and incident has a region restriction', () => { expect( diff --git a/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts index ce9698d807f..490fcf63000 100644 --- a/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts +++ b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts @@ -26,6 +26,9 @@ export function shouldShowBanner({ hasUnknownRegions?: boolean }): boolean { return incidents.some((incident) => { + // Forced incidents are shown unconditionally, regardless of regions or project state + if (incident.cache?.force) return true + const affectedRegions = incident.cache?.affected_regions ?? [] const affectsProjectCreation = incident.cache?.affects_project_creation ?? false diff --git a/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts b/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts index 2a37187faf3..9afe3538d0c 100644 --- a/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts +++ b/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts @@ -1,9 +1,10 @@ -import { useQueries } from '@tanstack/react-query' -import { LOCAL_STORAGE_KEYS, useFlag } from 'common' +import { useQueries, useQuery } from '@tanstack/react-query' +import { IS_PROD, LOCAL_STORAGE_KEYS, useFlag } from 'common' import { useCallback, useMemo } from 'react' import { getRelevantIncidentIds, shouldShowBanner } from './StatusPageBanner.utils' import { useOrganizationsQuery } from '@/data/organizations/organizations-query' +import { incidentBannerQueryOptions } from '@/data/platform/incident-banner-query' import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' import { projectKeys } from '@/data/projects/keys' import { @@ -14,13 +15,24 @@ import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' export type StatusPageBannerData = { title: string; dismiss?: () => void } +// In non-production environments the incident-banner endpoint is the source of truth. +const IS_NON_PROD = !IS_PROD + export function useStatusPageBannerVisibility(): StatusPageBannerData | null { const showIncidentBannerOverride = useFlag('ongoingIncident') || process.env.NEXT_PUBLIC_ONGOING_INCIDENT === 'true' + // Both queries run in parallel on all environments. const { data: allStatusPageEvents } = useIncidentStatusQuery() - const { incidents: allIncidents = [] } = allStatusPageEvents ?? {} - const incidents = allIncidents.filter((i) => i.impact !== 'none') + const { data: incidentBannerData } = useQuery(incidentBannerQueryOptions()) + + // In non-production: derive incidents from the incident-banner endpoint. + // In production: derive incidents from the statuspage endpoint (existing behaviour). + const bannerItems = incidentBannerData?.incidents ?? [] + + const incidents = IS_NON_PROD + ? bannerItems.map((i) => ({ id: i.id, cache: i.metadata })) + : (allStatusPageEvents?.incidents ?? []).filter((i) => i.impact !== 'none') const hasActiveIncidents = incidents.length > 0 @@ -75,14 +87,9 @@ export function useStatusPageBannerVisibility(): StatusPageBannerData | null { if (!hasActiveIncidents || !isProjectsFetched) 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({ @@ -98,8 +105,5 @@ export function useStatusPageBannerVisibility(): StatusPageBannerData | null { ? 'We are investigating a technical issue' : 'Project creation may be impacted in some regions' - return { - title, - dismiss, - } + return { title, dismiss } } diff --git a/apps/studio/data/platform/incident-banner-query.ts b/apps/studio/data/platform/incident-banner-query.ts new file mode 100644 index 00000000000..f0b6a4babbb --- /dev/null +++ b/apps/studio/data/platform/incident-banner-query.ts @@ -0,0 +1,37 @@ +import { queryOptions } from '@tanstack/react-query' +import { getAccessToken, IS_PLATFORM } from 'common' + +import { platformKeys } from './keys' +import { BASE_PATH } from '@/lib/constants' + +export interface IncidentBannerItem { + id: string + show_banner: true | 'force' + metadata: { + affected_regions: Array | null + affects_project_creation: boolean + force: boolean + } +} + +export type IncidentBannerData = { incidents: Array } +export type IncidentBannerError = unknown + +async function getIncidentBanner(signal?: AbortSignal): Promise { + const accessToken = await getAccessToken() + const response = await fetch(`${BASE_PATH}/api/incident-banner`, { + signal, + method: 'GET', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` }, + }) + if (!response.ok) throw new Error(`Failed to fetch incident banner: ${response.statusText}`) + return response.json() +} + +export const incidentBannerQueryOptions = () => + queryOptions({ + queryKey: platformKeys.incidentBanner(), + queryFn: ({ signal }) => getIncidentBanner(signal), + staleTime: 1000 * 60 * 5, + enabled: IS_PLATFORM, + }) diff --git a/apps/studio/data/platform/incident-status-query.ts b/apps/studio/data/platform/incident-status-query.ts index 7c767fc1cb5..a7e1e907f79 100644 --- a/apps/studio/data/platform/incident-status-query.ts +++ b/apps/studio/data/platform/incident-status-query.ts @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query' +import { getAccessToken } from 'common' import type { IncidentInfo } from 'lib/api/incident-status' import { BASE_PATH, IS_PLATFORM, IS_TEST_ENV } from 'lib/constants' import { partition } from 'lodash' @@ -9,11 +10,13 @@ import { platformKeys } from './keys' export async function getIncidentStatus( signal?: AbortSignal ): Promise<{ maintenanceEvents: IncidentInfo[]; incidents: IncidentInfo[] }> { + const accessToken = await getAccessToken() const response = await fetch(`${BASE_PATH}/api/incident-status`, { signal, method: 'GET', headers: { 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, }, }) diff --git a/apps/studio/data/platform/keys.ts b/apps/studio/data/platform/keys.ts index 3052d7b2fdf..b6ffba3d496 100644 --- a/apps/studio/data/platform/keys.ts +++ b/apps/studio/data/platform/keys.ts @@ -1,3 +1,4 @@ export const platformKeys = { incidentStatus: () => ['platform', 'incident-status'] as const, + incidentBanner: () => ['platform', 'incident-banner'] as const, } diff --git a/apps/studio/lib/api/apiWrapper.ts b/apps/studio/lib/api/apiWrapper.ts index e98f7559bae..eb749ffc007 100644 --- a/apps/studio/lib/api/apiWrapper.ts +++ b/apps/studio/lib/api/apiWrapper.ts @@ -31,9 +31,9 @@ export default async function apiWrapper( req: NextApiRequest, res: NextApiResponse, claims?: JwtPayload - ) => Promise, + ) => Promise, options?: { withAuth: boolean } -): Promise { +): Promise { try { const { withAuth } = options || {} let claims: JwtPayload | undefined diff --git a/apps/studio/lib/api/incident-status.ts b/apps/studio/lib/api/incident-status.ts index 7b19ee53c40..f1c3192e1e9 100644 --- a/apps/studio/lib/api/incident-status.ts +++ b/apps/studio/lib/api/incident-status.ts @@ -6,6 +6,8 @@ import { InternalServerError } from 'lib/api/apiHelpers' export type IncidentCache = { affected_regions: Array | null affects_project_creation: boolean + /** When true, the banner is shown unconditionally regardless of regions or project state. */ + force?: boolean } export type IncidentMetadata = { diff --git a/apps/studio/pages/api/incident-banner.ts b/apps/studio/pages/api/incident-banner.ts new file mode 100644 index 00000000000..7dc2f22aa0c --- /dev/null +++ b/apps/studio/pages/api/incident-banner.ts @@ -0,0 +1,163 @@ +import { createHash } from 'crypto' +import { IS_PLATFORM, IS_PROD } from 'common' +import { NextApiRequest, NextApiResponse } from 'next' +import { z } from 'zod' +import apiWrapper from 'lib/api/apiWrapper' + +const INCIDENT_IO_BASE_URL = 'https://api.incident.io/v2' + +const BANNER_FIELD_ID = '01KKCFNW31EGRMD3JQ58E2TJ2M' +const METADATA_FIELD_ID = '01KKCD4KNWQ7HYSXT72CHB7WR4' + +const MINOR_SEVERITY_ID = '01J7BTA8DEF371JQSXGBZYZY7D' + +const SENTINEL_VALUE_SHOW_BANNER = '1' +const SENTINEL_VALUE_FORCE_BANNER = '100' + +const FALLBACK_METADATA = { affected_regions: null, affects_project_creation: false } + +/** + * Cache on browser for 5 minutes + * Cache on CDN for 5 minutes + * Allow serving stale content for 1 minute while revalidating + */ +const CACHE_CONTROL_SETTINGS = 'public, max-age=300, s-maxage=300, stale-while-revalidate=60' + +const MetadataSchema = z.object({ + affected_regions: z.union([z.array(z.string()), z.null()]), + affects_project_creation: z.boolean(), +}) + +interface CustomFieldValue { + value_option?: { id: string; value: string } + value_text?: string + value_numeric?: string +} + +interface CustomFieldEntry { + custom_field: { id: string } + values: Array +} + +interface Incident { + id: string + name: string + mode: string + created_at: string + custom_field_entries: Array +} + +interface IncidentIoListResponse { + incidents: Array + pagination_meta?: { after?: string } +} + +type ShowBannerValue = true | 'force' + +interface BannerIncident { + id: string + show_banner: ShowBannerValue + metadata: z.infer & { force: boolean } +} + +function getFieldValue(entries: Array, fieldId: string): string | undefined { + const entry = entries.find((e) => e.custom_field.id === fieldId) + if (!entry || entry.values.length === 0) return undefined + const val = entry.values[0] + return val.value_option?.value ?? val.value_text ?? val.value_numeric +} + +async function fetchAllIncidents(apiKey: string, mode: string): Promise> { + const incidents: Array = [] + let after: string | undefined + + do { + const params = new URLSearchParams() + params.append('status_category[one_of]', 'live') + params.append('severity[gte]', MINOR_SEVERITY_ID) + params.append('mode[one_of]', mode) + params.set('page_size', '25') + if (after) params.set('after', after) + + const response = await fetch(`${INCIDENT_IO_BASE_URL}/incidents?${params}`, { + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + const cause = await response.text() + throw new Error(`incident.io API responded with status ${response.status}`, { cause }) + } + + const data: IncidentIoListResponse = await response.json() + incidents.push(...data.incidents) + after = data.pagination_meta?.after + } while (after) + + return incidents +} + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!IS_PLATFORM) { + return res.status(404).end() + } + + if (req.method !== 'GET') { + res.setHeader('Allow', ['GET']) + return res.status(405).json({ error: { message: `Method ${req.method} Not Allowed` } }) + } + + const apiKey = process.env.INCIDENT_IO_API_KEY + if (!apiKey) { + console.error('INCIDENT_IO_API_KEY is not set') + return res.status(500).json({ error: { message: 'Internal server error' } }) + } + + const incidentMode = IS_PROD ? 'standard' : 'test' + + let allIncidents: Array + try { + allIncidents = await fetchAllIncidents(apiKey, incidentMode) + } catch (error) { + console.error('Error fetching incidents from incident.io: %O', error) + return res.status(502).json({ error: { message: 'Internal server error' } }) + } + + const bannerIncidents: Array = [] + + for (const incident of allIncidents) { + const bannerValue = getFieldValue(incident.custom_field_entries, BANNER_FIELD_ID) + if (bannerValue !== SENTINEL_VALUE_SHOW_BANNER && bannerValue !== SENTINEL_VALUE_FORCE_BANNER) { + continue + } + + const metadataRaw = getFieldValue(incident.custom_field_entries, METADATA_FIELD_ID) + + let parsedJson: unknown = null + try { + parsedJson = JSON.parse(metadataRaw ?? 'null') + } catch { + // malformed JSON — fall through to default metadata + } + const parsed = MetadataSchema.safeParse(parsedJson) + const metadata: z.infer = parsed.success + ? parsed.data + : FALLBACK_METADATA + + bannerIncidents.push({ + id: createHash('sha256').update(incident.created_at).digest('hex'), + show_banner: bannerValue === SENTINEL_VALUE_FORCE_BANNER ? 'force' : true, + metadata: { ...metadata, force: bannerValue === SENTINEL_VALUE_FORCE_BANNER }, + }) + } + + res.setHeader('Cache-Control', CACHE_CONTROL_SETTINGS) + return res.status(200).json({ incidents: bannerIncidents }) +} + +const wrapper = (req: NextApiRequest, res: NextApiResponse) => + apiWrapper(req, res, handler, { withAuth: true }) + +export default wrapper diff --git a/apps/studio/pages/api/incident-status.ts b/apps/studio/pages/api/incident-status.ts index c075bbdd38a..9c61c8c2542 100644 --- a/apps/studio/pages/api/incident-status.ts +++ b/apps/studio/pages/api/incident-status.ts @@ -8,6 +8,7 @@ import { type IncidentInfo, } from '@/lib/api/incident-status' import { createAdminClient } from '@/lib/api/supabase-admin' +import apiWrapper from 'lib/api/apiWrapper' /** * Cache on browser for 5 minutes @@ -47,11 +48,7 @@ async function fetchIncidentCache(incidentIds: Array): Promise | { error: string }> -) { +async function handler(req: NextApiRequest, res: NextApiResponse) { if (!IS_PLATFORM) { return res.status(404).end() } @@ -100,3 +97,8 @@ export default async function handler( return res.status(500).json({ error: 'Unable to fetch incidents at this time' }) } } + +const wrapper = (req: NextApiRequest, res: NextApiResponse) => + apiWrapper(req, res, handler, { withAuth: true }) + +export default wrapper diff --git a/apps/studio/proxy.ts b/apps/studio/proxy.ts index 1b804831fb8..a349ad56797 100644 --- a/apps/studio/proxy.ts +++ b/apps/studio/proxy.ts @@ -25,6 +25,7 @@ const HOSTED_SUPPORTED_API_URLS = [ '/edge-functions/body', '/generate-attachment-url', '/incident-status', + '/incident-banner', '/status-override', '/api/integrations/stripe-sync', '/content/graphql', diff --git a/turbo.json b/turbo.json index cabd3802433..7cd2fef4ecf 100644 --- a/turbo.json +++ b/turbo.json @@ -116,6 +116,7 @@ "SUPPORT_SUPABASE_SECRET_KEY", "STATUSPAGE_API_KEY", "STATUSPAGE_PAGE_ID", + "INCIDENT_IO_API_KEY", "LIVE_SUPABASE_SECRET_KEY" ], "passThroughEnv": [