diff --git a/apps/studio/components/interfaces/Support/IncidentAdmonition.tsx b/apps/studio/components/interfaces/Support/IncidentAdmonition.tsx index d9db2eb7389..52ab0dee0f6 100644 --- a/apps/studio/components/interfaces/Support/IncidentAdmonition.tsx +++ b/apps/studio/components/interfaces/Support/IncidentAdmonition.tsx @@ -4,75 +4,21 @@ import Link from 'next/link' import { Button } from 'ui' import { Admonition } from 'ui-patterns/Admonition' -import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' -import { processIncidentData } from '@/data/platform/incident-status-utils' - interface IncidentAdmonitionProps { isActive: boolean + title: string + description: string + statusPageUrl: string className?: string } -const STATUS_DESCRIPTION_SIGN_OFF = 'Follow the status page for updates.' - -const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1) - -const getStatusDescription = ( - status: string, - hasMultipleIncidents: boolean, - allSameStatus: boolean -): string => { - const isPlural = hasMultipleIncidents - const issueTerm = isPlural ? 'these issues' : 'this issue' - - switch (status) { - case 'investigating': - if (hasMultipleIncidents && !allSameStatus) { - return `We are aware of multiple ongoing issues and are investigating. ${STATUS_DESCRIPTION_SIGN_OFF}` - } - return `We are investigating ${issueTerm}. ${STATUS_DESCRIPTION_SIGN_OFF}` - - case 'identified': - if (hasMultipleIncidents && !allSameStatus) { - return `We have identified the cause of some of ${issueTerm} and are working on fixes. ${STATUS_DESCRIPTION_SIGN_OFF}` - } - return `We have identified the cause of ${issueTerm} and are working on a fix. ${STATUS_DESCRIPTION_SIGN_OFF}` - - case 'monitoring': - if (hasMultipleIncidents && !allSameStatus) { - return `Fixes have been deployed for some of ${issueTerm} and we are monitoring the results. ${STATUS_DESCRIPTION_SIGN_OFF}` - } - return `A fix has been deployed and we are monitoring the results. ${STATUS_DESCRIPTION_SIGN_OFF}` - - case 'resolved': - if (hasMultipleIncidents && !allSameStatus) { - return `Some of ${issueTerm} have been resolved, but others may still be ongoing. ${STATUS_DESCRIPTION_SIGN_OFF}` - } - return `${capitalizeFirstLetter(issueTerm)} ${isPlural ? 'have' : 'has'} been resolved but may take some time to fully recover. ${STATUS_DESCRIPTION_SIGN_OFF}` - - default: - return `We are investigating ${issueTerm}. ${STATUS_DESCRIPTION_SIGN_OFF}` - } -} - -export function IncidentAdmonition({ isActive, className }: IncidentAdmonitionProps) { - const { data: allStatusPageEvents, isLoading, isError } = useIncidentStatusQuery() - const { incidents = [] } = allStatusPageEvents ?? {} - - // Don't render anything while loading, on error, or if no incidents - if (isLoading || isError || !incidents || incidents.length === 0) { - return null - } - - const { hasMultipleIncidents, mostCriticalIncident, overallStatus, allSameStatus } = - processIncidentData(incidents) - - // Show most recent incident name + count if multiple incidents - const statusTitle = - (mostCriticalIncident?.name ?? '') + - (hasMultipleIncidents - ? ` and ${incidents.length - 1} other issue${incidents.length > 2 ? 's' : ''}` - : '') - +export function IncidentAdmonition({ + isActive, + title, + description, + statusPageUrl, + className, +}: IncidentAdmonitionProps) { return ( {isActive && ( @@ -85,11 +31,11 @@ export function IncidentAdmonition({ isActive, className }: IncidentAdmonitionPr type="warning" layout="horizontal" className={className} - title={statusTitle} - description={getStatusDescription(overallStatus, hasMultipleIncidents, allSameStatus)} + title={title} + description={description} actions={ diff --git a/apps/studio/components/interfaces/Support/SupportFormPage.tsx b/apps/studio/components/interfaces/Support/SupportFormPage.tsx index 85176cc86ca..f665841c7b6 100644 --- a/apps/studio/components/interfaces/Support/SupportFormPage.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormPage.tsx @@ -22,9 +22,10 @@ import { } from './SupportForm.state' import { NO_PROJECT_MARKER } from './SupportForm.utils' import { SupportFormV2 } from './SupportFormV2' +import { DEFAULT_STATUS_PAGE_URL, getSupportStatusLabel } from './SupportStatus.utils' import { useSupportForm } from './useSupportForm' +import { useSupportStatus } from './useSupportStatus' import CopyButton from '@/components/ui/CopyButton' -import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' import { useStateTransition } from '@/hooks/misc/useStateTransition' import { BASE_PATH, DOCS_URL } from '@/lib/constants' import { useTrack } from '@/lib/telemetry/track' @@ -61,14 +62,11 @@ function SupportFormPageContent() { const [state, dispatch] = useReducer(supportFormReducer, undefined, createInitialSupportFormState) const { form, initialError, projectRef, orgSlug } = useSupportForm(dispatch) - const { - data: allStatusPageEvents, - isPending: isIncidentsPending, - isError: isIncidentsError, - } = useIncidentStatusQuery() - const { incidents = [] } = allStatusPageEvents ?? {} - const hasActiveIncidents = - !isIncidentsPending && !isIncidentsError && incidents && incidents.length > 0 + const supportStatus = useSupportStatus() + const hasActiveIncidents = supportStatus.status === 'success' && supportStatus.hasActiveIncidents + const admonition = supportStatus.status === 'success' ? supportStatus.admonition : null + const statusPageUrl = + supportStatus.status === 'success' ? supportStatus.pageUrl : DEFAULT_STATUS_PAGE_URL const sendTelemetry = useSupportFormTelemetry() useStateTransition(state, 'submitting', 'success', (_, curr) => { @@ -94,7 +92,12 @@ function SupportFormPageContent() { - + {!isSuccess && !hasActiveIncidents && (
@@ -126,10 +129,11 @@ function SupportFormWrapper({ children }: PropsWithChildren) { } function SupportFormHeader() { - const { data: allStatusPageEvents, isPending: isLoading, isError } = useIncidentStatusQuery() - const { incidents = [], maintenanceEvents = [] } = allStatusPageEvents ?? {} - const isMaintenance = maintenanceEvents.length > 0 - const isIncident = incidents.length > 0 + const supportStatus = useSupportStatus() + const isLoading = supportStatus.status === 'pending' + const isIncident = supportStatus.status === 'success' && supportStatus.hasActiveIncidents + const statusPageUrl = + supportStatus.status === 'success' ? supportStatus.pageUrl : DEFAULT_STATUS_PAGE_URL return (
@@ -165,16 +169,8 @@ function SupportFormHeader() { ) } > - - {isLoading - ? 'Checking status' - : isError - ? 'Failed to check status' - : isIncident - ? 'Active incident ongoing' - : isMaintenance - ? 'Scheduled maintenance' - : 'All systems operational'} + + {getSupportStatusLabel(supportStatus)} diff --git a/apps/studio/components/interfaces/Support/SupportSidebarForm.tsx b/apps/studio/components/interfaces/Support/SupportSidebarForm.tsx index f73d2ce1576..3bd871bd4d0 100644 --- a/apps/studio/components/interfaces/Support/SupportSidebarForm.tsx +++ b/apps/studio/components/interfaces/Support/SupportSidebarForm.tsx @@ -13,8 +13,9 @@ import { SupportAssistantSuccessCard } from './SupportAssistantSuccessCard' import { createInitialSupportFormState, supportFormReducer } from './SupportForm.state' import { NO_PROJECT_MARKER, type SupportFormUrlKeys } from './SupportForm.utils' import { SupportFormV3 } from './SupportFormV3' +import { DEFAULT_STATUS_PAGE_URL, getSupportStatusLabel } from './SupportStatus.utils' import { useSupportForm } from './useSupportForm' -import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' +import { useSupportStatus } from './useSupportStatus' import { useStateTransition } from '@/hooks/misc/useStateTransition' import { useTrack } from '@/lib/telemetry/track' @@ -49,14 +50,10 @@ export function SupportForm({ initialParams }: SupportFormProps) { const { form, initialError, projectRef } = useSupportForm(dispatch, initialParams) const showSupportAssistantFollowUp = useFlag('supportAssistantFollowUp') === true - const { - data: allStatusPageEvents, - isPending: isIncidentsPending, - isError: isIncidentsError, - } = useIncidentStatusQuery() - const { incidents = [] } = allStatusPageEvents ?? {} - const hasActiveIncidents = - !isIncidentsPending && !isIncidentsError && incidents && incidents.length > 0 + const supportStatus = useSupportStatus() + const admonition = supportStatus.status === 'success' ? supportStatus.admonition : null + const statusPageUrl = + supportStatus.status === 'success' ? supportStatus.pageUrl : DEFAULT_STATUS_PAGE_URL const sendTelemetry = useSupportFormTelemetry() useStateTransition(state, 'submitting', 'success', (_, curr) => { @@ -86,7 +83,10 @@ export function SupportForm({ initialParams }: SupportFormProps) { return (
@@ -122,10 +122,11 @@ export function SupportForm({ initialParams }: SupportFormProps) { } export function SupportFormStatusButton() { - const { data: allStatusPageEvents, isPending: isLoading, isError } = useIncidentStatusQuery() - const { incidents = [], maintenanceEvents = [] } = allStatusPageEvents ?? {} - const isMaintenance = maintenanceEvents.length > 0 - const isIncident = incidents.length > 0 + const supportStatus = useSupportStatus() + const isLoading = supportStatus.status === 'pending' + const isIncident = supportStatus.status === 'success' && supportStatus.hasActiveIncidents + const statusPageUrl = + supportStatus.status === 'success' ? supportStatus.pageUrl : DEFAULT_STATUS_PAGE_URL return ( @@ -146,16 +147,8 @@ export function SupportFormStatusButton() { ) } > - - {isLoading - ? 'Checking status' - : isError - ? 'Failed to check status' - : isIncident - ? 'Active incident ongoing' - : isMaintenance - ? 'Scheduled maintenance' - : 'All systems operational'} + + {getSupportStatusLabel(supportStatus)} diff --git a/apps/studio/components/interfaces/Support/SupportStatus.utils.test.ts b/apps/studio/components/interfaces/Support/SupportStatus.utils.test.ts new file mode 100644 index 00000000000..acec3119713 --- /dev/null +++ b/apps/studio/components/interfaces/Support/SupportStatus.utils.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from 'vitest' + +import { + getStatusPillLabel, + getSupportAdmonitionCopy, + getSupportStatusLabel, + prioritizeActiveItems, + type SupportStatus, +} from './SupportStatus.utils' +import type { StatusItem } from '@/lib/status-page/status-page.utils' + +function incident(overrides: Partial> = {}): StatusItem { + return { + kind: 'incident', + id: 'inc-1', + name: 'Elevated errors', + url: 'https://status.supabase.com/incidents/inc-1', + lastUpdateAt: '2026-01-01T00:00:00Z', + lastUpdateMessage: null, + components: [], + scope: { type: 'global' }, + projectCreationScope: null, + isProjectCreationOnly: false, + showBanner: true, + status: 'investigating', + impact: 'full_outage', + ...overrides, + } +} + +function maintenance( + overrides: Partial> = {} +): StatusItem { + return { + kind: 'maintenance', + id: 'maint-1', + name: 'Database maintenance', + url: 'https://status.supabase.com/incidents/maint-1', + lastUpdateAt: '2026-01-01T00:00:00Z', + lastUpdateMessage: null, + components: [], + scope: { type: 'global' }, + projectCreationScope: null, + isProjectCreationOnly: false, + showBanner: true, + startedAt: '2026-01-01T00:00:00Z', + scheduledEndAt: null, + ...overrides, + } +} + +function upcomingMaintenance( + overrides: Partial> = {} +): StatusItem { + return { + kind: 'upcoming_maintenance', + id: 'upcoming-1', + name: 'Upcoming maintenance', + url: 'https://status.supabase.com/incidents/upcoming-1', + lastUpdateAt: '2026-01-01T00:00:00Z', + lastUpdateMessage: null, + components: [], + scope: { type: 'global' }, + projectCreationScope: null, + isProjectCreationOnly: false, + showBanner: true, + startsAt: '2026-02-01T00:00:00Z', + endsAt: '2026-02-01T02:00:00Z', + leadDays: 7, + ...overrides, + } +} + +describe('prioritizeActiveItems', () => { + test('ranks incidents above maintenance by impact', () => { + const items = [ + maintenance({ id: 'm', lastUpdateAt: '2026-01-01T00:00:00Z' }), + incident({ + id: 'degraded', + impact: 'degraded_performance', + lastUpdateAt: '2026-01-01T00:00:00Z', + }), + incident({ id: 'full', impact: 'full_outage', lastUpdateAt: '2026-01-01T00:00:00Z' }), + ] + + expect(prioritizeActiveItems(items).map((item) => item.id)).toEqual(['full', 'degraded', 'm']) + }) + + test('breaks ties by lastUpdateAt descending', () => { + const items = [ + incident({ id: 'older', impact: 'full_outage', lastUpdateAt: '2026-01-01T00:00:00Z' }), + incident({ id: 'newer', impact: 'full_outage', lastUpdateAt: '2026-01-02T00:00:00Z' }), + ] + + expect(prioritizeActiveItems(items).map((item) => item.id)).toEqual(['newer', 'older']) + }) + + test('ignores upcoming maintenance entirely', () => { + const items = [upcomingMaintenance(), incident()] + expect(prioritizeActiveItems(items).map((item) => item.id)).toEqual(['inc-1']) + }) + + test('includes items regardless of showBanner', () => { + const items = [incident({ showBanner: false })] + expect(prioritizeActiveItems(items)).toHaveLength(1) + }) +}) + +describe('getSupportAdmonitionCopy', () => { + test('returns null when there is nothing active', () => { + expect(getSupportAdmonitionCopy([upcomingMaintenance()])).toBeNull() + }) + + test('singular title for one active item', () => { + const copy = getSupportAdmonitionCopy([incident({ name: 'Elevated errors' })]) + expect(copy?.title).toBe('Elevated errors') + }) + + test('plural title for two active items', () => { + const copy = getSupportAdmonitionCopy([ + incident({ id: 'a', name: 'Elevated errors', impact: 'full_outage' }), + incident({ id: 'b', name: 'Slow queries', impact: 'degraded_performance' }), + ]) + expect(copy?.title).toBe('Elevated errors and 1 other issue') + }) + + test('plural title uses plural "issues" for three or more', () => { + const copy = getSupportAdmonitionCopy([ + incident({ id: 'a', impact: 'full_outage' }), + incident({ id: 'b', impact: 'degraded_performance' }), + incident({ id: 'c', impact: 'degraded_performance' }), + ]) + expect(copy?.title).toBe('Elevated errors and 2 other issues') + }) + + test('describes an in-progress maintenance', () => { + const copy = getSupportAdmonitionCopy([maintenance()]) + expect(copy?.description).toContain('Scheduled maintenance is in progress') + }) +}) + +describe('getStatusPillLabel', () => { + test('all systems operational when nothing is active', () => { + expect(getStatusPillLabel([upcomingMaintenance()])).toBe('All systems operational') + }) + + test('active incident ongoing when any incident is active', () => { + expect(getStatusPillLabel([incident(), maintenance()])).toBe('Active incident ongoing') + }) + + test('scheduled maintenance when only maintenance is active', () => { + expect(getStatusPillLabel([maintenance()])).toBe('Scheduled maintenance') + }) +}) + +describe('getSupportStatusLabel', () => { + test('pending', () => { + expect(getSupportStatusLabel({ status: 'pending' })).toBe('Checking status') + }) + + test('error', () => { + expect(getSupportStatusLabel({ status: 'error' })).toBe('Failed to check status') + }) + + test('success uses the computed pill label', () => { + const status: SupportStatus = { + status: 'success', + hasActiveIncidents: true, + pillLabel: 'Active incident ongoing', + admonition: null, + pageUrl: 'https://status.supabase.com/', + } + expect(getSupportStatusLabel(status)).toBe('Active incident ongoing') + }) +}) diff --git a/apps/studio/components/interfaces/Support/SupportStatus.utils.ts b/apps/studio/components/interfaces/Support/SupportStatus.utils.ts new file mode 100644 index 00000000000..74a050350c5 --- /dev/null +++ b/apps/studio/components/interfaces/Support/SupportStatus.utils.ts @@ -0,0 +1,129 @@ +import { IMPACT_RANK, type StatusItem } from '@/lib/status-page/status-page.utils' + +export const DEFAULT_STATUS_PAGE_URL = 'https://status.supabase.com/' + +const STATUS_DESCRIPTION_SIGN_OFF = 'Follow the status page for updates.' + +function capitalizeFirstLetter(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1) +} + +/** + * Describes the current state of one or more active issues. `status` covers incident statuses + * plus the synthetic `maintenance_in_progress` status used for in-progress maintenance. + */ +export function getStatusDescription({ + status, + hasMultipleIncidents, + allSameStatus, +}: { + status: string + hasMultipleIncidents: boolean + allSameStatus: boolean +}): string { + const issueTerm = hasMultipleIncidents ? 'these issues' : 'this issue' + + switch (status) { + case 'investigating': + if (hasMultipleIncidents && !allSameStatus) { + return `We are aware of multiple ongoing issues and are investigating. ${STATUS_DESCRIPTION_SIGN_OFF}` + } + return `We are investigating ${issueTerm}. ${STATUS_DESCRIPTION_SIGN_OFF}` + + case 'identified': + if (hasMultipleIncidents && !allSameStatus) { + return `We have identified the cause of some of ${issueTerm} and are working on fixes. ${STATUS_DESCRIPTION_SIGN_OFF}` + } + return `We have identified the cause of ${issueTerm} and are working on a fix. ${STATUS_DESCRIPTION_SIGN_OFF}` + + case 'monitoring': + if (hasMultipleIncidents && !allSameStatus) { + return `Fixes have been deployed for some of ${issueTerm} and we are monitoring the results. ${STATUS_DESCRIPTION_SIGN_OFF}` + } + return `A fix has been deployed and we are monitoring the results. ${STATUS_DESCRIPTION_SIGN_OFF}` + + case 'resolved': + if (hasMultipleIncidents && !allSameStatus) { + return `Some of ${issueTerm} have been resolved, but others may still be ongoing. ${STATUS_DESCRIPTION_SIGN_OFF}` + } + return `${capitalizeFirstLetter(issueTerm)} ${hasMultipleIncidents ? 'have' : 'has'} been resolved but may take some time to fully recover. ${STATUS_DESCRIPTION_SIGN_OFF}` + + case 'maintenance_in_progress': + if (hasMultipleIncidents && !allSameStatus) { + return `Scheduled maintenance is in progress alongside other issues. ${STATUS_DESCRIPTION_SIGN_OFF}` + } + return `Scheduled maintenance is in progress. ${STATUS_DESCRIPTION_SIGN_OFF}` + + default: + return `We are investigating ${issueTerm}. ${STATUS_DESCRIPTION_SIGN_OFF}` + } +} + +export type ActiveStatusItem = Extract + +function getItemStatusLabel(item: ActiveStatusItem): string { + return item.kind === 'incident' ? item.status : 'maintenance_in_progress' +} + +/** + * Incidents and in-progress maintenance only (never upcoming maintenance), ranked by impact + * (maintenance ranks below every incident impact), then by recency. Ignores `showBanner`: + * "Hide banner" only hides the global banner, not the support form's status pill/admonition. + */ +export function prioritizeActiveItems(items: Array): Array { + const active = items.filter( + (item): item is ActiveStatusItem => item.kind === 'incident' || item.kind === 'maintenance' + ) + + return active.sort((a, b) => { + const rankA = a.kind === 'incident' ? IMPACT_RANK[a.impact] : 0 + const rankB = b.kind === 'incident' ? IMPACT_RANK[b.impact] : 0 + if (rankB !== rankA) return rankB - rankA + return new Date(b.lastUpdateAt).getTime() - new Date(a.lastUpdateAt).getTime() + }) +} + +export function getSupportAdmonitionCopy( + items: Array +): { title: string; description: string } | null { + const active = prioritizeActiveItems(items) + if (active.length === 0) return null + + const [top, ...rest] = active + const hasMultipleIncidents = rest.length > 0 + const title = hasMultipleIncidents + ? `${top.name} and ${rest.length} other issue${rest.length > 1 ? 's' : ''}` + : top.name + + const topStatus = getItemStatusLabel(top) + const allSameStatus = active.every((item) => getItemStatusLabel(item) === topStatus) + + return { + title, + description: getStatusDescription({ status: topStatus, hasMultipleIncidents, allSameStatus }), + } +} + +export function getStatusPillLabel(items: Array): string { + const active = prioritizeActiveItems(items) + if (active.length === 0) return 'All systems operational' + if (active.some((item) => item.kind === 'incident')) return 'Active incident ongoing' + return 'Scheduled maintenance' +} + +export type SupportStatus = + | { status: 'pending' } + | { status: 'error' } + | { + status: 'success' + hasActiveIncidents: boolean + pillLabel: string + admonition: { title: string; description: string } | null + pageUrl: string + } + +export function getSupportStatusLabel(status: SupportStatus): string { + if (status.status === 'pending') return 'Checking status' + if (status.status === 'error') return 'Failed to check status' + return status.pillLabel +} diff --git a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx index 96d8145ab49..ed5626ec530 100644 --- a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx +++ b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx @@ -11,6 +11,7 @@ import { SupportForm, SupportFormPage, SupportFormStatusButton } from '../Suppor // End of third-party imports import { API_URL, BASE_PATH } from '@/lib/constants' +import type { StatusPageResponse } from '@/lib/status-page/status-page.schema' import { createMockOrganizationResponse, createMockProject } from '@/tests/helpers' import { customRender } from '@/tests/lib/custom-render' import { addAPIMock, mswServer, type APIErrorBody } from '@/tests/lib/msw' @@ -142,6 +143,10 @@ const { mockCommitSha, mockCommitTime, mockUseDeploymentCommitQuery } = vi.hoist const mockStudioVersion = `SHA ${mockCommitSha} deployed at ${dayjs(mockCommitTime).format('YYYY-MM-DD HH:mm:ss Z')}` +const { mockUseFlag } = vi.hoisted(() => ({ + mockUseFlag: vi.fn().mockReturnValue(false), +})) + vi.mock('react-inlinesvg', () => ({ __esModule: true, default: () => null, @@ -190,6 +195,7 @@ vi.mock(import('common'), async (importOriginal) => { ...actual, useParams: vi.fn().mockReturnValue({ ref: 'default' }), useIsLoggedIn: vi.fn().mockReturnValue(true), + useFlag: mockUseFlag, isFeatureEnabled: vi.fn((feature: any, disabledFeatures: any) => { if (typeof feature === 'string') { if (feature === 'support:show_client_libraries') { @@ -392,6 +398,7 @@ describe('SupportFormPage', () => { }) beforeEach(async () => { + mockUseFlag.mockReset().mockReturnValue(false) mockUseDeploymentCommitQuery.mockReturnValue({ data: { commitSha: mockCommitSha, commitTime: mockCommitTime }, }) @@ -590,6 +597,48 @@ describe('SupportFormPage', () => { }) }) + test('flag on: shows an active incident from the new status page endpoint', async () => { + mockUseFlag.mockImplementation((name: string) => name === 'incidentIoStatusPage') + + mswServer.use( + http.get(`${BASE_PATH}/api/status-page`, () => + HttpResponse.json({ + page_title: 'Supabase status', + page_url: 'https://status.supabase.com/', + ongoing_incidents: [ + { + id: 'inc-1', + name: 'Elevated errors', + url: 'https://status.supabase.com/incidents/inc-1', + last_update_at: '2026-01-01T00:00:00Z', + last_update_message: null, + affected_components: [], + status: 'investigating', + current_worst_impact: 'full_outage', + visible: true, + show_banner: true, + }, + ], + in_progress_maintenances: [], + scheduled_maintenances: [], + }) + ) + ) + + renderSupportFormPage() + + await waitFor(() => { + expect(getStatusLink(screen)).toHaveTextContent('Active incident ongoing') + }) + + expect(await screen.findByText('Elevated errors')).toBeInTheDocument() + expect( + screen.getByText('We are investigating this issue. Follow the status page for updates.') + ).toBeInTheDocument() + + expect(screen.queryByText('Try Supabase Assistant')).not.toBeInTheDocument() + }) + test('loading with initial params prefills the organization and project', async () => { renderSupportForm({ initialParams: { projectRef: 'project-3' } }) diff --git a/apps/studio/components/interfaces/Support/useSupportStatus.ts b/apps/studio/components/interfaces/Support/useSupportStatus.ts new file mode 100644 index 00000000000..43d18b7ad6b --- /dev/null +++ b/apps/studio/components/interfaces/Support/useSupportStatus.ts @@ -0,0 +1,82 @@ +import { useQuery } from '@tanstack/react-query' +import { useFlag } from 'common' + +import { + DEFAULT_STATUS_PAGE_URL, + getStatusDescription, + getStatusPillLabel, + getSupportAdmonitionCopy, + prioritizeActiveItems, + type SupportStatus, +} from './SupportStatus.utils' +import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' +import { processIncidentData } from '@/data/platform/incident-status-utils' +import { statusPageQueryOptions } from '@/data/platform/status-page-query' +import { IS_PLATFORM } from '@/lib/constants' +import { normalizeStatusPage } from '@/lib/status-page/status-page.utils' + +function getLegacyPillLabel(hasActiveIncidents: boolean, isMaintenance: boolean): string { + if (hasActiveIncidents) return 'Active incident ongoing' + if (isMaintenance) return 'Scheduled maintenance' + return 'All systems operational' +} + +export function useSupportStatus(): SupportStatus { + const useNewStatusPage = useFlag('incidentIoStatusPage') === true + + const newStatusQuery = useQuery({ + ...statusPageQueryOptions(), + select: normalizeStatusPage, + enabled: useNewStatusPage && IS_PLATFORM, + }) + + const legacyQuery = useIncidentStatusQuery({ enabled: !useNewStatusPage }) + + if (useNewStatusPage) { + if (newStatusQuery.isPending) return { status: 'pending' } + if (newStatusQuery.isError) return { status: 'error' } + + const { pageUrl, items } = newStatusQuery.data + const activeItems = prioritizeActiveItems(items) + + return { + status: 'success', + hasActiveIncidents: activeItems.some((item) => item.kind === 'incident'), + pillLabel: getStatusPillLabel(items), + admonition: getSupportAdmonitionCopy(items), + pageUrl, + } + } + + if (legacyQuery.isPending) return { status: 'pending' } + if (legacyQuery.isError) return { status: 'error' } + + const { incidents = [], maintenanceEvents = [] } = legacyQuery.data ?? {} + const hasActiveIncidents = incidents.length > 0 + const isMaintenance = maintenanceEvents.length > 0 + const { hasMultipleIncidents, mostCriticalIncident, overallStatus, allSameStatus } = + processIncidentData(incidents) + + const admonitionTitle = + (mostCriticalIncident?.name ?? '') + + (hasMultipleIncidents + ? ` and ${incidents.length - 1} other issue${incidents.length > 2 ? 's' : ''}` + : '') + + return { + status: 'success', + hasActiveIncidents, + pillLabel: getLegacyPillLabel(hasActiveIncidents, isMaintenance), + admonition: hasActiveIncidents + ? { + title: admonitionTitle, + description: getStatusDescription({ + status: overallStatus, + hasMultipleIncidents, + allSameStatus, + }), + } + : null, + pageUrl: DEFAULT_STATUS_PAGE_URL, + } +} diff --git a/apps/studio/data/platform/keys.ts b/apps/studio/data/platform/keys.ts index b6ffba3d496..1c5356b497f 100644 --- a/apps/studio/data/platform/keys.ts +++ b/apps/studio/data/platform/keys.ts @@ -1,4 +1,5 @@ export const platformKeys = { incidentStatus: () => ['platform', 'incident-status'] as const, incidentBanner: () => ['platform', 'incident-banner'] as const, + statusPage: () => ['platform', 'status-page'] as const, } diff --git a/apps/studio/data/platform/status-page-query.ts b/apps/studio/data/platform/status-page-query.ts new file mode 100644 index 00000000000..fbbf1425b74 --- /dev/null +++ b/apps/studio/data/platform/status-page-query.ts @@ -0,0 +1,58 @@ +import { queryOptions } from '@tanstack/react-query' + +import { platformKeys } from './keys' +import { BASE_PATH, IS_PLATFORM } from '@/lib/constants' +import { + StatusPageResponseSchema, + type StatusPageResponse, +} from '@/lib/status-page/status-page.schema' +import { ResponseError } from '@/types' + +async function getStatusPage(signal?: AbortSignal): Promise { + const response = await fetch(`${BASE_PATH}/api/status-page`, { + signal, + method: 'GET', + credentials: 'omit', + }) + + if (!response.ok) { + const errorText = await response.text() + console.error('[getStatusPage] Failed:', response.status, errorText) + + let retryAfter: number | undefined + const retryAfterHeader = response.headers.get('Retry-After') + if (retryAfterHeader !== null) { + const parsed = Number(retryAfterHeader) + if (Number.isFinite(parsed) && parsed > 0) retryAfter = parsed + } + + throw new ResponseError( + `Failed to fetch status page: ${response.statusText}`, + response.status, + undefined, + retryAfter + ) + } + + const json = await response.json() + return StatusPageResponseSchema.parse(json) +} + +export type StatusPageData = Awaited> +export type StatusPageError = ResponseError + +export const statusPageQueryOptions = () => + queryOptions({ + queryKey: platformKeys.statusPage(), + queryFn: ({ signal }) => getStatusPage(signal), + staleTime: 60_000, + refetchInterval: 5 * 60_000, + refetchOnWindowFocus: false, + retryDelay: (attemptIndex: number, error: StatusPageError) => { + if (error instanceof ResponseError && error.retryAfter) { + return error.retryAfter * 1000 + } + return Math.min(1000 * 4 ** attemptIndex, 1000 * 60 * 5) + }, + enabled: IS_PLATFORM, + }) diff --git a/apps/studio/lib/status-page/status-page.schema.ts b/apps/studio/lib/status-page/status-page.schema.ts index f94deb55c42..a6653a352ce 100644 --- a/apps/studio/lib/status-page/status-page.schema.ts +++ b/apps/studio/lib/status-page/status-page.schema.ts @@ -6,7 +6,7 @@ const ComponentStatus = z.enum([ 'partial_outage', 'full_outage', ]) -const WorstImpact = z.enum(['degraded_performance', 'partial_outage', 'full_outage']) +export const WorstImpactSchema = z.enum(['degraded_performance', 'partial_outage', 'full_outage']) export const AffectedComponentSchema = z.object({ id: z.string(), @@ -27,7 +27,7 @@ const Base = { export const WidgetIncidentSchema = z.object({ ...Base, status: z.enum(['investigating', 'identified', 'monitoring']).catch('investigating'), - current_worst_impact: WorstImpact.catch('full_outage'), + current_worst_impact: WorstImpactSchema.catch('full_outage'), }) export const WidgetInProgressMaintenanceSchema = z.object({ @@ -94,3 +94,4 @@ export const StatusPageResponseSchema = WidgetResponseSchema.extend({ export type WidgetResponse = z.infer export type StatusPageResponse = z.infer export type AffectedComponent = z.infer +export type WorstImpact = z.infer diff --git a/apps/studio/lib/status-page/status-page.utils.test.ts b/apps/studio/lib/status-page/status-page.utils.test.ts new file mode 100644 index 00000000000..cc356e2ffc7 --- /dev/null +++ b/apps/studio/lib/status-page/status-page.utils.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, test } from 'vitest' + +import type { AffectedComponent, StatusPageResponse } from './status-page.schema' +import { + DEFAULT_UPCOMING_LEAD_DAYS, + getRegionScope, + isProjectCreationOnly, + isRegionCode, + isRelevantToUser, + normalizeStatusPage, + type UserRegionContext, +} from './status-page.utils' + +function component( + name: string, + groupName?: string | null, + status: AffectedComponent['current_status'] = 'degraded_performance' +): AffectedComponent { + return { id: name, name, group_name: groupName ?? undefined, current_status: status } +} + +function emptyStatusPage(): StatusPageResponse { + return { + page_title: 'Supabase status', + page_url: 'https://status.supabase.com/', + ongoing_incidents: [], + in_progress_maintenances: [], + scheduled_maintenances: [], + } +} + +describe('isRegionCode', () => { + test('accepts valid region codes', () => { + expect(isRegionCode('us-east-1')).toBe(true) + expect(isRegionCode('ap-southeast-2')).toBe(true) + expect(isRegionCode('eu-central-1')).toBe(true) + }) + + test('is case-insensitive and trims whitespace', () => { + expect(isRegionCode('US-EAST-1')).toBe(true) + expect(isRegionCode(' us-east-1 ')).toBe(true) + }) + + test('rejects non-region names', () => { + expect(isRegionCode('Dashboard')).toBe(false) + expect(isRegionCode('All regions')).toBe(false) + expect(isRegionCode('')).toBe(false) + expect(isRegionCode('us1')).toBe(false) + }) +}) + +describe('getRegionScope', () => { + test('returns regions when every component is a region code', () => { + expect(getRegionScope([component('us-east-1'), component('ap-southeast-2')])).toEqual({ + type: 'regions', + regions: ['us-east-1', 'ap-southeast-2'], + }) + }) + + test('returns global for a mixed set of components', () => { + expect(getRegionScope([component('us-east-1'), component('Dashboard')])).toEqual({ + type: 'global', + }) + }) + + test('returns global for an empty component list', () => { + expect(getRegionScope([])).toEqual({ type: 'global' }) + }) +}) + +describe('isProjectCreationOnly', () => { + test('true when every component belongs to Project Creation', () => { + expect( + isProjectCreationOnly([ + component('us-east-1', 'Project Creation'), + component('ap-southeast-2', 'project creation'), + ]) + ).toBe(true) + }) + + test('false when any component is outside Project Creation', () => { + expect( + isProjectCreationOnly([ + component('us-east-1', 'Project Creation'), + component('us-east-1', 'Database'), + ]) + ).toBe(false) + }) + + test('false for an empty component list', () => { + expect(isProjectCreationOnly([])).toBe(false) + }) +}) + +describe('normalizeStatusPage: split scopes', () => { + test('Project Creation plus a product component in the same region split into two scopes', () => { + const page: StatusPageResponse = { + ...emptyStatusPage(), + ongoing_incidents: [ + { + id: 'inc-1', + name: 'Elevated errors', + url: 'https://status.supabase.com/incidents/inc-1', + last_update_at: '2026-01-01T00:00:00Z', + last_update_message: null, + affected_components: [ + component('us-east-1', 'Project Creation'), + component('us-east-1', 'Database'), + ], + status: 'investigating', + current_worst_impact: 'degraded_performance', + visible: true, + show_banner: true, + }, + ], + } + + const { items } = normalizeStatusPage(page) + expect(items).toHaveLength(1) + expect(items[0].scope).toEqual({ type: 'regions', regions: ['us-east-1'] }) + expect(items[0].projectCreationScope).toEqual({ type: 'regions', regions: ['us-east-1'] }) + expect(items[0].isProjectCreationOnly).toBe(false) + }) + + test('Project Creation with a non-region component name gives a global creation scope', () => { + const page: StatusPageResponse = { + ...emptyStatusPage(), + ongoing_incidents: [ + { + id: 'inc-2', + name: 'Project creation delayed', + url: 'https://status.supabase.com/incidents/inc-2', + last_update_at: '2026-01-01T00:00:00Z', + last_update_message: null, + affected_components: [component('All regions', 'Project Creation')], + status: 'investigating', + current_worst_impact: 'degraded_performance', + visible: true, + show_banner: true, + }, + ], + } + + const { items } = normalizeStatusPage(page) + expect(items[0].isProjectCreationOnly).toBe(true) + expect(items[0].projectCreationScope).toEqual({ type: 'global' }) + expect(items[0].scope).toEqual({ type: 'global' }) + }) +}) + +describe('normalizeStatusPage', () => { + test('drops invisible items', () => { + const page: StatusPageResponse = { + ...emptyStatusPage(), + ongoing_incidents: [ + { + id: 'inc-hidden', + name: 'Hidden incident', + url: 'https://status.supabase.com/incidents/inc-hidden', + last_update_at: '2026-01-01T00:00:00Z', + last_update_message: null, + affected_components: [], + status: 'investigating', + current_worst_impact: 'full_outage', + visible: false, + show_banner: true, + }, + ], + } + + expect(normalizeStatusPage(page).items).toHaveLength(0) + }) + + test('carries showBanner through unchanged', () => { + const page: StatusPageResponse = { + ...emptyStatusPage(), + ongoing_incidents: [ + { + id: 'inc-quiet', + name: 'Quiet incident', + url: 'https://status.supabase.com/incidents/inc-quiet', + last_update_at: '2026-01-01T00:00:00Z', + last_update_message: null, + affected_components: [], + status: 'investigating', + current_worst_impact: 'full_outage', + visible: true, + show_banner: false, + }, + ], + } + + expect(normalizeStatusPage(page).items[0].showBanner).toBe(false) + }) + + test('resolves leadDays from banner_lead_days, falling back to the default', () => { + const page: StatusPageResponse = { + ...emptyStatusPage(), + scheduled_maintenances: [ + { + id: 'maint-1', + name: 'Custom lead time', + url: 'https://status.supabase.com/incidents/maint-1', + last_update_at: '2026-01-01T00:00:00Z', + last_update_message: null, + affected_components: [], + status: 'maintenance_scheduled', + starts_at: '2026-02-01T00:00:00Z', + ends_at: '2026-02-01T02:00:00Z', + visible: true, + show_banner: true, + banner_lead_days: 3, + }, + { + id: 'maint-2', + name: 'Default lead time', + url: 'https://status.supabase.com/incidents/maint-2', + last_update_at: '2026-01-01T00:00:00Z', + last_update_message: null, + affected_components: [], + status: 'maintenance_scheduled', + starts_at: '2026-02-01T00:00:00Z', + ends_at: '2026-02-01T02:00:00Z', + visible: true, + show_banner: true, + banner_lead_days: null, + }, + ], + } + + const { items } = normalizeStatusPage(page) + const customLead = items.find((item) => item.id === 'maint-1') + const defaultLead = items.find((item) => item.id === 'maint-2') + expect(customLead?.kind === 'upcoming_maintenance' && customLead.leadDays).toBe(3) + expect(defaultLead?.kind === 'upcoming_maintenance' && defaultLead.leadDays).toBe( + DEFAULT_UPCOMING_LEAD_DAYS + ) + }) +}) + +describe('isRelevantToUser', () => { + const regionalItem = normalizeStatusPage({ + ...emptyStatusPage(), + ongoing_incidents: [ + { + id: 'inc-region', + name: 'Regional incident', + url: 'https://status.supabase.com/incidents/inc-region', + last_update_at: '2026-01-01T00:00:00Z', + last_update_message: null, + affected_components: [component('us-east-1', 'Database')], + status: 'investigating', + current_worst_impact: 'full_outage', + visible: true, + show_banner: true, + }, + ], + }).items[0] + + const globalItem = normalizeStatusPage({ + ...emptyStatusPage(), + ongoing_incidents: [ + { + id: 'inc-global', + name: 'Global incident', + url: 'https://status.supabase.com/incidents/inc-global', + last_update_at: '2026-01-01T00:00:00Z', + last_update_message: null, + affected_components: [component('Dashboard', 'Dashboard')], + status: 'investigating', + current_worst_impact: 'full_outage', + visible: true, + show_banner: true, + }, + ], + }).items[0] + + test('false when the user has no projects', () => { + const user: UserRegionContext = { + hasProjects: false, + regions: new Set(['us-east-1']), + isComplete: true, + } + expect(isRelevantToUser(regionalItem, user)).toBe(false) + expect(isRelevantToUser(globalItem, user)).toBe(false) + }) + + test('global scope is always relevant when the user has projects', () => { + const user: UserRegionContext = { hasProjects: true, regions: new Set(), isComplete: true } + expect(isRelevantToUser(globalItem, user)).toBe(true) + }) + + test('incomplete region data fails open for regional items', () => { + const user: UserRegionContext = { hasProjects: true, regions: new Set(), isComplete: false } + expect(isRelevantToUser(regionalItem, user)).toBe(true) + }) + + test('intersecting regions are relevant', () => { + const user: UserRegionContext = { + hasProjects: true, + regions: new Set(['us-east-1']), + isComplete: true, + } + expect(isRelevantToUser(regionalItem, user)).toBe(true) + }) + + test('disjoint regions are not relevant', () => { + const user: UserRegionContext = { + hasProjects: true, + regions: new Set(['ap-southeast-2']), + isComplete: true, + } + expect(isRelevantToUser(regionalItem, user)).toBe(false) + }) + + test('region comparison is case-insensitive', () => { + const user: UserRegionContext = { + hasProjects: true, + regions: new Set(['US-EAST-1']), + isComplete: true, + } + expect(isRelevantToUser(regionalItem, user)).toBe(true) + }) +}) diff --git a/apps/studio/lib/status-page/status-page.utils.ts b/apps/studio/lib/status-page/status-page.utils.ts new file mode 100644 index 00000000000..a079909c5b1 --- /dev/null +++ b/apps/studio/lib/status-page/status-page.utils.ts @@ -0,0 +1,166 @@ +import type { AffectedComponent, StatusPageResponse, WorstImpact } from './status-page.schema' + +export const PROJECT_CREATION_GROUP_NAME = 'project creation' + +// Pattern, not a hardcoded region list, so new or test regions (e.g. a new AWS region, or a +// staging-only region) are recognized without a code change. +const REGION_CODE_PATTERN = /^[a-z]{2}(-[a-z]+)+-\d+$/ + +export const IMPACT_RANK: Record = { + full_outage: 3, + partial_outage: 2, + degraded_performance: 1, +} + +export const DEFAULT_UPCOMING_LEAD_DAYS = 7 + +function normalizeLabel(value: string): string { + return value.trim().toLowerCase() +} + +export function normalizeRegion(region: string): string { + return normalizeLabel(region) +} + +export function isRegionCode(name: string): boolean { + return REGION_CODE_PATTERN.test(normalizeRegion(name)) +} + +export function isProjectCreationComponent(component: AffectedComponent): boolean { + return normalizeLabel(component.group_name ?? '') === PROJECT_CREATION_GROUP_NAME +} + +export type RegionScope = { type: 'global' } | { type: 'regions'; regions: ReadonlyArray } + +export function getRegionScope(components: ReadonlyArray): RegionScope { + if (components.length > 0 && components.every((component) => isRegionCode(component.name))) { + return { + type: 'regions', + regions: components.map((component) => normalizeRegion(component.name)), + } + } + return { type: 'global' } +} + +export function isProjectCreationOnly(components: ReadonlyArray): boolean { + return components.length > 0 && components.every(isProjectCreationComponent) +} + +type StatusItemBase = { + id: string + name: string + url: string + lastUpdateAt: string + lastUpdateMessage: string | null + components: ReadonlyArray + /** Scope derived from the components that aren't Project Creation. */ + scope: RegionScope + /** Scope derived from Project Creation components; null when none affect creation. */ + projectCreationScope: RegionScope | null + isProjectCreationOnly: boolean + showBanner: boolean +} + +export type StatusItem = + | (StatusItemBase & { + kind: 'incident' + status: 'investigating' | 'identified' | 'monitoring' + impact: WorstImpact + }) + | (StatusItemBase & { kind: 'maintenance'; startedAt: string; scheduledEndAt: string | null }) + | (StatusItemBase & { + kind: 'upcoming_maintenance' + startsAt: string | null + endsAt: string | null + leadDays: number + }) + +export type NormalizedStatusPage = { pageUrl: string; items: Array } + +type WidgetItemCommon = { + id: string + name: string + url: string + last_update_at: string + last_update_message?: string | null + affected_components: Array + visible: boolean + show_banner: boolean +} + +function buildBase(item: WidgetItemCommon): StatusItemBase { + const nonCreationComponents = item.affected_components.filter( + (component) => !isProjectCreationComponent(component) + ) + const creationComponents = item.affected_components.filter(isProjectCreationComponent) + + return { + id: item.id, + name: item.name, + url: item.url, + lastUpdateAt: item.last_update_at, + lastUpdateMessage: item.last_update_message ?? null, + components: item.affected_components, + scope: getRegionScope(nonCreationComponents), + projectCreationScope: creationComponents.length > 0 ? getRegionScope(creationComponents) : null, + isProjectCreationOnly: isProjectCreationOnly(item.affected_components), + showBanner: item.show_banner, + } +} + +export function normalizeStatusPage(res: StatusPageResponse): NormalizedStatusPage { + const items: Array = [ + ...res.ongoing_incidents + .filter((incident) => incident.visible) + .map( + (incident): StatusItem => ({ + ...buildBase(incident), + kind: 'incident', + status: incident.status, + impact: incident.current_worst_impact, + }) + ), + ...res.in_progress_maintenances + .filter((maintenance) => maintenance.visible) + .map( + (maintenance): StatusItem => ({ + ...buildBase(maintenance), + kind: 'maintenance', + startedAt: maintenance.started_at, + scheduledEndAt: maintenance.scheduled_end_at ?? null, + }) + ), + ...res.scheduled_maintenances + .filter((maintenance) => maintenance.visible) + .map( + (maintenance): StatusItem => ({ + ...buildBase(maintenance), + kind: 'upcoming_maintenance', + startsAt: maintenance.starts_at ?? null, + endsAt: maintenance.ends_at ?? null, + leadDays: maintenance.banner_lead_days ?? DEFAULT_UPCOMING_LEAD_DAYS, + }) + ), + ] + + return { pageUrl: res.page_url, items } +} + +export function getDismissalKey(item: Pick): string { + return `${item.kind}:${item.id}` +} + +export type UserRegionContext = { + hasProjects: boolean + regions: ReadonlySet + isComplete: boolean +} + +export function isRelevantToUser(item: StatusItem, user: UserRegionContext): boolean { + if (!user.hasProjects) return false + if (item.scope.type === 'global') return true + if (!user.isComplete) return true + + const userRegions = new Set(Array.from(user.regions, normalizeRegion)) + return item.scope.regions.some((region) => userRegions.has(normalizeRegion(region))) +}