Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
1f8e1142
Unverified
Commit
1f8e1142
authored
Mar 12, 2026
by
Wesley Liddick
Committed by
GitHub
Mar 12, 2026
Browse files
Merge pull request #932 from 0xObjc/codex/usage-view-charts
feat(admin): add metric toggle to usage charts
parents
1e51de88
0ddaef3c
Changes
8
Show whitespace changes
Inline
Side-by-side
frontend/src/components/charts/GroupDistributionChart.vue
View file @
1f8e1142
<
template
>
<div
class=
"card p-4"
>
<h3
class=
"mb-4 text-sm font-semibold text-gray-900 dark:text-white"
>
<div
class=
"mb-4 flex items-center justify-between gap-3"
>
<h3
class=
"text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
admin.dashboard.groupDistribution
'
)
}}
</h3>
<div
v-if=
"showMetricToggle"
class=
"inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"
>
<button
type=
"button"
class=
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class=
"metric === 'tokens'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@
click=
"emit('update:metric', 'tokens')"
>
{{
t
(
'
admin.dashboard.metricTokens
'
)
}}
</button>
<button
type=
"button"
class=
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class=
"metric === 'actual_cost'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@
click=
"emit('update:metric', 'actual_cost')"
>
{{
t
(
'
admin.dashboard.metricActualCost
'
)
}}
</button>
</div>
</div>
<div
v-if=
"loading"
class=
"flex h-48 items-center justify-center"
>
<LoadingSpinner
/>
</div>
<div
v-else-if=
"
g
roupStats.length > 0 && chartData"
class=
"flex items-center gap-6"
>
<div
v-else-if=
"
displayG
roupStats.length > 0 && chartData"
class=
"flex items-center gap-6"
>
<div
class=
"h-48 w-48"
>
<Doughnut
:data=
"chartData"
:options=
"doughnutOptions"
/>
</div>
...
...
@@ -23,7 +50,7 @@
</thead>
<tbody>
<tr
v-for=
"group in
g
roupStats"
v-for=
"group in
displayG
roupStats"
:key=
"group.group_id"
class=
"border-t border-gray-100 dark:border-gray-700"
>
...
...
@@ -71,9 +98,21 @@ ChartJS.register(ArcElement, Tooltip, Legend)
const
{
t
}
=
useI18n
()
const
props
=
defineProps
<
{
type
DistributionMetric
=
'
tokens
'
|
'
actual_cost
'
const
props
=
withDefaults
(
defineProps
<
{
groupStats
:
GroupStat
[]
loading
?:
boolean
metric
?:
DistributionMetric
showMetricToggle
?:
boolean
}
>
(),
{
loading
:
false
,
metric
:
'
tokens
'
,
showMetricToggle
:
false
,
})
const
emit
=
defineEmits
<
{
'
update:metric
'
:
[
value
:
DistributionMetric
]
}
>
()
const
chartColors
=
[
...
...
@@ -89,15 +128,22 @@ const chartColors = [
'
#84cc16
'
]
const
displayGroupStats
=
computed
(()
=>
{
if
(
!
props
.
groupStats
?.
length
)
return
[]
const
metricKey
=
props
.
metric
===
'
actual_cost
'
?
'
actual_cost
'
:
'
total_tokens
'
return
[...
props
.
groupStats
].
sort
((
a
,
b
)
=>
b
[
metricKey
]
-
a
[
metricKey
])
})
const
chartData
=
computed
(()
=>
{
if
(
!
props
.
groupStats
?.
length
)
return
null
return
{
labels
:
props
.
g
roupStats
.
map
((
g
)
=>
g
.
group_name
||
String
(
g
.
group_id
)),
labels
:
displayG
roupStats
.
value
.
map
((
g
)
=>
g
.
group_name
||
String
(
g
.
group_id
)),
datasets
:
[
{
data
:
props
.
g
roupStats
.
map
((
g
)
=>
g
.
total_tokens
),
backgroundColor
:
chartColors
.
slice
(
0
,
props
.
g
roupStats
.
length
),
data
:
displayG
roupStats
.
value
.
map
((
g
)
=>
props
.
metric
===
'
actual_cost
'
?
g
.
actual_cost
:
g
.
total_tokens
),
backgroundColor
:
chartColors
.
slice
(
0
,
displayG
roupStats
.
value
.
length
),
borderWidth
:
0
}
]
...
...
@@ -116,8 +162,11 @@ const doughnutOptions = computed(() => ({
label
:
(
context
:
any
)
=>
{
const
value
=
context
.
raw
as
number
const
total
=
context
.
dataset
.
data
.
reduce
((
a
:
number
,
b
:
number
)
=>
a
+
b
,
0
)
const
percentage
=
((
value
/
total
)
*
100
).
toFixed
(
1
)
return
`
${
context
.
label
}
:
${
formatTokens
(
value
)}
(
${
percentage
}
%)`
const
percentage
=
total
>
0
?
((
value
/
total
)
*
100
).
toFixed
(
1
)
:
'
0.0
'
const
formattedValue
=
props
.
metric
===
'
actual_cost
'
?
`$
${
formatCost
(
value
)}
`
:
formatTokens
(
value
)
return
`
${
context
.
label
}
:
${
formattedValue
}
(
${
percentage
}
%)`
}
}
}
...
...
frontend/src/components/charts/ModelDistributionChart.vue
View file @
1f8e1142
<
template
>
<div
class=
"card p-4"
>
<h3
class=
"mb-4 text-sm font-semibold text-gray-900 dark:text-white"
>
<div
class=
"mb-4 flex items-center justify-between gap-3"
>
<h3
class=
"text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
admin.dashboard.modelDistribution
'
)
}}
</h3>
<div
v-if=
"showMetricToggle"
class=
"inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"
>
<button
type=
"button"
class=
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class=
"metric === 'tokens'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@
click=
"emit('update:metric', 'tokens')"
>
{{
t
(
'
admin.dashboard.metricTokens
'
)
}}
</button>
<button
type=
"button"
class=
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class=
"metric === 'actual_cost'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@
click=
"emit('update:metric', 'actual_cost')"
>
{{
t
(
'
admin.dashboard.metricActualCost
'
)
}}
</button>
</div>
</div>
<div
v-if=
"loading"
class=
"flex h-48 items-center justify-center"
>
<LoadingSpinner
/>
</div>
<div
v-else-if=
"
m
odelStats.length > 0 && chartData"
class=
"flex items-center gap-6"
>
<div
v-else-if=
"
displayM
odelStats.length > 0 && chartData"
class=
"flex items-center gap-6"
>
<div
class=
"h-48 w-48"
>
<Doughnut
:data=
"chartData"
:options=
"doughnutOptions"
/>
</div>
...
...
@@ -23,7 +50,7 @@
</thead>
<tbody>
<tr
v-for=
"model in
m
odelStats"
v-for=
"model in
displayM
odelStats"
:key=
"model.model"
class=
"border-t border-gray-100 dark:border-gray-700"
>
...
...
@@ -71,9 +98,21 @@ ChartJS.register(ArcElement, Tooltip, Legend)
const
{
t
}
=
useI18n
()
const
props
=
defineProps
<
{
type
DistributionMetric
=
'
tokens
'
|
'
actual_cost
'
const
props
=
withDefaults
(
defineProps
<
{
modelStats
:
ModelStat
[]
loading
?:
boolean
metric
?:
DistributionMetric
showMetricToggle
?:
boolean
}
>
(),
{
loading
:
false
,
metric
:
'
tokens
'
,
showMetricToggle
:
false
,
})
const
emit
=
defineEmits
<
{
'
update:metric
'
:
[
value
:
DistributionMetric
]
}
>
()
const
chartColors
=
[
...
...
@@ -89,15 +128,22 @@ const chartColors = [
'
#84cc16
'
]
const
displayModelStats
=
computed
(()
=>
{
if
(
!
props
.
modelStats
?.
length
)
return
[]
const
metricKey
=
props
.
metric
===
'
actual_cost
'
?
'
actual_cost
'
:
'
total_tokens
'
return
[...
props
.
modelStats
].
sort
((
a
,
b
)
=>
b
[
metricKey
]
-
a
[
metricKey
])
})
const
chartData
=
computed
(()
=>
{
if
(
!
props
.
modelStats
?.
length
)
return
null
return
{
labels
:
props
.
m
odelStats
.
map
((
m
)
=>
m
.
model
),
labels
:
displayM
odelStats
.
value
.
map
((
m
)
=>
m
.
model
),
datasets
:
[
{
data
:
props
.
m
odelStats
.
map
((
m
)
=>
m
.
total_tokens
),
backgroundColor
:
chartColors
.
slice
(
0
,
props
.
m
odelStats
.
length
),
data
:
displayM
odelStats
.
value
.
map
((
m
)
=>
props
.
metric
===
'
actual_cost
'
?
m
.
actual_cost
:
m
.
total_tokens
),
backgroundColor
:
chartColors
.
slice
(
0
,
displayM
odelStats
.
value
.
length
),
borderWidth
:
0
}
]
...
...
@@ -116,8 +162,11 @@ const doughnutOptions = computed(() => ({
label
:
(
context
:
any
)
=>
{
const
value
=
context
.
raw
as
number
const
total
=
context
.
dataset
.
data
.
reduce
((
a
:
number
,
b
:
number
)
=>
a
+
b
,
0
)
const
percentage
=
((
value
/
total
)
*
100
).
toFixed
(
1
)
return
`
${
context
.
label
}
:
${
formatTokens
(
value
)}
(
${
percentage
}
%)`
const
percentage
=
total
>
0
?
((
value
/
total
)
*
100
).
toFixed
(
1
)
:
'
0.0
'
const
formattedValue
=
props
.
metric
===
'
actual_cost
'
?
`$
${
formatCost
(
value
)}
`
:
formatTokens
(
value
)
return
`
${
context
.
label
}
:
${
formattedValue
}
(
${
percentage
}
%)`
}
}
}
...
...
frontend/src/components/charts/__tests__/GroupDistributionChart.spec.ts
0 → 100644
View file @
1f8e1142
import
{
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
import
{
mount
}
from
'
@vue/test-utils
'
import
GroupDistributionChart
from
'
../GroupDistributionChart.vue
'
const
messages
:
Record
<
string
,
string
>
=
{
'
admin.dashboard.groupDistribution
'
:
'
Group Distribution
'
,
'
admin.dashboard.group
'
:
'
Group
'
,
'
admin.dashboard.noGroup
'
:
'
No Group
'
,
'
admin.dashboard.requests
'
:
'
Requests
'
,
'
admin.dashboard.tokens
'
:
'
Tokens
'
,
'
admin.dashboard.actual
'
:
'
Actual
'
,
'
admin.dashboard.standard
'
:
'
Standard
'
,
'
admin.dashboard.metricTokens
'
:
'
By Tokens
'
,
'
admin.dashboard.metricActualCost
'
:
'
By Actual Cost
'
,
'
admin.dashboard.noDataAvailable
'
:
'
No data available
'
,
}
vi
.
mock
(
'
vue-i18n
'
,
async
()
=>
{
const
actual
=
await
vi
.
importActual
<
typeof
import
(
'
vue-i18n
'
)
>
(
'
vue-i18n
'
)
return
{
...
actual
,
useI18n
:
()
=>
({
t
:
(
key
:
string
)
=>
messages
[
key
]
??
key
,
}),
}
})
vi
.
mock
(
'
vue-chartjs
'
,
()
=>
({
Doughnut
:
{
props
:
[
'
data
'
],
template
:
'
<div class="chart-data">{{ JSON.stringify(data) }}</div>
'
,
},
}))
describe
(
'
GroupDistributionChart
'
,
()
=>
{
const
groupStats
=
[
{
group_id
:
1
,
group_name
:
'
group-a
'
,
requests
:
9
,
total_tokens
:
1200
,
cost
:
1.8
,
actual_cost
:
0.1
,
},
{
group_id
:
2
,
group_name
:
'
group-b
'
,
requests
:
4
,
total_tokens
:
600
,
cost
:
0.7
,
actual_cost
:
0.9
,
},
]
it
(
'
uses total_tokens and token ordering by default
'
,
()
=>
{
const
wrapper
=
mount
(
GroupDistributionChart
,
{
props
:
{
groupStats
,
},
global
:
{
stubs
:
{
LoadingSpinner
:
true
,
},
},
})
const
chartData
=
JSON
.
parse
(
wrapper
.
find
(
'
.chart-data
'
).
text
())
expect
(
chartData
.
labels
).
toEqual
([
'
group-a
'
,
'
group-b
'
])
expect
(
chartData
.
datasets
[
0
].
data
).
toEqual
([
1200
,
600
])
const
rows
=
wrapper
.
findAll
(
'
tbody tr
'
)
expect
(
rows
[
0
].
text
()).
toContain
(
'
group-a
'
)
expect
(
rows
[
1
].
text
()).
toContain
(
'
group-b
'
)
const
options
=
(
wrapper
.
vm
as
any
).
$
?.
setupState
.
doughnutOptions
const
label
=
options
.
plugins
.
tooltip
.
callbacks
.
label
({
label
:
'
group-a
'
,
raw
:
1200
,
dataset
:
{
data
:
[
1200
,
600
]
},
})
expect
(
label
).
toBe
(
'
group-a: 1.20K (66.7%)
'
)
})
it
(
'
uses actual_cost and reorders rows in actual cost mode
'
,
()
=>
{
const
wrapper
=
mount
(
GroupDistributionChart
,
{
props
:
{
groupStats
,
metric
:
'
actual_cost
'
,
},
global
:
{
stubs
:
{
LoadingSpinner
:
true
,
},
},
})
const
chartData
=
JSON
.
parse
(
wrapper
.
find
(
'
.chart-data
'
).
text
())
expect
(
chartData
.
labels
).
toEqual
([
'
group-b
'
,
'
group-a
'
])
expect
(
chartData
.
datasets
[
0
].
data
).
toEqual
([
0.9
,
0.1
])
const
rows
=
wrapper
.
findAll
(
'
tbody tr
'
)
expect
(
rows
[
0
].
text
()).
toContain
(
'
group-b
'
)
expect
(
rows
[
1
].
text
()).
toContain
(
'
group-a
'
)
const
options
=
(
wrapper
.
vm
as
any
).
$
?.
setupState
.
doughnutOptions
const
label
=
options
.
plugins
.
tooltip
.
callbacks
.
label
({
label
:
'
group-b
'
,
raw
:
0.9
,
dataset
:
{
data
:
[
0.9
,
0.1
]
},
})
expect
(
label
).
toBe
(
'
group-b: $0.900 (90.0%)
'
)
})
})
frontend/src/components/charts/__tests__/ModelDistributionChart.spec.ts
0 → 100644
View file @
1f8e1142
import
{
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
import
{
mount
}
from
'
@vue/test-utils
'
import
ModelDistributionChart
from
'
../ModelDistributionChart.vue
'
const
messages
:
Record
<
string
,
string
>
=
{
'
admin.dashboard.modelDistribution
'
:
'
Model Distribution
'
,
'
admin.dashboard.model
'
:
'
Model
'
,
'
admin.dashboard.requests
'
:
'
Requests
'
,
'
admin.dashboard.tokens
'
:
'
Tokens
'
,
'
admin.dashboard.actual
'
:
'
Actual
'
,
'
admin.dashboard.standard
'
:
'
Standard
'
,
'
admin.dashboard.metricTokens
'
:
'
By Tokens
'
,
'
admin.dashboard.metricActualCost
'
:
'
By Actual Cost
'
,
'
admin.dashboard.noDataAvailable
'
:
'
No data available
'
,
}
vi
.
mock
(
'
vue-i18n
'
,
async
()
=>
{
const
actual
=
await
vi
.
importActual
<
typeof
import
(
'
vue-i18n
'
)
>
(
'
vue-i18n
'
)
return
{
...
actual
,
useI18n
:
()
=>
({
t
:
(
key
:
string
)
=>
messages
[
key
]
??
key
,
}),
}
})
vi
.
mock
(
'
vue-chartjs
'
,
()
=>
({
Doughnut
:
{
props
:
[
'
data
'
],
template
:
'
<div class="chart-data">{{ JSON.stringify(data) }}</div>
'
,
},
}))
describe
(
'
ModelDistributionChart
'
,
()
=>
{
const
modelStats
=
[
{
model
:
'
model-a
'
,
requests
:
8
,
input_tokens
:
100
,
output_tokens
:
50
,
cache_creation_tokens
:
0
,
cache_read_tokens
:
0
,
total_tokens
:
1000
,
cost
:
1.5
,
actual_cost
:
0.2
,
},
{
model
:
'
model-b
'
,
requests
:
3
,
input_tokens
:
40
,
output_tokens
:
20
,
cache_creation_tokens
:
0
,
cache_read_tokens
:
0
,
total_tokens
:
500
,
cost
:
0.5
,
actual_cost
:
1.4
,
},
]
it
(
'
uses total_tokens and token ordering by default
'
,
()
=>
{
const
wrapper
=
mount
(
ModelDistributionChart
,
{
props
:
{
modelStats
,
},
global
:
{
stubs
:
{
LoadingSpinner
:
true
,
},
},
})
const
chartData
=
JSON
.
parse
(
wrapper
.
find
(
'
.chart-data
'
).
text
())
expect
(
chartData
.
labels
).
toEqual
([
'
model-a
'
,
'
model-b
'
])
expect
(
chartData
.
datasets
[
0
].
data
).
toEqual
([
1000
,
500
])
const
rows
=
wrapper
.
findAll
(
'
tbody tr
'
)
expect
(
rows
[
0
].
text
()).
toContain
(
'
model-a
'
)
expect
(
rows
[
1
].
text
()).
toContain
(
'
model-b
'
)
const
options
=
(
wrapper
.
vm
as
any
).
$
?.
setupState
.
doughnutOptions
const
label
=
options
.
plugins
.
tooltip
.
callbacks
.
label
({
label
:
'
model-a
'
,
raw
:
1000
,
dataset
:
{
data
:
[
1000
,
500
]
},
})
expect
(
label
).
toBe
(
'
model-a: 1.00K (66.7%)
'
)
})
it
(
'
uses actual_cost and reorders rows in actual cost mode
'
,
()
=>
{
const
wrapper
=
mount
(
ModelDistributionChart
,
{
props
:
{
modelStats
,
metric
:
'
actual_cost
'
,
},
global
:
{
stubs
:
{
LoadingSpinner
:
true
,
},
},
})
const
chartData
=
JSON
.
parse
(
wrapper
.
find
(
'
.chart-data
'
).
text
())
expect
(
chartData
.
labels
).
toEqual
([
'
model-b
'
,
'
model-a
'
])
expect
(
chartData
.
datasets
[
0
].
data
).
toEqual
([
1.4
,
0.2
])
const
rows
=
wrapper
.
findAll
(
'
tbody tr
'
)
expect
(
rows
[
0
].
text
()).
toContain
(
'
model-b
'
)
expect
(
rows
[
1
].
text
()).
toContain
(
'
model-a
'
)
const
options
=
(
wrapper
.
vm
as
any
).
$
?.
setupState
.
doughnutOptions
const
label
=
options
.
plugins
.
tooltip
.
callbacks
.
label
({
label
:
'
model-b
'
,
raw
:
1.4
,
dataset
:
{
data
:
[
1.4
,
0.2
]
},
})
expect
(
label
).
toBe
(
'
model-b: $1.40 (87.5%)
'
)
})
})
frontend/src/i18n/locales/en.ts
View file @
1f8e1142
...
...
@@ -950,6 +950,8 @@ export default {
hour
:
'
Hour
'
,
modelDistribution
:
'
Model Distribution
'
,
groupDistribution
:
'
Group Usage Distribution
'
,
metricTokens
:
'
By Tokens
'
,
metricActualCost
:
'
By Actual Cost
'
,
tokenUsageTrend
:
'
Token Usage Trend
'
,
userUsageTrend
:
'
User Usage Trend (Top 12)
'
,
model
:
'
Model
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
1f8e1142
...
...
@@ -963,6 +963,8 @@ export default {
hour
:
'
按小时
'
,
modelDistribution
:
'
模型分布
'
,
groupDistribution
:
'
分组使用分布
'
,
metricTokens
:
'
按 Token
'
,
metricActualCost
:
'
按实际消费
'
,
tokenUsageTrend
:
'
Token 使用趋势
'
,
noDataAvailable
:
'
暂无数据
'
,
model
:
'
模型
'
,
...
...
frontend/src/views/admin/UsageView.vue
View file @
1f8e1142
...
...
@@ -13,8 +13,18 @@
</div>
</div>
<div
class=
"grid grid-cols-1 gap-6 lg:grid-cols-2"
>
<ModelDistributionChart
:model-stats=
"modelStats"
:loading=
"chartsLoading"
/>
<GroupDistributionChart
:group-stats=
"groupStats"
:loading=
"chartsLoading"
/>
<ModelDistributionChart
v-model:metric=
"modelDistributionMetric"
:model-stats=
"modelStats"
:loading=
"chartsLoading"
:show-metric-toggle=
"true"
/>
<GroupDistributionChart
v-model:metric=
"groupDistributionMetric"
:group-stats=
"groupStats"
:loading=
"chartsLoading"
:show-metric-toggle=
"true"
/>
</div>
<TokenUsageTrend
:trend-data=
"trendData"
:loading=
"chartsLoading"
/>
</div>
...
...
@@ -93,8 +103,12 @@ import type { AdminUsageLog, TrendDataPoint, ModelStat, GroupStat, AdminUser } f
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
type
DistributionMetric
=
'
tokens
'
|
'
actual_cost
'
const
usageStats
=
ref
<
AdminUsageStatsResponse
|
null
>
(
null
);
const
usageLogs
=
ref
<
AdminUsageLog
[]
>
([]);
const
loading
=
ref
(
false
);
const
exporting
=
ref
(
false
)
const
trendData
=
ref
<
TrendDataPoint
[]
>
([]);
const
modelStats
=
ref
<
ModelStat
[]
>
([]);
const
groupStats
=
ref
<
GroupStat
[]
>
([]);
const
chartsLoading
=
ref
(
false
);
const
granularity
=
ref
<
'
day
'
|
'
hour
'
>
(
'
day
'
)
const
modelDistributionMetric
=
ref
<
DistributionMetric
>
(
'
tokens
'
)
const
groupDistributionMetric
=
ref
<
DistributionMetric
>
(
'
tokens
'
)
let
abortController
:
AbortController
|
null
=
null
;
let
exportAbortController
:
AbortController
|
null
=
null
let
chartReqSeq
=
0
const
exportProgress
=
reactive
({
show
:
false
,
progress
:
0
,
current
:
0
,
total
:
0
,
estimatedTime
:
''
})
...
...
frontend/src/views/admin/__tests__/UsageView.spec.ts
0 → 100644
View file @
1f8e1142
import
{
describe
,
expect
,
it
,
vi
,
beforeEach
,
afterEach
}
from
'
vitest
'
import
{
flushPromises
,
mount
}
from
'
@vue/test-utils
'
import
UsageView
from
'
../UsageView.vue
'
const
{
list
,
getStats
,
getSnapshotV2
,
getById
}
=
vi
.
hoisted
(()
=>
{
vi
.
stubGlobal
(
'
localStorage
'
,
{
getItem
:
vi
.
fn
(()
=>
null
),
setItem
:
vi
.
fn
(),
removeItem
:
vi
.
fn
(),
})
return
{
list
:
vi
.
fn
(),
getStats
:
vi
.
fn
(),
getSnapshotV2
:
vi
.
fn
(),
getById
:
vi
.
fn
(),
}
})
const
messages
:
Record
<
string
,
string
>
=
{
'
admin.dashboard.day
'
:
'
Day
'
,
'
admin.dashboard.hour
'
:
'
Hour
'
,
'
admin.usage.failedToLoadUser
'
:
'
Failed to load user
'
,
}
vi
.
mock
(
'
@/api/admin
'
,
()
=>
({
adminAPI
:
{
usage
:
{
list
,
getStats
,
},
dashboard
:
{
getSnapshotV2
,
},
users
:
{
getById
,
},
},
}))
vi
.
mock
(
'
@/api/admin/usage
'
,
()
=>
({
adminUsageAPI
:
{
list
:
vi
.
fn
(),
},
}))
vi
.
mock
(
'
@/stores/app
'
,
()
=>
({
useAppStore
:
()
=>
({
showError
:
vi
.
fn
(),
showWarning
:
vi
.
fn
(),
showSuccess
:
vi
.
fn
(),
showInfo
:
vi
.
fn
(),
}),
}))
vi
.
mock
(
'
@/utils/format
'
,
()
=>
({
formatReasoningEffort
:
(
value
:
string
|
null
|
undefined
)
=>
value
??
'
-
'
,
}))
vi
.
mock
(
'
vue-i18n
'
,
async
()
=>
{
const
actual
=
await
vi
.
importActual
<
typeof
import
(
'
vue-i18n
'
)
>
(
'
vue-i18n
'
)
return
{
...
actual
,
useI18n
:
()
=>
({
t
:
(
key
:
string
)
=>
messages
[
key
]
??
key
,
}),
}
})
const
AppLayoutStub
=
{
template
:
'
<div><slot /></div>
'
}
const
UsageFiltersStub
=
{
template
:
'
<div><slot name="after-reset" /></div>
'
}
const
ModelDistributionChartStub
=
{
props
:
[
'
metric
'
],
emits
:
[
'
update:metric
'
],
template
:
`
<div data-test="model-chart">
<span class="metric">{{ metric }}</span>
<button class="switch-metric" @click="$emit('update:metric', 'actual_cost')">switch</button>
</div>
`
,
}
const
GroupDistributionChartStub
=
{
props
:
[
'
metric
'
],
emits
:
[
'
update:metric
'
],
template
:
`
<div data-test="group-chart">
<span class="metric">{{ metric }}</span>
<button class="switch-metric" @click="$emit('update:metric', 'actual_cost')">switch</button>
</div>
`
,
}
describe
(
'
admin UsageView distribution metric toggles
'
,
()
=>
{
beforeEach
(()
=>
{
vi
.
useFakeTimers
()
list
.
mockReset
()
getStats
.
mockReset
()
getSnapshotV2
.
mockReset
()
getById
.
mockReset
()
list
.
mockResolvedValue
({
items
:
[],
total
:
0
,
pages
:
0
,
})
getStats
.
mockResolvedValue
({
total_requests
:
0
,
total_input_tokens
:
0
,
total_output_tokens
:
0
,
total_cache_tokens
:
0
,
total_tokens
:
0
,
total_cost
:
0
,
total_actual_cost
:
0
,
average_duration_ms
:
0
,
})
getSnapshotV2
.
mockResolvedValue
({
trend
:
[],
models
:
[],
groups
:
[],
})
})
afterEach
(()
=>
{
vi
.
useRealTimers
()
})
it
(
'
keeps model and group metric toggles independent without refetching chart data
'
,
async
()
=>
{
const
wrapper
=
mount
(
UsageView
,
{
global
:
{
stubs
:
{
AppLayout
:
AppLayoutStub
,
UsageStatsCards
:
true
,
UsageFilters
:
UsageFiltersStub
,
UsageTable
:
true
,
UsageExportProgress
:
true
,
UsageCleanupDialog
:
true
,
UserBalanceHistoryModal
:
true
,
Pagination
:
true
,
Select
:
true
,
Icon
:
true
,
TokenUsageTrend
:
true
,
ModelDistributionChart
:
ModelDistributionChartStub
,
GroupDistributionChart
:
GroupDistributionChartStub
,
},
},
})
vi
.
advanceTimersByTime
(
120
)
await
flushPromises
()
expect
(
getSnapshotV2
).
toHaveBeenCalledTimes
(
1
)
const
modelChart
=
wrapper
.
find
(
'
[data-test="model-chart"]
'
)
const
groupChart
=
wrapper
.
find
(
'
[data-test="group-chart"]
'
)
expect
(
modelChart
.
find
(
'
.metric
'
).
text
()).
toBe
(
'
tokens
'
)
expect
(
groupChart
.
find
(
'
.metric
'
).
text
()).
toBe
(
'
tokens
'
)
await
modelChart
.
find
(
'
.switch-metric
'
).
trigger
(
'
click
'
)
await
flushPromises
()
expect
(
modelChart
.
find
(
'
.metric
'
).
text
()).
toBe
(
'
actual_cost
'
)
expect
(
groupChart
.
find
(
'
.metric
'
).
text
()).
toBe
(
'
tokens
'
)
expect
(
getSnapshotV2
).
toHaveBeenCalledTimes
(
1
)
await
groupChart
.
find
(
'
.switch-metric
'
).
trigger
(
'
click
'
)
await
flushPromises
()
expect
(
modelChart
.
find
(
'
.metric
'
).
text
()).
toBe
(
'
actual_cost
'
)
expect
(
groupChart
.
find
(
'
.metric
'
).
text
()).
toBe
(
'
actual_cost
'
)
expect
(
getSnapshotV2
).
toHaveBeenCalledTimes
(
1
)
})
})
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment