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