878 lines
25 KiB
TypeScript
Vendored
878 lines
25 KiB
TypeScript
Vendored
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 {
|
|
QuotaDataItem,
|
|
ProcessedChartData,
|
|
ProcessedUserChartData,
|
|
} from '@/features/dashboard/types'
|
|
|
|
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
|
|
*/
|
|
export function processChartData(
|
|
data: QuotaDataItem[],
|
|
timeGranularity: TimeGranularity = 'day',
|
|
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 {
|
|
spec_pie: {
|
|
type: 'pie',
|
|
data: [{ id: 'id0', values: [] }],
|
|
outerRadius: 0.8,
|
|
innerRadius: 0.5,
|
|
padAngle: 0.6,
|
|
valueField: 'value',
|
|
categoryField: 'type',
|
|
title: {
|
|
visible: true,
|
|
text: tt('Call Count Distribution'),
|
|
subtext: tt('No data available'),
|
|
},
|
|
legends: { visible: false },
|
|
label: { visible: false },
|
|
tooltip: {
|
|
mark: {
|
|
content: [],
|
|
},
|
|
},
|
|
},
|
|
spec_line: {
|
|
type: 'bar',
|
|
data: [{ id: 'barData', values: [] }],
|
|
xField: 'Time',
|
|
yField: 'Usage',
|
|
seriesField: 'Model',
|
|
stack: true,
|
|
legends: { visible: true, selectMode: 'single' },
|
|
},
|
|
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',
|
|
data: [{ id: 'lineData', values: [] }],
|
|
xField: 'Time',
|
|
yField: 'Count',
|
|
seriesField: 'Model',
|
|
legends: { visible: true, selectMode: 'single' },
|
|
title: {
|
|
visible: true,
|
|
text: tt('Call Trend'),
|
|
subtext: `${tt('Total:')} ${formatInt(0)}`,
|
|
},
|
|
},
|
|
spec_rank_bar: {
|
|
type: 'bar',
|
|
data: [{ id: 'rankData', values: [] }],
|
|
xField: 'Model',
|
|
yField: 'Count',
|
|
seriesField: 'Model',
|
|
legends: { visible: true, selectMode: 'single' },
|
|
title: {
|
|
visible: true,
|
|
text: tt('Call Count Ranking'),
|
|
subtext: `${tt('Total:')} ${formatInt(0)}`,
|
|
},
|
|
},
|
|
totalQuotaDisplay: formatQuotaTotal(0),
|
|
}
|
|
}
|
|
|
|
const { config } = getCurrencyDisplay()
|
|
const quotaPerUnit = config.quotaPerUnit
|
|
|
|
// Aggregate all metrics by time and model
|
|
const timeModelMap = new Map<
|
|
string,
|
|
Map<string, { quota: number; count: number; tokens: number }>
|
|
>()
|
|
const modelTotalsMap = new Map<
|
|
string,
|
|
{ quota: number; count: number; tokens: number }
|
|
>()
|
|
|
|
data.forEach((item) => {
|
|
const timestamp = Number(item.created_at)
|
|
const timeKey = formatChartTime(timestamp, timeGranularity)
|
|
const model = item.model_name || 'Unknown'
|
|
const quota = Number(item.quota) || 0
|
|
const count = Number(item.count) || 0
|
|
const tokens = Number(item.token_used) || 0
|
|
|
|
// Aggregate by time and model
|
|
if (!timeModelMap.has(timeKey)) {
|
|
timeModelMap.set(timeKey, new Map())
|
|
}
|
|
const modelMap = timeModelMap.get(timeKey)!
|
|
const existing = modelMap.get(model) || { quota: 0, count: 0, tokens: 0 }
|
|
modelMap.set(model, {
|
|
quota: existing.quota + quota,
|
|
count: existing.count + count,
|
|
tokens: existing.tokens + tokens,
|
|
})
|
|
|
|
// Calculate totals
|
|
const totalExisting = modelTotalsMap.get(model) || {
|
|
quota: 0,
|
|
count: 0,
|
|
tokens: 0,
|
|
}
|
|
modelTotalsMap.set(model, {
|
|
quota: totalExisting.quota + quota,
|
|
count: totalExisting.count + count,
|
|
tokens: totalExisting.tokens + tokens,
|
|
})
|
|
})
|
|
|
|
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
|
|
const fillTimePoints = (times: string[]) => {
|
|
if (times.length >= MAX_TREND_POINTS) return times
|
|
const lastTime = Math.max(
|
|
...data.map((item) => Number(item.created_at) || 0)
|
|
)
|
|
const intervalSec =
|
|
timeGranularity === 'week'
|
|
? 604800
|
|
: timeGranularity === 'day'
|
|
? 86400
|
|
: 3600
|
|
const padded = Array.from({ length: MAX_TREND_POINTS }, (_, i) =>
|
|
formatChartTime(
|
|
lastTime - (MAX_TREND_POINTS - 1 - i) * intervalSec,
|
|
timeGranularity
|
|
)
|
|
)
|
|
return padded
|
|
}
|
|
const chartTimes = fillTimePoints(sortedTimes)
|
|
|
|
const totalTimes = Array.from(modelTotalsMap.values()).reduce(
|
|
(sum, x) => sum + (Number(x.count) || 0),
|
|
0
|
|
)
|
|
const totalQuotaRaw = Array.from(modelTotalsMap.values()).reduce(
|
|
(sum, x) => sum + (Number(x.quota) || 0),
|
|
0
|
|
)
|
|
|
|
// Pie chart (model call count proportion)
|
|
const pieValues = Array.from(modelTotalsMap.entries())
|
|
.map(([model, stats]) => ({
|
|
type: model,
|
|
value: Number(stats.count) || 0,
|
|
}))
|
|
.sort((a, b) => b.value - a.value)
|
|
|
|
// Stacked bar: model quota distribution (quota -> USD)
|
|
const lineValues: Array<{
|
|
Time: string
|
|
Model: string
|
|
rawQuota: number
|
|
Usage: number
|
|
TimeSum: number
|
|
}> = []
|
|
|
|
chartTimes.forEach((time) => {
|
|
let timeData = sortedModels.map((model) => {
|
|
const stats = timeModelMap.get(time)?.get(model)
|
|
const rawQuota = Number(stats?.quota) || 0
|
|
const usd = rawQuota ? rawQuota / quotaPerUnit : 0
|
|
// Match legacy frontend getQuotaWithUnit(..., 4)
|
|
const usage = usd ? Number(usd.toFixed(4)) : 0
|
|
return {
|
|
Time: time,
|
|
Model: model,
|
|
rawQuota,
|
|
Usage: usage,
|
|
TimeSum: 0,
|
|
}
|
|
})
|
|
|
|
const timeSum = timeData.reduce((sum, item) => sum + item.rawQuota, 0)
|
|
timeData.sort((a, b) => b.rawQuota - a.rawQuota)
|
|
timeData = timeData.map((item) => ({ ...item, TimeSum: timeSum }))
|
|
lineValues.push(...timeData)
|
|
})
|
|
lineValues.sort((a, b) => a.Time.localeCompare(b.Time))
|
|
|
|
// 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 = topTrendModels.map((model) => {
|
|
const stats = timeModelMap.get(time)?.get(model)
|
|
return {
|
|
Time: time,
|
|
Model: model,
|
|
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))
|
|
|
|
// Rank bar: model call count ranking (top 20 + "Other" bucket)
|
|
const MAX_RANK_MODELS = 20
|
|
const allRankValues = Array.from(modelTotalsMap.entries())
|
|
.map(([model, stats]) => ({
|
|
Model: model,
|
|
Count: Number(stats.count) || 0,
|
|
}))
|
|
.sort((a, b) => b.Count - a.Count)
|
|
|
|
let rankValues: typeof allRankValues
|
|
if (allRankValues.length > MAX_RANK_MODELS) {
|
|
const topModels = allRankValues.slice(0, MAX_RANK_MODELS)
|
|
const otherCount = allRankValues
|
|
.slice(MAX_RANK_MODELS)
|
|
.reduce((sum, item) => sum + item.Count, 0)
|
|
rankValues = [...topModels, { Model: otherLabel, Count: otherCount }]
|
|
} else {
|
|
rankValues = allRankValues
|
|
}
|
|
|
|
return {
|
|
spec_pie: {
|
|
type: 'pie',
|
|
data: [{ id: 'id0', values: pieValues }],
|
|
outerRadius: 0.8,
|
|
innerRadius: 0.5,
|
|
padAngle: 0.6,
|
|
valueField: 'value',
|
|
categoryField: 'type',
|
|
pie: {
|
|
style: { cornerRadius: 10 },
|
|
state: {
|
|
hover: { outerRadius: 0.85, stroke: '#000', lineWidth: 1 },
|
|
selected: { outerRadius: 0.85, stroke: '#000', lineWidth: 1 },
|
|
},
|
|
},
|
|
title: {
|
|
visible: true,
|
|
text: tt('Call Count Distribution'),
|
|
subtext: `${tt('Total:')} ${formatInt(totalTimes)}`,
|
|
},
|
|
legends: { visible: true, orient: 'left' },
|
|
label: { visible: true },
|
|
color: modelColor,
|
|
tooltip: {
|
|
mark: {
|
|
content: [
|
|
{
|
|
key: (datum: Record<string, unknown>) => datum?.type,
|
|
value: (datum: Record<string, unknown>) =>
|
|
formatInt(Number(datum?.value) || 0),
|
|
},
|
|
],
|
|
},
|
|
},
|
|
background: { fill: 'transparent' },
|
|
animation: true,
|
|
},
|
|
spec_line: {
|
|
type: 'bar',
|
|
data: [{ id: 'barData', values: lineValues }],
|
|
xField: 'Time',
|
|
yField: 'Usage',
|
|
seriesField: 'Model',
|
|
stack: true,
|
|
legends: { visible: true, selectMode: 'single' },
|
|
color: modelColor,
|
|
bar: {
|
|
state: {
|
|
hover: { stroke: '#000', lineWidth: 1 },
|
|
},
|
|
},
|
|
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(),
|
|
},
|
|
},
|
|
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: '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'),
|
|
subtext: `${tt('Total:')} ${formatInt(totalTimes)}`,
|
|
},
|
|
tooltip: {
|
|
mark: {
|
|
content: [
|
|
{
|
|
key: (datum: Record<string, unknown>) => datum?.Model,
|
|
value: (datum: Record<string, unknown>) =>
|
|
formatInt(Number(datum?.Count) || 0),
|
|
},
|
|
],
|
|
},
|
|
dimension: {
|
|
content: [
|
|
{
|
|
key: (datum: Record<string, unknown>) => datum?.Model,
|
|
value: (datum: Record<string, unknown>) =>
|
|
Number(datum?.Count) || 0,
|
|
},
|
|
],
|
|
updateContent: (
|
|
array: Array<{
|
|
key: string
|
|
value: string | number
|
|
}>
|
|
) => {
|
|
array.sort(
|
|
(a, b) => (Number(b.value) || 0) - (Number(a.value) || 0)
|
|
)
|
|
let sum = 0
|
|
for (let i = 0; i < array.length; i++) {
|
|
const v = Number(array[i].value) || 0
|
|
sum += v
|
|
array[i].value = formatInt(v)
|
|
}
|
|
array.unshift({
|
|
key: tt('Total:'),
|
|
value: formatInt(sum),
|
|
})
|
|
return array
|
|
},
|
|
},
|
|
},
|
|
area: {
|
|
style: {
|
|
fillOpacity: 0.08,
|
|
curveType: 'monotone',
|
|
},
|
|
},
|
|
line: {
|
|
style: {
|
|
lineWidth: 2,
|
|
curveType: 'monotone',
|
|
},
|
|
},
|
|
point: { visible: false },
|
|
background: { fill: 'transparent' },
|
|
animation: true,
|
|
},
|
|
spec_rank_bar: {
|
|
type: 'bar',
|
|
data: [{ id: 'rankData', values: rankValues }],
|
|
xField: 'Model',
|
|
yField: 'Count',
|
|
seriesField: 'Model',
|
|
legends: { visible: true, selectMode: 'single' },
|
|
color: modelColor,
|
|
title: {
|
|
visible: true,
|
|
text: tt('Call Count Ranking'),
|
|
subtext: `${tt('Total:')} ${formatInt(totalTimes)}`,
|
|
},
|
|
bar: {
|
|
state: {
|
|
hover: { stroke: '#000', lineWidth: 1 },
|
|
},
|
|
},
|
|
tooltip: {
|
|
mark: {
|
|
content: [
|
|
{
|
|
key: (datum: Record<string, unknown>) => datum?.Model,
|
|
value: (datum: Record<string, unknown>) =>
|
|
formatInt(Number(datum?.Count) || 0),
|
|
},
|
|
],
|
|
},
|
|
},
|
|
background: { fill: 'transparent' },
|
|
animation: true,
|
|
},
|
|
totalQuotaDisplay: formatQuotaTotal(totalQuotaRaw),
|
|
}
|
|
}
|
|
|
|
const USER_COLORS = [
|
|
'#5B8FF9',
|
|
'#5AD8A6',
|
|
'#F6BD16',
|
|
'#E8684A',
|
|
'#6DC8EC',
|
|
'#9270CA',
|
|
'#FF9D4D',
|
|
'#269A99',
|
|
'#FF99C3',
|
|
'#5D7092',
|
|
]
|
|
|
|
export function processUserChartData(
|
|
data: QuotaDataItem[],
|
|
timeGranularity: TimeGranularity = 'day',
|
|
t?: TFunction,
|
|
limit = 10
|
|
): ProcessedUserChartData {
|
|
const tt: TFunction = t ?? ((x) => x)
|
|
const { config } = getCurrencyDisplay()
|
|
const quotaPerUnit = config.quotaPerUnit
|
|
|
|
const formatVal = (raw: number) => renderQuotaCompat(raw, 2)
|
|
|
|
const emptyResult: ProcessedUserChartData = {
|
|
spec_user_rank: {
|
|
type: 'bar',
|
|
data: [{ id: 'userRankData', values: [] }],
|
|
xField: 'rawQuota',
|
|
yField: 'User',
|
|
seriesField: 'User',
|
|
direction: 'horizontal',
|
|
title: {
|
|
visible: true,
|
|
text: tt('User Consumption Ranking'),
|
|
subtext: tt('No data available'),
|
|
},
|
|
legends: { visible: false },
|
|
color: { type: 'ordinal', range: USER_COLORS },
|
|
background: { fill: 'transparent' },
|
|
},
|
|
spec_user_trend: {
|
|
type: 'area',
|
|
data: [{ id: 'userTrendData', values: [] }],
|
|
xField: 'Time',
|
|
yField: 'rawQuota',
|
|
seriesField: 'User',
|
|
title: {
|
|
visible: true,
|
|
text: tt('User Consumption Trend'),
|
|
subtext: tt('No data available'),
|
|
},
|
|
legends: { visible: true, selectMode: 'single' },
|
|
color: { type: 'ordinal', range: USER_COLORS },
|
|
point: { visible: false },
|
|
background: { fill: 'transparent' },
|
|
},
|
|
}
|
|
|
|
if (!data || data.length === 0) return emptyResult
|
|
|
|
const userQuotaTotal = new Map<string, number>()
|
|
data.forEach((item) => {
|
|
const username = item.username || 'unknown'
|
|
const prev = userQuotaTotal.get(username) || 0
|
|
userQuotaTotal.set(username, prev + (Number(item.quota) || 0))
|
|
})
|
|
|
|
const sorted = Array.from(userQuotaTotal.entries()).sort(
|
|
(a, b) => b[1] - a[1]
|
|
)
|
|
const topUsers = sorted.slice(0, limit).map(([u]) => u)
|
|
const topUserSet = new Set(topUsers)
|
|
const totalQuota = sorted.slice(0, limit).reduce((s, [, q]) => s + q, 0)
|
|
|
|
const rankValues = sorted.slice(0, limit).map(([username, quota]) => ({
|
|
User: username,
|
|
rawQuota: quota,
|
|
Usage: Number((quota / quotaPerUnit).toFixed(4)),
|
|
}))
|
|
|
|
const userColorMap = topUsers.reduce<Record<string, string>>(
|
|
(acc, user, i) => {
|
|
acc[user] = USER_COLORS[i % USER_COLORS.length]
|
|
return acc
|
|
},
|
|
{}
|
|
)
|
|
|
|
const timeUserMap = new Map<string, Map<string, number>>()
|
|
const allTimePoints = new Set<string>()
|
|
|
|
data.forEach((item) => {
|
|
const ts = Number(item.created_at)
|
|
const timeKey = formatChartTime(ts, timeGranularity)
|
|
allTimePoints.add(timeKey)
|
|
const user = item.username || 'unknown'
|
|
if (!topUserSet.has(user)) return
|
|
if (!timeUserMap.has(timeKey)) timeUserMap.set(timeKey, new Map())
|
|
const map = timeUserMap.get(timeKey)!
|
|
map.set(user, (map.get(user) || 0) + (Number(item.quota) || 0))
|
|
})
|
|
|
|
const sortedTimePoints = Array.from(allTimePoints).sort()
|
|
const trendValues: Array<{
|
|
Time: string
|
|
User: string
|
|
rawQuota: number
|
|
Usage: number
|
|
}> = []
|
|
|
|
sortedTimePoints.forEach((time) => {
|
|
topUsers.forEach((user) => {
|
|
const q = timeUserMap.get(time)?.get(user) || 0
|
|
trendValues.push({
|
|
Time: time,
|
|
User: user,
|
|
rawQuota: q,
|
|
Usage: Number((q / quotaPerUnit).toFixed(4)),
|
|
})
|
|
})
|
|
})
|
|
|
|
return {
|
|
spec_user_rank: {
|
|
type: 'bar',
|
|
data: [{ id: 'userRankData', values: rankValues }],
|
|
xField: 'rawQuota',
|
|
yField: 'User',
|
|
seriesField: 'User',
|
|
direction: 'horizontal',
|
|
title: {
|
|
visible: true,
|
|
text: tt('User Consumption Ranking'),
|
|
subtext: `${tt('Total:')} ${formatVal(totalQuota)}`,
|
|
},
|
|
legends: { visible: false },
|
|
bar: {
|
|
state: { hover: { stroke: '#000', lineWidth: 1 } },
|
|
},
|
|
label: {
|
|
visible: true,
|
|
position: 'outside',
|
|
formatMethod: (value: number) => formatVal(value),
|
|
style: { fontSize: 11 },
|
|
},
|
|
axes: [
|
|
{ orient: 'left', type: 'band' },
|
|
{ orient: 'bottom', type: 'linear', visible: false },
|
|
],
|
|
tooltip: {
|
|
mark: {
|
|
content: [
|
|
{
|
|
key: (datum: Record<string, unknown>) => datum?.User,
|
|
value: (datum: Record<string, unknown>) =>
|
|
formatVal(Number(datum?.rawQuota) || 0),
|
|
},
|
|
],
|
|
},
|
|
},
|
|
color: { specified: userColorMap },
|
|
background: { fill: 'transparent' },
|
|
animation: true,
|
|
},
|
|
spec_user_trend: {
|
|
type: 'area',
|
|
data: [{ id: 'userTrendData', values: trendValues }],
|
|
xField: 'Time',
|
|
yField: 'rawQuota',
|
|
seriesField: 'User',
|
|
stack: false,
|
|
title: {
|
|
visible: true,
|
|
text: tt('User Consumption Trend'),
|
|
subtext: `${tt('Total:')} ${formatVal(totalQuota)}`,
|
|
},
|
|
legends: { visible: true, selectMode: 'single' },
|
|
axes: [
|
|
{ orient: 'bottom', type: 'band' },
|
|
{
|
|
orient: 'left',
|
|
type: 'linear',
|
|
label: {
|
|
formatMethod: (value: number) => formatVal(value),
|
|
},
|
|
},
|
|
],
|
|
tooltip: {
|
|
mark: {
|
|
content: [
|
|
{
|
|
key: (datum: Record<string, unknown>) => datum?.User,
|
|
value: (datum: Record<string, unknown>) =>
|
|
formatVal(Number(datum?.rawQuota) || 0),
|
|
},
|
|
],
|
|
},
|
|
dimension: {
|
|
content: [
|
|
{
|
|
key: (datum: Record<string, unknown>) => datum?.User,
|
|
value: (datum: Record<string, unknown>) =>
|
|
Number(datum?.rawQuota) || 0,
|
|
},
|
|
],
|
|
updateContent: (
|
|
array: Array<{
|
|
key: string
|
|
value: string | number
|
|
}>
|
|
) => {
|
|
array.sort(
|
|
(a, b) => (Number(b.value) || 0) - (Number(a.value) || 0)
|
|
)
|
|
let sum = 0
|
|
for (let i = 0; i < array.length; i++) {
|
|
const v = Number(array[i].value) || 0
|
|
sum += v
|
|
array[i].value = formatVal(v)
|
|
}
|
|
array.unshift({
|
|
key: tt('Total:'),
|
|
value: formatVal(sum),
|
|
})
|
|
return array
|
|
},
|
|
},
|
|
},
|
|
area: { style: { fillOpacity: 0.15 } },
|
|
line: { style: { lineWidth: 2 } },
|
|
point: { visible: false },
|
|
color: { specified: userColorMap },
|
|
background: { fill: 'transparent' },
|
|
animation: true,
|
|
},
|
|
}
|
|
}
|