Files
new-api/web/default/src/features/channels/components/channels-table.tsx
T

407 lines
12 KiB
TypeScript
Vendored

/*
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 { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import {
type OnChangeFn,
type SortingState,
type Row,
} from '@tanstack/react-table'
import { useMediaQuery } from '@/hooks'
import { useTranslation } from 'react-i18next'
import { getLobeIcon } from '@/lib/lobe-icon'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { Input } from '@/components/ui/input'
import {
DISABLED_ROW_DESKTOP,
DISABLED_ROW_MOBILE,
DataTablePage,
useDebouncedColumnFilter,
useDataTable,
} from '@/components/data-table'
import { getChannels, searchChannels, getGroups } from '../api'
import {
DEFAULT_PAGE_SIZE,
CHANNEL_STATUS,
CHANNEL_STATUS_OPTIONS,
} from '../constants'
import {
channelsQueryKeys,
aggregateChannelsByTag,
isTagAggregateRow,
getChannelTypeIcon,
getChannelTypeLabel,
} from '../lib'
import type { Channel, ChannelSortBy } from '../types'
import { useChannelsColumns } from './channels-columns'
import { useChannels } from './channels-provider'
import { DataTableBulkActions } from './data-table-bulk-actions'
const route = getRouteApi('/_authenticated/channels/')
const CHANNELS_COLUMN_VISIBILITY_STORAGE_KEY =
'channels:column-visibility'
const CHANNEL_SORTABLE_COLUMNS = new Set<ChannelSortBy>([
'id',
'name',
'priority',
'balance',
'response_time',
'test_time',
])
function isDisabledChannelRow(channel: Channel) {
return (
!isTagAggregateRow(channel) && channel.status !== CHANNEL_STATUS.ENABLED
)
}
export function ChannelsTable() {
const { t } = useTranslation()
const { enableTagMode, idSort } = useChannels()
const isMobile = useMediaQuery('(max-width: 640px)')
// Table state
const [sorting, setSorting] = useState<SortingState>([])
// URL state management
const {
globalFilter,
onGlobalFilterChange,
columnFilters,
onColumnFiltersChange,
pagination,
onPaginationChange,
ensurePageInRange,
} = useTableUrlState({
search: route.useSearch(),
navigate: route.useNavigate(),
pagination: {
defaultPage: 1,
defaultPageSize: isMobile ? 10 : DEFAULT_PAGE_SIZE,
},
globalFilter: { enabled: true, key: 'filter' },
columnFilters: [
{ columnId: 'status', searchKey: 'status', type: 'array' },
{ columnId: 'type', searchKey: 'type', type: 'array' },
{ columnId: 'group', searchKey: 'group', type: 'array' },
{ columnId: 'model', searchKey: 'model', type: 'string' },
],
})
// Extract filters from column filters
const statusFilter =
(columnFilters.find((f) => f.id === 'status')?.value as string[]) || []
const typeFilter = useMemo(
() => (columnFilters.find((f) => f.id === 'type')?.value as string[]) || [],
[columnFilters]
)
const groupFilter =
(columnFilters.find((f) => f.id === 'group')?.value as string[]) || []
const {
value: modelFilter,
inputValue: modelFilterInput,
onChange: onModelFilterInputChange,
onCompositionStart: onModelFilterCompositionStart,
onCompositionEnd: onModelFilterCompositionEnd,
resetInput: resetModelFilterInput,
} = useDebouncedColumnFilter({
columnFilters,
columnId: 'model',
onColumnFiltersChange,
})
// Determine whether to use search or regular list API
const shouldSearch = Boolean(globalFilter?.trim() || modelFilter.trim())
const sortParams = useMemo(() => {
const activeSort = sorting[0]
if (
!activeSort ||
!CHANNEL_SORTABLE_COLUMNS.has(activeSort.id as ChannelSortBy)
) {
return {}
}
return {
sort_by: activeSort.id as ChannelSortBy,
sort_order: activeSort.desc ? 'desc' : 'asc',
} as const
}, [sorting])
const handleSortingChange: OnChangeFn<SortingState> = (updater) => {
setSorting((previous) => {
const next = typeof updater === 'function' ? updater(previous) : updater
if (pagination.pageIndex > 0) {
onPaginationChange({ ...pagination, pageIndex: 0 })
}
return next
})
}
// Fetch groups for filter
const { data: groupsData } = useQuery({
queryKey: ['groups'],
queryFn: getGroups,
})
const groupOptions = useMemo(
() =>
(groupsData?.data || []).map((g) => ({
label: g,
value: g,
})),
[groupsData]
)
// Fetch channels data
// eslint-disable-next-line @tanstack/query/exhaustive-deps
const { data, isLoading, isFetching } = useQuery({
queryKey: channelsQueryKeys.list({
keyword: globalFilter,
model: modelFilter,
group:
groupFilter.length > 0 && !groupFilter.includes('all')
? groupFilter[0]
: undefined,
status:
statusFilter.length > 0 && !statusFilter.includes('all')
? statusFilter[0]
: undefined,
type:
typeFilter.length > 0 && !typeFilter.includes('all')
? Number(typeFilter[0])
: undefined,
tag_mode: enableTagMode,
id_sort: idSort,
...sortParams,
p: pagination.pageIndex + 1,
page_size: pagination.pageSize,
}),
queryFn: async () => {
if (shouldSearch) {
return searchChannels({
keyword: globalFilter,
model: modelFilter,
group:
groupFilter.length > 0 && !groupFilter.includes('all')
? groupFilter[0]
: undefined,
status:
statusFilter.length > 0 && !statusFilter.includes('all')
? statusFilter[0]
: undefined,
type:
typeFilter.length > 0 && !typeFilter.includes('all')
? Number(typeFilter[0])
: undefined,
tag_mode: enableTagMode,
id_sort: idSort,
...sortParams,
p: pagination.pageIndex + 1,
page_size: pagination.pageSize,
})
} else {
return getChannels({
group:
groupFilter.length > 0 && !groupFilter.includes('all')
? groupFilter[0]
: undefined,
status:
statusFilter.length > 0 && !statusFilter.includes('all')
? statusFilter[0]
: undefined,
type:
typeFilter.length > 0 && !typeFilter.includes('all')
? Number(typeFilter[0])
: undefined,
tag_mode: enableTagMode,
id_sort: idSort,
...sortParams,
p: pagination.pageIndex + 1,
page_size: pagination.pageSize,
})
}
},
placeholderData: (previousData) => previousData,
})
// Apply tag aggregation if tag mode is enabled
const channels = useMemo(() => {
const rawChannels = data?.data?.items || []
if (enableTagMode && rawChannels.length > 0) {
return aggregateChannelsByTag(rawChannels)
}
return rawChannels
}, [data, enableTagMode])
const totalCount = data?.data?.total || 0
const typeCounts = data?.data?.type_counts
// Columns configuration
const columns = useChannelsColumns()
// React Table instance
const { table } = useDataTable({
data: channels,
columns,
totalCount,
sorting,
initialColumnVisibility: {
models: false,
tag: false,
},
columnVisibilityStorageKey: CHANNELS_COLUMN_VISIBILITY_STORAGE_KEY,
columnFilters,
pagination,
globalFilter,
enableRowSelection: (row: Row<Channel>) => !isTagAggregateRow(row.original),
onSortingChange: handleSortingChange,
onColumnFiltersChange,
onPaginationChange,
onGlobalFilterChange,
getSubRows: (row: Channel & { children?: Channel[] }) => row.children,
manualPagination: true,
manualSorting: true,
manualFiltering: true,
withExpandedRowModel: true,
ensurePageInRange,
})
// Prepare filter options from existing channel types only.
const typeFilterOptions = useMemo(() => {
const counts = typeCounts || {}
const typeIds = Object.entries(counts)
.map(([type, count]) => ({
type: Number(type),
count: Number(count) || 0,
}))
.filter((item) => item.type > 0 && item.count > 0)
.sort((a, b) => {
const labelA = t(getChannelTypeLabel(a.type))
const labelB = t(getChannelTypeLabel(b.type))
return labelA.localeCompare(labelB)
})
const selectedType = typeFilter.find((value) => value !== 'all')
if (selectedType) {
const selectedTypeId = Number(selectedType)
const alreadyIncluded = typeIds.some(
(item) => item.type === selectedTypeId
)
if (selectedTypeId > 0 && !alreadyIncluded) {
typeIds.push({
type: selectedTypeId,
count: Number(counts[selectedType]) || 0,
})
}
}
const totalTypes = Object.values(counts).reduce(
(sum, count) => sum + (Number(count) || 0),
0
)
return [
{
label: 'All Types',
value: 'all',
count: totalTypes,
},
...typeIds.map((item) => {
const iconName = getChannelTypeIcon(item.type)
return {
label: getChannelTypeLabel(item.type),
value: String(item.type),
count: item.count,
iconNode: getLobeIcon(`${iconName}.Color`, 16),
}
}),
]
}, [t, typeCounts, typeFilter])
const groupFilterOptions = [
{ label: t('All Groups'), value: 'all' },
...groupOptions,
]
return (
<DataTablePage
table={table}
columns={columns}
isLoading={isLoading}
isFetching={isFetching}
emptyTitle={t('No Channels Found')}
emptyDescription={t(
'No channels available. Create your first channel to get started.'
)}
skeletonKeyPrefix='channel-skeleton'
applyHeaderSize
toolbarProps={{
searchPlaceholder: t('Filter by name, ID, or key...'),
searchDebounceMs: 500,
onReset: () => {
resetModelFilterInput()
},
additionalSearch: (
<Input
placeholder={t('Filter by model...')}
value={modelFilterInput}
onChange={onModelFilterInputChange}
onCompositionStart={onModelFilterCompositionStart}
onCompositionEnd={onModelFilterCompositionEnd}
className='w-full sm:w-[150px] lg:w-[180px]'
/>
),
filters: [
{
columnId: 'status',
title: t('Status'),
options: [...CHANNEL_STATUS_OPTIONS],
singleSelect: true,
},
{
columnId: 'type',
title: t('Type'),
options: typeFilterOptions,
singleSelect: true,
},
{
columnId: 'group',
title: t('Group'),
options: groupFilterOptions,
singleSelect: true,
},
],
}}
getRowClassName={(row, { isMobile }) =>
isDisabledChannelRow(row.original)
? isMobile
? DISABLED_ROW_MOBILE
: DISABLED_ROW_DESKTOP
: undefined
}
bulkActions={<DataTableBulkActions table={table} />}
/>
)
}