channels-columns.tsx 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044
  1. /* eslint-disable react-refresh/only-export-components */
  2. import { useState } from 'react'
  3. import { useQueryClient } from '@tanstack/react-query'
  4. import { type ColumnDef } from '@tanstack/react-table'
  5. import {
  6. AlertTriangle,
  7. ChevronDown,
  8. ChevronRight,
  9. ListOrdered,
  10. Shuffle,
  11. } from 'lucide-react'
  12. import { useTranslation } from 'react-i18next'
  13. import { toast } from 'sonner'
  14. import { getCurrencyLabel } from '@/lib/currency'
  15. import {
  16. formatTimestampToDate,
  17. formatQuota as formatQuotaValue,
  18. } from '@/lib/format'
  19. import { getLobeIcon } from '@/lib/lobe-icon'
  20. import { cn, truncateText } from '@/lib/utils'
  21. import { Button } from '@/components/ui/button'
  22. import { Checkbox } from '@/components/ui/checkbox'
  23. import {
  24. Tooltip,
  25. TooltipContent,
  26. TooltipProvider,
  27. TooltipTrigger,
  28. } from '@/components/ui/tooltip'
  29. import { ConfirmDialog } from '@/components/confirm-dialog'
  30. import { DataTableColumnHeader } from '@/components/data-table/column-header'
  31. import { GroupBadge } from '@/components/group-badge'
  32. import {
  33. StatusBadge,
  34. dotColorMap,
  35. textColorMap,
  36. } from '@/components/status-badge'
  37. import { getCodexUsage } from '../api'
  38. import { CHANNEL_STATUS_CONFIG, MODEL_FETCHABLE_TYPES } from '../constants'
  39. import {
  40. formatBalance,
  41. formatRelativeTime,
  42. formatResponseTime,
  43. getBalanceVariant,
  44. getChannelTypeIcon,
  45. getChannelTypeLabel,
  46. getResponseTimeConfig,
  47. isMultiKeyChannel,
  48. parseModelsList,
  49. parseGroupsList,
  50. parseChannelSettings,
  51. handleUpdateChannelField,
  52. handleUpdateTagField,
  53. handleUpdateChannelBalance,
  54. isTagAggregateRow,
  55. type TagRow,
  56. } from '../lib'
  57. import { parseUpstreamUpdateMeta } from '../lib/upstream-update-utils'
  58. import type { Channel } from '../types'
  59. import { useChannels } from './channels-provider'
  60. import { DataTableRowActions } from './data-table-row-actions'
  61. import { DataTableTagRowActions } from './data-table-tag-row-actions'
  62. import {
  63. CodexUsageDialog,
  64. type CodexUsageDialogData,
  65. } from './dialogs/codex-usage-dialog'
  66. import { NumericSpinnerInput } from './numeric-spinner-input'
  67. function parseIonetMeta(otherInfo: string | null | undefined): null | {
  68. source?: string
  69. deployment_id?: string
  70. } {
  71. if (!otherInfo) return null
  72. try {
  73. const parsed = JSON.parse(otherInfo)
  74. if (parsed && typeof parsed === 'object') {
  75. return parsed
  76. }
  77. } catch {
  78. return null
  79. }
  80. return null
  81. }
  82. /**
  83. * Render limited items with "and X more" indicator
  84. */
  85. function renderLimitedItems(
  86. items: React.ReactNode[],
  87. maxDisplay: number = 2
  88. ): React.ReactNode {
  89. if (items.length === 0)
  90. return <span className='text-muted-foreground text-xs'>-</span>
  91. const displayed = items.slice(0, maxDisplay)
  92. const remaining = items.length - maxDisplay
  93. return (
  94. <div className='flex max-w-full items-center gap-1 overflow-hidden'>
  95. {displayed}
  96. {remaining > 0 && (
  97. <StatusBadge
  98. label={`+${remaining}`}
  99. variant='neutral'
  100. size='sm'
  101. copyable={false}
  102. className='flex-shrink-0'
  103. />
  104. )}
  105. </div>
  106. )
  107. }
  108. /**
  109. * Upstream update tags (+N / -N) shown on channel name for model-fetchable channels
  110. */
  111. function UpstreamUpdateTags({ channel }: { channel: Channel }) {
  112. const { upstream, setCurrentRow } = useChannels()
  113. if (!MODEL_FETCHABLE_TYPES.has(channel.type)) return null
  114. const meta = parseUpstreamUpdateMeta(channel.settings)
  115. if (!meta.enabled) return null
  116. const addCount = meta.pendingAddModels.length
  117. const removeCount = meta.pendingRemoveModels.length
  118. if (addCount === 0 && removeCount === 0) return null
  119. return (
  120. <div className='flex items-center gap-0.5'>
  121. {addCount > 0 && (
  122. <StatusBadge
  123. label={`+${addCount}`}
  124. variant='success'
  125. size='sm'
  126. copyable={false}
  127. className='cursor-pointer'
  128. onClick={(e: React.MouseEvent) => {
  129. e.stopPropagation()
  130. setCurrentRow(channel)
  131. upstream.openModal(
  132. channel,
  133. meta.pendingAddModels,
  134. meta.pendingRemoveModels,
  135. 'add'
  136. )
  137. }}
  138. />
  139. )}
  140. {removeCount > 0 && (
  141. <StatusBadge
  142. label={`-${removeCount}`}
  143. variant='danger'
  144. size='sm'
  145. copyable={false}
  146. className='cursor-pointer'
  147. onClick={(e: React.MouseEvent) => {
  148. e.stopPropagation()
  149. setCurrentRow(channel)
  150. upstream.openModal(
  151. channel,
  152. meta.pendingAddModels,
  153. meta.pendingRemoveModels,
  154. 'remove'
  155. )
  156. }}
  157. />
  158. )}
  159. </div>
  160. )
  161. }
  162. /**
  163. * Priority cell component with inline editing
  164. */
  165. function PriorityCell({ channel }: { channel: Channel }) {
  166. const { t } = useTranslation()
  167. const queryClient = useQueryClient()
  168. const isTagRow = isTagAggregateRow(channel)
  169. const priority = channel.priority
  170. const [confirmOpen, setConfirmOpen] = useState(false)
  171. const [pendingValue, setPendingValue] = useState<number | null>(null)
  172. // Tag row - editable with confirmation for all tag channels
  173. if (isTagRow) {
  174. const tag = channel.tag || ''
  175. const channelCount = channel.children?.length || 0
  176. return (
  177. <>
  178. <NumericSpinnerInput
  179. value={priority ?? 0}
  180. onChange={(value) => {
  181. setPendingValue(value)
  182. setConfirmOpen(true)
  183. }}
  184. min={-999}
  185. />
  186. <ConfirmDialog
  187. open={confirmOpen}
  188. onOpenChange={setConfirmOpen}
  189. title={t('Confirm Batch Update')}
  190. desc={`This will update the priority to ${pendingValue} for all ${channelCount} channel(s) with tag "${tag}". Continue?`}
  191. confirmText='Update'
  192. handleConfirm={() => {
  193. if (pendingValue !== null) {
  194. handleUpdateTagField(tag, 'priority', pendingValue, queryClient)
  195. }
  196. setConfirmOpen(false)
  197. }}
  198. />
  199. </>
  200. )
  201. }
  202. // Regular channel row - editable
  203. return (
  204. <NumericSpinnerInput
  205. value={priority ?? 0}
  206. onChange={(value) => {
  207. handleUpdateChannelField(channel.id, 'priority', value, queryClient)
  208. }}
  209. min={-999}
  210. />
  211. )
  212. }
  213. /**
  214. * Weight cell component with inline editing
  215. */
  216. function WeightCell({ channel }: { channel: Channel }) {
  217. const { t } = useTranslation()
  218. const queryClient = useQueryClient()
  219. const isTagRow = isTagAggregateRow(channel)
  220. const weight = channel.weight
  221. const [confirmOpen, setConfirmOpen] = useState(false)
  222. const [pendingValue, setPendingValue] = useState<number | null>(null)
  223. // Tag row - editable with confirmation for all tag channels
  224. if (isTagRow) {
  225. const tag = channel.tag || ''
  226. const channelCount = channel.children?.length || 0
  227. return (
  228. <>
  229. <NumericSpinnerInput
  230. value={weight ?? 0}
  231. onChange={(value) => {
  232. setPendingValue(value)
  233. setConfirmOpen(true)
  234. }}
  235. min={0}
  236. />
  237. <ConfirmDialog
  238. open={confirmOpen}
  239. onOpenChange={setConfirmOpen}
  240. title={t('Confirm Batch Update')}
  241. desc={`This will update the weight to ${pendingValue} for all ${channelCount} channel(s) with tag "${tag}". Continue?`}
  242. confirmText='Update'
  243. handleConfirm={() => {
  244. if (pendingValue !== null) {
  245. handleUpdateTagField(tag, 'weight', pendingValue, queryClient)
  246. }
  247. setConfirmOpen(false)
  248. }}
  249. />
  250. </>
  251. )
  252. }
  253. // Regular channel row - editable
  254. return (
  255. <NumericSpinnerInput
  256. value={weight ?? 0}
  257. onChange={(value) => {
  258. handleUpdateChannelField(channel.id, 'weight', value, queryClient)
  259. }}
  260. min={0}
  261. />
  262. )
  263. }
  264. /**
  265. * Balance cell component with click to update
  266. */
  267. function BalanceCell({ channel }: { channel: Channel }) {
  268. const { t } = useTranslation()
  269. const queryClient = useQueryClient()
  270. const isTagRow = isTagAggregateRow(channel)
  271. const balance = channel.balance || 0
  272. const usedQuota = channel.used_quota || 0
  273. const [isUpdating, setIsUpdating] = useState(false)
  274. const [codexUsageOpen, setCodexUsageOpen] = useState(false)
  275. const [codexUsageResponse, setCodexUsageResponse] =
  276. useState<CodexUsageDialogData | null>(null)
  277. const currencyLabel = getCurrencyLabel()
  278. const tokenSuffix = currencyLabel === 'Tokens' ? ' Tokens' : ''
  279. const withSuffix = (value: string) =>
  280. tokenSuffix && value !== '-' ? `${value}${tokenSuffix}` : value
  281. const usedDisplay = withSuffix(formatQuotaValue(usedQuota))
  282. const remainingDisplay = withSuffix(formatBalance(balance))
  283. // Tag row: only show cumulative used quota
  284. if (isTagRow) {
  285. return (
  286. <StatusBadge
  287. label={`Used: ${usedDisplay}`}
  288. variant='neutral'
  289. size='sm'
  290. copyable={false}
  291. />
  292. )
  293. }
  294. // Regular channel row: show used and remaining with click to update
  295. const variant = getBalanceVariant(balance)
  296. const handleClickUpdate = async () => {
  297. if (isUpdating) return
  298. setIsUpdating(true)
  299. if (channel.type === 57) {
  300. try {
  301. const res = await getCodexUsage(channel.id)
  302. if (!res.success) {
  303. throw new Error(res.message || t('Failed to fetch usage'))
  304. }
  305. setCodexUsageResponse(res)
  306. setCodexUsageOpen(true)
  307. } catch (error) {
  308. toast.error(
  309. error instanceof Error ? error.message : t('Failed to fetch usage')
  310. )
  311. } finally {
  312. setIsUpdating(false)
  313. }
  314. return
  315. }
  316. await handleUpdateChannelBalance(channel.id, queryClient)
  317. setIsUpdating(false)
  318. }
  319. return (
  320. <TooltipProvider>
  321. <div className='flex items-center gap-1.5 text-xs font-medium'>
  322. <span
  323. className={cn(
  324. 'size-1.5 shrink-0 rounded-full',
  325. dotColorMap[isUpdating ? 'neutral' : variant]
  326. )}
  327. aria-hidden='true'
  328. />
  329. <Tooltip>
  330. <TooltipTrigger asChild>
  331. <span className='text-muted-foreground cursor-help'>
  332. {usedDisplay}
  333. </span>
  334. </TooltipTrigger>
  335. <TooltipContent>
  336. <p>
  337. {t('Used:')} {usedDisplay}
  338. </p>
  339. </TooltipContent>
  340. </Tooltip>
  341. <span className='text-muted-foreground/30'>·</span>
  342. <Tooltip>
  343. <TooltipTrigger asChild>
  344. <span
  345. className={cn(
  346. 'cursor-pointer transition-opacity hover:opacity-70',
  347. channel.type === 57
  348. ? 'text-primary'
  349. : textColorMap[isUpdating ? 'neutral' : variant]
  350. )}
  351. onClick={handleClickUpdate}
  352. >
  353. {isUpdating
  354. ? 'Updating...'
  355. : channel.type === 57
  356. ? t('Account Info')
  357. : remainingDisplay}
  358. </span>
  359. </TooltipTrigger>
  360. <TooltipContent>
  361. <p>
  362. {channel.type === 57
  363. ? t('Click to view Codex usage')
  364. : `${t('Remaining:')} ${remainingDisplay}`}
  365. </p>
  366. {channel.type !== 57 && <p>{t('Click to update balance')}</p>}
  367. </TooltipContent>
  368. </Tooltip>
  369. </div>
  370. <CodexUsageDialog
  371. open={codexUsageOpen}
  372. onOpenChange={setCodexUsageOpen}
  373. channelName={channel.name}
  374. channelId={channel.id}
  375. response={codexUsageResponse}
  376. onRefresh={async () => {
  377. if (isUpdating) return
  378. setIsUpdating(true)
  379. try {
  380. const res = await getCodexUsage(channel.id)
  381. if (!res.success) {
  382. throw new Error(res.message || t('Failed to fetch usage'))
  383. }
  384. setCodexUsageResponse(res)
  385. } catch (error) {
  386. toast.error(
  387. error instanceof Error
  388. ? error.message
  389. : t('Failed to fetch usage')
  390. )
  391. } finally {
  392. setIsUpdating(false)
  393. }
  394. }}
  395. isRefreshing={isUpdating}
  396. />
  397. </TooltipProvider>
  398. )
  399. }
  400. /**
  401. * Generate channels columns configuration
  402. */
  403. export function useChannelsColumns(): ColumnDef<Channel>[] {
  404. const { t } = useTranslation()
  405. return [
  406. // Checkbox column
  407. {
  408. id: 'select',
  409. header: ({ table }) => (
  410. <Checkbox
  411. checked={
  412. table.getIsAllPageRowsSelected() ||
  413. (table.getIsSomePageRowsSelected() && 'indeterminate')
  414. }
  415. onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
  416. aria-label='Select all'
  417. />
  418. ),
  419. cell: ({ row }) => {
  420. const isTagRow = isTagAggregateRow(row.original)
  421. // Don't show checkbox for tag rows
  422. if (isTagRow) {
  423. return null
  424. }
  425. return (
  426. <Checkbox
  427. checked={row.getIsSelected()}
  428. onCheckedChange={(value) => row.toggleSelected(!!value)}
  429. aria-label='Select row'
  430. />
  431. )
  432. },
  433. enableSorting: false,
  434. enableHiding: false,
  435. size: 40,
  436. },
  437. // ID column
  438. {
  439. accessorKey: 'id',
  440. meta: { label: t('ID'), mobileHidden: true },
  441. header: ({ column }) => (
  442. <DataTableColumnHeader column={column} title='ID' />
  443. ),
  444. cell: ({ row }) => {
  445. const id = row.getValue('id') as number
  446. return (
  447. <StatusBadge
  448. label={String(id)}
  449. variant='neutral'
  450. copyText={String(id)}
  451. size='sm'
  452. className='font-mono'
  453. />
  454. )
  455. },
  456. size: 80,
  457. },
  458. // Name column
  459. {
  460. accessorKey: 'name',
  461. meta: { label: t('Name'), mobileTitle: true },
  462. header: ({ column }) => (
  463. <DataTableColumnHeader column={column} title={t('Name')} />
  464. ),
  465. cell: ({ row }) => {
  466. const isTagRow = isTagAggregateRow(row.original)
  467. const name = row.getValue('name') as string
  468. const channel = row.original
  469. const isMultiKey = isMultiKeyChannel(channel)
  470. // Tag row with expand/collapse
  471. if (isTagRow) {
  472. const tag = (row.original as TagRow).tag || name
  473. const childrenCount = (row.original as TagRow).children?.length || 0
  474. return (
  475. <div className='flex items-center gap-2'>
  476. <Button
  477. variant='ghost'
  478. size='sm'
  479. className='h-6 w-6 p-0'
  480. onClick={row.getToggleExpandedHandler()}
  481. >
  482. {row.getIsExpanded() ? (
  483. <ChevronDown className='h-4 w-4' />
  484. ) : (
  485. <ChevronRight className='h-4 w-4' />
  486. )}
  487. </Button>
  488. <div className='flex items-center gap-1.5'>
  489. <span className='font-semibold'>Tag:{tag}</span>
  490. <StatusBadge
  491. label={`${childrenCount} channels`}
  492. variant='blue'
  493. size='sm'
  494. copyable={false}
  495. />
  496. </div>
  497. </div>
  498. )
  499. }
  500. // Regular channel row
  501. const settings = parseChannelSettings(channel.setting)
  502. const isPassThrough = settings.pass_through_body_enabled === true
  503. return (
  504. <div className='flex items-center gap-2'>
  505. <div className='flex flex-col gap-1'>
  506. <div className='flex items-center gap-1.5'>
  507. <span className='font-medium'>{truncateText(name, 30)}</span>
  508. {isPassThrough && (
  509. <TooltipProvider delayDuration={100}>
  510. <Tooltip>
  511. <TooltipTrigger asChild>
  512. <AlertTriangle className='h-3.5 w-3.5 flex-shrink-0 text-amber-500' />
  513. </TooltipTrigger>
  514. <TooltipContent side='top'>
  515. {t(
  516. 'Request body pass-through is enabled. The request body will be sent directly to the upstream without any conversion.'
  517. )}
  518. </TooltipContent>
  519. </Tooltip>
  520. </TooltipProvider>
  521. )}
  522. {isMultiKey && (
  523. <StatusBadge
  524. label={`${channel.channel_info.multi_key_size} keys`}
  525. variant='purple'
  526. size='sm'
  527. copyable={false}
  528. />
  529. )}
  530. <UpstreamUpdateTags channel={channel} />
  531. </div>
  532. {channel.remark && (
  533. <TooltipProvider delayDuration={200}>
  534. <Tooltip>
  535. <TooltipTrigger asChild>
  536. <span className='text-muted-foreground text-xs'>
  537. {truncateText(channel.remark, 40)}
  538. </span>
  539. </TooltipTrigger>
  540. <TooltipContent side='bottom' className='max-w-xs'>
  541. {channel.remark}
  542. </TooltipContent>
  543. </Tooltip>
  544. </TooltipProvider>
  545. )}
  546. </div>
  547. </div>
  548. )
  549. },
  550. minSize: 200,
  551. },
  552. // Type column
  553. {
  554. accessorKey: 'type',
  555. meta: { label: t('Type') },
  556. header: t('Type'),
  557. cell: ({ row }) => {
  558. const isTagRow = isTagAggregateRow(row.original)
  559. if (isTagRow) {
  560. return (
  561. <StatusBadge
  562. label={t('Tag Aggregate')}
  563. variant='blue'
  564. size='sm'
  565. copyable={false}
  566. />
  567. )
  568. }
  569. const type = row.getValue('type') as number
  570. const typeNameKey = getChannelTypeLabel(type)
  571. const typeName = t(typeNameKey)
  572. const iconName = getChannelTypeIcon(type)
  573. const icon = getLobeIcon(`${iconName}.Color`, 20)
  574. const channel = row.original as Channel
  575. const isMultiKey = isMultiKeyChannel(channel)
  576. const multiKeyMode = channel.channel_info?.multi_key_mode ?? 'random'
  577. const MultiKeyModeIcon =
  578. multiKeyMode === 'random' ? Shuffle : ListOrdered
  579. const multiKeyTooltip =
  580. multiKeyMode === 'random'
  581. ? t('Multi-key: Random rotation')
  582. : t('Multi-key: Polling rotation')
  583. const ionetMeta = parseIonetMeta(channel.other_info)
  584. const isIonet = ionetMeta?.source === 'ionet'
  585. const deploymentId =
  586. typeof ionetMeta?.deployment_id === 'string'
  587. ? ionetMeta?.deployment_id
  588. : undefined
  589. return (
  590. <div className='flex items-center gap-2'>
  591. <div className='flex items-center gap-1.5'>
  592. {isMultiKey && (
  593. <TooltipProvider delayDuration={100}>
  594. <Tooltip>
  595. <TooltipTrigger asChild>
  596. <span className='border-border bg-muted text-primary inline-flex h-6 w-6 items-center justify-center rounded-full border'>
  597. <MultiKeyModeIcon className='h-3.5 w-3.5' />
  598. </span>
  599. </TooltipTrigger>
  600. <TooltipContent side='top'>
  601. {multiKeyTooltip}
  602. </TooltipContent>
  603. </Tooltip>
  604. </TooltipProvider>
  605. )}
  606. {icon}
  607. </div>
  608. <StatusBadge
  609. label={typeName}
  610. autoColor={typeName}
  611. size='sm'
  612. copyable={false}
  613. />
  614. {isIonet && (
  615. <TooltipProvider delayDuration={100}>
  616. <Tooltip>
  617. <TooltipTrigger asChild>
  618. <span
  619. className='flex cursor-pointer items-center gap-1.5 text-xs font-medium'
  620. onClick={(e) => {
  621. e.stopPropagation()
  622. if (!deploymentId) return
  623. const targetUrl = `/console/deployment?deployment_id=${deploymentId}`
  624. window.open(targetUrl, '_blank', 'noopener')
  625. }}
  626. >
  627. <span className='text-muted-foreground/30'>·</span>
  628. <span className={cn(textColorMap.purple)}>IO.NET</span>
  629. </span>
  630. </TooltipTrigger>
  631. <TooltipContent side='top'>
  632. <div className='max-w-xs space-y-1'>
  633. <div className='text-xs'>
  634. {t('From IO.NET deployment')}
  635. </div>
  636. {deploymentId && (
  637. <div className='text-muted-foreground font-mono text-xs'>
  638. {t('Deployment ID')}: {deploymentId}
  639. </div>
  640. )}
  641. <div className='text-muted-foreground text-xs'>
  642. {t('Click to open deployment')}
  643. </div>
  644. </div>
  645. </TooltipContent>
  646. </Tooltip>
  647. </TooltipProvider>
  648. )}
  649. </div>
  650. )
  651. },
  652. filterFn: (row, id, value) => {
  653. if (!value || value.length === 0 || value.includes('all')) return true
  654. return value.includes(String(row.getValue(id)))
  655. },
  656. size: 140,
  657. enableSorting: false,
  658. },
  659. // Status column
  660. {
  661. accessorKey: 'status',
  662. meta: { label: t('Status'), mobileBadge: true },
  663. header: t('Status'),
  664. cell: ({ row }) => {
  665. const isTagRow = isTagAggregateRow(row.original)
  666. const status = row.getValue('status') as number
  667. const channel = row.original as Channel
  668. // Tag row: show aggregated status
  669. if (isTagRow) {
  670. const childrenCount = (row.original as TagRow).children?.length || 0
  671. const hasEnabled = status === 1
  672. if (hasEnabled) {
  673. return (
  674. <StatusBadge
  675. label={`Active (${childrenCount})`}
  676. variant='success'
  677. showDot
  678. size='sm'
  679. copyable={false}
  680. />
  681. )
  682. } else {
  683. return (
  684. <StatusBadge
  685. label={`Inactive (${childrenCount})`}
  686. variant='neutral'
  687. size='sm'
  688. copyable={false}
  689. />
  690. )
  691. }
  692. }
  693. // Regular channel row
  694. const config =
  695. CHANNEL_STATUS_CONFIG[status as keyof typeof CHANNEL_STATUS_CONFIG] ||
  696. CHANNEL_STATUS_CONFIG[0]
  697. const isMultiKey = isMultiKeyChannel(channel)
  698. const keySize = channel.channel_info?.multi_key_size ?? 0
  699. const disabledCount = channel.channel_info?.multi_key_status_list
  700. ? Object.keys(channel.channel_info.multi_key_status_list).length
  701. : 0
  702. const enabledCount = Math.max(0, keySize - disabledCount)
  703. const label =
  704. isMultiKey && keySize > 0
  705. ? `${t(config.label)} (${enabledCount}/${keySize})`
  706. : t(config.label)
  707. // Auto-disabled: show reason and time tooltip
  708. if (status === 3) {
  709. let statusReason = ''
  710. let statusTime = ''
  711. try {
  712. const otherInfo = channel.other_info
  713. ? JSON.parse(channel.other_info)
  714. : null
  715. if (otherInfo) {
  716. statusReason = otherInfo.status_reason || ''
  717. statusTime = otherInfo.status_time
  718. ? formatTimestampToDate(otherInfo.status_time)
  719. : ''
  720. }
  721. } catch {
  722. /* empty */
  723. }
  724. if (statusReason || statusTime) {
  725. return (
  726. <TooltipProvider delayDuration={100}>
  727. <Tooltip>
  728. <TooltipTrigger asChild>
  729. <span>
  730. <StatusBadge
  731. label={label}
  732. variant={config.variant}
  733. showDot={config.showDot}
  734. size='sm'
  735. copyable={false}
  736. />
  737. </span>
  738. </TooltipTrigger>
  739. <TooltipContent side='top' className='max-w-xs'>
  740. <div className='space-y-1 text-xs'>
  741. {statusReason && (
  742. <div>
  743. {t('Reason:')} {statusReason}
  744. </div>
  745. )}
  746. {statusTime && (
  747. <div>
  748. {t('Time:')} {statusTime}
  749. </div>
  750. )}
  751. </div>
  752. </TooltipContent>
  753. </Tooltip>
  754. </TooltipProvider>
  755. )
  756. }
  757. }
  758. return (
  759. <StatusBadge
  760. label={label}
  761. variant={config.variant}
  762. showDot={config.showDot}
  763. size='sm'
  764. copyable={false}
  765. />
  766. )
  767. },
  768. filterFn: (row, id, value) => {
  769. if (!value || value.length === 0 || value.includes('all')) return true
  770. const status = row.getValue(id) as number
  771. if (value.includes('enabled')) return status === 1
  772. if (value.includes('disabled')) return status !== 1
  773. return false
  774. },
  775. size: 120,
  776. enableSorting: false,
  777. },
  778. // Models column
  779. {
  780. accessorKey: 'models',
  781. meta: { label: t('Models'), mobileHidden: true },
  782. header: t('Models'),
  783. cell: ({ row }) => {
  784. const models = row.getValue('models') as string
  785. const modelArray = parseModelsList(models)
  786. if (modelArray.length === 0) {
  787. return <span className='text-muted-foreground text-xs'>-</span>
  788. }
  789. const modelBadges = modelArray.map((model, idx) => (
  790. <StatusBadge
  791. key={idx}
  792. label={model}
  793. autoColor={model}
  794. size='sm'
  795. className='font-mono'
  796. />
  797. ))
  798. return (
  799. <TooltipProvider>
  800. <Tooltip>
  801. <TooltipTrigger asChild>
  802. <div>{renderLimitedItems(modelBadges, 2)}</div>
  803. </TooltipTrigger>
  804. {modelArray.length > 2 && (
  805. <TooltipContent
  806. side='top'
  807. className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
  808. >
  809. <div className='flex flex-wrap gap-1'>{modelBadges}</div>
  810. </TooltipContent>
  811. )}
  812. </Tooltip>
  813. </TooltipProvider>
  814. )
  815. },
  816. size: 200,
  817. enableSorting: false,
  818. },
  819. // Group column
  820. {
  821. accessorKey: 'group',
  822. meta: { label: t('Groups'), mobileHidden: true },
  823. header: t('Groups'),
  824. cell: ({ row }) => {
  825. const group = row.getValue('group') as string
  826. const groupArray = parseGroupsList(group)
  827. const groupBadges = groupArray.map((g) => (
  828. <GroupBadge key={g} group={g} size='sm' />
  829. ))
  830. return (
  831. <TooltipProvider>
  832. <Tooltip>
  833. <TooltipTrigger asChild>
  834. <div>{renderLimitedItems(groupBadges, 2)}</div>
  835. </TooltipTrigger>
  836. {groupArray.length > 2 && (
  837. <TooltipContent
  838. side='top'
  839. className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
  840. >
  841. <div className='flex flex-wrap gap-1'>{groupBadges}</div>
  842. </TooltipContent>
  843. )}
  844. </Tooltip>
  845. </TooltipProvider>
  846. )
  847. },
  848. filterFn: (row, id, value) => {
  849. if (!value || value.length === 0 || value.includes('all')) return true
  850. const group = row.getValue(id) as string
  851. const groupArray = parseGroupsList(group)
  852. return groupArray.some((g) => value.includes(g))
  853. },
  854. size: 150,
  855. enableSorting: false,
  856. },
  857. // Tag column
  858. {
  859. accessorKey: 'tag',
  860. meta: { label: t('Tag'), mobileHidden: true },
  861. header: t('Tag'),
  862. cell: ({ row }) => {
  863. const tag = row.getValue('tag') as string | null
  864. if (!tag)
  865. return <span className='text-muted-foreground text-xs'>-</span>
  866. return <StatusBadge label={tag} autoColor={tag} size='sm' />
  867. },
  868. size: 120,
  869. enableSorting: false,
  870. },
  871. // Priority column
  872. {
  873. accessorKey: 'priority',
  874. meta: { label: t('Priority'), mobileHidden: true },
  875. header: ({ column }) => (
  876. <DataTableColumnHeader column={column} title={t('Priority')} />
  877. ),
  878. cell: ({ row }) => <PriorityCell channel={row.original} />,
  879. size: 100,
  880. },
  881. // Weight column
  882. {
  883. accessorKey: 'weight',
  884. meta: { label: t('Weight'), mobileHidden: true },
  885. header: t('Weight'),
  886. cell: ({ row }) => <WeightCell channel={row.original} />,
  887. size: 90,
  888. enableSorting: false,
  889. },
  890. // Balance column (Used/Remaining)
  891. {
  892. accessorKey: 'balance',
  893. meta: { label: t('Used / Remaining') },
  894. header: ({ column }) => (
  895. <DataTableColumnHeader column={column} title={t('Used / Remaining')} />
  896. ),
  897. cell: ({ row }) => <BalanceCell channel={row.original} />,
  898. size: 180,
  899. },
  900. // Response Time column
  901. {
  902. accessorKey: 'response_time',
  903. meta: { label: t('Response'), mobileHidden: true },
  904. header: ({ column }) => (
  905. <DataTableColumnHeader column={column} title={t('Response')} />
  906. ),
  907. cell: ({ row }) => {
  908. const responseTime = row.getValue('response_time') as number
  909. const config = getResponseTimeConfig(responseTime)
  910. return (
  911. <StatusBadge
  912. label={formatResponseTime(responseTime, t)}
  913. variant={config.variant}
  914. size='sm'
  915. copyable={false}
  916. />
  917. )
  918. },
  919. size: 110,
  920. },
  921. // Test Time column
  922. {
  923. accessorKey: 'test_time',
  924. meta: { label: t('Last Tested'), mobileHidden: true },
  925. header: ({ column }) => (
  926. <DataTableColumnHeader column={column} title={t('Last Tested')} />
  927. ),
  928. cell: ({ row }) => {
  929. const testTime = row.getValue('test_time') as number
  930. // For invalid timestamps, show "Never" badge
  931. if (!testTime || testTime === 0) {
  932. return <span className='text-muted-foreground text-xs'>-</span>
  933. }
  934. const timeText = formatRelativeTime(testTime)
  935. const fullDate = formatTimestampToDate(testTime)
  936. // For valid timestamps, show tooltip with full date
  937. return (
  938. <TooltipProvider>
  939. <Tooltip>
  940. <TooltipTrigger asChild>
  941. <span className='text-muted-foreground cursor-pointer font-mono text-sm'>
  942. {timeText}
  943. </span>
  944. </TooltipTrigger>
  945. <TooltipContent side='top'>
  946. <p className='font-mono text-sm'>{fullDate}</p>
  947. </TooltipContent>
  948. </Tooltip>
  949. </TooltipProvider>
  950. )
  951. },
  952. size: 120,
  953. enableSorting: false,
  954. },
  955. // Actions column
  956. {
  957. id: 'actions',
  958. cell: ({ row }) => {
  959. // Check if this is a tag row (has children)
  960. const isTagRow = isTagAggregateRow(row.original)
  961. if (isTagRow) {
  962. return (
  963. <DataTableTagRowActions
  964. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  965. row={row as any}
  966. />
  967. )
  968. }
  969. return <DataTableRowActions row={row} />
  970. },
  971. size: 132,
  972. enableSorting: false,
  973. enableHiding: false,
  974. },
  975. ]
  976. }