feat: update theme colors
This commit is contained in:
+16
-18
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { Radio as RadioPrimitive } from '@base-ui/react/radio'
|
||||
import { RadioGroup as Radio } from '@base-ui/react/radio-group'
|
||||
import { CircleCheck, Palette, RotateCcw } from 'lucide-react'
|
||||
import { type SVGProps } from 'react'
|
||||
import type { SVGProps } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { IconDir } from '@/assets/custom/icon-dir'
|
||||
@@ -277,16 +277,12 @@ function PresetConfig() {
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute inset-0 rounded-md'
|
||||
style={
|
||||
preset.value === 'default'
|
||||
? {
|
||||
background:
|
||||
'linear-gradient(135deg, var(--background) 0%, var(--muted) 50%, var(--foreground) 100%)',
|
||||
}
|
||||
: {
|
||||
background: `linear-gradient(135deg, ${preset.swatches[0]} 0%, ${preset.swatches[1] ?? preset.swatches[0]} 100%)`,
|
||||
}
|
||||
}
|
||||
style={{
|
||||
background:
|
||||
preset.value === 'default'
|
||||
? 'linear-gradient(135deg, oklch(0.68 0.2 25) 0%, oklch(0.8 0.17 85) 25%, oklch(0.72 0.18 155) 50%, oklch(0.66 0.19 245) 75%, oklch(0.68 0.2 315) 100%)'
|
||||
: `linear-gradient(135deg, ${preset.swatches[0]} 0%, ${preset.swatches[1] ?? preset.swatches[0]} 100%)`,
|
||||
}}
|
||||
/>
|
||||
<CircleCheck
|
||||
className={cn(
|
||||
@@ -469,13 +465,15 @@ function ScalePreview(props: { rows: number; rowGap: string }) {
|
||||
className='absolute inset-2.5 flex flex-col justify-center'
|
||||
style={{ gap: props.rowGap }}
|
||||
>
|
||||
{Array.from({ length: props.rows }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className='bg-foreground/60 block h-[2px] rounded-full'
|
||||
style={{ width: `${85 - i * 10}%` }}
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: props.rows }, (_, index) => 85 - index * 10).map(
|
||||
(width) => (
|
||||
<span
|
||||
key={width}
|
||||
className='bg-foreground/60 block h-[2px] rounded-full'
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+4
-5
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { createElement, type ReactNode } from 'react'
|
||||
|
||||
import { IconBadge, type IconBadgeTone } from '@/components/ui/icon-badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const sideDrawerContentClassName = (className?: string) =>
|
||||
@@ -71,6 +72,7 @@ export function SideDrawerSectionHeader(props: {
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
icon?: ReactNode
|
||||
iconTone?: IconBadgeTone
|
||||
className?: string
|
||||
}) {
|
||||
return createElement(
|
||||
@@ -78,11 +80,8 @@ export function SideDrawerSectionHeader(props: {
|
||||
{ className: cn('flex items-start gap-3', props.className) },
|
||||
props.icon
|
||||
? createElement(
|
||||
'span',
|
||||
{
|
||||
className:
|
||||
'bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md',
|
||||
},
|
||||
IconBadge,
|
||||
{ tone: props.iconTone, size: 'md' },
|
||||
props.icon
|
||||
)
|
||||
: null,
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const iconBadgeVariants = cva(
|
||||
'flex shrink-0 items-center justify-center [&>svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
tone: {
|
||||
neutral: 'bg-muted text-muted-foreground',
|
||||
primary: 'bg-primary/10 text-primary',
|
||||
success: 'bg-success/10 text-success',
|
||||
warning: 'bg-warning/10 text-warning',
|
||||
info: 'bg-info/10 text-info',
|
||||
destructive: 'bg-destructive/10 text-destructive',
|
||||
'chart-1': 'bg-chart-1/10 text-chart-1',
|
||||
'chart-2': 'bg-chart-2/10 text-chart-2',
|
||||
'chart-3': 'bg-chart-3/10 text-chart-3',
|
||||
'chart-4': 'bg-chart-4/10 text-chart-4',
|
||||
'chart-5': 'bg-chart-5/10 text-chart-5',
|
||||
},
|
||||
size: {
|
||||
xs: 'size-5 rounded-md [&>svg]:size-3',
|
||||
sm: 'size-7 rounded-md [&>svg]:size-3.5',
|
||||
md: 'size-8 rounded-lg [&>svg]:size-4',
|
||||
title: 'size-8 rounded-lg sm:size-9 [&>svg]:size-4',
|
||||
lg: 'size-10 rounded-xl [&>svg]:size-5',
|
||||
stat: 'size-5 rounded-md sm:size-7 [&>svg]:size-3 sm:[&>svg]:size-3.5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
tone: 'neutral',
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export type IconBadgeTone = NonNullable<
|
||||
VariantProps<typeof iconBadgeVariants>['tone']
|
||||
>
|
||||
|
||||
export type IconBadgeSize = NonNullable<
|
||||
VariantProps<typeof iconBadgeVariants>['size']
|
||||
>
|
||||
|
||||
interface IconBadgeProps {
|
||||
children?: ReactNode
|
||||
tone?: IconBadgeTone
|
||||
size?: IconBadgeSize
|
||||
className?: string
|
||||
decorative?: boolean
|
||||
}
|
||||
|
||||
export function IconBadge(props: IconBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
iconBadgeVariants({ tone: props.tone, size: props.size }),
|
||||
props.className
|
||||
)}
|
||||
aria-hidden={props.decorative ?? true}
|
||||
>
|
||||
{props.children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -477,7 +477,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
|
||||
<ul
|
||||
data-slot='sidebar-menu'
|
||||
data-sidebar='menu'
|
||||
className={cn('flex w-full min-w-0 flex-col gap-0', className)}
|
||||
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot='table-body'
|
||||
className={cn('[&_tr:last-child]:border-0', className)}
|
||||
className={cn('[&>tr]:h-15 [&_tr:last-child]:border-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
+5
-7
@@ -27,6 +27,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from './card'
|
||||
import { IconBadge, type IconBadgeTone } from './icon-badge'
|
||||
|
||||
type TitledCardProps = {
|
||||
title: ReactNode
|
||||
@@ -39,6 +40,7 @@ type TitledCardProps = {
|
||||
headerClassName?: string
|
||||
contentClassName?: string
|
||||
iconClassName?: string
|
||||
iconTone?: IconBadgeTone
|
||||
titleClassName?: string
|
||||
descriptionClassName?: string
|
||||
}
|
||||
@@ -54,6 +56,7 @@ export function TitledCard({
|
||||
headerClassName,
|
||||
contentClassName,
|
||||
iconClassName,
|
||||
iconTone,
|
||||
titleClassName,
|
||||
descriptionClassName,
|
||||
}: TitledCardProps) {
|
||||
@@ -68,14 +71,9 @@ export function TitledCard({
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
|
||||
<div className='flex min-w-0 items-center gap-3'>
|
||||
{icon != null && (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-lg sm:h-9 sm:w-9',
|
||||
iconClassName
|
||||
)}
|
||||
>
|
||||
<IconBadge size='title' tone={iconTone} className={iconClassName}>
|
||||
{icon}
|
||||
</div>
|
||||
</IconBadge>
|
||||
)}
|
||||
<div className='min-w-0'>
|
||||
<CardTitle
|
||||
|
||||
+7
-6
@@ -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'>
|
||||
|
||||
+31
-11
@@ -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}
|
||||
|
||||
Vendored
+1
@@ -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>
|
||||
|
||||
+1
@@ -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>
|
||||
|
||||
+1
@@ -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>
|
||||
|
||||
+4
-1
@@ -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>
|
||||
|
||||
+14
-11
@@ -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(
|
||||
|
||||
+4
-1
@@ -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>
|
||||
}
|
||||
|
||||
+11
-7
@@ -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]
|
||||
|
||||
+14
-8
@@ -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>
|
||||
}
|
||||
|
||||
+59
-14
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
+175
@@ -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
|
||||
|
||||
+42
-23
@@ -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>
|
||||
|
||||
|
||||
+79
-62
@@ -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()
|
||||
|
||||
|
||||
+4
-1
@@ -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')}
|
||||
</>
|
||||
}
|
||||
|
||||
+30
-18
@@ -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>
|
||||
|
||||
+155
-30
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -3049,6 +3049,7 @@
|
||||
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "Only configured combinations are overridden. All other calls keep the token group base ratio.",
|
||||
"Only enabled parameters are sent with the request.": "Only enabled parameters are sent with the request.",
|
||||
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.",
|
||||
"Only Mine": "Only Mine",
|
||||
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.",
|
||||
"Only successful requests": "Only successful requests",
|
||||
"Only successful requests count toward this limit.": "Only successful requests count toward this limit.",
|
||||
|
||||
Vendored
+1
@@ -3049,6 +3049,7 @@
|
||||
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "Seules les combinaisons configurées sont remplacées. Les autres appels conservent le ratio de base du groupe du jeton.",
|
||||
"Only enabled parameters are sent with the request.": "Seuls les paramètres activés sont envoyés avec la requête.",
|
||||
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Saisissez uniquement l’origine du site, par exemple https://api.example.com. N’ajoutez aucun chemin comme /api/user/epay/notify. Laissez vide pour utiliser l’adresse du serveur.",
|
||||
"Only Mine": "Uniquement les miens",
|
||||
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Seuls les champs sélectionnés seront écrasés. Vous pouvez relancer l'assistant de synchronisation si de nouveaux conflits apparaissent.",
|
||||
"Only successful requests": "Uniquement les requêtes réussies",
|
||||
"Only successful requests count toward this limit.": "Seules les requêtes réussies comptent pour cette limite.",
|
||||
|
||||
Vendored
+1
@@ -3049,6 +3049,7 @@
|
||||
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "設定済みの組み合わせだけが上書きされます。他の呼び出しはトークングループの基本倍率を維持します。",
|
||||
"Only enabled parameters are sent with the request.": "有効なパラメータだけがリクエストに送信されます。",
|
||||
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "サイトのオリジンのみを入力してください。例: https://api.example.com。/api/user/epay/notify などのパスは含めないでください。空欄の場合はサーバーアドレスを使用します。",
|
||||
"Only Mine": "自分のみ",
|
||||
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "選択されたフィールドのみが上書きされます。新しい競合が発生した場合は、同期ウィザードを再実行できます。",
|
||||
"Only successful requests": "成功したリクエストのみ",
|
||||
"Only successful requests count toward this limit.": "成功したリクエストのみがこの制限にカウントされます。",
|
||||
|
||||
Vendored
+1
@@ -3049,6 +3049,7 @@
|
||||
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "Переопределяются только настроенные комбинации. Остальные вызовы используют базовый коэффициент группы токена.",
|
||||
"Only enabled parameters are sent with the request.": "С запросом отправляются только включенные параметры.",
|
||||
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Введите только origin сайта, например https://api.example.com. Не добавляйте пути, например /api/user/epay/notify. Оставьте пустым, чтобы использовать адрес сервера.",
|
||||
"Only Mine": "Только мои",
|
||||
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Будут перезаписаны только выбранные поля. Вы можете повторно запустить мастер синхронизации, если появятся новые конфликты.",
|
||||
"Only successful requests": "Только успешные запросы",
|
||||
"Only successful requests count toward this limit.": "Только успешные запросы учитываются в этом лимите.",
|
||||
|
||||
Vendored
+1
@@ -3049,6 +3049,7 @@
|
||||
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "Chỉ các tổ hợp đã cấu hình mới bị ghi đè. Các lệnh gọi khác giữ tỷ lệ cơ bản của nhóm token.",
|
||||
"Only enabled parameters are sent with the request.": "Chỉ các tham số đã bật mới được gửi trong yêu cầu.",
|
||||
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Chỉ nhập origin của trang, ví dụ https://api.example.com. Không nhập đường dẫn như /api/user/epay/notify. Để trống để dùng địa chỉ máy chủ.",
|
||||
"Only Mine": "Chỉ của tôi",
|
||||
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Chỉ các trường được chọn sẽ bị ghi đè. Bạn có thể chạy lại trình hướng dẫn đồng bộ hóa nếu có xung đột mới xuất hiện.",
|
||||
"Only successful requests": "Chỉ các yêu cầu thành công",
|
||||
"Only successful requests count toward this limit.": "Chỉ những yêu cầu thành công mới được tính vào giới hạn này.",
|
||||
|
||||
+2
-1
@@ -3049,6 +3049,7 @@
|
||||
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "只有已設定的組合會被覆蓋,其他呼叫仍使用令牌分組的基礎倍率。",
|
||||
"Only enabled parameters are sent with the request.": "只有啟用的參數會隨請求傳送。",
|
||||
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "只填寫站點根域名,例如 https://api.example.com。不要填寫 /api/user/epay/notify 這類路徑。留空則使用伺服器地址。",
|
||||
"Only Mine": "僅自己",
|
||||
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "僅選定的欄位將會被覆蓋。如果出現新的衝突,您可以重新執行同步精靈。",
|
||||
"Only successful requests": "僅成功的請求",
|
||||
"Only successful requests count toward this limit.": "僅成功的請求計入此限制。",
|
||||
@@ -3266,7 +3267,7 @@
|
||||
"Per 1M tokens": "每 1M tokens",
|
||||
"per request": "每次請求",
|
||||
"Per request": "每次請求",
|
||||
"Per Request": "按請求",
|
||||
"Per Request": "按次計費",
|
||||
"Per-call": "每次呼叫",
|
||||
"Per-feature metered windows split by model or capability.": "按模型或能力拆分的附加收費能力窗口。",
|
||||
"Per-group performance": "各分組效能",
|
||||
|
||||
Vendored
+2
-1
@@ -3049,6 +3049,7 @@
|
||||
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "只有已配置的组合会被覆盖,其他调用仍使用令牌分组的基础倍率。",
|
||||
"Only enabled parameters are sent with the request.": "只有启用的参数会随请求发送。",
|
||||
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "只填写站点根域名,例如 https://api.example.com。不要填写 /api/user/epay/notify 这类路径。留空则使用服务器地址。",
|
||||
"Only Mine": "仅自己",
|
||||
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "仅选定的字段将被覆盖。如果出现新的冲突,您可以重新运行同步向导。",
|
||||
"Only successful requests": "仅成功的请求",
|
||||
"Only successful requests count toward this limit.": "仅成功的请求计入此限制。",
|
||||
@@ -3266,7 +3267,7 @@
|
||||
"Per 1M tokens": "每 1M tokens",
|
||||
"per request": "每次请求",
|
||||
"Per request": "每次请求",
|
||||
"Per Request": "按请求",
|
||||
"Per Request": "按次计费",
|
||||
"Per-call": "每次调用",
|
||||
"Per-feature metered windows split by model or capability.": "按模型或能力拆分的附加计费能力窗口。",
|
||||
"Per-group performance": "各分组性能",
|
||||
|
||||
Vendored
+1
-1
@@ -35,7 +35,7 @@ export function getUserAvatarStyle(name: string): UserAvatarStyle {
|
||||
const lightness = 52 + ((hash >> 4) % 8)
|
||||
|
||||
return {
|
||||
backgroundColor: `hsl(${hue} ${saturation}% ${lightness}% / 0.82)`,
|
||||
backgroundColor: `hsl(${hue} ${saturation}% ${lightness}%)`,
|
||||
color: 'white',
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ export const THEME_PRESETS = [
|
||||
{
|
||||
value: 'default',
|
||||
name: 'Default',
|
||||
swatches: ['oklch(0.13 0 0)', 'oklch(0.95 0 0)'],
|
||||
swatches: ['oklch(0.72 0.18 250)', 'oklch(0.7 0.12 280)'],
|
||||
},
|
||||
{
|
||||
// Inspired by Anthropic's official brand language: warm cream canvas
|
||||
|
||||
Vendored
+38
-44
@@ -109,13 +109,13 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.13 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.58 0.2 250);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.95 0 0);
|
||||
--secondary-foreground: oklch(0.145 0 0);
|
||||
--secondary-foreground: oklch(0.42 0.16 250);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.49 0 0);
|
||||
--accent: oklch(0.95 0 0);
|
||||
--accent: color-mix(in oklch, var(--primary) 12%, var(--background));
|
||||
--accent-foreground: oklch(0.145 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
@@ -129,23 +129,23 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
--neutral-foreground: oklch(0.145 0 0);
|
||||
--border: oklch(0.93 0 0);
|
||||
--input: oklch(0.93 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--ring: oklch(0.58 0.2 250);
|
||||
--chart-1: oklch(0.72 0.18 250);
|
||||
--chart-2: oklch(0.65 0.15 200);
|
||||
--chart-3: oklch(0.7 0.12 280);
|
||||
--chart-4: oklch(0.68 0.19 325);
|
||||
--chart-5: oklch(0.68 0.16 155);
|
||||
--overview-accent-1: oklch(0.72 0.18 250);
|
||||
--overview-accent-2: oklch(0.65 0.15 200);
|
||||
--overview-accent-3: oklch(0.7 0.12 280);
|
||||
--sidebar: color-mix(in oklch, var(--foreground) 0.5%, var(--background));
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.13 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.92 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.58 0.2 250);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: color-mix(in oklch, var(--primary) 12%, var(--background));
|
||||
--sidebar-accent-foreground: oklch(0.42 0.16 250);
|
||||
--sidebar-border: oklch(0.93 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--sidebar-ring: oklch(0.58 0.2 250);
|
||||
--skeleton-base: oklch(0.97 0 0);
|
||||
--skeleton-highlight: oklch(0.985 0 0);
|
||||
--table-row: var(--background);
|
||||
@@ -159,19 +159,17 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
var(--foreground) 3%,
|
||||
var(--background)
|
||||
);
|
||||
--table-disabled: color-mix(
|
||||
in oklch,
|
||||
var(--foreground) 5.5%,
|
||||
var(--background)
|
||||
);
|
||||
/* Disabled rows: kept subtle so they read as "quiet", not as a colored
|
||||
alert state. */
|
||||
--table-disabled: color-mix(in oklch, var(--foreground) 3%, var(--background));
|
||||
--table-disabled-hover: color-mix(
|
||||
in oklch,
|
||||
var(--foreground) 7%,
|
||||
var(--foreground) 4.5%,
|
||||
var(--background)
|
||||
);
|
||||
--table-disabled-border: color-mix(
|
||||
in oklch,
|
||||
var(--foreground) 24%,
|
||||
var(--foreground) 16%,
|
||||
var(--background)
|
||||
);
|
||||
}
|
||||
@@ -184,13 +182,13 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
--card-foreground: oklch(0.965 0 0);
|
||||
--popover: oklch(0.305 0 0);
|
||||
--popover-foreground: oklch(0.965 0 0);
|
||||
--primary: oklch(0.965 0 0);
|
||||
--primary-foreground: oklch(0.155 0 0);
|
||||
--primary: oklch(0.66 0.17 250);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.335 0 0);
|
||||
--secondary-foreground: oklch(0.965 0 0);
|
||||
--secondary-foreground: oklch(0.9 0.05 250);
|
||||
--muted: oklch(0.305 0 0);
|
||||
--muted-foreground: oklch(0.78 0 0);
|
||||
--accent: oklch(0.365 0 0);
|
||||
--accent: color-mix(in oklch, var(--primary) 20%, var(--background));
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
@@ -204,20 +202,20 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
--neutral-foreground: oklch(0.155 0 0);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 17%);
|
||||
--ring: oklch(0.68 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--ring: oklch(0.66 0.17 250);
|
||||
--chart-1: oklch(0.76 0.14 250);
|
||||
--chart-2: oklch(0.76 0.13 200);
|
||||
--chart-3: oklch(0.76 0.13 285);
|
||||
--chart-4: oklch(0.75 0.16 325);
|
||||
--chart-5: oklch(0.76 0.14 155);
|
||||
--sidebar: oklch(0.225 0 0);
|
||||
--sidebar-foreground: oklch(0.95 0 0);
|
||||
--sidebar-primary: oklch(0.965 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.155 0 0);
|
||||
--sidebar-accent: oklch(0.355 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.66 0.17 250);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: color-mix(in oklch, var(--primary) 20%, var(--background));
|
||||
--sidebar-accent-foreground: oklch(0.9 0.05 250);
|
||||
--sidebar-border: oklch(1 0 0 / 11%);
|
||||
--sidebar-ring: oklch(0.68 0 0);
|
||||
--sidebar-ring: oklch(0.66 0.17 250);
|
||||
--skeleton-base: oklch(0.335 0 0);
|
||||
--skeleton-highlight: oklch(0.44 0 0);
|
||||
--table-row: var(--background);
|
||||
@@ -227,19 +225,15 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
var(--foreground) 9%,
|
||||
var(--background)
|
||||
);
|
||||
--table-disabled: color-mix(
|
||||
in oklch,
|
||||
var(--foreground) 10%,
|
||||
var(--background)
|
||||
);
|
||||
--table-disabled: color-mix(in oklch, var(--foreground) 7%, var(--background));
|
||||
--table-disabled-hover: color-mix(
|
||||
in oklch,
|
||||
var(--foreground) 13%,
|
||||
var(--foreground) 9.5%,
|
||||
var(--background)
|
||||
);
|
||||
--table-disabled-border: color-mix(
|
||||
in oklch,
|
||||
var(--foreground) 34%,
|
||||
var(--foreground) 24%,
|
||||
var(--background)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user