feat(timing): add timing metrics display for stream logs and enhance localization
This commit is contained in:
+30
-100
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { type ColumnDef } from '@tanstack/react-table'
|
||||
import { CircleAlert, GitBranch, Sparkles, KeyRound } from 'lucide-react'
|
||||
import { GitBranch, Sparkles, KeyRound } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -36,19 +36,13 @@ import {
|
||||
} from '@/components/ui/tooltip'
|
||||
import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
|
||||
import { formatBillingCurrencyFromUSD } from '@/lib/currency'
|
||||
import {
|
||||
formatUseTime,
|
||||
formatLogQuota,
|
||||
formatTimestampToDate,
|
||||
} from '@/lib/format'
|
||||
import { formatLogQuota, formatTimestampToDate } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { LOG_TYPE_ALL_VALUE } from '../../constants'
|
||||
import type { UsageLog } from '../../data/schema'
|
||||
import {
|
||||
formatModelName,
|
||||
getFirstResponseTimeColor,
|
||||
getResponseTimeColor,
|
||||
getTieredBillingSummary,
|
||||
hasAnyCacheTokens,
|
||||
parseLogOther,
|
||||
@@ -64,6 +58,7 @@ import {
|
||||
import type { LogOtherData } from '../../types'
|
||||
import { DetailsDialog } from '../dialogs/details-dialog'
|
||||
import { ModelBadge } from '../model-badge'
|
||||
import { TimingMetricsCell, StreamTpsCell } from '../timing-metrics-cell'
|
||||
import { useUsageLogsContext } from '../usage-logs-provider'
|
||||
|
||||
interface DetailSegment {
|
||||
@@ -619,113 +614,29 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
meta: { mobileTitle: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'use_time',
|
||||
header: t('Timing'),
|
||||
accessorKey: 'is_stream',
|
||||
header: t('Stream'),
|
||||
cell: ({ row }) => {
|
||||
const log = row.original
|
||||
if (!isTimingLogType(log.type)) return null
|
||||
|
||||
const useTime = row.getValue('use_time') as number
|
||||
const other = parseLogOther(log.other)
|
||||
const frt = other?.frt
|
||||
const tokensPerSecond =
|
||||
useTime > 0 && log.completion_tokens > 0
|
||||
? log.completion_tokens / useTime
|
||||
: null
|
||||
const timeVariant = getResponseTimeColor(useTime, log.completion_tokens)
|
||||
const frtVariant = frt
|
||||
? getFirstResponseTimeColor(frt / 1000)
|
||||
: 'neutral'
|
||||
|
||||
const timingBgMap: Record<string, string> = {
|
||||
success:
|
||||
'border border-emerald-200/40 bg-emerald-50/35 !text-emerald-600 dark:border-emerald-900/40 dark:bg-emerald-950/15 dark:!text-emerald-400',
|
||||
warning:
|
||||
'border border-amber-200/45 bg-amber-50/35 !text-amber-600 dark:border-amber-900/40 dark:bg-amber-950/15 dark:!text-amber-400',
|
||||
danger:
|
||||
'border border-rose-200/50 bg-rose-50/35 !text-red-600 dark:border-rose-900/40 dark:bg-rose-950/15 dark:!text-red-400',
|
||||
neutral:
|
||||
'border border-border/60 bg-muted/30 dark:border-border/40 dark:bg-muted/20',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<StatusBadge
|
||||
label={formatUseTime(useTime)}
|
||||
variant={timeVariant as StatusBadgeProps['variant']}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className={cn('rounded-md font-mono', timingBgMap[timeVariant])}
|
||||
/>
|
||||
{log.is_stream &&
|
||||
(frt != null && frt > 0 ? (
|
||||
<StatusBadge
|
||||
label={formatUseTime(frt / 1000)}
|
||||
variant={frtVariant as StatusBadgeProps['variant']}
|
||||
size='sm'
|
||||
showDot={false}
|
||||
copyable={false}
|
||||
className={cn(
|
||||
'rounded-md font-mono',
|
||||
timingBgMap[frtVariant]
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge
|
||||
label='N/A'
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
showDot={false}
|
||||
copyable={false}
|
||||
className={cn('rounded-md font-mono', timingBgMap.neutral)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className='flex items-center gap-1 [font-family:var(--font-body)] !text-xs leading-none'>
|
||||
<span className='text-muted-foreground/60 [font-family:var(--font-body)] !text-xs leading-none'>
|
||||
{log.is_stream ? t('Stream') : t('Non-stream')}
|
||||
{tokensPerSecond != null && (
|
||||
<>
|
||||
{' · '}
|
||||
<span className='tabular-nums'>
|
||||
{Math.round(tokensPerSecond)}
|
||||
</span>
|
||||
{' t/s'}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{log.is_stream &&
|
||||
other?.stream_status &&
|
||||
other.stream_status.status !== 'ok' && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<CircleAlert className='size-3 text-red-500' />}
|
||||
></TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className='space-y-0.5 text-xs'>
|
||||
<p>
|
||||
{t('Stream Status')}: {t('Error')}
|
||||
</p>
|
||||
<p>{other.stream_status.end_reason || 'unknown'}</p>
|
||||
{(other.stream_status.error_count ?? 0) > 0 && (
|
||||
<p>
|
||||
{t('Soft Errors')}:{' '}
|
||||
{other.stream_status.error_count}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StreamTpsCell
|
||||
isStream={log.is_stream}
|
||||
tokensPerSecond={tokensPerSecond}
|
||||
streamStatus={other?.stream_status}
|
||||
/>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Stream') },
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'prompt_tokens',
|
||||
header: 'Tokens',
|
||||
@@ -773,6 +684,25 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'use_time',
|
||||
header: t('Timing'),
|
||||
cell: ({ row }) => {
|
||||
const log = row.original
|
||||
if (!isTimingLogType(log.type)) return null
|
||||
|
||||
const useTime = row.getValue('use_time') as number
|
||||
const other = parseLogOther(log.other)
|
||||
|
||||
return (
|
||||
<TimingMetricsCell
|
||||
useTimeSec={useTime}
|
||||
frtMs={other?.frt}
|
||||
isStream={log.is_stream}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'quota',
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
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 { CircleAlert } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { formatUseTime } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { LogOtherData } from '../types'
|
||||
|
||||
interface TimingMetricsCellProps {
|
||||
useTimeSec: number
|
||||
frtMs?: number
|
||||
isStream: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TimingMetricsCell(props: TimingMetricsCellProps) {
|
||||
const { t } = useTranslation()
|
||||
const showFirstToken = props.isStream
|
||||
const hasFrt = props.frtMs != null && props.frtMs > 0
|
||||
const firstTokenLabel = hasFrt
|
||||
? formatUseTime(props.frtMs! / 1000)
|
||||
: t('N/A')
|
||||
const totalTimeLabel = formatUseTime(props.useTimeSec)
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-stretch gap-2', props.className)}>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'w-1 shrink-0 rounded-full',
|
||||
showFirstToken
|
||||
? 'bg-linear-to-b from-success to-warning'
|
||||
: 'bg-warning'
|
||||
)}
|
||||
/>
|
||||
<div className='flex min-w-0 flex-col justify-center gap-0.5 text-xs leading-tight'>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-baseline gap-1.5',
|
||||
!showFirstToken && 'invisible'
|
||||
)}
|
||||
aria-hidden={!showFirstToken}
|
||||
>
|
||||
<span className='text-muted-foreground shrink-0'>
|
||||
{t('First token')}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'tabular-nums',
|
||||
hasFrt ? 'text-success' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{showFirstToken ? firstTokenLabel : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-baseline gap-1.5'>
|
||||
<span className='text-muted-foreground shrink-0'>
|
||||
{t('Duration')}
|
||||
</span>
|
||||
<span className='text-warning tabular-nums'>{totalTimeLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface StreamTpsCellProps {
|
||||
isStream: boolean
|
||||
tokensPerSecond?: number | null
|
||||
streamStatus?: LogOtherData['stream_status']
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StreamTpsCell(props: StreamTpsCellProps) {
|
||||
const { t } = useTranslation()
|
||||
const showStreamError =
|
||||
props.isStream &&
|
||||
props.streamStatus &&
|
||||
props.streamStatus.status !== 'ok'
|
||||
const tpsLabel =
|
||||
props.tokensPerSecond != null
|
||||
? `${Math.round(props.tokensPerSecond)} t/s`
|
||||
: '—'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 flex-col items-start justify-center gap-0.5 text-xs leading-tight',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<span className='border-border/60 bg-muted/30 text-muted-foreground inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 leading-none'>
|
||||
{props.isStream ? t('Stream') : t('Non-stream')}
|
||||
{showStreamError && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<CircleAlert className='text-destructive size-3' />}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<div className='space-y-0.5 text-xs'>
|
||||
<p>
|
||||
{t('Stream Status')}: {t('Error')}
|
||||
</p>
|
||||
<p>{props.streamStatus?.end_reason || 'unknown'}</p>
|
||||
{(props.streamStatus?.error_count ?? 0) > 0 && (
|
||||
<p>
|
||||
{t('Soft Errors')}: {props.streamStatus?.error_count}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</span>
|
||||
<span className='text-muted-foreground/60 px-0.5 tabular-nums'>
|
||||
{tpsLabel}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -220,8 +220,8 @@ function CommonLogsCard<TData>({
|
||||
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')}
|
||||
label={t('Stream')}
|
||||
cell={cells.get('is_stream')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField
|
||||
@@ -229,6 +229,11 @@ function CommonLogsCard<TData>({
|
||||
cell={cells.get('prompt_tokens')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Timing')}
|
||||
cell={cells.get('use_time')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Details')}
|
||||
cell={cells.get('content')}
|
||||
|
||||
Reference in New Issue
Block a user