fix(web): improve channel and usage log UI

Fixes #5121
This commit is contained in:
CaIon
2026-05-26 20:28:28 +08:00
parent f8add4ca49
commit dc245ae764
21 changed files with 579 additions and 116 deletions
+1 -1
View File
@@ -131,7 +131,7 @@ export function StatusBadge({
return (
<span
className={cn(
'inline-flex w-fit max-w-full shrink-0 items-center rounded-full font-medium tracking-normal whitespace-nowrap transition-colors',
'inline-flex w-fit max-w-full shrink-0 items-center rounded-4xl font-medium tracking-normal whitespace-nowrap transition-colors',
sizeMap[size ?? 'sm'],
textColorMap[computedVariant],
pulse && 'animate-pulse',
+10 -3
View File
@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
'use client'
import * as React from 'react'
import { useMediaQuery } from '@/hooks'
import { Select as SelectPrimitive } from '@base-ui/react/select'
import {
UnfoldMoreIcon,
@@ -97,8 +98,9 @@ function SelectContent({
SelectPrimitive.Positioner.Props,
'align' | 'alignOffset' | 'side' | 'sideOffset' | 'alignItemWithTrigger'
>) {
return (
<SelectPrimitive.Portal>
const isMobile = useMediaQuery('(max-width: 640px)')
const content = (
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
@@ -121,8 +123,13 @@ function SelectContent({
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
if (isMobile) {
return content
}
return <SelectPrimitive.Portal>{content}</SelectPrimitive.Portal>
}
function SelectLabel({
+4 -1
View File
@@ -40,6 +40,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
@@ -418,7 +419,7 @@ function SidebarGroupLabel({
props: mergeProps<'div'>(
{
className: cn(
'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
className
),
},
@@ -556,6 +557,7 @@ function SidebarMenuButton({
}
return (
<TooltipProvider delay={0}>
<Tooltip>
{comp}
<TooltipContent
@@ -565,6 +567,7 @@ function SidebarMenuButton({
{...tooltip}
/>
</Tooltip>
</TooltipProvider>
)
}
@@ -26,6 +26,7 @@ import {
ChevronRight,
ListOrdered,
Shuffle,
SlidersHorizontal,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -301,15 +302,18 @@ function BalanceCell({ channel }: { channel: Channel }) {
const usedDisplay = withSuffix(formatQuotaValue(usedQuota))
const remainingDisplay = withSuffix(formatBalance(balance))
const usedLabel = `${t('Used:')} ${usedDisplay}`
const remainingLabel = `${t('Remaining:')} ${remainingDisplay}`
// Tag row: only show cumulative used quota
if (isTagRow) {
return (
<StatusBadge
label={`Used: ${usedDisplay}`}
label={usedLabel}
variant='neutral'
size='sm'
copyable={false}
showDot={false}
/>
)
}
@@ -354,14 +358,13 @@ function BalanceCell({ channel }: { channel: Channel }) {
variant='neutral'
size='sm'
copyable={false}
showDot={false}
className='cursor-help'
/>
}
/>
<TooltipContent>
<p>
{t('Used:')} {usedDisplay}
</p>
<p>{usedLabel}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -384,6 +387,7 @@ function BalanceCell({ channel }: { channel: Channel }) {
}
size='sm'
copyable={false}
showDot={false}
className='cursor-pointer'
onClick={handleClickUpdate}
/>
@@ -393,7 +397,7 @@ function BalanceCell({ channel }: { channel: Channel }) {
<p>
{channel.type === 57
? t('Click to view Codex usage')
: `${t('Remaining:')} ${remainingDisplay}`}
: remainingLabel}
</p>
{channel.type !== 57 && <p>{t('Click to update balance')}</p>}
</TooltipContent>
@@ -494,7 +498,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
const isTagRow = isTagAggregateRow(row.original)
const name = row.getValue('name') as string
const channel = row.original
const isMultiKey = isMultiKeyChannel(channel)
// Tag row with expand/collapse
if (isTagRow) {
@@ -531,6 +534,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
// Regular channel row
const settings = parseChannelSettings(channel.setting)
const isPassThrough = settings.pass_through_body_enabled === true
const hasParamOverride = Boolean(channel.param_override?.trim())
return (
<div className='flex items-center gap-2'>
@@ -557,13 +561,19 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
</Tooltip>
</TooltipProvider>
)}
{isMultiKey && (
<StatusBadge
label={`${channel.channel_info.multi_key_size} keys`}
variant='purple'
size='sm'
copyable={false}
/>
{hasParamOverride && (
<TooltipProvider delay={100}>
<Tooltip>
<TooltipTrigger
render={
<SlidersHorizontal className='text-info h-3.5 w-3.5 flex-shrink-0' />
}
></TooltipTrigger>
<TooltipContent side='top'>
{t('Override request parameters')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<UpstreamUpdateTags channel={channel} />
</div>
@@ -2754,23 +2754,15 @@ export function ChannelMutateDrawer({
</div>
</div>
<FormControl>
<JsonEditor
<Textarea
value={field.value || ''}
onChange={field.onChange}
disabled={isSubmitting}
keyPlaceholder='temperature'
valuePlaceholder='0.7'
keyLabel='Parameter'
valueLabel='Value'
emptyMessage={t(
'No parameter overrides configured.'
rows={8}
placeholder={t(
'Override request parameters. Cannot override stream parameter.'
)}
template={{
temperature: 0.7,
max_tokens: 2000,
top_p: 1,
}}
valueType='any'
className='max-h-72 min-h-40 resize-y overflow-auto font-mono text-xs'
/>
</FormControl>
<FormMessage />
@@ -26,6 +26,7 @@ import {
ChevronDown,
ChevronUp,
Circle,
Copy,
CreditCard,
FileText,
KeyRound,
@@ -38,13 +39,14 @@ import {
} from 'lucide-react'
import { motion, useReducedMotion } from 'motion/react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth-store'
import { getUserModels } from '@/lib/api'
import { MOTION_TRANSITION } from '@/lib/motion'
import { ROLE } from '@/lib/roles'
import { cn } from '@/lib/utils'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@/components/ui/button'
import { CopyButton } from '@/components/copy-button'
import {
CardStaggerContainer,
CardStaggerItem,
@@ -104,8 +106,8 @@ interface RequestExample {
endpoint: string
model: string
keyName: string
keyId?: number
displayKey: string
curl: string
ready: boolean
}
@@ -179,7 +181,7 @@ function SetupGuideBackdrop(props: { compact?: boolean }) {
<>
<div
className={cn(
'pointer-events-none absolute inset-0 bg-[linear-gradient(112deg,oklch(0.97_0.04_250/.92)_0%,oklch(0.95_0.08_315/.82)_38%,oklch(0.96_0.12_92/.78)_74%,oklch(0.94_0.1_132/.62)_100%)] dark:opacity-25',
'pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_48%_120%_at_78%_0%,color-mix(in_oklch,var(--primary)_8%,transparent)_0%,transparent_62%),linear-gradient(112deg,color-mix(in_oklch,var(--card)_98%,var(--primary)_2%)_0%,color-mix(in_oklch,var(--card)_94%,var(--muted)_6%)_48%,color-mix(in_oklch,var(--background)_92%,var(--accent)_8%)_100%)] dark:opacity-65',
props.compact
? '[mask-image:linear-gradient(90deg,black_0%,black_48%,transparent_74%)] opacity-55'
: 'opacity-85'
@@ -188,7 +190,7 @@ function SetupGuideBackdrop(props: { compact?: boolean }) {
/>
<div
className={cn(
'pointer-events-none absolute inset-y-0 right-0 hidden overflow-hidden font-mono text-lime-100/75 sm:block dark:text-lime-200/25',
'text-foreground/5 pointer-events-none absolute inset-y-0 right-0 hidden overflow-hidden font-mono sm:block dark:text-foreground/8',
props.compact ? 'w-1/2 opacity-45' : 'w-[58%] opacity-75'
)}
aria-hidden='true'
@@ -275,12 +277,41 @@ function RequestPreview(props: {
}) {
const { t } = useTranslation()
const shouldReduceMotion = useReducedMotion()
const previewLines = props.example.curl.split('\n').map((line) => {
if (line.includes('Authorization: Bearer')) {
return ` -H "Authorization: Bearer ${props.example.displayKey}" \\`
}
return line
const [isCopying, setIsCopying] = useState(false)
const { copyToClipboard } = useCopyToClipboard({ notify: false })
const previewCurl = buildCurlCommand({
endpoint: props.example.endpoint,
apiKey: props.example.displayKey,
model: props.example.model,
})
const previewLines = previewCurl.split('\n')
const handleCopyRequest = async () => {
if (!props.example.keyId || isCopying) return
setIsCopying(true)
try {
const result = await fetchTokenKey(props.example.keyId)
const key = result.success && result.data?.key ? result.data.key : ''
if (!key) {
toast.error(result.message || t('Failed to copy to clipboard'))
return
}
const realCurl = buildCurlCommand({
endpoint: props.example.endpoint,
apiKey: `sk-${key}`,
model: props.example.model,
})
const copied = await copyToClipboard(realCurl)
if (copied) {
toast.success(t('Copied to clipboard'))
} else {
toast.error(t('Failed to copy to clipboard'))
}
} finally {
setIsCopying(false)
}
}
return (
<motion.div
@@ -315,17 +346,17 @@ function RequestPreview(props: {
</div>
</div>
{props.example.ready ? (
<CopyButton
value={props.example.curl}
<Button
variant='outline'
size='sm'
className='h-7 gap-1.5 px-2 text-xs'
tooltip={t('Copy ready-to-run curl')}
successTooltip={t('Copied!')}
disabled={isCopying}
onClick={handleCopyRequest}
aria-label={t('Copy ready-to-run curl')}
>
{t('Copy')}
</CopyButton>
<Copy data-icon='inline-start' />
{isCopying ? t('Loading') : t('Copy')}
</Button>
) : (
<Button size='sm' variant='outline' render={<Link to='/keys' />}>
{t('Create API Key')}
@@ -463,17 +494,6 @@ export function OverviewDashboard() {
[apiKeysQuery.data]
)
const realKeyQuery = useQuery({
queryKey: ['dashboard', 'overview', 'token-key', preferredKey?.id],
queryFn: async () => {
if (!preferredKey?.id) return ''
const result = await fetchTokenKey(preferredKey.id)
return result.success && result.data?.key ? `sk-${result.data.key}` : ''
},
enabled: Boolean(preferredKey?.id),
staleTime: 5 * 60 * 1000,
})
const startSteps = useMemo<StartStep[]>(
() => [
{
@@ -561,23 +581,18 @@ export function OverviewDashboard() {
const requestExample = useMemo<RequestExample>(() => {
const endpoint = normalizeEndpoint(apiInfoItems[0]?.url)
const model = modelsQuery.data?.[0] ?? 'gpt-4o-mini'
const apiKey = realKeyQuery.data ?? ''
const keyName = preferredKey?.name ?? t('No API key yet')
const ready = Boolean(apiKey && model)
const ready = Boolean(preferredKey?.id && model)
return {
endpoint,
model,
keyName,
displayKey: formatDisplayKey(apiKey),
keyId: preferredKey?.id,
displayKey: preferredKey ? formatDisplayKey(`sk-${preferredKey.key}`) : 'sk-...',
ready,
curl: buildCurlCommand({
endpoint,
apiKey: apiKey || 'sk-...',
model,
}),
}
}, [apiInfoItems, modelsQuery.data, preferredKey, realKeyQuery.data, t])
}, [apiInfoItems, modelsQuery.data, preferredKey, t])
const completedStepCount = startSteps.filter((step) => step.completed).length
const setupComplete = completedStepCount === startSteps.length
@@ -150,7 +150,7 @@ export function createDurationColumn<T>(config: {
variant={variant}
size='sm'
copyable={false}
className={cn('font-mono', durationBgMap[variant])}
className={cn('rounded-md font-mono', durationBgMap[variant])}
/>
)
},
@@ -90,6 +90,12 @@ function getGroupRatioText(other: LogOtherData | null): string | null {
return null
}
function splitQuotaDisplay(value: string): { prefix: string; amount: string } {
const match = value.match(/^([^0-9+\-.,\s]+)(.+)$/)
if (!match) return { prefix: '', amount: value }
return { prefix: match[1], amount: match[2] }
}
function buildDetailSegments(
log: UsageLog,
other: LogOtherData | null,
@@ -277,6 +283,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
variant={config.color as StatusBadgeProps['variant']}
size='sm'
copyable={false}
className='!text-xs [&_span]:!text-xs'
/>
</div>
)
@@ -295,6 +302,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
columns.push(
{
id: 'channel',
accessorFn: (row) => row.channel,
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Channel')} />
),
@@ -395,10 +403,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
</TooltipProvider>
)
},
meta: { label: t('Channel'), mobileHidden: true },
meta: { label: t('Channel') },
},
{
id: 'user',
accessorFn: (row) => row.username,
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('User')} />
),
@@ -419,7 +428,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
setUserInfoDialogOpen(true)
}}
>
<Avatar className='ring-border/60 size-6 ring-1'>
<Avatar className='ring-border/60 size-6 ring-1 max-sm:hidden'>
<AvatarFallback
className={cn(
'text-[11px] font-semibold',
@@ -451,7 +460,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
</button>
)
},
meta: { label: t('User'), mobileHidden: true },
meta: { label: t('User') },
}
)
}
@@ -555,7 +564,9 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
? log.completion_tokens / useTime
: null
const timeVariant = getResponseTimeColor(useTime, log.completion_tokens)
const frtVariant = frt ? getFirstResponseTimeColor(frt / 1000) : 'neutral'
const frtVariant = frt
? getFirstResponseTimeColor(frt / 1000)
: 'neutral'
const timingBgMap: Record<string, string> = {
success:
@@ -576,7 +587,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
variant={timeVariant as StatusBadgeProps['variant']}
size='sm'
copyable={false}
className={cn('font-mono', timingBgMap[timeVariant])}
className={cn('rounded-md font-mono', timingBgMap[timeVariant])}
/>
{log.is_stream &&
(frt != null && frt > 0 ? (
@@ -586,7 +597,10 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
size='sm'
showDot={false}
copyable={false}
className={cn('font-mono', timingBgMap[frtVariant])}
className={cn(
'rounded-md font-mono',
timingBgMap[frtVariant]
)}
/>
) : (
<StatusBadge
@@ -595,7 +609,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
size='sm'
showDot={false}
copyable={false}
className={timingBgMap.neutral}
className={cn('rounded-md font-mono', timingBgMap.neutral)}
/>
))}
</div>
@@ -641,7 +655,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
</div>
)
},
meta: { label: t('Timing'), mobileHidden: true },
meta: { label: t('Timing') },
},
{
@@ -692,7 +706,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
</div>
)
},
meta: { label: 'Tokens', mobileHidden: true },
meta: { label: 'Tokens' },
},
{
@@ -734,11 +748,15 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
}
const quotaStr = formatLogQuota(quota)
const quotaDisplay = splitQuotaDisplay(quotaStr)
return (
<div className='flex flex-col gap-0.5'>
<span className='border-border/80 bg-muted/60 inline-flex w-fit items-center rounded-md border px-1.5 py-0.5 [font-family:var(--font-body)] font-semibold tabular-nums'>
{quotaStr}
<span className='border-border/80 bg-muted/60 inline-flex h-6 w-fit items-center rounded-md border px-2 text-sm leading-none [font-family:var(--font-body)] font-semibold tabular-nums'>
{quotaDisplay.prefix && (
<span className='mr-1'>{quotaDisplay.prefix}</span>
)}
<span>{quotaDisplay.amount}</span>
</span>
</div>
)
@@ -231,7 +231,7 @@ export function useDrawingLogsColumns(
</>
)
},
meta: { label: t('Image'), mobileHidden: true },
meta: { label: t('Image') },
},
{
accessorKey: 'prompt',
@@ -268,7 +268,7 @@ export function useDrawingLogsColumns(
</>
)
},
meta: { label: t('Prompt'), mobileHidden: true },
meta: { label: t('Prompt') },
size: 200,
maxSize: 220,
},
@@ -123,6 +123,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
if (isAdmin) {
columns.push(createChannelColumn<TaskLog>({ headerLabel: t('Channel') }), {
id: 'user',
accessorFn: (row) => row.username || row.user_id,
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('User')} />
),
@@ -142,7 +143,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
setUserInfoDialogOpen(true)
}}
>
<Avatar className='ring-border/60 size-6 ring-1'>
<Avatar className='ring-border/60 size-6 ring-1 max-sm:hidden'>
<AvatarFallback
className={cn(
'text-[11px] font-semibold',
@@ -161,7 +162,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
</button>
)
},
meta: { label: t('User'), mobileHidden: true },
meta: { label: t('User') },
})
}
@@ -101,12 +101,12 @@ function ModelBadgeContent(props: ModelBadgeProps) {
showDot={!provider}
autoColor={provider ? undefined : props.modelName}
className={cn(
'border-border/60 bg-muted/30 h-auto min-h-6 gap-1.5 rounded-md border px-2 py-0.5 [font-family:var(--font-body)] break-all whitespace-normal',
'border-border/60 bg-muted/30 h-6 max-w-full gap-1.5 rounded-md border px-2 [font-family:var(--font-body)]',
provider && 'text-foreground',
props.className
)}
>
<span className='flex min-w-0 items-center gap-1.5'>
<span className='flex max-w-full min-w-0 items-center gap-1.5'>
{provider && (
<span
className='flex size-3.5 shrink-0 items-center justify-center'
@@ -116,7 +116,7 @@ function ModelBadgeContent(props: ModelBadgeProps) {
{getLobeIcon(provider.icon, 14)}
</span>
)}
<span>{props.modelName}</span>
<span className='truncate'>{props.modelName}</span>
</span>
</StatusBadge>
)
@@ -0,0 +1,402 @@
/*
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 {
flexRender,
type Cell,
type Table,
} from '@tanstack/react-table'
import { Database } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton'
import { dotColorMap, textColorMap, type StatusVariant } from '@/components/status-badge'
import type { LogCategory } from '../types'
import { LOG_TYPE_ENUM } from '../constants'
import { getLogTypeConfig } from '../lib/utils'
const logTypeRowTint: Record<number, string> = {
[LOG_TYPE_ENUM.ERROR]: 'bg-rose-50/40 dark:bg-rose-950/20 border-rose-200/50 dark:border-rose-900/30',
[LOG_TYPE_ENUM.REFUND]: 'bg-blue-50/30 dark:bg-blue-950/15 border-blue-200/50 dark:border-blue-900/30',
}
interface UsageLogsMobileListProps<TData> {
table: Table<TData>
isLoading?: boolean
emptyTitle?: string
emptyDescription?: string
logCategory: LogCategory
}
function UsageLogsMobileSkeleton() {
return (
<div className='overflow-hidden rounded-lg border border-border/50 bg-card'>
{[1, 2, 3].map((i) => (
<div
key={i}
className='space-y-2.5 border-b border-border/40 p-3 last:border-b-0'
>
<div className='flex items-center justify-between gap-3'>
<Skeleton className='h-5 w-40 rounded-md' />
<Skeleton className='h-5 w-16 rounded-md' />
</div>
<div className='grid grid-cols-2 gap-x-4 gap-y-2'>
{[1, 2, 3, 4, 5, 6].map((j) => (
<div key={j} className='min-w-0 space-y-1'>
<Skeleton className='h-3 w-10 rounded' />
<Skeleton className='h-4 w-full rounded' />
</div>
))}
</div>
</div>
))}
</div>
)
}
function CompactCell<TData>({
cell,
fallback = '-',
className,
primaryOnly = false,
}: {
cell?: Cell<TData, unknown>
fallback?: string
className?: string
primaryOnly?: boolean
}) {
return (
<div
className={cn(
'min-w-0 overflow-hidden leading-tight [&_button]:max-w-full [&_span]:max-w-full',
primaryOnly &&
'[&_.flex-col>*:not(:first-child)]:hidden [&_.flex-col]:min-w-0',
className
)}
>
{cell ? (
flexRender(cell.column.columnDef.cell, cell.getContext())
) : (
<span className='text-muted-foreground/50'>{fallback}</span>
)}
</div>
)
}
function SummaryField<TData>({
label,
cell,
className,
valueClassName,
primaryOnly = false,
}: {
label: string
cell?: Cell<TData, unknown>
className?: string
valueClassName?: string
primaryOnly?: boolean
}) {
if (!cell) return null
return (
<div
className={cn(
'min-w-0 rounded-md bg-muted/20 px-2 py-1.5',
className
)}
>
<div className='text-muted-foreground mb-1 text-[11px] leading-none font-medium select-none'>
{label}
</div>
<CompactCell
cell={cell}
primaryOnly={primaryOnly}
className={valueClassName}
/>
</div>
)
}
function MobileLogTimeStatus({
createdAt,
type,
}: {
createdAt: unknown
type: unknown
}) {
const { t } = useTranslation()
const timestamp = typeof createdAt === 'number' ? createdAt : undefined
const logType = typeof type === 'number' ? type : undefined
const config = getLogTypeConfig(logType ?? LOG_TYPE_ENUM.UNKNOWN)
const variant = config.color as StatusVariant
return (
<div className='space-y-1'>
<div className='font-mono text-xs leading-tight tabular-nums'>
{formatTimestampToDate(timestamp)}
</div>
<div
className={cn(
'inline-flex items-center gap-1 text-xs leading-none font-medium',
textColorMap[variant]
)}
>
<span
className={cn('size-1.5 shrink-0 rounded-full', dotColorMap[variant])}
aria-hidden='true'
/>
<span>{t(config.label)}</span>
</div>
</div>
)
}
function CommonLogsCard<TData>({
cells,
}: {
cells: Map<string, Cell<TData, unknown>>
}) {
const { t } = useTranslation()
const modelCell = cells.get('model_name')
const quotaCell = cells.get('quota')
return (
<div className='space-y-2.5'>
<div className='flex min-w-0 items-start justify-between gap-3'>
<CompactCell cell={modelCell} className='flex-1' />
<CompactCell
cell={quotaCell}
className='shrink-0 text-right [&_span]:!h-6 [&_span]:!px-2 [&_span]:!text-sm [&_span]:!leading-none'
/>
</div>
<div className='grid grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)] gap-1.5'>
<div className='min-w-0 rounded-md bg-muted/20 px-2 py-1.5'>
<div className='text-muted-foreground mb-1 text-[11px] leading-none font-medium select-none'>
{t('Time')}
</div>
<MobileLogTimeStatus
createdAt={cells.get('created_at')?.row.original?.created_at}
type={cells.get('created_at')?.row.original?.type}
/>
</div>
<SummaryField
label={t('Channel')}
cell={cells.get('channel')}
primaryOnly
/>
<SummaryField label={t('User')} cell={cells.get('user')} primaryOnly />
<SummaryField
label={t('Token')}
cell={cells.get('token_name')}
valueClassName='[&_.flex-col]:max-w-none [&_.flex-col>*:not(:first-child)]:text-[11px] [&_.flex-col>*:not(:first-child)]:leading-none'
/>
<SummaryField
label={t('Timing')}
cell={cells.get('use_time')}
primaryOnly
/>
<SummaryField
label={t('Tokens')}
cell={cells.get('prompt_tokens')}
primaryOnly
/>
<SummaryField
label={t('Details')}
cell={cells.get('content')}
className='col-span-2 bg-transparent px-0 py-0'
/>
</div>
</div>
)
}
function TaskLogsCard<TData>({
cells,
}: {
cells: Map<string, Cell<TData, unknown>>
}) {
const { t } = useTranslation()
const taskIdCell = cells.get('task_id')
const statusCell = cells.get('status')
const submitTimeCell = cells.get('submit_time')
return (
<div className='space-y-2.5'>
<div className='flex min-w-0 items-start justify-between gap-3'>
<CompactCell cell={taskIdCell} className='flex-1' />
<CompactCell cell={statusCell} className='shrink-0 text-right' />
</div>
<div className='grid grid-cols-2 gap-1.5'>
<SummaryField
label={t('Submit Time')}
cell={submitTimeCell}
/>
<SummaryField
label={t('User')}
cell={cells.get('user')}
primaryOnly
/>
<SummaryField
label={t('Result')}
cell={cells.get('fail_reason')}
className='col-span-2 bg-transparent px-0 py-0'
/>
</div>
</div>
)
}
function DrawingLogsCard<TData>({
cells,
}: {
cells: Map<string, Cell<TData, unknown>>
}) {
const { t } = useTranslation()
const actionCell = cells.get('action')
const codeCell = cells.get('code')
const submitTimeCell = cells.get('submit_time')
return (
<div className='space-y-2.5'>
<div className='flex min-w-0 items-start justify-between gap-3'>
<CompactCell cell={actionCell} className='flex-1' />
<CompactCell cell={codeCell} className='shrink-0 text-right' />
</div>
<div className='grid grid-cols-2 gap-1.5'>
<SummaryField
label={t('Submit Time')}
cell={submitTimeCell}
/>
<SummaryField
label={t('Channel')}
cell={cells.get('channel')}
primaryOnly
/>
<SummaryField
label={t('Task ID')}
cell={cells.get('mj_id')}
/>
<SummaryField
label={t('Duration')}
cell={cells.get('duration')}
primaryOnly
/>
<SummaryField
label={t('Image')}
cell={cells.get('image_url')}
/>
<SummaryField
label={t('Prompt')}
cell={cells.get('prompt')}
primaryOnly
/>
<SummaryField
label={t('Fail Reason')}
cell={cells.get('fail_reason')}
className='col-span-2 bg-transparent px-0 py-0'
/>
</div>
</div>
)
}
export function UsageLogsMobileList<TData>({
table,
isLoading = false,
emptyTitle,
emptyDescription,
logCategory,
}: UsageLogsMobileListProps<TData>) {
const { t } = useTranslation()
const resolvedEmptyTitle = emptyTitle ?? t('No Logs Found')
const resolvedEmptyDescription =
emptyDescription ??
t('No usage logs available. Logs will appear here once API calls are made.')
if (isLoading) {
return <UsageLogsMobileSkeleton />
}
const rows = table.getRowModel().rows
if (!rows || rows.length === 0) {
return (
<div className="rounded-lg border p-6">
<Empty className="border-none p-0">
<EmptyHeader>
<EmptyMedia variant="icon">
<Database className="size-6" />
</EmptyMedia>
<EmptyTitle>{resolvedEmptyTitle}</EmptyTitle>
<EmptyDescription>{resolvedEmptyDescription}</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
)
}
return (
<div className='overflow-hidden rounded-lg border border-border/50 bg-card'>
{rows.map((row) => {
const cells = new Map(
row.getVisibleCells().map((cell) => [cell.column.id, cell])
)
const logType = (row.original as Record<string, unknown>).type as
| number
| undefined
const tintClass = logType != null ? (logTypeRowTint[logType] ?? '') : ''
return (
<div
key={row.id}
className={cn(
'border-l-2 border-l-transparent border-b border-border/40 p-3 transition-colors last:border-b-0',
tintClass
)}
>
{logCategory === 'common' && (
<CommonLogsCard cells={cells} />
)}
{logCategory === 'task' && (
<TaskLogsCard cells={cells} />
)}
{logCategory === 'drawing' && (
<DrawingLogsCard cells={cells} />
)}
</div>
)
})}
</div>
)
}
@@ -47,6 +47,7 @@ import { fetchLogsByCategory } from '../lib/utils'
import type { LogCategory } from '../types'
import { CommonLogsFilterBar } from './common-logs-filter-bar'
import { TaskLogsFilterBar } from './task-logs-filter-bar'
import { UsageLogsMobileList } from './usage-logs-mobile-card'
const route = getRouteApi('/_authenticated/usage-logs/$section')
@@ -190,6 +191,13 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
'[&_[data-slot=table]]:text-[13px] [&_[data-slot=table]_td]:text-[13px] [&_[data-slot=table]_td_*]:text-[13px] [&_[data-slot=table]_th]:text-[13px] [&_[data-slot=table]_th_*]:text-[13px]'
)}
tableHeaderClassName='bg-muted/30 sticky top-0 z-10'
mobile={
<UsageLogsMobileList
table={table}
isLoading={isLoadingData}
logCategory={logCategory}
/>
}
toolbar={
isCommon ? (
<CommonLogsFilterBar table={table} />
+1
View File
@@ -2778,6 +2778,7 @@
"Override auto-discovered endpoint": "Override auto-discovered endpoint",
"Override request headers": "Override request headers",
"Override request headers (JSON format)": "Override request headers (JSON format)",
"Override request parameters": "Override request parameters",
"Override request parameters (JSON format)": "Override request parameters (JSON format)",
"Override request parameters. Cannot override": "Override request parameters. Cannot override",
"Override request parameters. Cannot override stream parameter.": "Override request parameters. Cannot override stream parameter.",
+1
View File
@@ -2778,6 +2778,7 @@
"Override auto-discovered endpoint": "Remplacer le point de terminaison auto-découvert",
"Override request headers": "Remplacer les en-têtes de requête",
"Override request headers (JSON format)": "Surcharge des en-têtes de requête (format JSON)",
"Override request parameters": "Remplacer les paramètres de requête",
"Override request parameters (JSON format)": "Remplacer les paramètres de requête (format JSON)",
"Override request parameters. Cannot override": "Remplacer les paramètres de requête. Impossible de remplacer",
"Override request parameters. Cannot override stream parameter.": "Remplace les paramètres de requête. Impossible de remplacer le paramètre stream.",
+1
View File
@@ -2778,6 +2778,7 @@
"Override auto-discovered endpoint": "自動検出されたエンドポイントを上書きする",
"Override request headers": "リクエストヘッダーを上書きする",
"Override request headers (JSON format)": "リクエストヘッダーのオーバーライド (JSON 形式)",
"Override request parameters": "リクエストパラメータを上書き",
"Override request parameters (JSON format)": "リクエストパラメータの上書き (JSON形式)",
"Override request parameters. Cannot override": "リクエストパラメーターを上書きします。上書きできません",
"Override request parameters. Cannot override stream parameter.": "リクエストパラメータを上書きします。stream パラメータは上書きできません。",
+1
View File
@@ -2778,6 +2778,7 @@
"Override auto-discovered endpoint": "Переопределить автоматически обнаруженную конечную точку",
"Override request headers": "Переопределить заголовки запроса",
"Override request headers (JSON format)": "Переопределение заголовков запроса (формат JSON)",
"Override request parameters": "Переопределить параметры запроса",
"Override request parameters (JSON format)": "Переопределить параметры запроса (формат JSON)",
"Override request parameters. Cannot override": "Переопределить параметры запроса. Невозможно переопределить",
"Override request parameters. Cannot override stream parameter.": "Переопределяет параметры запроса. Параметр stream переопределить нельзя.",
+1
View File
@@ -2778,6 +2778,7 @@
"Override auto-discovered endpoint": "Ghi đè điểm cuối tự động phát hiện",
"Override request headers": "Ghi đè tiêu đề yêu cầu",
"Override request headers (JSON format)": "Ghi đè tiêu đề yêu cầu (định dạng JSON)",
"Override request parameters": "Ghi đè tham số yêu cầu",
"Override request parameters (JSON format)": "Ghi đè tham số yêu cầu (định dạng JSON)",
"Override request parameters. Cannot override": "Ghi đè tham số yêu cầu. Không thể ghi đè",
"Override request parameters. Cannot override stream parameter.": "Ghi đè tham số yêu cầu. Không thể ghi đè tham số stream.",
+1
View File
@@ -2778,6 +2778,7 @@
"Override auto-discovered endpoint": "覆盖自动发现的端点",
"Override request headers": "覆盖请求标头",
"Override request headers (JSON format)": "覆盖请求头(JSON 格式)",
"Override request parameters": "覆盖请求参数",
"Override request parameters (JSON format)": "覆盖请求参数 (JSON 格式)",
"Override request parameters. Cannot override": "覆盖请求参数。无法覆盖",
"Override request parameters. Cannot override stream parameter.": "覆盖请求参数。无法覆盖 stream 参数。",
+1
View File
@@ -315,6 +315,7 @@ export const STATIC_I18N_KEYS = [
'Regex Replace',
'Return Error',
'Param Override',
'Override request parameters',
// Profile / 2FA
'Backed up',