Commit 6bbddb10 by CaIon

feat(timing): add timing metrics display for stream logs and enhance localization

parent 1b1b23d1
...@@ -183,7 +183,7 @@ function SetupGuideBackdrop(props: { compact?: boolean }) { ...@@ -183,7 +183,7 @@ function SetupGuideBackdrop(props: { compact?: boolean }) {
<> <>
<div <div
className={cn( 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 props.compact
? '[mask-image:linear-gradient(90deg,black_0%,black_48%,transparent_74%)] opacity-55' ? '[mask-image:linear-gradient(90deg,black_0%,black_48%,transparent_74%)] opacity-55'
: 'opacity-85' : 'opacity-85'
...@@ -373,9 +373,9 @@ function RequestPreview(props: { ...@@ -373,9 +373,9 @@ function RequestPreview(props: {
<span className='bg-success size-2 rounded-full' /> <span className='bg-success size-2 rounded-full' />
</div> </div>
<div className='flex flex-col gap-1 overflow-hidden'> <div className='flex flex-col gap-1 overflow-hidden'>
{previewLines.map((line, index) => ( {previewLines.map((line) => (
<code <code
key={`${line}-${index}`} key={line}
className='text-muted-foreground truncate' className='text-muted-foreground truncate'
title={line} title={line}
> >
......
...@@ -211,6 +211,20 @@ export function SummaryCards() { ...@@ -211,6 +211,20 @@ export function SummaryCards() {
const runwayDays = getRunwayDays(remainQuota, recentUsage) const runwayDays = getRunwayDays(remainQuota, recentUsage)
const todayUsageDisplay = formatQuota(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({ const items = useSummaryCardsConfig({
...summaryValues, ...summaryValues,
...@@ -218,7 +232,7 @@ export function SummaryCards() { ...@@ -218,7 +232,7 @@ export function SummaryCards() {
currencyEnabled, currencyEnabled,
currencyLabel, currencyLabel,
}).map((config, index) => { }).map((config, index) => {
const tones = ['rose', 'teal', 'gray'] as const const tones = ['accent-1', 'accent-2', 'accent-3'] as const
return { return {
key: config.key, key: config.key,
...@@ -226,7 +240,7 @@ export function SummaryCards() { ...@@ -226,7 +240,7 @@ export function SummaryCards() {
value: config.value, value: config.value,
desc: config.description, desc: config.description,
icon: config.icon, icon: config.icon,
tone: tones[index] ?? 'gray', tone: tones[index] ?? 'accent-3',
sparkline: sparkline:
config.key === 'todayUsage' config.key === 'todayUsage'
? sparklineData.usage ? sparklineData.usage
...@@ -270,7 +284,7 @@ export function SummaryCards() { ...@@ -270,7 +284,7 @@ export function SummaryCards() {
</StaggerContainer> </StaggerContainer>
</div> </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 flex-col gap-3'>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<span className='text-muted-foreground text-xs font-medium'> <span className='text-muted-foreground text-xs font-medium'>
...@@ -323,15 +337,7 @@ export function SummaryCards() { ...@@ -323,15 +337,7 @@ export function SummaryCards() {
healthLevel === 'caution' && 'text-warning' healthLevel === 'caution' && 'text-warning'
)} )}
> >
{runwayDays !== null {runwayDisplay}
? 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')}
</div> </div>
</div> </div>
</div> </div>
......
...@@ -16,13 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,13 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com 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 { useId, type ReactNode } from 'react'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
type StatCardTone = 'rose' | 'teal' | 'gray' type StatCardTone = 'accent-1' | 'accent-2' | 'accent-3'
type StatCardSparklineVariant = 'bars' | 'line' type StatCardSparklineVariant = 'bars' | 'line'
type StatCardDetailTone = type StatCardDetailTone =
| 'default' | 'default'
...@@ -52,15 +52,18 @@ interface StatCardProps { ...@@ -52,15 +52,18 @@ interface StatCardProps {
} }
const TONE_CLASSES: Record<StatCardTone, string> = { 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', 'accent-1':
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', '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',
gray: 'from-muted-foreground/50 via-muted-foreground/20 to-transparent dark:from-muted-foreground/40 dark:via-muted-foreground/20', '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> = { const LINE_TONE_CLASSES: Record<StatCardTone, string> = {
rose: 'text-warning', 'accent-1': 'text-overview-accent-1',
teal: 'text-primary', 'accent-2': 'text-overview-accent-2',
gray: 'text-muted-foreground', 'accent-3': 'text-overview-accent-3',
} }
const DETAIL_TONE_CLASSES: Record<StatCardDetailTone, string> = { const DETAIL_TONE_CLASSES: Record<StatCardDetailTone, string> = {
...@@ -71,14 +74,24 @@ const DETAIL_TONE_CLASSES: Record<StatCardDetailTone, string> = { ...@@ -71,14 +74,24 @@ const DETAIL_TONE_CLASSES: Record<StatCardDetailTone, string> = {
destructive: 'text-destructive', destructive: 'text-destructive',
} }
function normalizeSparkline(values?: number[]): number[] { interface SparklineBucket {
position: number
height: number
}
function normalizeSparkline(values?: number[]): SparklineBucket[] {
if (!values?.length) return [] if (!values?.length) return []
const sanitized = values.map((value) => Math.max(0, Number(value) || 0)) const sanitized = values.map((value) => Math.max(0, Number(value) || 0))
const max = Math.max(...sanitized) 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[]) { function buildLineSparkline(values?: number[]) {
...@@ -97,7 +110,12 @@ function buildLineSparkline(values?: number[]) { ...@@ -97,7 +110,12 @@ function buildLineSparkline(values?: number[]) {
sanitized.length === 1 sanitized.length === 1
? width / 2 ? width / 2
: (index / (sanitized.length - 1)) * width : (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) const y = height - padding - normalized * (height - padding * 2)
return { x, y } return { x, y }
...@@ -106,8 +124,9 @@ function buildLineSparkline(values?: number[]) { ...@@ -106,8 +124,9 @@ function buildLineSparkline(values?: number[]) {
const linePath = points const linePath = points
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`) .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`)
.join(' ') .join(' ')
const firstPoint = points[0] const firstPoint = points.at(0)
const lastPoint = points[points.length - 1] const lastPoint = points.at(-1)
if (!firstPoint || !lastPoint) return null
const areaPath = `${linePath} L ${lastPoint.x} ${height} L ${firstPoint.x} ${height} Z` const areaPath = `${linePath} L ${lastPoint.x} ${height} L ${firstPoint.x} ${height} Z`
return { return {
...@@ -118,7 +137,7 @@ function buildLineSparkline(values?: number[]) { ...@@ -118,7 +137,7 @@ function buildLineSparkline(values?: number[]) {
function LineSparkline(props: { values?: number[]; tone: StatCardTone }) { function LineSparkline(props: { values?: number[]; tone: StatCardTone }) {
const rawGradientId = useId() const rawGradientId = useId()
const gradientId = `stat-card-line-${rawGradientId.replace(/:/g, '')}` const gradientId = `stat-card-line-${rawGradientId.replaceAll(':', '')}`
const paths = buildLineSparkline(props.values) const paths = buildLineSparkline(props.values)
if (!paths) return <div className='h-8' aria-hidden='true' /> if (!paths) return <div className='h-8' aria-hidden='true' />
...@@ -162,15 +181,15 @@ function BarSparkline(props: { values?: number[]; tone: StatCardTone }) { ...@@ -162,15 +181,15 @@ function BarSparkline(props: { values?: number[]; tone: StatCardTone }) {
return ( return (
<div className='flex h-8 items-end gap-1' aria-hidden='true'> <div className='flex h-8 items-end gap-1' aria-hidden='true'>
{sparkline.map((height, index) => ( {sparkline.map((bucket) => (
<span <span
key={`spark-${index}`} key={bucket.position}
className={cn( className={cn(
'flex-1 rounded-t-sm bg-linear-to-t', 'flex-1 rounded-t-sm bg-linear-to-t',
height <= 0 && 'opacity-20', bucket.height <= 0 && 'opacity-20',
TONE_CLASSES[props.tone] TONE_CLASSES[props.tone]
)} )}
style={{ height: `${height}%` }} style={{ height: `${bucket.height}%` }}
/> />
))} ))}
</div> </div>
...@@ -205,8 +224,46 @@ function StatCardDetails(props: { details: StatCardDetail[] }) { ...@@ -205,8 +224,46 @@ function StatCardDetails(props: { details: StatCardDetail[] }) {
export function StatCard(props: StatCardProps) { export function StatCard(props: StatCardProps) {
const Icon = props.icon const Icon = props.icon
const tone = props.tone ?? 'gray' const tone = props.tone ?? 'accent-3'
const sparklineVariant = props.sparklineVariant ?? 'bars' 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 ( return (
<div className='group flex min-h-32 flex-col justify-between gap-3'> <div className='group flex min-h-32 flex-col justify-between gap-3'>
...@@ -221,38 +278,9 @@ export function StatCard(props: StatCardProps) { ...@@ -221,38 +278,9 @@ export function StatCard(props: StatCardProps) {
{props.action && <div className='shrink-0'>{props.action}</div>} {props.action && <div className='shrink-0'>{props.action}</div>}
</div> </div>
{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>
) : 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>
)}
{props.details?.length ? ( {visualization}
<StatCardDetails details={props.details} />
) : sparklineVariant === 'line' ? (
<LineSparkline values={props.sparkline} tone={tone} />
) : (
<BarSparkline values={props.sparkline} tone={tone} />
)}
</div> </div>
) )
} }
...@@ -116,14 +116,13 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) { ...@@ -116,14 +116,13 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
<span className='text-foreground font-mono font-semibold'> <span className='text-foreground font-mono font-semibold'>
{entry.formatted} {entry.formatted}
</span> </span>
/{tokenUnitLabel}
</span> </span>
))} ))}
</> </>
) )
} else { } else {
priceSummary = ( priceSummary = (
<span className='text-muted-foreground text-xs'> <span className='text-muted-foreground text-sm'>
{t('Dynamic Pricing')} {t('Dynamic Pricing')}
</span> </span>
) )
...@@ -144,7 +143,6 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) { ...@@ -144,7 +143,6 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
props.selectedGroup props.selectedGroup
)} )}
</span> </span>
/{tokenUnitLabel}
</span> </span>
<span className='text-muted-foreground whitespace-nowrap'> <span className='text-muted-foreground whitespace-nowrap'>
{t('Output')}{' '} {t('Output')}{' '}
...@@ -159,7 +157,6 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) { ...@@ -159,7 +157,6 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
props.selectedGroup props.selectedGroup
)} )}
</span> </span>
/{tokenUnitLabel}
</span> </span>
{hasCachedPrice && ( {hasCachedPrice && (
<span className='text-muted-foreground/60 whitespace-nowrap'> <span className='text-muted-foreground/60 whitespace-nowrap'>
...@@ -217,7 +214,7 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) { ...@@ -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'> <h3 className='text-foreground truncate font-mono text-[15px] leading-tight font-bold'>
{props.model.model_name} {props.model.model_name}
</h3> </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} {priceSummary}
</div> </div>
</div> </div>
......
...@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { type ColumnDef } from '@tanstack/react-table' 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 { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
...@@ -36,19 +36,13 @@ import { ...@@ -36,19 +36,13 @@ import {
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar' import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
import { formatBillingCurrencyFromUSD } from '@/lib/currency' import { formatBillingCurrencyFromUSD } from '@/lib/currency'
import { import { formatLogQuota, formatTimestampToDate } from '@/lib/format'
formatUseTime,
formatLogQuota,
formatTimestampToDate,
} from '@/lib/format'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { LOG_TYPE_ALL_VALUE } from '../../constants' import { LOG_TYPE_ALL_VALUE } from '../../constants'
import type { UsageLog } from '../../data/schema' import type { UsageLog } from '../../data/schema'
import { import {
formatModelName, formatModelName,
getFirstResponseTimeColor,
getResponseTimeColor,
getTieredBillingSummary, getTieredBillingSummary,
hasAnyCacheTokens, hasAnyCacheTokens,
parseLogOther, parseLogOther,
...@@ -64,6 +58,7 @@ import { ...@@ -64,6 +58,7 @@ import {
import type { LogOtherData } from '../../types' import type { LogOtherData } from '../../types'
import { DetailsDialog } from '../dialogs/details-dialog' import { DetailsDialog } from '../dialogs/details-dialog'
import { ModelBadge } from '../model-badge' import { ModelBadge } from '../model-badge'
import { TimingMetricsCell, StreamTpsCell } from '../timing-metrics-cell'
import { useUsageLogsContext } from '../usage-logs-provider' import { useUsageLogsContext } from '../usage-logs-provider'
interface DetailSegment { interface DetailSegment {
...@@ -619,113 +614,29 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] { ...@@ -619,113 +614,29 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
meta: { mobileTitle: true }, meta: { mobileTitle: true },
}, },
{ {
accessorKey: 'use_time', accessorKey: 'is_stream',
header: t('Timing'), header: t('Stream'),
cell: ({ row }) => { cell: ({ row }) => {
const log = row.original const log = row.original
if (!isTimingLogType(log.type)) return null if (!isTimingLogType(log.type)) return null
const useTime = row.getValue('use_time') as number const useTime = row.getValue('use_time') as number
const other = parseLogOther(log.other) const other = parseLogOther(log.other)
const frt = other?.frt
const tokensPerSecond = const tokensPerSecond =
useTime > 0 && log.completion_tokens > 0 useTime > 0 && log.completion_tokens > 0
? log.completion_tokens / useTime ? log.completion_tokens / useTime
: null : 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 ( return (
<div className='flex flex-col gap-1'> <StreamTpsCell
<div className='flex items-center gap-1.5'> isStream={log.is_stream}
<StatusBadge tokensPerSecond={tokensPerSecond}
label={formatUseTime(useTime)} streamStatus={other?.stream_status}
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>
) )
}, },
meta: { label: t('Stream') },
}, },
{ {
accessorKey: 'prompt_tokens', accessorKey: 'prompt_tokens',
header: 'Tokens', header: 'Tokens',
...@@ -773,6 +684,25 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] { ...@@ -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', accessorKey: 'quota',
......
/*
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>({ ...@@ -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' valueClassName='[&_.flex-col]:max-w-none [&_.flex-col>*:not(:first-child)]:text-[11px] [&_.flex-col>*:not(:first-child)]:leading-none'
/> />
<SummaryField <SummaryField
label={t('Timing')} label={t('Stream')}
cell={cells.get('use_time')} cell={cells.get('is_stream')}
primaryOnly primaryOnly
/> />
<SummaryField <SummaryField
...@@ -230,6 +230,11 @@ function CommonLogsCard<TData>({ ...@@ -230,6 +230,11 @@ function CommonLogsCard<TData>({
primaryOnly primaryOnly
/> />
<SummaryField <SummaryField
label={t('Timing')}
cell={cells.get('use_time')}
primaryOnly
/>
<SummaryField
label={t('Details')} label={t('Details')}
cell={cells.get('content')} cell={cells.get('content')}
className='col-span-2 bg-transparent px-0 py-0' className='col-span-2 bg-transparent px-0 py-0'
......
...@@ -1979,6 +1979,7 @@ ...@@ -1979,6 +1979,7 @@
"Find the ratio.": "Find the ratio.", "Find the ratio.": "Find the ratio.",
"Finish Time": "Finish Time", "Finish Time": "Finish Time",
"First API request": "First API request", "First API request": "First API request",
"First token": "First token",
"First/Last Frame to Video": "First/Last Frame to Video", "First/Last Frame to Video": "First/Last Frame to Video",
"Fix Abilities": "Repair Channel Consistency", "Fix Abilities": "Repair Channel Consistency",
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "Channel consistency repaired: {{success}} succeeded, {{fails}} failed", "Fixed abilities: {{success}} succeeded, {{fails}} failed": "Channel consistency repaired: {{success}} succeeded, {{fails}} failed",
...@@ -4630,6 +4631,7 @@ ...@@ -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 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 allowed per period. 0 = unlimited.": "Total requests allowed per period. 0 = unlimited.",
"Total requests made": "Total requests made", "Total requests made": "Total requests made",
"Total time": "Total time",
"Total tokens": "Total tokens", "Total tokens": "Total tokens",
"Total Tokens": "Total Tokens", "Total Tokens": "Total Tokens",
"Total Usage": "Total Usage", "Total Usage": "Total Usage",
......
...@@ -1979,6 +1979,7 @@ ...@@ -1979,6 +1979,7 @@
"Find the ratio.": "Trouver le taux.", "Find the ratio.": "Trouver le taux.",
"Finish Time": "Heure de fin", "Finish Time": "Heure de fin",
"First API request": "Première requête API", "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", "First/Last Frame to Video": "Première/Dernière image vers vidéo",
"Fix Abilities": "Réparer la cohérence des canaux", "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)", "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 @@ ...@@ -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 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 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 requests made": "Requêtes totales effectuées",
"Total time": "Durée totale",
"Total tokens": "Jetons totaux", "Total tokens": "Jetons totaux",
"Total Tokens": "Jetons totaux", "Total Tokens": "Jetons totaux",
"Total Usage": "Utilisation totale", "Total Usage": "Utilisation totale",
......
...@@ -1979,6 +1979,7 @@ ...@@ -1979,6 +1979,7 @@
"Find the ratio.": "倍率を特定する。", "Find the ratio.": "倍率を特定する。",
"Finish Time": "完了時刻", "Finish Time": "完了時刻",
"First API request": "最初の API リクエスト", "First API request": "最初の API リクエスト",
"First token": "先頭トークン",
"First/Last Frame to Video": "先頭/末尾フレームから動画", "First/Last Frame to Video": "先頭/末尾フレームから動画",
"Fix Abilities": "チャネル整合性を修復", "Fix Abilities": "チャネル整合性を修復",
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "チャネル整合性を修復しました:成功 {{success}} 件、失敗 {{fails}} 件", "Fixed abilities: {{success}} succeeded, {{fails}} failed": "チャネル整合性を修復しました:成功 {{success}} 件、失敗 {{fails}} 件",
...@@ -4630,6 +4631,7 @@ ...@@ -4630,6 +4631,7 @@
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "プランに含まれる合計クォータ。請求期間ごとに使用可能。0 は無制限を意味します。", "Total quota included in the plan, usable per billing period. 0 means unlimited.": "プランに含まれる合計クォータ。請求期間ごとに使用可能。0 は無制限を意味します。",
"Total requests allowed per period. 0 = unlimited.": "期間ごとに許可されるリクエストの総数。0 = 無制限。", "Total requests allowed per period. 0 = unlimited.": "期間ごとに許可されるリクエストの総数。0 = 無制限。",
"Total requests made": "合計リクエスト数", "Total requests made": "合計リクエスト数",
"Total time": "総時間",
"Total tokens": "合計トークン", "Total tokens": "合計トークン",
"Total Tokens": "合計トークン", "Total Tokens": "合計トークン",
"Total Usage": "総使用量", "Total Usage": "総使用量",
......
...@@ -1979,6 +1979,7 @@ ...@@ -1979,6 +1979,7 @@
"Find the ratio.": "Определите коэффициент.", "Find the ratio.": "Определите коэффициент.",
"Finish Time": "Время завершения", "Finish Time": "Время завершения",
"First API request": "Первый API-запрос", "First API request": "Первый API-запрос",
"First token": "Первый токен",
"First/Last Frame to Video": "Первый/последний кадр в видео", "First/Last Frame to Video": "Первый/последний кадр в видео",
"Fix Abilities": "Восстановить согласованность каналов", "Fix Abilities": "Восстановить согласованность каналов",
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "Согласованность каналов восстановлена: успешно {{success}}, ошибок {{fails}}", "Fixed abilities: {{success}} succeeded, {{fails}} failed": "Согласованность каналов восстановлена: успешно {{success}}, ошибок {{fails}}",
...@@ -4630,6 +4631,7 @@ ...@@ -4630,6 +4631,7 @@
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "Общая квота, включённая в тариф, доступна за каждый расчётный период. 0 означает безлимит.", "Total quota included in the plan, usable per billing period. 0 means unlimited.": "Общая квота, включённая в тариф, доступна за каждый расчётный период. 0 означает безлимит.",
"Total requests allowed per period. 0 = unlimited.": "Общее количество запросов, разрешенных за период. 0 = без ограничений.", "Total requests allowed per period. 0 = unlimited.": "Общее количество запросов, разрешенных за период. 0 = без ограничений.",
"Total requests made": "Всего сделанных запросов", "Total requests made": "Всего сделанных запросов",
"Total time": "Общее время",
"Total tokens": "Всего токенов", "Total tokens": "Всего токенов",
"Total Tokens": "Всего токенов", "Total Tokens": "Всего токенов",
"Total Usage": "Общее использование", "Total Usage": "Общее использование",
......
...@@ -1979,6 +1979,7 @@ ...@@ -1979,6 +1979,7 @@
"Find the ratio.": "Xác định hệ số.", "Find the ratio.": "Xác định hệ số.",
"Finish Time": "Thời gian hoàn thành", "Finish Time": "Thời gian hoàn thành",
"First API request": "Yêu cầu API đầu tiên", "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", "First/Last Frame to Video": "Khung đầu/cuối sang video",
"Fix Abilities": "Sửa tính nhất quán kênh", "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", "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 @@ ...@@ -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 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 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 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 Tokens": "Tổng số token", "Total Tokens": "Tổng số token",
"Total Usage": "Tổng Mức Sử dụng", "Total Usage": "Tổng Mức Sử dụng",
......
...@@ -1979,6 +1979,7 @@ ...@@ -1979,6 +1979,7 @@
"Find the ratio.": "確定倍率。", "Find the ratio.": "確定倍率。",
"Finish Time": "完成時間", "Finish Time": "完成時間",
"First API request": "首個 API 請求", "First API request": "首個 API 請求",
"First token": "首字",
"First/Last Frame to Video": "首尾生影片", "First/Last Frame to Video": "首尾生影片",
"Fix Abilities": "修復渠道一致性", "Fix Abilities": "修復渠道一致性",
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "渠道一致性修復完成:{{success}} 個成功,{{fails}} 個失敗", "Fixed abilities: {{success}} succeeded, {{fails}} failed": "渠道一致性修復完成:{{success}} 個成功,{{fails}} 個失敗",
...@@ -2962,7 +2963,7 @@ ...@@ -2962,7 +2963,7 @@
"Node Name": "節點名稱", "Node Name": "節點名稱",
"Node role": "節點職責", "Node role": "節點職責",
"Nodes reporting from this deployment and their latest heartbeat.": "目前部署中上報的節點及其最新心跳。", "Nodes reporting from this deployment and their latest heartbeat.": "目前部署中上報的節點及其最新心跳。",
"Non-stream": "非流", "Non-stream": "非流",
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "非零邀請獎勵需要先在支付閘道設定中確認合規條款。", "Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "非零邀請獎勵需要先在支付閘道設定中確認合規條款。",
"None": "無", "None": "無",
"noreply@example.com": "noreply@example.com", "noreply@example.com": "noreply@example.com",
...@@ -4630,6 +4631,7 @@ ...@@ -4630,6 +4631,7 @@
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "套餐包含的總額度,每個收費周期可用;0 表示不限量", "Total quota included in the plan, usable per billing period. 0 means unlimited.": "套餐包含的總額度,每個收費周期可用;0 表示不限量",
"Total requests allowed per period. 0 = unlimited.": "每周期允許的總請求數。0 = 無限制。", "Total requests allowed per period. 0 = unlimited.": "每周期允許的總請求數。0 = 無限制。",
"Total requests made": "總請求數", "Total requests made": "總請求數",
"Total time": "總耗時",
"Total tokens": "總 Token", "Total tokens": "總 Token",
"Total Tokens": "總 Token 數", "Total Tokens": "總 Token 數",
"Total Usage": "總用量", "Total Usage": "總用量",
......
...@@ -1979,6 +1979,7 @@ ...@@ -1979,6 +1979,7 @@
"Find the ratio.": "确定倍率。", "Find the ratio.": "确定倍率。",
"Finish Time": "完成时间", "Finish Time": "完成时间",
"First API request": "首个 API 请求", "First API request": "首个 API 请求",
"First token": "首字",
"First/Last Frame to Video": "首尾生视频", "First/Last Frame to Video": "首尾生视频",
"Fix Abilities": "修复渠道一致性", "Fix Abilities": "修复渠道一致性",
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "渠道一致性修复完成:{{success}} 个成功,{{fails}} 个失败", "Fixed abilities: {{success}} succeeded, {{fails}} failed": "渠道一致性修复完成:{{success}} 个成功,{{fails}} 个失败",
...@@ -2962,7 +2963,7 @@ ...@@ -2962,7 +2963,7 @@
"Node Name": "节点名称", "Node Name": "节点名称",
"Node role": "节点职责", "Node role": "节点职责",
"Nodes reporting from this deployment and their latest heartbeat.": "当前部署中上报的节点及其最新心跳。", "Nodes reporting from this deployment and their latest heartbeat.": "当前部署中上报的节点及其最新心跳。",
"Non-stream": "非流", "Non-stream": "非流",
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "非零邀请奖励需要先在支付网关设置中确认合规条款。", "Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "非零邀请奖励需要先在支付网关设置中确认合规条款。",
"None": "无", "None": "无",
"noreply@example.com": "noreply@example.com", "noreply@example.com": "noreply@example.com",
...@@ -4630,6 +4631,7 @@ ...@@ -4630,6 +4631,7 @@
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "套餐包含的总额度,每个计费周期可用;0 表示不限量", "Total quota included in the plan, usable per billing period. 0 means unlimited.": "套餐包含的总额度,每个计费周期可用;0 表示不限量",
"Total requests allowed per period. 0 = unlimited.": "每周期允许的总请求数。0 = 无限制。", "Total requests allowed per period. 0 = unlimited.": "每周期允许的总请求数。0 = 无限制。",
"Total requests made": "总请求数", "Total requests made": "总请求数",
"Total time": "总耗时",
"Total tokens": "总 Token", "Total tokens": "总 Token",
"Total Tokens": "总 Token 数", "Total Tokens": "总 Token 数",
"Total Usage": "总用量", "Total Usage": "总用量",
......
...@@ -403,6 +403,15 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -403,6 +403,15 @@ For commercial licensing, please contact support@quantumnous.com
--skeleton-highlight: oklch(0.415 0 0); --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 ──────────────────────────────────────────── */ /* ── Semantic surface bridge ──────────────────────────────────────────── */
/* Color presets should tint the surfaces most components actually use, not /* Color presets should tint the surfaces most components actually use, not
* only primary buttons. These derived tokens keep the app theme-aware without * only primary buttons. These derived tokens keep the app theme-aware without
......
...@@ -72,6 +72,9 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -72,6 +72,9 @@ For commercial licensing, please contact support@quantumnous.com
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5); --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: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
...@@ -132,7 +135,10 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -132,7 +135,10 @@ For commercial licensing, please contact support@quantumnous.com
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429); --chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08); --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-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.13 0 0); --sidebar-primary: oklch(0.13 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment