{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({ >
{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 ? (
-
Маппинг status → human message: + *
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 (
+