Requests/day
-
{{ usage.total.requestsPerDay }}
+
Tokens in/day
-
{{ usage.total.tokensInPerDay }}
+
Tokens out/day
-
{{ usage.total.tokensOutPerDay }}
+
Cost/day
@@ -304,7 +305,8 @@ function isGlobPattern(entry: string): boolean {
{{ row.original.id }}
{{ memberCountOf(row.original) || '0' }} members
- {{ row.original.storeDown ? '?' : row.original.requestsPerDay }} req/day
+ ?
+ req/day
{{ row.original.storeDown ? '?' : formatCost(row.original.costPerDayMicroUsd) }}/day
@@ -394,23 +396,38 @@ function isGlobPattern(entry: string): boolean {
req/min
- {{ row.original.storeDown ? '?' : row.original.requestsPerMinute }}
+
+ ?
+
+
tokIn/day
- {{ row.original.storeDown ? '?' : row.original.tokensInPerDay }}
+
+ ?
+
+
tokOut/day
- {{ row.original.storeDown ? '?' : row.original.tokensOutPerDay }}
+
+ ?
+
+
tokIn/month
- {{ row.original.storeDown ? '?' : row.original.tokensInPerMonth }}
+
+ ?
+
+
tokOut/month
- {{ row.original.storeDown ? '?' : row.original.tokensOutPerMonth }}
+
+ ?
+
+
cost/month
diff --git a/webui/src/lib/target-columns.ts b/webui/src/lib/target-columns.ts
index 434e379..e818afd 100644
--- a/webui/src/lib/target-columns.ts
+++ b/webui/src/lib/target-columns.ts
@@ -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
{
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) }),
}
}
diff --git a/webui/src/lib/usage-columns.spec.ts b/webui/src/lib/usage-columns.spec.ts
new file mode 100644
index 0000000..a5a77ee
--- /dev/null
+++ b/webui/src/lib/usage-columns.spec.ts
@@ -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 {
+ 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.
+ })
+})
diff --git a/webui/src/lib/usage-columns.ts b/webui/src/lib/usage-columns.ts
index 197e79e..288d449 100644
--- a/webui/src/lib/usage-columns.ts
+++ b/webui/src/lib/usage-columns.ts
@@ -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 {
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),
]
}