Refactor the usage log filter toolbar into a shared reusable component for common, drawing, and task logs. Optimize desktop filters with a responsive grid, move secondary filters into a mobile drawer, standardize filter typography, remove redundant filter icons, and add the missing i18n translations for the new drawer description.
332 lines
10 KiB
TypeScript
Vendored
332 lines
10 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 { 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 { 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' }],
|
|
})
|
|
|
|
// 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,
|
|
refreshTrigger,
|
|
],
|
|
queryFn: async () => {
|
|
// If there's a global filter, use search
|
|
const hasFilter = globalFilter?.trim()
|
|
|
|
if (hasFilter) {
|
|
const result = await searchApiKeys({ keyword: globalFilter })
|
|
if (!result.success) {
|
|
toast.error(result.message || t(ERROR_MESSAGES.SEARCH_FAILED))
|
|
return { items: [], total: 0 }
|
|
}
|
|
return {
|
|
items: result.data || [],
|
|
total: result.data?.length || 0,
|
|
}
|
|
}
|
|
|
|
// Otherwise use pagination
|
|
const result = await getApiKeys({
|
|
p: pagination.pageIndex + 1,
|
|
size: pagination.pageSize,
|
|
})
|
|
|
|
if (!result.success) {
|
|
toast.error(result.message || t(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: (row, _columnId, filterValue) => {
|
|
const name = String(row.getValue('name')).toLowerCase()
|
|
const key = String(row.original.key).toLowerCase()
|
|
const searchValue = String(filterValue).toLowerCase()
|
|
|
|
return name.includes(searchValue) || key.includes(searchValue)
|
|
},
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getFilteredRowModel: getFilteredRowModel(),
|
|
getPaginationRowModel: getPaginationRowModel(),
|
|
getSortedRowModel: getSortedRowModel(),
|
|
getFacetedRowModel: getFacetedRowModel(),
|
|
getFacetedUniqueValues: getFacetedUniqueValues(),
|
|
onPaginationChange,
|
|
onGlobalFilterChange,
|
|
onColumnFiltersChange,
|
|
manualPagination: !globalFilter,
|
|
pageCount: globalFilter
|
|
? Math.ceil((data?.total || 0) / pagination.pageSize)
|
|
: 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 or key...'),
|
|
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} />}
|
|
/>
|
|
)
|
|
}
|