✨ refactor(ui): Improve usage log filter responsiveness and mobile UX
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.
This commit is contained in:
@@ -21,7 +21,6 @@ import { useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { Zap } from 'lucide-react'
|
||||
import { formatTimestampToDate, formatTokens } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -97,36 +96,6 @@ export function createTimestampColumn<T>(config: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Duration pill colors matching common logs timing column
|
||||
*/
|
||||
const durationPillBg: Record<string, string> = {
|
||||
green:
|
||||
'border border-emerald-200/60 bg-emerald-50/70 dark:border-emerald-800/50 dark:bg-emerald-950/25',
|
||||
red: 'border border-rose-200/70 bg-rose-50/70 dark:border-rose-800/50 dark:bg-rose-950/25',
|
||||
success:
|
||||
'border border-emerald-200/60 bg-emerald-50/50 dark:border-emerald-800/50 dark:bg-emerald-950/20',
|
||||
info: 'border border-sky-200/60 bg-sky-50/50 dark:border-sky-800/50 dark:bg-sky-950/20',
|
||||
warning:
|
||||
'border border-amber-200/60 bg-amber-50/50 dark:border-amber-800/50 dark:bg-amber-950/20',
|
||||
}
|
||||
|
||||
const durationTextColor: Record<string, string> = {
|
||||
green: 'text-emerald-700 dark:text-emerald-400',
|
||||
red: 'text-rose-700 dark:text-rose-400',
|
||||
success: 'text-emerald-700 dark:text-emerald-400',
|
||||
info: 'text-sky-700 dark:text-sky-400',
|
||||
warning: 'text-amber-700 dark:text-amber-400',
|
||||
}
|
||||
|
||||
const durationDotColor: Record<string, string> = {
|
||||
green: 'bg-emerald-500',
|
||||
red: 'bg-rose-500',
|
||||
success: 'bg-emerald-500',
|
||||
info: 'bg-sky-500',
|
||||
warning: 'bg-amber-500',
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a duration column - pill style matching common logs timing
|
||||
*/
|
||||
@@ -163,25 +132,16 @@ export function createDurationColumn<T>(config: {
|
||||
}
|
||||
|
||||
const variant =
|
||||
duration.durationSec > warningThresholdSec ? 'red' : 'green'
|
||||
duration.durationSec > warningThresholdSec ? 'danger' : 'success'
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex w-fit items-center gap-1 rounded-md px-1.5 py-0.5 font-mono text-xs font-medium',
|
||||
durationPillBg[variant],
|
||||
durationTextColor[variant]
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'size-1.5 shrink-0 rounded-full',
|
||||
durationDotColor[variant]
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
{duration.durationSec.toFixed(1)}s
|
||||
</span>
|
||||
<StatusBadge
|
||||
label={`${duration.durationSec.toFixed(1)}s`}
|
||||
variant={variant}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='font-mono'
|
||||
/>
|
||||
)
|
||||
},
|
||||
meta: { label: headerLabel },
|
||||
|
||||
+31
-57
@@ -37,6 +37,7 @@ import {
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
|
||||
import { LOG_TYPE_ALL_VALUE } from '../../constants'
|
||||
import type { UsageLog } from '../../data/schema'
|
||||
import {
|
||||
formatModelName,
|
||||
@@ -281,7 +282,8 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
)
|
||||
},
|
||||
filterFn: (row, _id, value) => {
|
||||
if (!value || value.length === 0) return true
|
||||
if (!Array.isArray(value) || value.length === 0) return true
|
||||
if (value.includes(LOG_TYPE_ALL_VALUE)) return true
|
||||
return value.includes(String(row.original.type))
|
||||
},
|
||||
enableHiding: false,
|
||||
@@ -488,7 +490,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
icon={KeyRound}
|
||||
copyText={sensitiveVisible ? tokenName : undefined}
|
||||
size='sm'
|
||||
showDot={false}
|
||||
className='border-border/60 bg-muted/30 text-foreground max-w-full overflow-hidden rounded-md border px-1.5 py-0.5 font-mono'
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
@@ -554,59 +555,32 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
const timeVariant = getResponseTimeColor(useTime, log.completion_tokens)
|
||||
const frtVariant = frt ? getFirstResponseTimeColor(frt / 1000) : null
|
||||
|
||||
const pillBg: Record<string, string> = {
|
||||
success:
|
||||
'border border-emerald-200/40 bg-emerald-50/35 dark:border-emerald-900/40 dark:bg-emerald-950/15',
|
||||
warning:
|
||||
'border border-amber-200/45 bg-amber-50/35 dark:border-amber-900/40 dark:bg-amber-950/15',
|
||||
danger:
|
||||
'border border-rose-200/50 bg-rose-50/35 dark:border-rose-900/40 dark:bg-rose-950/15',
|
||||
}
|
||||
const pillText: Record<string, string> = {
|
||||
success: 'text-emerald-700/85 dark:text-emerald-400/85',
|
||||
warning: 'text-amber-700/85 dark:text-amber-400/85',
|
||||
danger: 'text-rose-700/85 dark:text-rose-400/85',
|
||||
}
|
||||
const pillDot: Record<string, string> = {
|
||||
success: 'bg-emerald-500/80',
|
||||
warning: 'bg-amber-500/80',
|
||||
danger: 'bg-rose-500/80',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-mono text-xs font-medium',
|
||||
pillBg[timeVariant],
|
||||
pillText[timeVariant]
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'size-1.5 shrink-0 rounded-full',
|
||||
pillDot[timeVariant]
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
{formatUseTime(useTime)}
|
||||
</span>
|
||||
<StatusBadge
|
||||
label={formatUseTime(useTime)}
|
||||
variant={timeVariant as StatusBadgeProps['variant']}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='font-mono'
|
||||
/>
|
||||
{log.is_stream &&
|
||||
(frt != null && frt > 0 ? (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-md px-1.5 py-0.5 font-mono text-xs font-medium',
|
||||
pillBg[frtVariant!],
|
||||
pillText[frtVariant!]
|
||||
)}
|
||||
>
|
||||
{formatUseTime(frt / 1000)}
|
||||
</span>
|
||||
<StatusBadge
|
||||
label={formatUseTime(frt / 1000)}
|
||||
variant={frtVariant as StatusBadgeProps['variant']}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='font-mono'
|
||||
/>
|
||||
) : (
|
||||
<span className='border-border/60 text-muted-foreground/50 inline-flex items-center rounded-md border px-1.5 py-0.5 text-[11px]'>
|
||||
N/A
|
||||
</span>
|
||||
<StatusBadge
|
||||
label='N/A'
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className='flex items-center gap-1 text-[11px]'>
|
||||
@@ -724,15 +698,15 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className='inline-flex items-center gap-1 rounded-md border border-emerald-200 bg-emerald-50 px-1.5 py-0.5 text-xs font-medium text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300' />
|
||||
<StatusBadge
|
||||
label={t('Subscription')}
|
||||
variant='success'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='cursor-help'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span
|
||||
className='size-1.5 rounded-full bg-emerald-500'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
{t('Subscription')}
|
||||
</TooltipTrigger>
|
||||
/>
|
||||
<TooltipContent>
|
||||
<span>
|
||||
{t('Deducted by subscription')}: {formatLogQuota(quota)}
|
||||
|
||||
-3
@@ -132,7 +132,6 @@ export function useDrawingLogsColumns(
|
||||
icon={getDrawingTypeIcon(action)}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -157,7 +156,6 @@ export function useDrawingLogsColumns(
|
||||
label={mjId}
|
||||
autoColor={mjId}
|
||||
size='sm'
|
||||
showDot={false}
|
||||
className='border-border/60 bg-muted/30 max-w-full truncate rounded-md border px-1.5 py-0.5 font-mono'
|
||||
/>
|
||||
</div>
|
||||
@@ -189,7 +187,6 @@ export function useDrawingLogsColumns(
|
||||
variant={mjSubmitResultMapper.getVariant(String(code))}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
showDot
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -183,7 +183,6 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
label={taskId}
|
||||
autoColor={taskId}
|
||||
size='sm'
|
||||
showDot={false}
|
||||
className='border-border/60 bg-muted/30 max-w-full truncate rounded-md border px-1.5 py-0.5 font-mono'
|
||||
/>
|
||||
<span className='text-muted-foreground/60 truncate text-[11px]'>
|
||||
@@ -214,7 +213,6 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
variant={taskStatusMapper.getVariant(status)}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
showDot
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
||||
+181
-127
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useQueryClient, useIsFetching } from '@tanstack/react-query'
|
||||
import { useNavigate, getRouteApi } from '@tanstack/react-router'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
@@ -24,7 +24,6 @@ import { Eye, EyeOff } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useIsAdmin } from '@/hooks/use-admin'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -38,13 +37,17 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DataTableToolbar } from '@/components/data-table'
|
||||
import { LOG_TYPES } from '../constants'
|
||||
import { LOG_TYPE_ALL_VALUE, LOG_TYPE_FILTERS } from '../constants'
|
||||
import { buildSearchParams } from '../lib/filter'
|
||||
import { getDefaultTimeRange } from '../lib/utils'
|
||||
import type { CommonLogFilters } from '../types'
|
||||
import { CommonLogsStats } from './common-logs-stats'
|
||||
import { CompactDateTimeRangePicker } from './compact-date-time-range-picker'
|
||||
import {
|
||||
LogsFilterField,
|
||||
LogsFilterInput,
|
||||
LogsFilterToolbar,
|
||||
} from './logs-filter-toolbar'
|
||||
import { useUsageLogsContext } from './usage-logs-provider'
|
||||
|
||||
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
||||
@@ -75,30 +78,32 @@ export function CommonLogsFilterBar<TData>(
|
||||
const { start, end } = getDefaultTimeRange()
|
||||
return { startTime: start, endTime: end }
|
||||
})
|
||||
const [logType, setLogType] = useState<LogTypeValue | ''>('')
|
||||
const [logType, setLogType] = useState<LogTypeValue>(LOG_TYPE_ALL_VALUE)
|
||||
|
||||
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 (searchParams.upstreamRequestId)
|
||||
next.upstreamRequestId = searchParams.upstreamRequestId
|
||||
|
||||
if (Object.keys(next).length > 0) {
|
||||
setFilters((prev) => ({ ...prev, ...next }))
|
||||
}
|
||||
const { start, end } = getDefaultTimeRange()
|
||||
setFilters({
|
||||
startTime: searchParams.startTime
|
||||
? new Date(searchParams.startTime)
|
||||
: start,
|
||||
endTime: searchParams.endTime ? new Date(searchParams.endTime) : end,
|
||||
channel: searchParams.channel || undefined,
|
||||
model: searchParams.model || undefined,
|
||||
token: searchParams.token || undefined,
|
||||
group: searchParams.group || undefined,
|
||||
username: searchParams.username || undefined,
|
||||
requestId: searchParams.requestId || undefined,
|
||||
upstreamRequestId: searchParams.upstreamRequestId || undefined,
|
||||
})
|
||||
|
||||
const typeArr = searchParams.type
|
||||
if (Array.isArray(typeArr) && typeArr.length === 1) {
|
||||
setLogType(typeArr[0])
|
||||
}
|
||||
const nextLogType =
|
||||
Array.isArray(typeArr) &&
|
||||
typeArr.length === 1 &&
|
||||
isLogTypeValue(typeArr[0])
|
||||
? typeArr[0]
|
||||
: LOG_TYPE_ALL_VALUE
|
||||
setLogType(nextLogType)
|
||||
}, [
|
||||
searchParams.startTime,
|
||||
searchParams.endTime,
|
||||
@@ -126,7 +131,7 @@ export function CommonLogsFilterBar<TData>(
|
||||
params: { section: 'common' },
|
||||
search: {
|
||||
...filterParams,
|
||||
...(logType ? { type: [logType] } : {}),
|
||||
type: [logType],
|
||||
page: 1,
|
||||
},
|
||||
})
|
||||
@@ -138,13 +143,14 @@ export function CommonLogsFilterBar<TData>(
|
||||
const { start, end } = getDefaultTimeRange()
|
||||
const resetFilters: CommonLogFilters = { startTime: start, endTime: end }
|
||||
setFilters(resetFilters)
|
||||
setLogType('')
|
||||
setLogType(LOG_TYPE_ALL_VALUE)
|
||||
|
||||
navigate({
|
||||
to: '/usage-logs/$section',
|
||||
params: { section: 'common' },
|
||||
search: {
|
||||
page: 1,
|
||||
type: [LOG_TYPE_ALL_VALUE],
|
||||
startTime: start.getTime(),
|
||||
endTime: end.getTime(),
|
||||
},
|
||||
@@ -167,11 +173,28 @@ export function CommonLogsFilterBar<TData>(
|
||||
!!filters.requestId ||
|
||||
!!filters.upstreamRequestId
|
||||
|
||||
const hasTypeFilter = logType !== LOG_TYPE_ALL_VALUE
|
||||
const hasAdditionalFilters =
|
||||
!!filters.model || !!filters.group || !!logType || hasExpandedFilters
|
||||
!!filters.model || !!filters.group || hasTypeFilter || hasExpandedFilters
|
||||
|
||||
const inputClass = 'w-full sm:w-[140px] lg:w-[160px]'
|
||||
const expandedFilterCount = [
|
||||
filters.token,
|
||||
isAdmin ? filters.username : undefined,
|
||||
isAdmin ? filters.channel : undefined,
|
||||
filters.requestId,
|
||||
filters.upstreamRequestId,
|
||||
].filter(Boolean).length
|
||||
const sensitiveType = sensitiveVisible ? 'text' : 'password'
|
||||
const logTypeItems = useMemo(
|
||||
() =>
|
||||
LOG_TYPE_FILTERS.map((type) => ({
|
||||
value: type.value,
|
||||
label: t(type.label),
|
||||
})),
|
||||
[t]
|
||||
)
|
||||
const logTypeLabel =
|
||||
logTypeItems.find((type) => type.value === logType)?.label ?? t('All Types')
|
||||
|
||||
const statsBar = (
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
@@ -197,114 +220,145 @@ export function CommonLogsFilterBar<TData>(
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<DataTableToolbar
|
||||
table={props.table}
|
||||
leftActions={statsBar}
|
||||
customSearch={
|
||||
<CompactDateTimeRangePicker
|
||||
start={filters.startTime}
|
||||
end={filters.endTime}
|
||||
onChange={({ start, end }) => {
|
||||
handleChange('startTime', start)
|
||||
handleChange('endTime', end)
|
||||
}}
|
||||
className='w-full sm:w-[340px]'
|
||||
const dateRangeFilter = (
|
||||
<LogsFilterField wide>
|
||||
<CompactDateTimeRangePicker
|
||||
start={filters.startTime}
|
||||
end={filters.endTime}
|
||||
onChange={({ start, end }) => {
|
||||
handleChange('startTime', start)
|
||||
handleChange('endTime', end)
|
||||
}}
|
||||
/>
|
||||
</LogsFilterField>
|
||||
)
|
||||
const modelFilter = (
|
||||
<LogsFilterField>
|
||||
<LogsFilterInput
|
||||
placeholder={t('Model Name')}
|
||||
value={filters.model || ''}
|
||||
onChange={(e) => handleChange('model', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</LogsFilterField>
|
||||
)
|
||||
const groupFilter = (
|
||||
<LogsFilterField>
|
||||
<LogsFilterInput
|
||||
placeholder={t('Group')}
|
||||
type={sensitiveType}
|
||||
value={filters.group || ''}
|
||||
onChange={(e) => handleChange('group', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</LogsFilterField>
|
||||
)
|
||||
const typeFilter = (
|
||||
<LogsFilterField>
|
||||
<Select
|
||||
items={logTypeItems}
|
||||
value={logType}
|
||||
onValueChange={(value) => {
|
||||
setLogType(
|
||||
value !== null && isLogTypeValue(value) ? value : LOG_TYPE_ALL_VALUE
|
||||
)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue>{logTypeLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{LOG_TYPE_FILTERS.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{t(type.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</LogsFilterField>
|
||||
)
|
||||
const advancedFilters = (
|
||||
<>
|
||||
<LogsFilterField>
|
||||
<LogsFilterInput
|
||||
placeholder={t('Token Name')}
|
||||
type={sensitiveType}
|
||||
value={filters.token || ''}
|
||||
onChange={(e) => handleChange('token', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
}
|
||||
additionalSearch={
|
||||
<>
|
||||
<Input
|
||||
placeholder={t('Model Name')}
|
||||
value={filters.model || ''}
|
||||
onChange={(e) => handleChange('model', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={inputClass}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('Group')}
|
||||
</LogsFilterField>
|
||||
{isAdmin && (
|
||||
<LogsFilterField>
|
||||
<LogsFilterInput
|
||||
placeholder={t('Username')}
|
||||
type={sensitiveType}
|
||||
value={filters.group || ''}
|
||||
onChange={(e) => handleChange('group', e.target.value)}
|
||||
value={filters.username || ''}
|
||||
onChange={(e) => handleChange('username', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={inputClass}
|
||||
/>
|
||||
<Select
|
||||
items={[
|
||||
{ value: 'all', label: t('All Types') },
|
||||
...LOG_TYPES.map((type) => ({
|
||||
value: String(type.value),
|
||||
label: t(type.label),
|
||||
})),
|
||||
]}
|
||||
value={logType}
|
||||
onValueChange={(value) => {
|
||||
setLogType(value !== null && isLogTypeValue(value) ? value : '')
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={inputClass}>
|
||||
<SelectValue placeholder={t('All Types')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
<SelectItem value='all'>{t('All Types')}</SelectItem>
|
||||
{LOG_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={String(type.value)}>
|
||||
{t(type.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</LogsFilterField>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<LogsFilterField>
|
||||
<LogsFilterInput
|
||||
placeholder={t('Channel ID')}
|
||||
value={filters.channel || ''}
|
||||
onChange={(e) => handleChange('channel', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</LogsFilterField>
|
||||
)}
|
||||
<LogsFilterField>
|
||||
<LogsFilterInput
|
||||
placeholder={t('Request ID')}
|
||||
value={filters.requestId || ''}
|
||||
onChange={(e) => handleChange('requestId', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</LogsFilterField>
|
||||
<LogsFilterField>
|
||||
<LogsFilterInput
|
||||
placeholder={t('Upstream Request ID')}
|
||||
value={filters.upstreamRequestId || ''}
|
||||
onChange={(e) => handleChange('upstreamRequestId', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</LogsFilterField>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<LogsFilterToolbar
|
||||
table={props.table}
|
||||
stats={statsBar}
|
||||
primaryFilters={
|
||||
<>
|
||||
{dateRangeFilter}
|
||||
{modelFilter}
|
||||
{groupFilter}
|
||||
{typeFilter}
|
||||
</>
|
||||
}
|
||||
expandable={
|
||||
advancedFilters={advancedFilters}
|
||||
mobilePinnedFilters={dateRangeFilter}
|
||||
mobileFilters={
|
||||
<>
|
||||
<Input
|
||||
placeholder={t('Token Name')}
|
||||
type={sensitiveType}
|
||||
value={filters.token || ''}
|
||||
onChange={(e) => handleChange('token', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={inputClass}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<Input
|
||||
placeholder={t('Username')}
|
||||
type={sensitiveType}
|
||||
value={filters.username || ''}
|
||||
onChange={(e) => handleChange('username', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Input
|
||||
placeholder={t('Channel ID')}
|
||||
value={filters.channel || ''}
|
||||
onChange={(e) => handleChange('channel', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
placeholder={t('Request ID')}
|
||||
value={filters.requestId || ''}
|
||||
onChange={(e) => handleChange('requestId', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={inputClass}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('Upstream Request ID')}
|
||||
value={filters.upstreamRequestId || ''}
|
||||
onChange={(e) => handleChange('upstreamRequestId', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={inputClass}
|
||||
/>
|
||||
{modelFilter}
|
||||
{groupFilter}
|
||||
{typeFilter}
|
||||
{advancedFilters}
|
||||
</>
|
||||
}
|
||||
hasExpandedActiveFilters={hasExpandedFilters}
|
||||
hasAdditionalFilters={hasAdditionalFilters}
|
||||
mobileFilterCount={
|
||||
[filters.model, filters.group, hasTypeFilter].filter(Boolean).length +
|
||||
expandedFilterCount
|
||||
}
|
||||
hasAdvancedActiveFilters={hasExpandedFilters}
|
||||
advancedFilterCount={expandedFilterCount}
|
||||
hasActiveFilters={hasAdditionalFilters}
|
||||
onSearch={handleApply}
|
||||
searchLoading={fetchingLogs > 0}
|
||||
onReset={handleReset}
|
||||
|
||||
+3
-3
@@ -123,7 +123,7 @@ export function CompactDateTimeRangePicker({
|
||||
type='button'
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'w-full justify-start gap-2 px-2.5 font-mono text-xs font-normal',
|
||||
'w-full justify-start gap-2 px-2.5 text-sm leading-5 font-normal tabular-nums',
|
||||
!start && !end && 'text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
@@ -147,7 +147,7 @@ export function CompactDateTimeRangePicker({
|
||||
type='datetime-local'
|
||||
value={draftStart}
|
||||
onChange={(e) => setDraftStart(e.target.value)}
|
||||
className='h-8 font-mono text-xs'
|
||||
className='h-8 text-sm leading-5 tabular-nums'
|
||||
/>
|
||||
</div>
|
||||
<span className='text-muted-foreground hidden pb-2 text-xs sm:block'>
|
||||
@@ -161,7 +161,7 @@ export function CompactDateTimeRangePicker({
|
||||
type='datetime-local'
|
||||
value={draftEnd}
|
||||
onChange={(e) => setDraftEnd(e.target.value)}
|
||||
className='h-8 font-mono text-xs'
|
||||
className='h-8 text-sm leading-5 tabular-nums'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
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 { useState, type ComponentProps, type ReactNode } from 'react'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { ChevronDown, Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from '@/components/ui/drawer'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { DataTableViewOptions } from '@/components/data-table'
|
||||
|
||||
interface LogsFilterToolbarProps<TData> {
|
||||
table: Table<TData>
|
||||
primaryFilters: ReactNode
|
||||
advancedFilters?: ReactNode
|
||||
mobilePinnedFilters?: ReactNode
|
||||
mobileFilters?: ReactNode
|
||||
mobileFilterCount?: number
|
||||
stats?: ReactNode
|
||||
hasActiveFilters: boolean
|
||||
hasAdvancedActiveFilters?: boolean
|
||||
advancedFilterCount?: number
|
||||
searchLoading?: boolean
|
||||
onReset: () => void
|
||||
onSearch: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface LogsFilterFieldProps {
|
||||
children: ReactNode
|
||||
wide?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LogsFilterField(props: LogsFilterFieldProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 [&_[data-slot=select-trigger]]:w-full [&_[data-slot=select-trigger]]:text-sm [&_[data-slot=select-value]]:leading-5',
|
||||
props.wide && 'sm:col-span-2',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LogsFilterInput(props: ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
className={cn('h-8 min-w-0 text-sm leading-5', props.className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function LogsFilterToolbar<TData>(props: LogsFilterToolbarProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
|
||||
const isMobile = useMediaQuery('(max-width: 640px)')
|
||||
|
||||
const hasAdvancedFilters = props.advancedFilters != null
|
||||
const activeAdvancedCount =
|
||||
props.advancedFilterCount ?? (props.hasAdvancedActiveFilters ? 1 : 0)
|
||||
const activeMobileFilterCount = props.mobileFilterCount ?? activeAdvancedCount
|
||||
|
||||
const handleMobileReset = () => {
|
||||
props.onReset()
|
||||
setMobileFiltersOpen(false)
|
||||
}
|
||||
|
||||
const handleMobileSearch = () => {
|
||||
props.onSearch()
|
||||
setMobileFiltersOpen(false)
|
||||
}
|
||||
|
||||
if (isMobile && props.mobilePinnedFilters != null) {
|
||||
return (
|
||||
<Drawer open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
|
||||
<div
|
||||
className={cn('bg-card/50 rounded-lg border p-2.5', props.className)}
|
||||
>
|
||||
<div className='grid gap-2'>{props.mobilePinnedFilters}</div>
|
||||
|
||||
<div className='mt-2 flex flex-col gap-2'>
|
||||
{props.stats}
|
||||
<div className='flex items-center justify-end gap-1.5'>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground gap-1 px-2',
|
||||
activeMobileFilterCount > 0 &&
|
||||
'text-primary hover:text-primary'
|
||||
)}
|
||||
>
|
||||
{t('Filter')}
|
||||
{activeMobileFilterCount > 0 && (
|
||||
<Badge className='ml-0.5 size-5 justify-center p-0 text-[10px]'>
|
||||
{activeMobileFilterCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={props.onSearch}
|
||||
disabled={props.searchLoading}
|
||||
>
|
||||
{props.searchLoading && <Loader2 className='animate-spin' />}
|
||||
{t('Search')}
|
||||
</Button>
|
||||
<DataTableViewOptions table={props.table} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DrawerContent className='max-h-[85dvh] p-0'>
|
||||
<div className='mx-auto flex w-full max-w-md flex-1 flex-col overflow-hidden'>
|
||||
<DrawerHeader className='border-border/70 border-b px-4 py-3 text-left'>
|
||||
<DrawerTitle>{t('Filter')}</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
{t('Adjust filters, then search to refresh the logs.')}
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className='flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-4 py-3'>
|
||||
{props.mobileFilters ?? (
|
||||
<>
|
||||
{props.primaryFilters}
|
||||
{props.advancedFilters}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DrawerFooter className='border-border/70 grid grid-cols-2 gap-2 border-t px-4 py-3'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={handleMobileReset}
|
||||
disabled={!props.hasActiveFilters}
|
||||
>
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={handleMobileSearch}
|
||||
disabled={props.searchLoading}
|
||||
>
|
||||
{props.searchLoading && <Loader2 className='animate-spin' />}
|
||||
{t('Search')}
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-card/50 rounded-lg border p-2.5 sm:p-3',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<div className='grid grid-cols-1 gap-2 sm:grid-cols-[repeat(auto-fit,minmax(10rem,1fr))]'>
|
||||
{props.primaryFilters}
|
||||
{advancedOpen && props.advancedFilters}
|
||||
</div>
|
||||
|
||||
<div className='mt-2 flex flex-wrap items-center gap-2'>
|
||||
{props.stats}
|
||||
<div className='ms-auto flex flex-wrap items-center justify-end gap-1.5 sm:gap-2'>
|
||||
{hasAdvancedFilters && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => setAdvancedOpen((open) => !open)}
|
||||
aria-expanded={advancedOpen}
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground gap-1 px-2',
|
||||
props.hasAdvancedActiveFilters &&
|
||||
!advancedOpen &&
|
||||
'text-primary hover:text-primary'
|
||||
)}
|
||||
>
|
||||
{advancedOpen ? t('Collapse') : t('Expand')}
|
||||
{activeAdvancedCount > 0 && (
|
||||
<Badge className='ml-0.5 size-5 justify-center p-0 text-[10px]'>
|
||||
{activeAdvancedCount}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-3.5 transition-transform duration-200',
|
||||
advancedOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={props.onReset}
|
||||
disabled={!props.hasActiveFilters}
|
||||
>
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={props.onSearch}
|
||||
disabled={props.searchLoading}
|
||||
>
|
||||
{props.searchLoading && <Loader2 className='animate-spin' />}
|
||||
{t('Search')}
|
||||
</Button>
|
||||
<DataTableViewOptions table={props.table} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -22,12 +22,15 @@ import { useNavigate, getRouteApi } from '@tanstack/react-router'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useIsAdmin } from '@/hooks/use-admin'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { DataTableToolbar } from '@/components/data-table'
|
||||
import { buildSearchParams } from '../lib/filter'
|
||||
import { getDefaultTimeRange } from '../lib/utils'
|
||||
import type { DrawingLogFilters, LogCategory, TaskLogFilters } from '../types'
|
||||
import { CompactDateTimeRangePicker } from './compact-date-time-range-picker'
|
||||
import {
|
||||
LogsFilterField,
|
||||
LogsFilterInput,
|
||||
LogsFilterToolbar,
|
||||
} from './logs-filter-toolbar'
|
||||
|
||||
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
||||
|
||||
@@ -160,45 +163,60 @@ export function TaskLogsFilterBar<TData>(props: TaskLogsFilterBarProps<TData>) {
|
||||
props.logCategory === 'drawing'
|
||||
? t('Filter by Midjourney task ID')
|
||||
: t('Filter by task ID')
|
||||
const inputClass = 'w-full sm:w-[180px] lg:w-[200px]'
|
||||
const hasAdditionalFilters = !!filterValue || !!filters.channel
|
||||
const dateRangeFilter = (
|
||||
<LogsFilterField wide>
|
||||
<CompactDateTimeRangePicker
|
||||
start={filters.startTime}
|
||||
end={filters.endTime}
|
||||
onChange={({ start, end }) => {
|
||||
handleChange('startTime', start)
|
||||
handleChange('endTime', end)
|
||||
}}
|
||||
/>
|
||||
</LogsFilterField>
|
||||
)
|
||||
const taskIdFilter = (
|
||||
<LogsFilterField>
|
||||
<LogsFilterInput
|
||||
aria-label={t('Task ID')}
|
||||
placeholder={placeholder}
|
||||
value={filterValue}
|
||||
onChange={(e) => handleFilterChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</LogsFilterField>
|
||||
)
|
||||
const channelFilter = isAdmin ? (
|
||||
<LogsFilterField>
|
||||
<LogsFilterInput
|
||||
placeholder={t('Channel ID')}
|
||||
value={filters.channel || ''}
|
||||
onChange={(e) => handleChange('channel', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</LogsFilterField>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<DataTableToolbar
|
||||
<LogsFilterToolbar
|
||||
table={props.table}
|
||||
customSearch={
|
||||
<CompactDateTimeRangePicker
|
||||
start={filters.startTime}
|
||||
end={filters.endTime}
|
||||
onChange={({ start, end }) => {
|
||||
handleChange('startTime', start)
|
||||
handleChange('endTime', end)
|
||||
}}
|
||||
className='w-full sm:w-[340px]'
|
||||
/>
|
||||
}
|
||||
additionalSearch={
|
||||
primaryFilters={
|
||||
<>
|
||||
<Input
|
||||
aria-label={t('Task ID')}
|
||||
placeholder={placeholder}
|
||||
value={filterValue}
|
||||
onChange={(e) => handleFilterChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={inputClass}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<Input
|
||||
placeholder={t('Channel ID')}
|
||||
value={filters.channel || ''}
|
||||
onChange={(e) => handleChange('channel', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
{dateRangeFilter}
|
||||
{taskIdFilter}
|
||||
{channelFilter}
|
||||
</>
|
||||
}
|
||||
hasAdditionalFilters={hasAdditionalFilters}
|
||||
mobilePinnedFilters={dateRangeFilter}
|
||||
mobileFilters={
|
||||
<>
|
||||
{taskIdFilter}
|
||||
{channelFilter}
|
||||
</>
|
||||
}
|
||||
mobileFilterCount={[filterValue, filters.channel].filter(Boolean).length}
|
||||
hasActiveFilters={hasAdditionalFilters}
|
||||
onSearch={handleApply}
|
||||
searchLoading={fetchingLogs > 0}
|
||||
onReset={handleReset}
|
||||
|
||||
@@ -37,7 +37,11 @@ import { useIsAdmin } from '@/hooks/use-admin'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
import { TableCell, TableRow } from '@/components/ui/table'
|
||||
import { DataTablePage } from '@/components/data-table'
|
||||
import { DEFAULT_LOGS_DATA, LOG_TYPE_ENUM } from '../constants'
|
||||
import {
|
||||
DEFAULT_LOGS_DATA,
|
||||
LOG_TYPE_ALL_VALUE,
|
||||
LOG_TYPE_ENUM,
|
||||
} from '../constants'
|
||||
import { useColumnsByCategory } from '../lib/columns'
|
||||
import { fetchLogsByCategory } from '../lib/utils'
|
||||
import type { LogCategory } from '../types'
|
||||
@@ -51,6 +55,11 @@ const logTypeRowTint: Record<number, string> = {
|
||||
[LOG_TYPE_ENUM.REFUND]: 'bg-blue-50/30 dark:bg-blue-950/15',
|
||||
}
|
||||
|
||||
function deserializeLogTypeFilter(value: unknown): unknown[] {
|
||||
const values = Array.isArray(value) ? value : value ? [value] : []
|
||||
return values.filter((item) => String(item) !== LOG_TYPE_ALL_VALUE)
|
||||
}
|
||||
|
||||
interface UsageLogsTableProps {
|
||||
logCategory: LogCategory
|
||||
}
|
||||
@@ -73,7 +82,12 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
|
||||
pagination: { defaultPage: 1, defaultPageSize: isMobile ? 20 : 100 },
|
||||
globalFilter: { enabled: false },
|
||||
columnFilters: [
|
||||
{ columnId: 'created_at', searchKey: 'type', type: 'array' as const },
|
||||
{
|
||||
columnId: 'created_at',
|
||||
searchKey: 'type',
|
||||
type: 'array' as const,
|
||||
deserialize: deserializeLogTypeFilter,
|
||||
},
|
||||
{ columnId: 'model_name', searchKey: 'model', type: 'string' as const },
|
||||
{ columnId: 'token_name', searchKey: 'token', type: 'string' as const },
|
||||
{ columnId: 'group', searchKey: 'group', type: 'string' as const },
|
||||
|
||||
+17
-4
@@ -60,6 +60,12 @@ export const LOG_TYPE_ENUM = {
|
||||
REFUND: 6,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* The log list/stat backend uses type=0 as the "all types" sentinel.
|
||||
* Row rendering still displays records with type=0 as "Unknown".
|
||||
*/
|
||||
export const LOG_TYPE_ALL_VALUE = '0' as const
|
||||
|
||||
// ============================================================================
|
||||
// Time Range Presets
|
||||
// ============================================================================
|
||||
@@ -93,11 +99,18 @@ export const LOG_TYPES = [
|
||||
|
||||
/**
|
||||
* Log types for DataTableToolbar filters (single select mode)
|
||||
* Backend treats type=0 as "all logs" in list/stat endpoints, so the filter
|
||||
* must not expose the display-only "Unknown" label for that value.
|
||||
*/
|
||||
export const LOG_TYPE_FILTERS = LOG_TYPES.map((type) => ({
|
||||
label: type.label,
|
||||
value: String(type.value),
|
||||
}))
|
||||
export const LOG_TYPE_FILTERS = [
|
||||
{ label: 'All Types', value: LOG_TYPE_ALL_VALUE },
|
||||
...LOG_TYPES.filter((type) => type.value !== LOG_TYPE_ENUM.UNKNOWN).map(
|
||||
(type) => ({
|
||||
label: type.label,
|
||||
value: String(type.value),
|
||||
})
|
||||
),
|
||||
] as const
|
||||
|
||||
// ============================================================================
|
||||
// Drawing Logs (Midjourney) Constants
|
||||
|
||||
+10
-2
@@ -180,9 +180,17 @@ export function buildApiParams(config: {
|
||||
const { page, pageSize, searchParams, columnFilters = [], isAdmin } = config
|
||||
|
||||
// Helper to process type parameter (single value from array)
|
||||
const processType = (value: unknown) => {
|
||||
const processType = (value: unknown): number | undefined => {
|
||||
const parseType = (raw: unknown): number | undefined => {
|
||||
const type = Number(raw)
|
||||
return Number.isFinite(type) ? type : undefined
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && value.length === 1) {
|
||||
return Number(value[0])
|
||||
return parseType(value[0])
|
||||
}
|
||||
if (typeof value === 'string' && value !== '') {
|
||||
return parseType(value)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user