feat: enhance UI and functionality in various components
This commit is contained in:
+119
-96
@@ -8,6 +8,7 @@ import {
|
||||
formatLogQuota,
|
||||
formatTimestampToDate,
|
||||
} from '@/lib/format'
|
||||
import { getAvatarColorClass } from '@/lib/colors'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -241,19 +242,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
{log.request_id && (
|
||||
<StatusBadge
|
||||
label={
|
||||
log.request_id.length > 18
|
||||
? `${log.request_id.slice(0, 18)}…`
|
||||
: log.request_id
|
||||
}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyText={log.request_id}
|
||||
className='max-w-[140px] truncate font-mono'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@@ -267,45 +255,47 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
]
|
||||
|
||||
if (isAdmin) {
|
||||
columns.push({
|
||||
id: 'source',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Source')} />
|
||||
),
|
||||
cell: function SourceCell({ row }) {
|
||||
const {
|
||||
setAffinityTarget,
|
||||
setAffinityDialogOpen,
|
||||
setSelectedUserId,
|
||||
setUserInfoDialogOpen,
|
||||
} = useUsageLogsContext()
|
||||
const log = row.original
|
||||
columns.push(
|
||||
{
|
||||
id: 'channel',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Channel')} />
|
||||
),
|
||||
cell: function ChannelCell({ row }) {
|
||||
const {
|
||||
sensitiveVisible,
|
||||
setAffinityTarget,
|
||||
setAffinityDialogOpen,
|
||||
} = useUsageLogsContext()
|
||||
const log = row.original
|
||||
|
||||
if (!isDisplayableLogType(log.type)) return null
|
||||
if (!isDisplayableLogType(log.type)) return null
|
||||
|
||||
const other = parseLogOther(log.other)
|
||||
const affinity = other?.admin_info?.channel_affinity
|
||||
const useChannel = other?.admin_info?.use_channel
|
||||
const channelChain =
|
||||
useChannel && useChannel.length > 0
|
||||
? useChannel.join(' → ')
|
||||
: undefined
|
||||
const channelDisplay = log.channel_name
|
||||
? `${log.channel_name} #${log.channel}`
|
||||
: `#${log.channel}`
|
||||
const other = parseLogOther(log.other)
|
||||
const affinity = other?.admin_info?.channel_affinity
|
||||
const useChannel = other?.admin_info?.use_channel
|
||||
const channelChain =
|
||||
useChannel && useChannel.length > 0
|
||||
? useChannel.join(' → ')
|
||||
: undefined
|
||||
const channelDisplay = log.channel_name
|
||||
? `${log.channel_name} #${log.channel}`
|
||||
: `#${log.channel}`
|
||||
const channelIdDisplay = `#${log.channel}`
|
||||
const channelName = sensitiveVisible ? log.channel_name : '••••'
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='relative'>
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='flex max-w-[160px] flex-col gap-0.5'>
|
||||
<div className='relative inline-flex w-fit'>
|
||||
<StatusBadge
|
||||
label={channelDisplay}
|
||||
autoColor={log.channel_name || String(log.channel)}
|
||||
label={channelIdDisplay}
|
||||
autoColor={String(log.channel)}
|
||||
copyText={String(log.channel)}
|
||||
size='sm'
|
||||
className='font-mono'
|
||||
/>
|
||||
{affinity && (
|
||||
<button
|
||||
@@ -329,57 +319,89 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className='space-y-1'>
|
||||
<p>{channelDisplay}</p>
|
||||
{channelChain && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Chain')}: {channelChain}
|
||||
{log.channel_name && (
|
||||
<span className='text-muted-foreground/70 truncate text-[11px]'>
|
||||
{channelName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className='space-y-1'>
|
||||
<p>{sensitiveVisible ? channelDisplay : channelIdDisplay}</p>
|
||||
{channelChain && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Chain')}: {channelChain}
|
||||
</p>
|
||||
)}
|
||||
{affinity && (
|
||||
<div className='border-t pt-1 text-xs'>
|
||||
<p className='font-medium'>{t('Channel Affinity')}</p>
|
||||
<p>
|
||||
{t('Rule')}: {affinity.rule_name || '-'}
|
||||
</p>
|
||||
)}
|
||||
{affinity && (
|
||||
<div className='border-t pt-1 text-xs'>
|
||||
<p className='font-medium'>{t('Channel Affinity')}</p>
|
||||
<p>
|
||||
{t('Rule')}: {affinity.rule_name || '-'}
|
||||
</p>
|
||||
<p>
|
||||
{t('Group')}:{' '}
|
||||
{affinity.using_group ||
|
||||
<p>
|
||||
{t('Group')}:{' '}
|
||||
{sensitiveVisible
|
||||
? affinity.using_group ||
|
||||
affinity.selected_group ||
|
||||
'-'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
{log.username && (
|
||||
<button
|
||||
type='button'
|
||||
className='flex items-center gap-1 text-left'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setSelectedUserId(log.user_id)
|
||||
setUserInfoDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<span className='bg-primary/10 text-primary flex size-4 items-center justify-center rounded-full text-[10px] font-bold'>
|
||||
{log.username.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className='text-muted-foreground truncate text-xs hover:underline'>
|
||||
{log.username}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
'-'
|
||||
: '••••'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Channel'), mobileHidden: true },
|
||||
},
|
||||
meta: { label: t('Source'), mobileHidden: true },
|
||||
})
|
||||
{
|
||||
id: 'user',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('User')} />
|
||||
),
|
||||
cell: function UserCell({ row }) {
|
||||
const {
|
||||
sensitiveVisible,
|
||||
setSelectedUserId,
|
||||
setUserInfoDialogOpen,
|
||||
} = useUsageLogsContext()
|
||||
const log = row.original
|
||||
|
||||
if (!isDisplayableLogType(log.type) || !log.username) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className='flex items-center gap-1.5 text-left'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setSelectedUserId(log.user_id)
|
||||
setUserInfoDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-5 items-center justify-center rounded-full text-[11px] font-bold',
|
||||
sensitiveVisible
|
||||
? getAvatarColorClass(log.username)
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{sensitiveVisible ? log.username.charAt(0).toUpperCase() : '•'}
|
||||
</span>
|
||||
<span className='text-muted-foreground truncate text-sm hover:underline'>
|
||||
{sensitiveVisible ? log.username : '••••'}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
},
|
||||
meta: { label: t('User'), mobileHidden: true },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
columns.push(
|
||||
@@ -389,6 +411,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
<DataTableColumnHeader column={column} title={t('Model')} />
|
||||
),
|
||||
cell: function ModelCell({ row }) {
|
||||
const { sensitiveVisible } = useUsageLogsContext()
|
||||
const log = row.original
|
||||
if (!isDisplayableLogType(log.type)) return null
|
||||
|
||||
@@ -450,8 +473,8 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
)
|
||||
|
||||
const metaParts: string[] = []
|
||||
if (tokenName) metaParts.push(tokenName)
|
||||
if (group) metaParts.push(group)
|
||||
if (tokenName) metaParts.push(sensitiveVisible ? tokenName : '••••')
|
||||
if (group) metaParts.push(sensitiveVisible ? group : '••••')
|
||||
|
||||
return (
|
||||
<div className='flex max-w-[220px] flex-col gap-0.5'>
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||
import { useNavigate, getRouteApi } from '@tanstack/react-router'
|
||||
import { ChevronDown, Eye, EyeOff, RotateCcw, Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useIsAdmin } from '@/hooks/use-admin'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { LOG_TYPES } from '../constants'
|
||||
import { buildSearchParams } from '../lib/filter'
|
||||
import { getDefaultTimeRange } from '../lib/utils'
|
||||
import type { CommonLogFilters } from '../types'
|
||||
import { CompactDateTimeRangePicker } from './compact-date-time-range-picker'
|
||||
import { useUsageLogsContext } from './usage-logs-provider'
|
||||
|
||||
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
||||
|
||||
interface CommonLogsFilterBarProps {
|
||||
stats?: ReactNode
|
||||
viewOptions?: ReactNode
|
||||
}
|
||||
|
||||
export function CommonLogsFilterBar({
|
||||
stats,
|
||||
viewOptions,
|
||||
}: CommonLogsFilterBarProps) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const searchParams = route.useSearch()
|
||||
const isAdmin = useIsAdmin()
|
||||
const { sensitiveVisible, setSensitiveVisible } = useUsageLogsContext()
|
||||
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [filters, setFilters] = useState<CommonLogFilters>(() => {
|
||||
const { start, end } = getDefaultTimeRange()
|
||||
return { startTime: start, endTime: end }
|
||||
})
|
||||
const [logType, setLogType] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
const next: Partial<CommonLogFilters> = {}
|
||||
if (searchParams.startTime)
|
||||
next.startTime = new Date(searchParams.startTime)
|
||||
if (searchParams.endTime) next.endTime = new Date(searchParams.endTime)
|
||||
if (searchParams.channel) next.channel = String(searchParams.channel)
|
||||
if (searchParams.model) next.model = searchParams.model
|
||||
if (searchParams.token) next.token = searchParams.token
|
||||
if (searchParams.group) next.group = searchParams.group
|
||||
if (searchParams.username) next.username = searchParams.username
|
||||
if (searchParams.requestId) next.requestId = searchParams.requestId
|
||||
|
||||
if (Object.keys(next).length > 0) {
|
||||
setFilters((prev) => ({ ...prev, ...next }))
|
||||
}
|
||||
|
||||
const typeArr = searchParams.type
|
||||
if (Array.isArray(typeArr) && typeArr.length === 1) {
|
||||
setLogType(typeArr[0])
|
||||
}
|
||||
}, [
|
||||
searchParams.startTime,
|
||||
searchParams.endTime,
|
||||
searchParams.channel,
|
||||
searchParams.model,
|
||||
searchParams.token,
|
||||
searchParams.group,
|
||||
searchParams.username,
|
||||
searchParams.requestId,
|
||||
searchParams.type,
|
||||
])
|
||||
|
||||
const handleChange = useCallback(
|
||||
(field: keyof CommonLogFilters, value: Date | string | undefined) => {
|
||||
setFilters((prev) => ({ ...prev, [field]: value }))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
const filterParams = buildSearchParams(filters, 'common')
|
||||
navigate({
|
||||
to: '/usage-logs/$section',
|
||||
params: { section: 'common' },
|
||||
search: (prev: Record<string, unknown>) => ({
|
||||
...prev,
|
||||
...filterParams,
|
||||
...(logType ? { type: [logType] } : { type: undefined }),
|
||||
page: 1,
|
||||
}),
|
||||
})
|
||||
}, [filters, logType, navigate])
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
const { start, end } = getDefaultTimeRange()
|
||||
const resetFilters: CommonLogFilters = { startTime: start, endTime: end }
|
||||
setFilters(resetFilters)
|
||||
setLogType('')
|
||||
|
||||
navigate({
|
||||
to: '/usage-logs/$section',
|
||||
params: { section: 'common' },
|
||||
search: {
|
||||
page: 1,
|
||||
startTime: start.getTime(),
|
||||
endTime: end.getTime(),
|
||||
},
|
||||
})
|
||||
}, [navigate])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') handleApply()
|
||||
},
|
||||
[handleApply]
|
||||
)
|
||||
|
||||
const hasExpandedFilters =
|
||||
!!filters.token ||
|
||||
!!filters.username ||
|
||||
!!filters.channel ||
|
||||
!!filters.requestId
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
{/* Primary filter row */}
|
||||
<div className='grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-[minmax(280px,2fr)_minmax(140px,1fr)_minmax(120px,1fr)_minmax(120px,0.8fr)_auto]'>
|
||||
<CompactDateTimeRangePicker
|
||||
start={filters.startTime}
|
||||
end={filters.endTime}
|
||||
onChange={({ start, end }) => {
|
||||
handleChange('startTime', start)
|
||||
handleChange('endTime', end)
|
||||
}}
|
||||
className='col-span-2 lg:col-span-1'
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('Model Name')}
|
||||
value={filters.model || ''}
|
||||
onChange={(e) => handleChange('model', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className='h-9'
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('Group')}
|
||||
type={sensitiveVisible ? 'text' : 'password'}
|
||||
value={filters.group || ''}
|
||||
onChange={(e) => handleChange('group', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className='h-9'
|
||||
/>
|
||||
<Select
|
||||
value={logType}
|
||||
onValueChange={(v) => setLogType(v === 'all' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className='h-9'>
|
||||
<SelectValue placeholder={t('All Types')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>{t('All Types')}</SelectItem>
|
||||
{LOG_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={String(type.value)}>
|
||||
{t(type.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground flex h-9 items-center gap-1 rounded-md px-2 text-xs transition-colors',
|
||||
hasExpandedFilters && !expanded && 'text-primary'
|
||||
)}
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-3.5 transition-transform duration-200',
|
||||
expanded && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
{expanded ? t('Collapse') : t('Expand')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expandable filter row */}
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-2 overflow-hidden transition-all duration-200',
|
||||
expanded
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-0'
|
||||
)}
|
||||
>
|
||||
<div className='min-h-0 overflow-hidden'>
|
||||
<div className='grid grid-cols-2 gap-2 sm:grid-cols-4'>
|
||||
<Input
|
||||
placeholder={t('Token Name')}
|
||||
type={sensitiveVisible ? 'text' : 'password'}
|
||||
value={filters.token || ''}
|
||||
onChange={(e) => handleChange('token', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className='h-9'
|
||||
/>
|
||||
{isAdmin && (
|
||||
<Input
|
||||
placeholder={t('Username')}
|
||||
type={sensitiveVisible ? 'text' : 'password'}
|
||||
value={filters.username || ''}
|
||||
onChange={(e) => handleChange('username', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className='h-9'
|
||||
/>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Input
|
||||
placeholder={t('Channel ID')}
|
||||
value={filters.channel || ''}
|
||||
onChange={(e) => handleChange('channel', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className='h-9'
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
placeholder={t('Request ID')}
|
||||
value={filters.requestId || ''}
|
||||
onChange={(e) => handleChange('requestId', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className='h-9'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions row */}
|
||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='flex min-w-0 flex-wrap items-center gap-2 sm:gap-3'>
|
||||
{stats && <div className='min-w-0'>{stats}</div>}
|
||||
<button
|
||||
type='button'
|
||||
className='text-muted-foreground hover:text-foreground inline-flex h-6 items-center gap-1 rounded px-1 text-xs transition-colors'
|
||||
title={sensitiveVisible ? t('Hide') : t('Show')}
|
||||
aria-label={sensitiveVisible ? t('Hide') : t('Show')}
|
||||
onClick={() => setSensitiveVisible(!sensitiveVisible)}
|
||||
>
|
||||
{sensitiveVisible ? (
|
||||
<Eye className='size-3.5' />
|
||||
) : (
|
||||
<EyeOff className='size-3.5' />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='flex shrink-0 items-center gap-2 self-end sm:self-auto'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8'
|
||||
onClick={handleReset}
|
||||
>
|
||||
<RotateCcw className='size-3.5' />
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
<Button size='sm' className='h-8' onClick={handleApply}>
|
||||
<Search className='size-3.5' />
|
||||
{t('Search')}
|
||||
</Button>
|
||||
{viewOptions}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,10 +5,10 @@ import { formatLogQuota } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useIsAdmin } from '@/hooks/use-admin'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { dotColorMap, textColorMap } from '@/components/status-badge'
|
||||
import { getLogStats, getUserLogStats } from '../api'
|
||||
import { DEFAULT_LOG_STATS } from '../constants'
|
||||
import { buildApiParams } from '../lib/utils'
|
||||
import { useUsageLogsContext } from './usage-logs-provider'
|
||||
|
||||
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
||||
|
||||
@@ -16,6 +16,7 @@ export function CommonLogsStats() {
|
||||
const { t } = useTranslation()
|
||||
const isAdmin = useIsAdmin()
|
||||
const searchParams = route.useSearch()
|
||||
const { sensitiveVisible } = useUsageLogsContext()
|
||||
|
||||
const { data: stats, isLoading } = useQuery({
|
||||
queryKey: ['usage-logs-stats', isAdmin, searchParams],
|
||||
@@ -41,29 +42,42 @@ export function CommonLogsStats() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Skeleton className='h-6 w-[126px] rounded-md' />
|
||||
<Skeleton className='h-6 w-[58px] rounded-md' />
|
||||
<Skeleton className='h-6 w-[58px] rounded-md' />
|
||||
<Skeleton className='h-6 w-[76px] rounded-md' />
|
||||
<Skeleton className='h-6 w-[92px] rounded-md' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tagClass =
|
||||
'inline-flex h-6 items-center rounded-md border px-2.5 text-xs font-medium shadow-xs'
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-1.5 text-xs font-medium'>
|
||||
<div className='flex flex-wrap items-center gap-1.5'>
|
||||
<span
|
||||
className={cn('size-1.5 shrink-0 rounded-full', dotColorMap.blue)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span className={cn(textColorMap.blue)}>
|
||||
{t('Usage')}: {formatLogQuota(stats?.quota || 0)}
|
||||
className={cn(
|
||||
tagClass,
|
||||
'border-blue-200/70 bg-blue-50 text-blue-700 dark:border-blue-500/20 dark:bg-blue-500/10 dark:text-blue-300'
|
||||
)}
|
||||
>
|
||||
{t('Usage')}:{' '}
|
||||
{sensitiveVisible ? formatLogQuota(stats?.quota || 0) : '••••'}
|
||||
</span>
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
<span className={cn(textColorMap.pink)}>
|
||||
<span
|
||||
className={cn(
|
||||
tagClass,
|
||||
'border-pink-200/70 bg-pink-50 text-pink-700 dark:border-pink-500/20 dark:bg-pink-500/10 dark:text-pink-300'
|
||||
)}
|
||||
>
|
||||
{t('RPM')}: {stats?.rpm || 0}
|
||||
</span>
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
<span className='text-muted-foreground'>
|
||||
<span
|
||||
className={cn(
|
||||
tagClass,
|
||||
'border-border bg-background text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{t('TPM')}: {stats?.tpm || 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { CalendarDays } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
|
||||
interface CompactDateTimeRangePickerProps {
|
||||
start?: Date
|
||||
end?: Date
|
||||
onChange: (range: { start?: Date; end?: Date }) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
function toInputValue(date?: Date): string {
|
||||
return date ? dayjs(date).format('YYYY-MM-DDTHH:mm') : ''
|
||||
}
|
||||
|
||||
function fromInputValue(value: string): Date | undefined {
|
||||
if (!value) return undefined
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? undefined : date
|
||||
}
|
||||
|
||||
export function CompactDateTimeRangePicker({
|
||||
start,
|
||||
end,
|
||||
onChange,
|
||||
className,
|
||||
}: CompactDateTimeRangePickerProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [draftStart, setDraftStart] = useState(toInputValue(start))
|
||||
const [draftEnd, setDraftEnd] = useState(toInputValue(end))
|
||||
|
||||
const label = useMemo(() => {
|
||||
if (!start && !end) return t('Date Range')
|
||||
const startText = start ? dayjs(start).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||
const endText = end ? dayjs(end).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||
return `${startText} ~ ${endText}`
|
||||
}, [end, start, t])
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen) {
|
||||
setDraftStart(toInputValue(start))
|
||||
setDraftEnd(toInputValue(end))
|
||||
}
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
const applyDraft = () => {
|
||||
onChange({
|
||||
start: fromInputValue(draftStart),
|
||||
end: fromInputValue(draftEnd),
|
||||
})
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const applyPreset = (kind: 'today' | '7d' | 'week' | '30d' | 'month') => {
|
||||
const now = dayjs()
|
||||
const presets = {
|
||||
today: {
|
||||
start: now.startOf('day').toDate(),
|
||||
end: now.endOf('day').toDate(),
|
||||
},
|
||||
'7d': {
|
||||
start: now.subtract(6, 'day').startOf('day').toDate(),
|
||||
end: now.endOf('day').toDate(),
|
||||
},
|
||||
week: {
|
||||
start: now.startOf('week').toDate(),
|
||||
end: now.endOf('week').toDate(),
|
||||
},
|
||||
'30d': {
|
||||
start: now.subtract(29, 'day').startOf('day').toDate(),
|
||||
end: now.endOf('day').toDate(),
|
||||
},
|
||||
month: {
|
||||
start: now.startOf('month').toDate(),
|
||||
end: now.endOf('month').toDate(),
|
||||
},
|
||||
}
|
||||
const range = presets[kind]
|
||||
setDraftStart(toInputValue(range.start))
|
||||
setDraftEnd(toInputValue(range.end))
|
||||
onChange(range)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'h-9 w-full justify-start gap-2 px-3 font-mono text-xs font-normal',
|
||||
!start && !end && 'text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CalendarDays className='text-muted-foreground size-4 shrink-0' />
|
||||
<span className='truncate'>{label}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align='start' className='w-[min(520px,calc(100vw-2rem))] p-3'>
|
||||
<div className='space-y-3'>
|
||||
<div className='grid gap-2 sm:grid-cols-[1fr_auto_1fr] sm:items-end'>
|
||||
<div className='space-y-1.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t('Start Time')}
|
||||
</div>
|
||||
<Input
|
||||
type='datetime-local'
|
||||
value={draftStart}
|
||||
onChange={(e) => setDraftStart(e.target.value)}
|
||||
className='h-8 font-mono text-xs'
|
||||
/>
|
||||
</div>
|
||||
<span className='text-muted-foreground hidden pb-2 text-xs sm:block'>
|
||||
~
|
||||
</span>
|
||||
<div className='space-y-1.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t('End Time')}
|
||||
</div>
|
||||
<Input
|
||||
type='datetime-local'
|
||||
value={draftEnd}
|
||||
onChange={(e) => setDraftEnd(e.target.value)}
|
||||
className='h-8 font-mono text-xs'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap gap-1.5'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='h-7 flex-1 px-2 text-xs'
|
||||
onClick={() => applyPreset('today')}
|
||||
>
|
||||
{t('Today')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='h-7 flex-1 px-2 text-xs'
|
||||
onClick={() => applyPreset('7d')}
|
||||
>
|
||||
{t('7 Days')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='h-7 flex-1 px-2 text-xs'
|
||||
onClick={() => applyPreset('week')}
|
||||
>
|
||||
{t('This week')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='h-7 flex-1 px-2 text-xs'
|
||||
onClick={() => applyPreset('30d')}
|
||||
>
|
||||
{t('30 Days')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='h-7 flex-1 px-2 text-xs'
|
||||
onClick={() => applyPreset('month')}
|
||||
>
|
||||
{t('This month')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end'>
|
||||
<Button size='sm' className='h-8' onClick={applyDraft}>
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,8 @@ interface UsageLogsContextValue {
|
||||
setAffinityTarget: (target: ChannelAffinityInfo | null) => void
|
||||
affinityDialogOpen: boolean
|
||||
setAffinityDialogOpen: (open: boolean) => void
|
||||
sensitiveVisible: boolean
|
||||
setSensitiveVisible: (visible: boolean) => void
|
||||
}
|
||||
|
||||
const UsageLogsContext = createContext<UsageLogsContextValue | undefined>(
|
||||
@@ -23,6 +25,7 @@ export function UsageLogsProvider({ children }: { children: ReactNode }) {
|
||||
const [affinityTarget, setAffinityTarget] =
|
||||
useState<ChannelAffinityInfo | null>(null)
|
||||
const [affinityDialogOpen, setAffinityDialogOpen] = useState(false)
|
||||
const [sensitiveVisible, setSensitiveVisible] = useState(true)
|
||||
|
||||
return (
|
||||
<UsageLogsContext.Provider
|
||||
@@ -35,6 +38,8 @@ export function UsageLogsProvider({ children }: { children: ReactNode }) {
|
||||
setAffinityTarget,
|
||||
affinityDialogOpen,
|
||||
setAffinityDialogOpen,
|
||||
sensitiveVisible,
|
||||
setSensitiveVisible,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -28,15 +28,18 @@ import {
|
||||
import {
|
||||
DataTablePagination,
|
||||
DataTableToolbar,
|
||||
DataTableViewOptions,
|
||||
TableSkeleton,
|
||||
TableEmpty,
|
||||
MobileCardList,
|
||||
} from '@/components/data-table'
|
||||
import { PageFooterPortal } from '@/components/layout'
|
||||
import { LOG_TYPE_FILTERS, DEFAULT_LOGS_DATA } from '../constants'
|
||||
import { DEFAULT_LOGS_DATA } from '../constants'
|
||||
import { useColumnsByCategory } from '../lib/columns'
|
||||
import { fetchLogsByCategory } from '../lib/utils'
|
||||
import type { LogCategory } from '../types'
|
||||
import { CommonLogsFilterBar } from './common-logs-filter-bar'
|
||||
import { CommonLogsStats } from './common-logs-stats'
|
||||
|
||||
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
||||
|
||||
@@ -147,25 +150,23 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
|
||||
ensurePageInRange(pageCount)
|
||||
}, [pageCount, ensurePageInRange])
|
||||
|
||||
const filters =
|
||||
logCategory === 'common'
|
||||
? [
|
||||
{
|
||||
columnId: 'created_at',
|
||||
title: t('Log Type'),
|
||||
options: LOG_TYPE_FILTERS.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: t(opt.label),
|
||||
})),
|
||||
singleSelect: true,
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-4'>
|
||||
<DataTableToolbar table={table} filters={filters} customSearch={null} />
|
||||
{logCategory === 'common' ? (
|
||||
<div className='rounded-md border bg-card/50 p-3 shadow-xs'>
|
||||
<CommonLogsFilterBar
|
||||
stats={<CommonLogsStats />}
|
||||
viewOptions={<DataTableViewOptions table={table} />}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
filters={[]}
|
||||
customSearch={null}
|
||||
/>
|
||||
)}
|
||||
{isMobile ? (
|
||||
<MobileCardList
|
||||
table={table}
|
||||
|
||||
Reference in New Issue
Block a user