Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 32 additions & 5 deletions web/src/components/data-table/layout/data-table-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -92,6 +93,19 @@ export type DataTablePageProps<TData> = {
*/
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).
*/
Expand Down Expand Up @@ -310,6 +324,7 @@ export type DataTablePageProps<TData> = {
export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
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,
Expand All @@ -331,7 +346,7 @@ export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
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 (
<>
Expand All @@ -344,14 +359,26 @@ export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
)}
>
{toolbarNode}
{mobileNode}
{desktopNode}
{props.afterTable}
{hasError ? (
<ErrorState
error={props.error}
title={props.errorTitle}
description={props.errorDescription}
onRetry={props.onRetry}
className='min-h-0 flex-1'
/>
) : (
<>
{mobileNode}
{desktopNode}
{props.afterTable}
</>
)}
</div>

{/* 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}
</>
Expand Down
17 changes: 11 additions & 6 deletions web/src/components/error-state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
<FadeIn>
Expand All @@ -52,12 +61,8 @@ export function ErrorState(props: ErrorStateProps) {
<EmptyMedia variant='icon'>
<Icon className='text-destructive size-6' />
</EmptyMedia>
<EmptyTitle>
{props.title ?? t('Oops! Something went wrong')}
</EmptyTitle>
{props.description != null && (
<EmptyDescription>{props.description}</EmptyDescription>
)}
<EmptyTitle>{title}</EmptyTitle>
<EmptyDescription>{description}</EmptyDescription>
</EmptyHeader>
<EmptyContent>
{props.onRetry != null && (
Expand Down
5 changes: 5 additions & 0 deletions web/src/features/about/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.

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<AboutResponse>('/api/about')
if (!res.data.success) {
throw new Error(res.data.message || t('Request failed') || 'Request failed')
}
return res.data
}
15 changes: 14 additions & 1 deletion web/src/features/about/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
})
Expand All @@ -137,6 +138,18 @@ export function About() {
)
}

if (isError && data === undefined) {
return (
<PublicLayout>
<ErrorState
error={error}
description={t('Please try again later.')}
onRetry={() => void refetch()}
/>
</PublicLayout>
)
}

if (!hasContent) {
return (
<PublicLayout>
Expand Down
91 changes: 48 additions & 43 deletions web/src/features/channels/components/channels-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
})
Expand Down Expand Up @@ -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.'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -853,6 +862,7 @@ export function ChannelMutateDrawer({
const isBatchMode =
multiKeyMode === 'batch' || multiKeyMode === 'multi_to_single'
const isChannelDetailLoading = isEditing && isChannelLoading
const isChannelDetailError = isEditing && channelQuery.isError

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

According to official TanStack Query v5 documentation, when a background refetch fails after cached query data exists, are dataretained andisError set to true?

💡 Result:

According to the official TanStack Query v5 documentation, when a background refetch fails and cached data already exists, the library retains the previous successful data while also updating the state to reflect the error [1]. Specifically, in this scenario: - Data retention: The previously fetched data remains available to the application [1]. - Error state: The query will enter an error state, meaning isError will be set to true and the error property will be populated with the failure information [2][3][1]. - Refetch-specific state: To help distinguish this from a "hard" error (where no data was ever successfully fetched), the library provides the isRefetchError boolean, which will be true when a background refetch fails [2][4]. This behavior is designed to allow developers to continue displaying the existing data to the user while simultaneously acknowledging the background update failure [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for f in \
  "web/src/features/channels/components/drawers/channel-mutate-drawer.tsx" \
  "web/src/features/models/components/drawers/model-mutate-drawer.tsx" \
  "web/src/features/users/components/users-mutate-drawer.tsx"
do
  echo "===== $f ====="
  wc -l "$f"
  ast-grep outline "$f" --view concise | sed -n '1,220p'
  echo
done

Repository: QuantumNous/new-api

Length of output: 467


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "===== relevant channel mutate drawer excerpts ====="
sed -n '830,885p' web/src/features/channels/components/drawers/channel-mutate-drawer.tsx
echo
sed -n '1,120p' web/src/features/channels/components/drawers/channel-mutate-drawer.tsx

echo "===== relevant model mutate drawer excerpts ====="
sed -n '260,310p' web/src/features/models/components/drawers/model-mutate-drawer.tsx
echo
sed -n '1,110p' web/src/features/models/components/drawers/model-mutate-drawer.tsx

echo "===== relevant users mutate drawer excerpts ====="
sed -n '155,205p' web/src/features/users/components/users-mutate-drawer.tsx
echo
sed -n '1,120p' web/src/features/users/components/users-mutate-drawer.tsx

Repository: QuantumNous/new-api

Length of output: 15941


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for f in \
  "web/src/features/channels/components/drawers/channel-mutate-drawer.tsx" \
  "web/src/features/models/components/drawers/model-mutate-drawer.tsx" \
  "web/src/features/users/components/users-mutate-drawer.tsx"
do
  echo "===== usages in $f ====="
  python3 - <<'PY' "$f"
import re, sys
p = sys.argv[1]
text = open(p, encoding='utf-8').read()
for name in ['isChannelDetailError','isModelDetailError','drawerError','drawerLoading']:
    if name not in text:
        continue
    print(f'--- {name} ---')
    for m in re.finditer(re.escape(f'{{{name} |') | re.escape(f'{{ {name}') | re.escape(f',{name}') | re.escape(f'{name}:'), text):
        i = text.rfind('\n', 0, m.start())
        print(f'{i+1-1}:', text[i+1:i+1000].split('\n', 1)[0])
        print(f'{i+1+1}:', text[i+1:i+1000].split('\n', 1, maxsplit=1)[1].split('\n', 1)[0])
        break
PY
  echo
done

Repository: QuantumNous/new-api

Length of output: 414


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "===== channel drawer query refs and key definitions ====="
rg -n "channelQuery|isEditing|isChannelDetailError|isChannelDetailLoading|channel.*Query|queryKeys" web/src/features/channels/components/drawers/channel-mutate-drawer.tsx
echo

echo "===== model drawer query refs ====="
rg -n "modelQuery|isEditing|isModelDetailError|isModelDetailLoading|model.*Query|queryKeys" web/src/features/models/components/drawers/model-mutate-drawer.tsx
echo

echo "===== users drawer query refs ====="
rg -n "userDetailQuery|groupsQuery|permissionCatalogQuery|drawerError|drawerLoading|isUpdate" web/src/features/users/components/users-mutate-drawer.tsx
echo

echo "===== error/loading state components ====="
rg -n "ErrorState|LoadingState" web/src/features/channels/components/drawers/channel-mutate-drawer.tsx web/src/features/models/components/drawers/model-mutate-drawer.tsx web/src/features/users/components/users-mutate-drawer.tsx

Repository: QuantumNous/new-api

Length of output: 8455


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "===== channel drawer surrounding fetch/conditionals ====="
sed -n '630,690p' web/src/features/channels/components/drawers/channel-mutate-drawer.tsx
echo
sed -n '1230,1285p' web/src/features/channels/components/drawers/channel-mutate-drawer.tsx
echo

echo "===== model drawer surrounding fetch/conditionals ====="
sed -n '225,290p' web/src/features/models/components/drawers/model-mutate-drawer.tsx
echo
sed -n '390,460p' web/src/features/models/components/drawers/model-mutate-drawer.tsx
echo
sed -n '738,765p' web/src/features/models/components/drawers/model-mutate-drawer.tsx

echo "===== users drawer surrounding fetch/conditionals ====="
sed -n '105,195p' web/src/features/users/components/users-mutate-drawer.tsx
echo
sed -n '265,300p' web/src/features/users/components/users-mutate-drawer.tsx
echo
sed -n '628,645p' web/src/features/users/components/users-mutate-drawer.tsx

echo "===== error state behavior from source ====="
sed -n '1,140p' web/src/components/error-state.tsx
echo
sed -n '1,80p' web/src/components/loading-state.tsx

Repository: QuantumNous/new-api

Length of output: 19675


Keep cached detail data editable after a refetch error.

TanStack Query can keep previous data on a failed background refetch while isError becomes true; these flags hide usable forms and disable saving. Only treat a query error as blocking when the required data is missing.

  • web/src/features/channels/components/drawers/channel-mutate-drawer.tsx#L865: require missing channelQuery.data?.data before setting isChannelDetailError.
  • web/src featuring/models/components/drawers/model-mutate-drawer.tsx#L288/289: require missing modelQuery.data?.data before setting isModelDetailError.
  • web/src/features/users/components/users-mutate-drawer.tsx#L179/183/186: only set drawerLoading / drawerError when groups, permission catalog, or user details fail and have no usable cached data.
📍 Affects 3 files
  • web/src/features/channels/components/drawers/channel-mutate-drawer.tsx#L865-L865 (this comment)
  • web/src/features/models/components/drawers/model-mutate-drawer.tsx#L288-L289
  • web/src/features/users/components/users-mutate-drawer.tsx#L179-L188
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/features/channels/components/drawers/channel-mutate-drawer.tsx` at
line 865, Update channel-mutate-drawer.tsx:865 so isChannelDetailError is true
only when channelQuery.isError and channelQuery.data?.data is missing. Apply the
same cached-data guard to isModelDetailError in model-mutate-drawer.tsx:288-289.
In users-mutate-drawer.tsx:179-188, set drawerLoading and drawerError only when
the groups, permission catalog, or user-details query fails without usable
cached data; preserve editing and saving when cached data remains available.

const supportsMultiKeyAddMode =
currentType !== 57 && !(currentType === 41 && vertexKeyType === 'api_key')
const addModeOptions = useMemo(
Expand Down Expand Up @@ -1944,9 +1954,15 @@ export function ChannelMutateDrawer({
onSubmit={form.handleSubmit(onSubmit, onInvalid)}
className={sideDrawerFormClassName('gap-5')}
>
{isChannelDetailLoading ? (
<ChannelEditorLoadingState />
) : (
{isChannelDetailLoading && <ChannelEditorLoadingState />}
{isChannelDetailError && (
<ErrorState
error={channelQuery.error}
onRetry={() => void channelQuery.refetch()}
className='min-h-[320px]'
/>
)}
{!isChannelDetailLoading && !isChannelDetailError && (
<div className='grid gap-5 lg:grid-cols-[13rem_minmax(0,1fr)] lg:items-start'>
<ChannelEditorNav
providerLogo={
Expand Down Expand Up @@ -4233,9 +4249,7 @@ export function ChannelMutateDrawer({
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent
alignItemWithTrigger={false}
>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='auto'>
{t('Auto')}
Expand Down Expand Up @@ -4771,7 +4785,13 @@ export function ChannelMutateDrawer({
>
{t('Cancel')}
</SheetClose>
<Button form='channel-form' type='submit' disabled={isSubmitting}>
<Button
form='channel-form'
type='submit'
disabled={
isSubmitting || isChannelDetailLoading || isChannelDetailError
}
>
{isSubmitting && (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
)}
Expand Down
Loading
Loading