From 6112087c11688d72c26db94478e811e6408bb19a Mon Sep 17 00:00:00 2001 From: Seefs Date: Mon, 10 Aug 2026 22:45:35 +0800 Subject: [PATCH 1/2] fix(web): prevent query failures from forcing 500 redirects --- web/src/lib/query-retry.ts | 43 ++++++++++++++++++++++++++++++++++++++ web/src/main.tsx | 30 +++----------------------- 2 files changed, 46 insertions(+), 27 deletions(-) create mode 100644 web/src/lib/query-retry.ts diff --git a/web/src/lib/query-retry.ts b/web/src/lib/query-retry.ts new file mode 100644 index 000000000000..c6af4d680ed3 --- /dev/null +++ b/web/src/lib/query-retry.ts @@ -0,0 +1,43 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import axios from 'axios' + +const MAX_TRANSIENT_RETRIES = 2 + +function getHttpStatus(error: unknown): number | undefined { + if (axios.isAxiosError(error)) return error.response?.status + if (!error || typeof error !== 'object' || !('status' in error)) { + return undefined + } + + const status = Number(error.status) + return Number.isInteger(status) ? status : undefined +} + +export function shouldRetryQuery( + failureCount: number, + error: unknown +): boolean { + const status = getHttpStatus(error) + if (status !== undefined && status >= 400 && status < 500) { + return false + } + + return failureCount < MAX_TRANSIENT_RETRIES +} diff --git a/web/src/main.tsx b/web/src/main.tsx index b2cf827c1ece..f551f02ee985 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -16,11 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { - QueryCache, - QueryClient, - QueryClientProvider, -} from '@tanstack/react-query' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { RouterProvider, createRouter } from '@tanstack/react-router' import { AxiosError } from 'axios' import i18next from 'i18next' @@ -34,6 +30,7 @@ import { applyFaviconToDom } from '@/lib/dom-utils' import '@/lib/dayjs' import { initializeFrontendCache } from '@/lib/frontend-cache' import { handleServerError } from '@/lib/handle-server-error' +import { shouldRetryQuery } from '@/lib/query-retry' import { DirectionProvider } from './context/direction-provider' import { FontProvider } from './context/font-provider' @@ -53,18 +50,7 @@ installBuildMetadata() const queryClient = new QueryClient({ defaultOptions: { queries: { - retry: (failureCount, error) => { - // eslint-disable-next-line no-console - if (import.meta.env.DEV) console.log({ failureCount, error }) - - if (failureCount >= 0 && import.meta.env.DEV) return false - if (failureCount > 3 && import.meta.env.PROD) return false - - return !( - error instanceof AxiosError && - [401, 403].includes(error.response?.status ?? 0) - ) - }, + retry: shouldRetryQuery, // Keep focused tabs from silently re-running heavy pages like logs. refetchOnWindowFocus: false, staleTime: 10 * 1000, // 10s @@ -81,16 +67,6 @@ const queryClient = new QueryClient({ }, }, }, - queryCache: new QueryCache({ - onError: (error) => { - if (error instanceof AxiosError) { - if (error.response?.status === 500) { - toast.error(i18next.t('Internal Server Error!')) - router.navigate({ to: '/500' }) - } - } - }, - }), }) // Create a new router instance From 63dfdfd1e9ba2626fd0b085623af8ca3ca840100 Mon Sep 17 00:00:00 2001 From: Seefs Date: Mon, 10 Aug 2026 23:38:38 +0800 Subject: [PATCH 2/2] fix(web): harden page-level API error handling --- .../data-table/layout/data-table-page.tsx | 37 +++++- web/src/components/error-state.tsx | 17 ++- web/src/features/about/api.ts | 5 + web/src/features/about/index.tsx | 15 ++- .../channels/components/channels-table.tsx | 91 +++++++------ .../drawers/channel-mutate-drawer.tsx | 38 ++++-- .../models/performance-overview.tsx | 19 ++- .../overview/overview-dashboard.tsx | 10 +- .../overview/performance-health-panel.tsx | 16 ++- .../components/users/user-charts.tsx | 24 +++- .../features/errors/general-error-status.ts | 29 ++++ web/src/features/errors/general-error.tsx | 18 +-- .../keys/components/api-keys-table.tsx | 8 +- web/src/features/legal/legal-document.tsx | 15 ++- .../models/components/deployments-table.tsx | 32 +++-- .../drawers/model-mutate-drawer.tsx | 38 +++++- .../models/components/models-table.tsx | 34 +++-- web/src/features/performance-metrics/api.ts | 35 ++++- web/src/features/performance-metrics/types.ts | 14 +- web/src/features/playground/api.ts | 13 +- web/src/features/pricing/api.ts | 11 +- .../components/model-details-performance.tsx | 20 ++- .../pricing/components/model-details.tsx | 2 +- web/src/features/pricing/index.tsx | 15 +++ web/src/features/profile/hooks/use-profile.ts | 15 ++- web/src/features/profile/index.tsx | 20 ++- web/src/features/rankings/api.ts | 18 ++- .../components/redemptions-table.tsx | 8 +- .../components/subscriptions-table.tsx | 7 +- web/src/features/system-settings/api.ts | 5 + .../custom-oauth/custom-oauth-section.tsx | 17 ++- .../hooks/use-custom-oauth-providers.ts | 6 +- .../components/settings-page.tsx | 18 ++- .../components/common-logs-stats.tsx | 29 +++- .../components/usage-logs-table.tsx | 17 ++- web/src/features/users/api.ts | 12 +- .../users/components/users-mutate-drawer.tsx | 125 +++++++++++++----- .../features/users/components/users-table.tsx | 27 ++-- .../features/wallet/hooks/use-topup-info.ts | 12 +- web/src/features/wallet/index.tsx | 47 ++++++- web/src/lib/api.ts | 17 ++- 41 files changed, 733 insertions(+), 223 deletions(-) create mode 100644 web/src/features/errors/general-error-status.ts diff --git a/web/src/components/data-table/layout/data-table-page.tsx b/web/src/components/data-table/layout/data-table-page.tsx index 45dfdace1988..ae90b89aaa18 100644 --- a/web/src/components/data-table/layout/data-table-page.tsx +++ b/web/src/components/data-table/layout/data-table-page.tsx @@ -41,6 +41,7 @@ For commercial licensing, please contact support@quantumnous.com */ import * as React from 'react' +import { ErrorState } from '@/components/error-state' import { PageFooterPortal } from '@/components/layout/components/page-footer' import { useMediaQuery } from '@/hooks' import { cn } from '@/lib/utils' @@ -92,6 +93,19 @@ export type DataTablePageProps = { */ isFetching?: boolean + /** + * Initial query error. When provided, replaces the table with a retryable + * error state. Consumers should omit this when stale data remains available. + */ + error?: unknown + + /** + * Optional copy and retry action for the query error state. + */ + errorTitle?: string + errorDescription?: string + onRetry?: () => void + /** * Empty-state title (used for both desktop {@link TableEmpty} and mobile fallback). */ @@ -310,6 +324,7 @@ export type DataTablePageProps = { export function DataTablePage(props: DataTablePageProps) { const isMobile = useMediaQuery('(max-width: 640px)') const showMobile = isMobile && !props.hideMobile + const hasError = props.error !== undefined && props.error !== null const [internalViewMode, setInternalViewMode] = useDataTableViewMode({ storageKey: props.viewModeStorageKey, @@ -331,7 +346,7 @@ export function DataTablePage(props: DataTablePageProps) { const toolbarNode = renderToolbar(props, viewToggle) const mobileNode = renderMobile(props, showMobile, cardViewActive, viewMode) const desktopNode = renderDesktop(props, showMobile, cardViewActive, viewMode) - const paginationNode = renderPagination(props) + const paginationNode = hasError ? null : renderPagination(props) return ( <> @@ -344,14 +359,26 @@ export function DataTablePage(props: DataTablePageProps) { )} > {toolbarNode} - {mobileNode} - {desktopNode} - {props.afterTable} + {hasError ? ( + + ) : ( + <> + {mobileNode} + {desktopNode} + {props.afterTable} + + )} {/* Bulk actions are typically a fixed-position toolbar; let the consumer handle its own visibility, we just gate it to non-mobile. */} - {!showMobile && props.bulkActions} + {!hasError && !showMobile && props.bulkActions} {paginationNode} diff --git a/web/src/components/error-state.tsx b/web/src/components/error-state.tsx index adfc2f2db3b5..a06f392e5445 100644 --- a/web/src/components/error-state.tsx +++ b/web/src/components/error-state.tsx @@ -30,10 +30,12 @@ import { EmptyMedia, EmptyTitle, } from '@/components/ui/empty' +import { getHttpStatus } from '@/features/errors/general-error-status' import { cn } from '@/lib/utils' interface ErrorStateProps { icon?: LucideIcon + error?: unknown title?: string description?: string onRetry?: () => void @@ -44,6 +46,13 @@ interface ErrorStateProps { export function ErrorState(props: ErrorStateProps) { const { t } = useTranslation() const Icon = props.icon ?? AlertTriangle + const isRateLimited = getHttpStatus(props.error) === 429 + const title = isRateLimited + ? t('Too many requests') + : (props.title ?? t('Oops! Something went wrong')) + const description = isRateLimited + ? t('Please wait a moment before trying again.') + : (props.description ?? t('Please try again later.')) return ( @@ -52,12 +61,8 @@ export function ErrorState(props: ErrorStateProps) { - - {props.title ?? t('Oops! Something went wrong')} - - {props.description != null && ( - {props.description} - )} + {title} + {description} {props.onRetry != null && ( diff --git a/web/src/features/about/api.ts b/web/src/features/about/api.ts index de604be6324d..0e5446427125 100644 --- a/web/src/features/about/api.ts +++ b/web/src/features/about/api.ts @@ -16,11 +16,16 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import { t } from 'i18next' + import { api } from '@/lib/api' import type { AboutResponse } from './types' export async function getAboutContent() { const res = await api.get('/api/about') + if (!res.data.success) { + throw new Error(res.data.message || t('Request failed') || 'Request failed') + } return res.data } diff --git a/web/src/features/about/index.tsx b/web/src/features/about/index.tsx index 228343050e87..ab6ab1f72481 100644 --- a/web/src/features/about/index.tsx +++ b/web/src/features/about/index.tsx @@ -20,6 +20,7 @@ import { useQuery } from '@tanstack/react-query' import { Construction } from 'lucide-react' import { useTranslation } from 'react-i18next' +import { ErrorState } from '@/components/error-state' import { PublicLayout } from '@/components/layout' import { RichContent } from '@/components/rich-content' import { Skeleton } from '@/components/ui/skeleton' @@ -114,7 +115,7 @@ function EmptyAboutState() { export function About() { const { t } = useTranslation() - const { data, isLoading } = useQuery({ + const { data, error, isError, isLoading, refetch } = useQuery({ queryKey: ['about-content'], queryFn: getAboutContent, }) @@ -137,6 +138,18 @@ export function About() { ) } + if (isError && data === undefined) { + return ( + + void refetch()} + /> + + ) + } + if (!hasContent) { return ( diff --git a/web/src/features/channels/components/channels-table.tsx b/web/src/features/channels/components/channels-table.tsx index 51f3f3ecc71e..6b515f39e302 100644 --- a/web/src/features/channels/components/channels-table.tsx +++ b/web/src/features/channels/components/channels-table.tsx @@ -220,7 +220,7 @@ export function ChannelsTable() { // Fetch channels data // eslint-disable-next-line @tanstack/query/exhaustive-deps - const { data, isLoading, isFetching } = useQuery({ + const { data, error, isError, isLoading, isFetching, refetch } = useQuery({ queryKey: channelsQueryKeys.list({ keyword: globalFilter, model: modelFilter, @@ -243,49 +243,52 @@ export function ChannelsTable() { page_size: pagination.pageSize, }), 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, - }) - } 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, - }) + const result = shouldSearch + ? 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, + }) + : 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, + }) + + if (!result.success) { + throw new Error(result.message || t('Request failed')) } + return result }, placeholderData: (previousData) => previousData, }) @@ -413,6 +416,8 @@ export function ChannelsTable() { columns={columns} isLoading={isLoading} isFetching={isFetching} + error={isError && data === undefined ? error : undefined} + onRetry={() => void refetch()} emptyTitle={t('No Channels Found')} emptyDescription={t( 'No channels available. Create your first channel to get started.' diff --git a/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx index 3380d9e52c24..9c82ce610f68 100644 --- a/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -63,6 +63,7 @@ import { sideDrawerSectionClassName, sideDrawerSwitchItemClassName, } from '@/components/drawer-layout' +import { ErrorState } from '@/components/error-state' import { JsonCodeEditor } from '@/components/json-code-editor' import { JsonEditor } from '@/components/json-editor' import { MultiSelect } from '@/components/multi-select' @@ -658,11 +659,19 @@ export function ChannelMutateDrawer({ const sensitiveLocked = isEditing && !canEditSensitive // Fetch channel details if editing - const { data: channelData, isLoading: isChannelLoading } = useQuery({ + const channelQuery = useQuery({ queryKey: channelsQueryKeys.detail(channelId || 0), - queryFn: () => getChannel(channelId || 0), + queryFn: async () => { + const response = await getChannel(channelId || 0) + if (!response.success || !response.data) { + throw new Error(response.message || t('Request failed')) + } + return response + }, enabled: isEditing && Boolean(channelId), }) + const channelData = channelQuery.data + const isChannelLoading = channelQuery.isLoading // Fetch available groups const { data: groupsData, isLoading: isLoadingGroups } = useQuery({ @@ -853,6 +862,7 @@ export function ChannelMutateDrawer({ const isBatchMode = multiKeyMode === 'batch' || multiKeyMode === 'multi_to_single' const isChannelDetailLoading = isEditing && isChannelLoading + const isChannelDetailError = isEditing && channelQuery.isError const supportsMultiKeyAddMode = currentType !== 57 && !(currentType === 41 && vertexKeyType === 'api_key') const addModeOptions = useMemo( @@ -1944,9 +1954,15 @@ export function ChannelMutateDrawer({ onSubmit={form.handleSubmit(onSubmit, onInvalid)} className={sideDrawerFormClassName('gap-5')} > - {isChannelDetailLoading ? ( - - ) : ( + {isChannelDetailLoading && } + {isChannelDetailError && ( + void channelQuery.refetch()} + className='min-h-[320px]' + /> + )} + {!isChannelDetailLoading && !isChannelDetailError && (
- + {t('Auto')} @@ -4771,7 +4785,13 @@ export function ChannelMutateDrawer({ > {t('Cancel')} - +
+ ) + } + if (!loading && !hasData) { return (
diff --git a/web/src/features/dashboard/components/overview/overview-dashboard.tsx b/web/src/features/dashboard/components/overview/overview-dashboard.tsx index cdd5f4785779..4db670962734 100644 --- a/web/src/features/dashboard/components/overview/overview-dashboard.tsx +++ b/web/src/features/dashboard/components/overview/overview-dashboard.tsx @@ -478,7 +478,10 @@ export function OverviewDashboard() { queryKey: ['dashboard', 'overview', 'api-keys'], queryFn: async () => { const result = await getApiKeys({ p: 1, size: 10 }) - return result.success ? (result.data?.items ?? []) : [] + if (!result.success || !Array.isArray(result.data?.items)) { + throw new Error(result.message || t('Request failed')) + } + return result.data.items }, staleTime: 60 * 1000, }) @@ -487,7 +490,10 @@ export function OverviewDashboard() { queryKey: ['dashboard', 'overview', 'user-models'], queryFn: async () => { const result = await getUserModels() - return result.success ? (result.data ?? []) : [] + if (!result.success || !Array.isArray(result.data)) { + throw new Error(result.message || t('Request failed')) + } + return result.data }, staleTime: 5 * 60 * 1000, }) diff --git a/web/src/features/dashboard/components/overview/performance-health-panel.tsx b/web/src/features/dashboard/components/overview/performance-health-panel.tsx index 162023ec4f39..2b1e56e92834 100644 --- a/web/src/features/dashboard/components/overview/performance-health-panel.tsx +++ b/web/src/features/dashboard/components/overview/performance-health-panel.tsx @@ -21,6 +21,7 @@ import { Gauge, HeartPulse, Timer } from 'lucide-react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' +import { ErrorState } from '@/components/error-state' import { IconBadge, type IconBadgeTone } from '@/components/ui/icon-badge' import { Skeleton } from '@/components/ui/skeleton' import { getPerfMetricsSummary } from '@/features/performance-metrics/api' @@ -65,7 +66,7 @@ export function PerformanceHealthPanel() { }) const models = useMemo( - () => metricsQuery.data?.data.models ?? [], + () => metricsQuery.data?.data?.models ?? [], [metricsQuery.data] ) @@ -91,6 +92,19 @@ export function PerformanceHealthPanel() { const loading = metricsQuery.isLoading const hasData = models.length > 0 + if (metricsQuery.isError && metricsQuery.data === undefined) { + return ( +
+ void metricsQuery.refetch()} + className='min-h-[220px]' + /> +
+ ) + } + return (
diff --git a/web/src/features/dashboard/components/users/user-charts.tsx b/web/src/features/dashboard/components/users/user-charts.tsx index 97c44754e8ca..c3d5b11f554a 100644 --- a/web/src/features/dashboard/components/users/user-charts.tsx +++ b/web/src/features/dashboard/components/users/user-charts.tsx @@ -22,6 +22,7 @@ import { Users, Loader2 } from 'lucide-react' import { useEffect, useMemo, useState, useRef, useCallback } from 'react' import { useTranslation } from 'react-i18next' +import { ErrorState } from '@/components/error-state' import { IconBadge } from '@/components/ui/icon-badge' import { Skeleton } from '@/components/ui/skeleton' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' @@ -136,12 +137,19 @@ export function UserCharts(props: UserChartsProps) { updateTheme() }, [resolvedTheme]) - const { data: userData, isLoading } = useQuery({ + const userQuery = useQuery({ queryKey: ['dashboard', 'user-quota', timeRange], - queryFn: () => getUserQuotaDataByUsers(timeRange), - select: (res) => (res.success ? res.data : []), + queryFn: async () => { + const result = await getUserQuotaDataByUsers(timeRange) + if (!result.success || !Array.isArray(result.data)) { + throw new Error(t('Request failed')) + } + return result.data + }, staleTime: 60_000, }) + const userData = userQuery.data + const isLoading = userQuery.isLoading const chartData = useMemo( () => @@ -154,6 +162,16 @@ export function UserCharts(props: UserChartsProps) { [userData, isLoading, timeGranularity, t, topUserLimit] ) + if (userQuery.isError && userData === undefined) { + return ( + void userQuery.refetch()} + /> + ) + } + return (
diff --git a/web/src/features/errors/general-error-status.ts b/web/src/features/errors/general-error-status.ts new file mode 100644 index 000000000000..756de67d8b01 --- /dev/null +++ b/web/src/features/errors/general-error-status.ts @@ -0,0 +1,29 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +export function getHttpStatus(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null) return undefined + const response = (error as Record).response + if (typeof response !== 'object' || response === null) return undefined + const status = (response as Record).status + return typeof status === 'number' ? status : undefined +} + +export function getErrorDisplayStatus(error: unknown): number | undefined { + return getHttpStatus(error) ?? (error === undefined ? 500 : undefined) +} diff --git a/web/src/features/errors/general-error.tsx b/web/src/features/errors/general-error.tsx index a6414e833236..869ff70a8d77 100644 --- a/web/src/features/errors/general-error.tsx +++ b/web/src/features/errors/general-error.tsx @@ -22,6 +22,8 @@ import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' +import { getErrorDisplayStatus } from './general-error-status' + const FEEDBACK_URL = 'https://github.com/QuantumNous/new-api/issues' type GeneralErrorProps = React.HTMLAttributes & { @@ -29,14 +31,6 @@ type GeneralErrorProps = React.HTMLAttributes & { error?: unknown } -function getHttpStatus(error: unknown): number | undefined { - if (typeof error !== 'object' || error === null) return undefined - const response = (error as Record).response - if (typeof response !== 'object' || response === null) return undefined - const status = (response as Record).status - return typeof status === 'number' ? status : undefined -} - export function GeneralError({ className, minimal = false, @@ -45,7 +39,7 @@ export function GeneralError({ const { t } = useTranslation() const navigate = useNavigate() const { history } = useRouter() - const status = getHttpStatus(error) + const status = getErrorDisplayStatus(error) const isRateLimited = status === 429 const title = isRateLimited ? t('Too many requests') @@ -57,10 +51,8 @@ export function GeneralError({ return (
- {!minimal && ( -

- {status ?? 500} -

+ {!minimal && status !== undefined && ( +

{status}

)} {title}

diff --git a/web/src/features/keys/components/api-keys-table.tsx b/web/src/features/keys/components/api-keys-table.tsx index 2ee497924944..745e376917db 100644 --- a/web/src/features/keys/components/api-keys-table.tsx +++ b/web/src/features/keys/components/api-keys-table.tsx @@ -22,7 +22,6 @@ import 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, @@ -232,7 +231,7 @@ export function ApiKeysTable() { // Fetch data with React Query // eslint-disable-next-line @tanstack/query/exhaustive-deps - const { data, isLoading, isFetching } = useQuery({ + const { data, error, isError, isLoading, isFetching, refetch } = useQuery({ queryKey: [ 'keys', pagination.pageIndex + 1, @@ -255,7 +254,7 @@ export function ApiKeysTable() { }) if (!result.success) { - toast.error( + throw new Error( result.message || t( shouldSearch @@ -263,7 +262,6 @@ export function ApiKeysTable() { : ERROR_MESSAGES.LOAD_FAILED ) ) - return { items: [], total: 0 } } return { @@ -299,6 +297,8 @@ export function ApiKeysTable() { columns={columns} isLoading={isLoading} isFetching={isFetching} + error={isError && data === undefined ? error : undefined} + onRetry={() => void refetch()} emptyTitle={t('No API Keys Found')} emptyDescription={t( 'No API keys available. Create your first API key to get started.' diff --git a/web/src/features/legal/legal-document.tsx b/web/src/features/legal/legal-document.tsx index 41b31811b915..9faae4b65fb6 100644 --- a/web/src/features/legal/legal-document.tsx +++ b/web/src/features/legal/legal-document.tsx @@ -20,6 +20,7 @@ import { useQuery } from '@tanstack/react-query' import { FileWarning } from 'lucide-react' import { useTranslation } from 'react-i18next' +import { ErrorState } from '@/components/error-state' import { PublicLayout } from '@/components/layout' import { RichContent } from '@/components/rich-content' import { Button } from '@/components/ui/button' @@ -43,7 +44,7 @@ export function LegalDocument({ emptyMessage, }: LegalDocumentProps) { const { t } = useTranslation() - const { data, isLoading } = useQuery({ + const { data, error, isError, isLoading, refetch } = useQuery({ queryKey: [queryKey], queryFn: fetchDocument, staleTime: 10 * 60 * 1000, @@ -68,6 +69,18 @@ export function LegalDocument({ ) } + if (isError && data === undefined) { + return ( + + void refetch()} + /> + + ) + } + if (!success || !hasContent) { return ( diff --git a/web/src/features/models/components/deployments-table.tsx b/web/src/features/models/components/deployments-table.tsx index 56fb01260808..2d663f611f57 100644 --- a/web/src/features/models/components/deployments-table.tsx +++ b/web/src/features/models/components/deployments-table.tsx @@ -114,7 +114,7 @@ export function DeploymentsTable() { const [deleteTarget, setDeleteTarget] = useState(null) const [isDeleting, setIsDeleting] = useState(false) - const { data, isLoading, isFetching } = useQuery({ + const { data, error, isError, isLoading, isFetching, refetch } = useQuery({ queryKey: deploymentsQueryKeys.list({ keyword, status: activeStatus, @@ -122,19 +122,23 @@ export function DeploymentsTable() { page_size: pagination.pageSize, }), queryFn: async () => { - if (keyword.trim()) { - return searchDeployments({ - keyword, - status: activeStatus, - p: pagination.pageIndex + 1, - page_size: pagination.pageSize, - }) + const result = keyword.trim() + ? await searchDeployments({ + keyword, + status: activeStatus, + p: pagination.pageIndex + 1, + page_size: pagination.pageSize, + }) + : await listDeployments({ + status: activeStatus, + p: pagination.pageIndex + 1, + page_size: pagination.pageSize, + }) + + if (!result.success) { + throw new Error(result.message || t('Request failed')) } - return listDeployments({ - status: activeStatus, - p: pagination.pageIndex + 1, - page_size: pagination.pageSize, - }) + return result }, placeholderData: (prev) => prev, }) @@ -222,6 +226,8 @@ export function DeploymentsTable() { columns={columns} isLoading={isLoading} isFetching={isFetching} + error={isError && data === undefined ? error : undefined} + onRetry={() => void refetch()} emptyTitle={t('No Deployments Found')} emptyDescription={t( 'No deployments available. Create one to get started.' diff --git a/web/src/features/models/components/drawers/model-mutate-drawer.tsx b/web/src/features/models/components/drawers/model-mutate-drawer.tsx index 532b8931bc4d..e6a6b0951869 100644 --- a/web/src/features/models/components/drawers/model-mutate-drawer.tsx +++ b/web/src/features/models/components/drawers/model-mutate-drawer.tsx @@ -33,7 +33,9 @@ import { sideDrawerHeaderClassName, sideDrawerSwitchItemClassName, } from '@/components/drawer-layout' +import { ErrorState } from '@/components/error-state' import { JsonEditor } from '@/components/json-editor' +import { LoadingState } from '@/components/loading-state' import { TagInput } from '@/components/tag-input' import { Button } from '@/components/ui/button' import { @@ -80,6 +82,7 @@ import { useUpdateOption } from '@/features/system-settings/hooks/use-update-opt import { normalizeJsonString } from '@/features/system-settings/models/utils' import type { ModelSettings } from '@/features/system-settings/types' import { safeJsonParse } from '@/features/system-settings/utils/json-parser' +import { cn } from '@/lib/utils' import { createModel, updateModel, getModel, getVendors } from '../../api' import { getNameRuleOptions, ENDPOINT_TEMPLATES } from '../../constants' @@ -267,16 +270,23 @@ export function ModelMutateDrawer({ const vendors = vendorsData?.data?.items || [] // Fetch model detail if editing - const { data: modelData } = useQuery({ + const modelQuery = useQuery({ queryKey: modelsQueryKeys.detail(currentModelId || 0), - queryFn: () => { + queryFn: async () => { if (!currentModelId) { throw new Error('Model ID is required') } - return getModel(currentModelId) + const response = await getModel(currentModelId) + if (!response.success || !response.data) { + throw new Error(response.message || t('Request failed')) + } + return response }, enabled: open && isEditing, }) + const modelData = modelQuery.data + const isModelDetailLoading = isEditing && modelQuery.isLoading + const isModelDetailError = isEditing && modelQuery.isError // Fetch system options for ratio configuration const { data: systemOptionsData } = useSystemOptions() @@ -741,13 +751,25 @@ export function ModelMutateDrawer({ + {isModelDetailLoading && } + {isModelDetailError && ( + void modelQuery.refetch()} + className='min-h-[320px]' + /> + )} +

[0] )} - className={sideDrawerFormClassName()} + className={cn( + sideDrawerFormClassName(), + (isModelDetailLoading || isModelDetailError) && 'hidden' + )} > {/* Basic Information */} @@ -1381,7 +1403,13 @@ export function ModelMutateDrawer({ > {t('Cancel')} - diff --git a/web/src/features/models/components/models-table.tsx b/web/src/features/models/components/models-table.tsx index 4a9b7b6ecd07..cab49e209f56 100644 --- a/web/src/features/models/components/models-table.tsx +++ b/web/src/features/models/components/models-table.tsx @@ -120,7 +120,7 @@ export function ModelsTable() { // Fetch models data // eslint-disable-next-line @tanstack/query/exhaustive-deps - const { data, isLoading, isFetching } = useQuery({ + const { data, error, isError, isLoading, isFetching, refetch } = useQuery({ queryKey: modelsQueryKeys.list({ keyword: globalFilter, vendor: activeVendorFilter, @@ -130,20 +130,24 @@ export function ModelsTable() { page_size: pagination.pageSize, }), queryFn: async () => { - if (shouldSearch) { - return searchModels({ - keyword: globalFilter, - vendor: activeVendorFilter, - status: statusFilterValue, - sync_official: syncFilterValue, - p: pagination.pageIndex + 1, - page_size: pagination.pageSize, - }) + const result = shouldSearch + ? await searchModels({ + keyword: globalFilter, + vendor: activeVendorFilter, + status: statusFilterValue, + sync_official: syncFilterValue, + p: pagination.pageIndex + 1, + page_size: pagination.pageSize, + }) + : await getModels({ + p: pagination.pageIndex + 1, + page_size: pagination.pageSize, + }) + + if (!result.success) { + throw new Error(result.message || t('Request failed')) } - return getModels({ - p: pagination.pageIndex + 1, - page_size: pagination.pageSize, - }) + return result }, }) @@ -194,6 +198,8 @@ export function ModelsTable() { columns={columns} isLoading={isLoading} isFetching={isFetching} + error={isError && data === undefined ? error : undefined} + onRetry={() => void refetch()} emptyTitle={t('No Models Found')} emptyDescription={t( 'No models available. Create your first model to get started.' diff --git a/web/src/features/performance-metrics/api.ts b/web/src/features/performance-metrics/api.ts index e27ba8499e56..d7a16e91c3bc 100644 --- a/web/src/features/performance-metrics/api.ts +++ b/web/src/features/performance-metrics/api.ts @@ -16,28 +16,53 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import { t } from 'i18next' + import { api } from '@/lib/api' -import type { PerformanceMetricsData, PerfSummaryAllData } from './types' +import type { + PerformanceMetricsData, + PerfSummaryAllData, + SuccessfulPerformanceMetricsData, + SuccessfulPerfSummaryData, +} from './types' + +export function requirePerformanceSummaryResponse( + response: PerfSummaryAllData +): SuccessfulPerfSummaryData { + if (!response.success || !Array.isArray(response.data?.models)) { + throw new Error(response.message || t('Request failed') || 'Request failed') + } + return response as SuccessfulPerfSummaryData +} + +export function requirePerformanceMetricsResponse( + response: PerformanceMetricsData +): SuccessfulPerformanceMetricsData { + if (!response.success || !Array.isArray(response.data?.groups)) { + throw new Error(response.message || t('Request failed') || 'Request failed') + } + return response as SuccessfulPerformanceMetricsData +} export async function getPerfMetricsSummary( hours = 24 -): Promise { +): Promise { const res = await api.get('/api/perf-metrics/summary', { params: { hours }, }) - return res.data + return requirePerformanceSummaryResponse(res.data) } export async function getPerfMetrics( modelName: string, hours = 24 -): Promise { +): Promise { const res = await api.get('/api/perf-metrics', { params: { model: modelName, hours, }, }) - return res.data + return requirePerformanceMetricsResponse(res.data) } diff --git a/web/src/features/performance-metrics/types.ts b/web/src/features/performance-metrics/types.ts index 4e4450a51dc1..434a9ff2b2b9 100644 --- a/web/src/features/performance-metrics/types.ts +++ b/web/src/features/performance-metrics/types.ts @@ -36,13 +36,18 @@ export type PerformanceGroup = { export type PerformanceMetricsData = { success: boolean message?: string - data: { + data?: { model_name: string series_schema?: string groups: PerformanceGroup[] } } +export type SuccessfulPerformanceMetricsData = PerformanceMetricsData & { + success: true + data: NonNullable +} + export type PerfModelSummary = { model_name: string avg_latency_ms: number @@ -55,7 +60,12 @@ export type PerfModelSummary = { export type PerfSummaryAllData = { success: boolean message?: string - data: { + data?: { models: PerfModelSummary[] } } + +export type SuccessfulPerfSummaryData = PerfSummaryAllData & { + success: true + data: NonNullable +} diff --git a/web/src/features/playground/api.ts b/web/src/features/playground/api.ts index 4f6e205e9451..6081d1a5b12d 100644 --- a/web/src/features/playground/api.ts +++ b/web/src/features/playground/api.ts @@ -16,6 +16,8 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import { t } from 'i18next' + import { api } from '@/lib/api' import { API_ENDPOINTS } from './constants' @@ -50,7 +52,7 @@ export async function getUserModels(group: string): Promise { const { data } = res if (!data.success || !Array.isArray(data.data)) { - return [] + throw new Error(data.message || t('Request failed') || 'Request failed') } return data.data.map((model: string) => ({ @@ -66,8 +68,13 @@ export async function getUserGroups(): Promise { const res = await api.get(API_ENDPOINTS.USER_GROUPS) const { data } = res - if (!data.success || !data.data) { - return [] + if ( + !data.success || + !data.data || + typeof data.data !== 'object' || + Array.isArray(data.data) + ) { + throw new Error(data.message || t('Request failed') || 'Request failed') } const groupData = data.data as Record diff --git a/web/src/features/pricing/api.ts b/web/src/features/pricing/api.ts index a6a8d316311a..97def23fbe92 100644 --- a/web/src/features/pricing/api.ts +++ b/web/src/features/pricing/api.ts @@ -16,6 +16,8 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import { t } from 'i18next' + import { api } from '@/lib/api' import type { PricingData } from './types' @@ -26,6 +28,13 @@ import type { PricingData } from './types' // Get model pricing data export async function getPricing(): Promise { - const res = await api.get('/api/pricing') + const res = await api.get('/api/pricing') + if ( + !res.data.success || + !Array.isArray(res.data.data) || + !Array.isArray(res.data.vendors) + ) { + throw new Error(res.data.message || t('Request failed') || 'Request failed') + } return res.data } diff --git a/web/src/features/pricing/components/model-details-performance.tsx b/web/src/features/pricing/components/model-details-performance.tsx index 0be5d040d781..0e2a81f89eab 100644 --- a/web/src/features/pricing/components/model-details-performance.tsx +++ b/web/src/features/pricing/components/model-details-performance.tsx @@ -25,6 +25,7 @@ import { StaticDataTable, staticDataTableClassNames as tableStyles, } from '@/components/data-table' +import { ErrorState } from '@/components/error-state' import { GroupBadge } from '@/components/group-badge' import { getPerfMetrics } from '@/features/performance-metrics/api' import { @@ -36,7 +37,7 @@ import { import type { PerformanceGroup } from '@/features/performance-metrics/types' 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 = @@ -169,7 +170,7 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) { staleTime: 60 * 1000, }) const groups = useMemo( - () => metricsQuery.data?.data.groups ?? [], + () => metricsQuery.data?.data?.groups ?? [], [metricsQuery.data] ) const performances = useMemo( @@ -193,6 +194,17 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) { return map }, [groups]) + if (metricsQuery.isError && metricsQuery.data === undefined) { + return ( + void metricsQuery.refetch()} + className='min-h-[220px]' + /> + ) + } + if (metricsQuery.isLoading || performances.length === 0) { return (
diff --git a/web/src/features/pricing/components/model-details.tsx b/web/src/features/pricing/components/model-details.tsx index dbe105200866..c88b3600ba4f 100644 --- a/web/src/features/pricing/components/model-details.tsx +++ b/web/src/features/pricing/components/model-details.tsx @@ -182,7 +182,7 @@ function OverviewSummaryGrid(props: { model: PricingModel }) { staleTime: 60 * 1000, }) - const groups = metricsQuery.data?.data.groups ?? [] + const groups = metricsQuery.data?.data?.groups ?? [] const successRates = groups .map((group) => group.success_rate) .filter((rate) => Number.isFinite(rate)) diff --git a/web/src/features/pricing/index.tsx b/web/src/features/pricing/index.tsx index 7b98d343d036..6a5cc87a6f60 100644 --- a/web/src/features/pricing/index.tsx +++ b/web/src/features/pricing/index.tsx @@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { ErrorState } from '@/components/error-state' import { PublicLayout } from '@/components/layout' import { PageTransition } from '@/components/page-transition' @@ -50,6 +51,8 @@ export function Pricing() { endpointMap, autoGroups, isLoading, + error, + refetch, priceRate, usdExchangeRate, } = usePricingData() @@ -158,6 +161,18 @@ export function Pricing() { ) } + if (error && models.length === 0) { + return ( + + void refetch()} + /> + + ) + } + return (
diff --git a/web/src/features/profile/hooks/use-profile.ts b/web/src/features/profile/hooks/use-profile.ts index 0b61a703f583..044a91820627 100644 --- a/web/src/features/profile/hooks/use-profile.ts +++ b/web/src/features/profile/hooks/use-profile.ts @@ -33,6 +33,7 @@ import type { export function useProfile() { const [profile, setProfile] = useState(null) + const [error, setError] = useState(null) const [loading, setLoading] = useState(true) const [updating, setUpdating] = useState(false) @@ -44,14 +45,17 @@ export function useProfile() { } const response = await getUserProfile() - if (response.success && response.data) { - setProfile(response.data) + if (!response.success || !response.data) { + throw new Error(response.message || i18next.t('Failed to load profile')) } - } catch (error) { + + setProfile(response.data) + setError(null) + } catch (fetchError) { // eslint-disable-next-line no-console - console.error('Failed to fetch profile:', error) + console.error('Failed to fetch profile:', fetchError) if (!silent) { - toast.error(i18next.t('Failed to load profile')) + setError(fetchError) } } finally { if (!silent) { @@ -126,6 +130,7 @@ export function useProfile() { return { profile, + error, loading, updating, fetchProfile, diff --git a/web/src/features/profile/index.tsx b/web/src/features/profile/index.tsx index 4d5d3aaa8bbe..8b09c734c6a0 100644 --- a/web/src/features/profile/index.tsx +++ b/web/src/features/profile/index.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next' + /* Copyright (C) 2023-2026 QuantumNous @@ -16,6 +18,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import { ErrorState } from '@/components/error-state' import { Main } from '@/components/layout' import { CardStaggerContainer, @@ -36,7 +39,8 @@ import { TwoFACard } from './components/two-fa-card' import { useProfile } from './hooks' export function Profile() { - const { profile, loading, refreshProfile } = useProfile() + const { t } = useTranslation() + const { profile, error, loading, fetchProfile, refreshProfile } = useProfile() const { status } = useStatus() const permissions = useAuthStore((s) => s.auth.user?.permissions) @@ -47,6 +51,20 @@ export function Profile() { const turnstileSiteKey = status?.turnstile_site_key || '' const canConfigureSidebar = permissions?.sidebar_settings !== false + if (!loading && error && !profile) { + return ( +
+ void fetchProfile()} + className='min-h-0 flex-1' + /> +
+ ) + } + return (
diff --git a/web/src/features/rankings/api.ts b/web/src/features/rankings/api.ts index 6252ed061f82..b2f9197edc7f 100644 --- a/web/src/features/rankings/api.ts +++ b/web/src/features/rankings/api.ts @@ -16,6 +16,8 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import { t } from 'i18next' + import { api } from '@/lib/api' import type { RankingPeriod, RankingsSnapshot } from './types' @@ -23,12 +25,22 @@ import type { RankingPeriod, RankingsSnapshot } from './types' type RankingsResponse = { success: boolean message?: string + data?: RankingsSnapshot +} + +type SuccessfulRankingsResponse = RankingsResponse & { + success: true data: RankingsSnapshot } export async function getRankings( period: RankingPeriod -): Promise { - const res = await api.get('/api/rankings', { params: { period } }) - return res.data +): Promise { + const res = await api.get('/api/rankings', { + params: { period }, + }) + if (!res.data.success || !res.data.data) { + throw new Error(res.data.message || t('Request failed') || 'Request failed') + } + return res.data as SuccessfulRankingsResponse } diff --git a/web/src/features/redemption-codes/components/redemptions-table.tsx b/web/src/features/redemption-codes/components/redemptions-table.tsx index 850433eefffa..64fd0d574927 100644 --- a/web/src/features/redemption-codes/components/redemptions-table.tsx +++ b/web/src/features/redemption-codes/components/redemptions-table.tsx @@ -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, @@ -81,7 +80,7 @@ export function RedemptionsTable() { const statusFilterValue = statusFilter[0] ?? '' // Fetch data with React Query - const { data, isLoading, isFetching } = useQuery({ + const { data, error, isError, isLoading, isFetching, refetch } = useQuery({ queryKey: [ 'redemptions', pagination.pageIndex + 1, @@ -108,7 +107,7 @@ export function RedemptionsTable() { : await getRedemptions(params) if (!result.success) { - toast.error( + throw new Error( result.message || t( hasFilter || hasStatusFilter @@ -116,7 +115,6 @@ export function RedemptionsTable() { : ERROR_MESSAGES.LOAD_FAILED ) ) - return { items: [], total: 0 } } return { @@ -163,6 +161,8 @@ export function RedemptionsTable() { columns={columns} isLoading={isLoading} isFetching={isFetching} + error={isError && data === undefined ? error : undefined} + onRetry={() => void refetch()} emptyTitle={t('No Redemption Codes Found')} emptyDescription={t( 'No redemption codes available. Create your first redemption code to get started.' diff --git a/web/src/features/subscriptions/components/subscriptions-table.tsx b/web/src/features/subscriptions/components/subscriptions-table.tsx index ec371a1a925f..14783c32c906 100644 --- a/web/src/features/subscriptions/components/subscriptions-table.tsx +++ b/web/src/features/subscriptions/components/subscriptions-table.tsx @@ -31,10 +31,13 @@ export function SubscriptionsTable() { const columns = useSubscriptionsColumns() const { refreshTrigger } = useSubscriptions() - const { data, isLoading } = useQuery({ + const { data, error, isError, isLoading, refetch } = useQuery({ queryKey: ['admin-subscription-plans', refreshTrigger], queryFn: async () => { const result = await getAdminPlans() + if (!result.success) { + throw new Error(result.message || t('Request failed')) + } return result.data || [] }, placeholderData: (prev) => prev, @@ -54,6 +57,8 @@ export function SubscriptionsTable() { table={table} columns={columns} isLoading={isLoading} + error={isError && data === undefined ? error : undefined} + onRetry={() => void refetch()} emptyTitle={t('No subscription plans yet')} emptyDescription={t( 'Click "Create Plan" to create your first subscription plan' diff --git a/web/src/features/system-settings/api.ts b/web/src/features/system-settings/api.ts index 9e9c9a7f25e0..06a1c333b92a 100644 --- a/web/src/features/system-settings/api.ts +++ b/web/src/features/system-settings/api.ts @@ -16,6 +16,8 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import { t } from 'i18next' + import { api } from '@/lib/api' import type { @@ -33,6 +35,9 @@ import type { export async function getSystemOptions() { const res = await api.get('/api/option/') + if (!res.data.success || !Array.isArray(res.data.data)) { + throw new Error(res.data.message || t('Request failed') || 'Request failed') + } return res.data } diff --git a/web/src/features/system-settings/auth/custom-oauth/custom-oauth-section.tsx b/web/src/features/system-settings/auth/custom-oauth/custom-oauth-section.tsx index ebb1cd13fc28..3bb8a4911f42 100644 --- a/web/src/features/system-settings/auth/custom-oauth/custom-oauth-section.tsx +++ b/web/src/features/system-settings/auth/custom-oauth/custom-oauth-section.tsx @@ -20,6 +20,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { CopyButton } from '@/components/copy-button' +import { ErrorState } from '@/components/error-state' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { SettingsSection } from '../../components/settings-section' @@ -35,7 +36,9 @@ type CustomOAuthSectionProps = { export function CustomOAuthSection(props: CustomOAuthSectionProps) { const { t } = useTranslation() - const { data: providers = [], isLoading } = useCustomOAuthProviders() + const providersQuery = useCustomOAuthProviders() + const providers = providersQuery.data ?? [] + const isLoading = providersQuery.isLoading const [dialogOpen, setDialogOpen] = useState(false) const [editingProvider, setEditingProvider] = useState(null) @@ -72,6 +75,18 @@ export function CustomOAuthSection(props: CustomOAuthSectionProps) { ) } + if (providersQuery.isError) { + return ( + + void providersQuery.refetch()} + className='min-h-[220px]' + /> + + ) + } + return ( diff --git a/web/src/features/system-settings/auth/custom-oauth/hooks/use-custom-oauth-providers.ts b/web/src/features/system-settings/auth/custom-oauth/hooks/use-custom-oauth-providers.ts index e5a921dbaf6c..fc110d569758 100644 --- a/web/src/features/system-settings/auth/custom-oauth/hooks/use-custom-oauth-providers.ts +++ b/web/src/features/system-settings/auth/custom-oauth/hooks/use-custom-oauth-providers.ts @@ -17,6 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { useQuery } from '@tanstack/react-query' +import { t } from 'i18next' import { getCustomOAuthProviders } from '../api' @@ -25,7 +26,10 @@ export function useCustomOAuthProviders() { queryKey: ['custom-oauth-providers'], queryFn: async () => { const res = await getCustomOAuthProviders() - return res.data ?? [] + if (!res.success || !Array.isArray(res.data)) { + throw new Error(res.message || t('Request failed') || 'Request failed') + } + return res.data }, }) } diff --git a/web/src/features/system-settings/components/settings-page.tsx b/web/src/features/system-settings/components/settings-page.tsx index 678b387e0cc2..97a5ce6c85df 100644 --- a/web/src/features/system-settings/components/settings-page.tsx +++ b/web/src/features/system-settings/components/settings-page.tsx @@ -20,6 +20,7 @@ import { useParams } from '@tanstack/react-router' import { useMemo, useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' +import { ErrorState } from '@/components/error-state' import { SectionPageLayout } from '@/components/layout' import { useSystemOptions, getOptionValue } from '../hooks/use-system-options' @@ -111,7 +112,8 @@ export function SettingsPage< resolveSettings, }: SettingsPageProps) { const { t } = useTranslation() - const { data, isLoading } = useSystemOptions() + const optionsQuery = useSystemOptions() + const { data, isLoading } = optionsQuery // eslint-disable-next-line @typescript-eslint/no-explicit-any const params = useParams({ from: routePath as any }) const activeSection = (params?.section ?? defaultSection) as TSectionId @@ -137,6 +139,20 @@ export function SettingsPage< ) } + if (optionsQuery.isError) { + return ( + + void optionsQuery.refetch()} + className='min-h-0 flex-1' + /> + + ) + } + const sectionContent = getSectionContent( activeSection, settings, diff --git a/web/src/features/usage-logs/components/common-logs-stats.tsx b/web/src/features/usage-logs/components/common-logs-stats.tsx index dd754e227acb..e3867e6ba85f 100644 --- a/web/src/features/usage-logs/components/common-logs-stats.tsx +++ b/web/src/features/usage-logs/components/common-logs-stats.tsx @@ -20,6 +20,7 @@ import { useQuery } from '@tanstack/react-query' import { getRouteApi } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' +import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { formatLogQuota } from '@/lib/format' import { cn } from '@/lib/utils' @@ -53,7 +54,7 @@ export function CommonLogsStats() { const searchParams = route.useSearch() const { sensitiveVisible } = useUsageLogsContext() - const { data: stats, isLoading } = useQuery({ + const statsQuery = useQuery({ queryKey: ['usage-logs-stats', isAdmin, searchParams], queryFn: async () => { const params = buildApiParams({ @@ -68,13 +69,17 @@ export function CommonLogsStats() { ? await getLogStats(params) : await getUserLogStats(params) - return result.success - ? result.data || DEFAULT_LOG_STATS - : DEFAULT_LOG_STATS + if (!result.success) { + throw new Error(result.message || t('Failed to load logs')) + } + return result.data || DEFAULT_LOG_STATS }, placeholderData: (previousData) => previousData, }) + const stats = statsQuery.data + const isLoading = statsQuery.isLoading + if (isLoading) { return (
@@ -85,6 +90,22 @@ export function CommonLogsStats() { ) } + if (statsQuery.isError && stats === undefined) { + return ( +
+ {t('Please try again later.')} + +
+ ) + } + return (
String(item) !== LOG_TYPE_ALL_VALUE) } @@ -116,7 +120,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) { ], }) - const { data, isLoading, isFetching } = useQuery({ + const { data, error, isError, isLoading, isFetching, refetch } = useQuery({ queryKey: [ 'logs', logCategory, @@ -138,8 +142,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) { }) if (!result?.success) { - toast.error(result?.message || t('Failed to load logs')) - return DEFAULT_LOGS_DATA + throw new Error(result?.message || t('Failed to load logs')) } return result.data || DEFAULT_LOGS_DATA @@ -182,6 +185,8 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) { columns={columns as ColumnDef>[]} isLoading={isLoadingData} isFetching={isFetching} + error={isError && data === undefined ? error : undefined} + onRetry={() => void refetch()} emptyTitle={t('No Logs Found')} emptyDescription={t( 'No usage logs available. Logs will appear here once API calls are made.' diff --git a/web/src/features/users/api.ts b/web/src/features/users/api.ts index f3f2ba91a9ef..88b0709aa198 100644 --- a/web/src/features/users/api.ts +++ b/web/src/features/users/api.ts @@ -168,10 +168,16 @@ export async function getGroups(): Promise> { */ export async function getPermissionCatalog(): Promise { const res = await api.get('/api/authz/catalog') - return { - resources: res.data?.data?.resources ?? [], - roles: res.data?.data?.roles ?? [], + const resources = res.data?.data?.resources + const roles = res.data?.data?.roles + if ( + res.data?.success !== true || + !Array.isArray(resources) || + !Array.isArray(roles) + ) { + throw new Error(res.data?.message || 'Request failed') } + return { resources, roles } } // ============================================================================ diff --git a/web/src/features/users/components/users-mutate-drawer.tsx b/web/src/features/users/components/users-mutate-drawer.tsx index 8409f5721d64..a77df9e0ddde 100644 --- a/web/src/features/users/components/users-mutate-drawer.tsx +++ b/web/src/features/users/components/users-mutate-drawer.tsx @@ -31,6 +31,8 @@ import { sideDrawerFormClassName, sideDrawerHeaderClassName, } from '@/components/drawer-layout' +import { ErrorState } from '@/components/error-state' +import { LoadingState } from '@/components/loading-state' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { @@ -89,7 +91,7 @@ import { transformFormDataToPayload, transformUserToFormDefaults, } from '../lib' -import { type User } from '../types' +import type { User } from '../types' import { UserQuotaDialog } from './user-quota-dialog' import { useUsers } from './users-provider' @@ -112,40 +114,78 @@ export function UsersMutateDrawer({ const [quotaDialogOpen, setQuotaDialogOpen] = useState(false) // Fetch groups - const { data: groupsData } = useQuery({ + const groupsQuery = useQuery({ queryKey: ['groups'], - queryFn: getGroups, + queryFn: async () => { + const result = await getGroups() + if (!result.success || !Array.isArray(result.data)) { + throw new Error(result.message || t(ERROR_MESSAGES.LOAD_FAILED)) + } + return result.data + }, staleTime: 5 * 60 * 1000, }) - const groups = groupsData?.data || [] + const groups = groupsQuery.data ?? [] // Permission catalog is owned by the backend; fetched once and reused. - const { data: permissionCatalog = EMPTY_PERMISSION_CATALOG } = useQuery({ + const permissionCatalogQuery = useQuery({ queryKey: ['admin-permission-catalog'], queryFn: getPermissionCatalog, staleTime: 5 * 60 * 1000, }) + const permissionCatalog = + permissionCatalogQuery.data ?? EMPTY_PERMISSION_CATALOG + + const userDetailId = open && isUpdate ? currentRow?.id : undefined + const userDetailQuery = useQuery({ + queryKey: ['admin-user-detail', userDetailId], + queryFn: async () => { + if (userDetailId === undefined) { + throw new Error(t(ERROR_MESSAGES.NO_USER)) + } + const result = await getUser(userDetailId) + if (!result.success || !result.data) { + throw new Error(result.message || t(ERROR_MESSAGES.LOAD_FAILED)) + } + return result.data + }, + enabled: userDetailId !== undefined, + staleTime: 0, + }) const form = useForm({ resolver: zodResolver(userFormSchema), defaultValues: USER_FORM_DEFAULT_VALUES, }) - // Load existing data when updating useEffect(() => { - if (open && isUpdate && currentRow) { - // For update, fetch fresh data - getUser(currentRow.id).then((result) => { - if (result.success && result.data) { - form.reset(transformUserToFormDefaults(result.data)) - } - }) - } else if (open && !isUpdate) { - // For create, reset to defaults - form.reset(USER_FORM_DEFAULT_VALUES) + if (!open) return + form.reset(USER_FORM_DEFAULT_VALUES) + }, [currentRow?.id, form, open]) + + useEffect(() => { + if ( + !open || + !isUpdate || + !userDetailQuery.data || + userDetailQuery.data.id !== currentRow?.id + ) { + return } - }, [open, isUpdate, currentRow, form]) + form.reset(transformUserToFormDefaults(userDetailQuery.data)) + }, [currentRow?.id, form, isUpdate, open, userDetailQuery.data]) + + const drawerLoading = + groupsQuery.isLoading || + permissionCatalogQuery.isLoading || + (isUpdate && userDetailQuery.isLoading) + const drawerError = + groupsQuery.isError || + permissionCatalogQuery.isError || + (isUpdate && userDetailQuery.isError) + const drawerErrorValue = + groupsQuery.error ?? permissionCatalogQuery.error ?? userDetailQuery.error const { meta: currencyMeta } = getCurrencyDisplay() const currencyLabel = getCurrencyLabel() @@ -157,6 +197,8 @@ export function UsersMutateDrawer({ const targetIsAdmin = (selectedRole ?? currentRow?.role ?? 0) >= ROLE.ADMIN const onSubmit = async (data: UserFormValues) => { + if (isUpdate && (!userDetailQuery.data || userDetailQuery.isError)) return + if (!isUpdate) { const passwordLength = data.password?.length || 0 if (passwordLength < 8 || passwordLength > 20) { @@ -195,7 +237,7 @@ export function UsersMutateDrawer({ : t(ERROR_MESSAGES.CREATE_FAILED)) ) } - } catch (_error) { + } catch { toast.error(t(ERROR_MESSAGES.UNEXPECTED)) } finally { setIsSubmitting(false) @@ -204,10 +246,7 @@ export function UsersMutateDrawer({ const refreshUserData = async () => { if (!currentRow) return - const result = await getUser(currentRow.id) - if (result.success && result.data) { - form.reset(transformUserToFormDefaults(result.data)) - } + await userDetailQuery.refetch() triggerRefresh() } @@ -235,11 +274,27 @@ export function UsersMutateDrawer({ : t('Add a new user by providing necessary info.')} + {drawerLoading && ( + + )} + {drawerError && !drawerLoading && ( + { + void groupsQuery.refetch() + void permissionCatalogQuery.refetch() + if (isUpdate) void userDetailQuery.refetch() + }} + className='min-h-0 flex-1' + /> + )} {/* Basic Information */} @@ -278,7 +333,8 @@ export function UsersMutateDrawer({ { value: '10', label: t('Admin') }, ]} onValueChange={(value) => - value !== null && field.onChange(parseInt(value)) + value !== null && + field.onChange(Number.parseInt(value)) } value={String(field.value)} > @@ -360,12 +416,10 @@ export function UsersMutateDrawer({ {t('Group')}