mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-15 05:29:28 +00:00
feat(admin): add per-model usage statistics to the charts section
The Charts view could break usage down by user, group or total, but not by model. Per-model data existed only as day-window attempt and failure counts, which measure availability rather than delivered usage. A served model now accumulates the same four metrics a user or group does. The scope kind is "model" and its id is the canonical "provider/model", so a request that fails over counts against the provider that answered it, and one bare id served by two providers ranks as two rows. The request counter is written after the response, in the batch that already writes tokens and cost. That is the first point at which the serving model is known, so no extra round trip is needed and admission never double counts. GET /admin/api/usage/models ranks models for one metric and window. It returns only models with non-zero usage, by operator requirement: the catalog runs to hundreds of models and a padded ranking is unreadable. The read stays one storeGetMulti over the catalog. The Charts view gains a Models tab with its own metric picker, and the scope picker gains the models that have traffic.
This commit is contained in:
@@ -11,11 +11,19 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import ModelUsageChart from '@/components/ModelUsageChart.vue'
|
||||
import UsageChart from '@/components/UsageChart.vue'
|
||||
import { useSearchQuery } from '@/composables/useSearchQuery'
|
||||
import { groupMatches, userMatches } from '@/lib/usage-search'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { type ChartTab, useHistoryStore, WINDOW_LABEL } from '@/stores/history'
|
||||
import {
|
||||
type ChartTab,
|
||||
MODEL_METRIC_LABEL,
|
||||
type ModelMetric,
|
||||
type TimeSeriesTab,
|
||||
useHistoryStore,
|
||||
WINDOW_LABEL,
|
||||
} from '@/stores/history'
|
||||
import type { HistoryWindow } from '@/types/api'
|
||||
|
||||
const dashboard = useDashboardStore()
|
||||
@@ -44,10 +52,41 @@ const scopeOptions = computed(() => {
|
||||
!hasScopeQuery.value || groupMatches(g, allUsers, normalizedScopeQuery.value) || `group:${g.id}` === history.scope,
|
||||
)
|
||||
.map((g) => ({ value: `group:${g.id}`, label: g.id }))
|
||||
return [{ value: 'total', label: 'Total (all traffic)' }, ...groups, ...users]
|
||||
// Models come from the ranking endpoint, which returns only models with
|
||||
// non-zero traffic (operator requirement: the catalog is hundreds of
|
||||
// models, so the picker must never list idle ones). Matched on the plain
|
||||
// id rather than through lib/usage-search's helpers — those match a
|
||||
// user's or group's own shape, and a model entry has neither.
|
||||
const models = (history.modelOptions ?? [])
|
||||
.filter(
|
||||
(m) =>
|
||||
!hasScopeQuery.value ||
|
||||
m.id.toLowerCase().includes(normalizedScopeQuery.value) ||
|
||||
`model:${m.id}` === history.scope,
|
||||
)
|
||||
.map((m) => ({ value: `model:${m.id}`, label: m.id }))
|
||||
// A model already being charted stays in the list even once it drops out
|
||||
// of modelOptions entirely — which happens the moment it goes idle for
|
||||
// the selected window, since that list is non-zero-only. Without this,
|
||||
// the watch below would read it as a vanished scope and force-switch the
|
||||
// reader to "total" mid-look, for the ordinary reason that a model
|
||||
// simply stopped receiving traffic.
|
||||
if (history.scope.startsWith('model:') && !models.some((m) => m.value === history.scope)) {
|
||||
models.push({ value: history.scope, label: history.scope.slice('model:'.length) })
|
||||
}
|
||||
return [{ value: 'total', label: 'Total (all traffic)' }, ...groups, ...users, ...models]
|
||||
})
|
||||
|
||||
const windows: HistoryWindow[] = ['hour', 'day', 'month']
|
||||
const modelMetrics: ModelMetric[] = ['cost', 'req', 'tokin', 'tokout']
|
||||
|
||||
/**
|
||||
* UsageChart only ever renders on a time-series tab (the template's v-else
|
||||
* below), but a `v-else` narrows nothing for the type checker — this does.
|
||||
* The 'requests' stand-in is never displayed: it is the value handed over
|
||||
* on the one tab where the component is not rendered at all.
|
||||
*/
|
||||
const timeSeriesTab = computed<TimeSeriesTab>(() => (history.tab === 'models' ? 'requests' : history.tab))
|
||||
|
||||
function onTabChange(value: string | number): void {
|
||||
history.setTab(value as ChartTab)
|
||||
@@ -55,9 +94,12 @@ function onTabChange(value: string | number): void {
|
||||
function onScopeChange(value: unknown): void {
|
||||
if (typeof value === 'string') history.setScope(value)
|
||||
}
|
||||
function onModelMetricChange(value: unknown): void {
|
||||
if (typeof value === 'string') history.setModelMetric(value as ModelMetric)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void history.fetchSeries()
|
||||
void history.refresh()
|
||||
history.startAutoRefresh()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
@@ -79,9 +121,24 @@ watch(scopeOptions, (options) => {
|
||||
<CardHeader class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>Usage charts</CardTitle>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<SearchInput v-model="scopeQuery" placeholder="Filter users or groups" class="w-56" />
|
||||
<SearchInput v-model="scopeQuery" placeholder="Filter users, groups or models" class="w-56" />
|
||||
|
||||
<Select :model-value="history.scope" @update:model-value="onScopeChange">
|
||||
<Select
|
||||
v-if="history.tab === 'models'"
|
||||
:model-value="history.modelMetric"
|
||||
@update:model-value="onModelMetricChange"
|
||||
>
|
||||
<SelectTrigger class="w-40" aria-label="Rank models by">
|
||||
<SelectValue placeholder="Rank by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="m in modelMetrics" :key="m" :value="m">
|
||||
{{ MODEL_METRIC_LABEL[m] }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select v-else :model-value="history.scope" @update:model-value="onScopeChange">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Scope" />
|
||||
</SelectTrigger>
|
||||
@@ -105,11 +162,20 @@ watch(scopeOptions, (options) => {
|
||||
<TabsTrigger value="requests">Requests</TabsTrigger>
|
||||
<TabsTrigger value="tokens">Tokens</TabsTrigger>
|
||||
<TabsTrigger value="cost">Cost</TabsTrigger>
|
||||
<TabsTrigger value="models">Models</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<p v-if="history.error" class="text-sm text-destructive">{{ history.error }}</p>
|
||||
<UsageChart :tab="history.tab" :window="history.window" :series-by-metric="history.seriesByMetric" />
|
||||
<template v-if="history.tab === 'models'">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Models with usage in the selected window, ranked by
|
||||
{{ MODEL_METRIC_LABEL[history.modelMetric].toLowerCase() }}. Each row is the provider that served the
|
||||
traffic, so a model reached through more than one provider appears once per provider.
|
||||
</p>
|
||||
<ModelUsageChart :metric="history.modelMetric" :models="history.modelRanking" />
|
||||
</template>
|
||||
<UsageChart v-else :tab="timeSeriesTab" :window="history.window" :series-by-metric="history.seriesByMetric" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions } from 'chart.js'
|
||||
import { computed } from 'vue'
|
||||
import { Bar } from 'vue-chartjs'
|
||||
|
||||
import '@/lib/chart-setup'
|
||||
import { useThemeColors } from '@/composables/useThemeColors'
|
||||
import { formatCompactCount, formatExactInt } from '@/lib/format'
|
||||
import { MODEL_METRIC_LABEL, type ModelMetric } from '@/stores/history'
|
||||
import type { AdminUsageModelEntry } from '@/types/api'
|
||||
|
||||
const props = defineProps<{
|
||||
metric: ModelMetric
|
||||
models: AdminUsageModelEntry[]
|
||||
}>()
|
||||
|
||||
const { foreground, mutedForeground, border } = useThemeColors()
|
||||
|
||||
/**
|
||||
* One token per rankable metric, reusing the SAME --chart-* custom
|
||||
* properties the time-series chart uses (UsageChart.vue's DATASET_SPECS),
|
||||
* so a model's bar is the colour that metric already has elsewhere in the
|
||||
* view rather than a second, conflicting palette.
|
||||
*/
|
||||
const METRIC_COLOR: Record<ModelMetric, string> = {
|
||||
req: '--chart-requests',
|
||||
tokin: '--chart-tokens-in',
|
||||
tokout: '--chart-tokens-out',
|
||||
cost: '--chart-cost',
|
||||
}
|
||||
|
||||
/** Cost is stored as micro-USD (limits.go's account/history convention); every other metric is already the display unit. */
|
||||
function toDisplayValue(metric: ModelMetric, raw: number): number {
|
||||
return metric === 'cost' ? raw / 1_000_000 : raw
|
||||
}
|
||||
|
||||
/** resolveToken reads a --chart-* custom property from :root, live — same approach (and same reason) as UsageChart.vue. */
|
||||
function resolveToken(cssVar: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* The server already returns the ranking sorted, descending, and capped —
|
||||
* this component neither re-sorts nor re-filters it. Chart.js draws the
|
||||
* first category at the TOP of a horizontal bar chart, so the busiest
|
||||
* model lands where a reader looks first, with no reversal needed.
|
||||
*/
|
||||
const chartData = computed<ChartData<'bar'>>(() => {
|
||||
void foreground.value // re-run on a prefers-color-scheme flip (see UsageChart.vue)
|
||||
return {
|
||||
labels: props.models.map((m) => m.id),
|
||||
datasets: [
|
||||
{
|
||||
label: MODEL_METRIC_LABEL[props.metric],
|
||||
backgroundColor: resolveToken(METRIC_COLOR[props.metric]),
|
||||
data: props.models.map((m) => toDisplayValue(props.metric, m.value)),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Height grows with the row count so bars keep a readable thickness
|
||||
* instead of compressing into hairlines at 20 models, and so a two-model
|
||||
* ranking does not stretch each bar across a third of the screen.
|
||||
*/
|
||||
const chartHeight = computed<string>(() => `${Math.max(180, props.models.length * 28 + 60)}px`)
|
||||
|
||||
const chartOptions = computed<ChartOptions<'bar'>>(() => ({
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
scales: {
|
||||
x: {
|
||||
// Zero-based, honest axis: bar length is the whole comparison here.
|
||||
beginAtZero: true,
|
||||
grid: { color: border.value },
|
||||
ticks: {
|
||||
color: mutedForeground.value,
|
||||
callback: (value) => (props.metric === 'cost' ? `$${value}` : formatCompactCount(Number(value))),
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { display: false },
|
||||
ticks: { color: mutedForeground.value, autoSkip: false },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
// Compact figure plus the exact one, matching UsageChart.vue's
|
||||
// tooltip contract — a tooltip has room for both, and precision
|
||||
// must never be lost to the axis's compact form.
|
||||
label: (ctx) => {
|
||||
const value = ctx.parsed.x ?? 0
|
||||
if (props.metric === 'cost') return `${MODEL_METRIC_LABEL[props.metric]}: $${value.toFixed(4)}`
|
||||
return `${MODEL_METRIC_LABEL[props.metric]}: ${formatCompactCount(value)} (${formatExactInt(value)})`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p v-if="models.length === 0" class="py-8 text-center text-sm text-muted-foreground">
|
||||
No model recorded any usage in this window.
|
||||
</p>
|
||||
<div v-else class="w-full" :style="{ height: chartHeight }">
|
||||
<Bar :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -6,11 +6,11 @@ import { Bar } from 'vue-chartjs'
|
||||
import '@/lib/chart-setup'
|
||||
import { useThemeColors } from '@/composables/useThemeColors'
|
||||
import { formatBucketLabel, formatCompactCount, formatExactInt } from '@/lib/format'
|
||||
import type { ChartTab } from '@/stores/history'
|
||||
import type { TimeSeriesTab } from '@/stores/history'
|
||||
import type { HistoryMetric, HistoryWindow, UsageHistoryPoint } from '@/types/api'
|
||||
|
||||
const props = defineProps<{
|
||||
tab: ChartTab
|
||||
tab: TimeSeriesTab
|
||||
window: HistoryWindow
|
||||
seriesByMetric: Partial<Record<HistoryMetric, UsageHistoryPoint[]>>
|
||||
}>()
|
||||
@@ -18,7 +18,7 @@ const props = defineProps<{
|
||||
const { foreground, mutedForeground, border } = useThemeColors()
|
||||
|
||||
/** One dataset spec per tab: which metric(s), its label, and its token color. */
|
||||
const DATASET_SPECS: Record<ChartTab, { metric: HistoryMetric; label: string; color: string }[]> = {
|
||||
const DATASET_SPECS: Record<TimeSeriesTab, { metric: HistoryMetric; label: string; color: string }[]> = {
|
||||
requests: [{ metric: 'req', label: 'Requests', color: '--chart-requests' }],
|
||||
tokens: [
|
||||
{ metric: 'tokin', label: 'Tokens in', color: '--chart-tokens-in' },
|
||||
|
||||
Reference in New Issue
Block a user