Commit b36888ba by 同語 Committed by GitHub

fix(channels): refresh channel test dialog status (#5517)

Merge pull request #5517 from yyhhyyyyyy/fix/channel-test-dialog-status-refresh
parents b4176de8 426c9664
...@@ -17,7 +17,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,7 +17,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import * as React from 'react' 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 { cn } from '@/lib/utils'
import { TableCell, TableRow } from '@/components/ui/table' import { TableCell, TableRow } from '@/components/ui/table'
import { TruncatedCell } from './truncated-cell' import { TruncatedCell } from './truncated-cell'
...@@ -27,6 +32,7 @@ type DataTableRowProps<TData> = { ...@@ -27,6 +32,7 @@ type DataTableRowProps<TData> = {
row: Row<TData> row: Row<TData>
className?: string className?: string
getColumnClassName?: DataTableColumnClassName getColumnClassName?: DataTableColumnClassName
cellRenderColumns?: TanstackTable<TData>['options']['columns']
} & Omit<React.ComponentProps<typeof TableRow>, 'children'> } & Omit<React.ComponentProps<typeof TableRow>, 'children'>
type DataTableRowInnerProps<TData> = DataTableRowProps<TData> & { type DataTableRowInnerProps<TData> = DataTableRowProps<TData> & {
...@@ -38,8 +44,13 @@ function DataTableRowInner<TData>({ ...@@ -38,8 +44,13 @@ function DataTableRowInner<TData>({
isSelected, isSelected,
className, className,
getColumnClassName, getColumnClassName,
cellRenderColumns,
...rowProps ...rowProps
}: DataTableRowInnerProps<TData>) { }: 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 ( return (
<TableRow <TableRow
data-state={isSelected ? 'selected' : undefined} data-state={isSelected ? 'selected' : undefined}
...@@ -62,13 +73,20 @@ function DataTableRowInner<TData>({ ...@@ -62,13 +73,20 @@ function DataTableRowInner<TData>({
} }
const MemoizedDataTableRow = React.memo(DataTableRowInner, (prev, next) => { const MemoizedDataTableRow = React.memo(DataTableRowInner, (prev, next) => {
// Do not read row.getIsSelected() here: TanStack row objects may keep a stable // Do not read row.getIsSelected() inside the comparator: TanStack row objects
// reference while their selection state changes. // 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 ( return (
prev.row === next.row && prev.row === next.row &&
prev.className === next.className && prev.className === next.className &&
prev.isSelected === next.isSelected &&
prev.getColumnClassName === next.getColumnClassName && prev.getColumnClassName === next.getColumnClassName &&
prev.isSelected === next.isSelected prev.cellRenderColumns === next.cellRenderColumns
) )
}) as typeof DataTableRowInner }) as typeof DataTableRowInner
......
...@@ -136,8 +136,8 @@ function SplitHeaderTableView<TData>({ ...@@ -136,8 +136,8 @@ function SplitHeaderTableView<TData>({
<div <div
className={cn( className={cn(
'min-h-0 flex-1 overflow-auto', '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]:[--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]:bg-(--table-header-bg)',
props.splitHeaderScrollClassName, props.splitHeaderScrollClassName,
props.bodyContainerClassName props.bodyContainerClassName
)} )}
...@@ -320,6 +320,7 @@ function renderDefaultRow<TData>( ...@@ -320,6 +320,7 @@ function renderDefaultRow<TData>(
row={row} row={row}
className={cn(props.tableBodyRowClassName, props.getRowClassName?.(row))} className={cn(props.tableBodyRowClassName, props.getRowClassName?.(row))}
getColumnClassName={getColumnClassName} getColumnClassName={getColumnClassName}
cellRenderColumns={props.table.options.columns}
/> />
) )
} }
...@@ -16,7 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,7 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com 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 { import {
type ColumnDef, type ColumnDef,
type RowSelectionState, type RowSelectionState,
...@@ -67,7 +68,16 @@ import { ...@@ -67,7 +68,16 @@ import {
sideDrawerHeaderClassName, sideDrawerHeaderClassName,
} from '@/components/drawer-layout' } from '@/components/drawer-layout'
import { StatusBadge } from '@/components/status-badge' 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' import { useChannels } from '../channels-provider'
type ChannelTestDialogProps = { type ChannelTestDialogProps = {
...@@ -75,6 +85,10 @@ type ChannelTestDialogProps = { ...@@ -75,6 +85,10 @@ type ChannelTestDialogProps = {
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
} }
type ChannelTestDialogContentProps = ChannelTestDialogProps & {
currentRow: Channel
}
type ModelRow = { type ModelRow = {
model: string model: string
} }
...@@ -84,10 +98,59 @@ type TestStatus = 'idle' | 'testing' | 'success' | 'error' ...@@ -84,10 +98,59 @@ type TestStatus = 'idle' | 'testing' | 'success' | 'error'
type TestResult = { type TestResult = {
status: TestStatus status: TestStatus
responseTime?: number responseTime?: number
completedAt?: number
error?: string error?: string
errorCode?: 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 }> = [ const endpointTypeOptions: Array<{ value: string; label: string }> = [
{ value: 'auto', label: 'Auto detect (default)' }, { value: 'auto', label: 'Auto detect (default)' },
{ value: 'openai', label: 'OpenAI (/v1/chat/completions)' }, { value: 'openai', label: 'OpenAI (/v1/chat/completions)' },
...@@ -202,8 +265,30 @@ export function ChannelTestDialog({ ...@@ -202,8 +265,30 @@ export function ChannelTestDialog({
open, open,
onOpenChange, onOpenChange,
}: ChannelTestDialogProps) { }: ChannelTestDialogProps) {
const { t } = useTranslation()
const { currentRow } = useChannels() 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 [endpointType, setEndpointType] = useState('auto')
const [isStreamTest, setIsStreamTest] = useState(false) const [isStreamTest, setIsStreamTest] = useState(false)
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
...@@ -240,23 +325,30 @@ export function ChannelTestDialog({ ...@@ -240,23 +325,30 @@ export function ChannelTestDialog({
setPagination({ pageIndex: 0, pageSize: 10 }) 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 streamDisabled = STREAM_INCOMPATIBLE_ENDPOINTS.has(endpointType)
const effectiveStreamTest = !streamDisabled && isStreamTest
const handleEndpointTypeChange = useCallback((value: string | null) => {
if (value === null) return
useEffect(() => { setEndpointType(value)
if (streamDisabled) { if (STREAM_INCOMPATIBLE_ENDPOINTS.has(value)) {
setIsStreamTest(false) setIsStreamTest(false)
} }
}, [streamDisabled]) }, [])
const handleSearchTermChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
setSearchTerm(event.target.value)
setPagination((prev) =>
prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 }
)
},
[]
)
const modelsValue = currentRow?.models ?? '' const modelsValue = currentRow.models
const defaultTestModel = currentRow?.test_model?.trim() const defaultTestModel = currentRow.test_model?.trim()
const models = useMemo(() => { const models = useMemo(() => {
if (!modelsValue) return [] if (!modelsValue) return []
...@@ -272,10 +364,6 @@ export function ChannelTestDialog({ ...@@ -272,10 +364,6 @@ export function ChannelTestDialog({
return models.filter((model) => model.toLowerCase().includes(keyword)) return models.filter((model) => model.toLowerCase().includes(keyword))
}, [models, searchTerm]) }, [models, searchTerm])
useEffect(() => {
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
}, [searchTerm, modelsValue])
const tableData = useMemo<ModelRow[]>( const tableData = useMemo<ModelRow[]>(
() => filteredModels.map((model) => ({ model })), () => filteredModels.map((model) => ({ model })),
[filteredModels] [filteredModels]
...@@ -301,8 +389,60 @@ export function ChannelTestDialog({ ...@@ -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( const testSingleModel = useCallback(
async (model: string, silent = false): Promise<TestResult | undefined> => { async (
model: string,
silent = false,
refreshList = true
): Promise<TestResult | undefined> => {
if (!currentRow) return if (!currentRow) return
markModelTesting(model, true) markModelTesting(model, true)
...@@ -315,13 +455,15 @@ export function ChannelTestDialog({ ...@@ -315,13 +455,15 @@ export function ChannelTestDialog({
{ {
testModel: model, testModel: model,
endpointType: endpointType === 'auto' ? undefined : endpointType, endpointType: endpointType === 'auto' ? undefined : endpointType,
stream: isStreamTest || undefined, stream: effectiveStreamTest || undefined,
silent, silent,
}, },
(success, responseTime, error, errorCode) => { (success, responseTime, error, errorCode) => {
const completedAt = Date.now()
finalResult = { finalResult = {
status: success ? 'success' : 'error', status: success ? 'success' : 'error',
responseTime, responseTime,
completedAt,
error, error,
errorCode, errorCode,
} }
...@@ -331,19 +473,29 @@ export function ChannelTestDialog({ ...@@ -331,19 +473,29 @@ export function ChannelTestDialog({
} catch (error: unknown) { } catch (error: unknown) {
finalResult = { finalResult = {
status: 'error', status: 'error',
completedAt: Date.now(),
error: error instanceof Error ? error.message : t('Test failed'), error: error instanceof Error ? error.message : t('Test failed'),
} }
updateTestResult(model, finalResult) updateTestResult(model, finalResult)
} finally { } finally {
markModelTesting(model, false) markModelTesting(model, false)
if (refreshList) {
refreshChannelLists(
createChannelTestCachePatch(
finalResult?.responseTime,
finalResult?.completedAt
)
)
}
} }
return finalResult return finalResult
}, },
[ [
currentRow, currentRow,
endpointType, endpointType,
isStreamTest, effectiveStreamTest,
markModelTesting, markModelTesting,
refreshChannelLists,
t, t,
updateTestResult, updateTestResult,
] ]
...@@ -354,15 +506,19 @@ export function ChannelTestDialog({ ...@@ -354,15 +506,19 @@ export function ChannelTestDialog({
if (!modelsToTest.length) return if (!modelsToTest.length) return
setIsBatchTesting(true) setIsBatchTesting(true)
let resultPatch: ChannelTestCachePatch | undefined
try { try {
const settled = await Promise.allSettled( const settled = await Promise.allSettled(
modelsToTest.map((modelName) => testSingleModel(modelName, true)) modelsToTest.map((modelName) =>
testSingleModel(modelName, true, false)
)
) )
const results = settled const results = settled
.map((result) => .map((result) =>
result.status === 'fulfilled' ? result.value : undefined result.status === 'fulfilled' ? result.value : undefined
) )
.filter((result): result is TestResult => Boolean(result)) .filter((result): result is TestResult => Boolean(result))
resultPatch = getLatestChannelTestCachePatch(results)
const successCount = results.filter( const successCount = results.filter(
(result) => result.status === 'success' (result) => result.status === 'success'
).length ).length
...@@ -387,15 +543,25 @@ export function ChannelTestDialog({ ...@@ -387,15 +543,25 @@ export function ChannelTestDialog({
} finally { } finally {
setIsBatchTesting(false) setIsBatchTesting(false)
setRowSelection({}) setRowSelection({})
refreshChannelLists(resultPatch)
} }
}, },
[t, testSingleModel] [refreshChannelLists, t, testSingleModel]
) )
const handleClose = () => { const handleClose = useCallback(() => {
resetState() resetState()
onOpenChange(false) onOpenChange(false)
}, [onOpenChange, resetState])
const handleDialogOpenChange = useCallback(
(nextOpen: boolean) => {
if (!nextOpen) {
handleClose()
} }
},
[handleClose]
)
const isAnyTesting = testingModels.size > 0 || isBatchTesting const isAnyTesting = testingModels.size > 0 || isBatchTesting
...@@ -482,7 +648,7 @@ export function ChannelTestDialog({ ...@@ -482,7 +648,7 @@ export function ChannelTestDialog({
disabled={isTestingModel || isBatchTesting} disabled={isTestingModel || isBatchTesting}
> >
{isTestingModel && ( {isTestingModel && (
<Loader2 className='mr-2 h-4 w-4 animate-spin' /> <Loader2 className='animate-spin' data-icon='inline-start' />
)} )}
{t('Test')} {t('Test')}
</Button> </Button>
...@@ -515,15 +681,11 @@ export function ChannelTestDialog({ ...@@ -515,15 +681,11 @@ export function ChannelTestDialog({
withFacetedRowModel: false, withFacetedRowModel: false,
}) })
if (!currentRow) {
return null
}
return ( return (
<> <>
<Dialog <Dialog
open={open} open={open}
onOpenChange={handleClose} onOpenChange={handleDialogOpenChange}
title={t('Test Channel Connection')} title={t('Test Channel Connection')}
description={ description={
<> <>
...@@ -549,7 +711,7 @@ export function ChannelTestDialog({ ...@@ -549,7 +711,7 @@ export function ChannelTestDialog({
<Select <Select
items={endpointSelectItems} items={endpointSelectItems}
value={endpointType} value={endpointType}
onValueChange={(v) => v !== null && setEndpointType(v)} onValueChange={handleEndpointTypeChange}
> >
<SelectTrigger id='endpoint-type'> <SelectTrigger id='endpoint-type'>
<SelectValue placeholder={t('Auto detect (default)')} /> <SelectValue placeholder={t('Auto detect (default)')} />
...@@ -575,12 +737,12 @@ export function ChannelTestDialog({ ...@@ -575,12 +737,12 @@ export function ChannelTestDialog({
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Switch <Switch
id='stream-toggle' id='stream-toggle'
checked={isStreamTest} checked={effectiveStreamTest}
onCheckedChange={setIsStreamTest} onCheckedChange={setIsStreamTest}
disabled={streamDisabled} disabled={streamDisabled}
/> />
<span className='text-sm'> <span className='text-sm'>
{isStreamTest ? t('Enabled') : t('Disabled')} {effectiveStreamTest ? t('Enabled') : t('Disabled')}
</span> </span>
</div> </div>
<p className='text-muted-foreground text-xs'> <p className='text-muted-foreground text-xs'>
...@@ -600,7 +762,7 @@ export function ChannelTestDialog({ ...@@ -600,7 +762,7 @@ export function ChannelTestDialog({
<Input <Input
placeholder={t('Filter models...')} placeholder={t('Filter models...')}
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={handleSearchTermChange}
className='sm:w-64' className='sm:w-64'
/> />
</div> </div>
...@@ -685,7 +847,7 @@ function TestStatusCell({ ...@@ -685,7 +847,7 @@ function TestStatusCell({
if (result.status === 'testing') { if (result.status === 'testing') {
return ( return (
<div className='text-muted-foreground flex min-w-0 items-center gap-2 text-sm'> <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> <span className='truncate'>{t('Testing...')}</span>
</div> </div>
) )
...@@ -878,7 +1040,7 @@ function TestModelsBulkActions({ ...@@ -878,7 +1040,7 @@ function TestModelsBulkActions({
> >
{disabled ? ( {disabled ? (
<> <>
<Loader2 className='mr-2 h-4 w-4 animate-spin' /> <Loader2 className='animate-spin' data-icon='inline-start' />
{t('Testing...')} {t('Testing...')}
</> </>
) : ( ) : (
......
...@@ -37,7 +37,7 @@ import { ...@@ -37,7 +37,7 @@ import {
updateChannelBalance, updateChannelBalance,
} from '../api' } from '../api'
import { CHANNEL_STATUS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants' import { CHANNEL_STATUS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
import type { CopyChannelParams } from '../types' import type { ChannelTestResponse, CopyChannelParams } from '../types'
// ============================================================================ // ============================================================================
// Query Keys // Query Keys
...@@ -52,6 +52,25 @@ export const channelsQueryKeys = { ...@@ -52,6 +52,25 @@ export const channelsQueryKeys = {
detail: (id: number) => [...channelsQueryKeys.details(), id] as const, 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 // Single Channel Actions
// ============================================================================ // ============================================================================
...@@ -237,16 +256,22 @@ export async function handleTestChannel( ...@@ -237,16 +256,22 @@ export async function handleTestChannel(
try { try {
const response = await testChannel(id, payload) const response = await testChannel(id, payload)
const responseTime = getChannelTestResponseTime(response)
if (response.success) { if (response.success) {
if (!options?.silent) { if (!options?.silent) {
toast.success(i18next.t(SUCCESS_MESSAGES.TESTED)) toast.success(i18next.t(SUCCESS_MESSAGES.TESTED))
} }
onTestComplete?.(true, response.data?.response_time) onTestComplete?.(true, responseTime)
} else { } else {
if (!options?.silent) { if (!options?.silent) {
toast.error(response.message || i18next.t(ERROR_MESSAGES.TEST_FAILED)) 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) { } catch (_error: unknown) {
const err = _error as { response?: { data?: { message?: string } } } const err = _error as { response?: { data?: { message?: string } } }
......
...@@ -143,6 +143,7 @@ export interface ChannelTestResponse { ...@@ -143,6 +143,7 @@ export interface ChannelTestResponse {
success: boolean success: boolean
message?: string message?: string
error_code?: string error_code?: string
time?: number
data?: { data?: {
response_time?: number response_time?: number
error?: string error?: string
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment