feat: improve db overload debugging UX (#43564)

When the dashboard hits a DB connection timeout, users currently see a
raw error message with no
path forward. This PR adds an inline troubleshooting system that detects
known error types and
surfaces contextual next steps — restart the DB, read the docs, or debug
with AI.

##  Changes

- New ErrorDisplay component (packages/ui-patterns) — styled error card
with a title, monospace error
block, optional troubleshooting slot, and a "Contact support" link that
always renders. Accepts
  typed supportFormParams to pre-fill the support form.

- Error classification in handleError (data/fetchers.ts) — on every API
error, the message is tested
against ERROR_PATTERNS. If matched, handleError throws a typed subclass
(ConnectionTimeoutError
extends ResponseError) instead of a plain ResponseError. Stack traces
now show the exact error
  class. All existing instanceof ResponseError checks continue to work.

- ErrorMatcher component — reads errorType from the thrown class
instance, does an O(1) lookup into
ERROR_MAPPINGS, and renders the matching troubleshooting accordion as
children of ErrorDisplay.
  Falls back to plain ErrorDisplay for unclassified errors.

- Connection timeout mapping — first error type wired up, with three
troubleshooting steps: restart
the database, link to the docs, and "Debug with AI" (opens the AI
assistant sidebar with a
  pre-filled prompt).

- Telemetry — three new typed events track when the troubleshooter is
shown, when accordion steps are
   toggled, and which CTAs are clicked.

##  Adding a new error type

  1. Add a class to types/api-errors.ts
  2. Add { pattern, ErrorClass } to data/error-patterns.ts
  3. Create a troubleshooting component in errorMappings/
  4. Add an entry to error-mappings.tsx
This commit is contained in:
Jordi Enric authored and GitHub committed 2026-03-16 11:22:30 +01:00
1 parent 81dd47a421
commit ec26943390
32 files changed
+1444 -30

No files matched your search

@@ -0,0 +1,51 @@
---
name: studio-error-handling
description: Error display and troubleshooting pattern for Supabase Studio. Use when
rendering API errors in the UI, adding inline troubleshooting steps for a new
error type, or wiring up the AI assistant debug button from an error state.
---
# Studio Error Handling Pattern
Full docs and code examples: `apps/studio/components/interfaces/ErrorHandling/README.md`
## How it works
Classification happens in the **data layer**: `handleError` in `data/fetchers.ts` tests the error message against `ERROR_PATTERNS` and throws the matching error subclass (e.g. `ConnectionTimeoutError extends ResponseError`). The component (`ErrorMatcher`) reads `errorType` from the instance and does an O(1) lookup — it never does regex matching.
```
handleError() → throws ConnectionTimeoutError → React Query catches → ErrorMatcher reads errorType → renders troubleshooting
```
## Key files
| File | Purpose |
| ------------------------------------- | ---------------------------------------------------------------- |
| `data/error-patterns.ts` | Array of `{ pattern, ErrorClass }` — the regex lives here |
| `types/api-errors.ts` | Error classes, `KnownErrorType` union, `ClassifiedError` type |
| `ErrorMatcher.tsx` | Component — reads `errorType`, looks up mapping, renders |
| `error-mappings.tsx` | `Record<KnownErrorType, { id, Troubleshooting: ComponentType }>` |
| `errorMappings/ConnectionTimeout.tsx` | Reference troubleshooting component |
| `TroubleshootingSections.tsx` | Reusable accordion section components |
| `TroubleshootingAccordion.tsx` | Accordion wrapper with telemetry |
## Usage
Pass the **full error object** from React Query — not `error.message`:
```tsx
{
isError && (
<ErrorMatcher title="Failed to load tables" error={error} supportFormParams={{ projectRef }} />
)
}
```
## What NOT to do
- Do not pass `error.message` to `ErrorMatcher` — pass the full `error` object so the class is preserved.
- Do not put regex patterns in `error-mappings.tsx` — they belong in `data/error-patterns.ts`.
- Do not use `Object.assign` to stamp `errorType` — throw a proper subclass instead.
- Do not pass a raw URL string for support — use `supportFormParams={{ projectRef }}`.
- Do not put the page title inside the error mapping — it belongs on the `<ErrorMatcher>` caller.
- Do not add callback props (`onDebugWithAI`, `onRestartProject`) to troubleshooting components — use hooks inside them instead.
+5
View File
@@ -167,6 +167,11 @@ export const docsConfig: DocsConfig = {
href: '/docs/fragments/data-input',
items: [],
},
{
title: 'Error Display',
href: '/docs/fragments/error-display',
items: [],
},
{
title: 'Form Item Layout',
href: '/docs/fragments/form-item-layout',
@@ -0,0 +1,59 @@
---
title: Error Display
description: A card component for surfacing API errors with optional troubleshooting steps and a support link.
fragment: true
---
ErrorDisplay renders a styled error card with a warning header, a monospace error message block, an optional children slot for inline troubleshooting content, and a "Contact support" footer link that is always shown.
<ComponentPreview name="error-display-demo" peekCode wide />
Use ErrorDisplay as the base for any inline error state in the dashboard. For errors with known patterns, wire up [`ErrorMatcher`](https://github.com/supabase/supabase/tree/master/apps/studio/components/interfaces/ErrorHandling) on top to automatically inject matching troubleshooting steps.
## Usage
```tsx
import { ErrorDisplay } from 'ui-patterns/ErrorDisplay'
```
```tsx
<ErrorDisplay
title="Failed to load tables"
errorMessage="ERROR: CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT."
supportFormParams={{ projectRef: 'my-project' }}
/>
```
## Examples
### With troubleshooting steps
Pass any content as `children` — typically a `TroubleshootingAccordion` — to render inline troubleshooting between the error message and the support footer.
<ComponentPreview name="error-display-with-children" peekCode wide />
## Props
| Prop | Type | Default | Description |
| ------------------- | -------------------- | ------------------- | --------------------------------------------------------------------- |
| `title` | `string` | — | Displayed in the card header next to the warning icon. |
| `errorMessage` | `string` | — | Raw error string rendered in a monospace code block. |
| `supportFormParams` | `SupportFormParams?` | `undefined` | Typed params for the support form URL. The component builds the URL. |
| `supportLabel` | `string?` | `"Contact support"` | Override the support link label. |
| `children` | `ReactNode` | `undefined` | Slot for troubleshooting content rendered between message and footer. |
| `icon` | `ReactNode` | Warning triangle | Override the header icon. |
| `onRender` | `() => void?` | — | Fired once on mount — use for telemetry. |
| `onSupportClick` | `() => void?` | — | Fired when the support link is clicked — use for telemetry. |
| `className` | `string?` | — | Extra classes on the root `Card`. |
### SupportFormParams
| Field | Type | Description |
| ------------ | --------- | ----------------------------- |
| `projectRef` | `string?` | Project reference slug |
| `orgSlug` | `string?` | Organisation slug |
| `category` | `string?` | Pre-selected support category |
| `subject` | `string?` | Pre-filled subject line |
| `message` | `string?` | Pre-filled message body |
| `error` | `string?` | Raw error string for context |
| `sid` | `string?` | Sentry event ID |
@@ -0,0 +1,11 @@
import { ErrorDisplay } from 'ui-patterns/ErrorDisplay'
export default function ErrorDisplayDemo() {
return (
<ErrorDisplay
title="Failed to load tables"
errorMessage="ERROR: FAILED TO RUN SQL QUERY: CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT."
supportFormParams={{ projectRef: 'my-project' }}
/>
)
}
@@ -0,0 +1,16 @@
import { ErrorDisplay } from 'ui-patterns/ErrorDisplay'
export default function ErrorDisplayWithChildren() {
return (
<ErrorDisplay
title="Failed to load tables"
errorMessage="ERROR: FAILED TO RUN SQL QUERY: CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT."
supportFormParams={{ projectRef: 'my-project' }}
>
<div className="px-4 py-3 text-sm text-foreground-light border-b border-default">
Troubleshooting steps would appear here — e.g. a{' '}
<code className="font-mono text-xs">TroubleshootingAccordion</code>.
</div>
</ErrorDisplay>
)
}
+12
View File
@@ -1462,6 +1462,18 @@ export const examples: Registry = [
type: 'docs:example',
files: ['example/expanding-textarea-demo.tsx'],
},
{
name: 'error-display-demo',
type: 'components:example',
registryDependencies: ['error-display'],
files: ['example/error-display-demo.tsx'],
},
{
name: 'error-display-with-children',
type: 'components:example',
registryDependencies: ['error-display'],
files: ['example/error-display-with-children.tsx'],
},
{
name: 'logs-bar-chart',
type: 'components:example',
@@ -0,0 +1,101 @@
import { render, screen } from '@testing-library/react'
import { ConnectionTimeoutError } from 'types/api-errors'
import { ResponseError } from 'types/base'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ErrorMatcher } from './ErrorMatcher'
vi.mock('lib/telemetry/track', () => ({ useTrack: () => vi.fn() }))
vi.mock('state/ai-assistant-state', () => ({
useAiAssistantStateSnapshot: () => ({ newChat: vi.fn() }),
}))
vi.mock('state/sidebar-manager-state', () => ({
useSidebarManagerSnapshot: () => ({ openSidebar: vi.fn() }),
}))
vi.mock('components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider', () => ({
SIDEBAR_KEYS: { AI_ASSISTANT: 'ai-assistant' },
}))
vi.mock('./RestartProjectDialog', () => ({
RestartProjectDialog: () => null,
}))
describe('ErrorMatcher', () => {
beforeEach(() => vi.clearAllMocks())
it('renders the provided title and error message', () => {
render(
<ErrorMatcher
title="Failed to load tables"
error="ERROR: FAILED TO RUN SQL QUERY: CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT."
supportFormParams={{}}
/>
)
expect(screen.getByText('Failed to load tables')).toBeInTheDocument()
expect(
screen.getByText(
'ERROR: FAILED TO RUN SQL QUERY: CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT.'
)
).toBeInTheDocument()
})
it('renders troubleshooting steps for classified errors', () => {
const error = new ConnectionTimeoutError('CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT')
render(<ErrorMatcher title="Failed to load tables" error={error} supportFormParams={{}} />)
expect(screen.getByText('Try restarting your project')).toBeInTheDocument()
expect(screen.getByText('Try our troubleshooting guide')).toBeInTheDocument()
expect(screen.getByText('Debug with AI')).toBeInTheDocument()
})
it('renders fallback for plain ResponseError (not a classified subclass)', () => {
render(
<ErrorMatcher
title="Failed to load tables"
error={new ResponseError('CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT')}
supportFormParams={{}}
/>
)
expect(screen.getByText('Failed to load tables')).toBeInTheDocument()
expect(screen.queryByText('Try restarting your project')).not.toBeInTheDocument()
})
it('renders fallback with provided title for unmatched errors', () => {
render(
<ErrorMatcher title="Failed to load tables" error="UNKNOWN ERROR" supportFormParams={{}} />
)
expect(screen.getByText('Failed to load tables')).toBeInTheDocument()
expect(screen.getByText('UNKNOWN ERROR')).toBeInTheDocument()
})
it('accepts error as object with message property', () => {
render(
<ErrorMatcher
title="Failed to load tables"
error={{ message: 'UNKNOWN ERROR' }}
supportFormParams={{}}
/>
)
expect(screen.getByText('UNKNOWN ERROR')).toBeInTheDocument()
})
it('builds support link with projectRef param', () => {
render(
<ErrorMatcher
title="Failed to load tables"
error="UNKNOWN ERROR"
supportFormParams={{ projectRef: 'my-project' }}
/>
)
expect(screen.getByRole('link', { name: /contact support/i })).toHaveAttribute(
'href',
'/support/new?projectRef=my-project'
)
})
it('builds support link with no params when supportFormParams is omitted', () => {
render(<ErrorMatcher title="Failed to load tables" error="UNKNOWN ERROR" />)
expect(screen.getByRole('link', { name: /contact support/i })).toHaveAttribute(
'href',
'/support/new'
)
})
})
@@ -0,0 +1,51 @@
'use client'
import { useTrack } from 'lib/telemetry/track'
import { ErrorDisplay, SupportFormParams } from 'ui-patterns/ErrorDisplay'
import { getMappingForError } from './ErrorMatcher.utils'
interface ErrorMatcherProps {
title: string
error: string | { message: string }
supportFormParams?: SupportFormParams
className?: string
}
export function ErrorMatcher({ title, error, supportFormParams, className }: ErrorMatcherProps) {
const track = useTrack()
const message = typeof error === 'string' ? error : error.message
const mapping = getMappingForError(error)
const Troubleshooting = mapping?.Troubleshooting
return (
<ErrorDisplay
title={title}
errorMessage={message}
supportFormParams={supportFormParams}
className={className}
onRender={() => {
track('dashboard_error_created', {
source: 'error_display',
errorType: mapping?.id,
hasTroubleshooting: !!mapping,
})
if (mapping) {
track('inline_error_troubleshooter_exposed', { errorType: mapping.id })
}
}}
onSupportClick={
mapping
? () =>
track('inline_error_troubleshooter_action_clicked', {
errorType: mapping.id,
ctaType: 'contact_support',
})
: undefined
}
>
{Troubleshooting && <Troubleshooting />}
</ErrorDisplay>
)
}
@@ -0,0 +1,34 @@
import { ConnectionTimeoutError, UnknownAPIResponseError } from 'types/api-errors'
import { describe, expect, it } from 'vitest'
import { getMappingForError } from './ErrorMatcher.utils'
describe('getMappingForError', () => {
it('returns the mapping for a classified error with a known errorType', () => {
const error = new ConnectionTimeoutError('connection terminated due to connection timeout')
const mapping = getMappingForError(error)
expect(mapping).not.toBeNull()
expect(mapping?.id).toBe('connection-timeout')
})
it('returns null for UnknownAPIResponseError (no troubleshooting guide)', () => {
const error = new UnknownAPIResponseError('something went wrong')
expect(getMappingForError(error)).toBeNull()
})
it('returns null for a plain string', () => {
expect(getMappingForError('some error message')).toBeNull()
})
it('returns null for null', () => {
expect(getMappingForError(null)).toBeNull()
})
it('returns null for an object with no errorType', () => {
expect(getMappingForError({ message: 'error' })).toBeNull()
})
it('returns null for an object with an unrecognised errorType', () => {
expect(getMappingForError({ errorType: 'not-a-real-type' })).toBeNull()
})
})
@@ -0,0 +1,12 @@
import { ResponseError } from 'types/base'
import { ERROR_MAPPINGS, type ErrorMapping } from './error-mappings'
export function getMappingForError(error: unknown): ErrorMapping | null {
const isResponseError = error instanceof ResponseError
if (!isResponseError) return null
for (const [ErrorClass, mapping] of ERROR_MAPPINGS) {
if (error instanceof ErrorClass) return mapping
}
return null
}
@@ -0,0 +1,130 @@
# Error Handling
`ErrorMatcher` displays a typed API error. If the error was classified by `handleError` (i.e. it is an instance of a known error class), it shows matching troubleshooting steps. Otherwise it shows a generic error card.
Classification happens in the data layer — `handleError` in `data/fetchers.ts` matches the error message against patterns and throws the appropriate error subclass (e.g. `ConnectionTimeoutError`). The component never does regex matching itself.
The `title` always comes from the caller — the same error type can appear on different pages with different titles.
## Usage
```tsx
import { ErrorMatcher } from 'components/interfaces/ErrorHandling/ErrorMatcher'
{
isError && (
<ErrorMatcher title="Failed to load tables" error={error} supportFormParams={{ projectRef }} />
)
}
```
Pass the full `error` object from React Query — not `error.message`. This lets `ErrorMatcher` check the error class and show the right troubleshooting steps.
### Props
| Prop | Type | Description |
| ------------------- | ------------------------------- | ------------------------------------------------------------------ |
| `title` | `string` | Displayed in the error card header. Set by the caller. |
| `error` | `string \| { message: string }` | The error from React Query (pass the full object, not `.message`). |
| `supportFormParams` | `Partial<SupportFormUrlKeys>` | Typed params for the support form URL (projectRef, category…). |
| `className` | `string?` | Extra classes on the card. |
`supportFormParams` is typed as `Partial<SupportFormUrlKeys>` — autocomplete shows all available fields (`projectRef`, `orgSlug`, `category`, `subject`, `message`, `error`, `sid`). The URL is built by `createSupportFormUrl()` from `SupportForm.utils.tsx`.
## Adding a new error mapping
**1. Add the error class to `types/api-errors.ts`**
```ts
export type KnownErrorType = 'connection-timeout' | 'your-error'
export class YourError extends ResponseError {
readonly errorType = 'your-error' as const
}
export type ClassifiedError = ConnectionTimeoutError | FailedToRetrieveProjectsError | YourError
```
**2. Add a pattern entry to `data/error-patterns.ts`**
```ts
import { YourError } from 'types/api-errors'
export const ERROR_PATTERNS: ErrorPattern[] = [
// existing...
{
pattern: /YOUR_ERROR_PATTERN/i,
ErrorClass: YourError,
},
]
```
`handleError` picks this up automatically — any matching API error will be thrown as a `YourError` instance.
**3. Create `errorMappings/YourError.tsx`**
```tsx
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import { TroubleshootingAccordion } from '../TroubleshootingAccordion'
import {
FixWithAITroubleshootingSection,
TroubleshootingGuideSection,
} from '../TroubleshootingSections'
const ERROR_TYPE = 'your-error'
const BUILD_PROMPT = () => `Describe the issue for the AI assistant.`
export function YourErrorTroubleshooting() {
const { openSidebar } = useSidebarManagerSnapshot()
const aiSnap = useAiAssistantStateSnapshot()
return (
<TroubleshootingAccordion
errorType={ERROR_TYPE}
stepTitles={{ 1: 'Troubleshooting guide', 2: 'Debug with AI' }}
>
<TroubleshootingGuideSection
number={1}
errorType={ERROR_TYPE}
href="https://supabase.com/docs/guides/..."
/>
<FixWithAITroubleshootingSection
number={2}
errorType={ERROR_TYPE}
buildPrompt={BUILD_PROMPT}
onDebugWithAI={(prompt) => {
openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
aiSnap.newChat({ initialMessage: prompt })
}}
/>
</TroubleshootingAccordion>
)
}
```
**4. Add it to `error-mappings.tsx`**
```tsx
import { YourErrorTroubleshooting } from './errorMappings/YourError'
export const ERROR_MAPPINGS: Record<KnownErrorType, ErrorMapping> = {
// existing...
'your-error': {
id: 'your-error',
Troubleshooting: YourErrorTroubleshooting,
},
}
```
That's it. `ErrorMatcher` picks it up automatically.
## Available section components
| Component | Props |
| --------------------------------------- | ------------------------------------------------------- |
| `RestartDatabaseTroubleshootingSection` | `number`, `errorType`, `onRestartProject?` |
| `TroubleshootingGuideSection` | `number`, `errorType`, `href`, `title?`, `description?` |
| `FixWithAITroubleshootingSection` | `number`, `errorType`, `buildPrompt`, `onDebugWithAI?` |
@@ -0,0 +1,104 @@
'use client'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useSetProjectStatus } from 'data/projects/project-detail-query'
import { useProjectRestartMutation } from 'data/projects/project-restart-mutation'
import { useProjectRestartServicesMutation } from 'data/projects/project-restart-services-mutation'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from 'lib/constants'
import { useRouter } from 'next/router'
import { toast } from 'sonner'
import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
interface RestartProjectDialogProps {
visible: boolean
onClose: () => void
/** Restart type: 'project' for full restart, 'database' for fast database reboot */
restartType?: 'project' | 'database'
}
export function RestartProjectDialog({
visible,
onClose,
restartType = 'database',
}: RestartProjectDialogProps) {
const router = useRouter()
const { data: project } = useSelectedProjectQuery()
const { setProjectStatus } = useSetProjectStatus()
const { can: canRestartProject } = useAsyncCheckPermissions(
PermissionAction.INFRA_EXECUTE,
'reboot'
)
const { mutate: restartProject, isPending: isRestartingProject } = useProjectRestartMutation({
onSuccess: () => {
if (project?.ref) {
setProjectStatus({ ref: project.ref, status: PROJECT_STATUS.RESTARTING })
}
toast.success('Restarting project')
router.push(`/project/${project?.ref}`)
onClose()
},
onError: (error) => {
toast.error(`Unable to restart project: ${error.message}`)
},
})
const { mutate: restartProjectServices, isPending: isRestartingServices } =
useProjectRestartServicesMutation({
onSuccess: () => {
if (project?.ref) {
setProjectStatus({ ref: project.ref, status: PROJECT_STATUS.RESTARTING })
}
toast.success('Restarting database')
router.push(`/project/${project?.ref}`)
onClose()
},
onError: (error) => {
toast.error(`Unable to restart database: ${error.message}`)
},
})
const isLoading = isRestartingProject || isRestartingServices
const handleRestart = () => {
if (!project?.ref) return
if (!canRestartProject) {
return toast.error('You do not have the required permissions to restart this project')
}
if (restartType === 'project') {
restartProject({ ref: project.ref })
} else {
restartProjectServices({
ref: project.ref,
region: project.region,
services: ['postgresql'],
})
}
}
const title = restartType === 'project' ? 'Restart project' : 'Restart database'
const description =
restartType === 'project'
? 'Are you sure you want to restart your project? There will be a few minutes of downtime.'
: 'Are you sure you want to restart your database? There will be a brief downtime.'
return (
<ConfirmationModal
visible={visible}
variant="warning"
title={title}
description={description}
confirmLabel="Restart"
confirmLabelLoading="Restarting"
loading={isLoading}
disabled={!canRestartProject}
onCancel={onClose}
onConfirm={handleRestart}
/>
)
}
@@ -0,0 +1,48 @@
'use client'
import { useTrack } from 'lib/telemetry/track'
import { ReactNode } from 'react'
import { Accordion_Shadcn_ as Accordion, cn } from 'ui'
interface TroubleshootingAccordionProps {
children: ReactNode
/** Error mapping ID — used for telemetry */
errorType: string
/** Step titles keyed by step number — used for telemetry */
stepTitles?: Record<number, string>
/** Which step to expand by default (1-indexed), defaults to 1 */
defaultExpandedStep?: number
className?: string
}
export function TroubleshootingAccordion({
children,
errorType,
stepTitles,
defaultExpandedStep = 1,
className,
}: TroubleshootingAccordionProps) {
const track = useTrack()
const defaultValue = defaultExpandedStep > 0 ? `step-${defaultExpandedStep}` : undefined
return (
<Accordion
type="single"
collapsible
defaultValue={defaultValue}
className={cn('w-full', className)}
onValueChange={(value) => {
const expanded = Boolean(value)
const step = expanded ? parseInt(value.replace('step-', ''), 10) : null
track('inline_error_troubleshooter_step_clicked', {
errorType,
step,
stepTitle: step !== null ? stepTitles?.[step] : undefined,
expanded,
})
}}
>
{children}
</Accordion>
)
}
@@ -0,0 +1,179 @@
'use client'
import { AiAssistantDropdown } from 'components/ui/AiAssistantDropdown'
import { useTrack } from 'lib/telemetry/track'
import { ExternalLink } from 'lucide-react'
import { useState } from 'react'
import {
AccordionContent_Shadcn_ as AccordionContent,
AccordionItem_Shadcn_ as AccordionItem,
AccordionTrigger_Shadcn_ as AccordionTrigger,
Button,
} from 'ui'
import { RestartProjectDialog } from './RestartProjectDialog'
interface StepTriggerProps {
number: number
title: string
}
function StepTrigger({ number, title }: StepTriggerProps) {
return (
<AccordionTrigger className="py-3 hover:no-underline">
<div className="flex items-center gap-2.5">
<span className="flex-shrink-0 w-6 h-6 border border-button-hover text-foreground font-mono tabular-nums bg-button rounded-md text-xs font-medium flex items-center justify-center">
{number}
</span>
<span className="text-sm font-medium text-foreground text-left">{title}</span>
</div>
</AccordionTrigger>
)
}
interface RestartDatabaseTroubleshootingSectionProps {
number: number
errorType: string
/** Override the restart handler. If not provided, opens the restart dialog internally. */
onRestartProject?: () => void
}
export function RestartDatabaseTroubleshootingSection({
number,
errorType,
onRestartProject,
}: RestartDatabaseTroubleshootingSectionProps) {
const track = useTrack()
const [showDialog, setShowDialog] = useState(false)
const handleClick = () => {
track('inline_error_troubleshooter_action_clicked', {
errorType,
ctaType: 'restart_db',
})
if (onRestartProject) {
onRestartProject()
} else {
setShowDialog(true)
}
}
return (
<>
<AccordionItem
value={`step-${number}`}
className="border-b border-default last:border-b-0 px-3 py-2"
>
<StepTrigger number={number} title="Try restarting your project" />
<AccordionContent className="pt-1">
<div className="px-2">
<p className="text-sm text-foreground-light mb-3">
Restarting your project can help resolve timeout errors or stale connections.
</p>
<Button type="default" size="tiny" onClick={handleClick}>
Restart project
</Button>
</div>
</AccordionContent>
</AccordionItem>
<RestartProjectDialog
visible={showDialog}
onClose={() => setShowDialog(false)}
restartType="database"
/>
</>
)
}
interface TroubleshootingGuideSectionProps {
number: number
errorType: string
href: string
title?: string
description?: string
}
export function TroubleshootingGuideSection({
number,
errorType,
href,
title = 'Try our troubleshooting guide',
description,
}: TroubleshootingGuideSectionProps) {
const track = useTrack()
return (
<AccordionItem
value={`step-${number}`}
className="border-b border-default last:border-b-0 px-3 py-2"
>
<StepTrigger number={number} title={title} />
<AccordionContent className="pt-1">
<div className="px-2">
{description && <p className="text-sm text-foreground-light mb-3">{description}</p>}
<Button
asChild
type="default"
size="tiny"
onClick={() =>
track('inline_error_troubleshooter_action_clicked', {
errorType,
ctaType: 'troubleshooting_guide',
})
}
iconRight={<ExternalLink />}
>
<a href={href} target="_blank" rel="noopener noreferrer">
View troubleshooting guide
</a>
</Button>
</div>
</AccordionContent>
</AccordionItem>
)
}
interface FixWithAITroubleshootingSectionProps {
number: number
errorType: string
description?: string
onDebugWithAI?: (prompt: string) => void
buildPrompt: () => string
}
export function FixWithAITroubleshootingSection({
number,
errorType,
description = 'Let our AI assistant help diagnose and suggest solutions.',
onDebugWithAI,
buildPrompt,
}: FixWithAITroubleshootingSectionProps) {
const track = useTrack()
return (
<AccordionItem
value={`step-${number}`}
className="border-b border-default last:border-b-0 px-3 py-2"
>
<StepTrigger number={number} title="Debug with AI" />
<AccordionContent className="pt-1">
<div className="px-2">
<p className="text-sm text-foreground-light mb-3">{description}</p>
<AiAssistantDropdown
label="Debug with AI"
buildPrompt={buildPrompt}
onOpenAssistant={() => {
track('inline_error_troubleshooter_action_clicked', {
errorType,
ctaType: 'ask_ai',
})
onDebugWithAI?.(buildPrompt())
}}
size="tiny"
/>
</div>
</AccordionContent>
</AccordionItem>
)
}
@@ -0,0 +1,19 @@
import { ComponentType } from 'react'
import { ConnectionTimeoutError } from 'types/api-errors'
import type { ClassifiedError, KnownErrorType } from 'types/api-errors'
import { ConnectionTimeoutTroubleshooting } from './errorMappings/ConnectionTimeout'
export interface ErrorMapping {
id: KnownErrorType
Troubleshooting: ComponentType
}
type ErrorConstructor = new (...args: any[]) => ClassifiedError
export const ERROR_MAPPINGS = new Map<ErrorConstructor, ErrorMapping>([
[
ConnectionTimeoutError,
{ id: 'connection-timeout', Troubleshooting: ConnectionTimeoutTroubleshooting },
],
])
@@ -0,0 +1,48 @@
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import { TroubleshootingAccordion } from '../TroubleshootingAccordion'
import {
FixWithAITroubleshootingSection,
RestartDatabaseTroubleshootingSection,
TroubleshootingGuideSection,
} from '../TroubleshootingSections'
const ERROR_TYPE = 'connection-timeout'
const BUILD_PROMPT = () =>
`The user is encountering connection timeout errors. The error message is: "CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT". What are the most likely causes of this issue and how can the user resolve it?`
export function ConnectionTimeoutTroubleshooting() {
const { openSidebar } = useSidebarManagerSnapshot()
const aiSnap = useAiAssistantStateSnapshot()
return (
<TroubleshootingAccordion
errorType={ERROR_TYPE}
stepTitles={{
1: 'Try restarting your project',
2: 'Try our troubleshooting guide',
3: 'Debug with AI',
}}
>
<RestartDatabaseTroubleshootingSection number={1} errorType={ERROR_TYPE} />
<TroubleshootingGuideSection
number={2}
errorType={ERROR_TYPE}
href="https://supabase.com/docs/guides/troubleshooting/failed-to-run-sql-query-connection-terminated-due-to-connection-timeout"
description="Follow step-by-step instructions for diagnosing connection timeout issues."
/>
<FixWithAITroubleshootingSection
number={3}
errorType={ERROR_TYPE}
onDebugWithAI={(prompt) => {
openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
aiSnap.newChat({ initialMessage: prompt })
}}
buildPrompt={BUILD_PROMPT}
/>
</TroubleshootingAccordion>
)
}
@@ -26,6 +26,8 @@ import {
TableRow,
} from 'ui'
import { ErrorMatcher } from '../../ErrorHandling/ErrorMatcher'
import { TroubleshootingAccordion } from '../../ErrorHandling/TroubleshootingAccordion'
import { LoadingCardView, LoadingTableView, NoProjectsState } from './EmptyStates'
import { LoadMoreRows } from './LoadMoreRow'
import { ProjectCard } from './ProjectCard'
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { createSupportFormUrl } from './SupportForm.utils'
describe('createSupportFormUrl', () => {
it('returns base URL with no params', () => {
expect(createSupportFormUrl({})).toBe('/support/new')
})
it('does not append a bare ? when params are empty', () => {
expect(createSupportFormUrl({})).not.toContain('?')
})
it('includes provided params in the query string', () => {
const url = createSupportFormUrl({ projectRef: 'my-project' })
expect(url).toContain('projectRef=my-project')
})
it('includes multiple params', () => {
const url = createSupportFormUrl({ projectRef: 'my-project', subject: 'help' })
expect(url).toContain('projectRef=my-project')
expect(url).toContain('subject=help')
})
})
@@ -143,7 +143,8 @@ const serializeSupportFormInitialParams = createSerializer(supportFormUrlState)
export function createSupportFormUrl(initialParams: Partial<SupportFormUrlKeys>) {
const serializedParams = serializeSupportFormInitialParams(initialParams)
return `/support/new${serializedParams ?? ''}`
const query = serializedParams && serializedParams !== '?' ? serializedParams : ''
return `/support/new${query}`
}
/**
@@ -1,7 +1,4 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Filter, Plus } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { keepPreviousData } from '@tanstack/react-query'
import { useParams } from 'common'
import { useBreakpoint } from 'common/hooks/useBreakpoint'
@@ -9,8 +6,8 @@ import { ExportDialog } from 'components/grid/components/header/ExportDialog'
import { parseSupaTable } from 'components/grid/SupabaseGrid.utils'
import { SupaTable } from 'components/grid/types'
import { ProtectedSchemaWarning } from 'components/interfaces/Database/ProtectedSchemaWarning'
import { ErrorMatcher } from 'components/interfaces/ErrorHandling/ErrorMatcher'
import EditorMenuListSkeleton from 'components/layouts/TableEditorLayout/EditorMenuListSkeleton'
import AlertError from 'components/ui/AlertError'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { InfiniteListDefault, LoaderForIconMenuItems } from 'components/ui/InfiniteList'
import SchemaSelector from 'components/ui/SchemaSelector'
@@ -23,22 +20,25 @@ import { useLocalStorage } from 'hooks/misc/useLocalStorage'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useIsProtectedSchema } from 'hooks/useProtectedSchemas'
import { Filter, Plus } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import {
Button,
Checkbox_Shadcn_,
Label_Shadcn_,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
Popover_Shadcn_,
} from 'ui'
import {
InnerSideBarEmptyPanel,
InnerSideBarFilters,
InnerSideBarFilterSearchInput,
InnerSideBarFilterSortDropdown,
InnerSideBarFilterSortDropdownItem,
InnerSideBarFilters,
} from 'ui-patterns/InnerSideMenu'
import { useTableEditorTabsCleanUp } from '../Tabs/Tabs.utils'
import { EntityListItem } from './EntityListItem'
import { TableMenuEmptyState } from './TableMenuEmptyState'
@@ -277,9 +277,12 @@ export const TableEditorMenu = () => {
{isLoading && <EditorMenuListSkeleton />}
{isError && (
<div className="mx-4">
<AlertError error={(error ?? null) as any} subject="Failed to retrieve tables" />
</div>
<ErrorMatcher
title="Failed to load tables"
error={error ?? 'Failed to load tables'}
supportFormParams={{ projectRef: project?.ref }}
className="mx-4 mt-3"
/>
)}
{isSuccess && (
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { ERROR_PATTERNS } from './error-patterns'
// Representative sample messages for each error class.
// Keep this in sync when adding new patterns — the test will fail if you don't.
const PATTERN_SAMPLES: Record<string, { matches: string[]; nonMatches: string[] }> = {
ConnectionTimeoutError: {
matches: [
'CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT',
'connection terminated due to connection timeout',
'ERROR: FAILED TO RUN SQL QUERY: CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT.',
'Connection Terminated Due To Connection Timeout', // extra whitespace
],
nonMatches: [
'connection timeout',
'connection terminated',
'query timed out',
'idle connection timeout',
'',
],
},
}
describe('ERROR_PATTERNS registry', () => {
it('has a PATTERN_SAMPLES entry for every registered pattern (keep samples in sync)', () => {
for (const { ErrorClass } of ERROR_PATTERNS) {
expect(
PATTERN_SAMPLES,
`Add a PATTERN_SAMPLES entry for '${ErrorClass.name}'`
).toHaveProperty(ErrorClass.name)
}
})
describe('per-pattern match correctness', () => {
for (const { ErrorClass, pattern } of ERROR_PATTERNS) {
const samples = PATTERN_SAMPLES[ErrorClass.name]
if (!samples) continue
describe(ErrorClass.name, () => {
it.each(samples.matches)('matches: %s', (msg) => {
expect(pattern.test(msg)).toBe(true)
})
it.each(samples.nonMatches)('does not match: %s', (msg) => {
expect(pattern.test(msg)).toBe(false)
})
})
}
})
describe('no message matches more than one pattern', () => {
const allSamples = Object.entries(PATTERN_SAMPLES).flatMap(([className, { matches }]) =>
matches.map((msg) => ({ msg, sourceClass: className }))
)
it.each(allSamples)('$sourceClass sample "$msg" matches exactly one pattern', ({ msg }) => {
const matched = ERROR_PATTERNS.filter(({ pattern }) => pattern.test(msg))
expect(matched.length).toBe(1)
})
})
})
+25
View File
@@ -0,0 +1,25 @@
import { ConnectionTimeoutError } from 'types/api-errors'
import type { ClassifiedError } from 'types/api-errors'
import type { ResponseError } from 'types/base'
type ErrorConstructor = new (
...args: ConstructorParameters<typeof ResponseError>
) => ClassifiedError
export interface ErrorPattern {
pattern: RegExp
ErrorClass: ErrorConstructor
}
/**
* Maps each error class to its matching regex pattern.
* Using a Map guarantees each class can only appear once — duplicates are
* impossible by construction rather than only caught by tests.
*/
const ERROR_PATTERN_MAP = new Map<ErrorConstructor, RegExp>([
[ConnectionTimeoutError, /CONNECTION\s+TERMINATED\s+DUE\s+TO\s+CONNECTION\s+TIMEOUT/i],
])
export const ERROR_PATTERNS: ErrorPattern[] = Array.from(ERROR_PATTERN_MAP.entries()).map(
([ErrorClass, pattern]) => ({ ErrorClass, pattern })
)
+22 -10
View File
@@ -1,12 +1,14 @@
import * as Sentry from '@sentry/nextjs'
import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants'
import { IS_PLATFORM, getAccessToken } from 'common'
import { getAccessToken, IS_PLATFORM } from 'common'
import { API_URL } from 'lib/constants'
import { uuidv4 } from 'lib/helpers'
import createClient from 'openapi-fetch'
import { ResponseError } from 'types'
import { UnknownAPIResponseError } from 'types/api-errors'
import type { paths } from './api'
import { ERROR_PATTERNS } from './error-patterns'
import { ErrorMetadata } from '@/types/base'
// generated from openapi-typescript
@@ -172,14 +174,24 @@ export const handleError = (error: unknown, options: HandleErrorOptions = {}): n
: undefined
if (errorMessage) {
throw new ResponseError(
errorMessage,
errorCode,
requestId,
retryAfter,
requestPathname,
metadata
)
const matched = ERROR_PATTERNS.find(({ pattern }) => pattern.test(errorMessage))
throw matched
? new matched.ErrorClass(
errorMessage,
errorCode,
requestId,
retryAfter,
requestPathname,
metadata
)
: new UnknownAPIResponseError(
errorMessage,
errorCode,
requestId,
retryAfter,
requestPathname,
metadata
)
}
}
@@ -193,7 +205,7 @@ export const handleError = (error: unknown, options: HandleErrorOptions = {}): n
// throw a generic error if we don't know what the error is. The message is intentionally vague because it might show
// up in the UI.
throw new ResponseError(undefined)
throw new UnknownAPIResponseError(undefined)
}
// [Joshen] The methods below are brought over from lib/common/fetch because we still need them
+98
View File
@@ -0,0 +1,98 @@
import { ConnectionTimeoutError, UnknownAPIResponseError } from 'types/api-errors'
import { ResponseError } from 'types/base'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sentry/nextjs', () => ({ captureException: vi.fn() }))
vi.mock('common', () => ({ IS_PLATFORM: false, getAccessToken: vi.fn() }))
vi.mock('lib/constants', () => ({ API_URL: 'http://localhost' }))
vi.mock('lib/helpers', () => ({ uuidv4: () => 'test-uuid' }))
// Import after mocks are set up
const { handleError } = await import('./fetchers')
function throwAndCatch(error: unknown): ResponseError {
try {
handleError(error)
} catch (e) {
return e as ResponseError
}
throw new Error('handleError did not throw')
}
describe('handleError — error classification', () => {
beforeEach(() => vi.clearAllMocks())
describe('known patterns', () => {
it('classifies connection timeout via message field', () => {
const err = throwAndCatch({ message: 'CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT' })
expect(err).toBeInstanceOf(ConnectionTimeoutError)
expect((err as ConnectionTimeoutError).errorType).toBe('connection-timeout')
})
it('classifies connection timeout via msg field', () => {
const err = throwAndCatch({ msg: 'ERROR: CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT' })
expect(err).toBeInstanceOf(ConnectionTimeoutError)
})
it('classification is case-insensitive', () => {
const err = throwAndCatch({ message: 'connection terminated due to connection timeout' })
expect(err).toBeInstanceOf(ConnectionTimeoutError)
})
it('classified error is still instanceof ResponseError', () => {
const err = throwAndCatch({ message: 'CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT' })
expect(err).toBeInstanceOf(ResponseError)
})
})
describe('unclassified errors', () => {
it('throws UnknownAPIResponseError for unmatched messages', () => {
const err = throwAndCatch({ message: 'something went wrong' })
expect(err).toBeInstanceOf(UnknownAPIResponseError)
expect(err).toBeInstanceOf(ResponseError)
})
it('throws UnknownAPIResponseError for empty message', () => {
const err = throwAndCatch({ message: '' })
expect(err).toBeInstanceOf(UnknownAPIResponseError)
})
it('throws UnknownAPIResponseError for null', () => {
const err = throwAndCatch(null)
expect(err).toBeInstanceOf(UnknownAPIResponseError)
})
it('throws UnknownAPIResponseError for non-object', () => {
const err = throwAndCatch('raw string error')
expect(err).toBeInstanceOf(UnknownAPIResponseError)
})
})
describe('field preservation', () => {
it('preserves all ResponseError fields on classified errors', () => {
const err = throwAndCatch({
message: 'CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT',
code: 503,
requestId: 'req-abc',
retryAfter: 30,
requestPathname: '/rest/v1/table',
})
expect(err.message).toBe('CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT')
expect(err.code).toBe(503)
expect(err.requestId).toBe('req-abc')
expect(err.retryAfter).toBe(30)
expect(err.requestPathname).toBe('/rest/v1/table')
})
it('preserves all ResponseError fields on unclassified errors', () => {
const err = throwAndCatch({ message: 'some error', code: 500, requestId: 'req-xyz' })
expect(err.code).toBe(500)
expect(err.requestId).toBe('req-xyz')
})
it('msg field takes priority over message field for error text', () => {
const err = throwAndCatch({ msg: 'from msg field', message: 'from message field' })
expect(err.message).toBe('from msg field')
})
})
})
+2 -2
View File
@@ -51,7 +51,7 @@ export function captureCriticalError(
return
}
handleUnknownError(error, context)
handleUnknownAPIResponseError(error, context)
}
function handleResponseError(error: ResponseError, context: string) {
@@ -85,7 +85,7 @@ function handleError(error: Error, context: string) {
})
}
function handleUnknownError(error: unknown, context: string) {
function handleUnknownAPIResponseError(error: unknown, context: string) {
if (
error &&
typeof error === 'object' &&
+36
View File
@@ -0,0 +1,36 @@
import { ResponseError } from './base'
import type { ErrorMetadata } from './base'
export type KnownErrorType = 'connection-timeout'
export class ConnectionTimeoutError extends ResponseError {
readonly errorType = 'connection-timeout' as const
constructor(
message: string | undefined,
code?: number,
requestId?: string,
retryAfter?: number,
requestPathname?: string,
metadata?: ErrorMetadata
) {
super(message, code, requestId, retryAfter, requestPathname, metadata)
}
}
export class UnknownAPIResponseError extends ResponseError {
readonly errorType = 'unknown' as const
constructor(
message: string | undefined,
code?: number,
requestId?: string,
retryAfter?: number,
requestPathname?: string,
metadata?: ErrorMetadata
) {
super(message, code, requestId, retryAfter, requestPathname, metadata)
}
}
export type ClassifiedError = ConnectionTimeoutError | UnknownAPIResponseError
+1
View File
@@ -100,6 +100,7 @@ export class ResponseError extends Error {
retryAfter?: number
requestPathname?: string
metadata?: CostMetadata
errorType?: string
constructor(
message: string | undefined,
+67 -2
View File
@@ -2791,7 +2791,7 @@ export interface RequestUpgradeSubmittedEvent {
/**
* Triggered when a Studio error UI element is displayed (mounted).
* This includes error Admonitions and Toast notifications.
* This includes error Admonitions, Toast notifications, and ErrorDisplay components.
*
* @group Events
* @source studio
@@ -2802,7 +2802,69 @@ export interface DashboardErrorCreatedEvent {
/**
* Source of the error
*/
source?: 'admonition' | 'toast'
source?: 'admonition' | 'toast' | 'error_display'
/**
* Type of error matched (for error_display source)
*/
errorType?: string
/**
* Whether troubleshooting steps are available (for error_display source)
*/
hasTroubleshooting?: boolean
}
groups: TelemetryGroups
}
/**
* Triggered when the inline error troubleshooter is shown to the user.
*
* @group Events
* @source studio
*/
export interface InlineErrorTroubleshooterExposedEvent {
action: 'inline_error_troubleshooter_exposed'
properties: {
/** ID of the matched error mapping */
errorType: string
}
groups: TelemetryGroups
}
/**
* Triggered when a user opens or closes a troubleshooting accordion step.
*
* @group Events
* @source studio
*/
export interface InlineErrorTroubleshooterStepClickedEvent {
action: 'inline_error_troubleshooter_step_clicked'
properties: {
/** ID of the matched error mapping */
errorType: string
/** Step number that was clicked (1, 2, 3, ...) — null when a step is collapsed */
step: number | null
/** Title of the step that was clicked */
stepTitle?: string
/** Whether the step was opened (true) or closed (false) */
expanded: boolean
}
groups: TelemetryGroups
}
/**
* Triggered when a user clicks an action within the inline error troubleshooter.
* Covers all CTAs including the contact support link.
*
* @group Events
* @source studio
*/
export interface InlineErrorTroubleshooterActionClickedEvent {
action: 'inline_error_troubleshooter_action_clicked'
properties: {
/** ID of the matched error mapping */
errorType: string
/** Which CTA was clicked */
ctaType: 'restart_db' | 'troubleshooting_guide' | 'ask_ai' | 'contact_support'
}
groups: TelemetryGroups
}
@@ -3104,6 +3166,9 @@ export type TelemetryEvent =
| RequestUpgradeModalOpenedEvent
| RequestUpgradeSubmittedEvent
| DashboardErrorCreatedEvent
| InlineErrorTroubleshooterExposedEvent
| InlineErrorTroubleshooterStepClickedEvent
| InlineErrorTroubleshooterActionClickedEvent
| IntegrationInstallCompletedEvent
| IntegrationInstallSubmittedEvent
| IntegrationUninstallSubmittedEvent
+52 -4
View File
@@ -222,6 +222,10 @@
"import": "./src/CommandMenu/prepackaged/Support.tsx",
"types": "./src/CommandMenu/prepackaged/Support.tsx"
},
"./CommandMenu/prepackaged/ThemeSwitcher.test": {
"import": "./src/CommandMenu/prepackaged/ThemeSwitcher.test.tsx",
"types": "./src/CommandMenu/prepackaged/ThemeSwitcher.test.tsx"
},
"./CommandMenu/prepackaged/ThemeSwitcher": {
"import": "./src/CommandMenu/prepackaged/ThemeSwitcher.tsx",
"types": "./src/CommandMenu/prepackaged/ThemeSwitcher.tsx"
@@ -302,10 +306,30 @@
"import": "./src/EmptyStatePresentational/index.tsx",
"types": "./src/EmptyStatePresentational/index.tsx"
},
"./ErrorDisplay/ErrorDisplay": {
"import": "./src/ErrorDisplay/ErrorDisplay.tsx",
"types": "./src/ErrorDisplay/ErrorDisplay.tsx"
},
"./ErrorDisplay/ErrorDisplay.types": {
"import": "./src/ErrorDisplay/ErrorDisplay.types.ts",
"types": "./src/ErrorDisplay/ErrorDisplay.types.ts"
},
"./ErrorDisplay": {
"import": "./src/ErrorDisplay/ErrorDisplay.tsx",
"types": "./src/ErrorDisplay/ErrorDisplay.tsx"
},
"./ExpandableVideo": {
"import": "./src/ExpandableVideo/index.tsx",
"types": "./src/ExpandableVideo/index.tsx"
},
"./FilterBar/CommandListItem": {
"import": "./src/FilterBar/CommandListItem.tsx",
"types": "./src/FilterBar/CommandListItem.tsx"
},
"./FilterBar/DefaultCommandList.helpers": {
"import": "./src/FilterBar/DefaultCommandList.helpers.tsx",
"types": "./src/FilterBar/DefaultCommandList.helpers.tsx"
},
"./FilterBar/DefaultCommandList": {
"import": "./src/FilterBar/DefaultCommandList.tsx",
"types": "./src/FilterBar/DefaultCommandList.tsx"
@@ -330,6 +354,10 @@
"import": "./src/FilterBar/FilterGroup.tsx",
"types": "./src/FilterBar/FilterGroup.tsx"
},
"./FilterBar/OperatorSymbolBadge": {
"import": "./src/FilterBar/OperatorSymbolBadge.tsx",
"types": "./src/FilterBar/OperatorSymbolBadge.tsx"
},
"./FilterBar/hooks.test": {
"import": "./src/FilterBar/hooks.test.ts",
"types": "./src/FilterBar/hooks.test.ts"
@@ -342,6 +370,10 @@
"import": "./src/FilterBar/index.ts",
"types": "./src/FilterBar/index.ts"
},
"./FilterBar/menuItems.test": {
"import": "./src/FilterBar/menuItems.test.ts",
"types": "./src/FilterBar/menuItems.test.ts"
},
"./FilterBar/menuItems": {
"import": "./src/FilterBar/menuItems.ts",
"types": "./src/FilterBar/menuItems.ts"
@@ -350,10 +382,18 @@
"import": "./src/FilterBar/types.ts",
"types": "./src/FilterBar/types.ts"
},
"./FilterBar/useAIFilter": {
"import": "./src/FilterBar/useAIFilter.ts",
"types": "./src/FilterBar/useAIFilter.ts"
},
"./FilterBar/useCommandHandling": {
"import": "./src/FilterBar/useCommandHandling.ts",
"types": "./src/FilterBar/useCommandHandling.ts"
},
"./FilterBar/useCommandMenu": {
"import": "./src/FilterBar/useCommandMenu.ts",
"types": "./src/FilterBar/useCommandMenu.ts"
},
"./FilterBar/useKeyboardNavigation": {
"import": "./src/FilterBar/useKeyboardNavigation.ts",
"types": "./src/FilterBar/useKeyboardNavigation.ts"
@@ -422,6 +462,10 @@
"import": "./src/McpUrlBuilder/utils/getMcpButtonData.ts",
"types": "./src/McpUrlBuilder/utils/getMcpButtonData.ts"
},
"./McpUrlBuilder/utils/getMcpIconSrc": {
"import": "./src/McpUrlBuilder/utils/getMcpIconSrc.ts",
"types": "./src/McpUrlBuilder/utils/getMcpIconSrc.ts"
},
"./McpUrlBuilder/utils/getMcpUrl": {
"import": "./src/McpUrlBuilder/utils/getMcpUrl.ts",
"types": "./src/McpUrlBuilder/utils/getMcpUrl.ts"
@@ -430,6 +474,10 @@
"import": "./src/MetricCard/index.tsx",
"types": "./src/MetricCard/index.tsx"
},
"./MobileSheetNav/MobileSheetNav.test": {
"import": "./src/MobileSheetNav/MobileSheetNav.test.tsx",
"types": "./src/MobileSheetNav/MobileSheetNav.test.tsx"
},
"./MobileSheetNav/MobileSheetNav": {
"import": "./src/MobileSheetNav/MobileSheetNav.tsx",
"types": "./src/MobileSheetNav/MobileSheetNav.tsx"
@@ -450,6 +498,10 @@
"import": "./src/MultiSelectDeprecated/index.tsx",
"types": "./src/MultiSelectDeprecated/index.tsx"
},
"./MultipleCodeBlock": {
"import": "./src/MultipleCodeBlock/index.tsx",
"types": "./src/MultipleCodeBlock/index.tsx"
},
"./PageContainer": {
"import": "./src/PageContainer/index.tsx",
"types": "./src/PageContainer/index.tsx"
@@ -582,10 +634,6 @@
"import": "./src/admonition.tsx",
"types": "./src/admonition.tsx"
},
"./MultipleCodeBlock": {
"import": "./src/MultipleCodeBlock/index.tsx",
"types": "./src/MultipleCodeBlock/index.tsx"
},
"./consent": {
"import": "./src/consent.tsx",
"types": "./src/consent.tsx"
@@ -0,0 +1,90 @@
'use client'
import { HelpCircle } from 'lucide-react'
import { forwardRef, useEffect, useRef } from 'react'
import { Card, CardHeader, cn } from 'ui'
import { WarningIcon } from '../admonition'
import type { ErrorDisplayProps, SupportFormParams } from './ErrorDisplay.types'
export type { SupportFormParams } from './ErrorDisplay.types'
function buildSupportUrl(params?: SupportFormParams) {
if (!params) return '/support/new'
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== '')
if (entries.length === 0) return '/support/new'
return `/support/new?${new URLSearchParams(entries as [string, string][]).toString()}`
}
export const ErrorDisplay = forwardRef<HTMLDivElement, ErrorDisplayProps>(
(
{
title,
errorMessage,
supportFormParams,
supportLabel = 'Contact support',
children,
className,
icon,
onRender,
onSupportClick,
...props
},
ref
) => {
const hasFired = useRef(false)
useEffect(() => {
if (hasFired.current) return
hasFired.current = true
onRender?.()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const supportUrl = buildSupportUrl(supportFormParams)
return (
<Card
ref={ref}
className={cn('rounded-lg border border-default', className)}
role="alert"
aria-labelledby="error-display-title"
{...props}
>
<CardHeader className="flex-row items-center gap-2.5 space-y-0 p-3">
<div className="bg-warning p-1 text-background rounded-md">
{icon ?? <WarningIcon className="w-3 h-3" />}
</div>
<h3 id="error-display-title" className="text-sm text-foreground mt-0">
{title}
</h3>
</CardHeader>
<div className="px-4 py-3 bg-warning-200 border-y border-warning-500">
<pre className="text-xs font-mono text-warning-600 whitespace-pre-wrap break-words overflow-auto max-h-32">
{errorMessage}
</pre>
</div>
{children && <div>{children}</div>}
<div className="px-3 py-2 border-t border-default flex items-center gap-2">
<div className="flex-shrink-0">
<HelpCircle className="h-4 w-4 text-foreground-muted" />
</div>
<span className="text-sm text-foreground-light">Need help?</span>
<a
href={supportUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-foreground flex-shrink-0 underline hover:text-foreground-light transition-colors"
onClick={onSupportClick}
>
{supportLabel}
</a>
</div>
</Card>
)
}
)
ErrorDisplay.displayName = 'ErrorDisplay'
@@ -0,0 +1,67 @@
import { ComponentPropsWithoutRef, ReactNode } from 'react'
export interface SupportFormParams {
projectRef?: string
orgSlug?: string
category?: string
subject?: string
message?: string
error?: string
/** Sentry event ID */
sid?: string
}
export interface ErrorDisplayProps extends ComponentPropsWithoutRef<'div'> {
/**
* Title displayed in the header with warning icon
* @example "Failed to retrieve tables"
*/
title: string
/**
* Error message displayed in monospace code block style
* @example "ERROR: FAILED TO RUN SQL QUERY: CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT."
*/
errorMessage: string
/**
* Typed params for the support form URL. The component builds the URL automatically.
* The "Contact support" footer is always shown.
* @example { projectRef: 'my-project' }
*/
supportFormParams?: SupportFormParams
/**
* Text for the support link
* @default "Contact support"
*/
supportLabel?: string
/**
* Children slot for accordion-style troubleshooting steps
* Typically contains TroubleshootingAccordion or similar content
*/
children?: ReactNode
/**
* Additional CSS classes for the root container
*/
className?: string
/**
* Custom icon to display in the header. Defaults to a filled warning triangle SVG.
*/
icon?: ReactNode
/**
* Callback fired when the component is rendered
* Useful for tracking error display events
*/
onRender?: () => void
/**
* Callback fired when the contact support link is clicked
* Useful for tracking support escalations
*/
onSupportClick?: () => void
}
+2 -2
View File
@@ -55,11 +55,11 @@ const InfoIcon = () => (
</svg>
)
const WarningIcon = () => (
export const WarningIcon = ({ className }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 22 20"
className="w-6 h-6"
className={cn('w-6 h-6', className)}
fill="currentColor"
>
<path