Files
new-api/web/default/src/features/channels/components/channels-columns.tsx
T
QuentinHsuandt0ng7u 27b2b2c4b9 perf(data-table): improve data table layout and badge display (#5460)
* perf(table): use percentage-based column widths

- compute each column's width as a percentage of total column size instead of a fixed pixel value, letting the colgroup scale fluidly with the table container.

* perf(data-table): reduce unnecessary re-renders across table components

- stabilize commitSearchValue in toolbar by reading table/searchKey via refs, eliminating recreation on every parent render
- store onColumnFiltersChange in a ref so debounce effect is not reset when the caller passes a new function reference each render
- wrap DataTableRow in React.memo with a custom comparator that ignores getColumnClassName reference churn
- memoize selectedValues Set in DataTableFacetedFilter and wrap with React.memo to prevent rerenders on unrelated state changes
- cache cell meta reads in CompactRow and FallbackRow to a single pass per row; memoize hasCompactMeta in MobileCardList
- memoize hideable columns list in DataTableViewOptions and colSpan in DataTableView
- remove tableClassName and colgroup from scroll-sync effect deps; cache toolbar button NodeList via useLayoutEffect to avoid per-keydown DOM queries

* perf(data-table): replace scroll-sync split header with CSS sticky

- remove JS scroll-sync effect and event listener between split header and body containers.
- merge separate header/body tables into a single scrollable table element, reducing DOM complexity.
- apply CSS sticky positioning to the header for a simpler, hardware-accelerated freeze effect.

* fix(data-table): replace opacity muted colors with color-mix

- switch from bg-muted/50 and bg-muted/30 to color-mix(in oklch) to produce opaque blended backgrounds that prevent scroll content from showing through pinned cells.
- expose --table-header-bg CSS variable so pinned header cells inherit the exact same computed color as the thead background.
- add group class to TableRow to enable group-hover selectors on pinned cell styles.

* feat(data-table): support column pinning via meta.pinned

- add pinned?: 'left' | 'right' to ColumnMeta so pinning is declared once in the column definition and applies to both header and body automatically
- DataTableView derives pinnedColumns from meta.pinned at runtime, merged with any explicit pinnedColumns prop; explicit entries take precedence
- add header and meta.pinned: 'right' to all actions columns across channels, users, api-keys, redemption-codes, models, deployments, and subscriptions tables

* style(row-actions): align action buttons to leading edge of column

* refactor(data-table): extract BadgeListCell and centralize badge alignment

- add BadgeListCell component to data-table for badge lists with overflow tooltip, replacing duplicated renderLimitedItems helpers in channels, models, and pricing columns
- move StatusBadge -ml-1.5 alignment into the component itself via a table-cell context selector, so callers no longer need manual offset wrappers
- remove the table-cell-level -ml-1.5 selector from TableCell now that alignment is handled by StatusBadge directly

* style(row-actions): offset action buttons to align with column header text

* refactor(data-table): consolidate mobile meta into ColumnMeta declaration

- move mobileTitle, mobileBadge, mobileHidden into the global ColumnMeta augmentation so the type is shared across the project
- remove the local MobileColumnMeta interface and getCellMeta helper from mobile-card-list.tsx
- direct col.columnDef.meta access is now type-safe without explicit casting

* refactor(data-table): simplify column header and meta config

- auto-render string `header` values via DataTableColumnHeader so sortable/non-sortable columns work without boilerplate function wrappers
- promote mobile layout hints (mobileTitle, mobileBadge, mobileHidden) into the global ColumnMeta type, removing the local MobileColumnMeta cast in mobile-card-list
- migrate all column files from `meta: { label }` to top-level `header: t('...')`, cutting ~180 lines of repetitive template code
- ViewOptions and MobileCardList label resolution now reads string header first, then meta.label as fallback

* feat(status-badge): add text and underline display types

- introduce StatusBadgeType ('badge' | 'text' | 'underline') and StatusBadgeTypeContext so ancestors can override rendering without touching call sites
- mobile card field rows now use the text type via context, showing badges as plain colored text instead of pills
- ProviderBadge gains data-slot='provider-badge' to enable targeted CSS resets in compact layouts
- replace the implicit [[data-slot=table-cell]>&]:-ml-1.5 rule with explicit -ml-1.5 at each column call site

* refactor(data-table): simplify table filtering internals

- derive toolbar search state from the active table filter to avoid render-time ref writes.
- extract faceted filter selection updates into a pure helper for clearer single and multi-select behavior.
- split pinned column resolution into focused helpers so explicit and meta pins merge predictably.

Co-authored-by: t0ng7u <dev@aiass.cc>
2026-06-12 23:18:22 +08:00

1005 lines
30 KiB
TypeScript
Vendored
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 { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { type ColumnDef } from '@tanstack/react-table'
import {
AlertTriangle,
ChevronDown,
ChevronRight,
ListOrdered,
Shuffle,
SlidersHorizontal,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { getCurrencyLabel } from '@/lib/currency'
import {
formatTimestampToDate,
formatQuota as formatQuotaValue,
} from '@/lib/format'
import { truncateText } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
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 } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { TruncatedText } from '@/components/truncated-text'
import { getCodexUsage } from '../api'
import { CHANNEL_STATUS_CONFIG, MODEL_FETCHABLE_TYPES } from '../constants'
import {
formatBalance,
formatRelativeTime,
formatResponseTime,
getBalanceVariant,
getChannelTypeIcon,
getChannelTypeLabel,
getResponseTimeConfig,
isMultiKeyChannel,
parseModelsList,
parseGroupsList,
parseChannelSettings,
handleUpdateChannelField,
handleUpdateTagField,
handleUpdateChannelBalance,
isTagAggregateRow,
type TagRow,
} from '../lib'
import { parseUpstreamUpdateMeta } from '../lib/upstream-update-utils'
import type { Channel } from '../types'
import { useChannels } from './channels-provider'
import { DataTableRowActions } from './data-table-row-actions'
import { DataTableTagRowActions } from './data-table-tag-row-actions'
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 }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const isTagRow = isTagAggregateRow(channel)
const priority = channel.priority
const [confirmOpen, setConfirmOpen] = useState(false)
const [pendingValue, setPendingValue] = useState<number | null>(null)
// Tag row - editable with confirmation for all tag channels
if (isTagRow) {
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={`This will update the priority to ${pendingValue} for all ${channelCount} channel(s) with tag "${tag}". Continue?`}
confirmText='Update'
handleConfirm={() => {
if (pendingValue !== null) {
handleUpdateTagField(tag, 'priority', pendingValue, queryClient)
}
setConfirmOpen(false)
}}
/>
</>
)
}
// Regular channel row - editable
return (
<NumericSpinnerInput
value={priority ?? 0}
onChange={(value) => {
handleUpdateChannelField(channel.id, 'priority', value, queryClient)
}}
min={-999}
/>
)
}
/**
* Weight cell component with inline editing
*/
function WeightCell({ channel }: { channel: Channel }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const isTagRow = isTagAggregateRow(channel)
const weight = channel.weight
const [confirmOpen, setConfirmOpen] = useState(false)
const [pendingValue, setPendingValue] = useState<number | null>(null)
// Tag row - editable with confirmation for all tag channels
if (isTagRow) {
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={`This will update the weight to ${pendingValue} for all ${channelCount} channel(s) with tag "${tag}". Continue?`}
confirmText='Update'
handleConfirm={() => {
if (pendingValue !== null) {
handleUpdateTagField(tag, 'weight', pendingValue, queryClient)
}
setConfirmOpen(false)
}}
/>
</>
)
}
// Regular channel row - editable
return (
<NumericSpinnerInput
value={weight ?? 0}
onChange={(value) => {
handleUpdateChannelField(channel.id, 'weight', value, queryClient)
}}
min={0}
/>
)
}
/**
* Balance cell component with click to update
*/
function BalanceCell({ channel }: { channel: Channel }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const isTagRow = isTagAggregateRow(channel)
const balance = channel.balance || 0
const usedQuota = channel.used_quota || 0
const [isUpdating, setIsUpdating] = useState(false)
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 usedDisplay = withSuffix(formatQuotaValue(usedQuota))
const remainingDisplay = withSuffix(formatBalance(balance))
const usedLabel = `${t('Used:')} ${usedDisplay}`
const remainingLabel = `${t('Remaining:')} ${remainingDisplay}`
// Tag row: only show cumulative used quota
if (isTagRow) {
return (
<StatusBadge
label={usedLabel}
variant='neutral'
size='sm'
copyable={false}
showDot={false}
className='-ml-1.5'
/>
)
}
// 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
}
await handleUpdateChannelBalance(channel.id, queryClient)
setIsUpdating(false)
}
return (
<TooltipProvider>
<div className='-ml-1.5 flex items-center gap-1'>
<Tooltip>
<TooltipTrigger
render={
<StatusBadge
label={usedDisplay}
variant='neutral'
size='sm'
copyable={false}
showDot={false}
className='cursor-help'
/>
}
/>
<TooltipContent>
<p>{usedLabel}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<StatusBadge
label={
isUpdating
? t('Updating...')
: channel.type === 57
? t('Account Info')
: remainingDisplay
}
variant={
channel.type === 57
? 'info'
: isUpdating
? 'neutral'
: variant
}
size='sm'
copyable={false}
showDot={false}
className='cursor-pointer'
onClick={handleClickUpdate}
/>
}
/>
<TooltipContent>
<p>
{channel.type === 57
? t('Click to view Codex usage')
: remainingLabel}
</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}
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}
/>
</TooltipProvider>
)
}
/**
* Generate channels columns configuration
*/
export function useChannelsColumns(): ColumnDef<Channel>[] {
const { t } = useTranslation()
return [
// Checkbox column
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label='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='Select row'
/>
)
},
enableSorting: false,
enableHiding: false,
size: 40,
},
// ID column
{
accessorKey: 'id',
header: t('ID'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const id = row.getValue('id') as number
return <TableId value={id} />
},
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 items-center gap-2'>
<div className='flex flex-col gap-1'>
<div className='flex items-center gap-1.5'>
<TruncatedText
text={name}
className='font-medium'
maxWidth='max-w-[180px]'
/>
{isPassThrough && (
<TooltipProvider delay={100}>
<Tooltip>
<TooltipTrigger
render={
<AlertTriangle className='h-3.5 w-3.5 flex-shrink-0 text-amber-500' />
}
></TooltipTrigger>
<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' />
}
></TooltipTrigger>
<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>
)
},
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 items-center gap-2'>
{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>
)}
<ProviderBadge
iconKey={iconName}
label={typeName}
copyable={false}
showDot={false}
/>
{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: 140,
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}
className='-ml-1.5'
/>
)
},
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, idx) => (
<StatusBadge
key={idx}
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} 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)
const fullDate = formatTimestampToDate(testTime)
// For valid timestamps, show tooltip with full date
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<span className='text-muted-foreground cursor-pointer font-mono text-sm' />
}
>
{timeText}
</TooltipTrigger>
<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} />
},
size: 132,
enableSorting: false,
enableHiding: false,
meta: { pinned: 'right' as const },
},
]
}