Commit 2bec3706 by CaIon

feat(web): refine API key and user quota displays

Show remaining and used API key quota with a progress bar, and use consistent mobile cards, group multiplier badges, and activity timestamps. Present available user balance with used quota underneath and translate the new labels.

Resolve full API keys only for explicit copy or chat actions. Reviewed OWASP Authentication and Session Management guidance and ASVS 5.0.0 V14.2.6 and V8.3.1; backend authorization is unchanged, and regression tests cover refused and denied key resolution. This frontend change does not assert application-wide ASVS compliance.

Validation: 55 related component tests passed; the latest mobile group and quota changes passed 35 focused tests. TypeScript, scoped lint, formatting, production build, and git diff checks passed. Responsive previews verified narrow screens and finite, unlimited, exhausted, and inactive quota states.
parent a5e41a89
......@@ -148,7 +148,7 @@ function SplitHeaderTableView<TData>({
<table
data-slot='table'
className={cn(
'w-full caption-bottom text-sm tabular-nums [&_td]:text-sm [&_td_*]:text-sm [&_th]:text-sm [&_th_*]:text-sm',
'w-full caption-bottom text-sm tabular-nums',
props.tableClassName
)}
style={tableSizing.style}
......
......@@ -33,6 +33,7 @@ type TruncatedCellProps = {
side?: 'top' | 'bottom' | 'left' | 'right'
tooltipClassName?: string
tooltipContent?: React.ReactNode
tabIndex?: number
}
export function TruncatedCell({
......@@ -43,12 +44,14 @@ export function TruncatedCell({
side = 'top',
tooltipClassName,
tooltipContent,
tabIndex,
}: TruncatedCellProps) {
const content = tooltipContent ?? getTextContent(children)
if (!content) {
return (
<div
tabIndex={tabIndex}
className={cn(
'block max-w-full min-w-0 truncate',
cellClassName,
......@@ -65,6 +68,7 @@ export function TruncatedCell({
<TooltipTrigger
render={
<div
tabIndex={tabIndex}
className={cn(
'block max-w-full min-w-0 truncate',
cellClassName,
......
......@@ -16,11 +16,43 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { StatusBadge, type StatusBadgeProps } from './status-badge'
import { Badge } from './ui/badge'
export function GroupMultiplierBadge(props: {
children?: ReactNode
className?: string
label?: string
ratio?: number | null
}) {
let colorClassName =
'border-muted-foreground/30 bg-muted text-muted-foreground'
if (props.ratio != null && props.ratio > 1) {
colorClassName = 'border-warning/30 bg-warning/10 text-warning'
} else if (props.ratio != null && props.ratio < 1) {
colorClassName = 'border-info/30 bg-info/10 text-info'
}
return (
<Badge
variant='outline'
className={cn(
'relative h-5 min-w-12 rounded-full px-1.5 py-0 text-xs leading-none font-medium shadow-none',
!props.label && 'font-mono tabular-nums',
colorClassName,
props.className
)}
>
{props.children}
<span>{props.label ?? `${props.ratio}x`}</span>
</Badge>
)
}
type GroupBadgeProps = Omit<
StatusBadgeProps,
......@@ -29,16 +61,8 @@ type GroupBadgeProps = Omit<
group?: string | null
label?: string
ratio?: number | null
}
function getGroupRatioClassName(ratio: number): string {
if (ratio > 1) {
return 'bg-warning/10 text-warning'
}
if (ratio < 1) {
return 'bg-info/10 text-info'
}
return 'bg-muted text-muted-foreground'
ratioLabel?: string
containerClassName?: string
}
function getGroupLabel(params: {
......@@ -60,6 +84,8 @@ export function GroupBadge(props: GroupBadgeProps) {
group,
label: labelOverride,
ratio,
ratioLabel,
containerClassName,
copyable = false,
showDot,
className,
......@@ -89,21 +115,19 @@ export function GroupBadge(props: GroupBadgeProps) {
/>
)
if (ratio == null) {
if (ratio == null && !ratioLabel) {
return badge
}
return (
<span className='inline-flex max-w-full min-w-0 items-center gap-2 text-xs'>
<span
className={cn(
'inline-flex max-w-full min-w-0 items-center gap-2 text-xs',
containerClassName
)}
>
<span className='max-w-full min-w-0 overflow-hidden'>{badge}</span>
<span
className={cn(
'inline-flex h-5 shrink-0 items-center rounded-full px-1.5 font-mono text-xs leading-none font-medium tabular-nums',
getGroupRatioClassName(ratio)
)}
>
<span>{ratio}x</span>
</span>
<GroupMultiplierBadge ratio={ratio} label={ratioLabel} />
</span>
)
}
......@@ -30,10 +30,7 @@ function Table({ className, ...props }: React.ComponentProps<'table'>) {
>
<table
data-slot='table'
className={cn(
'w-full caption-bottom text-sm tabular-nums [&_td]:text-sm [&_td_*]:text-sm [&_th]:text-sm [&_th_*]:text-sm',
className
)}
className={cn('w-full caption-bottom text-sm tabular-nums', className)}
{...props}
/>
</div>
......
......@@ -16,7 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { render } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, test } from 'vitest'
const { createInstance } = await import('i18next')
......@@ -61,93 +62,99 @@ function CellHarness(props: {
}
describe('API key group table cell', () => {
test('renders an unclipped ring and a localized Auto ratio when API data uses a nonlocalized string', () => {
test('keeps the group and compact localized multiplier together with one subtle flowing edge', () => {
const { container } = render(
<CellHarness
group='auto'
ratio='自动'
crossGroupRetry
shouldReduceMotion={false}
/>
<CellHarness group='auto' ratio='自动' crossGroupRetry />
)
const badgeCell = container.querySelector<HTMLElement>(
'[data-api-key-group-cell="auto"]'
)
expect(badgeCell).toHaveClass('overflow-visible')
expect(badgeCell).not.toHaveClass('overflow-hidden')
const frames = container.querySelectorAll('[data-auto-group-frame]')
const movingRings = container.querySelectorAll(
'[data-auto-group-flow-border]'
)
expect(frames.length).toBe(1)
expect(movingRings.length).toBe(1)
for (const frame of frames) {
expect(frame).toHaveClass(
'relative',
'overflow-visible',
'rounded-4xl',
'p-px'
)
}
const ratio = container.querySelector<HTMLElement>(
'[data-auto-group-effect="ratio"]'
)
expect(ratio).toHaveTextContent('Auto Ratio')
expect(ratio).not.toHaveTextContent('x')
const group = screen.getByText('Cross-group')
const multiplier = screen
.getByText('Auto')
.closest<HTMLElement>('[data-slot="badge"]')
expect(group).toBeInTheDocument()
expect(multiplier).toHaveClass('h-5', 'min-w-12', 'rounded-md')
expect(multiplier).not.toHaveTextContent('Ratio')
expect(container).not.toHaveTextContent('自动')
expect(container).toHaveTextContent('Cross-group')
const crossGroupBadge = [
...container.querySelectorAll<HTMLElement>('[data-slot="status-badge"]'),
].find((badge) => badge.textContent === 'Cross-group')
expect(crossGroupBadge).not.toBeUndefined()
expect(crossGroupBadge?.closest('[data-auto-group-frame]')).toBeNull()
expect(container.querySelector('[data-auto-group-frame]')).toBeNull()
const flow = container.querySelector('[data-auto-group-flow-border]')
expect(flow).toHaveClass('auto-group-flow-border-subtle')
expect(flow).toHaveAttribute('aria-hidden', 'true')
expect(group.closest('[data-api-key-group-cell]')).toContainElement(
multiplier
)
})
test('keeps the static Auto ratio frame but omits its moving layer for reduced motion', () => {
test('keeps the automatic tag visible but static when reduced motion is requested', () => {
const { container } = render(
<CellHarness group='auto' ratio='Auto' shouldReduceMotion />
)
expect(screen.getByText('Auto')).toBeInTheDocument()
expect(container.querySelector('[data-auto-group-flow-border]')).toBeNull()
})
expect(container.querySelectorAll('[data-auto-group-frame]').length).toBe(1)
expect(
container.querySelectorAll('[data-auto-group-flow-border]').length
).toBe(0)
test('does not invent a multiplier while automatic ratio data is unavailable', () => {
render(<CellHarness group='auto' />)
expect(screen.getByText('Cross-group')).toBeInTheDocument()
expect(screen.queryByText('Auto')).not.toBeInTheDocument()
})
test('shows only the cross-group badge when ratio data is unavailable', () => {
const { container } = render(
<CellHarness group='auto' shouldReduceMotion={false} />
)
test.each([
[0.8, 'bg-info/10', 'text-info', 'border-info/30'],
[1, 'bg-muted', 'text-muted-foreground', 'border-muted-foreground/30'],
[3, 'bg-warning/10', 'text-warning', 'border-warning/30'],
])(
'preserves the original %s multiplier color in the compact layout',
(ratio, background, color, border) => {
const { container } = render(
<CellHarness group='default' ratio={ratio} />
)
const multiplier = screen.getByText(`${ratio}x`).parentElement
expect(multiplier).toHaveClass(
background,
color,
border,
'rounded-full',
'font-mono',
'h-5',
'min-w-12'
)
expect(
container.querySelector('[data-auto-group-flow-border]')
).toBeNull()
}
)
expect(container.querySelectorAll('[data-auto-group-frame]').length).toBe(0)
expect(
container.querySelectorAll('[data-auto-group-flow-border]').length
).toBe(0)
expect(container.querySelector('[data-auto-group-effect="ratio"]')).toBe(
null
test('labels the user group multiplier as inherited without inventing a numeric value', async () => {
render(<CellHarness group='' />)
expect(screen.getByText('User Group')).toBeInTheDocument()
expect(screen.getByText('Inherited')).toBeInTheDocument()
expect(screen.getByText('Inherited').parentElement).toHaveClass(
'border-muted-foreground/30',
'rounded-full'
)
expect(container).toHaveTextContent('Cross-group')
expect(container).not.toHaveTextContent('Auto')
expect(container).not.toHaveTextContent('Ratio')
expect(screen.queryByText('1x')).not.toBeInTheDocument()
await userEvent.tab()
expect(await screen.findByText('Follow user group')).toBeVisible()
})
test('narrows normal group ratios to numbers and never applies Auto rings', () => {
const { container, rerender } = render(
<CellHarness group='vip' ratio='自动' shouldReduceMotion={false} />
)
expect(container).toHaveTextContent('vip')
expect(container).not.toHaveTextContent('自动')
expect(container.querySelector('[data-auto-group-frame]')).toBe(null)
expect(container.querySelector('[data-auto-group-flow-border]')).toBe(null)
rerender(<CellHarness group='vip' ratio={3} shouldReduceMotion={false} />)
test('keeps a long group name and exact multiplier available through keyboard focus', async () => {
const groupName = 'production-with-a-very-long-custom-group-name'
render(<CellHarness group={groupName} ratio={12.345678} />)
expect(
screen.getByText(groupName).closest('[data-slot="tooltip-trigger"]')
).toHaveClass('max-w-50')
expect(screen.getByText('12.345678x')).toBeInTheDocument()
await userEvent.tab()
expect(
await screen.findByText(groupName, {
selector: '[data-slot="tooltip-content"]',
})
).toBeVisible()
})
expect(container).toHaveTextContent('3x')
expect(container.querySelector('[data-auto-group-frame]')).toBe(null)
test('never turns a string-valued normal group ratio into an automatic multiplier', () => {
render(<CellHarness group='vip' ratio='自动' />)
expect(screen.getByText('vip')).toBeInTheDocument()
expect(screen.queryByText('Auto')).not.toBeInTheDocument()
expect(screen.queryByText('自动')).not.toBeInTheDocument()
})
})
......@@ -97,7 +97,7 @@ function getCommandItem(label: string): HTMLElement {
}
describe('API key group combobox Auto effect', () => {
test('rings the selected Auto trigger and its localized ratio without rendering the API ratio text', () => {
test('uses the compact table capsules in the selected group and dropdown options', () => {
setReducedMotion(false)
render(<Harness initialValue='auto' />)
......@@ -116,20 +116,23 @@ describe('API key group combobox Auto effect', () => {
'auto-group-flow-border'
)
const triggerRatio = trigger.querySelector<HTMLElement>(
'[data-auto-group-effect="ratio"]'
)
expect(triggerRatio).toHaveTextContent('Auto Ratio')
const triggerRatio = within(trigger)
.getByText('Auto')
.closest('[data-slot="badge"]')
expect(triggerRatio).toHaveTextContent('Auto')
expect(triggerRatio).not.toHaveTextContent('Ratio')
expect(triggerRatio).not.toHaveTextContent('x')
expect(trigger).not.toHaveTextContent('自动')
expect(triggerRatio).toHaveClass(
'relative',
'overflow-visible',
'rounded-4xl'
'rounded-md',
'h-5',
'min-w-12'
)
expect(
triggerRatio?.querySelector('[data-auto-group-flow-border]')
).toBeInTheDocument()
).toHaveClass('auto-group-flow-border-subtle')
fireEvent.click(trigger)
expect(trigger).toHaveAttribute('aria-expanded', 'true')
......@@ -142,20 +145,31 @@ describe('API key group combobox Auto effect', () => {
expect(
autoOption.querySelector('[data-auto-group-flow-border]')
).toBeInTheDocument()
const optionRatio = autoOption.querySelector<HTMLElement>(
'[data-auto-group-effect="ratio"]'
)
expect(optionRatio).toHaveTextContent('Auto Ratio')
const optionRatio = within(autoOption)
.getByText('Auto')
.closest('[data-slot="badge"]')
expect(optionRatio).toHaveTextContent('Auto')
expect(optionRatio).not.toHaveTextContent('Ratio')
expect(
optionRatio?.querySelector('[data-auto-group-flow-border]')
).toBeInTheDocument()
).toHaveClass('auto-group-flow-border-subtle')
const defaultOption = getCommandItem('User group')
expect(defaultOption).not.toHaveAttribute('data-auto-group-effect')
expect(defaultOption.querySelector('[data-auto-group-flow-border]')).toBe(
null
)
expect(defaultOption).toHaveTextContent('1x Ratio')
const defaultRatio = within(defaultOption)
.getByText('1x')
.closest('[data-slot="badge"]')
expect(defaultRatio).toHaveClass(
'h-5',
'min-w-12',
'rounded-full',
'font-mono',
'border-muted-foreground/30'
)
expect(defaultRatio).not.toHaveTextContent('Ratio')
expect(
defaultOption.querySelector('[data-auto-group-effect="ratio"]')
).toBe(null)
......@@ -198,17 +212,13 @@ describe('API key group combobox Auto effect', () => {
const trigger = getTrigger()
expect(trigger).toHaveAttribute('data-auto-group-effect', 'trigger')
expect(trigger.querySelector('[data-auto-group-flow-border]')).toBe(null)
expect(
trigger.querySelector('[data-auto-group-effect="ratio"]')
).toBeInTheDocument()
expect(within(trigger).getByText('Auto')).toBeInTheDocument()
fireEvent.click(trigger)
const autoOption = getCommandItem('Global automatic routing')
expect(autoOption).toHaveAttribute('data-auto-group-effect', 'option')
expect(autoOption.querySelector('[data-auto-group-flow-border]')).toBe(null)
expect(
autoOption.querySelector('[data-auto-group-effect="ratio"]')
).toBeInTheDocument()
expect(within(autoOption).getByText('Auto')).toBeInTheDocument()
setReducedMotion(false)
})
})
......@@ -256,21 +256,21 @@ describe('Auto group order editor', () => {
name: 'VIP',
title: 'Priority access',
description: 'Priority access',
ratio: '3x Ratio',
ratio: '3x',
},
{
index: '2',
name: 'Default',
title: 'Standard access',
description: 'Standard access',
ratio: '1x Ratio',
ratio: '1x',
},
{
index: '3',
name: 'Team',
title: 'Shared access',
description: 'Shared access',
ratio: '2x Ratio',
ratio: '2x',
},
])
......
......@@ -26,12 +26,10 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useMediaQuery } from '@/hooks'
import { cn } from '@/lib/utils'
import {
// AutoGroupBadge,
GroupRatioBadge,
type GroupRatio,
} from './auto-group-visuals'
import { GroupRatioBadge, type GroupRatio } from './auto-group-visuals'
type ApiKeyGroupCellProps = {
crossGroupRetry: boolean
......@@ -42,16 +40,26 @@ type ApiKeyGroupCellProps = {
export function ApiKeyGroupCell(props: ApiKeyGroupCellProps) {
const { t } = useTranslation()
const isMobile = useMediaQuery('(max-width: 640px)')
if (props.group !== 'auto') {
const ratio = typeof props.ratio === 'number' ? props.ratio : undefined
const group = props.group?.trim() || ''
if (group !== 'auto') {
const ratio =
group && typeof props.ratio === 'number' ? props.ratio : undefined
return (
<TruncatedCell
className='-ml-1.5'
tooltipContent={props.group || '-'}
className={isMobile ? 'w-full' : 'max-w-50'}
tabIndex={0}
tooltipContent={group || t('Follow user group')}
tooltipClassName='break-all'
>
<GroupBadge group={props.group} ratio={ratio} />
<GroupBadge
group={group}
ratio={ratio}
ratioLabel={group ? undefined : t('Inherited')}
className='px-0'
containerClassName={cn('gap-3', isMobile && 'w-full justify-between')}
/>
</TruncatedCell>
)
}
......@@ -62,7 +70,11 @@ export function ApiKeyGroupCell(props: ApiKeyGroupCellProps) {
render={
<BadgeCell
data-api-key-group-cell='auto'
className='gap-1.5 overflow-visible text-xs'
tabIndex={0}
className={cn(
'ml-0 gap-3 overflow-visible text-xs',
isMobile ? 'w-full justify-between' : 'max-w-50'
)}
/>
}
>
......@@ -70,8 +82,8 @@ export function ApiKeyGroupCell(props: ApiKeyGroupCellProps) {
label={t('Cross-group')}
variant='info'
copyable={false}
className='px-0'
/>
{/*<AutoGroupBadge shouldReduceMotion={props.shouldReduceMotion} />*/}
<GroupRatioBadge
ratio={props.ratio}
isAuto
......
/*
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 { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTitle,
PopoverTrigger,
} from '@/components/ui/popover'
import { Progress } from '@/components/ui/progress'
import { toIntlLocale } from '@/i18n/languages'
import { formatQuotaWithCurrency, getCurrencyDisplay } from '@/lib/currency'
import { cn } from '@/lib/utils'
import { useSystemConfigStore } from '@/stores/system-config-store'
import { API_KEY_STATUS } from '../constants'
import type { ApiKey } from '../types'
type ApiKeyQuotaCellProps = {
apiKey: ApiKey
now: number
variant?: 'table' | 'card'
}
export function ApiKeyQuotaCell(props: ApiKeyQuotaCellProps) {
const { t, i18n } = useTranslation()
useSystemConfigStore((state) => state.config.currency)
const { meta: currency } = getCurrencyDisplay()
const quotaUnit = currency.kind === 'tokens' ? t('Tokens') : currency.symbol
const used = props.apiKey.used_quota
const remaining = props.apiKey.remain_quota
const total = used + remaining
const hasProgress = !props.apiKey.unlimited_quota && total > 0
const percentage = hasProgress
? Math.min(100, Math.max(0, (remaining / total) * 100))
: 0
const formattedUsed = formatQuotaWithCurrency(used, { showSymbol: false })
const formattedRemaining = formatQuotaWithCurrency(remaining, {
showSymbol: false,
})
const formattedTotal = formatQuotaWithCurrency(total, { showSymbol: false })
const formattedPercentage = new Intl.NumberFormat(
toIntlLocale(i18n.resolvedLanguage || i18n.language),
{ maximumFractionDigits: 1 }
).format(percentage)
const isInactive =
props.apiKey.status !== API_KEY_STATUS.ENABLED ||
remaining <= 0 ||
(props.apiKey.expired_time !== -1 &&
props.apiKey.expired_time * 1000 <= props.now)
let progressColor = 'text-emerald-500'
if (isInactive) progressColor = 'text-muted-foreground/60'
else if (percentage <= 10) progressColor = 'text-rose-500'
else if (percentage <= 30) progressColor = 'text-amber-500'
const usageDescription = `${t('Used amount')} ${formattedUsed}`
const remainingDescription = hasProgress
? `${t('Remaining')} ${formattedRemaining}; ${t('Remaining percentage')} ${formattedPercentage}%`
: `${t('Remaining')} ${formattedRemaining}`
const triggerLabel = props.apiKey.unlimited_quota
? `${t('Unlimited')}; ${usageDescription}`
: `${remainingDescription}; ${usageDescription}`
return (
<Popover>
<div
className={cn(
'w-full min-w-0',
props.variant === 'card' ? 'space-y-2.5' : 'space-y-1.5'
)}
>
<PopoverTrigger
render={
<Button
variant='ghost'
aria-label={
props.variant === 'card'
? `${t('Quota')} (${quotaUnit}); ${triggerLabel}`
: triggerLabel
}
className={cn(
'h-auto w-full min-w-0 justify-start px-0 text-left font-normal hover:bg-transparent aria-expanded:bg-transparent',
props.variant === 'card' ? 'py-0' : 'py-0.5'
)}
/>
}
>
<span
data-slot='api-key-quota-values'
className='grid w-full min-w-0 grid-cols-[auto_minmax(0,1fr)] items-baseline gap-x-2 gap-y-1 text-xs'
>
<span className='text-muted-foreground'>
{t('Remaining')}
{props.variant === 'card' && (
<span className='ml-1'>({quotaUnit})</span>
)}
</span>
<span
className={cn(
'min-w-0 truncate text-right',
props.variant === 'card'
? 'text-sm leading-5 font-normal'
: 'font-medium',
!props.apiKey.unlimited_quota && 'font-mono tabular-nums',
!props.apiKey.unlimited_quota &&
remaining < 0 &&
'text-destructive',
remaining === 0 &&
!props.apiKey.unlimited_quota &&
'text-muted-foreground'
)}
>
{props.apiKey.unlimited_quota
? t('Unlimited')
: formattedRemaining}
</span>
<span className='text-muted-foreground'>{t('Used amount')}</span>
<span
className={cn(
'text-muted-foreground min-w-0 truncate text-right font-mono tabular-nums',
props.variant === 'card' && 'text-sm leading-5 font-normal'
)}
>
{formattedUsed}
</span>
</span>
</PopoverTrigger>
{!props.apiKey.unlimited_quota && (
<Progress
value={percentage}
aria-label={t('Remaining percentage')}
className={cn(
'w-full [&_[data-slot=progress-indicator]]:bg-current',
progressColor
)}
/>
)}
</div>
<PopoverContent
align='start'
className='w-72 max-w-[calc(100vw-2rem)] gap-3 p-3'
>
<PopoverTitle>
{t('Quota')} ({quotaUnit})
</PopoverTitle>
<dl className='grid grid-cols-[1fr_auto] gap-x-4 gap-y-2 text-sm tabular-nums'>
{!props.apiKey.unlimited_quota && (
<>
<dt className='text-muted-foreground'>{t('Remaining')}</dt>
<dd className='text-right font-mono break-all'>
{formattedRemaining}
</dd>
</>
)}
<dt className='text-muted-foreground'>{t('Used amount')}</dt>
<dd className='text-right font-mono break-all'>{formattedUsed}</dd>
{!props.apiKey.unlimited_quota && (
<>
<dt className='text-muted-foreground'>
{t('Current total quota')}
</dt>
<dd className='text-right font-mono break-all'>
{formattedTotal}
</dd>
</>
)}
{hasProgress && (
<>
<dt className='text-muted-foreground'>
{t('Remaining percentage')}
</dt>
<dd className='text-right font-mono'>{formattedPercentage}%</dd>
</>
)}
</dl>
<p className='text-muted-foreground text-xs leading-relaxed'>
{props.apiKey.unlimited_quota
? t(
'This API key has no quota limit. Requests still require available wallet or subscription quota.'
)
: t(
'Total = used + remaining. It is not an initial allocation or a periodic budget; changing the remaining quota changes the total and percentage.'
)}
</p>
</PopoverContent>
</Popover>
)
}
......@@ -16,14 +16,20 @@ 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 {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { toIntlLocale } from '@/i18n/languages'
import dayjs from '@/lib/dayjs'
import { formatTimestampRelative, formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
import type { ApiKey } from '../types'
interface ApiKeyTimestampCellProps {
timestamp: number
now: number
......@@ -42,6 +48,9 @@ export function ApiKeyTimestampCell(props: ApiKeyTimestampCellProps) {
const relativeTime = isJustNow
? props.justNowLabel
: formatTimestampRelative(props.timestamp, 'seconds', props.locale)
const [relativePrefix, relativeNumber, relativeSuffix] = relativeTime.split(
/(\p{Number}+(?:[.,\u00a0\u202f]\p{Number}+)*)/u
)
const absoluteTime = formatTimestampToDate(props.timestamp)
return (
......@@ -51,14 +60,15 @@ export function ApiKeyTimestampCell(props: ApiKeyTimestampCellProps) {
<time
dateTime={new Date(timestampMs).toISOString()}
tabIndex={0}
className={cn(
'block truncate font-mono text-xs tabular-nums',
props.className
)}
className={cn('block truncate text-xs', props.className)}
/>
}
>
{relativeTime}
{relativePrefix}
{relativeNumber && (
<span className='font-mono tabular-nums'>{relativeNumber}</span>
)}
{relativeSuffix}
</TooltipTrigger>
<TooltipContent>
<span className='font-mono tabular-nums'>{absoluteTime}</span>
......@@ -66,3 +76,50 @@ export function ApiKeyTimestampCell(props: ApiKeyTimestampCellProps) {
</Tooltip>
)
}
export function ApiKeyActivityCell(props: {
apiKey: ApiKey
now: number
layout?: 'rows' | 'columns'
}) {
const { t, i18n } = useTranslation()
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
const accessedTime = props.apiKey.accessed_time
const isStale =
accessedTime > 0 &&
accessedTime * 1000 < dayjs(props.now).subtract(3, 'month').valueOf()
return (
<div
className={cn(
'grid min-w-0 gap-y-1 text-xs',
props.layout === 'columns'
? 'grid-flow-col grid-cols-2 grid-rows-[auto_1fr] items-start gap-x-3'
: 'grid-cols-[auto_minmax(0,1fr)] items-center gap-x-2'
)}
>
<span className='text-muted-foreground'>{t('Created')}</span>
<ApiKeyTimestampCell
timestamp={props.apiKey.created_time}
now={props.now}
locale={locale}
justNowLabel={t('Just now')}
className={cn(
'text-muted-foreground',
props.layout === 'columns' && 'whitespace-normal'
)}
/>
<span className='text-muted-foreground'>{t('Last Used')}</span>
<ApiKeyTimestampCell
timestamp={accessedTime}
now={props.now}
locale={locale}
justNowLabel={t('Just now')}
className={cn(
isStale ? 'text-warning' : 'text-muted-foreground',
props.layout === 'columns' && 'whitespace-normal'
)}
/>
</div>
)
}
......@@ -26,6 +26,7 @@ import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTitle,
PopoverTrigger,
} from '@/components/ui/popover'
import {
......@@ -34,7 +35,6 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { copyToClipboard } from '@/lib/copy-to-clipboard'
import { formatQuota } from '@/lib/format'
import type { ApiKey } from '../types'
import { useApiKeys } from './api-keys-provider'
......@@ -142,47 +142,65 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
)
}
type UnlimitedQuotaBadgeProps = {
used: number
type ApiKeyRestrictionProps = {
apiKey: ApiKey
detailsTrigger?: 'hover' | 'click'
}
export function UnlimitedQuotaBadge(props: UnlimitedQuotaBadgeProps) {
export function ModelLimitsCell(props: ApiKeyRestrictionProps) {
const { t } = useTranslation()
const formattedUsed = formatQuota(props.used)
const models = props.apiKey.model_limits_enabled
? (props.apiKey.model_limits || '').split(',').filter(Boolean)
: []
return (
<Popover>
<PopoverTrigger
render={
<button
type='button'
className='focus-visible:ring-ring/50 -ml-1.5 cursor-help rounded-4xl focus-visible:ring-[3px] focus-visible:outline-none'
aria-label={`${t('Unlimited')}; ${t('Used:')} ${formattedUsed}`}
/>
}
>
<StatusBadge
label={t('Unlimited')}
variant='neutral'
copyable={false}
/>
</PopoverTrigger>
<PopoverContent className='w-auto p-2' side='top'>
<span className='text-xs'>
{t('Used:')} {formattedUsed}
</span>
</PopoverContent>
</Popover>
<ApiKeyRestrictionCell
items={models}
label={t('{{count}} models', { count: models.length })}
title={t('Models')}
emptyLabel={t('Unlimited')}
detailsTrigger={props.detailsTrigger}
/>
)
}
export function ModelLimitsCell({ apiKey }: { apiKey: ApiKey }) {
export function IpRestrictionsCell(props: ApiKeyRestrictionProps) {
const { t } = useTranslation()
const ips = (props.apiKey.allow_ips || '')
.split('\n')
.map((ip) => ip.trim())
.filter(Boolean)
if (!apiKey.model_limits_enabled || !apiKey.model_limits) {
return (
<ApiKeyRestrictionCell
items={ips}
label={t('{{count}} IP(s)', { count: ips.length })}
title={t('IP Restriction')}
emptyLabel={t('No restriction')}
detailsTrigger={props.detailsTrigger}
/>
)
}
function ApiKeyRestrictionCell(props: {
items: string[]
label: string
title: string
emptyLabel: string
detailsTrigger?: 'hover' | 'click'
}) {
if (!props.items.length) {
if (props.detailsTrigger === 'click') {
return (
<span className='inline-flex items-center gap-1.5 text-xs'>
<span className='text-muted-foreground'>{props.title}</span>
<span>{props.emptyLabel}</span>
</span>
)
}
return (
<StatusBadge
label={t('Unlimited')}
label={props.emptyLabel}
variant='neutral'
copyable={false}
className='-ml-1.5'
......@@ -190,67 +208,46 @@ export function ModelLimitsCell({ apiKey }: { apiKey: ApiKey }) {
)
}
const models = apiKey.model_limits.split(',').filter(Boolean)
return (
<Tooltip>
<TooltipTrigger render={<BadgeCell />}>
<StatusBadge
label={t('{{count}} model(s)', { count: models.length })}
variant='neutral'
copyable={false}
/>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-xs'>
<div className='max-h-[200px] space-y-0.5 overflow-y-auto text-xs'>
{models.map((m) => (
<div key={m} className='font-mono'>
{m}
</div>
))}
const details = (
<div className='max-h-[200px] space-y-1 overflow-y-auto text-xs'>
{props.items.map((item) => (
<div key={item} className='font-mono break-all'>
{item}
</div>
</TooltipContent>
</Tooltip>
))}
</div>
)
}
export function IpRestrictionsCell({ apiKey }: { apiKey: ApiKey }) {
const { t } = useTranslation()
const allowIps = apiKey.allow_ips?.trim()
if (!allowIps) {
if (props.detailsTrigger === 'click') {
return (
<StatusBadge
label={t('No restriction')}
variant='neutral'
copyable={false}
className='-ml-1.5'
/>
<Popover>
<PopoverTrigger
render={
<Button
variant='ghost'
size='sm'
aria-label={`${props.title}: ${props.label}`}
className='h-7 max-w-full justify-start px-0 text-xs font-normal underline decoration-dotted underline-offset-4'
/>
}
>
{props.label}
</PopoverTrigger>
<PopoverContent align='start' className='max-w-[calc(100vw-2rem)]'>
<PopoverTitle>{props.title}</PopoverTitle>
{details}
</PopoverContent>
</Popover>
)
}
const ips = allowIps
.split('\n')
.map((ip) => ip.trim())
.filter(Boolean)
return (
<Tooltip>
<TooltipTrigger render={<BadgeCell />}>
<StatusBadge
label={t('{{count}} IP(s)', { count: ips.length })}
variant='neutral'
copyable={false}
/>
<StatusBadge label={props.label} variant='neutral' copyable={false} />
</TooltipTrigger>
<TooltipContent side='top' className='max-w-xs'>
<div className='max-h-[200px] space-y-0.5 overflow-y-auto text-xs'>
{ips.map((ip) => (
<div key={ip} className='font-mono'>
{ip}
</div>
))}
</div>
{details}
</TooltipContent>
</Tooltip>
)
......
......@@ -22,37 +22,27 @@ import { useTranslation } from 'react-i18next'
import { StatusBadge } from '@/components/status-badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Progress } from '@/components/ui/progress'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useMediaQuery } from '@/hooks'
import { toIntlLocale } from '@/i18n/languages'
import { getUserGroups } from '@/lib/api'
import dayjs from '@/lib/dayjs'
import { formatQuota } from '@/lib/format'
import { cn } from '@/lib/utils'
import { getCurrencyDisplay } from '@/lib/currency'
import { useSystemConfigStore } from '@/stores/system-config-store'
import { API_KEY_STATUSES } from '../constants'
import type { ApiKey } from '../types'
import { ApiKeyGroupCell } from './api-key-group-cell'
import { ApiKeyTimestampCell } from './api-key-timestamp-cell'
import { ApiKeyQuotaCell } from './api-key-quota-cell'
import {
ApiKeyActivityCell,
ApiKeyTimestampCell,
} from './api-key-timestamp-cell'
import {
ApiKeyCell,
IpRestrictionsCell,
ModelLimitsCell,
UnlimitedQuotaBadge,
} from './api-keys-cells'
import { DataTableRowActions } from './data-table-row-actions'
function getQuotaProgressColor(percentage: number): string {
if (percentage <= 10) return '[&_[data-slot=progress-indicator]]:bg-rose-500'
if (percentage <= 30) return '[&_[data-slot=progress-indicator]]:bg-amber-500'
return '[&_[data-slot=progress-indicator]]:bg-emerald-500'
}
function useGroupRatios(): Record<string, number | string> {
const { data } = useQuery({
queryKey: ['user-groups'],
......@@ -75,11 +65,13 @@ function useGroupRatios(): Record<string, number | string> {
export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
const { t, i18n } = useTranslation()
useSystemConfigStore((state) => state.config.currency)
const { meta: currency } = getCurrencyDisplay()
const quotaUnit = currency.kind === 'tokens' ? t('Tokens') : currency.symbol
const groupRatios = useGroupRatios()
const shouldReduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
const justNowLabel = t('Just now')
const staleAccessThreshold = dayjs(now).subtract(3, 'month').valueOf()
return [
{
id: 'select',
......@@ -88,7 +80,7 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label='Select all'
aria-label={t('Select all')}
className='translate-y-[2px]'
/>
),
......@@ -96,7 +88,7 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label='Select row'
aria-label={t('Select row')}
className='translate-y-[2px]'
/>
),
......@@ -143,52 +135,10 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
{
id: 'quota',
accessorKey: 'remain_quota',
header: t('Quota'),
cell: ({ row }) => {
const apiKey = row.original
if (apiKey.unlimited_quota) {
return <UnlimitedQuotaBadge used={apiKey.used_quota} />
}
const used = apiKey.used_quota
const remaining = apiKey.remain_quota
const total = used + remaining
const percentage = total > 0 ? (remaining / total) * 100 : 0
return (
<Tooltip>
<TooltipTrigger render={<div className='w-[150px] space-y-1' />}>
<div className='flex justify-between text-xs'>
<span className='font-medium tabular-nums'>
{formatQuota(remaining)}
</span>
<span className='text-muted-foreground tabular-nums'>
{formatQuota(total)}
</span>
</div>
<Progress
value={percentage}
className={cn('h-1.5', getQuotaProgressColor(percentage))}
/>
</TooltipTrigger>
<TooltipContent>
<div className='space-y-1 text-xs'>
<div>
{t('Used:')} {formatQuota(used)}
</div>
<div>
{t('Remaining:')} {formatQuota(remaining)} (
{percentage.toFixed(1)}%)
</div>
<div>
{t('Total:')} {formatQuota(total)}
</div>
</div>
</TooltipContent>
</Tooltip>
)
},
size: 170,
header: `${t('Quota')} (${quotaUnit})`,
cell: ({ row }) => <ApiKeyQuotaCell apiKey={row.original} now={now} />,
size: 220,
minSize: 220,
},
{
accessorKey: 'group',
......@@ -227,39 +177,11 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
meta: { mobileHidden: true },
},
{
id: 'activity_time',
accessorKey: 'created_time',
header: t('Created'),
cell: ({ row }) => (
<ApiKeyTimestampCell
timestamp={row.getValue('created_time')}
now={now}
locale={locale}
justNowLabel={justNowLabel}
className='text-muted-foreground'
/>
),
size: 180,
meta: { mobileHidden: true },
},
{
accessorKey: 'accessed_time',
header: t('Last Used'),
cell: ({ row }) => {
const accessedTime = row.getValue('accessed_time') as number
const isStale =
accessedTime > 0 && accessedTime * 1000 < staleAccessThreshold
return (
<ApiKeyTimestampCell
timestamp={accessedTime}
now={now}
locale={locale}
justNowLabel={justNowLabel}
className={isStale ? 'text-warning' : 'text-muted-foreground'}
/>
)
},
size: 180,
header: t('Time'),
cell: ({ row }) => <ApiKeyActivityCell apiKey={row.original} now={now} />,
size: 220,
meta: { mobileHidden: true },
},
{
......@@ -277,16 +199,17 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
/>
)
}
const isExpired = expiredTime * 1000 < now
return (
<ApiKeyTimestampCell
timestamp={expiredTime}
now={now}
locale={locale}
justNowLabel={justNowLabel}
className={cn(
isExpired ? 'text-destructive' : 'text-muted-foreground'
)}
className={
expiredTime * 1000 <= now
? 'text-destructive'
: 'text-muted-foreground'
}
/>
)
},
......
......@@ -18,7 +18,7 @@ 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 { flexRender, type Table as TanstackTable } from '@tanstack/react-table'
import { Database } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
......@@ -42,7 +42,6 @@ import {
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { formatQuota } from '@/lib/format'
import { cn } from '@/lib/utils'
import { getApiKeys, searchApiKeys } from '../api'
......@@ -53,7 +52,13 @@ import {
ERROR_MESSAGES,
} from '../constants'
import type { ApiKey } from '../types'
import { ApiKeyCell, UnlimitedQuotaBadge } from './api-keys-cells'
import { ApiKeyQuotaCell } from './api-key-quota-cell'
import { ApiKeyActivityCell } from './api-key-timestamp-cell'
import {
ApiKeyCell,
ModelLimitsCell,
IpRestrictionsCell,
} from './api-keys-cells'
import { useApiKeysColumns } from './api-keys-columns'
import { useApiKeys } from './api-keys-provider'
import { DataTableBulkActions } from './data-table-bulk-actions'
......@@ -72,11 +77,11 @@ function isDisabledApiKeyRow(apiKey: ApiKey) {
function ApiKeysMobileSkeleton() {
return (
<div className='divide-border overflow-hidden rounded-lg border'>
<div className='min-w-0 space-y-3'>
{API_KEYS_MOBILE_SKELETON_IDS.map((id) => (
<div
key={id}
className='space-y-2 border-b px-3 py-2.5 last:border-b-0'
className='border-border/60 bg-card space-y-2 rounded-xl border p-3.5'
>
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-32' />
......@@ -96,9 +101,11 @@ function ApiKeysMobileSkeleton() {
function ApiKeysMobileList({
table,
isLoading,
now,
}: {
table: TanstackTable<ApiKey>
isLoading: boolean
now: number
}) {
const { t } = useTranslation()
const rows = table.getRowModel().rows
......@@ -126,34 +133,37 @@ function ApiKeysMobileList({
}
return (
<div className='divide-border overflow-hidden rounded-lg border'>
<div className='min-w-0 space-y-3'>
{rows.map((row) => {
const apiKey = row.original
const statusConfig = API_KEY_STATUSES[apiKey.status]
const total = apiKey.used_quota + apiKey.remain_quota
const groupCell = row
.getAllCells()
.find((cell) => cell.column.id === 'group')
const expiryCell = row
.getAllCells()
.find((cell) => cell.column.id === 'expired_time')
return (
<div
key={row.id}
className={cn(
'bg-card space-y-2.5 border-b px-3 py-2.5 last:border-b-0',
'border-border/60 bg-card min-w-0 space-y-2 rounded-xl border p-3.5 text-xs leading-4',
isDisabledApiKeyRow(apiKey) && DISABLED_ROW_MOBILE
)}
>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<div className='truncate text-sm font-semibold'>
<div className='text-sm leading-5 font-semibold break-words'>
{apiKey.name}
</div>
<div className='text-muted-foreground text-[11px]'>
{t('API Key')}
</div>
</div>
{statusConfig && (
<StatusBadge
label={t(statusConfig.label)}
variant={statusConfig.variant}
copyable={false}
className='shrink-0 px-0 text-xs font-normal'
/>
)}
</div>
......@@ -165,19 +175,38 @@ function ApiKeysMobileList({
<DataTableRowActions row={row} />
</div>
<div className='flex items-center justify-between gap-2 text-xs'>
<span className='text-muted-foreground'>{t('Quota')}</span>
{apiKey.unlimited_quota ? (
<UnlimitedQuotaBadge used={apiKey.used_quota} />
) : (
<span className='font-medium tabular-nums'>
{formatQuota(apiKey.remain_quota)}
<span className='text-muted-foreground font-normal'>
{' / '}
{formatQuota(total)}
</span>
</span>
)}
<div className='min-w-0 space-y-3 py-1'>
<div className='min-w-0'>
{groupCell &&
flexRender(
groupCell.column.columnDef.cell,
groupCell.getContext()
)}
</div>
<ApiKeyQuotaCell apiKey={apiKey} now={now} variant='card' />
</div>
<div className='flex flex-wrap items-center gap-x-5 gap-y-1'>
<ModelLimitsCell apiKey={apiKey} detailsTrigger='click' />
<IpRestrictionsCell apiKey={apiKey} detailsTrigger='click' />
</div>
<div className='grid grid-cols-3 items-start gap-3 border-t pt-2'>
<div className='col-span-2 min-w-0'>
<ApiKeyActivityCell
apiKey={apiKey}
now={now}
layout='columns'
/>
</div>
<div className='min-w-0 space-y-1 [&_[data-slot=status-badge]]:text-xs [&_[data-slot=status-badge]]:font-normal'>
<div className='text-muted-foreground'>{t('Expires')}</div>
{expiryCell &&
flexRender(
expiryCell.column.columnDef.cell,
expiryCell.getContext()
)}
</div>
</div>
</div>
)
......@@ -293,6 +322,23 @@ export function ApiKeysTable() {
ensurePageInRange,
})
const columnVisibility = table.getState().columnVisibility
useEffect(() => {
// Restore the dates hidden by the previous default when adopting the combined time column.
if (
columnVisibility.activity_time === undefined &&
columnVisibility.created_time === false &&
columnVisibility.accessed_time === false &&
columnVisibility.expired_time === false
) {
table.setColumnVisibility((previous) => ({
...previous,
activity_time: true,
expired_time: true,
}))
}
}, [columnVisibility, table])
return (
<DataTablePage
table={table}
......@@ -305,6 +351,9 @@ export function ApiKeysTable() {
)}
skeletonKeyPrefix='api-keys-skeleton'
applyHeaderSize
getColumnClassName={(columnId) =>
columnId === 'quota' ? 'pr-8' : undefined
}
toolbarProps={{
searchPlaceholder: t('Filter by name...'),
searchDebounceMs: 500,
......@@ -326,7 +375,9 @@ export function ApiKeysTable() {
},
],
}}
mobile={<ApiKeysMobileList table={table} isLoading={isLoading} />}
mobile={
<ApiKeysMobileList table={table} isLoading={isLoading} now={now} />
}
getRowClassName={(row) =>
isDisabledApiKeyRow(row.original) ? DISABLED_ROW_DESKTOP : undefined
}
......
......@@ -19,8 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { GroupBadge } from '@/components/group-badge'
import { Badge } from '@/components/ui/badge'
import { GroupBadge, GroupMultiplierBadge } from '@/components/group-badge'
import { cn } from '@/lib/utils'
export type GroupRatio = number | string | null | undefined
......@@ -30,6 +29,7 @@ export const AUTO_GROUP_FRAME_CLASS_NAME =
type AutoGroupFlowBorderProps = {
shouldReduceMotion: boolean
appearance?: 'default' | 'subtle'
}
export function AutoGroupFlowBorder(props: AutoGroupFlowBorderProps) {
......@@ -39,7 +39,10 @@ export function AutoGroupFlowBorder(props: AutoGroupFlowBorderProps) {
<span
aria-hidden='true'
data-auto-group-flow-border='true'
className='auto-group-flow-border pointer-events-none absolute -inset-px'
className={cn(
'auto-group-flow-border pointer-events-none absolute -inset-px',
props.appearance === 'subtle' && 'auto-group-flow-border-subtle'
)}
/>
)
}
......@@ -68,22 +71,6 @@ export function AutoGroupFrame(props: AutoGroupFrameProps) {
)
}
function getRatioBadgeClassName(ratio: GroupRatio, isAuto: boolean): string {
if (isAuto || typeof ratio !== 'number') {
return 'border-primary/30 bg-primary/10 text-primary'
}
if (ratio > 5) {
return 'border-destructive/30 bg-destructive/10 text-destructive'
}
if (ratio > 3) {
return 'border-warning/30 bg-warning/10 text-warning'
}
if (ratio > 1) {
return 'border-info/30 bg-info/10 text-info'
}
return 'border-success/30 bg-success/10 text-success'
}
type GroupRatioBadgeProps = {
isAuto?: boolean
ratio: GroupRatio
......@@ -97,38 +84,26 @@ export function GroupRatioBadge(props: GroupRatioBadgeProps) {
return null
}
const label =
typeof props.ratio === 'number'
? `${props.ratio}x ${t('Ratio')}`
: `${t('Auto')} ${t('Ratio')}`
const badge = (
<Badge
variant='outline'
return (
<GroupMultiplierBadge
ratio={typeof props.ratio === 'number' ? props.ratio : undefined}
label={typeof props.ratio === 'number' ? undefined : t('Auto')}
className={cn(
'max-w-full truncate text-[10px] sm:text-xs',
getRatioBadgeClassName(props.ratio, props.isAuto === true)
props.isAuto &&
'overflow-visible rounded-md border-primary/30 bg-primary/10 text-primary'
)}
>
{label}
</Badge>
)
if (!props.isAuto) {
return <span className='max-w-24 shrink-0 sm:max-w-none'>{badge}</span>
}
return (
<AutoGroupFrame
effect='ratio'
shouldReduceMotion={props.shouldReduceMotion ?? false}
className='max-w-24 sm:max-w-none'
>
{badge}
</AutoGroupFrame>
{props.isAuto && (
<AutoGroupFlowBorder
appearance='subtle'
shouldReduceMotion={props.shouldReduceMotion ?? false}
/>
)}
</GroupMultiplierBadge>
)
}
export function AutoGroupBadge(props: AutoGroupFlowBorderProps) {
export function AutoGroupBadge(props: { shouldReduceMotion: boolean }) {
return (
<AutoGroupFrame
effect='badge'
......
......@@ -86,34 +86,16 @@ export function DataTableRowActions<TData>({
triggerRefresh,
setResolvedKey,
resolveRealKey,
resolvedKeys,
loadingKeys,
} = useApiKeys()
const isEnabled = apiKey.status === API_KEY_STATUS.ENABLED
const { chatPresets, serverAddress } = useChatPresets()
const [isTogglingStatus, setIsTogglingStatus] = useState(false)
const resolvedRealKey = resolvedKeys[apiKey.id]
const isRealKeyLoading = Boolean(loadingKeys[apiKey.id])
const hasChatPresets = chatPresets.length > 0
const toggleLabel = isEnabled ? t('Disable') : t('Enable')
const handleMenuOpenChange = useCallback(
(open: boolean) => {
if (open && !resolvedRealKey && !isRealKeyLoading) {
void resolveRealKey(apiKey.id)
}
},
[apiKey.id, isRealKeyLoading, resolvedRealKey, resolveRealKey]
)
const getCachedRealKey = useCallback(() => {
if (resolvedRealKey) return resolvedRealKey
void resolveRealKey(apiKey.id)
toast.info(t('API key is loading, please try again in a moment'))
return null
}, [apiKey.id, resolvedRealKey, resolveRealKey, t])
const handleOpenChatPreset = useCallback(
async (preset: ChatPreset) => {
const realKey = await resolveRealKey(apiKey.id)
......@@ -156,9 +138,9 @@ export function DataTableRowActions<TData>({
)
const handleToggleStatus = async (
e?: React.MouseEvent<HTMLButtonElement>
event?: React.MouseEvent<HTMLButtonElement>
) => {
e?.stopPropagation()
event?.stopPropagation()
const newStatus = isEnabled
? API_KEY_STATUS.DISABLED
: API_KEY_STATUS.ENABLED
......@@ -236,11 +218,11 @@ export function DataTableRowActions<TData>({
ariaLabel={t('Open menu')}
contentClassName='w-[200px]'
modal={false}
onOpenChange={handleMenuOpenChange}
>
<DropdownMenuItem
disabled={isRealKeyLoading}
onClick={async () => {
const realKey = getCachedRealKey()
const realKey = await resolveRealKey(apiKey.id)
if (!realKey) return
const ok = await copyToClipboard(realKey)
if (ok) toast.success(t('Copied'))
......@@ -252,8 +234,9 @@ export function DataTableRowActions<TData>({
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
disabled={isRealKeyLoading}
onClick={async () => {
const realKey = getCachedRealKey()
const realKey = await resolveRealKey(apiKey.id)
if (!realKey) return
const connStr = encodeChannelConnectionInfo(
realKey,
......
......@@ -19,34 +19,20 @@ For commercial licensing, please contact support@quantumnous.com
import { useTranslation } from 'react-i18next'
import { StatusBadge } from '@/components/status-badge'
import { Progress } from '@/components/ui/progress'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { formatQuota } from '@/lib/format'
import { formatQuotaWithCurrency } from '@/lib/currency'
import { cn } from '@/lib/utils'
import { useSystemConfigStore } from '@/stores/system-config-store'
type UserQuotaCellProps = {
used: number
remaining: number
}
function getQuotaProgressColor(percentage: number): string {
if (percentage <= 10) return '[&_[data-slot=progress-indicator]]:bg-rose-500'
if (percentage <= 30) return '[&_[data-slot=progress-indicator]]:bg-amber-500'
return '[&_[data-slot=progress-indicator]]:bg-emerald-500'
used: number
}
export function UserQuotaCell(props: UserQuotaCellProps) {
const { t } = useTranslation()
const total = props.used + props.remaining
const percentage = total > 0 ? (props.remaining / total) * 100 : 0
const formattedRemaining = formatQuota(props.remaining)
const formattedTotal = formatQuota(total)
useSystemConfigStore((state) => state.config.currency)
if (total === 0) {
if (props.remaining === 0 && props.used === 0) {
return (
<StatusBadge
label={t('No Quota')}
......@@ -58,41 +44,22 @@ export function UserQuotaCell(props: UserQuotaCellProps) {
}
return (
<Tooltip>
<TooltipTrigger
render={
<div className='w-full min-w-0 cursor-help space-y-1.5 overflow-hidden' />
}
<div className='min-w-0 space-y-1 text-left tabular-nums'>
<div
className={cn(
'font-mono text-sm font-semibold whitespace-nowrap',
props.remaining < 0 && 'text-destructive',
props.remaining === 0 && 'text-muted-foreground'
)}
>
<div className='grid min-w-0 grid-cols-2 gap-x-4 text-xs'>
<span className='min-w-0 truncate font-medium tabular-nums'>
{formattedRemaining}
</span>
<span className='text-muted-foreground min-w-0 truncate text-right tabular-nums'>
{formattedTotal}
</span>
</div>
<Progress
value={percentage}
className={cn('h-1.5', getQuotaProgressColor(percentage))}
/>
</TooltipTrigger>
<TooltipContent>
<div className='space-y-1 text-xs'>
<div>
{t('Used:')} {formatQuota(props.used)}
</div>
<div>
{t('Remaining:')} {formattedRemaining}
</div>
<div>
{t('Total:')} {formattedTotal}
</div>
<div>
{t('Percentage:')} {percentage.toFixed(1)}%
</div>
</div>
</TooltipContent>
</Tooltip>
{formatQuotaWithCurrency(props.remaining, { showSymbol: false })}
</div>
<div className='text-muted-foreground flex items-baseline gap-1 text-xs whitespace-nowrap'>
<span>{t('Used amount')}</span>
<span className='font-mono'>
{formatQuotaWithCurrency(props.used, { showSymbol: false })}
</span>
</div>
</div>
)
}
......@@ -30,7 +30,9 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { getCurrencyDisplay } from '@/lib/currency'
import { formatQuota, formatTimestamp } from '@/lib/format'
import { useSystemConfigStore } from '@/stores/system-config-store'
import {
USER_STATUS,
......@@ -44,6 +46,9 @@ import { UserQuotaCell } from './user-quota-cell'
export function useUsersColumns(): ColumnDef<User>[] {
const { t } = useTranslation()
useSystemConfigStore((state) => state.config.currency)
const { meta: currency } = getCurrencyDisplay()
const quotaUnit = currency.kind === 'tokens' ? t('Tokens') : currency.symbol
return [
{
id: 'select',
......@@ -52,7 +57,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label='Select all'
aria-label={t('Select all')}
className='translate-y-[2px]'
/>
),
......@@ -60,7 +65,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label='Select row'
aria-label={t('Select row')}
className='translate-y-[2px]'
/>
),
......@@ -141,7 +146,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
<TooltipTrigger render={<div className='-ml-1.5 cursor-help' />}>
<StatusBadge
label={t(statusConfig.labelKey)}
variant={statusConfig.variant}
variant={isUserDeleted(user) ? 'neutral' : statusConfig.variant}
copyable={false}
/>
</TooltipTrigger>
......@@ -163,18 +168,18 @@ export function useUsersColumns(): ColumnDef<User>[] {
{
id: 'quota',
accessorKey: 'quota',
header: t('Quota'),
header: `${t('Available Balance')} (${quotaUnit})`,
cell: ({ row }) => {
const user = row.original
return <UserQuotaCell used={user.used_quota} remaining={user.quota} />
return <UserQuotaCell remaining={user.quota} used={user.used_quota} />
},
size: 300,
minSize: 260,
size: 180,
minSize: 160,
meta: { mobileOrder: 40 },
},
{
accessorKey: 'group',
header: t('Group'),
header: t('User Group'),
cell: ({ row }) => {
const group = row.getValue('group') as string
return (
......@@ -202,14 +207,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
return null
}
return (
<div className='flex items-center gap-x-2'>
{roleConfig.icon && (
<roleConfig.icon size={16} className='text-muted-foreground' />
)}
<span className='text-sm'>{t(roleConfig.labelKey)}</span>
</div>
)
return <span className='text-sm'>{t(roleConfig.labelKey)}</span>
},
filterFn: (row, id, value) => {
return value.includes(String(row.getValue(id)))
......@@ -227,63 +225,25 @@ export function useUsersColumns(): ColumnDef<User>[] {
const affHistoryQuota = user.aff_history_quota || 0
const inviterId = user.inviter_id || 0
if (affCount === 0 && affHistoryQuota === 0 && inviterId === 0) {
return <span className='text-muted-foreground text-sm'></span>
}
return (
<div className='flex max-w-full min-w-0 flex-wrap items-center gap-1 overflow-hidden'>
<Tooltip>
<TooltipTrigger
render={
<StatusBadge
label={`${t('Invited')}: ${affCount}`}
variant='neutral'
copyable={false}
className='cursor-help'
/>
}
/>
<TooltipContent>
<p className='text-xs'>{t('Number of users invited')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<StatusBadge
label={`${t('Revenue')}: ${formatQuota(affHistoryQuota)}`}
variant='neutral'
copyable={false}
className='cursor-help'
/>
}
/>
<TooltipContent>
<p className='text-xs'>{t('Total invitation revenue')}</p>
</TooltipContent>
</Tooltip>
{inviterId > 0 && (
<Tooltip>
<TooltipTrigger
render={
<StatusBadge
label={`${t('Inviter')}: ${inviterId}`}
variant='neutral'
copyable={false}
className='cursor-help'
/>
}
/>
<TooltipContent>
<p className='text-xs'>
{t('Invited by user ID')} {inviterId}
</p>
</TooltipContent>
</Tooltip>
<div className='min-w-0 space-y-1 text-xs'>
{(affCount > 0 || affHistoryQuota !== 0) && (
<LongText>
{t('Invited {{count}} users', { count: affCount })} ·{' '}
{t('Earnings')}:{' '}
<span className='tabular-nums'>
{formatQuota(affHistoryQuota)}
</span>
</LongText>
)}
{inviterId === 0 && (
<StatusBadge
label={t('No Inviter')}
variant='neutral'
copyable={false}
/>
{inviterId > 0 && (
<LongText className='text-muted-foreground'>
{t('Inviter')} ID: {inviterId}
</LongText>
)}
</div>
)
......
......@@ -174,6 +174,7 @@ export function UsersTable() {
data: users,
columns,
enableRowSelection: true,
initialColumnVisibility: { created_at: false, last_login_at: false },
columnFilters,
globalFilter,
pagination,
......@@ -232,13 +233,10 @@ export function UsersTable() {
},
],
}}
getRowClassName={(row, { isMobile }) =>
isDisabledUserRow(row.original)
? isMobile
? DISABLED_ROW_MOBILE
: DISABLED_ROW_DESKTOP
: undefined
}
getRowClassName={(row, { isMobile }) => {
if (!isDisabledUserRow(row.original)) return undefined
return isMobile ? DISABLED_ROW_MOBILE : DISABLED_ROW_DESKTOP
}}
bulkActions={<DataTableBulkActions table={table} />}
/>
)
......
......@@ -713,6 +713,18 @@ For commercial licensing, please contact support@quantumnous.com
animation: auto-group-flow-border-travel 3.2s linear infinite;
}
.auto-group-flow-border-subtle {
padding: 1px;
background: conic-gradient(
from var(--auto-group-flow-angle),
transparent 0deg 300deg,
color-mix(in oklch, var(--primary) 30%, transparent) 318deg,
var(--primary) 348deg,
transparent 360deg
);
animation-duration: 3s;
}
@media (prefers-reduced-motion: reduce) {
.auto-group-flow-border {
display: none;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment