feat(studio): incident notice on support ticket creation (#41379)

* callout

* progress

* plural issues

* better handle multiple issues

* refactor

* remove fancy copywriting

* return IncidentAdmonition to support form page

* progress

* cleanup

* rabbit

Potential control flow issue: execution continues after handleError when data is undefined. When a non-401 error occurs, handleError(error) is called but execution continues to line 24 where data is accessed. If handleError doesn't throw, this will cause a runtime error accessing (data as any).is_healthy on undefined. Additionally, the as any cast on line 24 violates the coding guidelines. Consider validating the response shape instead.

* animate in

* fix

* reset

* remove unused dayjs

* rabbit

* rabbit

* fixes from code review

* rabbit

* rabbit

---------

Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com>
This commit is contained in:
Danny WhiteandCharis Lam authored and GitHub committed 2025-12-19 00:28:02 +00:00
1 parent 3d178fcad2
commit f0acececce
7 files changed
+273 -9

No files matched your search

@@ -0,0 +1,99 @@
import { AnimatePresence, motion } from 'framer-motion'
import Link from 'next/link'
import { useIncidentStatusQuery } from 'data/platform/incident-status-query'
import { processIncidentData } from 'data/platform/incident-status-utils'
import { ExternalLink } from 'lucide-react'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns/admonition'
interface IncidentAdmonitionProps {
isActive: boolean
}
const STATUS_DESCRIPTION_SIGN_OFF = 'Please 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 }: IncidentAdmonitionProps) {
const { data: incidents, isLoading, isError } = useIncidentStatusQuery()
// 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' : ''}`
: '')
return (
<AnimatePresence>
{isActive && (
<motion.aside
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
>
<Admonition
type="warning"
layout="horizontal"
title={statusTitle}
description={getStatusDescription(overallStatus, hasMultipleIncidents, allSameStatus)}
actions={
<Button asChild type="default" icon={<ExternalLink strokeWidth={1.5} />}>
<Link href="https://status.supabase.com/" target="_blank" rel="noreferrer">
Status page
</Link>
</Button>
}
/>
</motion.aside>
)}
</AnimatePresence>
)
}
@@ -9,6 +9,7 @@ import { toast } from 'sonner'
import CopyButton from 'components/ui/CopyButton'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { useIncidentStatusQuery } from 'data/platform/incident-status-query'
import { usePlatformStatusQuery } from 'data/platform/platform-status-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useStateTransition } from 'hooks/misc/useStateTransition'
@@ -17,6 +18,7 @@ import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { AIAssistantOption } from './AIAssistantOption'
import { DiscordCTACard } from './DiscordCTACard'
import { IncidentAdmonition } from './IncidentAdmonition'
import { Success } from './Success'
import type { ExtendedSupportCategories } from './Support.constants'
import type { SupportFormValues } from './SupportForm.schema'
@@ -69,6 +71,14 @@ function SupportFormPageContent() {
const selectedOrg = organizations?.find((org) => org.slug === orgSlug)
const isFreePlan = selectedOrg?.plan.id === 'free'
const {
data: incidents,
isPending: isIncidentsPending,
isError: isIncidentsError,
} = useIncidentStatusQuery()
const hasActiveIncidents =
!isIncidentsPending && !isIncidentsError && incidents && incidents.length > 0
const sendTelemetry = useSupportFormTelemetry()
useStateTransition(state, 'submitting', 'success', (_, curr) => {
toast.success('Support request sent. Thank you!')
@@ -91,7 +101,10 @@ function SupportFormPageContent() {
<SupportFormWrapper>
<SupportFormHeader />
{!isSuccess && (
<IncidentAdmonition isActive={hasActiveIncidents} />
{/* Only show AI Assistant and Discord CTAs if there are no active incidents and the user is still filling out the support form*/}
{!isSuccess && !hasActiveIncidents && (
<div className="flex flex-col gap-y-4">
<AIAssistantOption projectRef={projectRef} organizationSlug={orgSlug} />
<DiscordCTACard organizationSlug={orgSlug} />
@@ -168,7 +181,7 @@ function SupportFormHeader() {
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" align="center">
Check Supabase status page
Check the Supabase status page
</TooltipContent>
</Tooltip>
</div>
@@ -0,0 +1,38 @@
import { useQuery } from '@tanstack/react-query'
import type { IncidentInfo } from 'lib/api/incident-status'
import { BASE_PATH } from 'lib/constants'
import { UseCustomQueryOptions } from 'types'
import { platformKeys } from './keys'
export async function getIncidentStatus(signal?: AbortSignal): Promise<IncidentInfo[]> {
const response = await fetch(`${BASE_PATH}/api/incident-status`, {
signal,
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
const errorText = await response.text()
console.error('[getIncidentStatus] Failed:', response.status, errorText)
throw new Error(`Failed to fetch incident status: ${response.statusText}`)
}
const data = await response.json()
return data as IncidentInfo[]
}
export type IncidentStatusData = Awaited<ReturnType<typeof getIncidentStatus>>
export type IncidentStatusError = unknown
export const useIncidentStatusQuery = <TData = IncidentStatusData>(
options: UseCustomQueryOptions<IncidentStatusData, IncidentStatusError, TData> = {}
) =>
useQuery<IncidentStatusData, IncidentStatusError, TData>({
queryKey: platformKeys.incidentStatus(),
queryFn: ({ signal }) => getIncidentStatus(signal),
staleTime: 1000 * 60 * 5, // 5 minutes to match API cache
...options,
})
@@ -0,0 +1,110 @@
import type { IncidentInfo } from 'lib/api/incident-status'
export type IncidentImpact = 'critical' | 'major' | 'maintenance' | 'minor' | 'none'
/**
* Impact priority order: higher priority = more severe
*/
const IMPACT_PRIORITY: Record<IncidentImpact, number> = {
critical: 4,
major: 3,
maintenance: 2,
minor: 1,
none: 0,
}
const isIncidentImpact = (impact: string): impact is IncidentImpact => {
return Object.keys(IMPACT_PRIORITY).includes(impact)
}
export type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved'
/**
* Status priority order: higher priority = more urgent
*/
const STATUS_PRIORITY: Record<IncidentStatus, number> = {
investigating: 4,
identified: 3,
monitoring: 2,
resolved: 1,
}
const isIncidentStatus = (status: string): status is IncidentStatus => {
return Object.keys(STATUS_PRIORITY).includes(status)
}
/**
* Sorts incidents by priority: first by impact, then by date, then by status.
*
* @param incidents Array of incidents to sort
* @returns New array of incidents sorted by priority
*/
function sortIncidentsByPriority(incidents: Array<IncidentInfo>): Array<IncidentInfo> {
const incidentsCopy = [...incidents]
incidentsCopy.sort((a, b) => {
const impactA = isIncidentImpact(a.impact) ? a.impact : 'none'
const impactB = isIncidentImpact(b.impact) ? b.impact : 'none'
const impactPriorityA = IMPACT_PRIORITY[impactA]
const impactPriorityB = IMPACT_PRIORITY[impactB]
const dateA = new Date(a.active_since).getTime()
const dateB = new Date(b.active_since).getTime()
const hasDifferentDates = Number.isFinite(dateA) && Number.isFinite(dateB) && dateB !== dateA
const statusA = isIncidentStatus(a.status) ? a.status : 'investigating'
const statusB = isIncidentStatus(b.status) ? b.status : 'investigating'
const statusPriorityA = STATUS_PRIORITY[statusA]
const statusPriorityB = STATUS_PRIORITY[statusB]
if (impactPriorityB !== impactPriorityA) {
return impactPriorityB - impactPriorityA
} else if (hasDifferentDates) {
return dateB - dateA
} else {
return statusPriorityB - statusPriorityA
}
})
return incidentsCopy
}
/**
* Checks if all incidents have the same status
*
* @param incidents Array of incidents to check
* @returns true if all incidents share the same status
*/
function allIncidentsHaveSameStatus(incidents: Array<{ status: string }>): boolean {
if (incidents.length <= 1) return true
const firstStatus = incidents[0].status
return incidents.every((inc) => inc.status === firstStatus)
}
/**
* Processes incident data to extract useful metadata for display
*/
export function processIncidentData(incidents: IncidentInfo[]) {
if (incidents.length === 0) {
return {
hasMultipleIncidents: false,
mostCriticalIncident: null,
overallStatus: 'investigating' as const,
allSameStatus: true,
}
}
const hasMultipleIncidents = incidents.length > 1
const mostCriticalIncident = sortIncidentsByPriority(incidents)[0]
const overallStatus = isIncidentStatus(mostCriticalIncident.status)
? mostCriticalIncident.status
: 'investigating'
const allSameStatus = allIncidentsHaveSameStatus(incidents)
return {
hasMultipleIncidents,
mostCriticalIncident,
overallStatus,
allSameStatus,
}
}
+1
View File
@@ -1,3 +1,4 @@
export const platformKeys = {
status: () => ['platform', 'status'] as const,
incidentStatus: () => ['platform', 'incident-status'] as const,
}
+7
View File
@@ -0,0 +1,7 @@
export type IncidentInfo = {
id: string
name: string
status: string
impact: string
active_since: string
}
+3 -7
View File
@@ -3,13 +3,7 @@ import z from 'zod'
import { IS_PLATFORM } from 'common'
import { InternalServerError } from 'lib/api/apiHelpers'
type IncidentInfo = {
id: string
name: string
status: string
active_since: string
}
import type { IncidentInfo } from 'lib/api/incident-status'
const STATUSPAGE_API_URL = 'https://api.statuspage.io/v1'
const STATUSPAGE_PAGE_ID = process.env.STATUSPAGE_PAGE_ID
@@ -31,6 +25,7 @@ const StatusPageIncidentsSchema = z.array(
status: z.string(),
created_at: z.string(),
scheduled_for: z.string().nullable(),
impact: z.string(),
})
)
@@ -102,6 +97,7 @@ const getActiveIncidents = async (): Promise<IncidentInfo[]> => {
id: incident.id,
name: incident.name,
status: incident.status,
impact: incident.impact,
active_since: incident.scheduled_for ?? incident.created_at,
}))
}