Files
new-api/web/default/src/features/keys/components/api-keys-table.tsx
T
yyhhyyyyyy 349d5429ca fix: handle paginated API key search response (#5014)
* fix: handle paginated API key search response

* fix: add accessible label to API key filter
2026-05-25 23:15:59 +08:00

360 lines
11 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 { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import {
type SortingState,
type VisibilityState,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
import { useDebounce } from '@/hooks'
import { Database } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { formatQuota } from '@/lib/format'
import { cn } from '@/lib/utils'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import {
DISABLED_ROW_DESKTOP,
DISABLED_ROW_MOBILE,
DataTablePage,
} from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge'
import { getApiKeys, searchApiKeys } from '../api'
import {
API_KEY_STATUS,
API_KEY_STATUS_OPTIONS,
API_KEY_STATUSES,
ERROR_MESSAGES,
} from '../constants'
import { type ApiKey } from '../types'
import { ApiKeyCell } from './api-keys-cells'
import { useApiKeysColumns } from './api-keys-columns'
import { useApiKeys } from './api-keys-provider'
import { DataTableBulkActions } from './data-table-bulk-actions'
import { DataTableRowActions } from './data-table-row-actions'
const route = getRouteApi('/_authenticated/keys/')
function isDisabledApiKeyRow(apiKey: ApiKey) {
return apiKey.status !== API_KEY_STATUS.ENABLED
}
function ApiKeysMobileSkeleton() {
return (
<div className='divide-border overflow-hidden rounded-lg border'>
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className='space-y-2 border-b px-3 py-2.5 last:border-b-0'
>
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-32' />
<Skeleton className='h-5 w-16 rounded-md' />
</div>
<div className='flex items-center justify-between gap-3'>
<Skeleton className='h-7 w-44' />
<Skeleton className='h-8 w-16' />
</div>
<Skeleton className='h-3 w-28' />
</div>
))}
</div>
)
}
function ApiKeysMobileList({
table,
isLoading,
}: {
table: ReturnType<typeof useReactTable<ApiKey>>
isLoading: boolean
}) {
const { t } = useTranslation()
const rows = table.getRowModel().rows
if (isLoading) return <ApiKeysMobileSkeleton />
if (!rows.length) {
return (
<div className='rounded-lg border p-8'>
<Empty className='border-none p-0'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Database className='size-6' />
</EmptyMedia>
<EmptyTitle>{t('No API Keys Found')}</EmptyTitle>
<EmptyDescription>
{t(
'No API keys available. Create your first API key to get started.'
)}
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
)
}
return (
<div className='divide-border overflow-hidden rounded-lg border'>
{rows.map((row) => {
const apiKey = row.original
const statusConfig = API_KEY_STATUSES[apiKey.status]
const total = apiKey.used_quota + apiKey.remain_quota
return (
<div
key={row.id}
className={cn(
'bg-card space-y-2.5 border-b px-3 py-2.5 last:border-b-0',
isDisabledApiKeyRow(apiKey) && DISABLED_ROW_MOBILE
)}
>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<div className='truncate text-sm font-semibold'>
{apiKey.name}
</div>
<div className='text-muted-foreground text-[11px]'>
{t('API Key')}
</div>
</div>
{statusConfig && (
<StatusBadge
label={t(statusConfig.label)}
variant={statusConfig.variant}
copyable={false}
/>
)}
</div>
<div className='flex min-w-0 items-center justify-between gap-2'>
<div className='min-w-0 flex-1 [&_button:first-child]:max-w-full [&_button:first-child]:truncate [&_button:first-child]:px-0'>
<ApiKeyCell apiKey={apiKey} />
</div>
<DataTableRowActions row={row} />
</div>
<div className='flex items-center justify-between gap-2 text-xs'>
<span className='text-muted-foreground'>{t('Quota')}</span>
{apiKey.unlimited_quota ? (
<span className='font-medium'>{t('Unlimited')}</span>
) : (
<span className='font-medium tabular-nums'>
{formatQuota(apiKey.remain_quota)}
<span className='text-muted-foreground font-normal'>
{' / '}
{formatQuota(total)}
</span>
</span>
)}
</div>
</div>
)
})}
</div>
)
}
export function ApiKeysTable() {
const { t } = useTranslation()
const { refreshTrigger } = useApiKeys()
const columns = useApiKeysColumns()
const [rowSelection, setRowSelection] = useState({})
const [sorting, setSorting] = useState<SortingState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const {
globalFilter,
onGlobalFilterChange,
columnFilters,
onColumnFiltersChange,
pagination,
onPaginationChange,
ensurePageInRange,
} = useTableUrlState({
search: route.useSearch(),
navigate: route.useNavigate(),
pagination: { defaultPage: 1, defaultPageSize: 20 },
globalFilter: { enabled: true, key: 'filter' },
columnFilters: [
{ columnId: 'status', searchKey: 'status', type: 'array' },
{ columnId: '_tokenSearch', searchKey: 'token', type: 'string' },
],
})
const tokenFilterFromUrl =
(columnFilters.find((f) => f.id === '_tokenSearch')?.value as string) || ''
const [tokenFilterInput, setTokenFilterInput] = useState(tokenFilterFromUrl)
const debouncedTokenFilter = useDebounce(tokenFilterInput, 500)
useEffect(() => {
setTokenFilterInput(tokenFilterFromUrl)
}, [tokenFilterFromUrl])
useEffect(() => {
if (debouncedTokenFilter !== tokenFilterFromUrl) {
onColumnFiltersChange((prev) => {
const filtered = prev.filter((f) => f.id !== '_tokenSearch')
return debouncedTokenFilter
? [...filtered, { id: '_tokenSearch', value: debouncedTokenFilter }]
: filtered
})
}
}, [debouncedTokenFilter, tokenFilterFromUrl, onColumnFiltersChange])
const tokenFilter = tokenFilterFromUrl
const shouldSearch = Boolean(globalFilter?.trim() || tokenFilter.trim())
// Fetch data with React Query
// eslint-disable-next-line @tanstack/query/exhaustive-deps
const { data, isLoading, isFetching } = useQuery({
queryKey: [
'keys',
pagination.pageIndex + 1,
pagination.pageSize,
globalFilter,
tokenFilter,
refreshTrigger,
],
queryFn: async () => {
const result = shouldSearch
? await searchApiKeys({
keyword: globalFilter,
token: tokenFilter,
p: pagination.pageIndex + 1,
size: pagination.pageSize,
})
: await getApiKeys({
p: pagination.pageIndex + 1,
size: pagination.pageSize,
})
if (!result.success) {
toast.error(
result.message ||
t(
shouldSearch
? ERROR_MESSAGES.SEARCH_FAILED
: ERROR_MESSAGES.LOAD_FAILED
)
)
return { items: [], total: 0 }
}
return {
items: result.data?.items || [],
total: result.data?.total || 0,
}
},
placeholderData: (previousData) => previousData,
})
const apiKeys = data?.items || []
const table = useReactTable({
data: apiKeys,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
globalFilter,
pagination,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
globalFilterFn: () => true,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
onPaginationChange,
onGlobalFilterChange,
onColumnFiltersChange,
manualPagination: true,
pageCount: Math.ceil((data?.total || 0) / pagination.pageSize),
})
const pageCount = table.getPageCount()
useEffect(() => {
ensurePageInRange(pageCount)
}, [pageCount, ensurePageInRange])
return (
<DataTablePage
table={table}
columns={columns}
isLoading={isLoading}
isFetching={isFetching}
emptyTitle={t('No API Keys Found')}
emptyDescription={t(
'No API keys available. Create your first API key to get started.'
)}
skeletonKeyPrefix='api-keys-skeleton'
toolbarProps={{
searchPlaceholder: t('Filter by name...'),
additionalSearch: (
<Input
placeholder={t('Filter by API key...')}
aria-label={t('Filter by API key...')}
value={tokenFilterInput}
onChange={(e) => setTokenFilterInput(e.target.value)}
className='w-full sm:w-50 lg:w-60'
/>
),
filters: [
{
columnId: 'status',
title: t('Status'),
options: API_KEY_STATUS_OPTIONS,
singleSelect: true,
},
],
}}
mobile={<ApiKeysMobileList table={table} isLoading={isLoading} />}
getRowClassName={(row) =>
isDisabledApiKeyRow(row.original) ? DISABLED_ROW_DESKTOP : undefined
}
bulkActions={<DataTableBulkActions table={table} />}
/>
)
}