fix(channels): refresh channel test dialog status (#5517)
Merge pull request #5517 from yyhhyyyyyy/fix/channel-test-dialog-status-refresh
This commit is contained in:
@@ -17,7 +17,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
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,6 +32,7 @@ type DataTableRowProps<TData> = {
|
||||
row: Row<TData>
|
||||
className?: string
|
||||
getColumnClassName?: DataTableColumnClassName
|
||||
cellRenderColumns?: TanstackTable<TData>['options']['columns']
|
||||
} & Omit<React.ComponentProps<typeof TableRow>, 'children'>
|
||||
|
||||
type DataTableRowInnerProps<TData> = DataTableRowProps<TData> & {
|
||||
@@ -38,8 +44,13 @@ function DataTableRowInner<TData>({
|
||||
isSelected,
|
||||
className,
|
||||
getColumnClassName,
|
||||
cellRenderColumns,
|
||||
...rowProps
|
||||
}: DataTableRowInnerProps<TData>) {
|
||||
// Destructured only to keep it out of `rowProps` (it is not a valid DOM attr)
|
||||
// and to feed the memo comparator below; it is intentionally unused here.
|
||||
void cellRenderColumns
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
data-state={isSelected ? 'selected' : undefined}
|
||||
@@ -62,13 +73,20 @@ function DataTableRowInner<TData>({
|
||||
}
|
||||
|
||||
const MemoizedDataTableRow = React.memo(DataTableRowInner, (prev, next) => {
|
||||
// Do not read row.getIsSelected() here: TanStack row objects may keep a stable
|
||||
// reference while their selection state changes.
|
||||
// Do not read row.getIsSelected() inside the comparator: TanStack row objects
|
||||
// keep a stable reference while their selection state mutates, so reading it
|
||||
// here compares identical live values and misses selection changes. Selection
|
||||
// is lifted to the `isSelected` prop, captured per render in DataTableRow.
|
||||
//
|
||||
// Column cell renderers (and getColumnClassName) can close over external
|
||||
// state while the row stays stable, so column definitions and the class
|
||||
// resolver are part of the render identity and must be compared too.
|
||||
return (
|
||||
prev.row === next.row &&
|
||||
prev.className === next.className &&
|
||||
prev.isSelected === next.isSelected &&
|
||||
prev.getColumnClassName === next.getColumnClassName &&
|
||||
prev.isSelected === next.isSelected
|
||||
prev.cellRenderColumns === next.cellRenderColumns
|
||||
)
|
||||
}) as typeof DataTableRowInner
|
||||
|
||||
|
||||
@@ -136,8 +136,8 @@ function SplitHeaderTableView<TData>({
|
||||
<div
|
||||
className={cn(
|
||||
'min-h-0 flex-1 overflow-auto',
|
||||
'[&_[data-slot=table-header]]:[--table-header-bg:color-mix(in_oklch,var(--muted)_30%,var(--background))]',
|
||||
'[&_[data-slot=table-header]]:[background-color:var(--table-header-bg)]',
|
||||
'**:data-[slot=table-header]:[--table-header-bg:color-mix(in_oklch,var(--muted)_30%,var(--background))]',
|
||||
'**:data-[slot=table-header]:bg-(--table-header-bg)',
|
||||
props.splitHeaderScrollClassName,
|
||||
props.bodyContainerClassName
|
||||
)}
|
||||
@@ -320,6 +320,7 @@ function renderDefaultRow<TData>(
|
||||
row={row}
|
||||
className={cn(props.tableBodyRowClassName, props.getRowClassName?.(row))}
|
||||
getColumnClassName={getColumnClassName}
|
||||
cellRenderColumns={props.table.options.columns}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+200
-38
@@ -16,7 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
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,
|
||||
type RowSelectionState,
|
||||
@@ -67,7 +68,16 @@ 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 {
|
||||
Channel,
|
||||
GetChannelsResponse,
|
||||
SearchChannelsResponse,
|
||||
} from '../../types'
|
||||
import { useChannels } from '../channels-provider'
|
||||
|
||||
type ChannelTestDialogProps = {
|
||||
@@ -75,6 +85,10 @@ type ChannelTestDialogProps = {
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
type ChannelTestDialogContentProps = ChannelTestDialogProps & {
|
||||
currentRow: Channel
|
||||
}
|
||||
|
||||
type ModelRow = {
|
||||
model: string
|
||||
}
|
||||
@@ -84,10 +98,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<LatestChannelTestCachePatch | undefined>(
|
||||
(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)' },
|
||||
@@ -202,8 +265,30 @@ export function ChannelTestDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ChannelTestDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { currentRow } = useChannels()
|
||||
|
||||
if (!currentRow) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ChannelTestDialogContent
|
||||
key={currentRow.id}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
currentRow={currentRow}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ChannelTestDialogContent({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentRow,
|
||||
}: ChannelTestDialogContentProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const currentChannelId = currentRow.id
|
||||
const [endpointType, setEndpointType] = useState('auto')
|
||||
const [isStreamTest, setIsStreamTest] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
@@ -240,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<HTMLInputElement>) => {
|
||||
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 []
|
||||
@@ -272,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<ModelRow[]>(
|
||||
() => filteredModels.map((model) => ({ model })),
|
||||
[filteredModels]
|
||||
@@ -301,8 +389,60 @@ export function ChannelTestDialog({
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const updateChannelTestCache = useCallback(
|
||||
(patch?: ChannelTestCachePatch) => {
|
||||
if (!patch) return
|
||||
|
||||
queryClient.setQueriesData<ChannelListCache>(
|
||||
{ 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<TestResult | undefined> => {
|
||||
async (
|
||||
model: string,
|
||||
silent = false,
|
||||
refreshList = true
|
||||
): Promise<TestResult | undefined> => {
|
||||
if (!currentRow) return
|
||||
|
||||
markModelTesting(model, true)
|
||||
@@ -315,13 +455,15 @@ export function ChannelTestDialog({
|
||||
{
|
||||
testModel: model,
|
||||
endpointType: endpointType === 'auto' ? undefined : endpointType,
|
||||
stream: isStreamTest || undefined,
|
||||
stream: effectiveStreamTest || undefined,
|
||||
silent,
|
||||
},
|
||||
(success, responseTime, error, errorCode) => {
|
||||
const completedAt = Date.now()
|
||||
finalResult = {
|
||||
status: success ? 'success' : 'error',
|
||||
responseTime,
|
||||
completedAt,
|
||||
error,
|
||||
errorCode,
|
||||
}
|
||||
@@ -331,19 +473,29 @@ 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
|
||||
},
|
||||
[
|
||||
currentRow,
|
||||
endpointType,
|
||||
isStreamTest,
|
||||
effectiveStreamTest,
|
||||
markModelTesting,
|
||||
refreshChannelLists,
|
||||
t,
|
||||
updateTestResult,
|
||||
]
|
||||
@@ -354,15 +506,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,15 +543,25 @@ export function ChannelTestDialog({
|
||||
} finally {
|
||||
setIsBatchTesting(false)
|
||||
setRowSelection({})
|
||||
refreshChannelLists(resultPatch)
|
||||
}
|
||||
},
|
||||
[t, testSingleModel]
|
||||
[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
|
||||
|
||||
@@ -482,7 +648,7 @@ export function ChannelTestDialog({
|
||||
disabled={isTestingModel || isBatchTesting}
|
||||
>
|
||||
{isTestingModel && (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
<Loader2 className='animate-spin' data-icon='inline-start' />
|
||||
)}
|
||||
{t('Test')}
|
||||
</Button>
|
||||
@@ -515,15 +681,11 @@ export function ChannelTestDialog({
|
||||
withFacetedRowModel: false,
|
||||
})
|
||||
|
||||
if (!currentRow) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={handleClose}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
title={t('Test Channel Connection')}
|
||||
description={
|
||||
<>
|
||||
@@ -549,7 +711,7 @@ export function ChannelTestDialog({
|
||||
<Select
|
||||
items={endpointSelectItems}
|
||||
value={endpointType}
|
||||
onValueChange={(v) => v !== null && setEndpointType(v)}
|
||||
onValueChange={handleEndpointTypeChange}
|
||||
>
|
||||
<SelectTrigger id='endpoint-type'>
|
||||
<SelectValue placeholder={t('Auto detect (default)')} />
|
||||
@@ -575,12 +737,12 @@ export function ChannelTestDialog({
|
||||
<div className='flex items-center gap-2'>
|
||||
<Switch
|
||||
id='stream-toggle'
|
||||
checked={isStreamTest}
|
||||
checked={effectiveStreamTest}
|
||||
onCheckedChange={setIsStreamTest}
|
||||
disabled={streamDisabled}
|
||||
/>
|
||||
<span className='text-sm'>
|
||||
{isStreamTest ? t('Enabled') : t('Disabled')}
|
||||
{effectiveStreamTest ? t('Enabled') : t('Disabled')}
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
@@ -600,7 +762,7 @@ export function ChannelTestDialog({
|
||||
<Input
|
||||
placeholder={t('Filter models...')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onChange={handleSearchTermChange}
|
||||
className='sm:w-64'
|
||||
/>
|
||||
</div>
|
||||
@@ -685,7 +847,7 @@ function TestStatusCell({
|
||||
if (result.status === 'testing') {
|
||||
return (
|
||||
<div className='text-muted-foreground flex min-w-0 items-center gap-2 text-sm'>
|
||||
<Loader2 className='h-4 w-4 shrink-0 animate-spin' />
|
||||
<Loader2 className='size-4 shrink-0 animate-spin' />
|
||||
<span className='truncate'>{t('Testing...')}</span>
|
||||
</div>
|
||||
)
|
||||
@@ -878,7 +1040,7 @@ function TestModelsBulkActions({
|
||||
>
|
||||
{disabled ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
<Loader2 className='animate-spin' data-icon='inline-start' />
|
||||
{t('Testing...')}
|
||||
</>
|
||||
) : (
|
||||
|
||||
+28
-3
@@ -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 } } }
|
||||
|
||||
+1
@@ -143,6 +143,7 @@ export interface ChannelTestResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
error_code?: string
|
||||
time?: number
|
||||
data?: {
|
||||
response_time?: number
|
||||
error?: string
|
||||
|
||||
Reference in New Issue
Block a user