feat: update theme colors

This commit is contained in:
CaIon
2026-07-11 19:25:04 +08:00
parent 6bbddb1046
commit 162f87925c
75 changed files with 1553 additions and 655 deletions
+16 -18
View File
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { Radio as RadioPrimitive } from '@base-ui/react/radio'
import { RadioGroup as Radio } from '@base-ui/react/radio-group'
import { CircleCheck, Palette, RotateCcw } from 'lucide-react'
import { type SVGProps } from 'react'
import type { SVGProps } from 'react'
import { useTranslation } from 'react-i18next'
import { IconDir } from '@/assets/custom/icon-dir'
@@ -277,16 +277,12 @@ function PresetConfig() {
<div
aria-hidden='true'
className='absolute inset-0 rounded-md'
style={
preset.value === 'default'
? {
background:
'linear-gradient(135deg, var(--background) 0%, var(--muted) 50%, var(--foreground) 100%)',
}
: {
background: `linear-gradient(135deg, ${preset.swatches[0]} 0%, ${preset.swatches[1] ?? preset.swatches[0]} 100%)`,
}
}
style={{
background:
preset.value === 'default'
? 'linear-gradient(135deg, oklch(0.68 0.2 25) 0%, oklch(0.8 0.17 85) 25%, oklch(0.72 0.18 155) 50%, oklch(0.66 0.19 245) 75%, oklch(0.68 0.2 315) 100%)'
: `linear-gradient(135deg, ${preset.swatches[0]} 0%, ${preset.swatches[1] ?? preset.swatches[0]} 100%)`,
}}
/>
<CircleCheck
className={cn(
@@ -469,13 +465,15 @@ function ScalePreview(props: { rows: number; rowGap: string }) {
className='absolute inset-2.5 flex flex-col justify-center'
style={{ gap: props.rowGap }}
>
{Array.from({ length: props.rows }).map((_, i) => (
<span
key={i}
className='bg-foreground/60 block h-[2px] rounded-full'
style={{ width: `${85 - i * 10}%` }}
/>
))}
{Array.from({ length: props.rows }, (_, index) => 85 - index * 10).map(
(width) => (
<span
key={width}
className='bg-foreground/60 block h-[2px] rounded-full'
style={{ width: `${width}%` }}
/>
)
)}
</div>
)
}
+4 -5
View File
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { createElement, type ReactNode } from 'react'
import { IconBadge, type IconBadgeTone } from '@/components/ui/icon-badge'
import { cn } from '@/lib/utils'
export const sideDrawerContentClassName = (className?: string) =>
@@ -71,6 +72,7 @@ export function SideDrawerSectionHeader(props: {
title: ReactNode
description?: ReactNode
icon?: ReactNode
iconTone?: IconBadgeTone
className?: string
}) {
return createElement(
@@ -78,11 +80,8 @@ export function SideDrawerSectionHeader(props: {
{ className: cn('flex items-start gap-3', props.className) },
props.icon
? createElement(
'span',
{
className:
'bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md',
},
IconBadge,
{ tone: props.iconTone, size: 'md' },
props.icon
)
: null,
+85
View File
@@ -0,0 +1,85 @@
/*
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 { cva, type VariantProps } from 'class-variance-authority'
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
const iconBadgeVariants = cva(
'flex shrink-0 items-center justify-center [&>svg]:shrink-0',
{
variants: {
tone: {
neutral: 'bg-muted text-muted-foreground',
primary: 'bg-primary/10 text-primary',
success: 'bg-success/10 text-success',
warning: 'bg-warning/10 text-warning',
info: 'bg-info/10 text-info',
destructive: 'bg-destructive/10 text-destructive',
'chart-1': 'bg-chart-1/10 text-chart-1',
'chart-2': 'bg-chart-2/10 text-chart-2',
'chart-3': 'bg-chart-3/10 text-chart-3',
'chart-4': 'bg-chart-4/10 text-chart-4',
'chart-5': 'bg-chart-5/10 text-chart-5',
},
size: {
xs: 'size-5 rounded-md [&>svg]:size-3',
sm: 'size-7 rounded-md [&>svg]:size-3.5',
md: 'size-8 rounded-lg [&>svg]:size-4',
title: 'size-8 rounded-lg sm:size-9 [&>svg]:size-4',
lg: 'size-10 rounded-xl [&>svg]:size-5',
stat: 'size-5 rounded-md sm:size-7 [&>svg]:size-3 sm:[&>svg]:size-3.5',
},
},
defaultVariants: {
tone: 'neutral',
size: 'md',
},
}
)
export type IconBadgeTone = NonNullable<
VariantProps<typeof iconBadgeVariants>['tone']
>
export type IconBadgeSize = NonNullable<
VariantProps<typeof iconBadgeVariants>['size']
>
interface IconBadgeProps {
children?: ReactNode
tone?: IconBadgeTone
size?: IconBadgeSize
className?: string
decorative?: boolean
}
export function IconBadge(props: IconBadgeProps) {
return (
<span
className={cn(
iconBadgeVariants({ tone: props.tone, size: props.size }),
props.className
)}
aria-hidden={props.decorative ?? true}
>
{props.children}
</span>
)
}
+1 -1
View File
@@ -477,7 +477,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
<ul
data-slot='sidebar-menu'
data-sidebar='menu'
className={cn('flex w-full min-w-0 flex-col gap-0', className)}
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
{...props}
/>
)
+1 -1
View File
@@ -54,7 +54,7 @@ function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return (
<tbody
data-slot='table-body'
className={cn('[&_tr:last-child]:border-0', className)}
className={cn('[&>tr]:h-15 [&_tr:last-child]:border-0', className)}
{...props}
/>
)
+5 -7
View File
@@ -27,6 +27,7 @@ import {
CardHeader,
CardTitle,
} from './card'
import { IconBadge, type IconBadgeTone } from './icon-badge'
type TitledCardProps = {
title: ReactNode
@@ -39,6 +40,7 @@ type TitledCardProps = {
headerClassName?: string
contentClassName?: string
iconClassName?: string
iconTone?: IconBadgeTone
titleClassName?: string
descriptionClassName?: string
}
@@ -54,6 +56,7 @@ export function TitledCard({
headerClassName,
contentClassName,
iconClassName,
iconTone,
titleClassName,
descriptionClassName,
}: TitledCardProps) {
@@ -68,14 +71,9 @@ export function TitledCard({
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='flex min-w-0 items-center gap-3'>
{icon != null && (
<div
className={cn(
'bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-lg sm:h-9 sm:w-9',
iconClassName
)}
>
<IconBadge size='title' tone={iconTone} className={iconClassName}>
{icon}
</div>
</IconBadge>
)}
<div className='min-w-0'>
<CardTitle
@@ -24,6 +24,7 @@ import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { IconBadge } from '@/components/ui/icon-badge'
import { formatCurrencyFromUSD } from '@/lib/currency'
import { formatTimestampToDate } from '@/lib/format'
@@ -169,18 +170,18 @@ export function BalanceQueryDialog({
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={handleClose} disabled={isQuerying}>
{t('Close')}
</Button>
</>
<Button variant='outline' onClick={handleClose} disabled={isQuerying}>
{t('Close')}
</Button>
}
>
<div className='space-y-4 py-4'>
{/* Current Balance Display */}
<div className='bg-muted/50 rounded-lg border p-4'>
<div className='text-muted-foreground mb-2 flex items-center gap-2 text-sm'>
<DollarSign className='h-4 w-4' />
<IconBadge tone='success' size='xs'>
<DollarSign />
</IconBadge>
<span>{t('Current Balance')}</span>
</div>
<div className='text-2xl font-bold'>
@@ -78,6 +78,7 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { IconBadge, type IconBadgeTone } from '@/components/ui/icon-badge'
import { Input } from '@/components/ui/input'
import {
Select,
@@ -365,25 +366,37 @@ function formatUnixTime(timestamp: unknown): string {
return new Date(seconds * 1000).toLocaleString()
}
function CardHeading({ title, icon }: { title: string; icon?: ReactNode }) {
function CardHeading(props: {
title: string
icon?: ReactNode
iconTone?: IconBadgeTone
}) {
return (
<div className='flex items-center gap-3'>
{icon && (
<span className='bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md'>
{icon}
</span>
{props.icon && (
<IconBadge tone={props.iconTone} size='md'>
{props.icon}
</IconBadge>
)}
<h3 className='text-sm font-semibold tracking-tight'>{title}</h3>
<h3 className='text-sm font-semibold tracking-tight'>{props.title}</h3>
</div>
)
}
function SubHeading({ title, icon }: { title: string; icon?: ReactNode }) {
function SubHeading(props: {
title: string
icon?: ReactNode
iconTone?: IconBadgeTone
}) {
return (
<div className='flex items-center gap-2'>
{icon && <span className='text-muted-foreground'>{icon}</span>}
{props.icon && (
<IconBadge tone={props.iconTone} size='xs'>
{props.icon}
</IconBadge>
)}
<h4 className='text-muted-foreground text-xs font-medium tracking-wide uppercase'>
{title}
{props.title}
</h4>
</div>
)
@@ -1823,9 +1836,9 @@ export function ChannelMutateDrawer({
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='min-w-0'>
<SheetTitle className='flex items-center gap-3'>
<span className='bg-muted flex size-9 shrink-0 items-center justify-center rounded-md'>
<IconBadge tone='info' size='title'>
<ChannelTypeLogo type={currentType} size={22} />
</span>
</IconBadge>
<span>
{isEditing ? t('Edit Channel') : t('Create Channel')}
<span className='text-muted-foreground ml-2 text-sm font-normal'>
@@ -3583,6 +3596,7 @@ export function ChannelMutateDrawer({
<CardHeading
title={t('Routing & Overrides')}
icon={<Route className='h-4 w-4' />}
iconTone='info'
/>
<div
id={ADVANCED_SETTINGS_SECTION_IDS.routingStrategy}
@@ -3594,6 +3608,7 @@ export function ChannelMutateDrawer({
<SubHeading
title={t('Routing Strategy')}
icon={<Route className='h-3.5 w-3.5' />}
iconTone='info'
/>
<div className='grid gap-4 sm:grid-cols-2'>
<FormField
@@ -3701,6 +3716,7 @@ export function ChannelMutateDrawer({
<SubHeading
title={t('Internal Notes')}
icon={<FileText className='h-3.5 w-3.5' />}
iconTone='chart-3'
/>
<div className='grid gap-4 sm:grid-cols-2'>
<FormField
@@ -3758,6 +3774,7 @@ export function ChannelMutateDrawer({
<SubHeading
title={t('Override Rules')}
icon={<Code className='h-3.5 w-3.5' />}
iconTone='chart-4'
/>
<FormField
@@ -4024,6 +4041,7 @@ export function ChannelMutateDrawer({
<CardHeading
title={t('Channel Extra Settings')}
icon={<Settings className='h-4 w-4' />}
iconTone='chart-3'
/>
{sensitiveLocked && (
<Alert className='border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
@@ -4231,6 +4249,7 @@ export function ChannelMutateDrawer({
<CardHeading
title={t('Field passthrough controls')}
icon={<SlidersHorizontal className='h-4 w-4' />}
iconTone='chart-4'
/>
<fieldset
disabled={sensitiveLocked}
@@ -4474,6 +4493,7 @@ export function ChannelMutateDrawer({
<CardHeading
title={t('Upstream Model Detection Settings')}
icon={<RefreshCw className='h-4 w-4' />}
iconTone='info'
/>
<fieldset
disabled={sensitiveLocked}
@@ -38,6 +38,7 @@ export function ChannelApiAccessSection(props: ChannelApiAccessSectionProps) {
title={t('Credentials')}
description={t('Authentication')}
icon={<KeyRound className='h-4 w-4' aria-hidden='true' />}
iconTone='success'
/>
{props.children}
</SideDrawerSection>
@@ -38,6 +38,7 @@ export function ChannelBasicSection(props: ChannelBasicSectionProps) {
title={t('Basic Information')}
description={t('Name, provider type, and availability.')}
icon={<Server className='h-4 w-4' aria-hidden='true' />}
iconTone='info'
/>
{props.children}
</SideDrawerSection>
@@ -38,6 +38,7 @@ export function ChannelModelsSection(props: ChannelModelsSectionProps) {
title={t('Models & Groups')}
description={t('Published models, groups, and model remapping rules.')}
icon={<Boxes className='h-4 w-4' aria-hidden='true' />}
iconTone='chart-4'
/>
{props.children}
</SideDrawerSection>
@@ -50,6 +50,7 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { IconBadge } from '@/components/ui/icon-badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Toggle } from '@/components/ui/toggle'
@@ -666,7 +667,9 @@ export function FlowCharts(props: FlowChartsProps) {
<div className='overflow-hidden rounded-lg border'>
<div className='flex w-full flex-col gap-2 border-b px-3 py-2 sm:px-5 sm:py-3 lg:flex-row lg:items-center lg:justify-between'>
<div className='flex min-w-0 items-center gap-2'>
<GitBranch className='text-muted-foreground/60 size-4 shrink-0' />
<IconBadge tone='info' size='sm'>
<GitBranch />
</IconBadge>
<div className='text-sm font-semibold'>{chartTitle}</div>
</div>
<TooltipProvider>
@@ -21,6 +21,7 @@ import { AreaChart, BarChart3, WalletCards } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { IconBadge } from '@/components/ui/icon-badge'
import { useThemeCustomization } from '@/context/theme-customization-provider'
import { useTheme } from '@/context/theme-provider'
import {
@@ -122,7 +123,9 @@ export function ConsumptionDistributionChart(
<div className='overflow-hidden rounded-lg border'>
<div className='flex w-full flex-col gap-1.5 border-b px-3 py-2 sm:gap-3 sm:px-5 sm:py-3 lg:flex-row lg:items-center lg:justify-between'>
<div className='flex items-center gap-2'>
<WalletCards className='text-muted-foreground/60 size-4' />
<IconBadge tone='success' size='sm'>
<WalletCards />
</IconBadge>
<div className='text-sm font-semibold'>{t('Quota Distribution')}</div>
<span className='text-muted-foreground text-xs'>
{t('Total:')} {chartData.totalQuotaDisplay}
@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { IconBadge } from '@/components/ui/icon-badge'
import { Skeleton } from '@/components/ui/skeleton'
import { getUserQuotaDates } from '@/features/dashboard/api'
import { useModelStatCardsConfig } from '@/features/dashboard/hooks/use-dashboard-config'
@@ -90,7 +91,7 @@ export function LogStatCards(props: LogStatCardsProps) {
const timeDiff = (timeRange.end_timestamp - timeRange.start_timestamp) / 60
setTimeRangeMinutes(timeDiff)
getUserQuotaDates(buildQueryParams(timeRange, filters), isAdmin)
void getUserQuotaDates(buildQueryParams(timeRange, filters), isAdmin)
.then((res) => {
if (abortController.signal.aborted) return
const data = res?.data || []
@@ -137,6 +138,7 @@ export function LogStatCards(props: LogStatCardsProps) {
fullValue: formatted.fullValue,
desc: config.description,
icon: config.icon,
iconTone: config.iconTone,
}
})
@@ -145,50 +147,65 @@ export function LogStatCards(props: LogStatCardsProps) {
<div className='divide-border/60 grid min-w-0 grid-cols-2 divide-x sm:grid-cols-3 lg:grid-cols-5'>
{items.map((it, idx) => {
const Icon = it.icon
let valueContent
if (loading) {
valueContent = (
<div className='mt-1 flex flex-col gap-1 sm:mt-2 sm:gap-1.5'>
<Skeleton className='h-5 w-16 sm:h-7 sm:w-20' />
<Skeleton className='hidden h-3.5 w-28 md:block' />
</div>
)
} else if (error) {
valueContent = (
<>
<div className='text-muted-foreground mt-1 font-mono text-base leading-tight font-bold tracking-tight tabular-nums sm:mt-2 sm:text-2xl sm:leading-normal'>
--
</div>
<div className='text-muted-foreground/40 mt-1 hidden text-xs md:block'>
{it.desc}
</div>
</>
)
} else {
valueContent = (
<>
<div
className='text-foreground mt-1 max-w-full truncate font-mono text-base leading-tight font-bold tracking-tight tabular-nums sm:mt-2 sm:text-2xl sm:leading-normal'
title={it.fullValue}
>
{it.value}
</div>
<div className='text-muted-foreground/60 mt-1 hidden text-xs md:block'>
{it.desc}
</div>
</>
)
}
return (
<div
key={it.title}
className={cn(
'min-w-0 px-3 py-2.5 sm:px-5 sm:py-4',
'min-w-0 px-2.5 py-1.5 sm:px-5 sm:py-4',
idx === items.length - 1 &&
items.length % 2 !== 0 &&
'col-span-2 sm:col-span-1'
)}
>
<div className='flex min-w-0 items-center gap-2'>
<Icon className='text-muted-foreground/60 size-3.5 shrink-0' />
<div className='text-muted-foreground truncate text-xs font-medium tracking-wider uppercase'>
<div className='flex min-w-0 items-center gap-1.5 sm:gap-2'>
<IconBadge
tone={it.iconTone}
size='stat'
className='size-4 rounded-sm sm:size-7 sm:rounded-md [&>svg]:size-2.5 sm:[&>svg]:size-3.5'
>
<Icon />
</IconBadge>
<div className='text-muted-foreground truncate text-[11px] leading-4 font-medium tracking-wide uppercase sm:text-xs sm:tracking-wider'>
{it.title}
</div>
</div>
{loading ? (
<div className='mt-2 flex flex-col gap-1.5'>
<Skeleton className='h-7 w-20' />
<Skeleton className='h-3.5 w-28' />
</div>
) : error ? (
<>
<div className='text-muted-foreground mt-1.5 font-mono text-lg font-bold tracking-tight tabular-nums sm:mt-2 sm:text-2xl'>
--
</div>
<div className='text-muted-foreground/40 mt-1 hidden text-xs md:block'>
{it.desc}
</div>
</>
) : (
<>
<div
className='text-foreground mt-1.5 max-w-full truncate font-mono text-lg font-bold tracking-tight tabular-nums sm:mt-2 sm:text-2xl'
title={it.fullValue}
>
{it.value}
</div>
<div className='text-muted-foreground/60 mt-1 hidden text-xs md:block'>
{it.desc}
</div>
</>
)}
{valueContent}
</div>
)
})}
@@ -21,6 +21,7 @@ import { PieChart as PieChartIcon } from 'lucide-react'
import { useEffect, useMemo, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { IconBadge } from '@/components/ui/icon-badge'
import { useThemeCustomization } from '@/context/theme-customization-provider'
import { useTheme } from '@/context/theme-provider'
import {
@@ -121,7 +122,9 @@ export function ModelCharts(props: ModelChartsProps) {
<div className='overflow-hidden rounded-lg border'>
<div className='flex w-full flex-col gap-1.5 border-b px-3 py-2 sm:gap-3 sm:px-5 sm:py-3 lg:flex-row lg:items-center lg:justify-between'>
<div className='flex items-center gap-2'>
<PieChartIcon className='text-muted-foreground/60 size-4' />
<IconBadge tone='chart-4' size='sm'>
<PieChartIcon />
</IconBadge>
<div className='text-sm font-semibold'>
{t('Model Call Analytics')}
</div>
@@ -21,6 +21,7 @@ import { Gauge, HeartPulse, Timer } from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { IconBadge, type IconBadgeTone } from '@/components/ui/icon-badge'
import { Skeleton } from '@/components/ui/skeleton'
import { getPerfMetricsSummary } from '@/features/performance-metrics/api'
import {
@@ -60,7 +61,7 @@ function simpleAverage(
count++
}
return count > 0 ? total / count : NaN
return count > 0 ? total / count : Number.NaN
}
function buildPerformanceSummary(rows: PerfModelSummary[]): PerformanceSummary {
@@ -113,10 +114,9 @@ export function PerformanceOverview() {
<div className='flex flex-wrap items-center gap-x-5 gap-y-2.5 px-4 py-2.5 sm:px-5 sm:py-3'>
{/* Title */}
<div className='flex items-center gap-1.5'>
<HeartPulse
className='text-muted-foreground/60 size-3.5 shrink-0'
aria-hidden='true'
/>
<IconBadge tone='success' size='xs'>
<HeartPulse />
</IconBadge>
<span className='text-xs font-semibold whitespace-nowrap'>
{t('Performance health')}
</span>
@@ -128,8 +128,8 @@ export function PerformanceOverview() {
{/* 3 KPI inline metrics */}
{loading ? (
<div className='flex flex-wrap items-center gap-x-5 gap-y-2'>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className='flex items-center gap-1.5'>
{['success', 'latency', 'throughput'].map((key) => (
<div key={key} className='flex items-center gap-1.5'>
<Skeleton className='h-3 w-14' />
<Skeleton className='h-4 w-16' />
</div>
@@ -142,16 +142,19 @@ export function PerformanceOverview() {
label={t('Success rate')}
value={formatUptimePct(summary.successRate)}
valueClassName={getSuccessRateTextClass(summary.successRate)}
tone='success'
/>
<InlineMetric
icon={Timer}
label={t('Average latency')}
value={formatLatency(summary.avgLatencyMs)}
tone='warning'
/>
<InlineMetric
icon={Gauge}
label={t('Throughput')}
value={formatThroughput(summary.avgTps)}
tone='info'
/>
</div>
)}
@@ -177,15 +180,15 @@ function InlineMetric(props: {
label: string
value: string
valueClassName?: string
tone: IconBadgeTone
}) {
const Icon = props.icon
return (
<div className='flex items-center gap-1.5'>
<Icon
className='text-muted-foreground/50 size-3 shrink-0'
aria-hidden='true'
/>
<IconBadge tone={props.tone} size='xs'>
<Icon />
</IconBadge>
<span className='text-muted-foreground text-[11px]'>{props.label}</span>
<span
className={cn(
@@ -20,6 +20,7 @@ import { Megaphone } from 'lucide-react'
import { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { IconBadge } from '@/components/ui/icon-badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useAnnouncements } from '@/features/dashboard/hooks/use-status-data'
import { getPreviewText } from '@/features/dashboard/lib'
@@ -60,7 +61,9 @@ export function AnnouncementsPanel() {
<PanelWrapper
title={
<span className='flex items-center gap-2'>
<Megaphone className='text-muted-foreground/60 size-4' />
<IconBadge tone='warning' size='sm'>
<Megaphone />
</IconBadge>
{t('Announcements')}
</span>
}
@@ -20,6 +20,7 @@ import { Route } from 'lucide-react'
import { useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { IconBadge } from '@/components/ui/icon-badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useApiInfo } from '@/features/dashboard/hooks/use-status-data'
import {
@@ -50,7 +51,9 @@ export function ApiInfoPanel() {
<PanelWrapper
title={
<span className='flex items-center gap-2'>
<Route className='text-muted-foreground/60 size-4' />
<IconBadge tone='info' size='sm'>
<Route />
</IconBadge>
{t('API Info')}
</span>
}
@@ -25,6 +25,7 @@ import {
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import { IconBadge } from '@/components/ui/icon-badge'
import { Markdown } from '@/components/ui/markdown'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useFAQ } from '@/features/dashboard/hooks/use-status-data'
@@ -40,7 +41,9 @@ export function FAQPanel() {
<PanelWrapper
title={
<span className='flex items-center gap-2'>
<HelpCircle className='text-muted-foreground/60 size-4' />
<IconBadge tone='chart-4' size='sm'>
<HelpCircle />
</IconBadge>
{t('FAQ')}
</span>
}
@@ -46,6 +46,7 @@ import {
CardStaggerItem,
} from '@/components/page-transition'
import { Button } from '@/components/ui/button'
import { IconBadge, type IconBadgeTone } from '@/components/ui/icon-badge'
import { fetchTokenKey, getApiKeys } from '@/features/keys/api'
import type { ApiKey } from '@/features/keys/types'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
@@ -117,6 +118,7 @@ interface HeroSignal {
label: string
value: string
icon: LucideIcon
tone: IconBadgeTone
}
function getSavedSetupGuideExpanded(): boolean | null {
@@ -333,9 +335,9 @@ function RequestPreview(props: {
<div className='flex items-center justify-between gap-3 border-b pb-3'>
<div className='flex min-w-0 items-center gap-2'>
<span className='bg-muted flex size-8 shrink-0 items-center justify-center rounded-lg'>
<TerminalSquare className='size-4' aria-hidden='true' />
</span>
<IconBadge tone='info'>
<TerminalSquare />
</IconBadge>
<div className='min-w-0'>
<div className='truncate text-sm font-medium'>
{t('First API request')}
@@ -395,10 +397,9 @@ function RequestPreview(props: {
className='bg-muted/40 flex items-center justify-between gap-3 rounded-xl px-3 py-2'
>
<span className='flex min-w-0 items-center gap-2'>
<Icon
className='text-muted-foreground size-3.5 shrink-0'
aria-hidden='true'
/>
<IconBadge tone={signal.tone} size='xs'>
<Icon />
</IconBadge>
<span className='truncate text-xs font-medium'>
{signal.label}
</span>
@@ -565,16 +566,19 @@ export function OverviewDashboard() {
label: t('Route active'),
value: apiInfoItems.length > 0 ? t('Online') : t('Current domain'),
icon: RadioTower,
tone: 'info',
},
{
label: t('Auth configured'),
value: preferredKey ? t('Secured') : t('Needs API key'),
icon: ShieldCheck,
tone: 'success',
},
{
label: t('Model selected'),
value: modelsQuery.data?.[0] ?? t('Loading'),
icon: Timer,
tone: 'chart-4',
},
],
[apiInfoItems.length, modelsQuery.data, preferredKey, t]
@@ -21,6 +21,7 @@ import { Gauge, HeartPulse, Timer } from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { IconBadge, type IconBadgeTone } from '@/components/ui/icon-badge'
import { Skeleton } from '@/components/ui/skeleton'
import { getPerfMetricsSummary } from '@/features/performance-metrics/api'
import {
@@ -51,7 +52,7 @@ function simpleAverage(
total += value
count++
}
return count > 0 ? total / count : NaN
return count > 0 ? total / count : Number.NaN
}
export function PerformanceHealthPanel() {
@@ -93,10 +94,9 @@ export function PerformanceHealthPanel() {
return (
<section className='bg-card h-full overflow-hidden rounded-2xl border shadow-xs'>
<div className='flex items-center gap-2 border-b px-4 py-3 sm:px-5'>
<HeartPulse
className='text-muted-foreground/60 size-4 shrink-0'
aria-hidden='true'
/>
<IconBadge tone='success' size='sm'>
<HeartPulse />
</IconBadge>
<h3 className='text-sm font-semibold'>{t('Performance health')}</h3>
<span className='text-muted-foreground ml-auto text-xs'>
{t('Performance metrics for the last 24 hours')}
@@ -111,25 +111,28 @@ export function PerformanceHealthPanel() {
value={formatUptimePct(summary.successRate)}
loading={loading}
valueClassName={getSuccessRateTextClass(summary.successRate)}
tone='success'
/>
<MetricCell
icon={Timer}
label={t('Average latency')}
value={formatLatency(summary.avgLatencyMs)}
loading={loading}
tone='warning'
/>
<MetricCell
icon={Gauge}
label={t('Throughput')}
value={formatThroughput(summary.avgTps)}
loading={loading}
tone='info'
/>
</div>
{loading ? (
<div className='space-y-1'>
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className='h-5 w-full rounded' />
{['success', 'latency', 'throughput'].map((key) => (
<Skeleton key={key} className='h-5 w-full rounded' />
))}
</div>
) : (
@@ -181,12 +184,15 @@ function MetricCell(props: {
value: string
loading: boolean
valueClassName?: string
tone: IconBadgeTone
}) {
const Icon = props.icon
return (
<div className='bg-muted/40 rounded-xl px-3 py-2.5'>
<div className='text-muted-foreground flex items-center gap-1.5 text-[11px] font-medium'>
<Icon className='size-3 shrink-0' aria-hidden='true' />
<IconBadge tone={props.tone} size='xs'>
<Icon />
</IconBadge>
<span className='truncate'>{props.label}</span>
</div>
{props.loading ? (
@@ -252,22 +252,22 @@ export function SummaryCards() {
return (
<div className='bg-card overflow-hidden rounded-2xl border shadow-xs'>
<div className='grid xl:grid-cols-[minmax(0,1fr)_19rem]'>
<div className='flex flex-col gap-3 p-4 sm:p-5'>
<div className='flex flex-col gap-2.5 p-3 sm:gap-3 sm:p-5'>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div className='flex flex-col gap-1'>
<h3 className='text-base font-semibold'>
<h3 className='text-sm font-semibold sm:text-base'>
{t('Usage at a glance')}
</h3>
<p className='text-muted-foreground text-sm'>
<p className='text-muted-foreground text-xs sm:text-sm'>
{t('Monitor balance, usage, and request volume')}
</p>
</div>
</div>
<StaggerContainer className='grid gap-3 md:grid-cols-3'>
<StaggerContainer className='grid grid-cols-3 gap-1.5 sm:gap-3'>
{items.map((it) => (
<StaggerItem
key={it.key}
className='bg-background/60 rounded-xl border p-3'
className='bg-background/60 rounded-lg border px-2 py-1.5 sm:rounded-xl sm:p-3'
>
<StatCard
title={it.title}
@@ -278,14 +278,15 @@ export function SummaryCards() {
sparkline={it.sparkline}
sparklineVariant={it.sparklineVariant}
loading={loading}
compactMobile
/>
</StaggerItem>
))}
</StaggerContainer>
</div>
<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 justify-between gap-3 border-t bg-[linear-gradient(135deg,color-mix(in_oklch,var(--overview-accent-2)_12%,var(--background))_0%,color-mix(in_oklch,oklch(0.82_0.04_155)_8%,var(--background))_48%,color-mix(in_oklch,var(--overview-accent-1)_7%,var(--background))_100%)] p-3 sm:gap-4 sm:p-5 xl:border-t-0 xl:border-l'>
<div className='flex flex-col gap-2 sm:gap-3'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-xs font-medium'>
{t('Credit remaining')}
@@ -301,7 +302,7 @@ export function SummaryCards() {
</span>
</div>
<div className='font-mono text-2xl font-semibold tracking-tight'>
<div className='font-mono text-xl font-semibold tracking-tight sm:text-2xl'>
{formatQuota(remainQuota)}
</div>
@@ -21,6 +21,7 @@ import { memo, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { IconBadge } from '@/components/ui/icon-badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import { getUptimeStatus } from '@/features/dashboard/api'
import type {
@@ -53,7 +54,7 @@ export function UptimePanel() {
useEffect(() => {
const abortController = new AbortController()
getUptimeStatus()
void getUptimeStatus()
.then((res) => {
if (abortController.signal.aborted) return
setGroups(res?.data || [])
@@ -77,7 +78,7 @@ export function UptimePanel() {
const abortController = new AbortController()
setRefreshing(true)
getUptimeStatus()
void getUptimeStatus()
.then((res) => {
if (abortController.signal.aborted) return
setGroups(res?.data || [])
@@ -97,7 +98,9 @@ export function UptimePanel() {
<PanelWrapper
title={
<span className='flex items-center gap-2'>
<Activity className='text-muted-foreground/60 size-4' />
<IconBadge tone='success' size='sm'>
<Activity />
</IconBadge>
{t('Uptime')}
</span>
}
@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import type { LucideIcon } from 'lucide-react'
import { useId, type ReactNode } from 'react'
import { IconBadge, type IconBadgeTone } from '@/components/ui/icon-badge'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
@@ -49,6 +50,8 @@ interface StatCardProps {
loading?: boolean
error?: boolean
action?: ReactNode
iconTone?: IconBadgeTone
compactMobile?: boolean
}
const TONE_CLASSES: Record<StatCardTone, string> = {
@@ -66,6 +69,12 @@ const LINE_TONE_CLASSES: Record<StatCardTone, string> = {
'accent-3': 'text-overview-accent-3',
}
const ICON_TONE_BY_STAT_TONE: Record<StatCardTone, IconBadgeTone> = {
'accent-1': 'chart-1',
'accent-2': 'chart-2',
'accent-3': 'chart-3',
}
const DETAIL_TONE_CLASSES: Record<StatCardDetailTone, string> = {
default: 'text-foreground',
muted: 'text-muted-foreground',
@@ -225,13 +234,24 @@ function StatCardDetails(props: { details: StatCardDetail[] }) {
export function StatCard(props: StatCardProps) {
const Icon = props.icon
const tone = props.tone ?? 'accent-3'
const iconTone = props.iconTone ?? ICON_TONE_BY_STAT_TONE[tone]
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
className={cn(
'flex flex-col',
props.compactMobile ? 'gap-1' : 'gap-1.5'
)}
>
<Skeleton className='h-5 w-16 sm:h-7 sm:w-24' />
<Skeleton
className={cn(
'h-3 w-24 sm:h-3.5 sm:w-32',
props.compactMobile && 'hidden sm:block'
)}
/>
</div>
)
} else if (props.error) {
@@ -240,16 +260,28 @@ export function StatCard(props: StatCardProps) {
<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>
<p
className={cn(
'text-muted-foreground/60 line-clamp-1 text-[11px] sm:text-xs',
props.compactMobile && 'hidden sm:block'
)}
>
{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'>
<div className='text-foreground font-mono text-base font-semibold tracking-tight break-all tabular-nums sm:text-2xl'>
{props.value}
</div>
<p className='text-muted-foreground/60 text-xs leading-relaxed'>
<p
className={cn(
'text-muted-foreground/60 line-clamp-1 text-[11px] leading-relaxed sm:text-xs',
props.compactMobile && 'hidden sm:block'
)}
>
{props.description}
</p>
</div>
@@ -266,21 +298,34 @@ export function StatCard(props: StatCardProps) {
}
return (
<div className='group flex min-h-32 flex-col justify-between gap-3'>
<div
className={cn(
'group flex flex-col justify-between sm:min-h-32 sm:gap-3',
props.compactMobile ? 'gap-1' : 'gap-1.5'
)}
>
<div className='flex items-start justify-between gap-1'>
<div className='text-muted-foreground flex items-center gap-1.5 text-xs font-medium sm:gap-2'>
<Icon
className='text-muted-foreground/60 size-3.5 shrink-0'
aria-hidden='true'
/>
<span className='line-clamp-2 leading-snug'>{props.title}</span>
<div className='text-muted-foreground flex items-center gap-1 text-[11px] font-medium sm:gap-2 sm:text-xs'>
<IconBadge
tone={iconTone}
size='stat'
className={cn(
props.compactMobile &&
'size-4 rounded-sm [&>svg]:size-2.5 sm:size-7 sm:rounded-md sm:[&>svg]:size-3.5'
)}
>
<Icon />
</IconBadge>
<span className='line-clamp-1 leading-snug sm:line-clamp-2'>
{props.title}
</span>
</div>
{props.action && <div className='shrink-0'>{props.action}</div>}
</div>
{valueContent}
{visualization}
<div className='hidden sm:block'>{visualization}</div>
</div>
)
}
@@ -22,6 +22,7 @@ import { Users, Loader2 } from 'lucide-react'
import { useEffect, useMemo, useState, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { IconBadge } from '@/components/ui/icon-badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useTheme } from '@/context/theme-provider'
@@ -230,7 +231,9 @@ export function UserCharts(props: UserChartsProps) {
className='overflow-hidden rounded-lg border'
>
<div className='flex w-full items-center gap-2 border-b px-3 py-2 sm:px-5 sm:py-3'>
<Users className='text-muted-foreground/60 size-4' />
<IconBadge tone='info' size='sm'>
<Users />
</IconBadge>
<div className='text-sm font-semibold'>{t(chart.labelKey)}</div>
</div>
@@ -29,6 +29,7 @@ import {
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { IconBadgeTone } from '@/components/ui/icon-badge'
import { safeDivide } from '@/features/dashboard/lib'
interface StatCardConfig {
@@ -36,6 +37,7 @@ interface StatCardConfig {
title: string
description: string
icon: LucideIcon
iconTone: IconBadgeTone
getValue: (stat: Record<string, number>, days?: number) => number
}
@@ -48,6 +50,7 @@ export function useModelStatCardsConfig(): StatCardConfig[] {
title: t('Total Count'),
description: t('Statistical count'),
icon: Hash,
iconTone: 'info',
getValue: (stat) => stat?.rpm ?? 0,
},
{
@@ -55,6 +58,7 @@ export function useModelStatCardsConfig(): StatCardConfig[] {
title: t('Total Quota'),
description: t('Statistical quota'),
icon: Coins,
iconTone: 'success',
getValue: (stat) => stat?.quota ?? 0,
},
{
@@ -62,6 +66,7 @@ export function useModelStatCardsConfig(): StatCardConfig[] {
title: t('Total Tokens'),
description: t('Statistical tokens'),
icon: Layers,
iconTone: 'chart-4',
getValue: (stat) => stat?.tpm ?? 0,
},
{
@@ -69,6 +74,7 @@ export function useModelStatCardsConfig(): StatCardConfig[] {
title: t('Average RPM'),
description: t('Requests per minute'),
icon: Gauge,
iconTone: 'chart-2',
getValue: (stat, timeRangeMinutes = 1) =>
safeDivide(stat?.rpm ?? 0, timeRangeMinutes),
},
@@ -77,6 +83,7 @@ export function useModelStatCardsConfig(): StatCardConfig[] {
title: t('Average TPM'),
description: t('Tokens per minute'),
icon: Zap,
iconTone: 'warning',
getValue: (stat, timeRangeMinutes = 1) =>
safeDivide(stat?.tpm ?? 0, timeRangeMinutes),
},
+42 -14
View File
@@ -32,6 +32,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { ROLE } from '@/lib/roles'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth-store'
import { ModelsChartPreferences } from './components/models/models-chart-preferences'
@@ -50,15 +51,32 @@ import {
DASHBOARD_DEFAULT_SECTION,
DASHBOARD_SECTION_IDS,
} from './section-registry'
import {
type DashboardChartPreferences,
type DashboardFilters,
type QuotaDataItem,
type UserChartsFilters,
import type {
DashboardChartPreferences,
DashboardFilters,
QuotaDataItem,
UserChartsFilters,
} from './types'
const route = getRouteApi('/_authenticated/dashboard/$section')
const LOG_STAT_CARD_FALLBACK_KEYS = [
'count',
'quota',
'tokens',
'average-rpm',
'average-tpm',
] as const
const PERFORMANCE_METRIC_FALLBACK_KEYS = [
'success-rate',
'average-latency',
'throughput',
] as const
const PERFORMANCE_MODEL_FALLBACK_KEYS = [
'primary-model',
'secondary-model',
] as const
const LazyLogStatCards = lazy(() =>
import('./components/models/log-stat-cards').then((m) => ({
default: m.LogStatCards,
@@ -99,11 +117,21 @@ function LogStatCardsFallback() {
return (
<div className='overflow-hidden rounded-lg border'>
<div className='divide-border/60 grid grid-cols-2 divide-x sm:grid-cols-3 lg:grid-cols-5'>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className='px-4 py-3.5 sm:px-5 sm:py-4'>
<Skeleton className='h-3.5 w-16' />
<Skeleton className='mt-2 h-7 w-20' />
<Skeleton className='mt-1.5 h-3.5 w-28' />
{LOG_STAT_CARD_FALLBACK_KEYS.map((key, index) => (
<div
key={key}
className={cn(
'px-2.5 py-1.5 sm:px-5 sm:py-4',
index === LOG_STAT_CARD_FALLBACK_KEYS.length - 1 &&
'col-span-2 sm:col-span-1'
)}
>
<div className='flex items-center gap-1.5 sm:gap-2'>
<Skeleton className='size-4 rounded-sm sm:size-7 sm:rounded-md' />
<Skeleton className='h-4 w-16' />
</div>
<Skeleton className='mt-1 h-5 w-16 sm:mt-2 sm:h-7 sm:w-20' />
<Skeleton className='mt-1 hidden h-3.5 w-28 md:block' />
</div>
))}
</div>
@@ -132,15 +160,15 @@ function PerformanceOverviewFallback() {
<div className='flex items-center gap-2'>
<Skeleton className='h-4 w-24' />
</div>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className='flex items-center gap-1.5'>
{PERFORMANCE_METRIC_FALLBACK_KEYS.map((key) => (
<div key={key} className='flex items-center gap-1.5'>
<Skeleton className='h-3 w-14' />
<Skeleton className='h-4 w-16' />
</div>
))}
<div className='ml-auto flex items-center gap-2'>
{Array.from({ length: 2 }).map((_, i) => (
<Skeleton key={i} className='h-5 w-28 rounded-full' />
{PERFORMANCE_MODEL_FALLBACK_KEYS.map((key) => (
<Skeleton key={key} className='h-5 w-28 rounded-full' />
))}
</div>
</div>
@@ -0,0 +1,68 @@
/*
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 {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { formatTimestampRelative, formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
interface ApiKeyTimestampCellProps {
timestamp: number
now: number
locale?: string
justNowLabel: string
className?: string
}
export function ApiKeyTimestampCell(props: ApiKeyTimestampCellProps) {
if (!props.timestamp || props.timestamp === -1) {
return <span className='text-muted-foreground text-xs'>-</span>
}
const timestampMs = props.timestamp * 1000
const isJustNow = timestampMs <= props.now && props.now - timestampMs < 60_000
const relativeTime = isJustNow
? props.justNowLabel
: formatTimestampRelative(props.timestamp, 'seconds', props.locale)
const absoluteTime = formatTimestampToDate(props.timestamp)
return (
<Tooltip>
<TooltipTrigger
render={
<time
dateTime={new Date(timestampMs).toISOString()}
tabIndex={0}
className={cn(
'block truncate font-mono text-xs tabular-nums',
props.className
)}
/>
}
>
{relativeTime}
</TooltipTrigger>
<TooltipContent>
<span className='font-mono tabular-nums'>{absoluteTime}</span>
</TooltipContent>
</Tooltip>
)
}
+19 -33
View File
@@ -19,7 +19,6 @@ For commercial licensing, please contact support@quantumnous.com
import { Check, Copy, Loader2 } from 'lucide-react'
import { useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { BadgeCell } from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge'
@@ -36,7 +35,7 @@ import {
} from '@/components/ui/tooltip'
import { copyToClipboard } from '@/lib/copy-to-clipboard'
import { type ApiKey } from '../types'
import type { ApiKey } from '../types'
import { useApiKeys } from './api-keys-provider'
export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
@@ -66,17 +65,22 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
)
const handleCopy = useCallback(async () => {
const realKey = resolvedFullKey
if (!realKey) {
void resolveRealKey(apiKey.id)
toast.info(t('API key is loading, please try again in a moment'))
return
}
if (realKey) {
const ok = await copyToClipboard(realKey)
if (ok) markKeyCopied(apiKey.id)
}
}, [resolvedFullKey, resolveRealKey, apiKey.id, markKeyCopied, t])
const realKey = resolvedFullKey || (await resolveRealKey(apiKey.id))
if (!realKey) return
const ok = await copyToClipboard(realKey)
if (ok) markKeyCopied(apiKey.id)
}, [resolvedFullKey, resolveRealKey, apiKey.id, markKeyCopied])
let copyIcon = <Copy className='size-3.5' />
let copyTooltip = t('Copy API key')
if (isLoading) {
copyIcon = <Loader2 className='size-3.5 animate-spin' />
copyTooltip = t('Loading...')
} else if (isCopied) {
copyIcon = <Check className='size-3.5 text-green-600' />
copyTooltip = t('Copied!')
}
return (
<div className='flex max-w-full min-w-0 items-center'>
@@ -125,31 +129,13 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
size='icon'
className='size-7 shrink-0'
onClick={handleCopy}
onFocus={() => {
if (!resolvedFullKey) void resolveRealKey(apiKey.id)
}}
onPointerEnter={() => {
if (!resolvedFullKey) void resolveRealKey(apiKey.id)
}}
disabled={isLoading}
/>
}
>
{isLoading ? (
<Loader2 className='size-3.5 animate-spin' />
) : isCopied ? (
<Check className='size-3.5 text-green-600' />
) : (
<Copy className='size-3.5' />
)}
{copyIcon}
</TooltipTrigger>
<TooltipContent>
{isLoading
? t('Loading...')
: isCopied
? t('Copied!')
: t('Copy API key')}
</TooltipContent>
<TooltipContent>{copyTooltip}</TooltipContent>
</Tooltip>
</div>
)
+35 -20
View File
@@ -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 { useQuery } from '@tanstack/react-query'
import { type ColumnDef } from '@tanstack/react-table'
import type { ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import { BadgeCell, TruncatedCell } from '@/components/data-table'
@@ -30,12 +30,15 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { toIntlLocale } from '@/i18n/languages'
import { getUserGroups } from '@/lib/api'
import { formatQuota, formatTimestampToDate } from '@/lib/format'
import dayjs from '@/lib/dayjs'
import { formatQuota } from '@/lib/format'
import { cn } from '@/lib/utils'
import { API_KEY_STATUSES } from '../constants'
import { type ApiKey } from '../types'
import type { ApiKey } from '../types'
import { ApiKeyTimestampCell } from './api-key-timestamp-cell'
import {
ApiKeyCell,
ModelLimitsCell,
@@ -69,9 +72,12 @@ function useGroupRatios(): Record<string, number> {
return data ?? {}
}
export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
const { t } = useTranslation()
export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
const { t, i18n } = useTranslation()
const groupRatios = useGroupRatios()
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
const justNowLabel = t('Just now')
const staleAccessThreshold = dayjs(now).subtract(3, 'month').valueOf()
return [
{
id: 'select',
@@ -257,9 +263,13 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
accessorKey: 'created_time',
header: t('Created'),
cell: ({ row }) => (
<span className='text-muted-foreground block truncate font-mono text-xs tabular-nums'>
{formatTimestampToDate(row.getValue('created_time'))}
</span>
<ApiKeyTimestampCell
timestamp={row.getValue('created_time')}
now={now}
locale={locale}
justNowLabel={justNowLabel}
className='text-muted-foreground'
/>
),
size: 180,
meta: { mobileHidden: true },
@@ -269,13 +279,17 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
header: t('Last Used'),
cell: ({ row }) => {
const accessedTime = row.getValue('accessed_time') as number
if (!accessedTime) {
return <span className='text-muted-foreground text-xs'>-</span>
}
const isStale =
accessedTime > 0 && accessedTime * 1000 < staleAccessThreshold
return (
<span className='text-muted-foreground block truncate font-mono text-xs tabular-nums'>
{formatTimestampToDate(accessedTime)}
</span>
<ApiKeyTimestampCell
timestamp={accessedTime}
now={now}
locale={locale}
justNowLabel={justNowLabel}
className={isStale ? 'text-warning' : 'text-muted-foreground'}
/>
)
},
size: 180,
@@ -296,16 +310,17 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
/>
)
}
const isExpired = expiredTime * 1000 < Date.now()
const isExpired = expiredTime * 1000 < now
return (
<span
<ApiKeyTimestampCell
timestamp={expiredTime}
now={now}
locale={locale}
justNowLabel={justNowLabel}
className={cn(
'block truncate font-mono text-xs tabular-nums',
isExpired ? 'text-destructive' : 'text-muted-foreground'
)}
>
{formatTimestampToDate(expiredTime)}
</span>
/>
)
},
size: 180,
@@ -76,7 +76,7 @@ import {
transformFormDataToPayload,
transformApiKeyToFormDefaults,
} from '../lib'
import { type ApiKey } from '../types'
import type { ApiKey } from '../types'
import {
ApiKeyGroupCombobox,
type ApiKeyGroupOption,
@@ -139,7 +139,7 @@ export function ApiKeysMutateDrawer({
// Load existing data when updating
useEffect(() => {
if (open && isUpdate && currentRow) {
getApiKey(currentRow.id).then((result) => {
void getApiKey(currentRow.id).then((result) => {
if (result.success && result.data) {
form.reset(transformApiKeyToFormDefaults(result.data))
}
@@ -215,7 +215,7 @@ export function ApiKeysMutateDrawer({
triggerRefresh()
}
}
} catch (_error) {
} catch {
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
} finally {
setIsSubmitting(false)
@@ -284,6 +284,7 @@ export function ApiKeysMutateDrawer({
title={t('Basic Information')}
description={t('Set API key basic information')}
icon={<KeyRound className='size-4' />}
iconTone='info'
/>
<FormField
control={form.control}
@@ -418,7 +419,9 @@ export function ApiKeysMutateDrawer({
min='1'
placeholder={t('Number of keys to create')}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 1)
field.onChange(
Number.parseInt(e.target.value, 10) || 1
)
}
/>
</FormControl>
@@ -439,6 +442,7 @@ export function ApiKeysMutateDrawer({
title={t('Quota Settings')}
description={t('Set quota amount and limits')}
icon={<WalletCards className='size-4' />}
iconTone='success'
/>
{!unlimitedQuota && (
<FormField
@@ -454,7 +458,9 @@ export function ApiKeysMutateDrawer({
step={tokensOnly ? 1 : 0.01}
placeholder={quotaPlaceholder}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
field.onChange(
Number.parseFloat(e.target.value) || 0
)
}
/>
</FormControl>
+19 -5
View File
@@ -18,8 +18,9 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import { type Table as TanstackTable } from '@tanstack/react-table'
import type { Table as TanstackTable } from '@tanstack/react-table'
import { Database } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -51,7 +52,7 @@ import {
API_KEY_STATUSES,
ERROR_MESSAGES,
} from '../constants'
import { type ApiKey } from '../types'
import type { ApiKey } from '../types'
import { ApiKeyCell } from './api-keys-cells'
import { useApiKeysColumns } from './api-keys-columns'
import { useApiKeys } from './api-keys-provider'
@@ -60,6 +61,10 @@ import { DataTableRowActions } from './data-table-row-actions'
const route = getRouteApi('/_authenticated/keys/')
const API_KEYS_COLUMN_VISIBILITY_STORAGE_KEY = 'api-keys:column-visibility'
const API_KEYS_MOBILE_SKELETON_IDS = Array.from(
{ length: 5 },
(_, index) => `api-key-mobile-skeleton-${index + 1}`
)
function isDisabledApiKeyRow(apiKey: ApiKey) {
return apiKey.status !== API_KEY_STATUS.ENABLED
@@ -68,9 +73,9 @@ function isDisabledApiKeyRow(apiKey: ApiKey) {
function ApiKeysMobileSkeleton() {
return (
<div className='divide-border overflow-hidden rounded-lg border'>
{Array.from({ length: 5 }).map((_, index) => (
{API_KEYS_MOBILE_SKELETON_IDS.map((id) => (
<div
key={index}
key={id}
className='space-y-2 border-b px-3 py-2.5 last:border-b-0'
>
<div className='flex items-center justify-between'>
@@ -184,7 +189,16 @@ function ApiKeysMobileList({
export function ApiKeysTable() {
const { t } = useTranslation()
const { refreshTrigger } = useApiKeys()
const columns = useApiKeysColumns()
const [now, setNow] = useState(() => Date.now())
const columns = useApiKeysColumns(now)
useEffect(() => {
const intervalId = window.setInterval(() => {
setNow(Date.now())
}, 30_000)
return () => window.clearInterval(intervalId)
}, [])
const {
globalFilter,
@@ -23,6 +23,7 @@ import { useTranslation } from 'react-i18next'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { IconBadge } from '@/components/ui/icon-badge'
import {
Select,
SelectContent,
@@ -190,7 +191,9 @@ export function ViewLogsDialog({
onOpenChange={onOpenChange}
title={
<>
<Terminal className='h-5 w-5' />
<IconBadge tone='chart-3' size='sm'>
<Terminal />
</IconBadge>
{t('Deployment logs')}
</>
}
@@ -0,0 +1,54 @@
/*
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 { useTranslation } from 'react-i18next'
import { StatusBadge, type StatusVariant } from '@/components/status-badge'
import { isDynamicPricingModel } from '../lib/dynamic-price'
import { isTokenBasedModel } from '../lib/model-helpers'
import type { PricingModel } from '../types'
interface ModelBillingModeBadgeProps {
model: PricingModel
className?: string
}
export function ModelBillingModeBadge(props: ModelBillingModeBadgeProps) {
const { t } = useTranslation()
let label = t('Per Request')
let variant: StatusVariant = 'purple'
if (isDynamicPricingModel(props.model)) {
label = t('Dynamic Pricing')
variant = 'warning'
} else if (isTokenBasedModel(props.model)) {
label = t('Token-based')
variant = 'info'
}
return (
<StatusBadge
label={label}
variant={variant}
copyable={false}
size='sm'
className={props.className}
/>
)
}
+6 -16
View File
@@ -20,7 +20,6 @@ import { ChevronRight, Copy } from 'lucide-react'
import { memo, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { StatusBadge } from '@/components/status-badge'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { getLobeIcon } from '@/lib/lobe-icon'
import { cn } from '@/lib/utils'
@@ -34,6 +33,7 @@ import { parseTags } from '../lib/filters'
import { isTokenBasedModel } from '../lib/model-helpers'
import { formatPrice, formatRequestPrice } from '../lib/price'
import type { PricingModel, TokenUnit } from '../types'
import { ModelBillingModeBadge } from './model-billing-mode-badge'
import { ModelPerfBadge, type ModelPerfBadgeData } from './model-perf-badge'
export interface ModelCardProps {
@@ -159,9 +159,9 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
</span>
</span>
{hasCachedPrice && (
<span className='text-muted-foreground/60 whitespace-nowrap'>
<span className='text-muted-foreground whitespace-nowrap'>
{t('Cached')}{' '}
<span className='font-mono'>
<span className='text-foreground font-mono font-semibold'>
{formatPrice(
props.model,
'cache',
@@ -249,21 +249,11 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
<div className='mt-2 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 gap-y-1 sm:mt-4'>
<div className='flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1'>
{primaryGroup && (
<span className='text-muted-foreground text-xs font-medium'>
{primaryGroup} {t('Groups')}
<span className='text-muted-foreground text-sm font-medium'>
{primaryGroup}
</span>
)}
<span className='text-muted-foreground text-xs font-medium'>
{isTokenBased ? t('Token-based') : t('Per Request')}
</span>
{isDynamicPricing && (
<StatusBadge
label={t('Dynamic Pricing')}
variant='warning'
copyable={false}
size='sm'
/>
)}
<ModelBillingModeBadge model={props.model} />
</div>
<ModelPerfBadge perf={props.perf} className='row-span-2 self-start' />
+23 -38
View File
@@ -58,7 +58,7 @@ import {
import { getLobeIcon } from '@/lib/lobe-icon'
import { cn } from '@/lib/utils'
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
import { DEFAULT_TOKEN_UNIT } from '../constants'
import { usePricingData } from '../hooks/use-pricing-data'
import {
getDynamicPriceEntries,
@@ -76,6 +76,7 @@ import type {
TokenUnit,
} from '../types'
import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown'
import { ModelBillingModeBadge } from './model-billing-mode-badge'
import { ModelDetailsApi } from './model-details-api'
import { ModelDetailsPerformance } from './model-details-performance'
@@ -117,6 +118,7 @@ const MODALITY_LABEL_KEYS: Record<string, string> = {
const TOKEN_FORMAT = new Intl.NumberFormat(undefined, {
maximumFractionDigits: 1,
})
const MODEL_DETAILS_SKELETON_KEYS = ['first', 'second', 'third', 'fourth']
function formatCatalogTokenCount(tokens: number): string {
if (!Number.isFinite(tokens) || tokens <= 0) return ''
@@ -459,11 +461,7 @@ function ModelBackendProviderSection(props: { model: PricingModel }) {
cells.push(
<CatalogInfoCell key='type' label={t('Type')}>
<CatalogTextValue>
{model.quota_type === QUOTA_TYPE_VALUES.TOKEN
? t('Token-based')
: t('Per Request')}
</CatalogTextValue>
<ModelBillingModeBadge model={model} />
</CatalogInfoCell>
)
@@ -531,10 +529,6 @@ function ModelHeader(props: { model: PricingModel }) {
const modelIconKey = model.icon || model.vendor_icon
const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 20) : null
const description = model.description || model.vendor_description || null
const isSpecialExpression =
model.billing_mode === 'tiered_expr' &&
Boolean(model.billing_expr) &&
getDynamicPricingTiers(model).length === 0
return (
<header className='pb-4'>
@@ -557,21 +551,7 @@ function ModelHeader(props: { model: PricingModel }) {
<span className='text-muted-foreground'>{model.vendor_name}</span>
)}
<span className='text-muted-foreground/30'>·</span>
<span className='text-muted-foreground/70'>
{model.quota_type === QUOTA_TYPE_VALUES.TOKEN
? t('Token-based')
: t('Per Request')}
</span>
{model.billing_mode === 'tiered_expr' && model.billing_expr && (
<>
<span className='text-muted-foreground/30'>·</span>
<span className='rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-500/20 dark:text-amber-300'>
{isSpecialExpression
? t('Special billing expression')
: t('Dynamic Pricing')}
</span>
</>
)}
<ModelBillingModeBadge model={model} />
</div>
{description && (
<p className='text-muted-foreground mt-2 text-sm leading-relaxed'>
@@ -839,13 +819,13 @@ function getDynamicPriceFields(
tiers: DynamicPricingTier[],
options: DynamicPriceOptions
) {
return Array.from(
new Map(
return [
...new Map(
tiers
.flatMap((tier) => getDynamicPriceEntries(tier, options))
.map((entry) => [entry.field, entry])
).values()
)
).values(),
]
}
function getDynamicFormattedPricesByTier(
@@ -892,19 +872,24 @@ function GroupPricingSection(props: {
const extraPriceTypes = useMemo(() => {
const types: { label: string; type: PriceType }[] = []
if (props.model.cache_ratio != null)
if (props.model.cache_ratio != null) {
types.push({ label: t('Cache'), type: 'cache' })
if (props.model.create_cache_ratio != null)
}
if (props.model.create_cache_ratio != null) {
types.push({ label: t('Cache Write'), type: 'create_cache' })
if (props.model.image_ratio != null)
}
if (props.model.image_ratio != null) {
types.push({ label: t('Image'), type: 'image' })
if (props.model.audio_ratio != null)
}
if (props.model.audio_ratio != null) {
types.push({ label: t('Audio In'), type: 'audio_input' })
}
if (
props.model.audio_ratio != null &&
props.model.audio_completion_ratio != null
)
) {
types.push({ label: t('Audio Out'), type: 'audio_output' })
}
return types
}, [props.model, t])
@@ -1299,13 +1284,13 @@ export function ModelDetails() {
<Skeleton className='h-4 w-full max-w-md' />
</div>
<div className='mt-6 grid grid-cols-2 gap-2 sm:grid-cols-4'>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className='h-16 w-full' />
{MODEL_DETAILS_SKELETON_KEYS.map((key) => (
<Skeleton key={`metric-${key}`} className='h-16 w-full' />
))}
</div>
<div className='mt-6 space-y-3'>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className='h-24 w-full' />
{MODEL_DETAILS_SKELETON_KEYS.map((key) => (
<Skeleton key={`section-${key}`} className='h-24 w-full' />
))}
</div>
</div>
@@ -28,7 +28,7 @@ import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge'
import { getLobeIcon } from '@/lib/lobe-icon'
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
import { DEFAULT_TOKEN_UNIT } from '../constants'
import {
getDynamicDisplayGroupRatio,
getDynamicPricingSummary,
@@ -41,6 +41,7 @@ import {
stripTrailingZeros,
} from '../lib/price'
import type { PricingModel, TokenUnit } from '../types'
import { ModelBillingModeBadge } from './model-billing-mode-badge'
// ----------------------------------------------------------------------------
// Pricing Table Columns
@@ -97,18 +98,10 @@ export function usePricingColumns(
{
accessorKey: 'quota_type',
header: t('Type'),
cell: ({ row }) => {
const isTokenBased = row.original.quota_type === QUOTA_TYPE_VALUES.TOKEN
return (
<StatusBadge
label={isTokenBased ? t('Token') : t('Request')}
variant={isTokenBased ? 'info' : 'neutral'}
copyable={false}
className='-ml-1.5'
/>
)
},
size: 80,
cell: ({ row }) => (
<ModelBillingModeBadge model={row.original} className='-ml-1.5' />
),
size: 110,
enableSorting: false,
},
@@ -115,7 +115,7 @@ function FilterChip(props: {
{(props.option.suffix || props.option.count != null) && (
<span
className={cn(
'rounded-md px-1.5 py-0.5 text-[10px]',
'rounded-md px-1.5 py-0.5 text-[12px]',
props.active
? 'bg-background text-foreground'
: 'bg-muted text-muted-foreground'
@@ -33,6 +33,7 @@ import { Dialog } from '@/components/dialog'
import { Turnstile } from '@/components/turnstile'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { IconBadge } from '@/components/ui/icon-badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Tooltip,
@@ -162,7 +163,7 @@ export function CheckinCalendarCard({
}
toast.error(res.message || t('Check-in failed'))
}
} catch (_error) {
} catch {
toast.error(t('Check-in failed'))
} finally {
setCheckinLoading(false)
@@ -241,6 +242,13 @@ export function CheckinCalendarCard({
)
}
let checkinButtonLabel = t('Check in now')
if (checkinLoading) {
checkinButtonLabel = t('Loading...')
} else if (checkedToday) {
checkinButtonLabel = t('Checked in')
}
return (
<TooltipProvider delay={100}>
<Dialog
@@ -282,12 +290,12 @@ export function CheckinCalendarCard({
className='flex min-w-0 flex-1 items-start gap-3 rounded-lg text-left whitespace-normal outline-none'
onClick={() => setCollapsed((v) => !v)}
>
<div className='bg-primary/10 text-primary flex h-10 w-10 shrink-0 items-center justify-center rounded-xl sm:h-11 sm:w-11'>
<IconBadge tone='neutral' size='lg' className='sm:size-11'>
<CalendarDays
className='h-4 w-4 sm:h-5 sm:w-5'
strokeWidth={2}
/>
</div>
</IconBadge>
<div className='min-w-0 flex-1'>
<div className='flex flex-wrap items-center gap-1.5 sm:gap-2'>
<h3 className='text-base font-semibold tracking-tight sm:text-lg'>
@@ -320,11 +328,7 @@ export function CheckinCalendarCard({
size='sm'
className='w-full shrink-0 sm:w-auto'
>
{checkinLoading
? t('Loading...')
: checkedToday
? t('Checked in')
: t('Check in now')}
{checkinButtonLabel}
</Button>
</div>
</div>
@@ -405,7 +409,7 @@ export function CheckinCalendarCard({
))}
{/* Calendar days */}
{calendarDays.map((dayObj, idx) => {
{calendarDays.map((dayObj) => {
const dateStr = `${dayObj.date.getFullYear()}-${String(
dayObj.date.getMonth() + 1
).padStart(2, '0')}-${String(
@@ -418,7 +422,7 @@ export function CheckinCalendarCard({
const dayButton = (
<Button
key={idx}
key={dateStr}
variant={isToday ? 'default' : 'ghost'}
disabled={!dayObj.isCurrentMonth}
className={cn(
@@ -430,15 +434,15 @@ export function CheckinCalendarCard({
>
<span className='tabular-nums'>{dayNum}</span>
{isCheckedIn && !isToday && (
<span className='absolute bottom-0.5 h-1 w-1 rounded-full bg-emerald-500 sm:bottom-1' />
<span className='bg-success absolute bottom-0.5 size-1 rounded-full sm:bottom-1' />
)}
</Button>
)
if (isCheckedIn && dayObj.isCurrentMonth) {
return (
<Tooltip key={idx}>
<TooltipTrigger render={dayButton}></TooltipTrigger>
<Tooltip key={dateStr}>
<TooltipTrigger render={dayButton} />
<TooltipContent>
<div className='text-xs'>
<div className='font-medium'>
@@ -93,7 +93,7 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
props.onProfileUpdate()
toast.success(t('Language preference saved'))
} catch (_error) {
} catch {
setCurrentLanguage(previousLanguage)
await i18n.changeLanguage(previousLanguage)
toast.error(t('Failed to update settings'))
@@ -107,6 +107,7 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
title={t('Language Preferences')}
description={t('Set the language used across the interface')}
icon={<Languages className='h-4 w-4' />}
iconTone='chart-4'
disableHoverEffect
>
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4'>
@@ -120,12 +121,10 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
</div>
<div className='flex items-center gap-2 sm:min-w-48'>
<Select
items={[
...INTERFACE_LANGUAGE_OPTIONS.map((language) => ({
value: language.code,
label: language.label,
})),
]}
items={INTERFACE_LANGUAGE_OPTIONS.map((language) => ({
value: language.code,
label: language.label,
}))}
value={currentLanguage}
onValueChange={handleLanguageChange}
disabled={saving}
@@ -41,6 +41,7 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { IconBadge } from '@/components/ui/icon-badge'
import { Skeleton } from '@/components/ui/skeleton'
import { usePasskeyManagement } from '@/features/auth/passkey'
import {
@@ -242,9 +243,9 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
<div className='space-y-6'>
<div className='flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between xl:flex-col 2xl:flex-row'>
<div className='flex items-start gap-4'>
<div className='bg-muted rounded-md p-2'>
<KeyRound className='h-5 w-5' />
</div>
<IconBadge tone='info' size='sm'>
<KeyRound />
</IconBadge>
<div className='space-y-1'>
<div className='flex flex-wrap items-center gap-2'>
<p className='font-medium'>{t('Passkey Authentication')}</p>
@@ -22,6 +22,7 @@ import { useTranslation } from 'react-i18next'
import { StatusBadge } from '@/components/status-badge'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Card, CardContent } from '@/components/ui/card'
import { IconBadge, type IconBadgeTone } from '@/components/ui/icon-badge'
import { Skeleton } from '@/components/ui/skeleton'
import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
import { formatCompactNumber, formatQuota } from '@/lib/format'
@@ -63,8 +64,8 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
</CardContent>
<div className='border-t'>
<div className='divide-border/60 grid grid-cols-1 divide-y sm:grid-cols-3 sm:divide-x sm:divide-y-0'>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className='px-4 py-3.5 sm:px-5 sm:py-4'>
{['balance', 'usage', 'requests'].map((key) => (
<div key={key} className='px-4 py-3.5 sm:px-5 sm:py-4'>
<Skeleton className='h-3.5 w-20' />
<Skeleton className='mt-2 h-7 w-28' />
<Skeleton className='mt-1.5 h-3.5 w-24' />
@@ -83,24 +84,33 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
const avatarFallback = getUserAvatarFallback(avatarName)
const avatarFallbackStyle = getUserAvatarStyle(avatarName)
const roleLabel = getRoleLabel(profile.role)
const stats = [
const stats: {
label: string
value: string
description: string
icon: typeof WalletCards
tone: IconBadgeTone
}[] = [
{
label: t('Current Balance'),
value: formatQuota(profile.quota),
description: t('Remaining quota'),
icon: WalletCards,
tone: 'success',
},
{
label: t('Total Usage'),
value: formatQuota(profile.used_quota),
description: t('Total consumed quota'),
icon: BarChart3,
tone: 'info',
},
{
label: t('API Requests'),
value: formatCompactNumber(profile.request_count),
description: t('Total requests made'),
icon: Activity,
tone: 'chart-4',
},
]
@@ -157,7 +167,9 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
{stats.map((item) => (
<div key={item.label} className='min-w-0 px-3 py-3 sm:px-5 sm:py-4'>
<div className='flex items-center gap-2'>
<item.icon className='text-muted-foreground/60 size-3.5 shrink-0' />
<IconBadge tone={item.tone} size='stat'>
<item.icon />
</IconBadge>
<div className='text-muted-foreground truncate text-xs font-medium tracking-wider uppercase'>
{item.label}
</div>
@@ -20,6 +20,7 @@ import { Shield, Key, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { IconBadge } from '@/components/ui/icon-badge'
import { Skeleton } from '@/components/ui/skeleton'
import { TitledCard } from '@/components/ui/titled-card'
import { useDialogs } from '@/hooks/use-dialog'
@@ -55,8 +56,8 @@ export function ProfileSecurityCard({
<Skeleton className='mt-2 h-4 w-48' />
</CardHeader>
<CardContent className='space-y-3 p-3 sm:p-5'>
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className='h-16 w-full' />
{['password', 'token', 'delete'].map((key) => (
<Skeleton key={key} className='h-16 w-full' />
))}
</CardContent>
</Card>
@@ -95,6 +96,7 @@ export function ProfileSecurityCard({
title={t('Security')}
description={t('Manage your security settings and account access')}
icon={<Shield className='h-4 w-4' />}
iconTone='success'
disableHoverEffect
>
<div className='grid grid-cols-1 gap-2.5 sm:gap-3 md:grid-cols-3'>
@@ -107,15 +109,9 @@ export function ProfileSecurityCard({
item.variant === 'destructive' ? 'border-destructive/30' : ''
}`}
>
<div
className={`rounded-md p-2 ${
item.variant === 'destructive'
? 'bg-destructive/10 text-destructive'
: 'bg-muted'
}`}
>
<item.icon className='h-5 w-5' />
</div>
<IconBadge tone='neutral' size='sm'>
<item.icon />
</IconBadge>
<div className='min-w-0 md:contents'>
<p className='text-sm font-medium'>{item.title}</p>
<p className='text-muted-foreground line-clamp-1 text-xs md:line-clamp-none'>
@@ -56,8 +56,8 @@ export function ProfileSettingsCard({
</CardHeader>
<CardContent className='space-y-4 p-3 sm:p-5'>
<Skeleton className='h-10 w-full' />
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className='h-20 w-full' />
{['bindings', 'preferences', 'notifications'].map((key) => (
<Skeleton key={key} className='h-20 w-full' />
))}
</CardContent>
</Card>
@@ -69,6 +69,7 @@ export function ProfileSettingsCard({
title={t('Settings')}
description={t('Configure your account preferences and integrations')}
icon={<Settings className='h-4 w-4' />}
iconTone='info'
disableHoverEffect
>
<Tabs value={activeTab} onValueChange={setActiveTab}>
@@ -29,6 +29,7 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { IconBadge } from '@/components/ui/icon-badge'
import { Switch } from '@/components/ui/switch'
import { api } from '@/lib/api'
import { useAuthStore } from '@/stores/auth-store'
@@ -204,9 +205,9 @@ export function SidebarModulesCard() {
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='border-b p-3 !pb-3 sm:p-5 sm:!pb-5'>
<div className='flex items-center gap-3'>
<div className='bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-lg sm:h-9 sm:w-9'>
<LayoutDashboard className='h-4 w-4' />
</div>
<IconBadge tone='info' size='title'>
<LayoutDashboard />
</IconBadge>
<div className='min-w-0'>
<CardTitle className='text-lg tracking-tight sm:text-xl'>
{t('Sidebar Personal Settings')}
@@ -28,6 +28,7 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { IconBadge } from '@/components/ui/icon-badge'
import { Skeleton } from '@/components/ui/skeleton'
import { useDialogs } from '@/hooks/use-dialog'
@@ -82,9 +83,9 @@ export function TwoFACard({ loading: pageLoading }: TwoFACardProps) {
{/* Status Section */}
<div className='flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between xl:flex-col 2xl:flex-row'>
<div className='flex items-start gap-4'>
<div className='bg-muted rounded-md p-2'>
<Shield className='h-5 w-5' />
</div>
<IconBadge tone='success' size='sm'>
<Shield />
</IconBadge>
<div className='space-y-1'>
<div className='flex items-center gap-2'>
<p className='font-medium'>{t('Two-Step Verification')}</p>
@@ -0,0 +1,175 @@
/*
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 type { Table as TanstackTable } from '@tanstack/react-table'
import { Database } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { DISABLED_ROW_MOBILE } from '@/components/data-table'
import { MaskedValueDisplay } from '@/components/masked-value-display'
import { StatusBadge } from '@/components/status-badge'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton'
import { formatQuota } from '@/lib/format'
import { cn } from '@/lib/utils'
import { REDEMPTION_STATUS, REDEMPTION_STATUSES } from '../constants'
import { isRedemptionExpired } from '../lib'
import type { Redemption } from '../types'
import { DataTableRowActions } from './data-table-row-actions'
const MOBILE_SKELETON_KEYS = [
'redemption-mobile-skeleton-1',
'redemption-mobile-skeleton-2',
'redemption-mobile-skeleton-3',
'redemption-mobile-skeleton-4',
'redemption-mobile-skeleton-5',
]
function RedemptionsMobileSkeleton() {
return (
<div className='divide-border overflow-hidden rounded-lg border'>
{MOBILE_SKELETON_KEYS.map((key) => (
<div
key={key}
className='space-y-2 border-b px-3 py-2.5 last:border-b-0'
>
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-32' />
<Skeleton className='h-5 w-16 rounded-md' />
</div>
<div className='flex items-center justify-between gap-3'>
<Skeleton className='h-7 w-44' />
<Skeleton className='h-8 w-16' />
</div>
<Skeleton className='h-3 w-28' />
</div>
))}
</div>
)
}
interface RedemptionsMobileListProps {
table: TanstackTable<Redemption>
isLoading: boolean
}
export function RedemptionsMobileList(props: RedemptionsMobileListProps) {
const { t } = useTranslation()
const rows = props.table.getRowModel().rows
if (props.isLoading) return <RedemptionsMobileSkeleton />
if (!rows.length) {
return (
<div className='rounded-lg border p-8'>
<Empty className='border-none p-0'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Database className='size-6' />
</EmptyMedia>
<EmptyTitle>{t('No Redemption Codes Found')}</EmptyTitle>
<EmptyDescription>
{t(
'No redemption codes available. Create your first redemption code to get started.'
)}
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
)
}
return (
<div className='divide-border overflow-hidden rounded-lg border'>
{rows.map((row) => {
const redemption = row.original
const expired = isRedemptionExpired(
redemption.expired_time,
redemption.status
)
const statusConfig = REDEMPTION_STATUSES[redemption.status]
const maskedKey = `${redemption.key.slice(0, 8)}******${redemption.key.slice(-8)}`
return (
<div
key={row.id}
className={cn(
'bg-card space-y-2.5 border-b px-3 py-2.5 last:border-b-0',
expired || redemption.status !== REDEMPTION_STATUS.ENABLED
? DISABLED_ROW_MOBILE
: undefined
)}
>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<div className='truncate text-sm font-semibold'>
{redemption.name}
</div>
<div className='text-muted-foreground text-[11px]'>
{t('Redemption Code')}
</div>
</div>
{expired ? (
<StatusBadge
label={t('Expired')}
variant='warning'
copyable={false}
/>
) : (
statusConfig && (
<StatusBadge
label={t(statusConfig.labelKey)}
variant={statusConfig.variant}
copyable={false}
/>
)
)}
</div>
<div className='flex min-w-0 items-center justify-between gap-2'>
<div className='min-w-0 flex-1 [&_button:first-child]:max-w-full [&_button:first-child]:truncate [&_button:first-child]:px-0'>
<MaskedValueDisplay
label={t('Full Code')}
fullValue={redemption.key}
maskedValue={maskedKey}
copyTooltip={t('Copy code')}
copyAriaLabel={t('Copy redemption code')}
/>
</div>
<DataTableRowActions row={row} />
</div>
<div className='flex items-center justify-between gap-2 text-xs'>
<span className='text-muted-foreground'>{t('Quota')}</span>
<span className='font-medium tabular-nums'>
{formatQuota(redemption.quota)}
</span>
</div>
</div>
)
})}
</div>
)
}
@@ -41,6 +41,7 @@ import { isRedemptionExpired } from '../lib'
import type { Redemption } from '../types'
import { DataTableBulkActions } from './data-table-bulk-actions'
import { useRedemptionsColumns } from './redemptions-columns'
import { RedemptionsMobileList } from './redemptions-mobile-list'
import { useRedemptions } from './redemptions-provider'
const route = getRouteApi('/_authenticated/redemption-codes/')
@@ -179,6 +180,7 @@ export function RedemptionsTable() {
},
],
}}
mobile={<RedemptionsMobileList table={table} isLoading={isLoading} />}
getRowClassName={(row, { isMobile }) => {
if (!isDisabledRedemptionRow(row.original)) return undefined
return isMobile ? DISABLED_ROW_MOBILE : DISABLED_ROW_DESKTOP
@@ -41,6 +41,7 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { IconBadge } from '@/components/ui/icon-badge'
import { Input } from '@/components/ui/input'
import {
Select,
@@ -279,7 +280,9 @@ export function SubscriptionsMutateDrawer({
{/* Basic Info */}
<SideDrawerSection>
<h3 className='flex items-center gap-2 text-sm font-medium'>
<Settings2 className='h-4 w-4' />
<IconBadge tone='info' size='xs'>
<Settings2 />
</IconBadge>
{t('Basic Info')}
</h3>
@@ -328,7 +331,9 @@ export function SubscriptionsMutateDrawer({
step='0.01'
min={0}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
field.onChange(
Number.parseFloat(e.target.value) || 0
)
}
/>
</FormControl>
@@ -364,7 +369,9 @@ export function SubscriptionsMutateDrawer({
})
}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
field.onChange(
Number.parseFloat(e.target.value) || 0
)
}
/>
</FormControl>
@@ -480,7 +487,9 @@ export function SubscriptionsMutateDrawer({
type='number'
min={0}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
field.onChange(
Number.parseInt(e.target.value, 10) || 0
)
}
/>
</FormControl>
@@ -504,7 +513,9 @@ export function SubscriptionsMutateDrawer({
{...field}
type='number'
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
field.onChange(
Number.parseInt(e.target.value, 10) || 0
)
}
/>
</FormControl>
@@ -573,7 +584,9 @@ export function SubscriptionsMutateDrawer({
{/* Duration Settings */}
<SideDrawerSection>
<h3 className='flex items-center gap-2 text-sm font-medium'>
<CalendarClock className='h-4 w-4' />
<IconBadge tone='chart-4' size='xs'>
<CalendarClock />
</IconBadge>
{t('Duration Settings')}
</h3>
@@ -585,12 +598,10 @@ export function SubscriptionsMutateDrawer({
<FormItem>
<FormLabel>{t('Duration Unit')}</FormLabel>
<Select
items={[
...durationUnitOpts.map((o) => ({
value: o.value,
label: o.label,
})),
]}
items={durationUnitOpts.map((o) => ({
value: o.value,
label: o.label,
}))}
onValueChange={field.onChange}
value={field.value}
>
@@ -627,7 +638,9 @@ export function SubscriptionsMutateDrawer({
type='number'
min={1}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
field.onChange(
Number.parseInt(e.target.value, 10) || 0
)
}
/>
</FormControl>
@@ -648,7 +661,9 @@ export function SubscriptionsMutateDrawer({
type='number'
min={1}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
field.onChange(
Number.parseInt(e.target.value, 10) || 0
)
}
/>
</FormControl>
@@ -663,7 +678,9 @@ export function SubscriptionsMutateDrawer({
{/* Quota Reset */}
<SideDrawerSection>
<h3 className='flex items-center gap-2 text-sm font-medium'>
<RefreshCw className='h-4 w-4' />
<IconBadge tone='success' size='xs'>
<RefreshCw />
</IconBadge>
{t('Quota Reset')}
</h3>
@@ -675,12 +692,10 @@ export function SubscriptionsMutateDrawer({
<FormItem>
<FormLabel>{t('Reset Cycle')}</FormLabel>
<Select
items={[
...resetPeriodOpts.map((o) => ({
value: o.value,
label: o.label,
})),
]}
items={resetPeriodOpts.map((o) => ({
value: o.value,
label: o.label,
}))}
onValueChange={field.onChange}
value={field.value}
>
@@ -717,7 +732,9 @@ export function SubscriptionsMutateDrawer({
min={0}
disabled={resetPeriod !== 'custom'}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
field.onChange(
Number.parseInt(e.target.value, 10) || 0
)
}
/>
</FormControl>
@@ -731,7 +748,9 @@ export function SubscriptionsMutateDrawer({
{/* Payment Config */}
<SideDrawerSection>
<h3 className='flex items-center gap-2 text-sm font-medium'>
<CreditCard className='h-4 w-4' />
<IconBadge tone='warning' size='xs'>
<CreditCard />
</IconBadge>
{t('Third-party Payment Config')}
</h3>
@@ -16,11 +16,12 @@ 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 type { ColumnDef } from '@tanstack/react-table'
import { GitBranch, Sparkles, KeyRound } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { GroupBadge } from '@/components/group-badge'
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
@@ -74,19 +75,19 @@ function formatRatioCompact(ratio: number | undefined): string {
: ratio.toFixed(4).replace(/\.?0+$/, '')
}
function getGroupRatioText(other: LogOtherData | null): string | null {
function getGroupRatio(other: LogOtherData | null): number | null {
const userGroupRatio = other?.user_group_ratio
if (
userGroupRatio != null &&
userGroupRatio !== -1 &&
Number.isFinite(userGroupRatio)
) {
return `${formatRatioCompact(userGroupRatio)}x`
return userGroupRatio
}
const groupRatio = other?.group_ratio
if (groupRatio != null && groupRatio !== 1 && Number.isFinite(groupRatio)) {
return `${formatRatioCompact(groupRatio)}x`
return groupRatio
}
return null
@@ -218,10 +219,11 @@ function buildTypeDetailSegments(
})
}
} else {
const isPerCall = isPerCallBilling(other.model_price)
if (isPerCall) {
const modelPrice = other.model_price
const isPerCall = isPerCallBilling(modelPrice)
if (isPerCall && modelPrice != null) {
segments.push({
text: `${t('Per-call')} · ${formatBillingCurrencyFromUSD(other.model_price!, priceOpts)}`,
text: `${t('Per-call')} · ${formatBillingCurrencyFromUSD(modelPrice, priceOpts)}`,
})
} else if (other.model_ratio != null) {
const inputPriceUSD = other.model_ratio * 2.0
@@ -553,13 +555,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
const displayName = sensitiveVisible ? tokenName : '••••'
let group = log.group
if (!group) group = other?.group || ''
const metaParts: string[] = []
const groupRatioText = getGroupRatioText(other)
if (group) {
metaParts.push(sensitiveVisible ? group : '••••')
}
if (groupRatioText) metaParts.push(groupRatioText)
const groupRatio = getGroupRatio(other)
return (
<div className='flex max-w-[200px] flex-col gap-0.5'>
@@ -582,9 +578,23 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
)}
</Tooltip>
</TooltipProvider>
{metaParts.length > 0 && (
<span className='text-muted-foreground/60 truncate [font-family:var(--font-body)] !text-xs'>
{metaParts.join(' · ')}
{(group || groupRatio != null) && (
<span className='block max-w-full truncate text-xs leading-none'>
{group ? (
<GroupBadge
group={group}
label={sensitiveVisible ? undefined : '••••'}
type='text'
size='sm'
className='inline align-baseline text-xs leading-none [&>span]:leading-none'
/>
) : null}
{group && groupRatio != null ? ' ' : null}
{groupRatio != null ? (
<span className='text-muted-foreground/60 relative top-px align-baseline tabular-nums'>
{formatRatioCompact(groupRatio)}x
</span>
) : null}
</span>
)}
</div>
@@ -684,26 +694,6 @@ 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',
header: t('Cost'),
@@ -756,6 +746,27 @@ 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}
completionTokens={log.completion_tokens}
frtMs={other?.frt}
isStream={log.is_stream}
/>
)
},
},
{
accessorKey: 'content',
header: t('Details'),
@@ -767,6 +778,36 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
const segments = buildDetailSegments(log, other, t, isAdmin)
const primary = segments[0]
const hasMore = segments.length > 1
let primaryTextClass = 'text-foreground'
if (primary?.muted) {
primaryTextClass = 'text-muted-foreground/60'
} else if (primary?.danger) {
primaryTextClass = 'text-red-600 dark:text-red-400'
}
let detailPreview = <span className='text-muted-foreground/40'></span>
if (primary) {
detailPreview = (
<span
className={cn(
'truncate leading-snug group-hover:underline',
primaryTextClass
)}
>
{primary.text}
{hasMore && (
<span className='text-muted-foreground/40 ml-0.5'>
+{segments.length - 1}
</span>
)}
</span>
)
} else if (log.content) {
detailPreview = (
<span className='text-muted-foreground truncate group-hover:underline'>
{log.content}
</span>
)
}
return (
<>
@@ -776,31 +817,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
onClick={() => setDialogOpen(true)}
title={t('Click to view full details')}
>
{primary ? (
<span
className={cn(
'truncate leading-snug group-hover:underline',
primary.muted
? 'text-muted-foreground/60'
: primary.danger
? 'text-red-600 dark:text-red-400'
: 'text-foreground'
)}
>
{primary.text}
{hasMore && (
<span className='text-muted-foreground/40 ml-0.5'>
+{segments.length - 1}
</span>
)}
</span>
) : log.content ? (
<span className='text-muted-foreground truncate group-hover:underline'>
{log.content}
</span>
) : (
<span className='text-muted-foreground/40'></span>
)}
{detailPreview}
</button>
<DetailsDialog
log={log}
@@ -37,7 +37,6 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useIsAdmin } from '@/hooks/use-admin'
import { LOG_TYPE_ALL_VALUE, LOG_TYPE_FILTERS } from '../constants'
import { buildSearchParams } from '../lib/filter'
@@ -50,7 +49,7 @@ import {
LogsFilterInput,
LogsFilterToolbar,
} from './logs-filter-toolbar'
import { useUsageLogsContext } from './usage-logs-provider'
import { useLogsViewScope, useUsageLogsContext } from './usage-logs-provider'
const route = getRouteApi('/_authenticated/usage-logs/$section')
@@ -117,7 +116,7 @@ export function CommonLogsFilterBar<TData>(
const navigate = useNavigate()
const queryClient = useQueryClient()
const searchParams = route.useSearch()
const isAdmin = useIsAdmin()
const { isAdminView: isAdmin } = useLogsViewScope()
const { sensitiveVisible, setSensitiveVisible } = useUsageLogsContext()
const fetchingLogs = useIsFetching({ queryKey: ['logs'] })
@@ -21,14 +21,13 @@ import { getRouteApi } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Skeleton } from '@/components/ui/skeleton'
import { useIsAdmin } from '@/hooks/use-admin'
import { formatLogQuota } from '@/lib/format'
import { cn } from '@/lib/utils'
import { getLogStats, getUserLogStats } from '../api'
import { DEFAULT_LOG_STATS } from '../constants'
import { buildApiParams } from '../lib/utils'
import { useUsageLogsContext } from './usage-logs-provider'
import { useLogsViewScope, useUsageLogsContext } from './usage-logs-provider'
const route = getRouteApi('/_authenticated/usage-logs/$section')
@@ -50,7 +49,7 @@ function StatBadge(props: {
export function CommonLogsStats() {
const { t } = useTranslation()
const isAdmin = useIsAdmin()
const { isAdminView: isAdmin } = useLogsViewScope()
const searchParams = route.useSearch()
const { sensitiveVisible } = useUsageLogsContext()
@@ -24,6 +24,7 @@ import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import { IconBadge } from '@/components/ui/icon-badge'
import { ScrollArea } from '@/components/ui/scroll-area'
export interface AudioClip {
@@ -153,7 +154,9 @@ export function AudioPreviewDialog(props: AudioPreviewDialogProps) {
onOpenChange={props.onOpenChange}
title={
<>
<Music className='h-5 w-5' />
<IconBadge tone='chart-4' size='sm'>
<Music />
</IconBadge>
{t('Audio Preview')}
</>
}
@@ -36,6 +36,7 @@ import { useTranslation } from 'react-i18next'
import { Dialog } from '@/components/dialog'
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import { IconBadge, type IconBadgeTone } from '@/components/ui/icon-badge'
import { Label } from '@/components/ui/label'
import { DynamicPricingBreakdown } from '@/features/pricing/components/dynamic-pricing-breakdown'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
@@ -108,11 +109,13 @@ function DetailRow(props: {
function DetailSection(props: {
icon?: React.ReactNode
iconTone?: IconBadgeTone
label: string
variant?: 'default' | 'danger'
children: React.ReactNode
}) {
const isDanger = props.variant === 'danger'
const iconTone = isDanger ? 'destructive' : props.iconTone
return (
<div className='min-w-0 space-y-1.5'>
<Label
@@ -121,7 +124,11 @@ function DetailSection(props: {
isDanger && 'text-red-500'
)}
>
{props.icon}
{props.icon && (
<IconBadge tone={iconTone} size='xs'>
{props.icon}
</IconBadge>
)}
{props.label}
</Label>
<div
@@ -335,8 +342,8 @@ function BillingBreakdown(props: {
return (
<DetailSection label={t('Billing Details')}>
{rows.map((row, idx) => (
<DetailRow key={idx} label={row.label} value={row.value} mono />
{rows.map((row) => (
<DetailRow key={row.label} label={row.label} value={row.value} mono />
))}
</DetailSection>
)
@@ -401,8 +408,8 @@ function TokenBreakdown(props: { log: UsageLog; other: LogOtherData }) {
return (
<DetailSection label={t('Token Breakdown')}>
{rows.map((row, idx) => (
<DetailRow key={idx} label={row.label} value={row.value} mono />
{rows.map((row) => (
<DetailRow key={row.label} label={row.label} value={row.value} mono />
))}
</DetailSection>
)
@@ -543,6 +550,12 @@ export function DetailsDialog(props: DetailsDialogProps) {
const useChannel = other?.admin_info?.use_channel
const channelChain =
useChannel && useChannel.length > 0 ? useChannel.join(' → ') : undefined
let reasoningEffortVariant: StatusBadgeProps['variant'] = 'green'
if (other?.reasoning_effort === 'high') {
reasoningEffortVariant = 'orange'
} else if (other?.reasoning_effort === 'medium') {
reasoningEffortVariant = 'yellow'
}
return (
<Dialog
@@ -803,11 +816,12 @@ export function DetailsDialog(props: DetailsDialogProps) {
{showTopupAuditSection && (
<DetailSection
icon={<ShieldCheck className='size-3.5' aria-hidden='true' />}
iconTone='success'
label={t('Top-up Audit Info')}
>
{topupAuditFields.map((field, idx) => (
{topupAuditFields.map((field) => (
<DetailRow
key={idx}
key={field.label}
label={field.label}
value={field.value}
mono
@@ -847,6 +861,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
{showManageAuditSection && (
<DetailSection
icon={<ShieldCheck className='size-3.5' aria-hidden='true' />}
iconTone='info'
label={t('Operation Audit Info')}
>
{operationText != null && (
@@ -889,14 +904,15 @@ export function DetailsDialog(props: DetailsDialogProps) {
{isLogin && loginAuditFields.length > 0 && (
<DetailSection
icon={<LogIn className='size-3.5' aria-hidden='true' />}
iconTone='info'
label={t('Login Info')}
>
{operationText != null && (
<DetailRow label={t('Operation')} value={operationText} />
)}
{loginAuditFields.map((field, idx) => (
{loginAuditFields.map((field) => (
<DetailRow
key={idx}
key={field.label}
label={field.label}
value={field.value}
mono
@@ -909,6 +925,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
{hasAudioTokens && other && (
<DetailSection
icon={<Headphones className='size-3.5' aria-hidden='true' />}
iconTone='chart-4'
label={t('Audio Tokens')}
>
{other.audio_input != null && other.audio_input > 0 && (
@@ -949,13 +966,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
value={
<StatusBadge
label={other.reasoning_effort}
variant={
other.reasoning_effort === 'high'
? 'orange'
: other.reasoning_effort === 'medium'
? 'yellow'
: 'green'
}
variant={reasoningEffortVariant}
size='sm'
copyable={false}
/>
@@ -1139,14 +1150,15 @@ export function DetailsDialog(props: DetailsDialogProps) {
{other?.po && Array.isArray(other.po) && other.po.length > 0 && (
<DetailSection
icon={<Settings2 className='size-3.5' aria-hidden='true' />}
iconTone='chart-3'
label={`${t('Param Override')} (${other.po.length})`}
>
{other.po.filter(Boolean).map((line, idx) => {
{other.po.filter(Boolean).map((line) => {
const parsed = parseAuditLine(line)
if (!parsed) return null
return (
<div
key={idx}
key={`${parsed.action}-${parsed.content}`}
className='bg-background/60 flex min-w-0 flex-col gap-1.5 rounded border p-2 sm:flex-row sm:items-start sm:gap-2'
>
<StatusBadge
@@ -88,6 +88,7 @@ export function LogsFilterToolbar<TData>(props: LogsFilterToolbarProps<TData>) {
const { t } = useTranslation()
const [advancedOpen, setAdvancedOpen] = useState(false)
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
const [mobilePanelCollapsed, setMobilePanelCollapsed] = useState(false)
const isMobile = useMediaQuery('(max-width: 640px)')
const hasAdvancedFilters = props.advancedFilters != null
@@ -139,11 +140,36 @@ export function LogsFilterToolbar<TData>(props: LogsFilterToolbarProps<TData>) {
<div
className={cn('bg-card/50 rounded-lg border p-2.5', props.className)}
>
<div className='grid gap-2'>{props.mobilePinnedFilters}</div>
{!mobilePanelCollapsed && (
<div className='grid gap-2'>{props.mobilePinnedFilters}</div>
)}
<div className='mt-2 flex flex-col gap-2'>
{props.stats}
<div
className={cn(
'flex flex-col gap-2',
!mobilePanelCollapsed && 'mt-2'
)}
>
{!mobilePanelCollapsed && props.stats}
<div className='flex items-center justify-end gap-1.5'>
<Button
type='button'
variant='ghost'
size='icon'
onClick={() => setMobilePanelCollapsed((collapsed) => !collapsed)}
aria-expanded={!mobilePanelCollapsed}
aria-label={
mobilePanelCollapsed ? t('Expand') : t('Collapse')
}
className='text-muted-foreground hover:text-foreground mr-auto size-7'
>
<ChevronDown
className={cn(
'size-3.5 transition-transform duration-200',
!mobilePanelCollapsed && 'rotate-180'
)}
/>
</Button>
{props.actionStart}
<DrawerTrigger asChild>
<Button
@@ -22,8 +22,6 @@ import { type Table } from '@tanstack/react-table'
import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useIsAdmin } from '@/hooks/use-admin'
import { buildSearchParams } from '../lib/filter'
import { getDefaultTimeRange } from '../lib/utils'
import type { DrawingLogFilters, LogCategory, TaskLogFilters } from '../types'
@@ -33,6 +31,7 @@ import {
LogsFilterInput,
LogsFilterToolbar,
} from './logs-filter-toolbar'
import { useLogsViewScope } from './usage-logs-provider'
const route = getRouteApi('/_authenticated/usage-logs/$section')
@@ -70,7 +69,7 @@ export function TaskLogsFilterBar<TData>(props: TaskLogsFilterBarProps<TData>) {
const navigate = useNavigate()
const queryClient = useQueryClient()
const searchParams = route.useSearch()
const isAdmin = useIsAdmin()
const { isAdminView: isAdmin } = useLogsViewScope()
const fetchingLogs = useIsFetching({ queryKey: ['logs'] })
const [filters, setFilters] = useState<TaskLogsFilters>(() => {
@@ -19,6 +19,11 @@ For commercial licensing, please contact support@quantumnous.com
import { CircleAlert } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
dotColorMap,
textColorMap,
type StatusVariant,
} from '@/components/status-badge'
import {
Tooltip,
TooltipContent,
@@ -28,62 +33,124 @@ import {
import { formatUseTime } from '@/lib/format'
import { cn } from '@/lib/utils'
import { getFirstResponseTimeColor, getResponseTimeColor } from '../lib/format'
import type { LogOtherData } from '../types'
/**
* Softened fills for the full-height timing bar. The bar sits directly beside
* dense numeric text, so the saturated `dotColorMap` tones (tuned for small
* dots and badges) read as too high-contrast at that size; a translucent fill
* keeps the status legible while matching the page's muted palette.
*/
const barColorMap: Record<StatusVariant, string> = {
...dotColorMap,
success: 'bg-success/90',
warning: 'bg-warning/80',
danger: 'bg-destructive/80',
neutral: 'bg-neutral/80',
}
interface TimingMetricsCellProps {
useTimeSec: number
completionTokens: number
frtMs?: number
isStream: boolean
className?: string
/**
* `bar` (default) draws a full-height color segment beside the labels,
* matching the dense desktop table. `dot` swaps that segment for small
* status dots inline with each label, matching the lighter-weight status
* indicator used elsewhere on the mobile card.
*/
indicator?: 'bar' | 'dot'
}
export function TimingMetricsCell(props: TimingMetricsCellProps) {
const { t } = useTranslation()
const indicator = props.indicator ?? 'bar'
const showFirstToken = props.isStream
const hasFrt = props.frtMs != null && props.frtMs > 0
const firstTokenLabel = hasFrt
? formatUseTime(props.frtMs! / 1000)
: t('N/A')
const firstTokenSeconds =
props.frtMs != null && props.frtMs > 0 ? props.frtMs / 1000 : null
const firstTokenVariant: StatusVariant =
firstTokenSeconds == null
? 'neutral'
: getFirstResponseTimeColor(firstTokenSeconds)
const totalTimeVariant = getResponseTimeColor(
props.useTimeSec,
props.completionTokens
)
const firstTokenLabel =
firstTokenSeconds == null ? t('N/A') : formatUseTime(firstTokenSeconds)
const totalTimeLabel = formatUseTime(props.useTimeSec)
const labels = (
<div className='flex min-h-8 min-w-0 flex-col justify-center gap-0.5 text-xs leading-tight'>
{showFirstToken && (
<div className='flex items-baseline gap-1.5'>
{indicator === 'dot' && (
<span
aria-hidden
className={cn(
'size-1.5 shrink-0 rounded-full',
dotColorMap[firstTokenVariant]
)}
/>
)}
<span className='text-muted-foreground shrink-0'>
{t('First token')}
</span>
<span
className={cn('tabular-nums', textColorMap[firstTokenVariant])}
>
{firstTokenLabel}
</span>
</div>
)}
<div className='flex items-baseline gap-1.5'>
{indicator === 'dot' && (
<span
aria-hidden
className={cn(
'size-1.5 shrink-0 rounded-full',
dotColorMap[totalTimeVariant]
)}
/>
)}
<span className='text-muted-foreground shrink-0'>
{t('Duration')}
</span>
<span className={cn('tabular-nums', textColorMap[totalTimeVariant])}>
{totalTimeLabel}
</span>
</div>
</div>
)
if (indicator === 'dot') {
return (
<div className={cn('flex items-stretch', props.className)}>
{labels}
</div>
)
}
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'
'flex w-1 shrink-0 flex-col overflow-hidden rounded-full',
!showFirstToken && barColorMap[totalTimeVariant]
)}
/>
<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>
>
{showFirstToken && (
<>
<span className={cn('flex-1', barColorMap[firstTokenVariant])} />
<span className={cn('flex-1', barColorMap[totalTimeVariant])} />
</>
)}
</span>
{labels}
</div>
)
}
@@ -98,13 +165,12 @@ interface StreamTpsCellProps {
export function StreamTpsCell(props: StreamTpsCellProps) {
const { t } = useTranslation()
const showStreamError =
props.isStream &&
props.streamStatus &&
props.streamStatus.status !== 'ok'
props.isStream && props.streamStatus && props.streamStatus.status !== 'ok'
const tpsLabel =
props.tokensPerSecond != null
? `${Math.round(props.tokensPerSecond)} t/s`
: '—'
const streamLabel = props.isStream ? t('Stream') : t('Non-stream')
return (
<div
@@ -113,8 +179,13 @@ export function StreamTpsCell(props: StreamTpsCellProps) {
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')}
<span
className={cn(
'inline-flex items-center gap-1 font-medium',
props.isStream ? 'text-info' : 'text-muted-foreground'
)}
>
{streamLabel}
{showStreamError && (
<TooltipProvider>
<Tooltip>
@@ -25,6 +25,7 @@ import {
textColorMap,
type StatusVariant,
} from '@/components/status-badge'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
Empty,
EmptyDescription,
@@ -33,12 +34,21 @@ import {
EmptyTitle,
} from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton'
import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
import { formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
import { LOG_TYPE_ENUM } from '../constants'
import { getLogTypeConfig } from '../lib/utils'
import type { UsageLog } from '../data/schema'
import { parseLogOther } from '../lib/format'
import {
getLogTypeConfig,
isDisplayableLogType,
isTimingLogType,
} from '../lib/utils'
import type { LogCategory } from '../types'
import { StreamTpsCell, TimingMetricsCell } from './timing-metrics-cell'
import { useUsageLogsContext } from './usage-logs-provider'
const logTypeRowTint: Record<number, string> = {
[LOG_TYPE_ENUM.ERROR]:
@@ -117,7 +127,7 @@ function SummaryField<TData>({
valueClassName,
primaryOnly = false,
}: {
label: string
label?: string
cell?: Cell<TData, unknown>
className?: string
valueClassName?: string
@@ -129,9 +139,11 @@ function SummaryField<TData>({
<div
className={cn('bg-muted/20 min-w-0 rounded-md px-2 py-1.5', className)}
>
<div className='text-muted-foreground mb-1 text-[11px] leading-none font-medium select-none'>
{label}
</div>
{label != null && label !== '' && (
<div className='text-muted-foreground mb-1 text-[11px] leading-none font-medium select-none'>
{label}
</div>
)}
<CompactCell
cell={cell}
primaryOnly={primaryOnly}
@@ -175,6 +187,127 @@ function MobileLogTimeStatus({
)
}
/** Mobile-only Tokens block: always show cache ↓/↑ when present (no label). */
function MobileTokensField({ log }: { log: UsageLog }) {
const { t } = useTranslation()
if (!isDisplayableLogType(log.type)) return null
const promptTokens = log.prompt_tokens || 0
const completionTokens = log.completion_tokens || 0
if (promptTokens === 0 && completionTokens === 0) {
return (
<div className='bg-muted/20 min-w-0 rounded-md px-2 py-1.5'>
<span className='text-muted-foreground text-xs'>-</span>
</div>
)
}
const other = parseLogOther(log.other)
const cacheReadTokens = other?.cache_tokens || 0
const cacheWrite5m = other?.cache_creation_tokens_5m || 0
const cacheWrite1h = other?.cache_creation_tokens_1h || 0
const hasSplitCache = cacheWrite5m > 0 || cacheWrite1h > 0
const cacheWriteTokens = hasSplitCache
? cacheWrite5m + cacheWrite1h
: other?.cache_creation_tokens || 0
const showCache = cacheReadTokens > 0 || cacheWriteTokens > 0
return (
<div className='bg-muted/20 min-w-0 rounded-md px-2 py-1.5'>
<div className='flex flex-col gap-0.5'>
<span className='font-mono text-xs font-medium tabular-nums'>
{promptTokens.toLocaleString()} / {completionTokens.toLocaleString()}
</span>
{showCache ? (
<div className='text-muted-foreground flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] leading-none'>
{cacheReadTokens > 0 && (
<span>
{t('Cache')} {cacheReadTokens.toLocaleString()}
</span>
)}
{cacheWriteTokens > 0 && (
<span> {cacheWriteTokens.toLocaleString()}</span>
)}
</div>
) : (
<span className='text-muted-foreground/50 text-[11px] leading-none'>
</span>
)}
</div>
</div>
)
}
/** Mobile-only User block: own layout so avatar/name always line up on the same baseline. */
function MobileUserField({ log }: { log: UsageLog }) {
const { sensitiveVisible, setSelectedUserId, setUserInfoDialogOpen } =
useUsageLogsContext()
if (!log.username) return null
return (
<button
type='button'
className='bg-muted/20 flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1.5 text-left'
onClick={(e) => {
e.stopPropagation()
setSelectedUserId(log.user_id)
setUserInfoDialogOpen(true)
}}
>
<Avatar className='ring-border/60 size-6 shrink-0 ring-1'>
<AvatarFallback
className={cn(
'text-[11px] font-semibold',
!sensitiveVisible && 'bg-muted text-muted-foreground'
)}
style={
sensitiveVisible ? getUserAvatarStyle(log.username) : undefined
}
>
{sensitiveVisible ? getUserAvatarFallback(log.username) : '•'}
</AvatarFallback>
</Avatar>
<span className='text-foreground min-w-0 truncate text-sm'>
{sensitiveVisible ? log.username : '••••'}
</span>
</button>
)
}
/** Merge stream badge + TPS with first-token / duration on one row. */
function MobileStreamTimingField({ log }: { log: UsageLog }) {
if (!isTimingLogType(log.type)) return null
const other = parseLogOther(log.other)
const useTime = log.use_time || 0
const tokensPerSecond =
useTime > 0 && log.completion_tokens > 0
? log.completion_tokens / useTime
: null
return (
<div className='bg-muted/20 flex min-w-0 items-center gap-2.5 rounded-md px-2 py-1.5'>
<TimingMetricsCell
useTimeSec={useTime}
completionTokens={log.completion_tokens}
frtMs={other?.frt}
isStream={log.is_stream}
indicator='dot'
className='min-w-0 flex-1'
/>
<StreamTpsCell
isStream={log.is_stream}
tokensPerSecond={tokensPerSecond}
streamStatus={other?.stream_status}
className='shrink-0'
/>
</div>
)
}
function CommonLogsCard<TData>({
cells,
}: {
@@ -184,9 +317,7 @@ function CommonLogsCard<TData>({
const modelCell = cells.get('model_name')
const quotaCell = cells.get('quota')
const rowData = cells.get('created_at')?.row.original as
| Record<string, unknown>
| undefined
const rowData = cells.get('created_at')?.row.original as UsageLog | undefined
return (
<div className='space-y-2.5'>
@@ -198,42 +329,36 @@ function CommonLogsCard<TData>({
/>
</div>
<div className='grid grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)] gap-1.5'>
<div className='grid grid-cols-[minmax(0,1.35fr)_minmax(0,0.75fr)] gap-1.5'>
<div className='bg-muted/20 min-w-0 rounded-md px-2 py-1.5'>
<div className='text-muted-foreground mb-1 text-[11px] leading-none font-medium select-none'>
{t('Time')}
</div>
<MobileLogTimeStatus
createdAt={rowData?.created_at}
type={rowData?.type}
/>
</div>
<SummaryField
label={t('Channel')}
cell={cells.get('channel')}
valueClassName='[&_.flex-col]:max-w-none'
/>
<SummaryField label={t('User')} cell={cells.get('user')} primaryOnly />
{rowData && cells.has('user') ? (
<MobileUserField log={rowData} />
) : (
<SummaryField cell={cells.get('user')} />
)}
<SummaryField
label={t('Token')}
cell={cells.get('token_name')}
valueClassName='[&_.flex-col]:max-w-none [&_.flex-col>*:not(:first-child)]:text-[11px] [&_.flex-col>*:not(:first-child)]:leading-none'
/>
<SummaryField
label={t('Stream')}
cell={cells.get('is_stream')}
primaryOnly
/>
<SummaryField
label={t('Tokens')}
cell={cells.get('prompt_tokens')}
primaryOnly
/>
<SummaryField
label={t('Timing')}
cell={cells.get('use_time')}
primaryOnly
/>
{rowData ? (
<MobileStreamTimingField log={rowData} />
) : (
<SummaryField cell={cells.get('use_time')} />
)}
{rowData ? (
<MobileTokensField log={rowData} />
) : (
<SummaryField cell={cells.get('prompt_tokens')} />
)}
<SummaryField
label={t('Details')}
cell={cells.get('content')}
@@ -19,8 +19,12 @@ For commercial licensing, please contact support@quantumnous.com
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useState, type ReactNode } from 'react'
import { useIsAdmin } from '@/hooks/use-admin'
import type { ChannelAffinityInfo } from '../types'
export type LogsViewScope = 'all' | 'self'
interface UsageLogsContextValue {
selectedUserId: number | null
setSelectedUserId: (userId: number | null) => void
@@ -32,6 +36,8 @@ interface UsageLogsContextValue {
setAffinityDialogOpen: (open: boolean) => void
sensitiveVisible: boolean
setSensitiveVisible: (visible: boolean) => void
viewScope: LogsViewScope
setViewScope: (scope: LogsViewScope) => void
}
const UsageLogsContext = createContext<UsageLogsContextValue | undefined>(
@@ -45,6 +51,7 @@ export function UsageLogsProvider({ children }: { children: ReactNode }) {
useState<ChannelAffinityInfo | null>(null)
const [affinityDialogOpen, setAffinityDialogOpen] = useState(false)
const [sensitiveVisible, setSensitiveVisible] = useState(true)
const [viewScope, setViewScope] = useState<LogsViewScope>('all')
return (
<UsageLogsContext.Provider
@@ -59,6 +66,8 @@ export function UsageLogsProvider({ children }: { children: ReactNode }) {
setAffinityDialogOpen,
sensitiveVisible,
setSensitiveVisible,
viewScope,
setViewScope,
}}
>
{children}
@@ -73,3 +82,23 @@ export function useUsageLogsContext() {
}
return context
}
/**
* Resolves the effective admin scope for usage logs: whether the current
* user is allowed to view all users' logs (`canManageScope`), and whether
* their current view preference (`viewScope`) has that scope active
* (`isAdminView`). Data fetching and admin-only UI should key off
* `isAdminView` rather than raw role, so an admin who switches to "only
* mine" is treated exactly like a regular user for that view.
*/
export function useLogsViewScope() {
const canManageScope = useIsAdmin()
const { viewScope, setViewScope } = useUsageLogsContext()
return {
canManageScope,
viewScope,
setViewScope,
isAdminView: canManageScope && viewScope === 'all',
}
}
@@ -28,7 +28,6 @@ import {
useDataTable,
} from '@/components/data-table'
import { useMediaQuery } from '@/hooks'
import { useIsAdmin } from '@/hooks/use-admin'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { cn } from '@/lib/utils'
@@ -44,6 +43,7 @@ import type { LogCategory } from '../types'
import { CommonLogsFilterBar } from './common-logs-filter-bar'
import { TaskLogsFilterBar } from './task-logs-filter-bar'
import { UsageLogsMobileList } from './usage-logs-mobile-card'
import { useLogsViewScope } from './usage-logs-provider'
const route = getRouteApi('/_authenticated/usage-logs/$section')
@@ -74,7 +74,7 @@ interface UsageLogsTableProps {
export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
const { t } = useTranslation()
const isAdmin = useIsAdmin()
const { isAdminView: isAdmin } = useLogsViewScope()
const isMobile = useMediaQuery('(max-width: 640px)')
const searchParams = route.useSearch()
+22
View File
@@ -28,7 +28,9 @@ import { useSidebarConfig } from '@/hooks/use-sidebar-config'
import { UserInfoDialog } from './components/dialogs/user-info-dialog'
import {
type LogsViewScope,
UsageLogsProvider,
useLogsViewScope,
useUsageLogsContext,
} from './components/usage-logs-provider'
import { UsageLogsTable } from './components/usage-logs-table'
@@ -69,6 +71,7 @@ function UsageLogsContent() {
affinityDialogOpen,
setAffinityDialogOpen,
} = useUsageLogsContext()
const { canManageScope, viewScope, setViewScope } = useLogsViewScope()
const tabNavGroups = useMemo<NavGroup[]>(
() => [
{
@@ -105,6 +108,15 @@ function UsageLogsContent() {
[navigate]
)
const handleViewScopeChange = useCallback(
(scope: string) => {
if (scope === 'all' || scope === 'self') {
setViewScope(scope as LogsViewScope)
}
},
[setViewScope]
)
const pageMeta =
activeCategory === 'common' ? SECTION_META.common : SECTION_META.task
const showTaskSwitcher =
@@ -116,6 +128,16 @@ function UsageLogsContent() {
<SectionPageLayout.Title>
{t(pageMeta.titleKey)}
</SectionPageLayout.Title>
{canManageScope && (
<SectionPageLayout.Actions>
<Tabs value={viewScope} onValueChange={handleViewScopeChange}>
<TabsList>
<TabsTrigger value='all'>{t('All')}</TabsTrigger>
<TabsTrigger value='self'>{t('Only Mine')}</TabsTrigger>
</TabsList>
</Tabs>
</SectionPageLayout.Actions>
)}
<SectionPageLayout.Content>
<div className='flex h-full min-h-0 flex-col gap-4'>
{showTaskSwitcher && (
@@ -22,6 +22,7 @@ import { useTranslation } from 'react-i18next'
import { CopyButton } from '@/components/copy-button'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { IconBadge } from '@/components/ui/icon-badge'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { formatQuota } from '@/lib/format'
@@ -65,9 +66,9 @@ export function AffiliateRewardsCard({
<Card data-card-hover='false' className='bg-muted/20 py-0'>
<CardContent className='grid gap-3 p-3 sm:gap-4 sm:p-4 lg:grid-cols-[minmax(200px,1fr)_minmax(180px,0.65fr)_minmax(280px,1fr)] lg:items-center'>
<div className='flex min-w-0 items-center gap-2.5'>
<div className='bg-background flex size-8 shrink-0 items-center justify-center rounded-lg border'>
<Share2 className='text-muted-foreground size-4' />
</div>
<IconBadge tone='chart-3'>
<Share2 />
</IconBadge>
<div className='min-w-0'>
<h3 className='truncate text-sm font-semibold'>
{t('Referral Program')}
@@ -23,6 +23,7 @@ import { useTranslation } from 'react-i18next'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { IconBadge } from '@/components/ui/icon-badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
@@ -120,7 +121,7 @@ export function RechargeFormCard({
const handleAmountChange = (value: string) => {
setLocalAmount(value)
const numValue = parseInt(value) || 0
const numValue = Number.parseInt(value) || 0
if (numValue >= 0) {
onTopupAmountChange(numValue)
}
@@ -152,9 +153,11 @@ export function RechargeFormCard({
<div className='space-y-3'>
<Skeleton className='h-3 w-16' />
<div className='grid grid-cols-2 gap-3 sm:grid-cols-4'>
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className='h-[72px] rounded-lg' />
))}
{Array.from({ length: 8 }, (_, index) => `preset-${index}`).map(
(key) => (
<Skeleton key={key} className='h-[72px] rounded-lg' />
)
)}
</div>
</div>
@@ -168,8 +171,8 @@ export function RechargeFormCard({
<div className='space-y-3'>
<Skeleton className='h-3 w-32' />
<div className='flex flex-wrap gap-3'>
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className='h-10 w-24 rounded-lg' />
{['primary', 'secondary', 'tertiary'].map((key) => (
<Skeleton key={key} className='h-10 w-24 rounded-lg' />
))}
</div>
</div>
@@ -193,6 +196,7 @@ export function RechargeFormCard({
title={t('Add Funds')}
description={t('Choose an amount and payment method')}
icon={<WalletCards className='h-4 w-4' />}
iconTone='success'
disableHoverEffect
action={
onOpenBilling ? (
@@ -220,7 +224,7 @@ export function RechargeFormCard({
{t('Amount')}
</Label>
<div className='grid grid-cols-2 gap-1.5 sm:gap-3 md:grid-cols-4'>
{presetAmounts.map((preset, index) => {
{presetAmounts.map((preset) => {
const discount =
preset.discount ||
topupInfo?.discount?.[preset.value] ||
@@ -238,7 +242,7 @@ export function RechargeFormCard({
)
return (
<Button
key={index}
key={preset.value}
variant='outline'
className={cn(
'flex min-h-16 flex-col items-start rounded-lg px-3 py-2.5 text-left whitespace-normal sm:min-h-[72px] sm:p-4',
@@ -364,7 +368,7 @@ export function RechargeFormCard({
return disabled ? (
<TooltipProvider key={method.type}>
<Tooltip>
<TooltipTrigger render={button}></TooltipTrigger>
<TooltipTrigger render={button} />
<TooltipContent>{disabledReason}</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -373,7 +377,8 @@ export function RechargeFormCard({
)
})}
</div>
) : hasWaffoPaymentMethods ? null : (
) : null}
{!hasStandardPaymentMethods && !hasWaffoPaymentMethods && (
<Alert>
<AlertDescription>
{t(
@@ -394,6 +399,7 @@ export function RechargeFormCard({
<div className='grid grid-cols-2 gap-1.5 sm:gap-3 lg:grid-cols-3'>
{waffoPayMethods?.map((method, index) => {
const loadingKey = `waffo-${index}`
const methodKey = `${method.payMethodType ?? 'unknown'}-${method.payMethodName ?? method.name}`
const waffoMin = waffoMinTopup || 0
const belowMin = waffoMin > topupAmount
const disabledReason = belowMin
@@ -405,9 +411,24 @@ export function RechargeFormCard({
? `${t('Minimum:')} ${waffoMin}`
: undefined
let methodIcon = getPaymentIcon('waffo')
if (paymentLoading === loadingKey) {
methodIcon = (
<Loader2 className='h-4 w-4 animate-spin' />
)
} else if (method.icon) {
methodIcon = (
<img
src={method.icon}
alt={method.name}
className='h-4 w-4 object-contain'
/>
)
}
const button = (
<Button
key={`${method.name}-${index}`}
key={methodKey}
variant='outline'
onClick={() => onWaffoMethodSelect(method, index)}
disabled={belowMin || !!paymentLoading}
@@ -419,17 +440,7 @@ export function RechargeFormCard({
}
className='min-h-14 min-w-0 justify-start gap-2 rounded-lg px-3 py-2 text-left'
>
{paymentLoading === loadingKey ? (
<Loader2 className='h-4 w-4 animate-spin' />
) : method.icon ? (
<img
src={method.icon}
alt={method.name}
className='h-4 w-4 object-contain'
/>
) : (
getPaymentIcon('waffo')
)}
{methodIcon}
<span className='flex min-w-0 flex-col items-start gap-0.5'>
<span className='max-w-full truncate'>
{method.name}
@@ -444,9 +455,9 @@ export function RechargeFormCard({
)
return belowMin ? (
<TooltipProvider key={`${method.name}-${index}`}>
<TooltipProvider key={methodKey}>
<Tooltip>
<TooltipTrigger render={button}></TooltipTrigger>
<TooltipTrigger render={button} />
<TooltipContent>{disabledReason}</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -490,7 +501,9 @@ export function RechargeFormCard({
{redemptionEnabled ? (
<div className='space-y-2.5 border-t pt-4 sm:space-y-3 sm:pt-6'>
<div className='flex items-center gap-2'>
<Gift className='text-muted-foreground h-4 w-4' />
<IconBadge tone='warning' size='xs'>
<Gift />
</IconBadge>
<Label
htmlFor='redemption-code'
className='text-muted-foreground text-xs font-medium tracking-wider uppercase'
@@ -244,8 +244,8 @@ export function SubscriptionPlansCard({
<CardContent className='space-y-4 p-3 sm:p-5'>
<Skeleton className='h-20 w-full' />
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3'>
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className='h-48 w-full' />
{['first', 'second', 'third'].map((key) => (
<Skeleton key={key} className='h-48 w-full' />
))}
</div>
</CardContent>
@@ -263,6 +263,7 @@ export function SubscriptionPlansCard({
title={t('Subscription Plans')}
description={t('Subscribe to a plan for model access')}
icon={<Crown className='h-4 w-4' />}
iconTone='warning'
disableHoverEffect
contentClassName='space-y-4 sm:space-y-5'
>
@@ -411,6 +412,38 @@ export function SubscriptionPlansCard({
const isCancelled = subscription?.status === 'cancelled'
const isActive =
subscription?.status === 'active' && !isExpired
const nextResetTime = subscription?.next_reset_time ?? 0
let statusBadge = (
<StatusBadge
label={t('Expired')}
variant='neutral'
copyable={false}
/>
)
if (isActive) {
statusBadge = (
<StatusBadge
label={t('Active')}
variant='success'
copyable={false}
/>
)
} else if (isCancelled) {
statusBadge = (
<StatusBadge
label={t('Cancelled')}
variant='neutral'
copyable={false}
/>
)
}
let endTimeLabel = t('Expired at')
if (isActive) {
endTimeLabel = t('Until')
} else if (isCancelled) {
endTimeLabel = t('Cancelled at')
}
return (
<div
@@ -424,25 +457,7 @@ export function SubscriptionPlansCard({
? `${planTitle} · ${t('Subscription')} #${subscription?.id}`
: `${t('Subscription')} #${subscription?.id}`}
</span>
{isActive ? (
<StatusBadge
label={t('Active')}
variant='success'
copyable={false}
/>
) : isCancelled ? (
<StatusBadge
label={t('Cancelled')}
variant='neutral'
copyable={false}
/>
) : (
<StatusBadge
label={t('Expired')}
variant='neutral'
copyable={false}
/>
)}
{statusBadge}
</div>
{isActive && (
<span className='text-muted-foreground'>
@@ -453,21 +468,15 @@ export function SubscriptionPlansCard({
)}
</div>
<div className='text-muted-foreground mt-1.5'>
{isActive
? t('Until')
: isCancelled
? t('Cancelled at')
: t('Expired at')}{' '}
{endTimeLabel}{' '}
{new Date(
(subscription?.end_time || 0) * 1000
).toLocaleString()}
</div>
{isActive && (subscription?.next_reset_time ?? 0) > 0 && (
{isActive && nextResetTime > 0 && (
<div className='text-muted-foreground mt-1'>
{t('Next reset')}:{' '}
{new Date(
subscription!.next_reset_time! * 1000
).toLocaleString()}
{new Date(nextResetTime * 1000).toLocaleString()}
</div>
)}
<div className='text-muted-foreground mt-1'>
@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { Activity, BarChart3, WalletCards } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { IconBadge, type IconBadgeTone } from '@/components/ui/icon-badge'
import { Skeleton } from '@/components/ui/skeleton'
import { formatQuota } from '@/lib/format'
@@ -33,62 +34,69 @@ export function WalletStatsCard(props: WalletStatsCardProps) {
const { t } = useTranslation()
if (props.loading) {
return (
<div className='overflow-hidden rounded-lg border'>
<div className='divide-border/60 grid grid-cols-3 divide-x'>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className='px-3 py-3 sm:px-5 sm:py-4'>
<Skeleton className='h-3.5 w-20' />
<Skeleton className='mt-2 h-7 w-28' />
<Skeleton className='mt-1.5 h-3.5 w-24' />
</div>
))}
</div>
<div className='grid grid-cols-3 divide-x rounded-lg border'>
{['balance', 'usage', 'requests'].map((key) => (
<div key={key} className='min-w-0 px-2.5 py-2.5 sm:px-5 sm:py-4'>
<Skeleton className='h-3.5 w-full' />
<Skeleton className='mt-2 h-6 w-full sm:h-7' />
<Skeleton className='mt-1.5 hidden h-3.5 w-24 md:block' />
</div>
))}
</div>
)
}
const stats = [
const stats: {
label: string
value: string
description: string
icon: typeof WalletCards
tone: IconBadgeTone
}[] = [
{
label: t('Current Balance'),
value: formatQuota(props.user?.quota ?? 0),
description: t('Remaining quota'),
icon: WalletCards,
tone: 'success',
},
{
label: t('Total Usage'),
value: formatQuota(props.user?.used_quota ?? 0),
description: t('Total consumed quota'),
icon: BarChart3,
tone: 'info',
},
{
label: t('API Requests'),
value: (props.user?.request_count ?? 0).toLocaleString(),
description: t('Total requests made'),
icon: Activity,
tone: 'chart-4',
},
]
return (
<div className='overflow-hidden rounded-lg border'>
<div className='divide-border/60 grid grid-cols-3 divide-x'>
{stats.map((item) => (
<div key={item.label} className='px-3 py-3 sm:px-5 sm:py-4'>
<div className='flex items-center gap-2'>
<item.icon className='text-muted-foreground/60 size-3.5 shrink-0' />
<div className='text-muted-foreground truncate text-xs font-medium tracking-wider uppercase'>
{item.label}
</div>
</div>
<div className='text-foreground mt-1.5 font-mono text-base font-bold tracking-tight break-all tabular-nums sm:mt-2 sm:text-2xl'>
{item.value}
</div>
<div className='text-muted-foreground/60 mt-1 hidden text-xs md:block'>
{item.description}
<div className='grid grid-cols-3 divide-x rounded-lg border'>
{stats.map((item) => (
<div key={item.label} className='min-w-0 px-2.5 py-2.5 sm:px-5 sm:py-4'>
<div className='flex items-center gap-1.5 sm:gap-2.5'>
<IconBadge tone={item.tone} size='stat'>
<item.icon />
</IconBadge>
<div className='text-muted-foreground truncate text-[11px] font-medium tracking-wider uppercase sm:text-xs'>
{item.label}
</div>
</div>
))}
</div>
<div className='text-foreground mt-1.5 font-mono text-sm font-bold tracking-tight break-all tabular-nums sm:mt-2.5 sm:text-2xl'>
{item.value}
</div>
<div className='text-muted-foreground/60 mt-1 hidden text-xs md:block'>
{item.description}
</div>
</div>
))}
</div>
)
}
+1
View File
@@ -3049,6 +3049,7 @@
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "Only configured combinations are overridden. All other calls keep the token group base ratio.",
"Only enabled parameters are sent with the request.": "Only enabled parameters are sent with the request.",
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.",
"Only Mine": "Only Mine",
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.",
"Only successful requests": "Only successful requests",
"Only successful requests count toward this limit.": "Only successful requests count toward this limit.",
+1
View File
@@ -3049,6 +3049,7 @@
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "Seules les combinaisons configurées sont remplacées. Les autres appels conservent le ratio de base du groupe du jeton.",
"Only enabled parameters are sent with the request.": "Seuls les paramètres activés sont envoyés avec la requête.",
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Saisissez uniquement lorigine du site, par exemple https://api.example.com. Najoutez aucun chemin comme /api/user/epay/notify. Laissez vide pour utiliser ladresse du serveur.",
"Only Mine": "Uniquement les miens",
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Seuls les champs sélectionnés seront écrasés. Vous pouvez relancer l'assistant de synchronisation si de nouveaux conflits apparaissent.",
"Only successful requests": "Uniquement les requêtes réussies",
"Only successful requests count toward this limit.": "Seules les requêtes réussies comptent pour cette limite.",
+1
View File
@@ -3049,6 +3049,7 @@
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "設定済みの組み合わせだけが上書きされます。他の呼び出しはトークングループの基本倍率を維持します。",
"Only enabled parameters are sent with the request.": "有効なパラメータだけがリクエストに送信されます。",
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "サイトのオリジンのみを入力してください。例: https://api.example.com。/api/user/epay/notify などのパスは含めないでください。空欄の場合はサーバーアドレスを使用します。",
"Only Mine": "自分のみ",
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "選択されたフィールドのみが上書きされます。新しい競合が発生した場合は、同期ウィザードを再実行できます。",
"Only successful requests": "成功したリクエストのみ",
"Only successful requests count toward this limit.": "成功したリクエストのみがこの制限にカウントされます。",
+1
View File
@@ -3049,6 +3049,7 @@
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "Переопределяются только настроенные комбинации. Остальные вызовы используют базовый коэффициент группы токена.",
"Only enabled parameters are sent with the request.": "С запросом отправляются только включенные параметры.",
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Введите только origin сайта, например https://api.example.com. Не добавляйте пути, например /api/user/epay/notify. Оставьте пустым, чтобы использовать адрес сервера.",
"Only Mine": "Только мои",
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Будут перезаписаны только выбранные поля. Вы можете повторно запустить мастер синхронизации, если появятся новые конфликты.",
"Only successful requests": "Только успешные запросы",
"Only successful requests count toward this limit.": "Только успешные запросы учитываются в этом лимите.",
+1
View File
@@ -3049,6 +3049,7 @@
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "Chỉ các tổ hợp đã cấu hình mới bị ghi đè. Các lệnh gọi khác giữ tỷ lệ cơ bản của nhóm token.",
"Only enabled parameters are sent with the request.": "Chỉ các tham số đã bật mới được gửi trong yêu cầu.",
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Chỉ nhập origin của trang, ví dụ https://api.example.com. Không nhập đường dẫn như /api/user/epay/notify. Để trống để dùng địa chỉ máy chủ.",
"Only Mine": "Chỉ của tôi",
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Chỉ các trường được chọn sẽ bị ghi đè. Bạn có thể chạy lại trình hướng dẫn đồng bộ hóa nếu có xung đột mới xuất hiện.",
"Only successful requests": "Chỉ các yêu cầu thành công",
"Only successful requests count toward this limit.": "Chỉ những yêu cầu thành công mới được tính vào giới hạn này.",
+2 -1
View File
@@ -3049,6 +3049,7 @@
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "只有已設定的組合會被覆蓋,其他呼叫仍使用令牌分組的基礎倍率。",
"Only enabled parameters are sent with the request.": "只有啟用的參數會隨請求傳送。",
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "只填寫站點根域名,例如 https://api.example.com。不要填寫 /api/user/epay/notify 這類路徑。留空則使用伺服器地址。",
"Only Mine": "僅自己",
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "僅選定的欄位將會被覆蓋。如果出現新的衝突,您可以重新執行同步精靈。",
"Only successful requests": "僅成功的請求",
"Only successful requests count toward this limit.": "僅成功的請求計入此限制。",
@@ -3266,7 +3267,7 @@
"Per 1M tokens": "每 1M tokens",
"per request": "每次請求",
"Per request": "每次請求",
"Per Request": "按請求",
"Per Request": "按次計費",
"Per-call": "每次呼叫",
"Per-feature metered windows split by model or capability.": "按模型或能力拆分的附加收費能力窗口。",
"Per-group performance": "各分組效能",
+2 -1
View File
@@ -3049,6 +3049,7 @@
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "只有已配置的组合会被覆盖,其他调用仍使用令牌分组的基础倍率。",
"Only enabled parameters are sent with the request.": "只有启用的参数会随请求发送。",
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "只填写站点根域名,例如 https://api.example.com。不要填写 /api/user/epay/notify 这类路径。留空则使用服务器地址。",
"Only Mine": "仅自己",
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "仅选定的字段将被覆盖。如果出现新的冲突,您可以重新运行同步向导。",
"Only successful requests": "仅成功的请求",
"Only successful requests count toward this limit.": "仅成功的请求计入此限制。",
@@ -3266,7 +3267,7 @@
"Per 1M tokens": "每 1M tokens",
"per request": "每次请求",
"Per request": "每次请求",
"Per Request": "按请求",
"Per Request": "按次计费",
"Per-call": "每次调用",
"Per-feature metered windows split by model or capability.": "按模型或能力拆分的附加计费能力窗口。",
"Per-group performance": "各分组性能",
+1 -1
View File
@@ -35,7 +35,7 @@ export function getUserAvatarStyle(name: string): UserAvatarStyle {
const lightness = 52 + ((hash >> 4) % 8)
return {
backgroundColor: `hsl(${hue} ${saturation}% ${lightness}% / 0.82)`,
backgroundColor: `hsl(${hue} ${saturation}% ${lightness}%)`,
color: 'white',
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ export const THEME_PRESETS = [
{
value: 'default',
name: 'Default',
swatches: ['oklch(0.13 0 0)', 'oklch(0.95 0 0)'],
swatches: ['oklch(0.72 0.18 250)', 'oklch(0.7 0.12 280)'],
},
{
// Inspired by Anthropic's official brand language: warm cream canvas
+38 -44
View File
@@ -109,13 +109,13 @@ For commercial licensing, please contact support@quantumnous.com
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.13 0 0);
--primary-foreground: oklch(0.985 0 0);
--primary: oklch(0.58 0.2 250);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.95 0 0);
--secondary-foreground: oklch(0.145 0 0);
--secondary-foreground: oklch(0.42 0.16 250);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.49 0 0);
--accent: oklch(0.95 0 0);
--accent: color-mix(in oklch, var(--primary) 12%, var(--background));
--accent-foreground: oklch(0.145 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
@@ -129,23 +129,23 @@ For commercial licensing, please contact support@quantumnous.com
--neutral-foreground: oklch(0.145 0 0);
--border: oklch(0.93 0 0);
--input: oklch(0.93 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--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);
--ring: oklch(0.58 0.2 250);
--chart-1: oklch(0.72 0.18 250);
--chart-2: oklch(0.65 0.15 200);
--chart-3: oklch(0.7 0.12 280);
--chart-4: oklch(0.68 0.19 325);
--chart-5: oklch(0.68 0.16 155);
--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);
--sidebar-accent: oklch(0.92 0 0);
--sidebar-accent-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.58 0.2 250);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: color-mix(in oklch, var(--primary) 12%, var(--background));
--sidebar-accent-foreground: oklch(0.42 0.16 250);
--sidebar-border: oklch(0.93 0 0);
--sidebar-ring: oklch(0.708 0 0);
--sidebar-ring: oklch(0.58 0.2 250);
--skeleton-base: oklch(0.97 0 0);
--skeleton-highlight: oklch(0.985 0 0);
--table-row: var(--background);
@@ -159,19 +159,17 @@ For commercial licensing, please contact support@quantumnous.com
var(--foreground) 3%,
var(--background)
);
--table-disabled: color-mix(
in oklch,
var(--foreground) 5.5%,
var(--background)
);
/* Disabled rows: kept subtle so they read as "quiet", not as a colored
alert state. */
--table-disabled: color-mix(in oklch, var(--foreground) 3%, var(--background));
--table-disabled-hover: color-mix(
in oklch,
var(--foreground) 7%,
var(--foreground) 4.5%,
var(--background)
);
--table-disabled-border: color-mix(
in oklch,
var(--foreground) 24%,
var(--foreground) 16%,
var(--background)
);
}
@@ -184,13 +182,13 @@ For commercial licensing, please contact support@quantumnous.com
--card-foreground: oklch(0.965 0 0);
--popover: oklch(0.305 0 0);
--popover-foreground: oklch(0.965 0 0);
--primary: oklch(0.965 0 0);
--primary-foreground: oklch(0.155 0 0);
--primary: oklch(0.66 0.17 250);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.335 0 0);
--secondary-foreground: oklch(0.965 0 0);
--secondary-foreground: oklch(0.9 0.05 250);
--muted: oklch(0.305 0 0);
--muted-foreground: oklch(0.78 0 0);
--accent: oklch(0.365 0 0);
--accent: color-mix(in oklch, var(--primary) 20%, var(--background));
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
@@ -204,20 +202,20 @@ For commercial licensing, please contact support@quantumnous.com
--neutral-foreground: oklch(0.155 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 17%);
--ring: oklch(0.68 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--ring: oklch(0.66 0.17 250);
--chart-1: oklch(0.76 0.14 250);
--chart-2: oklch(0.76 0.13 200);
--chart-3: oklch(0.76 0.13 285);
--chart-4: oklch(0.75 0.16 325);
--chart-5: oklch(0.76 0.14 155);
--sidebar: oklch(0.225 0 0);
--sidebar-foreground: oklch(0.95 0 0);
--sidebar-primary: oklch(0.965 0 0);
--sidebar-primary-foreground: oklch(0.155 0 0);
--sidebar-accent: oklch(0.355 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.66 0.17 250);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: color-mix(in oklch, var(--primary) 20%, var(--background));
--sidebar-accent-foreground: oklch(0.9 0.05 250);
--sidebar-border: oklch(1 0 0 / 11%);
--sidebar-ring: oklch(0.68 0 0);
--sidebar-ring: oklch(0.66 0.17 250);
--skeleton-base: oklch(0.335 0 0);
--skeleton-highlight: oklch(0.44 0 0);
--table-row: var(--background);
@@ -227,19 +225,15 @@ For commercial licensing, please contact support@quantumnous.com
var(--foreground) 9%,
var(--background)
);
--table-disabled: color-mix(
in oklch,
var(--foreground) 10%,
var(--background)
);
--table-disabled: color-mix(in oklch, var(--foreground) 7%, var(--background));
--table-disabled-hover: color-mix(
in oklch,
var(--foreground) 13%,
var(--foreground) 9.5%,
var(--background)
);
--table-disabled-border: color-mix(
in oklch,
var(--foreground) 34%,
var(--foreground) 24%,
var(--background)
);
}