From be60e25a94dad0d24188ed28c13c93db9ee7cb7b Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Mon, 15 Jun 2026 16:42:41 +0800 Subject: [PATCH 1/4] 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 From c67c6fc740a6ce58f625167fe36d9e2404b39201 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Mon, 15 Jun 2026 16:56:36 +0800 Subject: [PATCH 2/4] fix(channels): remove effect-driven test dialog state resets --- .../dialogs/channel-test-dialog.tsx | 104 ++++++++++++------ 1 file changed, 70 insertions(+), 34 deletions(-) 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 a63817bf..e42eb8c6 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 @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { type ChangeEvent, useCallback, useMemo, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' import { type ColumnDef, @@ -73,7 +73,11 @@ import { formatResponseTime, handleTestChannel, } from '../../lib' -import type { GetChannelsResponse, SearchChannelsResponse } from '../../types' +import type { + Channel, + GetChannelsResponse, + SearchChannelsResponse, +} from '../../types' import { useChannels } from '../channels-provider' type ChannelTestDialogProps = { @@ -81,6 +85,10 @@ type ChannelTestDialogProps = { onOpenChange: (open: boolean) => void } +type ChannelTestDialogContentProps = ChannelTestDialogProps & { + currentRow: Channel +} + type ModelRow = { model: string } @@ -257,10 +265,30 @@ export function ChannelTestDialog({ open, onOpenChange, }: ChannelTestDialogProps) { - const { t } = useTranslation() const { currentRow } = useChannels() + + if (!currentRow) { + return null + } + + return ( + + ) +} + +function ChannelTestDialogContent({ + open, + onOpenChange, + currentRow, +}: ChannelTestDialogContentProps) { + const { t } = useTranslation() const queryClient = useQueryClient() - const currentChannelId = currentRow?.id + const currentChannelId = currentRow.id const [endpointType, setEndpointType] = useState('auto') const [isStreamTest, setIsStreamTest] = useState(false) const [searchTerm, setSearchTerm] = useState('') @@ -297,23 +325,30 @@ export function ChannelTestDialog({ setPagination({ pageIndex: 0, pageSize: 10 }) }, []) - useEffect(() => { - if (open && currentRow) { - resetState() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, currentRow?.id, resetState]) - const streamDisabled = STREAM_INCOMPATIBLE_ENDPOINTS.has(endpointType) + const effectiveStreamTest = !streamDisabled && isStreamTest - useEffect(() => { - if (streamDisabled) { + const handleEndpointTypeChange = useCallback((value: string | null) => { + if (value === null) return + + setEndpointType(value) + if (STREAM_INCOMPATIBLE_ENDPOINTS.has(value)) { setIsStreamTest(false) } - }, [streamDisabled]) + }, []) - const modelsValue = currentRow?.models ?? '' - const defaultTestModel = currentRow?.test_model?.trim() + const handleSearchTermChange = useCallback( + (event: ChangeEvent) => { + setSearchTerm(event.target.value) + setPagination((prev) => + prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 } + ) + }, + [] + ) + + const modelsValue = currentRow.models + const defaultTestModel = currentRow.test_model?.trim() const models = useMemo(() => { if (!modelsValue) return [] @@ -329,10 +364,6 @@ export function ChannelTestDialog({ return models.filter((model) => model.toLowerCase().includes(keyword)) }, [models, searchTerm]) - useEffect(() => { - setPagination((prev) => ({ ...prev, pageIndex: 0 })) - }, [searchTerm, modelsValue]) - const tableData = useMemo( () => filteredModels.map((model) => ({ model })), [filteredModels] @@ -360,7 +391,7 @@ export function ChannelTestDialog({ const updateChannelTestCache = useCallback( (patch?: ChannelTestCachePatch) => { - if (!patch || currentChannelId === undefined) return + if (!patch) return queryClient.setQueriesData( { queryKey: channelsQueryKeys.lists() }, @@ -424,7 +455,7 @@ export function ChannelTestDialog({ { testModel: model, endpointType: endpointType === 'auto' ? undefined : endpointType, - stream: isStreamTest || undefined, + stream: effectiveStreamTest || undefined, silent, }, (success, responseTime, error, errorCode) => { @@ -462,7 +493,7 @@ export function ChannelTestDialog({ [ currentRow, endpointType, - isStreamTest, + effectiveStreamTest, markModelTesting, refreshChannelLists, t, @@ -518,10 +549,19 @@ export function ChannelTestDialog({ [refreshChannelLists, t, testSingleModel] ) - const handleClose = () => { + const handleClose = useCallback(() => { resetState() onOpenChange(false) - } + }, [onOpenChange, resetState]) + + const handleDialogOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) { + handleClose() + } + }, + [handleClose] + ) const isAnyTesting = testingModels.size > 0 || isBatchTesting @@ -641,15 +681,11 @@ export function ChannelTestDialog({ withFacetedRowModel: false, }) - if (!currentRow) { - return null - } - return ( <> @@ -675,7 +711,7 @@ export function ChannelTestDialog({ setSearchTerm(e.target.value)} + onChange={handleSearchTermChange} className='sm:w-64' /> From a59e0eb59afeef807095c318b8b9f66e6d5dad76 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Mon, 15 Jun 2026 17:00:16 +0800 Subject: [PATCH 3/4] style(data-table): modernize header selector utilities --- .../src/components/data-table/core/data-table-view.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 d483e9fb..3def71dc 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 @@ -136,8 +136,8 @@ function SplitHeaderTableView({
Date: Mon, 15 Jun 2026 23:53:12 +0800 Subject: [PATCH 4/4] fix: missing newline at end of data-table-row.tsx Add a newline at the end of the file. --- web/default/src/components/data-table/core/data-table-row.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 808398c0..6ae1cf98 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 @@ -119,4 +119,4 @@ function getPrimitiveTextContent(content: React.ReactNode): string | null { } return null -} \ No newline at end of file +}