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
0ddaef3c
Commit
0ddaef3c
authored
Mar 11, 2026
by
Peter
Browse files
feat(admin): add metric toggle to usage charts
parent
7455476c
Changes
8
Hide whitespace changes
Inline
Side-by-side
frontend/src/components/charts/GroupDistributionChart.vue
View file @
0ddaef3c
<
template
>
<
template
>
<div
class=
"card p-4"
>
<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"
>
{{
t
(
'
admin.dashboard.groupDistribution
'
)
}}
<h3
class=
"text-sm font-semibold text-gray-900 dark:text-white"
>
</h3>
{{
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"
>
<div
v-if=
"loading"
class=
"flex h-48 items-center justify-center"
>
<LoadingSpinner
/>
<LoadingSpinner
/>
</div>
</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"
>
<div
class=
"h-48 w-48"
>
<Doughnut
:data=
"chartData"
:options=
"doughnutOptions"
/>
<Doughnut
:data=
"chartData"
:options=
"doughnutOptions"
/>
</div>
</div>
...
@@ -23,7 +50,7 @@
...
@@ -23,7 +50,7 @@
</thead>
</thead>
<tbody>
<tbody>
<tr
<tr
v-for=
"group in
g
roupStats"
v-for=
"group in
displayG
roupStats"
:key=
"group.group_id"
:key=
"group.group_id"
class=
"border-t border-gray-100 dark:border-gray-700"
class=
"border-t border-gray-100 dark:border-gray-700"
>
>
...
@@ -71,9 +98,21 @@ ChartJS.register(ArcElement, Tooltip, Legend)
...
@@ -71,9 +98,21 @@ ChartJS.register(ArcElement, Tooltip, Legend)
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
const
props
=
defineProps
<
{
type
DistributionMetric
=
'
tokens
'
|
'
actual_cost
'
const
props
=
withDefaults
(
defineProps
<
{
groupStats
:
GroupStat
[]
groupStats
:
GroupStat
[]
loading
?:
boolean
loading
?:
boolean
metric
?:
DistributionMetric
showMetricToggle
?:
boolean
}
>
(),
{
loading
:
false
,
metric
:
'
tokens
'
,
showMetricToggle
:
false
,
})
const
emit
=
defineEmits
<
{
'
update:metric
'
:
[
value
:
DistributionMetric
]
}
>
()
}
>
()
const
chartColors
=
[
const
chartColors
=
[
...
@@ -89,15 +128,22 @@ const chartColors = [
...
@@ -89,15 +128,22 @@ const chartColors = [
'
#84cc16
'
'
#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
(()
=>
{
const
chartData
=
computed
(()
=>
{
if
(
!
props
.
groupStats
?.
length
)
return
null
if
(
!
props
.
groupStats
?.
length
)
return
null
return
{
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
:
[
datasets
:
[
{
{
data
:
props
.
g
roupStats
.
map
((
g
)
=>
g
.
total_tokens
),
data
:
displayG
roupStats
.
value
.
map
((
g
)
=>
props
.
metric
===
'
actual_cost
'
?
g
.
actual_cost
:
g
.
total_tokens
),
backgroundColor
:
chartColors
.
slice
(
0
,
props
.
g
roupStats
.
length
),
backgroundColor
:
chartColors
.
slice
(
0
,
displayG
roupStats
.
value
.
length
),
borderWidth
:
0
borderWidth
:
0
}
}
]
]
...
@@ -116,8 +162,11 @@ const doughnutOptions = computed(() => ({
...
@@ -116,8 +162,11 @@ const doughnutOptions = computed(() => ({
label
:
(
context
:
any
)
=>
{
label
:
(
context
:
any
)
=>
{
const
value
=
context
.
raw
as
number
const
value
=
context
.
raw
as
number
const
total
=
context
.
dataset
.
data
.
reduce
((
a
:
number
,
b
:
number
)
=>
a
+
b
,
0
)
const
total
=
context
.
dataset
.
data
.
reduce
((
a
:
number
,
b
:
number
)
=>
a
+
b
,
0
)
const
percentage
=
((
value
/
total
)
*
100
).
toFixed
(
1
)
const
percentage
=
total
>
0
?
((
value
/
total
)
*
100
).
toFixed
(
1
)
:
'
0.0
'
return
`
${
context
.
label
}
:
${
formatTokens
(
value
)}
(
${
percentage
}
%)`
const
formattedValue
=
props
.
metric
===
'
actual_cost
'
?
`$
${
formatCost
(
value
)}
`
:
formatTokens
(
value
)
return
`
${
context
.
label
}
:
${
formattedValue
}
(
${
percentage
}
%)`
}
}
}
}
}
}
...
...
frontend/src/components/charts/ModelDistributionChart.vue
View file @
0ddaef3c
<
template
>
<
template
>
<div
class=
"card p-4"
>
<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"
>
{{
t
(
'
admin.dashboard.modelDistribution
'
)
}}
<h3
class=
"text-sm font-semibold text-gray-900 dark:text-white"
>
</h3>
{{
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"
>
<div
v-if=
"loading"
class=
"flex h-48 items-center justify-center"
>
<LoadingSpinner
/>
<LoadingSpinner
/>
</div>
</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"
>
<div
class=
"h-48 w-48"
>
<Doughnut
:data=
"chartData"
:options=
"doughnutOptions"
/>
<Doughnut
:data=
"chartData"
:options=
"doughnutOptions"
/>
</div>
</div>
...
@@ -23,7 +50,7 @@
...
@@ -23,7 +50,7 @@
</thead>
</thead>
<tbody>
<tbody>
<tr
<tr
v-for=
"model in
m
odelStats"
v-for=
"model in
displayM
odelStats"
:key=
"model.model"
:key=
"model.model"
class=
"border-t border-gray-100 dark:border-gray-700"
class=
"border-t border-gray-100 dark:border-gray-700"
>
>
...
@@ -71,9 +98,21 @@ ChartJS.register(ArcElement, Tooltip, Legend)
...
@@ -71,9 +98,21 @@ ChartJS.register(ArcElement, Tooltip, Legend)
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
const
props
=
defineProps
<
{
type
DistributionMetric
=
'
tokens
'
|
'
actual_cost
'
const
props
=
withDefaults
(
defineProps
<
{
modelStats
:
ModelStat
[]
modelStats
:
ModelStat
[]
loading
?:
boolean
loading
?:
boolean
metric
?:
DistributionMetric
showMetricToggle
?:
boolean
}
>
(),
{
loading
:
false
,
metric
:
'
tokens
'
,
showMetricToggle
:
false
,
})
const
emit
=
defineEmits
<
{
'
update:metric
'
:
[
value
:
DistributionMetric
]
}
>
()
}
>
()
const
chartColors
=
[
const
chartColors
=
[
...
@@ -89,15 +128,22 @@ const chartColors = [
...
@@ -89,15 +128,22 @@ const chartColors = [
'
#84cc16
'
'
#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
(()
=>
{
const
chartData
=
computed
(()
=>
{
if
(
!
props
.
modelStats
?.
length
)
return
null
if
(
!
props
.
modelStats
?.
length
)
return
null
return
{
return
{
labels
:
props
.
m
odelStats
.
map
((
m
)
=>
m
.
model
),
labels
:
displayM
odelStats
.
value
.
map
((
m
)
=>
m
.
model
),
datasets
:
[
datasets
:
[
{
{
data
:
props
.
m
odelStats
.
map
((
m
)
=>
m
.
total_tokens
),
data
:
displayM
odelStats
.
value
.
map
((
m
)
=>
props
.
metric
===
'
actual_cost
'
?
m
.
actual_cost
:
m
.
total_tokens
),
backgroundColor
:
chartColors
.
slice
(
0
,
props
.
m
odelStats
.
length
),
backgroundColor
:
chartColors
.
slice
(
0
,
displayM
odelStats
.
value
.
length
),
borderWidth
:
0
borderWidth
:
0
}
}
]
]
...
@@ -116,8 +162,11 @@ const doughnutOptions = computed(() => ({
...
@@ -116,8 +162,11 @@ const doughnutOptions = computed(() => ({
label
:
(
context
:
any
)
=>
{
label
:
(
context
:
any
)
=>
{
const
value
=
context
.
raw
as
number
const
value
=
context
.
raw
as
number
const
total
=
context
.
dataset
.
data
.
reduce
((
a
:
number
,
b
:
number
)
=>
a
+
b
,
0
)
const
total
=
context
.
dataset
.
data
.
reduce
((
a
:
number
,
b
:
number
)
=>
a
+
b
,
0
)
const
percentage
=
((
value
/
total
)
*
100
).
toFixed
(
1
)
const
percentage
=
total
>
0
?
((
value
/
total
)
*
100
).
toFixed
(
1
)
:
'
0.0
'
return
`
${
context
.
label
}
:
${
formatTokens
(
value
)}
(
${
percentage
}
%)`
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 @
0ddaef3c
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 @
0ddaef3c
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 @
0ddaef3c
...
@@ -950,6 +950,8 @@ export default {
...
@@ -950,6 +950,8 @@ export default {
hour
:
'
Hour
'
,
hour
:
'
Hour
'
,
modelDistribution
:
'
Model Distribution
'
,
modelDistribution
:
'
Model Distribution
'
,
groupDistribution
:
'
Group Usage Distribution
'
,
groupDistribution
:
'
Group Usage Distribution
'
,
metricTokens
:
'
By Tokens
'
,
metricActualCost
:
'
By Actual Cost
'
,
tokenUsageTrend
:
'
Token Usage Trend
'
,
tokenUsageTrend
:
'
Token Usage Trend
'
,
userUsageTrend
:
'
User Usage Trend (Top 12)
'
,
userUsageTrend
:
'
User Usage Trend (Top 12)
'
,
model
:
'
Model
'
,
model
:
'
Model
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
0ddaef3c
...
@@ -963,6 +963,8 @@ export default {
...
@@ -963,6 +963,8 @@ export default {
hour
:
'
按小时
'
,
hour
:
'
按小时
'
,
modelDistribution
:
'
模型分布
'
,
modelDistribution
:
'
模型分布
'
,
groupDistribution
:
'
分组使用分布
'
,
groupDistribution
:
'
分组使用分布
'
,
metricTokens
:
'
按 Token
'
,
metricActualCost
:
'
按实际消费
'
,
tokenUsageTrend
:
'
Token 使用趋势
'
,
tokenUsageTrend
:
'
Token 使用趋势
'
,
noDataAvailable
:
'
暂无数据
'
,
noDataAvailable
:
'
暂无数据
'
,
model
:
'
模型
'
,
model
:
'
模型
'
,
...
...
frontend/src/views/admin/UsageView.vue
View file @
0ddaef3c
...
@@ -13,8 +13,18 @@
...
@@ -13,8 +13,18 @@
</div>
</div>
</div>
</div>
<div
class=
"grid grid-cols-1 gap-6 lg:grid-cols-2"
>
<div
class=
"grid grid-cols-1 gap-6 lg:grid-cols-2"
>
<ModelDistributionChart
:model-stats=
"modelStats"
:loading=
"chartsLoading"
/>
<ModelDistributionChart
<GroupDistributionChart
:group-stats=
"groupStats"
:loading=
"chartsLoading"
/>
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>
</div>
<TokenUsageTrend
:trend-data=
"trendData"
:loading=
"chartsLoading"
/>
<TokenUsageTrend
:trend-data=
"trendData"
:loading=
"chartsLoading"
/>
</div>
</div>
...
@@ -93,8 +103,12 @@ import type { AdminUsageLog, TrendDataPoint, ModelStat, GroupStat, AdminUser } f
...
@@ -93,8 +103,12 @@ import type { AdminUsageLog, TrendDataPoint, ModelStat, GroupStat, AdminUser } f
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
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
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
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
abortController
:
AbortController
|
null
=
null
;
let
exportAbortController
:
AbortController
|
null
=
null
let
chartReqSeq
=
0
let
chartReqSeq
=
0
const
exportProgress
=
reactive
({
show
:
false
,
progress
:
0
,
current
:
0
,
total
:
0
,
estimatedTime
:
''
})
const
exportProgress
=
reactive
({
show
:
false
,
progress
:
0
,
current
:
0
,
total
:
0
,
estimatedTime
:
''
})
...
...
frontend/src/views/admin/__tests__/UsageView.spec.ts
0 → 100644
View file @
0ddaef3c
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