/* 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 . 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([ '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([]) // 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 = (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) => !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 ( { resetModelFilterInput() }, additionalSearch: ( ), 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={} /> ) }