Files
new-api/web/src/features/channels/components/channels-columns.tsx
T
Seefs 2b0efd8484 refactor: advanced custom channel route editor (#6865)
* refactor: advanced custom channel route editor

* fix(channels): show raw balance response from balance cell
2026-08-18 17:31:21 +08:00

1231 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
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
*/
/* eslint-disable react-refresh/only-export-components */
import { useQueryClient } from '@tanstack/react-query'
import type { ColumnDef } from '@tanstack/react-table'
import {
AlertTriangle,
ChevronDown,
ChevronRight,
ListOrdered,
Shuffle,
SlidersHorizontal,
} from 'lucide-react'
import { useState, useMemo, useContext, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { BadgeListCell } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { ProviderBadge } from '@/components/provider-badge'
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { TruncatedText } from '@/components/truncated-text'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { toIntlLocale } from '@/i18n/languages'
import {
formatCurrencyFromUSD,
formatQuotaWithCurrency,
getCurrencyLabel,
} from '@/lib/currency'
import { formatTimestampToDate } from '@/lib/format'
import { truncateText } from '@/lib/utils'
import { getCodexUsage, updateChannelBalance } from '../api'
import { CHANNEL_STATUS_CONFIG, MODEL_FETCHABLE_TYPES } from '../constants'
import {
formatRelativeTime,
formatResponseTime,
getBalanceVariant,
getChannelTypeIcon,
getChannelTypeLabel,
getResponseTimeConfig,
isMultiKeyChannel,
parseModelsList,
parseGroupsList,
parseChannelSettings,
channelsQueryKeys,
handleUpdateChannelField,
handleUpdateTagField,
createChannelFieldUpdateScheduler,
isTagAggregateRow,
type TagRow,
} from '../lib'
import { parseUpstreamUpdateMeta } from '../lib/upstream-update-utils'
import type { Channel } from '../types'
import { ChannelRowActionsLayoutContext } from './channel-row-actions-context'
import { useChannels } from './channels-provider'
import { DataTableRowActions } from './data-table-row-actions'
import { DataTableTagRowActions } from './data-table-tag-row-actions'
import { BalanceQueryDialog } from './dialogs/balance-query-dialog'
import {
CodexUsageDialog,
type CodexUsageDialogData,
} from './dialogs/codex-usage-dialog'
import { NumericSpinnerInput } from './numeric-spinner-input'
function parseIonetMeta(otherInfo: string | null | undefined): null | {
source?: string
deployment_id?: string
} {
if (!otherInfo) {
return null
}
try {
const parsed = JSON.parse(otherInfo)
if (parsed && typeof parsed === 'object') {
return parsed
}
} catch {
return null
}
return null
}
/**
* Upstream update tags (+N / -N) shown on channel name for model-fetchable channels
*/
function UpstreamUpdateTags({ channel }: { channel: Channel }) {
const { upstream, setCurrentRow } = useChannels()
if (!MODEL_FETCHABLE_TYPES.has(channel.type)) {
return null
}
const meta = parseUpstreamUpdateMeta(channel.settings)
if (!meta.enabled) {
return null
}
const addCount = meta.pendingAddModels.length
const removeCount = meta.pendingRemoveModels.length
if (addCount === 0 && removeCount === 0) {
return null
}
return (
<div className='flex items-center gap-0.5'>
{addCount > 0 && (
<StatusBadge
label={`+${addCount}`}
variant='success'
size='sm'
copyable={false}
className='cursor-pointer'
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
setCurrentRow(channel)
upstream.openModal(
channel,
meta.pendingAddModels,
meta.pendingRemoveModels,
'add'
)
}}
/>
)}
{removeCount > 0 && (
<StatusBadge
label={`-${removeCount}`}
variant='danger'
size='sm'
copyable={false}
className='cursor-pointer'
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
setCurrentRow(channel)
upstream.openModal(
channel,
meta.pendingAddModels,
meta.pendingRemoveModels,
'remove'
)
}}
/>
)}
</div>
)
}
/**
* Priority cell component with inline editing
*/
function PriorityCell({ channel }: { channel: Channel }) {
if (isTagAggregateRow(channel)) {
return <TagPriorityCell channel={channel} />
}
return (
<ChannelFieldCell
channelId={channel.id}
value={channel.priority}
field='priority'
min={-999}
/>
)
}
function TagPriorityCell({ channel }: { channel: TagRow }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const priority = channel.priority
const [confirmOpen, setConfirmOpen] = useState(false)
const [pendingValue, setPendingValue] = useState<number | null>(null)
const tag = channel.tag || ''
const channelCount = channel.children?.length || 0
return (
<>
<NumericSpinnerInput
value={priority ?? 0}
onChange={(value) => {
setPendingValue(value)
setConfirmOpen(true)
}}
min={-999}
/>
<ConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title={t('Confirm Batch Update')}
desc={t(
'This will update the priority to {{value}} for all {{count}} channel(s) with tag "{{tag}}". Continue?',
{ value: pendingValue, count: channelCount, tag }
)}
confirmText={t('Update')}
handleConfirm={() => {
if (pendingValue !== null) {
handleUpdateTagField(tag, 'priority', pendingValue, queryClient)
}
setConfirmOpen(false)
}}
/>
</>
)
}
function ChannelFieldCell({
channelId,
value,
field,
min,
}: {
channelId: number
value: number | null | undefined
field: 'priority' | 'weight'
min: number
}) {
const queryClient = useQueryClient()
const fieldUpdateScheduler = useMemo(
() =>
createChannelFieldUpdateScheduler((nextValue) => {
void handleUpdateChannelField(channelId, field, nextValue, queryClient)
}),
[channelId, field, queryClient]
)
useEffect(() => () => fieldUpdateScheduler.flush(), [fieldUpdateScheduler])
return (
<NumericSpinnerInput
value={value ?? 0}
onChange={fieldUpdateScheduler.schedule}
onCommit={fieldUpdateScheduler.flush}
min={min}
/>
)
}
/**
* Weight cell component with inline editing
*/
function WeightCell({ channel }: { channel: Channel }) {
if (isTagAggregateRow(channel)) {
return <TagWeightCell channel={channel} />
}
return (
<ChannelFieldCell
channelId={channel.id}
value={channel.weight}
field='weight'
min={0}
/>
)
}
function TagWeightCell({ channel }: { channel: TagRow }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const weight = channel.weight
const [confirmOpen, setConfirmOpen] = useState(false)
const [pendingValue, setPendingValue] = useState<number | null>(null)
const tag = channel.tag || ''
const channelCount = channel.children?.length || 0
return (
<>
<NumericSpinnerInput
value={weight ?? 0}
onChange={(value) => {
setPendingValue(value)
setConfirmOpen(true)
}}
min={0}
/>
<ConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title={t('Confirm Batch Update')}
desc={t(
'This will update the weight to {{value}} for all {{count}} channel(s) with tag "{{tag}}". Continue?',
{ value: pendingValue, count: channelCount, tag }
)}
confirmText={t('Update')}
handleConfirm={() => {
if (pendingValue !== null) {
handleUpdateTagField(tag, 'weight', pendingValue, queryClient)
}
setConfirmOpen(false)
}}
/>
</>
)
}
/**
* Inline balance/used values longer than this switch to locale-aware compact
* notation (e.g. "$28万"); the precise value stays available in the tooltip.
*/
const MAX_INLINE_BALANCE_CHARS = 8
const SENSITIVE_MASK = '••••'
/**
* Balance cell component with click to update
*/
export function BalanceCell({ channel }: { channel: Channel }) {
const { t, i18n } = useTranslation()
const queryClient = useQueryClient()
const layout = useContext(ChannelRowActionsLayoutContext)
const { sensitiveVisible, setCurrentRow } = useChannels()
const isTagRow = isTagAggregateRow(channel)
const balance = channel.balance || 0
const usedQuota = channel.used_quota || 0
const [isUpdating, setIsUpdating] = useState(false)
const [rawBalanceResponse, setRawBalanceResponse] = useState<string | null>(
null
)
const [codexUsageOpen, setCodexUsageOpen] = useState(false)
const [codexUsageResponse, setCodexUsageResponse] =
useState<CodexUsageDialogData | null>(null)
const currencyLabel = getCurrencyLabel()
const tokenSuffix = currencyLabel === 'Tokens' ? ' Tokens' : ''
const withSuffix = (value: string) =>
tokenSuffix && value !== '-' ? `${value}${tokenSuffix}` : value
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
const balanceFormatOptions = {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: false,
showSymbol: layout !== 'card',
} as const
// Precise values are kept for the tooltip; long values are shown compactly inline.
const usedFull = withSuffix(
formatQuotaWithCurrency(usedQuota, {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: true,
showSymbol: layout !== 'card',
})
)
const remainingFull = withSuffix(
formatCurrencyFromUSD(balance, balanceFormatOptions)
)
const usedDisplay =
usedFull.length > MAX_INLINE_BALANCE_CHARS
? withSuffix(
formatQuotaWithCurrency(usedQuota, {
compact: true,
locale,
showSymbol: layout !== 'card',
})
)
: usedFull
const remainingDisplay =
remainingFull.length > MAX_INLINE_BALANCE_CHARS
? withSuffix(
formatCurrencyFromUSD(balance, {
compact: true,
locale,
showSymbol: layout !== 'card',
})
)
: remainingFull
const usedLabel = `${t('Used:')} ${usedFull}`
const remainingLabel = `${t('Remaining:')} ${remainingFull}`
const maskedUsedLabel = `${t('Used:')} ${SENSITIVE_MASK}`
const maskedRemainingLabel = `${t('Remaining:')} ${SENSITIVE_MASK}`
// Tag row: only show cumulative used quota
if (isTagRow) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<StatusBadge
label={
sensitiveVisible
? `${t('Used:')} ${usedDisplay}`
: maskedUsedLabel
}
variant='neutral'
size='sm'
copyable={false}
showDot={false}
className='-ml-1.5 cursor-help'
/>
}
/>
<TooltipContent>
<p>{sensitiveVisible ? usedLabel : maskedUsedLabel}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
// Regular channel row: show used and remaining with click to update
const variant = getBalanceVariant(balance)
const handleClickUpdate = async () => {
if (isUpdating) {
return
}
setIsUpdating(true)
if (channel.type === 57) {
try {
const res = await getCodexUsage(channel.id)
if (!res.success) {
throw new Error(res.message || t('Failed to fetch usage'))
}
setCodexUsageResponse(res)
setCodexUsageOpen(true)
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('Failed to fetch usage')
)
} finally {
setIsUpdating(false)
}
return
}
try {
const response = await updateChannelBalance(channel.id)
if (response.success && response.balance !== undefined) {
toast.success(
t('Balance updated: {{balance}}', {
balance: formatCurrencyFromUSD(response.balance, {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: false,
}),
})
)
void queryClient.invalidateQueries({
queryKey: channelsQueryKeys.lists(),
})
} else if (response.success && response.raw_response !== undefined) {
setCurrentRow(channel)
setRawBalanceResponse(response.raw_response)
} else {
toast.error(response.message || t('Failed to update balance'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to update balance')
)
} finally {
setIsUpdating(false)
}
}
let remainingBadgeLabel = sensitiveVisible ? remainingDisplay : SENSITIVE_MASK
if (sensitiveVisible && isUpdating) {
remainingBadgeLabel = t('Updating...')
} else if (sensitiveVisible && channel.type === 57) {
remainingBadgeLabel = t('Account Info')
}
let remainingTooltipLabel = remainingLabel
if (!sensitiveVisible) {
remainingTooltipLabel = maskedRemainingLabel
} else if (channel.type === 57) {
remainingTooltipLabel = t('Click to view Codex usage')
}
let remainingBadgeVariant: StatusBadgeProps['variant'] = variant
if (channel.type === 57) {
remainingBadgeVariant = 'info'
} else if (isUpdating) {
remainingBadgeVariant = 'neutral'
}
return (
<TooltipProvider>
<div className='-ml-1.5 flex items-center gap-1'>
<Tooltip>
<TooltipTrigger
render={
<StatusBadge
label={sensitiveVisible ? usedDisplay : SENSITIVE_MASK}
variant='neutral'
size='sm'
copyable={false}
showDot={false}
className='cursor-help'
/>
}
/>
<TooltipContent>
<p>{sensitiveVisible ? usedLabel : maskedUsedLabel}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<StatusBadge
label={remainingBadgeLabel}
variant={remainingBadgeVariant}
size='sm'
copyable={false}
showDot={false}
className='cursor-pointer'
onClick={handleClickUpdate}
/>
}
/>
<TooltipContent>
<p>{remainingTooltipLabel}</p>
{channel.type !== 57 && <p>{t('Click to update balance')}</p>}
</TooltipContent>
</Tooltip>
</div>
<CodexUsageDialog
open={codexUsageOpen}
onOpenChange={setCodexUsageOpen}
channelName={channel.name}
channelId={channel.id}
channelDisplayName={sensitiveVisible ? undefined : SENSITIVE_MASK}
channelDisplayId={sensitiveVisible ? undefined : SENSITIVE_MASK}
response={codexUsageResponse}
onRefresh={async () => {
if (isUpdating) {
return
}
setIsUpdating(true)
try {
const res = await getCodexUsage(channel.id)
if (!res.success) {
throw new Error(res.message || t('Failed to fetch usage'))
}
setCodexUsageResponse(res)
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t('Failed to fetch usage')
)
} finally {
setIsUpdating(false)
}
}}
isRefreshing={isUpdating}
/>
{rawBalanceResponse !== null && (
<BalanceQueryDialog
initialRawResponse={rawBalanceResponse}
open
onOpenChange={(open) => {
if (!open) {
setRawBalanceResponse(null)
}
}}
/>
)}
</TooltipProvider>
)
}
/**
* Generate channels columns configuration
*/
export function useChannelsColumns(
options: {
enableSelection?: boolean
} = {}
): ColumnDef<Channel>[] {
const { t, i18n } = useTranslation()
const { sensitiveVisible } = useChannels()
const enableSelection = options.enableSelection ?? true
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
// The column definitions only depend on the translation function, the active
// locale, and sensitive-data visibility. Memoizing keeps the array (and every
// cell renderer reference) stable across unrelated re-renders, so react-table
// does not invalidate the whole row model on each parent render.
return useMemo<ColumnDef<Channel>[]>(
() => [
// Checkbox column
...(enableSelection
? [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label={t('Select all')}
/>
),
cell: ({ row }) => {
const isTagRow = isTagAggregateRow(row.original)
// Don't show checkbox for tag rows
if (isTagRow) {
return null
}
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label={t('Select row')}
/>
)
},
enableSorting: false,
enableHiding: false,
enableResizing: false,
size: 40,
} satisfies ColumnDef<Channel>,
]
: []),
// ID column
{
accessorKey: 'id',
header: t('ID'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const id = row.getValue('id') as number
return <TableId value={sensitiveVisible ? id : SENSITIVE_MASK} />
},
size: 80,
},
// Name column
{
accessorKey: 'name',
header: t('Name'),
meta: { mobileTitle: true },
cell: ({ row }) => {
const isTagRow = isTagAggregateRow(row.original)
const name = row.getValue('name') as string
const channel = row.original
// Tag row with expand/collapse
if (isTagRow) {
const tag = (row.original as TagRow).tag || name
const childrenCount = (row.original as TagRow).children?.length || 0
return (
<div className='flex items-center gap-2'>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-0'
onClick={row.getToggleExpandedHandler()}
>
{row.getIsExpanded() ? (
<ChevronDown className='h-4 w-4' />
) : (
<ChevronRight className='h-4 w-4' />
)}
</Button>
<div className='flex items-center gap-1.5'>
<span className='font-semibold'>Tag{tag}</span>
<StatusBadge
label={`${childrenCount} channels`}
variant='blue'
size='sm'
copyable={false}
/>
</div>
</div>
)
}
// Regular channel row
const settings = parseChannelSettings(channel.setting)
const isPassThrough = settings.pass_through_body_enabled === true
const hasParamOverride = Boolean(channel.param_override?.trim())
return (
<div className='flex max-w-full min-w-0 items-center gap-2'>
<div className='flex max-w-full min-w-0 flex-col gap-1'>
<div className='flex max-w-full min-w-0 items-center gap-1.5'>
<TruncatedText
text={sensitiveVisible ? name : SENSITIVE_MASK}
className='font-medium'
maxWidth='max-w-full'
/>
{isPassThrough && (
<TooltipProvider delay={100}>
<Tooltip>
<TooltipTrigger
render={
<AlertTriangle className='h-3.5 w-3.5 flex-shrink-0 text-amber-500' />
}
/>
<TooltipContent side='top'>
{t(
'Request body pass-through is enabled. The request body will be sent directly to the upstream without any conversion.'
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{hasParamOverride && (
<TooltipProvider delay={100}>
<Tooltip>
<TooltipTrigger
render={
<SlidersHorizontal className='text-info h-3.5 w-3.5 flex-shrink-0' />
}
/>
<TooltipContent side='top'>
{t('Override request parameters')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<UpstreamUpdateTags channel={channel} />
</div>
{channel.remark && (
<TooltipProvider delay={200}>
<Tooltip>
<TooltipTrigger
render={
<span className='text-muted-foreground text-xs' />
}
>
{truncateText(channel.remark, 40)}
</TooltipTrigger>
<TooltipContent side='bottom' className='max-w-xs'>
{channel.remark}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
)
},
size: 260,
minSize: 200,
},
// Type column
{
accessorKey: 'type',
header: t('Type'),
cell: ({ row }) => {
const isTagRow = isTagAggregateRow(row.original)
if (isTagRow) {
return (
<StatusBadge
label={t('Tag Aggregate')}
variant='blue'
size='sm'
copyable={false}
className='-ml-1.5'
/>
)
}
const type = row.getValue('type') as number
const typeNameKey = getChannelTypeLabel(type)
const typeName = t(typeNameKey)
const iconName = getChannelTypeIcon(type)
const channel = row.original as Channel
const isMultiKey = isMultiKeyChannel(channel)
const multiKeyMode = channel.channel_info?.multi_key_mode ?? 'random'
const MultiKeyModeIcon =
multiKeyMode === 'random' ? Shuffle : ListOrdered
const multiKeyTooltip =
multiKeyMode === 'random'
? t('Multi-key: Random rotation')
: t('Multi-key: Polling rotation')
const ionetMeta = parseIonetMeta(channel.other_info)
const isIonet = ionetMeta?.source === 'ionet'
const deploymentId =
typeof ionetMeta?.deployment_id === 'string'
? ionetMeta?.deployment_id
: undefined
return (
<div className='flex max-w-full min-w-0 items-center gap-2 overflow-hidden'>
{isMultiKey && (
<TooltipProvider delay={100}>
<Tooltip>
<TooltipTrigger
render={
<span className='border-border bg-muted text-primary inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border' />
}
>
<MultiKeyModeIcon className='h-3 w-3' />
</TooltipTrigger>
<TooltipContent side='top'>
{multiKeyTooltip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<TooltipProvider delay={300}>
<Tooltip>
<TooltipTrigger
render={
<div className='max-w-full min-w-0 overflow-hidden' />
}
>
<ProviderBadge
iconKey={`${iconName}.Color`}
iconSize={18}
label={typeName}
colorText={false}
copyable={false}
showDot={false}
className='max-w-full min-w-0 overflow-hidden'
/>
</TooltipTrigger>
<TooltipContent side='top'>{typeName}</TooltipContent>
</Tooltip>
</TooltipProvider>
{isIonet && (
<TooltipProvider delay={100}>
<Tooltip>
<TooltipTrigger
render={
<span
className='flex cursor-pointer items-center gap-1.5 text-xs font-medium'
onClick={(e) => {
e.stopPropagation()
if (!deploymentId) {
return
}
const targetUrl = `/models/deployments?dFilter=${encodeURIComponent(String(deploymentId))}`
window.open(targetUrl, '_blank', 'noopener')
}}
/>
}
>
<StatusBadge
label='IO.NET'
variant='purple'
size='sm'
copyable={false}
className='cursor-pointer'
/>
</TooltipTrigger>
<TooltipContent side='top'>
<div className='max-w-xs space-y-1'>
<div className='text-xs'>
{t('From IO.NET deployment')}
</div>
{deploymentId && (
<div className='text-muted-foreground font-mono text-xs'>
{t('Deployment ID')}: {deploymentId}
</div>
)}
<div className='text-muted-foreground text-xs'>
{t('Click to open deployment')}
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)
},
filterFn: (row, id, value) => {
if (!value || value.length === 0 || value.includes('all')) {
return true
}
return value.includes(String(row.getValue(id)))
},
size: 220,
enableSorting: false,
},
// Status column
{
accessorKey: 'status',
header: t('Status'),
meta: { mobileBadge: true },
cell: ({ row }) => {
const isTagRow = isTagAggregateRow(row.original)
const status = row.getValue('status') as number
const channel = row.original as Channel
// Tag row: show aggregated status
if (isTagRow) {
const childrenCount = (row.original as TagRow).children?.length || 0
const hasEnabled = status === 1
if (hasEnabled) {
return (
<StatusBadge
label={`Active (${childrenCount})`}
variant='success'
size='sm'
copyable={false}
className='-ml-1.5'
/>
)
} else {
return (
<StatusBadge
label={`Inactive (${childrenCount})`}
variant='neutral'
size='sm'
copyable={false}
className='-ml-1.5'
/>
)
}
}
// Regular channel row
const config =
CHANNEL_STATUS_CONFIG[
status as keyof typeof CHANNEL_STATUS_CONFIG
] || CHANNEL_STATUS_CONFIG[0]
const isMultiKey = isMultiKeyChannel(channel)
const keySize = channel.channel_info?.multi_key_size ?? 0
const disabledCount = channel.channel_info?.multi_key_status_list
? Object.keys(channel.channel_info.multi_key_status_list).length
: 0
const enabledCount = Math.max(0, keySize - disabledCount)
const label =
isMultiKey && keySize > 0
? `${t(config.label)} (${enabledCount}/${keySize})`
: t(config.label)
// Auto-disabled: show reason and time tooltip
if (status === 3) {
let statusReason = ''
let statusTime = ''
try {
const otherInfo = channel.other_info
? JSON.parse(channel.other_info)
: null
if (otherInfo) {
statusReason = otherInfo.status_reason || ''
statusTime = otherInfo.status_time
? formatTimestampToDate(otherInfo.status_time)
: ''
}
} catch {
/* empty */
}
if (statusReason || statusTime) {
return (
<TooltipProvider delay={100}>
<Tooltip>
<TooltipTrigger render={<span />}>
<StatusBadge
label={label}
variant={config.variant}
size='sm'
copyable={false}
/>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-xs'>
<div className='space-y-1 text-xs'>
{statusReason && (
<div>
{t('Reason:')} {statusReason}
</div>
)}
{statusTime && (
<div>
{t('Time:')} {statusTime}
</div>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
}
return (
<StatusBadge
label={label}
variant={config.variant}
size='sm'
copyable={false}
/>
)
},
filterFn: (row, id, value) => {
if (!value || value.length === 0 || value.includes('all')) {
return true
}
const status = row.getValue(id) as number
if (value.includes('enabled')) {
return status === 1
}
if (value.includes('disabled')) {
return status !== 1
}
return false
},
size: 120,
enableSorting: false,
},
// Models column
{
accessorKey: 'models',
header: t('Models'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const models = row.getValue('models') as string
const modelArray = parseModelsList(models)
return (
<BadgeListCell
items={modelArray.map((model) => (
<StatusBadge
key={model}
label={model}
autoColor={model}
size='sm'
className='font-mono'
/>
))}
/>
)
},
size: 200,
enableSorting: false,
},
// Group column
{
accessorKey: 'group',
header: t('Groups'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const group = row.getValue('group') as string
const groupArray = parseGroupsList(group)
return (
<BadgeListCell
items={groupArray.map((g) => (
<GroupBadge
key={g}
group={g}
label={sensitiveVisible ? undefined : SENSITIVE_MASK}
size='sm'
/>
))}
/>
)
},
filterFn: (row, id, value) => {
if (!value || value.length === 0 || value.includes('all')) {
return true
}
const group = row.getValue(id) as string
const groupArray = parseGroupsList(group)
return groupArray.some((g) => value.includes(g))
},
size: 150,
enableSorting: false,
},
// Tag column
{
accessorKey: 'tag',
header: t('Tag'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const tag = row.getValue('tag') as string | null
if (!tag) {
return <span className='text-muted-foreground text-xs'>-</span>
}
return (
<StatusBadge
label={tag}
autoColor={tag}
size='sm'
className='-ml-1.5'
/>
)
},
size: 120,
enableSorting: false,
},
// Priority column
{
accessorKey: 'priority',
header: t('Priority'),
meta: { mobileHidden: true },
cell: ({ row }) => <PriorityCell channel={row.original} />,
size: 100,
},
// Weight column
{
accessorKey: 'weight',
header: t('Weight'),
meta: { mobileHidden: true },
cell: ({ row }) => <WeightCell channel={row.original} />,
size: 90,
enableSorting: false,
},
// Balance column (Used/Remaining)
{
accessorKey: 'balance',
header: t('Used / Remaining'),
cell: ({ row }) => <BalanceCell channel={row.original} />,
size: 180,
},
// Response Time column
{
accessorKey: 'response_time',
header: t('Response'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const responseTime = row.getValue('response_time') as number
const config = getResponseTimeConfig(responseTime)
return (
<StatusBadge
label={formatResponseTime(responseTime, t)}
variant={config.variant}
size='sm'
copyable={false}
className='-ml-1.5'
/>
)
},
size: 110,
},
// Test Time column
{
accessorKey: 'test_time',
header: t('Last Tested'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const testTime = row.getValue('test_time') as number
// For invalid timestamps, show "Never" badge
if (!testTime || testTime === 0) {
return <span className='text-muted-foreground text-xs'>-</span>
}
const timeText = formatRelativeTime(testTime, locale)
const fullDate = formatTimestampToDate(testTime)
// For valid timestamps, show tooltip with full date
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<StatusBadge
label={timeText}
variant='neutral'
size='sm'
copyable={false}
className='-ml-1.5 cursor-pointer'
/>
}
/>
<TooltipContent side='top'>
<p className='font-mono text-sm'>{fullDate}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
},
size: 120,
enableSorting: false,
},
// Actions column
{
id: 'actions',
header: () => t('Actions'),
cell: ({ row }) => {
// Check if this is a tag row (has children)
const isTagRow = isTagAggregateRow(row.original)
if (isTagRow) {
return (
<DataTableTagRowActions
// eslint-disable-next-line @typescript-eslint/no-explicit-any
row={row as any}
/>
)
}
return <DataTableRowActions row={row} />
},
enableSorting: false,
enableHiding: false,
meta: { pinned: 'right' as const },
},
],
[enableSelection, t, locale, sensitiveVisible]
)
}