mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-15 05:29:28 +00:00
feat(webui): compact usage-count formatting
Operator scope addition: raw token/request counts reaching millions/
billions were unreadable as bare digits. formatCompactCount (lib/
format.ts, shipped in the previous commit alongside this feature's own
helpers) renders an SI-style label: below 10,000 the exact integer with
thousands separators, at or above it three significant figures with a
k/M/B suffix ("12.3k", "456k", "1.23M", "45.6M", "1.23B") —
every digit past the third significant one is FLOORED, never rounded,
the same honesty rule formatRatePercent already applies to the
success-rate badge: a count of 999,950 must read "999k", never
"1,000k", since it has not actually crossed that boundary.
CompactNumber.vue is the one shared, reusable presentational component
(component-first: no per-call-site wrapper markup) — renders the
compact form as text, with the exact value on both title and aria-label,
mirroring ProviderRateBadge.vue/ModelChip.vue's own hover-parity
pattern.
Applied everywhere a count renders: usage-columns.ts's numericColumn
(reqMin/reqDay/tokIn-out per day and month — cost columns explicitly
opt out via a new compact:false argument, since formatCost already
shows full precision), target-columns.ts's MCP/agent request counters,
UsageView.vue's Total card and group-accordion trigger/detail stat
grid, and UsageChart.vue's Chart.js y-axis ticks (compact only — no
room for the raw figure on an axis label) and tooltip labels (compact
form WITH the precise raw value alongside, e.g. "Tokens in: 1.23M
(1,234,567)").
Sorting is unaffected by construction: every numericColumn's accessorFn
still returns the raw number (what getSortedRowModel actually sorts by)
regardless of what the cell renders — usage-columns.spec.ts pins this
directly, including a sort-order assertion that would fail under a
lexicographic (string) sort but passes under the numeric one.
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { formatCompactCount, formatExactInt } from '@/lib/format'
|
||||
|
||||
/**
|
||||
* CompactNumber renders a large raw token/request count in its compact
|
||||
* SI-style form ("1.23M") while carrying the exact value via both
|
||||
* `title` (mouse hover) and `aria-label` (keyboard/screen-reader parity
|
||||
* — the same established pattern ProviderRateBadge.vue and ModelChip.vue
|
||||
* already apply to their own hover detail). Used anywhere a usage count
|
||||
* renders: DataTable numeric columns (usage-columns.ts, target-
|
||||
* columns.ts, via `h(CompactNumber, { value })`), the Usage tab's Total
|
||||
* card, and a group accordion's trigger/detail stat grid (UsageView.vue).
|
||||
*
|
||||
* Cost figures are NOT this component's concern — formatCost already
|
||||
* shows full 4-decimal precision, so there is nothing to compact or
|
||||
* disambiguate with a title there; only token/request COUNTS use this.
|
||||
*/
|
||||
const props = defineProps<{ value: number }>()
|
||||
|
||||
const compact = computed(() => formatCompactCount(props.value))
|
||||
const exact = computed(() => formatExactInt(props.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :title="exact" :aria-label="exact">{{ compact }}</span>
|
||||
</template>
|
||||
@@ -5,7 +5,7 @@ import { Bar } from 'vue-chartjs'
|
||||
|
||||
import '@/lib/chart-setup'
|
||||
import { useThemeColors } from '@/composables/useThemeColors'
|
||||
import { formatBucketLabel } from '@/lib/format'
|
||||
import { formatBucketLabel, formatCompactCount, formatExactInt } from '@/lib/format'
|
||||
import type { ChartTab } from '@/stores/history'
|
||||
import type { HistoryMetric, HistoryWindow, UsageHistoryPoint } from '@/types/api'
|
||||
|
||||
@@ -107,7 +107,11 @@ const chartOptions = computed<ChartOptions<'bar'>>(() => ({
|
||||
grid: { color: border.value },
|
||||
ticks: {
|
||||
color: mutedForeground.value,
|
||||
callback: (value) => (props.tab === 'cost' ? `$${value}` : String(value)),
|
||||
// Axis ticks stay compact (feature v0.23 addendum) — no room for
|
||||
// the exact figure on an axis label; the tooltip below carries
|
||||
// it. Cost keeps its existing money format, unaffected: a
|
||||
// dollar figure is not a "count" this formatter is meant for.
|
||||
callback: (value) => (props.tab === 'cost' ? `$${value}` : formatCompactCount(Number(value))),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -118,9 +122,15 @@ const chartOptions = computed<ChartOptions<'bar'>>(() => ({
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
// Tooltip shows the compact form WITH the precise raw value
|
||||
// alongside (feature v0.23 addendum) — e.g. "Tokens in: 1.23M
|
||||
// (1,234,567)" — since a tooltip, unlike an axis tick, has room
|
||||
// for both and precision must never be lost. Cost stays its
|
||||
// existing $-with-4-decimals format, unchanged.
|
||||
label: (ctx) => {
|
||||
const value = ctx.parsed.y ?? 0
|
||||
return `${ctx.dataset.label}: ${props.tab === 'cost' ? `$${value.toFixed(4)}` : value}`
|
||||
if (props.tab === 'cost') return `${ctx.dataset.label}: $${value.toFixed(4)}`
|
||||
return `${ctx.dataset.label}: ${formatCompactCount(value)} (${formatExactInt(value)})`
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SortingState } from '@tanstack/vue-table'
|
||||
import { getCoreRowModel, getSortedRowModel, useVueTable } from '@tanstack/vue-table'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import CompactNumber from '@/components/CompactNumber.vue'
|
||||
import ModelChip from '@/components/ModelChip.vue'
|
||||
import SearchInput from '@/components/SearchInput.vue'
|
||||
import SortHeaderButton from '@/components/SortHeaderButton.vue'
|
||||
@@ -267,15 +268,15 @@ function isGlobPattern(entry: string): boolean {
|
||||
<CardContent class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
|
||||
<div>
|
||||
<p class="text-muted-foreground">Requests/day</p>
|
||||
<p class="text-lg font-semibold tabular-nums">{{ usage.total.requestsPerDay }}</p>
|
||||
<p class="text-lg font-semibold tabular-nums"><CompactNumber :value="usage.total.requestsPerDay" /></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Tokens in/day</p>
|
||||
<p class="text-lg font-semibold tabular-nums">{{ usage.total.tokensInPerDay }}</p>
|
||||
<p class="text-lg font-semibold tabular-nums"><CompactNumber :value="usage.total.tokensInPerDay" /></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Tokens out/day</p>
|
||||
<p class="text-lg font-semibold tabular-nums">{{ usage.total.tokensOutPerDay }}</p>
|
||||
<p class="text-lg font-semibold tabular-nums"><CompactNumber :value="usage.total.tokensOutPerDay" /></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Cost/day</p>
|
||||
@@ -304,7 +305,8 @@ function isGlobPattern(entry: string): boolean {
|
||||
<span class="font-medium">{{ row.original.id }}</span>
|
||||
<span class="text-xs text-muted-foreground tabular-nums">{{ memberCountOf(row.original) || '0' }} members</span>
|
||||
<span class="text-xs text-muted-foreground tabular-nums">
|
||||
{{ row.original.storeDown ? '?' : row.original.requestsPerDay }} req/day
|
||||
<template v-if="row.original.storeDown">?</template>
|
||||
<CompactNumber v-else :value="row.original.requestsPerDay" /> req/day
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground tabular-nums">
|
||||
{{ row.original.storeDown ? '?' : formatCost(row.original.costPerDayMicroUsd) }}/day
|
||||
@@ -394,23 +396,38 @@ function isGlobPattern(entry: string): boolean {
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">req/min</dt>
|
||||
<dd class="tabular-nums">{{ row.original.storeDown ? '?' : row.original.requestsPerMinute }}</dd>
|
||||
<dd class="tabular-nums">
|
||||
<template v-if="row.original.storeDown">?</template>
|
||||
<CompactNumber v-else :value="row.original.requestsPerMinute" />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">tokIn/day</dt>
|
||||
<dd class="tabular-nums">{{ row.original.storeDown ? '?' : row.original.tokensInPerDay }}</dd>
|
||||
<dd class="tabular-nums">
|
||||
<template v-if="row.original.storeDown">?</template>
|
||||
<CompactNumber v-else :value="row.original.tokensInPerDay" />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">tokOut/day</dt>
|
||||
<dd class="tabular-nums">{{ row.original.storeDown ? '?' : row.original.tokensOutPerDay }}</dd>
|
||||
<dd class="tabular-nums">
|
||||
<template v-if="row.original.storeDown">?</template>
|
||||
<CompactNumber v-else :value="row.original.tokensOutPerDay" />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">tokIn/month</dt>
|
||||
<dd class="tabular-nums">{{ row.original.storeDown ? '?' : row.original.tokensInPerMonth }}</dd>
|
||||
<dd class="tabular-nums">
|
||||
<template v-if="row.original.storeDown">?</template>
|
||||
<CompactNumber v-else :value="row.original.tokensInPerMonth" />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">tokOut/month</dt>
|
||||
<dd class="tabular-nums">{{ row.original.storeDown ? '?' : row.original.tokensOutPerMonth }}</dd>
|
||||
<dd class="tabular-nums">
|
||||
<template v-if="row.original.storeDown">?</template>
|
||||
<CompactNumber v-else :value="row.original.tokensOutPerMonth" />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">cost/month</dt>
|
||||
|
||||
Reference in New Issue
Block a user