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:
2026-08-22 01:36:48 +01:00
parent eac0862d68
commit 040ff40ae0
6 changed files with 181 additions and 22 deletions
+28
View File
@@ -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>
+13 -3
View File
@@ -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)})`
},
},
},
+26 -9
View File
@@ -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>
+12 -7
View File
@@ -1,16 +1,21 @@
import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'
import CompactNumber from '@/components/CompactNumber.vue'
import { Badge } from '@/components/ui/badge'
import type { AdminTargetView } from '@/types/api'
/**
* numericColumn builds one right-aligned, sortable request-counter column.
* Mirrors usage-columns.ts's own helper of the same name and purpose, but
* without that one's storeDown '?' masking: a target's counters carry no
* storeDown flag (limiter.targetUsage, limits.go — a target scope has no
* limit to protect from a misleadingly-confident zero, unlike a limited
* user/group scope), so there is nothing to mask here.
* numericColumn builds one right-aligned, sortable request-counter
* column, rendered via CompactNumber (SI-style, exact value on title/
* aria-label — usage-columns.ts's own numericColumn applies the
* identical treatment). Mirrors usage-columns.ts's own helper of the
* same name and purpose, but without that one's storeDown '?' masking: a
* target's counters carry no storeDown flag (limiter.targetUsage,
* limits.go — a target scope has no limit to protect from a
* misleadingly-confident zero, unlike a limited user/group scope), so
* there is nothing to mask here. accessorFn (and therefore sorting)
* always reads the true raw number.
*/
function numericColumn(id: string, header: string, read: (t: AdminTargetView) => number): ColumnDef<AdminTargetView, unknown> {
return {
@@ -18,7 +23,7 @@ function numericColumn(id: string, header: string, read: (t: AdminTargetView) =>
header,
accessorFn: read,
meta: { align: 'right' },
cell: ({ row }) => String(read(row.original)),
cell: ({ row }) => h(CompactNumber, { value: read(row.original) }),
}
}
+84
View File
@@ -0,0 +1,84 @@
import { isVNode } from 'vue'
import { describe, expect, it } from 'vitest'
import { usageColumns } from './usage-columns'
import type { AdminUsageEntryView } from '@/types/api'
/**
* Compact-number rendering (feature v0.23 addendum) must land ONLY in
* each numeric column's cell renderer, never in its accessorFn — the
* accessorFn is what getSortedRowModel actually sorts by, so a compacted
* STRING there ("15.0M") would sort lexicographically instead of
* numerically. These tests exercise the ColumnDef objects directly
* (no table mount needed) to pin that separation.
*/
function testEntry(overrides: Partial<AdminUsageEntryView> = {}): AdminUsageEntryView {
return {
kind: 'user',
id: 'alice',
requestsPerMinute: 5,
requestsPerDay: 15_000_000,
tokensInPerDay: 999,
tokensOutPerDay: 0,
tokensInPerMonth: 0,
tokensOutPerMonth: 0,
costPerDayMicroUsd: 1_234_500,
costPerMonthMicroUsd: 0,
...overrides,
}
}
/**
* accessorFnOf narrows a plain `ColumnDef` (a union of accessor/display
* column shapes TypeScript does not narrow automatically) down to the
* `accessorFn` every numericColumn-built column in this test file
* actually carries — a thin, test-local cast, not a production type.
*/
function accessorFnOf(col: unknown): (row: AdminUsageEntryView, index: number) => unknown {
return (col as { accessorFn: (row: AdminUsageEntryView, index: number) => unknown }).accessorFn
}
describe('usageColumns: sorting stays on raw numeric values', () => {
const columns = usageColumns('Name', 'Group', () => 'g')
it('a count column (reqDay) accessorFn returns the raw number, not a compact string', () => {
const col = columns.find((c) => c.id === 'reqDay')
expect(accessorFnOf(col)(testEntry(), 0)).toBe(15_000_000)
})
it('a count column cell renders a CompactNumber vnode, not the raw number as text', () => {
const col = columns.find((c) => c.id === 'reqDay')
// ColumnDef's cell can be a string or a render function; every
// numericColumn call here uses the function form.
const cell = col?.cell as (ctx: { row: { original: AdminUsageEntryView } }) => unknown
const rendered = cell({ row: { original: testEntry() } })
expect(isVNode(rendered)).toBe(true)
})
it('a cost column cell still renders plain formatted text (compact:false), not a CompactNumber vnode', () => {
const col = columns.find((c) => c.id === 'costDay')
const cell = col?.cell as (ctx: { row: { original: AdminUsageEntryView } }) => unknown
const rendered = cell({ row: { original: testEntry() } })
expect(isVNode(rendered)).toBe(false)
expect(rendered).toBe('$1.2345')
})
it('a storeDown row still masks every count cell as "?" regardless of compact rendering', () => {
const col = columns.find((c) => c.id === 'tokInDay')
const cell = col?.cell as (ctx: { row: { original: AdminUsageEntryView } }) => unknown
const rendered = cell({ row: { original: testEntry({ storeDown: true }) } })
expect(rendered).toBe('?')
})
it('sorting a set of entries by reqDay orders numerically, not lexicographically (the bug this separation prevents)', () => {
const col = columns.find((c) => c.id === 'reqDay')
const accessor = accessorFnOf(col)
const entries = [testEntry({ id: 'a', requestsPerDay: 15_000_000 }), testEntry({ id: 'b', requestsPerDay: 999 }), testEntry({ id: 'c', requestsPerDay: 12_000 })]
const sorted = [...entries].sort((x, y) => (accessor(x, 0) as number) - (accessor(y, 0) as number))
expect(sorted.map((e) => e.id)).toEqual(['b', 'c', 'a'])
// A lexicographic sort on the COMPACT TEXT would instead order
// "1,000+" digits, "12k", "15.0M" as strings ("12k" < "15.0M" <
// "999" alphabetically) — the wrong order, which is exactly why
// accessorFn must stay numeric.
})
})
+18 -3
View File
@@ -1,6 +1,7 @@
import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'
import CompactNumber from '@/components/CompactNumber.vue'
import { formatCost, formatLimits } from '@/lib/format'
import type { AdminUsageEntryView } from '@/types/api'
@@ -12,19 +13,33 @@ import type { AdminUsageEntryView } from '@/types/api'
* (a storeDown row sorts by its last-known number, same as the rest of
* the table; only the DISPLAYED text is masked). Mirrors the pre-DataTable
* UsageTable.vue template's own `entry.storeDown ? '?' : ...` convention.
*
* compact (default true) renders the value through CompactNumber (SI-
* style, with the exact figure on title/aria-label) instead of the plain
* `format`/String path — used for every raw token/request COUNT column
* below; the two cost columns pass compact:false, since formatCost
* already shows full precision and compacting a dollar figure is not
* this feature's concern. The accessorFn (and therefore sorting) always
* reads the true raw number either way — only the cell's rendered text
* changes.
*/
function numericColumn(
id: string,
header: string,
read: (entry: AdminUsageEntryView) => number,
format: (n: number) => string = String,
compact = true,
): ColumnDef<AdminUsageEntryView, unknown> {
return {
id,
header,
accessorFn: read,
meta: { align: 'right' },
cell: ({ row }) => (row.original.storeDown ? '?' : format(read(row.original))),
cell: ({ row }) => {
if (row.original.storeDown) return '?'
const value = read(row.original)
return compact ? h(CompactNumber, { value }) : format(value)
},
}
}
@@ -69,7 +84,7 @@ export function usageColumns(
numericColumn('tokOutDay', 'tokOut/day', (e) => e.tokensOutPerDay),
numericColumn('tokInMonth', 'tokIn/month', (e) => e.tokensInPerMonth),
numericColumn('tokOutMonth', 'tokOut/month', (e) => e.tokensOutPerMonth),
numericColumn('costDay', 'cost/day', (e) => e.costPerDayMicroUsd, formatCost),
numericColumn('costMonth', 'cost/month', (e) => e.costPerMonthMicroUsd, formatCost),
numericColumn('costDay', 'cost/day', (e) => e.costPerDayMicroUsd, formatCost, false),
numericColumn('costMonth', 'cost/month', (e) => e.costPerMonthMicroUsd, formatCost, false),
]
}