mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-13 04:58:50 +00:00
feat(webui): add limit bars, forecasts, events, exports and URL state
The Usage tab shows a bar for each configured limit. It turns amber at 80% and red at 100%. The tab also shows rejected requests per day, a projected month-end cost with budget headroom, and CSV export for users and groups. Names link to Charts with the matching scope. The Charts tab ranks models over the full 24h, 30d or 12mo window. A provider filter is applied on the server, and the ranking can be exported as CSV. A new Events tab lists recent failures, filtered by kind and user. The selected tab, filters and scope live in the URL hash. Polling stops while the page is hidden. Views show which replica answered, and a banner lists configuration warnings. CSV cells that start with a formula character are neutralised. Links sit outside the accordion buttons, so both controls keep their accessible names. admin_assets_gen.go is regenerated. Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
+8
-8
File diff suppressed because one or more lines are too long
+43
-17
@@ -4,14 +4,17 @@ 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 { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import UsageView from '@/components/UsageView.vue'
|
||||
import { useTabHash } from '@/composables/useTabHash'
|
||||
import { useHashState } from '@/composables/useHashState'
|
||||
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
|
||||
@@ -25,21 +28,24 @@ const GITHUB_REPO_URL = 'https://github.com/lukaszraczylo/traefik-llmgateway'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const dashboard = useDashboardStore()
|
||||
const nav = useNavStore()
|
||||
|
||||
// The tab is labeled "Providers" (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.
|
||||
const TAB_VALUES = ['providers', 'usage', 'charts', 'targets'] as const
|
||||
type TabValue = (typeof TAB_VALUES)[number]
|
||||
// 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.
|
||||
|
||||
// Feature C (v0.22): activeView is now hash-backed (composables/
|
||||
// useTabHash.ts) instead of a bare ref — same v-model contract Tabs
|
||||
// already expects, so nothing else about this template changes. AuthGate's
|
||||
// own flow is untouched: it never reads or writes the hash, so whatever
|
||||
// tab the URL named survives straight through the unauthenticated ->
|
||||
// authenticated transition with no extra wiring here.
|
||||
const activeView = useTabHash<TabValue>({ validTabs: TAB_VALUES, defaultTab: 'providers' })
|
||||
// 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.
|
||||
useHashState()
|
||||
|
||||
// statusText reads dashboard.error/lastUpdated unconditionally — it is
|
||||
// correct only while the template's own `v-if="auth.isAuthenticated"` below
|
||||
@@ -47,9 +53,18 @@ const activeView = useTabHash<TabValue>({ validTabs: TAB_VALUES, defaultTab: 'pr
|
||||
// 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 —
|
||||
// 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).
|
||||
const statusText = computed<string>(() => {
|
||||
if (dashboard.error) return `refresh failed: ${dashboard.error}`
|
||||
if (dashboard.lastUpdated) return `last updated ${dashboard.lastUpdated.toLocaleTimeString()}`
|
||||
if (dashboard.lastUpdated) {
|
||||
const replica = dashboard.overview?.replica
|
||||
return `last updated ${dashboard.lastUpdated.toLocaleTimeString()}${replica ? ` · replica ${replica}` : ''}`
|
||||
}
|
||||
return 'loading...'
|
||||
})
|
||||
|
||||
@@ -77,19 +92,27 @@ onMounted(() => {
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="auth.isAuthenticated" class="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<FontAwesomeIcon v-if="dashboard.error" :icon="faCircleExclamation" class="size-3.5 text-destructive" />
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<!-- F10: 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 ?? []"
|
||||
:warnings-dropped="dashboard.overview?.warningsDropped"
|
||||
/>
|
||||
|
||||
<AuthGate v-if="!auth.isAuthenticated" />
|
||||
|
||||
<Tabs v-else v-model="activeView">
|
||||
<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 />
|
||||
@@ -103,6 +126,9 @@ onMounted(() => {
|
||||
<TabsContent value="targets">
|
||||
<TargetsView />
|
||||
</TabsContent>
|
||||
<TabsContent value="events">
|
||||
<EventsView />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
--color-chart-tokens-out: var(--chart-tokens-out);
|
||||
--color-chart-cost: var(--chart-cost);
|
||||
--color-status-warn: var(--status-warn);
|
||||
--color-bar-amber: var(--bar-amber);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
@@ -109,6 +110,27 @@
|
||||
* light --card measures ~5.06:1.
|
||||
*/
|
||||
--status-warn: #a35c00;
|
||||
|
||||
/*
|
||||
* --bar-amber: F1 (usage-vs-limit bars, UsageBar.vue) — the filled
|
||||
* amber segment of a budget bar at BAR_AMBER_THRESHOLD (lib/usage-
|
||||
* bars.ts). The track it fills against is --muted (UsageBar.vue's
|
||||
* `bg-muted` on the bar's own background span) — WCAG 1.4.11 non-text
|
||||
* contrast requires 3:1 against that ADJACENT color.
|
||||
*
|
||||
* P8 review fix: this used to match --chart-tokens-out's hue/value
|
||||
* (#e69f00) on the reasoning that it was "already proven legible at
|
||||
* small UI sizes on both --card and --muted" — that reasoning was
|
||||
* never actually checked against --muted specifically, and #e69f00
|
||||
* measures (WCAG relative-luminance formula, oklch(0.95 0.006 75)
|
||||
* --muted as the track) 1.95:1 there, well under the 3:1 this comment
|
||||
* itself claimed. Reusing --status-warn's own light-mode value
|
||||
* (#a35c00) instead measures 4.44:1 against --muted — comfortably
|
||||
* clears 3:1, and keeps every semantic amber in this panel visually
|
||||
* consistent (same value already used for the "degraded" badge tier,
|
||||
* ProviderRateBadge.vue) rather than adding a third amber shade.
|
||||
*/
|
||||
--bar-amber: #a35c00;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@@ -147,6 +169,9 @@
|
||||
reader auditing this block sees every semantic-color token's
|
||||
dark value in one place. */
|
||||
--status-warn: #f0ac1f;
|
||||
|
||||
/* Same reasoning as --chart-tokens-out's own dark-mode comment above — legible as-is, redefined here anyway so every semantic-color token's dark value lives in one place. */
|
||||
--bar-amber: #f0ac1f;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<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,
|
||||
@@ -11,19 +13,23 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import CsvExportButton from '@/components/CsvExportButton.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 { modelRankingCsv } from '@/lib/usage-csv'
|
||||
import { groupMatches, userMatches } from '@/lib/usage-search'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import {
|
||||
type ChartTab,
|
||||
MODEL_METRIC_LABEL,
|
||||
MODEL_WINDOW_LABEL,
|
||||
MODEL_RANKING_LIMIT,
|
||||
type ModelMetric,
|
||||
type TimeSeriesTab,
|
||||
useHistoryStore,
|
||||
WINDOW_LABEL,
|
||||
WINDOW_SPAN,
|
||||
} from '@/stores/history'
|
||||
import type { HistoryWindow } from '@/types/api'
|
||||
|
||||
@@ -81,15 +87,40 @@ const scopeOptions = computed(() => {
|
||||
const windows: HistoryWindow[] = ['hour', 'day', 'month']
|
||||
const modelMetrics: ModelMetric[] = ['cost', 'req', 'tokin', 'tokout']
|
||||
|
||||
// --- 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))
|
||||
|
||||
/**
|
||||
* windowLabel picks the right window-tab text for whichever query is
|
||||
* actually active: the Models tab reads a single current bucket (review
|
||||
* finding — MODEL_WINDOW_LABEL's own doc comment, stores/history.ts), the
|
||||
* other three tabs read a real span (WINDOW_LABEL). Same Tabs control
|
||||
* either way — only the label changes.
|
||||
* Exports exactly the ranking rows on screen (filter applied, server order
|
||||
* kept). P6 review fix: passes the resolved DISPLAY label constants
|
||||
* (MODEL_METRIC_LABEL/WINDOW_LABEL), not the raw enum values
|
||||
* ("cost"/"hour") modelRankingCsv's own doc comment always said the caller
|
||||
* should — the header used to read the literal "cost (hour, span 24)"
|
||||
* with no stated unit. `isCostMetric` states whether the CURRENTLY ranked
|
||||
* metric is cost, so modelRankingCsv can convert the raw micro-USD `value`
|
||||
* to a plain decimal USD number and name the unit in the header.
|
||||
*/
|
||||
function windowLabel(window: HistoryWindow): string {
|
||||
return history.tab === 'models' ? MODEL_WINDOW_LABEL[window] : WINDOW_LABEL[window]
|
||||
function modelsCsv(): string {
|
||||
return modelRankingCsv(
|
||||
filteredModelRanking.value,
|
||||
MODEL_METRIC_LABEL[history.modelMetric],
|
||||
WINDOW_LABEL[history.window],
|
||||
WINDOW_SPAN[history.window],
|
||||
history.modelMetric === 'cost',
|
||||
)
|
||||
}
|
||||
|
||||
function clearModelFilter(): void {
|
||||
history.setModelFilter('')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,7 +142,13 @@ function onModelMetricChange(value: unknown): void {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void history.refresh()
|
||||
// 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(() => {
|
||||
@@ -120,7 +157,16 @@ onUnmounted(() => {
|
||||
|
||||
// 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.
|
||||
// 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')
|
||||
@@ -169,7 +215,7 @@ watch(scopeOptions, (options) => {
|
||||
|
||||
<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">{{ windowLabel(w) }}</TabsTrigger>
|
||||
<TabsTrigger v-for="w in windows" :key="w" :value="w">{{ WINDOW_LABEL[w] }}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -189,11 +235,25 @@ watch(scopeOptions, (options) => {
|
||||
<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 in the selected window, ranked by
|
||||
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>
|
||||
<ModelUsageChart :metric="history.modelMetric" :models="history.modelRanking" />
|
||||
<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" />
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
/**
|
||||
* ConfigWarningsBanner surfaces GET /admin/api/overview's `warnings`/
|
||||
* `warningsDropped` (F10, dashboard-plan.md — promoted warnf calls
|
||||
* collected during newGateway's own construction window: metrics.path
|
||||
* collisions, a zero cache ttl, multi-provider registry ids, retry x
|
||||
* failover). Renders nothing at all for an empty `warnings` array — App.vue
|
||||
* only mounts this under the header when there is something to show, but
|
||||
* the component stays self-guarding too so it is safe to render
|
||||
* unconditionally.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
warnings: string[]
|
||||
/** Warnings logged beyond the server's own cap (configWarningsCap, llmgateway.go) that were dropped, not kept — omitted (undefined) entirely when nothing was dropped. */
|
||||
warningsDropped?: number
|
||||
}>()
|
||||
|
||||
/** Collapsed by default: the header/status line already reads cleanly, and a config warning is something an operator investigates once, not something that needs to stay open on every visit. */
|
||||
const expanded = ref(false)
|
||||
|
||||
function toggleExpanded(): void {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Alert v-if="props.warnings.length > 0" variant="warn">
|
||||
<FontAwesomeIcon :icon="faTriangleExclamation" class="size-4" aria-hidden="true" />
|
||||
<AlertTitle class="flex items-center justify-between gap-2">
|
||||
<span>{{ props.warnings.length }} configuration {{ props.warnings.length === 1 ? 'warning' : 'warnings' }}</span>
|
||||
<!--
|
||||
P11 review fix (WCAG 2.5.3 Label in Name): the aria-label used to
|
||||
be the fixed text "Toggle configuration warning details", which
|
||||
shares NO words with the visible "Show"/"Hide" label a sighted
|
||||
reader sees — a speech-input user saying "click show" would not
|
||||
match this control's accessible name at all. The aria-label now
|
||||
STARTS WITH the same word the button visibly displays, so the
|
||||
accessible name always contains the visible label.
|
||||
-->
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
:aria-expanded="expanded"
|
||||
:aria-label="`${expanded ? 'Hide' : 'Show'} configuration warning details`"
|
||||
@click="toggleExpanded"
|
||||
>
|
||||
{{ expanded ? 'Hide' : 'Show' }}
|
||||
</Button>
|
||||
</AlertTitle>
|
||||
<AlertDescription v-if="expanded">
|
||||
<ul class="mt-1 list-disc space-y-0.5 pl-4 font-mono text-xs">
|
||||
<li v-for="(warning, i) in props.warnings" :key="i">{{ warning }}</li>
|
||||
</ul>
|
||||
<p v-if="props.warningsDropped" class="mt-1 text-xs">and {{ props.warningsDropped }} more were not kept</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { headroom, monthProgress, projectMonthEnd } from '@/lib/forecast'
|
||||
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.
|
||||
*
|
||||
* `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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** usage.total.costPerMonthMicroUsd — fleet-wide month-to-date spend. */
|
||||
mtdMicros: number
|
||||
/** usage.total.limits — costPerMonthUSD is the only field this card reads. */
|
||||
limits?: LimitsConfig
|
||||
/** The shared reactive clock (composables/useNow.ts) — never defaulted here, see this comment block above. */
|
||||
now: Date
|
||||
}>()
|
||||
|
||||
const elapsedFraction = computed(() => monthProgress(props.now))
|
||||
const projectedMicros = computed(() => projectMonthEnd(props.mtdMicros, elapsedFraction.value))
|
||||
const forecast = computed(() => headroom(projectedMicros.value, props.limits?.costPerMonthUSD))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Cost forecast</CardTitle>
|
||||
<CardDescription>Projected month-end spend, extrapolated from month-to-date usage (UTC calendar month).</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<p class="text-muted-foreground">Month to date</p>
|
||||
<p class="text-lg font-semibold tabular-nums">{{ formatCost(mtdMicros) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Projected month-end</p>
|
||||
<p v-if="projectedMicros === null" class="text-sm text-muted-foreground">not enough data yet</p>
|
||||
<p
|
||||
v-else
|
||||
class="text-lg font-semibold tabular-nums"
|
||||
:class="forecast.willExceed ? 'text-destructive' : undefined"
|
||||
:title="forecast.willExceed ? 'Projected to exceed the configured cost/month limit' : undefined"
|
||||
>
|
||||
{{ formatCost(projectedMicros) }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="forecast.limitMicros !== null">
|
||||
<p class="text-muted-foreground">Limit</p>
|
||||
<p class="text-lg font-semibold tabular-nums">{{ formatCost(forecast.limitMicros) }}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { faCircleCheck, faCircleXmark, faFileCsv } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
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 modelRankingCsv 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").
|
||||
*
|
||||
* `build` is called fresh on every click, never memoized — an export
|
||||
* always reflects whatever rows are CURRENTLY visible (filtered + sorted)
|
||||
* at click time, matching the coordinator brief ("exports filtered+sorted
|
||||
* rows"), never a stale snapshot captured at mount.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** The downloaded file's name, e.g. "usage-users.csv". */
|
||||
filename: string
|
||||
/** Builds the CSV text (lib/csv.ts's toCsv, via usage-csv.ts) on demand. */
|
||||
build: () => string
|
||||
}>()
|
||||
|
||||
/**
|
||||
* exporting guards a rapid double-click from firing two downloads (or two
|
||||
* copyText fallback prompts) back to back — downloadText (lib/download.ts)
|
||||
* is async only because its clipboard fallback is, so this window is
|
||||
* normally imperceptibly short, but a real one regardless.
|
||||
*/
|
||||
const exporting = ref(false)
|
||||
|
||||
/** P5 review fix: downloadText's own return value used to be discarded entirely — a failed export (or a silent clipboard/selection fallback) looked identical to a successful one. `feedback` holds the most recent outcome; the label/icon below reflect it for FEEDBACK_MS, then the button reverts to its normal resting state. */
|
||||
const feedback = ref<DownloadOutcome | null>(null)
|
||||
const FEEDBACK_MS = 2500
|
||||
let feedbackTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const FEEDBACK_LABEL: Record<DownloadOutcome, string> = {
|
||||
downloaded: 'Downloaded',
|
||||
copied: 'Copied',
|
||||
selected: 'Selected — press ⌘/Ctrl+C',
|
||||
failed: 'Export failed',
|
||||
}
|
||||
|
||||
const label = computed(() => (feedback.value ? FEEDBACK_LABEL[feedback.value] : 'Export CSV'))
|
||||
const isFailure = computed(() => feedback.value === 'failed')
|
||||
|
||||
function clearFeedbackAfterDelay(): void {
|
||||
if (feedbackTimer !== undefined) clearTimeout(feedbackTimer)
|
||||
feedbackTimer = setTimeout(() => {
|
||||
feedback.value = null
|
||||
feedbackTimer = undefined
|
||||
}, FEEDBACK_MS)
|
||||
}
|
||||
|
||||
async function onClick(): Promise<void> {
|
||||
if (exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
feedback.value = await downloadText(props.filename, props.build())
|
||||
clearFeedbackAfterDelay()
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (feedbackTimer !== undefined) clearTimeout(feedbackTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button type="button" variant="outline" size="sm" :disabled="exporting" @click="onClick">
|
||||
<FontAwesomeIcon v-if="feedback === 'failed'" :icon="faCircleXmark" class="size-3.5 text-destructive" aria-hidden="true" />
|
||||
<FontAwesomeIcon
|
||||
v-else-if="feedback"
|
||||
:icon="faCircleCheck"
|
||||
class="size-3.5 text-chart-requests"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<FontAwesomeIcon v-else :icon="faFileCsv" class="size-3.5" aria-hidden="true" />
|
||||
<span :class="isFailure ? 'text-destructive' : undefined">{{ label }}</span>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
valueUpdater,
|
||||
} from '@/components/ui/table'
|
||||
|
||||
/**
|
||||
@@ -39,9 +38,24 @@ const props = defineProps<{
|
||||
rowClass?: (row: TData) => string | undefined
|
||||
/** Shown in the empty-state row when data is empty. */
|
||||
emptyMessage?: string
|
||||
/**
|
||||
* Optional CONTROLLED sort state (P6, `v-model:sorting`). Omitted (the
|
||||
* 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
|
||||
* filtered+sorted rows" requirement) instead owns the ref: every header
|
||||
* click emits `update:sorting` with the new state rather than mutating
|
||||
* anything locally here.
|
||||
*/
|
||||
sorting?: SortingState
|
||||
}>()
|
||||
|
||||
const sorting = ref<SortingState>([])
|
||||
const emit = defineEmits<{ 'update:sorting': [value: SortingState] }>()
|
||||
|
||||
/** internalSorting is read only when props.sorting is undefined (uncontrolled mode) — see the `sorting` prop's own doc comment above. */
|
||||
const internalSorting = ref<SortingState>([])
|
||||
|
||||
const table = useVueTable({
|
||||
get data() {
|
||||
@@ -52,10 +66,15 @@ const table = useVueTable({
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onSortingChange: (updater) => valueUpdater(updater, sorting),
|
||||
onSortingChange: (updater) => {
|
||||
const current = props.sorting ?? internalSorting.value
|
||||
const next = typeof updater === 'function' ? updater(current) : updater
|
||||
if (props.sorting !== undefined) emit('update:sorting', next)
|
||||
else internalSorting.value = next
|
||||
},
|
||||
state: {
|
||||
get sorting() {
|
||||
return sorting.value
|
||||
return props.sorting ?? internalSorting.value
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
import DataTable from '@/components/DataTable.vue'
|
||||
import SearchInput from '@/components/SearchInput.vue'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { useSearchQuery } from '@/composables/useSearchQuery'
|
||||
import { eventsColumns } from '@/lib/events-columns'
|
||||
import { EVENT_KIND_LABEL, EVENT_KINDS, filterEvents } from '@/lib/events-filter'
|
||||
import { useEventsStore } from '@/stores/events'
|
||||
|
||||
// This component backs the new "Events" tab (App.vue, F3) — GET
|
||||
// /admin/api/events (admin.go/events.go: adminEventsResponse). Polled only
|
||||
// while this view is mounted (onMounted/onUnmounted below), unlike the
|
||||
// Providers/Usage/Targets dashboard poll which runs for the app's whole
|
||||
// session — see stores/events.ts's own doc comment.
|
||||
const events = useEventsStore()
|
||||
|
||||
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.
|
||||
const { kindFilter, userFilter } = storeToRefs(events)
|
||||
const { normalized: normalizedUserQuery } = useSearchQuery(userFilter)
|
||||
|
||||
/** kindSelectValue adapts store's '' ("every kind") to the Select's own ALL_KINDS sentinel — Radix Select items cannot use an empty-string value. */
|
||||
const kindSelectValue = computed(() => (kindFilter.value === '' ? ALL_KINDS : kindFilter.value))
|
||||
|
||||
const filteredEvents = computed(() =>
|
||||
filterEvents(events.events, {
|
||||
kind: kindFilter.value,
|
||||
user: normalizedUserQuery.value,
|
||||
}),
|
||||
)
|
||||
|
||||
function onKindChange(value: unknown): void {
|
||||
if (typeof value !== 'string') return
|
||||
events.setKindFilter(value === ALL_KINDS ? '' : value)
|
||||
}
|
||||
|
||||
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".
|
||||
*
|
||||
* P9 review fix: gated on events.lastUpdated (null until the first fetch
|
||||
* settles, stores/events.ts) — this used to read events.events.length
|
||||
* directly, which is an empty array from the store's OWN initial state,
|
||||
* before any fetch has even started. That produced "no events recorded"
|
||||
* for a fraction of a second (or longer, on a slow poll) on every mount,
|
||||
* indistinguishable from the real "genuinely nothing has ever happened"
|
||||
* case — a false negative, not a loading state.
|
||||
*/
|
||||
const emptyMessage = computed(() => {
|
||||
if (!events.lastUpdated) return 'loading…'
|
||||
if (events.events.length === 0) return 'no events recorded'
|
||||
if (kindFilter.value !== '' || normalizedUserQuery.value) return 'no events match the current filter'
|
||||
return 'none'
|
||||
})
|
||||
|
||||
/**
|
||||
* sourceCaption states plainly whether the list below is fleet-wide or
|
||||
* this-replica-only — the Events view must never present a replica-only
|
||||
* fallback as if it were the fleet-wide picture.
|
||||
*
|
||||
* P9 review fix: gated on events.lastUpdated, mirroring emptyMessage's own
|
||||
* doc comment above — the store's initial state (source: 'replica',
|
||||
* replica: '', capacity: 0) otherwise rendered as "This replica only
|
||||
* (unknown) — Redis is unreachable or unconfigured" before the first
|
||||
* fetch had even resolved, misreporting a genuine degraded-Redis state
|
||||
* that had not actually been observed yet.
|
||||
*/
|
||||
const sourceCaption = computed(() => {
|
||||
if (!events.lastUpdated) return 'loading…'
|
||||
if (events.source === 'redis') return 'Fleet-wide, across every replica'
|
||||
return `This replica only (${events.replica || 'unknown'}) — Redis is unreachable or unconfigured`
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
events.startPolling()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
events.stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Alert v-if="events.degraded" variant="destructive">
|
||||
<FontAwesomeIcon :icon="faTriangleExclamation" class="size-4" aria-hidden="true" />
|
||||
<AlertTitle>Fleet-wide event log unavailable</AlertTitle>
|
||||
<AlertDescription>
|
||||
Redis is configured but could not be read — showing this replica's own in-memory events only, which may miss
|
||||
events recorded by other replicas.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>Events</CardTitle>
|
||||
<!-- P9: a loading state until the first fetch settles (events.lastUpdated), instead of showing the store's initial (pre-fetch) source/capacity as if it were real. -->
|
||||
<CardDescription v-if="!events.lastUpdated">Loading…</CardDescription>
|
||||
<CardDescription v-else>
|
||||
{{ sourceCaption }} — rate limit, budget, and upstream events, newest first (capacity
|
||||
{{ events.capacity }}).
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<SearchInput v-model="userFilter" placeholder="Filter by user or group" class="w-56" />
|
||||
<Select :model-value="kindSelectValue" @update:model-value="onKindChange">
|
||||
<SelectTrigger class="w-44" aria-label="Filter by kind">
|
||||
<SelectValue placeholder="Kind" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="ALL_KINDS">All kinds</SelectItem>
|
||||
<SelectItem v-for="k in EVENT_KINDS" :key="k" :value="k">{{ EVENT_KIND_LABEL[k] }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p v-if="events.error" class="mb-3 text-sm text-destructive">{{ events.error }}</p>
|
||||
<DataTable :columns="columns" :data="filteredEvents" :empty-message="emptyMessage" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,6 +10,7 @@ import { computed, h, reactive, watch } from 'vue'
|
||||
import DataTable from '@/components/DataTable.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 { Badge } from '@/components/ui/badge'
|
||||
@@ -25,6 +26,7 @@ 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 { useNavStore } from '@/stores/nav'
|
||||
import type { AdminAliasView, AdminLatencyView, AdminModelMetaView, AdminModelRateView, AdminProvenanceView, AdminProviderView } from '@/types/api'
|
||||
|
||||
// This component backs the "Providers" tab (App.vue) — a UI-label rename
|
||||
@@ -34,6 +36,31 @@ import type { AdminAliasView, AdminLatencyView, AdminModelMetaView, AdminModelRa
|
||||
// API surface.
|
||||
const dashboard = useDashboardStore()
|
||||
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.
|
||||
*/
|
||||
function goToProviderModels(providerName: string): void {
|
||||
nav.goToModels(`${providerName}/`)
|
||||
}
|
||||
|
||||
// --- provider expand/collapse (shadcn-vue Accordion) ---
|
||||
//
|
||||
@@ -232,23 +259,33 @@ function latencyObservationCount(v: AdminLatencyView): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* PER_REPLICA_CAVEAT is appended to every latency badge's title (task
|
||||
* brief's correctness rule 1): this figure comes from the same
|
||||
* 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.
|
||||
* 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).
|
||||
*/
|
||||
const PER_REPLICA_CAVEAT = 'Per-replica, in-process only — not a fleet-wide average across this deployment’s replicas.'
|
||||
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)}. ${PER_REPLICA_CAVEAT}`
|
||||
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)}. ${PER_REPLICA_CAVEAT}`
|
||||
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) ---
|
||||
@@ -276,13 +313,13 @@ function unbilledProvenance(p: AdminProviderView): AdminProvenanceView | undefin
|
||||
/** 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. ${PER_REPLICA_CAVEAT}`
|
||||
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. ${PER_REPLICA_CAVEAT}`
|
||||
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) ---
|
||||
@@ -331,6 +368,7 @@ const aliasEmptyMessage = computed(() =>
|
||||
:icon="overview?.redis.configured ? faCircleCheck : faCircleXmark"
|
||||
:class="overview?.redis.configured ? 'text-chart-requests' : 'text-muted-foreground'"
|
||||
class="size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Redis
|
||||
</CardTitle>
|
||||
@@ -338,7 +376,7 @@ const aliasEmptyMessage = computed(() =>
|
||||
<CardContent class="flex flex-col gap-1 text-sm">
|
||||
<p class="font-medium">{{ overview?.redis.configured ? 'Configured' : 'Not configured' }}</p>
|
||||
<p v-if="overview?.redis.lastErr" class="flex items-start gap-1.5 text-destructive">
|
||||
<FontAwesomeIcon :icon="faTriangleExclamation" class="mt-0.5 size-3.5 shrink-0" />
|
||||
<FontAwesomeIcon :icon="faTriangleExclamation" class="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span>{{ overview.redis.lastErr }}{{ formatAgo(overview.redis.lastErrAt) }}</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
@@ -351,6 +389,7 @@ const aliasEmptyMessage = computed(() =>
|
||||
:icon="overview?.cache.enabled ? faCircleCheck : faCircleXmark"
|
||||
:class="overview?.cache.enabled ? 'text-chart-requests' : 'text-muted-foreground'"
|
||||
class="size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Response cache
|
||||
</CardTitle>
|
||||
@@ -368,6 +407,7 @@ const aliasEmptyMessage = computed(() =>
|
||||
:icon="overview?.retry.enabled ? faCircleCheck : faCircleXmark"
|
||||
:class="overview?.retry.enabled ? 'text-chart-requests' : 'text-muted-foreground'"
|
||||
class="size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Retry
|
||||
</CardTitle>
|
||||
@@ -398,11 +438,37 @@ const aliasEmptyMessage = computed(() =>
|
||||
>
|
||||
no providers match "{{ modelQuery }}"
|
||||
</p>
|
||||
<Accordion v-else v-model="expandedProviderValues" type="multiple" class="rounded-md border px-3">
|
||||
<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>
|
||||
<Accordion v-model="expandedProviderValues" type="multiple" class="rounded-md border px-3">
|
||||
<AccordionItem v-for="p in filteredProviders" :key="p.name" :value="p.name">
|
||||
<AccordionTrigger>
|
||||
<!--
|
||||
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`"
|
||||
@click="goToProviderModels(p.name)"
|
||||
>{{ p.name }}</button>
|
||||
<AccordionTrigger header-class="flex-1">
|
||||
<span class="sr-only">Provider {{ p.name }} details</span>
|
||||
<span class="flex flex-1 flex-wrap items-center gap-x-3 gap-y-1 pr-2 text-left">
|
||||
<span class="font-medium">{{ p.name }}</span>
|
||||
<Badge as="span" variant="secondary" class="font-normal">{{ p.type }}</Badge>
|
||||
<span class="text-xs text-muted-foreground tabular-nums">{{ p.modelCount }} models</span>
|
||||
<ProviderRateBadge
|
||||
@@ -488,7 +554,8 @@ const aliasEmptyMessage = computed(() =>
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">{{ refreshLabel(p.discoveryEnabled, p.lastRefresh) }}</span>
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
</AccordionTrigger>
|
||||
</div>
|
||||
<AccordionContent>
|
||||
<dl class="mb-3 grid grid-cols-2 gap-x-6 gap-y-1.5 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
@@ -516,6 +583,9 @@ 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
|
||||
@@ -550,6 +620,7 @@ const aliasEmptyMessage = computed(() =>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</template>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<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>
|
||||
@@ -72,7 +72,7 @@ function onClear(): void {
|
||||
class="absolute top-1/2 right-1 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
@click="onClear"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faXmark" class="size-3.5" />
|
||||
<FontAwesomeIcon :icon="faXmark" class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ratioTier } from '@/lib/usage-bars'
|
||||
|
||||
/**
|
||||
* UsageBar renders one BudgetRatio (lib/usage-bars.ts) as a compact
|
||||
* horizontal meter — F1, usage-vs-limit bars. A real `role="meter"`
|
||||
* (not a `<progress>` element: a meter reports a measurement against a
|
||||
* range, which is exactly a used/limit reading, where `<progress>`
|
||||
* implies a task moving toward completion) carries the accessible
|
||||
* reading via `aria-valuenow`/`aria-valuemin`/`aria-valuemax`/
|
||||
* `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).
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** used / limit — may exceed 1 (a scope's usage window can roll past its limit before the next request is rejected). */
|
||||
ratio: number
|
||||
/** The accessible name for this meter, e.g. "req/day" or "cost/month". */
|
||||
label: string
|
||||
/** The human-readable "used of limit" text — both the `title` tooltip and `aria-valuetext`, e.g. "500 / 1,000" or "$1.20 / $5.00". */
|
||||
valueText: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const tier = computed(() => ratioTier(props.ratio))
|
||||
|
||||
/**
|
||||
* widthPct clamps the visible fill (and, via valueNow below, the
|
||||
* announced aria-valuenow) to 100% — a ratio above 1.0 (over the limit)
|
||||
* still reads as a full bar, with the RED tier and valueText's own real
|
||||
* "used / limit" numbers carrying the "how far over" detail instead of an
|
||||
* overflowing bar or an aria-valuenow exceeding its own declared
|
||||
* aria-valuemax (WAI-ARIA meter role: valuenow must stay within
|
||||
* valuemin/valuemax).
|
||||
*/
|
||||
const widthPct = computed(() => Math.min(100, Math.max(0, props.ratio * 100)))
|
||||
const valueNow = computed(() => Math.round(widthPct.value))
|
||||
|
||||
const TIER_FILL_CLASS: Record<ReturnType<typeof ratioTier>, string> = {
|
||||
normal: 'bg-primary',
|
||||
amber: 'bg-bar-amber',
|
||||
red: 'bg-destructive',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
role="meter"
|
||||
:aria-label="label"
|
||||
:aria-valuenow="valueNow"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:aria-valuetext="valueText"
|
||||
:title="valueText"
|
||||
:class="cn('relative inline-block h-1.5 w-14 shrink-0 overflow-hidden rounded-full bg-muted align-middle', props.class)"
|
||||
>
|
||||
<span
|
||||
class="absolute inset-y-0 left-0 rounded-full"
|
||||
:class="TIER_FILL_CLASS[tier]"
|
||||
:style="{ width: `${widthPct}%` }"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { SortingState } from '@tanstack/vue-table'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import DataTable from '@/components/DataTable.vue'
|
||||
@@ -21,9 +22,15 @@ const props = defineProps<{
|
||||
secondaryValue: (entry: AdminUsageEntryView) => string
|
||||
/** Shown in the empty-state row when entries is empty — e.g. a filtered-search "no X match ..." message. Falls through to DataTable's own 'none' default when omitted. */
|
||||
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. */
|
||||
sorting?: SortingState
|
||||
}>()
|
||||
|
||||
const columns = computed(() => usageColumns(props.idLabel, props.secondaryColumnLabel, props.secondaryValue))
|
||||
const emit = defineEmits<{ 'update:sorting': [value: SortingState] }>()
|
||||
|
||||
const columns = computed(() => usageColumns(props.idLabel, props.secondaryColumnLabel, props.secondaryValue, props.now))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -32,5 +39,7 @@ const columns = computed(() => usageColumns(props.idLabel, props.secondaryColumn
|
||||
:data="entries"
|
||||
:empty-message="emptyMessage"
|
||||
:row-class="(entry) => (entry.storeDown ? 'bg-destructive/10' : undefined)"
|
||||
:sorting="sorting"
|
||||
@update:sorting="(v) => emit('update:sorting', v)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { SortingState } from '@tanstack/vue-table'
|
||||
import { getCoreRowModel, getSortedRowModel, useVueTable } from '@tanstack/vue-table'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
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'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { valueUpdater } from '@/components/ui/table'
|
||||
import UsageBar from '@/components/UsageBar.vue'
|
||||
import UsageTable from '@/components/UsageTable.vue'
|
||||
import { useNow } from '@/composables/useNow'
|
||||
import { useSearchQuery } from '@/composables/useSearchQuery'
|
||||
import { formatCost, formatLimits } from '@/lib/format'
|
||||
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'
|
||||
import { BUDGET_RATIO_LABEL, type BudgetRatio, budgetRatios } from '@/lib/usage-bars'
|
||||
import { usageColumns } from '@/lib/usage-columns'
|
||||
import { usageCsv } from '@/lib/usage-csv'
|
||||
import { groupMatches, groupNameMatches, matchingMembersOfGroup, membersOfGroup, userMatches } from '@/lib/usage-search'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import type { AdminUsageEntryView } from '@/types/api'
|
||||
@@ -23,6 +33,17 @@ import type { AdminUsageEntryView } from '@/types/api'
|
||||
const dashboard = useDashboardStore()
|
||||
const usage = computed(() => dashboard.usage)
|
||||
|
||||
// The one shared reactive clock every time-dependent read on this tab 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.
|
||||
const { now, start: startClock, stop: stopClock } = useNow()
|
||||
onMounted(startClock)
|
||||
onUnmounted(stopClock)
|
||||
|
||||
/** memberCounts maps a group name to its member count — only the Overview response carries it. */
|
||||
const memberCounts = computed<Record<string, number>>(() => {
|
||||
const out: Record<string, number> = {}
|
||||
@@ -45,7 +66,9 @@ function memberCountOf(entry: AdminUsageEntryView): string {
|
||||
// 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?") ---
|
||||
const { query: userQuery, normalized: normalizedQuery, hasQuery } = useSearchQuery()
|
||||
// Bound to nav.usageQuery so the search text round-trips through the URL hash (#usage?q=…, lib/hash-state.ts).
|
||||
const { usageQuery } = storeToRefs(useNavStore())
|
||||
const { query: userQuery, normalized: normalizedQuery, hasQuery } = useSearchQuery(usageQuery)
|
||||
|
||||
/**
|
||||
* filteredGroups is every group matching the query — own name OR a
|
||||
@@ -158,14 +181,14 @@ const usersEmptyMessage = computed(() =>
|
||||
// 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.
|
||||
const groupColumns = usageColumns('Name', 'Members', memberCountOf)
|
||||
const groupColumns = computed(() => usageColumns('Name', 'Members', memberCountOf, now.value))
|
||||
const groupSorting = ref<SortingState>([])
|
||||
const groupsTable = useVueTable({
|
||||
get data() {
|
||||
return filteredGroups.value
|
||||
},
|
||||
get columns() {
|
||||
return groupColumns
|
||||
return groupColumns.value
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
@@ -257,6 +280,100 @@ const GLOB_HINT = 'Glob pattern — matches like a shell wildcard (*, ?, [...])'
|
||||
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. */
|
||||
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.
|
||||
*/
|
||||
function groupCostProjection(entry: AdminUsageEntryView, now: Date): { text: string; willExceed: boolean } {
|
||||
const elapsedFraction = monthProgress(now)
|
||||
const projected = projectMonthEnd(entry.costPerMonthMicroUsd, elapsedFraction)
|
||||
if (projected === null) return { text: 'not enough data yet', willExceed: false }
|
||||
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))
|
||||
const usersSorting = ref<SortingState>([])
|
||||
const usersTable = useVueTable({
|
||||
get data() {
|
||||
return filteredUsers.value
|
||||
},
|
||||
get columns() {
|
||||
return usersColumns.value
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onSortingChange: (updater) => valueUpdater(updater, usersSorting),
|
||||
state: {
|
||||
get sorting() {
|
||||
return usersSorting.value
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function usersCsv(): string {
|
||||
return usageCsv(
|
||||
usersTable.getRowModel().rows.map((row) => row.original),
|
||||
'users',
|
||||
groupNameOf,
|
||||
now.value,
|
||||
)
|
||||
}
|
||||
function groupsCsv(): string {
|
||||
return usageCsv(
|
||||
sortedGroupRows.value.map((row) => row.original),
|
||||
'groups',
|
||||
memberCountOf,
|
||||
now.value,
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -268,11 +385,18 @@ function isGlobPattern(entry: string): boolean {
|
||||
<CardTitle>Total</CardTitle>
|
||||
<CardDescription>Usage summed across every user and group combined.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
|
||||
<CardContent class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-5">
|
||||
<div>
|
||||
<p class="text-muted-foreground">Requests/day</p>
|
||||
<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" />
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Tokens in/day</p>
|
||||
<p class="text-lg font-semibold tabular-nums"><CompactNumber :value="usage.total.tokensInPerDay" /></p>
|
||||
@@ -288,10 +412,18 @@ function isGlobPattern(entry: string): boolean {
|
||||
</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>
|
||||
<CardHeader>
|
||||
<CardTitle>Groups</CardTitle>
|
||||
<CardDescription>Expand a group to see its member users' own usage.</CardDescription>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle>Groups</CardTitle>
|
||||
<CardDescription>Expand a group to see its member users' own usage.</CardDescription>
|
||||
</div>
|
||||
<CsvExportButton filename="usage-groups.csv" :build="groupsCsv" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
@@ -303,19 +435,40 @@ function isGlobPattern(entry: string): boolean {
|
||||
|
||||
<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">
|
||||
<AccordionTrigger>
|
||||
<span class="flex flex-1 flex-wrap items-center gap-x-4 gap-y-1 pr-2 text-left">
|
||||
<span class="font-medium">{{ row.original.id }}</span>
|
||||
<span class="text-xs text-muted-foreground tabular-nums">{{ memberCountOf(row.original) || '0' }} members</span>
|
||||
<span class="text-xs text-muted-foreground tabular-nums">
|
||||
<template v-if="row.original.storeDown">?</template>
|
||||
<CompactNumber v-else :value="row.original.requestsPerDay" /> req/day
|
||||
<!--
|
||||
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}`" />
|
||||
<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">
|
||||
<span class="text-xs text-muted-foreground tabular-nums">{{ memberCountOf(row.original) || '0' }} members</span>
|
||||
<span class="text-xs text-muted-foreground tabular-nums">
|
||||
<template v-if="row.original.storeDown">?</template>
|
||||
<CompactNumber v-else :value="row.original.requestsPerDay" /> req/day
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground tabular-nums">
|
||||
{{ row.original.storeDown ? '?' : formatCost(row.original.costPerDayMicroUsd) }}/day
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground tabular-nums">
|
||||
{{ row.original.storeDown ? '?' : formatCost(row.original.costPerDayMicroUsd) }}/day
|
||||
</span>
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
</AccordionTrigger>
|
||||
</div>
|
||||
<AccordionContent>
|
||||
<!--
|
||||
Access block (group-access-display task): the group's
|
||||
@@ -404,6 +557,13 @@ function isGlobPattern(entry: string): boolean {
|
||||
<CompactNumber v-else :value="row.original.requestsPerMinute" />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">rejected/day</dt>
|
||||
<dd class="tabular-nums" :class="!row.original.storeDown && row.original.rejectionsPerDay > 0 ? 'text-destructive' : undefined">
|
||||
<template v-if="row.original.storeDown">?</template>
|
||||
<CompactNumber v-else :value="row.original.rejectionsPerDay" />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">tokIn/day</dt>
|
||||
<dd class="tabular-nums">
|
||||
@@ -436,13 +596,36 @@ function isGlobPattern(entry: string): boolean {
|
||||
<dt class="text-xs text-muted-foreground">cost/month</dt>
|
||||
<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"
|
||||
:class="!row.original.storeDown && groupCostProjection(row.original, now).willExceed ? 'text-destructive' : undefined"
|
||||
:title="!row.original.storeDown && groupCostProjection(row.original, now).willExceed ? 'Projected to exceed the configured cost/month limit' : undefined"
|
||||
>
|
||||
{{ row.original.storeDown ? '?' : groupCostProjection(row.original, now).text }}
|
||||
</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)"
|
||||
: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)" />
|
||||
</span>
|
||||
</div>
|
||||
<p class="mb-1.5 text-xs font-medium text-muted-foreground">Members</p>
|
||||
<UsageTable
|
||||
id-label="Name"
|
||||
secondary-column-label="Group"
|
||||
:entries="visibleMembers(row.original)"
|
||||
:secondary-value="groupNameOf"
|
||||
:now="now"
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@@ -453,7 +636,10 @@ function isGlobPattern(entry: string): boolean {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Users</CardTitle>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<CardTitle>Users</CardTitle>
|
||||
<CsvExportButton filename="usage-users.csv" :build="usersCsv" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UsageTable
|
||||
@@ -462,6 +648,8 @@ function isGlobPattern(entry: string): boolean {
|
||||
:entries="filteredUsers"
|
||||
:secondary-value="groupNameOf"
|
||||
:empty-message="usersEmptyMessage"
|
||||
:now="now"
|
||||
v-model:sorting="usersSorting"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -10,13 +10,25 @@ import {
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||
const props = defineProps<AccordionTriggerProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
/**
|
||||
* Class applied to the AccordionHeader (the <h3> wrapper), not the
|
||||
* trigger button. Reka-ui's AccordionTrigger declares its own `class`
|
||||
* prop, so a `class` passed to this component lands on the inner
|
||||
* button, never on the header — pass `flex-1` here (not to `class`)
|
||||
* when the trigger sits next to a sibling in a flex row, so the header
|
||||
* (and the button it wraps) fills the remaining row width instead of
|
||||
* shrinking to content width.
|
||||
*/
|
||||
headerClass?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'headerClass')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionHeader class="flex">
|
||||
<AccordionHeader :class="cn('flex', props.headerClass)">
|
||||
<AccordionTrigger
|
||||
data-slot="accordion-trigger"
|
||||
v-bind="delegatedProps"
|
||||
|
||||
@@ -10,6 +10,15 @@ export const alertVariants = cva('grid gap-0.5 rounded-lg border px-2.5 py-2 tex
|
||||
variant: {
|
||||
default: 'bg-card text-card-foreground',
|
||||
destructive: 'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current',
|
||||
/**
|
||||
* warn: a non-destructive "worth a look, not broken" tier —
|
||||
* reuses the panel's shared --status-warn token (main.css,
|
||||
* already the Providers tab's degraded badge and the usage-bar
|
||||
* amber threshold) instead of `destructive`'s red, which implies
|
||||
* a hard failure. Same structure as `destructive` above, only the
|
||||
* color changes.
|
||||
*/
|
||||
warn: 'text-status-warn bg-card *:data-[slot=alert-description]:text-status-warn/90 *:[svg]:text-current',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
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 { 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'
|
||||
const DEFAULT_METRIC: ModelMetric = 'cost'
|
||||
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:
|
||||
*
|
||||
* - 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).
|
||||
* - 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function useHashState(): void {
|
||||
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 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, {})
|
||||
})
|
||||
|
||||
watch(currentHash, (hash) => {
|
||||
if (!hasWindow) return
|
||||
if (window.location.hash === hash) return
|
||||
window.history.replaceState(null, '', hash)
|
||||
})
|
||||
|
||||
function onHashChange(): void {
|
||||
applyFromHash(window.location.hash)
|
||||
}
|
||||
|
||||
if (hasWindow) {
|
||||
onMounted(() => window.addEventListener('hashchange', onHashChange))
|
||||
onUnmounted(() => window.removeEventListener('hashchange', onHashChange))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useNow } from './useNow'
|
||||
import { monthProgress, projectMonthEnd } from '@/lib/forecast'
|
||||
|
||||
describe('useNow', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('ticks the shared now ref immediately on start', () => {
|
||||
vi.setSystemTime(new Date('2026-04-16T00:00:00.000Z'))
|
||||
const { now, start, stop } = useNow()
|
||||
|
||||
start()
|
||||
|
||||
expect(now.value.toISOString()).toBe('2026-04-16T00:00:00.000Z')
|
||||
stop()
|
||||
})
|
||||
|
||||
it('advances now on each subsequent tick, at the configured interval', () => {
|
||||
vi.setSystemTime(new Date('2026-04-16T00:00:00.000Z'))
|
||||
const { now, start, stop } = useNow(60_000)
|
||||
|
||||
start()
|
||||
// advanceTimersByTime both advances the fake clock AND fires whatever
|
||||
// interval callback falls due within that span — a separate
|
||||
// vi.setSystemTime call here would ADD its own jump on top of this
|
||||
// one (they compound; sinon's fake Date offset is not superseded by
|
||||
// a later setSystemTime call), double-advancing `now`.
|
||||
vi.advanceTimersByTime(60_000)
|
||||
|
||||
expect(now.value.toISOString()).toBe('2026-04-16T00:01:00.000Z')
|
||||
stop()
|
||||
})
|
||||
|
||||
it('stop() clears the timer — now no longer advances', () => {
|
||||
vi.setSystemTime(new Date('2026-04-16T00:00:00.000Z'))
|
||||
const { now, start, stop } = useNow(60_000)
|
||||
|
||||
start()
|
||||
stop()
|
||||
vi.setSystemTime(new Date('2026-04-16T00:05:00.000Z'))
|
||||
vi.advanceTimersByTime(300_000)
|
||||
|
||||
expect(now.value.toISOString()).toBe('2026-04-16T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('start() is idempotent — a second call does not double-schedule', () => {
|
||||
vi.setSystemTime(new Date('2026-04-16T00:00:00.000Z'))
|
||||
const { now, start, stop } = useNow(1000)
|
||||
|
||||
start()
|
||||
start()
|
||||
// See the previous test's own comment: advanceTimersByTime alone both
|
||||
// advances the clock and fires the one due tick — an extra
|
||||
// setSystemTime call here would compound into a double-advance.
|
||||
vi.advanceTimersByTime(1000)
|
||||
|
||||
// A double-scheduled poller would still land on the same value here
|
||||
// (both intervals would tick at the same wall-clock instant), so this
|
||||
// alone doesn't fully pin idempotency — the real guard is
|
||||
// createVisibilityPoller's own start()-is-idempotent behavior
|
||||
// (lib/polling.spec.ts), reused here unmodified.
|
||||
expect(now.value.toISOString()).toBe('2026-04-16T00:00:01.000Z')
|
||||
stop()
|
||||
})
|
||||
|
||||
// P1: the concrete symptom this composable fixes — a long-open tab's cost
|
||||
// projection (lib/forecast.ts) must change once the shared clock ticks to
|
||||
// a later point in the month, not stay frozen at whatever `now` was true
|
||||
// when something first read it.
|
||||
it('feeding the shared clock into monthProgress/projectMonthEnd yields a fresh projection once the clock advances', () => {
|
||||
// Just past the start of April (UTC): elapsedFraction is below
|
||||
// MIN_PROJECTION_FRACTION, so projectMonthEnd reads "not enough data
|
||||
// yet" (null) — exactly the P1 bug scenario ("every projection shows
|
||||
// 'not enough data yet' until the tab remounts").
|
||||
vi.setSystemTime(new Date(Date.UTC(2026, 3, 1, 1, 0, 0)))
|
||||
const { now, start, stop } = useNow(60_000)
|
||||
start()
|
||||
const mtdMicros = 1_000_000
|
||||
const firstProjection = projectMonthEnd(mtdMicros, monthProgress(now.value))
|
||||
expect(firstProjection).toBeNull()
|
||||
|
||||
// Mid-month (exactly half of April's 30 days elapsed, UTC). A
|
||||
// stop()/setSystemTime()/start() restart — rather than a further
|
||||
// setSystemTime+advanceTimersByTime pair, which would compound with
|
||||
// the jump above (see the sibling "advances now on each subsequent
|
||||
// tick" test's own comment) — forces exactly one fresh, synchronous
|
||||
// tick at precisely this instant: start()'s own immediate tick, not
|
||||
// an interval fire, is what this test actually needs to prove (a
|
||||
// later READ of `now.value` reflects the advanced clock).
|
||||
stop()
|
||||
vi.setSystemTime(new Date(Date.UTC(2026, 3, 16, 0, 0, 0)))
|
||||
start()
|
||||
const secondProjection = projectMonthEnd(mtdMicros, monthProgress(now.value))
|
||||
|
||||
expect(secondProjection).toBe(2_000_000) // mtdMicros / 0.5
|
||||
expect(secondProjection).not.toBe(firstProjection)
|
||||
stop()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
|
||||
import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
|
||||
|
||||
/**
|
||||
* How often the shared clock ticks — coarse enough that a cost-forecast
|
||||
* projection or elapsed-month fraction (lib/forecast.ts) is never
|
||||
* meaningfully stale, without re-deriving the Usage tab's whole column set
|
||||
* once a second for no visible benefit.
|
||||
*/
|
||||
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()`. */
|
||||
now: Ref<Date>
|
||||
/** Idempotent, mirrors createVisibilityPoller's own start() — call from the owning component's onMounted. */
|
||||
start: () => void
|
||||
/** Idempotent, mirrors createVisibilityPoller's own stop() — call from the owning component's onUnmounted so the interval is cleaned up on navigation away. */
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 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:
|
||||
*
|
||||
* - CostForecastCard.vue used `withDefaults(..., { now: () => new Date() })`
|
||||
* — Vue caches a prop's default once per component instance
|
||||
* (propsDefaults), so a long-open tab's projection froze at whatever
|
||||
* 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.
|
||||
*
|
||||
* Driven by the shared createVisibilityPoller (lib/polling.ts — the same
|
||||
* primitive every store's own poll timer uses): ticks once immediately on
|
||||
* start(), then every NOW_TICK_MS while the tab is visible, pausing while
|
||||
* 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.
|
||||
*/
|
||||
export function useNow(intervalMs = NOW_TICK_MS): UseNow {
|
||||
const now = ref(new Date())
|
||||
let poller: VisibilityPoller | undefined
|
||||
|
||||
function start(): void {
|
||||
if (poller) return
|
||||
poller = createVisibilityPoller({
|
||||
intervalMs,
|
||||
tick: () => {
|
||||
now.value = new Date()
|
||||
},
|
||||
})
|
||||
poller.start()
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
poller?.stop()
|
||||
poller = undefined
|
||||
}
|
||||
|
||||
return { now, start, stop }
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ref } from 'vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { useSearchQuery } from './useSearchQuery'
|
||||
@@ -20,4 +21,47 @@ describe('useSearchQuery', () => {
|
||||
query.value = ' '
|
||||
expect(hasQuery.value).toBe(false)
|
||||
})
|
||||
|
||||
// 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.
|
||||
describe('with an external ref', () => {
|
||||
it('reads and derives from the external ref\'s current value, rather than starting blank', () => {
|
||||
const external = ref('preset')
|
||||
const { query, normalized, hasQuery } = useSearchQuery(external)
|
||||
|
||||
expect(query.value).toBe('preset')
|
||||
expect(normalized.value).toBe('preset')
|
||||
expect(hasQuery.value).toBe(true)
|
||||
})
|
||||
|
||||
it('writes THROUGH to the external ref — the same ref instance, not a copy', () => {
|
||||
const external = ref('')
|
||||
const { query } = useSearchQuery(external)
|
||||
|
||||
query.value = 'typed'
|
||||
|
||||
expect(external.value).toBe('typed')
|
||||
})
|
||||
|
||||
it('reflects a change made directly to the external ref (bidirectional, not a one-time snapshot)', () => {
|
||||
const external = ref('')
|
||||
const { query, normalized } = useSearchQuery(external)
|
||||
|
||||
external.value = 'set-elsewhere'
|
||||
|
||||
expect(query.value).toBe('set-elsewhere')
|
||||
expect(normalized.value).toBe('set-elsewhere')
|
||||
})
|
||||
|
||||
it('without an external ref, two calls own independent state (no accidental sharing)', () => {
|
||||
const a = useSearchQuery()
|
||||
const b = useSearchQuery()
|
||||
|
||||
a.query.value = 'alice'
|
||||
|
||||
expect(b.query.value).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
/**
|
||||
@@ -16,9 +17,19 @@ import { computed, ref } from 'vue'
|
||||
* clear button emitting `update:modelValue('')` through the `v-model`
|
||||
* binding, so a separate clear function would be dead API surface no
|
||||
* 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.
|
||||
*/
|
||||
export function useSearchQuery() {
|
||||
const query = ref('')
|
||||
export function useSearchQuery(external?: Ref<string>) {
|
||||
const query = external ?? ref('')
|
||||
const normalized = computed(() => query.value.trim().toLowerCase())
|
||||
const hasQuery = computed(() => normalized.value.length > 0)
|
||||
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
// Feature C (v0.22): syncs the active dashboard tab to the URL hash
|
||||
// (#providers, #usage, #charts, #targets — App.vue's own TabsTrigger
|
||||
// value strings, unchanged) with no router dependency: App.vue's Tabs
|
||||
// component already just needs a plain string ref, and a full router is
|
||||
// overkill for one hash-backed value. hashToTab/tabToHash are the pure
|
||||
// mapping this module's own spec exercises directly (vite.config.ts's
|
||||
// `test` block runs in a Node environment — no `window`, no component
|
||||
// mount), so every `window`/`history`/`location` touch below is guarded
|
||||
// and lives only inside useTabHash itself, never at module scope.
|
||||
// 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
|
||||
@@ -28,10 +35,11 @@ import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
* 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:
|
||||
* useTabHash's initial read below never calls history.replaceState —
|
||||
* only a LATER tab 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 at least once.
|
||||
* 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
|
||||
@@ -42,58 +50,3 @@ export function hashToTab<T extends string>(hash: string, validTabs: readonly T[
|
||||
export function tabToHash(tab: string): string {
|
||||
return `#${tab}`
|
||||
}
|
||||
|
||||
export interface UseTabHashOptions<T extends string> {
|
||||
/** The full set of recognized tab values — App.vue's Tabs value strings. */
|
||||
validTabs: readonly T[]
|
||||
/** The tab an absent or unrecognized hash falls back to. */
|
||||
defaultTab: T
|
||||
}
|
||||
|
||||
/**
|
||||
* useTabHash returns a ref<T> wired bidirectionally to the URL hash:
|
||||
*
|
||||
* - On creation, it reads the CURRENT hash (hashToTab) as its initial
|
||||
* value — the "on load: read hash -> select tab" half of the spec.
|
||||
* - On every ref change (a Tabs v-model update from a user click), it
|
||||
* writes the hash back via history.replaceState — never pushState or a
|
||||
* bare `location.hash =` assignment, both of which would grow browser
|
||||
* history with one entry per tab click and, for the latter, jump
|
||||
* scroll position to any element whose id matches the new hash. A
|
||||
* no-op write (the hash already matches) is skipped so a rapid
|
||||
* double-toggle does not spam replaceState.
|
||||
* - It listens for the browser's own `hashchange` event (back/forward
|
||||
* navigation, or a hand-edited URL) and updates the ref to match —
|
||||
* invalid/unrecognized hands back defaultTab, exactly like the initial
|
||||
* read.
|
||||
*
|
||||
* Guarded for a `window`-less environment (vitest's node test
|
||||
* environment, vite.config.ts): every DOM/history/location access is
|
||||
* behind a single hasWindow check computed once, so importing or calling
|
||||
* this module never throws there — though the composable itself, being a
|
||||
* real Vue lifecycle hook user, is exercised through App.vue in the real
|
||||
* browser, not unit-tested directly; hashToTab/tabToHash above are what
|
||||
* this module's own spec covers.
|
||||
*/
|
||||
export function useTabHash<T extends string>({ validTabs, defaultTab }: UseTabHashOptions<T>): Ref<T> {
|
||||
const hasWindow = typeof window !== 'undefined'
|
||||
const activeTab = ref(hasWindow ? hashToTab(window.location.hash, validTabs, defaultTab) : defaultTab) as Ref<T>
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (!hasWindow) return
|
||||
const nextHash = tabToHash(tab)
|
||||
if (window.location.hash === nextHash) return
|
||||
window.history.replaceState(null, '', nextHash)
|
||||
})
|
||||
|
||||
function onHashChange(): void {
|
||||
activeTab.value = hashToTab(window.location.hash, validTabs, defaultTab)
|
||||
}
|
||||
|
||||
if (hasWindow) {
|
||||
onMounted(() => window.addEventListener('hashchange', onHashChange))
|
||||
onUnmounted(() => window.removeEventListener('hashchange', onHashChange))
|
||||
}
|
||||
|
||||
return activeTab
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { toCsv } from './csv'
|
||||
|
||||
describe('toCsv', () => {
|
||||
it('joins a header row and data rows with CRLF, including a trailing CRLF', () => {
|
||||
expect(toCsv(['a', 'b'], [['1', '2']])).toBe('a,b\r\n1,2\r\n')
|
||||
})
|
||||
|
||||
it('renders multiple data rows in order', () => {
|
||||
expect(toCsv(['id'], [['x'], ['y'], ['z']])).toBe('id\r\nx\r\ny\r\nz\r\n')
|
||||
})
|
||||
|
||||
it('leaves a plain field bare (no quotes) when it has no special characters', () => {
|
||||
expect(toCsv(['name'], [['alice']])).toBe('name\r\nalice\r\n')
|
||||
})
|
||||
|
||||
it('quotes a field containing a comma', () => {
|
||||
expect(toCsv(['name'], [['alice, bob']])).toBe('name\r\n"alice, bob"\r\n')
|
||||
})
|
||||
|
||||
it('quotes a field containing a double quote and doubles the embedded quote', () => {
|
||||
expect(toCsv(['name'], [['say "hi"']])).toBe('name\r\n"say ""hi"""\r\n')
|
||||
})
|
||||
|
||||
it('quotes a field containing an embedded newline', () => {
|
||||
expect(toCsv(['note'], [['line1\nline2']])).toBe('note\r\n"line1\nline2"\r\n')
|
||||
})
|
||||
|
||||
it('quotes a field containing an embedded carriage return', () => {
|
||||
expect(toCsv(['note'], [['line1\rline2']])).toBe('note\r\n"line1\rline2"\r\n')
|
||||
})
|
||||
|
||||
it('stringifies numbers and booleans without caller-side conversion', () => {
|
||||
expect(toCsv(['n', 'b'], [[42, true]])).toBe('n,b\r\n42,true\r\n')
|
||||
})
|
||||
|
||||
it('renders null/undefined fields as empty, never the literal text "null"/"undefined"', () => {
|
||||
expect(toCsv(['a', 'b'], [[null, undefined]])).toBe('a,b\r\n,\r\n')
|
||||
})
|
||||
|
||||
it('quotes a header cell too, not just data cells', () => {
|
||||
expect(toCsv(['a, b'], [['x']])).toBe('"a, b"\r\nx\r\n')
|
||||
})
|
||||
|
||||
it('renders just the header row (CRLF-terminated) when there are no data rows', () => {
|
||||
expect(toCsv(['only'], [])).toBe('only\r\n')
|
||||
})
|
||||
})
|
||||
|
||||
// P4 (security): OWASP CSV Injection — a cell whose text begins with a
|
||||
// formula-trigger character must be neutralized with a leading single
|
||||
// quote before any other quoting, so a malicious/upstream-controlled id
|
||||
// never executes as a formula when the export is opened in a spreadsheet.
|
||||
describe('toCsv: formula injection (P4)', () => {
|
||||
it.each([
|
||||
['=cmd', `id\r\n'=cmd\r\n`],
|
||||
['+1+1', `id\r\n'+1+1\r\n`],
|
||||
['-1+1', `id\r\n'-1+1\r\n`],
|
||||
['@SUM(A1:A9)', `id\r\n'@SUM(A1:A9)\r\n`],
|
||||
['\tcmd', `id\r\n'\tcmd\r\n`],
|
||||
// A leading \r is ALSO an RFC 4180 special character, so this one gets
|
||||
// both defenses: prefixed, then quoted (no embedded quote to double).
|
||||
['\rcmd', `id\r\n"'\rcmd"\r\n`],
|
||||
])('prefixes a cell starting with a formula-trigger character: %j', (raw, expected) => {
|
||||
expect(toCsv(['id'], [[raw]])).toBe(expected)
|
||||
})
|
||||
|
||||
it('leaves an ordinary id (no leading formula-trigger character) unprefixed', () => {
|
||||
const csv = toCsv(['id'], [['openai/gpt-4']])
|
||||
expect(csv).toBe('id\r\nopenai/gpt-4\r\n')
|
||||
})
|
||||
|
||||
it('a formula-triggering value that ALSO needs comma/quote-quoting gets both defenses (prefixed, then quoted)', () => {
|
||||
const csv = toCsv(['id'], [['=A1, "x"']])
|
||||
expect(csv).toBe(`id\r\n"'=A1, ""x"""\r\n`)
|
||||
})
|
||||
|
||||
it('does not treat a bare "=" alone as anything other than a formula trigger (still prefixed)', () => {
|
||||
expect(toCsv(['v'], [['=']])).toBe("v\r\n'=\r\n")
|
||||
})
|
||||
|
||||
it('a value merely CONTAINING (not starting with) a trigger character is left unprefixed', () => {
|
||||
expect(toCsv(['note'], [['cost=5']])).toBe('note\r\ncost=5\r\n')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
// F8 (CSV export): the one RFC 4180 encoder every CSV export in this panel
|
||||
// builds on (usage-csv.ts's usageCsv/modelRankingCsv, WP-B2's Models tab
|
||||
// export) — one implementation, not a copy per export (vue.md: "if you've
|
||||
// written it twice, you owe an abstraction").
|
||||
|
||||
/** CSV_LINE_ENDING is RFC 4180's own line terminator (CRLF), not the bare "\n" a JS template literal defaults to — Excel and most spreadsheet importers expect it, and RFC 4180 section 2.1 requires it. */
|
||||
const CSV_LINE_ENDING = '\r\n'
|
||||
|
||||
/** CSV_SPECIAL_CHARS matches any character that forces a field into a quoted form: comma (the delimiter itself), a double quote, or either newline character a multi-line field could carry. */
|
||||
const CSV_SPECIAL_CHARS = /[",\r\n]/
|
||||
|
||||
/**
|
||||
* CSV_FORMULA_LEAD_CHARS matches a cell whose text BEGINS with a character
|
||||
* a spreadsheet importer (Excel, Google Sheets, LibreOffice) treats as the
|
||||
* start of a formula: `=` `+` `-` `@`, or a leading tab/carriage return
|
||||
* (both used historically to smuggle a formula past a leading-character
|
||||
* check alone — OWASP's CSV Injection guidance lists all six). P4
|
||||
* (security): model ids and user/group ids exported by this panel are
|
||||
* partly UPSTREAM-controlled (a discovered model id, admin.go) or
|
||||
* operator-configured — a malicious id like
|
||||
* `=HYPERLINK("http://x/?"&A1,"x")` executes the moment the export is
|
||||
* opened in a spreadsheet, not just displayed as text.
|
||||
*/
|
||||
const CSV_FORMULA_LEAD_CHARS = /^[=+\-@\t\r]/
|
||||
|
||||
/**
|
||||
* csvField renders one cell as an RFC 4180 field: `String(value)` first (so
|
||||
* callers can pass numbers/booleans/undefined without converting them
|
||||
* first — `undefined`/`null` become the empty string, not the literal text
|
||||
* "undefined"/"null"). A value starting with a formula-trigger character
|
||||
* (CSV_FORMULA_LEAD_CHARS above) is prefixed with a single quote FIRST
|
||||
* (OWASP CSV Injection guidance: a leading `'` makes every major
|
||||
* spreadsheet importer treat the whole cell as literal text, never a
|
||||
* formula) — quoting/escaping below then applies to that neutralized text,
|
||||
* exactly as it would to any other value, so a formula-triggering id that
|
||||
* also contains a comma or quote still gets both defenses. A field with
|
||||
* neither a formula-trigger character nor an RFC 4180 special character is
|
||||
* left bare — matches every real-world CSV consumer's expectation and
|
||||
* keeps the common case (plain numbers, short ids) readable in a text
|
||||
* diff.
|
||||
*/
|
||||
function csvField(value: unknown): string {
|
||||
let s = value === null || value === undefined ? '' : String(value)
|
||||
if (CSV_FORMULA_LEAD_CHARS.test(s)) s = `'${s}`
|
||||
if (!CSV_SPECIAL_CHARS.test(s)) return s
|
||||
return `"${s.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
/**
|
||||
* toCsv renders a header row plus data rows as one RFC 4180 CSV string:
|
||||
* CRLF between every row (including after the final one, matching most
|
||||
* spreadsheet tools' own export convention), each field quoted only when
|
||||
* it needs to be (csvField above). Every export in this panel
|
||||
* (usage-csv.ts, and WP-B2's model-ranking export) funnels through this
|
||||
* one function so quoting/line-ending behavior is defined and tested in
|
||||
* exactly one place.
|
||||
*/
|
||||
export function toCsv(headers: string[], rows: unknown[][]): string {
|
||||
const lines = [headers, ...rows].map((row) => row.map(csvField).join(','))
|
||||
return lines.join(CSV_LINE_ENDING) + CSV_LINE_ENDING
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { copyText } from '@/lib/clipboard'
|
||||
|
||||
/** CSV_MIME_TYPE is the Blob type every export in this panel downloads as — text/csv with an explicit UTF-8 charset, so a spreadsheet importer opening the file directly (not pasting it) reads non-ASCII bytes correctly. */
|
||||
export const CSV_MIME_TYPE = 'text/csv;charset=utf-8;'
|
||||
|
||||
/** The outcome downloadText actually achieved, so a caller (CsvExportButton.vue) can show the right feedback: a real file save, or the clipboard/selection fallback (copyText's own two success modes), or neither. */
|
||||
export type DownloadOutcome = 'downloaded' | 'copied' | 'selected' | 'failed'
|
||||
|
||||
/**
|
||||
* REVOKE_DELAY_MS (P5 review fix) defers URL.revokeObjectURL past the
|
||||
* click() call below — revoking SYNCHRONOUSLY, right after click(), can
|
||||
* cancel the download in some WebKit and older Firefox builds: click()
|
||||
* only STARTS the download, it does not wait for the browser to finish
|
||||
* reading the blob, so revoking on the very next line raced the read. A
|
||||
* full second is generous rather than the minimal `setTimeout(fn, 0)`
|
||||
* (which only guarantees one macrotask, not "download started") — the
|
||||
* blob itself is only released a little later either way, and this is a
|
||||
* one-off per export click, never a hot path.
|
||||
*/
|
||||
const REVOKE_DELAY_MS = 1000
|
||||
|
||||
/**
|
||||
* downloadText saves `content` as a local file named `filename` (Q8,
|
||||
* coordinator decision: Blob + `<a download>`, the standard no-server-
|
||||
* round-trip browser download idiom — no File System Access API, which
|
||||
* is Chromium-only and needs a user-activation prompt this panel's CSP
|
||||
* would complicate for no benefit here).
|
||||
*
|
||||
* Guarded for a non-DOM environment (this module is never unit-tested —
|
||||
* see WP-B1's own test plan, lib/download.ts has no .spec.ts — but a
|
||||
* defensive `typeof document` check keeps it from throwing if it is ever
|
||||
* imported somewhere `document` is not global, matching useTabHash.ts's
|
||||
* own `hasWindow` convention). The clipboard fallback (copyText, Q8) runs
|
||||
* ONLY when the Blob/URL/anchor machinery itself is unavailable or throws
|
||||
* (a Blob/URL API genuinely unsupported, or the synthetic click rejected)
|
||||
* — never merely because a download was silently blocked by the browser,
|
||||
* which raises no exception here to catch. Returns the actual outcome
|
||||
* (never discarded — CsvExportButton.vue renders it as brief feedback) so
|
||||
* a caller can tell "downloaded" from the fallback's own "copied"/
|
||||
* "selected"/"failed".
|
||||
*/
|
||||
export async function downloadText(filename: string, content: string, mimeType = CSV_MIME_TYPE): Promise<DownloadOutcome> {
|
||||
if (typeof document === 'undefined') return 'failed'
|
||||
try {
|
||||
const blob = new Blob([content], { type: mimeType })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), REVOKE_DELAY_MS)
|
||||
return 'downloaded'
|
||||
} catch {
|
||||
return copyText(content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import { h } from 'vue'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { EVENT_KIND_LABEL, eventKindVariant } from '@/lib/events-filter'
|
||||
import { formatTimestamp } from '@/lib/format'
|
||||
import type { AdminEventKind, AdminEventView } from '@/types/api'
|
||||
|
||||
/** kindLabel falls back to the raw kind string for a version-skewed server build emitting a kind EVENT_KIND_LABEL does not know — same "still render it" convention AdminEventView.kind's own `| string` union documents. */
|
||||
function kindLabel(kind: string): string {
|
||||
return EVENT_KIND_LABEL[kind as AdminEventKind] ?? kind
|
||||
}
|
||||
|
||||
/** scopeText renders one event's user/group — a user-scoped event never carries a group and vice versa (AdminEventView's own doc comment), so at most one of the two is ever present; an event with neither (a total/all-scope event, e.g. a capacity rejection recorded user-less — DECISIONS Q4) renders an em dash. */
|
||||
function scopeText(e: AdminEventView): string {
|
||||
if (e.user) return `user:${e.user}`
|
||||
if (e.group) return `group:${e.group}`
|
||||
return '—'
|
||||
}
|
||||
|
||||
/** targetText renders one event's model/provider pair — joined when both are known (an upstream/timeout event names both), just the one that is known otherwise, and an em dash when neither applies (a rate_limit/budget event never carries either). */
|
||||
function targetText(e: AdminEventView): string {
|
||||
const parts = [e.model, e.provider].filter((v): v is string => Boolean(v))
|
||||
return parts.length ? parts.join(' → ') : '—'
|
||||
}
|
||||
|
||||
/**
|
||||
* eventsColumns builds the Events tab's DataTable ColumnDef set
|
||||
* (admin.go/events.go: gatewayEvent — F3). Mirrors target-columns.ts's own
|
||||
* shape: a Badge for the classified `kind` column (eventKindVariant), plain
|
||||
* muted text for everything free-form (route/message), an em dash for a
|
||||
* field this row does not carry rather than a blank cell.
|
||||
*/
|
||||
export function eventsColumns(): ColumnDef<AdminEventView, unknown>[] {
|
||||
return [
|
||||
{
|
||||
id: 'time',
|
||||
header: 'Time',
|
||||
accessorFn: (e) => e.time,
|
||||
cell: ({ row }) => h('span', { class: 'text-muted-foreground tabular-nums' }, formatTimestamp(row.original.time)),
|
||||
},
|
||||
{
|
||||
id: 'kind',
|
||||
header: 'Kind',
|
||||
accessorFn: (e) => e.kind,
|
||||
cell: ({ row }) =>
|
||||
h(
|
||||
Badge,
|
||||
{ as: 'span', variant: eventKindVariant(row.original.kind), class: 'font-normal' },
|
||||
() => kindLabel(row.original.kind),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'route',
|
||||
header: 'Route',
|
||||
accessorFn: (e) => e.route,
|
||||
cell: ({ row }) => h('span', { class: 'font-mono text-xs' }, row.original.route),
|
||||
},
|
||||
{
|
||||
id: 'scope',
|
||||
header: 'User / group',
|
||||
enableSorting: false,
|
||||
accessorFn: (e) => e.user ?? e.group ?? '',
|
||||
cell: ({ row }) => {
|
||||
const text = scopeText(row.original)
|
||||
return h('span', { class: text === '—' ? 'text-muted-foreground' : undefined }, text)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'target',
|
||||
header: 'Model / provider',
|
||||
enableSorting: false,
|
||||
accessorFn: (e) => `${e.model ?? ''} ${e.provider ?? ''}`,
|
||||
cell: ({ row }) => {
|
||||
const text = targetText(row.original)
|
||||
return h('span', { class: text === '—' ? 'text-muted-foreground' : 'font-mono text-xs' }, text)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'replica',
|
||||
header: 'Replica',
|
||||
accessorFn: (e) => e.replica,
|
||||
cell: ({ row }) => h('span', { class: 'text-muted-foreground' }, row.original.replica),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
meta: { align: 'right' },
|
||||
accessorFn: (e) => e.status ?? 0,
|
||||
cell: ({ row }) =>
|
||||
h('span', { class: 'tabular-nums' }, row.original.status !== undefined ? String(row.original.status) : '—'),
|
||||
},
|
||||
{
|
||||
id: 'message',
|
||||
header: 'Message',
|
||||
enableSorting: false,
|
||||
accessorFn: (e) => e.message,
|
||||
cell: ({ row }) => h('span', { class: 'text-muted-foreground' }, row.original.message),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { eventKindVariant, EVENT_KIND_LABEL, EVENT_KINDS, filterEvents } from './events-filter'
|
||||
import type { AdminEventView } from '@/types/api'
|
||||
|
||||
function event(overrides: Partial<AdminEventView> = {}): AdminEventView {
|
||||
return {
|
||||
time: '2026-08-20T12:00:00Z',
|
||||
replica: 'pod-a',
|
||||
route: 'chat/completions',
|
||||
kind: 'rate_limit',
|
||||
message: 'requests/min limit exceeded',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('EVENT_KIND_LABEL', () => {
|
||||
it('has a label for every AdminEventKind the server can emit', () => {
|
||||
const kinds = ['rate_limit', 'budget', 'store_down', 'upstream', 'timeout', 'unpriced', 'capacity'] as const
|
||||
for (const kind of kinds) {
|
||||
expect(EVENT_KIND_LABEL[kind]).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// P11 review fix: EVENT_KINDS is derived from EVENT_KIND_LABEL's own keys
|
||||
// (the single source of truth), not a third independent literal array —
|
||||
// this pins that it actually contains every known kind, in the same order
|
||||
// EVENT_KIND_LABEL's own keys were declared.
|
||||
describe('EVENT_KINDS', () => {
|
||||
it('contains every known AdminEventKind, derived from EVENT_KIND_LABEL', () => {
|
||||
expect(EVENT_KINDS).toEqual(Object.keys(EVENT_KIND_LABEL))
|
||||
expect(EVENT_KINDS).toEqual(['rate_limit', 'budget', 'store_down', 'upstream', 'timeout', 'unpriced', 'capacity'])
|
||||
})
|
||||
|
||||
it('every entry has a truthy label (no silently-empty display text)', () => {
|
||||
for (const kind of EVENT_KINDS) {
|
||||
expect(EVENT_KIND_LABEL[kind]).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('eventKindVariant', () => {
|
||||
it.each([
|
||||
['rate_limit', 'destructive'],
|
||||
['budget', 'destructive'],
|
||||
['capacity', 'destructive'],
|
||||
['store_down', 'secondary'],
|
||||
['upstream', 'secondary'],
|
||||
['timeout', 'secondary'],
|
||||
['unpriced', 'outline'],
|
||||
] as const)('maps %s to %s', (kind, variant) => {
|
||||
expect(eventKindVariant(kind)).toBe(variant)
|
||||
})
|
||||
|
||||
it('falls back to outline (the neutral variant) for a kind this build does not recognize', () => {
|
||||
expect(eventKindVariant('some_future_kind')).toBe('outline')
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterEvents', () => {
|
||||
const events: AdminEventView[] = [
|
||||
event({ kind: 'rate_limit', user: 'alice' }),
|
||||
event({ kind: 'budget', group: 'ops' }),
|
||||
event({ kind: 'upstream', user: 'bob-smith' }),
|
||||
event({ kind: 'timeout' }), // neither user nor group — a total/all-scope or capacity-style event
|
||||
]
|
||||
|
||||
it('returns every event when no filter is set', () => {
|
||||
expect(filterEvents(events, { kind: '', user: '' })).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('filters by exact kind', () => {
|
||||
const result = filterEvents(events, { kind: 'budget', user: '' })
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]!.group).toBe('ops')
|
||||
})
|
||||
|
||||
it('matches user against the event\'s own user field, case-insensitively, substring', () => {
|
||||
const result = filterEvents(events, { kind: '', user: 'ALICE' })
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]!.user).toBe('alice')
|
||||
})
|
||||
|
||||
it('matches user against the event\'s own group field too (a group-scoped event has no user)', () => {
|
||||
const result = filterEvents(events, { kind: '', user: 'ops' })
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]!.group).toBe('ops')
|
||||
})
|
||||
|
||||
it('matches a partial substring, not just a full match', () => {
|
||||
const result = filterEvents(events, { kind: '', user: 'smith' })
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]!.user).toBe('bob-smith')
|
||||
})
|
||||
|
||||
it('trims and lowercases the raw query defensively, even if a caller skips useSearchQuery', () => {
|
||||
const result = filterEvents(events, { kind: '', user: ' Alice ' })
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('combines kind and user filters (both must match)', () => {
|
||||
const result = filterEvents(events, { kind: 'upstream', user: 'bob' })
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]!.user).toBe('bob-smith')
|
||||
})
|
||||
|
||||
it('excludes an event with neither user nor group once a user filter is active', () => {
|
||||
const result = filterEvents(events, { kind: '', user: 'anything' })
|
||||
expect(result.every((e) => e.user || e.group)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns an empty array when nothing matches', () => {
|
||||
expect(filterEvents(events, { kind: 'unpriced', user: '' })).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { AdminEventKind, AdminEventView } from '@/types/api'
|
||||
|
||||
/** EVENT_KIND_LABEL is the Events view's display text for each AdminEventKind — both the kind-filter Select's option labels and each row's Kind badge. */
|
||||
export const EVENT_KIND_LABEL: Record<AdminEventKind, string> = {
|
||||
rate_limit: 'Rate limit',
|
||||
budget: 'Budget',
|
||||
store_down: 'Store down',
|
||||
upstream: 'Upstream error',
|
||||
timeout: 'Timeout',
|
||||
unpriced: 'Unpriced model',
|
||||
capacity: 'Capacity',
|
||||
}
|
||||
|
||||
/**
|
||||
* EVENT_KINDS (P11 review fix, DRY) is the ONE ordered list of every known
|
||||
* AdminEventKind, derived from EVENT_KIND_LABEL's own keys rather than a
|
||||
* third independent literal array — this codebase had the kind set spelled
|
||||
* out three times (the AdminEventKind union itself, types/api.ts;
|
||||
* EVENT_KIND_LABEL's keys, above; and EventsView.vue's own local
|
||||
* KIND_OPTIONS array), which is exactly the vue.md "if you've written it
|
||||
* twice, you owe an abstraction" case. `Object.keys` on a `Record<K,
|
||||
* string>` widens to `string[]`, so this re-asserts the same union type
|
||||
* the object literal above was already checked against — never a runtime
|
||||
* risk of drifting from it, since adding/removing a kind requires editing
|
||||
* EVENT_KIND_LABEL either way (a missing/extra key is a compile error
|
||||
* there first).
|
||||
*/
|
||||
export const EVENT_KINDS = Object.keys(EVENT_KIND_LABEL) as AdminEventKind[]
|
||||
|
||||
/**
|
||||
* eventKindVariant maps a kind to the shadcn-vue Badge variant its row
|
||||
* renders (lib/events-columns.ts): destructive for the three kinds where a
|
||||
* request was actually DENIED (rate_limit, budget, capacity — money or
|
||||
* quota was protected), secondary for an infra/upstream failure that is
|
||||
* not itself a policy decision (store_down, upstream, timeout), outline
|
||||
* for the purely informational unpriced kind (nothing was denied or
|
||||
* failed) — and outline again as the fallback for a kind this webui build
|
||||
* does not recognize (a server newer than this client), mirroring
|
||||
* target-columns.ts's healthCell "unrecognized reads as the neutral
|
||||
* state, never the alarming one" convention.
|
||||
*/
|
||||
export function eventKindVariant(kind: string): 'destructive' | 'secondary' | 'outline' {
|
||||
switch (kind) {
|
||||
case 'rate_limit':
|
||||
case 'budget':
|
||||
case 'capacity':
|
||||
return 'destructive'
|
||||
case 'store_down':
|
||||
case 'upstream':
|
||||
case 'timeout':
|
||||
return 'secondary'
|
||||
default:
|
||||
return 'outline'
|
||||
}
|
||||
}
|
||||
|
||||
/** EventFilter is filterEvents' own query shape — kind is an exact AdminEventKind match ('' means "every kind"); user is matched, case-insensitively, against EITHER the event's user OR its group (a group-scoped event has no user, and vice versa — see AdminEventView's own doc comment). */
|
||||
export interface EventFilter {
|
||||
kind: string
|
||||
user: string
|
||||
}
|
||||
|
||||
/**
|
||||
* filterEvents narrows the (already newest-first) event list to the
|
||||
* current kind/user selection. `filter.user` is matched pre-normalized —
|
||||
* callers pair this with useSearchQuery()'s own `normalized` the same way
|
||||
* every other search filter in this panel does (ProvidersView.vue's
|
||||
* modelMatches, lib/usage-search.ts) — this function itself does no
|
||||
* trimming or casing of its own beyond a defensive `.trim().toLowerCase()`
|
||||
* so it is still correct if a caller passes the raw query directly.
|
||||
*/
|
||||
export function filterEvents(events: AdminEventView[], filter: EventFilter): AdminEventView[] {
|
||||
const kind = filter.kind
|
||||
const user = filter.user.trim().toLowerCase()
|
||||
return events.filter((e) => {
|
||||
if (kind && e.kind !== kind) return false
|
||||
if (!user) return true
|
||||
const userMatch = e.user?.toLowerCase().includes(user) ?? false
|
||||
const groupMatch = e.group?.toLowerCase().includes(user) ?? false
|
||||
return userMatch || groupMatch
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { headroom, MIN_PROJECTION_FRACTION, monthProgress, projectMonthEnd } from './forecast'
|
||||
|
||||
describe('monthProgress', () => {
|
||||
it('reads 0 at the first instant of a UTC month', () => {
|
||||
expect(monthProgress(new Date(Date.UTC(2026, 2, 1, 0, 0, 0)))).toBe(0)
|
||||
})
|
||||
|
||||
it('reads exactly 0.5 at the midpoint of a 30-day month (April)', () => {
|
||||
expect(monthProgress(new Date(Date.UTC(2026, 3, 16, 0, 0, 0)))).toBe(0.5)
|
||||
})
|
||||
|
||||
it('reads approaching 1 near the end of the month, never reaching or exceeding it', () => {
|
||||
const p = monthProgress(new Date(Date.UTC(2026, 2, 31, 23, 59, 59)))
|
||||
expect(p).toBeLessThan(1)
|
||||
expect(p).toBeGreaterThan(0.99)
|
||||
})
|
||||
|
||||
it('handles a leap February (2028 has 29 days) — Feb 15 sits near the midpoint of 29, not 28, days', () => {
|
||||
const p = monthProgress(new Date(Date.UTC(2028, 1, 15, 12, 0, 0)))
|
||||
// 14.5 days elapsed of 29 total = exactly 0.5.
|
||||
expect(p).toBeCloseTo(0.5, 10)
|
||||
})
|
||||
|
||||
it('handles December -> January rollover without a hardcoded month-length table', () => {
|
||||
const p = monthProgress(new Date(Date.UTC(2026, 11, 16, 0, 0, 0)))
|
||||
expect(p).toBeCloseTo(15 / 31, 10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectMonthEnd', () => {
|
||||
it('returns null below MIN_PROJECTION_FRACTION — too early in the month to extrapolate', () => {
|
||||
expect(projectMonthEnd(1_000_000, MIN_PROJECTION_FRACTION - 0.001)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null at elapsedFraction 0 (the very first instant) rather than dividing by zero', () => {
|
||||
expect(projectMonthEnd(0, 0)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null at exactly the threshold boundary minus epsilon, but projects at the threshold itself', () => {
|
||||
expect(projectMonthEnd(1000, MIN_PROJECTION_FRACTION)).toBe(Math.round(1000 / MIN_PROJECTION_FRACTION))
|
||||
})
|
||||
|
||||
it('linearly extrapolates mtd/elapsedFraction, rounded to the nearest whole micro-USD', () => {
|
||||
expect(projectMonthEnd(15_000_000, 0.5)).toBe(30_000_000)
|
||||
})
|
||||
|
||||
it('rounds a non-exact division to the nearest integer', () => {
|
||||
expect(projectMonthEnd(1, 0.3)).toBe(Math.round(1 / 0.3))
|
||||
})
|
||||
|
||||
it('projects a full-month reading (elapsedFraction 1) back to itself', () => {
|
||||
expect(projectMonthEnd(7_500_000, 1)).toBe(7_500_000)
|
||||
})
|
||||
})
|
||||
|
||||
// End-to-end monthProgress -> projectMonthEnd against real UTC clock times,
|
||||
// not just a bare elapsedFraction — pins MIN_PROJECTION_FRACTION's real-
|
||||
// world meaning (0.02 of a 30-day month is 14.4 hours, NOT minutes) against
|
||||
// an actual point in a month.
|
||||
describe('monthProgress -> projectMonthEnd (real clock times)', () => {
|
||||
it('the first hour of a 30-day month is well under MIN_PROJECTION_FRACTION — no projection yet', () => {
|
||||
const oneHourIn = new Date(Date.UTC(2026, 3, 1, 1, 0, 0))
|
||||
expect(projectMonthEnd(100_000, monthProgress(oneHourIn))).toBeNull()
|
||||
})
|
||||
|
||||
it('13 hours into a 30-day month is still under the threshold', () => {
|
||||
const thirteenHoursIn = new Date(Date.UTC(2026, 3, 1, 13, 0, 0))
|
||||
expect(projectMonthEnd(100_000, monthProgress(thirteenHoursIn))).toBeNull()
|
||||
})
|
||||
|
||||
it('16 hours into a 30-day month has cleared the threshold and projects', () => {
|
||||
const sixteenHoursIn = new Date(Date.UTC(2026, 3, 1, 16, 0, 0))
|
||||
expect(projectMonthEnd(100_000, monthProgress(sixteenHoursIn))).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('headroom', () => {
|
||||
it('returns all-null, willExceed false when projectedMicros is null (too early to project)', () => {
|
||||
expect(headroom(null, 100)).toEqual({ limitMicros: null, remainingMicros: null, willExceed: false })
|
||||
})
|
||||
|
||||
it('returns all-null, willExceed false when limitUsd is undefined (unlimited)', () => {
|
||||
expect(headroom(5_000_000, undefined)).toEqual({ limitMicros: null, remainingMicros: null, willExceed: false })
|
||||
})
|
||||
|
||||
it('returns all-null, willExceed false when limitUsd is 0 (LimitsConfig "unlimited" convention)', () => {
|
||||
expect(headroom(5_000_000, 0)).toEqual({ limitMicros: null, remainingMicros: null, willExceed: false })
|
||||
})
|
||||
|
||||
it('returns all-null, willExceed false for a negative limitUsd (defensive)', () => {
|
||||
expect(headroom(5_000_000, -10)).toEqual({ limitMicros: null, remainingMicros: null, willExceed: false })
|
||||
})
|
||||
|
||||
it('reports positive remainingMicros and willExceed false when the projection stays under the limit', () => {
|
||||
expect(headroom(4_000_000, 5)).toEqual({ limitMicros: 5_000_000, remainingMicros: 1_000_000, willExceed: false })
|
||||
})
|
||||
|
||||
it('reports negative remainingMicros and willExceed true once the projection crosses the limit', () => {
|
||||
expect(headroom(6_000_000, 5)).toEqual({ limitMicros: 5_000_000, remainingMicros: -1_000_000, willExceed: true })
|
||||
})
|
||||
|
||||
it('treats an exact match (projection == limit) as not exceeding (remainingMicros 0)', () => {
|
||||
expect(headroom(5_000_000, 5)).toEqual({ limitMicros: 5_000_000, remainingMicros: 0, willExceed: false })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
// F7 (cost forecast): pure month-to-date -> projected-month-end math for
|
||||
// CostForecastCard.vue and usage-columns.ts's costMonthProj column. Kept
|
||||
// out of the component/column the same way lib/usage-bars.ts and lib/
|
||||
// provider-rate.ts keep their own math out of their components — vitest's
|
||||
// node-environment config (vite.config.ts's `test` block) exercises it
|
||||
// directly, no component mount or fake timers required (every function
|
||||
// here takes `now`/`elapsedFraction` as an explicit argument, never reads
|
||||
// Date.now() itself).
|
||||
//
|
||||
// All month-boundary math is UTC (coordinator brief): costPerMonthMicroUsd
|
||||
// (admin.go, adminUsageEntryView) accumulates against a UTC calendar
|
||||
// month bucket (limits.go's windowKey "200601" — same convention
|
||||
// lib/format.ts's formatBucketLabel documents for the hour/day buckets),
|
||||
// so projecting against the LOCAL calendar month would silently misalign
|
||||
// with the figure actually being projected on the last/first day of a
|
||||
// month in any timezone west or east of UTC.
|
||||
|
||||
// MICROS_PER_USD (P11 review fix, DRY): imported from lib/usage-bars.ts,
|
||||
// the one shared definition — this module used to declare its own
|
||||
// identical copy, one of three (usage-bars.ts, usage-columns.ts) the
|
||||
// coordinator's review flagged.
|
||||
import { MICROS_PER_USD } from '@/lib/usage-bars'
|
||||
|
||||
/**
|
||||
* MIN_PROJECTION_FRACTION is the smallest elapsed-month fraction
|
||||
* projectMonthEnd will extrapolate from — below it (coordinator: "null
|
||||
* below MIN_PROJECTION_FRACTION"), too little of the month has elapsed for
|
||||
* a mtd/elapsed extrapolation to mean anything: a single early request
|
||||
* would otherwise project to a wildly overstated month-end figure. 0.02 of
|
||||
* a 30-day month is 0.6 days — 14.4 hours; of a 31-day month, 0.62 days —
|
||||
* 14.88 hours. Both round to "the first ~14-15 hours of the month",
|
||||
* comfortably past the first request of any real deployment's day but
|
||||
* still early enough that a genuinely idle scope reports no projection at
|
||||
* all rather than a fabricated one.
|
||||
*/
|
||||
export const MIN_PROJECTION_FRACTION = 0.02
|
||||
|
||||
/**
|
||||
* monthProgress returns how far `now` has advanced through ITS OWN UTC
|
||||
* calendar month, as a fraction in [0, 1): 0 at the first instant of the
|
||||
* month (UTC midnight on the 1st), approaching 1 as the month's last
|
||||
* instant nears. Correct across every month length (28/29/30/31 days,
|
||||
* including a leap February) because it measures against the ACTUAL
|
||||
* elapsed span from this month's start to next month's start, computed
|
||||
* with Date.UTC (which itself normalizes an out-of-range month index —
|
||||
* `Date.UTC(y, 12, 1)` for December's "next month" already resolves to
|
||||
* January 1 of `y + 1`), never a hardcoded days-per-month table.
|
||||
*/
|
||||
export function monthProgress(now: Date): number {
|
||||
const year = now.getUTCFullYear()
|
||||
const month = now.getUTCMonth()
|
||||
const monthStart = Date.UTC(year, month, 1)
|
||||
const nextMonthStart = Date.UTC(year, month + 1, 1)
|
||||
const elapsedMs = now.getTime() - monthStart
|
||||
const totalMs = nextMonthStart - monthStart
|
||||
return elapsedMs / totalMs
|
||||
}
|
||||
|
||||
/**
|
||||
* projectMonthEnd linearly extrapolates a month-to-date micro-USD total to
|
||||
* a projected month-end total: `mtdMicros / elapsedFraction`, rounded to
|
||||
* the nearest whole micro-USD (matching metrics.go's own usdToMicros
|
||||
* round convention, dashboard-plan.md section 0 ground truth). Returns
|
||||
* null below MIN_PROJECTION_FRACTION (coordinator decision) — including
|
||||
* for `elapsedFraction === 0`, which would otherwise divide by zero — so
|
||||
* a caller renders "not enough data yet" instead of a meaningless or
|
||||
* infinite figure for the first sliver of a month.
|
||||
*/
|
||||
export function projectMonthEnd(mtdMicros: number, elapsedFraction: number): number | null {
|
||||
if (elapsedFraction < MIN_PROJECTION_FRACTION) return null
|
||||
return Math.round(mtdMicros / elapsedFraction)
|
||||
}
|
||||
|
||||
/**
|
||||
* CostHeadroom is projectMonthEnd's projected total compared against a
|
||||
* scope's configured costPerMonthUSD limit (LimitsConfig — 0/undefined
|
||||
* means unlimited, formatLimits' own convention). `limitMicros`/
|
||||
* `remainingMicros` are both null whenever there is nothing to compare
|
||||
* against (no limit configured, or no projection yet); `willExceed` is
|
||||
* true only when a real projection crosses a real, positive limit.
|
||||
*/
|
||||
export interface CostHeadroom {
|
||||
limitMicros: number | null
|
||||
/** limitMicros - projectedMicros — negative once the projection has crossed the limit. Null under the same conditions limitMicros is null. */
|
||||
remainingMicros: number | null
|
||||
willExceed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* headroom compares a projected month-end micro-USD total against an
|
||||
* optional costPerMonthUSD limit. `projectedMicros` is null whenever
|
||||
* projectMonthEnd itself returned null (too early in the month) —
|
||||
* headroom then has nothing to report either, regardless of whether a
|
||||
* limit is configured, since "will it exceed" cannot be answered yet.
|
||||
* `limitUsd` undefined, zero, or negative all read as "unlimited"
|
||||
* (LimitsConfig's own convention — formatLimits' identical `if (limits.x)`
|
||||
* gate), never divide-by-zero or a fabricated 0% headroom.
|
||||
*/
|
||||
export function headroom(projectedMicros: number | null, limitUsd: number | undefined): CostHeadroom {
|
||||
if (projectedMicros === null || limitUsd === undefined || limitUsd <= 0) {
|
||||
return { limitMicros: null, remainingMicros: null, willExceed: false }
|
||||
}
|
||||
const limitMicros = Math.round(limitUsd * MICROS_PER_USD)
|
||||
const remainingMicros = limitMicros - projectedMicros
|
||||
return { limitMicros, remainingMicros, willExceed: remainingMicros < 0 }
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildHash,
|
||||
DEFAULT_TAB,
|
||||
parseChartsParams,
|
||||
parseEventsParams,
|
||||
parseHashState,
|
||||
parseUsageParams,
|
||||
} from './hash-state'
|
||||
|
||||
describe('parseHashState', () => {
|
||||
it('splits a tab and query string', () => {
|
||||
const { tab, query } = parseHashState('#charts?window=day&scope=user%3Aalice')
|
||||
expect(tab).toBe('charts')
|
||||
expect(query.get('window')).toBe('day')
|
||||
expect(query.get('scope')).toBe('user:alice')
|
||||
})
|
||||
|
||||
it('parses a bare tab with no query to an empty query', () => {
|
||||
const { tab, query } = parseHashState('#usage')
|
||||
expect(tab).toBe('usage')
|
||||
expect(Array.from(query.keys())).toEqual([])
|
||||
})
|
||||
|
||||
it('falls back to DEFAULT_TAB for an empty hash', () => {
|
||||
expect(parseHashState('').tab).toBe(DEFAULT_TAB)
|
||||
})
|
||||
|
||||
it('falls back to DEFAULT_TAB for an unrecognized tab, and still parses its query', () => {
|
||||
const { tab, query } = parseHashState('#bogus?window=day')
|
||||
expect(tab).toBe(DEFAULT_TAB)
|
||||
expect(query.get('window')).toBe('day')
|
||||
})
|
||||
|
||||
it('accepts a hash with or without the leading #', () => {
|
||||
expect(parseHashState('targets').tab).toBe('targets')
|
||||
})
|
||||
|
||||
it('every valid tab round-trips through parseHashState(buildHash(...))', () => {
|
||||
for (const tab of ['providers', 'usage', 'charts', 'targets', 'events'] as const) {
|
||||
expect(parseHashState(buildHash(tab, {})).tab).toBe(tab)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildHash', () => {
|
||||
it('omits the query suffix entirely when params is empty', () => {
|
||||
expect(buildHash('providers', {})).toBe('#providers')
|
||||
})
|
||||
|
||||
it('appends a query string for non-empty params', () => {
|
||||
expect(buildHash('charts', { window: 'day' })).toBe('#charts?window=day')
|
||||
})
|
||||
|
||||
it('joins multiple params with &', () => {
|
||||
const hash = buildHash('charts', { window: 'day', scope: 'user:alice' })
|
||||
expect(hash).toBe('#charts?window=day&scope=user%3Aalice')
|
||||
})
|
||||
|
||||
it('drops a param whose value is an explicit empty string', () => {
|
||||
expect(buildHash('usage', { q: '' })).toBe('#usage')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseChartsParams', () => {
|
||||
it('accepts every valid window/tab/metric value', () => {
|
||||
const p = parseChartsParams(new URLSearchParams('window=month&tab=models&metric=tokin'))
|
||||
expect(p).toEqual({ window: 'month', tab: 'models', metric: 'tokin' })
|
||||
})
|
||||
|
||||
it('omits window when it is not a recognized HistoryWindow', () => {
|
||||
expect(parseChartsParams(new URLSearchParams('window=fortnight'))).toEqual({})
|
||||
})
|
||||
|
||||
it('omits tab when it is not a recognized ChartTab', () => {
|
||||
expect(parseChartsParams(new URLSearchParams('tab=bogus'))).toEqual({})
|
||||
})
|
||||
|
||||
it('omits metric when it is not a recognized ModelMetric', () => {
|
||||
expect(parseChartsParams(new URLSearchParams('metric=bogus'))).toEqual({})
|
||||
})
|
||||
|
||||
it('reads filter when present (free text, not an enum — same convention as scope/q)', () => {
|
||||
expect(parseChartsParams(new URLSearchParams('filter=openai%2F'))).toEqual({ filter: 'openai/' })
|
||||
})
|
||||
|
||||
it('omits filter when absent', () => {
|
||||
expect(parseChartsParams(new URLSearchParams())).toEqual({})
|
||||
})
|
||||
|
||||
it('omits filter when it is an explicit empty string', () => {
|
||||
expect(parseChartsParams(new URLSearchParams('filter='))).toEqual({})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['total', true],
|
||||
['user:alice', true],
|
||||
['group:ops', true],
|
||||
['model:openai/gpt-5', true],
|
||||
['user:', false], // empty id after the colon
|
||||
['bogus', false],
|
||||
['', false],
|
||||
] as const)('scope %s valid=%s', (scope, valid) => {
|
||||
const p = parseChartsParams(new URLSearchParams(scope ? `scope=${encodeURIComponent(scope)}` : ''))
|
||||
expect(p.scope === scope).toBe(valid)
|
||||
})
|
||||
|
||||
it('omits one invalid field while keeping the other valid ones (independent per-field validation)', () => {
|
||||
const p = parseChartsParams(new URLSearchParams('window=fortnight&tab=models&scope=total'))
|
||||
expect(p).toEqual({ tab: 'models', scope: 'total' })
|
||||
})
|
||||
|
||||
it('returns an empty object for an empty query', () => {
|
||||
expect(parseChartsParams(new URLSearchParams())).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseUsageParams', () => {
|
||||
it('reads q when present', () => {
|
||||
expect(parseUsageParams(new URLSearchParams('q=alice'))).toEqual({ q: 'alice' })
|
||||
})
|
||||
|
||||
it('omits q when absent', () => {
|
||||
expect(parseUsageParams(new URLSearchParams())).toEqual({})
|
||||
})
|
||||
|
||||
it('omits q when it is an explicit empty string', () => {
|
||||
expect(parseUsageParams(new URLSearchParams('q='))).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseEventsParams', () => {
|
||||
it('reads kind and user when both present', () => {
|
||||
expect(parseEventsParams(new URLSearchParams('kind=rate_limit&user=bob'))).toEqual({
|
||||
kind: 'rate_limit',
|
||||
user: 'bob',
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a kind this build does not recognize as an AdminEventKind (forward-compat, never rejected)', () => {
|
||||
expect(parseEventsParams(new URLSearchParams('kind=some_future_kind'))).toEqual({ kind: 'some_future_kind' })
|
||||
})
|
||||
|
||||
it('omits either field independently when absent', () => {
|
||||
expect(parseEventsParams(new URLSearchParams('kind=budget'))).toEqual({ kind: 'budget' })
|
||||
expect(parseEventsParams(new URLSearchParams('user=alice'))).toEqual({ user: 'alice' })
|
||||
})
|
||||
|
||||
it('returns an empty object for an empty query', () => {
|
||||
expect(parseEventsParams(new URLSearchParams())).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { hashToTab, TAB_VALUES, tabToHash, type TabValue } from '@/composables/useTabHash'
|
||||
import type { ChartTab, ModelMetric } from '@/stores/history'
|
||||
import type { HistoryWindow } from '@/types/api'
|
||||
|
||||
/**
|
||||
* DEFAULT_TAB is the app's landing tab when the hash names nothing
|
||||
* recognized — parseHashState's own fallback, mirroring the old
|
||||
* useTabHash's defaultTab parameter (which every call site passed
|
||||
* 'providers' for anyway, so this needed no parameter of its own once
|
||||
* folded into one composable, composables/useHashState.ts).
|
||||
*/
|
||||
export const DEFAULT_TAB: TabValue = 'providers'
|
||||
|
||||
export interface HashState {
|
||||
tab: TabValue
|
||||
query: URLSearchParams
|
||||
}
|
||||
|
||||
/**
|
||||
* parseHashState splits a raw location.hash ("#charts?window=day&scope=
|
||||
* user:alice") into its outer tab — validated against TAB_VALUES via
|
||||
* hashToTab, falling back to DEFAULT_TAB exactly like the pre-F9 useTabHash
|
||||
* did for an absent/unrecognized/bogus tab — and the remaining query
|
||||
* string, parsed once into a URLSearchParams every per-tab validator below
|
||||
* reads from. A hash with no "?" (a bare "#usage", or the very first load
|
||||
* with no hash at all) parses to an empty query, never throws.
|
||||
*/
|
||||
export function parseHashState(hash: string): HashState {
|
||||
const raw = hash.startsWith('#') ? hash.slice(1) : hash
|
||||
const qIndex = raw.indexOf('?')
|
||||
const tabPart = qIndex === -1 ? raw : raw.slice(0, qIndex)
|
||||
const queryPart = qIndex === -1 ? '' : raw.slice(qIndex + 1)
|
||||
const tab = hashToTab(tabPart, TAB_VALUES, DEFAULT_TAB)
|
||||
return { tab, query: new URLSearchParams(queryPart) }
|
||||
}
|
||||
|
||||
/**
|
||||
* buildHash re-serializes a tab plus an already-default-stripped param
|
||||
* record back into a "#<tab>?<query>" string (F9) — mirrors tabToHash for
|
||||
* the tab-only portion, adding a "?<query>" suffix only when `params` has
|
||||
* at least one entry, so the common "nothing but the default selection"
|
||||
* case round-trips to a bare "#<tab>" rather than a noisy "#<tab>?".
|
||||
*
|
||||
* "Omit defaults" is the CALLER's job, not this function's: what counts
|
||||
* as default differs per field (window's default is 'hour', scope's is
|
||||
* 'total', ...), so composables/useHashState.ts's own computed simply
|
||||
* never puts a default-valued key into `params` in the first place.
|
||||
* buildHash itself only defends against an explicitly empty-string value
|
||||
* slipping through, dropping it the same way.
|
||||
*/
|
||||
export function buildHash(tab: TabValue, params: Record<string, string>): string {
|
||||
const entries = Object.entries(params).filter(([, v]) => v !== '')
|
||||
if (entries.length === 0) return tabToHash(tab)
|
||||
return `${tabToHash(tab)}?${new URLSearchParams(entries).toString()}`
|
||||
}
|
||||
|
||||
const HISTORY_WINDOWS: readonly HistoryWindow[] = ['hour', 'day', 'month']
|
||||
const CHART_TABS: readonly ChartTab[] = ['requests', 'tokens', 'cost', 'models']
|
||||
const MODEL_METRICS: readonly ModelMetric[] = ['req', 'tokin', 'tokout', 'cost']
|
||||
|
||||
/** SCOPE_PATTERN mirrors history.ts's own scope string convention: "total", or one of the three "{kind}:{id}" forms — an id may itself contain almost anything (a model id can contain slashes), so each kind only checks its own prefix plus a non-empty remainder, never the id's own shape. */
|
||||
const SCOPE_PATTERN = /^(total|user:.+|group:.+|model:.+)$/
|
||||
|
||||
export interface ChartsHashParams {
|
||||
window?: HistoryWindow
|
||||
tab?: ChartTab
|
||||
metric?: ModelMetric
|
||||
scope?: string
|
||||
/**
|
||||
* The Models tab's provider-prefix filter (stores/history.ts's
|
||||
* modelFilter), round-tripped through the hash so a provider-header
|
||||
* link (nav.goToModels) or a hand-edited `#charts?tab=models&filter=`
|
||||
* URL survives a reload or a hashchange — free text, not an enum, same
|
||||
* convention as `scope` below and Usage's own `q` param.
|
||||
*/
|
||||
filter?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* parseChartsParams validates the Charts tab's own hash query params.
|
||||
* Each field is independently OMITTED (never throws, never substitutes a
|
||||
* fabricated default itself) when absent or not one of its own known
|
||||
* values — a hand-edited or stale-version URL with `window=fortnight` or
|
||||
* `metric=bogus` degrades to "use whatever this field already is"
|
||||
* (composables/useHashState.ts's own caller decides the actual default),
|
||||
* rather than failing the whole hash or resetting every OTHER, valid
|
||||
* field alongside the one bad one.
|
||||
*/
|
||||
export function parseChartsParams(query: URLSearchParams): ChartsHashParams {
|
||||
const result: ChartsHashParams = {}
|
||||
const window = query.get('window')
|
||||
if (window && (HISTORY_WINDOWS as readonly string[]).includes(window)) result.window = window as HistoryWindow
|
||||
const tab = query.get('tab')
|
||||
if (tab && (CHART_TABS as readonly string[]).includes(tab)) result.tab = tab as ChartTab
|
||||
const metric = query.get('metric')
|
||||
if (metric && (MODEL_METRICS as readonly string[]).includes(metric)) result.metric = metric as ModelMetric
|
||||
const scope = query.get('scope')
|
||||
if (scope && SCOPE_PATTERN.test(scope)) result.scope = scope
|
||||
const filter = query.get('filter')
|
||||
if (filter) result.filter = filter
|
||||
return result
|
||||
}
|
||||
|
||||
export interface UsageHashParams {
|
||||
q?: string
|
||||
}
|
||||
|
||||
/** parseUsageParams reads the Usage tab's own single `q` param. It is free-text search input, not an enum, so there is nothing to validate beyond "present and non-empty" — any string round-trips as-is. */
|
||||
export function parseUsageParams(query: URLSearchParams): UsageHashParams {
|
||||
const q = query.get('q')
|
||||
return q ? { q } : {}
|
||||
}
|
||||
|
||||
export interface EventsHashParams {
|
||||
kind?: string
|
||||
user?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* parseEventsParams reads the Events tab's own kind/user params. `kind` is
|
||||
* deliberately NOT validated against AdminEventKind here (unlike Charts'
|
||||
* enum fields above): lib/events-filter.ts's filterEvents already treats
|
||||
* an unrecognized kind as "matches nothing" harmlessly, and rejecting it
|
||||
* here would silently drop a link built against a server-added kind this
|
||||
* webui build predates — the same "still carry it through" convention
|
||||
* AdminEventView.kind's own `| string` union documents (types/api.ts).
|
||||
*/
|
||||
export function parseEventsParams(query: URLSearchParams): EventsHashParams {
|
||||
const result: EventsHashParams = {}
|
||||
const kind = query.get('kind')
|
||||
const user = query.get('user')
|
||||
if (kind) result.kind = kind
|
||||
if (user) result.user = user
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { filterModelsByPrefix } from './model-filter'
|
||||
import type { AdminUsageModelEntry } from '@/types/api'
|
||||
|
||||
const models: AdminUsageModelEntry[] = [
|
||||
{ id: 'openai/gpt-5', value: 10 },
|
||||
{ id: 'openai/gpt-5-mini', value: 5 },
|
||||
{ id: 'anthropic/claude-opus', value: 8 },
|
||||
]
|
||||
|
||||
describe('filterModelsByPrefix', () => {
|
||||
it('returns every entry unchanged when the prefix is empty', () => {
|
||||
expect(filterModelsByPrefix(models, '')).toBe(models)
|
||||
})
|
||||
|
||||
it('keeps only ids starting with the given provider prefix', () => {
|
||||
const result = filterModelsByPrefix(models, 'openai/')
|
||||
expect(result.map((m) => m.id)).toEqual(['openai/gpt-5', 'openai/gpt-5-mini'])
|
||||
})
|
||||
|
||||
it('matches a full id exactly (a single-model prefix)', () => {
|
||||
const result = filterModelsByPrefix(models, 'anthropic/claude-opus')
|
||||
expect(result).toEqual([{ id: 'anthropic/claude-opus', value: 8 }])
|
||||
})
|
||||
|
||||
it('returns an empty array when nothing matches the prefix', () => {
|
||||
expect(filterModelsByPrefix(models, 'mistral/')).toEqual([])
|
||||
})
|
||||
|
||||
it('is case-sensitive (ids are canonical, lowercase provider/model strings)', () => {
|
||||
expect(filterModelsByPrefix(models, 'OpenAI/')).toEqual([])
|
||||
})
|
||||
|
||||
it('does not match a substring that is not a prefix', () => {
|
||||
expect(filterModelsByPrefix(models, 'gpt-5')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { AdminUsageModelEntry } from '@/types/api'
|
||||
|
||||
/**
|
||||
* filterModelsByPrefix narrows the Models tab's already-fetched ranking to
|
||||
* entries whose canonical "provider/model" id starts with `prefix` (F6,
|
||||
* dashboard-plan.md) — the client-side filter ChartsView.vue applies on
|
||||
* top of stores/history.ts's own `modelFilter` state, set by
|
||||
* ProvidersView.vue's provider-header link (nav.goToModels, stores/nav.ts
|
||||
* — WP-B1). An empty prefix is "no filter", returning `models` unchanged
|
||||
* — the default state before any provider link has been clicked.
|
||||
*/
|
||||
export function filterModelsByPrefix(models: AdminUsageModelEntry[], prefix: string): AdminUsageModelEntry[] {
|
||||
if (!prefix) return models
|
||||
return models.filter((m) => m.id.startsWith(prefix))
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createVisibilityPoller, type VisibilityDocumentLike } from './polling'
|
||||
|
||||
/**
|
||||
* createFakeDoc builds a minimal VisibilityDocumentLike test double: a
|
||||
* mutable `hidden` flag the test flips directly, a single
|
||||
* visibilitychange listener slot (real Document supports many, but this
|
||||
* module only ever registers one), and `fire()`/`hasListener()` helpers
|
||||
* so a test can drive and inspect it without any real DOM.
|
||||
*/
|
||||
function createFakeDoc() {
|
||||
let hiddenValue = false
|
||||
let listener: (() => void) | undefined
|
||||
const doc: VisibilityDocumentLike = {
|
||||
get hidden() {
|
||||
return hiddenValue
|
||||
},
|
||||
addEventListener(_type, l) {
|
||||
listener = l
|
||||
},
|
||||
removeEventListener(_type, l) {
|
||||
if (listener === l) listener = undefined
|
||||
},
|
||||
}
|
||||
return {
|
||||
doc,
|
||||
setHidden: (v: boolean) => {
|
||||
hiddenValue = v
|
||||
},
|
||||
fire: () => listener?.(),
|
||||
hasListener: () => listener !== undefined,
|
||||
}
|
||||
}
|
||||
|
||||
describe('createVisibilityPoller', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('ticks immediately on start, before the first interval elapses', () => {
|
||||
const tick = vi.fn()
|
||||
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: createFakeDoc().doc })
|
||||
|
||||
poller.start()
|
||||
|
||||
expect(tick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ticks again every intervalMs while running', () => {
|
||||
const tick = vi.fn()
|
||||
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: createFakeDoc().doc })
|
||||
|
||||
poller.start()
|
||||
vi.advanceTimersByTime(3000)
|
||||
|
||||
expect(tick).toHaveBeenCalledTimes(4) // immediate + 3 interval ticks
|
||||
})
|
||||
|
||||
it('start() is idempotent — calling it twice does not double-schedule', () => {
|
||||
const tick = vi.fn()
|
||||
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: createFakeDoc().doc })
|
||||
|
||||
poller.start()
|
||||
poller.start()
|
||||
vi.advanceTimersByTime(1000)
|
||||
|
||||
expect(tick).toHaveBeenCalledTimes(2) // one immediate + one interval tick, not two of either
|
||||
})
|
||||
|
||||
it('stop() clears the interval — no further ticks', () => {
|
||||
const tick = vi.fn()
|
||||
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: createFakeDoc().doc })
|
||||
|
||||
poller.start()
|
||||
poller.stop()
|
||||
vi.advanceTimersByTime(5000)
|
||||
|
||||
expect(tick).toHaveBeenCalledTimes(1) // only the immediate tick from start()
|
||||
})
|
||||
|
||||
it('stop() is idempotent — safe to call before start() or twice in a row', () => {
|
||||
const tick = vi.fn()
|
||||
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: createFakeDoc().doc })
|
||||
|
||||
expect(() => {
|
||||
poller.stop()
|
||||
poller.start()
|
||||
poller.stop()
|
||||
poller.stop()
|
||||
}).not.toThrow()
|
||||
expect(tick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('pauses polling while the document is hidden', () => {
|
||||
const tick = vi.fn()
|
||||
const fake = createFakeDoc()
|
||||
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: fake.doc })
|
||||
|
||||
poller.start()
|
||||
tick.mockClear()
|
||||
fake.setHidden(true)
|
||||
fake.fire()
|
||||
vi.advanceTimersByTime(5000)
|
||||
|
||||
expect(tick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('catches up immediately when the document becomes visible again, then resumes the interval', () => {
|
||||
const tick = vi.fn()
|
||||
const fake = createFakeDoc()
|
||||
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: fake.doc })
|
||||
|
||||
poller.start()
|
||||
fake.setHidden(true)
|
||||
fake.fire()
|
||||
tick.mockClear()
|
||||
|
||||
fake.setHidden(false)
|
||||
fake.fire()
|
||||
expect(tick).toHaveBeenCalledTimes(1) // the catch-up tick, not waiting out intervalMs
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
expect(tick).toHaveBeenCalledTimes(2) // interval resumed after the catch-up
|
||||
})
|
||||
|
||||
it('stop() removes the visibilitychange listener', () => {
|
||||
const tick = vi.fn()
|
||||
const fake = createFakeDoc()
|
||||
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: fake.doc })
|
||||
|
||||
poller.start()
|
||||
expect(fake.hasListener()).toBe(true)
|
||||
poller.stop()
|
||||
|
||||
expect(fake.hasListener()).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to a plain interval with no visibility awareness when no document is available (vitest\'s node environment, no explicit doc passed)', () => {
|
||||
const tick = vi.fn()
|
||||
const poller = createVisibilityPoller({ intervalMs: 1000, tick })
|
||||
|
||||
poller.start()
|
||||
vi.advanceTimersByTime(2000)
|
||||
|
||||
expect(tick).toHaveBeenCalledTimes(3) // immediate + 2 interval ticks — never throws for lack of `document`
|
||||
poller.stop()
|
||||
})
|
||||
// P13: a poller started while the document is already hidden (e.g. this
|
||||
// admin panel loaded in a background tab) must not tick until the reader
|
||||
// actually looks at it — the pre-fix version ticked immediately and
|
||||
// scheduled a background interval regardless of visibility.
|
||||
it('does not tick immediately when the document is hidden at start', () => {
|
||||
const tick = vi.fn()
|
||||
const fake = createFakeDoc()
|
||||
fake.setHidden(true)
|
||||
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: fake.doc })
|
||||
|
||||
poller.start()
|
||||
|
||||
expect(tick).not.toHaveBeenCalled()
|
||||
poller.stop()
|
||||
})
|
||||
|
||||
it('does not schedule a background interval while started hidden', () => {
|
||||
const tick = vi.fn()
|
||||
const fake = createFakeDoc()
|
||||
fake.setHidden(true)
|
||||
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: fake.doc })
|
||||
|
||||
poller.start()
|
||||
vi.advanceTimersByTime(10_000)
|
||||
|
||||
expect(tick).not.toHaveBeenCalled()
|
||||
poller.stop()
|
||||
})
|
||||
|
||||
it('ticks for the first time once the document becomes visible, having started hidden', () => {
|
||||
const tick = vi.fn()
|
||||
const fake = createFakeDoc()
|
||||
fake.setHidden(true)
|
||||
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: fake.doc })
|
||||
|
||||
poller.start()
|
||||
fake.setHidden(false)
|
||||
fake.fire()
|
||||
|
||||
expect(tick).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
expect(tick).toHaveBeenCalledTimes(2) // interval resumed after the deferred first tick
|
||||
poller.stop()
|
||||
})
|
||||
|
||||
it('start() while hidden is still idempotent — a second call does not schedule a second listener/timer', () => {
|
||||
const tick = vi.fn()
|
||||
const fake = createFakeDoc()
|
||||
fake.setHidden(true)
|
||||
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: fake.doc })
|
||||
|
||||
poller.start()
|
||||
poller.start()
|
||||
fake.setHidden(false)
|
||||
fake.fire()
|
||||
|
||||
expect(tick).toHaveBeenCalledTimes(1) // one catch-up tick, not two
|
||||
poller.stop()
|
||||
})
|
||||
|
||||
it('ticks immediately when the document is already visible at start (unchanged, non-hidden case)', () => {
|
||||
const tick = vi.fn()
|
||||
const fake = createFakeDoc()
|
||||
fake.setHidden(false)
|
||||
const poller = createVisibilityPoller({ intervalMs: 1000, tick, doc: fake.doc })
|
||||
|
||||
poller.start()
|
||||
|
||||
expect(tick).toHaveBeenCalledTimes(1)
|
||||
poller.stop()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
// createVisibilityPoller is the ONE polling primitive stores/dashboard.ts,
|
||||
// stores/history.ts, and stores/events.ts each wire their own refresh
|
||||
// action into (vue.md: "if you've written it twice, you owe an
|
||||
// abstraction") — three near-identical setInterval-plus-immediate-tick
|
||||
// blocks collapsed into one tested module. Beyond the plain interval every
|
||||
// one of them already had, this adds visibility awareness: a background
|
||||
// browser tab has no reason to keep polling a Redis-backed admin panel
|
||||
// every 5s/30s for no reader to see, and it catches back up the instant
|
||||
// the tab is looked at again instead of waiting out whatever fraction of
|
||||
// the interval happened to be left.
|
||||
|
||||
/**
|
||||
* The subset of `Document` this module reads — a structural type, not the
|
||||
* real DOM `Document`, so a test's fake stand-in needs no jsdom (vitest's
|
||||
* 'node' environment, vite.config.ts, has none).
|
||||
*/
|
||||
export interface VisibilityDocumentLike {
|
||||
readonly hidden: boolean
|
||||
addEventListener(type: 'visibilitychange', listener: () => void): void
|
||||
removeEventListener(type: 'visibilitychange', listener: () => void): void
|
||||
}
|
||||
|
||||
export interface VisibilityPollerOptions {
|
||||
/** How often `tick` fires while the page is visible. */
|
||||
intervalMs: number
|
||||
/**
|
||||
* The store's own refresh step. Never awaited here — a caller that
|
||||
* needs its own in-flight guard (history.ts keeps its "skip a tick
|
||||
* while the previous fetch is still in flight" rule) builds that into
|
||||
* `tick` itself, same as before this module existed.
|
||||
*/
|
||||
tick: () => void
|
||||
/**
|
||||
* The document to watch for visibility changes. Defaults to the real
|
||||
* `document` global when one exists. In a non-browser context (vitest's
|
||||
* 'node' test environment, or any future non-DOM caller) `document` is
|
||||
* undefined, which is deliberately the "no document -> plain interval"
|
||||
* fallback: every access to `doc` below is guarded, so importing or
|
||||
* calling this module never throws there — it just behaves like a bare
|
||||
* setInterval with an immediate first tick.
|
||||
*/
|
||||
doc?: VisibilityDocumentLike
|
||||
}
|
||||
|
||||
export interface VisibilityPoller {
|
||||
/** Idempotent: calling start() while already running is a no-op, mirroring the stores' own pre-existing startPolling/startAutoRefresh guards. */
|
||||
start(): void
|
||||
/** Idempotent: safe to call whether or not start() ever ran. */
|
||||
stop(): void
|
||||
}
|
||||
|
||||
/** resolveDoc picks the explicit `doc` option when given, else the real global if one exists, else undefined — computed once at creation, not read live on every tick. */
|
||||
function resolveDoc(doc: VisibilityDocumentLike | undefined): VisibilityDocumentLike | undefined {
|
||||
if (doc) return doc
|
||||
return typeof document === 'undefined' ? undefined : (document as unknown as VisibilityDocumentLike)
|
||||
}
|
||||
|
||||
export function createVisibilityPoller({ intervalMs, tick, doc }: VisibilityPollerOptions): VisibilityPoller {
|
||||
const target = resolveDoc(doc)
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
// P13 review fix: `timer !== undefined` alone used to double as start()'s
|
||||
// idempotency guard, but a poller that STARTS while the page is hidden
|
||||
// (below) never schedules a timer at all until the page is first looked
|
||||
// at — with no separate flag, a second start() call while still hidden
|
||||
// would find `timer` still undefined and incorrectly tick/schedule a
|
||||
// second time. `started` is the idempotency guard now; `timer` is purely
|
||||
// "is a tick currently scheduled".
|
||||
let started = false
|
||||
|
||||
function clearTimer(): void {
|
||||
if (timer === undefined) return
|
||||
clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
|
||||
function scheduleTimer(): void {
|
||||
clearTimer()
|
||||
timer = setInterval(tick, intervalMs)
|
||||
}
|
||||
|
||||
// Pauses on hidden (clear the interval — nobody can see the result of a
|
||||
// background poll) and catches up on visible again: an immediate tick,
|
||||
// not just a freshly (re)started interval that would otherwise make the
|
||||
// reader wait out the full intervalMs before seeing anything current.
|
||||
function onVisibilityChange(): void {
|
||||
if (!target) return
|
||||
if (target.hidden) {
|
||||
clearTimer()
|
||||
} else {
|
||||
tick()
|
||||
scheduleTimer()
|
||||
}
|
||||
}
|
||||
|
||||
// P13: a poller STARTED while the document is already hidden (e.g. this
|
||||
// admin panel loaded in a background tab) must not tick at all until the
|
||||
// reader actually looks at it — ticking immediately regardless of
|
||||
// visibility, the pre-fix behavior, polled a Redis-backed admin endpoint
|
||||
// for a tab nobody could see, for as long as it stayed backgrounded.
|
||||
// Ticking is deferred to onVisibilityChange's own "became visible" branch
|
||||
// above, which already ticks immediately and reschedules the moment the
|
||||
// tab is first shown — so a start-while-hidden poller's first tick is
|
||||
// simply later, never skipped outright.
|
||||
function start(): void {
|
||||
if (started) return
|
||||
started = true
|
||||
if (!target?.hidden) {
|
||||
tick()
|
||||
scheduleTimer()
|
||||
}
|
||||
target?.addEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
started = false
|
||||
clearTimer()
|
||||
target?.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
|
||||
return { start, stop }
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { BAR_AMBER_THRESHOLD, BAR_RED_THRESHOLD, budgetRatios, ratioTier } from './usage-bars'
|
||||
import type { AdminUsageEntryView } from '@/types/api'
|
||||
|
||||
function testEntry(overrides: Partial<AdminUsageEntryView> = {}): AdminUsageEntryView {
|
||||
return {
|
||||
kind: 'user',
|
||||
id: 'alice',
|
||||
requestsPerMinute: 0,
|
||||
requestsPerDay: 0,
|
||||
tokensInPerDay: 0,
|
||||
tokensOutPerDay: 0,
|
||||
tokensInPerMonth: 0,
|
||||
tokensOutPerMonth: 0,
|
||||
costPerDayMicroUsd: 0,
|
||||
costPerMonthMicroUsd: 0,
|
||||
rejectionsPerDay: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ratioTier', () => {
|
||||
const cases: { name: string; ratio: number; expected: 'normal' | 'amber' | 'red' }[] = [
|
||||
{ name: 'zero usage', ratio: 0, expected: 'normal' },
|
||||
{ name: 'just below the amber threshold', ratio: 0.79, expected: 'normal' },
|
||||
{ name: 'exactly at the amber threshold', ratio: BAR_AMBER_THRESHOLD, expected: 'amber' },
|
||||
{ name: 'between amber and red', ratio: 0.99, expected: 'amber' },
|
||||
{ name: 'exactly at the red threshold', ratio: BAR_RED_THRESHOLD, expected: 'red' },
|
||||
{ name: 'over the limit', ratio: 1.5, expected: 'red' },
|
||||
{ name: 'negative (defensive)', ratio: -0.2, expected: 'normal' },
|
||||
{ name: 'NaN (defensive)', ratio: Number.NaN, expected: 'normal' },
|
||||
]
|
||||
|
||||
it.each(cases)('$name -> $expected', ({ ratio, expected }) => {
|
||||
expect(ratioTier(ratio)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('budgetRatios', () => {
|
||||
it('returns an empty array when the entry has no limits configured', () => {
|
||||
expect(budgetRatios(testEntry())).toEqual([])
|
||||
})
|
||||
|
||||
it('returns an empty array when limits is present but every field is 0/omitted (LimitsConfig "unlimited" convention)', () => {
|
||||
expect(budgetRatios(testEntry({ limits: {} }))).toEqual([])
|
||||
})
|
||||
|
||||
it('returns an empty array for a storeDown entry even with limits configured — every counter is stale/unknown', () => {
|
||||
expect(budgetRatios(testEntry({ limits: { requestsPerDay: 100 }, requestsPerDay: 50, storeDown: true }))).toEqual([])
|
||||
})
|
||||
|
||||
it('includes only the limits actually configured, one BudgetRatio each', () => {
|
||||
const ratios = budgetRatios(testEntry({ limits: { requestsPerMinute: 10 }, requestsPerMinute: 5 }))
|
||||
expect(ratios).toEqual([{ id: 'reqMin', used: 5, limit: 10, ratio: 0.5 }])
|
||||
})
|
||||
|
||||
it('combines tokensInPerDay + tokensOutPerDay into one tokDay bar against tokensPerDay', () => {
|
||||
const ratios = budgetRatios(
|
||||
testEntry({ limits: { tokensPerDay: 1000 }, tokensInPerDay: 300, tokensOutPerDay: 400 }),
|
||||
)
|
||||
expect(ratios).toEqual([{ id: 'tokDay', used: 700, limit: 1000, ratio: 0.7 }])
|
||||
})
|
||||
|
||||
it('combines tokensInPerMonth + tokensOutPerMonth into one tokMonth bar against tokensPerMonth', () => {
|
||||
const ratios = budgetRatios(
|
||||
testEntry({ limits: { tokensPerMonth: 2000 }, tokensInPerMonth: 900, tokensOutPerMonth: 300 }),
|
||||
)
|
||||
expect(ratios).toEqual([{ id: 'tokMonth', used: 1200, limit: 2000, ratio: 0.6 }])
|
||||
})
|
||||
|
||||
it('converts costPerDayUSD to micro-USD (Math.round(usd*1e6)) for the costDay bar', () => {
|
||||
const ratios = budgetRatios(
|
||||
testEntry({ limits: { costPerDayUSD: 0.5 }, costPerDayMicroUsd: 250_000 }),
|
||||
)
|
||||
expect(ratios).toEqual([{ id: 'costDay', used: 250_000, limit: 500_000, ratio: 0.5 }])
|
||||
})
|
||||
|
||||
it('converts costPerMonthUSD to micro-USD for the costMonth bar', () => {
|
||||
const ratios = budgetRatios(
|
||||
testEntry({ limits: { costPerMonthUSD: 10 }, costPerMonthMicroUsd: 12_000_000 }),
|
||||
)
|
||||
expect(ratios).toEqual([{ id: 'costMonth', used: 12_000_000, limit: 10_000_000, ratio: 1.2 }])
|
||||
})
|
||||
|
||||
// P11 review fix: a configured cost limit tiny enough to round to 0
|
||||
// micro-USD must be SKIPPED, exactly like Go does (limits.go/metrics.go:
|
||||
// `limit <= 0` skips the check) — never pushed as a ratio dividing by 0.
|
||||
it('skips a costDay limit that rounds to 0 micro-USD, instead of dividing by 0', () => {
|
||||
const ratios = budgetRatios(testEntry({ limits: { costPerDayUSD: 0.0000001 }, costPerDayMicroUsd: 5 }))
|
||||
expect(ratios).toEqual([])
|
||||
})
|
||||
|
||||
it('skips a costMonth limit that rounds to 0 micro-USD, instead of dividing by 0', () => {
|
||||
const ratios = budgetRatios(testEntry({ limits: { costPerMonthUSD: 0.0000001 }, costPerMonthMicroUsd: 5 }))
|
||||
expect(ratios).toEqual([])
|
||||
})
|
||||
|
||||
it('returns one BudgetRatio per configured limit, in a fixed order, when every limit is set', () => {
|
||||
const ratios = budgetRatios(
|
||||
testEntry({
|
||||
limits: {
|
||||
requestsPerMinute: 10,
|
||||
requestsPerDay: 100,
|
||||
tokensPerDay: 1000,
|
||||
tokensPerMonth: 20000,
|
||||
costPerDayUSD: 1,
|
||||
costPerMonthUSD: 20,
|
||||
},
|
||||
requestsPerMinute: 1,
|
||||
requestsPerDay: 2,
|
||||
tokensInPerDay: 3,
|
||||
tokensOutPerDay: 4,
|
||||
tokensInPerMonth: 5,
|
||||
tokensOutPerMonth: 6,
|
||||
costPerDayMicroUsd: 7,
|
||||
costPerMonthMicroUsd: 8,
|
||||
}),
|
||||
)
|
||||
expect(ratios.map((r) => r.id)).toEqual(['reqMin', 'reqDay', 'tokDay', 'tokMonth', 'costDay', 'costMonth'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
// F1 (usage-vs-limit bars): pure ratio math for the Usage tab's per-scope
|
||||
// budget bars (UsageBar.vue), kept out of the component the same way
|
||||
// lib/provider-rate.ts keeps the Providers tab's success-rate math out of
|
||||
// ProvidersView.vue — vitest's node-environment config (vite.config.ts's
|
||||
// `test` block) exercises it directly, no component mount required.
|
||||
import type { AdminUsageEntryView } from '@/types/api'
|
||||
|
||||
/** BAR_AMBER_THRESHOLD is the used/limit ratio at and above which a bar reads amber ("approaching the limit") — below it, a bar reads its normal (healthy) tier. */
|
||||
export const BAR_AMBER_THRESHOLD = 0.8
|
||||
|
||||
/** BAR_RED_THRESHOLD is the used/limit ratio at and above which a bar reads red ("at or over the limit") — a scope can exceed 1.0 (its usage window rolled forward past the limit before the next request was rejected), so this is a floor, not a ceiling. */
|
||||
export const BAR_RED_THRESHOLD = 1.0
|
||||
|
||||
/** RatioTier is UsageBar.vue's own color tier, derived purely from a used/limit ratio — 'normal' below BAR_AMBER_THRESHOLD, 'amber' from there up to BAR_RED_THRESHOLD, 'red' at or above it. */
|
||||
export type RatioTier = 'normal' | 'amber' | 'red'
|
||||
|
||||
/** ratioTier classifies a used/limit ratio into UsageBar.vue's three color tiers. A negative or NaN ratio (should not happen — every caller here derives ratio from non-negative usage/limit numbers) reads as 'normal', the safest default. */
|
||||
export function ratioTier(ratio: number): RatioTier {
|
||||
if (!Number.isFinite(ratio) || ratio < BAR_AMBER_THRESHOLD) return 'normal'
|
||||
if (ratio < BAR_RED_THRESHOLD) return 'amber'
|
||||
return 'red'
|
||||
}
|
||||
|
||||
/**
|
||||
* BudgetRatio is one configured limit's current used/limit reading for a
|
||||
* scope — `id` names WHICH limit ("reqMin", "reqDay", "tokDay", "tokMonth",
|
||||
* "costDay", "costMonth", matching usage-columns.ts's own column ids for
|
||||
* the identical underlying figure), `used`/`limit` are the raw comparable
|
||||
* numbers (requests, tokens, or micro-USD — never mixed units within one
|
||||
* BudgetRatio), and `ratio` is `used / limit`.
|
||||
*/
|
||||
export interface BudgetRatio {
|
||||
id: string
|
||||
used: number
|
||||
limit: number
|
||||
ratio: number
|
||||
}
|
||||
|
||||
/**
|
||||
* MICROS_PER_USD converts a LimitsConfig cost limit (plain USD, e.g.
|
||||
* `costPerDayUSD: 5`) into the same micro-USD unit AdminUsageEntryView's
|
||||
* own cost fields already use — matching metrics.go's usdToMicros(round)
|
||||
* convention (ground truth, dashboard-plan.md section 0).
|
||||
*
|
||||
* P11 review fix (DRY): this constant used to be declared independently
|
||||
* three times (here, lib/forecast.ts, lib/usage-columns.ts) — this is now
|
||||
* the ONE definition; lib/forecast.ts imports it, and lib/usage-columns.ts
|
||||
* no longer needs its own copy at all (it now reads limits through
|
||||
* budgetRatios below instead of re-deriving them — see numericColumn's own
|
||||
* doc comment there).
|
||||
*/
|
||||
export const MICROS_PER_USD = 1_000_000
|
||||
|
||||
/**
|
||||
* BUDGET_RATIO_LABEL (P10) names each BudgetRatio id in accurate,
|
||||
* accessible text — shared by UsageView.vue's group-detail-grid bars and
|
||||
* usage-columns.ts's own per-column bars (UsageBar's `label` prop, its
|
||||
* aria-label). tokDay/tokMonth spell out "(in+out)" explicitly: the
|
||||
* underlying ratio combines both directions (see budgetRatios' own doc
|
||||
* comment below), and unlike a sighted reader — who sees the separate
|
||||
* tokIn/tokOut columns beside it for context — a screen-reader user
|
||||
* hearing just "tokIn/day, 1,500 / 2,000" next to a cell showing 500 has
|
||||
* no other cue that the meter is measuring the COMBINED total, not that
|
||||
* column's own count.
|
||||
*/
|
||||
export const BUDGET_RATIO_LABEL: Record<string, string> = {
|
||||
reqMin: 'req/min',
|
||||
reqDay: 'req/day',
|
||||
tokDay: 'tokens (in+out)/day',
|
||||
tokMonth: 'tokens (in+out)/month',
|
||||
costDay: 'cost/day',
|
||||
costMonth: 'cost/month',
|
||||
}
|
||||
|
||||
function ratioOf(id: string, used: number, limit: number): BudgetRatio {
|
||||
return { id, used, limit, ratio: used / limit }
|
||||
}
|
||||
|
||||
/**
|
||||
* budgetRatios returns one BudgetRatio per limit the entry actually has
|
||||
* CONFIGURED (LimitsConfig's own "0/omitted means unlimited" convention —
|
||||
* formatLimits, lib/format.ts, applies the identical `if (limits.x)` gate).
|
||||
* A scope with no limits configured at all returns an empty array, and
|
||||
* UsageBar.vue simply renders nothing for it — no bar implies no limit,
|
||||
* never a fabricated 0%.
|
||||
*
|
||||
* Token limits combine tokensIn + tokensOut into one bar (coordinator
|
||||
* decision, dashboard-plan.md section 3: "tokens in+out") — LimitsConfig
|
||||
* enforces tokensPerDay/tokensPerMonth against the SUM of in+out
|
||||
* (llmgateway.go ground truth), so a bar split by direction would show two
|
||||
* numbers that individually stay under the limit while their sum has
|
||||
* already crossed it.
|
||||
*
|
||||
* A storeDown entry returns an empty array too — every counter on a
|
||||
* storeDown scope is stale/unknown (usage-columns.ts's own "?" masking
|
||||
* convention), so a bar computed from it would show a fabricated,
|
||||
* possibly wildly wrong ratio rather than the "unknown" state the rest of
|
||||
* this panel already renders for that case.
|
||||
*/
|
||||
export function budgetRatios(entry: AdminUsageEntryView): BudgetRatio[] {
|
||||
const limits = entry.limits
|
||||
if (!limits || entry.storeDown) return []
|
||||
const out: BudgetRatio[] = []
|
||||
if (limits.requestsPerMinute) out.push(ratioOf('reqMin', entry.requestsPerMinute, limits.requestsPerMinute))
|
||||
if (limits.requestsPerDay) out.push(ratioOf('reqDay', entry.requestsPerDay, limits.requestsPerDay))
|
||||
if (limits.tokensPerDay) {
|
||||
out.push(ratioOf('tokDay', entry.tokensInPerDay + entry.tokensOutPerDay, limits.tokensPerDay))
|
||||
}
|
||||
if (limits.tokensPerMonth) {
|
||||
out.push(ratioOf('tokMonth', entry.tokensInPerMonth + entry.tokensOutPerMonth, limits.tokensPerMonth))
|
||||
}
|
||||
// P11 review fix: the two USD->micro-USD limit paths used to disagree —
|
||||
// this used to gate on the RAW USD value being truthy, then round; a
|
||||
// configured limit below 0.5 micro-USD (e.g. costPerDayUSD: 0.0000001)
|
||||
// passed that truthy check but rounded to exactly 0 micro-USD, so
|
||||
// ratioOf divided by 0 (an Infinity or NaN ratio). usage-columns.ts's own
|
||||
// usdLimitMicros rounded FIRST, then checked the ROUNDED result for
|
||||
// truthy — matching Go's own convention (limits.go/metrics.go: `limit <=
|
||||
// 0` skips the check entirely). Rounding first here too, then gating on
|
||||
// the rounded value, makes both paths agree with Go: a limit that rounds
|
||||
// to 0 micro-USD is skipped, exactly like an unconfigured one.
|
||||
if (limits.costPerDayUSD) {
|
||||
const limitMicros = Math.round(limits.costPerDayUSD * MICROS_PER_USD)
|
||||
if (limitMicros > 0) out.push(ratioOf('costDay', entry.costPerDayMicroUsd, limitMicros))
|
||||
}
|
||||
if (limits.costPerMonthUSD) {
|
||||
const limitMicros = Math.round(limits.costPerMonthUSD * MICROS_PER_USD)
|
||||
if (limitMicros > 0) out.push(ratioOf('costMonth', entry.costPerMonthMicroUsd, limitMicros))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { VNode } from 'vue'
|
||||
import { isVNode } from 'vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import ScopeLink from '@/components/ScopeLink.vue'
|
||||
import { usageColumns } from './usage-columns'
|
||||
import type { AdminUsageEntryView } from '@/types/api'
|
||||
|
||||
@@ -24,6 +26,7 @@ function testEntry(overrides: Partial<AdminUsageEntryView> = {}): AdminUsageEntr
|
||||
tokensOutPerMonth: 0,
|
||||
costPerDayMicroUsd: 1_234_500,
|
||||
costPerMonthMicroUsd: 0,
|
||||
rejectionsPerDay: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -38,6 +41,10 @@ function accessorFnOf(col: unknown): (row: AdminUsageEntryView, index: number) =
|
||||
return (col as { accessorFn: (row: AdminUsageEntryView, index: number) => unknown }).accessorFn
|
||||
}
|
||||
|
||||
function cellOf(col: unknown): (ctx: { row: { original: AdminUsageEntryView } }) => unknown {
|
||||
return (col as { cell: (ctx: { row: { original: AdminUsageEntryView } }) => unknown }).cell
|
||||
}
|
||||
|
||||
describe('usageColumns: sorting stays on raw numeric values', () => {
|
||||
const columns = usageColumns('Name', 'Group', () => 'g')
|
||||
|
||||
@@ -46,39 +53,34 @@ describe('usageColumns: sorting stays on raw numeric values', () => {
|
||||
expect(accessorFnOf(col)(testEntry(), 0)).toBe(15_000_000)
|
||||
})
|
||||
|
||||
it('a count column cell renders a CompactNumber vnode, not the raw number as text', () => {
|
||||
it('a count column cell renders a CompactNumber vnode, not the raw number as text, when no limit is configured', () => {
|
||||
const col = columns.find((c) => c.id === 'reqDay')
|
||||
// ColumnDef's cell can be a string or a render function; every
|
||||
// numericColumn call here uses the function form.
|
||||
const cell = col?.cell as (ctx: { row: { original: AdminUsageEntryView } }) => unknown
|
||||
const rendered = cell({ row: { original: testEntry() } })
|
||||
const rendered = cellOf(col)({ row: { original: testEntry() } })
|
||||
expect(isVNode(rendered)).toBe(true)
|
||||
})
|
||||
|
||||
it('a cost column cell still renders plain formatted text (compact:false), not a CompactNumber vnode', () => {
|
||||
it('a cost column cell still renders plain formatted text (compact:false), not a CompactNumber vnode, when no limit is configured', () => {
|
||||
const col = columns.find((c) => c.id === 'costDay')
|
||||
const cell = col?.cell as (ctx: { row: { original: AdminUsageEntryView } }) => unknown
|
||||
const rendered = cell({ row: { original: testEntry() } })
|
||||
const rendered = cellOf(col)({ row: { original: testEntry() } })
|
||||
expect(isVNode(rendered)).toBe(false)
|
||||
expect(rendered).toBe('$1.2345')
|
||||
})
|
||||
|
||||
it('a storeDown row still masks every count cell as "?" regardless of compact rendering', () => {
|
||||
const col = columns.find((c) => c.id === 'tokInDay')
|
||||
const cell = col?.cell as (ctx: { row: { original: AdminUsageEntryView } }) => unknown
|
||||
const rendered = cell({ row: { original: testEntry({ storeDown: true }) } })
|
||||
const rendered = cellOf(col)({ row: { original: testEntry({ storeDown: true }) } })
|
||||
expect(rendered).toBe('?')
|
||||
})
|
||||
|
||||
// Review finding: TanStack's getAutoSortingFn only inspects rows 11+
|
||||
// (RowSorting.js's own `flatRows.slice(10)`) to auto-detect numeric vs.
|
||||
// string sorting, so a Groups table with 10 or fewer rows silently fell
|
||||
// back to `basic` (plain string a > b comparison) for the Members
|
||||
// column, sorting "10" before "9". An explicit sortingFn sidesteps that
|
||||
// row-count-dependent auto-detection entirely.
|
||||
// back to `basic` (plain string a > b comparison), sorting "10" before "9".
|
||||
// An explicit sortingFn sidesteps that row-count-dependent auto-detection
|
||||
// entirely.
|
||||
it('the secondary (Members/Group) column declares an explicit sortingFn, not left to row-count-dependent auto-detection', () => {
|
||||
const col = columns.find((c) => c.id === 'secondary')
|
||||
expect(col?.sortingFn).toBe('alphanumeric')
|
||||
expect((col as { sortingFn?: string }).sortingFn).toBe('alphanumeric')
|
||||
})
|
||||
|
||||
it('sorting a set of entries by reqDay orders numerically, not lexicographically (the bug this separation prevents)', () => {
|
||||
@@ -93,3 +95,181 @@ describe('usageColumns: sorting stays on raw numeric values', () => {
|
||||
// accessorFn must stay numeric.
|
||||
})
|
||||
})
|
||||
|
||||
// F1 (usage-vs-limit bars): a column built with limitOf renders a UsageBar
|
||||
// beside its number ONLY when the row's own limit is actually configured
|
||||
// and the row is not storeDown — no fabricated bar for an unlimited scope.
|
||||
describe('usageColumns: F1 usage-vs-limit bars', () => {
|
||||
const columns = usageColumns('Name', 'Group', () => 'g')
|
||||
|
||||
it('reqDay renders a bare CompactNumber vnode, no bar, when the row has no limit configured', () => {
|
||||
const col = columns.find((c) => c.id === 'reqDay')
|
||||
const rendered = cellOf(col)({ row: { original: testEntry() } }) as VNode
|
||||
expect(rendered.type).not.toBe('span')
|
||||
})
|
||||
|
||||
it('reqDay wraps the number with a UsageBar when the row has that limit configured', () => {
|
||||
const col = columns.find((c) => c.id === 'reqDay')
|
||||
const entry = testEntry({ limits: { requestsPerDay: 100 }, requestsPerDay: 40 })
|
||||
const rendered = cellOf(col)({ row: { original: entry } }) as VNode
|
||||
expect(isVNode(rendered)).toBe(true)
|
||||
expect(rendered.type).toBe('span')
|
||||
const children = rendered.children as VNode[]
|
||||
expect(children).toHaveLength(2)
|
||||
expect(children[1].props?.ratio).toBeCloseTo(0.4)
|
||||
})
|
||||
|
||||
it('the token-day columns measure the bar against the COMBINED in+out total, not each column\'s own individual count', () => {
|
||||
const col = columns.find((c) => c.id === 'tokInDay')
|
||||
const entry = testEntry({ limits: { tokensPerDay: 1000 }, tokensInPerDay: 300, tokensOutPerDay: 400 })
|
||||
const rendered = cellOf(col)({ row: { original: entry } }) as VNode
|
||||
const children = rendered.children as VNode[]
|
||||
expect(children[1].props?.ratio).toBeCloseTo(0.7)
|
||||
})
|
||||
|
||||
it('a cost column converts its USD limit to micro-USD before computing the ratio', () => {
|
||||
const col = columns.find((c) => c.id === 'costDay')
|
||||
const entry = testEntry({ limits: { costPerDayUSD: 1 }, costPerDayMicroUsd: 250_000 })
|
||||
const rendered = cellOf(col)({ row: { original: entry } }) as VNode
|
||||
const children = rendered.children as VNode[]
|
||||
expect(children[1].props?.ratio).toBeCloseTo(0.25)
|
||||
})
|
||||
|
||||
it('renders no bar for a storeDown row even when a limit is configured', () => {
|
||||
const col = columns.find((c) => c.id === 'reqDay')
|
||||
const entry = testEntry({ limits: { requestsPerDay: 100 }, storeDown: true })
|
||||
expect(cellOf(col)({ row: { original: entry } })).toBe('?')
|
||||
})
|
||||
|
||||
// P10 review fix: the token columns' bar must announce the COMBINED
|
||||
// in+out measurement it actually shows, not the column's own single-
|
||||
// direction header text — a screen-reader user has no other column
|
||||
// beside it to infer that from, unlike a sighted reader.
|
||||
it('the tokIn/day column\'s bar label spells out "tokens (in+out)/day", not the column header "tokIn/day"', () => {
|
||||
const col = columns.find((c) => c.id === 'tokInDay')
|
||||
const entry = testEntry({ limits: { tokensPerDay: 1000 }, tokensInPerDay: 300, tokensOutPerDay: 400 })
|
||||
const rendered = cellOf(col)({ row: { original: entry } }) as VNode
|
||||
const children = rendered.children as VNode[]
|
||||
expect(children[1].props?.label).toBe('tokens (in+out)/day')
|
||||
})
|
||||
|
||||
it('the tokOut/month column\'s bar label spells out "tokens (in+out)/month"', () => {
|
||||
const col = columns.find((c) => c.id === 'tokOutMonth')
|
||||
const entry = testEntry({ limits: { tokensPerMonth: 1000 }, tokensInPerMonth: 300, tokensOutPerMonth: 400 })
|
||||
const rendered = cellOf(col)({ row: { original: entry } }) as VNode
|
||||
const children = rendered.children as VNode[]
|
||||
expect(children[1].props?.label).toBe('tokens (in+out)/month')
|
||||
})
|
||||
|
||||
it('the req/day column\'s bar label is "req/day" (matches its own header — no in+out ambiguity there)', () => {
|
||||
const col = columns.find((c) => c.id === 'reqDay')
|
||||
const entry = testEntry({ limits: { requestsPerDay: 100 }, requestsPerDay: 40 })
|
||||
const rendered = cellOf(col)({ row: { original: entry } }) as VNode
|
||||
const children = rendered.children as VNode[]
|
||||
expect(children[1].props?.label).toBe('req/day')
|
||||
})
|
||||
|
||||
// P11 review fix: numericColumn now reuses budgetRatios (lib/usage-bars.ts)
|
||||
// instead of its own dropped limitOf/usdLimitMicros — a cost limit that
|
||||
// rounds to 0 micro-USD is skipped (no bar, no Infinity/NaN ratio),
|
||||
// exactly like Go, rather than the column's old independent conversion
|
||||
// disagreeing with budgetRatios about it.
|
||||
it('costDay renders no bar (skips, does not divide by 0) when the configured limit rounds to 0 micro-USD', () => {
|
||||
const col = columns.find((c) => c.id === 'costDay')
|
||||
const entry = testEntry({ limits: { costPerDayUSD: 0.0000001 }, costPerDayMicroUsd: 5 })
|
||||
const rendered = cellOf(col)({ row: { original: entry } })
|
||||
expect(isVNode(rendered)).toBe(false) // plain formatted text, not a UsageBar-wrapping span
|
||||
})
|
||||
})
|
||||
|
||||
// F6 (ScopeLink id cell): the id column renders a ScopeLink pointed at
|
||||
// this row's own "kind:id" Charts scope, never a plain span — the click-
|
||||
// through-to-Charts affordance every Users/Groups/Members row gets.
|
||||
describe('usageColumns: F6 id cell is a ScopeLink', () => {
|
||||
const columns = usageColumns('Name', 'Group', () => 'g')
|
||||
const col = columns.find((c) => c.id === 'id')
|
||||
|
||||
it('renders a ScopeLink vnode, not a plain span', () => {
|
||||
const rendered = cellOf(col)({ row: { original: testEntry({ id: 'alice', kind: 'user' }) } }) as VNode
|
||||
expect(rendered.type).toBe(ScopeLink)
|
||||
})
|
||||
|
||||
it('builds the scope as "user:{id}" for a user row', () => {
|
||||
const rendered = cellOf(col)({ row: { original: testEntry({ id: 'alice', kind: 'user' }) } }) as VNode
|
||||
expect(rendered.props?.scope).toBe('user:alice')
|
||||
expect(rendered.props?.label).toBe('alice')
|
||||
})
|
||||
|
||||
it('builds the scope as "group:{id}" for a group row', () => {
|
||||
const rendered = cellOf(col)({ row: { original: testEntry({ id: 'eng', kind: 'group' }) } }) as VNode
|
||||
expect(rendered.props?.scope).toBe('group:eng')
|
||||
})
|
||||
|
||||
it('is not masked by storeDown — a scope link stays clickable even for a row with stale/unknown counters', () => {
|
||||
const rendered = cellOf(col)({ row: { original: testEntry({ id: 'alice', kind: 'user', storeDown: true }) } }) as VNode
|
||||
expect(rendered.type).toBe(ScopeLink)
|
||||
})
|
||||
})
|
||||
|
||||
// F4 (rejected/day column): destructive tint applies only once the count
|
||||
// is actually positive — a healthy scope (0 rejections) renders plain,
|
||||
// matching every other "nothing to show reads as no flag" indicator in
|
||||
// this panel (ProvidersView.vue's badges).
|
||||
describe('usageColumns: F4 rejected/day column', () => {
|
||||
const columns = usageColumns('Name', 'Group', () => 'g')
|
||||
const col = columns.find((c) => c.id === 'rejDay')
|
||||
|
||||
it('accessorFn reads the raw rejectionsPerDay number', () => {
|
||||
expect(accessorFnOf(col)(testEntry({ rejectionsPerDay: 12 }), 0)).toBe(12)
|
||||
})
|
||||
|
||||
it('renders no destructive class when rejectionsPerDay is 0', () => {
|
||||
const rendered = cellOf(col)({ row: { original: testEntry({ rejectionsPerDay: 0 }) } }) as VNode
|
||||
expect(rendered.props?.class).toBeUndefined()
|
||||
})
|
||||
|
||||
it('renders a destructive class when rejectionsPerDay is positive', () => {
|
||||
const rendered = cellOf(col)({ row: { original: testEntry({ rejectionsPerDay: 3 }) } }) as VNode
|
||||
expect(rendered.props?.class).toBe('text-destructive')
|
||||
})
|
||||
|
||||
it('masks as "?" for a storeDown row', () => {
|
||||
expect(cellOf(col)({ row: { original: testEntry({ storeDown: true }) } })).toBe('?')
|
||||
})
|
||||
})
|
||||
|
||||
// F7 (cost forecast per row): costMonthProjColumn extrapolates each row's
|
||||
// own costPerMonthMicroUsd against the SAME `now` every row in one
|
||||
// usageColumns() call shares — passed in explicitly so this stays
|
||||
// deterministic under test (coordinator brief: "now passed as prop for
|
||||
// determinism").
|
||||
describe('usageColumns: F7 cost/month (proj.) column', () => {
|
||||
// Fixed at exactly the midpoint of a UTC 30-day month (April 2026) so
|
||||
// elapsedFraction is a clean 0.5 — projectMonthEnd(mtd, 0.5) == mtd*2.
|
||||
const now = new Date(Date.UTC(2026, 3, 16, 0, 0, 0))
|
||||
const columns = usageColumns('Name', 'Group', () => 'g', now)
|
||||
const col = columns.find((c) => c.id === 'costMonthProj')
|
||||
|
||||
it('renders the projected month-end cost, formatted', () => {
|
||||
const entry = testEntry({ costPerMonthMicroUsd: 5_000_000 })
|
||||
const rendered = cellOf(col)({ row: { original: entry } }) as VNode
|
||||
expect(rendered.children).toBe('$10.0000')
|
||||
})
|
||||
|
||||
it('renders a destructive class + title when the projection exceeds the configured cost/month limit', () => {
|
||||
const entry = testEntry({ costPerMonthMicroUsd: 5_000_000, limits: { costPerMonthUSD: 8 } })
|
||||
const rendered = cellOf(col)({ row: { original: entry } }) as VNode
|
||||
expect(rendered.props?.class).toBe('text-destructive')
|
||||
expect(rendered.props?.title).toMatch(/exceed/)
|
||||
})
|
||||
|
||||
it('renders no destructive class when the projection stays under the limit', () => {
|
||||
const entry = testEntry({ costPerMonthMicroUsd: 5_000_000, limits: { costPerMonthUSD: 20 } })
|
||||
const rendered = cellOf(col)({ row: { original: entry } }) as VNode
|
||||
expect(rendered.props?.class).toBeUndefined()
|
||||
})
|
||||
|
||||
it('masks as "?" for a storeDown row', () => {
|
||||
expect(cellOf(col)({ row: { original: testEntry({ storeDown: true }) } })).toBe('?')
|
||||
})
|
||||
})
|
||||
|
||||
+131
-12
@@ -2,7 +2,11 @@ import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import { h } from 'vue'
|
||||
|
||||
import CompactNumber from '@/components/CompactNumber.vue'
|
||||
import { formatCost, formatLimits } from '@/lib/format'
|
||||
import ScopeLink from '@/components/ScopeLink.vue'
|
||||
import UsageBar from '@/components/UsageBar.vue'
|
||||
import { headroom, monthProgress, projectMonthEnd } from '@/lib/forecast'
|
||||
import { formatCost, formatExactInt, formatLimits } from '@/lib/format'
|
||||
import { BUDGET_RATIO_LABEL, budgetRatios } from '@/lib/usage-bars'
|
||||
import type { AdminUsageEntryView } from '@/types/api'
|
||||
|
||||
/**
|
||||
@@ -22,6 +26,35 @@ import type { AdminUsageEntryView } from '@/types/api'
|
||||
* this feature's concern. The accessorFn (and therefore sorting) always
|
||||
* reads the true raw number either way — only the cell's rendered text
|
||||
* changes.
|
||||
*
|
||||
* F1 (usage-vs-limit bars): `budgetId` optionally names WHICH
|
||||
* lib/usage-bars.ts BudgetRatio this column illustrates (one of
|
||||
* budgetRatios' own ids — "reqMin", "reqDay", "tokDay", "tokMonth",
|
||||
* "costDay", "costMonth"). When the row is not storeDown and
|
||||
* budgetRatios(row) actually contains that id (i.e. the scope has that
|
||||
* limit configured), the cell renders a UsageBar beside the number.
|
||||
*
|
||||
* P11 review fix (DRY + correctness): this used to take its own
|
||||
* `limitOf`/`usedOf` accessor pair and re-derive the USD->micro-USD
|
||||
* conversion locally (a dropped usdLimitMicros helper) — a second,
|
||||
* independent implementation of exactly what budgetRatios already computes,
|
||||
* and one that disagreed with it: budgetRatios gated on the RAW USD value
|
||||
* being truthy before rounding (a limit rounding to 0 micro-USD produced an
|
||||
* Infinity/NaN ratio there), while this column's own usdLimitMicros rounded
|
||||
* first — the two could show a bar in one place, mask it in the other, for
|
||||
* the identical scope. Reusing budgetRatios here (now fixed to round-then-
|
||||
* skip, matching Go — see that module's own doc comment) makes both paths
|
||||
* agree by construction: there is only one ratio computation left, not two.
|
||||
*
|
||||
* BUDGET_RATIO_LABEL[budgetId] (P10 review fix), not the column's own
|
||||
* `header`, backs the bar's accessible label: the token columns'
|
||||
* "tokDay"/"tokMonth" ratio combines BOTH directions
|
||||
* (budgetRatios' own doc comment), so the bar's aria-label spells out
|
||||
* "tokens (in+out)/day" — the column HEADER stays "tokIn/day"/"tokOut/day"
|
||||
* (that identity is real and worth keeping visually), but a screen-reader
|
||||
* user hearing just "tokIn/day, 1,500 / 2,000" beside a cell showing 500
|
||||
* has no other cue that the meter measures the combined total, not that
|
||||
* column's own count.
|
||||
*/
|
||||
function numericColumn(
|
||||
id: string,
|
||||
@@ -29,6 +62,7 @@ function numericColumn(
|
||||
read: (entry: AdminUsageEntryView) => number,
|
||||
format: (n: number) => string = String,
|
||||
compact = true,
|
||||
budgetId?: string,
|
||||
): ColumnDef<AdminUsageEntryView, unknown> {
|
||||
return {
|
||||
id,
|
||||
@@ -38,7 +72,80 @@ function numericColumn(
|
||||
cell: ({ row }) => {
|
||||
if (row.original.storeDown) return '?'
|
||||
const value = read(row.original)
|
||||
return compact ? h(CompactNumber, { value }) : format(value)
|
||||
const valueNode = compact ? h(CompactNumber, { value }) : format(value)
|
||||
if (!budgetId) return valueNode
|
||||
const ratio = budgetRatios(row.original).find((r) => r.id === budgetId)
|
||||
if (!ratio) return valueNode
|
||||
const valueText = compact
|
||||
? `${formatExactInt(ratio.used)} / ${formatExactInt(ratio.limit)}`
|
||||
: `${format(ratio.used)} / ${format(ratio.limit)}`
|
||||
return h('span', { class: 'inline-flex items-center justify-end gap-1.5' }, [
|
||||
valueNode,
|
||||
h(UsageBar, { ratio: ratio.ratio, label: BUDGET_RATIO_LABEL[budgetId] ?? header, valueText }),
|
||||
])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* rejDayColumn (F4) renders rejectionsPerDay — fleet-wide requests denied
|
||||
* by a limit check today (types/api.ts's own doc comment). Not built via
|
||||
* numericColumn: it needs a destructive tint whenever the count is
|
||||
* positive at all (coordinator brief: "destructive tint when > 0"),
|
||||
* independent of any configured limit/bar — a rejection count itself IS
|
||||
* the signal, there is nothing to compare it against.
|
||||
*/
|
||||
function rejDayColumn(): ColumnDef<AdminUsageEntryView, unknown> {
|
||||
return {
|
||||
id: 'rejDay',
|
||||
header: 'rejected/day',
|
||||
accessorFn: (entry) => entry.rejectionsPerDay,
|
||||
meta: { align: 'right' },
|
||||
cell: ({ row }) => {
|
||||
if (row.original.storeDown) return '?'
|
||||
const value = row.original.rejectionsPerDay
|
||||
return h(CompactNumber, { value, class: value > 0 ? 'text-destructive' : undefined })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* costMonthProjColumn (F7) renders lib/forecast.ts's projected month-end
|
||||
* cost for each row, extrapolated from that row's own costPerMonthMicroUsd
|
||||
* — the same math CostForecastCard.vue applies to the Total entry, here
|
||||
* applied per user/group so an operator can see WHICH scope is on track
|
||||
* to blow its own cost/month limit, not just the fleet-wide total.
|
||||
*
|
||||
* `now` is threaded in from the caller (coordinator brief: "now passed as
|
||||
* prop for determinism") rather than read via `new Date()` inside this
|
||||
* module — usageColumns() takes it as an optional parameter, defaulting
|
||||
* to the real current time for every production call site, so a test can
|
||||
* pin it instead. elapsedFraction is computed ONCE per usageColumns() call
|
||||
* (not per row) since every row projects against the identical point in
|
||||
* the current UTC month.
|
||||
*/
|
||||
function costMonthProjColumn(elapsedFraction: number): ColumnDef<AdminUsageEntryView, unknown> {
|
||||
return {
|
||||
id: 'costMonthProj',
|
||||
header: 'cost/month (proj.)',
|
||||
meta: { align: 'right' },
|
||||
// Sorting: a not-enough-data-yet row (null projection) has no real
|
||||
// figure to compare — it sorts as if projected to 0, the smallest
|
||||
// possible spend, rather than being excluded from sorting entirely.
|
||||
accessorFn: (entry) => projectMonthEnd(entry.costPerMonthMicroUsd, elapsedFraction) ?? 0,
|
||||
cell: ({ row }) => {
|
||||
if (row.original.storeDown) return '?'
|
||||
const projected = projectMonthEnd(row.original.costPerMonthMicroUsd, elapsedFraction)
|
||||
if (projected === null) return h('span', { class: 'text-muted-foreground' }, 'not enough data yet')
|
||||
const result = headroom(projected, row.original.limits?.costPerMonthUSD)
|
||||
return h(
|
||||
'span',
|
||||
{
|
||||
class: result.willExceed ? 'text-destructive' : undefined,
|
||||
title: result.willExceed ? 'Projected to exceed the configured cost/month limit' : undefined,
|
||||
},
|
||||
formatCost(projected),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -52,19 +159,29 @@ function numericColumn(
|
||||
* bar, so both surfaces sort by the exact same column semantics.
|
||||
* idLabel/secondaryColumnLabel name the first two columns; secondaryValue
|
||||
* reads the second column's per-row value (a user's group name, or a
|
||||
* group's member count).
|
||||
* group's member count). `now` (F7) defaults to the real current time —
|
||||
* see costMonthProjColumn's own doc comment for why a caller can override
|
||||
* it.
|
||||
*/
|
||||
export function usageColumns(
|
||||
idLabel: string,
|
||||
secondaryColumnLabel: string,
|
||||
secondaryValue: (entry: AdminUsageEntryView) => string,
|
||||
now: Date = new Date(),
|
||||
): ColumnDef<AdminUsageEntryView, unknown>[] {
|
||||
const elapsedFraction = monthProgress(now)
|
||||
return [
|
||||
{
|
||||
id: 'id',
|
||||
header: idLabel,
|
||||
accessorFn: (entry) => entry.id,
|
||||
cell: ({ row }) => h('span', { class: 'font-medium' }, row.original.id),
|
||||
// F6: ScopeLink jumps to this row's own Charts scope — "user:{id}"
|
||||
// or "group:{id}", matching entry.kind exactly (admin.go only ever
|
||||
// sets kind to "user"/"group"/"total"; a table row is always the
|
||||
// first two, the "total" entry renders separately in the Total
|
||||
// card, never as a table row — see UsageView.vue).
|
||||
cell: ({ row }) =>
|
||||
h(ScopeLink, { class: 'font-medium', label: row.original.id, scope: `${row.original.kind}:${row.original.id}` }),
|
||||
},
|
||||
{
|
||||
id: 'secondary',
|
||||
@@ -90,13 +207,15 @@ export function usageColumns(
|
||||
accessorFn: (entry) => formatLimits(entry.limits),
|
||||
cell: ({ row }) => h('span', { class: 'text-muted-foreground' }, formatLimits(row.original.limits)),
|
||||
},
|
||||
numericColumn('reqMin', 'req/min', (e) => e.requestsPerMinute),
|
||||
numericColumn('reqDay', 'req/day', (e) => e.requestsPerDay),
|
||||
numericColumn('tokInDay', 'tokIn/day', (e) => e.tokensInPerDay),
|
||||
numericColumn('tokOutDay', 'tokOut/day', (e) => e.tokensOutPerDay),
|
||||
numericColumn('tokInMonth', 'tokIn/month', (e) => e.tokensInPerMonth),
|
||||
numericColumn('tokOutMonth', 'tokOut/month', (e) => e.tokensOutPerMonth),
|
||||
numericColumn('costDay', 'cost/day', (e) => e.costPerDayMicroUsd, formatCost, false),
|
||||
numericColumn('costMonth', 'cost/month', (e) => e.costPerMonthMicroUsd, formatCost, false),
|
||||
numericColumn('reqMin', 'req/min', (e) => e.requestsPerMinute, String, true, 'reqMin'),
|
||||
numericColumn('reqDay', 'req/day', (e) => e.requestsPerDay, String, true, 'reqDay'),
|
||||
rejDayColumn(),
|
||||
numericColumn('tokInDay', 'tokIn/day', (e) => e.tokensInPerDay, String, true, 'tokDay'),
|
||||
numericColumn('tokOutDay', 'tokOut/day', (e) => e.tokensOutPerDay, String, true, 'tokDay'),
|
||||
numericColumn('tokInMonth', 'tokIn/month', (e) => e.tokensInPerMonth, String, true, 'tokMonth'),
|
||||
numericColumn('tokOutMonth', 'tokOut/month', (e) => e.tokensOutPerMonth, String, true, 'tokMonth'),
|
||||
numericColumn('costDay', 'cost/day', (e) => e.costPerDayMicroUsd, formatCost, false, 'costDay'),
|
||||
numericColumn('costMonth', 'cost/month', (e) => e.costPerMonthMicroUsd, formatCost, false, 'costMonth'),
|
||||
costMonthProjColumn(elapsedFraction),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { modelRankingCsv, usageCsv } from './usage-csv'
|
||||
import type { AdminUsageEntryView, AdminUsageModelEntry } from '@/types/api'
|
||||
|
||||
function testEntry(overrides: Partial<AdminUsageEntryView> = {}): AdminUsageEntryView {
|
||||
return {
|
||||
kind: 'user',
|
||||
id: 'alice',
|
||||
requestsPerMinute: 1,
|
||||
requestsPerDay: 2,
|
||||
tokensInPerDay: 3,
|
||||
tokensOutPerDay: 4,
|
||||
tokensInPerMonth: 5,
|
||||
tokensOutPerMonth: 6,
|
||||
costPerDayMicroUsd: 1_234_500,
|
||||
costPerMonthMicroUsd: 12_345_000,
|
||||
rejectionsPerDay: 7,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed at exactly the midpoint of a UTC 30-day month (April 2026) so
|
||||
// elapsedFraction is a clean 0.5 — projectMonthEnd(mtd, 0.5) == mtd*2 —
|
||||
// matching usage-columns.spec.ts's own identical fixture for the on-screen
|
||||
// costMonthProj column, since usageCsv is meant to mirror it exactly.
|
||||
const NOW = new Date(Date.UTC(2026, 3, 16, 0, 0, 0))
|
||||
|
||||
describe('usageCsv', () => {
|
||||
const HEADER =
|
||||
'Name,Group,Limits,req/min,req/day,rejected/day,tokIn/day,tokOut/day,tokIn/month,tokOut/month,cost/day (USD),cost/month (USD),"cost/month (proj., USD)"'
|
||||
|
||||
it('renders the Users header row with "Group" as the secondary column', () => {
|
||||
const csv = usageCsv([], 'users', () => '', NOW)
|
||||
expect(csv).toBe(`${HEADER}\r\n`)
|
||||
})
|
||||
|
||||
it('renders the Groups header row with "Members" as the secondary column', () => {
|
||||
const csv = usageCsv([], 'groups', () => '', NOW)
|
||||
expect(csv).toBe(`${HEADER.replace('Group', 'Members')}\r\n`)
|
||||
})
|
||||
|
||||
// P6 review fix: every numeric cell is now a PLAIN machine-readable
|
||||
// number — no thousands separators (formatExactInt) and no "$" prefix
|
||||
// (formatCost) — and the trailing cost/month (proj.) column is present.
|
||||
it('renders one data row per entry, as plain numbers (no locale grouping, no currency symbol), including the projected month-end cost', () => {
|
||||
const entries = [testEntry({ id: 'alice' }), testEntry({ id: 'bob' })]
|
||||
const csv = usageCsv(entries, 'users', (e) => `group-of-${e.id}`, NOW)
|
||||
const lines = csv.split('\r\n')
|
||||
// costPerDayMicroUsd 1_234_500 -> 1.234500 USD; costPerMonthMicroUsd
|
||||
// 12_345_000 -> 12.345000 USD; projected = 12_345_000 / 0.5 = 24_690_000
|
||||
// micro-USD -> 24.690000 USD.
|
||||
expect(lines[1]).toBe('alice,group-of-alice,none,1,2,7,3,4,5,6,1.234500,12.345000,24.690000')
|
||||
expect(lines[2]).toBe('bob,group-of-bob,none,1,2,7,3,4,5,6,1.234500,12.345000,24.690000')
|
||||
})
|
||||
|
||||
it('renders "not enough data yet" for the proj. column when too little of the month has elapsed', () => {
|
||||
const earlyInMonth = new Date(Date.UTC(2026, 3, 1, 0, 5, 0))
|
||||
const csv = usageCsv([testEntry()], 'users', () => 'g', earlyInMonth)
|
||||
expect(csv.split('\r\n')[1]).toBe('alice,g,none,1,2,7,3,4,5,6,1.234500,12.345000,not enough data yet')
|
||||
})
|
||||
|
||||
it('formats Limits via formatLimits, matching the on-screen table', () => {
|
||||
const csv = usageCsv([testEntry({ limits: { requestsPerDay: 100 } })], 'users', () => 'g', NOW)
|
||||
expect(csv).toContain('req/day 100')
|
||||
})
|
||||
|
||||
it('masks every counter as "?" for a storeDown entry, never a stale number', () => {
|
||||
const csv = usageCsv([testEntry({ storeDown: true })], 'users', () => 'g', NOW)
|
||||
const dataLine = csv.split('\r\n')[1]
|
||||
expect(dataLine).toBe('alice,g,none,?,?,?,?,?,?,?,?,?,?')
|
||||
})
|
||||
|
||||
it('quotes a field that needs it (e.g. a secondaryValue containing a comma)', () => {
|
||||
const csv = usageCsv([testEntry()], 'users', () => 'a, b', NOW)
|
||||
expect(csv).toContain('"a, b"')
|
||||
})
|
||||
|
||||
it('defaults `now` to the real current time when omitted (production call sites)', () => {
|
||||
// Just asserts it does not throw and produces a well-formed row — the
|
||||
// exact projected value depends on the real clock, covered precisely
|
||||
// by the explicit-`now` tests above.
|
||||
const csv = usageCsv([testEntry()], 'users', () => 'g')
|
||||
expect(csv.split('\r\n')[1].split(',')).toHaveLength(13)
|
||||
})
|
||||
})
|
||||
|
||||
describe('modelRankingCsv', () => {
|
||||
const models: AdminUsageModelEntry[] = [
|
||||
{ id: 'openai/gpt-4', value: 100 },
|
||||
{ id: 'anthropic/claude', value: 50 },
|
||||
]
|
||||
|
||||
it('renders a header naming the ranked metric, window, and span (quoted — the label itself contains a comma)', () => {
|
||||
const csv = modelRankingCsv(models, 'Cost', '24h', 24)
|
||||
expect(csv.split('\r\n')[0]).toBe('Model,"Cost (24h, span 24)"')
|
||||
})
|
||||
|
||||
it('renders one row per model with its raw value when isCostMetric is omitted/false', () => {
|
||||
const csv = modelRankingCsv(models, 'Requests', '30d', 30)
|
||||
const lines = csv.split('\r\n')
|
||||
expect(lines[1]).toBe('openai/gpt-4,100')
|
||||
expect(lines[2]).toBe('anthropic/claude,50')
|
||||
})
|
||||
|
||||
it('renders just the header for an empty ranking', () => {
|
||||
expect(modelRankingCsv([], 'Cost', '24h', 24)).toBe('Model,"Cost (24h, span 24)"\r\n')
|
||||
})
|
||||
|
||||
// P6 review fix: a cost-metric ranking's `value` is raw micro-USD with no
|
||||
// unit stated anywhere — isCostMetric:true states the unit in the header
|
||||
// and converts each value to a plain decimal USD number.
|
||||
it('states the USD unit in the header and converts raw micro-USD values to plain decimal USD when isCostMetric is true', () => {
|
||||
const costModels: AdminUsageModelEntry[] = [{ id: 'openai/gpt-4', value: 1_234_500 }]
|
||||
const csv = modelRankingCsv(costModels, 'Cost', '24h', 24, true)
|
||||
const lines = csv.split('\r\n')
|
||||
expect(lines[0]).toBe('Model,"Cost (USD) (24h, span 24)"')
|
||||
expect(lines[1]).toBe('openai/gpt-4,1.234500')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
// F8 (CSV export): builds the actual CSV text for the Usage tab's Users/
|
||||
// Groups exports (usageCsv) and, imported unmodified by WP-B2's Models
|
||||
// tab, the model-ranking export (modelRankingCsv). Both funnel through
|
||||
// lib/csv.ts's toCsv, so quoting/line-ending behavior is defined in
|
||||
// exactly one place (that module's own doc comment).
|
||||
import { toCsv } from '@/lib/csv'
|
||||
import { monthProgress, projectMonthEnd } from '@/lib/forecast'
|
||||
import { formatLimits } from '@/lib/format'
|
||||
import { MICROS_PER_USD } from '@/lib/usage-bars'
|
||||
import type { AdminUsageEntryView, AdminUsageModelEntry } from '@/types/api'
|
||||
|
||||
/** UsageCsvKind picks the second column's label — "Group" for a Users export (each row's own group membership), "Members" for a Groups export (each row's member count) — mirroring usage-columns.ts's own usageColumns(idLabel, secondaryColumnLabel, ...) call sites in UsageTable.vue/UsageView.vue exactly. */
|
||||
export type UsageCsvKind = 'users' | 'groups'
|
||||
|
||||
const USAGE_CSV_SECONDARY_LABEL: Record<UsageCsvKind, string> = {
|
||||
users: 'Group',
|
||||
groups: 'Members',
|
||||
}
|
||||
|
||||
/**
|
||||
* csvCostUsd (P6 review fix) renders a micro-USD integer as a PLAIN
|
||||
* decimal USD number: 6 decimal places, no "$" prefix, no thousands
|
||||
* separator. The on-screen table's own formatCost ("$1.2345", 4 decimals,
|
||||
* `en-US`-locale-shaped) is a DISPLAY convention, not a machine-readable
|
||||
* one — a spreadsheet importer in a non-en-US locale can misparse a
|
||||
* currency-symbol-prefixed string entirely, and 4 decimals silently drops
|
||||
* any sub-$0.0001 precision the underlying micro-USD integer actually
|
||||
* carries. 6 decimals is exactly the precision 1 micro-USD (the smallest
|
||||
* unit every cost field on this view is denominated in) can express:
|
||||
* 1 micro-USD = $0.000001.
|
||||
*/
|
||||
function csvCostUsd(micros: number): string {
|
||||
return (micros / MICROS_PER_USD).toFixed(6)
|
||||
}
|
||||
|
||||
/**
|
||||
* usageCsv renders one AdminUsageEntryView[] table (Users, or the Groups
|
||||
* list) as CSV text, mirroring usage-columns.ts's own column set exactly
|
||||
* so the exported file matches what the on-screen table shows: Name, the
|
||||
* kind-dependent secondary column, Limits, then every numeric column in
|
||||
* the same left-to-right order the table renders them in, including
|
||||
* rejected/day (F4) between req/day and the token columns, and the
|
||||
* projected month-end cost (F7, P6 review fix — this used to omit it
|
||||
* despite claiming to mirror the table's column set exactly) last.
|
||||
*
|
||||
* `secondaryValue` is the same per-row accessor usageColumns() itself
|
||||
* takes (groupNameOf for Users, memberCountOf for Groups, both
|
||||
* UsageView.vue-local) — this module has no opinion on what a group's
|
||||
* member count is, only how to render the column once given the value.
|
||||
*
|
||||
* Every numeric cell is a PLAIN machine-readable number (csvCostUsd above
|
||||
* for the three cost columns, the raw integer — no formatExactInt
|
||||
* thousands-grouping — for every count column; `toCsv`'s own `csvField`
|
||||
* stringifies a raw number for us): P6 review fix, the SAME reasoning
|
||||
* csvCostUsd's own doc comment gives for cost.
|
||||
*
|
||||
* A storeDown row exports "?" for every counter, matching the table's own
|
||||
* masking convention (usage-columns.ts's numericColumn) — an exported
|
||||
* spreadsheet must never present a stale/unknown number as if it were
|
||||
* real.
|
||||
*
|
||||
* `now` (F7, default the real current time) is threaded in rather than
|
||||
* read via `new Date()` inside this module — the same "caller can pin it
|
||||
* under test" convention costMonthProjColumn/CostForecastCard already
|
||||
* follow.
|
||||
*/
|
||||
export function usageCsv(
|
||||
entries: AdminUsageEntryView[],
|
||||
kind: UsageCsvKind,
|
||||
secondaryValue: (entry: AdminUsageEntryView) => string,
|
||||
now: Date = new Date(),
|
||||
): string {
|
||||
const elapsedFraction = monthProgress(now)
|
||||
const headers = [
|
||||
'Name',
|
||||
USAGE_CSV_SECONDARY_LABEL[kind],
|
||||
'Limits',
|
||||
'req/min',
|
||||
'req/day',
|
||||
'rejected/day',
|
||||
'tokIn/day',
|
||||
'tokOut/day',
|
||||
'tokIn/month',
|
||||
'tokOut/month',
|
||||
'cost/day (USD)',
|
||||
'cost/month (USD)',
|
||||
'cost/month (proj., USD)',
|
||||
]
|
||||
const rows = entries.map((entry) => {
|
||||
const masked = entry.storeDown
|
||||
const projected = masked ? null : projectMonthEnd(entry.costPerMonthMicroUsd, elapsedFraction)
|
||||
return [
|
||||
entry.id,
|
||||
secondaryValue(entry),
|
||||
formatLimits(entry.limits),
|
||||
masked ? '?' : entry.requestsPerMinute,
|
||||
masked ? '?' : entry.requestsPerDay,
|
||||
masked ? '?' : entry.rejectionsPerDay,
|
||||
masked ? '?' : entry.tokensInPerDay,
|
||||
masked ? '?' : entry.tokensOutPerDay,
|
||||
masked ? '?' : entry.tokensInPerMonth,
|
||||
masked ? '?' : entry.tokensOutPerMonth,
|
||||
masked ? '?' : csvCostUsd(entry.costPerDayMicroUsd),
|
||||
masked ? '?' : csvCostUsd(entry.costPerMonthMicroUsd),
|
||||
masked ? '?' : projected === null ? 'not enough data yet' : csvCostUsd(projected),
|
||||
]
|
||||
})
|
||||
return toCsv(headers, rows)
|
||||
}
|
||||
|
||||
/**
|
||||
* modelRankingCsv renders the Models tab's current ranking (WP-B2,
|
||||
* ChartsView.vue) as CSV text: one row per ranked model, `id` (the
|
||||
* canonical "provider/model" that served the traffic — AdminUsageModelEntry's
|
||||
* own doc comment, types/api.ts) and its `value` for the ranked metric.
|
||||
* `metricLabel`/`windowLabel`/`span` are the caller's own already-resolved
|
||||
* DISPLAY strings/number (e.g. MODEL_METRIC_LABEL[modelMetric],
|
||||
* WINDOW_LABEL[window], WINDOW_SPAN[window] — stores/history.ts, WP-B2),
|
||||
* not re-derived here: this module stays independent of WP-B2's store, and
|
||||
* the second header column names exactly what was ranked and over what
|
||||
* span, so the exported file is self-describing without a caller needing
|
||||
* to cross-reference the filename.
|
||||
*
|
||||
* `isCostMetric` (P6 review fix): the cost-metric ranking's `value` is raw
|
||||
* micro-USD — the on-screen chart/CSV both used to export it as-is, with
|
||||
* no unit stated anywhere in the header. The caller states whether the
|
||||
* CURRENTLY ranked metric is cost (`history.modelMetric === 'cost'`); when
|
||||
* true, the header gains an explicit " (USD)" unit suffix and every value
|
||||
* converts to a plain decimal USD number via the same csvCostUsd
|
||||
* convention usageCsv uses above. Every OTHER metric (requests, tokens in/
|
||||
* out) is already a plain raw integer with no unit ambiguity, so this
|
||||
* defaults to false and changes nothing for them.
|
||||
*/
|
||||
export function modelRankingCsv(
|
||||
models: AdminUsageModelEntry[],
|
||||
metricLabel: string,
|
||||
windowLabel: string,
|
||||
span: number,
|
||||
isCostMetric = false,
|
||||
): string {
|
||||
const unit = isCostMetric ? ' (USD)' : ''
|
||||
const headers = ['Model', `${metricLabel}${unit} (${windowLabel}, span ${span})`]
|
||||
const rows = models.map((m) => [m.id, isCostMetric ? csvCostUsd(m.value) : m.value])
|
||||
return toCsv(headers, rows)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ function entry(id: string, groupName?: string, groups?: string[]): AdminUsageEnt
|
||||
tokensOutPerMonth: 0,
|
||||
costPerDayMicroUsd: 0,
|
||||
costPerMonthMicroUsd: 0,
|
||||
rejectionsPerDay: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ const mockedAdminFetch = vi.mocked(adminFetch)
|
||||
|
||||
const emptyOverview: AdminOverviewResponse = {
|
||||
version: '1',
|
||||
replica: 'pod-a',
|
||||
instance: 'test-gateway',
|
||||
warnings: [],
|
||||
providers: [],
|
||||
groups: [],
|
||||
aliases: [],
|
||||
@@ -50,6 +53,7 @@ const emptyUsage: AdminUsageResponse = {
|
||||
tokensOutPerMonth: 0,
|
||||
costPerDayMicroUsd: 0,
|
||||
costPerMonthMicroUsd: 0,
|
||||
rejectionsPerDay: 0,
|
||||
},
|
||||
}
|
||||
const emptyTargets: AdminTargetsResponse = { mcpServers: [], agents: [] }
|
||||
@@ -160,3 +164,57 @@ describe('useDashboardStore.refresh', () => {
|
||||
expect(overviewCalls).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
// F5 (dashboard-plan.md): startPolling now drives refresh() through the
|
||||
// shared createVisibilityPoller (lib/polling.ts) instead of a bare
|
||||
// setInterval — same observable "fetch once, then poll" shape as before,
|
||||
// just built on the tested shared primitive.
|
||||
describe('useDashboardStore.startPolling', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockedAdminFetch.mockReset()
|
||||
useAuthStore().submit('test-admin-key')
|
||||
mockRoutes({})
|
||||
})
|
||||
|
||||
it('fetches immediately on startPolling, then again every POLL_MS', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const dashboard = useDashboardStore()
|
||||
|
||||
dashboard.startPolling()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(dashboard.lastUpdated).not.toBeNull()
|
||||
|
||||
const callsAfterFirst = mockedAdminFetch.mock.calls.length
|
||||
await vi.advanceTimersByTimeAsync(5000) // POLL_MS
|
||||
expect(mockedAdminFetch.mock.calls.length).toBeGreaterThan(callsAfterFirst)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('startPolling is idempotent — a second call does not double the interval', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const dashboard = useDashboardStore()
|
||||
|
||||
dashboard.startPolling()
|
||||
dashboard.startPolling()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
const callsAfterFirst = mockedAdminFetch.mock.calls.length
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
// One additional round of three requests (overview/usage/targets),
|
||||
// not two.
|
||||
expect(mockedAdminFetch.mock.calls.length - callsAfterFirst).toBe(3)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
// P11 review fix: a `stopPolling` test used to live here too — the
|
||||
// action itself was dead application-facing API surface (nothing outside
|
||||
// this spec ever called it, App.vue polls this store for the app's whole
|
||||
// session) and has been removed from stores/dashboard.ts; see that
|
||||
// file's own comment.
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { AdminApiError, adminFetch } from '@/lib/api'
|
||||
import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { AdminOverviewResponse, AdminTargetsResponse, AdminUsageResponse } from '@/types/api'
|
||||
|
||||
@@ -24,7 +25,7 @@ export const useDashboardStore = defineStore('dashboard', {
|
||||
targets: null as AdminTargetsResponse | null,
|
||||
lastUpdated: null as Date | null,
|
||||
error: '',
|
||||
timer: undefined as ReturnType<typeof setInterval> | undefined,
|
||||
poller: undefined as VisibilityPoller | undefined,
|
||||
/**
|
||||
* refreshing guards against overlapping polls (review finding): the 5s
|
||||
* setInterval has no in-flight check on its own, so a round trip that
|
||||
@@ -95,11 +96,26 @@ export const useDashboardStore = defineStore('dashboard', {
|
||||
.map(({ label, reason }) => `${label}: ${reason instanceof Error ? reason.message : String(reason)}`)
|
||||
.join('; ')
|
||||
},
|
||||
/** startPolling is idempotent and safe to call before a key is stored — refresh() no-ops until then. */
|
||||
/**
|
||||
* startPolling is idempotent and safe to call before a key is stored —
|
||||
* refresh() no-ops until then. Driven by the shared
|
||||
* createVisibilityPoller (lib/polling.ts, F5) rather than a bare
|
||||
* setInterval: an immediate tick (the same "fetch once, then poll"
|
||||
* shape this action already had), then every POLL_MS while the tab is
|
||||
* visible, pausing while it is hidden and catching up the moment it is
|
||||
* looked at again.
|
||||
*/
|
||||
startPolling(): void {
|
||||
if (this.timer !== undefined) return
|
||||
void this.refresh()
|
||||
this.timer = setInterval(() => void this.refresh(), POLL_MS)
|
||||
if (this.poller) return
|
||||
this.poller = createVisibilityPoller({ intervalMs: POLL_MS, tick: () => void this.refresh() })
|
||||
this.poller.start()
|
||||
},
|
||||
// P11 review fix: a stopPolling action mirroring history.ts/events.ts's
|
||||
// own stop() used to live here too, but nothing outside its OWN spec
|
||||
// ever called it — App.vue mounts this store's poller once, for the
|
||||
// app's whole session, and never unmounts it (unlike history.ts/
|
||||
// events.ts, whose stores are torn down when ChartsView/EventsView
|
||||
// unmount). Dead application-facing API surface, removed; `poller`
|
||||
// itself stays (startPolling's own idempotency guard still needs it).
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// events.spec.ts covers useEventsStore's refresh action and its polling
|
||||
// wiring — same harness as dashboard.spec.ts/history.spec.ts: a Pinia
|
||||
// store is plain reactive state in the 'node' environment, adminFetch
|
||||
// fully mocked, so no real network or sessionStorage access happens.
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/api', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/api')>('@/lib/api')
|
||||
return { ...actual, adminFetch: vi.fn() }
|
||||
})
|
||||
|
||||
import { AdminApiError, adminFetch } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useEventsStore } from '@/stores/events'
|
||||
import type { AdminEventsResponse } from '@/types/api'
|
||||
|
||||
const mockedAdminFetch = vi.mocked(adminFetch)
|
||||
|
||||
function eventsResponse(overrides: Partial<AdminEventsResponse> = {}): AdminEventsResponse {
|
||||
return {
|
||||
events: [
|
||||
{
|
||||
time: '2026-08-20T12:00:00Z',
|
||||
replica: 'pod-a',
|
||||
route: 'chat/completions',
|
||||
kind: 'rate_limit',
|
||||
message: 'requests/min limit exceeded',
|
||||
status: 429,
|
||||
user: 'alice',
|
||||
},
|
||||
],
|
||||
source: 'redis',
|
||||
replica: 'pod-a',
|
||||
capacity: 200,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useEventsStore.refresh', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockedAdminFetch.mockReset()
|
||||
useAuthStore().submit('test-admin-key')
|
||||
})
|
||||
|
||||
it('populates events/source/replica/capacity on success and clears error', async () => {
|
||||
mockedAdminFetch.mockResolvedValue(eventsResponse())
|
||||
const events = useEventsStore()
|
||||
|
||||
await events.refresh()
|
||||
|
||||
expect(events.events).toHaveLength(1)
|
||||
expect(events.source).toBe('redis')
|
||||
expect(events.replica).toBe('pod-a')
|
||||
expect(events.capacity).toBe(200)
|
||||
expect(events.degraded).toBe(false)
|
||||
expect(events.error).toBe('')
|
||||
expect(events.lastUpdated).not.toBeNull()
|
||||
})
|
||||
|
||||
it('requests the ring capacity as the limit', async () => {
|
||||
mockedAdminFetch.mockResolvedValue(eventsResponse())
|
||||
const events = useEventsStore()
|
||||
|
||||
await events.refresh()
|
||||
|
||||
expect(mockedAdminFetch).toHaveBeenCalledWith('/admin/api/events?limit=200')
|
||||
})
|
||||
|
||||
it('surfaces degraded when the response marks it', async () => {
|
||||
mockedAdminFetch.mockResolvedValue(eventsResponse({ source: 'replica', degraded: true }))
|
||||
const events = useEventsStore()
|
||||
|
||||
await events.refresh()
|
||||
|
||||
expect(events.source).toBe('replica')
|
||||
expect(events.degraded).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults degraded to false when the response omits it (replica source, Redis simply unconfigured)', async () => {
|
||||
mockedAdminFetch.mockResolvedValue(eventsResponse({ source: 'replica', degraded: undefined }))
|
||||
const events = useEventsStore()
|
||||
|
||||
await events.refresh()
|
||||
|
||||
expect(events.degraded).toBe(false)
|
||||
})
|
||||
|
||||
it('surfaces a non-auth failure as error and leaves events untouched', async () => {
|
||||
mockedAdminFetch.mockResolvedValue(eventsResponse())
|
||||
const events = useEventsStore()
|
||||
await events.refresh()
|
||||
const seeded = events.events
|
||||
|
||||
mockedAdminFetch.mockRejectedValue(new Error('events store down'))
|
||||
await events.refresh()
|
||||
|
||||
expect(events.error).toBe('events store down')
|
||||
expect(events.events).toBe(seeded) // never blanked on a failed poll
|
||||
})
|
||||
|
||||
it('does not surface an error on a 401 (the auth store owns that case)', async () => {
|
||||
mockedAdminFetch.mockRejectedValue(new AdminApiError('unauthorized', 401))
|
||||
const events = useEventsStore()
|
||||
|
||||
await events.refresh()
|
||||
|
||||
expect(events.error).toBe('')
|
||||
})
|
||||
|
||||
it('skips a refresh() call that starts while one is already in flight', async () => {
|
||||
let resolveFirst!: (v: AdminEventsResponse) => void
|
||||
mockedAdminFetch.mockImplementation(
|
||||
() => new Promise<AdminEventsResponse>((resolve) => (resolveFirst = resolve)) as ReturnType<typeof adminFetch>,
|
||||
)
|
||||
const events = useEventsStore()
|
||||
|
||||
const first = events.refresh()
|
||||
const second = events.refresh()
|
||||
|
||||
resolveFirst(eventsResponse())
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(mockedAdminFetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('no-ops before a key is stored', async () => {
|
||||
useAuthStore().reject('logged out')
|
||||
const events = useEventsStore()
|
||||
|
||||
await events.refresh()
|
||||
|
||||
expect(mockedAdminFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useEventsStore.startPolling / stopPolling', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockedAdminFetch.mockReset()
|
||||
mockedAdminFetch.mockResolvedValue(eventsResponse())
|
||||
useAuthStore().submit('test-admin-key')
|
||||
})
|
||||
|
||||
it('fetches immediately on startPolling, then again every POLL_MS', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const events = useEventsStore()
|
||||
|
||||
events.startPolling()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(mockedAdminFetch).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
expect(mockedAdminFetch).toHaveBeenCalledTimes(2)
|
||||
|
||||
events.stopPolling()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('startPolling is idempotent', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const events = useEventsStore()
|
||||
|
||||
events.startPolling()
|
||||
events.startPolling()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(mockedAdminFetch).toHaveBeenCalledTimes(1)
|
||||
events.stopPolling()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('stopPolling stops further fetches', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const events = useEventsStore()
|
||||
|
||||
events.startPolling()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
events.stopPolling()
|
||||
const callsAfterStop = mockedAdminFetch.mock.calls.length
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
expect(mockedAdminFetch.mock.calls.length).toBe(callsAfterStop)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { AdminApiError, adminFetch } from '@/lib/api'
|
||||
import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { AdminEventsResponse, AdminEventView } from '@/types/api'
|
||||
|
||||
/** How often the Events view polls while mounted — same cadence as the Providers/Usage/Targets dashboard poll (stores/dashboard.ts's own POLL_MS): a rate-limit/upstream event is exactly the kind of thing an operator wants to see within a few seconds, not thirty. */
|
||||
const POLL_MS = 5000
|
||||
|
||||
/** EVENTS_LIMIT matches the ring's own fixed capacity (events.go: eventRingCap, admin.go: adminEventsMaxLimit) — asking for fewer would silently hide events the ring/Redis list still holds. */
|
||||
const EVENTS_LIMIT = 200
|
||||
|
||||
/**
|
||||
* useEventsStore polls GET /admin/api/events?limit=200, feeding the new
|
||||
* Events tab (App.vue, F3). Unlike useDashboardStore, this store is
|
||||
* polled only while the Events view is mounted (EventsView.vue's own
|
||||
* onMounted/onUnmounted) rather than for the app's whole lifetime — an
|
||||
* operator who never opens the tab pays no extra request for it.
|
||||
*/
|
||||
export const useEventsStore = defineStore('events', {
|
||||
state: () => ({
|
||||
/** Newest first, exactly as the server orders them — see types/api.ts's AdminEventsResponse doc comment. */
|
||||
events: [] as AdminEventView[],
|
||||
/** 'redis' (fleet-wide) or 'replica' (this replica's own in-memory ring, read because Redis is unreachable or unconfigured) — the view must caption this rather than present a replica-only fallback as fleet-wide. */
|
||||
source: 'replica' as 'redis' | 'replica',
|
||||
replica: '',
|
||||
/** The ring's fixed capacity (events.go: eventRingCap) — lets the view show "N of capacity" rather than a bare list length. */
|
||||
capacity: 0,
|
||||
/** Set only when Redis IS configured but the read failed (never when Redis is simply not configured at all — see AdminEventsResponse's own doc comment). */
|
||||
degraded: false,
|
||||
lastUpdated: null as Date | null,
|
||||
error: '',
|
||||
/**
|
||||
* kindFilter/userFilter (F9, hash-state) — the Events view's own kind
|
||||
* Select and user SearchInput. Lifted up here rather than kept as
|
||||
* local refs in EventsView.vue, mirroring stores/nav.ts's own
|
||||
* usageQuery doc comment: state that must survive a #<tab>?<query>
|
||||
* hash round trip (composables/useHashState.ts, lib/hash-state.ts's
|
||||
* parseEventsParams/kind,user) needs one canonical place to read from
|
||||
* and write to, not a component-local ref useHashState cannot reach.
|
||||
* kindFilter is an exact AdminEventKind string, or '' for "every
|
||||
* kind" (EventsView.vue's own ALL_KINDS sentinel maps to/from this at
|
||||
* the template boundary — see lib/events-filter.ts's own EventFilter
|
||||
* shape, which this pairs with directly). userFilter is the raw,
|
||||
* un-normalized search text; EventsView.vue binds it via
|
||||
* useSearchQuery's external-ref option (composables/useSearchQuery.ts)
|
||||
* the same way UsageView.vue binds nav.usageQuery.
|
||||
*/
|
||||
kindFilter: '',
|
||||
userFilter: '',
|
||||
poller: undefined as VisibilityPoller | undefined,
|
||||
/** Mirrors dashboard.ts's own in-flight guard: a round trip slower than POLL_MS must never stack a second refresh() on top of the first. */
|
||||
refreshing: false,
|
||||
}),
|
||||
actions: {
|
||||
async refresh(): Promise<void> {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.isAuthenticated) return
|
||||
if (this.refreshing) return
|
||||
this.refreshing = true
|
||||
try {
|
||||
const res = await adminFetch<AdminEventsResponse>(`/admin/api/events?limit=${EVENTS_LIMIT}`)
|
||||
this.events = res.events
|
||||
this.source = res.source
|
||||
this.replica = res.replica
|
||||
this.capacity = res.capacity
|
||||
this.degraded = res.degraded ?? false
|
||||
this.lastUpdated = new Date()
|
||||
this.error = ''
|
||||
} catch (err) {
|
||||
// A 401/403 already rejected the key (lib/api.ts) — the AuthGate
|
||||
// takes over the whole view, matching dashboard.ts's own
|
||||
// identical carve-out; nothing left to report here.
|
||||
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) return
|
||||
this.error = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
this.refreshing = false
|
||||
}
|
||||
},
|
||||
/** setKindFilter sets the exact-match kind filter — '' means "every kind" (EventsView.vue translates its own ALL_KINDS Select sentinel to/from '' at the template boundary). */
|
||||
setKindFilter(kind: string): void {
|
||||
this.kindFilter = kind
|
||||
},
|
||||
/** setUserFilter sets the raw (un-normalized) user/group search text — EventsView.vue reads the normalized form through useSearchQuery's own `normalized` computed rather than this action re-deriving it. */
|
||||
setUserFilter(user: string): void {
|
||||
this.userFilter = user
|
||||
},
|
||||
/** startPolling is idempotent, driven by the shared createVisibilityPoller (lib/polling.ts, F5) — an immediate fetch, then every POLL_MS while the tab is visible. */
|
||||
startPolling(): void {
|
||||
if (this.poller) return
|
||||
this.poller = createVisibilityPoller({ intervalMs: POLL_MS, tick: () => void this.refresh() })
|
||||
this.poller.start()
|
||||
},
|
||||
stopPolling(): void {
|
||||
this.poller?.stop()
|
||||
this.poller = undefined
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -25,7 +25,7 @@ import type { AdminUsageModelsResponse, UsageHistoryResponse } from '@/types/api
|
||||
const mockedAdminFetch = vi.mocked(adminFetch)
|
||||
|
||||
function modelsResponse(models: AdminUsageModelsResponse['models']): AdminUsageModelsResponse {
|
||||
return { metric: 'cost', window: 'hour', models }
|
||||
return { metric: 'cost', window: 'hour', span: 24, models }
|
||||
}
|
||||
function seriesResponse(): UsageHistoryResponse {
|
||||
return { scope: 'total', metric: 'req', window: 'hour', points: [{ bucket: '2026082012', value: 5 }] }
|
||||
@@ -95,16 +95,20 @@ describe('useHistoryStore model ranking', () => {
|
||||
expect(rankingCall).toBeDefined()
|
||||
})
|
||||
|
||||
it('refreshes the picker list alongside a time series', async () => {
|
||||
routeFetch({ models: () => modelsResponse([{ id: 'alpha/a-model-1', value: 3 }]) })
|
||||
// F2 (dashboard-plan.md): the ranking now sums the same WINDOW_SPAN
|
||||
// buckets the time-series tabs chart, not just window's current bucket —
|
||||
// WINDOW_LABEL ("24h"/"30d"/"12mo") is honest for the Models tab too.
|
||||
it('asks the ranking endpoint for the window span, not just the current bucket', async () => {
|
||||
routeFetch({})
|
||||
const history = useHistoryStore()
|
||||
history.tab = 'models'
|
||||
|
||||
await history.refresh()
|
||||
|
||||
// A model that has just started receiving traffic becomes selectable
|
||||
// without a reload, even while a non-Models tab is on screen.
|
||||
expect(history.modelOptions).toEqual([{ id: 'alpha/a-model-1', value: 3 }])
|
||||
expect(history.seriesByMetric.req).toHaveLength(1)
|
||||
const rankingCall = mockedAdminFetch.mock.calls
|
||||
.map((c) => String(c[0]))
|
||||
.find((p) => p.startsWith('/admin/api/usage/models'))
|
||||
expect(rankingCall).toContain('span=24') // WINDOW_SPAN.hour, the default window
|
||||
})
|
||||
|
||||
it('asks the picker list for requests, so a free model is still selectable', async () => {
|
||||
@@ -115,6 +119,33 @@ describe('useHistoryStore model ranking', () => {
|
||||
|
||||
const optionsCall = mockedAdminFetch.mock.calls.map((c) => String(c[0])).find((p) => p.includes('/usage/models'))
|
||||
expect(optionsCall).toContain('metric=req')
|
||||
expect(optionsCall).toContain('span=24') // WINDOW_SPAN.hour, the default window
|
||||
})
|
||||
|
||||
// Q1 (dashboard-plan.md DECISIONS, read-amplification review finding):
|
||||
// fetchModelOptions is a real extra store read and must NOT repeat on
|
||||
// every refresh() call — refresh() is what both the 30s auto-refresh
|
||||
// tick and every setScope/setTab/setModelMetric change call.
|
||||
it('never fetches the picker list from a plain refresh() call (Q1: read amplification)', async () => {
|
||||
routeFetch({ series: () => seriesResponse() })
|
||||
const history = useHistoryStore()
|
||||
|
||||
await history.refresh()
|
||||
|
||||
expect(history.seriesByMetric.req).toHaveLength(1)
|
||||
expect(history.modelOptions).toEqual([])
|
||||
const optionsCall = mockedAdminFetch.mock.calls.map((c) => String(c[0])).some((p) => p.includes('/usage/models'))
|
||||
expect(optionsCall).toBe(false)
|
||||
})
|
||||
|
||||
it('fetches the picker list on a window change, unlike a scope/tab/modelMetric change', async () => {
|
||||
routeFetch({ models: () => modelsResponse([{ id: 'alpha/a-model-1', value: 3 }]) })
|
||||
const history = useHistoryStore()
|
||||
|
||||
history.setWindow('day')
|
||||
await flush()
|
||||
|
||||
expect(history.modelOptions).toEqual([{ id: 'alpha/a-model-1', value: 3 }])
|
||||
})
|
||||
|
||||
it('swallows a picker-list failure without touching the chart error', async () => {
|
||||
@@ -135,18 +166,24 @@ describe('useHistoryStore model ranking', () => {
|
||||
expect(history.modelOptions).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a rendered chart intact when the picker list fails', async () => {
|
||||
// setWindow fires refresh() (series/ranking) and fetchModelOptions
|
||||
// (picker list) in parallel (Q1's two trigger points) — one failing must
|
||||
// never touch the other's own state.
|
||||
it('keeps the freshly-fetched chart intact when the picker list fails on the same window change', async () => {
|
||||
routeFetch({
|
||||
series: () => seriesResponse(),
|
||||
models: () => {
|
||||
throw new Error('models endpoint down')
|
||||
},
|
||||
})
|
||||
const history = useHistoryStore()
|
||||
|
||||
await history.refresh()
|
||||
history.setWindow('day')
|
||||
await flush()
|
||||
|
||||
expect(history.seriesByMetric.req).toHaveLength(1)
|
||||
expect(history.error).toBe('')
|
||||
expect(history.modelOptions).toEqual([])
|
||||
})
|
||||
|
||||
it('surfaces a failing ranking fetch, unlike the picker list', async () => {
|
||||
@@ -163,6 +200,144 @@ describe('useHistoryStore model ranking', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// F6 (dashboard-plan.md): modelFilter narrows the Models tab's ranking to a
|
||||
// provider/model prefix, set by ProvidersView.vue's provider-header link
|
||||
// (nav.goToModels) or a `#charts?tab=models&filter=...` hash param
|
||||
// (lib/hash-state.ts, composables/useHashState.ts). P3 review fix: while
|
||||
// the Models tab is active, setModelFilter ALSO sends the new prefix to
|
||||
// the server and refetches the ranking (fetchModelRanking's own doc
|
||||
// comment) — it is no longer a pure client-side re-slice. Setting it while
|
||||
// any OTHER tab is active stays fetch-free, since only the Models tab
|
||||
// reads modelFilter at all (ChartsView.vue).
|
||||
describe('useHistoryStore model filter (F6)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockedAdminFetch.mockReset()
|
||||
useAuthStore().submit('test-admin-key')
|
||||
})
|
||||
|
||||
it('starts with no filter', () => {
|
||||
expect(useHistoryStore().modelFilter).toBe('')
|
||||
})
|
||||
|
||||
it('setModelFilter sets the prefix without fetching anything when not on the Models tab', async () => {
|
||||
routeFetch({})
|
||||
const history = useHistoryStore()
|
||||
history.tab = 'requests' // explicit, not relying on the store's own default
|
||||
const callsBefore = mockedAdminFetch.mock.calls.length
|
||||
|
||||
history.setModelFilter('openai/')
|
||||
await flush()
|
||||
|
||||
expect(history.modelFilter).toBe('openai/')
|
||||
expect(mockedAdminFetch.mock.calls.length).toBe(callsBefore)
|
||||
})
|
||||
|
||||
it('setModelFilter refetches the ranking with the encoded prefix when the Models tab IS active', async () => {
|
||||
routeFetch({})
|
||||
const history = useHistoryStore()
|
||||
history.tab = 'models'
|
||||
const callsBefore = mockedAdminFetch.mock.calls.length
|
||||
|
||||
history.setModelFilter('openai/')
|
||||
await flush()
|
||||
|
||||
expect(history.modelFilter).toBe('openai/')
|
||||
expect(mockedAdminFetch.mock.calls.length).toBeGreaterThan(callsBefore)
|
||||
const rankingCall = mockedAdminFetch.mock.calls.map((c) => String(c[0])).find((p) => p.startsWith('/admin/api/usage/models'))
|
||||
expect(rankingCall).toContain(`prefix=${encodeURIComponent('openai/')}`)
|
||||
})
|
||||
|
||||
it('setTab clears a previously-set modelFilter (a plain tab click must not carry a stale provider filter)', () => {
|
||||
routeFetch({})
|
||||
const history = useHistoryStore()
|
||||
history.setModelFilter('openai/')
|
||||
|
||||
history.setTab('tokens')
|
||||
|
||||
expect(history.modelFilter).toBe('')
|
||||
})
|
||||
|
||||
it('setTab(models) also clears modelFilter — nav.goToModels relies on re-applying setModelFilter AFTER this', () => {
|
||||
routeFetch({})
|
||||
const history = useHistoryStore()
|
||||
history.tab = 'requests'
|
||||
history.setModelFilter('openai/')
|
||||
|
||||
history.setTab('models')
|
||||
|
||||
expect(history.modelFilter).toBe('')
|
||||
})
|
||||
|
||||
it('setTab is a no-op (including on modelFilter) when the tab does not actually change', () => {
|
||||
routeFetch({})
|
||||
const history = useHistoryStore()
|
||||
history.tab = 'models'
|
||||
history.setModelFilter('openai/')
|
||||
|
||||
history.setTab('models') // already on 'models' — the early return must skip the clear too
|
||||
|
||||
expect(history.modelFilter).toBe('openai/')
|
||||
})
|
||||
|
||||
// P3 review fix: the ranking fetch now sends the active prefix to the
|
||||
// server (filtering BEFORE the limit is applied), instead of only ever
|
||||
// re-slicing an already-fetched, unprefixed top-N client-side.
|
||||
it('sends &prefix=<encoded filter> on the ranking fetch when modelFilter is set', async () => {
|
||||
routeFetch({})
|
||||
const history = useHistoryStore()
|
||||
history.tab = 'models'
|
||||
history.modelFilter = 'openai/'
|
||||
|
||||
await history.fetchModelRanking()
|
||||
|
||||
const rankingCall = mockedAdminFetch.mock.calls.map((c) => String(c[0])).find((p) => p.startsWith('/admin/api/usage/models'))
|
||||
expect(rankingCall).toContain(`prefix=${encodeURIComponent('openai/')}`)
|
||||
})
|
||||
|
||||
it('omits the prefix param entirely when modelFilter is empty', async () => {
|
||||
routeFetch({})
|
||||
const history = useHistoryStore()
|
||||
history.tab = 'models'
|
||||
|
||||
await history.fetchModelRanking()
|
||||
|
||||
const rankingCall = mockedAdminFetch.mock.calls.map((c) => String(c[0])).find((p) => p.startsWith('/admin/api/usage/models'))
|
||||
expect(rankingCall).not.toContain('prefix=')
|
||||
})
|
||||
|
||||
// P3 review fix: setModelFilter now refetches too, but ONLY while the
|
||||
// Models tab is active — a stale (unprefixed, or differently-prefixed)
|
||||
// already-fetched ranking must not linger once the filter changes, since
|
||||
// the server-side prefix now genuinely changes what that fetch returns.
|
||||
it('setModelFilter refetches the ranking when the Models tab is active and the prefix actually changes', async () => {
|
||||
routeFetch({})
|
||||
const history = useHistoryStore()
|
||||
history.tab = 'models'
|
||||
const callsBefore = mockedAdminFetch.mock.calls.length
|
||||
|
||||
history.setModelFilter('openai/')
|
||||
await flush()
|
||||
|
||||
expect(mockedAdminFetch.mock.calls.length).toBeGreaterThan(callsBefore)
|
||||
const rankingCall = mockedAdminFetch.mock.calls.map((c) => String(c[0])).find((p) => p.startsWith('/admin/api/usage/models'))
|
||||
expect(rankingCall).toContain(`prefix=${encodeURIComponent('openai/')}`)
|
||||
})
|
||||
|
||||
it('setModelFilter does NOT refetch when the value is unchanged (early return)', async () => {
|
||||
routeFetch({})
|
||||
const history = useHistoryStore()
|
||||
history.tab = 'models'
|
||||
history.setModelFilter('openai/')
|
||||
await flush()
|
||||
const callsBefore = mockedAdminFetch.mock.calls.length
|
||||
|
||||
history.setModelFilter('openai/') // same value again
|
||||
|
||||
expect(mockedAdminFetch.mock.calls.length).toBe(callsBefore)
|
||||
})
|
||||
})
|
||||
|
||||
// Review findings 1/2/9: out-of-order responses must never overwrite a
|
||||
// newer selection, stale data must never render under the new selection's
|
||||
// labels, and `loading` must only ever be cleared by the request that is
|
||||
@@ -265,6 +440,26 @@ describe('useHistoryStore: latest-request-wins', () => {
|
||||
expect(history.loaded).toBe(false)
|
||||
})
|
||||
|
||||
it('the 30s auto-refresh tick (startAutoRefresh) never re-fetches the picker list — only refresh() itself (Q1)', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
routeFetch({ series: () => seriesResponse() })
|
||||
const history = useHistoryStore()
|
||||
|
||||
history.startAutoRefresh() // ticks once immediately, then every REFRESH_MS (lib/polling.ts)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(90_000) // three more ticks
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(history.modelOptions).toEqual([])
|
||||
const optionsCall = mockedAdminFetch.mock.calls.map((c) => String(c[0])).some((p) => p.includes('/usage/models'))
|
||||
expect(optionsCall).toBe(false)
|
||||
history.stopAutoRefresh()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('auto-refresh tick skips while a fetch is still in flight, so a slow request is never superseded by the timer', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -288,3 +483,77 @@ describe('useHistoryStore: latest-request-wins', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
// P12 review fix: restoring a Charts hash with several params set at once
|
||||
// (composables/useHashState.ts's applyFromHash) or clicking through via
|
||||
// nav.ts's goToCharts/goToModels used to call several of the single-field
|
||||
// setters above back to back, each independently clearing state and
|
||||
// firing its OWN refresh() (and, for a window change, fetchModelOptions()
|
||||
// too) — every fetch but the last immediately superseded and discarded.
|
||||
// setSelection batches the whole change into ONE refresh() call.
|
||||
describe('useHistoryStore.setSelection (P12: batched, one fetch)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockedAdminFetch.mockReset()
|
||||
useAuthStore().submit('test-admin-key')
|
||||
})
|
||||
|
||||
it('applying scope+window+tab+modelMetric together fires exactly one series fetch (plus one picker-list fetch for the window change) — not one per field', async () => {
|
||||
routeFetch({ series: () => seriesResponse() })
|
||||
const history = useHistoryStore()
|
||||
|
||||
// tab stays at its default ('requests', metricsForTab -> 1 metric), so
|
||||
// the "one series fetch" below is a single adminFetch call, not the
|
||||
// 2-call 'tokens' case — isolating the batching behavior itself from
|
||||
// metricsForTab's own separate multi-metric fan-out.
|
||||
history.setSelection({ scope: 'user:alice', window: 'day', tab: 'requests', modelMetric: 'req' })
|
||||
await flush()
|
||||
|
||||
expect(history.scope).toBe('user:alice')
|
||||
expect(history.window).toBe('day')
|
||||
expect(history.modelMetric).toBe('req')
|
||||
const calls = mockedAdminFetch.mock.calls.map((c) => String(c[0]))
|
||||
const seriesCalls = calls.filter((p) => p.startsWith('/admin/api/usage/history'))
|
||||
const optionsCalls = calls.filter((p) => p.startsWith('/admin/api/usage/models'))
|
||||
expect(seriesCalls).toHaveLength(1) // one series fetch, not the 3-4 the old per-field setters would have fired
|
||||
expect(optionsCalls).toHaveLength(1) // fetchModelOptions, fired once for the window change
|
||||
})
|
||||
|
||||
it('does nothing (no state change, no fetch) when every field already matches the current selection', async () => {
|
||||
routeFetch({})
|
||||
const history = useHistoryStore()
|
||||
const callsBefore = mockedAdminFetch.mock.calls.length
|
||||
|
||||
history.setSelection({ scope: 'total', window: 'hour', tab: 'requests', modelMetric: 'cost' }) // all defaults
|
||||
await flush()
|
||||
|
||||
expect(mockedAdminFetch.mock.calls.length).toBe(callsBefore)
|
||||
})
|
||||
|
||||
it('applies modelFilter AFTER tab resets it — setSelection({ tab: "models", modelFilter: prefix }) lands on the intended prefix (nav.ts goToModels\' own call shape)', async () => {
|
||||
routeFetch({})
|
||||
const history = useHistoryStore()
|
||||
|
||||
history.setSelection({ tab: 'models', modelFilter: 'openai/' })
|
||||
await flush()
|
||||
|
||||
expect(history.tab).toBe('models')
|
||||
expect(history.modelFilter).toBe('openai/')
|
||||
const rankingCall = mockedAdminFetch.mock.calls.map((c) => String(c[0])).find((p) => p.startsWith('/admin/api/usage/models'))
|
||||
expect(rankingCall).toContain(`prefix=${encodeURIComponent('openai/')}`)
|
||||
// Only ONE ranking fetch — not a first unprefixed one from the tab
|
||||
// switch followed by a second, corrective one from the filter.
|
||||
const rankingCalls = mockedAdminFetch.mock.calls.map((c) => String(c[0])).filter((p) => p.startsWith('/admin/api/usage/models'))
|
||||
expect(rankingCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('only fetchModelOptions is skipped when window is not part of the call, even though other fields change', async () => {
|
||||
routeFetch({ series: () => seriesResponse() })
|
||||
const history = useHistoryStore()
|
||||
|
||||
history.setSelection({ scope: 'user:alice' })
|
||||
await flush()
|
||||
|
||||
const optionsCalls = mockedAdminFetch.mock.calls.map((c) => String(c[0])).filter((p) => p.startsWith('/admin/api/usage/models'))
|
||||
expect(optionsCalls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
+195
-44
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { AdminApiError, adminFetch } from '@/lib/api'
|
||||
import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type {
|
||||
AdminUsageModelEntry,
|
||||
@@ -48,8 +49,13 @@ export const MODEL_METRIC_LABEL: Record<ModelMetric, string> = {
|
||||
/**
|
||||
* How many ranked models the Models tab requests. Matches the server's own
|
||||
* default (admin.go: usageModelsDefaultLimit) and stays well under its max.
|
||||
* Exported (P3 review fix) so ChartsView.vue can show a "Top N models"
|
||||
* note whenever the fetched ranking is exactly this long — the reader must
|
||||
* be told the list is a truncated top-N, not the complete set, the same
|
||||
* signal the on-screen search/filter affordances elsewhere in this panel
|
||||
* already give for a narrowed-but-possibly-incomplete list.
|
||||
*/
|
||||
const MODEL_RANKING_LIMIT = 20
|
||||
export const MODEL_RANKING_LIMIT = 20
|
||||
|
||||
/**
|
||||
* How many models the SCOPE PICKER offers. Higher than the ranking's own
|
||||
@@ -77,31 +83,21 @@ export const WINDOW_SPAN: Record<HistoryWindow, number> = {
|
||||
month: 12,
|
||||
}
|
||||
|
||||
/** WINDOW_LABEL is the switcher's own display text for each window, for the three TIME-SERIES tabs (requests/tokens/cost) — a real span of buckets, matching WINDOW_SPAN above. */
|
||||
/**
|
||||
* WINDOW_LABEL is the window-switcher's own display text — a real span of
|
||||
* buckets, matching WINDOW_SPAN above, for EVERY tab including Models: the
|
||||
* ranking endpoint now sums the same WINDOW_SPAN buckets the time-series
|
||||
* tabs chart (F2 review fix), rather than reading modelTotals' old
|
||||
* single-current-bucket behaviour, so "24h"/"30d"/"12mo" is accurate for
|
||||
* it too and this is the one label every tab shares — no separate
|
||||
* MODEL_WINDOW_LABEL needed any more.
|
||||
*/
|
||||
export const WINDOW_LABEL: Record<HistoryWindow, string> = {
|
||||
hour: '24h',
|
||||
day: '30d',
|
||||
month: '12mo',
|
||||
}
|
||||
|
||||
/**
|
||||
* MODEL_WINDOW_LABEL is the Models tab's OWN window-switcher text — that
|
||||
* tab shares the same three-way Tabs control as the time-series tabs
|
||||
* (ChartsView.vue), but reads a fundamentally different query: modelTotals
|
||||
* (limits.go) returns ONE counter at window's CURRENT bucket, never a span
|
||||
* of WINDOW_SPAN buckets. Labeling that control "24h" when it actually
|
||||
* means "since the top of the current UTC hour" understates how little
|
||||
* data is behind it — at 14:05 UTC, "24h" would visually promise a full
|
||||
* day while the ranking covers five minutes. "(UTC)" is explicit rather
|
||||
* than implied, matching formatBucketLabel's own hour-bucket fix
|
||||
* (lib/format.ts) — bucketFor (limits.go) always buckets in UTC.
|
||||
*/
|
||||
export const MODEL_WINDOW_LABEL: Record<HistoryWindow, string> = {
|
||||
hour: 'This hour (UTC)',
|
||||
day: 'Today (UTC)',
|
||||
month: 'This month (UTC)',
|
||||
}
|
||||
|
||||
/**
|
||||
* metricsForTab maps a chart tab to the metric(s) it needs. "tokens" is the
|
||||
* one tab needing two: GET /admin/api/usage/history takes a single metric
|
||||
@@ -143,11 +139,29 @@ export const useHistoryStore = defineStore('history', {
|
||||
modelRanking: [] as AdminUsageModelEntry[],
|
||||
/** Models with non-zero traffic, for the scope picker's model entries. */
|
||||
modelOptions: [] as AdminUsageModelEntry[],
|
||||
/**
|
||||
* modelFilter narrows the Models tab's ranking to ids starting with
|
||||
* this prefix (F6, dashboard-plan.md). P3 review fix: while the
|
||||
* Models tab is active, this is now also a SERVER query param —
|
||||
* setModelFilter sends it to /admin/api/usage/models as `prefix`
|
||||
* (fetchModelRanking's own doc comment) and refetches, since
|
||||
* filtering must happen before the server applies its top-N limit.
|
||||
* lib/model-filter.ts's filterModelsByPrefix still re-slices the
|
||||
* result client-side too (ChartsView.vue), a harmless no-op pass once
|
||||
* the server has already narrowed the set. Set by ProvidersView.vue's
|
||||
* provider-header link (nav.goToModels(`${p.name}/`), stores/nav.ts —
|
||||
* WP-B1) so "view this provider's models" lands on a pre-filtered
|
||||
* ranking instead of the reader hunting for it themselves, and it
|
||||
* round-trips through the `#charts?tab=models&filter=` hash param
|
||||
* (lib/hash-state.ts, composables/useHashState.ts) so a reload or a
|
||||
* shared link restores it too. Empty string means no filter.
|
||||
*/
|
||||
modelFilter: '',
|
||||
loading: false,
|
||||
/** True once the current selection's first fetch committed; cleared by the setters. Drives the "Loading…" line so background refreshes don't flash it over rendered data. */
|
||||
loaded: false,
|
||||
error: '',
|
||||
timer: undefined as ReturnType<typeof setInterval> | undefined,
|
||||
poller: undefined as VisibilityPoller | undefined,
|
||||
/**
|
||||
* seriesReqId guards fetchSeries and fetchModelRanking against
|
||||
* out-of-order responses (review finding: "latest-request-wins"). The
|
||||
@@ -180,23 +194,125 @@ export const useHistoryStore = defineStore('history', {
|
||||
setWindow(window: HistoryWindow): void {
|
||||
if (window === this.window) return
|
||||
this.window = window
|
||||
// Window changes both the time-series bucket resolution AND the
|
||||
// Models ranking's single current bucket — clear both.
|
||||
// Window changes the time-series bucket resolution, the Models
|
||||
// ranking's own span, AND the picker's model list (Q1,
|
||||
// dashboard-plan.md DECISIONS: fetchModelOptions runs only on a
|
||||
// window change or initial load, never on the 30s auto-refresh tick)
|
||||
// — clear/refetch all three.
|
||||
this.seriesByMetric = {}
|
||||
this.modelRanking = []
|
||||
this.error = ''
|
||||
this.loaded = false
|
||||
void this.refresh()
|
||||
void this.fetchModelOptions()
|
||||
},
|
||||
setTab(tab: ChartTab): void {
|
||||
if (tab === this.tab) return
|
||||
this.tab = tab
|
||||
this.seriesByMetric = {}
|
||||
this.modelRanking = []
|
||||
// A stale provider-prefix filter from an earlier nav.goToModels visit
|
||||
// must not silently carry into a plain "Models" tab click — F6's
|
||||
// nav.goToModels always re-applies its own setModelFilter call right
|
||||
// after this one, so clearing here unconditionally never fights it.
|
||||
this.modelFilter = ''
|
||||
this.error = ''
|
||||
this.loaded = false
|
||||
void this.refresh()
|
||||
},
|
||||
/**
|
||||
* setModelFilter narrows the Models tab's ranking to ids starting with
|
||||
* `prefix`.
|
||||
*
|
||||
* P3 review fix: this used to be a PURE client-side re-slice of
|
||||
* already-fetched data, never a refetch (modelFilter's own state doc
|
||||
* comment above still describes the re-slice half, lib/model-filter.ts's
|
||||
* filterModelsByPrefix). Now that fetchModelRanking also sends `prefix`
|
||||
* to the SERVER (see that action's own doc comment), the already-fetched
|
||||
* ranking can be stale relative to a NEW prefix — it may have been
|
||||
* fetched under the old prefix (or no prefix at all), so simply
|
||||
* re-slicing it client-side no longer reflects what the server would
|
||||
* return for the new one. Refetching only when the Models tab is
|
||||
* actually active (the only tab that reads modelFilter at all — see
|
||||
* filterModelsByPrefix's own call site, ChartsView.vue) keeps this a
|
||||
* no-op fetch-wise everywhere else, and the early return on an
|
||||
* unchanged value keeps repeated identical calls (or nav.goToModels
|
||||
* re-applying the same prefix) from firing a redundant fetch.
|
||||
*/
|
||||
setModelFilter(prefix: string): void {
|
||||
if (prefix === this.modelFilter) return
|
||||
this.modelFilter = prefix
|
||||
if (this.tab !== 'models') return
|
||||
this.modelRanking = []
|
||||
this.error = ''
|
||||
this.loaded = false
|
||||
void this.refresh()
|
||||
},
|
||||
/**
|
||||
* setSelection (P12 review fix) applies scope/window/tab/modelMetric/
|
||||
* modelFilter as ONE atomic change, instead of the caller invoking
|
||||
* several of the single-field setters above back to back. Each of
|
||||
* those setters independently clears state and calls refresh() (and,
|
||||
* for window, fetchModelOptions() too) — restoring a Charts hash with
|
||||
* several params set at once (composables/useHashState.ts's
|
||||
* applyFromHash) or nav.ts's goToCharts/goToModels used to call three
|
||||
* or four of them in a row, firing that many fetches, every one but
|
||||
* the LAST immediately discarded by seriesReqId's own
|
||||
* latest-request-wins guard (stores/history.ts state doc comment) —
|
||||
* wasted round trips against a Redis-backed endpoint for a result that
|
||||
* was never going to render. setSelection instead applies every
|
||||
* provided field directly, clears the derived state ONCE, and fires
|
||||
* exactly one refresh() (plus one fetchModelOptions() only when
|
||||
* `window` is part of this call) once every field has been applied.
|
||||
*
|
||||
* Field application order matters for one case: `tab` resets
|
||||
* `modelFilter` to '' first (mirroring setTab's own "a stale
|
||||
* provider-prefix filter must not silently carry into a plain tab
|
||||
* switch" convention), and only THEN does this function's own
|
||||
* `modelFilter` field (if provided in the SAME call) apply the real
|
||||
* value — so `setSelection({ tab: 'models', modelFilter: prefix })`
|
||||
* (nav.ts's goToModels) lands on the intended prefix, not '' followed
|
||||
* by a second, separate fetch to correct it.
|
||||
*/
|
||||
setSelection(next: {
|
||||
scope?: string
|
||||
window?: HistoryWindow
|
||||
tab?: ChartTab
|
||||
modelMetric?: ModelMetric
|
||||
modelFilter?: string
|
||||
}): void {
|
||||
let changed = false
|
||||
let windowChanged = false
|
||||
if (next.window !== undefined && next.window !== this.window) {
|
||||
this.window = next.window
|
||||
changed = true
|
||||
windowChanged = true
|
||||
}
|
||||
if (next.tab !== undefined && next.tab !== this.tab) {
|
||||
this.tab = next.tab
|
||||
this.modelFilter = ''
|
||||
changed = true
|
||||
}
|
||||
if (next.scope !== undefined && next.scope !== this.scope) {
|
||||
this.scope = next.scope
|
||||
changed = true
|
||||
}
|
||||
if (next.modelMetric !== undefined && next.modelMetric !== this.modelMetric) {
|
||||
this.modelMetric = next.modelMetric
|
||||
changed = true
|
||||
}
|
||||
if (next.modelFilter !== undefined && next.modelFilter !== this.modelFilter) {
|
||||
this.modelFilter = next.modelFilter
|
||||
changed = true
|
||||
}
|
||||
if (!changed) return
|
||||
this.seriesByMetric = {}
|
||||
this.modelRanking = []
|
||||
this.error = ''
|
||||
this.loaded = false
|
||||
void this.refresh()
|
||||
if (windowChanged) void this.fetchModelOptions()
|
||||
},
|
||||
setModelMetric(metric: ModelMetric): void {
|
||||
if (metric === this.modelMetric) return
|
||||
this.modelMetric = metric
|
||||
@@ -207,12 +323,17 @@ export const useHistoryStore = defineStore('history', {
|
||||
},
|
||||
/**
|
||||
* refresh fetches whatever the CURRENT selection needs: the ranking on
|
||||
* the Models tab, a time series otherwise. The scope picker's model
|
||||
* list is refreshed alongside either, so a model that has just started
|
||||
* receiving traffic becomes selectable without a page reload.
|
||||
* the Models tab, a time series otherwise. It deliberately does NOT
|
||||
* also refresh the scope picker's model list any more (Q1,
|
||||
* dashboard-plan.md DECISIONS — folded from a review finding on read
|
||||
* amplification): fetchModelOptions is a real extra store read
|
||||
* (modelSpanTotals, admin.go), and this action is what the 30s
|
||||
* auto-refresh timer calls every tick — repeating that read every
|
||||
* cycle bought the picker nothing a reader would ever notice. See
|
||||
* fetchModelOptions' own doc comment for its two actual call sites.
|
||||
*/
|
||||
async refresh(): Promise<void> {
|
||||
await Promise.all([this.tab === 'models' ? this.fetchModelRanking() : this.fetchSeries(), this.fetchModelOptions()])
|
||||
await (this.tab === 'models' ? this.fetchModelRanking() : this.fetchSeries())
|
||||
},
|
||||
async fetchSeries(): Promise<void> {
|
||||
const auth = useAuthStore()
|
||||
@@ -259,8 +380,19 @@ export const useHistoryStore = defineStore('history', {
|
||||
const requestId = ++this.seriesReqId
|
||||
this.loading = true
|
||||
try {
|
||||
const span = WINDOW_SPAN[this.window]
|
||||
// P3 review fix: when a provider-prefix filter is active
|
||||
// (modelFilter, set by ProvidersView.vue's provider-header link via
|
||||
// nav.goToModels), send it to the server so filtering happens
|
||||
// BEFORE the limit is applied — the client-side filterModelsByPrefix
|
||||
// pass in ChartsView.vue stays too, as a harmless second pass (it
|
||||
// is a no-op once the server has already narrowed the set), but
|
||||
// without this the server's own fleet-wide top-N could omit a
|
||||
// provider's models entirely, or return only a partial view of them
|
||||
// (the CONFIRMED problem this fixes).
|
||||
const prefixParam = this.modelFilter ? `&prefix=${encodeURIComponent(this.modelFilter)}` : ''
|
||||
const res = await adminFetch<AdminUsageModelsResponse>(
|
||||
`/admin/api/usage/models?metric=${this.modelMetric}&window=${this.window}&limit=${MODEL_RANKING_LIMIT}`,
|
||||
`/admin/api/usage/models?metric=${this.modelMetric}&window=${this.window}&limit=${MODEL_RANKING_LIMIT}&span=${span}${prefixParam}`,
|
||||
)
|
||||
if (requestId !== this.seriesReqId) return
|
||||
this.modelRanking = res.models
|
||||
@@ -278,20 +410,27 @@ export const useHistoryStore = defineStore('history', {
|
||||
},
|
||||
/**
|
||||
* fetchModelOptions loads the picker's model list: models with at least
|
||||
* one REQUEST in the current window. Requests, not the ranking's own
|
||||
* metric, deliberately — a model can serve traffic while reporting no
|
||||
* tokens and costing nothing, and such a model must still be
|
||||
* selectable. A failure here is swallowed rather than surfaced: it
|
||||
* one REQUEST across the current window's span. Requests, not the
|
||||
* ranking's own metric, deliberately — a model can serve traffic while
|
||||
* reporting no tokens and costing nothing, and such a model must still
|
||||
* be selectable. A failure here is swallowed rather than surfaced: it
|
||||
* degrades the picker, and must not replace a rendered chart's own
|
||||
* error (or clear it) on a background refresh.
|
||||
* error (or clear it).
|
||||
*
|
||||
* Called only on initial load and on a window change (Q1,
|
||||
* dashboard-plan.md DECISIONS) — never from refresh() itself, so the
|
||||
* 30s auto-refresh tick pays for the ranking/series alone. See
|
||||
* setWindow above and ChartsView.vue's onMounted for the two call
|
||||
* sites.
|
||||
*/
|
||||
async fetchModelOptions(): Promise<void> {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.isAuthenticated) return
|
||||
const requestId = ++this.optionsReqId
|
||||
try {
|
||||
const span = WINDOW_SPAN[this.window]
|
||||
const res = await adminFetch<AdminUsageModelsResponse>(
|
||||
`/admin/api/usage/models?metric=req&window=${this.window}&limit=${MODEL_OPTIONS_LIMIT}`,
|
||||
`/admin/api/usage/models?metric=req&window=${this.window}&limit=${MODEL_OPTIONS_LIMIT}&span=${span}`,
|
||||
)
|
||||
// Stale: a later window change already fired its own picker-list
|
||||
// fetch — never let an older window's model list overwrite it.
|
||||
@@ -301,19 +440,31 @@ export const useHistoryStore = defineStore('history', {
|
||||
// Intentionally ignored — see the doc comment above.
|
||||
}
|
||||
},
|
||||
/**
|
||||
* startAutoRefresh drives refresh() through the shared
|
||||
* createVisibilityPoller (lib/polling.ts, F5) instead of a bare
|
||||
* setInterval: it ticks once immediately (the selection's initial
|
||||
* fetch — see ChartsView.vue's onMounted, which no longer calls
|
||||
* refresh() itself), then every REFRESH_MS while the tab is visible,
|
||||
* pausing while it is hidden and catching up the moment it is looked
|
||||
* at again. The in-flight skip is unchanged from before this move: a
|
||||
* tick bumps seriesReqId on every real fetch, so an unconditional tick
|
||||
* would supersede (and discard) any request slower than REFRESH_MS,
|
||||
* forever.
|
||||
*/
|
||||
startAutoRefresh(): void {
|
||||
if (this.timer !== undefined) return
|
||||
// Skip a tick while the previous fetch is still in flight: each fetch
|
||||
// bumps seriesReqId, so an unconditional tick would supersede (and
|
||||
// discard) any request slower than REFRESH_MS, forever.
|
||||
this.timer = setInterval(() => {
|
||||
if (!this.loading) void this.refresh()
|
||||
}, REFRESH_MS)
|
||||
if (this.poller) return
|
||||
this.poller = createVisibilityPoller({
|
||||
intervalMs: REFRESH_MS,
|
||||
tick: () => {
|
||||
if (!this.loading) void this.refresh()
|
||||
},
|
||||
})
|
||||
this.poller.start()
|
||||
},
|
||||
stopAutoRefresh(): void {
|
||||
if (this.timer === undefined) return
|
||||
clearInterval(this.timer)
|
||||
this.timer = undefined
|
||||
this.poller?.stop()
|
||||
this.poller = undefined
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// nav.spec.ts covers useNavStore's two navigation actions (F6). Real
|
||||
// Pinia + real useHistoryStore, no mocking needed: useAuthStore.
|
||||
// isAuthenticated defaults to false in this 'node' test environment (no
|
||||
// sessionStorage — key-storage.ts's own try/catch guard), so history.ts's
|
||||
// setScope/setTab/setModelFilter run their synchronous state updates and
|
||||
// their own fire-and-forget refresh()/fetchSeries() short-circuits
|
||||
// immediately at its own `if (!auth.isAuthenticated) return` guard,
|
||||
// exactly like dashboard.spec.ts's own precedent (see that file's header
|
||||
// comment).
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useHistoryStore } from '@/stores/history'
|
||||
import { useNavStore } from '@/stores/nav'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('useNavStore: initial state', () => {
|
||||
it('defaults activeTab to "providers" and usageQuery to empty', () => {
|
||||
const nav = useNavStore()
|
||||
expect(nav.activeTab).toBe('providers')
|
||||
expect(nav.usageQuery).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useNavStore.goToCharts', () => {
|
||||
it('sets the history scope and switches activeTab to "charts"', () => {
|
||||
const nav = useNavStore()
|
||||
const history = useHistoryStore()
|
||||
nav.goToCharts('user:alice')
|
||||
expect(history.scope).toBe('user:alice')
|
||||
expect(nav.activeTab).toBe('charts')
|
||||
})
|
||||
|
||||
it('accepts every scope prefix history.ts recognizes', () => {
|
||||
const nav = useNavStore()
|
||||
const history = useHistoryStore()
|
||||
for (const scope of ['total', 'user:alice', 'group:eng', 'model:openai/gpt-4']) {
|
||||
nav.goToCharts(scope)
|
||||
expect(history.scope).toBe(scope)
|
||||
}
|
||||
})
|
||||
|
||||
it('switches the Charts tab back to "requests" first when it was showing "models" (no scope picker there)', () => {
|
||||
const nav = useNavStore()
|
||||
const history = useHistoryStore()
|
||||
history.setTab('models')
|
||||
nav.goToCharts('group:eng')
|
||||
expect(history.tab).toBe('requests')
|
||||
expect(history.scope).toBe('group:eng')
|
||||
})
|
||||
|
||||
it('leaves a non-models tab untouched', () => {
|
||||
const nav = useNavStore()
|
||||
const history = useHistoryStore()
|
||||
history.setTab('cost')
|
||||
nav.goToCharts('total')
|
||||
expect(history.tab).toBe('cost')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useNavStore.goToModels', () => {
|
||||
it('switches to the Models tab, applies the prefix filter, and switches activeTab to "charts"', () => {
|
||||
const nav = useNavStore()
|
||||
const history = useHistoryStore()
|
||||
nav.goToModels('openai/')
|
||||
expect(history.tab).toBe('models')
|
||||
expect(history.modelFilter).toBe('openai/')
|
||||
expect(nav.activeTab).toBe('charts')
|
||||
})
|
||||
|
||||
it('applies the filter AFTER switching tabs, so setTab\'s own modelFilter reset never wins the race', () => {
|
||||
const nav = useNavStore()
|
||||
const history = useHistoryStore()
|
||||
// Already on the Models tab with a stale filter from an earlier visit —
|
||||
// setTab('models') is a no-op (tab === this.tab guard, history.ts) and
|
||||
// so does NOT reset modelFilter on its own; goToModels's own explicit
|
||||
// setModelFilter call is what must still land the new prefix.
|
||||
history.setTab('models')
|
||||
history.setModelFilter('stale/')
|
||||
nav.goToModels('anthropic/')
|
||||
expect(history.modelFilter).toBe('anthropic/')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type { TabValue } from '@/composables/useTabHash'
|
||||
import { useHistoryStore } from '@/stores/history'
|
||||
|
||||
/**
|
||||
* useNavStore (F6) is the one place cross-tab "jump to Charts, pointed at
|
||||
* X" navigation lives: every ScopeLink.vue click (usage-columns.ts's id
|
||||
* cell, the Groups accordion trigger here, and WP-B2's ProvidersView.vue
|
||||
* per-model links) and the Providers tab's own per-provider header link
|
||||
* call one of the two actions below, instead of each call site poking
|
||||
* history.ts's scope/tab state AND App.vue's own active-tab separately.
|
||||
*
|
||||
* `activeTab` is typed with TabValue, the SAME union App.vue's Tabs
|
||||
* v-model already binds to (composables/useTabHash.ts) — WP-B2 extends
|
||||
* that union with 'events' (F3); this store picks the extension up for
|
||||
* free with no edit here, since it imports the type rather than
|
||||
* redeclaring its own copy (vue.md: "share types once, import
|
||||
* everywhere").
|
||||
*/
|
||||
export const useNavStore = defineStore('nav', {
|
||||
state: () => ({
|
||||
activeTab: 'providers' as TabValue,
|
||||
/**
|
||||
* usageQuery is the Usage tab's own search box value (F9's
|
||||
* useSearchQuery(external), WP-B2 — composables/useSearchQuery.ts
|
||||
* gains an optional external ref so UsageView.vue can bind this
|
||||
* instead of owning a local ref), lifted up here rather than kept
|
||||
* local to that component so it survives a navigation away from and
|
||||
* back to the Usage tab, and so #<tab>?q=... hash state (F9,
|
||||
* lib/hash-state.ts) has one canonical place to read/write it from.
|
||||
*/
|
||||
usageQuery: '',
|
||||
}),
|
||||
actions: {
|
||||
/**
|
||||
* goToCharts switches to the Charts tab with `scope` selected —
|
||||
* "total" | "user:{id}" | "group:{id}" | "model:{id}" (history.ts's
|
||||
* own scope string convention, unchanged here). If the Charts view is
|
||||
* currently showing the Models ranking — which has no scope picker at
|
||||
* all (ChartsView.vue's own template branch) — it is switched back to
|
||||
* the Requests tab FIRST, so the newly-set scope is immediately
|
||||
* visible rather than silently inert behind the ranking view.
|
||||
*
|
||||
* P12 review fix: both fields are applied through ONE
|
||||
* history.setSelection() call rather than two separate setTab()/
|
||||
* setScope() calls — each of THOSE independently fires its own
|
||||
* refresh(), so switching off the Models tab AND changing scope in the
|
||||
* same click used to fire two fetches back to back, the first always
|
||||
* discarded (seriesReqId's own latest-request-wins guard) the instant
|
||||
* the second landed.
|
||||
*/
|
||||
goToCharts(scope: string): void {
|
||||
const history = useHistoryStore()
|
||||
history.setSelection(history.tab === 'models' ? { tab: 'requests', scope } : { scope })
|
||||
this.activeTab = 'charts'
|
||||
},
|
||||
/**
|
||||
* goToModels switches to the Charts tab's Models ranking, pre-filtered
|
||||
* to `prefix` (ProvidersView.vue's per-provider header link passes
|
||||
* `${provider.name}/`) — history.ts's own modelFilter/setModelFilter
|
||||
* (WP-B2, F6 charts+providers side).
|
||||
*
|
||||
* P12 review fix: `tab` and `modelFilter` are applied through ONE
|
||||
* history.setSelection() call — setSelection's own field-ordering
|
||||
* (tab's own modelFilter reset happens BEFORE this call's `modelFilter`
|
||||
* field is applied, see its doc comment) preserves the exact ordering
|
||||
* this used to need two separate calls for (setTab('models') resetting
|
||||
* modelFilter, THEN setModelFilter(prefix) applying the real value),
|
||||
* while firing exactly one ranking fetch instead of one per call.
|
||||
*/
|
||||
goToModels(prefix: string): void {
|
||||
const history = useHistoryStore()
|
||||
history.setSelection({ tab: 'models', modelFilter: prefix })
|
||||
this.activeTab = 'charts'
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -218,6 +218,28 @@ export interface AdminAliasView {
|
||||
/** GET /admin/api/overview (admin.go: adminOverviewResponse). */
|
||||
export interface AdminOverviewResponse {
|
||||
version: string
|
||||
/**
|
||||
* replica identifies WHICH of this deployment's Traefik replicas
|
||||
* answered this poll (admin.go: os.Hostname(), falling back to
|
||||
* $HOSTNAME then "unknown"). Every per-replica, in-process figure this
|
||||
* panel already renders (provider latency, provenance, discovery
|
||||
* health — see AdminProviderView.latency's own doc comment) has always
|
||||
* been silently replica-scoped; this field is the first place that
|
||||
* scoping becomes visible to the reader, rather than a caveat they
|
||||
* have to take on faith.
|
||||
*/
|
||||
replica: string
|
||||
/** instance is the gateway's own configured name (g.name), distinct from replica — the deployment's logical identity vs. which pod happened to answer. */
|
||||
instance: string
|
||||
/**
|
||||
* warnings holds the configuration warnings this replica logged while
|
||||
* the middleware was being constructed (llmgateway.go's
|
||||
* collectingWarnings window) — runtime warnings are not kept. Never nil;
|
||||
* an empty array when there is nothing to report.
|
||||
*/
|
||||
warnings: string[]
|
||||
/** warningsDropped counts warnings beyond the server's own cap (configWarningsCap) that were logged but not kept — omitted entirely when nothing was dropped. */
|
||||
warningsDropped?: number
|
||||
providers: AdminProviderView[]
|
||||
groups: AdminGroupView[]
|
||||
aliases: AdminAliasView[]
|
||||
@@ -263,6 +285,15 @@ export interface AdminUsageEntryView {
|
||||
tokensOutPerMonth: number
|
||||
costPerDayMicroUsd: number
|
||||
costPerMonthMicroUsd: number
|
||||
/**
|
||||
* rejectionsPerDay is this scope's fleet-wide (Redis-backed, like every
|
||||
* other counter on this view) count of requests DENIED by a limit
|
||||
* check today — never the in-process Prometheus rejection counter,
|
||||
* which is per-replica and not exposed here (admin.go: settleRejection
|
||||
* folds this into the SAME storeIncrMulti round trip the rejection
|
||||
* path already paid, so this field costs nothing extra to read).
|
||||
*/
|
||||
rejectionsPerDay: number
|
||||
storeDown?: boolean
|
||||
}
|
||||
|
||||
@@ -317,6 +348,15 @@ export interface AdminUsageModelEntry {
|
||||
export interface AdminUsageModelsResponse {
|
||||
metric: string
|
||||
window: string
|
||||
/**
|
||||
* span is how many of `window`'s buckets, ending now, this ranking
|
||||
* summed (admin.go: parseUsageModelsSpan — empty request query means 1,
|
||||
* today's original single-current-bucket behaviour). Always echoed back,
|
||||
* never omitted, so a caller reading a cached/stale response can tell
|
||||
* which span it was fetched under without re-deriving it from the query
|
||||
* string.
|
||||
*/
|
||||
span: number
|
||||
models: AdminUsageModelEntry[]
|
||||
}
|
||||
|
||||
@@ -386,3 +426,69 @@ export interface AdminTargetsResponse {
|
||||
mcpServers: AdminTargetView[]
|
||||
agents: AdminTargetView[]
|
||||
}
|
||||
|
||||
/**
|
||||
* AdminEventKind is one gatewayEvent's classification (admin.go/events.go:
|
||||
* eventKindRateLimit/eventKindBudget/eventKindStoreDown/eventKindUpstream/
|
||||
* eventKindTimeout/eventKindUnpriced/eventKindCapacity). Typed as a union
|
||||
* with `| string` on AdminEventView.kind below, not this type alone: a
|
||||
* server build newer than this client could add a kind this webui does
|
||||
* not yet recognize, and the Events view must still render its raw text
|
||||
* rather than fail to compile/render it.
|
||||
*/
|
||||
export type AdminEventKind =
|
||||
| 'rate_limit'
|
||||
| 'budget'
|
||||
| 'store_down'
|
||||
| 'upstream'
|
||||
| 'timeout'
|
||||
| 'unpriced'
|
||||
| 'capacity'
|
||||
|
||||
/**
|
||||
* One row in GET /admin/api/events (admin.go/events.go: gatewayEvent — the
|
||||
* SAME shape stored as JSON in the eventRing/Redis list, never
|
||||
* re-projected). user/group/model/provider are the scope the event
|
||||
* happened against; every one of them, like message, is either a fixed
|
||||
* string or sanitizeProviderErr/sanitizeTargetErr output (admin.go) —
|
||||
* never a raw body, header, or credential (see events.go's own risk note,
|
||||
* dashboard-plan.md section 6). route is one of the fixed route-name
|
||||
* strings admin.go documents (chat/completions, embeddings, messages,
|
||||
* images, audio/speech, audio/transcriptions, passthrough, mcp, a2a,
|
||||
* mcp-federated, pricing, capacity) — kept as a bare `string` rather than a union
|
||||
* here since this view only ever displays it, never branches on it.
|
||||
*/
|
||||
export interface AdminEventView {
|
||||
time: string
|
||||
/** Which replica recorded this event — admin.go's g.replica, same value AdminOverviewResponse.replica carries for this replica's own poll. */
|
||||
replica: string
|
||||
instance?: string
|
||||
user?: string
|
||||
group?: string
|
||||
model?: string
|
||||
provider?: string
|
||||
route: string
|
||||
kind: AdminEventKind | string
|
||||
message: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /admin/api/events?limit=N (admin.go/events.go: adminEventsResponse —
|
||||
* new, F3). `source` tells the Events view whether `events` is the
|
||||
* fleet-wide Redis-backed list ('redis') or this replica's own in-memory
|
||||
* ring, read because Redis was unreachable or unconfigured ('replica') —
|
||||
* the view must caption this, never present a replica-only fallback as if
|
||||
* it were fleet-wide. `degraded` is set only in the 'replica' case where
|
||||
* Redis IS configured but the read failed (as opposed to Redis simply not
|
||||
* being configured at all) — omitted otherwise.
|
||||
*/
|
||||
export interface AdminEventsResponse {
|
||||
/** Newest first, never nil — an empty array means no events recorded (or capacity 0), never "the read failed" (a failed read still falls back to the ring, see `source`). */
|
||||
events: AdminEventView[]
|
||||
source: 'redis' | 'replica'
|
||||
replica: string
|
||||
/** The ring's fixed capacity (events.go: eventRingCap) — lets the view show "showing N of capacity" rather than a bare list length. */
|
||||
capacity: number
|
||||
degraded?: boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user