mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
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:
+182
-8
File diff suppressed because one or more lines are too long
+49
-64
@@ -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 — 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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+110
-219
@@ -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 "{{ modelQuery }}"
|
||||
</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"
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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). */
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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)])),
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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%')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()}`
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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(', ')}}`
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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', ...)`).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user