Files
new-api-custom/web/src/features/channels/components/dialogs/channel-test-dialog.tsx
T

1395 lines
40 KiB
TypeScript

/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Combobox } from '@/components/ui/combobox'
import { useQueryClient } from '@tanstack/react-query'
import type {
ColumnDef,
RowSelectionState,
Table as TanStackTable,
} from '@tanstack/react-table'
import {
Check,
CheckCircle2,
Copy,
Gauge,
Info,
Loader2,
Settings,
Trash2,
} from 'lucide-react'
import {
type ChangeEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ConfirmDialog } from '@/components/confirm-dialog'
import {
DataTableBulkActions as BulkActionsToolbar,
DataTablePagination,
DataTableView,
useDataTable,
} from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import {
sideDrawerContentClassName,
sideDrawerFooterClassName,
sideDrawerFormClassName,
sideDrawerHeaderClassName,
} from '@/components/drawer-layout'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Switch } from '@/components/ui/switch'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { useIsMobile } from '@/hooks/use-mobile'
import { updateChannel } from '../../api'
import {
channelsQueryKeys,
formatResponseTime,
handleTestChannel,
} from '../../lib'
import type {
Channel,
GetChannelsResponse,
SearchChannelsResponse,
} from '../../types'
import { useChannels } from '../channels-provider'
type ChannelTestDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
type ChannelTestDialogContentProps = ChannelTestDialogProps & {
currentRow: Channel
}
type ModelRow = {
model: string
}
type TestStatus = 'idle' | 'testing' | 'success' | 'error'
type TestResult = {
status: TestStatus
responseTime?: number
completedAt?: number
error?: string
errorCode?: string
}
type BatchProgress = {
total: number
completed: number
success: number
failed: number
}
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)' },
{ value: 'openai-response', label: 'OpenAI Responses (/v1/responses)' },
{
value: 'openai-response-compact',
label: 'OpenAI Response Compaction (/v1/responses/compact)',
},
{ value: 'anthropic', label: 'Anthropic (/v1/messages)' },
{
value: 'gemini',
label: 'Gemini (/v1beta/models/{model}:generateContent)',
},
{ value: 'jina-rerank', label: 'Jina Rerank (/v1/rerank)' },
{
value: 'image-generation',
label: 'Image Generation (/v1/images/generations)',
},
{ value: 'embeddings', label: 'Embeddings (/v1/embeddings)' },
]
const STREAM_INCOMPATIBLE_ENDPOINTS = new Set([
'embeddings',
'image-generation',
'jina-rerank',
'openai-response-compact',
])
const MODEL_PRICE_ERROR_CODE = 'model_price_error'
const FAILURE_SUMMARY_MAX_LENGTH = 96
const BATCH_TEST_CONCURRENCY = 5
const BATCH_TEST_DELAY_MS = 100
type FailureStatusDisplay = {
summary: string
details?: string
}
type FailureDetailsState = {
model: string
summary: string
details: string
}
function sleep(ms: number) {
return new Promise<void>((resolve) => window.setTimeout(resolve, ms))
}
function normalizeInlineError(errorText: string) {
return errorText.replaceAll(/\s+/g, ' ').trim()
}
function getFirstErrorLine(errorText: string) {
return errorText
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean)
}
function truncateFailureSummary(summary: string) {
if (summary.length <= FAILURE_SUMMARY_MAX_LENGTH) {
return summary
}
return `${summary.slice(0, FAILURE_SUMMARY_MAX_LENGTH).trimEnd()}...`
}
function getFailureStatusDisplay({
errorText,
fallbackSummary,
isModelPriceError,
modelPriceSummary,
}: {
errorText?: string
fallbackSummary: string
isModelPriceError: boolean
modelPriceSummary: string
}): FailureStatusDisplay {
const rawError = errorText?.trim()
if (!rawError) {
return { summary: fallbackSummary }
}
if (isModelPriceError) {
return {
summary: modelPriceSummary,
details: rawError === modelPriceSummary ? undefined : rawError,
}
}
const firstLine = getFirstErrorLine(rawError) ?? rawError
const summary = truncateFailureSummary(normalizeInlineError(firstLine))
const normalizedRawError = normalizeInlineError(rawError)
return {
summary,
details: summary === normalizedRawError ? undefined : rawError,
}
}
function getTestTableColumnClass(columnId: string) {
switch (columnId) {
case 'select':
return 'w-10 min-w-10'
case 'model':
return 'w-auto min-w-48 whitespace-nowrap'
case 'status':
return 'w-28 min-w-28 whitespace-nowrap'
case 'result':
return 'w-80 min-w-80 max-w-80 whitespace-normal'
case 'actions':
return 'bg-popover w-px whitespace-nowrap'
default:
return undefined
}
}
export function ChannelTestDialog({
open,
onOpenChange,
}: ChannelTestDialogProps) {
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 batchStopRequestedRef = useRef(false)
const batchProgressToastIdRef = useRef<ReturnType<
typeof toast.loading
> | null>(null)
const [endpointType, setEndpointType] = useState('auto')
const [isStreamTest, setIsStreamTest] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [testResults, setTestResults] = useState<Record<string, TestResult>>({})
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [testingModels, setTestingModels] = useState<Set<string>>(
() => new Set()
)
const [isBatchTesting, setIsBatchTesting] = useState(false)
const [isBatchStopRequested, setIsBatchStopRequested] = useState(false)
const [batchProgress, setBatchProgress] = useState<BatchProgress | null>(null)
const [removedModels, setRemovedModels] = useState<Set<string>>(
() => new Set()
)
const [isDeleteFailedDialogOpen, setIsDeleteFailedDialogOpen] =
useState(false)
const [isDeletingFailed, setIsDeletingFailed] = useState(false)
const [failureDetails, setFailureDetails] =
useState<FailureDetailsState | null>(null)
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 30,
})
const endpointSelectItems = useMemo(
() =>
endpointTypeOptions.map((option) => ({
value: option.value,
label: t(option.label),
})),
[t]
)
const dismissBatchProgressToast = useCallback(() => {
if (batchProgressToastIdRef.current === null) return
toast.dismiss(batchProgressToastIdRef.current)
batchProgressToastIdRef.current = null
}, [])
useEffect(() => {
if (!batchProgress) {
dismissBatchProgressToast()
return
}
const title = isBatchStopRequested
? t('Stopping batch test...')
: t('Batch testing models...')
const completedText = t('{{completed}}/{{total}} completed', {
completed: batchProgress.completed,
total: batchProgress.total,
})
const resultText = t('{{success}} succeeded, {{failed}} failed', {
success: batchProgress.success,
failed: batchProgress.failed,
})
batchProgressToastIdRef.current = toast.loading(title, {
id: batchProgressToastIdRef.current ?? undefined,
description: `${completedText} · ${resultText}`,
})
}, [batchProgress, dismissBatchProgressToast, isBatchStopRequested, t])
useEffect(() => dismissBatchProgressToast, [dismissBatchProgressToast])
const resetState = useCallback(() => {
batchStopRequestedRef.current = true
setEndpointType('auto')
setIsStreamTest(false)
setSearchTerm('')
setTestResults({})
setRowSelection({})
setTestingModels(() => new Set())
setIsBatchTesting(false)
setIsBatchStopRequested(false)
setBatchProgress(null)
setRemovedModels(() => new Set())
setIsDeleteFailedDialogOpen(false)
setIsDeletingFailed(false)
setFailureDetails(null)
setPagination({ pageIndex: 0, pageSize: 30 })
}, [])
const streamDisabled = STREAM_INCOMPATIBLE_ENDPOINTS.has(endpointType)
const effectiveStreamTest = !streamDisabled && isStreamTest
const handleEndpointTypeChange = useCallback((value: string | null) => {
if (value === null) return
setEndpointType(value)
if (STREAM_INCOMPATIBLE_ENDPOINTS.has(value)) {
setIsStreamTest(false)
}
}, [])
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 baseModels = useMemo(() => {
if (!modelsValue) return []
return modelsValue
.split(',')
.map((model) => model.trim())
.filter(Boolean)
}, [modelsValue])
const models = useMemo(
() => baseModels.filter((model) => !removedModels.has(model)),
[baseModels, removedModels]
)
const successModels = useMemo(
() => models.filter((model) => testResults[model]?.status === 'success'),
[models, testResults]
)
const failedModels = useMemo(
() => models.filter((model) => testResults[model]?.status === 'error'),
[models, testResults]
)
const filteredModels = useMemo(() => {
if (!searchTerm) return models
const keyword = searchTerm.toLowerCase()
return models.filter((model) => model.toLowerCase().includes(keyword))
}, [models, searchTerm])
const tableData = useMemo<ModelRow[]>(
() => filteredModels.map((model) => ({ model })),
[filteredModels]
)
const markModelTesting = useCallback((key: string, isTesting: boolean) => {
setTestingModels((prev) => {
const next = new Set(prev)
if (isTesting) {
next.add(key)
} else {
next.delete(key)
}
return next
})
}, [])
const updateTestResult = useCallback((key: string, result: TestResult) => {
setFailureDetails((current) => (current?.model === key ? null : current))
setTestResults((prev) => ({
...prev,
[key]: result,
}))
}, [])
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,
refreshList = true
): Promise<TestResult | undefined> => {
if (!currentRow) return
markModelTesting(model, true)
updateTestResult(model, { status: 'testing' })
let finalResult: TestResult | undefined
try {
await handleTestChannel(
currentRow.id,
{
channelName: currentRow.name,
testModel: model,
endpointType: endpointType === 'auto' ? undefined : endpointType,
stream: effectiveStreamTest || undefined,
silent,
},
(success, responseTime, error, errorCode) => {
const completedAt = Date.now()
finalResult = {
status: success ? 'success' : 'error',
responseTime,
completedAt,
error,
errorCode,
}
updateTestResult(model, finalResult)
}
)
} 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,
effectiveStreamTest,
markModelTesting,
refreshChannelLists,
t,
updateTestResult,
]
)
const handleStopBatchTest = useCallback(() => {
if (!isBatchTesting || isBatchStopRequested) return
batchStopRequestedRef.current = true
setIsBatchStopRequested(true)
}, [isBatchStopRequested, isBatchTesting])
const handleBatchTest = useCallback(
async (modelsToTest: string[]) => {
const uniqueModels = [
...new Set(modelsToTest.map((model) => model.trim()).filter(Boolean)),
]
if (!uniqueModels.length) return
batchStopRequestedRef.current = false
setIsBatchTesting(true)
setIsBatchStopRequested(false)
setBatchProgress({
total: uniqueModels.length,
completed: 0,
success: 0,
failed: 0,
})
let resultPatch: ChannelTestCachePatch | undefined
const results: TestResult[] = []
let completedCount = 0
let successCount = 0
let failedCount = 0
try {
const createFallbackResult = (error?: unknown): TestResult => ({
status: 'error',
completedAt: Date.now(),
error: error instanceof Error ? error.message : t('Test failed'),
})
const recordBatchResult = (result: TestResult) => {
results.push(result)
completedCount += 1
if (result.status === 'success') {
successCount += 1
}
failedCount = completedCount - successCount
setBatchProgress({
total: uniqueModels.length,
completed: completedCount,
success: successCount,
failed: failedCount,
})
}
for (
let startIndex = 0;
startIndex < uniqueModels.length;
startIndex += BATCH_TEST_CONCURRENCY
) {
if (batchStopRequestedRef.current) {
break
}
const batch = uniqueModels.slice(
startIndex,
startIndex + BATCH_TEST_CONCURRENCY
)
const batchPromises = batch.map(async (modelName) => {
try {
const result = await testSingleModel(modelName, true, false)
const finalResult = result ?? createFallbackResult()
if (!result) {
updateTestResult(modelName, finalResult)
}
recordBatchResult(finalResult)
return finalResult
} catch (error: unknown) {
const fallbackResult = createFallbackResult(error)
updateTestResult(modelName, fallbackResult)
recordBatchResult(fallbackResult)
return fallbackResult
}
})
await Promise.allSettled(batchPromises)
if (
batchStopRequestedRef.current ||
startIndex + BATCH_TEST_CONCURRENCY >= uniqueModels.length
) {
break
}
await sleep(BATCH_TEST_DELAY_MS)
}
resultPatch = getLatestChannelTestCachePatch(results)
const stopped =
batchStopRequestedRef.current && completedCount < uniqueModels.length
dismissBatchProgressToast()
if (stopped) {
toast.info(
t(
'Batch test stopped: {{completed}}/{{total}} completed, {{success}} succeeded, {{failed}} failed',
{
completed: completedCount,
total: uniqueModels.length,
success: successCount,
failed: failedCount,
}
)
)
} else if (failedCount > 0) {
toast.error(
t(
'Batch test completed: {{success}} succeeded, {{failed}} failed',
{
success: successCount,
failed: failedCount,
}
)
)
} else {
toast.success(
t('Batch test completed: {{count}} succeeded', {
count: successCount,
})
)
}
} finally {
batchStopRequestedRef.current = false
setIsBatchTesting(false)
setIsBatchStopRequested(false)
setBatchProgress(null)
setRowSelection({})
refreshChannelLists(resultPatch)
}
},
[
dismissBatchProgressToast,
refreshChannelLists,
t,
testSingleModel,
updateTestResult,
]
)
const handleSelectSuccessfulModels = useCallback(() => {
setRowSelection(() => {
const next: RowSelectionState = {}
for (const model of successModels) {
next[model] = true
}
return next
})
}, [successModels])
const handleDeleteFailedModels = useCallback(async () => {
const failed = models.filter(
(model) => testResults[model]?.status === 'error'
)
if (!failed.length) {
setIsDeleteFailedDialogOpen(false)
return
}
const failedSet = new Set(failed)
const remaining = models.filter((model) => !failedSet.has(model))
setIsDeletingFailed(true)
try {
const response = await updateChannel(currentRow.id, {
models: remaining.join(','),
})
if (response.success) {
setRemovedModels((prev) => {
const next = new Set(prev)
for (const model of failed) next.add(model)
return next
})
setTestResults((prev) => {
const next = { ...prev }
for (const model of failed) delete next[model]
return next
})
setRowSelection((prev) => {
const next = { ...prev }
for (const model of failed) delete next[model]
return next
})
toast.success(
t('Deleted {{count}} failed models', { count: failed.length })
)
refreshChannelLists()
setIsDeleteFailedDialogOpen(false)
} else {
toast.error(response.message || t('Failed to delete failed models'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error
? error.message
: t('Failed to delete failed models')
)
} finally {
setIsDeletingFailed(false)
}
}, [currentRow.id, models, refreshChannelLists, t, testResults])
const handleClose = useCallback(() => {
resetState()
onOpenChange(false)
}, [onOpenChange, resetState])
const handleDialogOpenChange = useCallback(
(nextOpen: boolean) => {
if (!nextOpen) {
handleClose()
}
},
[handleClose]
)
const isAnyTesting = testingModels.size > 0 || isBatchTesting
const isFilteringModels = searchTerm.trim().length > 0
const testAllButtonLabel = isFilteringModels
? t('Test {{count}} matching models', { count: filteredModels.length })
: t('Test all {{count}} models', { count: filteredModels.length })
const columns = useMemo<ColumnDef<ModelRow>[]>(
() => [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllRowsSelected()}
indeterminate={
table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected()
}
onCheckedChange={(value) => table.toggleAllRowsSelected(!!value)}
aria-label={t('Select all models')}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label={t('Select model {{model}}', {
model: row.original.model,
})}
/>
),
enableSorting: false,
enableHiding: false,
size: 40,
},
{
accessorKey: 'model',
header: t('Model'),
cell: ({ row }) => {
const model = row.original.model
const isDefault = defaultTestModel === model
return (
<div className='flex w-max items-center gap-2 whitespace-nowrap'>
<span className='font-medium whitespace-nowrap' title={model}>
{model}
</span>
{isDefault && (
<StatusBadge
label={t('Default')}
variant='info'
size='sm'
copyable={false}
/>
)}
</div>
)
},
},
{
id: 'status',
header: t('Status'),
cell: ({ row }) => {
const model = row.original.model
const result = testResults[model]
return <TestStatusCell result={result} />
},
enableSorting: false,
size: 112,
},
{
id: 'result',
header: t('Result'),
cell: ({ row }) => {
const model = row.original.model
const result = testResults[model]
return (
<TestResultCell
result={result}
model={model}
onOpenDetails={setFailureDetails}
/>
)
},
enableSorting: false,
size: 320,
},
{
id: 'actions',
header: t('Actions'),
cell: ({ row }) => {
const model = row.original.model
const isTestingModel = testingModels.has(model)
return (
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={() => testSingleModel(model)}
disabled={isTestingModel || isBatchTesting}
aria-label={t('Test Connection')}
/>
}
>
{isTestingModel ? (
<Loader2 className='size-4 animate-spin' />
) : (
<Gauge className='size-4' />
)}
</TooltipTrigger>
<TooltipContent>{t('Test Connection')}</TooltipContent>
</Tooltip>
)
},
enableSorting: false,
},
],
[
defaultTestModel,
isBatchTesting,
t,
testResults,
testingModels,
testSingleModel,
]
)
const { table } = useDataTable({
data: tableData,
columns,
rowSelection,
pagination,
enableRowSelection: true,
getRowId: (row) => row.model,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
withFilteredRowModel: false,
withSortedRowModel: false,
withFacetedRowModel: false,
})
return (
<>
<Dialog
open={open}
onOpenChange={handleDialogOpenChange}
title={
<span className='inline-flex max-w-full min-w-0 items-center gap-1.5'>
<span className='shrink-0'>{t('Test Channel Connection')}:</span>
<span className='min-w-0 truncate'>{currentRow.name}</span>
</span>
}
contentClassName='max-h-[90vh] overflow-hidden sm:max-w-4xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<Button variant='outline' onClick={handleClose}>
{t('Close')}
</Button>
}
>
<div className='max-h-[78vh] space-y-4 overflow-y-auto py-4 pr-1'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='grid gap-2'>
<Label htmlFor='endpoint-type'>{t('Endpoint Type')}</Label>
<Combobox
options={endpointSelectItems}
value={endpointType}
onValueChange={handleEndpointTypeChange}
id='endpoint-type'
className='w-full min-w-0'
placeholder={t('Auto detect (default)')}
/>
<p className='text-muted-foreground text-xs'>
{t(
'Override the endpoint used for testing. Leave empty to auto detect.'
)}
</p>
</div>
<div className='grid gap-2'>
<Label htmlFor='stream-toggle'>{t('Stream Mode')}</Label>
<div className='flex items-center gap-2'>
<Switch
id='stream-toggle'
checked={effectiveStreamTest}
onCheckedChange={setIsStreamTest}
disabled={streamDisabled}
/>
<span className='text-sm'>
{effectiveStreamTest ? t('Enabled') : t('Disabled')}
</span>
</div>
<p className='text-muted-foreground text-xs'>
{t('Enable streaming mode for the test request.')}
</p>
</div>
</div>
<div className='space-y-3 max-sm:has-[div[role="toolbar"]]:pb-16'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='min-w-0 space-y-2'>
<p className='text-sm font-medium'>{t('Channel models')}</p>
<p className='text-muted-foreground text-xs'>
{t('Select models to run batch tests.')}
</p>
<div className='flex flex-wrap items-center gap-2'>
{isBatchTesting ? (
<Button
variant='outline'
size='sm'
onClick={handleStopBatchTest}
disabled={isBatchStopRequested}
>
{isBatchStopRequested
? t('Stopping...')
: t('Stop testing')}
</Button>
) : (
<>
<Button
size='sm'
onClick={() => handleBatchTest(filteredModels)}
disabled={isAnyTesting || filteredModels.length === 0}
>
{testAllButtonLabel}
</Button>
{successModels.length > 0 && (
<Button
variant='outline'
size='sm'
onClick={handleSelectSuccessfulModels}
>
<CheckCircle2 data-icon='inline-start' />
{t('Select successful models ({{count}})', {
count: successModels.length,
})}
</Button>
)}
{failedModels.length > 0 && (
<Button
variant='outline'
size='sm'
onClick={() => setIsDeleteFailedDialogOpen(true)}
>
<Trash2 data-icon='inline-start' />
{t('Delete failed models ({{count}})', {
count: failedModels.length,
})}
</Button>
)}
</>
)}
</div>
</div>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center'>
<Input
placeholder={t('Filter models...')}
value={searchTerm}
onChange={handleSearchTermChange}
className='sm:w-64'
/>
</div>
</div>
<div className='space-y-3'>
<DataTableView
table={table}
containerClassName='rounded-md'
containerProps={{
role: 'region',
'aria-label': t('Channel models'),
}}
tableContainerClassName='max-h-90 overflow-auto **:data-[slot=table-container]:overflow-visible'
tableClassName='w-max min-w-full table-auto'
pinnedColumns={[
{
columnId: 'actions',
side: 'right',
cellClassName: 'bg-popover',
},
]}
colgroup={
<colgroup>
<col className='w-10 min-w-10' />
<col className='w-auto' />
<col className='w-28' />
<col className='w-80' />
<col className='w-px' />
</colgroup>
}
getColumnClassName={(columnId) =>
getTestTableColumnClass(columnId)
}
emptyContent={
models.length
? t('No models matched your search.')
: t('This channel has no configured models.')
}
emptyCellClassName='text-muted-foreground h-16 text-center text-sm'
/>
<DataTablePagination table={table} />
</div>
<TestModelsBulkActions table={table} />
</div>
</div>
</Dialog>
<ConfirmDialog
open={isDeleteFailedDialogOpen}
onOpenChange={setIsDeleteFailedDialogOpen}
title={t('Delete failed models')}
desc={t(
'This removes {{count}} failed models from this channel. This action cannot be undone.',
{ count: failedModels.length }
)}
destructive
isLoading={isDeletingFailed}
confirmText={t('Delete')}
handleConfirm={handleDeleteFailedModels}
/>
<FailureDetailsSheet
details={failureDetails}
onOpenChange={(sheetOpen) => {
if (!sheetOpen) {
setFailureDetails(null)
}
}}
/>
</>
)
}
function TestStatusCell({ result }: { result?: TestResult }) {
const { t } = useTranslation()
if (!result || result.status === 'idle') {
return (
<StatusBadge label={t('Not tested')} variant='neutral' copyable={false} />
)
}
if (result.status === 'testing') {
return (
<StatusBadge variant='info' copyable={false}>
<Loader2 className='size-3.5 shrink-0 animate-spin' />
<span className='min-w-0 truncate leading-normal'>
{t('Testing...')}
</span>
</StatusBadge>
)
}
if (result.status === 'success') {
return (
<StatusBadge label={t('Success')} variant='success' copyable={false} />
)
}
return <StatusBadge label={t('Failed')} variant='danger' copyable={false} />
}
function TestResultCell({
result,
model,
onOpenDetails,
}: {
result?: TestResult
model: string
onOpenDetails: (details: FailureDetailsState) => void
}) {
const { t } = useTranslation()
if (!result || result.status === 'idle') {
return <span className='text-muted-foreground text-sm'>-</span>
}
if (result.status === 'testing') {
return (
<div className='text-muted-foreground flex min-w-0 items-center gap-2 text-sm'>
<Loader2 className='size-4 shrink-0 animate-spin' />
<span className='truncate'>{t('Testing...')}</span>
</div>
)
}
if (result.status === 'success') {
return typeof result.responseTime === 'number' ? (
<span className='text-muted-foreground text-sm'>
{formatResponseTime(result.responseTime, t)}
</span>
) : (
<span className='text-muted-foreground text-sm'>-</span>
)
}
return (
<FailureResultContent
result={result}
model={model}
onOpenDetails={onOpenDetails}
/>
)
}
function FailureResultContent({
result,
model,
onOpenDetails,
}: {
result: TestResult
model: string
onOpenDetails: (details: FailureDetailsState) => void
}) {
const { t } = useTranslation()
const errorText = result.error?.trim()
const isModelPriceError = result.errorCode === MODEL_PRICE_ERROR_CODE
const modelPriceSummary = t(
'Model price is not configured. Please complete model pricing in settings.'
)
const { summary, details } = getFailureStatusDisplay({
errorText,
fallbackSummary: t('Test failed'),
isModelPriceError,
modelPriceSummary,
})
return (
<div className='flex min-w-0 items-center gap-2 text-xs whitespace-normal'>
<p className='text-muted-foreground line-clamp-2 min-w-0 flex-1 leading-snug wrap-break-word'>
{summary}
</p>
<div className='flex shrink-0 flex-wrap items-center justify-end gap-1.5'>
{isModelPriceError && (
<Button
variant='outline'
size='sm'
className='h-7 w-fit px-2 text-xs'
onClick={() =>
window.open('/system-settings/billing/model-pricing', '_blank')
}
>
<Settings className='mr-1 h-3 w-3 shrink-0' />
{t('Go to Settings')}
</Button>
)}
{details && (
<Button
variant='ghost'
size='sm'
className='h-7 w-fit px-2 text-xs'
aria-haspopup='dialog'
onClick={() => onOpenDetails({ model, summary, details })}
>
<Info className='mr-1 h-3 w-3 shrink-0' />
{t('Details')}
</Button>
)}
</div>
</div>
)
}
function FailureDetailsSheet({
details,
onOpenChange,
}: {
details: FailureDetailsState | null
onOpenChange: (open: boolean) => void
}) {
const { t } = useTranslation()
const isMobile = useIsMobile()
const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
return (
<Sheet open={Boolean(details)} onOpenChange={onOpenChange}>
<SheetContent
side={isMobile ? 'bottom' : 'right'}
className={
isMobile
? sideDrawerContentClassName('h-auto max-h-[85dvh] rounded-t-xl')
: sideDrawerContentClassName('sm:max-w-lg')
}
>
{details && (
<>
<SheetHeader className={sideDrawerHeaderClassName('sm:px-5')}>
<SheetTitle className='pr-10'>{t('Details')}</SheetTitle>
<SheetDescription className='pr-10 wrap-break-word'>
{details.model}
</SheetDescription>
</SheetHeader>
<div className={sideDrawerFormClassName('gap-4 sm:px-5')}>
<section className='space-y-1'>
<div className='text-muted-foreground text-xs font-medium'>
{t('Model')}
</div>
<p className='text-sm font-medium break-all'>{details.model}</p>
</section>
<section className='space-y-1'>
<div className='text-muted-foreground text-xs font-medium'>
{t('Failed')}
</div>
<p className='text-muted-foreground text-sm leading-relaxed wrap-break-word'>
{details.summary}
</p>
</section>
<section className='space-y-2'>
<div className='text-muted-foreground text-xs font-medium'>
{t('Details')}
</div>
<pre className='bg-muted/30 text-muted-foreground m-0 max-w-full rounded-md border p-3 text-xs leading-relaxed wrap-break-word whitespace-pre-wrap'>
{details.details}
</pre>
</section>
</div>
<SheetFooter className={sideDrawerFooterClassName('sm:px-5')}>
<Button
variant='outline'
className='w-full sm:w-auto'
onClick={() => copyToClipboard(details.details)}
>
{copiedText === details.details ? (
<Check className='mr-2 h-4 w-4 text-green-600' />
) : (
<Copy className='mr-2 h-4 w-4' />
)}
{t('Copy')}
</Button>
</SheetFooter>
</>
)}
</SheetContent>
</Sheet>
)
}
function TestModelsBulkActions({ table }: { table: TanStackTable<ModelRow> }) {
const { t } = useTranslation()
const { copyToClipboard } = useCopyToClipboard()
const selectedRows = table.getFilteredSelectedRowModel().rows
const selectedModels = selectedRows.map((row) => row.original.model)
const handleCopySelected = useCallback(() => {
if (selectedModels.length === 0) return
void copyToClipboard(selectedModels.join(','))
}, [copyToClipboard, selectedModels])
return (
<BulkActionsToolbar table={table} entityName='model'>
<Tooltip>
<TooltipTrigger
render={
<Button
size='sm'
onClick={handleCopySelected}
disabled={selectedModels.length === 0}
/>
}
>
<Copy data-icon='inline-start' />
{t('Copy selected models')}
</TooltipTrigger>
<TooltipContent>
<p>{t('Copy selected models separated by commas (e.g. a,b)')}</p>
</TooltipContent>
</Tooltip>
</BulkActionsToolbar>
)
}