feat: enhance UI and functionality in various components

This commit is contained in:
CaIon 2026-04-28 18:38:02 +08:00
parent fc377dae3e
commit 28f7e9eb2e
No known key found for this signature in database
GPG Key ID: 0CFA613529A9921D
40 changed files with 1567 additions and 470 deletions

2
.gitignore vendored
View File

@ -10,6 +10,8 @@ build
logs
web/default/dist
web/classic/dist
web/node_modules
web/dist
.env
one-api
new-api

View File

@ -14,6 +14,7 @@ import {
EmptyTitle,
} from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
interface MobileCardListProps<TData> {
table: Table<TData>
@ -21,6 +22,7 @@ interface MobileCardListProps<TData> {
emptyTitle?: string
emptyDescription?: string
getRowKey?: (row: Row<TData>) => string | number
getRowClassName?: (row: Row<TData>) => string | undefined
}
interface MobileColumnMeta {
@ -238,6 +240,7 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
emptyTitle,
emptyDescription,
getRowKey,
getRowClassName,
} = props
const { t } = useTranslation()
@ -278,7 +281,10 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
{rows.map((row) => {
const key = getRowKey ? getRowKey(row) : row.id
return (
<div key={key} className='bg-card px-3 py-2.5'>
<div
key={key}
className={cn('bg-card px-3 py-2.5', getRowClassName?.(row))}
>
<RowComponent row={row} />
</div>
)

View File

@ -120,70 +120,87 @@ export function WorkspaceSwitcher({
return null
}
const canSwitchWorkspace = availableWorkspaces.length > 1
const workspaceButtonContent = (
<>
{activeWorkspace.id === WORKSPACE_IDS.SYSTEM_SETTINGS ? (
<div className='bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg'>
<activeWorkspace.logo className='size-4' />
</div>
) : (
<div className='flex aspect-square size-8 items-center justify-center overflow-hidden rounded-lg'>
<img
src={logo}
alt={t('Logo')}
className='size-full rounded-lg object-cover'
/>
</div>
)}
<div className='grid flex-1 text-start text-sm leading-tight group-data-[collapsible=icon]:hidden'>
<span className='truncate font-semibold'>{activeWorkspace.name}</span>
<span className='truncate text-xs'>{activeWorkspace.plan}</span>
</div>
{canSwitchWorkspace && (
<ChevronsUpDown className='ms-auto group-data-[collapsible=icon]:hidden' />
)}
</>
)
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size='lg'
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
>
{activeWorkspace.id === WORKSPACE_IDS.SYSTEM_SETTINGS ? (
<div className='bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg'>
<activeWorkspace.logo className='size-4' />
</div>
) : (
<div className='flex aspect-square size-8 items-center justify-center overflow-hidden rounded-lg'>
<img
src={logo}
alt={t('Logo')}
className='size-full rounded-lg object-cover'
/>
</div>
)}
<div className='grid flex-1 text-start text-sm leading-tight group-data-[collapsible=icon]:hidden'>
<span className='truncate font-semibold'>
{activeWorkspace.name}
</span>
<span className='truncate text-xs'>{activeWorkspace.plan}</span>
</div>
<ChevronsUpDown className='ms-auto group-data-[collapsible=icon]:hidden' />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
align='start'
side={isMobile ? 'bottom' : 'right'}
sideOffset={4}
>
<DropdownMenuLabel className='text-muted-foreground text-xs'>
{t('Workspaces')}
</DropdownMenuLabel>
{availableWorkspaces.map((workspace, index) => (
<DropdownMenuItem
key={workspace.id}
onClick={() => handleWorkspaceChange(workspace)}
className='gap-2 p-2'
{canSwitchWorkspace ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size='lg'
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
>
{index === 0 ? (
<div className='flex size-6 items-center justify-center overflow-hidden rounded-sm border'>
<img
src={logo}
alt='Logo'
className='size-full object-cover'
/>
</div>
) : (
<div className='flex size-6 items-center justify-center rounded-sm border'>
<workspace.logo className='size-4 shrink-0' />
</div>
)}
{workspace.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{workspaceButtonContent}
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
align='start'
side={isMobile ? 'bottom' : 'right'}
sideOffset={4}
>
<DropdownMenuLabel className='text-muted-foreground text-xs'>
{t('Workspaces')}
</DropdownMenuLabel>
{availableWorkspaces.map((workspace, index) => (
<DropdownMenuItem
key={workspace.id}
onClick={() => handleWorkspaceChange(workspace)}
className='gap-2 p-2'
>
{index === 0 ? (
<div className='flex size-6 items-center justify-center overflow-hidden rounded-sm border'>
<img
src={logo}
alt='Logo'
className='size-full object-cover'
/>
</div>
) : (
<div className='flex size-6 items-center justify-center rounded-sm border'>
<workspace.logo className='size-4 shrink-0' />
</div>
)}
{workspace.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<SidebarMenuButton
asChild
size='lg'
className='cursor-default hover:bg-transparent hover:text-sidebar-foreground active:bg-transparent active:text-sidebar-foreground'
>
<div>{workspaceButtonContent}</div>
</SidebarMenuButton>
)}
</SidebarMenuItem>
</SidebarMenu>
)

View File

@ -35,6 +35,7 @@ import { PageFooterPortal } from '@/components/layout'
import { getChannels, searchChannels, getGroups } from '../api'
import {
DEFAULT_PAGE_SIZE,
CHANNEL_STATUS,
CHANNEL_STATUS_OPTIONS,
CHANNEL_TYPE_OPTIONS,
} from '../constants'
@ -50,6 +51,10 @@ import { DataTableBulkActions } from './data-table-bulk-actions'
const route = getRouteApi('/_authenticated/channels/')
function isDisabledChannelRow(channel: Channel) {
return !isTagAggregateRow(channel) && channel.status !== CHANNEL_STATUS.ENABLED
}
export function ChannelsTable() {
const { t } = useTranslation()
const { enableTagMode, idSort } = useChannels()
@ -318,6 +323,11 @@ export function ChannelsTable() {
isLoading={isLoading}
emptyTitle='No Channels Found'
emptyDescription='No channels available. Create your first channel to get started.'
getRowClassName={(row) =>
isDisabledChannelRow(row.original)
? 'border-l-4 border-l-muted-foreground/35 bg-muted/85 dark:border-l-zinc-300/70 dark:bg-zinc-700/55'
: undefined
}
/>
) : (
<>
@ -363,6 +373,10 @@ export function ChannelsTable() {
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className={cn(
isDisabledChannelRow(row.original) &&
'bg-muted/85 hover:bg-muted dark:bg-zinc-700/55 dark:hover:bg-zinc-700/70 [&>td:first-child]:border-l-4 [&>td:first-child]:border-l-muted-foreground/35 [&>td:first-child]:pl-1 dark:[&>td:first-child]:border-l-zinc-300/70'
)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>

View File

@ -10,14 +10,17 @@ import type { QuotaDataItem, UptimeGroupResult } from './types'
// ----------------------------------------------------------------------------
// Get user quota data within a time range
// Admin users can specify 'username' to view other users' data
export async function getUserQuotaDates(params: {
start_timestamp: number
end_timestamp: number
default_time?: string
username?: string
}) {
const endpoint = params.username ? '/api/data' : '/api/data/self'
// Admin users get all users' data by default (matching classic frontend behavior)
export async function getUserQuotaDates(
params: {
start_timestamp: number
end_timestamp: number
default_time?: string
username?: string
},
isAdmin = false
) {
const endpoint = isAdmin ? '/api/data' : '/api/data/self'
const res = await api.get<{ success: boolean; data: QuotaDataItem[] }>(
endpoint,
{ params }

View File

@ -0,0 +1,120 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { VChart } from '@visactor/react-vchart'
import { AreaChart, BarChart3, WalletCards } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { TimeGranularity } from '@/lib/time'
import { VCHART_OPTION } from '@/lib/vchart'
import { useTheme } from '@/context/theme-provider'
import { DEFAULT_TIME_GRANULARITY } from '@/features/dashboard/constants'
import { processChartData } from '@/features/dashboard/lib'
import type { QuotaDataItem } from '@/features/dashboard/types'
let themeManagerPromise: Promise<
(typeof import('@visactor/vchart'))['ThemeManager']
> | null = null
type DistributionChartType = 'bar' | 'area'
interface ConsumptionDistributionChartProps {
data: QuotaDataItem[]
loading?: boolean
timeGranularity?: TimeGranularity
}
const CHART_TYPES: Array<{
value: DistributionChartType
labelKey: string
icon: typeof BarChart3
}> = [
{ value: 'bar', labelKey: 'Bar Chart', icon: BarChart3 },
{ value: 'area', labelKey: 'Area Chart', icon: AreaChart },
]
export function ConsumptionDistributionChart(
props: ConsumptionDistributionChartProps
) {
const { t } = useTranslation()
const { resolvedTheme } = useTheme()
const [chartType, setChartType] = useState<DistributionChartType>('bar')
const [themeReady, setThemeReady] = useState(false)
const themeManagerRef = useRef<
(typeof import('@visactor/vchart'))['ThemeManager'] | null
>(null)
const timeGranularity = props.timeGranularity ?? DEFAULT_TIME_GRANULARITY
useEffect(() => {
const updateTheme = async () => {
setThemeReady(false)
if (!themeManagerPromise) {
themeManagerPromise = import('@visactor/vchart').then(
(m) => m.ThemeManager
)
}
const ThemeManager = await themeManagerPromise
themeManagerRef.current = ThemeManager
ThemeManager.setCurrentTheme(resolvedTheme === 'dark' ? 'dark' : 'light')
setThemeReady(true)
}
updateTheme()
}, [resolvedTheme])
const chartData = useMemo(
() => processChartData(props.loading ? [] : props.data, timeGranularity, t),
[props.data, props.loading, timeGranularity, t]
)
const spec = chartType === 'bar' ? chartData.spec_line : chartData.spec_area
return (
<div className='overflow-hidden rounded-lg border'>
<div className='flex w-full flex-col gap-3 border-b px-4 py-3 sm:px-5 lg:flex-row lg:items-center lg:justify-between'>
<div className='flex items-center gap-2'>
<WalletCards className='text-muted-foreground/60 size-4' />
<div className='text-sm font-semibold'>
{t('Quota Distribution')}
</div>
<span className='text-muted-foreground text-xs'>
{t('Total:')} {chartData.totalQuotaDisplay}
</span>
</div>
<div className='bg-muted/60 inline-flex h-8 rounded-md border p-0.5'>
{CHART_TYPES.map((item) => {
const Icon = item.icon
return (
<button
key={item.value}
type='button'
onClick={() => setChartType(item.value)}
className={`inline-flex items-center gap-1.5 rounded-[5px] px-3 text-xs font-medium transition-colors ${
chartType === item.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Icon className='size-3.5' />
{t(item.labelKey)}
</button>
)
})}
</div>
</div>
<div className='h-96 p-2'>
{themeReady && spec && (
<VChart
key={`${chartType}-${resolvedTheme}`}
spec={{
...spec,
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
background: 'transparent',
}}
option={VCHART_OPTION}
/>
)}
</div>
</div>
)
}

View File

@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { formatNumber, formatQuota } from '@/lib/format'
import { computeTimeRange } from '@/lib/time'
import { useAuthStore } from '@/stores/auth-store'
import { Skeleton } from '@/components/ui/skeleton'
import { getUserQuotaDates } from '@/features/dashboard/api'
import { useModelStatCardsConfig } from '@/features/dashboard/hooks/use-dashboard-config'
@ -21,6 +22,8 @@ interface LogStatCardsProps {
export function LogStatCards(props: LogStatCardsProps) {
const statCardsConfig = useModelStatCardsConfig()
const user = useAuthStore((state) => state.auth.user)
const isAdmin = !!(user?.role && user.role >= 10)
const [stats, setStats] = useState<{
totalQuota: number
totalCount: number
@ -49,7 +52,7 @@ export function LogStatCards(props: LogStatCardsProps) {
const timeDiff = (timeRange.end_timestamp - timeRange.start_timestamp) / 60
setTimeRangeMinutes(timeDiff)
getUserQuotaDates(buildQueryParams(timeRange, filters))
getUserQuotaDates(buildQueryParams(timeRange, filters), isAdmin)
.then((res) => {
if (abortController.signal.aborted) return
const data = res?.data || []
@ -71,7 +74,7 @@ export function LogStatCards(props: LogStatCardsProps) {
return () => {
abortController.abort()
}
}, [filters, onDataUpdate])
}, [filters, isAdmin, onDataUpdate])
const adaptedStats = {
rpm: stats?.totalCount ?? 0,

View File

@ -7,26 +7,27 @@ import { VCHART_OPTION } from '@/lib/vchart'
import { useTheme } from '@/context/theme-provider'
import { DEFAULT_TIME_GRANULARITY } from '@/features/dashboard/constants'
import { processChartData } from '@/features/dashboard/lib'
import type {
ProcessedChartData,
QuotaDataItem,
} from '@/features/dashboard/types'
import type { QuotaDataItem } from '@/features/dashboard/types'
let themeManagerPromise: Promise<
(typeof import('@visactor/vchart'))['ThemeManager']
> | null = null
type ChartTab = '1' | '2' | '3' | '4'
type ChartTab = 'trend' | 'proportion' | 'top'
type ChartSpecKey = 'spec_model_line' | 'spec_pie' | 'spec_rank_bar'
const CHART_TABS: {
value: ChartTab
labelKey: string
specKey: keyof ProcessedChartData
specKey: ChartSpecKey
}[] = [
{ value: '1', labelKey: 'Quota Distribution', specKey: 'spec_line' },
{ value: '2', labelKey: 'Call Trend', specKey: 'spec_model_line' },
{ value: '3', labelKey: 'Call Proportion', specKey: 'spec_pie' },
{ value: '4', labelKey: 'Top Models', specKey: 'spec_rank_bar' },
{ value: 'trend', labelKey: 'Call Trend', specKey: 'spec_model_line' },
{
value: 'proportion',
labelKey: 'Call Count Distribution',
specKey: 'spec_pie',
},
{ value: 'top', labelKey: 'Call Count Ranking', specKey: 'spec_rank_bar' },
]
interface ModelChartsProps {
@ -38,7 +39,7 @@ interface ModelChartsProps {
export function ModelCharts(props: ModelChartsProps) {
const { t } = useTranslation()
const { resolvedTheme } = useTheme()
const [activeTab, setActiveTab] = useState<ChartTab>('1')
const [activeTab, setActiveTab] = useState<ChartTab>('trend')
const [themeReady, setThemeReady] = useState(false)
const themeManagerRef = useRef<
(typeof import('@visactor/vchart'))['ThemeManager'] | null

View File

@ -24,10 +24,8 @@ let themeManagerPromise: Promise<
(typeof import('@visactor/vchart'))['ThemeManager']
> | null = null
type UserChartTab = 'rank' | 'trend'
const CHART_TABS: {
value: UserChartTab
const USER_CHARTS: {
value: string
labelKey: string
specKey: keyof ProcessedUserChartData
}[] = [
@ -46,7 +44,6 @@ const CHART_TABS: {
export function UserCharts() {
const { t } = useTranslation()
const { resolvedTheme } = useTheme()
const [activeTab, setActiveTab] = useState<UserChartTab>('rank')
const [themeReady, setThemeReady] = useState(false)
const themeManagerRef = useRef<
(typeof import('@visactor/vchart'))['ThemeManager'] | null
@ -121,9 +118,6 @@ export function UserCharts() {
[userData, isLoading, timeGranularity, t]
)
const activeSpec = CHART_TABS.find((tab) => tab.value === activeTab)
const spec = activeSpec ? chartData[activeSpec.specKey] : null
return (
<div className='space-y-4'>
{/* Toolbar: time range presets + granularity */}
@ -169,50 +163,41 @@ export function UserCharts() {
)}
</div>
{/* Chart card */}
<div className='overflow-hidden rounded-lg border'>
<div className='flex w-full flex-col gap-3 border-b px-4 py-3 sm:px-5 lg:flex-row lg:items-center lg:justify-between'>
<div className='flex items-center gap-2'>
<Users className='text-muted-foreground/60 size-4' />
<div className='text-sm font-semibold'>{t('User Analytics')}</div>
</div>
<div className='grid gap-4'>
{USER_CHARTS.map((chart) => {
const spec = chartData[chart.specKey]
<div className='bg-muted/60 inline-flex h-8 rounded-md border p-0.5'>
{CHART_TABS.map((tab) => (
<button
key={tab.value}
type='button'
onClick={() => setActiveTab(tab.value)}
className={`rounded-[5px] px-3 text-xs font-medium transition-colors ${
activeTab === tab.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t(tab.labelKey)}
</button>
))}
</div>
</div>
return (
<div
key={chart.value}
className='overflow-hidden rounded-lg border'
>
<div className='flex w-full items-center gap-2 border-b px-4 py-3 sm:px-5'>
<Users className='text-muted-foreground/60 size-4' />
<div className='text-sm font-semibold'>{t(chart.labelKey)}</div>
</div>
<div className='h-96 p-2'>
{isLoading ? (
<Skeleton className='h-full w-full' />
) : (
themeReady &&
spec && (
<VChart
key={`user-${activeTab}-${resolvedTheme}`}
spec={{
...spec,
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
background: 'transparent',
}}
option={VCHART_OPTION}
/>
)
)}
</div>
<div className='h-96 p-2'>
{isLoading ? (
<Skeleton className='h-full w-full' />
) : (
themeReady &&
spec && (
<VChart
key={`user-${chart.value}-${resolvedTheme}`}
spec={{
...spec,
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
background: 'transparent',
}}
option={VCHART_OPTION}
/>
)
)}
</div>
</div>
)
})}
</div>
</div>
)

View File

@ -35,6 +35,12 @@ const LazyModelCharts = lazy(() =>
}))
)
const LazyConsumptionDistributionChart = lazy(() =>
import('./components/models/consumption-distribution-chart').then((m) => ({
default: m.ConsumptionDistributionChart,
}))
)
const LazyUserCharts = lazy(() =>
import('./components/users/user-charts').then((m) => ({
default: m.UserCharts,
@ -163,6 +169,17 @@ export function Dashboard() {
</Suspense>
</FadeIn>
<FadeIn delay={0.1}>
<Suspense fallback={<ModelChartsFallback />}>
<LazyConsumptionDistributionChart
data={modelData}
loading={dataLoading}
timeGranularity={
modelFilters.time_granularity || DEFAULT_TIME_GRANULARITY
}
/>
</Suspense>
</FadeIn>
<FadeIn delay={0.15}>
<Suspense fallback={<ModelChartsFallback />}>
<LazyModelCharts
data={modelData}

View File

@ -1,5 +1,5 @@
import { getChartColor } from '@/lib/colors'
import { formatQuotaWithCurrency, getCurrencyDisplay } from '@/lib/currency'
import { dataScheme as vchartDefaultDataScheme } from '@visactor/vchart/esm/theme/color-scheme/builtin/default'
import { getCurrencyDisplay } from '@/lib/currency'
import { formatChartTime, type TimeGranularity } from '@/lib/time'
import { MAX_CHART_TREND_POINTS } from '@/features/dashboard/constants'
import type {
@ -10,6 +10,38 @@ import type {
type TFunction = (key: string) => string
function getVChartDefaultColors(domainLength: number) {
const scheme =
vchartDefaultDataScheme.find(
(item) => !item.maxDomainLength || domainLength <= item.maxDomainLength
) ?? vchartDefaultDataScheme[vchartDefaultDataScheme.length - 1]
return scheme.scheme
}
function buildModelColorSpec(models: string[]) {
const domain = Array.from(new Set(models))
return {
type: 'ordinal',
domain,
range: getVChartDefaultColors(domain.length),
}
}
function renderQuotaCompat(rawQuota: number, digits = 4): string {
const { config, meta } = getCurrencyDisplay()
if (meta.kind === 'tokens') return rawQuota.toLocaleString()
const usd = rawQuota / config.quotaPerUnit
const rate = 'exchangeRate' in meta ? meta.exchangeRate : 1
const symbol = 'symbol' in meta ? meta.symbol : '$'
const value = usd * rate
const fixed = value.toFixed(digits)
if (parseFloat(fixed) === 0 && rawQuota > 0 && value > 0) {
return symbol + Math.pow(10, -digits).toFixed(digits)
}
return symbol + fixed
}
/**
* Process and aggregate chart data
*/
@ -19,9 +51,61 @@ export function processChartData(
t?: TFunction
): ProcessedChartData {
const tt: TFunction = t ?? ((x) => x)
const otherLabel = tt('Other')
const formatInt = (value: number) =>
Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value)
const formatQuotaValue = (value: number) => renderQuotaCompat(value, 4)
const formatQuotaTotal = (value: number) => renderQuotaCompat(value, 2)
const MAX_TOOLTIP_MODELS = 15
const makeTooltipDimensionUpdateContent = () => {
return (
array: Array<{
key: string
value: string | number
datum?: Record<string, unknown>
}>
) => {
array.sort((a, b) => (Number(b.value) || 0) - (Number(a.value) || 0))
let sum = 0
for (let i = 0; i < array.length; i++) {
if (array[i].key === 'Other' || array[i].key === otherLabel) continue
const v = Number(array[i].value) || 0
if (
array[i].datum &&
(array[i].datum as Record<string, unknown>)?.TimeSum
) {
sum =
Number((array[i].datum as Record<string, unknown>)?.TimeSum) || sum
}
array[i].value = formatQuotaValue(v)
}
if (array.length > MAX_TOOLTIP_MODELS) {
const visible = array.slice(0, MAX_TOOLTIP_MODELS)
let otherSum = 0
for (let i = MAX_TOOLTIP_MODELS; i < array.length; i++) {
const raw = array[i].datum
? Number((array[i].datum as Record<string, unknown>)?.rawQuota) || 0
: 0
otherSum += raw
}
visible.push({
key: otherLabel,
value: formatQuotaValue(otherSum),
})
array = visible
}
array.unshift({
key: tt('Total:'),
value: formatQuotaValue(sum),
})
return array
}
}
if (!data || data.length === 0) {
return {
@ -35,7 +119,7 @@ export function processChartData(
categoryField: 'type',
title: {
visible: true,
text: tt('Call Proportion'),
text: tt('Call Count Distribution'),
subtext: tt('No data available'),
},
legends: { visible: false },
@ -54,15 +138,15 @@ export function processChartData(
seriesField: 'Model',
stack: true,
legends: { visible: true, selectMode: 'single' },
title: {
visible: true,
text: tt('Quota Distribution'),
subtext: `${tt('Total:')} ${formatQuotaWithCurrency(0, {
digitsLarge: 2,
digitsSmall: 2,
abbreviate: false,
})}`,
},
},
spec_area: {
type: 'area',
data: [{ id: 'areaData', values: [] }],
xField: 'Time',
yField: 'Usage',
seriesField: 'Model',
stack: true,
legends: { visible: true, selectMode: 'single' },
},
spec_model_line: {
type: 'line',
@ -86,10 +170,11 @@ export function processChartData(
legends: { visible: true, selectMode: 'single' },
title: {
visible: true,
text: tt('Top Models'),
text: tt('Call Count Ranking'),
subtext: `${tt('Total:')} ${formatInt(0)}`,
},
},
totalQuotaDisplay: formatQuotaTotal(0),
}
}
@ -142,6 +227,7 @@ export function processChartData(
const allModels = Array.from(modelTotalsMap.keys())
const sortedTimes = Array.from(timeModelMap.keys()).sort()
const sortedModels = [...allModels].sort()
const modelColor = buildModelColorSpec([...sortedModels, otherLabel])
// Pad time points if too few (default 7 points)
const MAX_TREND_POINTS = MAX_CHART_TREND_POINTS
@ -166,14 +252,6 @@ export function processChartData(
}
const chartTimes = fillTimePoints(sortedTimes)
const modelColorMap = sortedModels.reduce<Record<string, string>>(
(acc, model, index) => {
acc[model] = getChartColor(index)
return acc
},
{}
)
const totalTimes = Array.from(modelTotalsMap.values()).reduce(
(sum, x) => sum + (Number(x.count) || 0),
0
@ -223,14 +301,70 @@ export function processChartData(
})
lineValues.sort((a, b) => a.Time.localeCompare(b.Time))
// Line chart: model call trend
// Area chart: top models by quota + "Other" bucket (too many series = unreadable)
const MAX_AREA_MODELS = 15
const rankedQuotaModels = Array.from(modelTotalsMap.entries())
.map(([model, stats]) => ({
Model: model,
Quota: Number(stats.quota) || 0,
}))
.sort((a, b) => b.Quota - a.Quota)
const topAreaModels = new Set(
rankedQuotaModels.slice(0, MAX_AREA_MODELS).map((m) => m.Model)
)
const areaValues: typeof lineValues = []
chartTimes.forEach((time) => {
const buckets = new Map<string, { rawQuota: number; usage: number }>()
const modelMap = timeModelMap.get(time)
let timeSum = 0
sortedModels.forEach((model) => {
const stats = modelMap?.get(model)
const rawQuota = Number(stats?.quota) || 0
const usd = rawQuota ? rawQuota / quotaPerUnit : 0
const usage = usd ? Number(usd.toFixed(4)) : 0
timeSum += rawQuota
const key = topAreaModels.has(model) ? model : otherLabel
const prev = buckets.get(key) || { rawQuota: 0, usage: 0 }
buckets.set(key, {
rawQuota: prev.rawQuota + rawQuota,
usage: Number((prev.usage + usage).toFixed(4)),
})
})
for (const [model, vals] of buckets) {
areaValues.push({
Time: time,
Model: model,
rawQuota: vals.rawQuota,
Usage: vals.usage,
TimeSum: timeSum,
})
}
})
areaValues.sort((a, b) => a.Time.localeCompare(b.Time))
// Line chart: model call trend (top models + "Other" bucket)
const MAX_TREND_MODELS = 20
const rankedTrendModels = Array.from(modelTotalsMap.entries())
.map(([model, stats]) => ({
Model: model,
Count: Number(stats.count) || 0,
}))
.sort((a, b) => b.Count - a.Count)
const topTrendModels = rankedTrendModels
.slice(0, MAX_TREND_MODELS)
.map((item) => item.Model)
const otherTrendModels = rankedTrendModels
.slice(MAX_TREND_MODELS)
.map((item) => item.Model)
const modelLineValues: Array<{
Time: string
Model: string
Count: number
}> = []
chartTimes.forEach((time) => {
const timeData = sortedModels.map((model) => {
const timeData = topTrendModels.map((model) => {
const stats = timeModelMap.get(time)?.get(model)
return {
Time: time,
@ -238,6 +372,17 @@ export function processChartData(
Count: Number(stats?.count) || 0,
}
})
if (otherTrendModels.length > 0) {
const otherCount = otherTrendModels.reduce((sum, model) => {
const stats = timeModelMap.get(time)?.get(model)
return sum + (Number(stats?.count) || 0)
}, 0)
timeData.push({
Time: time,
Model: otherLabel,
Count: otherCount,
})
}
modelLineValues.push(...timeData)
})
modelLineValues.sort((a, b) => a.Time.localeCompare(b.Time))
@ -257,7 +402,7 @@ export function processChartData(
const otherCount = allRankValues
.slice(MAX_RANK_MODELS)
.reduce((sum, item) => sum + item.Count, 0)
rankValues = [...topModels, { Model: tt('Other'), Count: otherCount }]
rankValues = [...topModels, { Model: otherLabel, Count: otherCount }]
} else {
rankValues = allRankValues
}
@ -280,11 +425,12 @@ export function processChartData(
},
title: {
visible: true,
text: tt('Call Proportion'),
text: tt('Call Count Distribution'),
subtext: `${tt('Total:')} ${formatInt(totalTimes)}`,
},
legends: { visible: true, orient: 'left' },
label: { visible: true },
color: modelColor,
tooltip: {
mark: {
content: [
@ -296,7 +442,6 @@ export function processChartData(
],
},
},
color: { specified: modelColorMap },
background: { fill: 'transparent' },
animation: true,
},
@ -308,15 +453,7 @@ export function processChartData(
seriesField: 'Model',
stack: true,
legends: { visible: true, selectMode: 'single' },
title: {
visible: true,
text: tt('Quota Distribution'),
subtext: `${tt('Total:')} ${formatQuotaWithCurrency(totalQuotaRaw, {
digitsLarge: 2,
digitsSmall: 2,
abbreviate: false,
})}`,
},
color: modelColor,
bar: {
state: {
hover: { stroke: '#000', lineWidth: 1 },
@ -328,11 +465,7 @@ export function processChartData(
{
key: (datum: Record<string, unknown>) => datum?.Model,
value: (datum: Record<string, unknown>) =>
formatQuotaWithCurrency(Number(datum?.rawQuota) || 0, {
digitsLarge: 4,
digitsSmall: 4,
abbreviate: false,
}),
formatQuotaValue(Number(datum?.rawQuota) || 0),
},
],
},
@ -344,58 +477,67 @@ export function processChartData(
Number(datum?.rawQuota) || 0,
},
],
updateContent: (
array: Array<{
key: string
value: string | number
datum?: Record<string, unknown>
}>
) => {
array.sort(
(a, b) => (Number(b.value) || 0) - (Number(a.value) || 0)
)
let sum = 0
for (let i = 0; i < array.length; i++) {
if (array[i].key === 'Other') continue
const v = Number(array[i].value) || 0
if (
array[i].datum &&
(array[i].datum as Record<string, unknown>)?.TimeSum
) {
sum =
Number(
(array[i].datum as Record<string, unknown>)?.TimeSum
) || sum
}
array[i].value = formatQuotaWithCurrency(v, {
digitsLarge: 4,
digitsSmall: 4,
abbreviate: false,
})
}
array.unshift({
key: tt('Total:'),
value: formatQuotaWithCurrency(sum, {
digitsLarge: 4,
digitsSmall: 4,
abbreviate: false,
}),
})
return array
},
updateContent: makeTooltipDimensionUpdateContent(),
},
},
color: { specified: modelColorMap },
background: { fill: 'transparent' },
animation: true,
},
spec_area: {
type: 'area',
data: [{ id: 'areaData', values: areaValues }],
xField: 'Time',
yField: 'Usage',
seriesField: 'Model',
stack: false,
legends: { visible: true, selectMode: 'single' },
color: modelColor,
tooltip: {
mark: {
content: [
{
key: (datum: Record<string, unknown>) => datum?.Model,
value: (datum: Record<string, unknown>) =>
formatQuotaValue(Number(datum?.rawQuota) || 0),
},
],
},
dimension: {
content: [
{
key: (datum: Record<string, unknown>) => datum?.Model,
value: (datum: Record<string, unknown>) =>
Number(datum?.rawQuota) || 0,
},
],
updateContent: makeTooltipDimensionUpdateContent(),
},
},
area: {
style: {
fillOpacity: 0.08,
curveType: 'monotone',
},
},
line: {
style: {
lineWidth: 2,
curveType: 'monotone',
},
},
point: { visible: false },
background: { fill: 'transparent' },
animation: true,
},
spec_model_line: {
type: 'line',
type: 'area',
data: [{ id: 'lineData', values: modelLineValues }],
xField: 'Time',
yField: 'Count',
seriesField: 'Model',
stack: false,
legends: { visible: true, selectMode: 'single' },
color: modelColor,
title: {
visible: true,
text: tt('Call Trend'),
@ -442,7 +584,18 @@ export function processChartData(
},
},
},
color: { specified: modelColorMap },
area: {
style: {
fillOpacity: 0.08,
curveType: 'monotone',
},
},
line: {
style: {
lineWidth: 2,
curveType: 'monotone',
},
},
point: { visible: false },
background: { fill: 'transparent' },
animation: true,
@ -454,9 +607,10 @@ export function processChartData(
yField: 'Count',
seriesField: 'Model',
legends: { visible: true, selectMode: 'single' },
color: modelColor,
title: {
visible: true,
text: tt('Top Models'),
text: tt('Call Count Ranking'),
subtext: `${tt('Total:')} ${formatInt(totalTimes)}`,
},
bar: {
@ -475,10 +629,10 @@ export function processChartData(
],
},
},
color: { specified: modelColorMap },
background: { fill: 'transparent' },
animation: true,
},
totalQuotaDisplay: formatQuotaTotal(totalQuotaRaw),
}
}
@ -505,12 +659,7 @@ export function processUserChartData(
const { config } = getCurrencyDisplay()
const quotaPerUnit = config.quotaPerUnit
const formatVal = (raw: number) =>
formatQuotaWithCurrency(raw, {
digitsLarge: 2,
digitsSmall: 2,
abbreviate: false,
})
const formatVal = (raw: number) => renderQuotaCompat(raw, 2)
const emptyResult: ProcessedUserChartData = {
spec_user_rank: {

View File

@ -45,14 +45,12 @@ export function buildQueryParams(
): {
start_timestamp: number
end_timestamp: number
default_time?: string
default_time: string
username?: string
} {
return {
...timeRange,
...(filters?.time_granularity && {
default_time: filters.time_granularity,
}),
default_time: getSavedGranularity(filters?.time_granularity),
...(filters?.username && { username: filters.username }),
}
}

View File

@ -71,8 +71,10 @@ type VChartSpec = Record<string, any>
export interface ProcessedChartData {
spec_pie: VChartSpec
spec_line: VChartSpec
spec_area: VChartSpec
spec_model_line: VChartSpec
spec_rank_bar: VChartSpec
totalQuotaDisplay: string
}
export interface ProcessedUserChartData {

View File

@ -0,0 +1,173 @@
import { useMemo, useState } from 'react'
import { Check, ChevronsUpDown } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
export type ApiKeyGroupOption = {
value: string
label: string
desc?: string
ratio?: number | string
}
type ApiKeyGroupComboboxProps = {
options: ApiKeyGroupOption[]
value?: string
onValueChange: (value: string) => void
placeholder?: string
disabled?: boolean
}
function formatGroupRatio(ratio: ApiKeyGroupOption['ratio'], ratioLabel: string) {
if (ratio === undefined || ratio === null || ratio === '') return null
return `${ratio}x ${ratioLabel}`
}
function getRatioBadgeClassName(ratio: ApiKeyGroupOption['ratio']) {
if (typeof ratio !== 'number') {
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300'
}
if (ratio > 5) {
return 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300'
}
if (ratio > 3) {
return 'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/60 dark:bg-orange-950/40 dark:text-orange-300'
}
if (ratio > 1) {
return 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-300'
}
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300'
}
function GroupRatioBadge({ ratio }: { ratio: ApiKeyGroupOption['ratio'] }) {
const { t } = useTranslation()
const label = formatGroupRatio(ratio, t('Ratio'))
if (!label) return null
return (
<Badge variant='outline' className={getRatioBadgeClassName(ratio)}>
{label}
</Badge>
)
}
export function ApiKeyGroupCombobox({
options,
value,
onValueChange,
placeholder,
disabled,
}: ApiKeyGroupComboboxProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [searchValue, setSearchValue] = useState('')
const selectedOption = options.find((option) => option.value === value)
const filteredOptions = useMemo(() => {
const search = searchValue.trim().toLowerCase()
if (!search) return options
return options.filter((option) => {
const ratioText = String(option.ratio ?? '').toLowerCase()
return (
option.value.toLowerCase().includes(search) ||
option.label.toLowerCase().includes(search) ||
option.desc?.toLowerCase().includes(search) ||
ratioText.includes(search)
)
})
}, [options, searchValue])
const handleSelect = (selectedValue: string) => {
onValueChange(selectedValue)
setOpen(false)
setSearchValue('')
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type='button'
variant='outline'
role='combobox'
aria-expanded={open}
disabled={disabled}
className='h-auto min-h-10 w-full justify-between gap-3 px-3 py-2 text-start'
>
<span className='flex min-w-0 flex-1 items-center justify-between gap-3'>
<span className='min-w-0'>
<span className='block truncate font-medium'>
{selectedOption?.value || placeholder || t('Select a group')}
</span>
{selectedOption?.desc && (
<span className='text-muted-foreground block truncate text-xs'>
{selectedOption.desc}
</span>
)}
</span>
<GroupRatioBadge ratio={selectedOption?.ratio} />
</span>
<ChevronsUpDown className='h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[var(--radix-popover-trigger-width)] p-0'>
<Command shouldFilter={false}>
<CommandInput
placeholder={t('Search...')}
value={searchValue}
onValueChange={setSearchValue}
/>
<CommandList className='max-h-[360px]'>
<CommandEmpty>{t('No group found.')}</CommandEmpty>
<CommandGroup>
{filteredOptions.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={handleSelect}
className='items-start gap-3 px-3 py-3'
>
<Check
className={cn(
'mt-0.5 h-4 w-4',
value === option.value ? 'opacity-100' : 'opacity-0'
)}
/>
<span className='min-w-0 flex-1'>
<span className='block truncate font-medium'>
{option.value}
</span>
{option.desc && (
<span className='text-muted-foreground block truncate text-xs'>
{option.desc}
</span>
)}
</span>
<GroupRatioBadge ratio={option.ratio} />
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@ -62,7 +62,7 @@ function useGroupRatios(): Record<string, number> {
if (!res.success || !res.data) return {}
const ratios: Record<string, number> = {}
for (const [group, info] of Object.entries(res.data)) {
if (info.ratio !== undefined) {
if (typeof info.ratio === 'number') {
ratios[group] = info.ratio
}
}

View File

@ -1,3 +1,4 @@
import { useEffect, useState } from 'react'
import { ApiKeysDeleteDialog } from './api-keys-delete-dialog'
import { ApiKeysMutateDrawer } from './api-keys-mutate-drawer'
import { useApiKeys } from './api-keys-provider'
@ -5,6 +6,19 @@ import { CCSwitchDialog } from './dialogs/cc-switch-dialog'
export function ApiKeysDialogs() {
const { open, setOpen, currentRow, resolvedKey } = useApiKeys()
const [lastMutateSide, setLastMutateSide] = useState<'left' | 'right'>(
'right'
)
const mutateSide =
open === 'create' ? 'left' : open === 'update' ? 'right' : lastMutateSide
useEffect(() => {
if (open === 'create') {
setLastMutateSide('left')
} else if (open === 'update') {
setLastMutateSide('right')
}
}, [open])
return (
<>
@ -12,6 +26,7 @@ export function ApiKeysDialogs() {
open={open === 'create' || open === 'update'}
onOpenChange={(isOpen) => !isOpen && setOpen(null)}
currentRow={open === 'update' ? currentRow || undefined : undefined}
side={mutateSide}
/>
<ApiKeysDeleteDialog />
<CCSwitchDialog

View File

@ -23,13 +23,6 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Sheet,
SheetClose,
@ -53,18 +46,24 @@ import {
transformApiKeyToFormDefaults,
} from '../lib'
import { type ApiKey } from '../types'
import {
ApiKeyGroupCombobox,
type ApiKeyGroupOption,
} from './api-key-group-combobox'
import { useApiKeys } from './api-keys-provider'
type ApiKeyMutateDrawerProps = {
open: boolean
onOpenChange: (open: boolean) => void
currentRow?: ApiKey
side?: 'left' | 'right'
}
export function ApiKeysMutateDrawer({
open,
onOpenChange,
currentRow,
side = 'right',
}: ApiKeyMutateDrawerProps) {
const { t } = useTranslation()
const isUpdate = !!currentRow
@ -88,14 +87,22 @@ export function ApiKeysMutateDrawer({
const models = modelsData?.data || []
const groupsRaw = groupsData?.data || {}
const groups = Object.entries(groupsRaw).map(([key, info]) => ({
value: key,
label: info.desc || key,
}))
const groups: ApiKeyGroupOption[] = Object.entries(groupsRaw).map(
([key, info]) => ({
value: key,
label: key,
desc: info.desc || key,
ratio: info.ratio,
})
)
// Add auto group if configured
if (!groups.some((g) => g.value === 'auto')) {
groups.unshift({ value: 'auto', label: t('Auto (Circuit Breaker)') })
groups.unshift({
value: 'auto',
label: 'auto',
desc: t('Auto (Circuit Breaker)'),
})
}
const form = useForm<ApiKeyFormValues>({
@ -187,10 +194,9 @@ export function ApiKeysMutateDrawer({
form.setValue('expired_time', now)
}
const { config: currencyConfig, meta: currencyMeta } = getCurrencyDisplay()
const { meta: currencyMeta } = getCurrencyDisplay()
const currencyLabel = getCurrencyLabel()
const tokensOnly =
!currencyConfig.displayInCurrency || currencyMeta.kind === 'tokens'
const tokensOnly = currencyMeta.kind === 'tokens'
const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel })
const quotaPlaceholder = tokensOnly
? t('Enter quota in tokens')
@ -206,7 +212,10 @@ export function ApiKeysMutateDrawer({
}
}}
>
<SheetContent className='flex w-full flex-col sm:max-w-[600px]'>
<SheetContent
side={side}
className='flex w-full flex-col sm:max-w-[600px]'
>
<SheetHeader className='text-start'>
<SheetTitle>
{isUpdate ? t('Update API Key') : t('Create API Key')}
@ -244,20 +253,14 @@ export function ApiKeysMutateDrawer({
render={({ field }) => (
<FormItem>
<FormLabel>{t('Group')}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('Select a group')} />
</SelectTrigger>
</FormControl>
<SelectContent>
{groups.map((group) => (
<SelectItem key={group.value} value={group.value}>
{group.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormControl>
<ApiKeyGroupCombobox
options={groups}
value={field.value}
onValueChange={field.onChange}
placeholder={t('Select a group')}
/>
</FormControl>
<FormDescription>
{t('Auto group enables circuit breaker mechanism')}
</FormDescription>

View File

@ -38,6 +38,7 @@ import { getApiKeys, searchApiKeys } from '../api'
import { API_KEY_STATUS_OPTIONS, ERROR_MESSAGES } from '../constants'
import { type ApiKey } from '../types'
import { useApiKeysColumns } from './api-keys-columns'
import { ApiKeysPrimaryButtons } from './api-keys-primary-buttons'
import { useApiKeys } from './api-keys-provider'
import { DataTableBulkActions } from './data-table-bulk-actions'
@ -160,17 +161,22 @@ export function ApiKeysTable() {
return (
<>
<div className='space-y-4'>
<DataTableToolbar
table={table}
searchPlaceholder={t('Filter by name or key...')}
filters={[
{
columnId: 'status',
title: t('Status'),
options: API_KEY_STATUS_OPTIONS,
},
]}
/>
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between'>
<ApiKeysPrimaryButtons />
<div className='min-w-0 sm:flex sm:justify-end'>
<DataTableToolbar
table={table}
searchPlaceholder={t('Filter by name or key...')}
filters={[
{
columnId: 'status',
title: t('Status'),
options: API_KEY_STATUS_OPTIONS,
},
]}
/>
</div>
</div>
{isMobile ? (
<MobileCardList
table={table}

View File

@ -1,7 +1,6 @@
import { useTranslation } from 'react-i18next'
import { SectionPageLayout } from '@/components/layout'
import { ApiKeysDialogs } from './components/api-keys-dialogs'
import { ApiKeysPrimaryButtons } from './components/api-keys-primary-buttons'
import { ApiKeysProvider } from './components/api-keys-provider'
import { ApiKeysTable } from './components/api-keys-table'
@ -14,9 +13,6 @@ export function ApiKeys() {
<SectionPageLayout.Description>
{t('Manage your API keys for accessing the service')}
</SectionPageLayout.Description>
<SectionPageLayout.Actions>
<ApiKeysPrimaryButtons />
</SectionPageLayout.Actions>
<SectionPageLayout.Content>
<ApiKeysTable />
</SectionPageLayout.Content>

View File

@ -115,10 +115,9 @@ export function RedemptionsMutateDrawer({
form.setValue('expired_time', newDate)
}
const { config: currencyConfig, meta: currencyMeta } = getCurrencyDisplay()
const { meta: currencyMeta } = getCurrencyDisplay()
const currencyLabel = getCurrencyLabel()
const tokensOnly =
!currencyConfig.displayInCurrency || currencyMeta.kind === 'tokens'
const tokensOnly = currencyMeta.kind === 'tokens'
const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel })
const quotaPlaceholder = tokensOnly
? t('Enter quota in tokens')

View File

@ -3,6 +3,7 @@ import type { Resolver } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { RotateCcw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { DEFAULT_CURRENCY_CONFIG } from '@/stores/system-config-store'
import { Button } from '@/components/ui/button'
import {
Form,
@ -110,6 +111,12 @@ export function PricingSection({ defaultValues }: PricingSectionProps) {
})
const displayType = form.watch('general_setting.quota_display_type') ?? 'USD'
const displayInCurrencyEnabled = form.watch('DisplayInCurrencyEnabled')
const showTokensOnlyOption = displayType === 'TOKENS'
const showQuotaPerUnit =
displayType === 'TOKENS' ||
defaultValues.QuotaPerUnit !== DEFAULT_CURRENCY_CONFIG.quotaPerUnit
const showDisplayInCurrencyOption = displayInCurrencyEnabled === false
return (
<>
@ -122,30 +129,32 @@ export function PricingSection({ defaultValues }: PricingSectionProps) {
<Form {...form}>
<form onSubmit={handleSubmit} className='space-y-6'>
<FormDirtyIndicator isDirty={isDirty} />
<FormField
control={form.control}
name='QuotaPerUnit'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Quota Per Unit')}</FormLabel>
<FormControl>
<Input
type='number'
step='0.01'
value={field.value as number}
onChange={(e) => field.onChange(e.target.valueAsNumber)}
name={field.name}
onBlur={field.onBlur}
ref={field.ref}
/>
</FormControl>
<FormDescription>
{t('Number of tokens per unit quota')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{showQuotaPerUnit && (
<FormField
control={form.control}
name='QuotaPerUnit'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Quota Per Unit')}</FormLabel>
<FormControl>
<Input
type='number'
step='0.01'
value={field.value as number}
disabled
name={field.name}
onBlur={field.onBlur}
ref={field.ref}
/>
</FormControl>
<FormDescription>
{t('Number of tokens per unit quota')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
@ -165,7 +174,11 @@ export function PricingSection({ defaultValues }: PricingSectionProps) {
<SelectItem value='CUSTOM'>
{t('Custom Currency')}
</SelectItem>
<SelectItem value='TOKENS'>{t('Tokens Only')}</SelectItem>
{showTokensOnlyOption && (
<SelectItem value='TOKENS'>
{t('Tokens Only')}
</SelectItem>
)}
</SelectContent>
</Select>
<FormDescription>
@ -272,32 +285,34 @@ export function PricingSection({ defaultValues }: PricingSectionProps) {
</div>
)}
<FormField
control={form.control}
name='DisplayInCurrencyEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Display in Currency')}
</FormLabel>
<FormDescription>
{displayType === 'TOKENS'
? t(
'Tokens-only mode will show raw quota values regardless of this toggle.'
)
: t('Show prices in currency instead of quota.')}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{showDisplayInCurrencyOption && (
<FormField
control={form.control}
name='DisplayInCurrencyEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Display in Currency')}
</FormLabel>
<FormDescription>
{displayType === 'TOKENS'
? t(
'Tokens-only mode will show raw quota values regardless of this toggle.'
)
: t('Show prices in currency instead of quota.')}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
<FormField
control={form.control}

View File

@ -8,6 +8,7 @@ import {
formatLogQuota,
formatTimestampToDate,
} from '@/lib/format'
import { getAvatarColorClass } from '@/lib/colors'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
@ -241,19 +242,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
size='sm'
copyable={false}
/>
{log.request_id && (
<StatusBadge
label={
log.request_id.length > 18
? `${log.request_id.slice(0, 18)}`
: log.request_id
}
variant='neutral'
size='sm'
copyText={log.request_id}
className='max-w-[140px] truncate font-mono'
/>
)}
</div>
)
},
@ -267,45 +255,47 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
]
if (isAdmin) {
columns.push({
id: 'source',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Source')} />
),
cell: function SourceCell({ row }) {
const {
setAffinityTarget,
setAffinityDialogOpen,
setSelectedUserId,
setUserInfoDialogOpen,
} = useUsageLogsContext()
const log = row.original
columns.push(
{
id: 'channel',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Channel')} />
),
cell: function ChannelCell({ row }) {
const {
sensitiveVisible,
setAffinityTarget,
setAffinityDialogOpen,
} = useUsageLogsContext()
const log = row.original
if (!isDisplayableLogType(log.type)) return null
if (!isDisplayableLogType(log.type)) return null
const other = parseLogOther(log.other)
const affinity = other?.admin_info?.channel_affinity
const useChannel = other?.admin_info?.use_channel
const channelChain =
useChannel && useChannel.length > 0
? useChannel.join(' → ')
: undefined
const channelDisplay = log.channel_name
? `${log.channel_name} #${log.channel}`
: `#${log.channel}`
const other = parseLogOther(log.other)
const affinity = other?.admin_info?.channel_affinity
const useChannel = other?.admin_info?.use_channel
const channelChain =
useChannel && useChannel.length > 0
? useChannel.join(' → ')
: undefined
const channelDisplay = log.channel_name
? `${log.channel_name} #${log.channel}`
: `#${log.channel}`
const channelIdDisplay = `#${log.channel}`
const channelName = sensitiveVisible ? log.channel_name : '••••'
return (
<div className='flex flex-col gap-1'>
<div className='flex items-center gap-1'>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='relative'>
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='flex max-w-[160px] flex-col gap-0.5'>
<div className='relative inline-flex w-fit'>
<StatusBadge
label={channelDisplay}
autoColor={log.channel_name || String(log.channel)}
label={channelIdDisplay}
autoColor={String(log.channel)}
copyText={String(log.channel)}
size='sm'
className='font-mono'
/>
{affinity && (
<button
@ -329,57 +319,89 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
</button>
)}
</div>
</TooltipTrigger>
<TooltipContent>
<div className='space-y-1'>
<p>{channelDisplay}</p>
{channelChain && (
<p className='text-muted-foreground text-xs'>
{t('Chain')}: {channelChain}
{log.channel_name && (
<span className='text-muted-foreground/70 truncate text-[11px]'>
{channelName}
</span>
)}
</div>
</TooltipTrigger>
<TooltipContent>
<div className='space-y-1'>
<p>{sensitiveVisible ? channelDisplay : channelIdDisplay}</p>
{channelChain && (
<p className='text-muted-foreground text-xs'>
{t('Chain')}: {channelChain}
</p>
)}
{affinity && (
<div className='border-t pt-1 text-xs'>
<p className='font-medium'>{t('Channel Affinity')}</p>
<p>
{t('Rule')}: {affinity.rule_name || '-'}
</p>
)}
{affinity && (
<div className='border-t pt-1 text-xs'>
<p className='font-medium'>{t('Channel Affinity')}</p>
<p>
{t('Rule')}: {affinity.rule_name || '-'}
</p>
<p>
{t('Group')}:{' '}
{affinity.using_group ||
<p>
{t('Group')}:{' '}
{sensitiveVisible
? affinity.using_group ||
affinity.selected_group ||
'-'}
</p>
</div>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{log.username && (
<button
type='button'
className='flex items-center gap-1 text-left'
onClick={(e) => {
e.stopPropagation()
setSelectedUserId(log.user_id)
setUserInfoDialogOpen(true)
}}
>
<span className='bg-primary/10 text-primary flex size-4 items-center justify-center rounded-full text-[10px] font-bold'>
{log.username.charAt(0).toUpperCase()}
</span>
<span className='text-muted-foreground truncate text-xs hover:underline'>
{log.username}
</span>
</button>
)}
</div>
)
'-'
: '••••'}
</p>
</div>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
},
meta: { label: t('Channel'), mobileHidden: true },
},
meta: { label: t('Source'), mobileHidden: true },
})
{
id: 'user',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('User')} />
),
cell: function UserCell({ row }) {
const {
sensitiveVisible,
setSelectedUserId,
setUserInfoDialogOpen,
} = useUsageLogsContext()
const log = row.original
if (!isDisplayableLogType(log.type) || !log.username) return null
return (
<button
type='button'
className='flex items-center gap-1.5 text-left'
onClick={(e) => {
e.stopPropagation()
setSelectedUserId(log.user_id)
setUserInfoDialogOpen(true)
}}
>
<span
className={cn(
'flex size-5 items-center justify-center rounded-full text-[11px] font-bold',
sensitiveVisible
? getAvatarColorClass(log.username)
: 'bg-muted text-muted-foreground'
)}
>
{sensitiveVisible ? log.username.charAt(0).toUpperCase() : '•'}
</span>
<span className='text-muted-foreground truncate text-sm hover:underline'>
{sensitiveVisible ? log.username : '••••'}
</span>
</button>
)
},
meta: { label: t('User'), mobileHidden: true },
}
)
}
columns.push(
@ -389,6 +411,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
<DataTableColumnHeader column={column} title={t('Model')} />
),
cell: function ModelCell({ row }) {
const { sensitiveVisible } = useUsageLogsContext()
const log = row.original
if (!isDisplayableLogType(log.type)) return null
@ -450,8 +473,8 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
)
const metaParts: string[] = []
if (tokenName) metaParts.push(tokenName)
if (group) metaParts.push(group)
if (tokenName) metaParts.push(sensitiveVisible ? tokenName : '••••')
if (group) metaParts.push(sensitiveVisible ? group : '••••')
return (
<div className='flex max-w-[220px] flex-col gap-0.5'>

View File

@ -0,0 +1,279 @@
import { useState, useEffect, useCallback, type ReactNode } from 'react'
import { useNavigate, getRouteApi } from '@tanstack/react-router'
import { ChevronDown, Eye, EyeOff, RotateCcw, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { useIsAdmin } from '@/hooks/use-admin'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { LOG_TYPES } from '../constants'
import { buildSearchParams } from '../lib/filter'
import { getDefaultTimeRange } from '../lib/utils'
import type { CommonLogFilters } from '../types'
import { CompactDateTimeRangePicker } from './compact-date-time-range-picker'
import { useUsageLogsContext } from './usage-logs-provider'
const route = getRouteApi('/_authenticated/usage-logs/$section')
interface CommonLogsFilterBarProps {
stats?: ReactNode
viewOptions?: ReactNode
}
export function CommonLogsFilterBar({
stats,
viewOptions,
}: CommonLogsFilterBarProps) {
const { t } = useTranslation()
const navigate = useNavigate()
const searchParams = route.useSearch()
const isAdmin = useIsAdmin()
const { sensitiveVisible, setSensitiveVisible } = useUsageLogsContext()
const [expanded, setExpanded] = useState(false)
const [filters, setFilters] = useState<CommonLogFilters>(() => {
const { start, end } = getDefaultTimeRange()
return { startTime: start, endTime: end }
})
const [logType, setLogType] = useState<string>('')
useEffect(() => {
const next: Partial<CommonLogFilters> = {}
if (searchParams.startTime)
next.startTime = new Date(searchParams.startTime)
if (searchParams.endTime) next.endTime = new Date(searchParams.endTime)
if (searchParams.channel) next.channel = String(searchParams.channel)
if (searchParams.model) next.model = searchParams.model
if (searchParams.token) next.token = searchParams.token
if (searchParams.group) next.group = searchParams.group
if (searchParams.username) next.username = searchParams.username
if (searchParams.requestId) next.requestId = searchParams.requestId
if (Object.keys(next).length > 0) {
setFilters((prev) => ({ ...prev, ...next }))
}
const typeArr = searchParams.type
if (Array.isArray(typeArr) && typeArr.length === 1) {
setLogType(typeArr[0])
}
}, [
searchParams.startTime,
searchParams.endTime,
searchParams.channel,
searchParams.model,
searchParams.token,
searchParams.group,
searchParams.username,
searchParams.requestId,
searchParams.type,
])
const handleChange = useCallback(
(field: keyof CommonLogFilters, value: Date | string | undefined) => {
setFilters((prev) => ({ ...prev, [field]: value }))
},
[]
)
const handleApply = useCallback(() => {
const filterParams = buildSearchParams(filters, 'common')
navigate({
to: '/usage-logs/$section',
params: { section: 'common' },
search: (prev: Record<string, unknown>) => ({
...prev,
...filterParams,
...(logType ? { type: [logType] } : { type: undefined }),
page: 1,
}),
})
}, [filters, logType, navigate])
const handleReset = useCallback(() => {
const { start, end } = getDefaultTimeRange()
const resetFilters: CommonLogFilters = { startTime: start, endTime: end }
setFilters(resetFilters)
setLogType('')
navigate({
to: '/usage-logs/$section',
params: { section: 'common' },
search: {
page: 1,
startTime: start.getTime(),
endTime: end.getTime(),
},
})
}, [navigate])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleApply()
},
[handleApply]
)
const hasExpandedFilters =
!!filters.token ||
!!filters.username ||
!!filters.channel ||
!!filters.requestId
return (
<div className='space-y-3'>
{/* Primary filter row */}
<div className='grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-[minmax(280px,2fr)_minmax(140px,1fr)_minmax(120px,1fr)_minmax(120px,0.8fr)_auto]'>
<CompactDateTimeRangePicker
start={filters.startTime}
end={filters.endTime}
onChange={({ start, end }) => {
handleChange('startTime', start)
handleChange('endTime', end)
}}
className='col-span-2 lg:col-span-1'
/>
<Input
placeholder={t('Model Name')}
value={filters.model || ''}
onChange={(e) => handleChange('model', e.target.value)}
onKeyDown={handleKeyDown}
className='h-9'
/>
<Input
placeholder={t('Group')}
type={sensitiveVisible ? 'text' : 'password'}
value={filters.group || ''}
onChange={(e) => handleChange('group', e.target.value)}
onKeyDown={handleKeyDown}
className='h-9'
/>
<Select
value={logType}
onValueChange={(v) => setLogType(v === 'all' ? '' : v)}
>
<SelectTrigger className='h-9'>
<SelectValue placeholder={t('All Types')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>{t('All Types')}</SelectItem>
{LOG_TYPES.map((type) => (
<SelectItem key={type.value} value={String(type.value)}>
{t(type.label)}
</SelectItem>
))}
</SelectContent>
</Select>
<button
type='button'
className={cn(
'text-muted-foreground hover:text-foreground flex h-9 items-center gap-1 rounded-md px-2 text-xs transition-colors',
hasExpandedFilters && !expanded && 'text-primary'
)}
onClick={() => setExpanded((p) => !p)}
>
<ChevronDown
className={cn(
'size-3.5 transition-transform duration-200',
expanded && 'rotate-180'
)}
/>
{expanded ? t('Collapse') : t('Expand')}
</button>
</div>
{/* Expandable filter row */}
<div
className={cn(
'grid gap-2 overflow-hidden transition-all duration-200',
expanded
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
)}
>
<div className='min-h-0 overflow-hidden'>
<div className='grid grid-cols-2 gap-2 sm:grid-cols-4'>
<Input
placeholder={t('Token Name')}
type={sensitiveVisible ? 'text' : 'password'}
value={filters.token || ''}
onChange={(e) => handleChange('token', e.target.value)}
onKeyDown={handleKeyDown}
className='h-9'
/>
{isAdmin && (
<Input
placeholder={t('Username')}
type={sensitiveVisible ? 'text' : 'password'}
value={filters.username || ''}
onChange={(e) => handleChange('username', e.target.value)}
onKeyDown={handleKeyDown}
className='h-9'
/>
)}
{isAdmin && (
<Input
placeholder={t('Channel ID')}
value={filters.channel || ''}
onChange={(e) => handleChange('channel', e.target.value)}
onKeyDown={handleKeyDown}
className='h-9'
/>
)}
<Input
placeholder={t('Request ID')}
value={filters.requestId || ''}
onChange={(e) => handleChange('requestId', e.target.value)}
onKeyDown={handleKeyDown}
className='h-9'
/>
</div>
</div>
</div>
{/* Actions row */}
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex min-w-0 flex-wrap items-center gap-2 sm:gap-3'>
{stats && <div className='min-w-0'>{stats}</div>}
<button
type='button'
className='text-muted-foreground hover:text-foreground inline-flex h-6 items-center gap-1 rounded px-1 text-xs transition-colors'
title={sensitiveVisible ? t('Hide') : t('Show')}
aria-label={sensitiveVisible ? t('Hide') : t('Show')}
onClick={() => setSensitiveVisible(!sensitiveVisible)}
>
{sensitiveVisible ? (
<Eye className='size-3.5' />
) : (
<EyeOff className='size-3.5' />
)}
</button>
</div>
<div className='flex shrink-0 items-center gap-2 self-end sm:self-auto'>
<Button
variant='outline'
size='sm'
className='h-8'
onClick={handleReset}
>
<RotateCcw className='size-3.5' />
{t('Reset')}
</Button>
<Button size='sm' className='h-8' onClick={handleApply}>
<Search className='size-3.5' />
{t('Search')}
</Button>
{viewOptions}
</div>
</div>
</div>
)
}

View File

@ -5,10 +5,10 @@ import { formatLogQuota } from '@/lib/format'
import { cn } from '@/lib/utils'
import { useIsAdmin } from '@/hooks/use-admin'
import { Skeleton } from '@/components/ui/skeleton'
import { dotColorMap, textColorMap } from '@/components/status-badge'
import { getLogStats, getUserLogStats } from '../api'
import { DEFAULT_LOG_STATS } from '../constants'
import { buildApiParams } from '../lib/utils'
import { useUsageLogsContext } from './usage-logs-provider'
const route = getRouteApi('/_authenticated/usage-logs/$section')
@ -16,6 +16,7 @@ export function CommonLogsStats() {
const { t } = useTranslation()
const isAdmin = useIsAdmin()
const searchParams = route.useSearch()
const { sensitiveVisible } = useUsageLogsContext()
const { data: stats, isLoading } = useQuery({
queryKey: ['usage-logs-stats', isAdmin, searchParams],
@ -41,29 +42,42 @@ export function CommonLogsStats() {
if (isLoading) {
return (
<div className='flex items-center gap-2'>
<div className='flex items-center gap-1.5'>
<Skeleton className='h-6 w-[126px] rounded-md' />
<Skeleton className='h-6 w-[58px] rounded-md' />
<Skeleton className='h-6 w-[58px] rounded-md' />
<Skeleton className='h-6 w-[76px] rounded-md' />
<Skeleton className='h-6 w-[92px] rounded-md' />
</div>
)
}
const tagClass =
'inline-flex h-6 items-center rounded-md border px-2.5 text-xs font-medium shadow-xs'
return (
<div className='flex items-center gap-1.5 text-xs font-medium'>
<div className='flex flex-wrap items-center gap-1.5'>
<span
className={cn('size-1.5 shrink-0 rounded-full', dotColorMap.blue)}
aria-hidden='true'
/>
<span className={cn(textColorMap.blue)}>
{t('Usage')}: {formatLogQuota(stats?.quota || 0)}
className={cn(
tagClass,
'border-blue-200/70 bg-blue-50 text-blue-700 dark:border-blue-500/20 dark:bg-blue-500/10 dark:text-blue-300'
)}
>
{t('Usage')}:{' '}
{sensitiveVisible ? formatLogQuota(stats?.quota || 0) : '••••'}
</span>
<span className='text-muted-foreground/30'>·</span>
<span className={cn(textColorMap.pink)}>
<span
className={cn(
tagClass,
'border-pink-200/70 bg-pink-50 text-pink-700 dark:border-pink-500/20 dark:bg-pink-500/10 dark:text-pink-300'
)}
>
{t('RPM')}: {stats?.rpm || 0}
</span>
<span className='text-muted-foreground/30'>·</span>
<span className='text-muted-foreground'>
<span
className={cn(
tagClass,
'border-border bg-background text-muted-foreground'
)}
>
{t('TPM')}: {stats?.tpm || 0}
</span>
</div>

View File

@ -0,0 +1,199 @@
import { useMemo, useState } from 'react'
import { CalendarDays } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import dayjs from '@/lib/dayjs'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
interface CompactDateTimeRangePickerProps {
start?: Date
end?: Date
onChange: (range: { start?: Date; end?: Date }) => void
className?: string
}
function toInputValue(date?: Date): string {
return date ? dayjs(date).format('YYYY-MM-DDTHH:mm') : ''
}
function fromInputValue(value: string): Date | undefined {
if (!value) return undefined
const date = new Date(value)
return Number.isNaN(date.getTime()) ? undefined : date
}
export function CompactDateTimeRangePicker({
start,
end,
onChange,
className,
}: CompactDateTimeRangePickerProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [draftStart, setDraftStart] = useState(toInputValue(start))
const [draftEnd, setDraftEnd] = useState(toInputValue(end))
const label = useMemo(() => {
if (!start && !end) return t('Date Range')
const startText = start ? dayjs(start).format('YYYY-MM-DD HH:mm:ss') : '-'
const endText = end ? dayjs(end).format('YYYY-MM-DD HH:mm:ss') : '-'
return `${startText} ~ ${endText}`
}, [end, start, t])
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen) {
setDraftStart(toInputValue(start))
setDraftEnd(toInputValue(end))
}
setOpen(nextOpen)
}
const applyDraft = () => {
onChange({
start: fromInputValue(draftStart),
end: fromInputValue(draftEnd),
})
setOpen(false)
}
const applyPreset = (kind: 'today' | '7d' | 'week' | '30d' | 'month') => {
const now = dayjs()
const presets = {
today: {
start: now.startOf('day').toDate(),
end: now.endOf('day').toDate(),
},
'7d': {
start: now.subtract(6, 'day').startOf('day').toDate(),
end: now.endOf('day').toDate(),
},
week: {
start: now.startOf('week').toDate(),
end: now.endOf('week').toDate(),
},
'30d': {
start: now.subtract(29, 'day').startOf('day').toDate(),
end: now.endOf('day').toDate(),
},
month: {
start: now.startOf('month').toDate(),
end: now.endOf('month').toDate(),
},
}
const range = presets[kind]
setDraftStart(toInputValue(range.start))
setDraftEnd(toInputValue(range.end))
onChange(range)
setOpen(false)
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
type='button'
variant='outline'
className={cn(
'h-9 w-full justify-start gap-2 px-3 font-mono text-xs font-normal',
!start && !end && 'text-muted-foreground',
className
)}
>
<CalendarDays className='text-muted-foreground size-4 shrink-0' />
<span className='truncate'>{label}</span>
</Button>
</PopoverTrigger>
<PopoverContent align='start' className='w-[min(520px,calc(100vw-2rem))] p-3'>
<div className='space-y-3'>
<div className='grid gap-2 sm:grid-cols-[1fr_auto_1fr] sm:items-end'>
<div className='space-y-1.5'>
<div className='text-muted-foreground text-xs'>
{t('Start Time')}
</div>
<Input
type='datetime-local'
value={draftStart}
onChange={(e) => setDraftStart(e.target.value)}
className='h-8 font-mono text-xs'
/>
</div>
<span className='text-muted-foreground hidden pb-2 text-xs sm:block'>
~
</span>
<div className='space-y-1.5'>
<div className='text-muted-foreground text-xs'>
{t('End Time')}
</div>
<Input
type='datetime-local'
value={draftEnd}
onChange={(e) => setDraftEnd(e.target.value)}
className='h-8 font-mono text-xs'
/>
</div>
</div>
<div className='flex flex-wrap gap-1.5'>
<Button
type='button'
variant='secondary'
size='sm'
className='h-7 flex-1 px-2 text-xs'
onClick={() => applyPreset('today')}
>
{t('Today')}
</Button>
<Button
type='button'
variant='secondary'
size='sm'
className='h-7 flex-1 px-2 text-xs'
onClick={() => applyPreset('7d')}
>
{t('7 Days')}
</Button>
<Button
type='button'
variant='secondary'
size='sm'
className='h-7 flex-1 px-2 text-xs'
onClick={() => applyPreset('week')}
>
{t('This week')}
</Button>
<Button
type='button'
variant='secondary'
size='sm'
className='h-7 flex-1 px-2 text-xs'
onClick={() => applyPreset('30d')}
>
{t('30 Days')}
</Button>
<Button
type='button'
variant='secondary'
size='sm'
className='h-7 flex-1 px-2 text-xs'
onClick={() => applyPreset('month')}
>
{t('This month')}
</Button>
</div>
<div className='flex justify-end'>
<Button size='sm' className='h-8' onClick={applyDraft}>
{t('Confirm')}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)
}

View File

@ -11,6 +11,8 @@ interface UsageLogsContextValue {
setAffinityTarget: (target: ChannelAffinityInfo | null) => void
affinityDialogOpen: boolean
setAffinityDialogOpen: (open: boolean) => void
sensitiveVisible: boolean
setSensitiveVisible: (visible: boolean) => void
}
const UsageLogsContext = createContext<UsageLogsContextValue | undefined>(
@ -23,6 +25,7 @@ export function UsageLogsProvider({ children }: { children: ReactNode }) {
const [affinityTarget, setAffinityTarget] =
useState<ChannelAffinityInfo | null>(null)
const [affinityDialogOpen, setAffinityDialogOpen] = useState(false)
const [sensitiveVisible, setSensitiveVisible] = useState(true)
return (
<UsageLogsContext.Provider
@ -35,6 +38,8 @@ export function UsageLogsProvider({ children }: { children: ReactNode }) {
setAffinityTarget,
affinityDialogOpen,
setAffinityDialogOpen,
sensitiveVisible,
setSensitiveVisible,
}}
>
{children}

View File

@ -28,15 +28,18 @@ import {
import {
DataTablePagination,
DataTableToolbar,
DataTableViewOptions,
TableSkeleton,
TableEmpty,
MobileCardList,
} from '@/components/data-table'
import { PageFooterPortal } from '@/components/layout'
import { LOG_TYPE_FILTERS, DEFAULT_LOGS_DATA } from '../constants'
import { DEFAULT_LOGS_DATA } from '../constants'
import { useColumnsByCategory } from '../lib/columns'
import { fetchLogsByCategory } from '../lib/utils'
import type { LogCategory } from '../types'
import { CommonLogsFilterBar } from './common-logs-filter-bar'
import { CommonLogsStats } from './common-logs-stats'
const route = getRouteApi('/_authenticated/usage-logs/$section')
@ -147,25 +150,23 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
ensurePageInRange(pageCount)
}, [pageCount, ensurePageInRange])
const filters =
logCategory === 'common'
? [
{
columnId: 'created_at',
title: t('Log Type'),
options: LOG_TYPE_FILTERS.map((opt) => ({
value: opt.value,
label: t(opt.label),
})),
singleSelect: true,
},
]
: []
return (
<>
<div className='space-y-4'>
<DataTableToolbar table={table} filters={filters} customSearch={null} />
{logCategory === 'common' ? (
<div className='rounded-md border bg-card/50 p-3 shadow-xs'>
<CommonLogsFilterBar
stats={<CommonLogsStats />}
viewOptions={<DataTableViewOptions table={table} />}
/>
</div>
) : (
<DataTableToolbar
table={table}
filters={[]}
customSearch={null}
/>
)}
{isMobile ? (
<MobileCardList
table={table}

View File

@ -2,7 +2,6 @@ import { getRouteApi } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { SectionPageLayout } from '@/components/layout'
import { CacheStatsDialog } from '@/features/system-settings/general/channel-affinity/cache-stats-dialog'
import { CommonLogsStats } from './components/common-logs-stats'
import { UserInfoDialog } from './components/dialogs/user-info-dialog'
import { UsageLogsPrimaryButtons } from './components/usage-logs-primary-buttons'
import {
@ -60,8 +59,9 @@ function UsageLogsContent() {
{description}
</SectionPageLayout.Description>
<SectionPageLayout.Actions>
{activeCategory === 'common' && <CommonLogsStats />}
<UsageLogsPrimaryButtons logCategory={activeCategory} />
{activeCategory !== 'common' && (
<UsageLogsPrimaryButtons logCategory={activeCategory} />
)}
</SectionPageLayout.Actions>
<SectionPageLayout.Content>
<UsageLogsTable logCategory={activeCategory} />

View File

@ -32,10 +32,9 @@ export function UserQuotaDialog(props: UserQuotaDialogProps) {
const [amount, setAmount] = useState('')
const [loading, setLoading] = useState(false)
const { config: currencyConfig, meta: currencyMeta } = getCurrencyDisplay()
const { meta: currencyMeta } = getCurrencyDisplay()
const currencyLabel = getCurrencyLabel()
const tokensOnly =
!currencyConfig.displayInCurrency || currencyMeta.kind === 'tokens'
const tokensOnly = currencyMeta.kind === 'tokens'
const amountValue = parseFloat(amount) || 0
const quotaValue = parseQuotaFromDollars(Math.abs(amountValue))

View File

@ -95,10 +95,9 @@ export function UsersMutateDrawer({
}
}, [open, isUpdate, currentRow, form])
const { config: currencyConfig, meta: currencyMeta } = getCurrencyDisplay()
const { meta: currencyMeta } = getCurrencyDisplay()
const currencyLabel = getCurrencyLabel()
const tokensOnly =
!currencyConfig.displayInCurrency || currencyMeta.kind === 'tokens'
const tokensOnly = currencyMeta.kind === 'tokens'
const currentQuotaRaw = form.watch('quota_dollars') || 0

View File

@ -331,6 +331,7 @@
"Are you sure?": "Are you sure?",
"Args (space separated)": "Args (space separated)",
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.",
"Area Chart": "Area Chart",
"Asc": "Asc",
"Ask anything": "Ask anything",
"Async task refund": "Async task refund",
@ -407,6 +408,7 @@
"Balance queried successfully": "Balance queried successfully",
"Balance updated successfully": "Balance updated successfully",
"Balance updated: {{balance}}": "Balance updated: {{balance}}",
"Bar Chart": "Bar Chart",
"Bark Push URL": "Bark Push URL",
"Base address provided by your Epay service": "Base address provided by your Epay service",
"Base amount. Actual deduction = base amount × system group rate.": "Base amount. Actual deduction = base amount × system group rate.",
@ -497,6 +499,8 @@
"Calculated price: ${{price}} per 1M tokens": "Calculated price: ${{price}} per 1M tokens",
"Calculated ratio: {{ratio}}": "Calculated ratio: {{ratio}}",
"Calculating...": "Calculating...",
"Call Count Distribution": "Call Count Distribution",
"Call Count Ranking": "Call Count Ranking",
"Call Proportion": "Call Proportion",
"Call Trend": "Call Trend",
"Callback address": "Callback address",
@ -3236,6 +3240,7 @@
"this token group": "this token group",
"this user group": "this user group",
"This user has no bindings": "This user has no bindings",
"This week": "This week",
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.",
"This will clear custom pricing ratios and revert to upstream defaults.": "This will clear custom pricing ratios and revert to upstream defaults.",
"This will delete all": "This will delete all",

View File

@ -331,6 +331,7 @@
"Are you sure?": "Êtes-vous sûr ?",
"Args (space separated)": "Arguments (séparés par des espaces)",
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Tableau de préréglages de clients de chat. Chaque élément est un objet avec une paire clé-valeur : nom du client et son URL.",
"Area Chart": "Graphique en aires",
"Asc": "Asc",
"Ask anything": "Demandez n'importe quoi",
"Async task refund": "Remboursement de tâche asynchrone",
@ -407,6 +408,7 @@
"Balance queried successfully": "Solde interrogé avec succès",
"Balance updated successfully": "Solde mis à jour avec succès",
"Balance updated: {{balance}}": "Solde mis à jour : {{balance}}",
"Bar Chart": "Graphique en barres",
"Bark Push URL": "URL de notification Bark",
"Base address provided by your Epay service": "Adresse de base fournie par votre service Epay",
"Base amount. Actual deduction = base amount × system group rate.": "Montant de base. Déduction réelle = montant de base × taux du groupe système.",
@ -497,6 +499,8 @@
"Calculated price: ${{price}} per 1M tokens": "Prix calculé : ${{price}} par 1M tokens",
"Calculated ratio: {{ratio}}": "Ratio calculé : {{ratio}}",
"Calculating...": "Calcul en cours...",
"Call Count Distribution": "Distribution du nombre d'appels",
"Call Count Ranking": "Classement du nombre d'appels",
"Call Proportion": "Proportion d'appels",
"Call Trend": "Tendance des appels",
"Callback address": "Adresse de rappel",
@ -3236,6 +3240,7 @@
"this token group": "ce groupe de jetons",
"this user group": "ce groupe d'utilisateurs",
"This user has no bindings": "Cet utilisateur n'a aucune liaison",
"This week": "Cette semaine",
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "Cela ajoutera 2 règles modèles (Codex CLI et Claude CLI) à la liste de règles existante.",
"This will clear custom pricing ratios and revert to upstream defaults.": "Cela effacera les ratios de tarification personnalisés et rétablira les valeurs par défaut du fournisseur.",
"This will delete all": "Cela supprimera tout",

View File

@ -331,6 +331,7 @@
"Are you sure?": "よろしいですか?",
"Args (space separated)": "引数 (スペース区切り)",
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "チャットクライアントプリセットの配列。各項目は、クライアント名とそのURLという1つのキーと値のペアを持つオブジェクトです。",
"Area Chart": "面グラフ",
"Asc": "昇順",
"Ask anything": "何でも質問する",
"Async task refund": "非同期タスク返金",
@ -407,6 +408,7 @@
"Balance queried successfully": "残高の取得に成功しました",
"Balance updated successfully": "残高が正常に更新されました",
"Balance updated: {{balance}}": "残高更新:{{balance}}",
"Bar Chart": "棒グラフ",
"Bark Push URL": "BarkプッシュURL",
"Base address provided by your Epay service": "Epayサービスによって提供されるベースアドレス",
"Base amount. Actual deduction = base amount × system group rate.": "基本金額。実際の控除 = 基本金額 × システムグループ倍率。",
@ -497,6 +499,8 @@
"Calculated price: ${{price}} per 1M tokens": "計算価格:${{price}} / 1M トークン",
"Calculated ratio: {{ratio}}": "計算倍率:{{ratio}}",
"Calculating...": "計算中...",
"Call Count Distribution": "呼び出し回数分布",
"Call Count Ranking": "呼び出し回数ランキング",
"Call Proportion": "呼び出し比率",
"Call Trend": "呼び出し傾向",
"Callback address": "コールバックアドレス",
@ -3236,6 +3240,7 @@
"this token group": "このトークングループ",
"this user group": "このユーザーグループ",
"This user has no bindings": "このユーザーには連携がありません",
"This week": "今週",
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "既存のルールリストに2つのテンプレートルールCodex CLIとClaude CLIを追加します。",
"This will clear custom pricing ratios and revert to upstream defaults.": "これにより、カスタム料金比率がクリアされ、上位のデフォルトに戻ります。",
"This will delete all": "これによりすべて削除されます",

View File

@ -331,6 +331,7 @@
"Are you sure?": "Вы уверены?",
"Args (space separated)": "Аргументы (разделённые пробелами)",
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Массив предустановок чат-клиентов. Каждый элемент представляет собой объект с одной парой ключ-значение: имя клиента и его URL.",
"Area Chart": "Диаграмма с областями",
"Asc": "По возрастанию",
"Ask anything": "Спросите что угодно",
"Async task refund": "Возврат асинхронной задачи",
@ -407,6 +408,7 @@
"Balance queried successfully": "Баланс успешно запрошен",
"Balance updated successfully": "Баланс успешно обновлён",
"Balance updated: {{balance}}": "Баланс обновлён: {{balance}}",
"Bar Chart": "Столбчатая диаграмма",
"Bark Push URL": "URL для push-уведомлений Bark",
"Base address provided by your Epay service": "Базовый адрес, предоставленный вашим сервисом Epay",
"Base amount. Actual deduction = base amount × system group rate.": "Базовая сумма. Фактический вычет = базовая сумма × коэффициент группы.",
@ -497,6 +499,8 @@
"Calculated price: ${{price}} per 1M tokens": "Расчётная цена: ${{price}} за 1М токенов",
"Calculated ratio: {{ratio}}": "Расчётный коэффициент: {{ratio}}",
"Calculating...": "Вычисление...",
"Call Count Distribution": "Распределение количества вызовов",
"Call Count Ranking": "Рейтинг по количеству вызовов",
"Call Proportion": "Доля вызовов",
"Call Trend": "Тенденция вызовов",
"Callback address": "Адрес обратного вызова",
@ -3236,6 +3240,7 @@
"this token group": "эта группа токенов",
"this user group": "эта группа пользователей",
"This user has no bindings": "У этого пользователя нет привязок",
"This week": "На этой неделе",
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "Это добавит 2 шаблонных правила (Codex CLI и Claude CLI) к существующему списку правил.",
"This will clear custom pricing ratios and revert to upstream defaults.": "Это очистит пользовательские ценовые коэффициенты и вернет к стандартным значениям поставщика.",
"This will delete all": "Это удалит все",

View File

@ -331,6 +331,7 @@
"Are you sure?": "Bạn có chắc không?",
"Args (space separated)": "Đối số (cách nhau bằng khoảng trắng)",
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Mảng các thiết lập sẵn của ứng dụng trò chuyện. Mỗi mục là một đối tượng với",
"Area Chart": "Biểu đồ vùng",
"Asc": "Asc",
"Ask anything": "Hỏi gì cũng được",
"Async task refund": "Hoàn tiền tác vụ bất đồng bộ",
@ -407,6 +408,7 @@
"Balance queried successfully": "Truy vấn số dư thành công",
"Balance updated successfully": "Đã cập nhật số dư thành công",
"Balance updated: {{balance}}": "Số dư đã cập nhật: {{balance}}",
"Bar Chart": "Biểu đồ cột",
"Bark Push URL": "URL đẩy Bark",
"Base address provided by your Epay service": "Địa chỉ cơ sở được cung cấp bởi dịch vụ Epay của bạn",
"Base amount. Actual deduction = base amount × system group rate.": "Số tiền cơ sở. Số tiền trừ thực tế = số tiền cơ sở × tỷ lệ nhóm hệ thống.",
@ -497,6 +499,8 @@
"Calculated price: ${{price}} per 1M tokens": "Giá tính toán: ${{price}} mỗi 1M token",
"Calculated ratio: {{ratio}}": "Tỷ lệ tính toán: {{ratio}}",
"Calculating...": "Đang tính...",
"Call Count Distribution": "Phân bổ số lượt gọi",
"Call Count Ranking": "Xếp hạng số lượt gọi",
"Call Proportion": "Tỷ lệ cuộc gọi",
"Call Trend": "Xu hướng cuộc gọi",
"Callback address": "Địa chỉ callback",
@ -3236,6 +3240,7 @@
"this token group": "nhóm token này",
"this user group": "nhóm người dùng này",
"This user has no bindings": "Người dùng này không có liên kết nào",
"This week": "Tuần này",
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "Thao tác này sẽ thêm 2 quy tắc mẫu (Codex CLI và Claude CLI) vào danh sách quy tắc hiện có.",
"This will clear custom pricing ratios and revert to upstream defaults.": "Điều này sẽ xóa các tỷ lệ giá tùy chỉnh và trở về mặc định ban đầu.",
"This will delete all": "Thao tác này sẽ xóa tất cả",

View File

@ -331,6 +331,7 @@
"Are you sure?": "您确定吗?",
"Args (space separated)": "参数 (空格分隔)",
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "聊天客户端预设数组。每个项目都是一个对象,包含一个键值对:客户端名称及其 URL。",
"Area Chart": "面积图",
"Asc": "升序",
"Ask anything": "随便问",
"Async task refund": "异步任务退款",
@ -407,6 +408,7 @@
"Balance queried successfully": "余额查询成功",
"Balance updated successfully": "余额更新成功",
"Balance updated: {{balance}}": "余额已更新:{{balance}}",
"Bar Chart": "柱状图",
"Bark Push URL": "Bark 推送 URL",
"Base address provided by your Epay service": "您的 Epay 服务提供的基础地址",
"Base amount. Actual deduction = base amount × system group rate.": "基础金额,实际扣费 = 基础金额 × 系统分组倍率。",
@ -497,6 +499,8 @@
"Calculated price: ${{price}} per 1M tokens": "计算价格:${{price}} / 1M tokens",
"Calculated ratio: {{ratio}}": "计算倍率:{{ratio}}",
"Calculating...": "计算中...",
"Call Count Distribution": "调用次数分布",
"Call Count Ranking": "调用次数排行",
"Call Proportion": "调用比例",
"Call Trend": "调用趋势",
"Callback address": "回调地址",
@ -3236,6 +3240,7 @@
"this token group": "此令牌分组",
"this user group": "此用户分组",
"This user has no bindings": "该用户无任何绑定",
"This week": "本周",
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "这将在现有规则列表中追加 2 条模板规则Codex CLI 和 Claude CLI。",
"This will clear custom pricing ratios and revert to upstream defaults.": "这将清除自定义定价比例并恢复到上游默认值。",
"This will delete all": "这将删除所有",

View File

@ -175,7 +175,7 @@ export async function getUserModels(): Promise<{
export async function getUserGroups(): Promise<{
success: boolean
message?: string
data?: Record<string, { desc: string; ratio: number }>
data?: Record<string, { desc: string; ratio: number | string }>
}> {
const res = await api.get('/api/user/self/groups')
return res.data

View File

@ -35,6 +35,29 @@ export const colorToBgClass: Record<SemanticColor, string> = {
grey: 'bg-gray-500',
}
export const avatarColorMap: Record<SemanticColor, string> = {
blue: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400',
green: 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400',
cyan: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-500/20 dark:text-cyan-400',
purple: 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-400',
pink: 'bg-pink-100 text-pink-700 dark:bg-pink-500/20 dark:text-pink-400',
red: 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400',
orange: 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-400',
amber: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400',
yellow: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400',
lime: 'bg-lime-100 text-lime-700 dark:bg-lime-500/20 dark:text-lime-400',
'light-green': 'bg-green-50 text-green-600 dark:bg-green-400/20 dark:text-green-300',
teal: 'bg-teal-100 text-teal-700 dark:bg-teal-500/20 dark:text-teal-400',
'light-blue': 'bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-400',
indigo: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-400',
violet: 'bg-violet-100 text-violet-700 dark:bg-violet-500/20 dark:text-violet-400',
grey: 'bg-gray-100 text-gray-700 dark:bg-gray-500/20 dark:text-gray-400',
}
export function getAvatarColorClass(name: string): string {
return avatarColorMap[stringToColor(name)]
}
export function getBgColorClass(color?: string): string {
if (!color) return colorToBgClass.blue
return (

View File

@ -262,6 +262,7 @@ function formatCurrencyValue(
const formatted = new Intl.NumberFormat(undefined, {
style: 'currency',
currency: meta.currencyCode,
currencyDisplay: 'narrowSymbol',
minimumFractionDigits: 0,
maximumFractionDigits: digits,
}).format(adjustedValue)
@ -337,11 +338,11 @@ export function formatCurrencyFromUSD(
const { config, meta } = getCurrencyDisplay()
const merged = mergeOptions(options)
if (!config.displayInCurrency || meta.kind === 'tokens') {
if (meta.kind === 'tokens') {
const tokens = amountUSD * config.quotaPerUnit
return formatNumberWithSuffix(
tokens,
meta.kind === 'tokens' ? 0 : merged.digitsLarge,
0,
merged.digitsSmall,
merged.abbreviate
)
@ -463,7 +464,7 @@ export function formatQuotaWithCurrency(
export function getCurrencyLabel(): string {
const { config, meta } = getCurrencyDisplay()
if (!config.displayInCurrency || meta.kind === 'tokens') {
if (meta.kind === 'tokens') {
return 'Tokens'
}
@ -494,8 +495,8 @@ export function getCurrencyLabel(): string {
* Use this to conditionally show currency-specific UI elements
*/
export function isCurrencyDisplayEnabled(): boolean {
const { config, meta } = getCurrencyDisplay()
return config.displayInCurrency && meta.kind !== 'tokens'
const { meta } = getCurrencyDisplay()
return meta.kind !== 'tokens'
}
/**

View File

@ -61,7 +61,7 @@ export function parseQuotaFromDollars(amount: number): number {
const { config, meta } = getCurrencyDisplay()
// Tokens-only or raw quota mode
if (!config.displayInCurrency || meta.kind === 'tokens') {
if (meta.kind === 'tokens') {
return Math.round(amount)
}
@ -80,7 +80,7 @@ export function parseQuotaFromDollars(amount: number): number {
export function quotaUnitsToDollars(units: number): number {
const { config, meta } = getCurrencyDisplay()
if (!config.displayInCurrency || meta.kind === 'tokens') {
if (meta.kind === 'tokens') {
return units
}