mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
feat(studio): status page client data layer + support form (#50984)
## Summary * Adds the client query layer for `/api/status-page` (`statusPageQueryOptions` in `data/platform/status-page-query.ts`) and a shared normalization module (`lib/status-page/status-page.utils.ts`) that maps the endpoint response into region- and project-creation-aware `StatusItem`s — later PRs in this stack (assistant tool, project-creation admonition, global banner) build on this same module. * Wires the support form's status pill and incident admonition to the new data behind the `incidentIoStatusPage` ConfigCat flag. `useSupportStatus` branches between the new endpoint and the existing `useIncidentStatusQuery`/`processIncidentData` path, both mapped to the same `SupportStatus` shape. * `IncidentAdmonition` becomes purely presentational; the status link now points at the `pageUrl` returned by the endpoint instead of a hardcoded URL. Part of [FE-4057](https://linear.app/supabase/issue/FE-4057/frontend-bannerbot-reconfigured) — see Linear for full design context. ## Test plan - [X] `pnpm --filter studio run typecheck` - [X] `pnpm --filter studio run lint:ratchet` - [X] `pnpm knip --workspace apps/studio` - [X] `pnpm test:prettier` - [X] `pnpm --filter studio exec vitest run` (touched files) — 70 tests passing, including a flag-on case for `SupportFormPage` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Support pages now display current incident and maintenance information, including relevant status descriptions and links to the status page. * Status details can reflect items affecting a user’s region or project-creation services. Upcoming maintenance is excluded from active alerts. * **Bug Fixes** * Status labels and alerts now account for loading, errors, incidents, and maintenance consistently across support pages. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
9aae037dff
commit
8b8569bd1a
12 files changed
+1037
-117
No files matched your search
@@ -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 (
|
||||
<AnimatePresence>
|
||||
{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={
|
||||
<Button asChild icon={<ExternalLink strokeWidth={1.5} />}>
|
||||
<Link href="https://status.supabase.com/" target="_blank" rel="noreferrer">
|
||||
<Link href={statusPageUrl} target="_blank" rel="noreferrer">
|
||||
Status page
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -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() {
|
||||
<SupportFormWrapper>
|
||||
<SupportFormHeader />
|
||||
|
||||
<IncidentAdmonition isActive={hasActiveIncidents} />
|
||||
<IncidentAdmonition
|
||||
isActive={admonition !== null}
|
||||
title={admonition?.title ?? ''}
|
||||
description={admonition?.description ?? ''}
|
||||
statusPageUrl={statusPageUrl}
|
||||
/>
|
||||
|
||||
{!isSuccess && !hasActiveIncidents && (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
@@ -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 (
|
||||
<div className="flex flex-col items-start justify-between gap-y-2 sm:flex-row sm:items-center">
|
||||
@@ -165,16 +169,8 @@ function SupportFormHeader() {
|
||||
)
|
||||
}
|
||||
>
|
||||
<Link href="https://status.supabase.com/" target="_blank" rel="noreferrer">
|
||||
{isLoading
|
||||
? 'Checking status'
|
||||
: isError
|
||||
? 'Failed to check status'
|
||||
: isIncident
|
||||
? 'Active incident ongoing'
|
||||
: isMaintenance
|
||||
? 'Scheduled maintenance'
|
||||
: 'All systems operational'}
|
||||
<Link href={statusPageUrl} target="_blank" rel="noreferrer">
|
||||
{getSupportStatusLabel(supportStatus)}
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -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 (
|
||||
<div className="relative h-full overflow-y-auto overflow-x-hidden">
|
||||
<IncidentAdmonition
|
||||
isActive={hasActiveIncidents}
|
||||
isActive={admonition !== null}
|
||||
title={admonition?.title ?? ''}
|
||||
description={admonition?.description ?? ''}
|
||||
statusPageUrl={statusPageUrl}
|
||||
className="rounded-none border-x-0 shadow-none"
|
||||
/>
|
||||
<div className="min-h-full px-5 pt-5">
|
||||
@@ -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 (
|
||||
<Tooltip>
|
||||
@@ -146,16 +147,8 @@ export function SupportFormStatusButton() {
|
||||
)
|
||||
}
|
||||
>
|
||||
<Link href="https://status.supabase.com/" target="_blank" rel="noreferrer">
|
||||
{isLoading
|
||||
? 'Checking status'
|
||||
: isError
|
||||
? 'Failed to check status'
|
||||
: isIncident
|
||||
? 'Active incident ongoing'
|
||||
: isMaintenance
|
||||
? 'Scheduled maintenance'
|
||||
: 'All systems operational'}
|
||||
<Link href={statusPageUrl} target="_blank" rel="noreferrer">
|
||||
{getSupportStatusLabel(supportStatus)}
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -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<Extract<StatusItem, { kind: 'incident' }>> = {}): 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<Extract<StatusItem, { kind: 'maintenance' }>> = {}
|
||||
): 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<Extract<StatusItem, { kind: 'upcoming_maintenance' }>> = {}
|
||||
): 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')
|
||||
})
|
||||
})
|
||||
@@ -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<StatusItem, { kind: 'incident' | 'maintenance' }>
|
||||
|
||||
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<StatusItem>): Array<ActiveStatusItem> {
|
||||
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<StatusItem>
|
||||
): { 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<StatusItem>): 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
|
||||
}
|
||||
@@ -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<StatusPageResponse>({
|
||||
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' } })
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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<StatusPageResponse> {
|
||||
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<ReturnType<typeof getStatusPage>>
|
||||
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,
|
||||
})
|
||||
@@ -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<typeof WidgetResponseSchema>
|
||||
export type StatusPageResponse = z.infer<typeof StatusPageResponseSchema>
|
||||
export type AffectedComponent = z.infer<typeof AffectedComponentSchema>
|
||||
export type WorstImpact = z.infer<typeof WorstImpactSchema>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<WorstImpact, number> = {
|
||||
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<string> }
|
||||
|
||||
export function getRegionScope(components: ReadonlyArray<AffectedComponent>): 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<AffectedComponent>): boolean {
|
||||
return components.length > 0 && components.every(isProjectCreationComponent)
|
||||
}
|
||||
|
||||
type StatusItemBase = {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
lastUpdateAt: string
|
||||
lastUpdateMessage: string | null
|
||||
components: ReadonlyArray<AffectedComponent>
|
||||
/** 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<StatusItem> }
|
||||
|
||||
type WidgetItemCommon = {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
last_update_at: string
|
||||
last_update_message?: string | null
|
||||
affected_components: Array<AffectedComponent>
|
||||
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<StatusItem> = [
|
||||
...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<StatusItem, 'kind' | 'id'>): string {
|
||||
return `${item.kind}:${item.id}`
|
||||
}
|
||||
|
||||
export type UserRegionContext = {
|
||||
hasProjects: boolean
|
||||
regions: ReadonlySet<string>
|
||||
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)))
|
||||
}
|
||||
Reference in new issue
Block a user