From befc817f9439d14fbb479e097fc3a9ce19489a52 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:55:53 -0400 Subject: [PATCH] feat: version who-knows-what of incident banner (#43726) Feature ## What is the current behavior? Incident banner logic depends on StatusPage and Supabase project for metadata. ## What is the new behavior? New incident banner logic that depends only on incident.io. Displays in non-production environments for now because I haven't wired up the rest of the workflow. This is just to allow a total end-to-end testing/playground for test incidents <-> Slack <-> preview dashboard for people to try out the UX. ## Additional context You can test using my [test incident](https://app.incident.io/supabase/incidents/405). This has severity minor, so the preview site should have a banner. Toggle to informative, hard refresh dashboard with cache off, and banner should disappear. Toggle back to minor, hard refresh without cache again, and banner should reappear. Same thing if you edit the "Banner shown" field from 1 to -1 and back. --- .../AppLayout/StatusPageBanner.utils.test.ts | 38 ++++ .../AppLayout/StatusPageBanner.utils.ts | 3 + .../useStatusPageBannerVisibility.ts | 30 ++-- .../data/platform/incident-banner-query.ts | 37 ++++ .../data/platform/incident-status-query.ts | 3 + apps/studio/data/platform/keys.ts | 1 + apps/studio/lib/api/apiWrapper.ts | 4 +- apps/studio/lib/api/incident-status.ts | 2 + apps/studio/pages/api/incident-banner.ts | 163 ++++++++++++++++++ apps/studio/pages/api/incident-status.ts | 12 +- apps/studio/proxy.ts | 1 + turbo.json | 1 + 12 files changed, 275 insertions(+), 20 deletions(-) create mode 100644 apps/studio/data/platform/incident-banner-query.ts create mode 100644 apps/studio/pages/api/incident-banner.ts 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": [