diff --git a/web/default/src/features/dashboard/components/overview/overview-dashboard.tsx b/web/default/src/features/dashboard/components/overview/overview-dashboard.tsx
index 6f094329..c5909ff6 100644
--- a/web/default/src/features/dashboard/components/overview/overview-dashboard.tsx
+++ b/web/default/src/features/dashboard/components/overview/overview-dashboard.tsx
@@ -183,7 +183,7 @@ function SetupGuideBackdrop(props: { compact?: boolean }) {
<>
- {previewLines.map((line, index) => (
+ {previewLines.map((line) => (
diff --git a/web/default/src/features/dashboard/components/overview/summary-cards.tsx b/web/default/src/features/dashboard/components/overview/summary-cards.tsx
index 6970b681..c714ccc8 100644
--- a/web/default/src/features/dashboard/components/overview/summary-cards.tsx
+++ b/web/default/src/features/dashboard/components/overview/summary-cards.tsx
@@ -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() {
-
+
@@ -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}
diff --git a/web/default/src/features/dashboard/components/ui/stat-card.tsx b/web/default/src/features/dashboard/components/ui/stat-card.tsx
index 65b8e346..e5eaea6a 100644
--- a/web/default/src/features/dashboard/components/ui/stat-card.tsx
+++ b/web/default/src/features/dashboard/components/ui/stat-card.tsx
@@ -16,13 +16,13 @@ along with this program. If not, see
.
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
= {
- 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 = {
- 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 = {
@@ -71,14 +74,24 @@ const DETAIL_TONE_CLASSES: Record = {
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
@@ -162,15 +181,15 @@ function BarSparkline(props: { values?: number[]; tone: StatCardTone }) {
return (
- {sparkline.map((height, index) => (
+ {sparkline.map((bucket) => (
))}
@@ -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 = (
+
+
+
+
+ )
+ } else if (props.error) {
+ valueContent = (
+
+
+ --
+
+
{props.description}
+
+ )
+ } else {
+ valueContent = (
+
+
+ {props.value}
+
+
+ {props.description}
+
+
+ )
+ }
+
+ let visualization: ReactNode
+ if (props.details?.length) {
+ visualization =
+ } else if (sparklineVariant === 'line') {
+ visualization =
+ } else {
+ visualization =
+ }
return (
@@ -221,38 +278,9 @@ export function StatCard(props: StatCardProps) {
{props.action &&
{props.action}
}
- {props.loading ? (
-
-
-
-
- ) : props.error ? (
-
-
- --
-
-
- {props.description}
-
-
- ) : (
-
-
- {props.value}
-
-
- {props.description}
-
-
- )}
+ {valueContent}
- {props.details?.length ? (
-
- ) : sparklineVariant === 'line' ? (
-
- ) : (
-
- )}
+ {visualization}
)
}
diff --git a/web/default/src/features/pricing/components/model-card.tsx b/web/default/src/features/pricing/components/model-card.tsx
index 4e25eace..24808763 100644
--- a/web/default/src/features/pricing/components/model-card.tsx
+++ b/web/default/src/features/pricing/components/model-card.tsx
@@ -116,14 +116,13 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
{entry.formatted}
- /{tokenUnitLabel}
))}
>
)
} else {
priceSummary = (
-
+
{t('Dynamic Pricing')}
)
@@ -144,7 +143,6 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
props.selectedGroup
)}
- /{tokenUnitLabel}
{t('Output')}{' '}
@@ -159,7 +157,6 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
props.selectedGroup
)}
- /{tokenUnitLabel}
{hasCachedPrice && (
@@ -217,7 +214,7 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
{props.model.model_name}
-
diff --git a/web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx b/web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx
index 404f02eb..b1af4e02 100644
--- a/web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx
+++ b/web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx
@@ -17,7 +17,7 @@ along with this program. If not, see .
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[] {
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 = {
- 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 (
-
-
-
- {log.is_stream &&
- (frt != null && frt > 0 ? (
-
- ) : (
-
- ))}
-
-
-
- {log.is_stream ? t('Stream') : t('Non-stream')}
- {tokensPerSecond != null && (
- <>
- {' · '}
-
- {Math.round(tokensPerSecond)}
-
- {' t/s'}
- >
- )}
-
- {log.is_stream &&
- other?.stream_status &&
- other.stream_status.status !== 'ok' && (
-
-
- }
- >
-
-
-
- {t('Stream Status')}: {t('Error')}
-
-
{other.stream_status.end_reason || 'unknown'}
- {(other.stream_status.error_count ?? 0) > 0 && (
-
- {t('Soft Errors')}:{' '}
- {other.stream_status.error_count}
-
- )}
-
-
-
-
- )}
-
-
+
)
},
+ meta: { label: t('Stream') },
},
-
{
accessorKey: 'prompt_tokens',
header: 'Tokens',
@@ -773,6 +684,25 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] {
)
},
},
+ {
+ 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 (
+
+ )
+ },
+ },
{
accessorKey: 'quota',
diff --git a/web/default/src/features/usage-logs/components/timing-metrics-cell.tsx b/web/default/src/features/usage-logs/components/timing-metrics-cell.tsx
new file mode 100644
index 00000000..40937818
--- /dev/null
+++ b/web/default/src/features/usage-logs/components/timing-metrics-cell.tsx
@@ -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 .
+
+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 (
+
+
+
+
+
+ {t('First token')}
+
+
+ {showFirstToken ? firstTokenLabel : '—'}
+
+
+
+
+ {t('Duration')}
+
+ {totalTimeLabel}
+
+
+
+ )
+}
+
+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 (
+
+
+ {props.isStream ? t('Stream') : t('Non-stream')}
+ {showStreamError && (
+
+
+ }
+ />
+
+
+
+ {t('Stream Status')}: {t('Error')}
+
+
{props.streamStatus?.end_reason || 'unknown'}
+ {(props.streamStatus?.error_count ?? 0) > 0 && (
+
+ {t('Soft Errors')}: {props.streamStatus?.error_count}
+
+ )}
+
+
+
+
+ )}
+
+
+ {tpsLabel}
+
+
+ )
+}
diff --git a/web/default/src/features/usage-logs/components/usage-logs-mobile-card.tsx b/web/default/src/features/usage-logs/components/usage-logs-mobile-card.tsx
index 203c0ab2..a9364fee 100644
--- a/web/default/src/features/usage-logs/components/usage-logs-mobile-card.tsx
+++ b/web/default/src/features/usage-logs/components/usage-logs-mobile-card.tsx
@@ -220,8 +220,8 @@ function CommonLogsCard({
valueClassName='[&_.flex-col]:max-w-none [&_.flex-col>*:not(:first-child)]:text-[11px] [&_.flex-col>*:not(:first-child)]:leading-none'
/>
({
cell={cells.get('prompt_tokens')}
primaryOnly
/>
+