mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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,3 +1,4 @@
|
||||
export const platformKeys = {
|
||||
status: () => ['platform', 'status'] as const,
|
||||
incidentStatus: () => ['platform', 'incident-status'] as const,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type IncidentInfo = {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
impact: string
|
||||
active_since: string
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user