From 3984d99004ce4a1e7de09cfc0a1d3b6de45f5bad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Tue, 12 May 2026 12:41:48 +0000 Subject: [PATCH] =?UTF-8?q?fix(ux):=20friendly=20error=20UI=20=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=20raw=20AxiosError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../changelog/ChangelogDiffModal.tsx | 10 +- .../src/components/graph/DictionaryGraph.tsx | 4 +- .../lineage/RecordDependentsPanel.tsx | 10 +- .../components/record/RecordHistoryDrawer.tsx | 10 +- .../components/workflow/SchemaDraftDrawer.tsx | 10 +- ordinis-admin-ui/src/i18n.ts | 36 +++ ordinis-admin-ui/src/routes/audit.tsx | 6 +- .../src/routes/dictionaries.$name.tsx | 7 +- .../src/routes/dictionaries.index.tsx | 10 +- ordinis-admin-ui/src/routes/outbox.tsx | 6 +- ordinis-admin-ui/src/routes/reviews.tsx | 5 +- ordinis-admin-ui/src/routes/search.tsx | 6 +- .../src/ui/components/query-error.tsx | 207 ++++++++++++++++++ ordinis-admin-ui/src/ui/index.ts | 4 + 14 files changed, 274 insertions(+), 57 deletions(-) create mode 100644 ordinis-admin-ui/src/ui/components/query-error.tsx diff --git a/ordinis-admin-ui/src/components/changelog/ChangelogDiffModal.tsx b/ordinis-admin-ui/src/components/changelog/ChangelogDiffModal.tsx index 8e53957..4dc8b27 100644 --- a/ordinis-admin-ui/src/components/changelog/ChangelogDiffModal.tsx +++ b/ordinis-admin-ui/src/components/changelog/ChangelogDiffModal.tsx @@ -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 && } - {error && ( - - {String(error)} - - )} + {error && refetch()} />} {data && (
{/* Header strip — kind + author */} diff --git a/ordinis-admin-ui/src/components/graph/DictionaryGraph.tsx b/ordinis-admin-ui/src/components/graph/DictionaryGraph.tsx index 497e13f..7fe411b 100644 --- a/ordinis-admin-ui/src/components/graph/DictionaryGraph.tsx +++ b/ordinis-admin-ui/src/components/graph/DictionaryGraph.tsx @@ -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
Ошибка загрузки: {String(dictsQuery.error)}
+ return dictsQuery.refetch()} /> } if (dicts.length === 0) { diff --git a/ordinis-admin-ui/src/components/lineage/RecordDependentsPanel.tsx b/ordinis-admin-ui/src/components/lineage/RecordDependentsPanel.tsx index f1b9990..96eb1cf 100644 --- a/ordinis-admin-ui/src/components/lineage/RecordDependentsPanel.tsx +++ b/ordinis-admin-ui/src/components/lineage/RecordDependentsPanel.tsx @@ -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 if (error) { - return ( - - {String(error)} - - ) + return refetch()} /> } if (!data || data.total === 0) return null diff --git a/ordinis-admin-ui/src/components/record/RecordHistoryDrawer.tsx b/ordinis-admin-ui/src/components/record/RecordHistoryDrawer.tsx index 7b1da26..726a262 100644 --- a/ordinis-admin-ui/src/components/record/RecordHistoryDrawer.tsx +++ b/ordinis-admin-ui/src/components/record/RecordHistoryDrawer.tsx @@ -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 — выносим из
чтобы хранить // expanded state поверх renders. const [expanded, setExpanded] = useState>(new Set()) @@ -48,11 +48,7 @@ export const RecordHistoryDrawer = ({ )} {isLoading && } - {error && ( - - {String(error)} - - )} + {error && refetch()} />} {data && data.length === 0 && (

{t('history.empty')}

)} diff --git a/ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx b/ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx index 3cb2114..bb9ff31 100644 --- a/ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx +++ b/ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx @@ -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({ >
{isLoading && } - {error && ( - - {String(error)} - - )} + {error && refetch()} />} {!isLoading && !draft && (

{t('workflow.schemaDraft.empty', { diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 504b1d2..73713a9 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -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', diff --git a/ordinis-admin-ui/src/routes/audit.tsx b/ordinis-admin-ui/src/routes/audit.tsx index fc97d98..87d8dfc 100644 --- a/ordinis-admin-ui/src/routes/audit.tsx +++ b/ordinis-admin-ui/src/routes/audit.tsx @@ -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 ? ( - - {String(error)} - + refetch()} /> ) : isLoading ? ( ) : !data || data.content.length === 0 ? ( diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index 867fa33..a8c794f 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -16,6 +16,7 @@ import { IconButton, LoadingBlock, Modal, + QueryErrorState, SearchInput, Table, TableBody, @@ -629,11 +630,7 @@ function DictionaryDetail() { ) } if (detailQuery.error) { - return ( - - {String(detailQuery.error)} - - ) + return detailQuery.refetch()} /> } return ( diff --git a/ordinis-admin-ui/src/routes/dictionaries.index.tsx b/ordinis-admin-ui/src/routes/dictionaries.index.tsx index 65e3835..c06a4fb 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.index.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.index.tsx @@ -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 ( - - {String(error)} - - ) + return refetch()} /> } if (!data || data.length === 0) { return ( diff --git a/ordinis-admin-ui/src/routes/outbox.tsx b/ordinis-admin-ui/src/routes/outbox.tsx index cf29424..7b353b3 100644 --- a/ordinis-admin-ui/src/routes/outbox.tsx +++ b/ordinis-admin-ui/src/routes/outbox.tsx @@ -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() {

{dlq.error ? ( - - {String(dlq.error)} - + dlq.refetch()} /> ) : dlq.isLoading ? ( ) : !dlq.data || dlq.data.content.length === 0 ? ( diff --git a/ordinis-admin-ui/src/routes/reviews.tsx b/ordinis-admin-ui/src/routes/reviews.tsx index 218c3bc..209e502 100644 --- a/ordinis-admin-ui/src/routes/reviews.tsx +++ b/ordinis-admin-ui/src/routes/reviews.tsx @@ -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 && ( - - {String(queue.error)} - + queue.refetch()} /> )} {activeTab === 'records' && bulkResult && ( diff --git a/ordinis-admin-ui/src/routes/search.tsx b/ordinis-admin-ui/src/routes/search.tsx index 060df02..3e47a50 100644 --- a/ordinis-admin-ui/src/routes/search.tsx +++ b/ordinis-admin-ui/src/routes/search.tsx @@ -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 && ( - - {String(result.error)} - + result.refetch()} /> )} {hasQuery && result.data && result.data.total === 0 && ( diff --git a/ordinis-admin-ui/src/ui/components/query-error.tsx b/ordinis-admin-ui/src/ui/components/query-error.tsx new file mode 100644 index 0000000..5633939 --- /dev/null +++ b/ordinis-admin-ui/src/ui/components/query-error.tsx @@ -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 {String(error)}} который раздавал + * raw {@code "AxiosError: Request failed with status code 503"} в UI. + * + *

Маппинг status → human message: + *

    + *
  • 503/502/504 — сервер временно недоступен (rolling restart, GW timeout)
  • + *
  • 500 — внутренняя ошибка сервера
  • + *
  • 404 — не найдено
  • + *
  • 403 — нет доступа
  • + *
  • 401 — handled global'ным interceptor'ом, но fallback'нем
  • + *
  • network err — нет соединения
  • + *
  • default — generic failure с raw message только в debug режиме
  • + *
+ * + *

Backend code/message парсятся если возвращены: вместо `AxiosError: ...` + * показывается {@code body.message} (что backend сам положил). Это работает + * только для structured 4xx responses; 5xx обычно plain html error page. + * + *

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 ( + } + > + {t('queryError.retry', { defaultValue: 'Повторить' })} + + ) : undefined + } + > +

+
{body}
+ {c.backendCode && ( +
+ code: {c.backendCode} +
+ )} +
+ + ) +} diff --git a/ordinis-admin-ui/src/ui/index.ts b/ordinis-admin-ui/src/ui/index.ts index 0e60d6c..279ebb2 100644 --- a/ordinis-admin-ui/src/ui/index.ts +++ b/ordinis-admin-ui/src/ui/index.ts @@ -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'