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 ) } 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}

-
+
{priceSummary}
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 /> +