mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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.
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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
|
||||
@@ -100,6 +100,7 @@ export class ResponseError extends Error {
|
||||
retryAfter?: number
|
||||
requestPathname?: string
|
||||
metadata?: CostMetadata
|
||||
errorType?: string
|
||||
|
||||
constructor(
|
||||
message: string | undefined,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user