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

479 lines
14 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,
useEffect,
useRef,
type ChangeEvent,
type CompositionEvent,
} from 'react'
import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import {
getCoreRowModel,
useReactTable,
getExpandedRowModel,
type OnChangeFn,
type SortingState,
type VisibilityState,
type ExpandedState,
type Row,
} from '@tanstack/react-table'
import { useDebounce, 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,
} 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 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>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
models: false,
tag: false,
})
const [rowSelection, setRowSelection] = useState({})
const [expanded, setExpanded] = useState<ExpandedState>({})
// 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 =
(columnFilters.find((f) => f.id === 'type')?.value as string[]) || []
const groupFilter =
(columnFilters.find((f) => f.id === 'group')?.value as string[]) || []
const modelFilterFromUrl =
(columnFilters.find((f) => f.id === 'model')?.value as string) || ''
// Local state for immediate input feedback
const isModelFilterComposingRef = useRef(false)
const [modelFilterInput, setModelFilterInput] = useState(modelFilterFromUrl)
const [modelFilterPendingValue, setModelFilterPendingValue] =
useState(modelFilterFromUrl)
const debouncedModelFilter = useDebounce(modelFilterPendingValue, 500)
// Sync local input with URL when URL changes (e.g., from back/forward navigation)
useEffect(() => {
if (!isModelFilterComposingRef.current) {
setModelFilterInput(modelFilterFromUrl)
}
setModelFilterPendingValue(modelFilterFromUrl)
}, [modelFilterFromUrl])
// Update URL when debounced value changes
useEffect(() => {
if (
debouncedModelFilter === modelFilterPendingValue &&
debouncedModelFilter !== modelFilterFromUrl
) {
onColumnFiltersChange((prev) => {
const filtered = prev.filter((f) => f.id !== 'model')
return debouncedModelFilter
? [...filtered, { id: 'model', value: debouncedModelFilter }]
: filtered
})
}
}, [
debouncedModelFilter,
modelFilterFromUrl,
modelFilterPendingValue,
onColumnFiltersChange,
])
const handleModelFilterChange = (event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
setModelFilterInput(value)
if (!isModelFilterComposingRef.current) {
setModelFilterPendingValue(value)
}
}
const handleModelFilterCompositionStart = () => {
isModelFilterComposingRef.current = true
}
const handleModelFilterCompositionEnd = (
event: CompositionEvent<HTMLInputElement>
) => {
isModelFilterComposingRef.current = false
const value = event.currentTarget.value
setModelFilterInput(value)
setModelFilterPendingValue(value)
}
const modelFilter = modelFilterFromUrl
// 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 = useReactTable({
data: channels,
columns,
pageCount: Math.ceil(totalCount / pagination.pageSize),
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
pagination,
expanded,
globalFilter,
},
enableRowSelection: (row: Row<Channel>) => !isTagAggregateRow(row.original),
onRowSelectionChange: setRowSelection,
onSortingChange: handleSortingChange,
onColumnFiltersChange,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange,
onExpandedChange: setExpanded,
onGlobalFilterChange,
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getSubRows: (row: Channel & { children?: Channel[] }) => row.children,
manualPagination: true,
manualSorting: true,
manualFiltering: true,
})
// Ensure page is in range when total count changes
const pageCount = table.getPageCount()
useEffect(() => {
ensurePageInRange(pageCount)
}, [pageCount, 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: () => {
isModelFilterComposingRef.current = false
setModelFilterInput('')
setModelFilterPendingValue('')
},
additionalSearch: (
<Input
placeholder={t('Filter by model...')}
value={modelFilterInput}
onChange={handleModelFilterChange}
onCompositionStart={handleModelFilterCompositionStart}
onCompositionEnd={handleModelFilterCompositionEnd}
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} />}
/>
)
}