mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-11 02:46:58 +08:00
fix(web): unify server error notifications
Preserve backend details and error causes, and report each failed operation once across request, Query/Mutation, and page handling layers. Independent operations with the same error message still receive separate notifications. Migrate direct requests and query consumers, preserve inline and silent error handling, and reject failed settings writes before success callbacks run. Retain safe authentication messages, refresh behavior, and single-use proof replay protection. Validation: independently checked the staged snapshot with 117 frontend test files (1105 tests), typecheck, scoped lint, and protected-header formatting. Security references: OWASP ASVS 5.0.0, Authentication Cheat Sheet, and Session Management Cheat Sheet: https://owasp.org/www-project-application-security-verification-standard/ https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
This commit is contained in:
+13
-10
@@ -92,14 +92,14 @@
|
||||
|
||||
以下为常用检索入口,不能把本表当作完整组件清单:
|
||||
|
||||
| 场景 | 优先检查的项目入口 |
|
||||
| --- | --- |
|
||||
| 通用弹窗布局 | `@/components/dialog` |
|
||||
| 删除、危险操作及普通确认 | `@/components/confirm-dialog` |
|
||||
| 复制按钮与剪贴板交互 | `@/components/copy-button`、`@/hooks/use-copy-to-clipboard` |
|
||||
| 空状态、加载状态、错误状态 | `@/components/empty-state`、`@/components/loading-state`、`@/components/error-state` |
|
||||
| 表格、分页、工具栏及列表布局 | `@/components/data-table`,先读该目录的 `README.md` 和公开导出 |
|
||||
| 按钮、输入、选择、提示等基础控件 | `@/components/ui/`,以 `components.json` 和本地实现为准 |
|
||||
| 场景 | 优先检查的项目入口 |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| 通用弹窗布局 | `@/components/dialog` |
|
||||
| 删除、危险操作及普通确认 | `@/components/confirm-dialog` |
|
||||
| 复制按钮与剪贴板交互 | `@/components/copy-button`、`@/hooks/use-copy-to-clipboard` |
|
||||
| 空状态、加载状态、错误状态 | `@/components/empty-state`、`@/components/loading-state`、`@/components/error-state` |
|
||||
| 表格、分页、工具栏及列表布局 | `@/components/data-table`,先读该目录的 `README.md` 和公开导出 |
|
||||
| 按钮、输入、选择、提示等基础控件 | `@/components/ui/`,以 `components.json` 和本地实现为准 |
|
||||
|
||||
- 使用函数式组件与 Hooks,单一职责;组件 props 须有明确类型(接口或类型别名)。
|
||||
- **Props 使用**:组件 props 非必要不要解构,直接使用 `props.xxx` 访问属性,保持代码清晰(详见 [3.2 代码风格与类型](#32-代码风格与类型))。
|
||||
@@ -121,7 +121,7 @@
|
||||
### 3.6 API 请求
|
||||
|
||||
- **React Query**:数据获取用 `useQuery`,变更用 `useMutation`;为每个查询配置唯一 `queryKey`(建议数组形式、层级一致);在 `onSuccess` 中对相关 query 做 `invalidateQueries`,可配合乐观更新。服务端错误统一通过 `handleServerError` 处理(详见 [3.9 错误处理](#39-错误处理))。
|
||||
- **Axios**:使用项目统一的 `api` 实例(含 `baseURL`、`headers`、`withCredentials: true`);GET 默认请求去重,特殊请求可通过配置关闭;认证与通用错误在拦截器中处理。
|
||||
- **Axios**:使用项目统一的 `api` 实例(含 `baseURL`、`headers`、`withCredentials: true`);GET 默认请求去重,特殊请求可通过配置关闭;认证刷新与请求重试在拦截器中处理;普通错误提示由 Query/Mutation 的最终失败回调或直接调用方负责。
|
||||
|
||||
### 3.7 表单
|
||||
|
||||
@@ -136,7 +136,10 @@
|
||||
|
||||
### 3.9 错误处理
|
||||
|
||||
- **服务端错误**:统一使用 `handleServerError`,在 React Query 全局配置与拦截器中接入;按 HTTP 状态码给出合适提示,文案使用 i18n。
|
||||
- **服务端错误**:统一使用 `handleServerError(error, fallbackMessage?)`,优先保留服务端具体原因。相同失败沿 `cause` 传播时只提示一次;不同操作即使文案相同也分别提示。禁止把完整 Axios 错误对象(可能包含凭据)直接写入控制台。
|
||||
- **提示归属**:普通请求拦截器不弹 Toast。使用共享 `createAppQueryClient` 在 Query/Mutation 最终失败时提示;Mutation 自定义 `onError` 负责自己的提示或表单错误。自动重试期间和主动取消时不报错。`meta.errorToast: false` 关闭自动提示,页面仍可主动调用错误处理函数。
|
||||
- **业务失败**:保留原始 API 响应契约;Query 使用 `requireServerSuccess` 检查 `success: false`,或在业务层用 `createServerError(response, fallbackMessage?)` 抛错并保留来源。直接请求须明确处理失败响应和 Promise 拒绝;需要展示失败状态的 Mutation 不得把失败结果当作保存成功。
|
||||
- **认证兼容**:保留稳定错误码映射及 `AuthOperationError` 的安全消息,不解开其来源来展示被屏蔽的细节。维护既有刷新、跳转和一次性授权不重放约束。
|
||||
- **展示**:使用 `toast.error` 等统一方式;路由级错误由 `errorComponent` 承接,提供友好错误页并记录便于排查的信息。
|
||||
- **表单**:校验与服务端错误映射到字段后,在字段下方展示;使用 `form.setError` 等与表单库一致的方式。
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
resolveChatUrl,
|
||||
type ChatPreset,
|
||||
} from '@/features/chat/lib/chat-links'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
|
||||
import { normalizeHref } from '../lib/url-utils'
|
||||
import type { NavChatPresets } from '../types'
|
||||
@@ -194,7 +195,7 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) {
|
||||
: t(
|
||||
'Unable to prepare chat link. Please ensure you have an enabled API key.'
|
||||
)
|
||||
toast.error(message)
|
||||
handleServerError(error, message)
|
||||
return
|
||||
} finally {
|
||||
loadingPresetIdRef.current = null
|
||||
|
||||
@@ -25,6 +25,7 @@ import { toast } from 'sonner'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { logout } from '@/features/auth/api'
|
||||
import { clearAuthenticatedClientState } from '@/lib/auth-session'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
|
||||
interface SignOutDialogProps {
|
||||
open: boolean
|
||||
@@ -42,7 +43,7 @@ export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
|
||||
try {
|
||||
const response = await logout()
|
||||
if (!response.success) {
|
||||
toast.error(response.message || t('Failed to sign out session'))
|
||||
handleServerError(response, t('Failed to sign out session'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -50,9 +51,7 @@ export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
|
||||
toast.success(t('Signed out'))
|
||||
void navigate({ to: '/sign-in', replace: true })
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Failed to sign out session')
|
||||
)
|
||||
handleServerError(error, t('Failed to sign out session'))
|
||||
} finally {
|
||||
setIsSigningOut(false)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { PublicLayout } from '@/components/layout'
|
||||
import { RichContent } from '@/components/rich-content'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { isHttpUrl, isLikelyHtml } from '@/lib/content-format'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
|
||||
import { getAboutContent } from './api'
|
||||
|
||||
@@ -116,7 +117,7 @@ export function About() {
|
||||
const { t } = useTranslation()
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['about-content'],
|
||||
queryFn: getAboutContent,
|
||||
queryFn: async () => requireServerSuccess(await getAboutContent()),
|
||||
})
|
||||
|
||||
const rawContent = data?.data?.trim() ?? ''
|
||||
|
||||
@@ -42,6 +42,9 @@ import {
|
||||
} from '@/features/auth/constants'
|
||||
import { useTurnstile } from '@/features/auth/hooks/use-turnstile'
|
||||
import { useCountdown } from '@/hooks/use-countdown'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { AuthOperationError } from '@/lib/secure-verification'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function ForgotPasswordForm({
|
||||
@@ -81,10 +84,14 @@ export function ForgotPasswordForm({
|
||||
startCountdown()
|
||||
toast.success(t('Reset email sent, please check your inbox'))
|
||||
} else {
|
||||
toast.error(res?.message || t('Failed to send reset email'))
|
||||
handleServerError(
|
||||
createServerError(res, t('Failed to send reset email'))
|
||||
)
|
||||
}
|
||||
} catch (_error) {
|
||||
// Errors are handled by global interceptor
|
||||
handleServerError(
|
||||
AuthOperationError.from(_error, t('Failed to send reset email'))
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useCountdown } from '@/hooks/use-countdown'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { AuthOperationError } from '@/lib/secure-verification'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
|
||||
import { sendEmailVerification } from '../api'
|
||||
import { EMAIL_VERIFICATION_COUNTDOWN } from '../constants'
|
||||
@@ -63,12 +66,17 @@ export function useEmailVerification(options?: UseEmailVerificationOptions) {
|
||||
toast.success(i18next.t('Verification email sent'))
|
||||
return true
|
||||
}
|
||||
toast.error(
|
||||
res?.message || i18next.t('Failed to send verification email')
|
||||
handleServerError(
|
||||
createServerError(res, i18next.t('Failed to send verification email'))
|
||||
)
|
||||
return false
|
||||
} catch (_error) {
|
||||
// Errors are handled by global interceptor
|
||||
handleServerError(
|
||||
AuthOperationError.from(
|
||||
_error,
|
||||
i18next.t('Failed to send verification email')
|
||||
)
|
||||
)
|
||||
return false
|
||||
} finally {
|
||||
setIsSending(false)
|
||||
|
||||
@@ -21,7 +21,9 @@ import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { clearAuthentication } from '@/lib/api'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { AuthOperationError } from '@/lib/secure-verification'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
|
||||
import { createOAuthAuthorization, createOAuthFlow, logout } from '../api'
|
||||
import {
|
||||
@@ -59,7 +61,7 @@ export function useOAuthLogin(
|
||||
const resetSession = async () => {
|
||||
const response = await logout()
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || t('Failed to sign out session'))
|
||||
throw createServerError(response, t('Failed to sign out session'))
|
||||
}
|
||||
clearAuthentication()
|
||||
}
|
||||
@@ -91,8 +93,10 @@ export function useOAuthLogin(
|
||||
|
||||
const url = buildGitHubOAuthUrl(status.github_client_id, state)
|
||||
window.open(url, '_self')
|
||||
} catch {
|
||||
toast.error(t('Failed to start GitHub login'))
|
||||
} catch (error) {
|
||||
handleServerError(
|
||||
AuthOperationError.from(error, t('Failed to start GitHub login'))
|
||||
)
|
||||
if (githubTimeoutRef.current) {
|
||||
clearTimeout(githubTimeoutRef.current)
|
||||
}
|
||||
@@ -113,8 +117,10 @@ export function useOAuthLogin(
|
||||
|
||||
const url = buildDiscordOAuthUrl(status.discord_client_id, state)
|
||||
window.open(url, '_self')
|
||||
} catch {
|
||||
toast.error(t('Failed to start Discord login'))
|
||||
} catch (error) {
|
||||
handleServerError(
|
||||
AuthOperationError.from(error, t('Failed to start Discord login'))
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -135,8 +141,10 @@ export function useOAuthLogin(
|
||||
state
|
||||
)
|
||||
window.open(url, '_self')
|
||||
} catch {
|
||||
toast.error(t('Failed to start OIDC login'))
|
||||
} catch (error) {
|
||||
handleServerError(
|
||||
AuthOperationError.from(error, t('Failed to start OIDC login'))
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -153,8 +161,10 @@ export function useOAuthLogin(
|
||||
|
||||
const url = buildLinuxDOOAuthUrl(status.linuxdo_client_id, state)
|
||||
window.open(url, '_self')
|
||||
} catch {
|
||||
toast.error(t('Failed to start LinuxDO login'))
|
||||
} catch (error) {
|
||||
handleServerError(
|
||||
AuthOperationError.from(error, t('Failed to start LinuxDO login'))
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -179,7 +189,7 @@ export function useOAuthLogin(
|
||||
rememberOAuthLoginRedirect(authorization.state, redirectTo)
|
||||
window.open(authorization.authorizationUrl, '_self')
|
||||
} catch (error) {
|
||||
toast.error(t(AuthOperationError.from(error).message))
|
||||
handleServerError(AuthOperationError.from(error))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -205,9 +215,12 @@ export function useOAuthLogin(
|
||||
}
|
||||
|
||||
window.open(url.toString(), '_self')
|
||||
} catch {
|
||||
toast.error(
|
||||
t('Failed to start {{provider}} login', { provider: provider.name })
|
||||
} catch (error) {
|
||||
handleServerError(
|
||||
AuthOperationError.from(
|
||||
error,
|
||||
t('Failed to start {{provider}} login', { provider: provider.name })
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { AuthOperationError } from '@/lib/secure-verification'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
@@ -77,7 +78,7 @@ export function OtpForm() {
|
||||
toast.success(t('Signed in'))
|
||||
} catch (error) {
|
||||
if (!active) return
|
||||
toast.error(t(AuthOperationError.from(error).message))
|
||||
handleServerError(AuthOperationError.from(error))
|
||||
redirectToLogin()
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -29,6 +29,9 @@ import { Label } from '@/components/ui/label'
|
||||
import { useCountdown } from '@/hooks/use-countdown'
|
||||
import { api } from '@/lib/api'
|
||||
import { copyToClipboard } from '@/lib/copy-to-clipboard'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { AuthOperationError } from '@/lib/secure-verification'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
|
||||
import { AuthLayout } from '../auth-layout'
|
||||
|
||||
@@ -82,9 +85,11 @@ export function ResetPasswordConfirm({
|
||||
} else {
|
||||
toast.success(t('Password reset: {{password}}', { password }))
|
||||
}
|
||||
} else {
|
||||
handleServerError(createServerError(res.data, t('Request failed')))
|
||||
}
|
||||
} catch {
|
||||
// Errors handled by global interceptor
|
||||
} catch (error) {
|
||||
handleServerError(AuthOperationError.from(error))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -179,13 +184,15 @@ export function ResetPasswordConfirm({
|
||||
newPassword ? false : loading || isActive || !isValidResetLink
|
||||
}
|
||||
>
|
||||
{newPassword
|
||||
? t('auth.resetPasswordConfirm.backToLogin')
|
||||
: isActive
|
||||
? t('auth.resetPasswordConfirm.retry', {
|
||||
seconds: secondsLeft,
|
||||
})
|
||||
: t('auth.resetPasswordConfirm.confirm')}
|
||||
{newPassword && t('auth.resetPasswordConfirm.backToLogin')}
|
||||
{!newPassword &&
|
||||
isActive &&
|
||||
t('auth.resetPasswordConfirm.retry', {
|
||||
seconds: secondsLeft,
|
||||
})}
|
||||
{!newPassword &&
|
||||
!isActive &&
|
||||
t('auth.resetPasswordConfirm.confirm')}
|
||||
</Button>
|
||||
|
||||
{!newPassword && (
|
||||
|
||||
@@ -18,7 +18,6 @@ For commercial licensing, please contact [email protected]
|
||||
*/
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import axios from 'axios'
|
||||
import { Loader2, LogIn, KeyRound } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
@@ -49,12 +48,14 @@ import { useTurnstile } from '@/features/auth/hooks/use-turnstile'
|
||||
import { beginPasskeyLogin, finishPasskeyLogin } from '@/features/auth/passkey'
|
||||
import type { AuthFormProps } from '@/features/auth/types'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import {
|
||||
buildAssertionResult,
|
||||
prepareCredentialRequestOptions,
|
||||
isPasskeySupported as detectPasskeySupport,
|
||||
} from '@/lib/passkey'
|
||||
import { getServerErrorMessageKey } from '@/lib/server-error-message'
|
||||
import { AuthOperationError } from '@/lib/secure-verification'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function UserAuthForm({
|
||||
@@ -178,10 +179,11 @@ export function UserAuthForm({
|
||||
if (await handleLoginResult(res.data, redirectTo)) {
|
||||
toast.success(t('Welcome back!'))
|
||||
}
|
||||
} else {
|
||||
handleServerError(createServerError(res, loginFailedMessage))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (axios.isAxiosError(error)) return
|
||||
toast.error(error instanceof Error ? error.message : loginFailedMessage)
|
||||
handleServerError(AuthOperationError.from(error, loginFailedMessage))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -219,12 +221,12 @@ export function UserAuthForm({
|
||||
toast.success(t('Signed in via WeChat'))
|
||||
}
|
||||
} else {
|
||||
if (getServerErrorMessageKey(res)) return
|
||||
toast.error(res?.message || loginFailedMessage)
|
||||
handleServerError(createServerError(res, loginFailedMessage))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (getServerErrorMessageKey(error)) return
|
||||
toast.error(loginFailedMessage)
|
||||
handleServerError(
|
||||
new AuthOperationError(loginFailedMessage, undefined, { cause: error })
|
||||
)
|
||||
} finally {
|
||||
setIsWeChatSubmitting(false)
|
||||
}
|
||||
@@ -250,8 +252,7 @@ export function UserAuthForm({
|
||||
try {
|
||||
const begin = await beginPasskeyLogin()
|
||||
if (!begin.success) {
|
||||
if (getServerErrorMessageKey(begin)) return
|
||||
throw new Error(begin.message || t('Failed to start Passkey login'))
|
||||
throw createServerError(begin, t('Failed to start Passkey login'))
|
||||
}
|
||||
|
||||
const publicKey = prepareCredentialRequestOptions(
|
||||
@@ -278,21 +279,21 @@ export function UserAuthForm({
|
||||
|
||||
const finish = await finishPasskeyLogin(flowToken, assertion)
|
||||
if (!finish.success) {
|
||||
if (getServerErrorMessageKey(finish)) return
|
||||
throw new Error(finish.message || t('Failed to complete Passkey login'))
|
||||
throw createServerError(finish, t('Failed to complete Passkey login'))
|
||||
}
|
||||
|
||||
if (await handleLoginResult(finish.data, redirectTo)) {
|
||||
toast.success(t('Signed in with Passkey'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (getServerErrorMessageKey(error)) return
|
||||
if (error instanceof DOMException && error.name === 'NotAllowedError') {
|
||||
toast.info(t('Passkey login was cancelled or timed out'))
|
||||
} else if (error instanceof Error) {
|
||||
toast.error(error.message)
|
||||
handleServerError(AuthOperationError.from(error))
|
||||
} else {
|
||||
toast.error(t('Passkey login failed'))
|
||||
handleServerError(
|
||||
AuthOperationError.from(error, t('Passkey login failed'))
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
setIsPasskeyLoading(false)
|
||||
|
||||
@@ -50,7 +50,9 @@ import {
|
||||
saveAffiliateCode,
|
||||
} from '@/features/auth/lib/storage'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { getServerErrorMessageKey } from '@/lib/server-error-message'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { AuthOperationError } from '@/lib/secure-verification'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function SignUpForm({
|
||||
@@ -172,10 +174,12 @@ export function SignUpForm({
|
||||
toast.success(t('Account created! Please sign in'))
|
||||
redirectToLogin()
|
||||
} else {
|
||||
toast.error(res?.message || t('Failed to create account'))
|
||||
handleServerError(createServerError(res, t('Failed to create account')))
|
||||
}
|
||||
} catch {
|
||||
// Errors are handled by global interceptor
|
||||
} catch (error) {
|
||||
handleServerError(
|
||||
AuthOperationError.from(error, t('Failed to create account'))
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -220,12 +224,12 @@ export function SignUpForm({
|
||||
toast.success(t('Signed in via WeChat'))
|
||||
}
|
||||
} else {
|
||||
if (getServerErrorMessageKey(res)) return
|
||||
toast.error(res?.message || t('Login failed'))
|
||||
handleServerError(createServerError(res, t('Login failed')))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (getServerErrorMessageKey(error)) return
|
||||
toast.error(t('Login failed'))
|
||||
handleServerError(
|
||||
new AuthOperationError(t('Login failed'), undefined, { cause: error })
|
||||
)
|
||||
} finally {
|
||||
setIsWeChatSubmitting(false)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ For commercial licensing, please contact [email protected]
|
||||
*/
|
||||
import { getGroups as getUserGroups } from '@/features/users/api'
|
||||
import { api, type ApiRequestConfig } from '@/lib/api'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
|
||||
import type {
|
||||
AddChannelRequest,
|
||||
@@ -64,7 +65,7 @@ export async function getTaskPluginOptions(): Promise<TaskPluginOption[]> {
|
||||
success: boolean
|
||||
data: TaskPluginOption[]
|
||||
}>('/api/task_plugin_options')
|
||||
return response.data.data
|
||||
return requireServerSuccess(response.data).data
|
||||
}
|
||||
|
||||
export type CodexUsageResponse = {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
hasPermission,
|
||||
} from '@/lib/admin-permissions'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
@@ -77,7 +78,7 @@ export function TaskPluginChannelBadge(props: { pluginKey?: string }) {
|
||||
)
|
||||
const query = useQuery({
|
||||
queryKey: ['task-plugin-options'],
|
||||
queryFn: getTaskPluginOptions,
|
||||
queryFn: async () => requireServerSuccess(await getTaskPluginOptions()),
|
||||
enabled: Boolean(props.pluginKey) && canBind,
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
|
||||
@@ -53,6 +53,8 @@ import {
|
||||
getCurrencyLabel,
|
||||
} from '@/lib/currency'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
import { truncateText } from '@/lib/utils'
|
||||
|
||||
import { getCodexUsage, updateChannelBalance } from '../api'
|
||||
@@ -437,14 +439,12 @@ export function BalanceCell({ channel }: { channel: Channel }) {
|
||||
try {
|
||||
const res = await getCodexUsage(channel.id)
|
||||
if (!res.success) {
|
||||
throw new Error(res.message || t('Failed to fetch usage'))
|
||||
throw createServerError(res, t('Failed to fetch usage'))
|
||||
}
|
||||
setCodexUsageResponse(res)
|
||||
setCodexUsageOpen(true)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Failed to fetch usage')
|
||||
)
|
||||
handleServerError(error, t('Failed to fetch usage'))
|
||||
} finally {
|
||||
setIsUpdating(false)
|
||||
}
|
||||
@@ -470,12 +470,10 @@ export function BalanceCell({ channel }: { channel: Channel }) {
|
||||
setCurrentRow(channel)
|
||||
setRawBalanceResponse(response.raw_response)
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to update balance'))
|
||||
handleServerError(response, t('Failed to update balance'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Failed to update balance')
|
||||
)
|
||||
handleServerError(error, t('Failed to update balance'))
|
||||
} finally {
|
||||
setIsUpdating(false)
|
||||
}
|
||||
@@ -556,15 +554,11 @@ export function BalanceCell({ channel }: { channel: Channel }) {
|
||||
try {
|
||||
const res = await getCodexUsage(channel.id)
|
||||
if (!res.success) {
|
||||
throw new Error(res.message || t('Failed to fetch usage'))
|
||||
throw createServerError(res, t('Failed to fetch usage'))
|
||||
}
|
||||
setCodexUsageResponse(res)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('Failed to fetch usage')
|
||||
)
|
||||
handleServerError(error, t('Failed to fetch usage'))
|
||||
} finally {
|
||||
setIsUpdating(false)
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from '@/components/ui/tooltip'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
|
||||
import { getChannels, searchChannels, getGroups } from '../api'
|
||||
import {
|
||||
@@ -205,7 +206,7 @@ export function ChannelsTable() {
|
||||
// Fetch groups for filter
|
||||
const { data: groupsData } = useQuery({
|
||||
queryKey: ['groups'],
|
||||
queryFn: getGroups,
|
||||
queryFn: async () => requireServerSuccess(await getGroups()),
|
||||
})
|
||||
|
||||
const groupOptions = useMemo(
|
||||
@@ -243,47 +244,51 @@ export function ChannelsTable() {
|
||||
}),
|
||||
queryFn: async () => {
|
||||
if (shouldSearch) {
|
||||
return searchChannels({
|
||||
keyword: globalFilter,
|
||||
model: modelFilter,
|
||||
group:
|
||||
groupFilter.length > 0 && !groupFilter.includes('all')
|
||||
? groupFilter[0]
|
||||
: undefined,
|
||||
status:
|
||||
statusFilter.length > 0 && !statusFilter.includes('all')
|
||||
? statusFilter[0]
|
||||
: undefined,
|
||||
type:
|
||||
typeFilter.length > 0 && !typeFilter.includes('all')
|
||||
? Number(typeFilter[0])
|
||||
: undefined,
|
||||
tag_mode: enableTagMode,
|
||||
id_sort: idSort,
|
||||
...sortParams,
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
})
|
||||
return requireServerSuccess(
|
||||
await searchChannels({
|
||||
keyword: globalFilter,
|
||||
model: modelFilter,
|
||||
group:
|
||||
groupFilter.length > 0 && !groupFilter.includes('all')
|
||||
? groupFilter[0]
|
||||
: undefined,
|
||||
status:
|
||||
statusFilter.length > 0 && !statusFilter.includes('all')
|
||||
? statusFilter[0]
|
||||
: undefined,
|
||||
type:
|
||||
typeFilter.length > 0 && !typeFilter.includes('all')
|
||||
? Number(typeFilter[0])
|
||||
: undefined,
|
||||
tag_mode: enableTagMode,
|
||||
id_sort: idSort,
|
||||
...sortParams,
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
})
|
||||
)
|
||||
} else {
|
||||
return getChannels({
|
||||
group:
|
||||
groupFilter.length > 0 && !groupFilter.includes('all')
|
||||
? groupFilter[0]
|
||||
: undefined,
|
||||
status:
|
||||
statusFilter.length > 0 && !statusFilter.includes('all')
|
||||
? statusFilter[0]
|
||||
: undefined,
|
||||
type:
|
||||
typeFilter.length > 0 && !typeFilter.includes('all')
|
||||
? Number(typeFilter[0])
|
||||
: undefined,
|
||||
tag_mode: enableTagMode,
|
||||
id_sort: idSort,
|
||||
...sortParams,
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
})
|
||||
return requireServerSuccess(
|
||||
await getChannels({
|
||||
group:
|
||||
groupFilter.length > 0 && !groupFilter.includes('all')
|
||||
? groupFilter[0]
|
||||
: undefined,
|
||||
status:
|
||||
statusFilter.length > 0 && !statusFilter.includes('all')
|
||||
? statusFilter[0]
|
||||
: undefined,
|
||||
type:
|
||||
typeFilter.length > 0 && !typeFilter.includes('all')
|
||||
? Number(typeFilter[0])
|
||||
: undefined,
|
||||
tag_mode: enableTagMode,
|
||||
id_sort: idSort,
|
||||
...sortParams,
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
placeholderData: (previousData) => previousData,
|
||||
|
||||
@@ -32,6 +32,8 @@ import { Button } from '@/components/ui/button'
|
||||
import { IconBadge } from '@/components/ui/icon-badge'
|
||||
import { formatCurrencyFromUSD } from '@/lib/currency'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
|
||||
import { getCodexUsage, updateChannelBalance } from '../../api'
|
||||
import { channelsQueryKeys } from '../../lib'
|
||||
@@ -71,13 +73,11 @@ export function BalanceQueryDialog(props: BalanceQueryDialogProps) {
|
||||
try {
|
||||
const res = await getCodexUsage(row.id)
|
||||
if (!res.success) {
|
||||
throw new Error(res.message || t('Failed to fetch usage'))
|
||||
throw createServerError(res, t('Failed to fetch usage'))
|
||||
}
|
||||
setCodexUsageResponse(res)
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Failed to fetch usage')
|
||||
)
|
||||
handleServerError(error, t('Failed to fetch usage'))
|
||||
} finally {
|
||||
setIsQuerying(false)
|
||||
}
|
||||
@@ -119,12 +119,10 @@ export function BalanceQueryDialog(props: BalanceQueryDialogProps) {
|
||||
} else if (response.success && response.raw_response !== undefined) {
|
||||
setRawResponse(response.raw_response)
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to query balance'))
|
||||
handleServerError(response, t('Failed to query balance'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Failed to query balance')
|
||||
)
|
||||
handleServerError(error, t('Failed to query balance'))
|
||||
} finally {
|
||||
setIsQuerying(false)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Combobox } from '@/components/ui/combobox'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import type {
|
||||
ColumnDef,
|
||||
@@ -61,9 +60,9 @@ import {
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Combobox } from '@/components/ui/combobox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -80,6 +79,7 @@ import {
|
||||
} from '@/components/ui/tooltip'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
|
||||
import { updateChannel } from '../../api'
|
||||
import {
|
||||
@@ -193,7 +193,6 @@ const endpointTypeOptions: Array<{ value: string; label: string }> = [
|
||||
{ value: 'embeddings', label: 'Embeddings (/v1/embeddings)' },
|
||||
]
|
||||
|
||||
|
||||
const STREAM_INCOMPATIBLE_ENDPOINTS = new Set([
|
||||
'embeddings',
|
||||
'image-generation',
|
||||
@@ -794,14 +793,10 @@ function ChannelTestDialogContent({
|
||||
refreshChannelLists()
|
||||
setIsDeleteFailedDialogOpen(false)
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to delete failed models'))
|
||||
handleServerError(response, t('Failed to delete failed models'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('Failed to delete failed models')
|
||||
)
|
||||
handleServerError(error, t('Failed to delete failed models'))
|
||||
} finally {
|
||||
setIsDeletingFailed(false)
|
||||
}
|
||||
@@ -988,13 +983,13 @@ function ChannelTestDialogContent({
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='endpoint-type'>{t('Endpoint Type')}</Label>
|
||||
<Combobox
|
||||
options={endpointSelectItems}
|
||||
value={endpointType}
|
||||
onValueChange={handleEndpointTypeChange}
|
||||
id='endpoint-type'
|
||||
className='w-full min-w-0'
|
||||
placeholder={t('Auto detect (default)')}
|
||||
/>
|
||||
options={endpointSelectItems}
|
||||
value={endpointType}
|
||||
onValueChange={handleEndpointTypeChange}
|
||||
id='endpoint-type'
|
||||
className='w-full min-w-0'
|
||||
placeholder={t('Auto detect (default)')}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Override the endpoint used for testing. Leave empty to auto detect.'
|
||||
|
||||
@@ -76,6 +76,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import { formatDateTimeStr, formatTimestampToDate } from '@/lib/format'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
@@ -983,8 +984,9 @@ export function CodexUsageDialog({
|
||||
try {
|
||||
const res = await getCodexResetCredits(channelId)
|
||||
if (!res.success) {
|
||||
throw new Error(
|
||||
res.message || t('Failed to fetch reset credit details')
|
||||
throw createServerError(
|
||||
res,
|
||||
t('Failed to fetch reset credit details')
|
||||
)
|
||||
}
|
||||
setResetCreditsResponse(res)
|
||||
@@ -1034,7 +1036,7 @@ export function CodexUsageDialog({
|
||||
try {
|
||||
const res = await resetCodexUsage(channelId)
|
||||
if (!res.success) {
|
||||
throw new Error(res.message || t('Failed to reset usage'))
|
||||
throw createServerError(res, t('Failed to reset usage'))
|
||||
}
|
||||
|
||||
const resetPayload = res.data as
|
||||
|
||||
@@ -16,7 +16,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Combobox } from '@/components/ui/combobox'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -28,11 +27,13 @@ import { GroupBadge } from '@/components/group-badge'
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Combobox } from '@/components/ui/combobox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
|
||||
import {
|
||||
editTagChannels,
|
||||
@@ -65,21 +66,24 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
|
||||
// Fetch tag models
|
||||
const { data: tagModelsData, isLoading: isLoadingTagModels } = useQuery({
|
||||
queryKey: ['tag-models', currentTag],
|
||||
queryFn: () => (currentTag ? getTagModels(currentTag) : null),
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(
|
||||
await (currentTag ? getTagModels(currentTag) : null)
|
||||
),
|
||||
enabled: open && !!currentTag,
|
||||
})
|
||||
|
||||
// Fetch all available models
|
||||
const { data: allModelsData } = useQuery({
|
||||
queryKey: ['all-models'],
|
||||
queryFn: getAllModels,
|
||||
queryFn: async () => requireServerSuccess(await getAllModels()),
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
// Fetch groups
|
||||
const { data: groupsData } = useQuery({
|
||||
queryKey: ['groups'],
|
||||
queryFn: getGroups,
|
||||
queryFn: async () => requireServerSuccess(await getGroups()),
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
@@ -193,12 +197,10 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
|
||||
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to update tag'))
|
||||
handleServerError(response, t('Failed to update tag'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Failed to update tag')
|
||||
)
|
||||
handleServerError(error, t('Failed to update tag'))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@@ -298,14 +300,19 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<Combobox
|
||||
options={availableModels.map((model) => ({ value: model, label: model }))}
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value !== null && !selectedModels.includes(value)) setSelectedModels([...selectedModels, value])
|
||||
}}
|
||||
className='flex-1'
|
||||
placeholder={t('Add from available models...')}
|
||||
aria-label={t('Add from available models...')}
|
||||
/>
|
||||
options={availableModels.map((model) => ({
|
||||
value: model,
|
||||
label: model,
|
||||
}))}
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value !== null && !selectedModels.includes(value)) {
|
||||
setSelectedModels([...selectedModels, value])
|
||||
}
|
||||
}}
|
||||
className='flex-1'
|
||||
placeholder={t('Add from available models...')}
|
||||
aria-label={t('Add from available models...')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
|
||||
import { fetchUpstreamModels, updateChannel } from '../../api'
|
||||
import {
|
||||
@@ -157,14 +158,12 @@ export function FetchModelsDialog({
|
||||
setSelectedModels(existingModels)
|
||||
toast.success(t('Fetched {{count}} models', { count: list.length }))
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to fetch models'))
|
||||
handleServerError(response, t('Failed to fetch models'))
|
||||
setFetchedModels([])
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Failed to fetch models')
|
||||
)
|
||||
handleServerError(error, t('Failed to fetch models'))
|
||||
setFetchedModels([])
|
||||
} finally {
|
||||
setIsFetching(false)
|
||||
@@ -193,12 +192,10 @@ export function FetchModelsDialog({
|
||||
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to update models'))
|
||||
handleServerError(response, t('Failed to update models'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Failed to update models')
|
||||
)
|
||||
handleServerError(error, t('Failed to update models'))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
ADMIN_PERMISSION_RESOURCES,
|
||||
hasPermission,
|
||||
} from '@/lib/admin-permissions'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import {
|
||||
@@ -137,19 +138,17 @@ export function MultiKeyManageDialog({
|
||||
setManualDisabledCount(response.data.manual_disabled_count || 0)
|
||||
setAutoDisabledCount(response.data.auto_disabled_count || 0)
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to load key status'))
|
||||
handleServerError(response, t('Failed to load key status'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Failed to load key status')
|
||||
)
|
||||
handleServerError(error, t('Failed to load key status'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusFilterChange = (value: string) => {
|
||||
const newFilter = value === 'all' ? null : parseInt(value)
|
||||
const newFilter = value === 'all' ? null : Number.parseInt(value)
|
||||
setStatusFilter(newFilter)
|
||||
setCurrentPage(1)
|
||||
loadKeyStatus(1, pageSize, newFilter)
|
||||
@@ -204,12 +203,10 @@ export function MultiKeyManageDialog({
|
||||
loadKeyStatus(currentPage, pageSize)
|
||||
}
|
||||
} else {
|
||||
toast.error(response?.message || t('Operation failed'))
|
||||
handleServerError(response, t('Operation failed'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Operation failed')
|
||||
)
|
||||
handleServerError(error, t('Operation failed'))
|
||||
} finally {
|
||||
setIsPerformingAction(false)
|
||||
setConfirmAction(null)
|
||||
@@ -294,12 +291,10 @@ export function MultiKeyManageDialog({
|
||||
{/* Toolbar */}
|
||||
<div className='flex shrink-0 items-center justify-between'>
|
||||
<Select
|
||||
items={[
|
||||
...MULTI_KEY_FILTER_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.label),
|
||||
})),
|
||||
]}
|
||||
items={MULTI_KEY_FILTER_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.label),
|
||||
}))}
|
||||
value={statusFilter === null ? 'all' : statusFilter.toString()}
|
||||
onValueChange={(v) => v !== null && handleStatusFilterChange(v)}
|
||||
>
|
||||
@@ -378,15 +373,17 @@ export function MultiKeyManageDialog({
|
||||
|
||||
{/* Table */}
|
||||
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
|
||||
{isLoading ? (
|
||||
{isLoading && (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
)}
|
||||
{!isLoading && keys.length === 0 && (
|
||||
<div className='text-muted-foreground py-12 text-center'>
|
||||
{t('No keys found')}
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
{!isLoading && !(keys.length === 0) && (
|
||||
<StaticDataTable
|
||||
className='rounded-none border-0'
|
||||
tableClassName='min-w-[800px]'
|
||||
|
||||
@@ -40,6 +40,7 @@ import { Label } from '@/components/ui/label'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { getFreshAuthHeaders } from '@/lib/api'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
|
||||
import {
|
||||
deleteOllamaModel,
|
||||
@@ -155,7 +156,7 @@ export function OllamaModelsDialog({
|
||||
}
|
||||
|
||||
if (!normalized.length && lastErr) {
|
||||
toast.error(lastErr || t('Failed to fetch models'))
|
||||
handleServerError(new Error(lastErr), t('Failed to fetch models'))
|
||||
}
|
||||
|
||||
setModels(normalized)
|
||||
@@ -169,8 +170,7 @@ export function OllamaModelsDialog({
|
||||
: normalized.map((m) => m.id)
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : undefined
|
||||
toast.error(msg || t('Failed to fetch models'))
|
||||
handleServerError(err, t('Failed to fetch models'))
|
||||
setModels([])
|
||||
} finally {
|
||||
setIsFetching(false)
|
||||
@@ -216,12 +216,10 @@ export function OllamaModelsDialog({
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
} else {
|
||||
toast.error(res.message || t('Failed to update models'))
|
||||
handleServerError(res, t('Failed to update models'))
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t('Failed to update models')
|
||||
)
|
||||
handleServerError(err, t('Failed to update models'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +291,7 @@ export function OllamaModelsDialog({
|
||||
if (data?.status) {
|
||||
setPullProgress(data)
|
||||
} else if (data?.error) {
|
||||
toast.error(String(data.error))
|
||||
handleServerError(data, String(data.error))
|
||||
setIsPulling(false)
|
||||
setPullProgress(null)
|
||||
pullAbortRef.current = null
|
||||
@@ -329,7 +327,7 @@ export function OllamaModelsDialog({
|
||||
(err as { name?: unknown }).name === 'AbortError'
|
||||
if (!isAbort) {
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
toast.error(t('Model pull failed: {{msg}}', { msg }))
|
||||
handleServerError(err, t('Model pull failed: {{msg}}', { msg }))
|
||||
}
|
||||
setIsPulling(false)
|
||||
setPullProgress(null)
|
||||
@@ -352,11 +350,10 @@ export function OllamaModelsDialog({
|
||||
setDeleteOpen(false)
|
||||
setDeleteTarget(null)
|
||||
} else {
|
||||
toast.error(payload?.message || t('Failed to delete model'))
|
||||
handleServerError(payload, t('Failed to delete model'))
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : undefined
|
||||
toast.error(msg || t('Failed to delete model'))
|
||||
handleServerError(err, t('Failed to delete model'))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
|
||||
import {
|
||||
getTagModels,
|
||||
@@ -66,14 +68,14 @@ export function TagBatchEditDialog({
|
||||
// Fetch available groups
|
||||
const { data: groupsData, isLoading: isLoadingGroups } = useQuery({
|
||||
queryKey: ['groups'],
|
||||
queryFn: getGroups,
|
||||
queryFn: async () => requireServerSuccess(await getGroups()),
|
||||
})
|
||||
|
||||
// Transform groups to multi-select options
|
||||
const groupOptions = useMemo(() => {
|
||||
if (!groupsData?.data) return []
|
||||
const allGroups = new Set([...groupsData.data, ...groups])
|
||||
return Array.from(allGroups).map((group) => ({
|
||||
return [...allGroups].map((group) => ({
|
||||
value: group,
|
||||
label: group,
|
||||
}))
|
||||
@@ -93,12 +95,13 @@ export function TagBatchEditDialog({
|
||||
try {
|
||||
// Fetch current tag models
|
||||
const tagModelsResponse = await getTagModels(currentTag)
|
||||
requireServerSuccess(tagModelsResponse)
|
||||
if (tagModelsResponse.success && tagModelsResponse.data) {
|
||||
setModels(tagModelsResponse.data)
|
||||
}
|
||||
|
||||
// Fetch all available models (for future use if needed)
|
||||
const allModelsResponse = await getAllModels()
|
||||
const allModelsResponse = requireServerSuccess(await getAllModels())
|
||||
if (allModelsResponse.success && allModelsResponse.data) {
|
||||
// Available models could be used for autocomplete in the future
|
||||
}
|
||||
@@ -106,9 +109,7 @@ export function TagBatchEditDialog({
|
||||
// Initialize new tag with current tag name
|
||||
setNewTag(currentTag)
|
||||
} catch (_error: unknown) {
|
||||
toast.error(
|
||||
_error instanceof Error ? _error.message : t('Failed to load tag data')
|
||||
)
|
||||
handleServerError(_error, t('Failed to load tag data'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -121,7 +122,7 @@ export function TagBatchEditDialog({
|
||||
if (modelMapping.trim()) {
|
||||
try {
|
||||
JSON.parse(modelMapping)
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error(t('Model mapping must be valid JSON'))
|
||||
return
|
||||
}
|
||||
@@ -163,12 +164,10 @@ export function TagBatchEditDialog({
|
||||
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
handleClose()
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to update tag'))
|
||||
handleServerError(response, t('Failed to update tag'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Failed to update tag')
|
||||
)
|
||||
handleServerError(error, t('Failed to update tag'))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
@@ -219,83 +218,79 @@ export function TagBatchEditDialog({
|
||||
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className='space-y-4 py-4'>
|
||||
<Alert>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'All edits are overwrite operations. Leave fields empty to keep current values unchanged.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Tag Name */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='new-tag'>{t('Tag Name')}</Label>
|
||||
<Input
|
||||
id='new-tag'
|
||||
placeholder={t(
|
||||
'Enter new tag name (leave empty to disband tag)'
|
||||
)}
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Leave empty to disband the tag')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Models */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='models'>{t('Models')}</Label>
|
||||
<Textarea
|
||||
id='models'
|
||||
placeholder={t(
|
||||
'Comma-separated model names (leave empty to keep current)'
|
||||
)}
|
||||
value={models}
|
||||
onChange={(e) => setModels(e.target.value)}
|
||||
disabled={isSaving}
|
||||
rows={3}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Current models for the longest channel in this tag. May not include all models from all channels.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Model Mapping */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='model-mapping'>{t('Model Mapping')}</Label>
|
||||
<ModelMappingEditor
|
||||
value={modelMapping}
|
||||
onChange={setModelMapping}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Groups */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='groups'>{t('Groups')}</Label>
|
||||
{isLoadingGroups ? (
|
||||
<Skeleton className='h-10 w-full' />
|
||||
) : (
|
||||
<MultiSelect
|
||||
options={groupOptions}
|
||||
selected={groups}
|
||||
onChange={setGroups}
|
||||
placeholder={t('Select groups (leave empty to keep current)')}
|
||||
/>
|
||||
<div className='space-y-4 py-4'>
|
||||
<Alert>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'All edits are overwrite operations. Leave fields empty to keep current values unchanged.'
|
||||
)}
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('User groups that can access channels with this tag')}
|
||||
</p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Tag Name */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='new-tag'>{t('Tag Name')}</Label>
|
||||
<Input
|
||||
id='new-tag'
|
||||
placeholder={t('Enter new tag name (leave empty to disband tag)')}
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Leave empty to disband the tag')}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
|
||||
{/* Models */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='models'>{t('Models')}</Label>
|
||||
<Textarea
|
||||
id='models'
|
||||
placeholder={t(
|
||||
'Comma-separated model names (leave empty to keep current)'
|
||||
)}
|
||||
value={models}
|
||||
onChange={(e) => setModels(e.target.value)}
|
||||
disabled={isSaving}
|
||||
rows={3}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Current models for the longest channel in this tag. May not include all models from all channels.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Model Mapping */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='model-mapping'>{t('Model Mapping')}</Label>
|
||||
<ModelMappingEditor
|
||||
value={modelMapping}
|
||||
onChange={setModelMapping}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Groups */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='groups'>{t('Groups')}</Label>
|
||||
{isLoadingGroups ? (
|
||||
<Skeleton className='h-10 w-full' />
|
||||
) : (
|
||||
<MultiSelect
|
||||
options={groupOptions}
|
||||
selected={groups}
|
||||
onChange={setGroups}
|
||||
placeholder={t('Select groups (leave empty to keep current)')}
|
||||
/>
|
||||
)}
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('User groups that can access channels with this tag')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -120,7 +120,12 @@ import {
|
||||
parseChannelConnectionInfo,
|
||||
type ChannelConnectionInfo,
|
||||
} from '@/lib/channel-connection-info'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import {
|
||||
requireServerSuccess,
|
||||
createServerError,
|
||||
} from '@/lib/server-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
@@ -641,26 +646,26 @@ export function ChannelMutateDrawer({
|
||||
// Fetch channel details if editing
|
||||
const { data: channelData, isLoading: isChannelLoading } = useQuery({
|
||||
queryKey: channelsQueryKeys.detail(channelId || 0),
|
||||
queryFn: () => getChannel(channelId || 0),
|
||||
queryFn: async () => requireServerSuccess(await getChannel(channelId || 0)),
|
||||
enabled: isEditing && Boolean(channelId),
|
||||
})
|
||||
|
||||
// Fetch available groups
|
||||
const { data: groupsData, isLoading: isLoadingGroups } = useQuery({
|
||||
queryKey: ['groups'],
|
||||
queryFn: getGroups,
|
||||
queryFn: async () => requireServerSuccess(await getGroups()),
|
||||
})
|
||||
|
||||
// Fetch all available models
|
||||
const { data: allModelsData } = useQuery({
|
||||
queryKey: ['channel_models'],
|
||||
queryFn: getAllModels,
|
||||
queryFn: async () => requireServerSuccess(await getAllModels()),
|
||||
})
|
||||
|
||||
// Fetch prefill model groups
|
||||
const { data: prefillGroupsData } = useQuery({
|
||||
queryKey: ['prefill_groups', 'model'],
|
||||
queryFn: () => getPrefillGroups('model'),
|
||||
queryFn: async () => requireServerSuccess(await getPrefillGroups('model')),
|
||||
})
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
@@ -893,7 +898,7 @@ export function ChannelMutateDrawer({
|
||||
)
|
||||
const taskPluginOptionsQuery = useQuery({
|
||||
queryKey: ['task-plugin-options'],
|
||||
queryFn: getTaskPluginOptions,
|
||||
queryFn: async () => requireServerSuccess(await getTaskPluginOptions()),
|
||||
enabled: currentType === CHANNEL_TYPE_TASK_PLUGIN && canBindTaskPlugin,
|
||||
})
|
||||
const boundTaskPlugin =
|
||||
@@ -1345,14 +1350,14 @@ export function ChannelMutateDrawer({
|
||||
try {
|
||||
const res = await refreshCodexCredential(channelId)
|
||||
if (!res.success) {
|
||||
throw new Error(res.message || t('Failed to refresh credential'))
|
||||
throw createServerError(res, t('Failed to refresh credential'))
|
||||
}
|
||||
toast.success(t('Credential refreshed'))
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: channelsQueryKeys.detail(channelId),
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('Refresh failed'))
|
||||
handleServerError(error, t('Refresh failed'))
|
||||
} finally {
|
||||
setIsCodexCredentialRefreshing(false)
|
||||
}
|
||||
@@ -1418,7 +1423,7 @@ export function ChannelMutateDrawer({
|
||||
if (response.success && response.data) {
|
||||
return response.data
|
||||
}
|
||||
throw new Error(response.message || t('No models fetched from upstream'))
|
||||
throw createServerError(response, t('No models fetched from upstream'))
|
||||
}, [canEditSensitive, channelId, form, isEditing, t])
|
||||
|
||||
// Handle model operations
|
||||
@@ -1625,7 +1630,10 @@ export function ChannelMutateDrawer({
|
||||
if (hasModelMapping) {
|
||||
const validation = validateModelMappingJson(modelMappingValue)
|
||||
if (!validation.valid) {
|
||||
toast.error(t(validation.error || 'Invalid model mapping'))
|
||||
handleServerError(
|
||||
validation,
|
||||
t(validation.error || 'Invalid model mapping')
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useSecureVerification } from '@/features/auth/secure-verification'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { AuthOperationError } from '@/lib/secure-verification'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
|
||||
import { getChannelKey } from '../api'
|
||||
|
||||
@@ -72,13 +74,13 @@ export function useChannelKeyDisclosure(
|
||||
)
|
||||
if (operation.current !== current) return
|
||||
if (!res.success) {
|
||||
throw new Error(res.message || t('Failed to fetch channel key'))
|
||||
throw createServerError(res, t('Failed to fetch channel key'))
|
||||
}
|
||||
setDisclosedKey({ channelId, key: res.data?.key ?? '' })
|
||||
toast.success(t('Channel key unlocked'))
|
||||
} catch (error) {
|
||||
if (operation.current === current && !current.signal.aborted) {
|
||||
toast.error(t(AuthOperationError.from(error).message))
|
||||
handleServerError(AuthOperationError.from(error))
|
||||
}
|
||||
} finally {
|
||||
if (operation.current === current) {
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
ADMIN_PERMISSION_RESOURCES,
|
||||
hasPermission,
|
||||
} from '@/lib/admin-permissions'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import { createChannel, updateChannel } from '../api'
|
||||
@@ -55,31 +57,6 @@ const SENSITIVE_UPDATE_FIELDS = [
|
||||
'other',
|
||||
] satisfies (keyof Channel)[]
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string | undefined {
|
||||
if (error instanceof Error && typeof error.message === 'string') {
|
||||
return error.message
|
||||
}
|
||||
|
||||
if (!isRecord(error)) return undefined
|
||||
|
||||
const response = error.response
|
||||
if (isRecord(response)) {
|
||||
const data = response.data
|
||||
if (isRecord(data)) {
|
||||
const message = data.message
|
||||
if (typeof message === 'string') return message
|
||||
}
|
||||
}
|
||||
|
||||
const message = error.message
|
||||
if (typeof message === 'string') return message
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function useChannelMutateForm(props: UseChannelMutateFormParams) {
|
||||
const { t } = useTranslation()
|
||||
const currentUser = useAuthStore((s) => s.auth.user)
|
||||
@@ -120,7 +97,7 @@ export function useChannelMutateForm(props: UseChannelMutateFormParams) {
|
||||
payloadWithKeyMode
|
||||
)
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
throw createServerError(response, t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
return SUCCESS_MESSAGES.UPDATED
|
||||
}
|
||||
@@ -128,7 +105,7 @@ export function useChannelMutateForm(props: UseChannelMutateFormParams) {
|
||||
const payload = transformFormDataToCreatePayload(data)
|
||||
const response = await createChannel(payload)
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || t(ERROR_MESSAGES.CREATE_FAILED))
|
||||
throw createServerError(response, t(ERROR_MESSAGES.CREATE_FAILED))
|
||||
}
|
||||
return SUCCESS_MESSAGES.CREATED
|
||||
},
|
||||
@@ -137,7 +114,7 @@ export function useChannelMutateForm(props: UseChannelMutateFormParams) {
|
||||
props.onSuccess()
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(getErrorMessage(error) || t(ERROR_MESSAGES.CREATE_FAILED))
|
||||
handleServerError(error, t(ERROR_MESSAGES.CREATE_FAILED))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { api, type ApiRequestConfig } from '@/lib/api'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
|
||||
import { normalizeModelList } from '../lib/upstream-update-utils'
|
||||
|
||||
@@ -31,9 +32,9 @@ const upstreamUpdateRequestConfig = {
|
||||
|
||||
function getManualIgnoredModelCount(settings: unknown): number {
|
||||
let parsed: Record<string, unknown> | null = null
|
||||
if (settings && typeof settings === 'object')
|
||||
if (settings && typeof settings === 'object') {
|
||||
parsed = settings as Record<string, unknown>
|
||||
else if (typeof settings === 'string') {
|
||||
} else if (typeof settings === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(settings)
|
||||
} catch {
|
||||
@@ -126,9 +127,9 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
|
||||
},
|
||||
upstreamUpdateRequestConfig
|
||||
)
|
||||
const { success, message, data } = res.data || {}
|
||||
const { success, data } = res.data || {}
|
||||
if (!success) {
|
||||
toast.error(message || t('Operation failed'))
|
||||
handleServerError(res.data, t('Operation failed'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -146,13 +147,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
|
||||
closeModal()
|
||||
await refresh()
|
||||
} catch (e: unknown) {
|
||||
const err = e as {
|
||||
response?: { data?: { message?: string } }
|
||||
message?: string
|
||||
}
|
||||
toast.error(
|
||||
err?.response?.data?.message || err?.message || t('Operation failed')
|
||||
)
|
||||
handleServerError(e, t('Operation failed'))
|
||||
} finally {
|
||||
applyRef.current = false
|
||||
setApplyLoading(false)
|
||||
@@ -171,9 +166,9 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
|
||||
{},
|
||||
upstreamUpdateRequestConfig
|
||||
)
|
||||
const { success, message, data } = res.data || {}
|
||||
const { success, data } = res.data || {}
|
||||
if (!success) {
|
||||
toast.error(message || t('Batch processing failed'))
|
||||
handleServerError(res.data, t('Batch processing failed'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -190,15 +185,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
|
||||
)
|
||||
await refresh()
|
||||
} catch (e: unknown) {
|
||||
const err = e as {
|
||||
response?: { data?: { message?: string } }
|
||||
message?: string
|
||||
}
|
||||
toast.error(
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
t('Batch processing failed')
|
||||
)
|
||||
handleServerError(e, t('Batch processing failed'))
|
||||
} finally {
|
||||
applyAllRef.current = false
|
||||
setApplyAllLoading(false)
|
||||
@@ -215,9 +202,9 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
|
||||
{ id: ch.id },
|
||||
upstreamUpdateRequestConfig
|
||||
)
|
||||
const { success, message, data } = res.data || {}
|
||||
const { success, data } = res.data || {}
|
||||
if (!success) {
|
||||
toast.error(message || t('Detection failed'))
|
||||
handleServerError(res.data, t('Detection failed'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -229,13 +216,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
|
||||
)
|
||||
await refresh()
|
||||
} catch (e: unknown) {
|
||||
const err = e as {
|
||||
response?: { data?: { message?: string } }
|
||||
message?: string
|
||||
}
|
||||
toast.error(
|
||||
err?.response?.data?.message || err?.message || t('Detection failed')
|
||||
)
|
||||
handleServerError(e, t('Detection failed'))
|
||||
} finally {
|
||||
detectRef.current = false
|
||||
}
|
||||
@@ -253,9 +234,9 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
|
||||
{},
|
||||
upstreamUpdateRequestConfig
|
||||
)
|
||||
const { success, message } = res.data || {}
|
||||
const { success } = res.data || {}
|
||||
if (!success) {
|
||||
toast.error(message || t('Batch detection failed'))
|
||||
handleServerError(res.data, t('Batch detection failed'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -266,15 +247,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
|
||||
)
|
||||
await refresh()
|
||||
} catch (e: unknown) {
|
||||
const err = e as {
|
||||
response?: { data?: { message?: string } }
|
||||
message?: string
|
||||
}
|
||||
toast.error(
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
t('Batch detection failed')
|
||||
)
|
||||
handleServerError(e, t('Batch detection failed'))
|
||||
} finally {
|
||||
detectAllRef.current = false
|
||||
setDetectAllLoading(false)
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import { getChannelOps } from './api'
|
||||
@@ -44,7 +45,7 @@ export function Channels() {
|
||||
)
|
||||
const channelOpsQuery = useQuery({
|
||||
queryKey: ['channel-ops'],
|
||||
queryFn: getChannelOps,
|
||||
queryFn: async () => requireServerSuccess(await getChannelOps()),
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
@@ -20,6 +20,8 @@ import type { QueryClient } from '@tanstack/react-query'
|
||||
import i18next from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
|
||||
import {
|
||||
copyChannel,
|
||||
deleteChannel,
|
||||
@@ -126,10 +128,10 @@ export async function handleEnableChannel(
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
} else {
|
||||
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
handleServerError(response, i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
} catch {
|
||||
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
} catch (error) {
|
||||
handleServerError(error, i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,10 +153,10 @@ export async function handleDisableChannel(
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
} else {
|
||||
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
handleServerError(response, i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
} catch {
|
||||
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
} catch (error) {
|
||||
handleServerError(error, i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,10 +191,10 @@ export async function handleDeleteChannel(
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
} else {
|
||||
toast.error(response.message || i18next.t(ERROR_MESSAGES.DELETE_FAILED))
|
||||
handleServerError(response, i18next.t(ERROR_MESSAGES.DELETE_FAILED))
|
||||
}
|
||||
} catch {
|
||||
toast.error(i18next.t(ERROR_MESSAGES.DELETE_FAILED))
|
||||
} catch (error) {
|
||||
handleServerError(error, i18next.t(ERROR_MESSAGES.DELETE_FAILED))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,10 +223,10 @@ export async function handleUpdateChannelField(
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
} else {
|
||||
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
handleServerError(response, i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
} catch {
|
||||
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
} catch (error) {
|
||||
handleServerError(error, i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,10 +257,10 @@ export async function handleUpdateTagField(
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
} else {
|
||||
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
handleServerError(response, i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
} catch {
|
||||
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
} catch (error) {
|
||||
handleServerError(error, i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +316,8 @@ export async function handleTestChannel(
|
||||
} else {
|
||||
const errorMsg = response.message || i18next.t(ERROR_MESSAGES.TEST_FAILED)
|
||||
if (!options?.silent) {
|
||||
toast.error(i18next.t('{{target}} test failed', { target }), {
|
||||
handleServerError(response, undefined, {
|
||||
title: i18next.t('{{target}} test failed', { target }),
|
||||
description: response.error_code
|
||||
? `${errorMsg} (${response.error_code})`
|
||||
: errorMsg,
|
||||
@@ -328,7 +331,8 @@ export async function handleTestChannel(
|
||||
err?.response?.data?.message || i18next.t(ERROR_MESSAGES.TEST_FAILED)
|
||||
const target = getChannelTestLabel(options)
|
||||
if (!options?.silent) {
|
||||
toast.error(i18next.t('{{target}} test failed', { target }), {
|
||||
handleServerError(_error, undefined, {
|
||||
title: i18next.t('{{target}} test failed', { target }),
|
||||
description: errorMsg,
|
||||
})
|
||||
}
|
||||
@@ -352,10 +356,10 @@ export async function handleCopyChannel(
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.(response.data?.id ?? 0)
|
||||
} else {
|
||||
toast.error(response.message || i18next.t('Failed to copy channel'))
|
||||
handleServerError(response, i18next.t('Failed to copy channel'))
|
||||
}
|
||||
} catch {
|
||||
toast.error(i18next.t('Failed to copy channel'))
|
||||
} catch (error) {
|
||||
handleServerError(error, i18next.t('Failed to copy channel'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,10 +391,10 @@ export async function handleBatchDelete(
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.(response.data || ids.length)
|
||||
} else {
|
||||
toast.error(response.message || i18next.t(ERROR_MESSAGES.DELETE_FAILED))
|
||||
handleServerError(response, i18next.t(ERROR_MESSAGES.DELETE_FAILED))
|
||||
}
|
||||
} catch {
|
||||
toast.error(i18next.t(ERROR_MESSAGES.DELETE_FAILED))
|
||||
} catch (error) {
|
||||
handleServerError(error, i18next.t(ERROR_MESSAGES.DELETE_FAILED))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,14 +425,14 @@ export async function handleBatchEnable(
|
||||
}
|
||||
|
||||
if (!response.success) {
|
||||
toast.error(response.message || i18next.t('Failed to enable channels'))
|
||||
handleServerError(response, i18next.t('Failed to enable channels'))
|
||||
} else if (failCount > 0) {
|
||||
toast.error(
|
||||
i18next.t('{{count}} channel(s) failed to enable', { count: failCount })
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
toast.error(i18next.t('Failed to enable channels'))
|
||||
} catch (error) {
|
||||
handleServerError(error, i18next.t('Failed to enable channels'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,7 +466,7 @@ export async function handleBatchDisable(
|
||||
}
|
||||
|
||||
if (!response.success) {
|
||||
toast.error(response.message || i18next.t('Failed to disable channels'))
|
||||
handleServerError(response, i18next.t('Failed to disable channels'))
|
||||
} else if (failCount > 0) {
|
||||
toast.error(
|
||||
i18next.t('{{count}} channel(s) failed to disable', {
|
||||
@@ -470,8 +474,8 @@ export async function handleBatchDisable(
|
||||
})
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
toast.error(i18next.t('Failed to disable channels'))
|
||||
} catch (error) {
|
||||
handleServerError(error, i18next.t('Failed to disable channels'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,10 +500,10 @@ export async function handleBatchSetTag(
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
} else {
|
||||
toast.error(response.message || i18next.t('Failed to set tag'))
|
||||
handleServerError(response, i18next.t('Failed to set tag'))
|
||||
}
|
||||
} catch {
|
||||
toast.error(i18next.t('Failed to set tag'))
|
||||
} catch (error) {
|
||||
handleServerError(error, i18next.t('Failed to set tag'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,12 +528,10 @@ export async function handleEnableTagChannels(
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
} else {
|
||||
toast.error(
|
||||
response.message || i18next.t('Failed to enable tag channels')
|
||||
)
|
||||
handleServerError(response, i18next.t('Failed to enable tag channels'))
|
||||
}
|
||||
} catch {
|
||||
toast.error(i18next.t('Failed to enable tag channels'))
|
||||
} catch (error) {
|
||||
handleServerError(error, i18next.t('Failed to enable tag channels'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,12 +552,10 @@ export async function handleDisableTagChannels(
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
} else {
|
||||
toast.error(
|
||||
response.message || i18next.t('Failed to disable tag channels')
|
||||
)
|
||||
handleServerError(response, i18next.t('Failed to disable tag channels'))
|
||||
}
|
||||
} catch {
|
||||
toast.error(i18next.t('Failed to disable tag channels'))
|
||||
} catch (error) {
|
||||
handleServerError(error, i18next.t('Failed to disable tag channels'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,12 +581,13 @@ export async function handleDeleteAllDisabled(
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.(response.data || 0)
|
||||
} else {
|
||||
toast.error(
|
||||
response.message || i18next.t('Failed to delete disabled channels')
|
||||
handleServerError(
|
||||
response,
|
||||
i18next.t('Failed to delete disabled channels')
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
toast.error(i18next.t('Failed to delete disabled channels'))
|
||||
} catch (error) {
|
||||
handleServerError(error, i18next.t('Failed to delete disabled channels'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,12 +613,13 @@ export async function handleFixAbilities(
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.(response.data)
|
||||
} else {
|
||||
toast.error(
|
||||
response.message || i18next.t('Failed to repair channel consistency')
|
||||
handleServerError(
|
||||
response,
|
||||
i18next.t('Failed to repair channel consistency')
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
toast.error(i18next.t('Failed to repair channel consistency'))
|
||||
} catch (error) {
|
||||
handleServerError(error, i18next.t('Failed to repair channel consistency'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,12 +641,13 @@ export async function handleTestAllChannels(
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
} else {
|
||||
toast.error(
|
||||
response.message || i18next.t('Failed to start testing all channels')
|
||||
handleServerError(
|
||||
response,
|
||||
i18next.t('Failed to start testing all channels')
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
toast.error(i18next.t('Failed to test all channels'))
|
||||
} catch (error) {
|
||||
handleServerError(error, i18next.t('Failed to test all channels'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -666,11 +669,9 @@ export async function handleUpdateAllBalances(
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
} else {
|
||||
toast.error(
|
||||
response.message || i18next.t('Failed to update all balances')
|
||||
)
|
||||
handleServerError(response, i18next.t('Failed to update all balances'))
|
||||
}
|
||||
} catch {
|
||||
toast.error(i18next.t('Failed to update all balances'))
|
||||
} catch (error) {
|
||||
handleServerError(error, i18next.t('Failed to update all balances'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,15 +17,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { t } from 'i18next'
|
||||
|
||||
import { fetchTokenKey, getApiKeys } from '@/features/keys/api'
|
||||
import { API_KEY_STATUS } from '@/features/keys/constants'
|
||||
import {
|
||||
requireServerSuccess,
|
||||
createServerError,
|
||||
} from '@/lib/server-error-message'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
export async function fetchActiveChatKey() {
|
||||
const result = await getApiKeys({ p: 1, size: 50 })
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'Failed to load API keys')
|
||||
throw createServerError(result, t('Failed to load API keys'))
|
||||
}
|
||||
|
||||
const items = result.data?.items ?? []
|
||||
@@ -36,7 +41,7 @@ export async function fetchActiveChatKey() {
|
||||
|
||||
const keyResult = await fetchTokenKey(active.id)
|
||||
if (!keyResult.success || !keyResult.data?.key) {
|
||||
throw new Error(keyResult.message || 'Failed to load API key')
|
||||
throw createServerError(keyResult, t('Failed to load API keys'))
|
||||
}
|
||||
|
||||
return `sk-${keyResult.data.key}`
|
||||
@@ -50,7 +55,7 @@ export function useActiveChatKey(enabled: boolean) {
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['chat-active-key', userId],
|
||||
queryFn: fetchActiveChatKey,
|
||||
queryFn: async () => requireServerSuccess(await fetchActiveChatKey()),
|
||||
enabled: enabled && Boolean(userId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
|
||||
@@ -86,6 +86,7 @@ import type {
|
||||
} from '@/features/dashboard/types'
|
||||
import { formatQuota } from '@/lib/format'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
import { computeTimeRange } from '@/lib/time'
|
||||
import { useChartTheme } from '@/lib/use-chart-theme'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -336,7 +337,8 @@ export function FlowCharts(props: FlowChartsProps) {
|
||||
isLoading,
|
||||
} = useQuery({
|
||||
queryKey: ['dashboard', 'flow', flowQueryParams, flowRole],
|
||||
queryFn: () => getFlowQuotaDates(flowQueryParams, isAdmin),
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(await getFlowQuotaDates(flowQueryParams, isAdmin)),
|
||||
select: (res) =>
|
||||
requireSuccessfulFlowRows(res, t('Please try again later.')),
|
||||
staleTime: 60_000,
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
getSuccessRateTextClass,
|
||||
} from '@/features/performance-metrics/lib/format'
|
||||
import type { PerfModelSummary } from '@/features/performance-metrics/types'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const PERFORMANCE_WINDOW_HOURS = 24
|
||||
@@ -87,7 +88,10 @@ export function PerformanceOverview() {
|
||||
const { t } = useTranslation()
|
||||
const metricsQuery = useQuery({
|
||||
queryKey: ['perf-metrics-summary', PERFORMANCE_WINDOW_HOURS],
|
||||
queryFn: () => getPerfMetricsSummary(PERFORMANCE_WINDOW_HOURS),
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(
|
||||
await getPerfMetricsSummary(PERFORMANCE_WINDOW_HOURS)
|
||||
),
|
||||
staleTime: 60 * 1000,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
@@ -52,8 +52,10 @@ import { fetchTokenKey, getApiKeys } from '@/features/keys/api'
|
||||
import type { ApiKey } from '@/features/keys/types'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { getUserModels } from '@/lib/api'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { MOTION_TRANSITION } from '@/lib/motion'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
@@ -298,7 +300,7 @@ function RequestPreview(props: {
|
||||
const result = await fetchTokenKey(props.example.keyId)
|
||||
const key = result.success && result.data?.key ? result.data.key : ''
|
||||
if (!key) {
|
||||
toast.error(result.message || t('Failed to copy to clipboard'))
|
||||
handleServerError(result, t('Failed to copy to clipboard'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -313,6 +315,8 @@ function RequestPreview(props: {
|
||||
} else {
|
||||
toast.error(t('Failed to copy to clipboard'))
|
||||
}
|
||||
} catch (error) {
|
||||
handleServerError(error, t('Failed to copy to clipboard'))
|
||||
} finally {
|
||||
setIsCopying(false)
|
||||
}
|
||||
@@ -480,7 +484,7 @@ export function OverviewDashboard() {
|
||||
const apiKeysQuery = useQuery({
|
||||
queryKey: ['dashboard', 'overview', 'api-keys'],
|
||||
queryFn: async () => {
|
||||
const result = await getApiKeys({ p: 1, size: 10 })
|
||||
const result = requireServerSuccess(await getApiKeys({ p: 1, size: 10 }))
|
||||
return result.success ? (result.data?.items ?? []) : []
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
@@ -489,7 +493,7 @@ export function OverviewDashboard() {
|
||||
const modelsQuery = useQuery({
|
||||
queryKey: ['dashboard', 'overview', 'user-models'],
|
||||
queryFn: async () => {
|
||||
const result = await getUserModels()
|
||||
const result = requireServerSuccess(await getUserModels())
|
||||
return result.success ? (result.data ?? []) : []
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
getSuccessRateTextClass,
|
||||
} from '@/features/performance-metrics/lib/format'
|
||||
import type { PerfModelSummary } from '@/features/performance-metrics/types'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const PERFORMANCE_WINDOW_HOURS = 24
|
||||
@@ -59,7 +60,10 @@ export function PerformanceHealthPanel() {
|
||||
const { t } = useTranslation()
|
||||
const metricsQuery = useQuery({
|
||||
queryKey: ['perf-metrics-summary', PERFORMANCE_WINDOW_HOURS],
|
||||
queryFn: () => getPerfMetricsSummary(PERFORMANCE_WINDOW_HOURS),
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(
|
||||
await getPerfMetricsSummary(PERFORMANCE_WINDOW_HOURS)
|
||||
),
|
||||
staleTime: 60 * 1000,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
@@ -30,6 +30,7 @@ import type { QuotaDataItem } from '@/features/dashboard/types'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { getCurrencyLabel, isCurrencyDisplayEnabled } from '@/lib/currency'
|
||||
import { formatNumber, formatQuota } from '@/lib/format'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
import { computeTimeRange } from '@/lib/time'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
@@ -155,11 +156,13 @@ export function SummaryCards() {
|
||||
summaryTimeRange.end_timestamp,
|
||||
],
|
||||
queryFn: async () =>
|
||||
getUserQuotaDates({
|
||||
start_timestamp: summaryTimeRange.start_timestamp,
|
||||
end_timestamp: summaryTimeRange.end_timestamp,
|
||||
default_time: 'hour',
|
||||
}),
|
||||
requireServerSuccess(
|
||||
await getUserQuotaDates({
|
||||
start_timestamp: summaryTimeRange.start_timestamp,
|
||||
end_timestamp: summaryTimeRange.end_timestamp,
|
||||
default_time: 'hour',
|
||||
})
|
||||
),
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import type {
|
||||
ProcessedUserChartData,
|
||||
UserChartsFilters,
|
||||
} from '@/features/dashboard/types'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
import { getRollingDateRange, type TimeGranularity } from '@/lib/time'
|
||||
import { VCHART_OPTION } from '@/lib/vchart'
|
||||
|
||||
@@ -138,7 +139,8 @@ export function UserCharts(props: UserChartsProps) {
|
||||
|
||||
const { data: userData, isLoading } = useQuery({
|
||||
queryKey: ['dashboard', 'user-quota', timeRange],
|
||||
queryFn: () => getUserQuotaDataByUsers(timeRange),
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(await getUserQuotaDataByUsers(timeRange)),
|
||||
select: (res) => (res.success ? res.data : []),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
FlowQuotaDataItem,
|
||||
FlowUserFilterOption,
|
||||
} from '@/features/dashboard/types'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
|
||||
export type FlowDisplayState = 'loading' | 'error' | 'empty' | 'chart'
|
||||
|
||||
@@ -34,7 +35,7 @@ export function requireSuccessfulFlowRows(
|
||||
fallbackMessage: string
|
||||
): FlowQuotaDataItem[] {
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || fallbackMessage)
|
||||
throw createServerError(response, response.message || fallbackMessage)
|
||||
}
|
||||
return response.data ?? []
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ For commercial licensing, please contact [email protected]
|
||||
*/
|
||||
import i18next from 'i18next'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { isHttpUrl } from '@/lib/content-format'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
|
||||
import { getHomePageContent } from '../api'
|
||||
import type { HomePageContentResult } from '../types'
|
||||
@@ -61,9 +61,7 @@ export function useHomePageContent(): HomePageContentResult {
|
||||
}
|
||||
} catch (error) {
|
||||
if (!mounted) return
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to load home page content:', error)
|
||||
toast.error(i18next.t('Failed to load home page content'))
|
||||
handleServerError(error, i18next.t('Failed to load home page content'))
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setIsLoaded(true)
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useMediaQuery } from '@/hooks'
|
||||
import { toIntlLocale } from '@/i18n/languages'
|
||||
import { getUserGroups } from '@/lib/api'
|
||||
import { getCurrencyDisplay } from '@/lib/currency'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
import { useSystemConfigStore } from '@/stores/system-config-store'
|
||||
|
||||
import { API_KEY_STATUSES } from '../constants'
|
||||
@@ -46,7 +47,7 @@ import { DataTableRowActions } from './data-table-row-actions'
|
||||
function useGroupRatios(): Record<string, number | string> {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['user-groups'],
|
||||
queryFn: getUserGroups,
|
||||
queryFn: async () => requireServerSuccess(await getUserGroups()),
|
||||
staleTime: 0,
|
||||
select: (res) => {
|
||||
if (!res.success || !res.data) return {}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
|
||||
import { deleteApiKey } from '../api'
|
||||
import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
|
||||
@@ -51,10 +52,10 @@ export function ApiKeysDeleteDialog() {
|
||||
setOpen(null)
|
||||
triggerRefresh()
|
||||
} else {
|
||||
toast.error(result.message || t(ERROR_MESSAGES.DELETE_FAILED))
|
||||
handleServerError(result, t(ERROR_MESSAGES.DELETE_FAILED))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
|
||||
} catch (error) {
|
||||
handleServerError(error, t(ERROR_MESSAGES.UNEXPECTED))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
|
||||
@@ -16,16 +16,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import type { Table } from '@tanstack/react-table'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
|
||||
import { batchDeleteApiKeys } from '../api'
|
||||
import { ERROR_MESSAGES } from '../constants'
|
||||
import { type ApiKey } from '../types'
|
||||
import type { ApiKey } from '../types'
|
||||
import { useApiKeys } from './api-keys-provider'
|
||||
|
||||
type ApiKeysMultiDeleteDialogProps<TData> = {
|
||||
@@ -57,10 +58,10 @@ export function ApiKeysMultiDeleteDialog<TData>({
|
||||
triggerRefresh()
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
toast.error(result.message || t(ERROR_MESSAGES.BATCH_DELETE_FAILED))
|
||||
handleServerError(result, t(ERROR_MESSAGES.BATCH_DELETE_FAILED))
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
|
||||
handleServerError(_error, t(ERROR_MESSAGES.UNEXPECTED))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ import { Textarea } from '@/components/ui/textarea'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { getUserModels, getUserGroups } from '@/lib/api'
|
||||
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
@@ -115,7 +117,7 @@ export function ApiKeysMutateDrawer({
|
||||
// Fetch models
|
||||
const { data: modelsData } = useQuery({
|
||||
queryKey: ['user-models'],
|
||||
queryFn: getUserModels,
|
||||
queryFn: async () => requireServerSuccess(await getUserModels()),
|
||||
enabled: open,
|
||||
staleTime: 0,
|
||||
})
|
||||
@@ -127,7 +129,7 @@ export function ApiKeysMutateDrawer({
|
||||
isFetching: groupsFetching,
|
||||
} = useQuery({
|
||||
queryKey: ['user-groups'],
|
||||
queryFn: getUserGroups,
|
||||
queryFn: async () => requireServerSuccess(await getUserGroups()),
|
||||
enabled: open,
|
||||
staleTime: 0,
|
||||
})
|
||||
@@ -138,7 +140,8 @@ export function ApiKeysMutateDrawer({
|
||||
isFetching: apiKeyFetching,
|
||||
} = useQuery({
|
||||
queryKey: ['api-key', currentRowId],
|
||||
queryFn: () => getApiKey(currentRowId ?? 0),
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(await getApiKey(currentRowId ?? 0)),
|
||||
enabled: open && isUpdate && currentRowId !== undefined,
|
||||
staleTime: 0,
|
||||
})
|
||||
@@ -149,7 +152,7 @@ export function ApiKeysMutateDrawer({
|
||||
isFetching: autoGroupsFetching,
|
||||
} = useQuery({
|
||||
queryKey: ['token-auto-groups'],
|
||||
queryFn: getTokenAutoGroups,
|
||||
queryFn: async () => requireServerSuccess(await getTokenAutoGroups()),
|
||||
enabled: open,
|
||||
staleTime: 0,
|
||||
})
|
||||
@@ -292,7 +295,7 @@ export function ApiKeysMutateDrawer({
|
||||
onOpenChange(false)
|
||||
triggerRefresh()
|
||||
} else {
|
||||
toast.error(result.message || t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
handleServerError(result, t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
} else {
|
||||
// Create mode - handle batch creation
|
||||
@@ -310,7 +313,7 @@ export function ApiKeysMutateDrawer({
|
||||
if (result.success) {
|
||||
successCount++
|
||||
} else {
|
||||
toast.error(result.message || t(ERROR_MESSAGES.CREATE_FAILED))
|
||||
handleServerError(result, t(ERROR_MESSAGES.CREATE_FAILED))
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -325,8 +328,8 @@ export function ApiKeysMutateDrawer({
|
||||
triggerRefresh()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
|
||||
} catch (error) {
|
||||
handleServerError(error, t(ERROR_MESSAGES.UNEXPECTED))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
@@ -18,13 +18,13 @@ For commercial licensing, please contact [email protected]
|
||||
*/
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import useDialogState from '@/hooks/use-dialog'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
|
||||
import { fetchTokenKey, fetchTokenKeysBatch } from '../api'
|
||||
import { ERROR_MESSAGES } from '../constants'
|
||||
import { type ApiKey, type ApiKeysDialogType } from '../types'
|
||||
import type { ApiKey, ApiKeysDialogType } from '../types'
|
||||
|
||||
type ApiKeysContextType = {
|
||||
open: ApiKeysDialogType | null
|
||||
@@ -87,10 +87,10 @@ export function ApiKeysProvider({ children }: { children: React.ReactNode }) {
|
||||
setResolvedKeys((prev) => ({ ...prev, [id]: fullKey }))
|
||||
return fullKey
|
||||
}
|
||||
toast.error(res.message || t(ERROR_MESSAGES.UNEXPECTED))
|
||||
handleServerError(res, t(ERROR_MESSAGES.UNEXPECTED))
|
||||
return null
|
||||
} catch {
|
||||
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
|
||||
} catch (error) {
|
||||
handleServerError(error, t(ERROR_MESSAGES.UNEXPECTED))
|
||||
return null
|
||||
} finally {
|
||||
delete pendingRequests.current[id]
|
||||
@@ -136,10 +136,10 @@ export function ApiKeysProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
return result
|
||||
}
|
||||
toast.error(res.message || t(ERROR_MESSAGES.UNEXPECTED))
|
||||
handleServerError(res, t(ERROR_MESSAGES.UNEXPECTED))
|
||||
return {}
|
||||
} catch {
|
||||
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
|
||||
} catch (error) {
|
||||
handleServerError(error, t(ERROR_MESSAGES.UNEXPECTED))
|
||||
return {}
|
||||
} finally {
|
||||
for (const id of uncachedIds) {
|
||||
|
||||
@@ -22,7 +22,6 @@ import { flexRender, type Table as TanstackTable } from '@tanstack/react-table'
|
||||
import { Database } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
@@ -42,6 +41,7 @@ import {
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { getApiKeys, searchApiKeys } from '../api'
|
||||
@@ -284,15 +284,14 @@ export function ApiKeysTable() {
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(
|
||||
result.message ||
|
||||
t(
|
||||
shouldSearch
|
||||
? ERROR_MESSAGES.SEARCH_FAILED
|
||||
: ERROR_MESSAGES.LOAD_FAILED
|
||||
)
|
||||
throw createServerError(
|
||||
result,
|
||||
t(
|
||||
shouldSearch
|
||||
? ERROR_MESSAGES.SEARCH_FAILED
|
||||
: ERROR_MESSAGES.LOAD_FAILED
|
||||
)
|
||||
)
|
||||
return { items: [], total: 0 }
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -52,6 +52,7 @@ import { resolveChatUrl, type ChatPreset } from '@/features/chat/lib/chat-links'
|
||||
import { sendToFluent } from '@/features/chat/lib/send-to-fluent'
|
||||
import { encodeChannelConnectionInfo } from '@/lib/channel-connection-info'
|
||||
import { copyToClipboard } from '@/lib/copy-to-clipboard'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
|
||||
import { updateApiKeyStatus } from '../api'
|
||||
import { API_KEY_STATUS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
|
||||
@@ -155,10 +156,10 @@ export function DataTableRowActions<TData>({
|
||||
toast.success(message)
|
||||
triggerRefresh()
|
||||
} else {
|
||||
toast.error(result.message || t(ERROR_MESSAGES.STATUS_UPDATE_FAILED))
|
||||
handleServerError(result, t(ERROR_MESSAGES.STATUS_UPDATE_FAILED))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
|
||||
} catch (error) {
|
||||
handleServerError(error, t(ERROR_MESSAGES.UNEXPECTED))
|
||||
} finally {
|
||||
setIsTogglingStatus(false)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { ComboboxInput } from '@/components/ui/combobox-input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { getUserModels } from '@/lib/api'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
|
||||
const APP_CONFIGS = {
|
||||
claude: {
|
||||
@@ -73,7 +74,7 @@ function buildCCSwitchURL(
|
||||
apiKey: string
|
||||
): string {
|
||||
const serverAddress = getServerAddress()
|
||||
const endpoint = app === 'codex' ? serverAddress + '/v1' : serverAddress
|
||||
const endpoint = app === 'codex' ? `${serverAddress}/v1` : serverAddress
|
||||
const params = new URLSearchParams()
|
||||
params.set('resource', 'provider')
|
||||
params.set('app', app)
|
||||
@@ -102,7 +103,7 @@ export function CCSwitchDialog(props: Props) {
|
||||
|
||||
const { data: modelsData } = useQuery({
|
||||
queryKey: ['user-models-ccswitch'],
|
||||
queryFn: getUserModels,
|
||||
queryFn: async () => requireServerSuccess(await getUserModels()),
|
||||
enabled: props.open,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
@@ -196,7 +197,7 @@ export function CCSwitchDialog(props: Props) {
|
||||
onValueChange={setName}
|
||||
placeholder={currentConfig.defaultName}
|
||||
emptyText=''
|
||||
allowCustomValue={true}
|
||||
allowCustomValue
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { AxiosError, type AxiosAdapter } from 'axios'
|
||||
import { toast } from 'sonner'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import { api } from '@/lib/http-client'
|
||||
import { createAppQueryClient } from '@/lib/query-client'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { usePricingPreferencesStore } from '@/stores/pricing-preferences-store'
|
||||
|
||||
import type { ModelPricingConfig } from '../api'
|
||||
import { ModelPricingPanel } from '../model-pricing-panel'
|
||||
import { pricingOptions } from '../pricing'
|
||||
|
||||
const originalAdapter = api.defaults.adapter
|
||||
let client: QueryClient | undefined
|
||||
|
||||
afterEach(() => {
|
||||
client?.clear()
|
||||
api.defaults.adapter = originalAdapter
|
||||
useAuthStore.getState().auth.setUser(null)
|
||||
localStorage.clear()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it.each([200, 400])(
|
||||
'shows the backend pricing error once, preserves the draft, and reports a new attempt separately (HTTP %i)',
|
||||
async (status) => {
|
||||
useAuthStore
|
||||
.getState()
|
||||
.auth.setUser({ id: 1, username: 'administrator', role: 100 })
|
||||
usePricingPreferencesStore.setState({ currency: 'USD' })
|
||||
const values = {
|
||||
'billing_setting.billing_mode': 'tiered_expr',
|
||||
'billing_setting.billing_expr': 'tier("standard", p * 1 + c * 2)',
|
||||
}
|
||||
const snapshot: ModelPricingConfig = {
|
||||
entries: [
|
||||
{
|
||||
model_name: 'example',
|
||||
version: 'v1',
|
||||
configured: values,
|
||||
effective: values,
|
||||
},
|
||||
],
|
||||
options: pricingOptions({}),
|
||||
empty_version: 'empty',
|
||||
}
|
||||
const message = 'model_pricing: expression validation failed (1:18)'
|
||||
const notify = vi.spyOn(toast, 'error').mockReturnValue('pricing-error')
|
||||
const requests: unknown[] = []
|
||||
const adapter: AxiosAdapter = async (config) => {
|
||||
if (
|
||||
config.method === 'get' &&
|
||||
['/api/status', '/api/pricing'].includes(config.url ?? '')
|
||||
) {
|
||||
const data =
|
||||
config.url === '/api/status'
|
||||
? { success: true, data: {} }
|
||||
: { success: true, data: [], vendors: [] }
|
||||
return { data, status: 200, statusText: 'OK', headers: {}, config }
|
||||
}
|
||||
if (
|
||||
config.method === 'get' &&
|
||||
config.url === '/api/option/model_pricing'
|
||||
) {
|
||||
return {
|
||||
data: { success: true, data: snapshot },
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config,
|
||||
}
|
||||
}
|
||||
if (
|
||||
config.method === 'patch' &&
|
||||
config.url === '/api/option/model_pricing'
|
||||
) {
|
||||
requests.push(config.data)
|
||||
const response = {
|
||||
data: { success: false, message },
|
||||
status,
|
||||
statusText: 'Error',
|
||||
headers: {},
|
||||
config,
|
||||
}
|
||||
if (status === 400) {
|
||||
throw new AxiosError(
|
||||
'Request failed with status code 400',
|
||||
'ERR_BAD_REQUEST',
|
||||
config,
|
||||
undefined,
|
||||
response
|
||||
)
|
||||
}
|
||||
return response
|
||||
}
|
||||
throw new Error(`Unexpected request: ${config.method} ${config.url}`)
|
||||
}
|
||||
api.defaults.adapter = adapter
|
||||
client = createAppQueryClient()
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ModelPricingPanel modelName='example' />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
const price = await screen.findByRole('textbox', {
|
||||
name: 'Input price',
|
||||
})
|
||||
await user.clear(price)
|
||||
await user.type(price, '3')
|
||||
const save = screen.getByRole('button', { name: 'Save model prices' })
|
||||
await user.click(save)
|
||||
await waitFor(() =>
|
||||
expect(notify.mock.calls.map(([text]) => text)).toEqual([message])
|
||||
)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(message)
|
||||
expect(price).toHaveValue('3')
|
||||
await waitFor(() => expect(save).toBeEnabled())
|
||||
await user.click(save)
|
||||
await waitFor(() =>
|
||||
expect(notify.mock.calls.map(([text]) => text)).toEqual([
|
||||
message,
|
||||
message,
|
||||
])
|
||||
)
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[0]).toContain('p * 3')
|
||||
}
|
||||
)
|
||||
@@ -22,12 +22,12 @@ import {
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { t } from 'i18next'
|
||||
|
||||
import type { BillingUsageSchema } from '@/features/pricing/types'
|
||||
import { api } from '@/lib/api'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import {
|
||||
@@ -68,7 +68,7 @@ export async function getModelPricing(
|
||||
for (const name of names) params.append('model', name)
|
||||
const res = await api.get('/api/option/model_pricing', { params })
|
||||
if (!res.data.success) {
|
||||
throw new Error(res.data.message || t('Failed to load model pricing'))
|
||||
throw createServerError(res.data, t('Failed to load model pricing'))
|
||||
}
|
||||
return res.data.data
|
||||
}
|
||||
@@ -94,19 +94,9 @@ export async function invalidateModelPricing(client: QueryClient) {
|
||||
|
||||
export async function saveModelPricing(changes: ModelPricingChange[]) {
|
||||
if (!changes.length) return
|
||||
try {
|
||||
const res = await api.patch('/api/option/model_pricing', { changes })
|
||||
if (!res.data.success) {
|
||||
throw new Error(res.data.message || t('Failed to save model pricing'))
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
isAxiosError<{ message?: string }>(error) &&
|
||||
error.response?.data.message
|
||||
) {
|
||||
throw new Error(error.response.data.message, { cause: error })
|
||||
}
|
||||
throw error
|
||||
const res = await api.patch('/api/option/model_pricing', { changes })
|
||||
if (!res.data.success) {
|
||||
throw createServerError(res.data, t('Failed to save model pricing'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
|
||||
import { deleteDeployment, listDeployments, searchDeployments } from '../api'
|
||||
import { getDeploymentStatusOptions } from '../constants'
|
||||
@@ -123,18 +125,22 @@ export function DeploymentsTable() {
|
||||
}),
|
||||
queryFn: async () => {
|
||||
if (keyword.trim()) {
|
||||
return searchDeployments({
|
||||
keyword,
|
||||
return requireServerSuccess(
|
||||
await searchDeployments({
|
||||
keyword,
|
||||
status: activeStatus,
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
})
|
||||
)
|
||||
}
|
||||
return requireServerSuccess(
|
||||
await listDeployments({
|
||||
status: activeStatus,
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
})
|
||||
}
|
||||
return listDeployments({
|
||||
status: activeStatus,
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
})
|
||||
)
|
||||
},
|
||||
placeholderData: (prev) => prev,
|
||||
})
|
||||
@@ -153,10 +159,10 @@ export function DeploymentsTable() {
|
||||
queryKey: deploymentsQueryKeys.lists(),
|
||||
})
|
||||
} else {
|
||||
toast.error(res?.message || t('Delete failed'))
|
||||
handleServerError(res, t('Delete failed'))
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('Delete failed'))
|
||||
handleServerError(err, t('Delete failed'))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
setDeleteOpen(false)
|
||||
|
||||
@@ -16,7 +16,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Combobox } from '@/components/ui/combobox'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
@@ -35,6 +34,7 @@ import {
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import { MultiSelect } from '@/components/multi-select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Combobox } from '@/components/ui/combobox'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -44,7 +44,14 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
@@ -54,6 +61,8 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
|
||||
import {
|
||||
checkClusterNameAvailability,
|
||||
@@ -136,7 +145,7 @@ export function CreateDeploymentDrawer({
|
||||
|
||||
const { data: hardwareTypesData, isLoading: isLoadingHardware } = useQuery({
|
||||
queryKey: ['deployment-hardware-types'],
|
||||
queryFn: getHardwareTypes,
|
||||
queryFn: async () => requireServerSuccess(await getHardwareTypes()),
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
@@ -165,11 +174,13 @@ export function CreateDeploymentDrawer({
|
||||
|
||||
const { data: replicasData, isLoading: isLoadingReplicas } = useQuery({
|
||||
queryKey: ['deployment-available-replicas', hardwareId, gpuCount],
|
||||
queryFn: () =>
|
||||
getAvailableReplicas({
|
||||
hardware_id: hardwareId,
|
||||
gpu_count: gpuCount,
|
||||
}),
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(
|
||||
await getAvailableReplicas({
|
||||
hardware_id: hardwareId,
|
||||
gpu_count: gpuCount,
|
||||
})
|
||||
),
|
||||
enabled: open && Boolean(hardwareId) && gpuCount > 0,
|
||||
})
|
||||
|
||||
@@ -190,7 +201,7 @@ export function CreateDeploymentDrawer({
|
||||
map.set(key, { label: String(name), value: key })
|
||||
}
|
||||
})
|
||||
return Array.from(map.values())
|
||||
return [...map.values()]
|
||||
}, [replicasData])
|
||||
|
||||
const { data: priceData, isLoading: _isLoadingPrice } = useQuery({
|
||||
@@ -203,15 +214,17 @@ export function CreateDeploymentDrawer({
|
||||
locationIds,
|
||||
currency,
|
||||
],
|
||||
queryFn: () =>
|
||||
estimatePrice({
|
||||
location_ids: locationIds,
|
||||
hardware_id: hardwareId,
|
||||
gpus_per_container: gpuCount,
|
||||
duration_hours: durationHours,
|
||||
replica_count: replicaCount,
|
||||
currency: currency || 'usdc',
|
||||
}),
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(
|
||||
await estimatePrice({
|
||||
location_ids: locationIds,
|
||||
hardware_id: hardwareId,
|
||||
gpus_per_container: gpuCount,
|
||||
duration_hours: durationHours,
|
||||
replica_count: replicaCount,
|
||||
currency: currency || 'usdc',
|
||||
})
|
||||
),
|
||||
enabled:
|
||||
open &&
|
||||
Boolean(hardwareId) &&
|
||||
@@ -226,7 +239,7 @@ export function CreateDeploymentDrawer({
|
||||
queryFn: async () => {
|
||||
const name = (resourceName || '').trim()
|
||||
if (!name) return null
|
||||
return await checkClusterNameAvailability(name)
|
||||
return requireServerSuccess(await checkClusterNameAvailability(name))
|
||||
},
|
||||
enabled: open && Boolean(resourceName && resourceName.trim().length > 0),
|
||||
staleTime: 10_000,
|
||||
@@ -326,10 +339,10 @@ export function CreateDeploymentDrawer({
|
||||
onOpenChange(false)
|
||||
return
|
||||
}
|
||||
toast.error(data?.message || t('Failed to create deployment'))
|
||||
handleServerError(data, t('Failed to create deployment'))
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || t('Failed to create deployment'))
|
||||
handleServerError(err, t('Failed to create deployment'))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -411,13 +424,18 @@ export function CreateDeploymentDrawer({
|
||||
</FormControl>
|
||||
{open && field.value?.trim() ? (
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{isCheckingName
|
||||
? t('Checking name...')
|
||||
: nameAvailable === true
|
||||
? t('Name is available')
|
||||
: nameAvailable === false
|
||||
? t('Name is not available')
|
||||
: ''}
|
||||
{isCheckingName && t('Checking name...')}
|
||||
{!isCheckingName &&
|
||||
nameAvailable === true &&
|
||||
t('Name is available')}
|
||||
{!isCheckingName &&
|
||||
!(nameAvailable === true) &&
|
||||
nameAvailable === false &&
|
||||
t('Name is not available')}
|
||||
{!isCheckingName &&
|
||||
!(nameAvailable === true) &&
|
||||
!(nameAvailable === false) &&
|
||||
''}
|
||||
</div>
|
||||
) : null}
|
||||
<FormMessage />
|
||||
@@ -453,19 +471,19 @@ export function CreateDeploymentDrawer({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Hardware type')}</FormLabel>
|
||||
<FormControl><Combobox
|
||||
options={[
|
||||
...hardwareOptions.map((opt) => ({
|
||||
<FormControl>
|
||||
<Combobox
|
||||
options={hardwareOptions.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
})),
|
||||
]}
|
||||
value={field.value}
|
||||
onValueChange={(v) => field.onChange(v)}
|
||||
disabled={isLoadingHardware}
|
||||
className='w-full'
|
||||
placeholder={t('Select')}
|
||||
/></FormControl>
|
||||
}))}
|
||||
value={field.value}
|
||||
onValueChange={(v) => field.onChange(v)}
|
||||
disabled={isLoadingHardware}
|
||||
className='w-full'
|
||||
placeholder={t('Select')}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
@@ -26,6 +26,8 @@ import { Dialog } from '@/components/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
|
||||
import { estimatePrice, extendDeployment, getDeployment } from '../../api'
|
||||
import { deploymentsQueryKeys } from '../../lib'
|
||||
@@ -55,7 +57,10 @@ export function ExtendDeploymentDialog({
|
||||
|
||||
const { data: detailsRes, isLoading: isLoadingDetails } = useQuery({
|
||||
queryKey: ['deployment-details-for-extend', deploymentId],
|
||||
queryFn: () => (deploymentId ? getDeployment(deploymentId) : null),
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(
|
||||
await (deploymentId ? getDeployment(deploymentId) : null)
|
||||
),
|
||||
enabled: open && deploymentId !== null,
|
||||
})
|
||||
|
||||
@@ -97,17 +102,19 @@ export function ExtendDeploymentDialog({
|
||||
isFetching: isFetchingPrice,
|
||||
} = useQuery({
|
||||
queryKey: ['deployment-extend-price', deploymentId, hours, priceParams],
|
||||
queryFn: () =>
|
||||
priceParams
|
||||
? estimatePrice({
|
||||
location_ids: priceParams.location_ids,
|
||||
hardware_id: priceParams.hardware_id,
|
||||
gpus_per_container: priceParams.gpus_per_container,
|
||||
replica_count: priceParams.replica_count,
|
||||
duration_hours: hours,
|
||||
currency: 'usdc',
|
||||
})
|
||||
: null,
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(
|
||||
await (priceParams
|
||||
? estimatePrice({
|
||||
location_ids: priceParams.location_ids,
|
||||
hardware_id: priceParams.hardware_id,
|
||||
gpus_per_container: priceParams.gpus_per_container,
|
||||
replica_count: priceParams.replica_count,
|
||||
duration_hours: hours,
|
||||
currency: 'usdc',
|
||||
})
|
||||
: null)
|
||||
),
|
||||
enabled: open && Boolean(priceParams) && hours > 0,
|
||||
})
|
||||
|
||||
@@ -151,9 +158,9 @@ export function ExtendDeploymentDialog({
|
||||
onOpenChange(false)
|
||||
return
|
||||
}
|
||||
toast.error(res.message || t('Extend failed'))
|
||||
handleServerError(res, t('Extend failed'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('Extend failed'))
|
||||
handleServerError(err, t('Extend failed'))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@@ -211,16 +218,18 @@ export function ExtendDeploymentDialog({
|
||||
<div className='space-y-1'>
|
||||
<div className='text-sm font-medium'>{t('Estimated cost')}</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{isLoadingPrice || isFetchingPrice ? (
|
||||
{(isLoadingPrice || isFetchingPrice) && (
|
||||
<span className='inline-flex items-center gap-2'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
{t('Calculating...')}
|
||||
</span>
|
||||
) : priceParams ? (
|
||||
priceSummary || t('Not available')
|
||||
) : (
|
||||
t('Not available')
|
||||
)}
|
||||
{!(isLoadingPrice || isFetchingPrice) &&
|
||||
priceParams &&
|
||||
(priceSummary || t('Not available'))}
|
||||
{!(isLoadingPrice || isFetchingPrice) &&
|
||||
!priceParams &&
|
||||
t('Not available')}
|
||||
</div>
|
||||
{!priceParams ? (
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from '@/components/ui/empty'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
|
||||
import { getMissingModels } from '../../api'
|
||||
import { DEFAULT_PAGE_SIZE } from '../../constants'
|
||||
@@ -62,7 +63,7 @@ export function MissingModelsDialog({
|
||||
queryFn: async () => {
|
||||
const response = await getMissingModels()
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || t('Operation failed'))
|
||||
throw createServerError(response, t('Operation failed'))
|
||||
}
|
||||
return response
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
useCanEditModelPricing,
|
||||
invalidateModelPricing,
|
||||
} from '@/features/model-pricing/api'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
|
||||
import { deleteModel, deleteModels } from '../../api'
|
||||
import type { Model } from '../../types'
|
||||
@@ -67,7 +68,7 @@ export function ModelDeleteDialog(props: ModelDeleteDialogProps) {
|
||||
removePricing && canEditPricing
|
||||
)
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || t('Failed to delete model'))
|
||||
throw createServerError(response, t('Failed to delete model'))
|
||||
}
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -52,6 +52,8 @@ import {
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { deletePrefillGroup, getPrefillGroups } from '../../api'
|
||||
@@ -93,7 +95,7 @@ export function PrefillGroupManagementDialog({
|
||||
refetch: refetchGroups,
|
||||
} = useQuery({
|
||||
queryKey: prefillGroupsQueryKeys.list(),
|
||||
queryFn: () => getPrefillGroups(),
|
||||
queryFn: async () => requireServerSuccess(await getPrefillGroups()),
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
@@ -149,10 +151,10 @@ export function PrefillGroupManagementDialog({
|
||||
})
|
||||
setDeleteState({ open: false, group: null })
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to delete group'))
|
||||
handleServerError(response, t('Failed to delete group'))
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as Error)?.message || t('Failed to delete group'))
|
||||
handleServerError(err, t('Failed to delete group'))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ import { toast } from 'sonner'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
|
||||
import { checkClusterNameAvailability, updateDeploymentName } from '../../api'
|
||||
import { deploymentsQueryKeys } from '../../lib'
|
||||
@@ -53,7 +55,10 @@ export function RenameDeploymentDialog({
|
||||
|
||||
const { data: checkRes, isFetching: isChecking } = useQuery({
|
||||
queryKey: ['deployment-rename-check', trimmed],
|
||||
queryFn: () => (trimmed ? checkClusterNameAvailability(trimmed) : null),
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(
|
||||
await (trimmed ? checkClusterNameAvailability(trimmed) : null)
|
||||
),
|
||||
enabled: open && Boolean(trimmed),
|
||||
staleTime: 10_000,
|
||||
})
|
||||
@@ -98,9 +103,9 @@ export function RenameDeploymentDialog({
|
||||
onOpenChange(false)
|
||||
return
|
||||
}
|
||||
toast.error(res.message || t('Rename failed'))
|
||||
handleServerError(res, t('Rename failed'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('Rename failed'))
|
||||
handleServerError(err, t('Rename failed'))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
@@ -29,8 +29,16 @@ import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
|
||||
import { previewUpstreamDiff, syncUpstream } from '../../api'
|
||||
import { getSyncLocaleOptions } from '../../constants'
|
||||
@@ -97,14 +105,14 @@ export function SyncWizardDialog(props: {
|
||||
mutationFn: async () => {
|
||||
const response = await previewUpstreamDiff({ locale })
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.message || t('Failed to preview metadata'))
|
||||
throw createServerError(response, t('Failed to preview metadata'))
|
||||
}
|
||||
return response.data
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setPreview(data)
|
||||
},
|
||||
onError: handleServerError,
|
||||
onError: (error) => handleServerError(error),
|
||||
})
|
||||
|
||||
const apply = useMutation({
|
||||
@@ -116,7 +124,7 @@ export function SyncWizardDialog(props: {
|
||||
selections,
|
||||
})
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.message || t('Metadata sync failed'))
|
||||
throw createServerError(response, t('Metadata sync failed'))
|
||||
}
|
||||
return response.data
|
||||
},
|
||||
@@ -128,7 +136,7 @@ export function SyncWizardDialog(props: {
|
||||
)
|
||||
setStep(3)
|
||||
},
|
||||
onError: handleServerError,
|
||||
onError: (error) => handleServerError(error),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -373,20 +381,31 @@ export function SyncWizardDialog(props: {
|
||||
<div className='space-y-1'>
|
||||
<Label>{t('Metadata language')}</Label>
|
||||
<Select
|
||||
value={locale}
|
||||
disabled={busy}
|
||||
items={getSyncLocaleOptions(t)}
|
||||
onValueChange={(value) => {
|
||||
if (value) setLocale(value as SyncLocale)
|
||||
setPreview(null)
|
||||
setSelection({})
|
||||
setPage(0)
|
||||
load.reset()
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='w-44' aria-label={t('Metadata language')}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{getSyncLocaleOptions(t).map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
value={locale}
|
||||
disabled={busy}
|
||||
items={getSyncLocaleOptions(t)}
|
||||
onValueChange={(value) => {
|
||||
if (value) setLocale(value as SyncLocale)
|
||||
setPreview(null)
|
||||
setSelection({})
|
||||
setPage(0)
|
||||
load.reset()
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className='w-44'
|
||||
aria-label={t('Metadata language')}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{getSyncLocaleOptions(t).map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
|
||||
import { getDeployment, updateDeployment } from '../../api'
|
||||
import { deploymentsQueryKeys } from '../../lib'
|
||||
@@ -105,7 +107,10 @@ export function UpdateConfigDialog({
|
||||
|
||||
const { data: detailsRes, isLoading } = useQuery({
|
||||
queryKey: ['deployment-details-for-update', deploymentId],
|
||||
queryFn: () => (deploymentId ? getDeployment(deploymentId) : null),
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(
|
||||
await (deploymentId ? getDeployment(deploymentId) : null)
|
||||
),
|
||||
enabled: open && deploymentId !== null,
|
||||
})
|
||||
|
||||
@@ -202,10 +207,10 @@ export function UpdateConfigDialog({
|
||||
onOpenChange(false)
|
||||
return
|
||||
}
|
||||
toast.error(res.message || t('Update failed'))
|
||||
handleServerError(res, t('Update failed'))
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : t('Update failed')
|
||||
toast.error(msg)
|
||||
handleServerError(err, msg)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
|
||||
import { createVendor, getVendor, updateVendor } from '../../api'
|
||||
import { vendorsQueryKeys } from '../../lib'
|
||||
@@ -87,7 +88,7 @@ export function VendorMutateDialog(props: {
|
||||
if (!id) throw new Error(t('Select a saved vendor.'))
|
||||
const response = await getVendor(id)
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.message || t('Failed to load vendor'))
|
||||
throw createServerError(response, t('Failed to load vendor'))
|
||||
}
|
||||
return response.data
|
||||
},
|
||||
@@ -116,7 +117,7 @@ export function VendorMutateDialog(props: {
|
||||
? await updateVendor({ ...values, id })
|
||||
: await createVendor(values)
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || t('Operation failed'))
|
||||
throw createServerError(response, t('Operation failed'))
|
||||
}
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Combobox } from '@/components/ui/combobox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
|
||||
import { getVendors } from '../../api'
|
||||
import { vendorsQueryKeys } from '../../lib'
|
||||
@@ -57,7 +58,7 @@ export function VendorOperationDialog(props: {
|
||||
queryFn: async () => {
|
||||
const response = await getVendors({ page_size: 1000 })
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || t('Failed to load vendors'))
|
||||
throw createServerError(response, t('Failed to load vendors'))
|
||||
}
|
||||
return response
|
||||
},
|
||||
|
||||
@@ -27,6 +27,7 @@ import { LoadingState } from '@/components/loading-state'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
|
||||
import { searchVendors } from '../../api'
|
||||
import { vendorsQueryKeys } from '../../lib'
|
||||
@@ -48,7 +49,7 @@ export function VendorsManagementDialog(props: {
|
||||
queryFn: async () => {
|
||||
const response = await searchVendors({ keyword, p: page, page_size: 20 })
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.message || t('Failed to load vendors'))
|
||||
throw createServerError(response, t('Failed to load vendors'))
|
||||
}
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -48,6 +48,8 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
|
||||
import { getDeployment, listDeploymentContainers } from '../../api'
|
||||
|
||||
@@ -69,7 +71,10 @@ export function ViewDetailsDialog({
|
||||
isFetching: isFetchingDetails,
|
||||
} = useQuery({
|
||||
queryKey: ['deployment-details', deploymentId],
|
||||
queryFn: () => (deploymentId ? getDeployment(deploymentId) : null),
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(
|
||||
await (deploymentId ? getDeployment(deploymentId) : null)
|
||||
),
|
||||
enabled: open && deploymentId !== null,
|
||||
})
|
||||
|
||||
@@ -80,8 +85,10 @@ export function ViewDetailsDialog({
|
||||
isFetching: isFetchingContainers,
|
||||
} = useQuery({
|
||||
queryKey: ['deployment-details-containers', deploymentId],
|
||||
queryFn: () =>
|
||||
deploymentId ? listDeploymentContainers(deploymentId) : null,
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(
|
||||
await (deploymentId ? listDeploymentContainers(deploymentId) : null)
|
||||
),
|
||||
enabled: open && deploymentId !== null,
|
||||
})
|
||||
|
||||
@@ -116,8 +123,8 @@ export function ViewDetailsDialog({
|
||||
try {
|
||||
await navigator.clipboard.writeText(String(deploymentId))
|
||||
toast.success(t('Copied'))
|
||||
} catch {
|
||||
toast.error(t('Copy failed'))
|
||||
} catch (error) {
|
||||
handleServerError(error, t('Copy failed'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Combobox } from '@/components/ui/combobox'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Download, Loader2, RefreshCcw, Terminal } from 'lucide-react'
|
||||
/*
|
||||
@@ -42,9 +41,18 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Combobox } from '@/components/ui/combobox'
|
||||
import { IconBadge } from '@/components/ui/icon-badge'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
|
||||
import { getDeploymentLogs, listDeploymentContainers } from '../../api'
|
||||
|
||||
@@ -73,8 +81,10 @@ export function ViewLogsDialog({
|
||||
isFetching: isFetchingContainers,
|
||||
} = useQuery({
|
||||
queryKey: ['deployment-containers', deploymentId],
|
||||
queryFn: () =>
|
||||
deploymentId ? listDeploymentContainers(deploymentId) : null,
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(
|
||||
await (deploymentId ? listDeploymentContainers(deploymentId) : null)
|
||||
),
|
||||
enabled: open && deploymentId !== null,
|
||||
})
|
||||
|
||||
@@ -111,14 +121,16 @@ export function ViewLogsDialog({
|
||||
isFetching: isFetchingLogs,
|
||||
} = useQuery({
|
||||
queryKey: ['deployment-logs', deploymentId, containerId, stream],
|
||||
queryFn: () =>
|
||||
deploymentId && containerId
|
||||
? getDeploymentLogs(deploymentId, {
|
||||
container_id: containerId,
|
||||
stream,
|
||||
limit: 500,
|
||||
})
|
||||
: null,
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(
|
||||
await (deploymentId && containerId
|
||||
? getDeploymentLogs(deploymentId, {
|
||||
container_id: containerId,
|
||||
stream,
|
||||
limit: 500,
|
||||
})
|
||||
: null)
|
||||
),
|
||||
enabled: open && deploymentId !== null && Boolean(containerId),
|
||||
refetchInterval: open && autoRefresh ? 5000 : false,
|
||||
})
|
||||
@@ -254,7 +266,7 @@ export function ViewLogsDialog({
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs'>{t('Container')}</div>
|
||||
<Combobox
|
||||
options={containers.flatMap((c) => {
|
||||
options={containers.flatMap((c) => {
|
||||
const id = c?.container_id
|
||||
if (typeof id !== 'string' || !id) return []
|
||||
const status =
|
||||
@@ -268,12 +280,12 @@ options={containers.flatMap((c) => {
|
||||
},
|
||||
]
|
||||
})}
|
||||
value={containerId}
|
||||
onValueChange={(v) => v !== null && setContainerId(v)}
|
||||
disabled={isLoadingContainers || containers.length === 0}
|
||||
className='w-full'
|
||||
placeholder={containerPlaceholder}
|
||||
/>
|
||||
value={containerId}
|
||||
onValueChange={(v) => v !== null && setContainerId(v)}
|
||||
disabled={isLoadingContainers || containers.length === 0}
|
||||
className='w-full'
|
||||
placeholder={containerPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs'>{t('Stream')}</div>
|
||||
|
||||
@@ -18,7 +18,6 @@ For commercial licensing, please contact [email protected]
|
||||
*/
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { AxiosError } from 'axios'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -65,6 +64,11 @@ import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { ModelPricingPanel } from '@/features/model-pricing/model-pricing-panel'
|
||||
import {
|
||||
requireServerSuccess,
|
||||
createServerError,
|
||||
getServerErrorMessage,
|
||||
} from '@/lib/server-error-message'
|
||||
|
||||
import { createModel, updateModel, getModel, getVendors } from '../../api'
|
||||
import { getNameRuleOptions, ENDPOINT_TEMPLATES } from '../../constants'
|
||||
@@ -120,7 +124,8 @@ export function ModelMutateDrawer(props: {
|
||||
})
|
||||
const vendorsQuery = useQuery({
|
||||
queryKey: vendorsQueryKeys.list(),
|
||||
queryFn: () => getVendors({ page_size: 1000 }),
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(await getVendors({ page_size: 1000 })),
|
||||
enabled: props.open,
|
||||
})
|
||||
const vendors = vendorsQuery.data?.data?.items ?? []
|
||||
@@ -133,7 +138,7 @@ export function ModelMutateDrawer(props: {
|
||||
if (!currentRow?.id) throw new Error(t('Model ID is required'))
|
||||
const response = await getModel(currentRow.id)
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.message || t('Failed to load model'))
|
||||
throw createServerError(response, t('Failed to load model'))
|
||||
}
|
||||
return response.data
|
||||
},
|
||||
@@ -182,6 +187,7 @@ export function ModelMutateDrawer(props: {
|
||||
}, [props.open, currentRow, isEditing, modelQuery.data, form])
|
||||
|
||||
const save = useMutation({
|
||||
meta: { errorToast: false },
|
||||
onMutate: () => form.clearErrors('root.server'),
|
||||
mutationFn: async (values: ModelFormValues) => {
|
||||
if (pricingDirty && values.model_name !== currentRow?.model_name) {
|
||||
@@ -194,7 +200,7 @@ export function ModelMutateDrawer(props: {
|
||||
? await updateModel({ ...payload, id: currentRow.id })
|
||||
: await createModel(payload)
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || t('Operation failed'))
|
||||
throw createServerError(response, t('Operation failed'))
|
||||
}
|
||||
return response
|
||||
},
|
||||
@@ -218,12 +224,8 @@ export function ModelMutateDrawer(props: {
|
||||
if (!pricingDirty) props.onOpenChange(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
const message =
|
||||
error instanceof AxiosError
|
||||
? error.response?.data?.message || error.message
|
||||
: error.message
|
||||
form.setError('root.server', {
|
||||
message: message || t('Operation failed'),
|
||||
message: getServerErrorMessage(error, t('Operation failed')),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
|
||||
import { createPrefillGroup, updatePrefillGroup } from '../../api'
|
||||
import { ENDPOINT_TEMPLATES } from '../../constants'
|
||||
@@ -143,27 +144,27 @@ export function PrefillGroupFormDrawer({
|
||||
|
||||
const handleSubmit = async (values: PrefillGroupFormValues) => {
|
||||
setIsSaving(true)
|
||||
let items: string | string[] = []
|
||||
if (values.type === 'endpoint') {
|
||||
items = typeof values.items === 'string' ? values.items : ''
|
||||
} else if (Array.isArray(values.items)) {
|
||||
items = values.items
|
||||
}
|
||||
const payload = {
|
||||
name: values.name.trim(),
|
||||
type: values.type,
|
||||
description: values.description?.trim() || '',
|
||||
items:
|
||||
values.type === 'endpoint'
|
||||
? typeof values.items === 'string'
|
||||
? values.items
|
||||
: ''
|
||||
: Array.isArray(values.items)
|
||||
? values.items
|
||||
: [],
|
||||
items,
|
||||
}
|
||||
|
||||
try {
|
||||
const response = isEdit
|
||||
? await updatePrefillGroup({
|
||||
id: currentGroup!.id,
|
||||
...payload,
|
||||
})
|
||||
: await createPrefillGroup(payload)
|
||||
const response =
|
||||
isEdit && currentGroup
|
||||
? await updatePrefillGroup({
|
||||
id: currentGroup.id,
|
||||
...payload,
|
||||
})
|
||||
: await createPrefillGroup(payload)
|
||||
|
||||
if (response.success) {
|
||||
toast.success(
|
||||
@@ -174,10 +175,10 @@ export function PrefillGroupFormDrawer({
|
||||
})
|
||||
onClose()
|
||||
} else {
|
||||
toast.error(response.message || 'Operation failed')
|
||||
handleServerError(response, t('Operation failed'))
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as Error)?.message || 'Operation failed')
|
||||
handleServerError(err, t('Operation failed'))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
@@ -277,22 +278,20 @@ export function PrefillGroupFormDrawer({
|
||||
<FormItem>
|
||||
<FormLabel>Group Type</FormLabel>
|
||||
<Select
|
||||
items={[
|
||||
...PREFILL_GROUP_TYPES.map((type) => ({
|
||||
value: type.value,
|
||||
label: (
|
||||
<div className='flex flex-col text-left'>
|
||||
<span className='font-medium'>{type.label}</span>
|
||||
<span
|
||||
data-prefill-description
|
||||
className='text-muted-foreground text-xs'
|
||||
>
|
||||
{type.description}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
})),
|
||||
]}
|
||||
items={PREFILL_GROUP_TYPES.map((type) => ({
|
||||
value: type.value,
|
||||
label: (
|
||||
<div className='flex flex-col text-left'>
|
||||
<span className='font-medium'>{type.label}</span>
|
||||
<span
|
||||
data-prefill-description
|
||||
className='text-muted-foreground text-xs'
|
||||
>
|
||||
{type.description}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
value={field.value}
|
||||
onValueChange={(value) =>
|
||||
value !== null &&
|
||||
@@ -399,11 +398,9 @@ export function PrefillGroupFormDrawer({
|
||||
</SheetClose>
|
||||
<Button type='submit' form='prefill-group-form' disabled={isSaving}>
|
||||
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
{isSaving
|
||||
? t('Saving...')
|
||||
: isEdit
|
||||
? t('Save changes')
|
||||
: t('Create')}
|
||||
{isSaving && t('Saving...')}
|
||||
{!isSaving && isEdit && t('Save changes')}
|
||||
{!isSaving && !isEdit && t('Create')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { ErrorState } from '@/components/error-state'
|
||||
import { useModelPricing } from '@/features/model-pricing/api'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
|
||||
import { getModels, searchModels, getVendors } from '../api'
|
||||
import { DEFAULT_PAGE_SIZE } from '../constants'
|
||||
@@ -84,7 +85,8 @@ export function ModelsTable() {
|
||||
// Fetch vendors for filter
|
||||
const { data: vendorsData } = useQuery({
|
||||
queryKey: vendorsQueryKeys.list(),
|
||||
queryFn: () => getVendors({ page_size: 1000 }),
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(await getVendors({ page_size: 1000 })),
|
||||
})
|
||||
|
||||
const vendors = useMemo(
|
||||
@@ -139,22 +141,26 @@ export function ModelsTable() {
|
||||
}),
|
||||
queryFn: async () => {
|
||||
if (shouldSearch) {
|
||||
return searchModels({
|
||||
return requireServerSuccess(
|
||||
await searchModels({
|
||||
include_channel_models: true,
|
||||
keyword: globalFilter,
|
||||
vendor: activeVendorFilter,
|
||||
status: statusFilterValue,
|
||||
square_state: squareState,
|
||||
sync_official: syncFilterValue,
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
})
|
||||
)
|
||||
}
|
||||
return requireServerSuccess(
|
||||
await getModels({
|
||||
include_channel_models: true,
|
||||
keyword: globalFilter,
|
||||
vendor: activeVendorFilter,
|
||||
status: statusFilterValue,
|
||||
square_state: squareState,
|
||||
sync_official: syncFilterValue,
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
})
|
||||
}
|
||||
return getModels({
|
||||
include_channel_models: true,
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
})
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { ErrorState } from '@/components/error-state'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
|
||||
import { searchModels } from '../api'
|
||||
import { getNameRuleConfig } from '../constants'
|
||||
@@ -63,7 +64,7 @@ export function VendorLinkedModels({
|
||||
queryFn: async () => {
|
||||
const response = await searchModels(params)
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || t('Failed to load models'))
|
||||
throw createServerError(response, t('Failed to load models'))
|
||||
}
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
|
||||
import { searchVendors } from '../api'
|
||||
import { vendorsQueryKeys } from '../lib'
|
||||
@@ -84,7 +85,7 @@ export function VendorsTable() {
|
||||
queryFn: async () => {
|
||||
const response = await searchVendors(params)
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || t('Failed to load vendors'))
|
||||
throw createServerError(response, t('Failed to load vendors'))
|
||||
}
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { SectionPageLayout } from '@/components/layout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
|
||||
import { listDeployments } from './api'
|
||||
import { DeploymentAccessGuard } from './components/deployment-access-guard'
|
||||
@@ -168,7 +169,8 @@ function DeploymentsSection() {
|
||||
const defaultParams = { p: 1, page_size: 10 }
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: deploymentsQueryKeys.list(defaultParams),
|
||||
queryFn: () => listDeployments(defaultParams),
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(await listDeployments(defaultParams)),
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import type { QueryClient } from '@tanstack/react-query'
|
||||
import i18next from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
|
||||
import { updateModelStatus } from '../api'
|
||||
import { invalidateVendorData } from '../vendor-api'
|
||||
|
||||
@@ -42,15 +44,13 @@ export async function handleEnableModel(
|
||||
if (queryClient) await invalidateVendorData(queryClient)
|
||||
onSuccess?.()
|
||||
} else {
|
||||
toast.error(
|
||||
response.message || i18next.t('Failed to show model in model square')
|
||||
handleServerError(
|
||||
response,
|
||||
i18next.t('Failed to show model in model square')
|
||||
)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
(error as Error)?.message ||
|
||||
i18next.t('Failed to show model in model square')
|
||||
)
|
||||
handleServerError(error, i18next.t('Failed to show model in model square'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,14 +69,15 @@ export async function handleDisableModel(
|
||||
if (queryClient) await invalidateVendorData(queryClient)
|
||||
onSuccess?.()
|
||||
} else {
|
||||
toast.error(
|
||||
response.message || i18next.t('Failed to hide model from model square')
|
||||
handleServerError(
|
||||
response,
|
||||
i18next.t('Failed to hide model from model square')
|
||||
)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
(error as Error)?.message ||
|
||||
i18next.t('Failed to hide model from model square')
|
||||
handleServerError(
|
||||
error,
|
||||
i18next.t('Failed to hide model from model square')
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -145,7 +146,7 @@ export async function handleBatchEnableModels(
|
||||
)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error((error as Error)?.message || i18next.t('Batch enable failed'))
|
||||
handleServerError(error, i18next.t('Batch enable failed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +196,6 @@ export async function handleBatchDisableModels(
|
||||
)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error((error as Error)?.message || i18next.t('Batch disable failed'))
|
||||
handleServerError(error, i18next.t('Batch disable failed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { type QueryClient } from '@tanstack/react-query'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import i18next from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
|
||||
import { deleteVendor as deleteVendorAPI } from '../api'
|
||||
import { vendorsQueryKeys, modelsQueryKeys } from './query-keys'
|
||||
|
||||
@@ -43,11 +45,9 @@ export async function handleDeleteVendor(
|
||||
queryClient?.invalidateQueries({ queryKey: modelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
} else {
|
||||
toast.error(response.message || i18next.t('Failed to delete vendor'))
|
||||
handleServerError(response, i18next.t('Failed to delete vendor'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
(error as Error)?.message || i18next.t('Failed to delete vendor')
|
||||
)
|
||||
handleServerError(error, i18next.t('Failed to delete vendor'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { isAxiosError } from 'axios'
|
||||
import { t } from 'i18next'
|
||||
|
||||
import { api } from '@/lib/api'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
|
||||
import type { Model, Vendor } from './types'
|
||||
|
||||
@@ -106,8 +107,9 @@ export async function previewVendorOperation(
|
||||
): Promise<VendorOperationPreview> {
|
||||
const response = await api.post('/api/vendors/operations/preview', operation)
|
||||
if (!response.data.success) {
|
||||
throw new Error(
|
||||
response.data.message || t('Failed to preview vendor changes')
|
||||
throw createServerError(
|
||||
response.data,
|
||||
t('Failed to preview vendor changes')
|
||||
)
|
||||
}
|
||||
return response.data.data
|
||||
@@ -117,9 +119,7 @@ export async function applyVendorOperation(
|
||||
): Promise<VendorOperationResult> {
|
||||
const response = await api.post('/api/vendors/operations', operation)
|
||||
if (!response.data.success) {
|
||||
throw new Error(
|
||||
response.data.message || t('Failed to apply vendor changes')
|
||||
)
|
||||
throw createServerError(response.data, t('Failed to apply vendor changes'))
|
||||
}
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { api } from '@/lib/api'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
|
||||
import { API_ENDPOINTS } from './constants'
|
||||
import type {
|
||||
@@ -48,6 +49,7 @@ export async function getUserModels(group: string): Promise<ModelOption[]> {
|
||||
params: { group },
|
||||
})
|
||||
const { data } = res
|
||||
requireServerSuccess(data)
|
||||
|
||||
if (!data.success || !Array.isArray(data.data)) {
|
||||
return []
|
||||
@@ -65,6 +67,7 @@ export async function getUserModels(group: string): Promise<ModelOption[]> {
|
||||
export async function getUserGroups(): Promise<GroupOption[]> {
|
||||
const res = await api.get(API_ENDPOINTS.USER_GROUPS)
|
||||
const { data } = res
|
||||
requireServerSuccess(data)
|
||||
|
||||
if (!data.success || !data.data) {
|
||||
return []
|
||||
|
||||
@@ -18,7 +18,8 @@ For commercial licensing, please contact [email protected]
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
|
||||
import { sendChatCompletion } from '../api'
|
||||
import { ERROR_MESSAGES } from '../constants'
|
||||
@@ -225,7 +226,7 @@ export function useChatHandler({
|
||||
flushStreamUpdates(generation)
|
||||
setIsRequesting(false)
|
||||
const displayError = getDisplayError(error)
|
||||
toast.error(displayError)
|
||||
handleServerError(new Error(displayError))
|
||||
const errorTitle = t(ERROR_MESSAGES.API_REQUEST_ERROR)
|
||||
onMessageUpdate((prev) => {
|
||||
if (generation !== requestGenerationRef.current) return prev
|
||||
|
||||
@@ -37,7 +37,9 @@ For commercial licensing, please contact [email protected]
|
||||
*/
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
|
||||
import { getUserGroups, getUserModels } from '../api'
|
||||
import {
|
||||
@@ -75,7 +77,8 @@ export function usePlaygroundOptions({
|
||||
isLoading: isLoadingModels,
|
||||
} = useQuery({
|
||||
queryKey: ['playground-models', currentGroup],
|
||||
queryFn: () => getUserModels(currentGroup),
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(await getUserModels(currentGroup)),
|
||||
enabled: currentGroup !== '',
|
||||
})
|
||||
|
||||
@@ -85,13 +88,14 @@ export function usePlaygroundOptions({
|
||||
isError: isGroupsError,
|
||||
} = useQuery({
|
||||
queryKey: ['playground-groups'],
|
||||
queryFn: getUserGroups,
|
||||
queryFn: async () => requireServerSuccess(await getUserGroups()),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isModelsError) return
|
||||
|
||||
toast.error(
|
||||
handleServerError(
|
||||
modelsError,
|
||||
getOptionLoadErrorMessage(
|
||||
modelsError,
|
||||
t('Failed to load playground models')
|
||||
@@ -102,7 +106,8 @@ export function usePlaygroundOptions({
|
||||
useEffect(() => {
|
||||
if (!isGroupsError) return
|
||||
|
||||
toast.error(
|
||||
handleServerError(
|
||||
groupsError,
|
||||
getOptionLoadErrorMessage(
|
||||
groupsError,
|
||||
t('Failed to load playground groups')
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getPerfMetricsSummary } from '@/features/performance-metrics/api'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
|
||||
import { DEFAULT_PRICING_PAGE_SIZE, DEFAULT_TOKEN_UNIT } from '../constants'
|
||||
import type { PricingModel, TokenUnit } from '../types'
|
||||
@@ -49,7 +50,7 @@ export function ModelCardGrid(props: ModelCardGridProps) {
|
||||
|
||||
const perfQuery = useQuery({
|
||||
queryKey: ['perf-metrics-summary', 24],
|
||||
queryFn: () => getPerfMetricsSummary(24),
|
||||
queryFn: async () => requireServerSuccess(await getPerfMetricsSummary(24)),
|
||||
staleTime: 60 * 1000,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
@@ -34,9 +34,10 @@ import {
|
||||
getSuccessRateTextClass,
|
||||
} from '@/features/performance-metrics/lib/format'
|
||||
import type { PerformanceGroup } from '@/features/performance-metrics/types'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { type UptimeDayPoint } from '../lib/mock-stats'
|
||||
import type { UptimeDayPoint } from '../lib/mock-stats'
|
||||
import type { PricingModel } from '../types'
|
||||
import { LatencyTrendChart, UptimeTrendChart } from './model-details-charts'
|
||||
import { UptimeSparkline } from './model-details-uptime-sparkline'
|
||||
@@ -97,7 +98,7 @@ function toLatencySeries(groups: PerformanceGroup[]) {
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byTs.entries())
|
||||
return [...byTs.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([ts, values]) => ({
|
||||
timestamp: new Date(ts * 1000).toISOString(),
|
||||
@@ -121,7 +122,7 @@ function toUptimeSeries(groups: PerformanceGroup[]): UptimeDayPoint[] {
|
||||
byTs.set(point.ts, current)
|
||||
}
|
||||
}
|
||||
return Array.from(byTs.entries())
|
||||
return [...byTs.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([ts, value]) => {
|
||||
const uptime =
|
||||
@@ -165,7 +166,8 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
|
||||
const { t } = useTranslation()
|
||||
const metricsQuery = useQuery({
|
||||
queryKey: ['perf-metrics', props.model.model_name],
|
||||
queryFn: () => getPerfMetrics(props.model.model_name, 24),
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(await getPerfMetrics(props.model.model_name, 24)),
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
const groups = useMemo(
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
getSuccessRateTextClass,
|
||||
} from '@/features/performance-metrics/lib/format'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { DEFAULT_TOKEN_UNIT } from '../constants'
|
||||
@@ -259,7 +260,8 @@ function OverviewSummaryGrid(props: { model: PricingModel }) {
|
||||
const { t } = useTranslation()
|
||||
const metricsQuery = useQuery({
|
||||
queryKey: ['perf-metrics', props.model.model_name],
|
||||
queryFn: () => getPerfMetrics(props.model.model_name, 24),
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(await getPerfMetrics(props.model.model_name, 24)),
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
|
||||
import { getPricing } from '../api'
|
||||
|
||||
@@ -28,7 +29,7 @@ export function usePricingData(enabled = true) {
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['pricing'],
|
||||
queryFn: getPricing,
|
||||
queryFn: async () => requireServerSuccess(await getPricing()),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled,
|
||||
})
|
||||
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
} from '@/components/ui/tooltip'
|
||||
import { formatQuotaWithCurrency } from '@/lib/currency'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { getCheckinStatus, performCheckin } from '../api'
|
||||
@@ -89,7 +91,7 @@ export function CheckinCalendarCard({
|
||||
if (res.success && res.data) {
|
||||
return res.data
|
||||
}
|
||||
throw new Error(res.message || t('Failed to fetch checkin status'))
|
||||
throw createServerError(res, t('Failed to fetch checkin status'))
|
||||
},
|
||||
enabled: checkinEnabled,
|
||||
staleTime: 30000,
|
||||
@@ -161,10 +163,10 @@ export function CheckinCalendarCard({
|
||||
if (token && shouldTriggerTurnstile(res.message)) {
|
||||
setTurnstileWidgetKey((v) => v + 1)
|
||||
}
|
||||
toast.error(res.message || t('Check-in failed'))
|
||||
handleServerError(res, t('Check-in failed'))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('Check-in failed'))
|
||||
} catch (error) {
|
||||
handleServerError(error, t('Check-in failed'))
|
||||
} finally {
|
||||
setCheckinLoading(false)
|
||||
}
|
||||
|
||||
@@ -16,18 +16,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Combobox } from '@/components/ui/combobox'
|
||||
import { Languages, Loader2 } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
|
||||
import { Combobox } from '@/components/ui/combobox'
|
||||
import { TitledCard } from '@/components/ui/titled-card'
|
||||
import {
|
||||
INTERFACE_LANGUAGE_OPTIONS,
|
||||
normalizeInterfaceLanguage,
|
||||
} from '@/i18n/languages'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import { updateUserLanguage } from '../api'
|
||||
@@ -68,7 +69,7 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
|
||||
try {
|
||||
const response = await updateUserLanguage(nextLanguage)
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || t('Failed to update settings'))
|
||||
throw createServerError(response, t('Failed to update settings'))
|
||||
}
|
||||
|
||||
if (auth.user) {
|
||||
@@ -87,10 +88,10 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
|
||||
|
||||
props.onProfileUpdate()
|
||||
toast.success(t('Language preference saved'))
|
||||
} catch {
|
||||
} catch (error) {
|
||||
setCurrentLanguage(previousLanguage)
|
||||
await i18n.changeLanguage(previousLanguage)
|
||||
toast.error(t('Failed to update settings'))
|
||||
handleServerError(error, t('Failed to update settings'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -115,16 +116,16 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
|
||||
</div>
|
||||
<div className='flex items-center gap-2 sm:min-w-48'>
|
||||
<Combobox
|
||||
options={INTERFACE_LANGUAGE_OPTIONS.map((language) => ({
|
||||
options={INTERFACE_LANGUAGE_OPTIONS.map((language) => ({
|
||||
value: language.code,
|
||||
label: language.label,
|
||||
}))}
|
||||
value={currentLanguage}
|
||||
onValueChange={handleLanguageChange}
|
||||
disabled={saving}
|
||||
className='w-full sm:w-48'
|
||||
placeholder={t('Select language')}
|
||||
/>
|
||||
value={currentLanguage}
|
||||
onValueChange={handleLanguageChange}
|
||||
disabled={saving}
|
||||
className='w-full sm:w-48'
|
||||
placeholder={t('Select language')}
|
||||
/>
|
||||
{saving && (
|
||||
<Loader2 className='text-muted-foreground size-4 animate-spin' />
|
||||
)}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { IconBadge } from '@/components/ui/icon-badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { api } from '@/lib/api'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
type SidebarModuleConfig = {
|
||||
@@ -192,10 +193,10 @@ export function SidebarModulesCard() {
|
||||
}
|
||||
toast.success(t('Saved successfully'))
|
||||
} else {
|
||||
toast.error(res.data.message || t('Save failed'))
|
||||
handleServerError(res.data, t('Save failed'))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('Save failed, please retry'))
|
||||
} catch (error) {
|
||||
handleServerError(error, t('Save failed, please retry'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
|
||||
import { updateUserSettings } from '../../api'
|
||||
@@ -83,10 +84,10 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
|
||||
toast.success(t('Settings updated successfully'))
|
||||
onUpdate()
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to update settings'))
|
||||
handleServerError(response, t('Failed to update settings'))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('Failed to update settings'))
|
||||
} catch (error) {
|
||||
handleServerError(error, t('Failed to update settings'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import i18next from 'i18next'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
|
||||
import { getUserProfile, updateUserProfile, updateUserSettings } from '../api'
|
||||
import type {
|
||||
UserProfile,
|
||||
@@ -46,12 +48,12 @@ export function useProfile() {
|
||||
|
||||
if (response.success && response.data) {
|
||||
setProfile(response.data)
|
||||
} else if (!silent) {
|
||||
handleServerError(response, i18next.t('Failed to load profile'))
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to fetch profile:', error)
|
||||
if (!silent) {
|
||||
toast.error(i18next.t('Failed to load profile'))
|
||||
handleServerError(error, i18next.t('Failed to load profile'))
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
@@ -78,12 +80,10 @@ export function useProfile() {
|
||||
return true
|
||||
}
|
||||
|
||||
toast.error(response.message || i18next.t('Failed to update profile'))
|
||||
handleServerError(response, i18next.t('Failed to update profile'))
|
||||
return false
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to update profile:', error)
|
||||
toast.error(i18next.t('Failed to update profile'))
|
||||
handleServerError(error, i18next.t('Failed to update profile'))
|
||||
return false
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
@@ -105,12 +105,10 @@ export function useProfile() {
|
||||
return true
|
||||
}
|
||||
|
||||
toast.error(response.message || i18next.t('Failed to update settings'))
|
||||
handleServerError(response, i18next.t('Failed to update settings'))
|
||||
return false
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to update settings:', error)
|
||||
toast.error(i18next.t('Failed to update settings'))
|
||||
handleServerError(error, i18next.t('Failed to update settings'))
|
||||
return false
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
|
||||
@@ -18,13 +18,15 @@ For commercial licensing, please contact [email protected]
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
|
||||
import { getRankings } from '../api'
|
||||
import type { RankingPeriod } from '../types'
|
||||
|
||||
export function useRankings(period: RankingPeriod) {
|
||||
return useQuery({
|
||||
queryKey: ['rankings', period],
|
||||
queryFn: () => getRankings(period),
|
||||
queryFn: async () => requireServerSuccess(await getRankings(period)),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -199,7 +199,9 @@ test.each(['server', 'network'])(
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Delete' }))
|
||||
await waitFor(() =>
|
||||
expect(document.body).toHaveTextContent(
|
||||
'Failed to delete 2 redemption codes'
|
||||
failure === 'server'
|
||||
? 'Failed to delete 2 redemption codes'
|
||||
: 'Network unavailable'
|
||||
)
|
||||
)
|
||||
expect(dialog).toHaveAccessibleName('Delete 2 redemption codes?')
|
||||
|
||||
+18
-14
@@ -213,7 +213,7 @@ describe('redemption drawer', () => {
|
||||
|
||||
await renderDrawer(redemption(1))
|
||||
await waitFor(() =>
|
||||
expect(document.body).toHaveTextContent('Something went wrong!')
|
||||
expect(document.body).toHaveTextContent('network failure')
|
||||
)
|
||||
|
||||
expect(getSaveButton()).toBeDisabled()
|
||||
@@ -221,19 +221,23 @@ describe('redemption drawer', () => {
|
||||
expect(updates).toEqual([])
|
||||
})
|
||||
|
||||
test('blocks updates and uses localized feedback for unsuccessful responses', async () => {
|
||||
apiClient.get = async () => ({
|
||||
data: { success: false, message: 'raw server message' },
|
||||
})
|
||||
|
||||
await renderDrawer(redemption(1))
|
||||
await waitFor(() =>
|
||||
expect(document.body).toHaveTextContent('Failed to load')
|
||||
)
|
||||
|
||||
expect(getSaveButton()).toBeDisabled()
|
||||
expect(document.body).not.toHaveTextContent('raw server message')
|
||||
})
|
||||
test.each([
|
||||
{
|
||||
message: 'Redemption code is no longer available',
|
||||
expected: 'Redemption code is no longer available',
|
||||
},
|
||||
{ message: undefined, expected: 'Failed to load' },
|
||||
])(
|
||||
'blocks updates and reports the server reason or localized fallback: $expected',
|
||||
async ({ message, expected }) => {
|
||||
apiClient.get = async () => ({
|
||||
data: { success: false, message },
|
||||
})
|
||||
await renderDrawer(redemption(1))
|
||||
await waitFor(() => expect(document.body).toHaveTextContent(expected))
|
||||
expect(getSaveButton()).toBeDisabled()
|
||||
}
|
||||
)
|
||||
|
||||
test('keeps the original quota when another field changes', async () => {
|
||||
const original = redemption(1)
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
|
||||
import { batchDeleteRedemptions } from '../api'
|
||||
import type { Redemption } from '../types'
|
||||
@@ -60,7 +62,7 @@ export function DataTableBulkActions(props: DataTableBulkActionsProps) {
|
||||
const result = await batchDeleteRedemptions(
|
||||
targets.map((code) => code.id)
|
||||
)
|
||||
if (!result.success) throw new Error(result.message)
|
||||
if (!result.success) throw createServerError(result)
|
||||
return result.data ?? 0
|
||||
},
|
||||
onSuccess: (count, targets) => {
|
||||
@@ -76,7 +78,8 @@ export function DataTableBulkActions(props: DataTableBulkActionsProps) {
|
||||
triggerRefresh()
|
||||
},
|
||||
onError: (_error, targets) => {
|
||||
toast.error(
|
||||
handleServerError(
|
||||
_error,
|
||||
t('Failed to delete {{count}} redemption codes', {
|
||||
count: targets.length,
|
||||
})
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
|
||||
import { updateRedemptionStatus } from '../api'
|
||||
import { REDEMPTION_STATUS, SUCCESS_MESSAGES } from '../constants'
|
||||
@@ -62,13 +63,19 @@ export function DataTableRowActions<TData>({
|
||||
? REDEMPTION_STATUS.DISABLED
|
||||
: REDEMPTION_STATUS.ENABLED
|
||||
|
||||
const result = await updateRedemptionStatus(redemption.id, newStatus)
|
||||
if (result.success) {
|
||||
const message = isEnabled
|
||||
? t(SUCCESS_MESSAGES.REDEMPTION_DISABLED)
|
||||
: t(SUCCESS_MESSAGES.REDEMPTION_ENABLED)
|
||||
toast.success(message)
|
||||
triggerRefresh()
|
||||
try {
|
||||
const result = await updateRedemptionStatus(redemption.id, newStatus)
|
||||
if (result.success) {
|
||||
const message = isEnabled
|
||||
? t(SUCCESS_MESSAGES.REDEMPTION_DISABLED)
|
||||
: t(SUCCESS_MESSAGES.REDEMPTION_ENABLED)
|
||||
toast.success(message)
|
||||
triggerRefresh()
|
||||
} else {
|
||||
handleServerError(result)
|
||||
}
|
||||
} catch (error) {
|
||||
handleServerError(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
|
||||
import { deleteRedemption } from '../api'
|
||||
import { SUCCESS_MESSAGES } from '../constants'
|
||||
@@ -50,7 +51,11 @@ export function RedemptionsDeleteDialog() {
|
||||
toast.success(t(SUCCESS_MESSAGES.REDEMPTION_DELETED))
|
||||
setOpen(null)
|
||||
triggerRefresh()
|
||||
} else {
|
||||
handleServerError(result)
|
||||
}
|
||||
} catch (error) {
|
||||
handleServerError(error)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ export function RedemptionsMutateDrawer({
|
||||
result.data.id !== redemptionId
|
||||
) {
|
||||
setRedemptionLoadState('error')
|
||||
toast.error(t('Failed to load'))
|
||||
handleServerError(result, t('Failed to load'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -188,6 +188,8 @@ export function RedemptionsMutateDrawer({
|
||||
toast.success(t(SUCCESS_MESSAGES.REDEMPTION_UPDATED))
|
||||
onOpenChange(false)
|
||||
triggerRefresh()
|
||||
} else {
|
||||
handleServerError(result)
|
||||
}
|
||||
} else {
|
||||
// Create mode
|
||||
@@ -212,8 +214,12 @@ export function RedemptionsMutateDrawer({
|
||||
}
|
||||
onOpenChange(false)
|
||||
triggerRefresh()
|
||||
} else {
|
||||
handleServerError(result)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
handleServerError(error)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { toast } from 'sonner'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
|
||||
import { deleteInvalidRedemptions } from '../api'
|
||||
import { ERROR_MESSAGES } from '../constants'
|
||||
@@ -49,7 +50,7 @@ export function RedemptionsPrimaryButtons() {
|
||||
triggerRefresh()
|
||||
setShowDeleteInvalidConfirm(false)
|
||||
} else {
|
||||
toast.error(result.message || t(ERROR_MESSAGES.DELETE_INVALID_FAILED))
|
||||
handleServerError(result, t(ERROR_MESSAGES.DELETE_INVALID_FAILED))
|
||||
}
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
|
||||
@@ -20,7 +20,6 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
} from '@/components/data-table'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
|
||||
import { getRedemptions, searchRedemptions } from '../api'
|
||||
import {
|
||||
@@ -108,15 +108,14 @@ export function RedemptionsTable() {
|
||||
: await getRedemptions(params)
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(
|
||||
result.message ||
|
||||
t(
|
||||
hasFilter || hasStatusFilter
|
||||
? ERROR_MESSAGES.SEARCH_FAILED
|
||||
: ERROR_MESSAGES.LOAD_FAILED
|
||||
)
|
||||
throw createServerError(
|
||||
result,
|
||||
t(
|
||||
hasFilter || hasStatusFilter
|
||||
? ERROR_MESSAGES.SEARCH_FAILED
|
||||
: ERROR_MESSAGES.LOAD_FAILED
|
||||
)
|
||||
)
|
||||
return { items: [], total: 0 }
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('privacy settings', () => {
|
||||
const { onUpdate } = renderPrivacy()
|
||||
await user.click(screen.getByRole('switch', { name: 'Record IP Address' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Save Settings' }))
|
||||
expect(await screen.findByText('Failed to update settings')).toBeVisible()
|
||||
expect(await screen.findByText('offline')).toBeVisible()
|
||||
expect(put).not.toHaveBeenCalled()
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
expect(
|
||||
|
||||
@@ -27,7 +27,10 @@ import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { TELEGRAM_BIND_RESULT_MESSAGE } from '@/features/auth/constants'
|
||||
import { startTelegramBind } from '@/features/profile/api'
|
||||
import { getServerErrorMessageKey } from '@/lib/server-error-message'
|
||||
import {
|
||||
createServerError,
|
||||
getServerErrorMessageKey,
|
||||
} from '@/lib/server-error-message'
|
||||
|
||||
// ============================================================================
|
||||
// Telegram Bind Dialog Component
|
||||
@@ -59,9 +62,7 @@ export function TelegramBindDialog({
|
||||
try {
|
||||
const response = await startTelegramBind()
|
||||
if (!response.success || !response.data?.callback_url) {
|
||||
throw new Error(
|
||||
response.message || t('Failed to start Telegram binding')
|
||||
)
|
||||
throw createServerError(response, t('Failed to start Telegram binding'))
|
||||
}
|
||||
setFlowToken(response.data.flow_token)
|
||||
setCallbackUrl(
|
||||
|
||||
@@ -48,6 +48,8 @@ import {
|
||||
revokeOtherLoginSessions,
|
||||
} from '@/features/profile/api'
|
||||
import { clearAuthenticatedClientState } from '@/lib/api'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
import type { LoginSession } from '@/stores/auth-store'
|
||||
|
||||
import { LoginSessionDialogs } from './login-session-dialogs'
|
||||
@@ -67,7 +69,7 @@ export function LoginSessionsCard() {
|
||||
queryFn: async () => {
|
||||
const response = await getLoginSessions()
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || t('Failed to load login sessions'))
|
||||
throw createServerError(response, t('Failed to load login sessions'))
|
||||
}
|
||||
return response.data ?? []
|
||||
},
|
||||
@@ -77,7 +79,7 @@ export function LoginSessionsCard() {
|
||||
mutationFn: async (sid: string) => {
|
||||
const response = await revokeLoginSession(sid)
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || t('Failed to sign out session'))
|
||||
throw createServerError(response, t('Failed to sign out session'))
|
||||
}
|
||||
return sid
|
||||
},
|
||||
@@ -94,15 +96,16 @@ export function LoginSessionsCard() {
|
||||
toast.success(t('Session signed out'))
|
||||
await queryClient.invalidateQueries({ queryKey: sessionQueryKey })
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message),
|
||||
onError: (error: Error) => handleServerError(error),
|
||||
})
|
||||
|
||||
const revokeOthersMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await revokeOtherLoginSessions()
|
||||
if (!response.success) {
|
||||
throw new Error(
|
||||
response.message || t('Failed to sign out other sessions')
|
||||
throw createServerError(
|
||||
response,
|
||||
t('Failed to sign out other sessions')
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -111,7 +114,7 @@ export function LoginSessionsCard() {
|
||||
toast.success(t('Other sessions signed out'))
|
||||
await queryClient.invalidateQueries({ queryKey: sessionQueryKey })
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message),
|
||||
onError: (error: Error) => handleServerError(error),
|
||||
})
|
||||
|
||||
const sessions = sessionsQuery.data ?? []
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
useSecureVerification,
|
||||
} from '@/features/auth/secure-verification'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { AuthOperationError } from '@/lib/secure-verification'
|
||||
|
||||
interface PasskeyCardProps {
|
||||
@@ -85,7 +86,7 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
|
||||
toast.success(t('Passkey registered successfully'))
|
||||
} catch (error) {
|
||||
const failure = AuthOperationError.from(error)
|
||||
if (failure.code !== 'AUTH_CANCELLED') toast.error(t(failure.message))
|
||||
if (failure.code !== 'AUTH_CANCELLED') handleServerError(failure)
|
||||
}
|
||||
}, [register, registering, removing, t, verification])
|
||||
|
||||
@@ -101,7 +102,7 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
|
||||
toast.success(t('Passkey removed successfully'))
|
||||
} catch (error) {
|
||||
const failure = AuthOperationError.from(error)
|
||||
if (failure.code !== 'AUTH_CANCELLED') toast.error(t(failure.message))
|
||||
if (failure.code !== 'AUTH_CANCELLED') handleServerError(failure)
|
||||
}
|
||||
}, [registering, remove, removing, t, verification])
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ import { TitledCard } from '@/components/ui/titled-card'
|
||||
import { updateUserSettings } from '@/features/profile/api'
|
||||
import { parseUserSettings } from '@/features/profile/lib/format'
|
||||
import type { UserProfile } from '@/features/profile/types'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
|
||||
type PrivacyCardProps = {
|
||||
profile: UserProfile
|
||||
@@ -50,14 +52,15 @@ export function PrivacyCard(props: PrivacyCardProps) {
|
||||
mutationFn: async () => {
|
||||
const response = await updateUserSettings({ record_ip_log: recordIpLog })
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || t('Failed to update settings'))
|
||||
throw createServerError(response, t('Failed to update settings'))
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('Settings updated successfully'))
|
||||
props.onUpdate()
|
||||
},
|
||||
onError: () => toast.error(t('Failed to update settings')),
|
||||
onError: (error) =>
|
||||
handleServerError(error, t('Failed to update settings')),
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useSecureVerification } from '@/features/auth/secure-verification'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { AuthOperationError } from '@/lib/secure-verification'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
@@ -95,7 +96,7 @@ export function useAccessToken() {
|
||||
} catch (error) {
|
||||
if (currentOperation.current !== controller) return
|
||||
const failure = AuthOperationError.from(error)
|
||||
if (failure.code !== 'AUTH_CANCELLED') toast.error(t(failure.message))
|
||||
if (failure.code !== 'AUTH_CANCELLED') handleServerError(failure)
|
||||
void client.invalidateQueries({
|
||||
queryKey: ['security', 'access-token', 'status', userId],
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type RequestVerificationOptions,
|
||||
} from '@/features/auth/secure-verification'
|
||||
import type { AccountSecurityResult } from '@/features/profile/types'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { AuthOperationError } from '@/lib/secure-verification'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
@@ -77,7 +78,7 @@ export function useAccountSecurity() {
|
||||
const failure = AuthOperationError.from(error)
|
||||
if (failure.code !== 'AUTH_CANCELLED') {
|
||||
setError(failure)
|
||||
toast.error(t(failure.message))
|
||||
handleServerError(failure)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useSecureVerification } from '@/features/auth/secure-verification'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { AuthOperationError } from '@/lib/secure-verification'
|
||||
|
||||
import { enable2FA, setup2FA, type TwoFASetupData } from '../api'
|
||||
@@ -74,12 +75,12 @@ export function useTwoFASetup(refreshStatus: () => void | Promise<void>) {
|
||||
setState({ phase: 'ready', setup })
|
||||
} catch (error) {
|
||||
if (currentFlow.current !== current) return
|
||||
toast.error(t(AuthOperationError.from(error).message))
|
||||
handleServerError(AuthOperationError.from(error))
|
||||
cancel()
|
||||
await refreshStatus()
|
||||
}
|
||||
},
|
||||
[cancel, refreshStatus, requestVerification, t]
|
||||
[cancel, refreshStatus, requestVerification]
|
||||
)
|
||||
|
||||
const start = useCallback(() => {
|
||||
@@ -133,7 +134,7 @@ export function useTwoFASetup(refreshStatus: () => void | Promise<void>) {
|
||||
return
|
||||
}
|
||||
cancel()
|
||||
toast.error(t(failure.message))
|
||||
handleServerError(failure)
|
||||
toast.info(t('Verify again before retrying this action.'))
|
||||
await refreshStatus()
|
||||
} finally {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user