From f0acececce4b216ae9dfd11bffee5df165b238e8 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Fri, 19 Dec 2025 10:28:02 +1000 Subject: [PATCH] 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> --- .../interfaces/Support/IncidentAdmonition.tsx | 99 ++++++++++++++++ .../interfaces/Support/SupportFormPage.tsx | 17 ++- .../data/platform/incident-status-query.ts | 38 ++++++ .../data/platform/incident-status-utils.ts | 110 ++++++++++++++++++ apps/studio/data/platform/keys.ts | 1 + apps/studio/lib/api/incident-status.ts | 7 ++ apps/studio/pages/api/incident-status.ts | 10 +- 7 files changed, 273 insertions(+), 9 deletions(-) create mode 100644 apps/studio/components/interfaces/Support/IncidentAdmonition.tsx create mode 100644 apps/studio/data/platform/incident-status-query.ts create mode 100644 apps/studio/data/platform/incident-status-utils.ts create mode 100644 apps/studio/lib/api/incident-status.ts diff --git a/apps/studio/components/interfaces/Support/IncidentAdmonition.tsx b/apps/studio/components/interfaces/Support/IncidentAdmonition.tsx new file mode 100644 index 00000000000..2bbe58f10b6 --- /dev/null +++ b/apps/studio/components/interfaces/Support/IncidentAdmonition.tsx @@ -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 ( + + {isActive && ( + + }> + + Status page + + + } + /> + + )} + + ) +} diff --git a/apps/studio/components/interfaces/Support/SupportFormPage.tsx b/apps/studio/components/interfaces/Support/SupportFormPage.tsx index a0a73db04c9..a985e090f13 100644 --- a/apps/studio/components/interfaces/Support/SupportFormPage.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormPage.tsx @@ -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() { - {!isSuccess && ( + + + {/* 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 && (
@@ -168,7 +181,7 @@ function SupportFormHeader() { - Check Supabase status page + Check the Supabase status page
diff --git a/apps/studio/data/platform/incident-status-query.ts b/apps/studio/data/platform/incident-status-query.ts new file mode 100644 index 00000000000..87452bbbbb0 --- /dev/null +++ b/apps/studio/data/platform/incident-status-query.ts @@ -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 { + 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> +export type IncidentStatusError = unknown + +export const useIncidentStatusQuery = ( + options: UseCustomQueryOptions = {} +) => + useQuery({ + queryKey: platformKeys.incidentStatus(), + queryFn: ({ signal }) => getIncidentStatus(signal), + staleTime: 1000 * 60 * 5, // 5 minutes to match API cache + ...options, + }) diff --git a/apps/studio/data/platform/incident-status-utils.ts b/apps/studio/data/platform/incident-status-utils.ts new file mode 100644 index 00000000000..7c9400d56e6 --- /dev/null +++ b/apps/studio/data/platform/incident-status-utils.ts @@ -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 = { + 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 = { + 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): Array { + 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, + } +} diff --git a/apps/studio/data/platform/keys.ts b/apps/studio/data/platform/keys.ts index 5b17e948137..4b7877df0f0 100644 --- a/apps/studio/data/platform/keys.ts +++ b/apps/studio/data/platform/keys.ts @@ -1,3 +1,4 @@ export const platformKeys = { status: () => ['platform', 'status'] as const, + incidentStatus: () => ['platform', 'incident-status'] as const, } diff --git a/apps/studio/lib/api/incident-status.ts b/apps/studio/lib/api/incident-status.ts new file mode 100644 index 00000000000..9f526e4540f --- /dev/null +++ b/apps/studio/lib/api/incident-status.ts @@ -0,0 +1,7 @@ +export type IncidentInfo = { + id: string + name: string + status: string + impact: string + active_since: string +} diff --git a/apps/studio/pages/api/incident-status.ts b/apps/studio/pages/api/incident-status.ts index 0c6847f7cde..d18e9a9394e 100644 --- a/apps/studio/pages/api/incident-status.ts +++ b/apps/studio/pages/api/incident-status.ts @@ -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 => { id: incident.id, name: incident.name, status: incident.status, + impact: incident.impact, active_since: incident.scheduled_for ?? incident.created_at, })) }