diff --git a/webui/src/components/CompactNumber.vue b/webui/src/components/CompactNumber.vue new file mode 100644 index 0000000..0429405 --- /dev/null +++ b/webui/src/components/CompactNumber.vue @@ -0,0 +1,28 @@ + + + diff --git a/webui/src/components/UsageChart.vue b/webui/src/components/UsageChart.vue index 130b2f4..ebfed1a 100644 --- a/webui/src/components/UsageChart.vue +++ b/webui/src/components/UsageChart.vue @@ -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>(() => ({ 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>(() => ({ }, 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)})` }, }, }, diff --git a/webui/src/components/UsageView.vue b/webui/src/components/UsageView.vue index fcadcb0..9806824 100644 --- a/webui/src/components/UsageView.vue +++ b/webui/src/components/UsageView.vue @@ -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 {

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), ] }