perf(web): debounce server and large-list searches (#6727)

This commit is contained in:
Seefs
2026-08-10 12:47:55 +08:00
committed by GitHub
parent 5d3423bec1
commit 7dd1000a19
10 changed files with 45 additions and 18 deletions
@@ -307,6 +307,7 @@ export function ApiKeysTable() {
applyHeaderSize applyHeaderSize
toolbarProps={{ toolbarProps={{
searchPlaceholder: t('Filter by name...'), searchPlaceholder: t('Filter by name...'),
searchDebounceMs: 500,
additionalSearch: ( additionalSearch: (
<Input <Input
placeholder={t('Filter by API key...')} placeholder={t('Filter by API key...')}
@@ -230,6 +230,7 @@ export function DeploymentsTable() {
applyHeaderSize applyHeaderSize
toolbarProps={{ toolbarProps={{
searchPlaceholder: t('Search deployments...'), searchPlaceholder: t('Search deployments...'),
searchDebounceMs: 500,
filters: [ filters: [
{ {
columnId: 'status', columnId: 'status',
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { type ColumnDef, type RowSelectionState } from '@tanstack/react-table' import type { ColumnDef, RowSelectionState } from '@tanstack/react-table'
import { import {
Search, Search,
Info, Info,
@@ -48,6 +48,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { useDebounce } from '@/hooks/use-debounce'
import { useIsMobile } from '@/hooks/use-mobile' import { useIsMobile } from '@/hooks/use-mobile'
import { applyUpstreamOverwrite } from '../../api' import { applyUpstreamOverwrite } from '../../api'
@@ -117,6 +118,7 @@ export function UpstreamConflictDialog({
} = useModels() } = useModels()
const isMobile = useIsMobile() const isMobile = useIsMobile()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 200)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}) const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pageSize, setPageSize] = useState(10) const [pageSize, setPageSize] = useState(10)
@@ -149,7 +151,7 @@ export function UpstreamConflictDialog({
const totalModels = upstreamConflicts.length const totalModels = upstreamConflicts.length
const totalFields = conflictRows.length const totalFields = conflictRows.length
const normalizedSearch = search.trim().toLowerCase() const normalizedSearch = debouncedSearch.trim().toLowerCase()
const { matchingModelNames, visibleRowIds } = useMemo(() => { const { matchingModelNames, visibleRowIds } = useMemo(() => {
if (!normalizedSearch) { if (!normalizedSearch) {
@@ -396,7 +398,7 @@ export function UpstreamConflictDialog({
const payload: SyncOverwritePayload[] = Object.entries(groupedSelections) const payload: SyncOverwritePayload[] = Object.entries(groupedSelections)
.map(([modelName, fields]) => ({ .map(([modelName, fields]) => ({
model_name: modelName, model_name: modelName,
fields: Array.from(fields), fields: [...fields],
})) }))
.filter((item) => item.fields.length > 0) .filter((item) => item.fields.length > 0)
@@ -202,6 +202,7 @@ export function ModelsTable() {
applyHeaderSize applyHeaderSize
toolbarProps={{ toolbarProps={{
searchPlaceholder: t('Filter by model name...'), searchPlaceholder: t('Filter by model name...'),
searchDebounceMs: 500,
filters: [ filters: [
{ {
columnId: 'status', columnId: 'status',
@@ -19,6 +19,8 @@ For commercial licensing, please contact support@quantumnous.com
import { useSearch } from '@tanstack/react-router' import { useSearch } from '@tanstack/react-router'
import { useMemo, useCallback, useState } from 'react' import { useMemo, useCallback, useState } from 'react'
import { useDebounce } from '@/hooks/use-debounce'
import { import {
FILTER_ALL, FILTER_ALL,
SORT_OPTIONS, SORT_OPTIONS,
@@ -67,6 +69,7 @@ export function useFilters(models: PricingModel[]) {
})) }))
const searchInput = filterState.search || '' const searchInput = filterState.search || ''
const debouncedSearchInput = useDebounce(searchInput, 200)
const sortBy = filterState.sort || SORT_OPTIONS.NAME const sortBy = filterState.sort || SORT_OPTIONS.NAME
const vendorFilter = filterState.vendor || FILTER_ALL const vendorFilter = filterState.vendor || FILTER_ALL
const groupFilter = filterState.group || FILTER_ALL const groupFilter = filterState.group || FILTER_ALL
@@ -147,7 +150,7 @@ export function useFilters(models: PricingModel[]) {
if (!models || models.length === 0) return [] if (!models || models.length === 0) return []
return filterAndSortModels(models, { return filterAndSortModels(models, {
search: searchInput, search: debouncedSearchInput,
vendor: vendorFilter, vendor: vendorFilter,
group: groupFilter, group: groupFilter,
quotaType: quotaTypeFilter, quotaType: quotaTypeFilter,
@@ -157,7 +160,7 @@ export function useFilters(models: PricingModel[]) {
}) })
}, [ }, [
models, models,
searchInput, debouncedSearchInput,
vendorFilter, vendorFilter,
groupFilter, groupFilter,
quotaTypeFilter, quotaTypeFilter,
@@ -171,6 +171,7 @@ export function RedemptionsTable() {
applyHeaderSize applyHeaderSize
toolbarProps={{ toolbarProps={{
searchPlaceholder: t('Filter by name or ID...'), searchPlaceholder: t('Filter by name or ID...'),
searchDebounceMs: 500,
filters: [ filters: [
{ {
columnId: 'status', columnId: 'status',
@@ -39,6 +39,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { useDebounce } from '@/hooks/use-debounce'
import type { UpstreamChannel } from '../types' import type { UpstreamChannel } from '../types'
import { import {
@@ -80,6 +81,7 @@ export function ChannelSelectorDialog({
}: ChannelSelectorDialogProps) { }: ChannelSelectorDialogProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 200)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}) const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
useEffect(() => { useEffect(() => {
@@ -273,15 +275,15 @@ export function ChannelSelectorDialog({
) )
const filteredChannels = useMemo(() => { const filteredChannels = useMemo(() => {
if (!search.trim()) return channels if (!debouncedSearch.trim()) return channels
const searchLower = search.toLowerCase() const searchLower = debouncedSearch.toLowerCase()
return channels.filter( return channels.filter(
(ch) => (ch) =>
ch.name.toLowerCase().includes(searchLower) || ch.name.toLowerCase().includes(searchLower) ||
ch.base_url.toLowerCase().includes(searchLower) ch.base_url.toLowerCase().includes(searchLower)
) )
}, [channels, search]) }, [channels, debouncedSearch])
const sortedChannels = useMemo(() => { const sortedChannels = useMemo(() => {
return [...filteredChannels].sort((a, b) => { return [...filteredChannels].sort((a, b) => {
@@ -682,6 +682,7 @@ const ModelRatioVisualEditorComponent = forwardRef<
<DataTableToolbar <DataTableToolbar
table={table} table={table}
searchPlaceholder={t('Search models...')} searchPlaceholder={t('Search models...')}
searchDebounceMs={250}
filters={[ filters={[
{ {
columnId: 'billingMode', columnId: 'billingMode',
@@ -34,6 +34,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { useDebounce } from '@/hooks/use-debounce'
import type { DifferencesMap, RatioType } from '../types' import type { DifferencesMap, RatioType } from '../types'
import { RATIO_TYPE_OPTIONS } from './constants' import { RATIO_TYPE_OPTIONS } from './constants'
@@ -88,6 +89,7 @@ export function UpstreamRatioSyncTable({
}: UpstreamRatioSyncTableProps) { }: UpstreamRatioSyncTableProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 250)
const [ratioTypeFilter, setRatioTypeFilter] = useState<string>('') const [ratioTypeFilter, setRatioTypeFilter] = useState<string>('')
const dataSource = useMemo<ModelRow[]>(() => { const dataSource = useMemo<ModelRow[]>(() => {
@@ -106,8 +108,8 @@ export function UpstreamRatioSyncTable({
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
let data = dataSource let data = dataSource
if (search.trim()) { if (debouncedSearch.trim()) {
const lower = search.toLowerCase() const lower = debouncedSearch.toLowerCase()
data = data.filter((row) => row.model.toLowerCase().includes(lower)) data = data.filter((row) => row.model.toLowerCase().includes(lower))
} }
@@ -116,7 +118,7 @@ export function UpstreamRatioSyncTable({
} }
return data return data
}, [dataSource, search, ratioTypeFilter]) }, [dataSource, debouncedSearch, ratioTypeFilter])
const upstreamNames = useMemo(() => { const upstreamNames = useMemo(() => {
const set = new Set<string>() const set = new Set<string>()
@@ -17,10 +17,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import i18next from 'i18next' import i18next from 'i18next'
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useIsAdmin } from '@/hooks/use-admin' import { useIsAdmin } from '@/hooks/use-admin'
import { useDebounce } from '@/hooks/use-debounce'
import { import {
getUserBillingHistory, getUserBillingHistory,
@@ -50,6 +51,8 @@ export function useBillingHistory(options: UseBillingHistoryOptions = {}) {
const [page, setPage] = useState(initialPage) const [page, setPage] = useState(initialPage)
const [pageSize, setPageSize] = useState(initialPageSize) const [pageSize, setPageSize] = useState(initialPageSize)
const [keyword, setKeyword] = useState('') const [keyword, setKeyword] = useState('')
const debouncedKeyword = useDebounce(keyword)
const requestIdRef = useRef(0)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [completing, setCompleting] = useState(false) const [completing, setCompleting] = useState(false)
@@ -57,11 +60,14 @@ export function useBillingHistory(options: UseBillingHistoryOptions = {}) {
* Fetch billing history * Fetch billing history
*/ */
const fetchBillingHistory = useCallback(async () => { const fetchBillingHistory = useCallback(async () => {
const requestId = ++requestIdRef.current
setLoading(true) setLoading(true)
try { try {
const response = isAdmin const response = isAdmin
? await getAllBillingHistory(page, pageSize, keyword) ? await getAllBillingHistory(page, pageSize, debouncedKeyword)
: await getUserBillingHistory(page, pageSize, keyword) : await getUserBillingHistory(page, pageSize, debouncedKeyword)
if (requestId !== requestIdRef.current) return
if (isApiSuccess(response) && response.data) { if (isApiSuccess(response) && response.data) {
setRecords(response.data.items || []) setRecords(response.data.items || [])
@@ -74,15 +80,19 @@ export function useBillingHistory(options: UseBillingHistoryOptions = {}) {
setTotal(0) setTotal(0)
} }
} catch (error) { } catch (error) {
if (requestId !== requestIdRef.current) return
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error('Failed to fetch billing history:', error) console.error('Failed to fetch billing history:', error)
toast.error(i18next.t('Failed to load billing history')) toast.error(i18next.t('Failed to load billing history'))
setRecords([]) setRecords([])
setTotal(0) setTotal(0)
} finally { } finally {
setLoading(false) if (requestId === requestIdRef.current) {
setLoading(false)
}
} }
}, [isAdmin, page, pageSize, keyword]) }, [debouncedKeyword, isAdmin, page, pageSize])
/** /**
* Complete a pending order (admin only) * Complete a pending order (admin only)
@@ -137,14 +147,17 @@ export function useBillingHistory(options: UseBillingHistoryOptions = {}) {
* Search by keyword * Search by keyword
*/ */
const handleSearch = useCallback((newKeyword: string) => { const handleSearch = useCallback((newKeyword: string) => {
requestIdRef.current += 1
setKeyword(newKeyword) setKeyword(newKeyword)
setPage(1) // Reset to first page when searching setPage(1) // Reset to first page when searching
}, []) }, [])
// Fetch data when dependencies change // Fetch data after the search draft has settled.
useEffect(() => { useEffect(() => {
if (keyword !== debouncedKeyword) return
fetchBillingHistory() fetchBillingHistory()
}, [fetchBillingHistory]) }, [debouncedKeyword, fetchBillingHistory, keyword])
return { return {
records, records,