From be60e25a94dad0d24188ed28c13c93db9ee7cb7b Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Mon, 15 Jun 2026 16:42:41 +0800 Subject: [PATCH] fix(channels): refresh channel test dialog status --- .../data-table/core/data-table-row.tsx | 16 +- .../data-table/core/data-table-view.tsx | 1 + .../dialogs/channel-test-dialog.tsx | 140 +++++++++++++++++- .../features/channels/lib/channel-actions.ts | 31 +++- web/default/src/features/channels/types.ts | 1 + 5 files changed, 177 insertions(+), 12 deletions(-) diff --git a/web/default/src/components/data-table/core/data-table-row.tsx b/web/default/src/components/data-table/core/data-table-row.tsx index 9973e419..456ee87b 100644 --- a/web/default/src/components/data-table/core/data-table-row.tsx +++ b/web/default/src/components/data-table/core/data-table-row.tsx @@ -17,7 +17,12 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import * as React from 'react' -import { flexRender, type Cell, type Row } from '@tanstack/react-table' +import { + flexRender, + type Cell, + type Row, + type Table as TanstackTable, +} from '@tanstack/react-table' import { cn } from '@/lib/utils' import { TableCell, TableRow } from '@/components/ui/table' import { TruncatedCell } from './truncated-cell' @@ -27,14 +32,18 @@ type DataTableRowProps = { row: Row className?: string getColumnClassName?: DataTableColumnClassName + cellRenderColumns?: TanstackTable['options']['columns'] } & Omit, 'children'> function DataTableRowInner({ row, className, getColumnClassName, + cellRenderColumns, ...rowProps }: DataTableRowProps) { + void cellRenderColumns + return ( { // Skip re-render when only the getColumnClassName reference changed but the // row identity and selection state are the same — callers rarely stabilize // this callback, so excluding it from comparison avoids unnecessary renders. + // Column cell renderers can close over external state while the row stays + // stable, so column definitions are part of the render identity. return ( prev.row === next.row && prev.className === next.className && - prev.row.getIsSelected() === next.row.getIsSelected() + prev.row.getIsSelected() === next.row.getIsSelected() && + prev.cellRenderColumns === next.cellRenderColumns ) }) as typeof DataTableRowInner diff --git a/web/default/src/components/data-table/core/data-table-view.tsx b/web/default/src/components/data-table/core/data-table-view.tsx index 9bc1d1e4..d483e9fb 100644 --- a/web/default/src/components/data-table/core/data-table-view.tsx +++ b/web/default/src/components/data-table/core/data-table-view.tsx @@ -320,6 +320,7 @@ function renderDefaultRow( row={row} className={cn(props.tableBodyRowClassName, props.getRowClassName?.(row))} getColumnClassName={getColumnClassName} + cellRenderColumns={props.table.options.columns} /> ) } diff --git a/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx b/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx index 5d65f346..a63817bf 100644 --- a/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx @@ -17,6 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { useCallback, useEffect, useMemo, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' import { type ColumnDef, type RowSelectionState, @@ -67,7 +68,12 @@ import { sideDrawerHeaderClassName, } from '@/components/drawer-layout' import { StatusBadge } from '@/components/status-badge' -import { formatResponseTime, handleTestChannel } from '../../lib' +import { + channelsQueryKeys, + formatResponseTime, + handleTestChannel, +} from '../../lib' +import type { GetChannelsResponse, SearchChannelsResponse } from '../../types' import { useChannels } from '../channels-provider' type ChannelTestDialogProps = { @@ -84,10 +90,59 @@ type TestStatus = 'idle' | 'testing' | 'success' | 'error' type TestResult = { status: TestStatus responseTime?: number + completedAt?: number error?: string errorCode?: string } +type ChannelTestCachePatch = { + responseTime: number + testTime: number +} + +type LatestChannelTestCachePatch = { + patch: ChannelTestCachePatch + completedAt: number +} + +type ChannelListCache = GetChannelsResponse | SearchChannelsResponse + +function createChannelTestCachePatch( + responseTime?: number, + completedAt = Date.now() +): ChannelTestCachePatch | undefined { + if (typeof responseTime !== 'number' || !Number.isFinite(responseTime)) { + return undefined + } + + return { + responseTime, + testTime: Math.floor(completedAt / 1000), + } +} + +function getLatestChannelTestCachePatch( + results: TestResult[] +): ChannelTestCachePatch | undefined { + const latest = results.reduce( + (latestPatch, result) => { + const completedAt = result.completedAt ?? 0 + const patch = createChannelTestCachePatch( + result.responseTime, + completedAt + ) + if (!patch) return latestPatch + if (!latestPatch || completedAt >= latestPatch.completedAt) { + return { patch, completedAt } + } + return latestPatch + }, + undefined + ) + + return latest?.patch +} + const endpointTypeOptions: Array<{ value: string; label: string }> = [ { value: 'auto', label: 'Auto detect (default)' }, { value: 'openai', label: 'OpenAI (/v1/chat/completions)' }, @@ -204,6 +259,8 @@ export function ChannelTestDialog({ }: ChannelTestDialogProps) { const { t } = useTranslation() const { currentRow } = useChannels() + const queryClient = useQueryClient() + const currentChannelId = currentRow?.id const [endpointType, setEndpointType] = useState('auto') const [isStreamTest, setIsStreamTest] = useState(false) const [searchTerm, setSearchTerm] = useState('') @@ -301,8 +358,60 @@ export function ChannelTestDialog({ })) }, []) + const updateChannelTestCache = useCallback( + (patch?: ChannelTestCachePatch) => { + if (!patch || currentChannelId === undefined) return + + queryClient.setQueriesData( + { queryKey: channelsQueryKeys.lists() }, + (oldData) => { + const data = oldData?.data + if (!oldData || !data?.items.length) return oldData + + let changed = false + const nextItems = data.items.map((channel) => { + if (channel.id !== currentChannelId) return channel + + changed = true + return { + ...channel, + response_time: patch.responseTime, + test_time: patch.testTime, + } + }) + + if (!changed) return oldData + + return { + ...oldData, + data: { + ...data, + items: nextItems, + }, + } + } + ) + }, + [currentChannelId, queryClient] + ) + + const refreshChannelLists = useCallback( + (patch?: ChannelTestCachePatch) => { + updateChannelTestCache(patch) + void queryClient + .invalidateQueries({ queryKey: channelsQueryKeys.lists() }) + .then(() => updateChannelTestCache(patch)) + .catch(() => undefined) + }, + [queryClient, updateChannelTestCache] + ) + const testSingleModel = useCallback( - async (model: string, silent = false): Promise => { + async ( + model: string, + silent = false, + refreshList = true + ): Promise => { if (!currentRow) return markModelTesting(model, true) @@ -319,9 +428,11 @@ export function ChannelTestDialog({ silent, }, (success, responseTime, error, errorCode) => { + const completedAt = Date.now() finalResult = { status: success ? 'success' : 'error', responseTime, + completedAt, error, errorCode, } @@ -331,11 +442,20 @@ export function ChannelTestDialog({ } catch (error: unknown) { finalResult = { status: 'error', + completedAt: Date.now(), error: error instanceof Error ? error.message : t('Test failed'), } updateTestResult(model, finalResult) } finally { markModelTesting(model, false) + if (refreshList) { + refreshChannelLists( + createChannelTestCachePatch( + finalResult?.responseTime, + finalResult?.completedAt + ) + ) + } } return finalResult }, @@ -344,6 +464,7 @@ export function ChannelTestDialog({ endpointType, isStreamTest, markModelTesting, + refreshChannelLists, t, updateTestResult, ] @@ -354,15 +475,19 @@ export function ChannelTestDialog({ if (!modelsToTest.length) return setIsBatchTesting(true) + let resultPatch: ChannelTestCachePatch | undefined try { const settled = await Promise.allSettled( - modelsToTest.map((modelName) => testSingleModel(modelName, true)) + modelsToTest.map((modelName) => + testSingleModel(modelName, true, false) + ) ) const results = settled .map((result) => result.status === 'fulfilled' ? result.value : undefined ) .filter((result): result is TestResult => Boolean(result)) + resultPatch = getLatestChannelTestCachePatch(results) const successCount = results.filter( (result) => result.status === 'success' ).length @@ -387,9 +512,10 @@ export function ChannelTestDialog({ } finally { setIsBatchTesting(false) setRowSelection({}) + refreshChannelLists(resultPatch) } }, - [t, testSingleModel] + [refreshChannelLists, t, testSingleModel] ) const handleClose = () => { @@ -482,7 +608,7 @@ export function ChannelTestDialog({ disabled={isTestingModel || isBatchTesting} > {isTestingModel && ( - + )} {t('Test')} @@ -685,7 +811,7 @@ function TestStatusCell({ if (result.status === 'testing') { return (
- + {t('Testing...')}
) @@ -878,7 +1004,7 @@ function TestModelsBulkActions({ > {disabled ? ( <> - + {t('Testing...')} ) : ( diff --git a/web/default/src/features/channels/lib/channel-actions.ts b/web/default/src/features/channels/lib/channel-actions.ts index a4d0e09a..bd8dfd71 100644 --- a/web/default/src/features/channels/lib/channel-actions.ts +++ b/web/default/src/features/channels/lib/channel-actions.ts @@ -37,7 +37,7 @@ import { updateChannelBalance, } from '../api' import { CHANNEL_STATUS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants' -import type { CopyChannelParams } from '../types' +import type { ChannelTestResponse, CopyChannelParams } from '../types' // ============================================================================ // Query Keys @@ -52,6 +52,25 @@ export const channelsQueryKeys = { detail: (id: number) => [...channelsQueryKeys.details(), id] as const, } +function getChannelTestResponseTime( + response: ChannelTestResponse +): number | undefined { + const responseTime = response.data?.response_time + if (typeof responseTime === 'number' && Number.isFinite(responseTime)) { + return responseTime + } + + if ( + typeof response.time === 'number' && + Number.isFinite(response.time) && + response.time > 0 + ) { + return Math.round(response.time * 1000) + } + + return undefined +} + // ============================================================================ // Single Channel Actions // ============================================================================ @@ -237,16 +256,22 @@ export async function handleTestChannel( try { const response = await testChannel(id, payload) + const responseTime = getChannelTestResponseTime(response) if (response.success) { if (!options?.silent) { toast.success(i18next.t(SUCCESS_MESSAGES.TESTED)) } - onTestComplete?.(true, response.data?.response_time) + onTestComplete?.(true, responseTime) } else { if (!options?.silent) { toast.error(response.message || i18next.t(ERROR_MESSAGES.TEST_FAILED)) } - onTestComplete?.(false, undefined, response.message, response.error_code) + onTestComplete?.( + false, + responseTime, + response.message, + response.error_code + ) } } catch (_error: unknown) { const err = _error as { response?: { data?: { message?: string } } } diff --git a/web/default/src/features/channels/types.ts b/web/default/src/features/channels/types.ts index a282053a..d115ece4 100644 --- a/web/default/src/features/channels/types.ts +++ b/web/default/src/features/channels/types.ts @@ -143,6 +143,7 @@ export interface ChannelTestResponse { success: boolean message?: string error_code?: string + time?: number data?: { response_time?: number error?: string