* fix: handle paginated API key search response * fix: add accessible label to API key filter
360 lines
11 KiB
TypeScript
Vendored
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} />}
|
|
/>
|
|
)
|
|
}
|