feat: enhance UI and functionality in various components
This commit is contained in:
+11
-8
@@ -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 }
|
||||
|
||||
+120
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
+17
@@ -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}
|
||||
|
||||
+239
-90
@@ -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: {
|
||||
|
||||
+2
-4
@@ -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 }),
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user