feat(timing): add timing metrics display for stream logs and enhance localization
This commit is contained in:
+3
-3
@@ -183,7 +183,7 @@ function SetupGuideBackdrop(props: { compact?: boolean }) {
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'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',
|
||||
'pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_48%_120%_at_78%_0%,color-mix(in_oklch,var(--overview-accent-1)_14%,transparent)_0%,transparent_62%),linear-gradient(112deg,color-mix(in_oklch,var(--card)_94%,var(--overview-accent-2)_6%)_0%,color-mix(in_oklch,var(--card)_94%,var(--overview-accent-3)_6%)_48%,color-mix(in_oklch,var(--background)_90%,var(--overview-accent-1)_10%)_100%)] dark:opacity-60',
|
||||
props.compact
|
||||
? '[mask-image:linear-gradient(90deg,black_0%,black_48%,transparent_74%)] opacity-55'
|
||||
: 'opacity-85'
|
||||
@@ -373,9 +373,9 @@ function RequestPreview(props: {
|
||||
<span className='bg-success size-2 rounded-full' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 overflow-hidden'>
|
||||
{previewLines.map((line, index) => (
|
||||
{previewLines.map((line) => (
|
||||
<code
|
||||
key={`${line}-${index}`}
|
||||
key={line}
|
||||
className='text-muted-foreground truncate'
|
||||
title={line}
|
||||
>
|
||||
|
||||
@@ -211,6 +211,20 @@ export function SummaryCards() {
|
||||
const runwayDays = getRunwayDays(remainQuota, recentUsage)
|
||||
|
||||
const todayUsageDisplay = formatQuota(recentUsage)
|
||||
let runwayDisplay: string
|
||||
if (runwayDays !== null) {
|
||||
if (runwayDays < 1) {
|
||||
runwayDisplay = t('Less than 1 day left')
|
||||
} else if (runwayDays > 999) {
|
||||
runwayDisplay = `999+ ${t('days')}`
|
||||
} else {
|
||||
runwayDisplay = `~${formatNumber(Math.floor(runwayDays))} ${t('days')}`
|
||||
}
|
||||
} else if (remainQuota <= 0) {
|
||||
runwayDisplay = t('Balance depleted')
|
||||
} else {
|
||||
runwayDisplay = t('No recent usage')
|
||||
}
|
||||
|
||||
const items = useSummaryCardsConfig({
|
||||
...summaryValues,
|
||||
@@ -218,7 +232,7 @@ export function SummaryCards() {
|
||||
currencyEnabled,
|
||||
currencyLabel,
|
||||
}).map((config, index) => {
|
||||
const tones = ['rose', 'teal', 'gray'] as const
|
||||
const tones = ['accent-1', 'accent-2', 'accent-3'] as const
|
||||
|
||||
return {
|
||||
key: config.key,
|
||||
@@ -226,7 +240,7 @@ export function SummaryCards() {
|
||||
value: config.value,
|
||||
desc: config.description,
|
||||
icon: config.icon,
|
||||
tone: tones[index] ?? 'gray',
|
||||
tone: tones[index] ?? 'accent-3',
|
||||
sparkline:
|
||||
config.key === 'todayUsage'
|
||||
? sparklineData.usage
|
||||
@@ -270,7 +284,7 @@ export function SummaryCards() {
|
||||
</StaggerContainer>
|
||||
</div>
|
||||
|
||||
<div className='bg-warning/10 flex flex-col justify-between gap-4 border-t p-4 sm:p-5 xl:border-t-0 xl:border-l'>
|
||||
<div className='flex flex-col justify-between gap-4 border-t bg-[linear-gradient(135deg,color-mix(in_oklch,var(--overview-accent-1)_11%,var(--background))_0%,color-mix(in_oklch,var(--overview-accent-2)_7%,var(--background))_48%,color-mix(in_oklch,var(--overview-accent-3)_10%,var(--background))_100%)] p-4 sm:p-5 xl:border-t-0 xl:border-l'>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
@@ -323,15 +337,7 @@ export function SummaryCards() {
|
||||
healthLevel === 'caution' && 'text-warning'
|
||||
)}
|
||||
>
|
||||
{runwayDays !== null
|
||||
? runwayDays < 1
|
||||
? t('Less than 1 day left')
|
||||
: runwayDays > 999
|
||||
? `999+ ${t('days')}`
|
||||
: `~${formatNumber(Math.floor(runwayDays))} ${t('days')}`
|
||||
: remainQuota <= 0
|
||||
? t('Balance depleted')
|
||||
: t('No recent usage')}
|
||||
{runwayDisplay}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+79
-51
@@ -16,13 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { type LucideIcon } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { useId, type ReactNode } from 'react'
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type StatCardTone = 'rose' | 'teal' | 'gray'
|
||||
type StatCardTone = 'accent-1' | 'accent-2' | 'accent-3'
|
||||
type StatCardSparklineVariant = 'bars' | 'line'
|
||||
type StatCardDetailTone =
|
||||
| 'default'
|
||||
@@ -52,15 +52,18 @@ interface StatCardProps {
|
||||
}
|
||||
|
||||
const TONE_CLASSES: Record<StatCardTone, string> = {
|
||||
rose: 'from-rose-500/80 via-rose-300/70 to-rose-200/20 dark:from-rose-400/70 dark:via-rose-500/30 dark:to-rose-500/5',
|
||||
teal: 'from-teal-500/80 via-teal-300/70 to-teal-200/20 dark:from-teal-400/70 dark:via-teal-500/30 dark:to-teal-500/5',
|
||||
gray: 'from-muted-foreground/50 via-muted-foreground/20 to-transparent dark:from-muted-foreground/40 dark:via-muted-foreground/20',
|
||||
'accent-1':
|
||||
'from-overview-accent-1/80 via-overview-accent-1/45 to-overview-accent-1/5 dark:from-overview-accent-1/70 dark:via-overview-accent-1/30',
|
||||
'accent-2':
|
||||
'from-overview-accent-2/80 via-overview-accent-2/45 to-overview-accent-2/5 dark:from-overview-accent-2/70 dark:via-overview-accent-2/30',
|
||||
'accent-3':
|
||||
'from-overview-accent-3/80 via-overview-accent-3/45 to-overview-accent-3/5 dark:from-overview-accent-3/70 dark:via-overview-accent-3/30',
|
||||
}
|
||||
|
||||
const LINE_TONE_CLASSES: Record<StatCardTone, string> = {
|
||||
rose: 'text-warning',
|
||||
teal: 'text-primary',
|
||||
gray: 'text-muted-foreground',
|
||||
'accent-1': 'text-overview-accent-1',
|
||||
'accent-2': 'text-overview-accent-2',
|
||||
'accent-3': 'text-overview-accent-3',
|
||||
}
|
||||
|
||||
const DETAIL_TONE_CLASSES: Record<StatCardDetailTone, string> = {
|
||||
@@ -71,14 +74,24 @@ const DETAIL_TONE_CLASSES: Record<StatCardDetailTone, string> = {
|
||||
destructive: 'text-destructive',
|
||||
}
|
||||
|
||||
function normalizeSparkline(values?: number[]): number[] {
|
||||
interface SparklineBucket {
|
||||
position: number
|
||||
height: number
|
||||
}
|
||||
|
||||
function normalizeSparkline(values?: number[]): SparklineBucket[] {
|
||||
if (!values?.length) return []
|
||||
|
||||
const sanitized = values.map((value) => Math.max(0, Number(value) || 0))
|
||||
const max = Math.max(...sanitized)
|
||||
if (max <= 0) return sanitized.map(() => 0)
|
||||
if (max <= 0) {
|
||||
return sanitized.map((_, position) => ({ position, height: 0 }))
|
||||
}
|
||||
|
||||
return sanitized.map((value) => Math.max(8, (value / max) * 100))
|
||||
return sanitized.map((value, position) => ({
|
||||
position,
|
||||
height: Math.max(8, (value / max) * 100),
|
||||
}))
|
||||
}
|
||||
|
||||
function buildLineSparkline(values?: number[]) {
|
||||
@@ -97,7 +110,12 @@ function buildLineSparkline(values?: number[]) {
|
||||
sanitized.length === 1
|
||||
? width / 2
|
||||
: (index / (sanitized.length - 1)) * width
|
||||
const normalized = range > 0 ? (value - min) / range : max > 0 ? 0.5 : 0
|
||||
let normalized = 0
|
||||
if (range > 0) {
|
||||
normalized = (value - min) / range
|
||||
} else if (max > 0) {
|
||||
normalized = 0.5
|
||||
}
|
||||
const y = height - padding - normalized * (height - padding * 2)
|
||||
|
||||
return { x, y }
|
||||
@@ -106,8 +124,9 @@ function buildLineSparkline(values?: number[]) {
|
||||
const linePath = points
|
||||
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`)
|
||||
.join(' ')
|
||||
const firstPoint = points[0]
|
||||
const lastPoint = points[points.length - 1]
|
||||
const firstPoint = points.at(0)
|
||||
const lastPoint = points.at(-1)
|
||||
if (!firstPoint || !lastPoint) return null
|
||||
const areaPath = `${linePath} L ${lastPoint.x} ${height} L ${firstPoint.x} ${height} Z`
|
||||
|
||||
return {
|
||||
@@ -118,7 +137,7 @@ function buildLineSparkline(values?: number[]) {
|
||||
|
||||
function LineSparkline(props: { values?: number[]; tone: StatCardTone }) {
|
||||
const rawGradientId = useId()
|
||||
const gradientId = `stat-card-line-${rawGradientId.replace(/:/g, '')}`
|
||||
const gradientId = `stat-card-line-${rawGradientId.replaceAll(':', '')}`
|
||||
const paths = buildLineSparkline(props.values)
|
||||
|
||||
if (!paths) return <div className='h-8' aria-hidden='true' />
|
||||
@@ -162,15 +181,15 @@ function BarSparkline(props: { values?: number[]; tone: StatCardTone }) {
|
||||
|
||||
return (
|
||||
<div className='flex h-8 items-end gap-1' aria-hidden='true'>
|
||||
{sparkline.map((height, index) => (
|
||||
{sparkline.map((bucket) => (
|
||||
<span
|
||||
key={`spark-${index}`}
|
||||
key={bucket.position}
|
||||
className={cn(
|
||||
'flex-1 rounded-t-sm bg-linear-to-t',
|
||||
height <= 0 && 'opacity-20',
|
||||
bucket.height <= 0 && 'opacity-20',
|
||||
TONE_CLASSES[props.tone]
|
||||
)}
|
||||
style={{ height: `${height}%` }}
|
||||
style={{ height: `${bucket.height}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -205,8 +224,46 @@ function StatCardDetails(props: { details: StatCardDetail[] }) {
|
||||
|
||||
export function StatCard(props: StatCardProps) {
|
||||
const Icon = props.icon
|
||||
const tone = props.tone ?? 'gray'
|
||||
const tone = props.tone ?? 'accent-3'
|
||||
const sparklineVariant = props.sparklineVariant ?? 'bars'
|
||||
let valueContent: ReactNode
|
||||
if (props.loading) {
|
||||
valueContent = (
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<Skeleton className='h-7 w-24' />
|
||||
<Skeleton className='h-3.5 w-32' />
|
||||
</div>
|
||||
)
|
||||
} else if (props.error) {
|
||||
valueContent = (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='text-muted-foreground mt-0.5 font-mono text-base font-bold tracking-tight break-all tabular-nums sm:text-2xl'>
|
||||
--
|
||||
</div>
|
||||
<p className='text-muted-foreground/60 text-xs'>{props.description}</p>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
valueContent = (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='text-foreground font-mono text-2xl font-semibold tracking-tight break-all tabular-nums'>
|
||||
{props.value}
|
||||
</div>
|
||||
<p className='text-muted-foreground/60 text-xs leading-relaxed'>
|
||||
{props.description}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
let visualization: ReactNode
|
||||
if (props.details?.length) {
|
||||
visualization = <StatCardDetails details={props.details} />
|
||||
} else if (sparklineVariant === 'line') {
|
||||
visualization = <LineSparkline values={props.sparkline} tone={tone} />
|
||||
} else {
|
||||
visualization = <BarSparkline values={props.sparkline} tone={tone} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='group flex min-h-32 flex-col justify-between gap-3'>
|
||||
@@ -221,38 +278,9 @@ export function StatCard(props: StatCardProps) {
|
||||
{props.action && <div className='shrink-0'>{props.action}</div>}
|
||||
</div>
|
||||
|
||||
{props.loading ? (
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<Skeleton className='h-7 w-24' />
|
||||
<Skeleton className='h-3.5 w-32' />
|
||||
</div>
|
||||
) : props.error ? (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='text-muted-foreground mt-0.5 font-mono text-base font-bold tracking-tight break-all tabular-nums sm:text-2xl'>
|
||||
--
|
||||
</div>
|
||||
<p className='text-muted-foreground/60 text-xs'>
|
||||
{props.description}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='text-foreground font-mono text-2xl font-semibold tracking-tight break-all tabular-nums'>
|
||||
{props.value}
|
||||
</div>
|
||||
<p className='text-muted-foreground/60 text-xs leading-relaxed'>
|
||||
{props.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{valueContent}
|
||||
|
||||
{props.details?.length ? (
|
||||
<StatCardDetails details={props.details} />
|
||||
) : sparklineVariant === 'line' ? (
|
||||
<LineSparkline values={props.sparkline} tone={tone} />
|
||||
) : (
|
||||
<BarSparkline values={props.sparkline} tone={tone} />
|
||||
)}
|
||||
{visualization}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -116,14 +116,13 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
|
||||
<span className='text-foreground font-mono font-semibold'>
|
||||
{entry.formatted}
|
||||
</span>
|
||||
/{tokenUnitLabel}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
priceSummary = (
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('Dynamic Pricing')}
|
||||
</span>
|
||||
)
|
||||
@@ -144,7 +143,6 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
|
||||
props.selectedGroup
|
||||
)}
|
||||
</span>
|
||||
/{tokenUnitLabel}
|
||||
</span>
|
||||
<span className='text-muted-foreground whitespace-nowrap'>
|
||||
{t('Output')}{' '}
|
||||
@@ -159,7 +157,6 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
|
||||
props.selectedGroup
|
||||
)}
|
||||
</span>
|
||||
/{tokenUnitLabel}
|
||||
</span>
|
||||
{hasCachedPrice && (
|
||||
<span className='text-muted-foreground/60 whitespace-nowrap'>
|
||||
@@ -217,7 +214,7 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
|
||||
<h3 className='text-foreground truncate font-mono text-[15px] leading-tight font-bold'>
|
||||
{props.model.model_name}
|
||||
</h3>
|
||||
<div className='mt-0.5 flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-xs sm:mt-1 sm:gap-x-3'>
|
||||
<div className='mt-0.5 flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-sm sm:mt-1 sm:gap-x-3'>
|
||||
{priceSummary}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+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')}
|
||||
|
||||
Vendored
+2
@@ -1979,6 +1979,7 @@
|
||||
"Find the ratio.": "Find the ratio.",
|
||||
"Finish Time": "Finish Time",
|
||||
"First API request": "First API request",
|
||||
"First token": "First token",
|
||||
"First/Last Frame to Video": "First/Last Frame to Video",
|
||||
"Fix Abilities": "Repair Channel Consistency",
|
||||
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "Channel consistency repaired: {{success}} succeeded, {{fails}} failed",
|
||||
@@ -4630,6 +4631,7 @@
|
||||
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "Total quota included in the plan, usable per billing period. 0 means unlimited.",
|
||||
"Total requests allowed per period. 0 = unlimited.": "Total requests allowed per period. 0 = unlimited.",
|
||||
"Total requests made": "Total requests made",
|
||||
"Total time": "Total time",
|
||||
"Total tokens": "Total tokens",
|
||||
"Total Tokens": "Total Tokens",
|
||||
"Total Usage": "Total Usage",
|
||||
|
||||
Vendored
+2
@@ -1979,6 +1979,7 @@
|
||||
"Find the ratio.": "Trouver le taux.",
|
||||
"Finish Time": "Heure de fin",
|
||||
"First API request": "Première requête API",
|
||||
"First token": "1er token",
|
||||
"First/Last Frame to Video": "Première/Dernière image vers vidéo",
|
||||
"Fix Abilities": "Réparer la cohérence des canaux",
|
||||
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "Cohérence des canaux réparée : {{success}} réussie(s), {{fails}} échouée(s)",
|
||||
@@ -4630,6 +4631,7 @@
|
||||
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "Quota total inclus dans le forfait, utilisable par période de facturation. 0 signifie illimité.",
|
||||
"Total requests allowed per period. 0 = unlimited.": "Total des requêtes autorisées par période. 0 = illimité.",
|
||||
"Total requests made": "Requêtes totales effectuées",
|
||||
"Total time": "Durée totale",
|
||||
"Total tokens": "Jetons totaux",
|
||||
"Total Tokens": "Jetons totaux",
|
||||
"Total Usage": "Utilisation totale",
|
||||
|
||||
Vendored
+2
@@ -1979,6 +1979,7 @@
|
||||
"Find the ratio.": "倍率を特定する。",
|
||||
"Finish Time": "完了時刻",
|
||||
"First API request": "最初の API リクエスト",
|
||||
"First token": "先頭トークン",
|
||||
"First/Last Frame to Video": "先頭/末尾フレームから動画",
|
||||
"Fix Abilities": "チャネル整合性を修復",
|
||||
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "チャネル整合性を修復しました:成功 {{success}} 件、失敗 {{fails}} 件",
|
||||
@@ -4630,6 +4631,7 @@
|
||||
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "プランに含まれる合計クォータ。請求期間ごとに使用可能。0 は無制限を意味します。",
|
||||
"Total requests allowed per period. 0 = unlimited.": "期間ごとに許可されるリクエストの総数。0 = 無制限。",
|
||||
"Total requests made": "合計リクエスト数",
|
||||
"Total time": "総時間",
|
||||
"Total tokens": "合計トークン",
|
||||
"Total Tokens": "合計トークン",
|
||||
"Total Usage": "総使用量",
|
||||
|
||||
Vendored
+2
@@ -1979,6 +1979,7 @@
|
||||
"Find the ratio.": "Определите коэффициент.",
|
||||
"Finish Time": "Время завершения",
|
||||
"First API request": "Первый API-запрос",
|
||||
"First token": "Первый токен",
|
||||
"First/Last Frame to Video": "Первый/последний кадр в видео",
|
||||
"Fix Abilities": "Восстановить согласованность каналов",
|
||||
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "Согласованность каналов восстановлена: успешно {{success}}, ошибок {{fails}}",
|
||||
@@ -4630,6 +4631,7 @@
|
||||
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "Общая квота, включённая в тариф, доступна за каждый расчётный период. 0 означает безлимит.",
|
||||
"Total requests allowed per period. 0 = unlimited.": "Общее количество запросов, разрешенных за период. 0 = без ограничений.",
|
||||
"Total requests made": "Всего сделанных запросов",
|
||||
"Total time": "Общее время",
|
||||
"Total tokens": "Всего токенов",
|
||||
"Total Tokens": "Всего токенов",
|
||||
"Total Usage": "Общее использование",
|
||||
|
||||
Vendored
+2
@@ -1979,6 +1979,7 @@
|
||||
"Find the ratio.": "Xác định hệ số.",
|
||||
"Finish Time": "Thời gian hoàn thành",
|
||||
"First API request": "Yêu cầu API đầu tiên",
|
||||
"First token": "Token đầu",
|
||||
"First/Last Frame to Video": "Khung đầu/cuối sang video",
|
||||
"Fix Abilities": "Sửa tính nhất quán kênh",
|
||||
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "Đã sửa tính nhất quán kênh: {{success}} thành công, {{fails}} thất bại",
|
||||
@@ -4630,6 +4631,7 @@
|
||||
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "Tổng hạn ngạch bao gồm trong gói, dùng được mỗi kỳ thanh toán. 0 nghĩa là không giới hạn.",
|
||||
"Total requests allowed per period. 0 = unlimited.": "Tổng số yêu cầu được phép mỗi kỳ. 0 = không giới hạn.",
|
||||
"Total requests made": "Tổng lượt yêu cầu",
|
||||
"Total time": "Tổng thời gian",
|
||||
"Total tokens": "Tổng số token",
|
||||
"Total Tokens": "Tổng số token",
|
||||
"Total Usage": "Tổng Mức Sử dụng",
|
||||
|
||||
+3
-1
@@ -1979,6 +1979,7 @@
|
||||
"Find the ratio.": "確定倍率。",
|
||||
"Finish Time": "完成時間",
|
||||
"First API request": "首個 API 請求",
|
||||
"First token": "首字",
|
||||
"First/Last Frame to Video": "首尾生影片",
|
||||
"Fix Abilities": "修復渠道一致性",
|
||||
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "渠道一致性修復完成:{{success}} 個成功,{{fails}} 個失敗",
|
||||
@@ -2962,7 +2963,7 @@
|
||||
"Node Name": "節點名稱",
|
||||
"Node role": "節點職責",
|
||||
"Nodes reporting from this deployment and their latest heartbeat.": "目前部署中上報的節點及其最新心跳。",
|
||||
"Non-stream": "非串流",
|
||||
"Non-stream": "非流",
|
||||
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "非零邀請獎勵需要先在支付閘道設定中確認合規條款。",
|
||||
"None": "無",
|
||||
"noreply@example.com": "noreply@example.com",
|
||||
@@ -4630,6 +4631,7 @@
|
||||
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "套餐包含的總額度,每個收費周期可用;0 表示不限量",
|
||||
"Total requests allowed per period. 0 = unlimited.": "每周期允許的總請求數。0 = 無限制。",
|
||||
"Total requests made": "總請求數",
|
||||
"Total time": "總耗時",
|
||||
"Total tokens": "總 Token",
|
||||
"Total Tokens": "總 Token 數",
|
||||
"Total Usage": "總用量",
|
||||
|
||||
Vendored
+3
-1
@@ -1979,6 +1979,7 @@
|
||||
"Find the ratio.": "确定倍率。",
|
||||
"Finish Time": "完成时间",
|
||||
"First API request": "首个 API 请求",
|
||||
"First token": "首字",
|
||||
"First/Last Frame to Video": "首尾生视频",
|
||||
"Fix Abilities": "修复渠道一致性",
|
||||
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "渠道一致性修复完成:{{success}} 个成功,{{fails}} 个失败",
|
||||
@@ -2962,7 +2963,7 @@
|
||||
"Node Name": "节点名称",
|
||||
"Node role": "节点职责",
|
||||
"Nodes reporting from this deployment and their latest heartbeat.": "当前部署中上报的节点及其最新心跳。",
|
||||
"Non-stream": "非流式",
|
||||
"Non-stream": "非流",
|
||||
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "非零邀请奖励需要先在支付网关设置中确认合规条款。",
|
||||
"None": "无",
|
||||
"noreply@example.com": "noreply@example.com",
|
||||
@@ -4630,6 +4631,7 @@
|
||||
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "套餐包含的总额度,每个计费周期可用;0 表示不限量",
|
||||
"Total requests allowed per period. 0 = unlimited.": "每周期允许的总请求数。0 = 无限制。",
|
||||
"Total requests made": "总请求数",
|
||||
"Total time": "总耗时",
|
||||
"Total tokens": "总 Token",
|
||||
"Total Tokens": "总 Token 数",
|
||||
"Total Usage": "总用量",
|
||||
|
||||
+9
@@ -403,6 +403,15 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
--skeleton-highlight: oklch(0.415 0 0);
|
||||
}
|
||||
|
||||
/* Overview accents mirror the public blue/cyan/violet palette by default.
|
||||
* Named presets map those slots to their own chart palette so the overview
|
||||
* stays coherent with the user's selected theme. */
|
||||
[data-theme-preset]:not([data-theme-preset='default']) {
|
||||
--overview-accent-1: var(--chart-1);
|
||||
--overview-accent-2: var(--chart-2);
|
||||
--overview-accent-3: var(--chart-3);
|
||||
}
|
||||
|
||||
/* ── Semantic surface bridge ──────────────────────────────────────────── */
|
||||
/* Color presets should tint the surfaces most components actually use, not
|
||||
* only primary buttons. These derived tokens keep the app theme-aware without
|
||||
|
||||
Vendored
+7
-1
@@ -72,6 +72,9 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-overview-accent-1: var(--overview-accent-1);
|
||||
--color-overview-accent-2: var(--overview-accent-2);
|
||||
--color-overview-accent-3: var(--overview-accent-3);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
@@ -132,7 +135,10 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.975 0 0);
|
||||
--overview-accent-1: oklch(0.72 0.18 250);
|
||||
--overview-accent-2: oklch(0.65 0.15 200);
|
||||
--overview-accent-3: oklch(0.7 0.12 280);
|
||||
--sidebar: color-mix(in oklch, var(--foreground) 0.5%, var(--background));
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.13 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
|
||||
Reference in New Issue
Block a user