feat(webui): redesign the admin dashboard around admin tasks

A sidebar replaces the flat tabs with seven pages, and one filter bar
sets the time range, comparison period and scope for all of them.

- Home: spend against budget, forecast, request rate, error rate,
  open breakers, top users and models, recent events and warnings.
- Spend: spend over time with comparison, budget burn-down, an
  attribution drill-down from group to user to model, pricing health
  and cost avoided by free models.
- Consumers: a directory with last seen and limit bars, an access
  matrix, and a detail page per user.
- Models: the catalog with billing prices, fleet latency and error
  rates, and provider health.
- Reliability: error, timeout, failover, 429 and 402 trends, events,
  and latency per provider.
- Config: the redacted config and a change helper that emits YAML or
  users.json snippets to paste into git.
- MCP & Agents: callers per target.

Page state lives in the URL hash, and old tab links map to the new
pages. The replaced views are removed. admin_assets_gen.go is
regenerated.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
2026-09-23 16:41:01 +01:00
parent e8abb91dc8
commit ee49467e64
127 changed files with 11226 additions and 3414 deletions
+182 -8
View File
File diff suppressed because one or more lines are too long
+49 -64
View File
@@ -1,64 +1,66 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { faGithub } from '@fortawesome/free-brands-svg-icons'
import { faCircleExclamation } from '@fortawesome/free-solid-svg-icons'
import { computed, defineAsyncComponent, onMounted } from 'vue'
import AuthGate from '@/components/AuthGate.vue'
import ConfigWarningsBanner from '@/components/ConfigWarningsBanner.vue'
import EventsView from '@/components/EventsView.vue'
import ProvidersView from '@/components/ProvidersView.vue'
import TargetsView from '@/components/TargetsView.vue'
import GlobalFilterBar from '@/components/GlobalFilterBar.vue'
import SidebarNav from '@/components/SidebarNav.vue'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import UsageView from '@/components/UsageView.vue'
import { useHashState } from '@/composables/useHashState'
import type { PageId } from '@/lib/pages'
import { useAuthStore } from '@/stores/auth'
import { useDashboardStore } from '@/stores/dashboard'
import { useNavStore } from '@/stores/nav'
// Lazy: Chart.js (UsageChart.vue's dependency, pulled in transitively) is
// the single largest piece of this bundle, and most admin sessions open on
// Providers/Usage. Splitting it into its own chunk keeps the initial load
// lean; it fetches once, on first visit to the Charts tab.
const ChartsView = defineAsyncComponent(() => import('@/components/ChartsView.vue'))
/** The module path (go.mod / .traefik.yml `import:`) — same repo the plugin
* ships from, so this is the one canonical URL rather than a guess. */
/** The module path (go.mod / .traefik.yml `import:`) — same repo the plugin ships from, so this is the one canonical URL rather than a guess. */
const GITHUB_REPO_URL = 'https://github.com/lukaszraczylo/traefik-llmgateway'
const auth = useAuthStore()
const dashboard = useDashboardStore()
const nav = useNavStore()
// The "Providers" tab is labeled that way (operator rename); the backend
// endpoint it fetches from, GET /admin/api/overview, and the store's own
// `overview` field (AdminOverviewResponse) keep their original names
// unchanged — the rename is UI-only, not an API surface change.
// F9 (dashboard-plan.md) supersedes Feature C (v0.22)'s useTabHash()
// composable: the Tabs v-model now binds nav.activeTab (stores/nav.ts)
// directly, and useHashState() keeps that field — plus history.ts's
// scope/window/tab/modelMetric, events.ts's kindFilter/userFilter, and
// nav.usageQuery — synced with a single "#<tab>?<query>" URL hash
// (lib/hash-state.ts), both on load and on every change. MUST be created
// here, before the `v-if="!auth.isAuthenticated"` branch below (Risks,
// dashboard-plan.md: "load-with-hash-then-authenticate") — every store
// action it calls is safe to run before a key is stored, so the hash's
// selection is already restored by the time the reader authenticates.
// The old flat-tab shell (activeTab/useTabHash) is superseded by the
// sidebar-page redesign (redesign-plan.md section 3.1): nav.page/params
// now drive routing, and useHashState() keeps them — plus the global
// filters (stores/filters.ts) — synced with a single
// "#<page>?range=...&cmp=...&scope=...&<page params>" URL hash. MUST be
// created here, before the `v-if="!auth.isAuthenticated"` branch below
// (load-with-hash-then-authenticate) — every store write it makes is
// safe to run before a key is stored, so the hash's selection is already
// restored by the time the reader authenticates.
useHashState()
/**
* PAGES maps each PageId (lib/pages.ts) to its own top-level page
* component, lazily loaded — an admin session only ever looks at one
* page at a time, so only that page's own code (and, for the pages
* other work packages are filling in with chart.js-backed views) ships
* on first load, generalizing the same reasoning the old shell already
* applied to its one chart-heavy tab.
*/
const PAGES: Record<PageId, Component> = {
home: defineAsyncComponent(() => import('@/pages/HomePage.vue')),
spend: defineAsyncComponent(() => import('@/pages/SpendPage.vue')),
consumers: defineAsyncComponent(() => import('@/pages/ConsumersPage.vue')),
models: defineAsyncComponent(() => import('@/pages/ModelsPage.vue')),
reliability: defineAsyncComponent(() => import('@/pages/ReliabilityPage.vue')),
config: defineAsyncComponent(() => import('@/pages/ConfigPage.vue')),
targets: defineAsyncComponent(() => import('@/pages/TargetsPage.vue')),
}
const currentPage = computed<Component>(() => PAGES[nav.page])
// statusText reads dashboard.error/lastUpdated unconditionally — it is
// correct only while the template's own `v-if="auth.isAuthenticated"` below
// keeps it off-screen otherwise. Without that gate a 401 (which resolves
// before touching error/lastUpdated) left the PRIOR "last updated ..." or
// "refresh failed: ..." line showing right under the auth gate, and a first
// load with no stored key showed "loading..." forever (review finding).
// F4 (dashboard-plan.md): appended only on a genuinely successful poll —
// correct only while the template's own `v-if="auth.isAuthenticated"`
// below keeps it off-screen otherwise, so a first load with no stored
// key never shows a stale "refresh failed"/"loading..." line under the
// auth gate. Appended only on a genuinely successful poll —
// dashboard.overview.replica reflects WHICH replica answered that poll,
// so it must never be shown alongside a stale value during a failed or
// still-loading refresh (a prior successful poll's overview object can
// still be sitting there while `error` is set, and showing its replica
// next to "refresh failed" would misattribute the failure to it).
// still-loading refresh.
const statusText = computed<string>(() => {
if (dashboard.error) return `refresh failed: ${dashboard.error}`
if (dashboard.lastUpdated) {
@@ -74,8 +76,8 @@ onMounted(() => {
</script>
<template>
<div class="flex w-full flex-col gap-6 px-4 py-8 sm:px-6 lg:px-8">
<header class="flex flex-col gap-1">
<div class="flex w-full flex-col gap-4 px-4 py-6 sm:px-6 lg:px-8">
<header class="flex flex-col gap-3">
<div class="flex items-center justify-between gap-2">
<h1 class="text-xl font-semibold tracking-tight">LLM Gateway &mdash; Admin</h1>
<Button
@@ -95,9 +97,10 @@ onMounted(() => {
<FontAwesomeIcon v-if="dashboard.error" :icon="faCircleExclamation" class="size-3.5 text-destructive" aria-hidden="true" />
<span :class="dashboard.error ? 'text-destructive' : undefined">{{ statusText }}</span>
</p>
<GlobalFilterBar v-if="auth.isAuthenticated" />
</header>
<!-- F10: GET /admin/api/overview's config warnings — renders nothing itself once `warnings` is empty, so this is always safe to mount. -->
<!-- GET /admin/api/overview's config warnings — renders nothing itself once `warnings` is empty, so this is always safe to mount. -->
<ConfigWarningsBanner
v-if="auth.isAuthenticated"
:warnings="dashboard.overview?.warnings ?? []"
@@ -106,29 +109,11 @@ onMounted(() => {
<AuthGate v-if="!auth.isAuthenticated" />
<Tabs v-else v-model="nav.activeTab">
<TabsList>
<TabsTrigger value="providers">Providers</TabsTrigger>
<TabsTrigger value="usage">Usage</TabsTrigger>
<TabsTrigger value="charts">Charts</TabsTrigger>
<TabsTrigger value="targets">MCP & Agents</TabsTrigger>
<TabsTrigger value="events">Events</TabsTrigger>
</TabsList>
<TabsContent value="providers">
<ProvidersView />
</TabsContent>
<TabsContent value="usage">
<UsageView />
</TabsContent>
<TabsContent value="charts">
<ChartsView />
</TabsContent>
<TabsContent value="targets">
<TargetsView />
</TabsContent>
<TabsContent value="events">
<EventsView />
</TabsContent>
</Tabs>
<div v-else class="flex flex-col gap-4 md:flex-row">
<SidebarNav />
<main class="min-w-0 flex-1">
<component :is="currentPage" />
</main>
</div>
</div>
</template>
+33
View File
@@ -97,6 +97,29 @@
--chart-tokens-out: #e69f00;
--chart-cost: #d55e00;
/*
* --chart-series-a..d: Spend's breakdown chart (pages/SpendPage.vue,
* BREAKDOWN_PALETTE) extends the four series above with the
* remaining four swatches of that same Okabe & Ito (2008) 8-color
* qualitative set, for its open-ended per-model/provider/group
* series count. These tokens used to live in SpendPage.vue's own
* unscoped <style> block; moved here (P3 item 22) so every chart
* color token lives in the one design-tokens file, matching the
* Tailwind-only rule (no plain CSS outside a component's own scoped
* block or this file).
*
* --chart-series-b is NOT the Okabe-Ito yellow (#f0e442) verbatim in
* light mode: that measures well under WCAG 1.4.11's 3:1 non-text
* contrast minimum as a chart line on this light --card (~1.4:1) —
* #8a6d00 instead measures ~4.9:1 against --card, clearing both the
* 3:1 graphical-object minimum and the 4.5:1 text minimum with
* margin, while staying recognizably the same yellow/gold hue.
*/
--chart-series-a: #56b4e9;
--chart-series-b: #8a6d00;
--chart-series-c: #cc79a7;
--chart-series-d: #4d4d4d;
/*
* --status-warn: a semantic "degraded, not broken" indicator (the
* Providers tab's amber success-rate badge, ProviderRateBadge.vue) —
@@ -162,6 +185,16 @@
--chart-tokens-out: #f0ac1f;
--chart-cost: #e8763a;
/* Same Okabe-Ito hues as the light-mode block's own doc comment
above — already legible on a dark ground at this saturation
(Okabe-Ito was designed for slide/print use on light and dark
backgrounds alike), so only --chart-series-b's LIGHT value
needed darkening; here it keeps the original #f0e442. */
--chart-series-a: #56b4e9;
--chart-series-b: #f0e442;
--chart-series-c: #cc79a7;
--chart-series-d: #b8b8b8;
/* #f0ac1f on the dark --card measures ~8.97:1 — no separate dark
value was needed to clear WCAG 1.4.3 here, unlike light mode
(see --status-warn's own doc comment above), but the token is
+114
View File
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { faCheck, faTriangleExclamation, faXmark } from '@fortawesome/free-solid-svg-icons'
import { computed, onMounted } from 'vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { buildAccessMatrix } from '@/lib/access-matrix'
import type { AccessCell } from '@/lib/access-matrix'
import { useConsumersStore } from '@/stores/consumers'
import { useDashboardStore } from '@/stores/dashboard'
/**
* AccessMatrix (redesign-plan.md section 3.4) is the Consumers page's
* `view=matrix` alternative to ConsumerDirectory: rows are groups, columns
* are every configured provider (allowed check + "n/m models", from GET
* /admin/api/consumers' AllowedProviders/modelAccess) and every configured
* MCP server/agent target (from GET /admin/api/targets' own `access`
* list). Pure data shaping lives in lib/access-matrix.ts; this component
* only fetches, lays out columns, and renders.
*/
const consumers = useConsumersStore()
const dashboard = useDashboardStore()
onMounted(() => {
void consumers.ensureConsumers()
})
const providerNames = computed(() => (dashboard.overview?.providers ?? []).map((p) => p.name))
const mcpServers = computed(() => dashboard.targets?.mcpServers ?? [])
const agents = computed(() => dashboard.targets?.agents ?? [])
/** MatrixColumn flattens the three heterogeneous column groups (providers, MCP servers, agents) into one ordered list — a single v-for renders every header AND every cell, rather than three near-identical template blocks. */
interface MatrixColumn {
key: string
label: string
section: 'providers' | 'mcpServers' | 'agents'
}
const columns = computed<MatrixColumn[]>(() => [
...providerNames.value.map((name): MatrixColumn => ({ key: name, label: name, section: 'providers' })),
...mcpServers.value.map((t): MatrixColumn => ({ key: t.name, label: `${t.name} (MCP)`, section: 'mcpServers' })),
...agents.value.map((t): MatrixColumn => ({ key: t.name, label: `${t.name} (agent)`, section: 'agents' })),
])
const rows = computed(() => buildAccessMatrix(consumers.data?.groups ?? [], providerNames.value, mcpServers.value, agents.value))
const EMPTY_CELL: AccessCell = { allowed: false }
function cellFor(row: (typeof rows.value)[number], column: MatrixColumn): AccessCell {
return row[column.section][column.key] ?? EMPTY_CELL
}
/**
* cellSrText is the ONLY accessible text a screen reader announces for
* one cell — the visible icon is aria-hidden and the visible detail span
* is aria-hidden too (P3 item 18: it used to be read TWICE, once as
* plain visible text and again inside this string). A zero-model cell
* (`zero`) gets its own wording rather than the plain "Allowed, 0/5
* models" reading, which sounds identical to "Allowed, 5/5 models" to a
* screen reader user skimming past the number.
*/
function cellSrText(cell: AccessCell): string {
if (!cell.allowed) return 'Not allowed'
if (cell.zero) return `Allowed, but ${cell.detail} reachable — the model grant matches none of this provider's models`
return cell.detail ? `Allowed, ${cell.detail}` : 'Allowed'
}
/** cellIcon picks the FontAwesome icon for a cell's three visual states: not allowed (X), allowed-but-zero-usable-models (warning triangle — P3 item 18: this used to render an identical green check to a genuinely unrestricted cell), and ordinarily allowed (check). */
function cellIcon(cell: AccessCell) {
if (!cell.allowed) return faXmark
if (cell.zero) return faTriangleExclamation
return faCheck
}
/** cellIconClass colors cellIcon's three states distinctly — not-allowed and zero-but-allowed must never share the "everything is fine" green. */
function cellIconClass(cell: AccessCell): string {
if (!cell.allowed) return 'text-muted-foreground/40'
if (cell.zero) return 'text-status-warn'
return 'text-chart-requests'
}
</script>
<template>
<Card>
<CardHeader>
<CardTitle>Access matrix</CardTitle>
<CardDescription>Which groups can reach which providers, MCP servers, and agents.</CardDescription>
</CardHeader>
<CardContent v-if="consumers.error" class="text-sm text-destructive">{{ consumers.error }}</CardContent>
<CardContent v-else-if="rows.length === 0" class="py-6 text-center text-sm text-muted-foreground">
{{ consumers.loading ? 'loading…' : 'no groups configured' }}
</CardContent>
<CardContent v-else class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Group</TableHead>
<TableHead v-for="column in columns" :key="column.key + column.section">{{ column.label }}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="row in rows" :key="row.group">
<TableHead scope="row" class="font-medium">{{ row.group }}</TableHead>
<TableCell v-for="column in columns" :key="column.key + column.section" class="text-center" :title="cellFor(row, column).detail">
<FontAwesomeIcon :icon="cellIcon(cellFor(row, column))" :class="cellIconClass(cellFor(row, column))" class="size-3.5" aria-hidden="true" />
<span v-if="cellFor(row, column).detail" aria-hidden="true" class="ml-1 text-xs text-muted-foreground">{{ cellFor(row, column).detail }}</span>
<span class="sr-only">{{ cellSrText(cellFor(row, column)) }}</span>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
</template>
@@ -0,0 +1,163 @@
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import { faChevronRight } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed, h, ref } from 'vue'
import ClampedRangeNotice from '@/components/ClampedRangeNotice.vue'
import CompactNumber from '@/components/CompactNumber.vue'
import DataTable from '@/components/DataTable.vue'
import EntityLink from '@/components/EntityLink.vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { formatCost } from '@/lib/format'
import { SPEND_METRIC_LABEL } from '@/stores/spend'
import type { AdminTotalsResponse, HistoryMetric, HistoryWindow } from '@/types/api'
/** DrilldownRow is one GET /admin/api/usage/totals row (types/api.ts's AdminTotalsResponse.rows entry). */
type DrilldownRow = AdminTotalsResponse['rows'][number]
/**
* AttributionDrilldown (redesign-plan.md section 3.4) descends group ->
* users (GET /admin/api/usage/totals?kind=user&group=) -> models (kind=
* usermodel, only when AdminFeaturesView.userModelStats — the config's
* admin.stats.userModel), breadcrumbs tracked in the `drill` page param
* (section 3.1). This component owns no fetch itself — CONTROLLED, like
* CostAvoidedCard.vue's own `ref` prop — stores/spend.ts's fetchDrilldown
* reads `drill` and supplies `rows`/`metrics`/`disabled`; every click here
* only emits `update:drill`, letting SpendPage.vue keep it in nav.params
* (so a reload or shared link restores the same drilldown position).
*
* `userModelStatsEnabled` gates whether a USER row is clickable at all
* (redesign-plan.md's own "only when features.userModelStats, else hint
* naming the config key") — checked client-side from the already-polled
* overview response, before ever firing a request that would only come
* back 404. `disabled` (from the store, a REAL 404) is the fallback for
* the deep-link case: a reader who lands directly on `drill=user:x` via a
* restored hash before this prop has had a chance to gate the click.
*/
const props = defineProps<{
rows: DrilldownRow[]
metrics: string[]
drill: string
disabled: boolean
userModelStatsEnabled: boolean
/** The global filters store's current window/span (N3 fix) — ClampedRangeNotice's own props, forwarded so this stays a controlled, props-driven component rather than reaching into stores/filters.ts itself. */
window: HistoryWindow
span: number
}>()
const emit = defineEmits<{ 'update:drill': [value: string] }>()
type Level = { kind: 'group' } | { kind: 'user'; group: string } | { kind: 'usermodel'; user: string }
const level = computed<Level>(() => {
if (props.drill === '') return { kind: 'group' }
if (props.drill.startsWith('group:')) return { kind: 'user', group: props.drill.slice('group:'.length) }
return { kind: 'usermodel', user: props.drill.slice('user:'.length) }
})
/**
* parentGroupLabel is a BEST-EFFORT breadcrumb label for the group a
* drilled-into user belongs to — the `drill` param itself only ever
* encodes the current LEAF selection (`user:{id}`, redesign-plan.md
* section 3.1's own `drill=group:x|user:x` shape has no room for a
* compound path), so this is tracked as local component state, set at
* the moment a user row is clicked FROM the user-list level. A reader who
* lands directly on `drill=user:x` via a restored hash sees no middle
* breadcrumb segment — an acceptable, honest degradation (nothing else
* available in `drill` records it) rather than a fabricated group name.
*/
const parentGroupLabel = ref('')
function goToGroups(): void {
emit('update:drill', '')
}
function goToParentGroup(): void {
if (parentGroupLabel.value) emit('update:drill', `group:${parentGroupLabel.value}`)
}
function onRowClick(id: string): void {
const l = level.value
if (l.kind === 'group') {
emit('update:drill', `group:${id}`)
} else if (l.kind === 'user' && props.userModelStatsEnabled) {
parentGroupLabel.value = l.group
emit('update:drill', `user:${id}`)
}
}
const idColumnLabel = computed<string>(() => (level.value.kind === 'group' ? 'Group' : level.value.kind === 'user' ? 'User' : 'Model'))
/** Row id is clickable to drill further at the group level always, and at the user level only when userModelStatsEnabled — the leaf (usermodel) level renders the model id as an EntityLink to the Models page instead, since there is nowhere further to drill. */
function idCell(row: DrilldownRow) {
const l = level.value
if (l.kind === 'usermodel') return h(EntityLink, { label: row.id, kind: 'model', id: row.id })
const clickable = l.kind === 'group' || props.userModelStatsEnabled
if (!clickable) return h('span', {}, row.id)
return h(
'button',
{
type: 'button',
class:
'inline-flex cursor-pointer items-center gap-1 rounded-sm hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50',
onClick: () => onRowClick(row.id),
},
[row.id, h(FontAwesomeIcon, { icon: faChevronRight, class: 'size-3', 'aria-hidden': 'true' })],
)
}
const columns = computed<ColumnDef<DrilldownRow, unknown>[]>(() => [
{ id: 'id', header: idColumnLabel.value, accessorFn: (r) => r.id, cell: ({ row }) => idCell(row.original) },
...props.metrics.map(
(metric): ColumnDef<DrilldownRow, unknown> => ({
id: metric,
header: SPEND_METRIC_LABEL[metric as HistoryMetric] ?? metric,
meta: { align: 'right' },
accessorFn: (r) => r.values[metric] ?? 0,
cell: ({ row }) => {
const value = row.original.values[metric] ?? 0
return metric === 'cost' ? formatCost(value) : h(CompactNumber, { value })
},
}),
),
])
const showUserModelHint = computed<boolean>(() => level.value.kind === 'user' && (!props.userModelStatsEnabled || props.disabled))
</script>
<template>
<Card>
<CardHeader>
<CardTitle>Attribution</CardTitle>
<CardDescription>Drill from groups into users into the models they used.</CardDescription>
<nav aria-label="Breadcrumb" class="flex flex-wrap items-center gap-1.5 text-sm text-muted-foreground">
<button type="button" class="hover:text-foreground hover:underline" :disabled="level.kind === 'group'" @click="goToGroups">
All groups
</button>
<template v-if="level.kind === 'user'">
<FontAwesomeIcon :icon="faChevronRight" class="size-3" aria-hidden="true" />
<span class="font-medium text-foreground">{{ level.group }}</span>
</template>
<template v-if="level.kind === 'usermodel'">
<FontAwesomeIcon :icon="faChevronRight" class="size-3" aria-hidden="true" />
<button v-if="parentGroupLabel" type="button" class="hover:text-foreground hover:underline" @click="goToParentGroup">
{{ parentGroupLabel }}
</button>
<FontAwesomeIcon :icon="faChevronRight" class="size-3" aria-hidden="true" />
<span class="font-medium text-foreground">{{ level.user }}</span>
</template>
</nav>
</CardHeader>
<CardContent class="flex flex-col gap-3">
<ClampedRangeNotice v-if="level.kind === 'usermodel'" :window="window" :span="span" />
<p v-if="showUserModelHint" class="text-sm text-muted-foreground">
Per-model attribution for this user needs <code class="font-mono text-xs">admin.stats.userModel</code> enabled.
</p>
<DataTable
v-else
:columns="columns"
:data="rows"
:empty-message="level.kind === 'group' ? 'No group has spend in this range.' : 'Nothing in this range.'"
/>
</CardContent>
</Card>
</template>
+92
View File
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed } from 'vue'
import TimeSeriesChart from '@/components/TimeSeriesChart.vue'
import type { TimeSeriesDataset } from '@/components/TimeSeriesChart.vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { cumulative, runOutDate } from '@/lib/burndown'
import { formatBucketLabel, formatCost } from '@/lib/format'
/**
* BurnDownChart (redesign-plan.md section 3.4) renders the current UTC
* calendar month's cumulative spend against its configured budget: a
* solid line (lib/burndown.ts's `cumulative`, run over the store's own raw
* per-day points — see stores/spend.ts's own doc comment on why the
* running-sum step lives here rather than in the store) plus, when a
* budget is configured, a dashed reference line at that flat limit
* (TimeSeriesChart's own `comparisonDatasets` — a budget ceiling is
* exactly the "reference overlay, not a component of the current total"
* that prop already documents) and the projected run-out date
* (lib/burndown.ts's runOutDate, MTD average daily rate).
*
* `now` is a required prop, not read from `new Date()` internally —
* CostForecastCard.vue's own doc comment explains why a component-local
* default silently freezes at first mount (Vue caches a prop default
* once per instance): the caller passes the SAME shared clock
* (composables/useNow.ts) every other time-dependent read on the page
* shares.
*/
const props = defineProps<{
/** Day-window bucket keys, oldest first (limits.go's "20060102" format — lib/format.ts's formatBucketLabel). */
buckets: string[]
/** Raw (not cumulative) per-day cost in micro-USD, index-aligned with `buckets`. */
points: number[]
/** The scope's configured cost/month budget in micro-USD, or null when none is configured. */
budgetMicros: number | null
now: Date
}>()
const labels = computed<string[]>(() => props.buckets.map((b) => formatBucketLabel(b, 'day')))
const cumulativePoints = computed<number[]>(() => cumulative(props.points))
const spentMicros = computed<number>(() => cumulativePoints.value.at(-1) ?? 0)
const datasets = computed<TimeSeriesDataset[]>(() => [{ label: 'Spent (MTD)', data: cumulativePoints.value, color: '--chart-cost' }])
const comparisonDatasets = computed<TimeSeriesDataset[]>(() => {
if (props.budgetMicros === null) return []
const flat = new Array<number>(cumulativePoints.value.length).fill(props.budgetMicros)
return [{ label: 'Budget', data: flat, color: '--destructive' }]
})
const runOut = computed<Date | null>(() => (props.budgetMicros === null ? null : runOutDate(spentMicros.value, props.budgetMicros, props.now)))
const willExceed = computed<boolean>(() => props.budgetMicros !== null && spentMicros.value >= props.budgetMicros)
</script>
<template>
<Card>
<CardHeader>
<CardTitle>Burn-down</CardTitle>
<CardDescription>Cumulative spend this UTC calendar month against the configured budget.</CardDescription>
</CardHeader>
<CardContent class="flex flex-col gap-4">
<div class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-3">
<div>
<p class="text-muted-foreground">Spent so far</p>
<p class="text-lg font-semibold tabular-nums" :class="willExceed ? 'text-destructive' : undefined">
{{ formatCost(spentMicros) }}
</p>
</div>
<div v-if="budgetMicros !== null">
<p class="text-muted-foreground">Budget</p>
<p class="text-lg font-semibold tabular-nums">{{ formatCost(budgetMicros) }}</p>
</div>
<div v-if="budgetMicros !== null">
<p class="text-muted-foreground">Projected run-out</p>
<p v-if="runOut === null" class="text-sm text-muted-foreground">not enough data yet</p>
<p v-else class="text-lg font-semibold tabular-nums" :class="willExceed ? 'text-destructive' : undefined">
{{ runOut.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) }}
</p>
</div>
</div>
<p v-if="budgetMicros === null" class="text-sm text-muted-foreground">No budget configured for this scope.</p>
<TimeSeriesChart
:labels="labels"
:datasets="datasets"
:comparison-datasets="comparisonDatasets"
:value-formatter="formatCost"
ariaLabel="Cumulative spend this month against the configured budget"
/>
</CardContent>
</Card>
</template>
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed } from 'vue'
import GrantForm from '@/components/forms/GrantForm.vue'
import LimitsForm from '@/components/forms/LimitsForm.vue'
import ModelMetaForm from '@/components/forms/ModelMetaForm.vue'
import PricingForm from '@/components/forms/PricingForm.vue'
import UserForm from '@/components/forms/UserForm.vue'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useNavStore } from '@/stores/nav'
/** HELPER_IDS mirrors the Config page's own `helper` hash param (redesign-plan.md section 3.1: `helper=limits|grant|pricing|model|user`) — the single source of truth for which tab is selected and which form renders. */
const HELPER_IDS = ['limits', 'grant', 'pricing', 'model', 'user'] as const
type HelperId = (typeof HELPER_IDS)[number]
const HELPER_LABEL: Record<HelperId, string> = {
limits: 'Limits',
grant: 'Access grant',
pricing: 'Pricing',
model: 'Model metadata',
user: 'New user',
}
/**
* ChangeHelper (redesign-plan.md section 3.4) is the Config page's
* snippet-building surface: five forms (LimitsForm, GrantForm,
* PricingForm, ModelMetaForm, UserForm), each producing a paste-ready
* YAML/users.json fragment via lib/snippets.ts, switched by the page's own
* `helper` hash param so a shared link opens the same tab.
*/
const props = defineProps<{
groupNames: string[]
userNames: string[]
}>()
const nav = useNavStore()
const helper = computed<HelperId>(() => {
const value = nav.params.helper
return (HELPER_IDS as readonly string[]).includes(value ?? '') ? (value as HelperId) : 'limits'
})
function setHelper(next: unknown): void {
if (typeof next !== 'string' || !(HELPER_IDS as readonly string[]).includes(next)) return
nav.goTo('config', { ...nav.params, helper: next })
}
</script>
<template>
<div class="flex flex-col gap-4">
<Tabs :model-value="helper" @update:model-value="setHelper">
<TabsList>
<TabsTrigger v-for="id in HELPER_IDS" :key="id" :value="id">{{ HELPER_LABEL[id] }}</TabsTrigger>
</TabsList>
</Tabs>
<LimitsForm v-if="helper === 'limits'" :group-names="props.groupNames" :user-names="props.userNames" />
<GrantForm v-else-if="helper === 'grant'" :group-names="props.groupNames" :user-names="props.userNames" />
<PricingForm v-else-if="helper === 'pricing'" />
<ModelMetaForm v-else-if="helper === 'model'" />
<UserForm v-else :group-names="props.groupNames" />
</div>
</template>
-281
View File
@@ -1,281 +0,0 @@
<script setup lang="ts">
import { faXmark } from '@fortawesome/free-solid-svg-icons'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import SearchInput from '@/components/SearchInput.vue'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import CsvExportButton from '@/components/CsvExportButton.vue'
import DataTable from '@/components/DataTable.vue'
import ModelUsageChart from '@/components/ModelUsageChart.vue'
import UsageChart from '@/components/UsageChart.vue'
import { useSearchQuery } from '@/composables/useSearchQuery'
import { filterModelsByPrefix } from '@/lib/model-filter'
import { modelTableColumns } from '@/lib/model-table-columns'
import { modelDetailCsv } from '@/lib/usage-csv'
import { groupMatches, userMatches } from '@/lib/usage-search'
import { useDashboardStore } from '@/stores/dashboard'
import {
type ChartTab,
MODEL_METRIC_LABEL,
MODEL_RANKING_LIMIT,
type ModelMetric,
type TimeSeriesTab,
useHistoryStore,
WINDOW_LABEL,
WINDOW_SPAN,
} from '@/stores/history'
import type { HistoryWindow } from '@/types/api'
const dashboard = useDashboardStore()
const history = useHistoryStore()
// --- user/group search filter (operator feature) ---
//
// Filters the scope picker's user/group list only — "total" always stays
// reachable (unconditionally the list's first entry, below), and the
// CURRENTLY SELECTED scope is always kept in the list even if it stops
// matching a new query: the filter narrows what's OFFERED, it must never
// force-switch (or hide the label of) whatever chart is already rendered.
// Same shared SearchInput/useSearchQuery and lib/usage-search.ts matching
// helpers as UsageView.vue — one implementation, no second copy.
const { query: scopeQuery, normalized: normalizedScopeQuery, hasQuery: hasScopeQuery } = useSearchQuery()
const scopeOptions = computed(() => {
const allUsers = dashboard.usage?.users ?? []
const allGroups = dashboard.usage?.groups ?? []
const users = allUsers
.filter((u) => !hasScopeQuery.value || userMatches(u, normalizedScopeQuery.value) || `user:${u.id}` === history.scope)
.map((u) => ({ value: `user:${u.id}`, label: u.id }))
const groups = allGroups
.filter(
(g) =>
!hasScopeQuery.value || groupMatches(g, allUsers, normalizedScopeQuery.value) || `group:${g.id}` === history.scope,
)
.map((g) => ({ value: `group:${g.id}`, label: g.id }))
// Models come from the ranking endpoint, which returns only models with
// non-zero traffic (operator requirement: the catalog is hundreds of
// models, so the picker must never list idle ones). Matched on the plain
// id rather than through lib/usage-search's helpers — those match a
// user's or group's own shape, and a model entry has neither.
const models = (history.modelOptions ?? [])
.filter(
(m) =>
!hasScopeQuery.value ||
m.id.toLowerCase().includes(normalizedScopeQuery.value) ||
`model:${m.id}` === history.scope,
)
.map((m) => ({ value: `model:${m.id}`, label: m.id }))
// A model already being charted stays in the list even once it drops out
// of modelOptions entirely — which happens the moment it goes idle for
// the selected window, since that list is non-zero-only. Without this,
// the watch below would read it as a vanished scope and force-switch the
// reader to "total" mid-look, for the ordinary reason that a model
// simply stopped receiving traffic.
if (history.scope.startsWith('model:') && !models.some((m) => m.value === history.scope)) {
models.push({ value: history.scope, label: history.scope.slice('model:'.length) })
}
return [{ value: 'total', label: 'Total (all traffic)' }, ...groups, ...users, ...models]
})
const windows: HistoryWindow[] = ['hour', 'day', 'month']
const modelMetrics: ModelMetric[] = ['cost', 'req', 'tokin', 'tokout']
/**
* modelColumns is a stateless ColumnDef set (lib/model-table-columns.ts),
* built once rather than per-render — the same convention
* TargetsView.vue's own `columns` constant follows for its identical
* reason (no per-row closures capture component state, so there is
* nothing to rebuild).
*/
const modelColumns = modelTableColumns()
// --- Models-tab prefix filter (F6, dashboard-plan.md) ---
//
// history.modelFilter is set by ProvidersView.vue's provider-header link
// (nav.goToModels(`${p.name}/`), stores/nav.ts — WP-B1) or restored from
// the `#charts?tab=models&filter=...` hash (lib/hash-state.ts). P3 review
// fix: setModelFilter now also sends the prefix to the SERVER and
// refetches (stores/history.ts's own state doc comment on modelFilter),
// so this re-slice is a harmless second pass over an already-narrowed
// ranking, not the only filtering step — see lib/model-filter.ts's own
// doc comment.
const filteredModelRanking = computed(() => filterModelsByPrefix(history.modelRanking, history.modelFilter))
/**
* Exports exactly the ranking rows on screen (filter applied, server order
* kept). free-models-plan.md: every ranking fetch now requests detail=1
* (stores/history.ts's fetchModelRanking), so the export carries every
* per-row figure (requests/tokens in/tokens out/cost/free) via
* modelDetailCsv, not just the single metric currently being ranked by —
* matching the on-screen table (lib/model-table-columns.ts) exactly,
* regardless of which metric history.modelMetric happens to be.
*/
function modelsCsv(): string {
return modelDetailCsv(filteredModelRanking.value, WINDOW_LABEL[history.window], WINDOW_SPAN[history.window])
}
function clearModelFilter(): void {
history.setModelFilter('')
}
/**
* UsageChart only ever renders on a time-series tab (the template's v-else
* below), but a `v-else` narrows nothing for the type checker — this does.
* The 'requests' stand-in is never displayed: it is the value handed over
* on the one tab where the component is not rendered at all.
*/
const timeSeriesTab = computed<TimeSeriesTab>(() => (history.tab === 'models' ? 'requests' : history.tab))
function onTabChange(value: string | number): void {
history.setTab(value as ChartTab)
}
function onScopeChange(value: unknown): void {
if (typeof value === 'string') history.setScope(value)
}
function onModelMetricChange(value: unknown): void {
if (typeof value === 'string') history.setModelMetric(value as ModelMetric)
}
onMounted(() => {
// The picker's model list is fetched here (initial load) and again on
// any window change (history.ts's own setWindow) — nowhere else (Q1,
// dashboard-plan.md DECISIONS). The selection's own series/ranking
// fetch no longer needs a separate call here: startAutoRefresh's poller
// (lib/polling.ts) ticks once immediately on start, which is that
// initial fetch.
void history.fetchModelOptions()
history.startAutoRefresh()
})
onUnmounted(() => {
history.stopAutoRefresh()
})
// The scope list is only known once the Usage response has loaded — if the
// previously-selected scope disappears (a user/group config change), fall
// back to the total scope rather than requesting a now-unknown id. This is
// ALSO what guards a bogus scope restored from a hand-edited URL hash (F9,
// dashboard-plan.md: lib/hash-state.ts's parseChartsParams only validates
// SHAPE — "total"/"user:.+"/"group:.+"/"model:.+" — never that the id
// actually exists): `watch` without `{ immediate: true }` skips its first
// evaluation, so a hash-restored scope like "user:ghost" survives the
// (likely still-empty, pre-fetch) initial scopeOptions and is only judged
// once dashboard.usage genuinely loads and this computed's value actually
// changes — at which point a truly nonexistent id resets to 'total' here,
// exactly like any other vanished scope.
watch(scopeOptions, (options) => {
if (!options.some((o) => o.value === history.scope)) {
history.setScope('total')
}
})
</script>
<template>
<Card>
<CardHeader class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Usage charts</CardTitle>
<div class="flex flex-wrap items-center gap-3">
<!-- The Models tab has no scope picker to filter (its own Select branch below is the ranking-metric picker instead) — a visible search box that silently does nothing is worse than no box at all. -->
<SearchInput
v-if="history.tab !== 'models'"
v-model="scopeQuery"
placeholder="Filter users, groups or models"
class="w-56"
/>
<Select
v-if="history.tab === 'models'"
:model-value="history.modelMetric"
@update:model-value="onModelMetricChange"
>
<SelectTrigger class="w-40" aria-label="Rank models by">
<SelectValue placeholder="Rank by" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="m in modelMetrics" :key="m" :value="m">
{{ MODEL_METRIC_LABEL[m] }}
</SelectItem>
</SelectContent>
</Select>
<Select v-else :model-value="history.scope" @update:model-value="onScopeChange">
<SelectTrigger class="w-56">
<SelectValue placeholder="Scope" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="opt in scopeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
<Tabs :model-value="history.window" @update:model-value="(v) => history.setWindow(v as HistoryWindow)">
<TabsList>
<TabsTrigger v-for="w in windows" :key="w" :value="w">{{ WINDOW_LABEL[w] }}</TabsTrigger>
</TabsList>
</Tabs>
</div>
</CardHeader>
<CardContent class="flex flex-col gap-4">
<Tabs :model-value="history.tab" @update:model-value="onTabChange">
<TabsList>
<TabsTrigger value="requests">Requests</TabsTrigger>
<TabsTrigger value="tokens">Tokens</TabsTrigger>
<TabsTrigger value="cost">Cost</TabsTrigger>
<TabsTrigger value="models">Models</TabsTrigger>
</TabsList>
</Tabs>
<p v-if="history.error" class="text-sm text-destructive">{{ history.error }}</p>
<!-- Only until the current selection's first fetch lands (history.loaded) — background refreshes never flash it over rendered data. -->
<p v-else-if="history.loading && !history.loaded" class="text-sm text-muted-foreground">Loading…</p>
<template v-if="history.tab === 'models'">
<p class="text-sm text-muted-foreground">
Models with usage over the last {{ WINDOW_LABEL[history.window] }}, ranked by
{{ MODEL_METRIC_LABEL[history.modelMetric].toLowerCase() }}. Each row is the provider that served the
traffic, so a model reached through more than one provider appears once per provider.
</p>
<p v-if="history.modelFilter" class="flex items-center gap-1.5 text-sm">
<span class="text-muted-foreground">Filtered to</span>
<span class="font-mono text-xs">{{ history.modelFilter }}</span>
<Button variant="ghost" size="icon-xs" aria-label="Clear model filter" @click="clearModelFilter">
<FontAwesomeIcon :icon="faXmark" class="size-3.5" aria-hidden="true" />
</Button>
</p>
<!-- P3: the ranking is a top-N (MODEL_RANKING_LIMIT), never the complete set — say so whenever the fetched ranking is exactly that long, so a full list doesn't silently read as "this is everything". -->
<p v-if="history.modelRanking.length === MODEL_RANKING_LIMIT" class="text-xs text-muted-foreground">
Top {{ MODEL_RANKING_LIMIT }} models
</p>
<ModelUsageChart :metric="history.modelMetric" :models="filteredModelRanking" />
<!--
free-models-plan.md: the per-model detail table beneath the
chart, with a per-row Requests/Tokens in/Tokens out/Cost
breakdown (a free model shows a "free" badge instead of $0 —
lib/model-table-columns.ts). Guarded by the same
filteredModelRanking.length check ModelUsageChart's own empty
state already uses above, so an empty window shows exactly ONE
"no model recorded any usage" message, not a second, empty
table stacked under it.
-->
<DataTable
v-if="filteredModelRanking.length"
:columns="modelColumns"
:data="filteredModelRanking"
empty-message="No model recorded any usage in this window."
/>
<div class="flex justify-end">
<CsvExportButton :filename="`usage-models-${history.window}.csv`" :build="modelsCsv" />
</div>
</template>
<UsageChart v-else :tab="timeSeriesTab" :window="history.window" :series-by-metric="history.seriesByMetric" />
</CardContent>
</Card>
</template>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { dayOrMonthClampNotice } from '@/lib/range'
import type { HistoryWindow } from '@/types/api'
/**
* ClampedRangeNotice (N3 fix, verify-redesign-final.md) is the ONE shared
* "this view silently clamped your range" banner for every day/month-only
* endpoint reached through lib/range.ts's dayOrMonthWindow:
* - UserDetail.vue's "Models used" table (usermodel totals)
* - AttributionDrilldown.vue's usermodel level (the Spend drilldown)
* - TargetCallers.vue (targetcaller totals)
* An hour-resolution global range (24h/48h) has no day/month equivalent
* for any of these — dayOrMonthWindow silently substitutes a fixed
* 7-day day-window instead, and this renders nothing (dayOrMonthClampNotice
* returns null) unless that substitution actually happened, so a
* day/month-window reader never sees a spurious banner.
*
* Distinct from ReliabilityPage.vue's own inline month-clamp Alert — a
* DIFFERENT clamp (hour/day -> a fixed 30-day window, stores/
* reliability.ts's hourOrDayWindow) that stays its own local computed,
* not this component.
*/
const props = defineProps<{
window: HistoryWindow
span: number
}>()
const note = computed<string | null>(() => dayOrMonthClampNotice(props.window, props.span))
</script>
<template>
<Alert v-if="note" variant="warn">
<AlertDescription>{{ note }}</AlertDescription>
</Alert>
</template>
+2 -2
View File
@@ -9,8 +9,8 @@ import { formatCompactCount, formatExactInt } from '@/lib/format'
* (mouse hover) and a visually-hidden sibling span (keyboard/screen-
* reader parity). Used anywhere a usage count renders: DataTable numeric
* columns (usage-columns.ts, target-columns.ts, via
* `h(CompactNumber, { value })`), the Usage tab's Total card, and a
* group accordion's trigger/detail stat grid (UsageView.vue).
* `h(CompactNumber, { value })`), the Consumers page's Total row, and a
* group accordion's trigger/detail stat grid (ConsumerDirectory.vue).
*
* Review fix (folded minor, ARIA): `aria-label` on a plain `<span>` is
* an axe `aria-prohibited-attr` violation — a `<span>`'s implicit ARIA
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed } from 'vue'
import SnippetBlock from '@/components/SnippetBlock.vue'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { emitYamlDocument, toYamlValue } from '@/lib/yaml-emit'
import type { YamlValue } from '@/lib/yaml-emit'
/**
* ConfigTree (redesign-plan.md section 3.4) renders GET /admin/api/config's
* already-redacted `config` (admin_config.go's redactConfig — every
* apiKey/password literal replaced with "[redacted]", an "env:NAME"/
* "file:/path" indirection kept, every baseUrl/url stripped of userinfo)
* as read-only YAML via lib/yaml-emit.ts's generic recursive renderer —
* safe to render as-is, never re-sanitized client-side (types/api.ts's
* AdminConfigResponse doc comment).
*/
const props = defineProps<{
config: Record<string, unknown>
warnings: string[]
}>()
const yamlText = computed(() => {
const value = toYamlValue(props.config) as Record<string, YamlValue | undefined>
return emitYamlDocument(value)
})
</script>
<template>
<div class="flex flex-col gap-4">
<Alert v-for="(warning, index) in warnings" :key="index" variant="warn">
<AlertTitle>Config warning</AlertTitle>
<AlertDescription>{{ warning }}</AlertDescription>
</Alert>
<SnippetBlock label="Redacted running config (read-only)" :text="yamlText" />
</div>
</template>
@@ -2,13 +2,11 @@
import type { SortingState } from '@tanstack/vue-table'
import { getCoreRowModel, getSortedRowModel, useVueTable } from '@tanstack/vue-table'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import CompactNumber from '@/components/CompactNumber.vue'
import CostForecastCard from '@/components/CostForecastCard.vue'
import CsvExportButton from '@/components/CsvExportButton.vue'
import ModelChip from '@/components/ModelChip.vue'
import ScopeLink from '@/components/ScopeLink.vue'
import SearchInput from '@/components/SearchInput.vue'
import SortHeaderButton from '@/components/SortHeaderButton.vue'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
@@ -19,7 +17,6 @@ import UsageBar from '@/components/UsageBar.vue'
import UsageTable from '@/components/UsageTable.vue'
import { useNow } from '@/composables/useNow'
import { useSearchQuery } from '@/composables/useSearchQuery'
import { useNavStore } from '@/stores/nav'
import { headroom, monthProgress, projectMonthEnd } from '@/lib/forecast'
import { formatCost, formatExactInt, formatLimits } from '@/lib/format'
import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand'
@@ -27,19 +24,37 @@ import { BUDGET_RATIO_LABEL, type BudgetRatio, budgetRatios } from '@/lib/usage-
import { usageColumns } from '@/lib/usage-columns'
import { usageCsv } from '@/lib/usage-csv'
import { groupMatches, groupNameMatches, matchingMembersOfGroup, membersOfGroup, userMatches } from '@/lib/usage-search'
import { useConsumersStore } from '@/stores/consumers'
import { useDashboardStore } from '@/stores/dashboard'
import { useNavStore } from '@/stores/nav'
import type { AdminUsageEntryView } from '@/types/api'
/**
* ConsumerDirectory (redesign-plan.md section 3.4) is the Consumers page's
* default view: the Users/Groups usage-and-access directory that used to
* be UsageView.vue's whole tab, moved here verbatim (the Groups accordion
* + its access block included, per the coordinator's own instruction) with
* three redesign-era changes: nav.usageQuery -> the page's own `q` hash
* param (stores/nav.ts no longer special-cases a search string), the id
* cell's ScopeLink -> EntityLink (usage-columns.ts, opens this row's own
* UserDetail instead of jumping to the old flat Charts tab), and a new
* Source column (inline/file, GET /admin/api/consumers) on the flat Users
* table only.
*/
const dashboard = useDashboardStore()
const consumers = useConsumersStore()
const nav = useNavStore()
const usage = computed(() => dashboard.usage)
// The one shared reactive clock every time-dependent read on this tab uses
onMounted(() => {
void consumers.ensureConsumers()
})
// The one shared reactive clock every time-dependent read on this page uses
// (P1, composables/useNow.ts's own doc comment) — CostForecastCard's own
// elapsedFraction, usageColumns()'s `now` parameter (both the real Users/
// Members <table>s below and the headless Groups toolbar's groupColumns),
// and groupCostProjection all read THIS ref, never their own `new Date()`,
// so a long-open tab's cost projections advance together instead of
// freezing at whatever instant each one happened to first run.
// and groupCostProjection all read THIS ref, never their own `new Date()`.
const { now, start: startClock, stop: stopClock } = useNow()
onMounted(startClock)
onUnmounted(stopClock)
@@ -60,25 +75,24 @@ function memberCountOf(entry: AdminUsageEntryView): string {
return count === undefined ? '' : String(count)
}
// --- user/group search filter (operator feature, mirrors
// ProvidersView.vue's model search via the shared SearchInput component and
// useSearchQuery composable — same styling, same clear-button behavior.
// Matching logic lives in lib/usage-search.ts, which ChartsView.vue's own
// scope-picker filter calls directly too (groupMatches, userMatches) — no
// second copy of "does this user/group match?") ---
// Bound to nav.usageQuery so the search text round-trips through the URL hash (#usage?q=…, lib/hash-state.ts).
const { usageQuery } = storeToRefs(useNavStore())
/** sourceOf (Consumers redesign) joins a usage entry's id against GET /admin/api/consumers' own users list — 'inline' or 'file' (admin.go: adminConsumerUser.Source), undefined until that on-demand fetch resolves or for an id it has no match for (a group row never has one). Passed to usage-columns.ts's usageColumns() as `sourceValue` — see that module's sourceColumn doc comment. */
function sourceOf(entry: AdminUsageEntryView): string | undefined {
return consumers.data?.users.find((u) => u.name === entry.id)?.source
}
// --- user/group search filter (operator feature) ---
// Matching logic lives in lib/usage-search.ts. Bound to nav.params.q so
// the search text round-trips through the URL hash (#consumers?q=...,
// lib/hash-state.ts) — stores/nav.ts's own `params` is opaque, so this
// page owns interpreting/writing its own `q` key directly, rather than
// the old nav.usageQuery special case.
const usageQuery = computed<string>({
get: () => nav.params.q ?? '',
set: (value) => nav.goTo('consumers', { ...nav.params, q: value }),
})
const { query: userQuery, normalized: normalizedQuery, hasQuery } = useSearchQuery(usageQuery)
/**
* filteredGroups is every group matching the query — own name OR a
* matching member (lib/usage-search.ts's groupMatches, binding semantics)
* — or every group when there is no query. This feeds groupsTable's
* `data()` below directly, so filtering happens upstream of sorting: the
* Groups sort toolbar and the Accordion both operate on the same
* (possibly narrowed) list, no separate filtering pass downstream of the
* table.
*/
/** filteredGroups is every group matching the query — own name OR a matching member (lib/usage-search.ts's groupMatches) — or every group when there is no query. */
const filteredGroups = computed<AdminUsageEntryView[]>(() => {
const all = usage.value?.groups ?? []
if (!hasQuery.value) return all
@@ -86,101 +100,46 @@ const filteredGroups = computed<AdminUsageEntryView[]>(() => {
return all.filter((g) => groupMatches(g, users, normalizedQuery.value))
})
/**
* memberOnlyMatchIds is filteredGroups narrowed to groups that matched
* ONLY via a member, never via their own name — the auto-expand target
* set (search-expand.ts's computeExpandedItems `matchingIds`).
*
* This is a DELIBERATE divergence from ProvidersView.vue, not an
* oversight, and NOT because Providers lacks a provider-name match —
* it doesn't lack one: providerMatches there (ProvidersView.vue) checks
* each model's full ROUTABLE id, `${provider}/${model}`
* (lib/format.ts's routableModelId), so a query like "openai" matches
* every "openai/..." model id and the whole provider auto-expands with
* every one of them showing. That is correct there — a provider's own
* model list IS what the search is for, so revealing it is the point,
* and EVERY provider match (name-flavored or not) auto-expands.
*
* Groups differ: a group's own name is already fully legible on its
* COLLAPSED trigger row (the `<AccordionTrigger>` below) — no expansion
* needed to read a name match. Auto-expanding every name-matched group too
* would dump every one of their full member rosters onto the screen for a
* query that only needed the trigger row to already be visible. A member-ONLY
* match has no such shortcut — the member that matched stays invisible
* until the group opens — so that case alone still auto-expands.
* search-expand.ts itself has no opinion on any of this; it is purely
* this view's own choice of matchingIds.
*/
/** memberOnlyMatchIds is filteredGroups narrowed to groups that matched ONLY via a member, never via their own name — the auto-expand target set (search-expand.ts's computeExpandedItems `matchingIds`). A group's own name is already legible on its collapsed trigger row, so only a member-only match needs auto-expansion to reveal what matched. */
const memberOnlyMatchIds = computed<string[]>(() => {
if (!hasQuery.value) return []
return filteredGroups.value.filter((g) => !groupNameMatches(g, normalizedQuery.value)).map((g) => g.id)
})
/**
* visibleMembers implements the members-table distinction (binding
* semantics): a group matched by its OWN name shows every member
* (membersOfGroup); a group that matched only via a member shows only
* the matching ones — the exported, tested matchingMembersOfGroup
* helper (lib/usage-search.ts), not a re-filter of userMatches here. No
* active query behaves like a name match — show everyone, today's
* behavior unchanged. Replaces the former local membersOf helper.
*/
/** visibleMembers: a group matched by its OWN name shows every member; a group that matched only via a member shows only the matching ones. */
function visibleMembers(group: AdminUsageEntryView): AdminUsageEntryView[] {
const users = usage.value?.users ?? []
if (!hasQuery.value || groupNameMatches(group, normalizedQuery.value)) return membersOfGroup(group, users)
return matchingMembersOfGroup(group, users, normalizedQuery.value)
}
/** groupsEmptyMessage mirrors ProvidersView.vue's aliasEmptyMessage three-way pattern: distinguishes nothing configured from nothing matching the active query, so an empty Groups card never reads the same regardless of why it's empty. */
const groupsEmptyMessage = computed(() =>
!usage.value?.groups.length ? 'none configured' : hasQuery.value ? `no groups match "${userQuery.value}"` : 'none',
)
/**
* filteredUsers is every user matching the query directly, PLUS every
* member of a group that matched by NAME (semantics item 5: "a
* group-name match includes all of that group's member rows"). A group
* that matched only via ONE member must not pull its other, non-matching
* members into this flat table too — that narrower distinction is
* visibleMembers' job, scoped to the nested table inside an expanded
* group, not this one.
*/
/** filteredUsers is every user matching the query directly, PLUS every member of a group that matched by NAME. */
const filteredUsers = computed<AdminUsageEntryView[]>(() => {
const all = usage.value?.users ?? []
if (!hasQuery.value) return all
const groups = usage.value?.groups ?? []
const nameMatchedGroupIds = new Set(
groups.filter((g) => groupNameMatches(g, normalizedQuery.value)).map((g) => g.id),
)
const nameMatchedGroupIds = new Set(groups.filter((g) => groupNameMatches(g, normalizedQuery.value)).map((g) => g.id))
return all.filter(
(u) =>
userMatches(u, normalizedQuery.value) ||
(u.groups ?? [u.groupName]).some((g) => g !== undefined && nameMatchedGroupIds.has(g)),
(u) => userMatches(u, normalizedQuery.value) || (u.groups ?? [u.groupName]).some((g) => g !== undefined && nameMatchedGroupIds.has(g)),
)
})
/** usersEmptyMessage mirrors groupsEmptyMessage's three-way pattern (see its own doc comment). */
const usersEmptyMessage = computed(() =>
!usage.value?.users.length ? 'none configured' : hasQuery.value ? `no users match "${userQuery.value}"` : 'none',
)
// --- Groups accordion (operator directive) ---
// --- Groups accordion ---
//
// Groups render as a shadcn-vue Accordion, same component and rationale
// as Providers (ProvidersView.vue) — expanding a group shows its member
// users' own usage rows below it. A group's row cannot ALSO be a literal
// <table> row for the identical reason Providers' old expandable row was
// retired: AccordionItem wraps its trigger+content in a <div>, illegal
// directly inside a <tbody>/<tr>.
//
// "Sortable groups usage" (operator directive) is therefore implemented
// headlessly: the SAME usageColumns() defs used by the real <table>-based
// UsageTable (Users, and each group's own nested member list) drive a
// second, unrendered useVueTable instance here purely for its
// getSortedRowModel() — sortedGroupRows below. The small button toolbar
// under the Groups heading toggles that sort using the identical
// SortHeaderButton DataTable.vue's real <th>s use (same icons, same
// toggleSorting call, same accessible name), just laid out as a toolbar
// instead of <th>s, because there is no <table> here to put a <th> in.
// Groups render as a shadcn-vue Accordion — expanding a group shows its
// member users' own usage rows below it. "Sortable groups usage" is
// implemented headlessly: the SAME usageColumns() defs the real <table>-
// based UsageTable uses drive a second, unrendered useVueTable instance
// here purely for its getSortedRowModel() — sortedGroupRows below. The
// small button toolbar under the Groups heading toggles that sort using
// the identical SortHeaderButton DataTable.vue's real <th>s use.
const groupColumns = computed(() => usageColumns('Name', 'Members', memberCountOf, now.value))
const groupSorting = ref<SortingState>([])
const groupsTable = useVueTable({
@@ -201,36 +160,16 @@ const groupsTable = useVueTable({
})
const sortedGroupRows = computed(() => groupsTable.getRowModel().rows)
/**
* TOOLBAR_COLUMN_IDS restricts the toolbar to exactly the fields each
* trigger row displays (Name, Members, req/day, cost/day) — review fix:
* exposing all 11 usageColumns() fields as sortable when only 4 are
* visible per row let the accordion's order silently diverge from what
* the toolbar visibly claims to control. The full stat set stays
* available inside each expanded item's own detail grid, just not as a
* sort key here.
*/
/** TOOLBAR_COLUMN_IDS restricts the toolbar to exactly the fields each trigger row displays (Name, Members, req/day, cost/day) — the full stat set stays available inside each expanded item's own detail grid, just not as a sort key here. */
const TOOLBAR_COLUMN_IDS = new Set(['id', 'secondary', 'reqDay', 'costDay'])
const toolbarHeaders = computed(() =>
groupsTable.getHeaderGroups()[0].headers.filter((header) => TOOLBAR_COLUMN_IDS.has(header.column.id)),
)
const toolbarHeaders = computed(() => groupsTable.getHeaderGroups()[0].headers.filter((header) => TOOLBAR_COLUMN_IDS.has(header.column.id)))
/**
* Which groups are open — driven by the SAME tested lib/search-expand.ts
* state ProvidersView.vue uses for providers (generalized from
* provider-expand.ts specifically so this view could reuse it, rather
* than fork a second copy — see that module's own doc comment). The
* adapter shape is the identical get/set computed pattern
* expandedProviderValues uses there, translating the Set-based effective
* state into the string[] Accordion's `type="multiple"` v-model expects.
*/
/** Which groups are open — driven by the shared tested lib/search-expand.ts state. */
const groupExpandState: ExpandState = reactive({
manuallyExpanded: new Set<string>(),
manuallyCollapsed: new Set<string>(),
})
const expandedGroups = computed<Set<string>>(() =>
computeExpandedItems(groupExpandState, hasQuery.value, memberOnlyMatchIds.value),
)
const expandedGroups = computed<Set<string>>(() => computeExpandedItems(groupExpandState, hasQuery.value, memberOnlyMatchIds.value))
function toggleGroup(id: string): void {
toggleItemExpand(groupExpandState, id, expandedGroups.value.has(id))
}
@@ -249,74 +188,26 @@ const expandedGroupValues = computed<string[]>({
// A stale manuallyCollapsed suppression from one search must never
// silently carry into a later, unrelated one — see ExpandState's own doc
// comment. Watching userQuery (not just the clear button) covers
// backspacing to empty too — same convention as ProvidersView.vue.
// comment.
watch(userQuery, (value) => {
if (value.trim() === '') clearExpandOverrides(groupExpandState)
})
// --- Access block (group-access-display task, review round) ---
//
// GLOB_HINT is the title attribute on every Access label (Providers,
// Models, MCP servers, Agents): group.allowsX (auth.go) matches every one
// of these four configured lists via matchesGlob/path.Match semantics, not
// literal equality — a heading that just says "Models" leaves that
// invisible. One constant, not four copies of the same string (vue.md:
// "if you've written it twice, you owe an abstraction").
// --- Access block (moved from UsageView.vue, unchanged) ---
const GLOB_HINT = 'Glob pattern — matches like a shell wildcard (*, ?, [...])'
/**
* isGlobPattern reports whether one configured models entry is a glob
* pattern rather than a literal model id — the same special characters
* path.Match recognizes (auth.go's matchesGlob, which group.allowsModel
* calls). Only Models needs this distinction rendered differently: a
* literal id gets the copyable ModelChip treatment (it IS a routable id a
* caller can paste into a request's `model` field); a pattern like
* "gpt-4*" is not a valid id and must not invite copying it as one, so it
* renders as a plain, non-copyable Badge instead (see the template below).
* Providers/MCPServers/Agents render every entry as a plain Badge either
* way, so they need no such split.
*/
/** isGlobPattern reports whether one configured models entry is a glob pattern rather than a literal model id — the same special characters path.Match recognizes (auth.go's matchesGlob). */
function isGlobPattern(entry: string): boolean {
return /[*?[]/.test(entry)
}
// --- F1 usage-vs-limit bars (group detail grid) ---
//
// usage-columns.ts's own numericColumn already renders a bar beside each
// TABLE cell that has a configured limit (Users table, and — reused — the
// Members table inside an expanded group). A group's own accordion detail
// grid (the dl/dd block below, distinct from its nested Members table) has
// no per-cell numeric columns at all, so it renders budgetRatios directly
// as one compact row of labeled bars instead — the coordinator brief's own
// phrasing ("Group detail grid ... renders bars from budgetRatios").
// BUDGET_RATIO_LABEL (P10/P11 review fix): imported from lib/usage-bars.ts
// — this used to be a second, independently-declared copy here (BUDGET_LABEL)
// with slightly different text ("tok/day" here vs usage-columns.ts's own
// bar label), the exact DRY drift the coordinator's review flagged. The one
// shared definition also spells out "tokens (in+out)/day" (P10) for the
// tokDay/tokMonth ids — accurate for a screen-reader user, who has no OTHER
// column beside this compact grid's own labeled bar to infer the combined-
// total measurement from.
/** budgetValueText renders one BudgetRatio's "used / limit" detail text (UsageBar's aria-valuetext/title) — cost ids in dollars (formatCost, the micro-USD unit budgetRatios already computed them in), every other id as a plain thousands-separated count. */
/** budgetValueText renders one BudgetRatio's "used / limit" detail text (UsageBar's aria-valuetext/title). */
function budgetValueText(ratio: BudgetRatio): string {
if (ratio.id.startsWith('cost')) return `${formatCost(ratio.used)} / ${formatCost(ratio.limit)}`
return `${formatExactInt(ratio.used)} / ${formatExactInt(ratio.limit)}`
}
/**
* groupCostProjection (F7) mirrors usage-columns.ts's own costMonthProjColumn
* math for one group row — the coordinator brief's own phrasing ("also in
* group grid"): the group detail dl/dd block is not a DataTable column, so
* it cannot reuse that column's ColumnDef cell renderer directly, but the
* underlying lib/forecast.ts math (monthProgress/projectMonthEnd/headroom)
* is identical. `now` is the shared clock (useNow, above) — the SAME
* instant every other time-dependent read on this tab uses — not a fresh
* `new Date()` read per call, so this grid's own projection never disagrees
* with CostForecastCard's or a column's mid-render.
*/
/** groupCostProjection mirrors usage-columns.ts's own costMonthProjColumn math for one group row. */
function groupCostProjection(entry: AdminUsageEntryView, now: Date): { text: string; willExceed: boolean } {
const elapsedFraction = monthProgress(now)
const projected = projectMonthEnd(entry.costPerMonthMicroUsd, elapsedFraction)
@@ -324,22 +215,8 @@ function groupCostProjection(entry: AdminUsageEntryView, now: Date): { text: str
return { text: formatCost(projected), willExceed: headroom(projected, entry.limits?.costPerMonthUSD).willExceed }
}
// --- F8 CSV export ---
//
// Each button exports whatever rows are CURRENTLY visible: Groups export
// in the Groups toolbar's own sort order (sortedGroupRows, already
// computed below for the headless sort toolbar); Users export in the
// Users <table>'s own CURRENT column sort (P6 review fix — this used to
// export in filtered order alone, a self-declared deviation from the plan's
// "exports filtered+sorted rows"). The Users DataTable's sort state is
// LIFTED UP here via DataTable.vue's `v-model:sorting` (that component's
// own doc comment) — usersSorting below is bound two ways: down into the
// rendered UsageTable (template) so a header click writes back here, and
// into a second, headless useVueTable instance (usersTable) that applies
// the identical sort to filteredUsers so usersCsv can read it back in the
// same order the reader is actually looking at — the same "one sort state,
// two consumers" shape sortedGroupRows/groupsTable already used for Groups.
const usersColumns = computed(() => usageColumns('Name', 'Group', groupNameOf, now.value))
// --- CSV export ---
const usersColumns = computed(() => usageColumns('Name', 'Group', groupNameOf, now.value, sourceOf))
const usersSorting = ref<SortingState>([])
const usersTable = useVueTable({
get data() {
@@ -391,7 +268,6 @@ function groupsCsv(): string {
<p class="text-lg font-semibold tabular-nums"><CompactNumber :value="usage.total.requestsPerDay" /></p>
</div>
<div>
<!-- F4: fleet-wide requests denied by a limit check today (types/api.ts's own doc comment) — destructive tint once positive, mirroring usage-columns.ts's own rejDay column convention. -->
<p class="text-muted-foreground">Rejected/day</p>
<p class="text-lg font-semibold tabular-nums" :class="usage.total.rejectionsPerDay > 0 ? 'text-destructive' : undefined">
<CompactNumber :value="usage.total.rejectionsPerDay" />
@@ -412,7 +288,6 @@ function groupsCsv(): string {
</CardContent>
</Card>
<!-- F7: fleet-wide cost forecast, directly under the Total card (coordinator brief). -->
<CostForecastCard v-if="usage" :mtd-micros="usage.total.costPerMonthMicroUsd" :limits="usage.total.limits" :now="now" />
<Card>
@@ -422,39 +297,18 @@ function groupsCsv(): string {
<CardTitle>Groups</CardTitle>
<CardDescription>Expand a group to see its member users' own usage.</CardDescription>
</div>
<CsvExportButton filename="usage-groups.csv" :build="groupsCsv" />
<CsvExportButton filename="consumers-groups.csv" :build="groupsCsv" />
</div>
</CardHeader>
<CardContent>
<div
v-if="sortedGroupRows.length"
class="mb-3 flex flex-wrap gap-x-4 gap-y-1 text-xs font-medium text-muted-foreground"
>
<div v-if="sortedGroupRows.length" class="mb-3 flex flex-wrap gap-x-4 gap-y-1 text-xs font-medium text-muted-foreground">
<SortHeaderButton v-for="header in toolbarHeaders" :key="header.id" :header="header" />
</div>
<Accordion v-if="sortedGroupRows.length" v-model="expandedGroupValues" type="multiple" class="rounded-md border px-3">
<AccordionItem v-for="row in sortedGroupRows" :key="row.original.id" :value="row.original.id">
<!--
P2 review fix: ScopeLink moved OUTSIDE AccordionTrigger — a
real interactive element can never be a valid descendant of
another interactive element (AccordionTrigger's own <button>,
reka-ui), so it is now a sibling in this header row instead
of nested inside the trigger's own slot. `py-2.5` matches
AccordionTrigger.vue's own vertical padding so the two line
up, keeping the same visual row the nested version had.
`header-class="flex-1"` fills the AccordionHeader (<h3>) that
wraps the trigger button, since AccordionTrigger's own
`class` prop lands on the inner button, not the header — see
AccordionTrigger.vue's doc comment. Without it the header
shrinks to content width, so the chevron drifts left of the
row's right edge and the empty space to its right stops
being clickable. The sr-only span gives the toggle button an
accessible name that names the group, since the visible text
beside it is just counts.
-->
<div class="flex items-center gap-2">
<ScopeLink class="shrink-0 py-2.5 font-medium" :label="row.original.id" :scope="`group:${row.original.id}`" />
<span class="shrink-0 py-2.5 font-medium">{{ row.original.id }}</span>
<AccordionTrigger header-class="flex-1">
<span class="sr-only">Group {{ row.original.id }} details</span>
<span class="flex flex-1 flex-wrap items-center gap-x-4 gap-y-1 pr-2 text-left">
@@ -470,16 +324,6 @@ function groupsCsv(): string {
</AccordionTrigger>
</div>
<AccordionContent>
<!--
Access block (group-access-display task): the group's
configured provider/model/MCP-server/agent access,
display-only — not part of the user/group search filter
above (row.original.providers/models/mcpServers/agents are
plain display data, never touched by groupMatches/
userMatches in lib/usage-search.ts). Same dl/dt/dd
convention as the stat grid immediately below — one
convention per panel.
-->
<dl class="mb-3 grid grid-cols-2 gap-x-6 gap-y-1.5 text-sm sm:grid-cols-4">
<div>
<dt class="text-xs text-muted-foreground" :title="GLOB_HINT">Providers</dt>
@@ -531,13 +375,7 @@ function groupsCsv(): string {
<dt class="text-xs text-muted-foreground" :title="GLOB_HINT">Agents</dt>
<dd>
<div v-if="row.original.agents?.length" class="flex flex-wrap gap-1.5">
<Badge
v-for="agentName in row.original.agents"
:key="agentName"
as="span"
variant="secondary"
class="font-mono font-normal"
>
<Badge v-for="agentName in row.original.agents" :key="agentName" as="span" variant="secondary" class="font-mono font-normal">
{{ agentName }}
</Badge>
</div>
@@ -597,7 +435,6 @@ function groupsCsv(): string {
<dd class="tabular-nums">{{ row.original.storeDown ? '?' : formatCost(row.original.costPerMonthMicroUsd) }}</dd>
</div>
<div>
<!-- F7: projected month-end cost, extrapolated from this group's own month-to-date spend (mirrors usage-columns.ts's costMonthProj column). -->
<dt class="text-xs text-muted-foreground">cost/month (proj.)</dt>
<dd
class="tabular-nums"
@@ -608,7 +445,6 @@ function groupsCsv(): string {
</dd>
</div>
</dl>
<!-- F1: usage-vs-limit bars, one per configured limit — budgetRatios (lib/usage-bars.ts) returns nothing for an unlimited group or a storeDown one, so this whole row is simply absent then. -->
<div v-if="budgetRatios(row.original).length" class="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1.5">
<span
v-for="ratio in budgetRatios(row.original)"
@@ -626,6 +462,7 @@ function groupsCsv(): string {
:entries="visibleMembers(row.original)"
:secondary-value="groupNameOf"
:now="now"
:source-value="sourceOf"
/>
</AccordionContent>
</AccordionItem>
@@ -637,8 +474,11 @@ function groupsCsv(): string {
<Card>
<CardHeader>
<div class="flex items-start justify-between gap-2">
<CardTitle>Users</CardTitle>
<CsvExportButton filename="usage-users.csv" :build="usersCsv" />
<div>
<CardTitle>Users</CardTitle>
<CardDescription v-if="consumers.data?.usersFile">File-backed users load from {{ consumers.data.usersFile }}.</CardDescription>
</div>
<CsvExportButton filename="consumers-users.csv" :build="usersCsv" />
</div>
</CardHeader>
<CardContent>
@@ -649,6 +489,7 @@ function groupsCsv(): string {
:secondary-value="groupNameOf"
:empty-message="usersEmptyMessage"
:now="now"
:source-value="sourceOf"
v-model:sorting="usersSorting"
/>
</CardContent>
+142
View File
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { costAvoidedMicros, freeRows, pickReferenceModel } from '@/lib/cost-avoided'
import { formatCost } from '@/lib/format'
import type { AdminCatalogModel, AdminUsageModelEntry } from '@/types/api'
/**
* CostAvoidedCard (redesign-plan.md section 3.4) reports what the fleet's
* free-tier traffic in range WOULD have cost against a real, priced
* reference model — lib/cost-avoided.ts's own doc comment has the unit
* derivation. The reference model defaults to the most-requested priced
* model actually served in range (pickReferenceModel) but is always
* reader-overridable via the picker below; `ref` is a CONTROLLED prop
* (this card owns no picker state itself), mirroring DataTable.vue's own
* `sorting`/`update:sorting` convention — SpendPage.vue keeps the current
* choice in the `ref` page param (redesign-plan.md section 3.1) so a
* reload or shared link restores the same comparison.
*
* Cache savings (redesign-plan.md section 3.4: "cache savings (sum csave)
* shown alongside when enabled") render as a second figure beside the
* cost-avoided one, only when `cacheStatsEnabled` — a genuinely different
* saving (a repeat request served from cache, never re-billed at all) that
* would otherwise be easy to conflate with "served by a free model".
*
* N2 fix (verify-redesign-final.md): the figure is not always summable.
* `cacheSavingsAvailable` (lib/cache-savings.ts's own doc comment has the
* full backend grounding) is false for an hour-window range — the
* backend never writes a per-hour cache-savings bucket at all, so
* `m.cacheSavedMicroUsd` is undefined on every row and summing it would
* silently render a fabricated "$0.00" rather than "no data for this
* range". The card renders "n/a" instead whenever it is false.
*/
const props = defineProps<{
/** The model ranking (GET /admin/api/usage/models, detail=1) — every free row feeds costAvoidedMicros; every catalog-priced row is a candidate reference model. */
models: AdminUsageModelEntry[]
catalog: Map<string, AdminCatalogModel>
/**
* The reader's explicit reference-model override (the `ref` page param),
* or '' for "use the default". Named `refModel`, not `ref` — `ref` is a
* reserved Vue template attribute (component template refs); a prop
* literally named `ref` can never be set from a parent's template via
* `:ref="..."`, since Vue always intercepts that binding for its own
* template-ref mechanism instead of passing it through as a prop.
*/
refModel: string
/** AdminFeaturesView.cacheStats — gates the cache-savings figure. */
cacheStatsEnabled: boolean
/**
* cacheSavingsAvailable (N2 fix, lib/cache-savings.ts's own
* cacheSavingsStatus) — false for an hour-window range, where the
* backend has no per-hour cache-savings bucket at all. The card
* renders "n/a" instead of a figure when this is false, never a
* fabricated "$0.00". SpendPage.vue computes this from the current
* global range (it owns the filters store; this card stays a pure,
* props-driven component).
*/
cacheSavingsAvailable: boolean
/**
* cacheSavingsNote (N2 fix) — a caveat to show ALONGSIDE an available
* figure (currently only the month-window "35-day retention" note,
* lib/cache-savings.ts), or the reason text when
* `cacheSavingsAvailable` is false. Null when the figure is available
* and exact (day-window ranges).
*/
cacheSavingsNote?: string | null
}>()
const emit = defineEmits<{ 'update:refModel': [value: string] }>()
/** priceableOptions is the picker's own list: every served model whose catalog entry is genuinely priced (excludes free/unpriced — comparing free traffic against another $0 model is meaningless, the same filter pickReferenceModel itself applies), sorted by descending requests so the most relevant choices sit at the top. */
const priceableOptions = computed<{ id: string; requests: number }[]>(() => {
const out: { id: string; requests: number }[] = []
for (const m of props.models) {
const cat = props.catalog.get(m.id)
if (!cat || cat.priceSource === 'free' || cat.priceSource === 'unpriced') continue
out.push({ id: m.id, requests: m.requests ?? 0 })
}
return out.sort((a, b) => b.requests - a.requests)
})
const defaultReference = computed<string | null>(() => pickReferenceModel(props.models, props.catalog))
const effectiveReference = computed<string | null>(() => props.refModel || defaultReference.value)
const referenceCatalog = computed<AdminCatalogModel | undefined>(() =>
effectiveReference.value ? props.catalog.get(effectiveReference.value) : undefined,
)
const avoidedMicros = computed<number | null>(() => {
const cat = referenceCatalog.value
if (!cat || cat.inputPerMTokUsd === undefined || cat.outputPerMTokUsd === undefined) return null
return costAvoidedMicros(freeRows(props.models), cat.inputPerMTokUsd, cat.outputPerMTokUsd)
})
const cacheSavedMicros = computed<number>(() => {
let total = 0
for (const m of props.models) total += m.cacheSavedMicroUsd ?? 0
return total
})
function onReferenceUpdate(value: unknown): void {
if (typeof value === 'string') emit('update:refModel', value)
}
</script>
<template>
<Card>
<CardHeader class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Cost avoided</CardTitle>
<CardDescription>What free-tier traffic in range would have cost at a real model's own rates.</CardDescription>
</div>
<div v-if="priceableOptions.length > 0" class="flex flex-col gap-1">
<label id="cost-avoided-ref-label" class="text-xs font-medium text-muted-foreground">Reference model</label>
<Select :model-value="effectiveReference ?? undefined" @update:model-value="onReferenceUpdate">
<SelectTrigger aria-labelledby="cost-avoided-ref-label" class="w-56">
<SelectValue placeholder="Pick a reference model" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="opt in priceableOptions" :key="opt.id" :value="opt.id">{{ opt.id }}</SelectItem>
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="text-muted-foreground">Cost avoided (free-tier traffic)</p>
<p v-if="avoidedMicros === null" class="text-sm text-muted-foreground">
{{ priceableOptions.length === 0 ? 'No priced model served in range to compare against.' : 'Pick a reference model above.' }}
</p>
<p v-else class="text-lg font-semibold tabular-nums text-chart-requests">{{ formatCost(avoidedMicros) }}</p>
</div>
<div v-if="cacheStatsEnabled">
<p class="text-muted-foreground">Cache savings</p>
<p v-if="!cacheSavingsAvailable" class="text-lg font-semibold tabular-nums text-muted-foreground">n/a</p>
<p v-else class="text-lg font-semibold tabular-nums text-chart-requests">{{ formatCost(cacheSavedMicros) }}</p>
<p v-if="cacheSavingsNote" class="text-xs text-status-warn">{{ cacheSavingsNote }}</p>
</div>
</CardContent>
</Card>
</template>
+15 -12
View File
@@ -7,27 +7,30 @@ import { formatCost } from '@/lib/format'
import type { LimitsConfig } from '@/types/api'
/**
* CostForecastCard renders the Usage tab's fleet-wide cost projection —
* F7, placed under the Total card (UsageView.vue). All math is delegated
* to lib/forecast.ts's pure functions; this component only formats and
* lays the result out.
* CostForecastCard renders a fleet- or scope-wide cost projection — F7,
* originally the pre-redesign Usage tab's own card, now used by both
* SpendPage.vue (the global range's own scope, under the burn-down chart)
* and ConsumerDirectory.vue (fleet-wide, under the Total row). All math is
* delegated to lib/forecast.ts's pure functions; this component only
* formats and lays the result out.
*
* `now` is a REQUIRED prop, not read from `new Date()` inside this
* component (coordinator brief: "now passed as prop for determinism") —
* the caller (UsageView.vue) supplies it, keeping every `Date.now()`-
* flavored read in this feature at the boundary rather than buried inside
* a component, same discipline lib/forecast.ts's own functions already
* follow by taking `now`/`elapsedFraction` as plain arguments.
* every caller supplies it from its OWN composables/useNow.ts instance,
* keeping every `Date.now()`-flavored read in this feature at the
* boundary rather than buried inside a component, same discipline
* lib/forecast.ts's own functions already follow by taking
* `now`/`elapsedFraction` as plain arguments.
*
* P1 review fix: this used to be optional with a `withDefaults(...,
* { now: () => new Date() })` default. Vue caches a prop's default value
* ONCE per component instance (propsDefaults) — the very first time it is
* read, not on every render — so a caller that omitted `now` froze this
* card's projection at whatever instant it happened to mount, never
* advancing again for as long as the tab stayed open. `now` is now
* required: UsageView.vue passes the ONE shared reactive clock
* (composables/useNow.ts) every time-dependent read on this tab shares,
* so there is no default left to accidentally freeze.
* advancing again for as long as the page stayed open. `now` is now
* required: every caller passes the ONE shared reactive clock its own
* useNow() call returns, so there is no default left to accidentally
* freeze.
*/
const props = defineProps<{
/** usage.total.costPerMonthMicroUsd — fleet-wide month-to-date spend. */
+5 -5
View File
@@ -7,11 +7,11 @@ import { downloadText, type DownloadOutcome } from '@/lib/download'
/**
* CsvExportButton is the one "Export CSV" control every table export in
* this panel uses — F8. Owned by WP-B1 (usage-csv.ts, this component) but
* consumed unmodified by WP-B2's Models tab, which builds its own CSV text
* via lib/usage-csv.ts's modelDetailCsv and passes it through the same
* `build` prop — one export affordance, not a copy per tab (vue.md: "if
* you've written it twice, you owe an abstraction").
* this panel uses (F8) — ConsumerDirectory.vue's Users/Groups exports
* (lib/usage-csv.ts's usageCsv) and PricingHealthTable.vue's own
* pricing-health export each build their own CSV text and pass it
* through the same `build` prop — one export affordance, not a copy per
* table (vue.md: "if you've written it twice, you owe an abstraction").
*
* `build` is called fresh on every click, never memoized — an export
* always reflects whatever rows are CURRENTLY visible (filtered + sorted)
+2 -2
View File
@@ -43,8 +43,8 @@ const props = defineProps<{
* default) keeps every existing call site's original UNCONTROLLED
* behavior unchanged — this component owns `internalSorting` itself and
* a caller has no way to read the current sort. Passed, a caller (e.g.
* UsageView.vue's Users export, which needs to export rows in the
* table's own current sort order — the plan's own "exports
* ConsumerDirectory.vue's Users export, which needs to export rows in
* the table's own current sort order — the plan's own "exports
* filtered+sorted rows" requirement) instead owns the ref: every header
* click emits `update:sorting` with the new state rather than mutating
* anything locally here.
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { faArrowUpRightFromSquare } from '@fortawesome/free-solid-svg-icons'
import { useNavStore } from '@/stores/nav'
/**
* EntityLink (redesign-plan.md section 3.5) is the redesigned shell's own
* navigation link for an entity id: a user id opens Consumers pre-filtered
* to that user (lib/usage-columns.ts's id cell, UserDetail.vue), a model
* id opens Models pre-filtered to that model (lib/model-table-columns.ts's
* id cell) — nav.goTo rather than a bare href, so it stays inside the
* SPA's own hash-routed shell. It replaced ScopeLink.vue (deleted, section
* 3.5) once every one of that component's own call sites had switched
* over.
*
* Structural note carried over from ScopeLink.vue: a real
* `<button type="button">`, always rendered as a SIBLING of whatever
* interactive element it sits beside (a table cell, an accordion header),
* never nested inside one — the HTML button content model forbids a
* focusable descendant, and nesting one breaks both keyboard operation
* and how assistive technology exposes it.
*/
const props = defineProps<{
/** The visible + accessible label, e.g. a user id or model id. */
label: string
/** Which page this id opens — 'user' -> Consumers, 'model' -> Models. */
kind: 'user' | 'model'
/** The raw entity id (never pre-formatted as "kind:id" — this component builds nav.goTo's own params itself). */
id: string
}>()
const nav = useNavStore()
const TARGET: Record<'user' | 'model', { page: 'consumers' | 'models'; paramKey: 'user' | 'model'; noun: string }> = {
user: { page: 'consumers', paramKey: 'user', noun: 'Consumers' },
model: { page: 'models', paramKey: 'model', noun: 'Models' },
}
function onClick(event: MouseEvent): void {
event.stopPropagation()
const target = TARGET[props.kind]
nav.goTo(target.page, { [target.paramKey]: props.id })
}
</script>
<template>
<button
type="button"
:aria-label="`View ${label} in ${TARGET[kind].noun}`"
:title="`View ${label} in ${TARGET[kind].noun}`"
class="inline-flex cursor-pointer items-center gap-1 rounded-sm text-inherit hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
@click="onClick"
>
<span>{{ label }}</span>
<FontAwesomeIcon :icon="faArrowUpRightFromSquare" class="size-3" aria-hidden="true" />
</button>
</template>
+6 -5
View File
@@ -25,8 +25,9 @@ const ALL_KINDS = 'all'
// kindFilter/userFilter live on the store, not as local refs (F9,
// hash-state): composables/useHashState.ts reads/writes them directly so
// this filter round-trips through the URL hash. userFilter binds through
// useSearchQuery's own external-ref option, the same way UsageView.vue
// binds nav.usageQuery — see useSearchQuery.ts's own doc comment.
// useSearchQuery's own external-ref option — see useSearchQuery.ts's own
// doc comment for the other current external-ref caller
// (ConsumerDirectory.vue).
const { kindFilter, userFilter } = storeToRefs(events)
const { normalized: normalizedUserQuery } = useSearchQuery(userFilter)
@@ -48,9 +49,9 @@ function onKindChange(value: unknown): void {
const columns = eventsColumns()
/**
* emptyMessage mirrors TargetsView.vue/UsageView.vue's own three-way
* pattern: distinguishes "nothing recorded at all" from "nothing matches
* the active filter".
* emptyMessage mirrors TargetsView.vue/ConsumerDirectory.vue's own
* three-way pattern: distinguishes "nothing recorded at all" from
* "nothing matches the active filter".
*
* P9 review fix: gated on events.lastUpdated (null until the first fetch
* settles, stores/events.ts) — this used to read events.events.length
+134
View File
@@ -0,0 +1,134 @@
<script setup lang="ts">
import { faArrowRightArrowLeft } from '@fortawesome/free-solid-svg-icons'
import { computed, ref, watch } from 'vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { SCOPE_PATTERN } from '@/lib/hash-state'
import { RANGE_KEYS } from '@/lib/range'
import type { RangeKey } from '@/lib/range'
import { DEFAULT_SCOPE, useFiltersStore } from '@/stores/filters'
/** RANGE_LABEL is the range Select's display text per lib/range.ts's RangeKey — kept here (not lib/range.ts) since it is pure presentation, not logic a store or another page's fetch needs. */
const RANGE_LABEL: Record<RangeKey, string> = {
'24h': 'Last 24 hours',
'48h': 'Last 48 hours',
'7d': 'Last 7 days',
'14d': 'Last 14 days',
'30d': 'Last 30 days',
'3mo': 'Last 3 months',
'6mo': 'Last 6 months',
'12mo': 'Last 12 months',
}
/**
* GlobalFilterBar (redesign-plan.md section 3.1) is the shell's one
* control surface for the three global filters (stores/filters.ts):
* time range, "vs previous period" comparison, and an optional
* group/user/provider scope narrowing. Every page reads the SAME filters
* store; this bar is mounted once, in App.vue's header, not per-page.
*/
const filters = useFiltersStore()
function onRangeUpdate(value: unknown): void {
if (typeof value !== 'string') return
filters.setFilters({ range: value as RangeKey })
}
/**
* toggleCmp (P3 item 19): turning the comparison OFF is always allowed,
* even when the CURRENT range does not support one — a `cmp=prev` value
* restored from the hash (e.g. loaded directly onto a 48h/30d/12mo range,
* where 2*span exceeds the retention ceiling) used to leave the button
* permanently pressed-and-disabled, with no way to turn it off short of
* changing the range first. filters.cmpSpec.requested already gates the
* actual fetch on availability (stores/filters.ts's own doc comment), so
* this was purely a stuck AFFORDANCE, never a bad request. Turning it ON
* still requires availability — there is nothing to compare against
* otherwise.
*/
function toggleCmp(): void {
if (filters.cmp === 'prev') {
filters.setFilters({ cmp: 'none' })
return
}
if (!filters.cmpSpec.available) return
filters.setFilters({ cmp: 'prev' })
}
/** cmpDisabled only blocks turning the toggle ON when unavailable — turning an already-on comparison OFF is never blocked (toggleCmp's own doc comment). */
const cmpDisabled = computed<boolean>(() => filters.cmp !== 'prev' && !filters.cmpSpec.available)
const cmpTitle = computed<string>(() => {
if (filters.cmp === 'prev' && !filters.cmpSpec.available) {
return `Comparison unavailable for this range (${filters.cmpSpec.reason}) — click to turn off`
}
if (filters.cmpSpec.available) return 'Compare against the previous equal-length period'
return `Comparison unavailable for this range: ${filters.cmpSpec.reason}`
})
// scopeDraft is a local editing buffer, not bound straight to
// filters.scope: a partially-typed value ("group:") must not spam
// setFilters/refetch every store on every keystroke, and an invalid
// commit (onBlur/Enter, below) must revert the FIELD, not the store —
// the store keeps whatever scope was last valid.
const scopeDraft = ref(filters.scope)
watch(
() => filters.scope,
(scope) => {
scopeDraft.value = scope
},
)
function commitScope(): void {
const value = scopeDraft.value.trim() || DEFAULT_SCOPE
if (SCOPE_PATTERN.test(value)) {
filters.setFilters({ scope: value })
} else {
// Invalid — revert the field to the store's own current value rather than keeping the bad text on screen.
scopeDraft.value = filters.scope
}
}
</script>
<template>
<div class="flex flex-wrap items-end gap-3">
<div class="flex flex-col gap-1">
<label id="global-filter-range-label" class="text-xs font-medium text-muted-foreground">Range</label>
<Select :model-value="filters.range" @update:model-value="onRangeUpdate">
<SelectTrigger aria-labelledby="global-filter-range-label" class="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="key in RANGE_KEYS" :key="key" :value="key">{{ RANGE_LABEL[key] }}</SelectItem>
</SelectContent>
</Select>
</div>
<Button
type="button"
variant="outline"
:aria-pressed="filters.cmp === 'prev'"
:disabled="cmpDisabled"
:title="cmpTitle"
@click="toggleCmp"
>
<FontAwesomeIcon :icon="faArrowRightArrowLeft" class="size-3.5" aria-hidden="true" />
vs previous period
</Button>
<div class="flex flex-col gap-1">
<label for="global-filter-scope" class="text-xs font-medium text-muted-foreground">Scope</label>
<Input
id="global-filter-scope"
v-model="scopeDraft"
class="w-48"
placeholder="all"
title="all, group:<name>, user:<name>, or provider:<name>"
@keyup.enter="commitScope"
@blur="commitScope"
/>
</div>
</div>
</template>
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Card, CardContent } from '@/components/ui/card'
import type { KpiTier } from '@/lib/kpi'
import type { PageId } from '@/lib/pages'
import { useNavStore } from '@/stores/nav'
/**
* KpiTile (redesign-plan.md section 3.4) is the Home page's one KPI-tile
* shape: a label, a headline value, an optional delta/detail line, and a
* tier (lib/kpi.ts) that colors the value text — 'ok' (default
* foreground), 'warn' (--status-warn), 'critical' (--destructive), the
* same two semantic tokens the rest of this panel's badges/bars already
* use, never a fourth ad-hoc color.
*
* Optionally a link: when `to` is set, the whole tile becomes a real
* `<button>` that calls nav.goTo(to.page, to.params) — a budget tile
* jumps to Spend, an open-breakers tile jumps to Reliability, and so on.
* Without `to`, it renders as a plain, non-interactive Card (a tile with
* nowhere useful to send the reader, e.g. "requests/min", stays inert
* rather than a button that does nothing).
*/
const props = withDefaults(
defineProps<{
label: string
value: string
delta?: string
tier?: KpiTier
to?: { page: PageId; params?: Record<string, string> }
}>(),
{ tier: 'ok' },
)
const nav = useNavStore()
const valueClass = computed<string>(() => {
switch (props.tier) {
case 'warn':
return 'text-status-warn'
case 'critical':
return 'text-destructive'
default:
return 'text-foreground'
}
})
function onClick(): void {
if (!props.to) return
nav.goTo(props.to.page, props.to.params ?? {})
}
</script>
<template>
<component
:is="to ? 'button' : 'div'"
:type="to ? 'button' : undefined"
class="block w-full rounded-xl text-left"
:class="to ? 'cursor-pointer transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50' : undefined"
@click="onClick"
>
<Card>
<CardContent class="flex flex-col gap-1 px-4 py-3">
<span class="text-xs font-medium text-muted-foreground">{{ label }}</span>
<span class="text-2xl font-semibold tracking-tight tabular-nums" :class="valueClass">{{ value }}</span>
<span v-if="delta" class="text-xs text-muted-foreground">{{ delta }}</span>
</CardContent>
</Card>
</component>
</template>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed } from 'vue'
import DataTable from '@/components/DataTable.vue'
import SearchInput from '@/components/SearchInput.vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { filterModelRows } from '@/lib/model-filter'
import { modelCatalogColumns } from '@/lib/model-table-columns'
import type { ModelCatalogRow } from '@/lib/model-table-columns'
/**
* ModelCatalogTable (redesign-plan.md section 3.4, Models & providers
* page) is the catalog-driven table: every configured model (not just
* ones with observed traffic), joined with the current range's ranking
* and fleet performance — ModelCatalogRow's own doc comment
* (lib/model-table-columns.ts) has the full join contract.
*
* `query` is a controlled v-model, not owned locally: ModelsPage.vue keeps
* it so the Models page's own `q` hash param (redesign-plan.md section
* 3.1) round-trips through the URL, the same controlled-component
* convention SearchInput.vue itself already documents.
*/
const props = defineProps<{
rows: ModelCatalogRow[]
query: string
/** GET /admin/api/performance's own latencyEnabled (AdminPerfResponse) — false means admin.stats.latency is off, so p50/p95 read "—" for every row, not "no traffic yet". */
latencyEnabled: boolean
loading: boolean
}>()
const emit = defineEmits<{ 'update:query': [value: string] }>()
const filtered = computed<ModelCatalogRow[]>(() => filterModelRows(props.rows, props.query))
const columns = modelCatalogColumns()
const emptyMessage = computed<string>(() => {
if (props.loading) return 'loading…'
if (props.rows.length === 0) return 'no models configured'
if (props.query.trim()) return `no models match "${props.query}"`
return 'none'
})
</script>
<template>
<Card>
<CardHeader>
<CardTitle>Model catalog</CardTitle>
<CardDescription>
Every configured model: price source, per-model usage for the current range, fleet p50/p95, and error rate.
</CardDescription>
<SearchInput
:model-value="query"
placeholder="Search models or aliases..."
class="mt-2 max-w-sm"
@update:model-value="emit('update:query', $event)"
/>
</CardHeader>
<CardContent class="flex flex-col gap-3">
<Alert v-if="!latencyEnabled" variant="warn">
<AlertDescription>
Latency percentiles (p50/p95) are off for this deployment. Enable <code>admin.stats.latency</code> to see them.
</AlertDescription>
</Alert>
<DataTable :columns="columns" :data="filtered" :empty-message="emptyMessage" />
</CardContent>
</Card>
</template>
+9 -7
View File
@@ -9,13 +9,15 @@ import { formatContextWindow, formatModelCostHover } from '@/lib/format'
/**
* ModelChip is one clickable, copyable model-id chip, used anywhere a
* resolvable model or alias id renders: a provider's expanded model list
* (ProvidersView.vue's accordion), the model-alias table, and a group's
* usage-detail model list (UsageView.vue — passed with no metadata props
* there, since a raw group-config glob entry has no resolvable provider/
* model pair to look metadata up against). id is the full routable id
* (lib/format.ts's routableModelId) or an alias name — what actually
* gets copied, so a pasted value works straight into a `model: "..."`
* request field.
* and the model-alias table (both ProviderHealthPanel.vue's accordion),
* the Models page's own catalog table (lib/model-table-columns.ts, WITH
* metadata props — see contextTokens/inputPerMTokUsd/outputPerMTokUsd
* below), and a group's own allowed-models list (ConsumerDirectory.vue —
* passed with no metadata props there for a raw glob entry, since it has
* no resolvable provider/model pair to look metadata up against). id is
* the full routable id (lib/format.ts's routableModelId) or an alias
* name — what actually gets copied, so a pasted value works straight
* into a `model: "..."` request field.
*
* contextTokens/inputPerMTokUsd/outputPerMTokUsd are this model's
* resolved metadata (feature v0.23, hover-detail refinement:
-119
View File
@@ -1,119 +0,0 @@
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js'
import { computed } from 'vue'
import { Bar } from 'vue-chartjs'
import '@/lib/chart-setup'
import { useThemeColors } from '@/composables/useThemeColors'
import { formatCompactCount, formatCost, formatExactInt } from '@/lib/format'
import { MODEL_METRIC_LABEL, type ModelMetric } from '@/stores/history'
import type { AdminUsageModelEntry } from '@/types/api'
const props = defineProps<{
metric: ModelMetric
models: AdminUsageModelEntry[]
}>()
const { foreground, mutedForeground, border } = useThemeColors()
/**
* One token per rankable metric, reusing the SAME --chart-* custom
* properties the time-series chart uses (UsageChart.vue's DATASET_SPECS),
* so a model's bar is the colour that metric already has elsewhere in the
* view rather than a second, conflicting palette.
*/
const METRIC_COLOR: Record<ModelMetric, string> = {
req: '--chart-requests',
tokin: '--chart-tokens-in',
tokout: '--chart-tokens-out',
cost: '--chart-cost',
}
/** Cost is stored as micro-USD (limits.go's account/history convention); every other metric is already the display unit. */
function toDisplayValue(metric: ModelMetric, raw: number): number {
return metric === 'cost' ? raw / 1_000_000 : raw
}
/** resolveToken reads a --chart-* custom property from :root, live — same approach (and same reason) as UsageChart.vue. */
function resolveToken(cssVar: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim()
}
/**
* The server already returns the ranking sorted, descending, and capped —
* this component neither re-sorts nor re-filters it. Chart.js draws the
* first category at the TOP of a horizontal bar chart, so the busiest
* model lands where a reader looks first, with no reversal needed.
*/
const chartData = computed<ChartData<'bar'>>(() => {
void foreground.value // re-run on a prefers-color-scheme flip (see UsageChart.vue)
return {
labels: props.models.map((m) => m.id),
datasets: [
{
label: MODEL_METRIC_LABEL[props.metric],
backgroundColor: resolveToken(METRIC_COLOR[props.metric]),
data: props.models.map((m) => toDisplayValue(props.metric, m.value)),
},
],
}
})
/**
* Height grows with the row count so bars keep a readable thickness
* instead of compressing into hairlines at 20 models, and so a two-model
* ranking does not stretch each bar across a third of the screen.
*/
const chartHeight = computed<string>(() => `${Math.max(180, props.models.length * 28 + 60)}px`)
const chartOptions = computed<ChartOptions<'bar'>>(() => ({
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
animation: false,
scales: {
x: {
// Zero-based, honest axis: bar length is the whole comparison here.
beginAtZero: true,
grid: { color: border.value },
ticks: {
color: mutedForeground.value,
// Reuses formatCost, same rationale as UsageChart.vue's identical
// fix: a raw tick float interpolated directly into "$${value}" can
// print unrounded binary-float noise; formatCost's .toFixed(4)
// resolves it, and is the same formatter every other cost figure
// in this app already renders through.
callback: (value) => (props.metric === 'cost' ? formatCost(Number(value) * 1_000_000) : formatCompactCount(Number(value))),
},
},
y: {
grid: { display: false },
ticks: { color: mutedForeground.value, autoSkip: false },
},
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
// Compact figure plus the exact one, matching UsageChart.vue's
// tooltip contract — a tooltip has room for both, and precision
// must never be lost to the axis's compact form.
label: (ctx) => {
const value = ctx.parsed.x ?? 0
if (props.metric === 'cost') return `${MODEL_METRIC_LABEL[props.metric]}: $${value.toFixed(4)}`
return `${MODEL_METRIC_LABEL[props.metric]}: ${formatCompactCount(value)} (${formatExactInt(value)})`
},
},
},
},
}))
</script>
<template>
<p v-if="models.length === 0" class="py-8 text-center text-sm text-muted-foreground">
No model recorded any usage in this window.
</p>
<div v-else class="w-full" :style="{ height: chartHeight }">
<Bar :data="chartData" :options="chartOptions" />
</div>
</template>
+152
View File
@@ -0,0 +1,152 @@
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import { computed, h } from 'vue'
import CompactNumber from '@/components/CompactNumber.vue'
import CsvExportButton from '@/components/CsvExportButton.vue'
import DataTable from '@/components/DataTable.vue'
import EntityLink from '@/components/EntityLink.vue'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { toCsv } from '@/lib/csv'
import { formatContextWindow, formatCost, formatModelCostHover } from '@/lib/format'
import { joinPricingHealth, pricingHealthOrder } from '@/lib/pricing-health'
import type { PricingHealthRow } from '@/lib/pricing-health'
import type { AdminCatalogModel, AdminUsageModelEntry, PriceSource } from '@/types/api'
/**
* PricingHealthTable (redesign-plan.md section 3.4) lists every model
* actually served in the current range, joined with its catalog entry
* (lib/pricing-health.ts's joinPricingHealth), unpriced first with a
* destructive badge (pricingHealthOrder) — the row an operator most needs
* to act on (a served model billing at $0 because no pricing rule
* matched) is always the first thing they see, before any manual sort.
*/
const props = defineProps<{
models: AdminUsageModelEntry[]
catalog: Map<string, AdminCatalogModel>
}>()
const rows = computed<PricingHealthRow[]>(() => pricingHealthOrder(joinPricingHealth(props.models, props.catalog)))
const unpricedCount = computed<number>(() => rows.value.filter((r) => r.priceSource === 'unpriced').length)
/** SOURCE_BADGE_VARIANT mirrors lib/model-table-columns.ts's own priceCell badge treatment (WP-F, ModelCatalogTable.vue) so the two tables read consistently: destructive for unpriced (needs attention), secondary for a deliberate free model, outline for an explicit rule. */
const SOURCE_BADGE_VARIANT: Record<PriceSource, 'destructive' | 'secondary' | 'outline'> = {
unpriced: 'destructive',
free: 'secondary',
override: 'outline',
builtin: 'outline',
litellm: 'outline',
}
function sourceCell(row: PricingHealthRow) {
return h(Badge, { as: 'span', variant: SOURCE_BADGE_VARIANT[row.priceSource], class: 'font-normal' }, () => row.priceSource)
}
function priceCell(row: PricingHealthRow) {
if (row.inputPerMTokUsd === undefined || row.outputPerMTokUsd === undefined) return h('span', { class: 'text-muted-foreground' }, '—')
return h('span', {}, formatModelCostHover(row.inputPerMTokUsd, row.outputPerMTokUsd))
}
const columns: ColumnDef<PricingHealthRow, unknown>[] = [
{
id: 'id',
header: 'Model',
accessorFn: (r) => r.id,
cell: ({ row }) => h(EntityLink, { label: row.original.id, kind: 'model', id: row.original.id }),
},
{
id: 'source',
header: 'Source',
accessorFn: (r) => r.priceSource,
cell: ({ row }) => sourceCell(row.original),
},
{
id: 'price',
header: 'Price',
enableSorting: false,
cell: ({ row }) => priceCell(row.original),
},
{
id: 'context',
header: 'Context',
meta: { align: 'right' },
accessorFn: (r) => r.contextTokens ?? 0,
cell: ({ row }) => h('span', { class: 'tabular-nums' }, row.original.contextTokens !== undefined ? formatContextWindow(row.original.contextTokens) : '—'),
},
{
id: 'requests',
header: 'Requests',
meta: { align: 'right' },
accessorFn: (r) => r.requests,
cell: ({ row }) => h(CompactNumber, { value: row.original.requests }),
},
{
id: 'tokensIn',
header: 'Tokens in',
meta: { align: 'right' },
accessorFn: (r) => r.tokensIn,
cell: ({ row }) => h(CompactNumber, { value: row.original.tokensIn }),
},
{
id: 'tokensOut',
header: 'Tokens out',
meta: { align: 'right' },
accessorFn: (r) => r.tokensOut,
cell: ({ row }) => h(CompactNumber, { value: row.original.tokensOut }),
},
{
id: 'cost',
header: 'Cost',
meta: { align: 'right' },
accessorFn: (r) => r.costMicroUsd,
cell: ({ row }) => formatCost(row.original.costMicroUsd),
},
]
/**
* pricingHealthCsv exports exactly the rows on screen (unpriced-first
* order, or whatever the reader has since re-sorted to — DataTable's own
* `data` prop is passed through unmodified) as raw, unformatted values
* (CsvExportButton.vue's own doc comment: "always reflects whatever rows
* are CURRENTLY visible... never a stale snapshot") — costMicroUsd stays
* micro-USD, not a pre-formatted "$X.XXXX" string, so the export re-opens
* cleanly as a spreadsheet numeric column.
*/
function pricingHealthCsv(): string {
return toCsv(
['id', 'priceSource', 'displayFree', 'requests', 'tokensIn', 'tokensOut', 'costMicroUsd', 'contextTokens', 'inputPerMTokUsd', 'outputPerMTokUsd'],
rows.value.map((r) => [
r.id,
r.priceSource,
r.displayFree,
r.requests,
r.tokensIn,
r.tokensOut,
r.costMicroUsd,
r.contextTokens ?? '',
r.inputPerMTokUsd ?? '',
r.outputPerMTokUsd ?? '',
]),
)
}
</script>
<template>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
Pricing health
<Badge v-if="unpricedCount > 0" variant="destructive" class="font-normal">{{ unpricedCount }} unpriced</Badge>
</CardTitle>
<CardDescription>Every model served in range, joined with its billing price source.</CardDescription>
</CardHeader>
<CardContent class="flex flex-col gap-3">
<DataTable :columns="columns" :data="rows" empty-message="No model served any traffic in this range." />
<div class="flex justify-end">
<CsvExportButton filename="pricing-health.csv" :build="pricingHealthCsv" />
</div>
</CardContent>
</Card>
</template>
@@ -8,11 +8,12 @@ import {
import { computed, h, reactive, watch } from 'vue'
import DataTable from '@/components/DataTable.vue'
import EntityLink from '@/components/EntityLink.vue'
import ModelChip from '@/components/ModelChip.vue'
import ProviderRateBadge from '@/components/ProviderRateBadge.vue'
import ScopeLink from '@/components/ScopeLink.vue'
import SearchInput from '@/components/SearchInput.vue'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import {
Card,
@@ -26,79 +27,55 @@ import { formatAgo, formatContextWindow, formatLatencyMs, formatUntil, refreshDe
import { isModelDegraded } from '@/lib/provider-rate'
import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand'
import { useDashboardStore } from '@/stores/dashboard'
import { useModelsStore } from '@/stores/models'
import { useNavStore } from '@/stores/nav'
import type { AdminAliasView, AdminLatencyView, AdminModelMetaView, AdminModelRateView, AdminProvenanceView, AdminProviderView } from '@/types/api'
import type {
AdminAliasView,
AdminLatencyView,
AdminModelMetaView,
AdminModelRateView,
AdminPerfRow,
AdminProvenanceView,
AdminProviderView,
} from '@/types/api'
// This component backs the "Providers" tab (App.vue) — a UI-label rename
// only. The data it renders still comes from GET /admin/api/overview
// (admin.go's adminOverviewResponse) and the store field below keeps that
// same "overview" name, since the backend route/type names are unchanged
// API surface.
/**
* ProviderHealthPanel (redesign-plan.md section 3.4, Models & providers
* page) is ProvidersView.vue's own Providers accordion + aliases table,
* moved here verbatim, PLUS fleet-wide performance (p50/p95, timeouts,
* failovers — GET /admin/api/performance?kind=provider, read from
* useModelsStore, which the Models page's own onMounted fetches once for
* the whole page, shared with ModelCatalogTable.vue). The per-replica
* discovery/latency/provenance badges below are UNCHANGED from
* ProvidersView.vue — still sourced from the SAME 5s dashboard poll
* (GET /admin/api/overview), still per-replica/in-process (see each
* badge's own doc comment for why that never averages across replicas).
*/
const dashboard = useDashboardStore()
const models = useModelsStore()
const overview = computed(() => dashboard.overview)
const nav = useNavStore()
/**
* goToProviderModels navigates to the Charts Models ranking, pre-filtered
* to this provider's own models (F6, dashboard-plan.md: `nav.goToModels`).
*
* P2 review fix: this used to back a `<span role="link" tabindex="0">`
* nested INSIDE AccordionTrigger's own real `<button>` — invalid per the
* HTML button content model (no descendant may carry a `tabindex`
* attribute, not just no nested button/anchor) and effectively invisible
* to assistive technology (ARIA's `button` role declares "children
* presentational: true", so the nested span was never announced as its
* own link — see ScopeLink.vue's own doc comment for the identical
* finding there). The provider-name label is now a real `<button>`,
* rendered as a SIBLING of AccordionTrigger rather than nested inside it
* (this component's template) — a plain click handler is enough: a click
* on a sibling element never bubbles INTO the trigger's own listener
* (event bubbling only travels through actual ancestors of the click
* target), so the stopPropagation/Enter-only-keydown machinery this used
* to need is gone along with the span it protected — native `<button>`
* keyboard activation (Enter AND Space) is correct here for free.
* goToProviderModels navigates to the Models catalog table, pre-filtered
* (via its own `q` search) to this provider's own models — the redesign's
* replacement for the pre-redesign nav.goToModels (stores/nav.ts no
* longer has a per-page goTo* method at all, see that store's own doc
* comment; every navigation goes through the single goTo(page, params)).
*/
function goToProviderModels(providerName: string): void {
nav.goToModels(`${providerName}/`)
nav.goTo('models', { q: `${providerName}/` })
}
// --- provider expand/collapse (shadcn-vue Accordion) ---
//
// Providers render as a real shadcn-vue Accordion (operator directive:
// use the library component, restructure the layout to fit it — the
// earlier hand-rolled table-row-toggle accordion is retired). The
// trigger is one provider's summary line; the content holds its detail
// (base URL, last refresh, last error) plus its model-id chips.
//
// Expand STATE itself is unchanged: lib/search-expand.ts's plain,
// Vue-free two-set module (manuallyExpanded/manuallyCollapsed, generalized
// from provider-expand.ts so UsageView.vue's Groups accordion shares the
// same tested logic) — the exact search-interaction bug fix a prior review
// flagged, covered by a real vitest spec (lib/search-expand.spec.ts) that
// exercises the module directly, without mounting Vue. Only the
// template-facing adapter changed: expandedProviderValues below is a
// writable computed translating that Set-based state into the string[]
// shape Accordion's `type="multiple"` v-model expects, and back —
// clicking a trigger fires the setter with the new array; diffing it
// against the previous effective set finds the one name that changed
// and replays it through the SAME tested toggleItemExpand used before, so
// Accordion never owns this state itself, only displays it.
// --- provider expand/collapse (shadcn-vue Accordion) — unchanged from ProvidersView.vue ---
const expandState: ExpandState = reactive({
manuallyExpanded: new Set<string>(),
manuallyCollapsed: new Set<string>(),
})
// --- model/alias search filter (operator feature) ---
// --- model/alias search filter (operator feature) — unchanged from ProvidersView.vue ---
const { query: modelQuery, normalized: normalizedQuery, hasQuery } = useSearchQuery()
/**
* Matching is against each model's full ROUTABLE id
* (routableModelId(p.name, m) — the same string ModelChip both displays
* and copies), not the bare model id: copying a chip's text and pasting
* it back into search must find it, and the routable form is strictly
* more permissive (it contains the bare id as a substring), so this
* never hides a match the bare-id form would have found.
*/
function modelMatches(providerName: string, modelId: string): boolean {
return routableModelId(providerName, modelId).toLowerCase().includes(normalizedQuery.value)
}
@@ -118,7 +95,6 @@ const filteredAliases = computed<AdminAliasView[]>(() => {
return hasQuery.value ? all.filter(aliasMatches) : all
})
/** expandedProviders is the EFFECTIVE (possibly auto-expanded-by-search) set — see lib/search-expand.ts's own doc comments for the manual/auto-expand/override semantics. */
const expandedProviders = computed<Set<string>>(() =>
computeExpandedItems(
expandState,
@@ -127,18 +103,10 @@ const expandedProviders = computed<Set<string>>(() =>
),
)
/** toggleProvider reads the CURRENT effective (visible) state for name before flipping it — see toggleItemExpand's own doc comment for exactly which bug this avoids. */
function toggleProvider(name: string): void {
toggleItemExpand(expandState, name, expandedProviders.value.has(name))
}
/**
* expandedProviderValues adapts expandedProviders (a Set) to Accordion's
* `type="multiple"` v-model contract (a string[]): reads out as
* Array.from(expandedProviders.value); writes replay each name whose
* membership changed through toggleProvider — Accordion never mutates
* expandState directly, it only tells this setter what changed.
*/
const expandedProviderValues = computed<string[]>({
get: () => Array.from(expandedProviders.value),
set: (newValues) => {
@@ -152,58 +120,33 @@ const expandedProviderValues = computed<string[]>({
},
})
// A stale manuallyCollapsed suppression from one search must never
// silently carry into a later, unrelated one — see ExpandState's own doc
// comment. Watching modelQuery (not just the explicit clear button)
// covers backspacing to empty too.
watch(modelQuery, (value) => {
if (value.trim() === '') clearExpandOverrides(expandState)
})
/** visibleModels is p's own model list, filtered to matches while a query is active — p only appears in filteredProviders at all because it has one, so this is never empty in that case; it exists so the accordion shows WHICH models matched instead of re-showing all 70+ with no distinction. */
function visibleModels(p: AdminProviderView): string[] {
return hasQuery.value ? p.models.filter((m) => modelMatches(p.name, m)) : p.models
}
/** ZERO_MODEL_RATE is the fallback ProviderRateBadge reads for a model p.modelRates has no entry for — should not happen (admin.go's buildAdminOverview populates one entry per Models id, unconditionally), but a defensive fallback keeps a stale/mismatched client build from throwing rather than just under-reporting. */
const ZERO_MODEL_RATE: AdminModelRateView = { attemptsDay: 0, failuresDay: 0 }
/**
* modelRateFor looks up one model's counters within p.modelRates (Feature
* A, v0.22), falling back to ZERO_MODEL_RATE. The optional-chain on
* modelRates itself (folded review minor, v0.22 review round), not just
* on the lookup, matches ZERO_MODEL_RATE's own doc comment: a stale
* client talking to an older server build (before this field existed at
* all) would otherwise throw on `.modelRates[model]` rather than fall
* back — the type says modelRates is always present, but a real response
* across a version skew is the one case that type cannot guarantee.
*/
function modelRateFor(p: AdminProviderView, model: string): AdminModelRateView {
return p.modelRates?.[model] ?? ZERO_MODEL_RATE
}
/** modelIsDegraded gates the per-model rate badge (spec: "ONLY when that model is degraded") so a healthy or no-traffic model's ModelChip renders with no badge beside it at all. */
function modelIsDegraded(p: AdminProviderView, model: string): boolean {
const r = modelRateFor(p, model)
return isModelDegraded(r.attemptsDay, r.failuresDay)
}
/** ZERO_MODEL_META is modelMetaFor's fallback, mirroring ZERO_MODEL_RATE's own doc comment above — every sub-field undefined reads as "nothing known", not a thrown error, across a version-skewed client/server pair. */
const ZERO_MODEL_META: AdminModelMetaView = {}
/** modelMetaFor looks up one model's resolved metadata within p.modelMeta (feature v0.23), falling back to ZERO_MODEL_META. */
function modelMetaFor(p: AdminProviderView, model: string): AdminModelMetaView {
return p.modelMeta?.[model] ?? ZERO_MODEL_META
}
// --- discovery circuit breaker (feat/provider-health) ---
//
// Badge shown ONLY for 'open'/'half-open' — mirroring modelIsDegraded's
// own "no badge when healthy" convention above: a 'closed' provider (the
// common case) renders no breaker badge at all, exactly like a
// non-degraded model gets no ProviderRateBadge.
// --- discovery circuit breaker (feat/provider-health) — unchanged from ProvidersView.vue ---
/** healthBadgeVariant maps healthState to the shadcn-vue Badge variant: destructive (backing off) for 'open', secondary (in progress) for 'half-open', undefined (no badge) for 'closed'. */
function healthBadgeVariant(state: AdminProviderView['healthState']): 'destructive' | 'secondary' | undefined {
switch (state) {
case 'open':
@@ -215,36 +158,17 @@ function healthBadgeVariant(state: AdminProviderView['healthState']): 'destructi
}
}
/** healthBadgeLabel renders the breaker badge's text: "open (in Ns)" while backing off (formatUntil omits the parenthetical once openUntil has passed or is unset), or "half-open" while probing. */
function healthBadgeLabel(p: AdminProviderView): string {
const until = formatUntil(p.openUntil)
return until ? `${p.healthState} (${until})` : p.healthState
}
/** healthDetailClass matches the accordion-content detail row's text color to healthBadgeVariant's own trigger-row Badge color, rather than hardcoding text-destructive for every non-closed state: destructive (red) for 'open', muted (grey, the same neutral tone secondary conveys on the Badge) for 'half-open'. */
function healthDetailClass(state: AdminProviderView['healthState']): string {
return state === 'open' ? 'text-destructive' : 'text-muted-foreground'
}
// --- upstream latency (feat: instrument upstream latency) ---
//
// Beside the discovery-health badge above: no badge at all for a stream
// state with no observations yet (p.latency undefined, or missing that
// key) — mirrors healthBadgeVariant/modelIsDegraded's own "nothing to
// show reads as no badge" convention, so absent data reads as absent,
// never as a fabricated zero.
//
// The two stream states are deliberately labeled and worded differently,
// never merged into one figure (task brief's own correctness rule):
// streaming shows avgTtfbMs, the load-sensitive reading, labeled "ttfb";
// non-streaming shows avgDurationMs labeled "avg" — NEVER "latency" or
// "ttfb" — because a non-streaming provider buffers its whole completion
// before sending anything, so its own avgTtfbMs is approximately equal
// to avgDurationMs and carries the identical output-length contamination
// a raw total-duration figure always has (a 4000-token answer legitimately
// takes longer than a 50-token one on an equally healthy provider).
// --- per-replica upstream latency (feat: instrument upstream latency) — unchanged from ProvidersView.vue ---
/** streamingLatency/nonStreamingLatency pull p.latency's two known stream-state keys (admin.go's buildAdminLatencyViews emits exactly these two strings) — undefined when that stream state has no observations yet. */
function streamingLatency(p: AdminProviderView): AdminLatencyView | undefined {
return p.latency?.streaming
}
@@ -252,57 +176,27 @@ function nonStreamingLatency(p: AdminProviderView): AdminLatencyView | undefined
return p.latency?.['non-streaming']
}
/** latencyObservationCount renders v.count (omitted, per admin.go, when 0) as a singular/plural detail phrase for a badge title, falling back to "no observations counted" for the defensive case of a present view with an absent count (version skew). */
function latencyObservationCount(v: AdminLatencyView): string {
if (v.count === undefined) return 'no observations counted'
return `${v.count} ${v.count === 1 ? 'observation' : 'observations'} on this replica`
}
/**
* perReplicaCaveat is appended to every latency/provenance badge's title
* (task brief's correctness rule 1): this figure comes from the same
* in-process accumulator the rate-limit rejection counter reads, never
* Redis, so it reflects only whichever of this deployment's several
* Traefik replicas answered the current poll — never a fleet-wide
* average, and a freshly restarted pod shows a short window. F4
* (dashboard-plan.md) names the CONCRETE replica (admin.go's g.replica,
* echoed as overview.replica) rather than the abstract warning alone — a
* reader can now tell not just THAT this figure is replica-scoped, but
* WHICH replica it came from. Falls back to the generic "this replica"
* phrasing for a version-skewed server build that has not shipped the
* field yet (undefined, never a fabricated id).
*/
function perReplicaCaveat(): string {
const replica = overview.value?.replica
const who = replica ? `replica ${replica}` : 'this replica'
return `Per-replica (${who}), in-process only — not a fleet-wide average across this deployment’s replicas.`
}
/** streamingLatencyTitle is the streaming ttfb badge's hover/title detail. */
function streamingLatencyTitle(v: AdminLatencyView): string {
return `Average time to first byte, the load-sensitive reading for streaming traffic. ${latencyObservationCount(v)}. ${perReplicaCaveat()}`
}
/** nonStreamingLatencyTitle is the non-streaming avg-duration badge's hover/title detail — explicit that this is NOT a latency/ttfb reading (correctness rule 2). */
function nonStreamingLatencyTitle(v: AdminLatencyView): string {
return `Average total response time, including generation — not a load-sensitive signal like streaming ttfb, since this provider buffers the whole completion before sending anything. ${latencyObservationCount(v)}. ${perReplicaCaveat()}`
}
// --- usage provenance (feat: expose token-accounting provenance) ---
//
// Token accounting has three provenances (see AdminProvenanceView's own
// doc comment): reported (the healthy default — no badge, mirroring the
// health/latency badges' own "nothing to show reads as no badge"
// convention), estimated (a non-streaming response carried no usage, so
// prompt was substituted from request body size), and unbilled (a
// streaming response carried no usage, so the request was counted but
// zero tokens were billed — a provider potentially serving completions
// entirely free against every budget). estimated/unbilled are two
// DISTINCT badges, never merged into one "not reported" indicator —
// collapsing them would hide exactly the fact this feature exists to
// surface (task brief's own correctness rule).
// --- usage provenance (feat: expose token-accounting provenance) — unchanged from ProvidersView.vue ---
/** estimatedProvenance/unbilledProvenance pull p.provenance's two known kind keys (admin.go's buildAdminProvenanceViews emits only these two strings, never "reported") — undefined when that kind has no observations yet. */
function estimatedProvenance(p: AdminProviderView): AdminProvenanceView | undefined {
return p.provenance?.estimated
}
@@ -310,34 +204,48 @@ function unbilledProvenance(p: AdminProviderView): AdminProvenanceView | undefin
return p.provenance?.unbilled
}
/** estimatedProvenanceTitle is the estimated-accounting badge's hover/title detail. */
function estimatedProvenanceTitle(v: AdminProvenanceView): string {
const plural = v.requests === 1 ? '' : 's'
return `${v.requests} non-streaming response${plural} carried no usage from the provider — prompt tokens were estimated from request body size instead (${v.tokens} tokens billed from the estimate), completion billed as zero. ${perReplicaCaveat()}`
}
/** unbilledProvenanceTitle is the unbilled-accounting badge's hover/title detail — explicit about the budget-exposure implication (task brief's own framing). */
function unbilledProvenanceTitle(v: AdminProvenanceView): string {
const plural = v.requests === 1 ? '' : 's'
return `${v.requests} streaming response${plural} carried no usage from the provider — the request was counted but zero tokens were billed. If this keeps happening, this provider may be serving completions entirely free against every budget. ${perReplicaCaveat()}`
}
// --- model aliases (sortable DataTable, operator directive) ---
// --- fleet-wide performance (NEW: GET /admin/api/performance?kind=provider) ---
//
// The alias id gets the ModelChip copy treatment (an alias name IS the
// routable string a caller sends as `model`); the target column stays
// plain text.
// Fleet-wide (Redis-backed via the latency histogram counters, WP-A), UNLIKE
// every badge above this point in the file — a provider's own p50/p95
// here is the SAME reading regardless which replica answered the poll,
// the opposite of the per-replica caveat every latency/provenance badge
// above carries. timeouts/failovers are always populated (they do not
// need admin.stats.latency — only the percentile fields do, AdminPerfRow's
// own doc comment); a provider with zero of either renders no badge at
// all, mirroring every other "nothing to show" convention in this file.
function providerPerfFor(name: string): AdminPerfRow | undefined {
return models.providerPerf.find((r) => r.id === name)
}
function fleetLatencyTitle(kind: 'p50' | 'p95'): string {
const { window, span } = models.perfWindow
return `Fleet-wide (every replica combined) ${kind === 'p50' ? 'median' : '95th percentile'} response time, over the last ${span} ${window}${span === 1 ? '' : 's'}.`
}
function fleetCountTitle(kind: 'timeouts' | 'failovers'): string {
const { window, span } = models.perfWindow
const noun = kind === 'timeouts' ? 'requests that exceeded the deadline' : "requests failed away from this provider to another candidate"
return `${noun}, fleet-wide, over the last ${span} ${window}${span === 1 ? '' : 's'}.`
}
// --- model aliases (sortable DataTable) — unchanged from ProvidersView.vue ---
const aliasColumns: ColumnDef<AdminAliasView, unknown>[] = [
{
id: 'alias',
header: 'Alias',
accessorFn: (a) => a.alias,
// Optional-chained (review fix, folded minor) the same way
// modelMetaFor guards the provider path below: AdminAliasView.
// modelMeta is typed as always-present, but a real response from a
// server build older than this field (version skew) would otherwise
// throw on `.modelMeta.contextTokens` here instead of just omitting
// the hover detail.
cell: ({ row }) =>
h(ModelChip, {
id: row.original.alias,
@@ -425,12 +333,17 @@ const aliasEmptyMessage = computed(() =>
<CardHeader>
<CardTitle>Providers</CardTitle>
<CardDescription>
Every configured upstream and its last discovery refresh. Search filters providers and aliases by
model id.
Every configured upstream, its last discovery refresh, and fleet-wide performance. Search filters providers and
aliases by model id.
</CardDescription>
<SearchInput v-model="modelQuery" placeholder="Search models or aliases..." class="mt-2 max-w-sm" />
</CardHeader>
<CardContent>
<CardContent class="flex flex-col gap-3">
<Alert v-if="!models.latencyEnabled" variant="warn">
<AlertDescription>
Fleet p50/p95 are off for this deployment. Enable <code>admin.stats.latency</code> to see them.
</AlertDescription>
</Alert>
<p v-if="!overview?.providers.length" class="py-6 text-center text-sm text-muted-foreground">none</p>
<p
v-else-if="hasQuery && filteredProviders.length === 0"
@@ -439,31 +352,15 @@ const aliasEmptyMessage = computed(() =>
no providers match &quot;{{ modelQuery }}&quot;
</p>
<template v-else>
<!-- F4 (dashboard-plan.md): latency/provenance badges below are per-replica, in-process figures — this caption names which replica this poll answered from, up front, instead of leaving it to each badge's own hover title. -->
<p class="mb-2 text-xs text-muted-foreground">{{ perReplicaCaveat() }}</p>
<p class="text-xs text-muted-foreground">{{ perReplicaCaveat() }} Fleet p50/p95/timeouts/failovers below are fleet-wide, not per-replica.</p>
<Accordion v-model="expandedProviderValues" type="multiple" class="rounded-md border px-3">
<AccordionItem v-for="p in filteredProviders" :key="p.name" :value="p.name">
<!--
P2 review fix: the provider-name link moved OUTSIDE
AccordionTrigger — see goToProviderModels' own doc comment
above for why. `py-2.5` matches AccordionTrigger.vue's own
vertical padding so the two line up in this row.
`header-class="flex-1"` fills the AccordionHeader (<h3>) that
wraps the trigger button, since AccordionTrigger's own
`class` prop lands on the inner button, not the header — see
AccordionTrigger.vue's doc comment. Without it the header
shrinks to content width, so the chevron drifts left of the
row's right edge and the empty space to its right stops
being clickable. The sr-only span gives the toggle button an
accessible name that names the provider, since the visible
text beside it is just badges and counts.
-->
<div class="flex items-center gap-2">
<button
type="button"
class="shrink-0 cursor-pointer rounded-sm py-2.5 font-medium hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
:aria-label="`View ${p.name}'s models in Charts`"
:title="`View ${p.name}'s models in Charts`"
:aria-label="`View ${p.name}'s models in the Models table`"
:title="`View ${p.name}'s models in the Models table`"
@click="goToProviderModels(p.name)"
>{{ p.name }}</button>
<AccordionTrigger header-class="flex-1">
@@ -477,12 +374,6 @@ const aliasEmptyMessage = computed(() =>
:attempts-minute="p.attemptsMinute"
:failures-minute="p.failuresMinute"
/>
<!--
Discovery circuit breaker (feat/provider-health): no
badge at all while 'closed' (the healthy, common case),
mirroring modelIsDegraded's own convention for the
per-model rate badge above.
-->
<Badge
v-if="p.healthState !== 'closed'"
as="span"
@@ -492,15 +383,6 @@ const aliasEmptyMessage = computed(() =>
>
{{ healthBadgeLabel(p) }}
</Badge>
<!--
Upstream latency (feat: instrument upstream latency):
streaming's ttfb is the load-sensitive figure; the
non-streaming badge deliberately says "avg", never
"ttfb"/"latency" — see nonStreamingLatencyTitle's own
doc comment for why that number is not a load signal.
No badge at all for a stream state with no observations
yet, mirroring the health badge's own convention above.
-->
<Badge
v-if="streamingLatency(p)?.avgTtfbMs !== undefined"
as="span"
@@ -519,15 +401,42 @@ const aliasEmptyMessage = computed(() =>
>
non-streaming avg {{ formatLatencyMs(nonStreamingLatency(p)!.avgDurationMs as number) }}
</Badge>
<!--
Usage provenance (feat: expose token-accounting
provenance): no badge at all for a kind with no
observations yet, mirroring the health/latency badges'
own convention above. estimated and unbilled are two
distinct badges — never merged (correctness rule, task
brief) — since one substitutes an approximation and the
other bills nothing at all.
-->
<Badge
v-if="providerPerfFor(p.name)?.p50Ms !== undefined"
as="span"
variant="outline"
class="font-normal tabular-nums"
:title="fleetLatencyTitle('p50')"
>
fleet p50 {{ formatLatencyMs(providerPerfFor(p.name)!.p50Ms as number) }}
</Badge>
<Badge
v-if="providerPerfFor(p.name)?.p95Ms !== undefined"
as="span"
variant="outline"
class="font-normal tabular-nums"
:title="fleetLatencyTitle('p95')"
>
fleet p95 {{ formatLatencyMs(providerPerfFor(p.name)!.p95Ms as number) }}
</Badge>
<Badge
v-if="(providerPerfFor(p.name)?.timeouts ?? 0) > 0"
as="span"
variant="secondary"
class="font-normal tabular-nums"
:title="fleetCountTitle('timeouts')"
>
{{ providerPerfFor(p.name)!.timeouts }} timeouts
</Badge>
<Badge
v-if="(providerPerfFor(p.name)?.failovers ?? 0) > 0"
as="span"
variant="secondary"
class="font-normal tabular-nums"
:title="fleetCountTitle('failovers')"
>
{{ providerPerfFor(p.name)!.failovers }} failovers
</Badge>
<Badge
v-if="estimatedProvenance(p)"
as="span"
@@ -583,17 +492,7 @@ const aliasEmptyMessage = computed(() =>
:input-per-m-tok-usd="modelMetaFor(p, m).inputPerMTokUsd"
:output-per-m-tok-usd="modelMetaFor(p, m).outputPerMTokUsd"
/>
<!-- ScopeLink (F6): jumps to Charts, scoped to this exact model — renders here as a real <button> inside AccordionContent (not AccordionTrigger), same as the provider-name button above (P2 review fix moved both out of the trigger). -->
<ScopeLink :label="routableModelId(p.name, m)" :scope="`model:${routableModelId(p.name, m)}`" />
<!--
Context chip, visible, muted (feature v0.23) — only
when known. Cost stays hover-only on ModelChip itself
(operator directive: chips + hover would otherwise
show the identical price figures twice; context
visible for scanning, price on hover for the exact
numbers).
-->
<EntityLink :label="routableModelId(p.name, m)" kind="model" :id="routableModelId(p.name, m)" />
<span
v-if="modelMetaFor(p, m).contextTokens !== undefined"
title="context window"
@@ -601,14 +500,6 @@ const aliasEmptyMessage = computed(() =>
>
{{ formatContextWindow(modelMetaFor(p, m).contextTokens as number) }}
</span>
<!--
attempts-minute/failures-minute deliberately omitted
(SHOULD-2, v0.22 review round): AdminModelRateView no
longer carries them at all, so leaving them unbound
(undefined) is what tells ProviderRateBadge to render
its day-window-only detail (dayRateTitle) instead of
claiming a live minute-window reading no data backs.
-->
<ProviderRateBadge
v-if="modelIsDegraded(p, m)"
:attempts-day="modelRateFor(p, m).attemptsDay"
+8 -8
View File
@@ -5,10 +5,10 @@ import { Badge } from '@/components/ui/badge'
import { dayRateTitle, formatRatePercent, minuteRateTitle, providerRateStatus, providerSuccessRate } from '@/lib/provider-rate'
/**
* ProviderRateBadge is the Providers tab's success-rate badge (Feature A,
* v0.22) — reused for both a provider's own row (ProvidersView.vue's
* ProviderRateBadge is the Models page's success-rate badge (Feature A,
* v0.22) — reused for both a provider's own row (ProviderHealthPanel.vue's
* accordion trigger, always shown) and a single degraded model beside its
* ModelChip (ProvidersView.vue's accordion content, shown only when
* ModelChip (ProviderHealthPanel.vue's accordion content, shown only when
* isModelDegraded). attemptsMinute/failuresMinute are OPTIONAL (SHOULD-2,
* v0.22 review round): the provider-row call site passes them (admin.go's
* adminProviderView carries minute counters); the per-model call site
@@ -56,11 +56,11 @@ const detail = computed(() =>
// Badge has no dedicated "amber"/"muted" variant (ui/badge/index.ts) —
// severe reuses the built-in `destructive` variant exactly like every
// other error state in this panel (e.g. ProvidersView's own lastErr
// icon); healthy/no-traffic reuse `secondary`/`outline`, the panel's
// existing "quiet" idioms (ProvidersView's provider-type badge is
// `variant="secondary"`); degraded borrows the semantic --status-warn
// token (main.css — MUST-1, v0.22 review round: --chart-tokens-out, this
// other error state in this panel (e.g. ProviderHealthPanel.vue's own
// lastErr icon); healthy/no-traffic reuse `secondary`/`outline`, the
// panel's existing "quiet" idioms (ProviderHealthPanel.vue's
// provider-type badge is `variant="secondary"`); degraded borrows the
// semantic --status-warn token (main.css — MUST-1, v0.22 review round: --chart-tokens-out, this
// component's original choice, measures ~2.22:1 against --card in light
// mode, a WCAG 1.4.3 text-contrast failure on exactly the "something's
// wrong" tier; --status-warn is tuned for body-text contrast instead,
-69
View File
@@ -1,69 +0,0 @@
<script setup lang="ts">
import { faChartLine } from '@fortawesome/free-solid-svg-icons'
import { useNavStore } from '@/stores/nav'
/**
* ScopeLink (F6) is the one "jump to this scope's Charts view" control in
* this panel: usage-columns.ts's id cell (Users/Groups tables and the
* nested Members table), UsageView.vue's Groups accordion header, and
* WP-B2's ProvidersView.vue per-model chips all render one of these
* instead of each hand-rolling their own click-through-to-Charts wiring.
*
* A real `<button type="button">` (P2 review fix). This used to be a
* `<span role="link" tabindex="0">`, justified at the time by the Groups
* accordion trigger use site nesting it INSIDE shadcn-vue's
* AccordionTrigger — itself a real `<button>` (reka-ui's own
* AccordionTrigger primitive). That justification was wrong on its own
* terms: the HTML button content model forbids ANY descendant carrying a
* `tabindex` attribute, not just a nested button/anchor, and ARIA's
* `button` role additionally declares "children presentational: true", so
* assistive technology flattened the inner span and never exposed it as
* its own link at all — axe flags this as `nested-interactive`. Reka's
* AccordionTrigger also only listens for `click` (not `keydown`), so
* `stopPropagation()` on click and Enter suppressed the accordion's own
* toggle, but Space on the focused span did nothing and fell through to
* page scroll instead of activating anything.
*
* The actual fix is structural, not a markup trick: every call site now
* renders this component as a SIBLING of whatever it used to nest inside
* (UsageView.vue's Groups header, ProvidersView.vue's provider-name
* header — see both templates), never a descendant of another interactive
* element, so a real `<button>` is valid there. No `role` override: a
* plain button's implicit role is already correct here (activating it
* performs an action — a programmatic Pinia-store navigation, not a
* traditional `href` follow — which is exactly what a native `<button>`,
* not `<a>`, is for; WAI-ARIA's first rule of ARIA use is not to override
* a native element's semantics without a reason, and there is none left
* here). `stopPropagation` on click stays — harmless everywhere (a table
* cell, ProvidersView's per-model chips) and still correct defensively at
* the Groups/Providers headers, where this button sits next to, not
* inside, the accordion's own trigger.
*/
const props = defineProps<{
/** The visible + accessible label, e.g. a user id, group id, or model id. */
label: string
/** The scope string useNavStore.goToCharts expects — "total" | "user:{id}" | "group:{id}" | "model:{id}" (history.ts's own convention). */
scope: string
}>()
const nav = useNavStore()
function onClick(event: MouseEvent): void {
event.stopPropagation()
nav.goToCharts(props.scope)
}
</script>
<template>
<button
type="button"
:aria-label="`View ${label} in Charts`"
:title="`View ${label} in Charts`"
class="inline-flex cursor-pointer items-center gap-1 rounded-sm text-inherit hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
@click="onClick"
>
<span>{{ label }}</span>
<FontAwesomeIcon :icon="faChartLine" class="size-3" aria-hidden="true" />
</button>
</template>
+7 -5
View File
@@ -9,11 +9,13 @@ import { cn } from '@/lib/utils'
/**
* SearchInput is the one search-box control every filter in this panel
* uses — ProvidersView's model search, UsageView's user/group search, and
* ChartsView's scope-picker search each grew an identical ~22-line
* magnifying-glass-icon + Input + clear-button block; extracted into one
* component (vue.md: "if you've written it twice, you owe an
* abstraction"). A controlled component (modelValue/update:modelValue),
* uses (today: ProviderHealthPanel's model search, ConsumerDirectory's
* user/group search, TargetsView's target search, EventsView's user
* filter, ModelCatalogTable's model search) — each pre-redesign call site
* grew an identical ~22-line magnifying-glass-icon + Input + clear-button
* block on its own; extracted into one component (vue.md: "if you've
* written it twice, you owe an abstraction"). A controlled component
* (modelValue/update:modelValue),
* not v-model'd internally, since the incoming modelValue is a prop —
* callers pair it with `useSearchQuery()` (composables/useSearchQuery.ts)
* for the query/normalized/hasQuery/clear state, or v-model it against any
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { PAGE_NAV } from '@/lib/pages'
import { useNavStore } from '@/stores/nav'
/**
* SidebarNav (redesign-plan.md section 3.1) is the shell's primary
* navigation: a vertical list of pages on a normal-width viewport,
* collapsing to a horizontally-scrollable bar on a narrow one (the
* `md:` breakpoint below) rather than a hamburger/drawer — every page is
* still one tap away, and the panel is an internal admin tool with no
* phone-first audience to justify hiding the list behind an extra tap.
*
* Real `<button>` elements, not `<a href>` (mirrors EntityLink.vue's own
* structural note): activating one is a programmatic Pinia navigation
* (nav.goTo), not a traditional link follow, and there is no separate
* URL to navigate to server-side — the hash is derived FROM this state
* (composables/useHashState.ts), not the other way around.
*/
const nav = useNavStore()
</script>
<template>
<nav
aria-label="Primary"
class="flex gap-1 overflow-x-auto border-b border-border pb-2 md:w-52 md:shrink-0 md:flex-col md:border-r md:border-b-0 md:pr-3 md:pb-0"
>
<button
v-for="entry in PAGE_NAV"
:key="entry.id"
type="button"
:aria-current="nav.page === entry.id ? 'page' : undefined"
class="flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 md:w-full"
:class="nav.page === entry.id ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-muted hover:text-foreground'"
@click="nav.goTo(entry.id)"
>
<FontAwesomeIcon :icon="entry.icon" class="size-4" aria-hidden="true" />
<span>{{ entry.label }}</span>
</button>
</nav>
</template>
+60
View File
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { faCheck, faCopy, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
import { onUnmounted, ref, useTemplateRef } from 'vue'
import { Button } from '@/components/ui/button'
import { copyText } from '@/lib/clipboard'
/**
* SnippetBlock (redesign-plan.md section 3.4) is the one "here is text to
* paste into a file" surface every ChangeHelper form and ConfigTree.vue
* share — a `<pre>` plus a copy button (lib/clipboard.ts's copyText, same
* Clipboard-API-with-selection-fallback convention CsvExportButton.vue's
* download path and ModelChip.vue's own click-to-copy already use).
*/
const props = defineProps<{
/** The exact text to display and copy — YAML (lib/snippets.ts) or a single-line JSON fragment, verbatim, never re-formatted by this component. */
text: string
/** A short caption above the block, e.g. "Paste into middleware.yaml" or "Add to the user's users.json line". */
label?: string
}>()
const preRef = useTemplateRef<HTMLElement>('pre')
const state = ref<'idle' | 'copied' | 'selected' | 'failed'>('idle')
let resetTimer: ReturnType<typeof setTimeout> | undefined
async function onCopy(): Promise<void> {
state.value = await copyText(props.text, preRef.value ?? undefined)
clearTimeout(resetTimer)
resetTimer = setTimeout(() => {
state.value = 'idle'
}, 1500)
}
const BUTTON_LABEL: Record<typeof state.value, string> = {
idle: 'Copy',
copied: 'Copied',
selected: 'Selected — press ⌘/Ctrl+C',
failed: 'Copy failed',
}
onUnmounted(() => clearTimeout(resetTimer))
</script>
<template>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between gap-2">
<span v-if="label" class="text-xs font-medium text-muted-foreground">{{ label }}</span>
<Button type="button" variant="outline" size="sm" class="ml-auto" @click="onCopy">
<FontAwesomeIcon
:icon="state === 'idle' ? faCopy : state === 'failed' ? faTriangleExclamation : faCheck"
class="size-3.5"
:class="state === 'failed' ? 'text-destructive' : undefined"
aria-hidden="true"
/>
{{ BUTTON_LABEL[state] }}
</Button>
</div>
<pre ref="pre" class="overflow-x-auto rounded-md border bg-muted p-3 font-mono text-xs select-text whitespace-pre">{{ text }}</pre>
</div>
</template>
+3 -2
View File
@@ -7,8 +7,9 @@ import { computed } from 'vue'
/**
* SortHeaderButton is the one clickable, sortable-column control every
* sortable header in the panel uses — DataTable.vue's real `<th>` header
* buttons AND UsageView.vue's Groups sort toolbar (which has no `<table>`
* to put a `<th>` in — see that view's own doc comment for why).
* buttons AND ConsumerDirectory.vue's Groups sort toolbar (which has no
* `<table>` to put a `<th>` in — see that component's own "Groups
* accordion" doc comment for why).
* Extracted so the button markup, sort-icon logic, click handler, and
* accessible name exist in exactly one place (vue.md: "if you've written
* it twice, you owe an abstraction"). Extra classes on this component
+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import ClampedRangeNotice from '@/components/ClampedRangeNotice.vue'
import DataTable from '@/components/DataTable.vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { adminFetch } from '@/lib/api'
import { dayOrMonthWindow, totalsUrl } from '@/lib/range'
import { targetCallerColumns } from '@/lib/target-columns'
import { useFiltersStore } from '@/stores/filters'
import type { AdminTotalsResponse } from '@/types/api'
/**
* TargetCallers (redesign-plan.md section 3.4, MCP & Agents page) reads
* GET /admin/api/usage/totals?kind=targetcaller — per-caller request
* counts, ranked descending, dropped to zero rows server-side. Used in two
* modes from TargetsPage.vue, sharing this one component (vue.md: "if
* you've written it twice, you owe an abstraction") rather than a
* near-duplicate for each:
*
* - Per-target ("TargetCallers.vue expander"): `target` is set to the
* composed "mcp/{name}"/"agent/{name}" id (lib/target-columns.ts's
* composeTargetId) of whichever row the reader clicked in TargetsView.vue
* — shows only that target's own callers, no Target column (the panel's
* own heading already names it).
* - Fleet-wide ("top callers" card): `target` omitted — every configured
* target's callers, ranked together, WITH a Target column so a caller
* who reaches more than one target is not ambiguous.
*
* `kind=targetcaller` only supports day/month windows (redesign-plan.md
* section 1.3.v) — lib/range.ts's dayOrMonthWindow clamps the global
* filters store's current (possibly hour-window) range down to a window
* this endpoint actually accepts, rather than firing an invalid request
* whenever the reader has an hour-resolution range (24h/48h) selected.
*/
const props = withDefaults(
defineProps<{
/** Composed "mcp/{name}" / "agent/{name}" id — omitted means fleet-wide, every target. */
target?: string
title: string
limit?: number
}>(),
{ limit: 10 },
)
const filters = useFiltersStore()
const rows = ref<AdminTotalsResponse['rows']>([])
const loading = ref(false)
const loaded = ref(false)
const error = ref('')
/** reqId guards against an out-of-order response overwriting a newer one (latest-request-wins, stores/catalog.ts's own convention) — a quick target switch, or a range change mid-flight, no longer risks showing a STALE target's callers under the new heading. */
let reqId = 0
async function load(): Promise<void> {
const requestId = ++reqId
loading.value = true
try {
const { window, span } = dayOrMonthWindow(filters.window, filters.span)
const res = await adminFetch<AdminTotalsResponse>(
totalsUrl({ kind: 'targetcaller', window, span, metrics: ['req'], limit: props.limit, target: props.target }),
)
if (requestId !== reqId) return
rows.value = res.rows
error.value = ''
} catch (err) {
if (requestId !== reqId) return
error.value = err instanceof Error ? err.message : String(err)
} finally {
if (requestId === reqId) {
loading.value = false
loaded.value = true
}
}
}
watch(
[() => props.target, () => filters.window, () => filters.span],
() => void load(),
{ immediate: true },
)
const columns = computed(() => targetCallerColumns(!props.target))
const emptyMessage = computed(() => {
if (!loaded.value) return 'loading…'
return 'no callers recorded in this window'
})
</script>
<template>
<Card>
<CardHeader>
<CardTitle>{{ title }}</CardTitle>
<CardDescription>Ranked by requests, most recent day/month window available for the current range.</CardDescription>
</CardHeader>
<CardContent class="flex flex-col gap-3">
<ClampedRangeNotice :window="filters.window" :span="filters.span" />
<p v-if="error" class="text-sm text-destructive">{{ error }}</p>
<DataTable :columns="columns" :data="rows" :empty-message="emptyMessage" />
</CardContent>
</Card>
</template>
+43 -16
View File
@@ -5,20 +5,38 @@ import DataTable from '@/components/DataTable.vue'
import SearchInput from '@/components/SearchInput.vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { useSearchQuery } from '@/composables/useSearchQuery'
import { targetColumns } from '@/lib/target-columns'
import { composeTargetId, targetColumns } from '@/lib/target-columns'
import type { TargetKind } from '@/lib/target-columns'
import { useDashboardStore } from '@/stores/dashboard'
import type { AdminTargetView } from '@/types/api'
// This component backs the "MCP & Agents" tab (App.vue) — GET
// /admin/api/targets (admin.go: adminTargetsResponse, Feature B v0.21).
// This component backs the "MCP & Agents" page (redesign-plan.md section
// 3.4, TargetsPage.vue) — GET /admin/api/targets (admin.go:
// adminTargetsResponse, Feature B v0.21).
const dashboard = useDashboardStore()
const targets = computed(() => dashboard.targets)
/**
* select (redesign-plan.md section 3.4's "TargetCallers.vue expander")
* fires with the composed "mcp/{name}"/"agent/{name}" id and the target's
* own display name — TargetsPage.vue listens and renders TargetCallers.vue
* for whichever target the reader last clicked. Two separate
* targetColumns() instantiations below (MCP servers, Agents) close over
* their own fixed `kind` so a click always composes the correct prefix,
* without this component needing to carry a per-row kind field itself.
*/
const emit = defineEmits<{ select: [id: string, label: string] }>()
function onSelect(kind: TargetKind): (t: AdminTargetView) => void {
return (t) => emit('select', composeTargetId(kind, t.name), t.name)
}
// Simple, single-field substring match on name — no separate lib/*-search
// module: ProvidersView.vue's own aliasMatches/providerMatches stay inline
// for the identical reason (one field, one comparison), reserving the
// extracted-and-tested lib/usage-search.ts treatment for UsageView.vue's
// genuinely multi-entity (user-or-group-member) matching semantics.
// module: ProviderHealthPanel.vue's own aliasMatches/providerMatches stay
// inline for the identical reason (one field, one comparison), reserving
// the extracted-and-tested lib/usage-search.ts treatment for
// ConsumerDirectory.vue's genuinely multi-entity (user-or-group-member)
// matching semantics.
const { query, normalized: normalizedQuery, hasQuery } = useSearchQuery()
function nameMatches(t: AdminTargetView): boolean {
@@ -34,7 +52,7 @@ const filteredAgents = computed<AdminTargetView[]>(() => {
return hasQuery.value ? all.filter(nameMatches) : all
})
/** emptyMessage mirrors UsageView.vue's groupsEmptyMessage/usersEmptyMessage three-way pattern: distinguishes nothing configured from nothing matching the active query. */
/** emptyMessage mirrors ConsumerDirectory.vue's groupsEmptyMessage/usersEmptyMessage three-way pattern: distinguishes nothing configured from nothing matching the active query. */
function emptyMessage(configuredCount: number, filteredCount: number): string {
if (!configuredCount) return 'none configured'
if (hasQuery.value && filteredCount === 0) return `no targets match "${query.value}"`
@@ -44,10 +62,13 @@ function emptyMessage(configuredCount: number, filteredCount: number): string {
const mcpEmptyMessage = computed(() => emptyMessage(targets.value?.mcpServers.length ?? 0, filteredMCPServers.value.length))
const agentsEmptyMessage = computed(() => emptyMessage(targets.value?.agents.length ?? 0, filteredAgents.value.length))
// One shared column-def set (lib/target-columns.ts) for both tables below —
// stateless ColumnDef objects, safe to reuse across DataTable's two
// separate useVueTable instances (each owns its own sorting state).
const columns = targetColumns()
// Two separate column-def sets (lib/target-columns.ts) — MCP servers and
// Agents each close over their own fixed `kind` (see onSelect above) so a
// name click composes the right "mcp/…"/"agent/…" id. Stateless ColumnDef
// objects otherwise, safe to reuse across DataTable's two separate
// useVueTable instances (each owns its own sorting state).
const mcpColumns = targetColumns(onSelect('mcp'))
const agentColumns = targetColumns(onSelect('agent'))
</script>
<template>
@@ -57,20 +78,26 @@ const columns = targetColumns()
<Card>
<CardHeader>
<CardTitle>MCP servers</CardTitle>
<CardDescription>Configured MCP proxy targets, the groups allowed to reach them, and their request counters.</CardDescription>
<CardDescription>
Configured MCP proxy targets, the groups allowed to reach them, and their request counters. Click a name to see its
callers.
</CardDescription>
</CardHeader>
<CardContent>
<DataTable :columns="columns" :data="filteredMCPServers" :empty-message="mcpEmptyMessage" />
<DataTable :columns="mcpColumns" :data="filteredMCPServers" :empty-message="mcpEmptyMessage" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Agents</CardTitle>
<CardDescription>Configured A2A agent proxy targets, the groups allowed to reach them, and their request counters.</CardDescription>
<CardDescription>
Configured A2A agent proxy targets, the groups allowed to reach them, and their request counters. Click a name to
see its callers.
</CardDescription>
</CardHeader>
<CardContent>
<DataTable :columns="columns" :data="filteredAgents" :empty-message="agentsEmptyMessage" />
<DataTable :columns="agentColumns" :data="filteredAgents" :empty-message="agentsEmptyMessage" />
</CardContent>
</Card>
</div>
+183
View File
@@ -0,0 +1,183 @@
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js'
import { computed, useId } from 'vue'
import { Line } from 'vue-chartjs'
import '@/lib/chart-setup'
import { useThemeColors } from '@/composables/useThemeColors'
import { formatExactInt } from '@/lib/format'
/** One line-chart series: a label, its values (index-aligned with the `labels` prop below), and which main.css --chart-* token colors it. */
export interface TimeSeriesDataset {
label: string
data: number[]
/** A main.css --chart-* custom property name (e.g. '--chart-requests') — resolved live via getComputedStyle (this component's own resolveToken, below), so a chart already on screen repaints on a `prefers-color-scheme` flip. */
color: string
}
/**
* TimeSeriesComparisonDataset is one `comparisonDatasets` entry — a plain
* TimeSeriesDataset plus an OPT-IN `labelSuffix`, appended to `label`
* verbatim (undefined/empty appends nothing). Callers decide per dataset,
* not this component for all of them: SpendPage.vue's own "vs previous
* period" comparison sets `labelSuffix: ' (previous period)'` (Q5), while
* BurnDownChart.vue's flat budget reference line — also rendered through
* this same `comparisonDatasets` prop, since a budget ceiling is exactly
* the "reference overlay, not a component of the current total" this prop
* already means — sets none, so its line reads plain "Budget", never the
* misleading "Budget (previous period)" (P2 item 5: a fixed
* budget line is not a previous-period ANYTHING).
*/
export interface TimeSeriesComparisonDataset extends TimeSeriesDataset {
labelSuffix?: string
}
/**
* TimeSeriesChart (redesign-plan.md section 3.4) is the redesign's shared
* line-chart primitive — Spend's cost breakdown (stacked), Reliability's
* per-provider error rate (overlaid, not stacked), and any other
* multi-series-over-time view build on this ONE component rather than
* each growing its own vue-chartjs wiring. It is the only chart component
* in this panel — lib/chart-setup.ts registers exactly the Line/Filler
* pieces it uses, nothing more.
*
* `comparisonDatasets` (Q5/section 3.1 "vs previous period", and
* BurnDownChart.vue's budget reference line) renders as a SEPARATE set of
* dashed lines, one per entry, matched to its solid counterpart by array
* index and color — never stacked, never filled, regardless of `stacked`,
* since a comparison line is a reference overlay, not a component of the
* current total.
*/
const props = withDefaults(
defineProps<{
labels: string[]
datasets: TimeSeriesDataset[]
/** A reference/overlay series, one dashed line per entry — omitted/empty draws no comparison. Each entry's own `labelSuffix` (TimeSeriesComparisonDataset) decides whether its label reads "vs previous period" or plain. */
comparisonDatasets?: TimeSeriesComparisonDataset[]
/** Stack every dataset into one filled total (Spend's cost breakdown) instead of overlaying them as independent lines (Reliability's per-provider error rate). */
stacked?: boolean
/** Formats a raw value for the tooltip and y-axis ticks — defaults to formatExactInt (lib/format.ts). Cost callers pass formatCost, a ratio series a percent formatter, and so on. */
valueFormatter?: (value: number) => string
/**
* ariaLabel is this chart's accessible name (P3 item 16) — a bare
* `<canvas>` has no text alternative at all for a screen reader, so
* every call site supplies a short description of what the chart
* shows (e.g. "Spend by model over time"). Required, not optional:
* there is no safe generic default that would be meaningful across
* this component's very different callers (Spend's breakdown,
* BurnDownChart's burn-down, Reliability's five series charts,
* UserDetail's two per-user charts).
*/
ariaLabel: string
}>(),
{
comparisonDatasets: () => [],
stacked: false,
valueFormatter: formatExactInt,
},
)
const descId = `timeseries-chart-desc-${useId()}`
/**
* srSummary is the sr-only fallback's own text content (P3 item 16's fix
* instruction: "an sr-only summary (latest value, total)") — one short
* sentence per dataset, latest bucket's value and the sum across the
* whole range, run through the SAME valueFormatter the visible tooltip/
* axis use, so a screen reader hears figures in the identical units a
* sighted reader sees. Comparison datasets are left out: they are a
* reference overlay of the SAME primary series shifted back a period,
* not new information this summary needs to repeat.
*/
const srSummary = computed<string>(() => {
if (props.datasets.length === 0 || props.labels.length === 0) return 'No data for this range.'
return props.datasets
.map((d) => {
const latest = d.data.length > 0 ? d.data[d.data.length - 1] : 0
const total = d.data.reduce((sum, v) => sum + v, 0)
return `${d.label}: latest ${props.valueFormatter(latest)}, total ${props.valueFormatter(total)} over ${props.labels.length} buckets.`
})
.join(' ')
})
const { foreground, mutedForeground, border } = useThemeColors()
/** resolveToken reads one of main.css's --chart-* custom properties from :root, live — see this component's own doc comment on `color` above. */
function resolveToken(cssVar: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim()
}
const chartData = computed<ChartData<'line'>>(() => {
// Touch the theme ref so this computed re-runs (and re-reads the CSS
// custom properties via resolveToken) on a `prefers-color-scheme` flip.
void foreground.value
const solid = props.datasets.map((d) => {
const color = resolveToken(d.color)
return {
label: d.label,
data: d.data,
borderColor: color,
backgroundColor: color,
fill: props.stacked,
stack: props.stacked ? 'main' : undefined,
tension: 0.2,
pointRadius: 2,
borderWidth: 2,
}
})
const comparison = props.comparisonDatasets.map((d) => {
const color = resolveToken(d.color)
return {
label: `${d.label}${d.labelSuffix ?? ''}`,
data: d.data,
borderColor: color,
backgroundColor: 'transparent',
borderDash: [6, 4],
fill: false,
tension: 0.2,
pointRadius: 0,
borderWidth: 1.5,
}
})
return { labels: props.labels, datasets: [...solid, ...comparison] }
})
const chartOptions = computed<ChartOptions<'line'>>(() => ({
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: { mode: 'index', intersect: false },
scales: {
x: {
stacked: props.stacked,
grid: { color: border.value, display: false },
ticks: { color: mutedForeground.value },
},
y: {
// Zero-based, honest axis (dataviz rule) — never a truncated y-scale that would exaggerate a small change.
stacked: props.stacked,
beginAtZero: true,
grid: { color: border.value },
ticks: { color: mutedForeground.value, callback: (value) => props.valueFormatter(Number(value)) },
},
},
plugins: {
legend: {
display: props.datasets.length > 1 || props.comparisonDatasets.length > 0,
labels: { color: foreground.value },
},
tooltip: {
callbacks: {
label: (ctx) => `${ctx.dataset.label}: ${props.valueFormatter(ctx.parsed.y ?? 0)}`,
},
},
},
}))
</script>
<template>
<div class="h-72 w-full" role="img" :aria-label="ariaLabel" :aria-describedby="descId">
<Line :data="chartData" :options="chartOptions" />
<p :id="descId" class="sr-only">{{ srSummary }}</p>
</div>
</template>
+50
View File
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import EntityLink from '@/components/EntityLink.vue'
/** One TopList.vue row — an entity id/label, its formatted value, and which EntityLink.vue target it opens. */
export interface TopListItem {
/** The raw entity id EntityLink.vue navigates on (a user id or a canonical model id). */
id: string
/** The visible label — usually the same as `id`, but callers may pass a friendlier form. */
label: string
/** Already-formatted display value (lib/format.ts) — this component renders it verbatim, never reformats. */
value: string
kind: 'user' | 'model'
}
/**
* TopList (redesign-plan.md section 3.4) is the Home page's ranked-list
* card: "top 5 users by cost", "top 5 models". Each row is an EntityLink
* so clicking a user opens Consumers pre-filtered to them, a model opens
* Models — the SAME "jump to detail" affordance every table's id column
* (usage-columns.ts, model-table-columns.ts) offers, reused here rather
* than a plain unlinked list.
*/
defineProps<{
title: string
items: TopListItem[]
/** Shown in place of the list when `items` is empty — e.g. "no traffic in this range yet". */
emptyText: string
}>()
</script>
<template>
<Card>
<CardHeader>
<CardTitle>{{ title }}</CardTitle>
</CardHeader>
<CardContent>
<p v-if="items.length === 0" class="text-sm text-muted-foreground">{{ emptyText }}</p>
<ol v-else class="flex flex-col gap-1.5">
<li v-for="(item, i) in items" :key="item.id" class="flex items-center justify-between gap-3 text-sm">
<span class="flex min-w-0 items-center gap-2">
<span class="w-4 shrink-0 text-right text-xs text-muted-foreground">{{ i + 1 }}</span>
<EntityLink :label="item.label" :kind="item.kind" :id="item.id" class="min-w-0 truncate" />
</span>
<span class="shrink-0 font-medium tabular-nums">{{ item.value }}</span>
</li>
</ol>
</CardContent>
</Card>
</template>
+7 -6
View File
@@ -15,12 +15,13 @@ import { ratioTier } from '@/lib/usage-bars'
* `aria-valuetext`, so a screen reader announces the same number a
* sighted reader sees in the fill width and the `title` tooltip.
*
* Used two places (usage-columns.ts's numeric cells, UsageView.vue's
* group detail grid) — both pass a fully-computed BudgetRatio plus the
* caller's own human-readable `valueText`, so this component has no
* formatting opinion of its own (formatCost/formatExactInt stay the
* caller's job, same "logic lives in lib/, not the component" split
* every other shared component in this panel follows).
* Used across the panel (usage-columns.ts's numeric cells,
* ConsumerDirectory.vue's group detail grid, UserDetail.vue's headroom
* bars) — every caller passes a fully-computed BudgetRatio plus its own
* human-readable `valueText`, so this component has no formatting
* opinion of its own (formatCost/formatExactInt stay the caller's job,
* same "logic lives in lib/, not the component" split every other
* shared component in this panel follows).
*/
const props = defineProps<{
/** used / limit — may exceed 1 (a scope's usage window can roll past its limit before the next request is rejected). */
-153
View File
@@ -1,153 +0,0 @@
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js'
import { computed } from 'vue'
import { Bar } from 'vue-chartjs'
import '@/lib/chart-setup'
import { useThemeColors } from '@/composables/useThemeColors'
import { formatBucketLabel, formatCompactCount, formatCost, formatExactInt } from '@/lib/format'
import type { TimeSeriesTab } from '@/stores/history'
import type { HistoryMetric, HistoryWindow, UsageHistoryPoint } from '@/types/api'
const props = defineProps<{
tab: TimeSeriesTab
window: HistoryWindow
seriesByMetric: Partial<Record<HistoryMetric, UsageHistoryPoint[]>>
}>()
const { foreground, mutedForeground, border } = useThemeColors()
/** One dataset spec per tab: which metric(s), its label, and its token color. */
const DATASET_SPECS: Record<TimeSeriesTab, { metric: HistoryMetric; label: string; color: string }[]> = {
requests: [{ metric: 'req', label: 'Requests', color: '--chart-requests' }],
tokens: [
{ metric: 'tokin', label: 'Tokens in', color: '--chart-tokens-in' },
{ metric: 'tokout', label: 'Tokens out', color: '--chart-tokens-out' },
],
cost: [{ metric: 'cost', label: 'Cost (USD)', color: '--chart-cost' }],
}
/** cost is stored as micro-USD (limits.go's account/history convention) — every other metric is already the display unit. */
function toDisplayValue(metric: HistoryMetric, raw: number): number {
return metric === 'cost' ? raw / 1_000_000 : raw
}
/**
* canonicalBuckets is the union of every active dataset's bucket keys,
* sorted. The "tokens" tab fetches tokin and tokout as two separate HTTP
* requests (history.ts's metricsForTab) — not one atomic call — so a
* window rollover landing between the two requests can leave one series
* one bucket ahead of the other. Zipping the two arrays positionally
* would silently mis-stack that refresh's chart. Bucket strings are
* fixed-width, zero-padded digit strings (limits.go's windowKey: hour
* "2006010215", day "20060102", month "200601"), so a plain lexicographic
* sort is also a chronological sort — no date parsing needed to align
* them.
*/
const canonicalBuckets = computed<string[]>(() => {
const specs = DATASET_SPECS[props.tab]
const bucketSet = new Set<string>()
for (const spec of specs) {
for (const p of props.seriesByMetric[spec.metric] ?? []) bucketSet.add(p.bucket)
}
return Array.from(bucketSet).sort()
})
const labels = computed<string[]>(() => canonicalBuckets.value.map((b) => formatBucketLabel(b, props.window)))
/** resolveToken reads one of main.css's --chart-* custom properties from :root, live — so a chart already on screen repaints on a `prefers-color-scheme` flip exactly like useThemeColors does for axis/legend text. */
function resolveToken(cssVar: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim()
}
const chartData = computed<ChartData<'bar'>>(() => {
// Touch the theme refs so this computed re-runs (and re-reads the CSS
// custom properties via resolveToken) on a `prefers-color-scheme` flip —
// resolveToken's own getComputedStyle read is not itself reactive.
void foreground.value
const buckets = canonicalBuckets.value
return {
labels: labels.value,
datasets: DATASET_SPECS[props.tab].map((spec) => {
// Map<bucket, value> per series, so each canonical bucket looks up
// its own value rather than assuming index i means the same bucket
// across every dataset (the mis-stacking canonicalBuckets exists to
// avoid — see its own doc comment). A bucket this series has no
// point for reads as 0, matching the zero-based axis: a genuinely
// missing bucket (not yet reported by the store) is indistinguishable
// from zero usage, which is the honest reading for a bar chart.
const byBucket = new Map((props.seriesByMetric[spec.metric] ?? []).map((p) => [p.bucket, p.value]))
return {
label: spec.label,
backgroundColor: resolveToken(spec.color),
data: buckets.map((b) => toDisplayValue(spec.metric, byBucket.get(b) ?? 0)),
}
}),
}
})
const stacked = computed<boolean>(() => DATASET_SPECS[props.tab].length > 1)
const chartOptions = computed<ChartOptions<'bar'>>(() => ({
responsive: true,
maintainAspectRatio: false,
animation: false,
scales: {
x: {
stacked: stacked.value,
grid: { color: border.value, display: false },
ticks: { color: mutedForeground.value },
},
y: {
// Zero-based, honest axis (dataviz rule): a bar chart's y-axis never
// starts anywhere but 0, so bar length always means what it looks
// like it means.
stacked: stacked.value,
beginAtZero: true,
grid: { color: border.value },
ticks: {
color: mutedForeground.value,
// Axis ticks stay compact (feature v0.23 addendum) — no room for
// the exact figure on an axis label; the tooltip below carries
// it. Cost reuses formatCost (the same formatter cost/day and
// cost/month already render through elsewhere in the app) rather
// than interpolating the raw tick float directly — Chart.js hands
// this callback whatever value it picked for gridline placement,
// which for a non-terminating division (e.g. cost/3) prints as
// "0.30000000000000004" without it. formatCost takes micro-USD, so
// the already-converted display value (toDisplayValue above) is
// scaled back up before the call — .toFixed(4) inside it then
// resolves any such float noise the same way the tooltip below
// already does by hand.
callback: (value) => (props.tab === 'cost' ? formatCost(Number(value) * 1_000_000) : formatCompactCount(Number(value))),
},
},
},
plugins: {
legend: {
display: stacked.value,
labels: { color: foreground.value },
},
tooltip: {
callbacks: {
// Tooltip shows the compact form WITH the precise raw value
// alongside (feature v0.23 addendum) — e.g. "Tokens in: 1.23M
// (1,234,567)" — since a tooltip, unlike an axis tick, has room
// for both and precision must never be lost. Cost stays its
// existing $-with-4-decimals format, unchanged.
label: (ctx) => {
const value = ctx.parsed.y ?? 0
if (props.tab === 'cost') return `${ctx.dataset.label}: $${value.toFixed(4)}`
return `${ctx.dataset.label}: ${formatCompactCount(value)} (${formatExactInt(value)})`
},
},
},
},
}))
</script>
<template>
<div class="h-72 w-full">
<Bar :data="chartData" :options="chartOptions" />
</div>
</template>
+6 -2
View File
@@ -24,13 +24,17 @@ const props = defineProps<{
emptyMessage?: string
/** P1: the shared reactive clock (composables/useNow.ts) — threaded into usageColumns() below so this table's own costMonthProj column recomputes as the clock ticks, instead of freezing at whatever instant this computed first ran. */
now: Date
/** P6: optional controlled sort state, forwarded to DataTable's own `v-model:sorting` (see that component's identical prop doc comment) — omitted everywhere except UsageView.vue's top-level Users table, whose export must reflect the table's current sort. */
/** P6: optional controlled sort state, forwarded to DataTable's own `v-model:sorting` (see that component's identical prop doc comment) — omitted everywhere except ConsumerDirectory.vue's top-level Users table, whose export must reflect the table's current sort. */
sorting?: SortingState
/** Optional per-row source lookup ('inline'/'file'/undefined), forwarded to usage-columns.ts's usageColumns — see that module's sourceColumn doc comment. Omitted everywhere except ConsumerDirectory.vue's flat Users table. */
sourceValue?: (entry: AdminUsageEntryView) => string | undefined
}>()
const emit = defineEmits<{ 'update:sorting': [value: SortingState] }>()
const columns = computed(() => usageColumns(props.idLabel, props.secondaryColumnLabel, props.secondaryValue, props.now))
const columns = computed(() =>
usageColumns(props.idLabel, props.secondaryColumnLabel, props.secondaryValue, props.now, props.sourceValue),
)
</script>
<template>
+274
View File
@@ -0,0 +1,274 @@
<script setup lang="ts">
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import ClampedRangeNotice from '@/components/ClampedRangeNotice.vue'
import CompactNumber from '@/components/CompactNumber.vue'
import ModelChip from '@/components/ModelChip.vue'
import TimeSeriesChart from '@/components/TimeSeriesChart.vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import UsageBar from '@/components/UsageBar.vue'
import { useNow } from '@/composables/useNow'
import { runOutDate as computeRunOutDate } from '@/lib/burndown'
import { formatBucketLabel, formatCost, formatExactInt, formatLimits, formatTimestamp } from '@/lib/format'
import { BUDGET_RATIO_LABEL, budgetRatios } from '@/lib/usage-bars'
import { useConsumersStore } from '@/stores/consumers'
import { useDashboardStore } from '@/stores/dashboard'
import { useEventsStore } from '@/stores/events'
import { useFiltersStore } from '@/stores/filters'
/**
* UserDetail (redesign-plan.md section 3.4) is the Consumers page's
* `user=<name>` drill-down: a timeline of the user's own req/cost series,
* their per-model breakdown (usermodel totals, when admin.stats.userModel
* is on), recent errors filtered from the shared Events store, headroom
* bars + an estimated run-out date, and their limits/grants.
*/
const props = defineProps<{ userId: string }>()
const emit = defineEmits<{ close: [] }>()
const dashboard = useDashboardStore()
const consumers = useConsumersStore()
const events = useEventsStore()
const filters = useFiltersStore()
const { now, start: startClock, stop: stopClock } = useNow()
onMounted(startClock)
onUnmounted(stopClock)
/** entry is this user's own row from the polled /admin/api/usage response — live counters, limits, lastSeen. undefined for a configured user who has never made a request (no usage scope created yet) or an id that does not exist at all. */
const entry = computed(() => dashboard.usage?.users.find((u) => u.id === props.userId))
/** consumerUser is this user's own row from GET /admin/api/consumers — source, admin flag, personal grant. */
const consumerUser = computed(() => consumers.data?.users.find((u) => u.name === props.userId))
const featuresUserModelStats = computed(() => dashboard.overview?.features.userModelStats ?? false)
function loadDetail(): void {
void consumers.fetchUserDetail(props.userId, filters.window, filters.span, featuresUserModelStats.value)
}
onMounted(() => {
loadDetail()
void events.refresh()
})
// featuresUserModelStats is included alongside userId/range: on a deep
// link or reload (#consumers?user=x), this component mounts and fires
// loadDetail() BEFORE the polled overview (stores/dashboard.ts) has
// necessarily landed its first response, so the very first loadDetail()
// call can run with userModelStatsEnabled still false even though the
// feature really is on. Once the overview lands and that computed flips
// true, this watch refetches — otherwise "Models used" stayed
// permanently empty for the rest of the session even after the alert
// telling the reader to enable it disappeared (P2).
watch([() => props.userId, () => filters.range, featuresUserModelStats], loadDetail)
const detail = computed(() => consumers.detail[props.userId])
// Labels are built from the RESPONSE's own `window` (AdminSeriesResponse),
// not from the current filters.window directly — two fetchUserDetail
// calls for different ranges can land out of order, and reading the
// window off the live global filter would then mislabel a still-
// displayed stale response's buckets (P1 item 7). Falls back to
// filters.window only before any response has ever landed.
const reqLabels = computed(() =>
(detail.value?.reqSeries?.buckets ?? []).map((b) => formatBucketLabel(b, detail.value?.reqSeries?.window ?? filters.window)),
)
const reqDatasets = computed(() => [
{ label: 'Requests', data: detail.value?.reqSeries?.series[0]?.points ?? [], color: '--chart-requests' },
])
const costLabels = computed(() =>
(detail.value?.costSeries?.buckets ?? []).map((b) => formatBucketLabel(b, detail.value?.costSeries?.window ?? filters.window)),
)
const costDatasets = computed(() => [
{ label: 'Cost', data: (detail.value?.costSeries?.series[0]?.points ?? []).map((v) => v), color: '--chart-cost' },
])
/** showUserModelHint mirrors AttributionDrilldown.vue's own convention (Spend page): the hint shows when the client's own feature flag reads off, OR the usermodel call itself came back 404 (stores/consumers.ts's modelTotalsUnavailable) — the second case catches a stale/not-yet-loaded overview on a deep link or reload, where featuresUserModelStats can read true for a moment while the server still says otherwise. */
const showUserModelHint = computed(() => !featuresUserModelStats.value || detail.value?.modelTotalsUnavailable === true)
/** modelRows ranks usermodel totals by cost desc (falling back to req when cost is absent from the requested metrics) — a small, page-local sort since this table has no other consumer that would justify a shared column/sort helper. */
const modelRows = computed(() => {
const rows = detail.value?.modelTotals?.rows ?? []
return [...rows].sort((a, b) => (b.values.cost ?? b.values.req ?? 0) - (a.values.cost ?? a.values.req ?? 0))
})
/** recentErrors filters the shared Events store (WP-F, read-only import) down to this user — the newest 10, matching the store's own newest-first order. */
const recentErrors = computed(() => events.events.filter((e) => e.user === props.userId).slice(0, 10))
const ratios = computed(() => (entry.value ? budgetRatios(entry.value) : []))
function budgetValueText(id: string, used: number, limit: number): string {
if (id.startsWith('cost')) return `${formatCost(used)} / ${formatCost(limit)}`
return `${formatExactInt(used)} / ${formatExactInt(limit)}`
}
/**
* runOutDate reuses lib/burndown.ts's own runOutDate — the SAME function
* BurnDownChart.vue projects the Spend page's burn-down line with — rather
* than a second, disagreeing implementation (P2 item 14: the two used to
* differ on an already-exceeded budget and on day 1 of the month).
*/
const runOutDate = computed(() => {
const limitUsd = entry.value?.limits?.costPerMonthUSD
if (!entry.value || !limitUsd) return null
return computeRunOutDate(entry.value.costPerMonthMicroUsd, Math.round(limitUsd * 1_000_000), now.value)
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2">
<Button type="button" variant="ghost" size="sm" @click="emit('close')">
<FontAwesomeIcon :icon="faArrowLeft" class="size-3.5" aria-hidden="true" />
Back to directory
</Button>
</div>
<Card>
<CardHeader>
<div class="flex flex-wrap items-center gap-2">
<CardTitle class="font-mono">{{ userId }}</CardTitle>
<Badge v-if="consumerUser?.admin" variant="destructive">admin</Badge>
<Badge v-if="consumerUser?.source" variant="secondary">{{ consumerUser.source }}</Badge>
</div>
<CardDescription v-if="entry?.groups?.length || entry?.groupName">
Member of {{ (entry?.groups ?? [entry?.groupName]).filter(Boolean).join(', ') }}
</CardDescription>
</CardHeader>
<CardContent v-if="!entry" class="text-sm text-muted-foreground">
No usage scope for this user yet — it appears after their first request.
</CardContent>
<CardContent v-else class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
<div>
<p class="text-muted-foreground">Requests/day</p>
<p class="text-lg font-semibold tabular-nums"><CompactNumber :value="entry.requestsPerDay" /></p>
</div>
<div>
<p class="text-muted-foreground">Cost/day</p>
<p class="text-lg font-semibold tabular-nums">{{ formatCost(entry.costPerDayMicroUsd) }}</p>
</div>
<div>
<p class="text-muted-foreground">Cost/month</p>
<p class="text-lg font-semibold tabular-nums">{{ formatCost(entry.costPerMonthMicroUsd) }}</p>
</div>
<div>
<p class="text-muted-foreground">Limits</p>
<p class="text-sm">{{ formatLimits(entry.limits) }}</p>
</div>
</CardContent>
</Card>
<Card v-if="entry && ratios.length">
<CardHeader>
<CardTitle>Headroom</CardTitle>
<CardDescription v-if="runOutDate">At the current pace, cost/month runs out around {{ formatTimestamp(runOutDate.toISOString()) }}.</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap items-center gap-x-4 gap-y-1.5">
<span v-for="ratio in ratios" :key="ratio.id" class="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
{{ BUDGET_RATIO_LABEL[ratio.id] }}
<UsageBar
:ratio="ratio.ratio"
:label="BUDGET_RATIO_LABEL[ratio.id]"
:value-text="budgetValueText(ratio.id, ratio.used, ratio.limit)"
/>
</span>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Requests over time</CardTitle>
</CardHeader>
<CardContent>
<p v-if="detail?.loading" class="text-sm text-muted-foreground">loading…</p>
<p v-else-if="detail?.error" class="text-sm text-destructive">{{ detail.error }}</p>
<TimeSeriesChart v-else :labels="reqLabels" :datasets="reqDatasets" :ariaLabel="`Requests over time for ${userId}`" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Cost over time</CardTitle>
</CardHeader>
<CardContent>
<p v-if="detail?.loading" class="text-sm text-muted-foreground">loading…</p>
<p v-else-if="detail?.error" class="text-sm text-destructive">{{ detail.error }}</p>
<TimeSeriesChart v-else :labels="costLabels" :datasets="costDatasets" :value-formatter="formatCost" :ariaLabel="`Cost over time for ${userId}`" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Models used</CardTitle>
</CardHeader>
<CardContent class="flex flex-col gap-3">
<ClampedRangeNotice :window="filters.window" :span="filters.span" />
<Alert v-if="showUserModelHint" variant="warn">
<AlertDescription>Enable admin.stats.userModel in the plugin config to see a per-model breakdown for this user.</AlertDescription>
</Alert>
<Table v-else-if="modelRows.length">
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead class="text-right">Requests</TableHead>
<TableHead class="text-right">Cost</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="row in modelRows" :key="row.id">
<TableCell><ModelChip :id="row.id" /></TableCell>
<TableCell class="text-right tabular-nums"><CompactNumber :value="row.values.req ?? 0" /></TableCell>
<TableCell class="text-right tabular-nums">{{ formatCost(row.values.cost ?? 0) }}</TableCell>
</TableRow>
</TableBody>
</Table>
<p v-else class="py-6 text-center text-sm text-muted-foreground">no model usage in this range</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Recent errors</CardTitle>
</CardHeader>
<CardContent>
<ul v-if="recentErrors.length" class="flex flex-col gap-2 text-sm">
<li v-for="(event, index) in recentErrors" :key="index" class="border-b pb-2 last:border-0 last:pb-0">
<p class="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{{ event.kind }}</Badge>
<span class="text-muted-foreground">{{ formatTimestamp(event.time) }}</span>
</p>
<p class="text-muted-foreground">{{ event.message }}</p>
</li>
</ul>
<p v-else class="py-6 text-center text-sm text-muted-foreground">none in the current event window</p>
</CardContent>
</Card>
<Card v-if="consumerUser?.providers?.length || consumerUser?.models?.length">
<CardHeader>
<CardTitle>Personal grant</CardTitle>
<CardDescription>Access this user has beyond their group's own.</CardDescription>
</CardHeader>
<CardContent class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<p class="mb-1.5 text-xs text-muted-foreground">Providers</p>
<div class="flex flex-wrap gap-1.5">
<Badge v-for="name in consumerUser?.providers ?? []" :key="name" as="span" variant="secondary" class="font-mono font-normal">
{{ name }}
</Badge>
</div>
</div>
<div>
<p class="mb-1.5 text-xs text-muted-foreground">Models</p>
<div class="flex flex-wrap gap-1.5">
<ModelChip v-for="id in consumerUser?.models ?? []" :key="id" :id="id" />
</div>
</div>
</CardContent>
</Card>
</div>
</template>
+138
View File
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import SnippetBlock from '@/components/SnippetBlock.vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { groupGrantSnippet, isValidName, isValidUserName, userGrantJsonFragment, validatePersonalGrant } from '@/lib/snippets'
/**
* GrantForm (ChangeHelper, redesign-plan.md section 3.4) builds a group's
* access-list snippet (providers/models/mcpServers/agents, YAML) or a
* user's personal grant (providers/models only — GroupConfig has no
* per-user mcpServers/agents grant field, auth.go's own UserConfig shape)
* as a bare JSON fragment for an existing users.json entry.
*
* Every list field is a comma-separated text Input rather than a proper
* multi-select (no such shadcn-vue primitive is installed in this panel) —
* parsed, trimmed, and de-duplicated below.
*/
const props = defineProps<{
groupNames: string[]
userNames: string[]
}>()
const targetKind = ref<'group' | 'user'>('group')
const targetName = ref('')
const providersRaw = ref('')
const modelsRaw = ref('')
const mcpServersRaw = ref('')
const agentsRaw = ref('')
/** parseList splits a comma-separated field into trimmed, non-empty, de-duplicated entries — the one shared parser every list field below uses. */
function parseList(raw: string): string[] {
const seen = new Set<string>()
for (const part of raw.split(',')) {
const trimmed = part.trim()
if (trimmed) seen.add(trimmed)
}
return Array.from(seen)
}
const providers = computed(() => parseList(providersRaw.value))
const models = computed(() => parseList(modelsRaw.value))
const mcpServers = computed(() => parseList(mcpServersRaw.value))
const agents = computed(() => parseList(agentsRaw.value))
const targetNames = computed(() => (targetKind.value === 'group' ? props.groupNames : props.userNames))
// A group name follows NAME_PATTERN (auth.go's configNamePattern — a
// route-path-segment safety rule); a user name only needs to be
// non-empty (auth.go's buildEntry, no character restriction at all —
// P3 item 27). isValidUserName, not isValidName, for targetKind === 'user'.
const nameValid = computed(() => (targetKind.value === 'group' ? isValidName(targetName.value.trim()) : isValidUserName(targetName.value)))
// validatePersonalGrant is a USER-ONLY rule (lib/snippets.ts's own doc
// comment) — a group's own `providers: []` legitimately means "every
// provider" and Go applies it with no such check at group-construction
// time (auth.go). Gated on targetKind so a group grant with an empty
// providers list + a models list (a perfectly valid group config) no
// longer shows a rejection that does not apply to it (P1 item 13).
const grantError = computed(() => (targetKind.value === 'user' ? validatePersonalGrant(providers.value, models.value) : null))
const isEmpty = computed(
() => providers.value.length === 0 && models.value.length === 0 && mcpServers.value.length === 0 && agents.value.length === 0,
)
const snippet = computed<string | null>(() => {
if (!nameValid.value || grantError.value || isEmpty.value) return null
if (targetKind.value === 'group') {
return groupGrantSnippet(targetName.value.trim(), {
providers: providers.value,
models: models.value,
mcpServers: mcpServers.value,
agents: agents.value,
})
}
const fragment = userGrantJsonFragment({ providers: providers.value, models: models.value })
return fragment || null
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<label id="grant-target-kind-label" class="text-xs font-medium text-muted-foreground">Target</label>
<Select :model-value="targetKind" @update:model-value="(v) => (targetKind = v as 'group' | 'user')">
<SelectTrigger aria-labelledby="grant-target-kind-label"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="group">Group</SelectItem>
<SelectItem value="user">User (personal grant, existing users.json entry)</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex flex-col gap-1">
<label id="grant-target-name-label" class="text-xs font-medium text-muted-foreground">{{ targetKind === 'group' ? 'Group name' : 'User name' }}</label>
<Select :model-value="targetName" @update:model-value="(v) => (targetName = String(v))">
<SelectTrigger aria-labelledby="grant-target-name-label"><SelectValue placeholder="Select…" /></SelectTrigger>
<SelectContent>
<SelectItem v-for="name in targetNames" :key="name" :value="name">{{ name }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<label for="grant-providers" class="text-xs font-medium text-muted-foreground">Providers (comma-separated, empty = all)</label>
<Input id="grant-providers" v-model="providersRaw" placeholder="anthropic, openai" />
</div>
<div class="flex flex-col gap-1">
<label for="grant-models" class="text-xs font-medium text-muted-foreground">Models (comma-separated, empty = all)</label>
<Input id="grant-models" v-model="modelsRaw" placeholder="anthropic/claude-sonnet-5" />
</div>
<template v-if="targetKind === 'group'">
<div class="flex flex-col gap-1">
<label for="grant-mcp" class="text-xs font-medium text-muted-foreground">MCP servers (comma-separated, empty = all)</label>
<Input id="grant-mcp" v-model="mcpServersRaw" placeholder="fetch, weather" />
</div>
<div class="flex flex-col gap-1">
<label for="grant-agents" class="text-xs font-medium text-muted-foreground">Agents (comma-separated, empty = all)</label>
<Input id="grant-agents" v-model="agentsRaw" placeholder="agentkit" />
</div>
</template>
</div>
<Alert v-if="grantError" variant="destructive">
<AlertDescription>{{ grantError }}</AlertDescription>
</Alert>
<SnippetBlock
v-if="snippet"
:label="targetKind === 'group' ? 'Paste into middleware.yaml' : `Merge into ${targetName}’s users.json line`"
:text="snippet"
/>
<p v-else class="text-sm text-muted-foreground">Pick a target and at least one list to build a snippet.</p>
</div>
</template>
+143
View File
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import SnippetBlock from '@/components/SnippetBlock.vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { groupLimitsSnippet, isValidName, isValidNonNegative, isValidNonNegativeInt, isValidUserName, userLimitsJsonFragment } from '@/lib/snippets'
import type { LimitsConfig } from '@/types/api'
/**
* LimitsForm (ChangeHelper, redesign-plan.md section 3.4) builds a
* group's or a user's limits snippet — a group's is a full YAML block
* (lib/snippets.ts's groupLimitsSnippet); a user's own limits live inside
* their users.json line, so it is a bare JSON field fragment
* (userLimitsJsonFragment) meant to be merged into that user's existing
* entry, not a whole new line (UserForm.vue owns building a whole new
* line).
*/
const props = defineProps<{
groupNames: string[]
userNames: string[]
}>()
const targetKind = ref<'group' | 'user'>('group')
const targetName = ref('')
/** INTEGER_FIELDS is every LimitsConfig field Go decodes as `int64` (llmgateway.go) — requestsPerMinute/requestsPerDay/tokensPerDay/tokensPerMonth reject a fractional value like "1.5" at json.Unmarshal outright (P2 item 12), unlike costPerDayUSD/costPerMonthUSD, which are `float64`. */
const INTEGER_FIELDS = new Set<keyof LimitsConfig>(['requestsPerMinute', 'requestsPerDay', 'tokensPerDay', 'tokensPerMonth'])
/** fieldValid applies isValidNonNegativeInt to the four int64 fields and isValidNonNegative (fractions allowed) to the two float64 cost fields — the ONE gate both invalidFields and limits below share, so they can never disagree on which fields are int-only. */
function fieldValid(key: keyof LimitsConfig, value: number): boolean {
return INTEGER_FIELDS.has(key) ? isValidNonNegativeInt(value) : isValidNonNegative(value)
}
const FIELDS: { key: keyof LimitsConfig; label: string }[] = [
{ key: 'requestsPerMinute', label: 'Requests/minute' },
{ key: 'requestsPerDay', label: 'Requests/day' },
{ key: 'tokensPerDay', label: 'Tokens/day (in+out)' },
{ key: 'tokensPerMonth', label: 'Tokens/month (in+out)' },
{ key: 'costPerDayUSD', label: 'Cost/day (USD)' },
{ key: 'costPerMonthUSD', label: 'Cost/month (USD)' },
]
const raw = reactive<Record<keyof LimitsConfig, string>>({
requestsPerMinute: '',
requestsPerDay: '',
tokensPerDay: '',
tokensPerMonth: '',
costPerDayUSD: '',
costPerMonthUSD: '',
})
const targetNames = computed(() => (targetKind.value === 'group' ? props.groupNames : props.userNames))
/** invalidFields lists every field whose entered text is non-empty but fails its own domain check (fieldValid above) — surfaced individually rather than one generic "invalid input" message, so the operator can see exactly which field to fix. Split by INTEGER_FIELDS so the error text is accurate for both: a fraction is invalid for requestsPerMinute etc. but perfectly valid for costPerDayUSD/costPerMonthUSD. */
const invalidFields = computed(() =>
FIELDS.filter(({ key }) => raw[key].trim() !== '' && !fieldValid(key, Number(raw[key]))).map((f) => f.label),
)
const invalidIntegerFields = computed(() =>
FIELDS.filter(({ key }) => INTEGER_FIELDS.has(key) && raw[key].trim() !== '' && !fieldValid(key, Number(raw[key]))).map((f) => f.label),
)
const invalidDecimalFields = computed(() =>
FIELDS.filter(({ key }) => !INTEGER_FIELDS.has(key) && raw[key].trim() !== '' && !fieldValid(key, Number(raw[key]))).map((f) => f.label),
)
const limits = computed<LimitsConfig>(() => {
const out: LimitsConfig = {}
for (const { key } of FIELDS) {
const value = Number(raw[key])
if (raw[key].trim() !== '' && fieldValid(key, value) && value > 0) out[key] = value
}
return out
})
// A group name follows NAME_PATTERN (auth.go's configNamePattern — a
// route-path-segment safety rule); a user name only needs to be
// non-empty (auth.go's buildEntry, no character restriction at all —
// P3 item 27). isValidUserName, not isValidName, for targetKind === 'user'.
const nameValid = computed(() => (targetKind.value === 'group' ? isValidName(targetName.value.trim()) : isValidUserName(targetName.value)))
const snippet = computed<string | null>(() => {
if (!nameValid.value || invalidFields.value.length > 0) return null
if (Object.keys(limits.value).length === 0) return null
return targetKind.value === 'group'
? groupLimitsSnippet(targetName.value.trim(), limits.value)
: userLimitsJsonFragment(limits.value)
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<label id="limits-target-kind-label" class="text-xs font-medium text-muted-foreground">Target</label>
<Select :model-value="targetKind" @update:model-value="(v) => (targetKind = v as 'group' | 'user')">
<SelectTrigger aria-labelledby="limits-target-kind-label"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="group">Group</SelectItem>
<SelectItem value="user">User (existing users.json entry)</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex flex-col gap-1">
<label id="limits-target-name-label" class="text-xs font-medium text-muted-foreground">{{ targetKind === 'group' ? 'Group name' : 'User name' }}</label>
<Select :model-value="targetName" @update:model-value="(v) => (targetName = String(v))">
<SelectTrigger aria-labelledby="limits-target-name-label"><SelectValue placeholder="Select…" /></SelectTrigger>
<SelectContent>
<SelectItem v-for="name in targetNames" :key="name" :value="name">{{ name }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div v-for="field in FIELDS" :key="field.key" class="flex flex-col gap-1">
<label :for="`limits-${field.key}`" class="text-xs font-medium text-muted-foreground">{{ field.label }}</label>
<Input
:id="`limits-${field.key}`"
v-model="raw[field.key]"
type="number"
min="0"
:step="INTEGER_FIELDS.has(field.key) ? '1' : 'any'"
placeholder="unset"
/>
</div>
</div>
<Alert v-if="invalidFields.length" variant="destructive">
<AlertDescription>
<p v-if="invalidIntegerFields.length">Enter a non-negative whole number for: {{ invalidIntegerFields.join(', ') }}.</p>
<p v-if="invalidDecimalFields.length">Enter a non-negative number for: {{ invalidDecimalFields.join(', ') }}.</p>
</AlertDescription>
</Alert>
<SnippetBlock
v-if="snippet"
:label="targetKind === 'group' ? 'Paste into middleware.yaml' : `Merge into ${targetName}’s users.json line`"
:text="snippet"
/>
<p v-else class="text-sm text-muted-foreground">Pick a target and at least one limit to build a snippet.</p>
</div>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import SnippetBlock from '@/components/SnippetBlock.vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { isValidNonNegativeInt, modelMetaSnippet } from '@/lib/snippets'
/**
* ModelMetaForm (ChangeHelper, redesign-plan.md section 3.4) builds a
* model's contextTokens/free modelMeta entry — independent fields (unlike
* PricingForm.vue's paired input/output price), either or both may be
* set: contextTokens alone documents a window the upstream cannot report;
* free alone marks a self-hosted model billed as 0 regardless of any
* discovered/override price (pricing.go: modelMetaFree short-circuits
* before override/builtin/LiteLLM resolution).
*/
const modelId = ref('')
const contextRaw = ref('')
const free = ref(false)
const modelIdValid = computed(() => modelId.value.trim() !== '')
const contextValue = computed(() => Number(contextRaw.value))
const contextEntered = computed(() => contextRaw.value.trim() !== '')
// ModelMetaConfig.ContextTokens is Go `int` (llmgateway.go) — a fraction
// like "128000.5" fails json.Unmarshal outright, so isValidNonNegativeInt
// (not isValidNonNegative) is the right domain check here (P2 item 12).
const contextValid = computed(() => !contextEntered.value || (isValidNonNegativeInt(contextValue.value) && contextValue.value > 0))
const snippet = computed<string | null>(() => {
if (!modelIdValid.value || !contextValid.value) return null
if (!contextEntered.value && !free.value) return null
return modelMetaSnippet(modelId.value.trim(), {
contextTokens: contextEntered.value ? contextValue.value : undefined,
free: free.value || undefined,
})
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="modelmeta-model-id" class="text-xs font-medium text-muted-foreground">Model id (provider/model)</label>
<Input id="modelmeta-model-id" v-model="modelId" placeholder="gx10/current" class="font-mono" />
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-end">
<div class="flex flex-col gap-1">
<label for="modelmeta-context" class="text-xs font-medium text-muted-foreground">Context window (tokens)</label>
<Input id="modelmeta-context" v-model="contextRaw" type="number" min="1" step="1" placeholder="131072" />
</div>
<label class="flex items-center gap-2 pb-1.5 text-sm">
<input id="modelmeta-free" v-model="free" type="checkbox" class="size-4 rounded border-input" />
Free (billed as $0 regardless of any resolved price)
</label>
</div>
<Alert v-if="contextEntered && !contextValid" variant="destructive">
<AlertDescription>Context window must be a positive whole number of tokens.</AlertDescription>
</Alert>
<SnippetBlock v-if="snippet" label="Paste into middleware.yaml" :text="snippet" />
<p v-else class="text-sm text-muted-foreground">Enter a model id and set a context window and/or free to build a snippet.</p>
</div>
</template>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import SnippetBlock from '@/components/SnippetBlock.vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { isValidNonNegative, pricingSnippet } from '@/lib/snippets'
/**
* PricingForm (ChangeHelper, redesign-plan.md section 3.4) builds a
* model's per-token pricing override — inputPerM/outputPerM, the two
* ModelPricing fields (llmgateway.go) that live in the top-level
* Config.Pricing map (`spec.plugin.llmgateway.pricing`) and OVERRIDE the
* built-in/LiteLLM-derived price for one model id (pricing.go's
* billingPriceSource: `override` always wins). This is a DIFFERENT config
* section from ModelMetaForm.vue's own modelMeta entry — ModelMeta's own
* cost fields drive metadata exposure only, never billing (see
* lib/snippets.ts's own ModelMeta doc comment). Both fields are required
* together — a one-sided price makes no billing sense — unlike
* ModelMetaForm.vue's independent contextTokens/free fields.
*/
const modelId = ref('')
const inputRaw = ref('')
const outputRaw = ref('')
const modelIdValid = computed(() => modelId.value.trim() !== '')
const inputValue = computed(() => Number(inputRaw.value))
const outputValue = computed(() => Number(outputRaw.value))
const inputValid = computed(() => inputRaw.value.trim() !== '' && isValidNonNegative(inputValue.value))
const outputValid = computed(() => outputRaw.value.trim() !== '' && isValidNonNegative(outputValue.value))
const errors = computed(() => {
const out: string[] = []
if (inputRaw.value.trim() !== '' && !inputValid.value) out.push('Input price/MTok must be a non-negative number.')
if (outputRaw.value.trim() !== '' && !outputValid.value) out.push('Output price/MTok must be a non-negative number.')
if ((inputValid.value && !outputValid.value) || (!inputValid.value && outputValid.value)) {
out.push('Set both input and output price/MTok — a one-sided override has no billing meaning.')
}
return out
})
const snippet = computed<string | null>(() => {
if (!modelIdValid.value || !inputValid.value || !outputValid.value) return null
return pricingSnippet(modelId.value.trim(), { inputPerM: inputValue.value, outputPerM: outputValue.value })
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="pricing-model-id" class="text-xs font-medium text-muted-foreground">Model id (provider/model)</label>
<Input id="pricing-model-id" v-model="modelId" placeholder="openai/gpt-4" class="font-mono" />
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<label for="pricing-input" class="text-xs font-medium text-muted-foreground">Input price / MTok (USD)</label>
<Input id="pricing-input" v-model="inputRaw" type="number" min="0" step="any" placeholder="0.50" />
</div>
<div class="flex flex-col gap-1">
<label for="pricing-output" class="text-xs font-medium text-muted-foreground">Output price / MTok (USD)</label>
<Input id="pricing-output" v-model="outputRaw" type="number" min="0" step="any" placeholder="1.50" />
</div>
</div>
<Alert v-if="errors.length" variant="destructive">
<AlertDescription>
<p v-for="(error, index) in errors" :key="index">{{ error }}</p>
</AlertDescription>
</Alert>
<SnippetBlock v-if="snippet" label="Paste into middleware.yaml" :text="snippet" />
<p v-else class="text-sm text-muted-foreground">Enter a model id and both prices to build a snippet.</p>
</div>
</template>
+123
View File
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import SnippetBlock from '@/components/SnippetBlock.vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { API_KEY_PLACEHOLDER, generateApiKey, isValidUserName, userJsonLine, validatePersonalGrant } from '@/lib/snippets'
/**
* UserForm (ChangeHelper, redesign-plan.md section 3.4) builds a whole new
* users.json line — name, apiKey (placeholder by default, Q10 DECISION;
* "Generate" produces a real client-side random key, never sent anywhere),
* a primary group (production's own single-`group` convention) plus an
* optional comma list of ADDITIONAL groups that switches the emitted field
* to plural `groups` (lib/snippets.ts's userJsonLine), an optional
* personal grant (validated the same way GrantForm.vue's user-target path
* is), and an admin checkbox.
*/
const props = defineProps<{ groupNames: string[] }>()
const name = ref('')
const apiKey = ref(API_KEY_PLACEHOLDER)
const primaryGroup = ref('')
const extraGroupsRaw = ref('')
const providersRaw = ref('')
const modelsRaw = ref('')
const admin = ref(false)
function generate(): void {
apiKey.value = generateApiKey()
}
function parseList(raw: string): string[] {
const seen = new Set<string>()
for (const part of raw.split(',')) {
const trimmed = part.trim()
if (trimmed) seen.add(trimmed)
}
return Array.from(seen)
}
const extraGroups = computed(() => parseList(extraGroupsRaw.value))
const providers = computed(() => parseList(providersRaw.value))
const models = computed(() => parseList(modelsRaw.value))
const nameValid = computed(() => isValidUserName(name.value))
const groupValid = computed(() => primaryGroup.value !== '')
const apiKeyValid = computed(() => apiKey.value.trim() !== '' && apiKey.value !== API_KEY_PLACEHOLDER)
const grantError = computed(() => validatePersonalGrant(providers.value, models.value))
const snippet = computed<string | null>(() => {
if (!nameValid.value || !groupValid.value || !apiKeyValid.value || grantError.value) return null
const allGroups = extraGroups.value.length > 0 ? [primaryGroup.value, ...extraGroups.value] : null
return userJsonLine({
name: name.value.trim(),
apiKey: apiKey.value.trim(),
group: allGroups ? undefined : primaryGroup.value,
groups: allGroups ?? undefined,
providers: providers.value,
models: models.value,
admin: admin.value,
})
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<label for="user-name" class="text-xs font-medium text-muted-foreground">Name</label>
<Input id="user-name" v-model="name" placeholder="alice" />
</div>
<div class="flex flex-col gap-1">
<label id="user-group-label" class="text-xs font-medium text-muted-foreground">Primary group</label>
<Select :model-value="primaryGroup" @update:model-value="(v) => (primaryGroup = String(v))">
<SelectTrigger aria-labelledby="user-group-label"><SelectValue placeholder="Select…" /></SelectTrigger>
<SelectContent>
<SelectItem v-for="g in props.groupNames" :key="g" :value="g">{{ g }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="flex flex-col gap-1">
<label for="user-api-key" class="text-xs font-medium text-muted-foreground">API key</label>
<div class="flex gap-2">
<Input id="user-api-key" v-model="apiKey" class="font-mono" />
<Button type="button" variant="outline" size="sm" @click="generate">Generate</Button>
</div>
<p v-if="apiKey === API_KEY_PLACEHOLDER" class="text-xs text-muted-foreground">Still the placeholder — click Generate for a real key.</p>
</div>
<div class="flex flex-col gap-1">
<label for="user-extra-groups" class="text-xs font-medium text-muted-foreground">Additional groups (comma-separated, optional)</label>
<Input id="user-extra-groups" v-model="extraGroupsRaw" placeholder="friends" />
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<label for="user-providers" class="text-xs font-medium text-muted-foreground">Personal grant: providers (optional)</label>
<Input id="user-providers" v-model="providersRaw" placeholder="gx10" />
</div>
<div class="flex flex-col gap-1">
<label for="user-models" class="text-xs font-medium text-muted-foreground">Personal grant: models (optional)</label>
<Input id="user-models" v-model="modelsRaw" placeholder="gx10/current" />
</div>
</div>
<label class="flex items-center gap-2 text-sm">
<input v-model="admin" type="checkbox" class="size-4 rounded border-input" />
Admin (full access to /admin/api/*)
</label>
<Alert v-if="grantError" variant="destructive">
<AlertDescription>{{ grantError }}</AlertDescription>
</Alert>
<SnippetBlock v-if="snippet" label="Add as a new line in users.json" :text="snippet" />
<p v-else class="text-sm text-muted-foreground">Fill in a name, group, and a real (generated) API key to build a snippet.</p>
</div>
</template>
+68 -119
View File
@@ -1,148 +1,97 @@
import { computed, onMounted, onUnmounted, watch } from 'vue'
import {
buildHash,
parseChartsParams,
parseEventsParams,
parseHashState,
parseUsageParams,
} from '@/lib/hash-state'
import { useEventsStore } from '@/stores/events'
import { useHistoryStore } from '@/stores/history'
import { buildHash, parseGlobalParams, parseHashState, parsePageParams } from '@/lib/hash-state'
import { DEFAULT_CMP, DEFAULT_RANGE } from '@/lib/range'
import { DEFAULT_SCOPE, useFiltersStore } from '@/stores/filters'
import { useNavStore } from '@/stores/nav'
import type { ChartTab, ModelMetric } from '@/stores/history'
import type { HistoryWindow } from '@/types/api'
/** The five defaults every per-tab param omits from the built hash when the field is already at its default value (buildHash's own "caller decides what's default" contract, lib/hash-state.ts). */
const DEFAULT_WINDOW: HistoryWindow = 'hour'
const DEFAULT_CHART_TAB: ChartTab = 'requests'
/**
* DEFAULT_METRIC mirrors stores/history.ts's own initial `modelMetric`
* state (free-models-plan.md: 'req', not 'cost' — a cost-default ranking
* hides every free model). An omitted `metric` hash param, or one this
* build does not recognize (parseChartsParams' own independent-field
* validation, lib/hash-state.ts), falls back to this value here; an
* explicit `metric=cost` in the hash still parses and applies normally.
*/
const DEFAULT_METRIC: ModelMetric = 'req'
const DEFAULT_SCOPE = 'total'
const DEFAULT_MODEL_FILTER = ''
/**
* useHashState (F9, dashboard-plan.md) is the single composable that keeps
* the URL hash and every store it names in sync, both ways:
* useHashState (redesign-plan.md section 3.1) is the single composable
* that keeps the URL hash and the two shell stores it drives — stores/
* filters.ts (range/cmp/scope) and stores/nav.ts (page/params) — in sync,
* both ways:
*
* - On creation, it parses the CURRENT hash exactly once and applies it to
* stores/nav.ts (activeTab, usageQuery), stores/history.ts (Charts'
* scope/window/tab/modelMetric/modelFilter), and stores/events.ts (kindFilter/
* userFilter) — "on load: read hash -> restore state" for all three, not
* just the active tab the old useTabHash() composable covered.
* - A single computed re-derives the hash from whichever store the
* CURRENT nav.activeTab reads from, and writes it back via
* history.replaceState whenever it changes — never pushState (one
* browser-history entry per admin session, not per click) and never a
* bare `location.hash =` assignment (which would also jump scroll
* position to any element whose id happens to match).
* - On creation, it parses the CURRENT hash exactly once and applies it:
* the three global filters to filters.setFilters, and the page plus
* its own opaque param record to nav.goTo. Every per-page STORE
* (spend/consumers/models/reliability, each owned by its own work
* package) is responsible for reading nav.params itself when its page
* mounts — this composable never touches a page-specific store, by
* design (stores/nav.ts's own doc comment).
* - A single computed re-derives the hash from filters' current state
* plus nav.page/nav.params, and writes it back via history.
* replaceState whenever it changes — never pushState (one browser-
* history entry per admin session, not per click) and never a bare
* `location.hash =` assignment (which would also jump scroll position
* to any element whose id happens to match).
* - It listens for the browser's own `hashchange` event (back/forward
* navigation, or a hand-edited URL) and re-parses from scratch,
* reapplying the same restore path as the initial read.
*
* MUST be called from App.vue's setup, before the `v-if="!auth.
* isAuthenticated"` branch (dashboard-plan.md's own Risks section:
* "load-with-hash-then-authenticate") — every store action this touches
* (history.setScope, etc.) is safe to call before a key is stored (each
* one's own fetch no-ops on an unauthenticated auth store), so the hash's
* selection is captured immediately and is already in place the moment
* the reader authenticates, rather than being lost because this composable
* was never constructed while the AuthGate was showing.
* isAuthenticated"` branch — filters.setFilters/nav.goTo are plain
* synchronous state writes, safe to call before an admin key is stored,
* so the hash's selection is already restored by the time the reader
* authenticates.
*
* Guarded for a `window`-less environment (vitest's node test
* environment, vite.config.ts) the same way useTabHash's old
* implementation was: every DOM/history/location access sits behind one
* hasWindow check computed once. Being a real Vue lifecycle-hook user
* (onMounted/onUnmounted) exercised through App.vue in the real browser,
* not unit-tested directly — lib/hash-state.ts's own pure functions are
* what hash-state.spec.ts covers.
* environment, vite.config.ts): every DOM/history/location access sits
* behind one hasWindow check computed once. Exercised through App.vue in
* the real browser, not unit-tested directly — lib/hash-state.ts's own
* pure functions are what hash-state.spec.ts covers.
*/
export function useHashState(): void {
const filters = useFiltersStore()
const nav = useNavStore()
const history = useHistoryStore()
const events = useEventsStore()
function applyFromHash(hash: string): void {
const { tab, query } = parseHashState(hash)
nav.activeTab = tab
if (tab === 'charts') {
// P7 review fix: this used to apply ONLY the params present in the
// hash, so a PARTIAL Charts hash on hashchange (back/forward
// navigation, or the reader hand-editing the URL down to a bare
// "#charts") left every OMITTED field exactly as it was — buildHash
// itself treats an omitted param as "use the default" (its own doc
// comment), but this restore path never actually reset anything to
// that default, so the address bar and the rendered chart silently
// disagreed. Every field is now applied on EVERY call — explicitly
// falling back to its own default when the hash omits it — so a
// partial or bare hash genuinely resets whatever it doesn't name —
// including `filter` (P7 review follow-up): the Models tab's
// provider-prefix filter is a hash param now (lib/hash-state.ts's
// ChartsHashParams.filter), so it falls back to
// DEFAULT_MODEL_FILTER exactly like every other field here, rather
// than being left over from whatever the store already had.
//
// P12 review fix: applied through ONE history.setSelection() call
// (batches the state writes and fires exactly one refresh(), plus
// one fetchModelOptions() only when window is part of the change)
// instead of up to four separate setWindow/setTab/setModelMetric/
// setScope calls, each of which used to fire its OWN refresh() —
// every fetch but the last immediately discarded.
const p = parseChartsParams(query)
history.setSelection({
window: p.window ?? DEFAULT_WINDOW,
tab: p.tab ?? DEFAULT_CHART_TAB,
modelMetric: p.metric ?? DEFAULT_METRIC,
scope: p.scope ?? DEFAULT_SCOPE,
modelFilter: p.filter ?? DEFAULT_MODEL_FILTER,
})
} else if (tab === 'usage') {
const p = parseUsageParams(query)
nav.usageQuery = p.q ?? ''
} else if (tab === 'events') {
const p = parseEventsParams(query)
events.setKindFilter(p.kind ?? '')
events.setUserFilter(p.user ?? '')
}
const { page, query } = parseHashState(hash)
const globals = parseGlobalParams(query)
filters.setFilters({
range: globals.range ?? DEFAULT_RANGE,
cmp: globals.cmp ?? DEFAULT_CMP,
scope: globals.scope ?? DEFAULT_SCOPE,
})
nav.goTo(page, parsePageParams(query))
}
const hasWindow = typeof window !== 'undefined'
if (hasWindow) applyFromHash(window.location.hash)
const currentHash = computed<string>(() => {
if (nav.activeTab === 'charts') {
const params: Record<string, string> = {}
if (history.window !== DEFAULT_WINDOW) params.window = history.window
if (history.tab !== DEFAULT_CHART_TAB) params.tab = history.tab
if (history.modelMetric !== DEFAULT_METRIC) params.metric = history.modelMetric
if (history.scope !== DEFAULT_SCOPE) params.scope = history.scope
if (history.modelFilter !== DEFAULT_MODEL_FILTER) params.filter = history.modelFilter
return buildHash(nav.activeTab, params)
}
if (nav.activeTab === 'usage') {
return buildHash(nav.activeTab, nav.usageQuery ? { q: nav.usageQuery } : {})
}
if (nav.activeTab === 'events') {
const params: Record<string, string> = {}
if (events.kindFilter) params.kind = events.kindFilter
if (events.userFilter) params.user = events.userFilter
return buildHash(nav.activeTab, params)
}
return buildHash(nav.activeTab, {})
const params: Record<string, string> = {}
if (filters.range !== DEFAULT_RANGE) params.range = filters.range
if (filters.cmp !== DEFAULT_CMP) params.cmp = filters.cmp
if (filters.scope !== DEFAULT_SCOPE) params.scope = filters.scope
// Page params are merged in AFTER the three global keys, so the
// built query string always lists range/cmp/scope first — readable,
// and matches redesign-plan.md section 3.1's own example ordering.
// No current page uses a param named range/cmp/scope (see stores/
// nav.ts's own doc comment on `params` staying opaque), so this
// merge never actually overwrites one of the three above in
// practice; it is a plain Object.assign, not a defensive filter.
Object.assign(params, nav.params)
return buildHash(nav.page, params)
})
watch(currentHash, (hash) => {
if (!hasWindow) return
if (window.location.hash === hash) return
window.history.replaceState(null, '', hash)
})
// `immediate: true` (P3 item 24): applyFromHash already normalized
// filters/nav state from window.location.hash above, synchronously,
// before this watch is even registered — so currentHash's FIRST
// computed value is already the canonical hash for whatever the reader
// arrived with. Without `immediate`, that first value is never written
// back until some LATER state change happens to fire the watch, so a
// legacy or garbage hash (`#charts`, `#nope?range=30d`) stayed in the
// address bar — correct app state, stale URL — until the reader
// happened to touch a filter.
watch(
currentHash,
(hash) => {
if (!hasWindow) return
if (window.location.hash === hash) return
window.history.replaceState(null, '', hash)
},
{ immediate: true },
)
function onHashChange(): void {
applyFromHash(window.location.hash)
+22 -15
View File
@@ -11,7 +11,7 @@ import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
const NOW_TICK_MS = 60_000
export interface UseNow {
/** The shared clock — every time-dependent read on the Usage tab (CostForecastCard's elapsedFraction, usageColumns()'s own `now` parameter, UsageView.vue's groupCostProjection) reads THIS ref, never its own `new Date()`. */
/** The shared clock — every time-dependent read on the Consumers page (CostForecastCard's elapsedFraction, usageColumns()'s own `now` parameter, ConsumerDirectory.vue's groupCostProjection, UserDetail.vue's runOutDate) reads THIS ref, never its own `new Date()`. */
now: Ref<Date>
/** Idempotent, mirrors createVisibilityPoller's own start() — call from the owning component's onMounted. */
start: () => void
@@ -20,9 +20,12 @@ export interface UseNow {
}
/**
* useNow (P1) is the ONE reactive clock every time-dependent read on the
* Usage tab shares, instead of each freezing its own `new Date()` at mount
* or re-deriving a fresh one on every render:
* useNow (P1) is the ONE reactive clock every time-dependent read across
* the admin UI shares (today: SpendPage.vue's cost forecast, UserDetail.vue's
* run-out date, ConsumerDirectory.vue's group cost projection — see this
* file's own UseNow doc comment for the full current-caller list), instead
* of each freezing its own `new Date()` at mount or re-deriving a fresh one
* on every render:
*
* - CostForecastCard.vue used `withDefaults(..., { now: () => new Date() })`
* — Vue caches a prop's default once per component instance
@@ -30,12 +33,14 @@ export interface UseNow {
* instant the card happened to mount, never advancing again.
* - usage-columns.ts's usageColumns() takes `now` as a plain parameter,
* evaluated once per call — UsageTable.vue's `computed(() =>
* usageColumns(...))` and UsageView.vue's headless Groups toolbar
* columns both called it with no reactive dependency on the current
* time, so neither ever recomputed either.
* - UsageView.vue's groupCostProjection called `new Date()` fresh on every
* invocation instead, so the group grid could disagree with the above
* two on what "now" even was, mid-render.
* usageColumns(...))` and the pre-redesign Usage tab's own headless
* Groups toolbar columns (UsageView.vue, deleted) both called it with no
* reactive dependency on the current time, so neither ever recomputed
* either.
* - The pre-redesign Usage tab's own groupCostProjection (UsageView.vue,
* deleted) called `new Date()` fresh on every invocation instead, so the
* group grid could disagree with the above two on what "now" even was,
* mid-render.
*
* Driven by the shared createVisibilityPoller (lib/polling.ts — the same
* primitive every store's own poll timer uses): ticks once immediately on
@@ -43,11 +48,13 @@ export interface UseNow {
* hidden and catching up the moment it is looked at again (and, per P13,
* never ticking at all if started while already hidden).
*
* This composable does NOT call onMounted/onUnmounted itself — the caller
* (UsageView.vue) owns its lifecycle explicitly via the returned start/stop,
* the same reason lib/polling.ts itself takes no lifecycle-hook dependency:
* it keeps this composable callable, and testable, outside a mounted
* component.
* This composable does NOT call onMounted/onUnmounted itself — each of its
* three current callers (SpendPage.vue, UserDetail.vue,
* ConsumerDirectory.vue) owns its OWN clock instance's lifecycle
* explicitly via the returned start/stop (`onMounted(startClock)` /
* `onUnmounted(stopClock)`), the same reason lib/polling.ts itself takes
* no lifecycle-hook dependency: it keeps this composable callable, and
* testable, outside a mounted component.
*/
export function useNow(intervalMs = NOW_TICK_MS): UseNow {
const now = ref(new Date())
+4 -2
View File
@@ -24,8 +24,10 @@ describe('useSearchQuery', () => {
// F9 (hash-state): a caller can supply the ref this composable reads and
// writes instead of a private local one, so the query round-trips
// through an external owner (nav.usageQuery, UsageView.vue) rather than
// being lost on remount.
// through an external owner (e.g. ConsumerDirectory.vue's own
// `nav.params.q`-backed computed, or EventsView.vue's store-backed
// `userFilter` — useSearchQuery.ts's own doc comment) rather than being
// lost on remount.
describe('with an external ref', () => {
it('reads and derives from the external ref\'s current value, rather than starting blank', () => {
const external = ref('preset')
+22 -16
View File
@@ -3,14 +3,15 @@ import { computed, ref } from 'vue'
/**
* useSearchQuery is the one raw-query → {normalized, hasQuery} adapter
* every search filter in this panel uses — ProvidersView's model search,
* UsageView's user/group search, ChartsView's scope-picker search all had
* their own copy of this identical query/normalizedQuery/hasQuery
* pairing; extracted so it exists once (vue.md: "if you've written it
* twice, you owe an abstraction"). Callers still own their own matching
* logic entirely (ProvidersView's modelMatches/providerMatches,
* lib/usage-search.ts's helpers) — this only normalizes the raw input the
* same way every call site already agreed on: trimmed, lowercased.
* every search filter in this panel uses — ProviderHealthPanel's
* model/provider search, ConsumerDirectory's user/group search
* (lib/usage-search.ts), EventsView's user/group filter, TargetsView's
* target search, each with its own matching logic but the identical
* query/normalizedQuery/hasQuery pairing; extracted so it exists once
* (vue.md: "if you've written it twice, you owe an abstraction"). Callers
* still own their own matching logic entirely — this only normalizes the
* raw input the same way every call site already agreed on: trimmed,
* lowercased.
*
* No `clear()` here — clearing is just setting `query.value = ''`, and
* every caller already gets that for free from SearchInput.vue's own
@@ -19,14 +20,19 @@ import { computed, ref } from 'vue'
* caller needs.
*
* `external` (F9, hash-state) lets a caller supply the ref this composable
* reads and writes instead of owning a private local one — UsageView.vue
* binds `nav.usageQuery` (stores/nav.ts) so the Usage tab's own search
* text round-trips through the URL hash (lib/hash-state.ts's `q` param)
* the same way ChartsView's scope/window/tab selection already does.
* Every OTHER caller (ProvidersView, ChartsView's own scope-picker search,
* EventsView) omits it and keeps its private, hash-independent ref exactly
* as before — passing `external` changes nothing about `normalized`/
* `hasQuery`'s own derivation, only where the raw value itself lives.
* reads and writes instead of owning a private local one, so that ref's
* own search text round-trips through the URL hash. Two current callers
* pass it: ConsumerDirectory.vue binds a local `computed` get/set over
* `nav.params.q` directly (stores/nav.ts's own `params` is opaque, so the
* page owns interpreting/writing its own `q` key — see that computed's
* own doc comment for why this replaced an earlier, page-specific
* `nav.usageQuery` special case), and EventsView.vue binds
* `storeToRefs(events).userFilter`, which composables/useHashState.ts
* reads/writes directly against the same hash. Every OTHER caller
* (ProviderHealthPanel's model/provider search, TargetsView's target
* search) omits it and keeps its private, hash-independent ref instead —
* passing `external` changes nothing about `normalized`/`hasQuery`'s own
* derivation, only where the raw value itself lives.
*/
export function useSearchQuery(external?: Ref<string>) {
const query = external ?? ref('')
-49
View File
@@ -1,49 +0,0 @@
import { describe, expect, it } from 'vitest'
import { hashToTab, tabToHash } from './useTabHash'
const TAB_VALUES = ['providers', 'usage', 'charts', 'targets'] as const
describe('hashToTab', () => {
it('maps a recognized hash (with leading #) to its tab value', () => {
expect(hashToTab('#usage', TAB_VALUES, 'providers')).toBe('usage')
})
it('maps a recognized hash without a leading # too (defensive: a caller might pass window.location.hash.slice(1) already)', () => {
expect(hashToTab('charts', TAB_VALUES, 'providers')).toBe('charts')
})
it('falls back to defaultTab for an empty hash (absent, load with no hash at all)', () => {
expect(hashToTab('', TAB_VALUES, 'providers')).toBe('providers')
})
it('falls back to defaultTab for a bare "#" (empty fragment)', () => {
expect(hashToTab('#', TAB_VALUES, 'providers')).toBe('providers')
})
it('falls back to defaultTab for an unrecognized hash (hand-edited URL)', () => {
expect(hashToTab('#nonexistent-tab', TAB_VALUES, 'providers')).toBe('providers')
})
it('is case-sensitive: a differently-cased match still falls back to defaultTab', () => {
expect(hashToTab('#Providers', TAB_VALUES, 'providers')).toBe('providers')
})
it('every valid tab round-trips to itself', () => {
for (const tab of TAB_VALUES) {
expect(hashToTab(`#${tab}`, TAB_VALUES, 'providers')).toBe(tab)
}
})
})
describe('tabToHash', () => {
it('prefixes the tab value with "#"', () => {
expect(tabToHash('targets')).toBe('#targets')
})
it('round-trips through hashToTab back to the same tab', () => {
for (const tab of TAB_VALUES) {
expect(hashToTab(tabToHash(tab), TAB_VALUES, 'providers')).toBe(tab)
}
})
})
-52
View File
@@ -1,52 +0,0 @@
// Feature C (v0.22) introduced hashToTab/tabToHash below, plus a
// useTabHash() composable wrapping them. F9 (dashboard-plan.md) supersedes
// that composable with useHashState() (composables/useHashState.ts):
// App.vue now needs a single hash that carries BOTH the active tab AND
// each tab's own query-string state (scope/window/tab/metric for Charts, q
// for Usage, kind/user for Events — lib/hash-state.ts), not the tab alone,
// so useTabHash() itself was removed as the now-superseded, unreachable
// half of this file. hashToTab/tabToHash stay: useHashState.ts still
// builds the OUTER "#<tab>" portion of the combined hash directly on top
// of these same two pure functions, and this module's own spec exercises
// them directly (vite.config.ts's `test` block runs in a Node environment
// — no `window`, no component mount).
//
// TAB_VALUES/TabValue also live here — the single source of truth for the
// app's tab union (vue.md: "share types once, import everywhere"):
// stores/nav.ts's `activeTab`, composables/useHashState.ts, and App.vue's
// own Tabs all import the SAME union rather than each redeclaring it.
export const TAB_VALUES = ['providers', 'usage', 'charts', 'targets', 'events'] as const
export type TabValue = (typeof TAB_VALUES)[number]
/**
* hashToTab maps a raw location.hash string (with or without its leading
* "#") to one of validTabs. An absent, empty, or unrecognized hash
* returns defaultTab — never throws, and never returns a value outside
* validTabs. Matching is a plain string comparison: a tab value is
* already a fixed lowercase identifier (App.vue's TabsTrigger value=
* strings), never re-cased anywhere in this pipeline.
*
* Two intentional URL-normalization quirks (folded review minors, v0.22
* review round), both worth naming rather than leaving implicit:
*
* 1. Case sensitivity: "#Providers" does NOT match "providers" — it falls
* back to defaultTab like any other unrecognized hash, rather than
* being normalized case-insensitively. A URL a person typed or edited
* by hand with different casing is treated as invalid, not corrected.
* 2. An invalid or absent hash is normalized only in memory (this
* function's own return value), never in the address bar itself:
* useHashState's initial read (composables/useHashState.ts) never
* calls history.replaceState on its own — only a LATER state change
* does (see its own doc comment) — so loading "#bogus" shows the
* Providers tab while the address bar still reads "#bogus" until the
* reader switches tabs, or something else changes, at least once.
*/
export function hashToTab<T extends string>(hash: string, validTabs: readonly T[], defaultTab: T): T {
const raw = hash.startsWith('#') ? hash.slice(1) : hash
return (validTabs as readonly string[]).includes(raw) ? (raw as T) : defaultTab
}
/** tabToHash is hashToTab's inverse: the URL hash (leading "#" included) one tab value maps to. */
export function tabToHash(tab: string): string {
return `#${tab}`
}
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import { buildAccessMatrix, providerAccessCell, targetAccessCell } from './access-matrix'
import type { AdminConsumerGroup, AdminTargetView } from '@/types/api'
function group(overrides: Partial<AdminConsumerGroup> = {}): AdminConsumerGroup {
return {
name: 'friends',
allowedProviders: [],
modelAccess: {},
memberCount: 3,
...overrides,
}
}
function target(overrides: Partial<AdminTargetView> = {}): AdminTargetView {
return {
name: 'fetch',
url: 'http://mcp-fetch/mcp',
counters: { requestsPerMinute: 0, requestsPerDay: 0, requestsPerMonth: 0 },
health: { state: 'unknown', consecutiveFailures: 0 },
...overrides,
}
}
describe('providerAccessCell', () => {
it('not allowed when the provider is absent from allowedProviders', () => {
const g = group({ allowedProviders: ['minimax'] })
expect(providerAccessCell(g, 'uni')).toEqual({ allowed: false })
})
it('allowed with a "n/m models" detail when modelAccess has an entry', () => {
const g = group({ allowedProviders: ['minimax'], modelAccess: { minimax: { allowed: 2, total: 5 } } })
expect(providerAccessCell(g, 'minimax')).toEqual({ allowed: true, detail: '2/5 models' })
})
it('allowed with no detail when modelAccess has no entry for this provider', () => {
const g = group({ allowedProviders: ['minimax'], modelAccess: {} })
expect(providerAccessCell(g, 'minimax')).toEqual({ allowed: true, detail: undefined })
})
// P3 item 18: allowed but 0 of the provider's models are reachable
// (a `models` glob matching none of it) must NOT render identically to
// an ordinary, genuinely-unrestricted allowed cell.
it('sets zero: true when the provider is allowed but 0 of its models are reachable', () => {
const g = group({ allowedProviders: ['minimax'], modelAccess: { minimax: { allowed: 0, total: 5 } } })
expect(providerAccessCell(g, 'minimax')).toEqual({ allowed: true, detail: '0/5 models', zero: true })
})
it('does NOT set zero when the provider itself has 0 total models (unrelated to the grant)', () => {
const g = group({ allowedProviders: ['minimax'], modelAccess: { minimax: { allowed: 0, total: 0 } } })
expect(providerAccessCell(g, 'minimax')).toEqual({ allowed: true, detail: '0/0 models' })
})
})
describe('targetAccessCell', () => {
it('allowed for every group when access is undefined (unrestricted)', () => {
expect(targetAccessCell(group({ name: 'friends' }), target({ access: undefined }))).toEqual({ allowed: true })
})
it('allowed for every group when access is null (unrestricted)', () => {
expect(targetAccessCell(group({ name: 'friends' }), target({ access: null }))).toEqual({ allowed: true })
})
it('not allowed for any group when access is an empty array', () => {
expect(targetAccessCell(group({ name: 'friends' }), target({ access: [] }))).toEqual({ allowed: false })
})
it('allowed only for a named group', () => {
expect(targetAccessCell(group({ name: 'friends' }), target({ access: ['friends'] }))).toEqual({ allowed: true })
expect(targetAccessCell(group({ name: 'home' }), target({ access: ['friends'] }))).toEqual({ allowed: false })
})
})
describe('buildAccessMatrix', () => {
it('builds one row per group, keyed by column name', () => {
const groups = [
group({ name: 'home', allowedProviders: ['anthropic', 'openai'], modelAccess: { anthropic: { allowed: 5, total: 5 } } }),
group({ name: 'friends', allowedProviders: ['minimax'] }),
]
const mcp = [target({ name: 'fetch', access: undefined }), target({ name: 'weather', access: ['home'] })]
const agents = [target({ name: 'agentkit', access: [] })]
const rows = buildAccessMatrix(groups, ['anthropic', 'openai', 'minimax'], mcp, agents)
expect(rows).toHaveLength(2)
expect(rows[0].group).toBe('home')
expect(rows[0].providers.anthropic).toEqual({ allowed: true, detail: '5/5 models' })
expect(rows[0].providers.openai).toEqual({ allowed: true, detail: undefined })
expect(rows[0].providers.minimax).toEqual({ allowed: false })
expect(rows[0].mcpServers.fetch).toEqual({ allowed: true })
expect(rows[0].mcpServers.weather).toEqual({ allowed: true })
expect(rows[0].agents.agentkit).toEqual({ allowed: false })
expect(rows[1].group).toBe('friends')
expect(rows[1].mcpServers.weather).toEqual({ allowed: false })
})
it('returns an empty array for no groups', () => {
expect(buildAccessMatrix([], ['anthropic'], [], [])).toEqual([])
})
})
+94
View File
@@ -0,0 +1,94 @@
// AccessMatrix.vue (redesign-plan.md section 3.4): pure matrix-building
// logic — rows are groups, columns are providers (an allowed check plus
// "n/m models") and every configured MCP server/agent target. Kept out of
// the component the same way every other pure data-shaping helper in this
// panel is (lib/usage-bars.ts, lib/forecast.ts) — vitest's node-environment
// config exercises it directly, no component mount required.
import type { AdminConsumerGroup, AdminTargetView } from '@/types/api'
/**
* AccessCell is one matrix cell's reading: whether the group can reach
* this provider/target at all, plus an optional human-readable detail (a
* provider cell's "n/m models"; a target cell has none). `zero` (P3 item
* 18) marks a provider cell where the provider itself is allowed but the
* group's own model grant resolves to 0 of the provider's models — e.g.
* a `models` glob that happens to match nothing this provider currently
* serves — distinct from an ordinary allowed cell (0 usable models is
* not the same reading as "unrestricted access", even though both would
* otherwise render an identical green check).
*/
export interface AccessCell {
allowed: boolean
detail?: string
zero?: boolean
}
/**
* providerAccessCell reads a group's resolved provider access (admin.go:
* adminConsumerGroup.AllowedProviders — never nil, already glob-matched
* server-side, so this is a plain membership check, not a re-implementation
* of path.Match) plus its per-provider model count (modelAccess, "n/m
* models") when the provider is allowed at all. A provider absent from
* modelAccess (the group is allowed the provider but the server reported no
* model breakdown for it — should not happen in practice, but the type
* itself is a plain Record with no guaranteed key) renders `allowed: true`
* with no detail, rather than a fabricated "0/0 models". `zero` is set only
* when the provider genuinely serves models (`total > 0`) but the group's
* grant reaches none of them (`allowed === 0`) — a `total` of 0 (a
* provider with no discovered models at all) is a different, unrelated
* situation and does not trip it.
*/
export function providerAccessCell(group: AdminConsumerGroup, providerName: string): AccessCell {
if (!group.allowedProviders.includes(providerName)) return { allowed: false }
const access = group.modelAccess[providerName]
if (!access) return { allowed: true }
// `zero` follows this module's own "undefined means unset" convention
// (every other optional AccessCell/BudgetRatio-style field in this
// panel) — omitted (never a literal `false`) so the ordinary allowed
// case's shape stays exactly `{ allowed, detail }`.
const zero = access.allowed === 0 && access.total > 0 ? true : undefined
return { allowed: true, detail: `${access.allowed}/${access.total} models`, zero }
}
/**
* targetAccessCell reads a group's access to one MCP server/agent target
* from that target's OWN `access` field (admin.go: adminTargetView.Access —
* types/api.ts's own doc comment): `null`/`undefined` means every
* configured group can reach it, `[]` means none can, a non-empty list
* names the groups that can. No detail (unlike a provider cell) — a
* target has no "n/m" breakdown, only reachable or not.
*/
export function targetAccessCell(group: AdminConsumerGroup, target: Pick<AdminTargetView, 'name' | 'access'>): AccessCell {
if (target.access === undefined || target.access === null) return { allowed: true }
return { allowed: target.access.includes(group.name) }
}
/** AccessMatrixRow is one group's full row: its own provider/MCP-server/agent cells, each keyed by that column's own name — AccessMatrix.vue iterates `providerNames`/`mcpServers`/`agents` (the caller's own column order) and looks up each cell by name rather than relying on object key iteration order. */
export interface AccessMatrixRow {
group: string
providers: Record<string, AccessCell>
mcpServers: Record<string, AccessCell>
agents: Record<string, AccessCell>
}
/**
* buildAccessMatrix builds one row per group, over the given provider
* names and MCP-server/agent target lists — the caller (AccessMatrix.vue)
* supplies the column universe (every configured provider name from GET
* /admin/api/overview; every target from GET /admin/api/targets) so this
* function stays a pure join, with no fetching or ordering opinion of
* its own.
*/
export function buildAccessMatrix(
groups: AdminConsumerGroup[],
providerNames: string[],
mcpServers: AdminTargetView[],
agents: AdminTargetView[],
): AccessMatrixRow[] {
return groups.map((group) => ({
group: group.name,
providers: Object.fromEntries(providerNames.map((name) => [name, providerAccessCell(group, name)])),
mcpServers: Object.fromEntries(mcpServers.map((target) => [target.name, targetAccessCell(group, target)])),
agents: Object.fromEntries(agents.map((target) => [target.name, targetAccessCell(group, target)])),
}))
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { cumulative, currentMonthDaySpan, MIN_ELAPSED_DAYS, runOutDate } from './burndown'
describe('currentMonthDaySpan', () => {
it('returns the UTC day-of-month', () => {
expect(currentMonthDaySpan(new Date(Date.UTC(2026, 8, 23, 12, 0, 0)))).toBe(23)
})
it('returns 1 on the first day of the month', () => {
expect(currentMonthDaySpan(new Date(Date.UTC(2026, 8, 1, 0, 0, 0)))).toBe(1)
})
})
describe('cumulative', () => {
it('returns a running total, oldest first', () => {
expect(cumulative([1, 2, 3])).toEqual([1, 3, 6])
})
it('returns an empty array for empty input', () => {
expect(cumulative([])).toEqual([])
})
it('handles a single point', () => {
expect(cumulative([5])).toEqual([5])
})
})
describe('runOutDate', () => {
it('returns null when no budget is configured (limitMicros <= 0)', () => {
expect(runOutDate(1_000_000, 0, new Date(Date.UTC(2026, 3, 15)))).toBeNull()
expect(runOutDate(1_000_000, -10, new Date(Date.UTC(2026, 3, 15)))).toBeNull()
})
it('returns now itself once spend has already met or exceeded the limit', () => {
const now = new Date(Date.UTC(2026, 3, 15, 8, 0, 0))
expect(runOutDate(5_000_000, 5_000_000, now)).toEqual(now)
expect(runOutDate(6_000_000, 5_000_000, now)).toEqual(now)
})
it('returns null before MIN_ELAPSED_DAYS have elapsed in the month', () => {
// A few hours into day 1 — well under one full elapsed day.
const earlyMonth = new Date(Date.UTC(2026, 3, 1, 2, 0, 0))
expect(runOutDate(1000, 1_000_000, earlyMonth)).toBeNull()
})
it('returns null when the daily rate so far is 0 — spend has not started', () => {
const now = new Date(Date.UTC(2026, 3, 16, 0, 0, 0))
expect(runOutDate(0, 1_000_000, now)).toBeNull()
})
it('projects forward at the MTD average daily rate', () => {
// April 2026 (30 days): 15 full days elapsed, 15,000,000 micros spent
// -> 1,000,000 micros/day. A 20,000,000 limit has 5,000,000 remaining
// -> 5 more days to exhaust.
const now = new Date(Date.UTC(2026, 3, 16, 0, 0, 0))
const result = runOutDate(15_000_000, 20_000_000, now)
expect(result).not.toBeNull()
expect(result!.getTime()).toBe(now.getTime() + 5 * 24 * 60 * 60 * 1000)
})
it('exposes MIN_ELAPSED_DAYS as a stable, importable constant', () => {
expect(MIN_ELAPSED_DAYS).toBe(1)
})
})
+93
View File
@@ -0,0 +1,93 @@
// Pure math behind BurnDownChart.vue (redesign-plan.md section 3.4): a
// cumulative month-to-date spend line against a flat budget reference, and
// the date that spend would cross the budget at the current pace. Kept out
// of the component the same way lib/forecast.ts keeps CostForecastCard.vue's
// own month-end projection math out of it — every function here takes `now`
// as an explicit argument (never reads Date.now() itself), so vitest's
// node-environment config exercises the real calendar-boundary cases
// deterministically.
//
// All month-boundary math is UTC, mirroring lib/forecast.ts's own
// monthProgress (costPerMonthMicroUsd/AdminSeriesResponse's day buckets
// both accumulate against a UTC calendar month — limits.go's windowKey
// "200601"/"20060102" — so projecting against the LOCAL calendar month
// would silently misalign on the first/last day of a month in any
// non-UTC timezone).
import { monthProgress } from '@/lib/forecast'
const MS_PER_DAY = 24 * 60 * 60 * 1000
/**
* daysInMonthUTC returns how many days are in `now`'s own UTC calendar
* month (28-31) — correct for every month length, including a leap
* February, via the same Date.UTC month-rollover normalization
* monthProgress (lib/forecast.ts) already relies on, never a hardcoded
* days-per-month table.
*/
function daysInMonthUTC(now: Date): number {
const year = now.getUTCFullYear()
const month = now.getUTCMonth()
return (Date.UTC(year, month + 1, 1) - Date.UTC(year, month, 1)) / MS_PER_DAY
}
/**
* currentMonthDaySpan is how many day buckets (1..31) `now`'s own UTC
* calendar month has elapsed so far, INCLUDING today — the `span` the
* spend store requests from GET /admin/api/usage/series for the burn-down
* line (redesign-plan.md section 3.3: "burn-down current-month day series
* span=dayOfMonth"), so a day-3-of-the-month poll asks for exactly 3
* buckets, never the whole month's worth of not-yet-existing future ones.
*/
export function currentMonthDaySpan(now: Date): number {
return now.getUTCDate()
}
/**
* cumulative turns a bucket-per-day points array (day-window
* AdminSeriesResponse series, oldest first) into a running total —
* BurnDownChart.vue's own "spend so far" line, since the series endpoint
* itself reports each day's OWN cost, not a running sum.
*/
export function cumulative(points: number[]): number[] {
let sum = 0
return points.map((p) => (sum += p))
}
/**
* MIN_ELAPSED_DAYS is runOutDate's own extrapolation floor, mirroring
* lib/forecast.ts's MIN_PROJECTION_FRACTION for the identical reason: the
* first day of a month is too little data for a daily-average run rate to
* mean anything, so runOutDate reports "not enough data yet" (null) below
* it rather than a wildly overstated projection from a single day's spend.
*/
export const MIN_ELAPSED_DAYS = 1
/**
* runOutDate projects the date month-to-date spend would cross
* `limitMicros`, extrapolating from the MTD average daily rate
* (mtdMicros / elapsed days so far) — redesign-plan.md section 3.4:
* "run-out date lib/burndown.ts runOutDate(mtdMicros, limitMicros, now) at
* MTD average daily rate". Mirrors lib/forecast.ts's headroom() null
* conventions: null when there is no budget to compare against
* (`limitMicros <= 0`), and null when there is too little of the month
* elapsed to extrapolate from (MIN_ELAPSED_DAYS) or the rate so far is 0
* (spend has not started — "never" is not representable as a date, so
* this reports "no forecast" rather than a fabricated far-future one).
* Already-exceeded budgets return `now` itself (the run-out already
* happened, as of this read) rather than a nonsensical past-vs-future date
* from continuing the division.
*/
export function runOutDate(mtdMicros: number, limitMicros: number, now: Date): Date | null {
if (limitMicros <= 0) return null
if (mtdMicros >= limitMicros) return new Date(now.getTime())
const elapsedDays = monthProgress(now) * daysInMonthUTC(now)
if (elapsedDays < MIN_ELAPSED_DAYS) return null
const dailyRateMicros = mtdMicros / elapsedDays
if (dailyRateMicros <= 0) return null
const remainingMicros = limitMicros - mtdMicros
const daysToExhaust = remainingMicros / dailyRateMicros
return new Date(now.getTime() + daysToExhaust * MS_PER_DAY)
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { cacheSavingsStatus } from './cache-savings'
describe('cacheSavingsStatus (N2)', () => {
it('is unavailable for an hour window, with a reason naming the granularity, not a fabricated $0', () => {
const status = cacheSavingsStatus('hour')
expect(status.available).toBe(false)
expect(status.note).toMatch(/hour/i)
})
it('is available with no caveat for a day window (exact figure)', () => {
expect(cacheSavingsStatus('day')).toEqual({ available: true, note: null })
})
it('is available but notes the 35-day retention cap for a month window', () => {
const status = cacheSavingsStatus('month')
expect(status.available).toBe(true)
expect(status.note).toMatch(/35/)
})
})
+47
View File
@@ -0,0 +1,47 @@
import type { HistoryWindow } from '@/types/api'
/**
* CacheSavingsStatus is cacheSavingsStatus's own result: whether the
* requested bucket window can report a cache-savings figure at all, and
* any caveat text worth showing alongside a figure that IS available but
* incomplete.
*/
export interface CacheSavingsStatus {
available: boolean
note: string | null
}
/**
* cacheSavingsStatus (N2 fix, verify-redesign-final.md) reports whether
* GET /admin/api/usage/models' `detail=1` `cacheSavedMicroUsd` figure is
* available for `window`, and any caveat note CostAvoidedCard.vue should
* show alongside it — grounded in admin.go's own cache-savings read
* (serveAdminUsageModelsDetail, admin.go:~1826-1851):
* - `'hour'`: chit/csave are OMITTED entirely (both fields left
* undefined, never a fabricated `$0.00`) — recordCacheHit only ever
* writes them at DAY granularity (plan §1.2: "model day only"), so
* there is no per-hour figure to recover at all, exact or otherwise.
* - `'day'`: read directly at the exact requested day buckets — exact,
* no caveat needed.
* - `'month'`: read via monthRangeToDayBuckets' exact covering day
* buckets, but those are capped at the day counter's own 35-day
* retention (historyMaxSpan(windowDay), admin.go) — a 3mo/6mo/12mo
* range's figure only ever covers the most recent 35 days of it,
* never silently more, and never the whole selected range once that
* range exceeds 35 days.
*
* This replaces an earlier, stale note ("reflects the last N-bucket
* window, not the selected range") that described a DIFFERENT, already-
* fixed backend bug (P3 item 15: cache savings used to always sum DAY
* buckets regardless of the requested window) — the current backend
* behaviour above is what ships now, not that one.
*/
export function cacheSavingsStatus(window: HistoryWindow): CacheSavingsStatus {
if (window === 'hour') {
return { available: false, note: 'Cache savings are not tracked per hour — pick a day or month range instead.' }
}
if (window === 'month') {
return { available: true, note: 'Covers only the most recent 35 days (day-bucket retention), not the full selected range.' }
}
return { available: true, note: null }
}
+16 -13
View File
@@ -1,15 +1,18 @@
import {
BarController,
BarElement,
CategoryScale,
Chart,
Legend,
LinearScale,
Tooltip,
} from 'chart.js'
import { CategoryScale, Chart, Filler, Legend, LinearScale, LineController, LineElement, PointElement, Tooltip } from 'chart.js'
// Registered once at module load (ES module caching makes this a true
// singleton regardless of how many components import it) — only the bar
// chart pieces this panel actually uses, not chart.js's full auto-registry,
// to keep the baked-in bundle lean.
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Legend, Tooltip)
// singleton regardless of how many components import it) — only the
// chart pieces this panel actually uses, not chart.js's full
// auto-registry, to keep the baked-in bundle lean. TimeSeriesChart.vue
// (redesign-plan.md section 3.4) is the only chart component in this
// panel, and it only ever imports `Line` from vue-chartjs (never `Bar`) —
// so this registers exactly Line's own pieces, plus `Filler`:
// TimeSeriesChart.vue sets `fill: props.stacked` on every stacked
// dataset, and without Filler registered chart.js silently ignores that
// option — a stacked area chart drew UNFILLED lines at cumulative
// heights, so a reader saw the top line at the height of the whole total
// rather than its own value (P2). A `BarController`/`BarElement`
// registration used to sit here too; it was dead — grep confirms no
// component imports vue-chartjs's `Bar`.
Chart.register(CategoryScale, LinearScale, Legend, Tooltip, LineController, LineElement, PointElement, Filler)
+128
View File
@@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest'
import { costAvoidedMicros, freeRows, pickReferenceModel } from './cost-avoided'
import type { AdminCatalogModel, AdminUsageModelEntry } from '@/types/api'
describe('freeRows', () => {
it('keeps only entries with free === true, reading out their token counts', () => {
const models: AdminUsageModelEntry[] = [
{ id: 'a/free-model:free', value: 0, free: true, tokensIn: 100, tokensOut: 50 },
{ id: 'b/paid-model', value: 10, free: false, tokensIn: 20, tokensOut: 10 },
]
expect(freeRows(models)).toEqual([{ tokensIn: 100, tokensOut: 50 }])
})
it('defaults missing token counts to 0', () => {
const models: AdminUsageModelEntry[] = [{ id: 'a/free', value: 0, free: true }]
expect(freeRows(models)).toEqual([{ tokensIn: 0, tokensOut: 0 }])
})
it('returns an empty array when nothing is free', () => {
const models: AdminUsageModelEntry[] = [{ id: 'a/paid', value: 5, free: false }]
expect(freeRows(models)).toEqual([])
})
})
describe('costAvoidedMicros', () => {
it('sums tokensIn*refIn + tokensOut*refOut across rows, already in micro-USD', () => {
// 1_000_000 tokensIn at $1/MTok = 1,000,000 micros ($1); 500_000
// tokensOut at $2/MTok = 1,000,000 micros ($1). Total 2,000,000.
const rows = [{ tokensIn: 1_000_000, tokensOut: 500_000 }]
expect(costAvoidedMicros(rows, 1, 2)).toBe(2_000_000)
})
it('sums across multiple rows', () => {
const rows = [
{ tokensIn: 1_000_000, tokensOut: 0 },
{ tokensIn: 0, tokensOut: 1_000_000 },
]
expect(costAvoidedMicros(rows, 0.5, 1.5)).toBe(500_000 + 1_500_000)
})
it('returns 0 for an empty rows array', () => {
expect(costAvoidedMicros([], 1, 1)).toBe(0)
})
it('rounds the summed total to the nearest whole micro-USD', () => {
const rows = [{ tokensIn: 1, tokensOut: 1 }]
expect(costAvoidedMicros(rows, 0.33, 0.33)).toBe(Math.round(0.33 + 0.33))
})
})
describe('pickReferenceModel', () => {
const catalog = new Map<string, AdminCatalogModel>([
['a/paid-high', { id: 'a/paid-high', model: 'paid-high', priceSource: 'builtin' }],
['a/paid-low', { id: 'a/paid-low', model: 'paid-low', priceSource: 'override' }],
['a/free', { id: 'a/free', model: 'free', priceSource: 'free' }],
['a/unpriced', { id: 'a/unpriced', model: 'unpriced', priceSource: 'unpriced' }],
])
it('picks the most-requested model among genuinely priced catalog entries', () => {
const models: AdminUsageModelEntry[] = [
{ id: 'a/paid-low', value: 1, requests: 5 },
{ id: 'a/paid-high', value: 1, requests: 50 },
]
expect(pickReferenceModel(models, catalog)).toBe('a/paid-high')
})
it('skips free and unpriced catalog entries even if most-requested', () => {
const models: AdminUsageModelEntry[] = [
{ id: 'a/free', value: 1, requests: 1000 },
{ id: 'a/paid-low', value: 1, requests: 1 },
]
expect(pickReferenceModel(models, catalog)).toBe('a/paid-low')
})
it('skips a served id missing from the catalog entirely', () => {
const models: AdminUsageModelEntry[] = [{ id: 'a/removed', value: 1, requests: 999 }]
expect(pickReferenceModel(models, catalog)).toBeNull()
})
it('skips a detail-less entry with no requests field', () => {
const models: AdminUsageModelEntry[] = [{ id: 'a/paid-high', value: 1 }]
expect(pickReferenceModel(models, catalog)).toBeNull()
})
it('returns null when nothing qualifies', () => {
const models: AdminUsageModelEntry[] = [{ id: 'a/free', value: 1, requests: 10 }]
expect(pickReferenceModel(models, catalog)).toBeNull()
})
it('returns null for an empty ranking', () => {
expect(pickReferenceModel([], catalog)).toBeNull()
})
})
/**
* N1 (verify-redesign-final.md): CostAvoidedCard.vue's own `avoidedMicros`
* computed is exactly `pickReferenceModel` feeding `costAvoidedMicros`
* with the picked model's `cat.inputPerMTokUsd`/`outputPerMTokUsd` — this
* end-to-end shape is what broke when the catalog reported `priceSource:
* 'override'` alongside undefined prices (admin_catalog.go's now-fixed
* bug). This exercises the same two-step flow directly against a fixed-
* shape catalog (billing price present for every priced source,
* including 'override' — the contract GET /admin/api/catalog now
* guarantees) so a regression back to undefined-but-priced catalog
* entries fails here, not just in a Go-only test.
*/
describe('pickReferenceModel + costAvoidedMicros end-to-end (N1 regression)', () => {
it('computes a non-null avoided-cost figure when the picked reference model is override-priced', () => {
const catalog = new Map<string, AdminCatalogModel>([
['a/override-priced', { id: 'a/override-priced', model: 'override-priced', priceSource: 'override', inputPerMTokUsd: 5, outputPerMTokUsd: 10 }],
])
const models: AdminUsageModelEntry[] = [
{ id: 'a/override-priced', value: 1, requests: 42, free: false },
{ id: 'a/free-model', value: 0, free: true, tokensIn: 1_000_000, tokensOut: 500_000 },
]
const picked = pickReferenceModel(models, catalog)
expect(picked).toBe('a/override-priced')
const cat = catalog.get(picked as string)
expect(cat?.inputPerMTokUsd).toBe(5)
expect(cat?.outputPerMTokUsd).toBe(10)
const avoided = costAvoidedMicros(freeRows(models), cat?.inputPerMTokUsd as number, cat?.outputPerMTokUsd as number)
expect(avoided).toBe(1_000_000 * 5 + 500_000 * 10)
})
})
+84
View File
@@ -0,0 +1,84 @@
// Pure math behind CostAvoidedCard.vue (redesign-plan.md section 3.4): how
// much a fleet's FREE-tier traffic would have cost against a real, priced
// reference model, plus which model to default that reference to. Kept out
// of the component the same way lib/forecast.ts/lib/burndown.ts keep their
// own math out of theirs.
import type { AdminCatalogModel, AdminUsageModelEntry } from '@/types/api'
/**
* FreeUsageRow is the subset of a GET /admin/api/usage/models `detail=1`
* entry costAvoidedMicros needs — deliberately narrower than
* AdminUsageModelEntry so the function is testable against plain literals
* without constructing a full entry.
*/
export interface FreeUsageRow {
tokensIn: number
tokensOut: number
}
/**
* freeRows filters a model ranking down to its FREE rows (detail=1's own
* `free` flag — a model billed as $0, whether a configured modelMeta.free
* override or a genuinely unpriced model, types/api.ts's PriceSource doc
* comment) and reads out just the token counts costAvoidedMicros needs.
* `tokensIn`/`tokensOut` default to 0 for a detail-less entry (should not
* happen once the caller has requested detail=1, but this keeps the
* function total rather than reading `undefined` into arithmetic).
*/
export function freeRows(models: AdminUsageModelEntry[]): FreeUsageRow[] {
return models.filter((m) => m.free).map((m) => ({ tokensIn: m.tokensIn ?? 0, tokensOut: m.tokensOut ?? 0 }))
}
/**
* costAvoidedMicros sums, over every free-tier row, what that traffic
* would have cost at a REAL reference model's own per-token-million rates
* — the fleet's "cost avoided by routing to free models" figure
* (redesign-plan.md section 3.4).
*
* Unit derivation (why no explicit /1e6 appears despite `refInputPerMTokUsd`
* being USD PER MILLION tokens, types/api.ts's AdminCatalogModel doc
* comment): cost_usd = tokensIn * (refInputPerMTokUsd / 1_000_000);
* cost_micros = cost_usd * 1_000_000 (MICROS_PER_USD, lib/usage-bars.ts) =
* tokensIn * refInputPerMTokUsd. The two factors of 1_000_000 cancel
* exactly, so multiplying token counts directly by the $-per-million-token
* rate already yields micro-USD — the same unit AdminUsageEntryView's own
* costPerDayMicroUsd/costPerMonthMicroUsd fields use, with no separate
* scaling step. Rounded once, over the summed total, not per-row, so a sum
* of many sub-micro rows never underflows to a visibly wrong 0 before
* accumulating.
*/
export function costAvoidedMicros(rows: FreeUsageRow[], refInputPerMTokUsd: number, refOutputPerMTokUsd: number): number {
let total = 0
for (const row of rows) total += row.tokensIn * refInputPerMTokUsd + row.tokensOut * refOutputPerMTokUsd
return Math.round(total)
}
/** REFERENCE_INELIGIBLE_SOURCES are the catalog priceSource values pickReferenceModel refuses to default to — a "what would this have cost at a real price" comparison is meaningless against another $0 model. */
const REFERENCE_INELIGIBLE_SOURCES = new Set(['free', 'unpriced'])
/**
* pickReferenceModel defaults the "compare free traffic against" model to
* the MOST-REQUESTED genuinely-priced model actually served in the current
* range (redesign-plan.md section 3.4: "reference model picker from priced
* catalog models, default most-requested paid model in range"). Ranked by
* `requests` specifically — NOT by whichever metric the Spend page's own
* breakdown happens to be sorted by — since a cost-ranked or token-ranked
* list would bias the default toward an expensive or verbose model rather
* than the one readers actually reach for most. A model missing from
* `catalog` (not currently in the live registry — a stale ranking entry
* for a since-removed model) or lacking a `requests` count (a detail-less
* entry) is skipped. Returns null when nothing in the range qualifies —
* every served model was itself free/unpriced, or usage/models was empty
* — the caller then falls back to an explicit picker with no preselection
* rather than a fabricated default.
*/
export function pickReferenceModel(models: AdminUsageModelEntry[], catalog: Map<string, AdminCatalogModel>): string | null {
let best: AdminUsageModelEntry | null = null
for (const m of models) {
if (m.requests === undefined) continue
const cat = catalog.get(m.id)
if (!cat || REFERENCE_INELIGIBLE_SOURCES.has(cat.priceSource)) continue
if (best === null || m.requests > (best.requests ?? 0)) best = m
}
return best?.id ?? null
}
+3 -3
View File
@@ -1,7 +1,7 @@
// F8 (CSV export): the one RFC 4180 encoder every CSV export in this panel
// builds on (usage-csv.ts's usageCsv/modelDetailCsv, the Models tab's
// export) — one implementation, not a copy per export (vue.md: "if you've
// written it twice, you owe an abstraction").
// builds on (usage-csv.ts's usageCsv, PricingHealthTable.vue's own
// pricingHealthCsv) — 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'
+3 -2
View File
@@ -29,8 +29,9 @@ const REVOKE_DELAY_MS = 1000
* 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
* imported somewhere `document` is not global, matching
* composables/useHashState.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,
+1 -1
View File
@@ -64,7 +64,7 @@ export interface EventFilter {
* 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
* every other search filter in this panel does (ProviderHealthPanel.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.
+2 -2
View File
@@ -98,8 +98,8 @@ describe('formatModelCostHover', () => {
// Discovery circuit breaker (feat/provider-health, round 3): openUntil's
// future-time counterpart to formatAgo's past-time "(Ns ago)". Only
// caller today is ProvidersView.vue's healthBadgeLabel, rendering the
// breaker's live "open (in Ns)" countdown.
// caller today is ProviderHealthPanel.vue's healthBadgeLabel, rendering
// the breaker's live "open (in Ns)" countdown.
describe('formatUntil', () => {
it('renders "" for the unset zero-time sentinel', () => {
expect(formatUntil(ZERO_TIME)).toBe('')
+70 -91
View File
@@ -1,153 +1,132 @@
import { describe, expect, it } from 'vitest'
import {
buildHash,
DEFAULT_TAB,
parseChartsParams,
parseEventsParams,
parseHashState,
parseUsageParams,
} from './hash-state'
import { PAGE_IDS } from './pages'
import { buildHash, parseGlobalParams, parseHashState, parsePageParams } 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('splits a page and query string', () => {
const { page, query } = parseHashState('#spend?range=30d&by=model')
expect(page).toBe('spend')
expect(query.get('range')).toBe('30d')
expect(query.get('by')).toBe('model')
})
it('parses a bare tab with no query to an empty query', () => {
const { tab, query } = parseHashState('#usage')
expect(tab).toBe('usage')
it('parses a bare page with no query to an empty query', () => {
const { page, query } = parseHashState('#consumers')
expect(page).toBe('consumers')
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_PAGE ("home") for an empty hash', () => {
expect(parseHashState('').page).toBe('home')
})
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('falls back to DEFAULT_PAGE for an unrecognized, non-legacy page, and still parses its query', () => {
const { page, query } = parseHashState('#bogus?range=30d')
expect(page).toBe('home')
expect(query.get('range')).toBe('30d')
})
it('accepts a hash with or without the leading #', () => {
expect(parseHashState('targets').tab).toBe('targets')
expect(parseHashState('targets').page).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)
it('every current page id round-trips through parseHashState(buildHash(...))', () => {
for (const page of PAGE_IDS) {
expect(parseHashState(buildHash(page, {})).page).toBe(page)
}
})
describe('legacy tab-name mapping', () => {
it.each([
['providers', 'models'],
['usage', 'consumers'],
['charts', 'spend'],
['events', 'reliability'],
['targets', 'targets'],
] as const)('#%s maps to #%s', (legacy, mapped) => {
expect(parseHashState(`#${legacy}`).page).toBe(mapped)
})
it('drops the old hash\'s query params entirely, regardless of shape', () => {
const { page, query } = parseHashState('#charts?tab=models&metric=cost&scope=user%3Aalice')
expect(page).toBe('spend')
expect(Array.from(query.keys())).toEqual([])
})
})
})
describe('buildHash', () => {
it('omits the query suffix entirely when params is empty', () => {
expect(buildHash('providers', {})).toBe('#providers')
expect(buildHash('home', {})).toBe('#home')
})
it('appends a query string for non-empty params', () => {
expect(buildHash('charts', { window: 'day' })).toBe('#charts?window=day')
expect(buildHash('spend', { range: '30d' })).toBe('#spend?range=30d')
})
it('joins multiple params with &', () => {
const hash = buildHash('charts', { window: 'day', scope: 'user:alice' })
expect(hash).toBe('#charts?window=day&scope=user%3Aalice')
it('joins multiple params with &, in insertion order', () => {
const hash = buildHash('spend', { range: '30d', cmp: 'prev', scope: 'user:alice' })
expect(hash).toBe('#spend?range=30d&cmp=prev&scope=user%3Aalice')
})
it('drops a param whose value is an explicit empty string', () => {
expect(buildHash('usage', { q: '' })).toBe('#usage')
expect(buildHash('consumers', { q: '' })).toBe('#consumers')
})
})
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' })
describe('parseGlobalParams', () => {
it('accepts every valid range/cmp/scope value', () => {
const p = parseGlobalParams(new URLSearchParams('range=30d&cmp=prev&scope=group:eng'))
expect(p).toEqual({ range: '30d', cmp: 'prev', scope: 'group:eng' })
})
it('omits window when it is not a recognized HistoryWindow', () => {
expect(parseChartsParams(new URLSearchParams('window=fortnight'))).toEqual({})
it('omits range when it is not a recognized RangeKey', () => {
expect(parseGlobalParams(new URLSearchParams('range=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('omits cmp when it is not "none" or "prev"', () => {
expect(parseGlobalParams(new URLSearchParams('cmp=bogus'))).toEqual({})
})
it.each([
['total', true],
['all', true],
['group:eng', true],
['user:alice', true],
['group:ops', true],
['model:openai/gpt-5', true],
['user:', false], // empty id after the colon
['provider:openai', true],
['user:', false],
['bogus', false],
['', false],
] as const)('scope %s valid=%s', (scope, valid) => {
const p = parseChartsParams(new URLSearchParams(scope ? `scope=${encodeURIComponent(scope)}` : ''))
const p = parseGlobalParams(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('omits one invalid field while keeping the other valid ones', () => {
const p = parseGlobalParams(new URLSearchParams('range=fortnight&cmp=prev&scope=all'))
expect(p).toEqual({ cmp: 'prev', scope: 'all' })
})
it('returns an empty object for an empty query', () => {
expect(parseChartsParams(new URLSearchParams())).toEqual({})
expect(parseGlobalParams(new URLSearchParams())).toEqual({})
})
})
describe('parseUsageParams', () => {
it('reads q when present', () => {
expect(parseUsageParams(new URLSearchParams('q=alice'))).toEqual({ q: 'alice' })
describe('parsePageParams', () => {
it('excludes range/cmp/scope but keeps every other key', () => {
const p = parsePageParams(new URLSearchParams('range=30d&cmp=prev&scope=all&by=model&metric=cost'))
expect(p).toEqual({ by: 'model', metric: 'cost' })
})
it('omits q when absent', () => {
expect(parseUsageParams(new URLSearchParams())).toEqual({})
it('drops a key with an explicit empty-string value', () => {
expect(parsePageParams(new URLSearchParams('q='))).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('passes through a key this build does not recognize (forward-compat)', () => {
expect(parsePageParams(new URLSearchParams('someFutureParam=x'))).toEqual({ someFutureParam: 'x' })
})
it('returns an empty object for an empty query', () => {
expect(parseEventsParams(new URLSearchParams())).toEqual({})
expect(parsePageParams(new URLSearchParams())).toEqual({})
})
})
+90 -99
View File
@@ -1,135 +1,126 @@
import { hashToTab, TAB_VALUES, tabToHash, type TabValue } from '@/composables/useTabHash'
import type { ChartTab, ModelMetric } from '@/stores/history'
import type { HistoryWindow } from '@/types/api'
import { DEFAULT_PAGE, isPageId } from '@/lib/pages'
import type { PageId } from '@/lib/pages'
import { isCmpMode, isRangeKey } from '@/lib/range'
import type { CmpMode, RangeKey } from '@/lib/range'
/**
* 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).
* LEGACY_PAGE_MAP redirects the pre-redesign flat-tab hash names to their
* redesign home (redesign-plan.md section 3.1's legacy mapping): a
* bookmark or shared link built against the old #providers/#usage/
* #charts/#events/#targets shell still lands somewhere sensible instead
* of falling back to the default page. `targets` keeps its own name —
* the MCP & Agents page is the one page that did not move.
*
* The OLD hash's query params never carry over (see parseHashState below)
* — they were shaped for the old tab (e.g. #charts's `tab=models&metric=
* cost`), and none of that maps cleanly onto the new page's own param
* names, so a legacy hash lands on a clean instance of its mapped page
* rather than misapplying a stale param.
*/
export const DEFAULT_TAB: TabValue = 'providers'
const LEGACY_PAGE_MAP: Record<string, PageId> = {
providers: 'models',
usage: 'consumers',
charts: 'spend',
events: 'reliability',
targets: 'targets',
}
export interface HashState {
tab: TabValue
page: PageId
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.
* parseHashState splits a raw location.hash ("#spend?range=30d&by=model")
* into its outer page and the remaining query string. Three cases:
*
* 1. The page segment is a current PageId (lib/pages.ts) — parse the
* query normally.
* 2. The page segment is a legacy tab name (LEGACY_PAGE_MAP) — map to the
* new page, with an EMPTY query (old params dropped, see the map's own
* doc comment).
* 3. Anything else (absent, empty, or unrecognized) — DEFAULT_PAGE, query
* parsed as-is (so a malformed page segment does not also swallow an
* otherwise-valid query string).
*
* 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 pagePart = 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) }
if (isPageId(pagePart)) return { page: pagePart, query: new URLSearchParams(queryPart) }
const legacy = LEGACY_PAGE_MAP[pagePart]
if (legacy) return { page: legacy, query: new URLSearchParams() }
return { page: DEFAULT_PAGE, 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.
* buildHash re-serializes a page plus an already-default-stripped param
* record (global filters and/or page params merged by the caller) back
* into a "#<page>?<query>" string. "Omit defaults" is the CALLER's job —
* composables/useHashState.ts's own computed decides what counts as
* default for range/cmp/scope; buildHash itself only defends against an
* explicitly empty-string value slipping through, dropping it the same
* way. No query suffix at all when `params` ends up empty, so the common
* "nothing but defaults" case round-trips to a bare "#<page>".
*/
export function buildHash(tab: TabValue, params: Record<string, string>): string {
export function buildHash(page: PageId, 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()}`
if (entries.length === 0) return `#${page}`
return `#${page}?${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
export interface GlobalHashParams {
range?: RangeKey
cmp?: CmpMode
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
}
/** SCOPE_PATTERN mirrors the global filter bar's own scope convention (redesign-plan.md section 3.1): "all", or one of the three "{kind}:{id}" forms — an id may contain almost anything, so each kind only checks its own prefix plus a non-empty remainder. Exported so GlobalFilterBar.vue validates its own scope input against the exact same rule this module uses to parse it back out of the hash. */
export const SCOPE_PATTERN = /^(all|group:.+|user:.+|provider:.+)$/
/**
* 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.
* parseGlobalParams validates the three page-independent hash params
* (range/cmp/scope) every page's hash shares. Each field is independently
* OMITTED (never throws) when absent or not a recognized value — a
* hand-edited or stale-version URL with `range=fortnight` degrades to
* "use whatever this field already is" (the caller decides the actual
* default), rather than failing the whole hash or resetting the other,
* valid fields 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
export function parseGlobalParams(query: URLSearchParams): GlobalHashParams {
const result: GlobalHashParams = {}
const range = query.get('range')
if (range && isRangeKey(range)) result.range = range
const cmp = query.get('cmp')
if (cmp && isCmpMode(cmp)) result.cmp = cmp
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
}
/** GLOBAL_PARAM_KEYS names the three query keys parseGlobalParams reads — parsePageParams below excludes exactly these, and nothing else, from a page's own param record. */
const GLOBAL_PARAM_KEYS: ReadonlySet<string> = new Set(['range', 'cmp', 'scope'])
/**
* 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).
* parsePageParams returns every hash query param EXCEPT the three global
* ones, verbatim, as a plain record. Deliberately opaque (no per-page
* validation here — see stores/nav.ts's own doc comment for why): each
* page owns interpreting its own param names/values, and a key this
* build does not recognize (an older link, or one from a newer page)
* simply passes through unused rather than being rejected.
*/
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
export function parsePageParams(query: URLSearchParams): Record<string, string> {
const result: Record<string, string> = {}
for (const [key, value] of query.entries()) {
if (GLOBAL_PARAM_KEYS.has(key)) continue
if (value === '') continue
result[key] = value
}
return result
}
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import {
budgetRatio,
budgetTier,
errorRateTier,
fleetErrorRate,
openBreakerCount,
sumGroupBudgetMicros,
} from './kpi'
import type { AdminGroupView, AdminProviderView } from '@/types/api'
function makeGroup(overrides: Partial<AdminGroupView> = {}): AdminGroupView {
return { name: 'eng', memberCount: 3, ...overrides }
}
function makeProvider(overrides: Partial<AdminProviderView> = {}): AdminProviderView {
return {
name: 'openai',
type: 'openai',
baseUrl: 'https://api.openai.com',
lastRefresh: '0001-01-01T00:00:00Z',
models: [],
modelCount: 0,
modelMeta: {},
discoveryEnabled: false,
healthState: 'closed',
openUntil: '0001-01-01T00:00:00Z',
attemptsDay: 0,
failuresDay: 0,
attemptsMinute: 0,
failuresMinute: 0,
modelRates: {},
...overrides,
}
}
describe('sumGroupBudgetMicros', () => {
it('sums every group\'s costPerMonthUSD limit, converted to micro-USD', () => {
const groups = [
makeGroup({ limits: { costPerMonthUSD: 10 } }),
makeGroup({ limits: { costPerMonthUSD: 25.5 } }),
]
expect(sumGroupBudgetMicros(groups)).toBe(35_500_000)
})
it('skips a group with no costPerMonthUSD configured', () => {
const groups = [makeGroup({ limits: undefined }), makeGroup({ limits: { costPerMonthUSD: 0 } })]
expect(sumGroupBudgetMicros(groups)).toBe(0)
})
it('returns 0 for an empty group list', () => {
expect(sumGroupBudgetMicros([])).toBe(0)
})
})
describe('budgetRatio / budgetTier', () => {
it('returns null when budgetMicros is 0 ("no budget set")', () => {
expect(budgetRatio(5_000_000, 0)).toBeNull()
expect(budgetTier(null)).toBe('ok')
})
it('classifies below 0.8 as ok, [0.8, 1.0) as warn, >= 1.0 as critical', () => {
expect(budgetTier(budgetRatio(700_000, 1_000_000))).toBe('ok')
expect(budgetTier(budgetRatio(900_000, 1_000_000))).toBe('warn')
expect(budgetTier(budgetRatio(1_200_000, 1_000_000))).toBe('critical')
})
})
describe('openBreakerCount', () => {
it('counts providers whose breaker is not closed', () => {
const providers = [
makeProvider({ healthState: 'closed' }),
makeProvider({ healthState: 'open' }),
makeProvider({ healthState: 'half-open' }),
]
expect(openBreakerCount(providers)).toBe(2)
})
it('returns 0 when every breaker is closed', () => {
expect(openBreakerCount([makeProvider(), makeProvider()])).toBe(0)
})
})
describe('fleetErrorRate / errorRateTier', () => {
it('sums attemptsMinute/failuresMinute across every provider', () => {
const providers = [
makeProvider({ attemptsMinute: 100, failuresMinute: 1 }),
makeProvider({ attemptsMinute: 50, failuresMinute: 4 }),
]
const result = fleetErrorRate(providers)
expect(result.attempts).toBe(150)
expect(result.failures).toBe(5)
expect(result.rate).toBeCloseTo(5 / 150)
})
it('rate is null when there were no attempts (never a fabricated 0%)', () => {
const result = fleetErrorRate([makeProvider({ attemptsMinute: 0, failuresMinute: 0 })])
expect(result.rate).toBeNull()
expect(errorRateTier(result.rate)).toBe('ok')
})
it('classifies error rate into ok/warn/critical using the shared provider-rate thresholds', () => {
expect(errorRateTier(0.001)).toBe('ok') // 99.9% success
expect(errorRateTier(0.05)).toBe('warn') // 95% success, below the 99% amber threshold
expect(errorRateTier(0.2)).toBe('critical') // 80% success, below the 90% destructive threshold
})
})
+91
View File
@@ -0,0 +1,91 @@
// Pure math behind the Home page's KpiTile.vue tiles (redesign-plan.md
// section 3.4) — kept out of the component/page the same way lib/
// provider-rate.ts and lib/usage-bars.ts keep their own tier math out of
// their components, so vitest's node-environment config exercises it
// directly, no component mount required. Deliberately reuses those two
// modules' existing thresholds (BAR_AMBER_THRESHOLD/BAR_RED_THRESHOLD,
// PROVIDER_RATE_AMBER_THRESHOLD/PROVIDER_RATE_DESTRUCTIVE_THRESHOLD)
// rather than declaring a third, independent set of budget/error-rate
// boundaries for the same two underlying concepts (a used/limit ratio,
// a success rate) the rest of this panel already colors consistently.
import { providerRateStatus, providerSuccessRate } from '@/lib/provider-rate'
import { MICROS_PER_USD, ratioTier } from '@/lib/usage-bars'
import type { AdminGroupView, AdminProviderView } from '@/types/api'
/** KpiTier is KpiTile.vue's own three-color scale — 'ok' (normal), 'warn' (approaching a limit / minor degradation), 'critical' (over a limit / severe degradation). */
export type KpiTier = 'ok' | 'warn' | 'critical'
/**
* sumGroupBudgetMicros sums every configured group's costPerMonthUSD
* limit, converted to micro-USD (Q2, redesign-plan.md DECISIONS: "Home
* budget baseline = sum of group costPerMonthUSD"). A group with no
* costPerMonthUSD configured (0 or undefined) contributes nothing — the
* caller reads a 0 total as "no budget set" (budgetRatio below), never a
* genuine zero-dollar budget.
*/
export function sumGroupBudgetMicros(groups: AdminGroupView[]): number {
let total = 0
for (const group of groups) {
const limitUsd = group.limits?.costPerMonthUSD
if (limitUsd && limitUsd > 0) total += Math.round(limitUsd * MICROS_PER_USD)
}
return total
}
/** budgetRatio is `spentMicros / budgetMicros`, or null when budgetMicros is 0 or negative ("no budget set" — Q2) — the fleet has nothing configured to compare month-to-date spend against. */
export function budgetRatio(spentMicros: number, budgetMicros: number): number | null {
if (budgetMicros <= 0) return null
return spentMicros / budgetMicros
}
/** budgetTier classifies a budgetRatio result into the Home spend tile's KpiTier, reusing lib/usage-bars.ts's own ratioTier thresholds (0.8 amber, 1.0 red) so a budget tile and a per-scope UsageBar agree on what "approaching the limit" means. A null ratio (no budget set) reads as 'ok' — there is nothing to warn about. */
export function budgetTier(ratio: number | null): KpiTier {
if (ratio === null) return 'ok'
switch (ratioTier(ratio)) {
case 'normal':
return 'ok'
case 'amber':
return 'warn'
case 'red':
return 'critical'
}
}
/** openBreakerCount counts providers whose discovery circuit breaker is not 'closed' (AdminProviderView.healthState) — the Home "open breakers" tile's own count, per-replica like the field it reads (see AdminProviderView.healthState's own doc comment). */
export function openBreakerCount(providers: AdminProviderView[]): number {
return providers.filter((p) => p.healthState !== 'closed').length
}
/** FleetErrorRate is the Home error-rate tile's fleet-wide reading: summed minute-window attempts/failures across every provider, and the resulting rate. */
export interface FleetErrorRate {
/** failures/attempts as a 0-1 fraction, or null when there were no attempts in the last minute (never a fabricated 0%). */
rate: number | null
attempts: number
failures: number
}
/** fleetErrorRate sums every provider's attemptsMinute/failuresMinute (AdminProviderView — the SAME per-replica, in-process minute counters ProviderRateBadge.vue's own minuteRateTitle reads) into one fleet-wide error rate. */
export function fleetErrorRate(providers: AdminProviderView[]): FleetErrorRate {
let attempts = 0
let failures = 0
for (const p of providers) {
attempts += p.attemptsMinute
failures += p.failuresMinute
}
const success = providerSuccessRate(attempts, failures)
return { rate: success === null ? null : 1 - success, attempts, failures }
}
/** errorRateTier classifies a fleetErrorRate() result into the Home error-rate tile's KpiTier, reusing lib/provider-rate.ts's own success-rate thresholds (99% amber, 90% red) inverted for an error-rate reading — the same boundaries the Providers tab's own badges already use. */
export function errorRateTier(rate: number | null): KpiTier {
if (rate === null) return 'ok'
switch (providerRateStatus(1 - rate)) {
case 'no-traffic':
case 'healthy':
return 'ok'
case 'degraded':
return 'warn'
case 'severe':
return 'critical'
}
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { formatLastSeen } from './last-seen'
const NOW = new Date(Date.UTC(2026, 3, 16, 12, 0, 0))
const NOW_SECONDS = Math.floor(NOW.getTime() / 1000)
describe('formatLastSeen', () => {
it.each([
[undefined, 'never'],
[0, 'never'],
])('%s seconds -> "never" (types/api.ts: 0/omitted means never seen or the feature is off)', (seconds, expected) => {
expect(formatLastSeen(seconds, NOW)).toBe(expected)
})
it('renders "just now" for a timestamp under a minute old', () => {
expect(formatLastSeen(NOW_SECONDS - 30, NOW)).toBe('just now')
})
it('renders "just now" for a timestamp in the FUTURE (clock skew), clamping delta to 0 rather than a negative duration', () => {
expect(formatLastSeen(NOW_SECONDS + 500, NOW)).toBe('just now')
})
it('renders minutes under an hour', () => {
expect(formatLastSeen(NOW_SECONDS - 5 * 60, NOW)).toBe('5m ago')
})
it('renders hours under a day', () => {
expect(formatLastSeen(NOW_SECONDS - 3 * 3600, NOW)).toBe('3h ago')
})
it('renders days under the month threshold', () => {
expect(formatLastSeen(NOW_SECONDS - 10 * 86400, NOW)).toBe('10d ago')
})
it('renders months at/beyond the month threshold', () => {
expect(formatLastSeen(NOW_SECONDS - 90 * 86400, NOW)).toBe('3mo ago')
})
it('boundary: exactly 59 seconds is still "just now"', () => {
expect(formatLastSeen(NOW_SECONDS - 59, NOW)).toBe('just now')
})
it('boundary: exactly 60 seconds is "1m ago"', () => {
expect(formatLastSeen(NOW_SECONDS - 60, NOW)).toBe('1m ago')
})
it('boundary: exactly 23h59m is still hours, not a day', () => {
expect(formatLastSeen(NOW_SECONDS - (86400 - 60), NOW)).toBe('23h ago')
})
it('defaults `now` to the real current time when omitted', () => {
const nowSeconds = Math.floor(Date.now() / 1000)
expect(formatLastSeen(nowSeconds)).toBe('just now')
})
})
+39
View File
@@ -0,0 +1,39 @@
// Consumers page (redesign-plan.md section 3.4): pure relative-time
// formatting for AdminUsageEntryView.lastSeen — a fleet-wide, throttled
// (60s/replica/scope) unix-seconds timestamp (types/api.ts's own doc
// comment). Kept out of usage-columns.ts/ConsumerDirectory.vue the same way
// every other pure display helper in this panel (lib/format.ts, lib/
// usage-bars.ts, lib/forecast.ts) is — vitest's node-environment config
// exercises it directly, no component mount or fake timers required (every
// function here takes `now` as an explicit argument, never reads Date.now()
// itself, matching lib/forecast.ts's own determinism convention).
const SECONDS_PER_MINUTE = 60
const SECONDS_PER_HOUR = 3600
const SECONDS_PER_DAY = 86400
/** MONTH_THRESHOLD_DAYS is where formatLastSeen switches from "Nd ago" to "Nmo ago" — 2 months' worth of days, so a scope last seen 6 weeks ago still reads as a legible day count rather than rounding down to "1mo ago" (a needlessly coarse figure for something that recent). */
const MONTH_THRESHOLD_DAYS = 60
const DAYS_PER_MONTH_APPROX = 30
/**
* formatLastSeen renders a scope's last-seen unix-seconds timestamp as a
* short relative-time label: "just now" under a minute, "Nm ago" under an
* hour, "Nh ago" under a day, "Nd ago" under MONTH_THRESHOLD_DAYS, "Nmo
* ago" beyond it. `undefined` or `0` means "never seen, or admin.stats.
* lastSeen is off" (types/api.ts's own doc comment: "never render 0 as an
* epoch date") — rendered as the literal "never", never "0s ago" or a
* fabricated 1970 date. A timestamp in the future (clock skew between this
* replica and the reader's own clock) clamps to "just now" rather than a
* nonsensical negative duration.
*/
export function formatLastSeen(seconds: number | undefined, now: Date = new Date()): string {
if (!seconds) return 'never'
const nowSeconds = Math.floor(now.getTime() / 1000)
const delta = Math.max(0, nowSeconds - seconds)
if (delta < SECONDS_PER_MINUTE) return 'just now'
if (delta < SECONDS_PER_HOUR) return `${Math.floor(delta / SECONDS_PER_MINUTE)}m ago`
if (delta < SECONDS_PER_DAY) return `${Math.floor(delta / SECONDS_PER_HOUR)}h ago`
const days = Math.floor(delta / SECONDS_PER_DAY)
if (days < MONTH_THRESHOLD_DAYS) return `${days}d ago`
return `${Math.floor(days / DAYS_PER_MONTH_APPROX)}mo ago`
}
+36 -21
View File
@@ -1,38 +1,53 @@
import { describe, expect, it } from 'vitest'
import { filterModelsByPrefix } from './model-filter'
import type { AdminUsageModelEntry } from '@/types/api'
import { filterModelRows } from './model-filter'
import type { ModelCatalogRow } from './model-table-columns'
const models: AdminUsageModelEntry[] = [
{ id: 'openai/gpt-5', value: 10 },
{ id: 'openai/gpt-5-mini', value: 5 },
{ id: 'anthropic/claude-opus', value: 8 },
function row(overrides: Partial<ModelCatalogRow> = {}): ModelCatalogRow {
return {
id: 'openai/gpt-5',
model: 'gpt-5',
providerName: 'openai',
priceSource: 'builtin',
aliases: [],
requests: 0,
tokensIn: 0,
tokensOut: 0,
costMicroUsd: 0,
attempts: 0,
failures: 0,
...overrides,
}
}
const rows: ModelCatalogRow[] = [
row({ id: 'openai/gpt-5' }),
row({ id: 'openai/gpt-5-mini' }),
row({ id: 'anthropic/claude-opus', aliases: ['smart', 'flagship'] }),
]
describe('filterModelsByPrefix', () => {
it('returns every entry unchanged when the prefix is empty', () => {
expect(filterModelsByPrefix(models, '')).toBe(models)
describe('filterModelRows', () => {
it('returns every row unchanged for an empty query', () => {
expect(filterModelRows(rows, '')).toBe(rows)
})
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('returns every row unchanged for a whitespace-only query', () => {
expect(filterModelRows(rows, ' ')).toBe(rows)
})
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('matches a substring of the canonical id, case-insensitively', () => {
expect(filterModelRows(rows, 'GPT-5').map((r) => r.id)).toEqual(['openai/gpt-5', 'openai/gpt-5-mini'])
})
it('returns an empty array when nothing matches the prefix', () => {
expect(filterModelsByPrefix(models, 'mistral/')).toEqual([])
it('matches a provider prefix', () => {
expect(filterModelRows(rows, 'anthropic/').map((r) => r.id)).toEqual(['anthropic/claude-opus'])
})
it('is case-sensitive (ids are canonical, lowercase provider/model strings)', () => {
expect(filterModelsByPrefix(models, 'OpenAI/')).toEqual([])
it('matches an alias, case-insensitively', () => {
expect(filterModelRows(rows, 'FLAGSHIP').map((r) => r.id)).toEqual(['anthropic/claude-opus'])
})
it('does not match a substring that is not a prefix', () => {
expect(filterModelsByPrefix(models, 'gpt-5')).toEqual([])
it('returns an empty array when nothing matches', () => {
expect(filterModelRows(rows, 'mistral')).toEqual([])
})
})
+13 -11
View File
@@ -1,15 +1,17 @@
import type { AdminUsageModelEntry } from '@/types/api'
import type { ModelCatalogRow } from '@/lib/model-table-columns'
/**
* 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.
* filterModelRows narrows the Models page's already-fetched catalog rows
* (lib/model-table-columns.ts's ModelCatalogRow, built from GET
* /admin/api/catalog + usage/models + performance) to whichever match a
* free-text query (redesign-plan.md section 3.1's Models page `q` param) —
* against the canonical id (ModelChip's own copyable text) OR any of the
* model's own aliases, case-insensitively. An empty/whitespace-only query
* is "no filter", returning `rows` unchanged (the pre-redesign
* filterModelsByPrefix this function replaces made the identical choice).
*/
export function filterModelsByPrefix(models: AdminUsageModelEntry[], prefix: string): AdminUsageModelEntry[] {
if (!prefix) return models
return models.filter((m) => m.id.startsWith(prefix))
export function filterModelRows(rows: ModelCatalogRow[], query: string): ModelCatalogRow[] {
const q = query.trim().toLowerCase()
if (!q) return rows
return rows.filter((r) => r.id.toLowerCase().includes(q) || r.aliases.some((a) => a.toLowerCase().includes(q)))
}
+222 -103
View File
@@ -2,138 +2,257 @@ import type { VNode } from 'vue'
import { isVNode } from 'vue'
import { describe, expect, it } from 'vitest'
import EntityLink from '@/components/EntityLink.vue'
import ModelChip from '@/components/ModelChip.vue'
import ScopeLink from '@/components/ScopeLink.vue'
import { Badge } from '@/components/ui/badge'
import { modelTableColumns } from './model-table-columns'
import type { AdminUsageModelEntry } from '@/types/api'
import { buildModelCatalogRows, modelCatalogColumns } from './model-table-columns'
import type { ModelCatalogRow } from './model-table-columns'
import type { AdminCatalogResponse, AdminPerfResponse, AdminUsageModelsResponse } from '@/types/api'
function testEntry(overrides: Partial<AdminUsageModelEntry> = {}): AdminUsageModelEntry {
function testRow(overrides: Partial<ModelCatalogRow> = {}): ModelCatalogRow {
return {
id: 'openai/gpt-5',
value: 42,
requests: 10,
tokensIn: 100,
tokensOut: 200,
costMicroUsd: 1_234_500,
free: false,
model: 'gpt-5',
providerName: 'openai',
priceSource: 'builtin',
aliases: [],
requests: 0,
tokensIn: 0,
tokensOut: 0,
costMicroUsd: 0,
attempts: 0,
failures: 0,
...overrides,
}
}
function accessorFnOf(col: unknown): (row: AdminUsageModelEntry, index: number) => unknown {
return (col as { accessorFn: (row: AdminUsageModelEntry, index: number) => unknown }).accessorFn
function accessorFnOf(col: unknown): (row: ModelCatalogRow, index: number) => unknown {
return (col as { accessorFn: (row: ModelCatalogRow, index: number) => unknown }).accessorFn
}
function cellOf(col: unknown): (ctx: { row: { original: AdminUsageModelEntry } }) => unknown {
return (col as { cell: (ctx: { row: { original: AdminUsageModelEntry } }) => unknown }).cell
function cellOf(col: unknown): (ctx: { row: { original: ModelCatalogRow } }) => unknown {
return (col as { cell: (ctx: { row: { original: ModelCatalogRow } }) => unknown }).cell
}
describe('modelTableColumns: Model column', () => {
const columns = modelTableColumns()
const col = columns.find((c) => c.id === 'model')
describe('buildModelCatalogRows', () => {
function catalog(): AdminCatalogResponse {
return {
providers: [
{
lastRefresh: '2026-01-01T00:00:00Z',
openUntil: '0001-01-01T00:00:00Z',
name: 'openai',
type: 'openai',
healthState: 'closed',
discoveryEnabled: true,
models: [
{ id: 'openai/gpt-5', model: 'gpt-5', priceSource: 'builtin', inputPerMTokUsd: 1.5, outputPerMTokUsd: 6 },
{ id: 'openai/gpt-5-mini', model: 'gpt-5-mini', priceSource: 'unpriced' },
],
},
],
aliases: [],
}
}
it('accessorFn reads the raw canonical id (no routableModelId join — id is already provider/model)', () => {
expect(accessorFnOf(col)(testEntry({ id: 'anthropic/claude' }), 0)).toBe('anthropic/claude')
function ranking(): AdminUsageModelsResponse {
return {
metric: 'cost',
window: 'day',
span: 7,
offset: 0,
detail: true,
models: [{ id: 'openai/gpt-5', value: 5, requests: 10, tokensIn: 100, tokensOut: 200, costMicroUsd: 5_000_000 }],
}
}
function perf(): AdminPerfResponse {
return {
kind: 'model',
window: 'day',
span: 7,
offset: 0,
latencyEnabled: true,
rows: [{ id: 'openai/gpt-5', p50Ms: 120, p95Ms: 400, count: 10, attempts: 10, failures: 1 }],
}
}
it('produces one row per CATALOG model, not per ranking/performance row', () => {
const rows = buildModelCatalogRows(catalog(), ranking(), perf())
expect(rows).toHaveLength(2)
expect(rows.map((r) => r.id)).toEqual(['openai/gpt-5', 'openai/gpt-5-mini'])
})
it('cell renders a ModelChip with the id, no context/cost props (this table has no resolved metadata to pass)', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ id: 'anthropic/claude' }) } }) as VNode
it('joins ranking and performance fields onto a matching catalog row', () => {
const rows = buildModelCatalogRows(catalog(), ranking(), perf())
const row = rows.find((r) => r.id === 'openai/gpt-5')
expect(row).toMatchObject({
requests: 10,
tokensIn: 100,
tokensOut: 200,
costMicroUsd: 5_000_000,
p50Ms: 120,
p95Ms: 400,
attempts: 10,
failures: 1,
})
})
it('defaults usage/performance fields to zero/undefined for a catalog model with no traffic', () => {
const rows = buildModelCatalogRows(catalog(), ranking(), perf())
const row = rows.find((r) => r.id === 'openai/gpt-5-mini')
expect(row).toMatchObject({ requests: 0, tokensIn: 0, tokensOut: 0, costMicroUsd: 0, attempts: 0, failures: 0 })
expect(row?.p50Ms).toBeUndefined()
expect(row?.p95Ms).toBeUndefined()
})
it('carries priceSource/displayFree/aliases straight from the catalog model', () => {
const c = catalog()
c.providers[0].models[0].displayFree = true
c.providers[0].models[0].aliases = ['smart']
const rows = buildModelCatalogRows(c, ranking(), perf())
const row = rows.find((r) => r.id === 'openai/gpt-5')
expect(row?.priceSource).toBe('builtin')
expect(row?.displayFree).toBe(true)
expect(row?.aliases).toEqual(['smart'])
})
it('defaults aliases to an empty array when the catalog model omits it', () => {
const rows = buildModelCatalogRows(catalog(), ranking(), perf())
expect(rows.find((r) => r.id === 'openai/gpt-5-mini')?.aliases).toEqual([])
})
})
describe('modelCatalogColumns: id column', () => {
const col = modelCatalogColumns().find((c) => c.id === 'id')
it('cell renders a ModelChip and an EntityLink pointed at kind "model"', () => {
const rendered = cellOf(col)({ row: { original: testRow({ id: 'anthropic/claude' }) } }) as VNode
const children = rendered.children as VNode[]
const chip = children.find((c) => c.type === ModelChip)
expect(chip).toBeDefined()
const link = children.find((c) => c.type === EntityLink)
expect(chip?.props?.id).toBe('anthropic/claude')
})
it('cell renders a ScopeLink pointed at "model:{id}"', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ id: 'anthropic/claude' }) } }) as VNode
const children = rendered.children as VNode[]
const link = children.find((c) => c.type === ScopeLink)
expect(link).toBeDefined()
expect(link?.props?.scope).toBe('model:anthropic/claude')
expect(link?.props?.kind).toBe('model')
expect(link?.props?.id).toBe('anthropic/claude')
expect(link?.props?.label).toBe('anthropic/claude')
})
})
describe('modelTableColumns: detail count columns sort on raw numbers, render CompactNumber', () => {
const columns = modelTableColumns()
describe('modelCatalogColumns: price column', () => {
const col = modelCatalogColumns().find((c) => c.id === 'price')
it.each([
['requests', (e: AdminUsageModelEntry) => e.requests],
['tokensIn', (e: AdminUsageModelEntry) => e.tokensIn],
['tokensOut', (e: AdminUsageModelEntry) => e.tokensOut],
] as const)('%s accessorFn returns the raw detail number, not a compact string', (id, read) => {
const col = columns.find((c) => c.id === id)
const entry = testEntry({ requests: 15_000_000, tokensIn: 999, tokensOut: 12_000 })
expect(accessorFnOf(col)(entry, 0)).toBe(read(entry))
})
it.each(['requests', 'tokensIn', 'tokensOut'] as const)('%s cell renders a CompactNumber vnode', (id) => {
const col = columns.find((c) => c.id === id)
const rendered = cellOf(col)({ row: { original: testEntry() } })
expect(isVNode(rendered)).toBe(true)
})
it.each(['requests', 'tokensIn', 'tokensOut'] as const)(
'%s falls back to 0 (never undefined) when detail was not requested, defensively',
(id) => {
const col = columns.find((c) => c.id === id)
const entry = testEntry({ requests: undefined, tokensIn: undefined, tokensOut: undefined })
expect(accessorFnOf(col)(entry, 0)).toBe(0)
},
)
it('sorting by requests orders numerically, not lexicographically', () => {
const col = columns.find((c) => c.id === 'requests')
const accessor = accessorFnOf(col)
const entries = [
testEntry({ id: 'a', requests: 15_000_000 }),
testEntry({ id: 'b', requests: 999 }),
testEntry({ id: 'c', requests: 12_000 }),
]
const sorted = [...entries].sort((x, y) => (accessor(x, 0) as number) - (accessor(y, 0) as number))
expect(sorted.map((e) => e.id)).toEqual(['b', 'c', 'a'])
})
})
describe('modelTableColumns: Cost column', () => {
const columns = modelTableColumns()
const col = columns.find((c) => c.id === 'cost')
it('accessorFn reads the raw micro-USD cost, even for a free model (real 0, not omitted)', () => {
expect(accessorFnOf(col)(testEntry({ costMicroUsd: 1_234_500, free: false }), 0)).toBe(1_234_500)
expect(accessorFnOf(col)(testEntry({ costMicroUsd: 0, free: true }), 0)).toBe(0)
})
it('renders plain formatCost text for a priced (non-free) entry', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ costMicroUsd: 1_234_500, free: false }) } })
expect(isVNode(rendered)).toBe(false)
expect(rendered).toBe('$1.2345')
})
it('shows the real recorded cost, not the badge, for a model marked free after it accrued cost', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ costMicroUsd: 1_234_500, free: true }) } })
expect(isVNode(rendered)).toBe(false)
expect(rendered).toBe('$1.2345')
})
it('renders a Badge with the accessible text "free" for a free entry, instead of $0', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ costMicroUsd: 0, free: true }) } }) as VNode
it('renders a destructive "unpriced" Badge for priceSource "unpriced"', () => {
const rendered = cellOf(col)({ row: { original: testRow({ priceSource: 'unpriced' }) } }) as VNode
expect(rendered.type).toBe(Badge)
expect(typeof rendered.children).toBe('object')
const slotDefault = (rendered.children as { default: () => unknown }).default
expect(slotDefault()).toBe('free')
expect(rendered.props?.variant).toBe('destructive')
})
it('a free entry still sorts by its real (0) cost, not excluded or reordered by the badge', () => {
const entries = [testEntry({ id: 'paid', costMicroUsd: 5_000_000, free: false }), testEntry({ id: 'free', costMicroUsd: 0, free: true })]
it('renders a "free" Badge for priceSource "free"', () => {
const rendered = cellOf(col)({ row: { original: testRow({ priceSource: 'free' }) } }) as VNode
expect(rendered.type).toBe(Badge)
const slot = (rendered.children as { default: () => string }).default
expect(slot()).toBe('free')
})
it('renders the formatted price plus a source Badge for override/builtin/litellm', () => {
const rendered = cellOf(col)({
row: { original: testRow({ priceSource: 'litellm', inputPerMTokUsd: 1, outputPerMTokUsd: 2 }) },
}) as VNode
const children = rendered.children as VNode[]
expect((children[0] as VNode).children).toBe('in $1.00 / out $2.00 per MTok')
const badge = children[1] as VNode
expect(badge.type).toBe(Badge)
expect((badge.children as { default: () => string }).default()).toBe('litellm')
})
it('renders an em dash placeholder for a priced source with unknown per-token cost (version skew)', () => {
const rendered = cellOf(col)({ row: { original: testRow({ priceSource: 'override' }) } }) as VNode
const children = rendered.children as VNode[]
expect((children[0] as VNode).children).toBe('—')
})
it('sorts unpriced first, then ascending by input price', () => {
const accessor = accessorFnOf(col)
const sorted = [...entries].sort((x, y) => (accessor(y, 0) as number) - (accessor(x, 0) as number)) // desc
expect(sorted.map((e) => e.id)).toEqual(['paid', 'free'])
const rows = [
testRow({ id: 'a', priceSource: 'builtin', inputPerMTokUsd: 5 }),
testRow({ id: 'b', priceSource: 'unpriced' }),
testRow({ id: 'c', priceSource: 'builtin', inputPerMTokUsd: 1 }),
]
const sorted = [...rows].sort((x, y) => (accessor(x, 0) as number) - (accessor(y, 0) as number))
expect(sorted.map((r) => r.id)).toEqual(['b', 'c', 'a'])
})
})
it('falls back to formatCost($0) when free is false/undefined and costMicroUsd is undefined (detail not requested)', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ costMicroUsd: undefined, free: undefined }) } })
expect(rendered).toBe('$0.0000')
describe('modelCatalogColumns: free column (":free" suffix, distinct from priceSource)', () => {
const col = modelCatalogColumns().find((c) => c.id === 'free')
it('renders a Badge only when displayFree is true', () => {
const rendered = cellOf(col)({ row: { original: testRow({ displayFree: true }) } }) as VNode
expect(rendered.type).toBe(Badge)
})
it('renders an em dash when displayFree is false/undefined, even for priceSource "free"', () => {
const rendered = cellOf(col)({ row: { original: testRow({ priceSource: 'free' }) } })
expect(isVNode(rendered) && rendered.type).toBe('span')
expect(isVNode(rendered) && rendered.children).toBe('—')
})
})
describe('modelCatalogColumns: aliases column', () => {
const col = modelCatalogColumns().find((c) => c.id === 'aliases')
it('renders an em dash for an empty alias list', () => {
const rendered = cellOf(col)({ row: { original: testRow({ aliases: [] }) } })
expect(isVNode(rendered) && rendered.children).toBe('—')
})
it('renders one Badge per alias', () => {
const rendered = cellOf(col)({ row: { original: testRow({ aliases: ['a', 'b'] }) } }) as VNode
const children = rendered.children as VNode[]
expect(children).toHaveLength(2)
expect(children.every((c) => c.type === Badge)).toBe(true)
})
})
describe('modelCatalogColumns: cost column', () => {
const col = modelCatalogColumns().find((c) => c.id === 'cost')
it('renders a "free" Badge for a zero-cost free/displayFree row instead of $0.0000', () => {
const rendered = cellOf(col)({ row: { original: testRow({ priceSource: 'free', costMicroUsd: 0 }) } }) as VNode
expect(rendered.type).toBe(Badge)
const rendered2 = cellOf(col)({ row: { original: testRow({ displayFree: true, costMicroUsd: 0 }) } }) as VNode
expect(rendered2.type).toBe(Badge)
})
it('renders the real formatted cost when non-zero, even if free', () => {
const rendered = cellOf(col)({ row: { original: testRow({ priceSource: 'free', costMicroUsd: 1_234_500 }) } })
expect(rendered).toBe('$1.2345')
})
it('renders plain formatCost for a priced, non-free row', () => {
const rendered = cellOf(col)({ row: { original: testRow({ costMicroUsd: 2_000_000 }) } })
expect(rendered).toBe('$2.0000')
})
})
describe('modelCatalogColumns: p50/p95/error rate columns', () => {
it('p50/p95 render an em dash when undefined, formatted latency otherwise', () => {
const p50 = modelCatalogColumns().find((c) => c.id === 'p50')
const p95 = modelCatalogColumns().find((c) => c.id === 'p95')
expect(cellOf(p50)({ row: { original: testRow() } })).toMatchObject({ children: '—' })
expect(cellOf(p95)({ row: { original: testRow({ p95Ms: 1500 }) } })).toMatchObject({ children: '1.5s' })
})
it('error rate renders "no data" for zero attempts, a ceiled percent otherwise', () => {
const col = modelCatalogColumns().find((c) => c.id === 'errorRate')
expect(cellOf(col)({ row: { original: testRow({ attempts: 0, failures: 0 }) } })).toMatchObject({ children: 'no data' })
expect(cellOf(col)({ row: { original: testRow({ attempts: 100, failures: 1 }) } })).toMatchObject({ children: '1%' })
})
it('error rate sorts a no-traffic row (-1) below every real rate', () => {
const col = modelCatalogColumns().find((c) => c.id === 'errorRate')
const accessor = accessorFnOf(col)
expect(accessor(testRow({ attempts: 0, failures: 0 }), 0)).toBe(-1)
expect(accessor(testRow({ attempts: 10, failures: 0 }), 0)).toBe(0)
})
})
+242 -85
View File
@@ -2,109 +2,266 @@ import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'
import CompactNumber from '@/components/CompactNumber.vue'
import EntityLink from '@/components/EntityLink.vue'
import ModelChip from '@/components/ModelChip.vue'
import ScopeLink from '@/components/ScopeLink.vue'
import { Badge } from '@/components/ui/badge'
import { formatCost } from '@/lib/format'
import type { AdminUsageModelEntry } from '@/types/api'
import { formatContextWindow, formatCost, formatLatencyMs, formatModelCostHover } from '@/lib/format'
import { formatErrorRatePercent, providerErrorRate } from '@/lib/provider-rate'
import type { AdminCatalogResponse, AdminPerfResponse, AdminUsageModelsResponse, PriceSource } from '@/types/api'
/**
* modelColumn renders a ranked row's id as a ModelChip (the same copyable
* chip ProvidersView.vue's own per-model list renders) beside a ScopeLink
* that jumps to Charts scoped to this exact model — the identical pair
* ProvidersView.vue already builds from `routableModelId(provider, model)`,
* reused here rather than a third hand-rolled copy (vue.md: "if you've
* written it twice, you owe an abstraction"). No routableModelId join is
* needed: `id` is already the canonical "provider/model" form
* (AdminUsageModelEntry's own doc comment, types/api.ts).
* ModelCatalogRow (redesign-plan.md section 3.4: "join catalog + ranking +
* performance") is the Models page's one row shape, built by
* buildModelCatalogRows below from THREE independent responses: GET
* /admin/api/catalog (stores/catalog.ts, WP-D — every configured model,
* regardless of traffic), GET /admin/api/usage/models?detail=1 (the
* current global range's ranking — zero for a model with no traffic in
* range), and GET /admin/api/performance?kind=model (fleet p50/p95/
* attempts/failures — top 50 by req in span, server default; a model
* outside that top 50 simply carries zero attempts/failures here, same as
* one with no ranking entry).
*
* The catalog is the DRIVING list (one row per catalog model, not per
* ranking/performance row) — this is a catalog table first, matching
* AdminCatalogModel's own doc comment ("computed for EVERY catalog model,
* not just ones with observed traffic").
*/
function modelColumn(): ColumnDef<AdminUsageModelEntry, unknown> {
return {
id: 'model',
header: 'Model',
accessorFn: (entry) => entry.id,
cell: ({ row }) =>
h('span', { class: 'inline-flex flex-wrap items-center gap-1.5' }, [
h(ModelChip, { id: row.original.id }),
h(ScopeLink, { label: row.original.id, scope: `model:${row.original.id}` }),
]),
}
export interface ModelCatalogRow {
/** Canonical "provider/model" — the join key across all three source responses. */
id: string
/** The bare upstream model id, without the provider prefix (AdminCatalogModel.model). */
model: string
providerName: string
contextTokens?: number
inputPerMTokUsd?: number
outputPerMTokUsd?: number
priceSource: PriceSource
/** True for a ':free' suffix model — display-only; priceSource carries the real billing truth regardless (types/api.ts's own AdminCatalogModel doc comment). */
displayFree?: boolean
aliases: string[]
requests: number
tokensIn: number
tokensOut: number
costMicroUsd: number
p50Ms?: number
p95Ms?: number
attempts: number
failures: number
}
/**
* numericDetailColumn builds one right-aligned, sortable count column
* reading a `detail=1`-only field (requests/tokensIn/tokensOut —
* AdminUsageModelEntry's own doc comment): undefined only when the caller
* fetched without `detail=1`, which stores/history.ts's fetchModelRanking
* never does any more (free-models-plan.md) — `?? 0` covers that
* theoretical case defensively, the same fallback numericColumn
* (usage-columns.ts) applies for its own optional fields, never a silent
* mis-sort of a genuine 0 count. Rendered via CompactNumber, mirroring
* every other count column in this panel (usage-columns.ts,
* target-columns.ts).
* buildModelCatalogRows joins the three source responses by canonical id.
* Pure and Vue-free — testable directly, and reusable by stores/models.ts
* without that store needing to know the join's own internals.
*/
function numericDetailColumn(
id: string,
header: string,
read: (entry: AdminUsageModelEntry) => number | undefined,
): ColumnDef<AdminUsageModelEntry, unknown> {
return {
id,
header,
accessorFn: (entry) => read(entry) ?? 0,
meta: { align: 'right' },
cell: ({ row }) => h(CompactNumber, { value: read(row.original) ?? 0 }),
export function buildModelCatalogRows(
catalog: AdminCatalogResponse,
ranking: AdminUsageModelsResponse,
perf: AdminPerfResponse,
): ModelCatalogRow[] {
const rankingById = new Map(ranking.models.map((m) => [m.id, m]))
const perfById = new Map((perf.rows ?? []).map((r) => [r.id, r]))
const rows: ModelCatalogRow[] = []
for (const provider of catalog.providers) {
for (const m of provider.models) {
const usage = rankingById.get(m.id)
const perfRow = perfById.get(m.id)
rows.push({
id: m.id,
model: m.model,
providerName: provider.name,
contextTokens: m.contextTokens,
inputPerMTokUsd: m.inputPerMTokUsd,
outputPerMTokUsd: m.outputPerMTokUsd,
priceSource: m.priceSource,
displayFree: m.displayFree,
aliases: m.aliases ?? [],
requests: usage?.requests ?? 0,
tokensIn: usage?.tokensIn ?? 0,
tokensOut: usage?.tokensOut ?? 0,
costMicroUsd: usage?.costMicroUsd ?? 0,
p50Ms: perfRow?.p50Ms,
p95Ms: perfRow?.p95Ms,
attempts: perfRow?.attempts ?? 0,
failures: perfRow?.failures ?? 0,
})
}
}
return rows
}
/** PRICE_SOURCE_LABEL is the price-source Badge's visible text for the three "priced by a rule" sources — 'free'/'unpriced' get their own dedicated badge treatment in priceCell below, never this generic label. */
const PRICE_SOURCE_LABEL: Record<Extract<PriceSource, 'override' | 'builtin' | 'litellm'>, string> = {
override: 'override',
builtin: 'builtin',
litellm: 'litellm',
}
function priceCell(row: ModelCatalogRow) {
if (row.priceSource === 'unpriced') {
return h(
Badge,
{ as: 'span', variant: 'destructive', class: 'font-normal', title: 'No pricing rule matched — billed as $0.' },
() => 'unpriced',
)
}
if (row.priceSource === 'free') {
return h(Badge, { as: 'span', variant: 'secondary', class: 'font-normal', title: 'Configured free (modelMeta.free) — priced at $0.' }, () => 'free')
}
const known = row.inputPerMTokUsd !== undefined && row.outputPerMTokUsd !== undefined
// Narrowed to a local BEFORE the h() callback below — TS control-flow
// narrowing on `row.priceSource` (from the two early returns above)
// does not cross a closure boundary, so the callback itself still sees
// the full PriceSource union unless the already-narrowed value is
// captured into its own variable first.
const source = row.priceSource as Exclude<PriceSource, 'unpriced' | 'free'>
return h('span', { class: 'inline-flex flex-wrap items-center gap-1.5' }, [
h('span', {}, known ? formatModelCostHover(row.inputPerMTokUsd as number, row.outputPerMTokUsd as number) : '—'),
h(Badge, { as: 'span', variant: 'outline', class: 'font-normal' }, () => PRICE_SOURCE_LABEL[source]),
])
}
/**
* costColumn renders a "free" Badge (accessible text "free" —
* free-models-plan.md's own explicit requirement) in place of the dollar
* figure whenever `entry.free` is true and no cost was recorded, plain
* formatCost otherwise.
* Sorting is unaffected by the badge: the accessorFn always reads the raw
* costMicroUsd number (0 for a free model, same as any other zero cost),
* so a cost-descending sort still puts every free model at the bottom
* exactly where a real $0 belongs — only the DISPLAYED cell changes.
* priceSortValue orders unpriced first (nothing to report, worth an
* operator's attention), then by ascending input price — accessorFn's
* numeric return, not the rendered text, is what DataTable actually sorts
* on.
*/
function costColumn(): ColumnDef<AdminUsageModelEntry, unknown> {
return {
id: 'cost',
header: 'Cost',
accessorFn: (entry) => entry.costMicroUsd ?? 0,
meta: { align: 'right' },
cell: ({ row }) => {
const entry = row.original
// A model marked free now may still carry cost recorded before it was
// marked free: show the real dollars then, not the badge.
if (entry.free && (entry.costMicroUsd ?? 0) === 0) {
return h(Badge, { as: 'span', variant: 'secondary', class: 'font-normal', title: 'priced at $0 per token' }, () => 'free')
}
return formatCost(entry.costMicroUsd ?? 0)
},
}
function priceSortValue(row: ModelCatalogRow): number {
if (row.priceSource === 'unpriced') return -1
return row.inputPerMTokUsd ?? 0
}
/**
* modelTableColumns builds the Models tab's ranking table (free-models-
* plan.md, UI section): Model, Requests, Tokens in, Tokens out, Cost. Every
* numeric column reads its OWN `detail=1` field independently of whichever
* metric the ranking is currently sorted by (stores/history.ts's
* modelMetric) — the table always shows all four figures per row, so a
* reader ranking by cost can still see a model's request count (and vice
* versa) without switching the ranking metric. The server already returns
* the ranking sorted by the ranked metric descending (ModelUsageChart.vue's
* own doc comment), and DataTable.vue's uncontrolled sort state starts
* empty, so that server order — "current metric desc" — is exactly what
* renders before any header is clicked; no explicit initial `sorting` prop
* is needed here.
* modelCatalogColumns builds the Models page's catalog table (redesign-
* plan.md section 3.4): id, context, price + source badge, free (the
* ':free' suffix flag — distinct from the price column's own 'free'
* priceSource badge, see ModelCatalogRow's own doc comment), aliases,
* req/tokens/cost for the current global range, p50/p95, and error rate.
* p50/p95 read "—" whenever undefined, regardless of WHY (no observations
* yet, or admin.stats.latency off entirely) — ModelCatalogTable.vue itself
* carries the admin.stats.latency hint at the table level (task
* instruction: "handle latencyEnabled=false with a hint naming
* admin.stats.latency"), not repeated on every row.
*/
export function modelTableColumns(): ColumnDef<AdminUsageModelEntry, unknown>[] {
export function modelCatalogColumns(): ColumnDef<ModelCatalogRow, unknown>[] {
return [
modelColumn(),
numericDetailColumn('requests', 'Requests', (e) => e.requests),
numericDetailColumn('tokensIn', 'Tokens in', (e) => e.tokensIn),
numericDetailColumn('tokensOut', 'Tokens out', (e) => e.tokensOut),
costColumn(),
{
id: 'id',
header: 'Model',
accessorFn: (r) => r.id,
cell: ({ row }) =>
h('span', { class: 'inline-flex flex-wrap items-center gap-1.5' }, [
h(ModelChip, {
id: row.original.id,
contextTokens: row.original.contextTokens,
inputPerMTokUsd: row.original.inputPerMTokUsd,
outputPerMTokUsd: row.original.outputPerMTokUsd,
}),
h(EntityLink, { label: row.original.id, kind: 'model', id: row.original.id }),
]),
},
{
id: 'context',
header: 'Context',
meta: { align: 'right' },
accessorFn: (r) => r.contextTokens ?? 0,
cell: ({ row }) =>
h(
'span',
{ class: 'tabular-nums' },
row.original.contextTokens !== undefined ? formatContextWindow(row.original.contextTokens) : '—',
),
},
{
id: 'price',
header: 'Price',
accessorFn: (r) => priceSortValue(r),
cell: ({ row }) => priceCell(row.original),
},
{
id: 'free',
header: 'Free',
accessorFn: (r) => (r.displayFree ? 1 : 0),
cell: ({ row }) =>
row.original.displayFree
? h(
Badge,
{ as: 'span', variant: 'secondary', class: 'font-normal', title: "':free' suffix model — display-only, billing follows the Price column's own source" },
() => 'free',
)
: h('span', { class: 'text-muted-foreground' }, '—'),
},
{
id: 'aliases',
header: 'Aliases',
enableSorting: false,
accessorFn: (r) => r.aliases.join(', '),
cell: ({ row }) => {
const aliases = row.original.aliases
if (aliases.length === 0) return h('span', { class: 'text-muted-foreground' }, '—')
return h(
'div',
{ class: 'flex flex-wrap gap-1' },
aliases.map((a) => h(Badge, { key: a, as: 'span', variant: 'outline', class: 'font-mono font-normal' }, () => a)),
)
},
},
{
id: 'requests',
header: 'Requests',
meta: { align: 'right' },
accessorFn: (r) => r.requests,
cell: ({ row }) => h(CompactNumber, { value: row.original.requests }),
},
{
id: 'tokensIn',
header: 'Tokens in',
meta: { align: 'right' },
accessorFn: (r) => r.tokensIn,
cell: ({ row }) => h(CompactNumber, { value: row.original.tokensIn }),
},
{
id: 'tokensOut',
header: 'Tokens out',
meta: { align: 'right' },
accessorFn: (r) => r.tokensOut,
cell: ({ row }) => h(CompactNumber, { value: row.original.tokensOut }),
},
{
id: 'cost',
header: 'Cost',
meta: { align: 'right' },
accessorFn: (r) => r.costMicroUsd,
cell: ({ row }) => {
const r = row.original
if ((r.priceSource === 'free' || r.displayFree) && r.costMicroUsd === 0) {
return h(Badge, { as: 'span', variant: 'secondary', class: 'font-normal', title: 'priced at $0 per token' }, () => 'free')
}
return formatCost(r.costMicroUsd)
},
},
{
id: 'p50',
header: 'p50',
meta: { align: 'right' },
accessorFn: (r) => r.p50Ms ?? -1,
cell: ({ row }) => h('span', { class: 'tabular-nums' }, row.original.p50Ms !== undefined ? formatLatencyMs(row.original.p50Ms) : '—'),
},
{
id: 'p95',
header: 'p95',
meta: { align: 'right' },
accessorFn: (r) => r.p95Ms ?? -1,
cell: ({ row }) => h('span', { class: 'tabular-nums' }, row.original.p95Ms !== undefined ? formatLatencyMs(row.original.p95Ms) : '—'),
},
{
id: 'errorRate',
header: 'Error rate',
meta: { align: 'right' },
accessorFn: (r) => providerErrorRate(r.attempts, r.failures) ?? -1,
cell: ({ row }) => h('span', { class: 'tabular-nums' }, formatErrorRatePercent(providerErrorRate(row.original.attempts, row.original.failures))),
},
]
}
+48
View File
@@ -0,0 +1,48 @@
import {
faCoins,
faCubes,
faHeartPulse,
faHouse,
faPlug,
faSliders,
faUsers,
} from '@fortawesome/free-solid-svg-icons'
import type { IconDefinition } from '@fortawesome/free-solid-svg-icons'
/**
* PAGE_IDS is the sidebar redesign's page union (redesign-plan.md section
* 3.1) — replaces composables/useTabHash.ts's old flat TAB_VALUES. The
* single source of truth for every "which page" check: SidebarNav.vue's
* link list, stores/nav.ts's `page` state, and lib/hash-state.ts's
* outer "#<page>" segment all import PAGE_IDS/PageId rather than each
* redeclaring their own copy.
*/
export const PAGE_IDS = ['home', 'spend', 'consumers', 'models', 'reliability', 'config', 'targets'] as const
export type PageId = (typeof PAGE_IDS)[number]
/** The landing page when the hash names nothing recognized, or is absent (first load). */
export const DEFAULT_PAGE: PageId = 'home'
/** One sidebar entry: the page it navigates to, its visible label, and its icon (redesign-plan.md section 3.1's fixed icon list). */
export interface PageNavEntry {
id: PageId
label: string
icon: IconDefinition
}
/** PAGE_NAV drives SidebarNav.vue's link list, in display order. */
export const PAGE_NAV: readonly PageNavEntry[] = [
{ id: 'home', label: 'Home', icon: faHouse },
{ id: 'spend', label: 'Spend', icon: faCoins },
{ id: 'consumers', label: 'Consumers', icon: faUsers },
{ id: 'models', label: 'Models', icon: faCubes },
{ id: 'reliability', label: 'Reliability', icon: faHeartPulse },
{ id: 'config', label: 'Config', icon: faSliders },
{ id: 'targets', label: 'MCP & Agents', icon: faPlug },
]
/** isPageId is a runtime type guard against PAGE_IDS — the only place that string union is checked against untrusted input (a hash, a hand-typed URL). */
export function isPageId(value: string): value is PageId {
return (PAGE_IDS as readonly string[]).includes(value)
}
+6 -4
View File
@@ -1,8 +1,10 @@
// 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
// stores/spend.ts, stores/events.ts, and composables/useNow.ts each wire
// their own refresh action into (vue.md: "if you've written it twice,
// you owe an abstraction") — originally three near-identical
// setInterval-plus-immediate-tick blocks (including the pre-redesign
// stores/history.ts, since deleted) 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
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest'
import { joinPricingHealth, pricingHealthOrder } from './pricing-health'
import type { AdminCatalogModel, AdminUsageModelEntry } from '@/types/api'
describe('joinPricingHealth', () => {
const catalog = new Map<string, AdminCatalogModel>([
[
'a/priced',
{
id: 'a/priced',
model: 'priced',
priceSource: 'builtin',
contextTokens: 128000,
inputPerMTokUsd: 1,
outputPerMTokUsd: 2,
aliases: ['a/alias'],
},
],
['a/free-suffix', { id: 'a/free-suffix', model: 'free-suffix:free', priceSource: 'unpriced', displayFree: true }],
])
it('joins a served model with its catalog entry by canonical id', () => {
const models: AdminUsageModelEntry[] = [
{ id: 'a/priced', value: 5, requests: 5, tokensIn: 100, tokensOut: 50, costMicroUsd: 1234, free: false },
]
expect(joinPricingHealth(models, catalog)).toEqual([
{
id: 'a/priced',
priceSource: 'builtin',
displayFree: false,
requests: 5,
tokensIn: 100,
tokensOut: 50,
costMicroUsd: 1234,
contextTokens: 128000,
inputPerMTokUsd: 1,
outputPerMTokUsd: 2,
aliases: ['a/alias'],
},
])
})
it('reads displayFree from the catalog join, not the ranking free flag', () => {
const models: AdminUsageModelEntry[] = [{ id: 'a/free-suffix', value: 1, requests: 1, free: false }]
expect(joinPricingHealth(models, catalog)[0]?.displayFree).toBe(true)
expect(joinPricingHealth(models, catalog)[0]?.priceSource).toBe('unpriced')
})
it('skips a served id missing from the catalog entirely', () => {
const models: AdminUsageModelEntry[] = [{ id: 'a/removed', value: 1, requests: 1 }]
expect(joinPricingHealth(models, catalog)).toEqual([])
})
it('defaults every detail field to 0 when the ranking entry omits it', () => {
const models: AdminUsageModelEntry[] = [{ id: 'a/priced', value: 1 }]
const row = joinPricingHealth(models, catalog)[0]!
expect(row.requests).toBe(0)
expect(row.tokensIn).toBe(0)
expect(row.tokensOut).toBe(0)
expect(row.costMicroUsd).toBe(0)
})
it('returns an empty array for an empty ranking', () => {
expect(joinPricingHealth([], catalog)).toEqual([])
})
// N1 (verify-redesign-final.md): an override-priced model must join
// with its billed inputPerMTokUsd/outputPerMTokUsd present, not
// undefined — PricingHealthTable.vue's own priceCell renders "—"
// whenever either is undefined, which used to fire for EVERY
// 'override' row regardless of whether a `pricing:` override was
// actually configured (admin_catalog.go filled these fields from a
// separate display resolution that an override-only model never
// populated).
it('joins an override-priced model with its billed price present, not "—"', () => {
const overrideCatalog = new Map<string, AdminCatalogModel>([
['a/override', { id: 'a/override', model: 'override', priceSource: 'override', inputPerMTokUsd: 5, outputPerMTokUsd: 10 }],
])
const models: AdminUsageModelEntry[] = [{ id: 'a/override', value: 1, requests: 3, tokensIn: 10, tokensOut: 5, costMicroUsd: 100 }]
const row = joinPricingHealth(models, overrideCatalog)[0]!
expect(row.priceSource).toBe('override')
expect(row.inputPerMTokUsd).toBe(5)
expect(row.outputPerMTokUsd).toBe(10)
})
})
describe('pricingHealthOrder', () => {
it('puts unpriced rows first regardless of their requests', () => {
const rows = [
{ id: 'a', priceSource: 'builtin' as const, displayFree: false, requests: 1000, tokensIn: 0, tokensOut: 0, costMicroUsd: 0 },
{ id: 'b', priceSource: 'unpriced' as const, displayFree: false, requests: 1, tokensIn: 0, tokensOut: 0, costMicroUsd: 0 },
]
expect(pricingHealthOrder(rows).map((r) => r.id)).toEqual(['b', 'a'])
})
it('orders the full priceSource priority: unpriced, override, builtin, litellm, free', () => {
const rows = (['free', 'litellm', 'builtin', 'override', 'unpriced'] as const).map((priceSource, i) => ({
id: `m${i}`,
priceSource,
displayFree: false,
requests: 1,
tokensIn: 0,
tokensOut: 0,
costMicroUsd: 0,
}))
expect(pricingHealthOrder(rows).map((r) => r.priceSource)).toEqual(['unpriced', 'override', 'builtin', 'litellm', 'free'])
})
it('breaks ties within the same priceSource by descending requests', () => {
const rows = [
{ id: 'low', priceSource: 'unpriced' as const, displayFree: false, requests: 5, tokensIn: 0, tokensOut: 0, costMicroUsd: 0 },
{ id: 'high', priceSource: 'unpriced' as const, displayFree: false, requests: 50, tokensIn: 0, tokensOut: 0, costMicroUsd: 0 },
]
expect(pricingHealthOrder(rows).map((r) => r.id)).toEqual(['high', 'low'])
})
it('does not mutate the input array', () => {
const rows = [
{ id: 'a', priceSource: 'free' as const, displayFree: false, requests: 1, tokensIn: 0, tokensOut: 0, costMicroUsd: 0 },
{ id: 'b', priceSource: 'unpriced' as const, displayFree: false, requests: 1, tokensIn: 0, tokensOut: 0, costMicroUsd: 0 },
]
const copy = [...rows]
pricingHealthOrder(rows)
expect(rows).toEqual(copy)
})
})
+94
View File
@@ -0,0 +1,94 @@
// Pure join/ordering behind PricingHealthTable.vue (redesign-plan.md
// section 3.4): "served models in range joined with catalog; unpriced
// first with destructive badge". Kept out of the component the same way
// every other lib/*.ts module in this feature keeps its own math/ordering
// out of its component.
import type { AdminCatalogModel, AdminUsageModelEntry, PriceSource } from '@/types/api'
/**
* PricingHealthRow is one PricingHealthTable.vue row: a model actually
* served in the current range (GET /admin/api/usage/models, detail=1),
* joined with its catalog entry (GET /admin/api/catalog) for the pricing
* fields the ranking alone does not carry. `priceSource`/`displayFree`
* come from the catalog join — the BILLING truth (types/api.ts's
* PriceSource doc comment) — never re-derived from the ranking's own
* `free` flag, which reflects display-only relabeling for a ':free'
* suffix, not necessarily the same thing.
*/
export interface PricingHealthRow {
id: string
priceSource: PriceSource
displayFree: boolean
requests: number
tokensIn: number
tokensOut: number
costMicroUsd: number
contextTokens?: number
inputPerMTokUsd?: number
outputPerMTokUsd?: number
aliases?: string[]
}
/**
* joinPricingHealth joins every served model (`models`, a detail=1 ranking
* — only entries the server already knows had non-zero usage in range) against
* its catalog entry by canonical id. A served id absent from `catalog`
* entirely (a model removed from configuration since it was last used, or
* the catalog simply has not loaded yet) is SKIPPED — there is no pricing
* fact to report for it, and fabricating an "unpriced" row for a model
* that may no longer even exist would be misleading rather than merely
* incomplete.
*/
export function joinPricingHealth(models: AdminUsageModelEntry[], catalog: Map<string, AdminCatalogModel>): PricingHealthRow[] {
const rows: PricingHealthRow[] = []
for (const m of models) {
const cat = catalog.get(m.id)
if (!cat) continue
rows.push({
id: m.id,
priceSource: cat.priceSource,
displayFree: cat.displayFree ?? false,
requests: m.requests ?? 0,
tokensIn: m.tokensIn ?? 0,
tokensOut: m.tokensOut ?? 0,
costMicroUsd: m.costMicroUsd ?? 0,
contextTokens: cat.contextTokens,
inputPerMTokUsd: cat.inputPerMTokUsd,
outputPerMTokUsd: cat.outputPerMTokUsd,
aliases: cat.aliases,
})
}
return rows
}
/**
* PRICING_HEALTH_SOURCE_RANK orders pricingHealthOrder's primary sort key
* — 'unpriced' first (redesign-plan.md section 3.4: "unpriced first with
* destructive badge", the row that most needs an operator's attention),
* then the two explicit/deliberate sources (override, builtin — an
* operator or the built-in table already priced these correctly), then
* litellm (a live, less-pinned lookup), then 'free' last (a deliberately
* zero-cost model — nothing to flag at all).
*/
const PRICING_HEALTH_SOURCE_RANK: Record<PriceSource, number> = {
unpriced: 0,
override: 1,
builtin: 2,
litellm: 3,
free: 4,
}
/**
* pricingHealthOrder sorts joined rows: unpriced-first by priceSource
* (PRICING_HEALTH_SOURCE_RANK), then by descending requests within each
* source group — the biggest-traffic unpriced model surfaces first, since
* that is the one costing the operator the most in unbilled requests.
* Returns a NEW array; never mutates `rows`.
*/
export function pricingHealthOrder(rows: PricingHealthRow[]): PricingHealthRow[] {
return [...rows].sort((a, b) => {
const bySource = PRICING_HEALTH_SOURCE_RANK[a.priceSource] - PRICING_HEALTH_SOURCE_RANK[b.priceSource]
if (bySource !== 0) return bySource
return b.requests - a.requests
})
}
+35
View File
@@ -2,11 +2,13 @@ import { describe, expect, it } from 'vitest'
import {
dayRateTitle,
formatErrorRatePercent,
formatRatePercent,
isModelDegraded,
minuteRateTitle,
PROVIDER_RATE_AMBER_THRESHOLD,
PROVIDER_RATE_DESTRUCTIVE_THRESHOLD,
providerErrorRate,
providerRateStatus,
providerSuccessRate,
} from './provider-rate'
@@ -143,3 +145,36 @@ describe('dayRateTitle', () => {
expect(dayRateTitle(5, 0)).toBe('5 attempts, 0 failures today (100%)')
})
})
describe('providerErrorRate', () => {
it('is 1 minus the success rate', () => {
expect(providerErrorRate(100, 1)).toBeCloseTo(0.01)
})
it('is null for zero attempts (no traffic), never a fabricated 0', () => {
expect(providerErrorRate(0, 0)).toBeNull()
})
it('is 1 when every attempt failed', () => {
expect(providerErrorRate(10, 10)).toBe(1)
})
})
describe('formatErrorRatePercent', () => {
it('ceils rather than floors, so a genuinely non-zero rate never rounds down to "0%"', () => {
expect(formatErrorRatePercent(0.001)).toBe('1%')
expect(formatErrorRatePercent(0.011)).toBe('2%')
})
it('renders exactly 0 as "0%"', () => {
expect(formatErrorRatePercent(0)).toBe('0%')
})
it('renders null as "no data", never "0%"', () => {
expect(formatErrorRatePercent(null)).toBe('no data')
})
it('does not clamp a rate above 1 (should not occur with real counters, but is not silently capped)', () => {
expect(formatErrorRatePercent(1.8)).toBe('180%')
})
})
+39
View File
@@ -90,6 +90,45 @@ export function formatRatePercent(rate: number): string {
return `${Math.floor(rate * 100)}%`
}
/**
* providerErrorRate is failures/attempts directly — conceptually
* providerSuccessRate's complement, but computed as its OWN division
* rather than `1 - providerSuccessRate(...)`: chaining a subtraction onto
* an already-divided value compounds binary floating-point error (e.g.
* 1 - (99/100) computes a hair above 0.01, which then ceils to "2%"
* instead of "1%" once formatErrorRatePercent multiplies by 100 — a
* caught review bug, not a hypothetical one). Returns null (never a
* fabricated 0%) when there is no traffic to compute a rate from at all.
* Shared by the Models page's per-model error-rate column
* (lib/model-table-columns.ts) and the Reliability page's own bucket-by-
* bucket series (which instead derives its ratio via lib/series.ts's
* ratioSeries, but formats it through formatErrorRatePercent below) — the
* ONE place "attempts/failures -> error rate" is computed for a single
* snapshot value, as opposed to a time series.
*/
export function providerErrorRate(attempts: number, failures: number): number | null {
if (!Number.isFinite(attempts) || attempts <= 0) return null
const safeFailures = Number.isFinite(failures) ? failures : 0
return safeFailures / attempts
}
/**
* formatErrorRatePercent renders a 0-1(+) error-rate fraction CEILING
* rather than floored — the opposite honesty direction from
* formatRatePercent above (a SUCCESS rate must never overstate itself as
* 100%; an ERROR rate must never round down to a misleadingly clean 0%).
* `null` (no traffic to compute a rate from — providerErrorRate's own
* "nothing to report" case) renders as "no data", never "0%". Not clamped
* to 100%: a rate above 1 should not occur with real counters, but this
* function reports whatever the caller computed rather than silently
* capping it (lib/series.ts's own ratioSeries makes the identical choice).
*/
export function formatErrorRatePercent(rate: number | null): string {
if (rate === null) return 'no data'
if (rate <= 0) return '0%'
return `${Math.ceil(rate * 100)}%`
}
/** countWord returns singular when n is exactly 1, plural otherwise — shared by minuteRateTitle and dayRateTitle so their wording never drifts apart. */
function countWord(n: number, singular: string, plural: string): string {
return n === 1 ? singular : plural
+140
View File
@@ -0,0 +1,140 @@
import { describe, expect, it } from 'vitest'
import {
comparison,
dayOrMonthClampNotice,
dayOrMonthWindow,
DEFAULT_CMP,
DEFAULT_RANGE,
isCmpMode,
isRangeKey,
RANGE_KEYS,
RANGE_PRESETS,
seriesUrl,
totalsUrl,
} from './range'
describe('RANGE_PRESETS', () => {
it('resolves every preset to the documented (window, span) pair', () => {
expect(RANGE_PRESETS['24h']).toEqual({ window: 'hour', span: 24 })
expect(RANGE_PRESETS['48h']).toEqual({ window: 'hour', span: 48 })
expect(RANGE_PRESETS['7d']).toEqual({ window: 'day', span: 7 })
expect(RANGE_PRESETS['14d']).toEqual({ window: 'day', span: 14 })
expect(RANGE_PRESETS['30d']).toEqual({ window: 'day', span: 30 })
expect(RANGE_PRESETS['3mo']).toEqual({ window: 'month', span: 3 })
expect(RANGE_PRESETS['6mo']).toEqual({ window: 'month', span: 6 })
expect(RANGE_PRESETS['12mo']).toEqual({ window: 'month', span: 12 })
})
it('covers every RangeKey with no gaps', () => {
for (const key of RANGE_KEYS) expect(RANGE_PRESETS[key]).toBeDefined()
})
})
describe('DEFAULT_RANGE / DEFAULT_CMP', () => {
it('defaults to 7d / none', () => {
expect(DEFAULT_RANGE).toBe('7d')
expect(DEFAULT_CMP).toBe('none')
})
})
describe('comparison', () => {
it.each([
['24h', true, 24],
['7d', true, 7],
['14d', true, 14],
['3mo', true, 3],
['6mo', true, 6],
] as const)('%s is available with offset %i', (range, available, offset) => {
const result = comparison(range)
expect(result.available).toBe(available)
expect(result.offset).toBe(offset)
expect(result.reason).toBeUndefined()
})
it.each(['48h', '30d', '12mo'] as const)('%s is unavailable (2*span exceeds retention max)', (range) => {
const result = comparison(range)
expect(result.available).toBe(false)
expect(result.reason).toBe('retention limit')
})
})
describe('isRangeKey / isCmpMode', () => {
it('accepts every real value and rejects bogus ones', () => {
for (const key of RANGE_KEYS) expect(isRangeKey(key)).toBe(true)
expect(isRangeKey('bogus')).toBe(false)
expect(isCmpMode('none')).toBe(true)
expect(isCmpMode('prev')).toBe(true)
expect(isCmpMode('bogus')).toBe(false)
})
})
// dayOrMonthWindow (P2 item 2): the shared clamp GET /admin/api/usage/
// totals?kind=usermodel|modeluser|targetcaller needs — moved here from
// lib/target-columns.ts so stores/consumers.ts (usermodel) and
// stores/spend.ts (usermodel drilldown) share the identical clamp
// TargetCallers.vue already used, rather than each re-deriving it (or,
// before this fix, not clamping at all and 400ing on an hour-window
// range).
describe('dayOrMonthWindow', () => {
it('passes a day window through unchanged, with its own span', () => {
expect(dayOrMonthWindow('day', 14)).toEqual({ window: 'day', span: 14 })
})
it('passes a month window through unchanged, with its own span', () => {
expect(dayOrMonthWindow('month', 6)).toEqual({ window: 'month', span: 6 })
})
it('clamps an hour window (no day/month equivalent span) to a fixed one-week day window', () => {
expect(dayOrMonthWindow('hour', 48)).toEqual({ window: 'day', span: 7 })
expect(dayOrMonthWindow('hour', 24)).toEqual({ window: 'day', span: 7 })
})
})
// dayOrMonthClampNotice (N3, verify-redesign-final.md): the shared
// "this view silently clamped your range" note text for every reader of
// dayOrMonthWindow above that surfaces the clamp to a screen.
describe('dayOrMonthClampNotice', () => {
it('is null for a day window — dayOrMonthWindow passes it through unchanged', () => {
expect(dayOrMonthClampNotice('day', 14)).toBeNull()
})
it('is null for a month window — dayOrMonthWindow passes it through unchanged', () => {
expect(dayOrMonthClampNotice('month', 6)).toBeNull()
})
it('names the exact substituted range for an hour window, regardless of the requested span', () => {
expect(dayOrMonthClampNotice('hour', 24)).toBe('Showing last 7 days — per-hour data is not kept for this view.')
expect(dayOrMonthClampNotice('hour', 48)).toBe('Showing last 7 days — per-hour data is not kept for this view.')
})
})
describe('seriesUrl', () => {
it('repeats scope as separate params and omits offset when 0', () => {
const url = seriesUrl({ scope: ['user:alice', 'group:eng'], metric: 'cost', window: 'day', span: 7 })
expect(url).toBe('/admin/api/usage/series?scope=user%3Aalice&scope=group%3Aeng&metric=cost&window=day&span=7')
})
it('includes offset when non-zero', () => {
const url = seriesUrl({ scope: ['total'], metric: 'req', window: 'hour', span: 24, offset: 24 })
expect(url).toContain('offset=24')
})
})
describe('totalsUrl', () => {
it('builds the minimal query for kind alone', () => {
expect(totalsUrl({ kind: 'user' })).toBe('/admin/api/usage/totals?kind=user')
})
it('joins metrics with commas and includes every optional param provided', () => {
const url = totalsUrl({
kind: 'usermodel',
window: 'month',
span: 1,
metrics: ['req', 'cost'],
limit: 5,
user: 'alice',
})
expect(url).toBe('/admin/api/usage/totals?kind=usermodel&window=month&span=1&metrics=req%2Ccost&limit=5&user=alice')
})
})
+184
View File
@@ -0,0 +1,184 @@
import type { HistoryWindow, TotalsKind } from '@/types/api'
/**
* RANGE_KEYS is the global filter bar's fixed set of time-range presets
* (redesign-plan.md section 3.2). Each maps to a bucket window and a
* bucket count (`span`) — the same (window, span) pair GET /admin/api/
* usage/series and /usage/models already accept.
*/
export const RANGE_KEYS = ['24h', '48h', '7d', '14d', '30d', '3mo', '6mo', '12mo'] as const
export type RangeKey = (typeof RANGE_KEYS)[number]
export const DEFAULT_RANGE: RangeKey = '7d'
/** One range preset's resolved (window, span) pair. */
export interface RangePreset {
window: HistoryWindow
span: number
}
export const RANGE_PRESETS: Record<RangeKey, RangePreset> = {
'24h': { window: 'hour', span: 24 },
'48h': { window: 'hour', span: 48 },
'7d': { window: 'day', span: 7 },
'14d': { window: 'day', span: 14 },
'30d': { window: 'day', span: 30 },
'3mo': { window: 'month', span: 3 },
'6mo': { window: 'month', span: 6 },
'12mo': { window: 'month', span: 12 },
}
/**
* HISTORY_MAX_SPAN mirrors the server's own retention ceiling per window
* (limits.go: historyMaxSpan — hour 48, day 35, month 13). The frontend
* keeps its own copy rather than reading it from an endpoint: it never
* changes without a server redeploy, and comparison() below needs it
* synchronously to decide whether a comparison request would even be
* valid before firing it.
*/
export const HISTORY_MAX_SPAN: Record<HistoryWindow, number> = {
hour: 48,
day: 35,
month: 13,
}
/** The comparison toggle's two states (redesign-plan.md section 3.1's `cmp` hash param). */
export const CMP_MODES = ['none', 'prev'] as const
export type CmpMode = (typeof CMP_MODES)[number]
export const DEFAULT_CMP: CmpMode = 'none'
export interface ComparisonResult {
/** Whether "vs previous period" can be requested for this range at all. */
available: boolean
/** How many buckets back the comparison window starts (span+offset stays within HISTORY_MAX_SPAN when available). */
offset: number
/** Set only when `available` is false — the reason a GlobalFilterBar cmp toggle disables itself, e.g. as a disabled control's title. */
reason?: string
}
/**
* comparison reports whether `range` can be paired with a "vs previous
* period" comparison request, and what `offset` that comparison would
* use (Q5, redesign-plan.md DECISIONS). A comparison covers the SAME
* span twice — the current period and the one immediately before it — so
* it needs `2 * span` buckets total; it is disabled whenever that would
* exceed the window's own retention ceiling (HISTORY_MAX_SPAN), which is
* exactly 48h, 30d, and 12mo among the eight presets above.
*/
export function comparison(range: RangeKey): ComparisonResult {
const { window, span } = RANGE_PRESETS[range]
const max = HISTORY_MAX_SPAN[window]
if (2 * span > max) return { available: false, offset: span, reason: 'retention limit' }
return { available: true, offset: span }
}
/**
* dayOrMonthWindow clamps an arbitrary HistoryWindow/span pair (usually the
* global filters store's current range) down to the day/month-only window
* GET /admin/api/usage/totals accepts for its usermodel/modeluser/
* targetcaller kinds (redesign-plan.md section 1.3.v: "usermodel/modeluser/
* targetcaller: day/month only"). 'month' passes through unchanged; 'hour'
* (the 24h/48h global ranges, which have no day/month equivalent span at
* all) falls back to a fixed one-week day window rather than an arbitrary
* guess at what span the reader meant; 'day' passes its own span through
* unchanged. The single shared clamp for every one of those three kinds —
* stores/consumers.ts (usermodel), stores/spend.ts (usermodel drilldown),
* lib/target-columns.ts/TargetCallers.vue (targetcaller) all import this
* rather than each re-deriving their own.
*/
export function dayOrMonthWindow(window: HistoryWindow, span: number): { window: 'day' | 'month'; span: number } {
if (window === 'month') return { window: 'month', span }
if (window === 'day') return { window: 'day', span }
return { window: 'day', span: 7 }
}
/**
* dayOrMonthClampNotice (N3 fix, verify-redesign-final.md) is
* dayOrMonthWindow's own "worth telling the reader" half: dayOrMonthWindow
* silently substitutes a fixed day-window whenever `window` is 'hour'
* (24h/48h has no day/month equivalent span at all) — this reports the
* exact substituted range as a one-line note when that happened, and
* null when it did not ('day'/'month' pass through dayOrMonthWindow
* unchanged, so there is nothing to disclose).
*
* Every reader of dayOrMonthWindow that surfaces its result to a screen
* — UserDetail's "Models used", the Spend drilldown's usermodel level,
* TargetCallers — shares this one function via ClampedRangeNotice.vue,
* so the wording never drifts between them. ReliabilityPage.vue's own
* month-clamp note is a SEPARATE, unrelated clamp (hour/day -> a fixed
* 30-day window, stores/reliability.ts's hourOrDayWindow) and stays its
* own inline Alert, not this one.
*/
export function dayOrMonthClampNotice(window: HistoryWindow, span: number): string | null {
if (window !== 'hour') return null
const clamped = dayOrMonthWindow(window, span)
return `Showing last ${clamped.span} days — per-hour data is not kept for this view.`
}
/** isRangeKey is a runtime type guard against RANGE_KEYS — the only place that string union is checked against untrusted input (a hash, a hand-typed URL). */
export function isRangeKey(value: string): value is RangeKey {
return (RANGE_KEYS as readonly string[]).includes(value)
}
/** isCmpMode is CMP_MODES' own runtime type guard, mirroring isRangeKey above. */
export function isCmpMode(value: string): value is CmpMode {
return (CMP_MODES as readonly string[]).includes(value)
}
export interface SeriesUrlParams {
/** One or more scope ids — repeated as separate `scope` query params, matching admin.go's `scope` (1..100, repeated). */
scope: string[]
metric: string
window: HistoryWindow
span: number
/** Buckets back from "now" the window starts — omitted (server default 0) when 0. */
offset?: number
}
/** seriesUrl builds the query string for GET /admin/api/usage/series (redesign-plan.md section 1.3.iv) — the one place every store that reads it (spend/consumers/models/reliability, phase 2) builds that URL, rather than each hand-rolling its own URLSearchParams. */
export function seriesUrl(params: SeriesUrlParams): string {
const q = new URLSearchParams()
for (const scope of params.scope) q.append('scope', scope)
q.set('metric', params.metric)
q.set('window', params.window)
q.set('span', String(params.span))
if (params.offset) q.set('offset', String(params.offset))
return `/admin/api/usage/series?${q.toString()}`
}
export interface TotalsUrlParams {
kind: TotalsKind
window?: HistoryWindow
span?: number
offset?: number
/** Comma-joined server-side (admin.go's `metrics` query param) — omitted entirely (server default: every metric that kind allows) when empty/absent. */
metrics?: string[]
limit?: number
/** kind 'user' only — narrows to one group's members. */
group?: string
/** kind 'usermodel' only — required by the server. */
user?: string
/** kind 'modeluser' only — required by the server. */
model?: string
/** kind 'targetcaller' only — "mcp/{name}" or "agent/{name}". */
target?: string
}
/** totalsUrl builds the query string for GET /admin/api/usage/totals (redesign-plan.md section 1.3.v), mirroring seriesUrl above. */
export function totalsUrl(params: TotalsUrlParams): string {
const q = new URLSearchParams()
q.set('kind', params.kind)
if (params.window) q.set('window', params.window)
if (params.span !== undefined) q.set('span', String(params.span))
if (params.offset) q.set('offset', String(params.offset))
if (params.metrics && params.metrics.length > 0) q.set('metrics', params.metrics.join(','))
if (params.limit !== undefined) q.set('limit', String(params.limit))
if (params.group) q.set('group', params.group)
if (params.user) q.set('user', params.user)
if (params.model) q.set('model', params.model)
if (params.target) q.set('target', params.target)
return `/admin/api/usage/totals?${q.toString()}`
}
+5 -5
View File
@@ -118,11 +118,11 @@ describe('an item manually opened before search, still matches, clicked closed d
})
// This module is domain-agnostic on what an "id" represents — it started as
// ProvidersView.vue's provider names, but UsageView.vue now feeds it group
// ids too (see that view's own doc comment: matchingIds there is every
// group that matches ONLY via a member, never via its own name). These two
// cases exist to pin that the module behaves identically for that second
// domain, not because anything here differs by id shape.
// ProviderHealthPanel.vue's provider names, but ConsumerDirectory.vue also
// feeds it group ids (see that component's own doc comment: matchingIds
// there is every group that matches ONLY via a member, never via its own
// name). These two cases exist to pin that the module behaves identically
// for that second domain, not because anything here differs by id shape.
describe('generic ids (Usage group ids, not just Providers provider names)', () => {
it('auto-expands and can be collapsed/overridden identically for a group id', () => {
const state = newState()
+18 -17
View File
@@ -1,18 +1,18 @@
/**
* Pure expand/collapse state for a search-filterable shadcn-vue Accordion —
* shared by ProvidersView.vue (providers) and UsageView.vue (groups), and
* generalized (originally provider-expand.ts) so both consume ONE tested
* implementation instead of two copies of the same logic (vue.md: "if
* you've written it twice, you owe an abstraction"). Extracted out of the
* component so the exact toggle sequence a review flagged (see
* toggleItemExpand's own comment) is tested in isolation, without mounting
* Vue — see search-expand.spec.ts (vitest), which exercises this module
* directly, including the full flagged sequence.
* shared by ProviderHealthPanel.vue (providers) and ConsumerDirectory.vue
* (groups), and generalized (originally provider-expand.ts) so both
* consume ONE tested implementation instead of two copies of the same
* logic (vue.md: "if you've written it twice, you owe an abstraction").
* Extracted out of the component so the exact toggle sequence a review
* flagged (see toggleItemExpand's own comment) is tested in isolation,
* without mounting Vue — see search-expand.spec.ts (vitest), which
* exercises this module directly, including the full flagged sequence.
*
* Two sets track state, keyed on each accordion item's own string id (a
* provider name in Providers, a group id in Usage — this module has no
* opinion on what the id means or on what counts as a "match"; callers
* decide that and pass the resulting id list in):
* provider name on the Models page, a group id on the Consumers page —
* this module has no opinion on what the id means or on what counts as a
* "match"; callers decide that and pass the resulting id list in):
* - manuallyExpanded: items the user explicitly opened, independent of any
* search query. This is the state a cleared search returns to.
* - manuallyCollapsed: items the user explicitly closed WHILE a search
@@ -34,12 +34,13 @@ export interface ExpandState {
* manuallyExpanded plus every id in matchingIds that is not in
* manuallyCollapsed (auto-expand every match, unless the user explicitly
* closed that one). The caller decides what "matching" means for its own
* view, and this module has no opinion on it either way — ProvidersView.vue
* passes every provider that itself matched at all (every match there
* implies at least one matching model, so every match auto-expands);
* UsageView.vue instead passes only the subset of its own matches that
* matched via a member rather than via the group's own name (see that
* view's own doc comment for why it draws that narrower distinction).
* view, and this module has no opinion on it either way —
* ProviderHealthPanel.vue passes every provider that itself matched at
* all (every match there implies at least one matching model, so every
* match auto-expands); ConsumerDirectory.vue instead passes only the
* subset of its own matches that matched via a member rather than via
* the group's own name (see that component's own doc comment for why it
* draws that narrower distinction).
*/
export function computeExpandedItems(state: ExpandState, hasQuery: boolean, matchingIds: string[]): Set<string> {
if (!hasQuery) return new Set(state.manuallyExpanded)
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest'
import { other, ratioSeries, stackTopN, sumByProvider } from './series'
describe('stackTopN', () => {
it('returns the n series with the largest total, descending', () => {
const series = [
{ scope: 'model:a', points: [1, 1] }, // total 2
{ scope: 'model:b', points: [10, 10] }, // total 20
{ scope: 'model:c', points: [5, 5] }, // total 10
]
expect(stackTopN(series, 2)).toEqual([
{ scope: 'model:b', points: [10, 10] },
{ scope: 'model:c', points: [5, 5] },
])
})
it('returns every series, sorted, when n exceeds the array length', () => {
const series = [
{ scope: 'model:a', points: [1] },
{ scope: 'model:b', points: [2] },
]
expect(stackTopN(series, 10)).toHaveLength(2)
})
it('returns an empty array for n <= 0', () => {
const series = [{ scope: 'model:a', points: [1] }]
expect(stackTopN(series, 0)).toEqual([])
expect(stackTopN(series, -1)).toEqual([])
})
it('breaks ties on original array order (stable sort)', () => {
const series = [
{ scope: 'model:a', points: [5] },
{ scope: 'model:b', points: [5] },
]
expect(stackTopN(series, 2).map((s) => s.scope)).toEqual(['model:a', 'model:b'])
})
it('does not mutate the input array', () => {
const series = [
{ scope: 'model:a', points: [1] },
{ scope: 'model:b', points: [2] },
]
const copy = [...series]
stackTopN(series, 1)
expect(series).toEqual(copy)
})
})
describe('other', () => {
it('subtracts the sum of charted series from the total, per bucket', () => {
const total = [100, 200]
const charted = [
{ scope: 'model:a', points: [30, 50] },
{ scope: 'model:b', points: [20, 40] },
]
expect(other(total, charted)).toEqual([50, 110])
})
it('returns the total verbatim when charted is empty', () => {
expect(other([1, 2, 3], [])).toEqual([1, 2, 3])
})
it('floors a bucket at 0 rather than going negative', () => {
const total = [10]
const charted = [{ scope: 'model:a', points: [15] }]
expect(other(total, charted)).toEqual([0])
})
it('treats a missing point in a shorter charted series as 0', () => {
const total = [10, 10]
const charted = [{ scope: 'model:a', points: [4] }]
expect(other(total, charted)).toEqual([6, 10])
})
})
describe('sumByProvider', () => {
it('groups model-scoped series by their provider prefix and sums per bucket', () => {
const series = [
{ scope: 'model:openai/gpt-5', points: [10, 20] },
{ scope: 'model:openai/gpt-5-mini', points: [5, 5] },
{ scope: 'model:anthropic/claude', points: [1, 1] },
]
const result = sumByProvider(series)
expect(result).toEqual([
{ scope: 'openai', points: [15, 25] },
{ scope: 'anthropic', points: [1, 1] },
])
})
it('handles a bare (unprefixed) scope without a "model:" prefix', () => {
const series = [{ scope: 'openai/gpt-5', points: [3] }]
expect(sumByProvider(series)).toEqual([{ scope: 'openai', points: [3] }])
})
it('handles a discovered id with an extra internal slash — only the first segment is the provider', () => {
const series = [{ scope: 'model:real/uni/deepseek-v4-flash-0731', points: [7] }]
expect(sumByProvider(series)).toEqual([{ scope: 'real', points: [7] }])
})
it('sorts the grouped result by descending total', () => {
const series = [
{ scope: 'model:small/x', points: [1] },
{ scope: 'model:big/y', points: [100] },
]
expect(sumByProvider(series).map((s) => s.scope)).toEqual(['big', 'small'])
})
it('returns an empty array for an empty input', () => {
expect(sumByProvider([])).toEqual([])
})
})
describe('ratioSeries', () => {
it('divides numerator by denominator per bucket', () => {
expect(ratioSeries([1, 5, 10], [10, 10, 10])).toEqual([0.1, 0.5, 1])
})
it('returns null for a bucket with 0 attempts rather than NaN or a fabricated 0', () => {
expect(ratioSeries([0, 3], [0, 10])).toEqual([null, 0.3])
})
it('treats a missing numerator point as 0', () => {
expect(ratioSeries([], [5])).toEqual([0])
})
it('returns an empty array for empty input', () => {
expect(ratioSeries([], [])).toEqual([])
})
})
+112
View File
@@ -0,0 +1,112 @@
// Pure series math shared by every page that renders a GET
// /admin/api/usage/series response through TimeSeriesChart.vue: Spend's
// cost breakdown (by=model/provider/group, redesign-plan.md section 3.4)
// and, per that same section, WP-F's Reliability page ("TimeSeriesChart
// for error rate per provider (lib/series.ts ratioSeries fail/attempt per
// bucket)"). Owned by WP-D (section 4) precisely because Spend needs it
// first, but kept generic — no metric/scope-kind-specific logic — so a
// second page building its own breakdown never grows a duplicate copy
// (vue.md: "if you've written it twice, you owe an abstraction"). Kept out
// of any component/store the same way lib/forecast.ts and lib/usage-bars.ts
// keep their own math out of theirs — vitest's node-environment config
// exercises every function here directly, no component mount required.
/** One AdminSeriesResponse series entry: a scope id and its bucket-aligned points (types/api.ts: AdminSeriesResponse['series'][number]). */
export interface Series {
scope: string
points: number[]
}
/**
* sumSeries totals one series' own points — the ranking key stackTopN
* sorts by, and the per-bucket contribution `other` subtracts. A missing
* point (an index beyond `points.length`, which should not happen for a
* well-formed AdminSeriesResponse where every series is exactly
* `buckets.length` long) reads as 0 rather than throwing.
*/
function sumSeries(points: number[]): number {
let total = 0
for (const p of points) total += p
return total
}
/**
* stackTopN returns the N series with the largest total value (sumSeries,
* descending), for stacking individually in a breakdown chart — Spend's
* by=model view fetches series for every model the ranking endpoint
* already reported as non-idle, then charts only the biggest N of them,
* folding the rest into an "other" bucket via `other` below. Ties break on
* the ORIGINAL array order (Array.prototype.sort is stable), so two
* series with an identical total never reorder from one call to the next.
* `n` beyond `series.length` simply returns every series, sorted; `n <= 0`
* returns an empty array.
*/
export function stackTopN(series: Series[], n: number): Series[] {
if (n <= 0) return []
return [...series].sort((a, b) => sumSeries(b.points) - sumSeries(a.points)).slice(0, n)
}
/**
* other computes the per-bucket remainder of `totalPoints` once every
* series in `charted` (Spend's top-N breakdown) is subtracted — the
* "other" bucket redesign-plan.md section 3.3 describes ("other = total -
* sum(top)"). Floored at 0 per bucket: a genuine remainder is never
* negative (every charted series is a strict subset of the total), so a
* small negative reading can only be a rounding/timing artifact between
* the two separate HTTP requests that produced `totalPoints` and
* `charted` — the same "never render a fabricated crossed boundary"
* discipline lib/format.ts's formatCompactCount already documents for an
* unrelated flooring case. `charted` may be empty (no breakdown at all —
* `other` then equals `totalPoints` verbatim, floored).
*/
export function other(totalPoints: number[], charted: Series[]): number[] {
return totalPoints.map((total, i) => {
let chartedSum = 0
for (const s of charted) chartedSum += s.points[i] ?? 0
return Math.max(0, total - chartedSum)
})
}
/**
* sumByProvider groups a set of `model:{canonical}` (or bare canonical)
* scoped series by their provider prefix (routableModelId's own inverse:
* the FIRST path segment before the first "/", lib/format.ts's own doc
* comment on routableModelId spells out why only the first segment counts
* even when a discovered model id itself contains further slashes) and
* sums their points per bucket — Spend's by=provider view (redesign-
* plan.md section 3.3): the /usage/series endpoint has no cost/req/tokin/
* tokout metric for a `provider:{name}` scope directly (section 1.3.iv's
* own metric x kind table — provider scope only carries attempt/fail/
* timeout/fover), so a provider-level cost breakdown can only be DERIVED
* by summing its own models' series client-side. Returned in descending
* total order (stackTopN's own convention) — the biggest provider first,
* both for a stable, useful stacking order and so a caller never has to
* re-sort.
*/
export function sumByProvider(series: Series[]): Series[] {
const byProvider = new Map<string, number[]>()
for (const s of series) {
const id = s.scope.startsWith('model:') ? s.scope.slice('model:'.length) : s.scope
const slash = id.indexOf('/')
const provider = slash === -1 ? id : id.slice(0, slash)
const acc = byProvider.get(provider) ?? new Array<number>(s.points.length).fill(0)
for (let i = 0; i < s.points.length; i++) acc[i] = (acc[i] ?? 0) + (s.points[i] ?? 0)
byProvider.set(provider, acc)
}
const grouped = Array.from(byProvider.entries()).map(([scope, points]) => ({ scope, points }))
return grouped.sort((a, b) => sumSeries(b.points) - sumSeries(a.points))
}
/**
* ratioSeries divides `numerator` by `denominator` bucket-for-bucket
* (Reliability's per-provider error rate, lib/provider-rate.ts's own
* fleet-wide equivalent for a single snapshot) — null for any bucket
* where `denominator` is 0 (no attempts that bucket), never a fabricated
* 0% or a NaN from dividing by zero. Both arrays must already be aligned
* to the SAME buckets (the caller's responsibility, matching
* AdminSeriesResponse's own "every series is buckets.length long, index-
* for-index" contract).
*/
export function ratioSeries(numerator: number[], denominator: number[]): (number | null)[] {
return denominator.map((d, i) => (d > 0 ? (numerator[i] ?? 0) / d : null))
}
+321
View File
@@ -0,0 +1,321 @@
import { describe, expect, it, vi } from 'vitest'
import {
API_KEY_PLACEHOLDER,
generateApiKey,
groupGrantSnippet,
groupLimitsSnippet,
isValidName,
isValidNonNegative,
isValidNonNegativeInt,
isValidUserName,
modelMetaSnippet,
pricingSnippet,
userGrantJsonFragment,
userJsonLine,
userLimitsJsonFragment,
validatePersonalGrant,
} from './snippets'
// Golden strings below are checked against the REAL production shapes in
// home-cluster/kubernetes/namespaces/traefik/base/llmgateway/middleware.yaml
// and users-configmap.yaml (read this session) — structure and field order
// only. Every value used here is fabricated/public (model ids, provider
// names, group names are not secrets); no api key or password literal from
// either production file appears anywhere in this file.
describe('groupLimitsSnippet: golden YAML, spec.plugin.llmgateway.groups.<name>.limits', () => {
it('renders one configured field', () => {
expect(groupLimitsSnippet('friends', { requestsPerDay: 1000 })).toBe(
['spec:', ' plugin:', ' llmgateway:', ' groups:', ' friends:', ' limits:', ' requestsPerDay: 1000'].join(
'\n',
),
)
})
it('renders every configured field in LimitsConfig field order, regardless of input key order', () => {
const snippet = groupLimitsSnippet('eng', {
costPerMonthUSD: 50,
requestsPerMinute: 10,
tokensPerDay: 500000,
})
expect(snippet).toBe(
[
'spec:',
' plugin:',
' llmgateway:',
' groups:',
' eng:',
' limits:',
' requestsPerMinute: 10',
' tokensPerDay: 500000',
' costPerMonthUSD: 50',
].join('\n'),
)
})
it('omits an unset (0/undefined) field entirely, never emitting a fabricated 0', () => {
const snippet = groupLimitsSnippet('friends', { requestsPerDay: 0, costPerDayUSD: 5 })
expect(snippet).not.toContain('requestsPerDay')
expect(snippet).toContain('costPerDayUSD: 5')
})
})
describe('groupGrantSnippet: golden YAML, matches middleware.yaml groups.friends/groups.home shape', () => {
it('emits every one of the four access lists explicitly, even when empty — "friends" shape (providers set, rest empty)', () => {
const snippet = groupGrantSnippet('friends', { providers: ['minimax', 'uni'], models: [], mcpServers: [], agents: [] })
expect(snippet).toBe(
[
'spec:',
' plugin:',
' llmgateway:',
' groups:',
' friends:',
' providers: [minimax, uni]',
' models: []',
' mcpServers: []',
' agents: []',
].join('\n'),
)
})
it('renders "home" shape — every list empty (unrestricted)', () => {
const snippet = groupGrantSnippet('home', { providers: [], models: [], mcpServers: [], agents: [] })
expect(snippet).toBe(
[
'spec:',
' plugin:',
' llmgateway:',
' groups:',
' home:',
' providers: []',
' models: []',
' mcpServers: []',
' agents: []',
].join('\n'),
)
})
})
describe('modelMetaSnippet: golden YAML flow map, matches middleware.yaml modelMeta entries exactly', () => {
it('matches the gx10-embed entry shape: "{ contextTokens: 16384, free: true }"', () => {
const snippet = modelMetaSnippet('gx10-embed/text-embedding-qwen3-0.6b', { contextTokens: 16384, free: true })
expect(snippet).toBe(
['spec:', ' plugin:', ' llmgateway:', ' modelMeta:', ' "gx10-embed/text-embedding-qwen3-0.6b": { contextTokens: 16384, free: true }'].join(
'\n',
),
)
})
it('matches the "gx10/current" entry shape: "{ free: true }"', () => {
const snippet = modelMetaSnippet('gx10/current', { free: true })
expect(snippet.split('\n').at(-1)).toBe(' "gx10/current": { free: true }')
})
// P2 item 11: modelMeta keys are ALWAYS quoted in production, even a
// bare id with no "/" — unlike pricingSnippet below, whose own golden
// test (examples/kubernetes.yaml, read this session) shows an
// unquoted bare id in that DIFFERENT config section.
it('a bare model id with no "/" or ":" is still quoted — modelMeta always quotes, unlike pricing', () => {
const snippet = modelMetaSnippet('ornith-397b', { contextTokens: 262144, free: true })
expect(snippet.split('\n').at(-1)).toBe(' "ornith-397b": { contextTokens: 262144, free: true }')
})
it('field order is always contextTokens, free — contextTokens alone', () => {
const snippet = modelMetaSnippet('openai/gpt-4', { contextTokens: 128000 })
expect(snippet.split('\n').at(-1)).toBe(' "openai/gpt-4": { contextTokens: 128000 }')
})
it('omits every field the caller did not set — an empty ModelMeta renders "{}"', () => {
const snippet = modelMetaSnippet('openai/gpt-4', {})
expect(snippet.split('\n').at(-1)).toBe(' "openai/gpt-4": {}')
})
})
describe('pricingSnippet: golden YAML flow map, matches examples/kubernetes.yaml pricing entry exactly', () => {
it('matches the "gpt-5-mini" entry shape: "pricing:\\n gpt-5-mini: {inputPerM: 0.25, outputPerM: 2.0}"', () => {
const snippet = pricingSnippet('gpt-5-mini', { inputPerM: 0.25, outputPerM: 2.0 })
expect(snippet).toBe(
['spec:', ' plugin:', ' llmgateway:', ' pricing:', ' gpt-5-mini: { inputPerM: 0.25, outputPerM: 2 }'].join('\n'),
)
})
it('nests under spec.plugin.llmgateway.pricing, NOT modelMeta — a different config section from ModelMetaForm', () => {
const snippet = pricingSnippet('openai/gpt-4', { inputPerM: 0.5, outputPerM: 1.5 })
expect(snippet).toContain(' pricing:')
expect(snippet).not.toContain('modelMeta')
})
it('quotes a "provider/model" canonical id (contains "/")', () => {
const snippet = pricingSnippet('openai/gpt-4', { inputPerM: 0.5, outputPerM: 1.5 })
expect(snippet.split('\n').at(-1)).toBe(' "openai/gpt-4": { inputPerM: 0.5, outputPerM: 1.5 }')
})
it('field order is always inputPerM then outputPerM', () => {
const snippet = pricingSnippet('bare-model', { inputPerM: 1, outputPerM: 2 })
expect(snippet.split('\n').at(-1)).toBe(' bare-model: { inputPerM: 1, outputPerM: 2 }')
})
})
describe('userLimitsJsonFragment / userGrantJsonFragment: bare JSON fields for an EXISTING users.json entry', () => {
it('renders the limits fragment in LimitsConfig field order', () => {
expect(userLimitsJsonFragment({ costPerMonthUSD: 5, requestsPerDay: 100 })).toBe('"limits": {"requestsPerDay": 100, "costPerMonthUSD": 5}')
})
it('renders the grant fragment — providers then models, both present', () => {
expect(userGrantJsonFragment({ providers: ['gx10'], models: ['gx10/current'] })).toBe('"providers": ["gx10"], "models": ["gx10/current"]')
})
it('renders only providers when models is empty — matches barteq\'s real shape ("providers": ["gx10"]) structurally', () => {
expect(userGrantJsonFragment({ providers: ['gx10'], models: [] })).toBe('"providers": ["gx10"]')
})
})
describe('userJsonLine: golden JSON, matches users-configmap.yaml entry shape exactly (fabricated apiKey, never a real one)', () => {
it('matches the "barteq" entry shape: name, apiKey, group, providers', () => {
const line = userJsonLine({ name: 'barteq', apiKey: 'sk-test-FAKEKEY0000000000000000000000000000', group: 'friends', providers: ['gx10'] })
expect(line).toBe('{"name": "barteq", "apiKey": "sk-test-FAKEKEY0000000000000000000000000000", "group": "friends", "providers": ["gx10"]}')
})
it('matches the "admin" entry shape: name, apiKey, group, admin', () => {
const line = userJsonLine({ name: 'admin', apiKey: 'sk-test-FAKEKEY0000000000000000000000000000', group: 'home', admin: true })
expect(line).toBe('{"name": "admin", "apiKey": "sk-test-FAKEKEY0000000000000000000000000000", "group": "home", "admin": true}')
})
it('matches a minimal entry shape: name, apiKey, group only (no providers/admin)', () => {
const line = userJsonLine({ name: 'yalso', apiKey: 'sk-test-FAKEKEY0000000000000000000000000000', group: 'home' })
expect(line).toBe('{"name": "yalso", "apiKey": "sk-test-FAKEKEY0000000000000000000000000000", "group": "home"}')
})
it('emits "groups" (plural) instead of "group" when multiple groups are set', () => {
const line = userJsonLine({ name: 'multi', apiKey: 'sk-test-x', groups: ['home', 'friends'] })
expect(line).toBe('{"name": "multi", "apiKey": "sk-test-x", "groups": ["home", "friends"]}')
})
it('includes a nested limits object and admin, in key order name/apiKey/group/groups/providers/models/limits/admin', () => {
const line = userJsonLine({
name: 'full',
apiKey: 'sk-test-x',
group: 'friends',
providers: ['gx10'],
models: ['gx10/current'],
limits: { requestsPerDay: 10 },
admin: true,
})
expect(line).toBe(
'{"name": "full", "apiKey": "sk-test-x", "group": "friends", "providers": ["gx10"], "models": ["gx10/current"], "limits": {"requestsPerDay": 10}, "admin": true}',
)
})
it('omits admin entirely when false (never emits "admin": false, matching every non-admin production entry)', () => {
const line = userJsonLine({ name: 'plain', apiKey: 'sk-test-x', group: 'home', admin: false })
expect(line).not.toContain('admin')
})
})
describe('validatePersonalGrant: mirrors auth.go\'s models-without-providers rejection', () => {
it('rejects models with no providers', () => {
expect(validatePersonalGrant([], ['gx10/current'])).not.toBeNull()
})
it('accepts models with at least one provider', () => {
expect(validatePersonalGrant(['gx10'], ['gx10/current'])).toBeNull()
})
it('accepts providers with no models (providers-only grant)', () => {
expect(validatePersonalGrant(['gx10'], [])).toBeNull()
})
it('accepts an entirely empty grant', () => {
expect(validatePersonalGrant([], [])).toBeNull()
})
})
describe('isValidName', () => {
it.each([
['gx10-embed', true],
['friends_2', true],
['eng.team', true],
['has space', false],
['', false],
['slash/here', false],
['wild*card', false],
])('%s -> %s', (name, expected) => {
expect(isValidName(name)).toBe(expected)
})
})
describe('isValidNonNegative', () => {
it.each([
[0, true],
[1, true],
[0.5, true],
[-1, false],
[NaN, false],
[Infinity, false],
])('%s -> %s', (n, expected) => {
expect(isValidNonNegative(n)).toBe(expected)
})
})
// P2 item 12: LimitsConfig.requestsPerMinute/requestsPerDay/tokensPerDay/
// tokensPerMonth and ModelMetaConfig.ContextTokens are Go int64/int —
// isValidNonNegative alone (fractions allowed) lets a value through that
// json.Unmarshal rejects server-side.
describe('isValidNonNegativeInt', () => {
it.each([
[0, true],
[1, true],
[1000, true],
[0.5, false],
[-1, false],
[NaN, false],
[Infinity, false],
])('%s -> %s', (n, expected) => {
expect(isValidNonNegativeInt(n)).toBe(expected)
})
})
// P3 item 27: auth.go's buildEntry only requires a non-empty user name —
// no configNamePattern character restriction (that pattern exists only
// because provider/mcpServers/agents names are embedded as URL path
// segments; a user name never is).
describe('isValidUserName', () => {
it.each([
['alice', true],
['jan@x', true],
["O'Brien", true],
['has space', true],
['', false],
[' ', false],
])('%s -> %s', (name, expected) => {
expect(isValidUserName(name)).toBe(expected)
})
})
describe('generateApiKey / API_KEY_PLACEHOLDER', () => {
it('the placeholder is visibly not a real key', () => {
expect(API_KEY_PLACEHOLDER).toBe('sk-llmgw-REPLACE_ME')
})
it('generates a "sk-llmgw-" prefixed base64url token from 32 random bytes', () => {
const spy = vi.spyOn(crypto, 'getRandomValues').mockImplementation(((arr: Uint8Array) => {
arr.fill(7)
return arr
}) as typeof crypto.getRandomValues)
try {
const key = generateApiKey()
expect(key.startsWith('sk-llmgw-')).toBe(true)
expect(key).not.toContain('+')
expect(key).not.toContain('/')
expect(key).not.toContain('=')
expect(key.length).toBeGreaterThan('sk-llmgw-'.length)
} finally {
spy.mockRestore()
}
})
it('two calls produce different keys (real CSPRNG, not mocked)', () => {
expect(generateApiKey()).not.toBe(generateApiKey())
})
})
+293
View File
@@ -0,0 +1,293 @@
// Config page's ChangeHelper (redesign-plan.md section 3.4): every form
// (LimitsForm, GrantForm, PricingForm, ModelMetaForm, UserForm) funnels
// through this module to build the actual paste-ready text — the ONE place
// that knows the production file's exact shapes (home-cluster namespaces/
// traefik/base/llmgateway/middleware.yaml, users-configmap.yaml), so every
// form's snippet output is provably identical in format, not five
// independent guesses. Group/provider/admin snippets are YAML (lib/
// yaml-emit.ts, nested at spec.plugin.llmgateway.<section>); a user's own
// entry lives in users.json instead, so USER-targeted output is a
// single-line JSON object (new user) or a bare JSON field fragment (adding
// limits/a personal grant to an EXISTING user's line) — never YAML.
//
// Validation lives here too, not in each form component, for the same
// "one place, not five copies" reason: NAME_PATTERN/isValidName (provider
// and group names), isValidNonNegative (every limit/price field), and
// validatePersonalGrant (auth.go's own "models without providers" rejection
// — auth.go ~1006, a user/group with a `models` glob but an EMPTY
// `providers` list can never actually reach any of those models, since
// authorizedProviders is computed first and gates model resolution).
import { emitConfigSnippet, emitYamlFlowMap, emitYamlKey, emitYamlScalar } from '@/lib/yaml-emit'
import type { YamlValue } from '@/lib/yaml-emit'
import type { LimitsConfig } from '@/types/api'
/** NAME_PATTERN is the operator-facing name rule every provider/group id must satisfy — letters, digits, dot, underscore, hyphen, matching every real name in middleware.yaml (anthropic, gx10-embed, macstudio-rerank, friends, ...) and mirroring the plugin's own path.Match glob-safe charset. */
export const NAME_PATTERN = /^[a-zA-Z0-9._-]+$/
/** isValidName reports whether a provider/group name satisfies NAME_PATTERN — GrantForm/LimitsForm's own `targetKind === 'group'` path, and PricingForm/ModelMetaForm's provider-prefixed model ids indirectly (via NAME_PATTERN-shaped provider segments). NOT for a user name — see isValidUserName below. */
export function isValidName(name: string): boolean {
return NAME_PATTERN.test(name)
}
/**
* isValidUserName mirrors auth.go's own buildEntry check (~955-975,
* ground truth): a user name only needs to be non-empty. Go deliberately
* does NOT apply configNamePattern's provider/mcpServers/agents character
* restriction (providers.go ~497-502, `^[a-zA-Z0-9._-]+$`) to user names
* — that pattern exists ONLY because those three kinds get embedded
* verbatim as a URL path segment (passthroughRoute, targetRoute); a user
* name is never used as a route path segment anywhere, only as a
* counter-key component and a JSON field (auth.go's own doc comment,
* word for word: "Restricting the character set beyond 'non-empty' would
* risk rejecting a real, currently-working operator-chosen name"). P3
* item 27: GrantForm.vue/LimitsForm.vue/UserForm.vue used to apply
* isValidName (NAME_PATTERN) to a user name too, so a real user such as
* "jan@x" could never get a snippet. userJsonLine/userGrantJsonFragment/
* userLimitsJsonFragment all go through JSON.stringify for every string
* field, so relaxing this check to match Go carries no injection risk.
*/
export function isValidUserName(name: string): boolean {
return name.trim() !== ''
}
/** isValidNonNegative reports whether a limit/price field is a finite, non-negative number — every LimitsConfig/pricing field's own domain (a negative or NaN limit/price is meaningless). Fractions ARE valid here: costPerDayUSD/costPerMonthUSD (LimitsConfig) and inputPerM/outputPerM (ModelPricing) are all Go float64 fields. */
export function isValidNonNegative(n: number): boolean {
return Number.isFinite(n) && n >= 0
}
/**
* isValidNonNegativeInt reports whether a count/limit field is a finite,
* non-negative WHOLE number — the domain LimitsForm.vue's
* requestsPerMinute/requestsPerDay/tokensPerDay/tokensPerMonth
* (LimitsConfig, all Go `int64`) and ModelMetaForm.vue's contextTokens
* (ModelMetaConfig.ContextTokens, Go `int`) actually decode into (P2 item
* 12): `json.Unmarshal` rejects a fractional value like `1.5` against an
* int64/int target outright, so a snippet built from an unchecked
* fraction would fail to apply, not just round unexpectedly.
*/
export function isValidNonNegativeInt(n: number): boolean {
return Number.isInteger(n) && n >= 0
}
/**
* validatePersonalGrant mirrors auth.go's own buildEntry rejection
* (~line 1005-1007, ground truth: redesign-plan.md section 0): a USER's
* personal `models` glob list with an EMPTY `providers` list is rejected
* outright — `llmgateway: user %q: personal "models" requires personal
* "providers"`. This is a USER-ONLY rule (GrantForm.vue must call this
* only when targetKind === 'user'): a GROUP's own `providers: []` means
* "every configured provider" (matchesGlob's own empty-means-all
* contract, auth.go ~487-490) and is applied directly with no such check
* at group-construction time (auth.go ~763) — a group with `providers: []`
* and a `models` list is valid config today. For a user's PERSONAL grant
* specifically, that same empty-means-all reading is the problem, not the
* fix: it would silently widen the grant to reach every configured
* provider, including ones no member group of that user covers at all
* (auth.go's own comment, ~995-1000) — so Go rejects it rather than
* silently granting more than the operator meant to type. Returns an
* error string when invalid, null when the grant is acceptable.
*/
export function validatePersonalGrant(providers: string[], models: string[]): string | null {
if (models.length > 0 && providers.length === 0) {
return 'A personal grant naming models needs at least one provider too — an empty providers list means EVERY configured provider (not none), so this would silently widen access far beyond what was typed (auth.go rejects it).'
}
return null
}
/** LIMITS_FIELD_ORDER is LimitsConfig's own field order (types/api.ts) — the single source of truth every limits snippet (YAML block or JSON fragment) below renders in, so the two never drift relative to each other. */
const LIMITS_FIELD_ORDER: (keyof LimitsConfig)[] = [
'requestsPerMinute',
'requestsPerDay',
'tokensPerDay',
'tokensPerMonth',
'costPerDayUSD',
'costPerMonthUSD',
]
/** limitsEntries reads only the CONFIGURED (truthy) fields off a LimitsConfig, in canonical order — mirrors lib/format.ts's formatLimits/lib/usage-bars.ts's budgetRatios own "0/omitted means unlimited" convention: an unset limit is never emitted as `0`. */
function limitsEntries(limits: LimitsConfig): [string, number][] {
const out: [string, number][] = []
for (const key of LIMITS_FIELD_ORDER) {
const value = limits[key]
if (value) out.push([key, value])
}
return out
}
/** groupLimitsSnippet builds a group's YAML limits block, nested at spec.plugin.llmgateway.groups.<name>.limits — LimitsForm's group-target output. */
export function groupLimitsSnippet(groupName: string, limits: LimitsConfig): string {
const body: { [key: string]: YamlValue | undefined } = {}
for (const [key, value] of limitsEntries(limits)) body[key] = value
return emitConfigSnippet(['groups', groupName], { limits: body })
}
function jsonString(s: string): string {
return JSON.stringify(s)
}
function jsonStringArray(values: string[]): string {
return `[${values.map(jsonString).join(', ')}]`
}
/** jsonLimitsObject renders a LimitsConfig as a JSON object literal (no surrounding key) — "{\"requestsPerDay\": 1000}" — the SAME field order/omit-when-unset convention limitsEntries applies for the YAML path, just JSON syntax instead. */
function jsonLimitsObject(limits: LimitsConfig): string {
const parts = limitsEntries(limits).map(([key, value]) => `${jsonString(key)}: ${value}`)
return `{${parts.join(', ')}}`
}
/** userLimitsJsonFragment builds the bare `"limits": {...}` field to paste into an EXISTING user's users.json line — LimitsForm's user-target output (a user's own limits live inside their JSON object, not a separate YAML block). */
export function userLimitsJsonFragment(limits: LimitsConfig): string {
return `"limits": ${jsonLimitsObject(limits)}`
}
/** GroupGrant is the four access lists GroupConfig carries (llmgateway.go) — GrantForm's group-target input. Every field is always emitted, even empty (middleware.yaml's own "every field explicit and empty, not a bare {}" convention, groups.home). */
export interface GroupGrant {
providers: string[]
models: string[]
mcpServers: string[]
agents: string[]
}
/** groupGrantSnippet builds a group's YAML access-list block, nested at spec.plugin.llmgateway.groups.<name> — GrantForm's group-target output. */
export function groupGrantSnippet(groupName: string, grant: GroupGrant): string {
return emitConfigSnippet(['groups', groupName], {
providers: grant.providers,
models: grant.models,
mcpServers: grant.mcpServers,
agents: grant.agents,
})
}
/** userGrantJsonFragment builds the bare `"providers": [...], "models": [...]` fields to paste into an EXISTING user's users.json line — GrantForm's user-target (personal grant) output. Only non-empty lists are emitted (an omitted personal-grant field means "nothing extra beyond the user's group", the same "0/omitted means unset" convention every other optional field in this file follows) — callers MUST reject the grant first via validatePersonalGrant. */
export function userGrantJsonFragment(grant: { providers: string[]; models: string[] }): string {
const parts: string[] = []
if (grant.providers.length > 0) parts.push(`"providers": ${jsonStringArray(grant.providers)}`)
if (grant.models.length > 0) parts.push(`"models": ${jsonStringArray(grant.models)}`)
return parts.join(', ')
}
/**
* ModelMeta is a model's modelMeta entry — every field independently
* optional (ModelMetaConfig, llmgateway.go: `contextTokens`,
* `inputCostPerMTokMicroUsd`, `outputCostPerMTokMicroUsd`, `free`), mirroring
* AdminModelMetaView/AdminCatalogModel's own "undefined means unset"
* convention. ModelMetaForm sets only contextTokens/free — the two fields
* this webui exposes an editor for (its own doc comment). PricingForm
* does NOT go through this type or modelMetaSnippet below: per-token
* price overrides that drive BILLING live in the top-level Config.Pricing
* map (`spec.plugin.llmgateway.pricing`, ModelPricing{InputPerM,
* OutputPerM} — see pricingSnippet), a config section entirely distinct
* from modelMeta's own cost fields, which only drive metadata EXPOSURE
* (GET /v1/models, Config's own ModelMeta doc comment: "distinct from
* Pricing above, which drives request cost ACCOUNTING").
*/
export interface ModelMeta {
contextTokens?: number
free?: boolean
}
/**
* modelMetaSnippet builds a model's modelMeta YAML entry as a single-line
* flow map, matching middleware.yaml's own convention EXACTLY (golden
* strings, e.g. `"gx10-embed/text-embedding-qwen3-0.6b": { contextTokens:
* 16384, free: true }`): field order contextTokens, free — only the
* fields the caller actually set, in that fixed order, every other field
* entirely absent from the flow map (never a fabricated 0/false). modelId
* is ALWAYS quoted (JSON.stringify, not lib/yaml-emit.ts's conditional
* emitYamlKey — production quotes every modelMeta id, P2 item 11), and
* the flow map itself via emitYamlFlowMap — placed at modelMeta's own
* fixed 4-level indent by hand rather than through the generic recursive
* block renderer, since a flow map is a single line, not a further nested
* block.
*/
export function modelMetaSnippet(modelId: string, meta: ModelMeta): string {
const entries: [string, boolean | number][] = []
if (meta.contextTokens !== undefined) entries.push(['contextTokens', meta.contextTokens])
if (meta.free !== undefined) entries.push(['free', meta.free])
const flow = emitYamlFlowMap(entries)
// Always quoted (JSON.stringify, not the conditional emitYamlKey) — the
// production file quotes every modelMeta id regardless of whether
// emitYamlKey's own general rule would require it (P2 item 11),
// matching pricingSnippet's identical convention below.
return ['spec:', ' plugin:', ' llmgateway:', ' modelMeta:', ` ${JSON.stringify(modelId)}: ${flow}`].join('\n')
}
/** ModelPricingOverride mirrors ModelPricing exactly (llmgateway.go: `InputPerM float64 json:"inputPerM"`, `OutputPerM float64 json:"outputPerM"`) — the billing-authoritative per-token price override, keyed by canonical model id under the top-level Config.Pricing map. Both fields are required together (PricingForm.vue's own validation): a one-sided override has no billing meaning. */
export interface ModelPricingOverride {
inputPerM: number
outputPerM: number
}
/**
* pricingSnippet builds one model's entry in the top-level `pricing` map
* (`spec.plugin.llmgateway.pricing.<id>`, Config.Pricing/ModelPricing,
* llmgateway.go) as a single-line flow map — matching examples/
* kubernetes.yaml's own real shape exactly: `gpt-5-mini: {inputPerM: 0.25,
* outputPerM: 2.0}`. Hand-built at `pricing`'s own fixed depth, the same
* convention modelMetaSnippet above follows for its own sibling top-level
* map — a flow map is a single line, not a further nested block, so it is
* not routed through emitConfigSnippet's generic recursive block
* renderer.
*/
export function pricingSnippet(modelId: string, pricing: ModelPricingOverride): string {
const flow = emitYamlFlowMap([
['inputPerM', pricing.inputPerM],
['outputPerM', pricing.outputPerM],
])
// Conditional (yaml-emit.ts's own emitYamlKey), NOT always-quoted —
// unlike modelMetaSnippet's own convention above: examples/
// kubernetes.yaml's real "pricing:" entry (line 146, read this session)
// is `gpt-5-mini: {inputPerM: 0.25, outputPerM: 2.0}`, a bare id with
// no quotes, so pricing does NOT share modelMeta's "always quote"
// production convention — it follows the general rule instead.
return ['spec:', ' plugin:', ' llmgateway:', ' pricing:', ` ${emitYamlKey(modelId)}: ${flow}`].join('\n')
}
/** API_KEY_PLACEHOLDER is UserForm's default apiKey field value before the operator clicks "Generate" — visibly a placeholder, never mistaken for a real key (Q10, redesign-plan.md DECISIONS: "browser key generation offered, placeholder by default"). */
export const API_KEY_PLACEHOLDER = 'sk-llmgw-REPLACE_ME'
/** generateApiKey produces a real random bearer token client-side: 32 bytes from the Web Crypto CSPRNG, base64url-encoded — the same byte length and character set as the production tokens in users-configmap.yaml (openssl rand -hex 24 there is 24 bytes/48 hex chars; 32 raw bytes here is a comparable-or-stronger entropy budget, base64url rather than hex purely for a shorter pasted string). Never sent anywhere — ChangeHelper only ever displays it for the operator to copy into their own users.json edit. */
export function generateApiKey(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32))
let binary = ''
for (const b of bytes) binary += String.fromCharCode(b)
const base64 = btoa(binary)
return `sk-llmgw-${base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`
}
/** UserSnippetInput is UserForm's full input shape — mirrors AdminConsumerUser (types/api.ts) plus the two config-only fields (groups plural, limits) that view never carries but users.json itself supports. */
export interface UserSnippetInput {
name: string
apiKey: string
/** Single primary group — production's own convention (users-configmap.yaml: every existing user sets exactly this field). Mutually exclusive with `groups` in practice (userJsonLine emits whichever is set; a caller offering both is a UserForm UI bug, not something this function arbitrates). */
group?: string
/** Multi-group membership (newer plugin support, mirrors AdminUsageEntryView.groups' own multi-group doc comment) — used instead of `group` when the operator selects more than one. */
groups?: string[]
providers?: string[]
models?: string[]
limits?: LimitsConfig
admin?: boolean
}
/**
* userJsonLine builds one users.json array entry as a single-line JSON
* object — GOLDEN-STRING matched against users-configmap.yaml's own real
* entries, e.g. `{"name": "barteq", "apiKey": "sk-friend-...", "group":
* "friends", "providers": ["gx10"]}`: key order name, apiKey, group,
* groups, providers, models, limits, admin (redesign-plan.md section 3.4),
* `": "` after every key and `", "` between fields (NOT JSON.stringify's
* own no-space-after-colon default — production's own file has the space),
* only present/truthy fields emitted, `admin` only ever appears as the
* literal `true` (an admin:false entry is simply omitted, matching every
* non-admin user in the production file).
*/
export function userJsonLine(user: UserSnippetInput): string {
const parts: string[] = [`"name": ${jsonString(user.name)}`, `"apiKey": ${jsonString(user.apiKey)}`]
if (user.group) parts.push(`"group": ${jsonString(user.group)}`)
if (user.groups && user.groups.length > 0) parts.push(`"groups": ${jsonStringArray(user.groups)}`)
if (user.providers && user.providers.length > 0) parts.push(`"providers": ${jsonStringArray(user.providers)}`)
if (user.models && user.models.length > 0) parts.push(`"models": ${jsonStringArray(user.models)}`)
if (user.limits && limitsEntries(user.limits).length > 0) parts.push(`"limits": ${jsonLimitsObject(user.limits)}`)
if (user.admin) parts.push(`"admin": ${emitYamlScalar(true)}`)
return `{${parts.join(', ')}}`
}
+94 -2
View File
@@ -1,9 +1,10 @@
import { isVNode } from 'vue'
import { describe, expect, it } from 'vitest'
import CompactNumber from '@/components/CompactNumber.vue'
import { Badge } from '@/components/ui/badge'
import { targetColumns } from './target-columns'
import type { AdminTargetHealthView, AdminTargetView } from '@/types/api'
import { composeTargetId, parseTargetCallerId, targetCallerColumns, targetColumns } from './target-columns'
import type { AdminTargetHealthView, AdminTargetView, AdminTotalsResponse } from '@/types/api'
/**
* Target health rendering (feat/target-health): mirrors usage-columns.
@@ -184,3 +185,94 @@ describe('targetColumns: access column', () => {
expect(children.every((c) => isVNode(c) && c.type === Badge)).toBe(true)
})
})
describe('targetColumns: name column selection', () => {
it('renders plain text with no onSelect (default, backward-compatible)', () => {
const col = targetColumns().find((c) => c.id === 'name')
const cell = col?.cell as (ctx: { row: { original: AdminTargetView } }) => unknown
const rendered = cell({ row: { original: testTarget({ state: 'unknown', consecutiveFailures: 0 }) } })
expect(isVNode(rendered) && rendered.type).toBe('span')
})
it('renders a clickable button that invokes onSelect with the row when given', () => {
const selected: AdminTargetView[] = []
const col = targetColumns((t) => selected.push(t)).find((c) => c.id === 'name')
const cell = col?.cell as (ctx: { row: { original: AdminTargetView } }) => unknown
const target = testTarget({ state: 'unknown', consecutiveFailures: 0 })
const rendered = cell({ row: { original: target } })
expect(isVNode(rendered) && rendered.type).toBe('button')
const onClick = (rendered as { props?: Record<string, unknown> }).props?.onClick as (e: MouseEvent) => void
onClick({ stopPropagation: () => {} } as MouseEvent)
expect(selected).toEqual([target])
})
})
describe('composeTargetId', () => {
it('joins kind and name with a slash', () => {
expect(composeTargetId('mcp', 'fetch')).toBe('mcp/fetch')
expect(composeTargetId('agent', 'researcher')).toBe('agent/researcher')
})
})
describe('parseTargetCallerId', () => {
it('splits a well-formed "kind/target/caller" id into its three parts', () => {
expect(parseTargetCallerId('mcp/fetch/alice')).toEqual({ kind: 'mcp', target: 'fetch', caller: 'alice' })
})
it('handles a caller name that itself contains a slash by keeping it in `caller` verbatim', () => {
expect(parseTargetCallerId('agent/researcher/team/bob')).toEqual({ kind: 'agent', target: 'researcher', caller: 'team/bob' })
})
it('falls back to an empty target/caller for an id with no slash at all, without throwing', () => {
expect(parseTargetCallerId('malformed')).toEqual({ kind: '', target: '', caller: 'malformed' })
})
it('falls back to an empty caller for an id with only one slash', () => {
expect(parseTargetCallerId('mcp/fetch')).toEqual({ kind: 'mcp', target: 'fetch', caller: '' })
})
})
// dayOrMonthWindow moved to lib/range.ts (P2 item 2: a SHARED clamp used
// by stores/consumers.ts, stores/spend.ts, AND this module's own
// TargetCallers.vue — see range.spec.ts's own describe('dayOrMonthWindow').
describe('targetCallerColumns', () => {
function row(id: string, req: number): AdminTotalsResponse['rows'][number] {
return { id, values: { req } }
}
it('omits the Target column when showTarget is false', () => {
const columns = targetCallerColumns(false)
expect(columns.find((c) => c.id === 'target')).toBeUndefined()
expect(columns.map((c) => c.id)).toEqual(['caller', 'requests'])
})
it('includes the Target column, rendering "kind/target", when showTarget is true', () => {
const columns = targetCallerColumns(true)
const col = columns.find((c) => c.id === 'target')
expect(col).toBeDefined()
const cell = col?.cell as (ctx: { row: { original: AdminTotalsResponse['rows'][number] } }) => unknown
const rendered = cell({ row: { original: row('mcp/fetch/alice', 3) } })
expect(isVNode(rendered) && rendered.children).toBe('mcp/fetch')
})
it('caller column reads the parsed caller name, falling back to the raw id', () => {
const columns = targetCallerColumns(false)
const col = columns.find((c) => c.id === 'caller') as unknown as {
accessorFn: (r: AdminTotalsResponse['rows'][number], i: number) => unknown
}
const accessor = col.accessorFn
expect(accessor(row('mcp/fetch/alice', 3), 0)).toBe('alice')
expect(accessor(row('malformed', 3), 0)).toBe('malformed')
})
it('requests column renders a CompactNumber reading values.req, defaulting to 0', () => {
const columns = targetCallerColumns(false)
const col = columns.find((c) => c.id === 'requests')
const cell = col?.cell as (ctx: { row: { original: AdminTotalsResponse['rows'][number] } }) => unknown
const rendered = cell({ row: { original: { id: 'mcp/fetch/alice', values: {} } } })
expect(isVNode(rendered) && rendered.type).toBe(CompactNumber)
expect(isVNode(rendered) && rendered.props?.value).toBe(0)
})
})
+127 -10
View File
@@ -4,7 +4,7 @@ import { h } from 'vue'
import CompactNumber from '@/components/CompactNumber.vue'
import { Badge } from '@/components/ui/badge'
import { formatElapsedAgo, formatLatencyMs } from '@/lib/format'
import type { AdminTargetHealthView, AdminTargetView } from '@/types/api'
import type { AdminTargetHealthView, AdminTargetView, AdminTotalsResponse } from '@/types/api'
/**
* numericColumn builds one right-aligned, sortable request-counter
@@ -30,7 +30,7 @@ function numericColumn(id: string, header: string, read: (t: AdminTargetView) =>
// --- target health (feat/target-health) ---
//
// Mirrors ProvidersView.vue's own healthBadgeVariant/healthBadgeLabel
// Mirrors ProviderHealthPanel.vue's own healthBadgeVariant/healthBadgeLabel
// convention for the discovery circuit breaker: a Badge only for the two
// states worth flagging (unhealthy, unknown); the common healthy case
// renders no badge at all, just muted inline text — the same "nothing to
@@ -107,20 +107,40 @@ function healthCell(health: AdminTargetHealthView) {
* servers and the Agents DataTable in TargetsView.vue use (admin.go:
* adminTargetView — the two tables share one identical row shape). name is
* plain text (not the ModelChip copy treatment — a target name is not a
* routable model id); url is muted and truncated with a title attribute
* for the full value; health is the Badge/muted-text pair healthCell above
* builds (feat/target-health); access renders as a Badge per allowed
* group, or a muted "All groups" when unrestricted — the exact idiom
* UsageView.vue's own group Access block already established (Providers/
* Models/MCP servers/Agents dt/dd pairs there).
* routable model id) UNLESS `onSelect` is given, in which case it renders
* as a real `<button>` (same structural convention as EntityLink.vue: a
* click opens that target's own caller breakdown, TargetCallers.vue,
* rather than navigating away — redesign-plan.md section 3.4's
* "TargetCallers.vue expander"). url is muted and truncated with a title
* attribute for the full value; health is the Badge/muted-text pair
* healthCell above builds (feat/target-health); access renders as a
* Badge per allowed group, or a muted "All groups" when unrestricted —
* the exact idiom ConsumerDirectory.vue's own group Access block already
* established (Providers/Models/MCP servers/Agents dt/dd pairs there).
*/
export function targetColumns(): ColumnDef<AdminTargetView, unknown>[] {
export function targetColumns(onSelect?: (target: AdminTargetView) => void): ColumnDef<AdminTargetView, unknown>[] {
return [
{
id: 'name',
header: 'Name',
accessorFn: (t) => t.name,
cell: ({ row }) => h('span', { class: 'font-medium' }, row.original.name),
cell: ({ row }) => {
if (!onSelect) return h('span', { class: 'font-medium' }, row.original.name)
return h(
'button',
{
type: 'button',
class:
'cursor-pointer rounded-sm font-medium hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50',
title: `View ${row.original.name}'s callers`,
onClick: (event: MouseEvent) => {
event.stopPropagation()
onSelect(row.original)
},
},
row.original.name,
)
},
},
{
id: 'url',
@@ -166,3 +186,100 @@ export function targetColumns(): ColumnDef<AdminTargetView, unknown>[] {
numericColumn('reqMonth', 'req/month', (t) => t.counters.requestsPerMonth),
]
}
// --- target x caller (redesign-plan.md section 3.4's "TargetCallers.vue
// expander" + "top callers" card) ---
//
// GET /admin/api/usage/totals?kind=targetcaller (redesign-plan.md section
// 1.3.v) has no dedicated row type in types/api.ts — AdminTotalsResponse
// inlines its row shape rather than naming it, so `TargetCallerRow` is
// derived from that response's own `rows` element type here (single source
// of truth: a future field added to the inline shape needs no second edit
// in this file).
type TargetCallerRow = AdminTotalsResponse['rows'][number]
export type TargetKind = 'mcp' | 'agent'
/**
* composeTargetId builds the "mcp/{name}" / "agent/{name}" composite id
* GET /admin/api/usage/totals?kind=targetcaller&target= expects (admin.go)
* — the SAME prefix a targetcaller row's own `id` starts with
* (parseTargetCallerId below splits it back apart).
*/
export function composeTargetId(kind: TargetKind, name: string): string {
return `${kind}/${name}`
}
/**
* ParsedTargetCallerId is one targetcaller row's `id`
* ("mcp/fetch/alice" — AdminTotalsResponse's own doc comment) split into
* its three parts.
*/
export interface ParsedTargetCallerId {
kind: string
target: string
caller: string
}
/**
* parseTargetCallerId never throws: an id missing either slash (a
* version-skewed server, or a caller name that happens to contain no
* slash of its own — the only way this format is ambiguous) degrades
* gracefully rather than dropping the row — `target`/`caller` fall back to
* "" so a caller can still detect the shortfall (empty string, not
* undefined) and choose to render the raw id verbatim.
*/
export function parseTargetCallerId(id: string): ParsedTargetCallerId {
const first = id.indexOf('/')
if (first === -1) return { kind: '', target: '', caller: id }
const kind = id.slice(0, first)
const rest = id.slice(first + 1)
const second = rest.indexOf('/')
if (second === -1) return { kind, target: rest, caller: '' }
return { kind, target: rest.slice(0, second), caller: rest.slice(second + 1) }
}
/**
* targetCallerColumns builds the shared TanStack `ColumnDef` set
* TargetCallers.vue's DataTable uses for both its modes: a single
* target's own caller breakdown (`showTarget: false` — the panel's own
* heading already names the target) and the fleet-wide "top callers" card
* (`showTarget: true` — each row's own target is not implied by anything
* else on screen). `requests` is the only metric the server returns for
* this kind (contract: "targetcaller req only"), so there is no metric
* picker here, unlike model-table-columns.ts's multi-metric table.
*/
export function targetCallerColumns(showTarget: boolean): ColumnDef<TargetCallerRow, unknown>[] {
const columns: ColumnDef<TargetCallerRow, unknown>[] = []
if (showTarget) {
columns.push({
id: 'target',
header: 'Target',
accessorFn: (row) => {
const p = parseTargetCallerId(row.id)
return `${p.kind}/${p.target}`
},
cell: ({ row }) => {
const p = parseTargetCallerId(row.original.id)
return h('span', { class: 'font-mono text-xs text-muted-foreground' }, `${p.kind}/${p.target}`)
},
})
}
columns.push({
id: 'caller',
header: 'Caller',
accessorFn: (row) => parseTargetCallerId(row.id).caller || row.id,
cell: ({ row }) => {
const caller = parseTargetCallerId(row.original.id).caller
return h('span', { class: 'font-medium' }, caller || row.original.id)
},
})
columns.push({
id: 'requests',
header: 'Requests',
meta: { align: 'right' },
accessorFn: (row) => row.values.req ?? 0,
cell: ({ row }) => h(CompactNumber, { value: row.original.values.req ?? 0 }),
})
return columns
}
+6
View File
@@ -120,3 +120,9 @@ describe('budgetRatios', () => {
expect(ratios.map((r) => r.id)).toEqual(['reqMin', 'reqDay', 'tokDay', 'tokMonth', 'costDay', 'costMonth'])
})
})
// estimatedRunOutDate was removed (P2 item 14 — it disagreed with
// lib/burndown.ts's runOutDate on an already-exceeded budget and on day 1
// of the month; UserDetail.vue and BurnDownChart.vue now both call the
// one function in lib/burndown.ts, which owns this coverage —
// lib/burndown.spec.ts's own `describe('runOutDate', ...)`).
+16 -8
View File
@@ -1,8 +1,9 @@
// 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.
// F1 (usage-vs-limit bars): pure ratio math for the Consumers page's
// per-scope budget bars (UsageBar.vue), kept out of the component the
// same way lib/provider-rate.ts keeps ProviderHealthPanel.vue's own
// success-rate math out of it — 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. */
@@ -53,9 +54,10 @@ 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
* accessible text — shared by ConsumerDirectory.vue's group-detail-grid
* bars, usage-columns.ts's own per-column bars, and UserDetail.vue's
* headroom 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
@@ -129,3 +131,9 @@ export function budgetRatios(entry: AdminUsageEntryView): BudgetRatio[] {
}
return out
}
// estimatedRunOutDate was removed (P2 item 14, DRY): UserDetail.vue and
// BurnDownChart.vue now both call lib/burndown.ts's own runOutDate, the
// ONE run-out projection in this codebase — it used to disagree with this
// one on an already-exceeded budget (null here vs `now` there) and on day
// 1 of the month (clamped/overstated here vs null there).
+64 -16
View File
@@ -2,7 +2,7 @@ import type { VNode } from 'vue'
import { isVNode } from 'vue'
import { describe, expect, it } from 'vitest'
import ScopeLink from '@/components/ScopeLink.vue'
import EntityLink from '@/components/EntityLink.vue'
import { usageColumns } from './usage-columns'
import type { AdminUsageEntryView } from '@/types/api'
@@ -182,39 +182,87 @@ describe('usageColumns: F1 usage-vs-limit bars', () => {
})
})
// 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', () => {
// F6 (EntityLink id cell): the id column renders an EntityLink pointed at
// Consumers?user={id}, never a plain span — the click-through-to-detail
// affordance every Users/Members row gets.
describe('usageColumns: F6 id cell is an EntityLink', () => {
const columns = usageColumns('Name', 'Group', () => 'g')
const col = columns.find((c) => c.id === 'id')
it('renders a ScopeLink vnode, not a plain span', () => {
it('renders an EntityLink vnode, not a plain span', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ id: 'alice', kind: 'user' }) } }) as VNode
expect(rendered.type).toBe(ScopeLink)
expect(rendered.type).toBe(EntityLink)
})
it('builds the scope as "user:{id}" for a user row', () => {
it('builds kind "user" and the row id, with the label matching', () => {
const rendered = cellOf(col)({ row: { original: testEntry({ id: 'alice', kind: 'user' }) } }) as VNode
expect(rendered.props?.scope).toBe('user:alice')
expect(rendered.props?.kind).toBe('user')
expect(rendered.props?.id).toBe('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 — the 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(EntityLink)
})
})
describe('usageColumns: last seen column', () => {
const now = new Date(Date.UTC(2026, 3, 16, 12, 0, 0))
const columns = usageColumns('Name', 'Group', () => 'g', now)
const col = columns.find((c) => c.id === 'lastSeen')
it('accessorFn reads the raw unix-seconds value, defaulting to 0 when unset', () => {
expect(accessorFnOf(col)(testEntry(), 0)).toBe(0)
expect(accessorFnOf(col)(testEntry({ lastSeen: 123 }), 0)).toBe(123)
})
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)
it('cell renders "never" when unset', () => {
expect(cellOf(col)({ row: { original: testEntry() } })).toBe('never')
})
it('cell renders a relative-time label when set', () => {
const seconds = Math.floor(now.getTime() / 1000) - 300
expect(cellOf(col)({ row: { original: testEntry({ lastSeen: seconds }) } })).toBe('5m ago')
})
it('is NOT masked by storeDown — lastSeen is its own counter family, independent of the usage counters storeDown guards', () => {
const seconds = Math.floor(now.getTime() / 1000) - 300
expect(cellOf(col)({ row: { original: testEntry({ lastSeen: seconds, storeDown: true }) } })).toBe('5m ago')
})
})
describe('usageColumns: optional source column', () => {
it('is absent when no sourceValue is passed', () => {
const columns = usageColumns('Name', 'Group', () => 'g')
expect(columns.find((c) => c.id === 'source')).toBeUndefined()
})
it('is inserted right after the secondary column when sourceValue is passed', () => {
const columns = usageColumns('Name', 'Group', () => 'g', new Date(), (e) => (e.id === 'alice' ? 'inline' : undefined))
const ids = columns.map((c) => c.id)
expect(ids.indexOf('source')).toBe(ids.indexOf('secondary') + 1)
})
it('renders the resolved source text', () => {
const columns = usageColumns('Name', 'Group', () => 'g', new Date(), (e) => (e.id === 'alice' ? 'inline' : undefined))
const col = columns.find((c) => c.id === 'source')
const rendered = cellOf(col)({ row: { original: testEntry({ id: 'alice' }) } }) as VNode
expect(rendered.children).toBe('inline')
})
it('renders an em dash when the lookup returns undefined (no matching /consumers entry)', () => {
const columns = usageColumns('Name', 'Group', () => 'g', new Date(), () => undefined)
const col = columns.find((c) => c.id === 'source')
const rendered = cellOf(col)({ row: { original: testEntry({ id: 'bob' }) } }) as VNode
expect(rendered.children).toBe('—')
})
})
// 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).
// this panel (ProviderHealthPanel.vue's badges).
describe('usageColumns: F4 rejected/day column', () => {
const columns = usageColumns('Name', 'Group', () => 'g')
const col = columns.find((c) => c.id === 'rejDay')
+73 -21
View File
@@ -2,10 +2,11 @@ import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'
import CompactNumber from '@/components/CompactNumber.vue'
import ScopeLink from '@/components/ScopeLink.vue'
import EntityLink from '@/components/EntityLink.vue'
import UsageBar from '@/components/UsageBar.vue'
import { headroom, monthProgress, projectMonthEnd } from '@/lib/forecast'
import { formatCost, formatExactInt, formatLimits } from '@/lib/format'
import { formatLastSeen } from '@/lib/last-seen'
import { BUDGET_RATIO_LABEL, budgetRatios } from '@/lib/usage-bars'
import type { AdminUsageEntryView } from '@/types/api'
@@ -150,38 +151,84 @@ function costMonthProjColumn(elapsedFraction: number): ColumnDef<AdminUsageEntry
}
}
/**
* sourceColumn (Consumers redesign, redesign-plan.md section 3.4) renders
* a user's directory source — 'inline' (defined directly in the plugin
* config) or 'file' (loaded from users.file, admin.go: adminConsumerUser.
* Source) — via the caller-supplied `sourceValue` lookup (ConsumerDirectory.
* vue joins this row's id against the /admin/api/consumers response, which
* this module has no fetch access to itself). Only ever added when a
* caller passes `sourceValue` to usageColumns (see its own doc comment) —
* a group row (no such concept) and the headless Groups sort toolbar never
* get this column at all. `sourceValue` returning undefined (a group's
* nested member row whose name has no matching /consumers entry yet, e.g.
* mid-fetch) renders an em dash rather than blank, so the column never
* looks like a rendering bug.
*/
function sourceColumn(sourceValue: (entry: AdminUsageEntryView) => string | undefined): ColumnDef<AdminUsageEntryView, unknown> {
return {
id: 'source',
header: 'Source',
accessorFn: (entry) => sourceValue(entry) ?? '',
cell: ({ row }) => h('span', { class: 'text-muted-foreground' }, sourceValue(row.original) ?? '—'),
}
}
/**
* lastSeenColumn (Consumers redesign) renders AdminUsageEntryView.lastSeen
* via lib/last-seen.ts's formatLastSeen — added unconditionally (unlike
* sourceColumn above): lastSeen is a fleet-wide counter written for BOTH
* user and group scopes (redesign-plan.md section 1.2's last-seen family:
* "user + every group scope"), so it is meaningful in every context
* usageColumns() is used in, not just the flat Users table. A storeDown
* row is NOT masked as "?" here (unlike every numericColumn) — lastSeen is
* its own separate counter family (a SET, not the same INCRBY scope
* counters storeDown actually guards), so a down usage store does not
* make this value stale/unknown the way it does for req/tok/cost.
*/
function lastSeenColumn(now: Date): ColumnDef<AdminUsageEntryView, unknown> {
return {
id: 'lastSeen',
header: 'Last seen',
accessorFn: (entry) => entry.lastSeen ?? 0,
cell: ({ row }) => formatLastSeen(row.original.lastSeen, now),
}
}
/**
* usageColumns builds the shared TanStack `ColumnDef` set for one
* AdminUsageEntryView table. Used by UsageTable.vue (the flat Users list,
* and — reused unmodified — a group's nested member-user list inside the
* Usage view's Groups accordion) and, headlessly (no `<Table>` render, see
* that view's own doc comment for why), by UsageView.vue's Groups sort
* bar, so both surfaces sort by the exact same column semantics.
* and — reused unmodified — a group's nested member-user list inside
* ConsumerDirectory.vue's Groups accordion) and, headlessly (no `<Table>`
* render, see that component's own doc comment for why), by its Groups
* sort 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). `now` (F7) defaults to the real current time —
* see costMonthProjColumn's own doc comment for why a caller can override
* it.
* it. `sourceValue`, when supplied, inserts sourceColumn right after the
* secondary column — ConsumerDirectory.vue's flat Users table is the only
* caller that passes it (see sourceColumn's own doc comment).
*/
export function usageColumns(
idLabel: string,
secondaryColumnLabel: string,
secondaryValue: (entry: AdminUsageEntryView) => string,
now: Date = new Date(),
sourceValue?: (entry: AdminUsageEntryView) => string | undefined,
): ColumnDef<AdminUsageEntryView, unknown>[] {
const elapsedFraction = monthProgress(now)
return [
const columns: ColumnDef<AdminUsageEntryView, unknown>[] = [
{
id: 'id',
header: idLabel,
accessorFn: (entry) => entry.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}` }),
// EntityLink (redesign-plan.md section 3.5, replacing ScopeLink):
// this column only ever renders 'user' kind entries in practice —
// UsageTable.vue's own doc comment ("users, or a group's member
// users") — so it links straight to Consumers?user={id}, opening
// that row's own UserDetail, rather than a Charts scope jump.
cell: ({ row }) => h(EntityLink, { class: 'font-medium', label: row.original.id, kind: 'user', id: row.original.id }),
},
{
id: 'secondary',
@@ -189,18 +236,21 @@ export function usageColumns(
accessorFn: secondaryValue,
// 'alphanumeric' (not the default auto-detected sortingFn): this
// column's value is a plain string everywhere EXCEPT the Groups
// toolbar, where UsageView.vue passes memberCountOf — a numeric count
// rendered as a string. TanStack's getAutoSortingFn only inspects
// rows 11+ (flatRows.slice(10)) to decide numeric vs basic, so a
// groups table with 10 or fewer rows silently fell back to `basic`
// (plain a > b string comparison), sorting "10" before "9".
// 'alphanumeric' handles both cases correctly regardless of row
// toolbar, where ConsumerDirectory.vue passes memberCountOf — a
// numeric count rendered as a string. TanStack's getAutoSortingFn
// only inspects rows 11+ (flatRows.slice(10)) to decide numeric vs
// basic, so a groups table with 10 or fewer rows silently fell back
// to `basic` (plain a > b string comparison), sorting "10" before
// "9". 'alphanumeric' handles both cases correctly regardless of row
// count: numeric substrings compare numerically, and it degrades
// gracefully for the plain-text group-name case (secondaryValue on
// the Users table).
sortingFn: 'alphanumeric',
cell: ({ row }) => h('span', { class: 'text-muted-foreground' }, secondaryValue(row.original)),
},
]
if (sourceValue) columns.push(sourceColumn(sourceValue))
columns.push(
{
id: 'limits',
header: 'Limits',
@@ -217,5 +267,7 @@ export function usageColumns(
numericColumn('costDay', 'cost/day', (e) => e.costPerDayMicroUsd, formatCost, false, 'costDay'),
numericColumn('costMonth', 'cost/month', (e) => e.costPerMonthMicroUsd, formatCost, false, 'costMonth'),
costMonthProjColumn(elapsedFraction),
]
lastSeenColumn(now),
)
return columns
}
+19 -81
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { modelDetailCsv, modelRankingCsv, usageCsv } from './usage-csv'
import type { AdminUsageEntryView, AdminUsageModelEntry } from '@/types/api'
import { usageCsv } from './usage-csv'
import type { AdminUsageEntryView } from '@/types/api'
function testEntry(overrides: Partial<AdminUsageEntryView> = {}): AdminUsageEntryView {
return {
@@ -28,7 +28,7 @@ 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)"'
'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)",last seen (unix seconds)'
it('renders the Users header row with "Group" as the secondary column', () => {
const csv = usageCsv([], 'users', () => '', NOW)
@@ -43,21 +43,26 @@ describe('usageCsv', () => {
// 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' })]
it('renders one data row per entry, as plain numbers (no locale grouping, no currency symbol), including the projected month-end cost and last-seen unix seconds', () => {
const entries = [testEntry({ id: 'alice', lastSeen: 1_700_000_000 }), 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')
// micro-USD -> 24.690000 USD. bob has no lastSeen -> 0.
expect(lines[1]).toBe('alice,group-of-alice,none,1,2,7,3,4,5,6,1.234500,12.345000,24.690000,1700000000')
expect(lines[2]).toBe('bob,group-of-bob,none,1,2,7,3,4,5,6,1.234500,12.345000,24.690000,0')
})
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')
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,0')
})
it('never masks last-seen as "?" for a storeDown entry — it is its own counter family, independent of the counters storeDown guards', () => {
const csv = usageCsv([testEntry({ storeDown: true, lastSeen: 1_700_000_000 })], 'users', () => 'g', NOW)
expect(csv.split('\r\n')[1]).toBe('alice,g,none,?,?,?,?,?,?,?,?,?,?,1700000000')
})
it('formats Limits via formatLimits, matching the on-screen table', () => {
@@ -68,7 +73,7 @@ describe('usageCsv', () => {
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,?,?,?,?,?,?,?,?,?,?')
expect(dataLine).toBe('alice,g,none,?,?,?,?,?,?,?,?,?,?,0')
})
it('quotes a field that needs it (e.g. a secondaryValue containing a comma)', () => {
@@ -81,77 +86,10 @@ describe('usageCsv', () => {
// 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)
expect(csv.split('\r\n')[1].split(',')).toHaveLength(14)
})
})
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')
})
})
// free-models-plan.md: the Models tab's CSV export now includes every
// detail=1 figure per row (Requests/Tokens in/Tokens out/Cost), plus a
// separate Free flag column — not just the single ranked metric
// modelRankingCsv above exports.
describe('modelDetailCsv', () => {
it('renders a header naming every detail column, with window/span folded into Requests only', () => {
const csv = modelDetailCsv([], '24h', 24)
expect(csv).toBe('Model,"Requests (24h, span 24)",Tokens in,Tokens out,Cost (USD),Free\r\n')
})
it('renders one row per model with every detail figure as a plain machine-readable number', () => {
const models: AdminUsageModelEntry[] = [
{ id: 'openai/gpt-4', value: 100, requests: 10, tokensIn: 1000, tokensOut: 2000, costMicroUsd: 1_234_500, free: false },
]
const csv = modelDetailCsv(models, '24h', 24)
expect(csv.split('\r\n')[1]).toBe('openai/gpt-4,10,1000,2000,1.234500,false')
})
it('exports the real 0 cost (never the string "free") in the Cost column, with Free as its own "true" column', () => {
const models: AdminUsageModelEntry[] = [
{ id: 'uni/free-model', value: 0, requests: 5, tokensIn: 50, tokensOut: 75, costMicroUsd: 0, free: true },
]
const csv = modelDetailCsv(models, '24h', 24)
expect(csv.split('\r\n')[1]).toBe('uni/free-model,5,50,75,0.000000,true')
})
it('falls back every missing detail field to 0/false when detail was not actually requested (defensive)', () => {
const models: AdminUsageModelEntry[] = [{ id: 'openai/gpt-4', value: 100 }]
const csv = modelDetailCsv(models, '24h', 24)
expect(csv.split('\r\n')[1]).toBe('openai/gpt-4,0,0,0,0.000000,false')
})
it('renders just the header for an empty ranking', () => {
expect(modelDetailCsv([], '30d', 30)).toBe('Model,"Requests (30d, span 30)",Tokens in,Tokens out,Cost (USD),Free\r\n')
})
})
// modelRankingCsv/modelDetailCsv (and their own describe blocks here)
// were removed with the functions themselves (P3 item 25, dead code) —
// see usage-csv.ts's own doc comment.
+20 -85
View File
@@ -1,9 +1,5 @@
// F8 (CSV export): builds the actual CSV text for the Usage tab's Users/
// Groups exports (usageCsv) and the Models tab's ranking export. The
// Models tab's own ChartsView.vue calls modelDetailCsv (free-models-
// plan.md's per-row requests/tokens/cost/free breakdown) — modelRankingCsv
// stays exported and tested as a general single-metric exporter, kept for
// any future caller that only wants the ranked value column. Every export
// F8 (CSV export): builds the actual CSV text for ConsumerDirectory.vue's
// Users/Groups exports (usageCsv, CsvExportButton.vue). Every export
// funnels through lib/csv.ts's toCsv, so quoting/line-ending (and CSV-
// formula-injection neutralisation) behavior is defined in exactly one
// place (that module's own doc comment).
@@ -11,9 +7,9 @@ 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'
import type { AdminUsageEntryView } 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. */
/** 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/ConsumerDirectory.vue exactly. */
export type UsageCsvKind = 'users' | 'groups'
const USAGE_CSV_SECONDARY_LABEL: Record<UsageCsvKind, string> = {
@@ -49,8 +45,9 @@ function csvCostUsd(micros: number): string {
*
* `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.
* ConsumerDirectory.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
@@ -89,6 +86,7 @@ export function usageCsv(
'cost/day (USD)',
'cost/month (USD)',
'cost/month (proj., USD)',
'last seen (unix seconds)',
]
const rows = entries.map((entry) => {
const masked = entry.storeDown
@@ -107,83 +105,20 @@ export function usageCsv(
masked ? '?' : csvCostUsd(entry.costPerDayMicroUsd),
masked ? '?' : csvCostUsd(entry.costPerMonthMicroUsd),
masked ? '?' : projected === null ? 'not enough data yet' : csvCostUsd(projected),
// Never masked — lastSeen is its own SET-based counter family
// (redesign-plan.md section 1.2), independent of the INCRBY usage
// counters `storeDown` actually guards (see usage-columns.ts's
// lastSeenColumn's own doc comment). 0 means never seen, matching
// AdminUsageEntryView.lastSeen's own "0/omitted" convention.
entry.lastSeen ?? 0,
]
})
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)
}
/**
* modelDetailCsv renders the Models tab's current ranking with every
* `detail=1` figure per row (free-models-plan.md, UI section), not just
* the single ranked metric modelRankingCsv above exports: Model, Requests,
* Tokens in, Tokens out, Cost (USD), Free. stores/history.ts's
* fetchModelRanking always requests detail=1 now, so every entry passed
* here is expected to carry every detail field; `?? 0` covers the same
* theoretical-undefined case lib/model-table-columns.ts's
* numericDetailColumn already guards against, never a silent mis-export of
* a genuine 0.
*
* Cost is the SAME plain, machine-readable decimal-USD convention
* usageCsv/modelRankingCsv above already use (csvCostUsd) — a free model's
* own costMicroUsd is a real 0, exported as "0.000000" like any other zero
* cost, never overwritten by the text "free"; `free` is its own separate
* boolean column instead ("true"/"false"), so a spreadsheet reader can
* filter/sum the numeric Cost column without a stray non-numeric value
* breaking that — mirrors the on-screen table's own "the badge only
* changes what's displayed, never the sort key" rule
* (model-table-columns.ts's costColumn doc comment).
*
* `windowLabel`/`span` are the caller's own already-resolved DISPLAY
* strings/number (mirroring modelRankingCsv's identical parameters),
* folded into the Requests column's header only — every detail figure
* shares the identical span, so naming it once keeps the file
* self-describing without repeating it four times.
*/
export function modelDetailCsv(models: AdminUsageModelEntry[], windowLabel: string, span: number): string {
const headers = ['Model', `Requests (${windowLabel}, span ${span})`, 'Tokens in', 'Tokens out', 'Cost (USD)', 'Free']
const rows = models.map((m) => [
m.id,
m.requests ?? 0,
m.tokensIn ?? 0,
m.tokensOut ?? 0,
csvCostUsd(m.costMicroUsd ?? 0),
m.free ? 'true' : 'false',
])
return toCsv(headers, rows)
}
// modelRankingCsv/modelDetailCsv were removed (P3 item 25, dead code):
// both were the pre-redesign Models tab's own CSV export (ChartsView.vue,
// stores/history.ts), and neither has a caller anywhere in the
// redesigned shell — grep confirms only this file and its own now-deleted
// spec section ever referenced them. The redesigned Models page
// (ModelCatalogTable.vue) has no CSV export of its own.
+13 -9
View File
@@ -2,11 +2,14 @@ import type { AdminUsageEntryView } from '@/types/api'
/**
* Shared matching helpers for the "Filter users or groups" search
* UsageView.vue and ChartsView.vue both offer (operator directive:
* identical semantics, ONE implementation — no second copy of this logic).
* ConsumerDirectory.vue offers (operator directive: identical semantics,
* ONE implementation — no second copy of this logic; extracted here
* rather than left inline specifically because the pre-redesign Usage
* AND Charts tabs both needed the identical matching rules before this
* module existed).
*
* Every `query` parameter here is expected to already be trimmed and
* lowercased by the caller, mirroring ProvidersView.vue's own
* lowercased by the caller, mirroring ProviderHealthPanel.vue's own
* normalizedQuery convention (modelMatches/aliasMatches there never
* re-normalize either) — these functions stay pure string/array
* operations, with the caller owning the single source of truth for "is a
@@ -32,11 +35,11 @@ export function groupNameMatches(group: Pick<AdminUsageEntryView, 'id'>, query:
* `groupName` alone: a multi-group user would otherwise silently drop out
* of every group but their first. This is a plain client-side join against
* the already-polled GET /admin/api/usage response, no extra fetch.
* UsageView.vue calls this directly (replacing its own former local
* membersOf helper); matchingMembersOfGroup below builds on it too, which
* is how ChartsView.vue's groupMatches call ends up depending on this same
* join — indirectly, through matchingMembersOfGroup — without calling
* membersOfGroup itself.
* ConsumerDirectory.vue calls this directly (replacing the pre-redesign
* Usage tab's own former local membersOf helper); matchingMembersOfGroup
* below builds on it too, which is how ConsumerDirectory.vue's own
* groupMatches call ends up depending on this same join — indirectly,
* through matchingMembersOfGroup — without calling membersOfGroup itself.
*/
export function membersOfGroup(group: Pick<AdminUsageEntryView, 'id'>, users: AdminUsageEntryView[]): AdminUsageEntryView[] {
return users.filter((u) => (u.groups ?? [u.groupName]).includes(group.id))
@@ -57,7 +60,8 @@ export function matchingMembersOfGroup(
* need to distinguish WHICH of the two reasons a group matched — to decide
* whether to show all of its members or only the matching ones — use
* groupNameMatches and matchingMembersOfGroup directly instead (see
* UsageView.vue's own doc comment for that distinction).
* ConsumerDirectory.vue's own memberOnlyMatchIds doc comment for that
* distinction).
*/
export function groupMatches(group: Pick<AdminUsageEntryView, 'id'>, users: AdminUsageEntryView[], query: string): boolean {
return groupNameMatches(group, query) || matchingMembersOfGroup(group, users, query).length > 0
+287
View File
@@ -0,0 +1,287 @@
import { describe, expect, it } from 'vitest'
import {
emitConfigSnippet,
emitYamlDocument,
emitYamlFlowList,
emitYamlFlowMap,
emitYamlKey,
emitYamlScalar,
toYamlValue,
yamlKeyNeedsQuoting,
} from './yaml-emit'
describe('yamlKeyNeedsQuoting / emitYamlKey', () => {
it.each([
['gx10/current', true],
['a:b', true],
['plain', false],
['eng-team', false],
['friends_2', false],
])('key %s needs quoting: %s', (key, expected) => {
expect(yamlKeyNeedsQuoting(key)).toBe(expected)
})
it('quotes a key containing "/" — modelMeta\'s own routable-id convention', () => {
expect(emitYamlKey('gx10/current')).toBe('"gx10/current"')
})
it('leaves a plain key unquoted', () => {
expect(emitYamlKey('friends')).toBe('friends')
})
})
/**
* P2 item 11 — a table of tricky scalars a naive quoting rule gets wrong,
* checked against BOTH emitYamlKey and emitYamlScalar (one shared rule,
* needsQuoting — the fix instruction's own "apply the same rule to
* keys"). Every "needs quoting: true" row here was independently
* sanity-checked with PyYAML's own safe_load round trip during the
* verify pass this fixes (no network access needed to reason about it:
* PyYAML, like every YAML 1.1-lineage parser, resolves a flow-indicator
* character anywhere in a plain scalar, a leading special/digit
* character, a "key: value"-shaped substring, or a case-insensitive
* boolean/null spelling, the same way regardless of position — this
* table exercises each category at least once).
*/
describe('needsQuoting (P2 item 11): tricky inputs table', () => {
it.each([
// Flow indicators ANYWHERE, not just leading (auth.go:492 path.Match
// globs are legitimate config — `gpt-[45]*` used to break a flow
// list because only a LEADING `[`/`]` was quoted).
['gpt-[45]*', 'glob bracket mid-string', true],
['a]b', 'closing bracket mid-string', true],
['a}b', 'closing brace mid-string', true],
['a,b', 'comma mid-string', true],
['*m', 'leading flow/alias indicator', true],
['{m}', 'leading brace', true],
['- x', 'leading "- " (block sequence indicator)', true],
// Leading/trailing space, and a comment start that used to be
// truncated mid-scalar rather than quoted away entirely.
[' leading-space', 'leading space', true],
['trailing-space ', 'trailing space', true],
['x #y', 'space then "#" (comment start)', true],
// "key: value"-shaped substrings.
['a: b', 'colon-space (nested-mapping shape)', true],
['a:', 'trailing colon', true],
['12:30', 'time-of-day shaped', true],
// YAML 1.1 type-changers: numbers, dates, booleans, null, "infinity".
['0x10', 'hex-looking number', true],
['1e3', 'exponent-looking number', true],
['2026-01-01', 'date-shaped', true],
['.inf', 'YAML 1.1 infinity literal', true],
['~', 'YAML null shorthand', true],
['on', 'YAML 1.1 boolean (lowercase)', true],
['Off', 'YAML 1.1 boolean (mixed case)', true],
['yes', 'YAML 1.1 boolean', true],
['NO', 'YAML 1.1 boolean (uppercase)', true],
['true', 'YAML 1.2 boolean', true],
['null', 'YAML null literal', true],
['y', 'YAML 1.1 single-letter boolean', true],
// Safe: this module's real values (group/provider names, canonical
// model ids) must stay unquoted for readability.
['plain', 'ordinary bare word', false],
['gx10-embed', 'hyphenated name', false],
['friends_2', 'underscored name', false],
['eng.team', 'dotted name', false],
])('%s (%s) needs quoting: %s', (input, _label, expected) => {
expect(yamlKeyNeedsQuoting(input)).toBe(expected)
const scalarRendered = emitYamlScalar(input)
expect(scalarRendered === `"${input}"` || scalarRendered === input).toBe(true)
expect(scalarRendered.startsWith('"')).toBe(expected)
})
it('a group named "on"/"yes"/"true"/"null" (all allowed by GrantForm/LimitsForm\'s NAME_PATTERN) renders as a quoted, still-a-string YAML key', () => {
for (const name of ['on', 'yes', 'true', 'null']) {
expect(emitYamlKey(name)).toBe(JSON.stringify(name))
}
})
})
describe('emitYamlScalar', () => {
it.each([
[null, 'null'],
[true, 'true'],
[false, 'false'],
[0, '0'],
[5, '5'],
[0.5, '0.5'],
['anthropic', 'anthropic'],
])('renders %p as %s', (value, expected) => {
expect(emitYamlScalar(value as never)).toBe(expected)
})
it('quotes an empty string', () => {
expect(emitYamlScalar('')).toBe('""')
})
it('quotes a string that would otherwise parse as a boolean literal', () => {
expect(emitYamlScalar('true')).toBe('"true"')
})
it('quotes a string that would otherwise parse as a number', () => {
expect(emitYamlScalar('123')).toBe('"123"')
})
it('quotes a string containing ": " (would misparse as a nested mapping)', () => {
expect(emitYamlScalar('a: b')).toBe('"a: b"')
})
// N6 (verify-redesign-final.md): JSON.stringify leaves these four code
// points as literal bytes/code units — valid JSON, but a YAML 1.1
// scanner (PyYAML, go-yaml v3) treats U+0085/U+2028/U+2029 as line
// breaks (folded into whitespace, or a scanner error on a key) and
// rejects U+007F outright as non-printable. Each must come back
// escaped, never as a raw character.
it.each([
['NEL (U+0085)', '\u0085', '\\x85'],
['a C1 control (U+009F)', '\u009f', '\\x9f'],
['LINE SEPARATOR (U+2028)', '\u2028', '\\u2028'],
['PARAGRAPH SEPARATOR (U+2029)', '\u2029', '\\u2029'],
['DEL (U+007F)', '\u007f', '\\x7f'],
])('escapes %s inside a quoted scalar', (_label, char, escape) => {
const rendered = emitYamlScalar(`a${char}b`)
expect(rendered).toBe(`"a${escape}b"`)
expect(rendered).not.toContain(char)
})
it('escapes the same control characters inside a quoted key', () => {
const rendered = emitYamlKey('a\u0085b')
expect(rendered).toBe('"a\\x85b"')
expect(rendered).not.toContain('\u0085')
})
it('round-trips a string that is ONLY the unsafe character, still quoted and escaped', () => {
expect(emitYamlScalar('\u2028')).toBe('"\\u2028"')
})
})
describe('emitYamlFlowList', () => {
it('renders an empty list as "[]" — the production file\'s own "allow everything" convention', () => {
expect(emitYamlFlowList([])).toBe('[]')
})
it('renders a short string list inline, matching middleware.yaml\'s alibaba/xiaomi provider entries', () => {
expect(emitYamlFlowList(['qwen3.8-max', 'qwen3.8-flash'])).toBe('[qwen3.8-max, qwen3.8-flash]')
})
it('renders numbers unquoted inside a flow list', () => {
expect(emitYamlFlowList([1, 2, 3])).toBe('[1, 2, 3]')
})
})
describe('emitYamlFlowMap', () => {
it('renders an empty map as "{}"', () => {
expect(emitYamlFlowMap([])).toBe('{}')
})
// Golden strings: middleware.yaml's own modelMeta entries, verbatim.
it('matches "{ free: true }" (middleware.yaml modelMeta, e.g. gx10/current)', () => {
expect(emitYamlFlowMap([['free', true]])).toBe('{ free: true }')
})
it('matches "{ contextTokens: 16384, free: true }" (middleware.yaml, gx10-embed model)', () => {
expect(
emitYamlFlowMap([
['contextTokens', 16384],
['free', true],
]),
).toBe('{ contextTokens: 16384, free: true }')
})
})
describe('emitYamlDocument', () => {
it('renders nested maps at 2-space-per-level indent, matching middleware.yaml exactly', () => {
const doc = emitYamlDocument({
spec: {
plugin: {
llmgateway: {
admin: { enabled: true },
},
},
},
})
expect(doc).toBe(['spec:', ' plugin:', ' llmgateway:', ' admin:', ' enabled: true'].join('\n'))
})
it('drops undefined keys entirely (omitempty parity), never emitting them as null', () => {
const doc = emitYamlDocument({ a: 1, b: undefined, c: 'x' })
expect(doc).toBe('a: 1\nc: x')
})
it('renders an explicitly-empty nested map as "key: {}", not omitted', () => {
const doc = emitYamlDocument({ cache: {} })
expect(doc).toBe('cache: {}')
})
it('renders an empty string-array field as "[]" inline, matching middleware.yaml\'s groups.home block (every field explicit and empty, not a bare "{}")', () => {
const doc = emitYamlDocument({
home: { providers: [], models: [], mcpServers: [], agents: [] },
})
expect(doc).toBe(['home:', ' providers: []', ' models: []', ' mcpServers: []', ' agents: []'].join('\n'))
})
// Defensive-only path: this schema never actually produces an array of
// objects, but a generic render of an unforeseen server shape must not
// throw.
it('falls back to block "-" items with JSON.stringify per element for an array containing a non-primitive (defensive, never hit by this schema)', () => {
const doc = emitYamlDocument({ weird: [{ a: 1 }] })
expect(doc).toBe('weird:\n - {"a":1}')
})
})
describe('emitConfigSnippet: matches middleware.yaml\'s own indent depth exactly', () => {
it('wraps a group\'s access-list body under spec.plugin.llmgateway.groups.<name> (middleware.yaml groups.home shape)', () => {
const snippet = emitConfigSnippet(['groups', 'home'], {
providers: [],
models: [],
mcpServers: [],
agents: [],
})
expect(snippet).toBe(
[
'spec:',
' plugin:',
' llmgateway:',
' groups:',
' home:',
' providers: []',
' models: []',
' mcpServers: []',
' agents: []',
].join('\n'),
)
})
it('wraps a group\'s limits at one further nesting level', () => {
const snippet = emitConfigSnippet(['groups', 'friends'], { limits: { requestsPerDay: 1000 } })
expect(snippet).toBe(
['spec:', ' plugin:', ' llmgateway:', ' groups:', ' friends:', ' limits:', ' requestsPerDay: 1000'].join(
'\n',
),
)
})
it('wraps a top-level, non-nested section (e.g. admin) with no extra name level', () => {
const snippet = emitConfigSnippet(['admin'], { enabled: true })
expect(snippet).toBe(['spec:', ' plugin:', ' llmgateway:', ' admin:', ' enabled: true'].join('\n'))
})
})
describe('toYamlValue', () => {
it('passes primitives through unchanged', () => {
expect(toYamlValue('x')).toBe('x')
expect(toYamlValue(5)).toBe(5)
expect(toYamlValue(true)).toBe(true)
expect(toYamlValue(null)).toBe(null)
})
it('recurses through nested arrays and objects', () => {
expect(toYamlValue({ a: [1, { b: 'c' }] })).toEqual({ a: [1, { b: 'c' }] })
})
it('never throws on an unexpected type, falling back to String()', () => {
const weird = (() => 1) as unknown
expect(toYamlValue(weird)).toBe(String(weird))
})
})
+260
View File
@@ -0,0 +1,260 @@
// Config page (redesign-plan.md section 3.4): a deterministic, dependency-
// free YAML emitter. Two jobs share it:
// 1. ConfigTree.vue renders the FULL redacted config (GET /admin/api/
// config's arbitrary Record<string, unknown>) as read-only YAML —
// emitYamlDocument, via toYamlValue's defensive unknown->YamlValue cast.
// 2. lib/snippets.ts builds small, targeted "paste this into middleware.
// yaml" fragments (emitConfigSnippet) for the ChangeHelper forms —
// GOLDEN-STRING tested against the real production file (home-cluster
// namespaces/traefik/base/llmgateway/middleware.yaml), never guessed.
//
// Kept intentionally narrow: this schema's own config never nests an array
// of objects (every ProviderConfig/GroupConfig/LimitsConfig/RedisConfig/
// RetryConfig/CacheConfig/AdminConfig field that is a list is a list of
// strings — providers/models/mcpServers/agents/passthroughPaths), so this
// module does not attempt general-purpose YAML (anchors, multiline scalars,
// comments) — only what this one config shape, and its own redacted mirror,
// ever produce.
/** YamlPrimitive is one leaf value — a plain scalar, matching every non-container field this config's Go structs carry (strings, numbers, bools, or Go's untyped nil for an omitted pointer). */
export type YamlPrimitive = string | number | boolean | null
/** YamlValue is any value this emitter can render: a primitive, a list of primitives, or a nested map. `undefined` on a map's own value is the one non-YAML sentinel this module recognizes — it drops that key entirely (mirrors Go's `omitempty`), never emitted as `null`. */
export type YamlValue = YamlPrimitive | YamlValue[] | { [key: string]: YamlValue | undefined }
const INDENT_UNIT = ' '
/**
* SAFE_UNQUOTED_PATTERN is the whitelist every plain (unquoted) YAML
* scalar this module emits — key OR value, block OR flow context — must
* satisfy: a leading letter or underscore, then any run of letters,
* digits, `.`, `_`, or `-`. This is deliberately far narrower than what
* YAML 1.2 actually permits unquoted (this module's own top-of-file doc
* comment: "only what this one config shape ... ever produces"), so every
* structurally significant character this schema's real values could
* ever contain — a space, `:`, `,`, `[`, `]`, `{`, `}`, `#`, a quote, a
* leading digit, or a leading special character — falls OUTSIDE it and
* gets quoted, unconditionally, in BOTH the two places this module
* renders a scalar: a block "key: value" line (renderMapLines) and a
* flow list/map element (emitYamlFlowList/emitYamlFlowMap) — the same
* needsQuoting call backs both emitYamlKey and emitYamlScalar below, so
* neither can drift from the other's rule (P2 item 11's own fix
* instruction: "apply the same rule to keys").
*
* `/` is deliberately NOT in the allowed charset either, even though it
* is not YAML-structurally significant on its own: this codebase's own
* pre-existing golden tests (yamlKeyNeedsQuoting('gx10/current') === true,
* checked against the real production file) already establish that a
* routable "provider/model" id is quoted by this GENERAL rule, not by a
* one-off special case — modelMetaSnippet's own "always quoted" note
* (P2 item 11: "Always quote modelMeta keys") is then true by
* CONSTRUCTION for every real model id (they all contain "/"), not a
* separate mechanism fighting this whitelist.
*
* P2 item 11 (PyYAML-checked): a flow-list glob like `gpt-[45]*`
* (path.Match globs are legitimate config, auth.go:492) previously broke
* parsing because `[`/`]` were only checked as the FIRST character, not
* anywhere in the string — the old scalar rule quoted a LEADING `[` but
* not one in the middle. A comma or `}`/`{` anywhere has the identical
* problem inside this module's own flow-style output. This whitelist
* quotes the whole string whenever ANY of those characters appear, at
* any position — always correct, since a flow-context character is
* unsafe everywhere in a flow scalar, and quoting it in a block context
* too costs nothing (`"literal"` is valid wherever `literal` was).
*/
const SAFE_UNQUOTED_PATTERN = /^[A-Za-z_][A-Za-z0-9._-]*$/
/**
* RESERVED_WORDS is every bare word SAFE_UNQUOTED_PATTERN would otherwise
* let through unquoted (it is made entirely of letters, so the pattern's
* charset alone cannot exclude it) but that a YAML parser resolves to a
* non-string type instead of the literal string it looks like. This
* config is consumed as a Kubernetes CRD spec field (YAML -> JSON via the
* API machinery's own converter, which follows YAML 1.1-style scalar
* resolution — the same lineage go.yaml.in/yaml/v3's own decode defaults
* sit in), so the broader YAML 1.1 boolean/null set is included
* defensively (on/off, yes/no, not just YAML 1.2's own true/false/null),
* matched case-insensitively since YAML resolution itself is
* case-insensitive for these ("True", "ON", "Null" all resolve the same
* way a parser's plain-scalar resolver would).
*/
const RESERVED_WORDS = new Set(['true', 'false', 'yes', 'no', 'y', 'n', 'on', 'off', 'null', 'nil', 'nan', 'inf'])
/**
* needsQuoting is the ONE predicate this module uses to decide whether a
* plain scalar is safe to emit unquoted — SAFE_UNQUOTED_PATTERN first (a
* fast, purely structural check that also independently catches every
* number-looking string this schema ever produces: `0x10`, `1e3`,
* `2026-01-01`, `12:30`, `.inf`, and a bare `~` all contain a character
* outside the whitelist, or start with a digit/`.`/`~` rather than a
* letter/underscore, so none of them need their own separate numeric
* check), then RESERVED_WORDS for the purely-alphabetic words the
* pattern alone cannot rule out.
*/
function needsQuoting(s: string): boolean {
if (!SAFE_UNQUOTED_PATTERN.test(s)) return true
return RESERVED_WORDS.has(s.toLowerCase())
}
/** yamlKeyNeedsQuoting reports whether a mapping key needs JSON-style double-quoting to round-trip as the literal string it is — the same needsQuoting rule emitYamlScalar applies to values below (P2 item 11: keys and values share one rule, not two independently-drifting ones). A modelMeta/pricing "provider/model" key always trips this (contains "/"), matching the production file's own convention of quoting every routable id. */
export function yamlKeyNeedsQuoting(key: string): boolean {
return needsQuoting(key)
}
/**
* YAML_UNSAFE_CONTROL_PATTERN matches every character JSON.stringify's own
* quoting leaves as a literal byte/code unit but that a YAML 1.1 scanner
* (this config's consumer: Kubernetes' CRD machinery, go.yaml.in/yaml/v3 —
* needsQuoting's own doc comment) does not accept literally inside a
* double-quoted scalar (N6, verify-redesign-final.md — checked against
* PyYAML and go-yaml v3 across 346 round-trip cases):
* - U+0085 (NEL) and the rest of the C1 control range U+0080-U+009F —
* NEL is itself a YAML 1.1 line-break character, folded into a space
* or rejected outright by a scanner mid-key; the surrounding C1 range
* is non-printable by the same spec.
* - U+2028 (LINE SEPARATOR) / U+2029 (PARAGRAPH SEPARATOR) — YAML's other
* two line-break characters.
* - U+007F (DEL) — non-printable, rejected outright by PyYAML.
* JSON already escapes every C0 control (U+0000-U+001F) and the two
* structural characters ('"', '\\'), so this only needs to add the range
* JSON's own grammar has no reason to touch.
*/
const YAML_UNSAFE_CONTROL_PATTERN = new RegExp(
'[\\u007f\\u0080-\\u009f' + String.fromCharCode(0x2028) + String.fromCharCode(0x2029) + ']',
'g',
)
/**
* escapeYamlUnsafeControls post-processes a JSON.stringify'd, already-
* quoted string, replacing every YAML_UNSAFE_CONTROL_PATTERN character
* with a YAML double-quoted escape a scanner accepts literally: `\xHH`
* (YAML's 2-hex-digit, 8-bit escape) for a single-byte code point
* (U+007F, U+0080-U+009F), `\uHHHH` (4-hex-digit, 16-bit escape) for
* U+2028/U+2029. Safe to run over the whole quoted string, including its
* surrounding quotes and any escape sequences JSON.stringify already
* produced: none of those ever contain a raw byte in this pattern's
* range (JSON's own escapes are all plain ASCII), so this only ever
* touches a literal occurrence of one of the target characters.
*/
function escapeYamlUnsafeControls(quoted: string): string {
return quoted.replace(YAML_UNSAFE_CONTROL_PATTERN, (ch) => {
const code = ch.codePointAt(0) as number
return code <= 0xff ? `\\x${code.toString(16).padStart(2, '0')}` : `\\u${code.toString(16).padStart(4, '0')}`
})
}
/** emitYamlKey renders one mapping key — quoted (JSON-style, valid YAML double-quoting) only when yamlKeyNeedsQuoting says so, plain otherwise. */
export function emitYamlKey(key: string): string {
return yamlKeyNeedsQuoting(key) ? escapeYamlUnsafeControls(JSON.stringify(key)) : key
}
/** emitYamlScalar renders one leaf value in "key: <here>" position OR a flow list/map element — quoted (JSON-style) whenever needsQuoting says the plain form is unsafe, plain otherwise. Booleans/numbers/null render via their own JS literal, never through the string quoting path (a real `true`/`false`/`null`/number is safe unquoted by construction — only a STRING value can accidentally spell one of those). */
export function emitYamlScalar(value: YamlPrimitive): string {
if (value === null) return 'null'
if (typeof value === 'boolean' || typeof value === 'number') return String(value)
return needsQuoting(value) ? escapeYamlUnsafeControls(JSON.stringify(value)) : value
}
/** emitYamlFlowList renders a primitive list as one flow-style line — "[]" empty, "[a, b, c]" otherwise — matching the production file's own short-list convention (middleware.yaml: `providers: []`, `models: ["qwen3.8-max", "qwen3.8-flash"]`). */
export function emitYamlFlowList(values: YamlPrimitive[]): string {
if (values.length === 0) return '[]'
return `[${values.map((v) => emitYamlScalar(v)).join(', ')}]`
}
/** emitYamlFlowMap renders a primitive-valued map as one flow-style line — "{}" empty, "{ k: v, k2: v2 }" otherwise (note the padding spaces inside the braces) — matching modelMeta's own production convention (`{ free: true }`, `{ contextTokens: 16384, free: true }`). */
export function emitYamlFlowMap(entries: [string, YamlPrimitive][]): string {
if (entries.length === 0) return '{}'
return `{ ${entries.map(([k, v]) => `${emitYamlKey(k)}: ${emitYamlScalar(v)}`).join(', ')} }`
}
function isPlainObject(value: YamlValue): value is { [key: string]: YamlValue | undefined } {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isPrimitive(value: YamlValue): value is YamlPrimitive {
return value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
}
/**
* renderMapLines recursively renders a map's own entries as already-
* indented lines, at INDENT_UNIT * depth spaces. A nested map with at
* least one (non-undefined) key opens on its own "key:" line and recurses
* one level deeper; an empty nested map renders inline as "key: {}"; a
* primitive-only array renders inline as one flow list. A defensive
* fallback (block "- " items, JSON.stringify per item) covers an array
* containing anything else — this schema never produces one, but a
* generic render of the server's redacted config must never throw on a
* shape this module did not anticipate.
*/
function renderMapLines(map: { [key: string]: YamlValue | undefined }, depth: number): string[] {
const indent = INDENT_UNIT.repeat(depth)
const lines: string[] = []
for (const [key, value] of Object.entries(map)) {
if (value === undefined) continue
const keyPrefix = `${indent}${emitYamlKey(key)}:`
if (isPrimitive(value)) {
lines.push(`${keyPrefix} ${emitYamlScalar(value)}`)
} else if (Array.isArray(value)) {
if (value.every(isPrimitive)) {
lines.push(`${keyPrefix} ${emitYamlFlowList(value as YamlPrimitive[])}`)
} else {
lines.push(keyPrefix)
for (const item of value) {
lines.push(`${indent}${INDENT_UNIT}- ${isPrimitive(item) ? emitYamlScalar(item) : JSON.stringify(item)}`)
}
}
} else if (isPlainObject(value)) {
const nestedKeys = Object.keys(value).filter((k) => value[k] !== undefined)
if (nestedKeys.length === 0) {
lines.push(`${keyPrefix} {}`)
} else {
lines.push(keyPrefix)
lines.push(...renderMapLines(value, depth + 1))
}
}
}
return lines
}
/** emitYamlDocument renders a top-level map as block-style YAML, starting at zero indent — ConfigTree.vue's own top-level call against the whole redacted config. */
export function emitYamlDocument(value: { [key: string]: YamlValue | undefined }, depth = 0): string {
return renderMapLines(value, depth).join('\n')
}
/**
* emitConfigSnippet wraps `body` under the fixed "spec.plugin.llmgateway.
* <...sectionPath>" prefix every ChangeHelper YAML snippet shares
* (redesign-plan.md section 3.4: "emits YAML nested at spec.plugin.
* llmgateway.<section>") — e.g. sectionPath ["groups", "eng"] with body
* {limits: {...}} renders the group's limits block at exactly the depth
* the production file itself uses (spec:0 plugin:1 llmgateway:2 groups:3
* eng:4 limits:5 <fields>:6 — 0/2/4/6/8/10/12 spaces).
*/
export function emitConfigSnippet(sectionPath: string[], body: { [key: string]: YamlValue | undefined }): string {
let wrapped: { [key: string]: YamlValue | undefined } = body
const fullPath = ['spec', 'plugin', 'llmgateway', ...sectionPath]
for (let i = fullPath.length - 1; i >= 0; i--) {
wrapped = { [fullPath[i]]: wrapped }
}
return emitYamlDocument(wrapped)
}
/**
* toYamlValue defensively converts an arbitrary JSON value (GET /admin/api/
* config's already-redacted `config: Record<string, unknown>`) into a
* YamlValue this module can render — recursing through plain objects and
* arrays, passing primitives through unchanged, and falling back to
* `String(input)` for anything else (a function, a Symbol — never actually
* present in a JSON-decoded response, but this must not throw even if the
* server's shape someday surprises it).
*/
export function toYamlValue(input: unknown): YamlValue {
if (input === null || typeof input === 'string' || typeof input === 'number' || typeof input === 'boolean') return input
if (Array.isArray(input)) return input.map(toYamlValue)
if (typeof input === 'object') {
const out: { [key: string]: YamlValue } = {}
for (const [k, v] of Object.entries(input as Record<string, unknown>)) out[k] = toYamlValue(v)
return out
}
return String(input)
}
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import ChangeHelper from '@/components/ChangeHelper.vue'
import ConfigTree from '@/components/ConfigTree.vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { useConfigStore } from '@/stores/config'
import { useConsumersStore } from '@/stores/consumers'
import { useDashboardStore } from '@/stores/dashboard'
/**
* ConfigPage (redesign-plan.md section 3.4) composes the redacted config
* tree (ConfigTree.vue, GET /admin/api/config, fetched on demand) above
* the change helper (ChangeHelper.vue's five snippet-building forms) —
* group/user names for those forms' target pickers come from the already-
* polled dashboard store (overview.groups) and the on-demand consumers
* store (users), so this page fetches config once and reuses whatever the
* other two stores already have rather than a third redundant call.
*/
const config = useConfigStore()
const dashboard = useDashboardStore()
const consumers = useConsumersStore()
onMounted(() => {
void config.ensureConfig()
void consumers.ensureConsumers()
})
const groupNames = computed(() => (dashboard.overview?.groups ?? []).map((g) => g.name))
const userNames = computed(() => (consumers.data?.users ?? []).map((u) => u.name))
</script>
<template>
<div class="flex flex-col gap-6">
<Card v-if="config.error">
<CardHeader>
<CardTitle>Config</CardTitle>
<CardDescription class="text-destructive">{{ config.error }}</CardDescription>
</CardHeader>
</Card>
<p v-else-if="config.loading && !config.data" class="text-sm text-muted-foreground">loading…</p>
<ConfigTree v-else-if="config.data" :config="config.data.config" :warnings="config.data.warnings" />
<Card>
<CardHeader>
<CardTitle>Change helper</CardTitle>
<CardDescription>Build a paste-ready snippet for middleware.yaml or users.json — nothing here writes to the running config.</CardDescription>
</CardHeader>
<CardContent>
<ChangeHelper :group-names="groupNames" :user-names="userNames" />
</CardContent>
</Card>
</div>
</template>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { computed } from 'vue'
import AccessMatrix from '@/components/AccessMatrix.vue'
import ConsumerDirectory from '@/components/ConsumerDirectory.vue'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import UserDetail from '@/components/UserDetail.vue'
import { useNavStore } from '@/stores/nav'
/**
* ConsumersPage (redesign-plan.md section 3.4) is the Consumers page's own
* router: `user=<name>` (set by an EntityLink kind="user" click anywhere in
* the panel) opens UserDetail; otherwise `view=directory|matrix` (default
* directory) switches between ConsumerDirectory and AccessMatrix. Both
* params live in nav.params (stores/nav.ts's own opaque per-page record),
* never a private local ref — a bookmarked/shared #consumers?user=... link
* must restore the same view.
*/
const nav = useNavStore()
const userParam = computed(() => nav.params.user)
const view = computed<'directory' | 'matrix'>(() => (nav.params.view === 'matrix' ? 'matrix' : 'directory'))
function setView(next: unknown): void {
if (next !== 'directory' && next !== 'matrix') return
const { user: _user, ...rest } = nav.params
nav.goTo('consumers', { ...rest, view: next })
}
function closeUserDetail(): void {
const { user: _user, ...rest } = nav.params
nav.goTo('consumers', rest)
}
</script>
<template>
<UserDetail v-if="userParam" :user-id="userParam" @close="closeUserDetail" />
<div v-else class="flex flex-col gap-4">
<Tabs :model-value="view" @update:model-value="setView">
<TabsList>
<TabsTrigger value="directory">Directory</TabsTrigger>
<TabsTrigger value="matrix">Access matrix</TabsTrigger>
</TabsList>
</Tabs>
<ConsumerDirectory v-if="view === 'directory'" />
<AccessMatrix v-else />
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More