fix(ux): friendly error UI вместо raw AxiosError

This commit is contained in:
Александр Зимин
2026-05-12 12:41:48 +00:00
parent db0986d235
commit 3984d99004
14 changed files with 274 additions and 57 deletions
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import { Alert, Badge, LoadingBlock, Modal } from '@/ui'
import { Badge, LoadingBlock, Modal, QueryErrorState } from '@/ui'
import { useChangelogDiff } from '@/api/queries'
import { kindBadgeVariant, kindLabel } from './kind-meta'
@@ -28,7 +28,7 @@ export function ChangelogDiffModal({
entryId: number | undefined
}) {
const { t } = useTranslation()
const { data, isLoading, error } = useChangelogDiff(
const { data, isLoading, error, refetch } = useChangelogDiff(
open ? dictionaryName : undefined,
open ? entryId : undefined,
)
@@ -46,11 +46,7 @@ export function ChangelogDiffModal({
}
>
{isLoading && <LoadingBlock size="md" label={t('loading')} />}
{error && (
<Alert variant="error" title={t('error.failed')}>
{String(error)}
</Alert>
)}
{error && <QueryErrorState error={error} onRetry={() => refetch()} />}
{data && (
<div className="space-y-4">
{/* Header strip — kind + author */}
@@ -13,7 +13,7 @@ import {
} from 'd3-force'
import { useDictionaries, dictionaryDependentsQuery, recordsQuery } from '@/api/queries'
import type { DictionaryDefinition, SchemaDependent } from '@/api/client'
import { GlobeLoader, LoadingBlock } from '@/ui'
import { GlobeLoader, LoadingBlock, QueryErrorState } from '@/ui'
import { cn } from '@/lib/utils'
// 3D-режим лениво грузится — three.js (~650kb) и react-force-graph-3d не
@@ -165,7 +165,7 @@ export function DictionaryGraph() {
}
if (dictsQuery.error) {
return <div className="text-body text-pink">Ошибка загрузки: {String(dictsQuery.error)}</div>
return <QueryErrorState error={dictsQuery.error} onRetry={() => dictsQuery.refetch()} />
}
if (dicts.length === 0) {
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from '@tanstack/react-router'
import { Alert, Badge, Button, LoadingBlock } from '@/ui'
import { Badge, Button, LoadingBlock, QueryErrorState } from '@/ui'
import { CaretLeftIcon, CaretRightIcon, ArrowSquareOutIcon } from '@phosphor-icons/react'
import { useRecordDependents } from '@/api/queries'
import type { OnCloseAction } from '@/api/client'
@@ -32,7 +32,7 @@ export const RecordDependentsPanel = ({ dictionaryName, businessKey }: Props) =>
const { t } = useTranslation()
const [page, setPage] = useState(0)
const size = 20
const { data, isLoading, error } = useRecordDependents(
const { data, isLoading, error, refetch } = useRecordDependents(
dictionaryName,
businessKey,
page,
@@ -42,11 +42,7 @@ export const RecordDependentsPanel = ({ dictionaryName, businessKey }: Props) =>
if (!businessKey) return null
if (isLoading) return <LoadingBlock size="sm" label={t('loading')} />
if (error) {
return (
<Alert variant="error" title={t('error.failed')}>
{String(error)}
</Alert>
)
return <QueryErrorState error={error} onRetry={() => refetch()} />
}
if (!data || data.total === 0) return null
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Alert, Badge, Button, Drawer, LoadingBlock } from '@/ui'
import { Badge, Button, Drawer, LoadingBlock, QueryErrorState } from '@/ui'
import { ArrowsLeftRightIcon, ArrowCounterClockwiseIcon, BracketsCurlyIcon, InfoIcon } from '@phosphor-icons/react'
import { useRecordHistory } from '@/api/queries'
import { RecordDependentsPanel } from '@/components/lineage/RecordDependentsPanel'
@@ -23,7 +23,7 @@ export const RecordHistoryDrawer = ({
open, onClose, dictionaryName, businessKey, onCompare, onRevert,
}: Props) => {
const { t } = useTranslation()
const { data, isLoading, error } = useRecordHistory(dictionaryName, open ? businessKey : undefined)
const { data, isLoading, error, refetch } = useRecordHistory(dictionaryName, open ? businessKey : undefined)
// Per-row collapsible JSON viewer — выносим из <details> чтобы хранить
// expanded state поверх renders.
const [expanded, setExpanded] = useState<Set<string>>(new Set())
@@ -48,11 +48,7 @@ export const RecordHistoryDrawer = ({
)}
{isLoading && <LoadingBlock size="md" label={t('loading')} />}
{error && (
<Alert variant="error" title={t('error.failed')}>
{String(error)}
</Alert>
)}
{error && <QueryErrorState error={error} onRetry={() => refetch()} />}
{data && data.length === 0 && (
<p className="text-body text-mute">{t('history.empty')}</p>
)}
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import axios from 'axios'
import { Alert, Badge, Button, Drawer, LoadingBlock, TextArea, toast } from '@/ui'
import { Badge, Button, Drawer, LoadingBlock, QueryErrorState, TextArea, toast } from '@/ui'
import {
CheckIcon,
PaperPlaneTiltIcon,
@@ -69,7 +69,7 @@ export function SchemaDraftDrawer({
const { t } = useTranslation()
const [comment, setComment] = useState('')
const [editOpen, setEditOpen] = useState(false)
const { data: draft, isLoading, error } = useDictActiveSchemaDraft(
const { data: draft, isLoading, error, refetch } = useDictActiveSchemaDraft(
open ? dictionaryName : undefined,
)
@@ -204,11 +204,7 @@ export function SchemaDraftDrawer({
>
<div className="space-y-4">
{isLoading && <LoadingBlock size="md" label={t('loading')} />}
{error && (
<Alert variant="error" title={t('error.failed')}>
{String(error)}
</Alert>
)}
{error && <QueryErrorState error={error} onRetry={() => refetch()} />}
{!isLoading && !draft && (
<p className="text-body text-mute">
{t('workflow.schemaDraft.empty', {
+36
View File
@@ -183,6 +183,24 @@ i18n
'webhooks.stats.successRateOf_one': 'из {{count}} завершённой',
'webhooks.stats.successRateOf_few': 'из {{count}} завершённых',
'webhooks.stats.successRateOf_many': 'из {{count}} завершённых',
// === Friendly query error states ===
'queryError.retry': 'Повторить',
'queryError.network.title': 'Нет соединения с сервером',
'queryError.network.body': 'Проверьте сеть. Когда соединение восстановится — повторите.',
'queryError.unavailable.title': 'Сервер временно недоступен',
'queryError.unavailable.body': 'Возможно идёт обновление или плановое обслуживание. Подождите несколько секунд и повторите.',
'queryError.server.title': 'Внутренняя ошибка сервера',
'queryError.server.body': 'Что-то пошло не так на стороне сервера. Если ошибка повторяется — сообщите администратору.',
'queryError.notFound.title': 'Не найдено',
'queryError.notFound.body': 'Запрашиваемые данные не существуют или были удалены.',
'queryError.forbidden.title': 'Нет доступа',
'queryError.forbidden.body': 'У вас нет прав на просмотр этих данных.',
'queryError.unauthorized.title': 'Требуется вход',
'queryError.unauthorized.body': 'Сессия истекла. Войдите снова чтобы продолжить.',
'queryError.client.title': 'Запрос некорректен',
'queryError.client.body': 'Сервер отклонил запрос.',
'queryError.generic.title': 'Не удалось загрузить данные',
'queryError.generic.body': 'Что-то пошло не так. Попробуйте ещё раз.',
'webhooks.deliveries.col.eventId': 'Event ID',
'webhooks.deliveries.col.attempts': 'Попыток',
'webhooks.deliveries.col.lastAttempt': 'Последняя попытка',
@@ -830,6 +848,24 @@ i18n
'webhooks.stats.successRate': 'Success rate',
'webhooks.stats.successRateOf_one': 'of {{count}} terminal',
'webhooks.stats.successRateOf_other': 'of {{count}} terminal',
// === Friendly query error states ===
'queryError.retry': 'Retry',
'queryError.network.title': 'No connection to server',
'queryError.network.body': 'Check your network. Once connected, retry.',
'queryError.unavailable.title': 'Server temporarily unavailable',
'queryError.unavailable.body': 'Possibly an update or scheduled maintenance. Wait a few seconds and retry.',
'queryError.server.title': 'Internal server error',
'queryError.server.body': 'Something went wrong on the server side. If it keeps happening, please contact the admin.',
'queryError.notFound.title': 'Not found',
'queryError.notFound.body': 'The requested data does not exist or has been removed.',
'queryError.forbidden.title': 'Access denied',
'queryError.forbidden.body': 'You do not have permission to view this data.',
'queryError.unauthorized.title': 'Login required',
'queryError.unauthorized.body': 'Session expired. Sign in again to continue.',
'queryError.client.title': 'Request rejected',
'queryError.client.body': 'The server rejected the request.',
'queryError.generic.title': 'Failed to load data',
'queryError.generic.body': 'Something went wrong. Please try again.',
'webhooks.deliveries.col.eventId': 'Event ID',
'webhooks.deliveries.col.attempts': 'Attempts',
'webhooks.deliveries.col.lastAttempt': 'Last attempt',
+2 -4
View File
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import {
Alert,
Badge,
Button,
DatePicker,
@@ -10,6 +9,7 @@ import {
IconButton,
LoadingBlock,
PageHeader,
QueryErrorState,
SingleSelect,
Table,
TableBody,
@@ -266,9 +266,7 @@ function AuditPage() {
)}
{error ? (
<Alert variant="error" title={t('error.failed')}>
{String(error)}
</Alert>
<QueryErrorState error={error} onRetry={() => refetch()} />
) : isLoading ? (
<LoadingBlock size="md" label={t('loading')} />
) : !data || data.content.length === 0 ? (
@@ -16,6 +16,7 @@ import {
IconButton,
LoadingBlock,
Modal,
QueryErrorState,
SearchInput,
Table,
TableBody,
@@ -629,11 +630,7 @@ function DictionaryDetail() {
)
}
if (detailQuery.error) {
return (
<Alert variant="error" title={t('error.failed')}>
{String(detailQuery.error)}
</Alert>
)
return <QueryErrorState error={detailQuery.error} onRetry={() => detailQuery.refetch()} />
}
return (
@@ -16,7 +16,7 @@
import { useDeferredValue, useMemo, useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader } from '@/ui'
import { Badge, Button, EmptyState, LoadingBlock, PageHeader, QueryErrorState } from '@/ui'
import { PlusIcon } from '@phosphor-icons/react'
import { useQueries } from '@tanstack/react-query'
import { dictionaryDependentsQuery, useDictionaries, useDictionaryDependents, useRecords } from '@/api/queries'
@@ -94,7 +94,7 @@ function DictionariesPage() {
const { t } = useTranslation()
const navigate = useNavigate({ from: '/dictionaries/' })
const search = Route.useSearch()
const { data, isLoading, error } = useDictionaries()
const { data, isLoading, error, refetch } = useDictionaries()
const [createOpen, setCreateOpen] = useState(false)
const canMutate = useCanMutate()
@@ -193,11 +193,7 @@ function DictionariesPage() {
)
}
if (error) {
return (
<Alert variant="error" title={t('error.failed')}>
{String(error)}
</Alert>
)
return <QueryErrorState error={error} onRetry={() => refetch()} />
}
if (!data || data.length === 0) {
return (
+2 -4
View File
@@ -2,12 +2,12 @@ import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import {
Alert,
Badge,
Button,
EmptyState,
LoadingBlock,
PageHeader,
QueryErrorState,
Table,
TableBody,
TableCell,
@@ -64,9 +64,7 @@ function OutboxPage() {
</div>
{dlq.error ? (
<Alert variant="error" title={t('error.failed')}>
{String(dlq.error)}
</Alert>
<QueryErrorState error={dlq.error} onRetry={() => dlq.refetch()} />
) : dlq.isLoading ? (
<LoadingBlock size="md" label={t('loading')} />
) : !dlq.data || dlq.data.content.length === 0 ? (
+2 -3
View File
@@ -21,6 +21,7 @@ import {
Tabs,
TextArea,
TextInput,
QueryErrorState,
} from '@/ui'
import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react'
import { useDraft, useLiveRecord, useReviewQueue, useSchemaReviewQueue } from '@/api/queries'
@@ -204,9 +205,7 @@ function ReviewsPage() {
)}
{activeTab === 'records' && queue.error && (
<Alert variant="error" title={t('error.failed')}>
{String(queue.error)}
</Alert>
<QueryErrorState error={queue.error} onRetry={() => queue.refetch()} />
)}
{activeTab === 'records' && bulkResult && (
+2 -4
View File
@@ -2,12 +2,12 @@ import { useState } from 'react'
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import {
Alert,
Badge,
EmptyState,
LoadingBlock,
PageHeader,
Panel,
QueryErrorState,
SearchInput,
} from '@/ui'
import { useSearch } from '@/api/queries'
@@ -78,9 +78,7 @@ function SearchPage() {
)}
{hasQuery && result.error && (
<Alert variant="error" title={t('error.failed')}>
{String(result.error)}
</Alert>
<QueryErrorState error={result.error} onRetry={() => result.refetch()} />
)}
{hasQuery && result.data && result.data.total === 0 && (
@@ -0,0 +1,207 @@
import { useTranslation } from 'react-i18next'
import axios from 'axios'
import { Alert } from './alert'
import { Button } from './button'
import { ArrowClockwiseIcon } from '@phosphor-icons/react'
/**
* Friendly error renderer для TanStack Query errors. Замена паттерна
* {@code <Alert variant="error">{String(error)}</Alert>} который раздавал
* raw {@code "AxiosError: Request failed with status code 503"} в UI.
*
* <p>Маппинг status human message:
* <ul>
* <li>503/502/504 сервер временно недоступен (rolling restart, GW timeout)</li>
* <li>500 внутренняя ошибка сервера</li>
* <li>404 не найдено</li>
* <li>403 нет доступа</li>
* <li>401 handled global'ным interceptor'ом, но fallback'нем</li>
* <li>network err нет соединения</li>
* <li>default generic failure с raw message только в debug режиме</li>
* </ul>
*
* <p>Backend code/message парсятся если возвращены: вместо `AxiosError: ...`
* показывается {@code body.message} (что backend сам положил). Это работает
* только для structured 4xx responses; 5xx обычно plain html error page.
*
* <p>Retry кнопка opt-in через {@code onRetry} prop. Caller передаёт
* {@code () => query.refetch()}.
*/
export type QueryErrorStateProps = {
/** TanStack Query error (или любая Error/unknown) */
error: unknown
/** Optional retry callback — обычно `() => query.refetch()`. */
onRetry?: () => void
/** Optional title override (по умолчанию по status code'у). */
title?: React.ReactNode
/** className для Alert wrapper (e.g. mt-4). */
className?: string
}
type ErrorClassification = {
titleKey: string
titleFallback: string
bodyKey: string
bodyFallback: string
/** Можно retry'нуть. False для permanent errors (404/403). */
retryable: boolean
/** Backend-provided code (если был structured response). */
backendCode?: string
/** Backend-provided human message (если был). */
backendMessage?: string
}
function classify(error: unknown): ErrorClassification {
if (axios.isAxiosError(error)) {
const status = error.response?.status
const body = error.response?.data as
| { code?: string; message?: string; error?: string }
| undefined
const backendCode = body?.code
const backendMessage = body?.message ?? body?.error
// Network error (no response, e.g. DNS, offline, CORS preflight failed).
if (!error.response) {
return {
titleKey: 'queryError.network.title',
titleFallback: 'Нет соединения с сервером',
bodyKey: 'queryError.network.body',
bodyFallback: 'Проверьте сеть. Когда соединение восстановится — повторите.',
retryable: true,
}
}
if (status === 503 || status === 502 || status === 504) {
return {
titleKey: 'queryError.unavailable.title',
titleFallback: 'Сервер временно недоступен',
bodyKey: 'queryError.unavailable.body',
bodyFallback:
'Возможно идёт обновление или плановое обслуживание. Подождите несколько секунд и повторите.',
retryable: true,
}
}
if (status === 500) {
return {
titleKey: 'queryError.server.title',
titleFallback: 'Внутренняя ошибка сервера',
bodyKey: 'queryError.server.body',
bodyFallback:
'Что-то пошло не так на стороне сервера. Если ошибка повторяется — сообщите администратору.',
retryable: true,
backendCode,
backendMessage,
}
}
if (status === 404) {
return {
titleKey: 'queryError.notFound.title',
titleFallback: 'Не найдено',
bodyKey: 'queryError.notFound.body',
bodyFallback: 'Запрашиваемые данные не существуют или были удалены.',
retryable: false,
backendCode,
backendMessage,
}
}
if (status === 403) {
return {
titleKey: 'queryError.forbidden.title',
titleFallback: 'Нет доступа',
bodyKey: 'queryError.forbidden.body',
bodyFallback: 'У вас нет прав на просмотр этих данных.',
retryable: false,
backendCode,
backendMessage,
}
}
if (status === 401) {
return {
titleKey: 'queryError.unauthorized.title',
titleFallback: 'Требуется вход',
bodyKey: 'queryError.unauthorized.body',
bodyFallback: 'Сессия истекла. Войдите снова чтобы продолжить.',
retryable: false,
}
}
if (status && status >= 400 && status < 500) {
return {
titleKey: 'queryError.client.title',
titleFallback: 'Запрос некорректен',
bodyKey: 'queryError.client.body',
bodyFallback: backendMessage ?? 'Сервер отклонил запрос.',
retryable: false,
backendCode,
backendMessage,
}
}
}
// Non-axios error: Error instance or unknown
const msg =
error instanceof Error
? error.message
: typeof error === 'string'
? error
: 'unknown error'
return {
titleKey: 'queryError.generic.title',
titleFallback: 'Не удалось загрузить данные',
bodyKey: 'queryError.generic.body',
bodyFallback: msg,
retryable: true,
}
}
export function QueryErrorState({
error,
onRetry,
title,
className,
}: QueryErrorStateProps) {
const { t } = useTranslation()
const c = classify(error)
// Backend message приоритетней generic — если backend сказал что-то
// понятное в body.message, покажем это. Иначе fallback.
const body = c.backendMessage
? c.backendMessage
: t(c.bodyKey, { defaultValue: c.bodyFallback })
// Alert.title типизирован как HTML title (string) из-за HTMLAttributes
// intersection с custom React.ReactNode. Stringify чтобы TS не ругался —
// в practice title всегда plain string label.
const titleStr =
typeof title === 'string'
? title
: String(title ?? t(c.titleKey, { defaultValue: c.titleFallback }))
return (
<Alert
variant="error"
title={titleStr}
className={className}
action={
c.retryable && onRetry ? (
<Button
variant="ghost"
size="sm"
onClick={onRetry}
leftIcon={<ArrowClockwiseIcon weight="regular" size={14} />}
>
{t('queryError.retry', { defaultValue: 'Повторить' })}
</Button>
) : undefined
}
>
<div className="space-y-1">
<div className="text-body">{body}</div>
{c.backendCode && (
<div className="text-cap text-mute font-mono uppercase tracking-wider">
code: {c.backendCode}
</div>
)}
</div>
</Alert>
)
}
+4
View File
@@ -25,6 +25,10 @@
export { Button, buttonVariants, type ButtonProps } from './components/button'
export { Badge, type BadgeProps } from './components/badge'
export { Alert, type AlertProps } from './components/alert'
export {
QueryErrorState,
type QueryErrorStateProps,
} from './components/query-error'
export { IconButton, type IconButtonProps } from './components/icon-button'
export { Input, type InputProps } from './components/input'
export { Label } from './components/label'