Files
mdm-ordinis/ordinis-admin-ui/src/ui/components/query-error.tsx
T
2026-05-12 12:41:48 +00:00

208 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}